cja-top-movers-watchlist
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseTop Movers Watchlist (Customer Journey Analytics)
异动项监控清单(客户旅程分析,CJA)
Surface the biggest gainers and decliners for a metric across any dimension in
two time periods. The output tells the user exactly what moved, by how much, and
whether items appeared or disappeared entirely — which is often the most
interesting signal.
This skill is a faster, more targeted alternative to a full anomaly triage.
Use it when the user wants to scan the landscape of changes rather than drill
into a single anomaly.
展示两个时间段内,某一指标在任意维度下的最大涨幅项与最大跌幅项。输出内容会明确告知用户哪些项目发生了变动、变动幅度,以及是否有项目完全新增或消失——这往往是最有价值的信号。
此技能是完整异常排查流程的更快捷、更具针对性的替代方案。当用户希望快速浏览整体变动情况,而非深入分析单个异常时,可使用该技能。
CJA MCP Tools Used
使用的CJA MCP工具
- — load data view calendar/timezone
describeCja(DATAVIEW_CONTEXT_GUIDE) - — resolve the metric being watched
findMetrics - — if the metric is a custom KPI
findCalculatedMetrics - — resolve the dimension to break down by
findDimensions - — pull dimension-metric data for both periods
runReport - — validate dimension values if user specifies names
searchDimensionItems
- — 加载数据视图的日历/时区信息
describeCja(DATAVIEW_CONTEXT_GUIDE) - — 确定要监控的指标
findMetrics - — 若指标为自定义KPI时使用
findCalculatedMetrics - — 确定用于拆分的维度
findDimensions - — 拉取两个时间段的维度-指标数据
runReport - — 若用户指定维度名称,验证维度值
searchDimensionItems
Phase 0 — Setup
阶段0 — 准备工作
- Call and
findDataViewsas needed.setDefaultSessionDataViewId - Call to load data view context. Record the first-day-of-week as
describeCja("DATAVIEW_CONTEXT_GUIDE")and timezone asWEEK_START_DOW. If the context guide does not return a week-start value, default to Monday (ISO 8601). You will use both in Phase 1.3.TIMEZONE
- 根据需要调用和
findDataViews。setDefaultSessionDataViewId - 调用加载数据视图上下文。记录一周起始日为
describeCja("DATAVIEW_CONTEXT_GUIDE"),时区为WEEK_START_DOW。若上下文未返回一周起始值,默认使用周一(ISO 8601标准)。这两个参数将在阶段1.3中使用。TIMEZONE
Phase 1 — Clarify Inputs
阶段1 — 明确输入信息
1.1 Metric
1.1 指标
If the user specified a metric, resolve it:
findMetrics(search: "<user's metric name>")If not specified, suggest the top 3 metrics from usage:
"Which metric would you like to track? I can suggest: Sessions, Revenue, Orders based on what your team uses most."
若用户指定了指标,执行以下操作解析:
findMetrics(search: "<用户指定的指标名称>")若未指定,根据使用情况推荐Top3指标:
"您希望跟踪哪个指标?根据团队常用情况,我可以推荐:会话数(Sessions)、收入(Revenue)、订单数(Orders)。"
1.2 Dimension (what to break down by)
1.2 维度(拆分依据)
Common dimension choices and their typical use cases:
| Dimension | Use Case |
|---|---|
| Marketing Channel | "Which channels moved?" |
| Page Name | "Which pages are trending?" |
| Campaign | "Which campaigns improved?" |
| Product | "Which products gained/lost traction?" |
| Country / Region | "Which markets moved?" |
| Device Type | "Did mobile or desktop shift?" |
| Referring Domain | "Which referrers changed?" |
If the user did not specify, ask:
"Which dimension should I break down by — for example, marketing channel, page, campaign, country, or product?"
Call to resolve the dimension ID.
findDimensions(search: "<dimension keyword>")常见维度选择及其典型使用场景:
| 维度 | 使用场景 |
|---|---|
| Marketing Channel | "哪些渠道发生了变动?" |
| Page Name | "哪些页面呈趋势性变化?" |
| Campaign | "哪些营销活动表现提升?" |
| Product | "哪些产品的关注度上升/下降?" |
| Country / Region | "哪些市场发生了变动?" |
| Device Type | "移动端或桌面端的表现是否有变化?" |
| Referring Domain | "哪些引荐域名的流量发生了变化?" |
若用户未指定,询问:
"我应该按哪个维度进行拆分?例如,营销渠道、页面、营销活动、国家/地区或产品。"
调用解析维度ID。
findDimensions(search: "<维度关键词>")1.3 Periods
1.3 时间段
Define Period A (current) and Period B (comparison). Defaults:
- Period A: this week (or last 7 days)
- Period B: last week (or the 7 days before that)
If the user specifies "this month vs last month" or a custom range, map
accordingly. Always confirm the periods before running reports:
"I'll compare this week (Mar 13–19) vs last week (Mar 6–12). Sound right?"
Calendar rule (mandatory):
Use from Phase 0 to define what "week" means. Period A and
Period B MUST use the same first-day-of-week — i.e., both periods'
fall on the same day-of-week, both are exactly equal length,
and Period B ends immediately before Period A starts. Never mix conventions
(e.g., a Mon–Sun Period A with a Sun–Sat Period B) within the same run.
Pick the boundary once, then derive both periods from it. For custom date
ranges, compute Period B as the equal-length window ending immediately
before Period A starts.
WEEK_START_DOWstartDateSanity check before calling : confirm
and are the same day-of-week and that
. If not, recompute.
runReportperiodA.startDateperiodB.startDateperiodA.startDate - periodB.endDate == 1 day定义时间段A(当前)和时间段B(对比)。默认设置:
- 时间段A:本周(或过去7天)
- 时间段B:上周(或之前的7天)
若用户指定“本月 vs 上月”或自定义时间范围,相应调整。在生成报告前务必确认时间段:
"我将对比本周(3月13日-19日)与上周(3月6日-12日)。是否正确?"
日历规则(强制要求):
使用阶段0中获取的定义“周”的范围。时间段A和时间段B必须使用相同的一周起始日——即两个时间段的为同一星期几,长度完全相同,且时间段B结束后立即开始时间段A。切勿混合使用不同规则(例如,时间段A为周一至周日,而时间段B为周日至周六)。一旦确定边界,两个时间段均需基于此边界推导。对于自定义时间范围,时间段B为与时间段A长度相同、且在时间段A开始前结束的窗口。
WEEK_START_DOWstartDate**调用前的合理性检查:**确认和为同一星期几,且。若不符合,重新计算。
runReportperiodA.startDateperiodB.startDateperiodA.startDate - periodB.endDate == 1天Phase 2 — Pull Data for Both Periods
阶段2 — 拉取两个时间段的数据
Run two reports — one per period — with the same dimension breakdown:
runReport(
dimensionIds: "<dimension id>",
metricIds: "<metric id>",
startDate: "<period A start>T00:00:00",
endDate: "<period A end>T23:59:59",
page: 0,
limit: 50
)runReport(
dimensionIds: "<dimension id>",
metricIds: "<metric id>",
startDate: "<period B start>T00:00:00",
endDate: "<period B end>T23:59:59",
page: 0,
limit: 50
)Use to capture enough items to surface meaningful movers.
If the user's dimension has thousands of values (e.g., page names), limit to
top 100 by Period A volume to keep the comparison meaningful.
limit: 50Row data is in the array — each row has (dimension item name)
and (the metric value). There is no limit on dimension cardinality
but results default to sorted by metric descending, which is what you want.
rowsvaluedata[0]Always verify the dimension ID with
before running — dimension IDs can vary from what you might guess
(e.g., not ).
findDimensions(searchQuery: "<name>")variables/marketing_channelvariables/marketingchannel运行两份报告——每个时间段一份——使用相同的维度拆分:
runReport(
dimensionIds: "<维度ID>",
metricIds: "<指标ID>",
startDate: "<时间段A起始时间>T00:00:00",
endDate: "<时间段A结束时间>T23:59:59",
page: 0,
limit: 50
)runReport(
dimensionIds: "<维度ID>",
metricIds: "<指标ID>",
startDate: "<时间段B起始时间>T00:00:00",
endDate: "<时间段B结束时间>T23:59:59",
page: 0,
limit: 50
)使用捕获足够多的项目,以展示有意义的异动项。若用户选择的维度包含数千个值(例如页面名称),则限制为时间段A中Top100的项目,以确保对比的有效性。
limit: 50行数据位于数组中——每行包含(维度项名称)和(指标值)。维度基数无限制,但结果默认按指标降序排列,这正是我们需要的。
rowsvaluedata[0]运行报告前务必通过验证维度ID——维度ID可能与预期不同(例如而非)。
findDimensions(searchQuery: "<名称>")variables/marketing_channelvariables/marketingchannelPhase 3 — Compute Rankings
阶段3 — 计算排名
Build a unified table joining both result sets on dimension value:
For each dimension value present in either period:
- = metric value in Period A (0 if not present)
valueA - = metric value in Period B (0 if not present)
valueB - = valueA − valueB
delta - = (delta / valueB) × 100 if valueB > 0, else "New Entry"
pctChange - :
status- Present in A but not B → New Entry (appeared this period)
- Present in B but not A → Disappeared (dropped out this period)
- Both present → normal mover
Sort by:
- Top Gainers: sort by delta descending (biggest absolute gains first)
- Top Decliners: sort by delta ascending (biggest absolute drops first)
- % Gainers: sort by pctChange descending
- % Decliners: sort by pctChange ascending
Limit each list to Top 10. New Entries and Disappeared items get their
own sections regardless of count (they are always interesting signals).
构建一个统一表格,按维度值合并两个结果集:
对于任一时间段中存在的每个维度值:
- = 时间段A中的指标值(若不存在则为0)
valueA - = 时间段B中的指标值(若不存在则为0)
valueB - = valueA − valueB
delta - = (delta / valueB) × 100(若valueB > 0),否则为"新增项"
pctChange - :
status- 存在于A但不存在于B → 新增项(本时间段首次出现)
- 存在于B但不存在于A → 已消失(本时间段不再出现)
- 两个时间段均存在 → 常规异动项
排序规则:
- Top涨幅项:按delta降序排列(绝对涨幅最大的在前)
- Top跌幅项:按delta升序排列(绝对跌幅最大的在前)
- 涨幅百分比Top:按pctChange降序排列
- 跌幅百分比Top:按pctChange升序排列
每个列表限制为Top10。新增项和已消失项单独成区,无论数量多少(它们始终是有价值的信号)。
Phase 4 — Generate HTML Report
阶段4 — 生成HTML报告
Generate the movers report inline and write to
.
/tmp/cja_top_movers_report_<YYYY-MM-DD_HHMMSS>.html在线生成异动项报告,并写入。
/tmp/cja_top_movers_report_<YYYY-MM-DD_HHMMSS>.htmlRendering rules — apply consistently across runs
渲染规则 — 所有运行需保持一致
Two runs of this skill on the same data view + metric/dimension + period must
render identically (modulo the generation timestamp). The rules below pin the
formatting choices that the AI would otherwise drift on.
在同一数据视图+指标/维度+时间段下,两次运行此技能必须生成完全相同的报告(仅生成时间戳可不同)。以下规则固定了AI可能随意变动的格式选择。
Number formatting
数字格式
- KPI values (the big number in each summary tile, and per-row mover
values) — use full digits with thousands separators (,
8,160,77,584). Do NOT use SI suffixes like1,250,000orK, even for large values. Stakeholders want exact numbers, not abbreviations.M - Percent change (in pills and narrative bullets) — always one decimal
place, rounded half-away-from-zero. For example, displays as
−23.55%, never−23.6%. Compute on full-precision values; round only at display time.−23.5% - Percentage-point change (for already-percentage metrics like Conversion
Rate or Bounce Rate) — same rounding, suffix . Example:
pp.+0.40 pp - Currency — prefix with thousands separators and no decimals for values ≥ $100 (
$); cents only when value < $100 ($1,240,000).$45.20
- KPI值(每个汇总卡片中的大数字,以及每行异动项的数值)——使用带千位分隔符的完整数字(、
8,160、77,584)。即使数值很大,切勿使用1,250,000或K等国际单位制后缀。相关人员需要精确数字,而非缩写。M - 百分比变化(在标签和叙述性项目符号中)——始终保留一位小数,采用四舍五入(远离零方向)。例如,显示为
−23.55%,而非−23.6%。基于全精度数值计算;仅在展示时进行四舍五入。−23.5% - 百分点变化(针对已为百分比的指标,如转化率或跳出率)——四舍五入规则相同,后缀为。示例:
pp。+0.40 pp - 货币——添加前缀,带千位分隔符;数值≥$100时不保留小数(
$);数值<$100时保留分位($1,240,000)。$45.20
Null / missing data handling
空值/缺失数据处理
A KPI tile or mover row must reflect what the data view actually returned.
The AI must not silently substitute a different metric or hide a tile to
make the report look cleaner.
- Both periods return 0 or NULL for the tracked metric in a summary tile:
render the tile with =
kpi-value, pill classData unavailable, pill textflat, and⚠ N/Atext =prior. The tile stays in the grid; do not omit it.Both periods returned no data — validate instrumentation - One period returns valid data, the other 0 / NULL: render the tile with
the valid value as , pill class
kpi-value, pill textflat, and⚠ N/Atext =prior.Prior {period_noun}: no data - Never substitute a different metric (e.g., switching from Revenue to Orders because Revenue came back $0). The metric being analyzed MUST be the metric rendered.
KPI卡片或异动项行必须准确反映数据视图实际返回的内容。AI不得静默替换为其他指标或隐藏卡片以美化报告。
- 汇总卡片中两个时间段的跟踪指标均返回0或NULL:渲染卡片时设为
kpi-value,标签类为数据不可用,标签文本为flat,⚠ N/A文本为prior。卡片需保留在网格中,不得省略。两个时间段均无数据——请验证数据采集情况 - 一个时间段返回有效数据,另一个返回0/NULL:渲染卡片时设为有效数值,标签类为
kpi-value,标签文本为flat,⚠ N/A文本为prior。上一{时间段名词}:无数据 - 切勿替换为其他指标(例如,因收入返回$0而切换为订单数)。分析的指标必须与展示的指标一致。
HTML Template
HTML模板
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Top Movers — {ORG_NAME} — {METRIC_NAME} by {DIMENSION_NAME}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f4f1;
--surface: #ffffff;
--ink: #1a1a1a;
--ink-muted: #6b6b6b;
--border: #e5e2dc;
--header-bg: #0e0e10;
--header-warm: #3a1010;
--accent-red: #c8312f;
--accent-red-bright: #ff6b68;
--accent-red-soft: #fdecea;
--accent-green: #1f7a4d;
--accent-yellow: #d4a017;
}
body { font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg); color: var(--ink); line-height: 1.5;
-webkit-font-smoothing: antialiased; }
/* === Header === */
header { background: linear-gradient(120deg, var(--header-bg) 0%, #1a0d0d 55%, var(--header-warm) 100%);
color: #fff; padding: 56px 56px 44px; position: relative; overflow: hidden; }
header::after { content: ""; position: absolute; right: -140px; top: -140px;
width: 460px; height: 460px;
background: radial-gradient(circle, rgba(200,49,47,.35) 0%, transparent 70%);
pointer-events: none; }
.header-inner { max-width: 1080px; margin: 0 auto; position: relative; z-index: 1; }
.eyebrow { display: inline-flex; align-items: center; gap: 8px;
padding: 6px 14px; border: 1px solid rgba(255,107,104,.55);
border-radius: 999px; color: var(--accent-red-bright);
font-size: 11px; font-weight: 600; letter-spacing: 1.2px;
text-transform: uppercase; margin-bottom: 24px;
background: rgba(200,49,47,.10); }
.eyebrow::before { content: ""; width: 6px; height: 6px;
background: var(--accent-red-bright); border-radius: 50%; }
header h1 { font-family: "Playfair Display", Georgia, serif;
font-size: 56px; font-weight: 700; letter-spacing: -1.5px;
line-height: 1.05; margin-bottom: 14px; color: #fff; }
header .lede { font-size: 16px; max-width: 560px;
color: rgba(255,255,255,.80); margin-bottom: 24px;
line-height: 1.55; }
header .meta { display: flex; flex-wrap: wrap; gap: 22px;
font-size: 13px; color: rgba(255,255,255,.60); }
header .meta span { display: inline-flex; align-items: center; gap: 6px; }
header .meta .icon { opacity: .8; }
/* === Tabs === */
nav { background: var(--surface); border-bottom: 1px solid var(--border);
padding: 0 56px; display: flex; gap: 28px;
position: sticky; top: 0; z-index: 50; }
nav a { display: block; padding: 16px 0; font-size: 14px;
color: var(--ink); text-decoration: none;
border-bottom: 2px solid transparent;
transition: border-color .15s ease; }
nav a:hover { border-bottom-color: var(--accent-red); }
/* === Container === */
.container { max-width: 1080px; margin: 0 auto; padding: 36px 56px 60px; }
/* === Section label === */
.section-label { font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 1.4px;
color: var(--ink-muted); margin-bottom: 14px;
padding-bottom: 10px; border-bottom: 1px solid var(--border); }
/* === KPI grid === */
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px; margin-bottom: 36px; }
.kpi-tile { background: var(--surface); border-radius: 8px;
padding: 22px 22px 20px;
border-top: 3px solid #b9b6ae;
box-shadow: 0 1px 3px rgba(0,0,0,.05); }
.kpi-tile.down { border-top-color: var(--accent-red); }
.kpi-tile.up { border-top-color: var(--accent-green); }
.kpi-tile.flat { border-top-color: #b9b6ae; }
.kpi-head { display: flex; justify-content: space-between;
align-items: center; margin-bottom: 10px; }
.kpi-label { font-size: 11px; font-weight: 700;
text-transform: uppercase;
color: var(--ink-muted); letter-spacing: 1px; }
.kpi-value { font-family: "Playfair Display", Georgia, serif;
font-weight: 700; font-size: 34px;
line-height: 1; color: var(--ink);
margin-bottom: 12px; }
.pill { display: inline-flex; align-items: center; gap: 4px;
padding: 3px 9px; border-radius: 4px;
font-size: 12px; font-weight: 600; line-height: 1.4; }
.pill.down { background: var(--accent-red-soft); color: var(--accent-red); }
.pill.up { background: #ebf5ef; color: var(--accent-green); }
.pill.flat { background: #f1efea; color: var(--ink-muted); }
.prior { display: block; margin-top: 10px;
font-size: 12px; color: var(--ink-muted); }
/* === Dual risers / fallers panels === */
.two-col { display: grid; grid-template-columns: 1fr 1fr;
gap: 20px; margin-bottom: 22px; }
@media (max-width: 760px) { .two-col { grid-template-columns: 1fr; } }
/* === Sections (collapsible tables) === */
.section { background: var(--surface); border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,.04);
margin-bottom: 22px; overflow: hidden; }
/* Top-border accent on risers/fallers tiles */
.section.risers { border-top: 3px solid var(--accent-green); }
.section.fallers { border-top: 3px solid var(--accent-red); }
.section-header { padding: 18px 28px; border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between;
align-items: center; cursor: pointer; }
.section-header h2 { font-family: "Playfair Display", Georgia, serif;
font-size: 18px; font-weight: 700; }
.section.risers .section-header h2 { color: var(--accent-green); }
.section.fallers .section-header h2 { color: var(--accent-red); }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th { background: #faf8f4; padding: 12px 22px;
text-align: left; font-weight: 600;
text-transform: uppercase; letter-spacing: .6px;
font-size: 11px; color: var(--ink-muted);
border-bottom: 1px solid var(--border); }
tbody td { padding: 12px 22px; border-bottom: 1px solid #f4f1eb; }
tbody tr:last-child td { border-bottom: none; }
.delta-up { color: var(--accent-green); font-weight: 600; }
.delta-down { color: var(--accent-red); font-weight: 600; }
.badge { display: inline-block; padding: 3px 9px; border-radius: 4px;
font-size: 11px; font-weight: 600; }
.badge.green { background: #ebf5ef; color: var(--accent-green); }
.badge.red { background: var(--accent-red-soft); color: var(--accent-red); }
.badge.yellow { background: #fef6e3; color: #b67a08; }
.badge.blue { background: #eef2f8; color: #2c4a7a; }
.badge.grey { background: #f1efea; color: var(--ink-muted); }
.back-top { position: fixed; bottom: 24px; right: 24px;
background: var(--accent-red); color: #fff;
width: 44px; height: 44px; border-radius: 50%;
border: none; font-size: 20px; cursor: pointer;
box-shadow: 0 4px 12px rgba(200,49,47,0.30); }
footer { text-align: center; padding: 32px 24px;
font-size: 12px; color: var(--ink-muted); }
/* === Print === */
@media print {
nav { display: none; position: static; }
header { padding: 36px 32px 28px; }
header h1 { font-size: 42px; }
.section-header { cursor: default; }
.kpi-row { page-break-inside: avoid; }
.kpi-tile, .section {
box-shadow: none; border: 1px solid var(--border);
}
.back-top { display: none; }
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<div class="eyebrow">Top Movers Report</div>
<h1>{ORG_NAME} Top Movers</h1>
<p class="lede">Biggest gainers and decliners for {METRIC_NAME} by {DIMENSION_NAME}, {PERIOD_A_LABEL} vs {PERIOD_B_LABEL}.</p>
<div class="meta">
<span><span class="icon">📅</span> {PERIOD_A_LABEL}</span>
<span><span class="icon">📊</span> {DATA_VIEW}</span>
<span><span class="icon">🕔</span> Prepared {GENERATED_DATE}</span>
</div>
</div>
</header>
<nav>
<a href="#summary">Summary</a>
<a href="#gainers">Gainers</a>
<a href="#decliners">Decliners</a>
<a href="#newdisappeared">New & Gone</a>
<a href="#fulltable">Full Table</a>
</nav>
<div class="container">
<!-- Summary KPI Tiles -->
<div class="section-label">Watchlist Summary</div>
<div id="summary" class="kpi-row">
<div class="kpi-tile flat">
<div class="kpi-head"><div class="kpi-label">Total Items Compared</div></div>
<div class="kpi-value">{TOTAL_ITEMS}</div>
<span class="prior">Dimension values in scope</span>
</div>
<div class="kpi-tile up">
<div class="kpi-head"><div class="kpi-label">Biggest Gain</div></div>
<div class="kpi-value">{TOP_GAINER_VALUE}</div>
<span class="pill up">▲ {TOP_GAINER_NAME}</span>
</div>
<div class="kpi-tile down">
<div class="kpi-head"><div class="kpi-label">Biggest Drop</div></div>
<div class="kpi-value">{TOP_DECLINER_VALUE}</div>
<span class="pill down">▼ {TOP_DECLINER_NAME}</span>
</div>
<div class="kpi-tile flat">
<div class="kpi-head"><div class="kpi-label">New Entries</div></div>
<div class="kpi-value">{NEW_ENTRIES}</div>
<span class="prior">Appeared this period</span>
</div>
<div class="kpi-tile flat">
<div class="kpi-head"><div class="kpi-label">Disappeared</div></div>
<div class="kpi-value">{DISAPPEARED}</div>
<span class="prior">Dropped out this period</span>
</div>
</div>
<!-- Gainers + Decliners side by side -->
<div class="two-col">
<div id="gainers" class="section risers">
<div class="section-header" onclick="toggle('gain-body')">
<h2>▲ Top 10 Gainers</h2>
<span id="gain-body-icon">▾</span>
</div>
<div id="gain-body">
<table>
<thead><tr>
<th>Item</th>
<th>{PERIOD_A_LABEL}</th>
<th>{PERIOD_B_LABEL}</th>
<th>Delta</th>
<th>% Change</th>
</tr></thead>
<tbody>
<!-- Top 10 gainers, sorted by delta desc -->
<!--
<tr>
<td>{ITEM_NAME}</td>
<td>{VALUE_A}</td>
<td>{VALUE_B}</td>
<td class="delta-up">+{DELTA}</td>
<td><span class="badge green">+{PCT}%</span></td>
</tr>
-->
</tbody>
</table>
</div>
</div>
<div id="decliners" class="section fallers">
<div class="section-header" onclick="toggle('dec-body')">
<h2>▼ Top 10 Decliners</h2>
<span id="dec-body-icon">▾</span>
</div>
<div id="dec-body">
<table>
<thead><tr>
<th>Item</th>
<th>{PERIOD_A_LABEL}</th>
<th>{PERIOD_B_LABEL}</th>
<th>Delta</th>
<th>% Change</th>
</tr></thead>
<tbody>
<!-- Top 10 decliners, sorted by delta asc -->
<!--
<tr>
<td>{ITEM_NAME}</td>
<td>{VALUE_A}</td>
<td>{VALUE_B}</td>
<td class="delta-down">−{DELTA}</td>
<td><span class="badge red">−{PCT}%</span></td>
</tr>
-->
</tbody>
</table>
</div>
</div>
</div>
<!-- New Entries & Disappeared -->
<div id="newdisappeared" class="section">
<div class="section-header" onclick="toggle('nd-body')">
<h2>New Entries & Disappeared Items</h2>
<span id="nd-body-icon">▾</span>
</div>
<div id="nd-body">
<table>
<thead><tr>
<th>Item</th>
<th>Status</th>
<th>{PERIOD_A_LABEL}</th>
<th>{PERIOD_B_LABEL}</th>
<th>Notes</th>
</tr></thead>
<tbody>
<!-- New entries: badge blue "New Entry" -->
<!-- Disappeared: badge grey "Disappeared" -->
</tbody>
</table>
</div>
</div>
<!-- Full Table -->
<div id="fulltable" class="section">
<div class="section-header" onclick="toggle('full-body')">
<h2>Full Comparison Table</h2>
<span id="full-body-icon">▾</span>
</div>
<div id="full-body">
<table>
<thead><tr>
<th>Item</th>
<th>{PERIOD_A_LABEL}</th>
<th>{PERIOD_B_LABEL}</th>
<th>Delta</th>
<th>% Change</th>
<th>Status</th>
</tr></thead>
<tbody>
<!-- All items sorted by absolute delta desc -->
</tbody>
</table>
</div>
</div>
</div>
<button class="back-top" onclick="window.scrollTo({top:0,behavior:'smooth'})">↑</button>
<footer>Top Movers — {ORG_NAME} — Generated {GENERATED_DATE}</footer>
<script>
function toggle(id) {
var el = document.getElementById(id);
var ic = document.getElementById(id + '-icon');
if (el.style.display === 'none') { el.style.display=''; ic.textContent='\u25be'; }
else { el.style.display='none'; ic.textContent='\u25b8'; }
}
</script>
</body></html>html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Top Movers — {ORG_NAME} — {METRIC_NAME} by {DIMENSION_NAME}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f4f1;
--surface: #ffffff;
--ink: #1a1a1a;
--ink-muted: #6b6b6b;
--border: #e5e2dc;
--header-bg: #0e0e10;
--header-warm: #3a1010;
--accent-red: #c8312f;
--accent-red-bright: #ff6b68;
--accent-red-soft: #fdecea;
--accent-green: #1f7a4d;
--accent-yellow: #d4a017;
}
body { font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg); color: var(--ink); line-height: 1.5;
-webkit-font-smoothing: antialiased; }
/* === Header === */
header { background: linear-gradient(120deg, var(--header-bg) 0%, #1a0d0d 55%, var(--header-warm) 100%);
color: #fff; padding: 56px 56px 44px; position: relative; overflow: hidden; }
header::after { content: ""; position: absolute; right: -140px; top: -140px;
width: 460px; height: 460px;
background: radial-gradient(circle, rgba(200,49,47,.35) 0%, transparent 70%);
pointer-events: none; }
.header-inner { max-width: 1080px; margin: 0 auto; position: relative; z-index: 1; }
.eyebrow { display: inline-flex; align-items: center; gap: 8px;
padding: 6px 14px; border: 1px solid rgba(255,107,104,.55);
border-radius: 999px; color: var(--accent-red-bright);
font-size: 11px; font-weight: 600; letter-spacing: 1.2px;
text-transform: uppercase; margin-bottom: 24px;
background: rgba(200,49,47,.10); }
.eyebrow::before { content: ""; width: 6px; height: 6px;
background: var(--accent-red-bright); border-radius: 50%; }
header h1 { font-family: "Playfair Display", Georgia, serif;
font-size: 56px; font-weight: 700; letter-spacing: -1.5px;
line-height: 1.05; margin-bottom: 14px; color: #fff; }
header .lede { font-size: 16px; max-width: 560px;
color: rgba(255,255,255,.80); margin-bottom: 24px;
line-height: 1.55; }
header .meta { display: flex; flex-wrap: wrap; gap: 22px;
font-size: 13px; color: rgba(255,255,255,.60); }
header .meta span { display: inline-flex; align-items: center; gap: 6px; }
header .meta .icon { opacity: .8; }
/* === Tabs === */
nav { background: var(--surface); border-bottom: 1px solid var(--border);
padding: 0 56px; display: flex; gap: 28px;
position: sticky; top: 0; z-index: 50; }
nav a { display: block; padding: 16px 0; font-size: 14px;
color: var(--ink); text-decoration: none;
border-bottom: 2px solid transparent;
transition: border-color .15s ease; }
nav a:hover { border-bottom-color: var(--accent-red); }
/* === Container === */
.container { max-width: 1080px; margin: 0 auto; padding: 36px 56px 60px; }
/* === Section label === */
.section-label { font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 1.4px;
color: var(--ink-muted); margin-bottom: 14px;
padding-bottom: 10px; border-bottom: 1px solid var(--border); }
/* === KPI grid === */
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px; margin-bottom: 36px; }
.kpi-tile { background: var(--surface); border-radius: 8px;
padding: 22px 22px 20px;
border-top: 3px solid #b9b6ae;
box-shadow: 0 1px 3px rgba(0,0,0,.05); }
.kpi-tile.down { border-top-color: var(--accent-red); }
.kpi-tile.up { border-top-color: var(--accent-green); }
.kpi-tile.flat { border-top-color: #b9b6ae; }
.kpi-head { display: flex; justify-content: space-between;
align-items: center; margin-bottom: 10px; }
.kpi-label { font-size: 11px; font-weight: 700;
text-transform: uppercase;
color: var(--ink-muted); letter-spacing: 1px; }
.kpi-value { font-family: "Playfair Display", Georgia, serif;
font-weight: 700; font-size: 34px;
line-height: 1; color: var(--ink);
margin-bottom: 12px; }
.pill { display: inline-flex; align-items: center; gap: 4px;
padding: 3px 9px; border-radius: 4px;
font-size: 12px; font-weight: 600; line-height: 1.4; }
.pill.down { background: var(--accent-red-soft); color: var(--accent-red); }
.pill.up { background: #ebf5ef; color: var(--accent-green); }
.pill.flat { background: #f1efea; color: var(--ink-muted); }
.prior { display: block; margin-top: 10px;
font-size: 12px; color: var(--ink-muted); }
/* === Dual risers / fallers panels === */
.two-col { display: grid; grid-template-columns: 1fr 1fr;
gap: 20px; margin-bottom: 22px; }
@media (max-width: 760px) { .two-col { grid-template-columns: 1fr; } }
/* === Sections (collapsible tables) === */
.section { background: var(--surface); border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,.04);
margin-bottom: 22px; overflow: hidden; }
/* Top-border accent on risers/fallers tiles */
.section.risers { border-top: 3px solid var(--accent-green); }
.section.fallers { border-top: 3px solid var(--accent-red); }
.section-header { padding: 18px 28px; border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between;
align-items: center; cursor: pointer; }
.section-header h2 { font-family: "Playfair Display", Georgia, serif;
font-size: 18px; font-weight: 700; }
.section.risers .section-header h2 { color: var(--accent-green); }
.section.fallers .section-header h2 { color: var(--accent-red); }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th { background: #faf8f4; padding: 12px 22px;
text-align: left; font-weight: 600;
text-transform: uppercase; letter-spacing: .6px;
font-size: 11px; color: var(--ink-muted);
border-bottom: 1px solid var(--border); }
tbody td { padding: 12px 22px; border-bottom: 1px solid #f4f1eb; }
tbody tr:last-child td { border-bottom: none; }
.delta-up { color: var(--accent-green); font-weight: 600; }
.delta-down { color: var(--accent-red); font-weight: 600; }
.badge { display: inline-block; padding: 3px 9px; border-radius: 4px;
font-size: 11px; font-weight: 600; }
.badge.green { background: #ebf5ef; color: var(--accent-green); }
.badge.red { background: var(--accent-red-soft); color: var(--accent-red); }
.badge.yellow { background: #fef6e3; color: #b67a08; }
.badge.blue { background: #eef2f8; color: #2c4a7a; }
.badge.grey { background: #f1efea; color: var(--ink-muted); }
.back-top { position: fixed; bottom: 24px; right: 24px;
background: var(--accent-red); color: #fff;
width: 44px; height: 44px; border-radius: 50%;
border: none; font-size: 20px; cursor: pointer;
box-shadow: 0 4px 12px rgba(200,49,47,0.30); }
footer { text-align: center; padding: 32px 24px;
font-size: 12px; color: var(--ink-muted); }
/* === Print === */
@media print {
nav { display: none; position: static; }
header { padding: 36px 32px 28px; }
header h1 { font-size: 42px; }
.section-header { cursor: default; }
.kpi-row { page-break-inside: avoid; }
.kpi-tile, .section {
box-shadow: none; border: 1px solid var(--border);
}
.back-top { display: none; }
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<div class="eyebrow">Top Movers Report</div>
<h1>{ORG_NAME} Top Movers</h1>
<p class="lede">Biggest gainers and decliners for {METRIC_NAME} by {DIMENSION_NAME}, {PERIOD_A_LABEL} vs {PERIOD_B_LABEL}.</p>
<div class="meta">
<span><span class="icon">📅</span> {PERIOD_A_LABEL}</span>
<span><span class="icon">📊</span> {DATA_VIEW}</span>
<span><span class="icon">🕔</span> Prepared {GENERATED_DATE}</span>
</div>
</div>
</header>
<nav>
<a href="#summary">Summary</a>
<a href="#gainers">Gainers</a>
<a href="#decliners">Decliners</a>
<a href="#newdisappeared">New & Gone</a>
<a href="#fulltable">Full Table</a>
</nav>
<div class="container">
<!-- Summary KPI Tiles -->
<div class="section-label">Watchlist Summary</div>
<div id="summary" class="kpi-row">
<div class="kpi-tile flat">
<div class="kpi-head"><div class="kpi-label">Total Items Compared</div></div>
<div class="kpi-value">{TOTAL_ITEMS}</div>
<span class="prior">Dimension values in scope</span>
</div>
<div class="kpi-tile up">
<div class="kpi-head"><div class="kpi-label">Biggest Gain</div></div>
<div class="kpi-value">{TOP_GAINER_VALUE}</div>
<span class="pill up">▲ {TOP_GAINER_NAME}</span>
</div>
<div class="kpi-tile down">
<div class="kpi-head"><div class="kpi-label">Biggest Drop</div></div>
<div class="kpi-value">{TOP_DECLINER_VALUE}</div>
<span class="pill down">▼ {TOP_DECLINER_NAME}</span>
</div>
<div class="kpi-tile flat">
<div class="kpi-head"><div class="kpi-label">New Entries</div></div>
<div class="kpi-value">{NEW_ENTRIES}</div>
<span class="prior">Appeared this period</span>
</div>
<div class="kpi-tile flat">
<div class="kpi-head"><div class="kpi-label">Disappeared</div></div>
<div class="kpi-value">{DISAPPEARED}</div>
<span class="prior">Dropped out this period</span>
</div>
</div>
<!-- Gainers + Decliners side by side -->
<div class="two-col">
<div id="gainers" class="section risers">
<div class="section-header" onclick="toggle('gain-body')">
<h2>▲ Top 10 Gainers</h2>
<span id="gain-body-icon">▾</span>
</div>
<div id="gain-body">
<table>
<thead><tr>
<th>Item</th>
<th>{PERIOD_A_LABEL}</th>
<th>{PERIOD_B_LABEL}</th>
<th>Delta</th>
<th>% Change</th>
</tr></thead>
<tbody>
<!-- Top 10 gainers, sorted by delta desc -->
<!--
<tr>
<td>{ITEM_NAME}</td>
<td>{VALUE_A}</td>
<td>{VALUE_B}</td>
<td class="delta-up">+{DELTA}</td>
<td><span class="badge green">+{PCT}%</span></td>
</tr>
-->
</tbody>
</table>
</div>
</div>
<div id="decliners" class="section fallers">
<div class="section-header" onclick="toggle('dec-body')">
<h2>▼ Top 10 Decliners</h2>
<span id="dec-body-icon">▾</span>
</div>
<div id="dec-body">
<table>
<thead><tr>
<th>Item</th>
<th>{PERIOD_A_LABEL}</th>
<th>{PERIOD_B_LABEL}</th>
<th>Delta</th>
<th>% Change</th>
</tr></thead>
<tbody>
<!-- Top 10 decliners, sorted by delta asc -->
<!--
<tr>
<td>{ITEM_NAME}</td>
<td>{VALUE_A}</td>
<td>{VALUE_B}</td>
<td class="delta-down">−{DELTA}</td>
<td><span class="badge red">−{PCT}%</span></td>
</tr>
-->
</tbody>
</table>
</div>
</div>
</div>
<!-- New Entries & Disappeared -->
<div id="newdisappeared" class="section">
<div class="section-header" onclick="toggle('nd-body')">
<h2>New Entries & Disappeared Items</h2>
<span id="nd-body-icon">▾</span>
</div>
<div id="nd-body">
<table>
<thead><tr>
<th>Item</th>
<th>Status</th>
<th>{PERIOD_A_LABEL}</th>
<th>{PERIOD_B_LABEL}</th>
<th>Notes</th>
</tr></thead>
<tbody>
<!-- New entries: badge blue "New Entry" -->
<!-- Disappeared: badge grey "Disappeared" -->
</tbody>
</table>
</div>
</div>
<!-- Full Table -->
<div id="fulltable" class="section">
<div class="section-header" onclick="toggle('full-body')">
<h2>Full Comparison Table</h2>
<span id="full-body-icon">▾</span>
</div>
<div id="full-body">
<table>
<thead><tr>
<th>Item</th>
<th>{PERIOD_A_LABEL}</th>
<th>{PERIOD_B_LABEL}</th>
<th>Delta</th>
<th>% Change</th>
<th>Status</th>
</tr></thead>
<tbody>
<!-- All items sorted by absolute delta desc -->
</tbody>
</table>
</div>
</div>
</div>
<button class="back-top" onclick="window.scrollTo({top:0,behavior:'smooth'})">↑</button>
<footer>Top Movers — {ORG_NAME} — Generated {GENERATED_DATE}</footer>
<script>
function toggle(id) {
var el = document.getElementById(id);
var ic = document.getElementById(id + '-icon');
if (el.style.display === 'none') { el.style.display=''; ic.textContent='\u25be'; }
else { el.style.display='none'; ic.textContent='\u25b8'; }
}
</script>
</body></html>Workflow Summary
工作流程总结
- Confirm metric, dimension, and both time periods.
- Run for Period A with dimension breakdown (limit 50).
runReport - Run for Period B with same parameters.
runReport - Join on dimension value; compute delta, pctChange, and status.
- Sort into: Top 10 Gainers, Top 10 Decliners, New Entries, Disappeared.
- Generate HTML report inline, write to .
/tmp/cja_top_movers_report_<YYYY-MM-DD_HHMMSS>.html - Open with .
open /tmp/cja_top_movers_report_<YYYY-MM-DD_HHMMSS>.html - Deliver a 3-line inline summary: "Top gainer: X (+Y%), Top decliner: Z (−W%). N new items entered the top 50; M items disappeared."
- 确认指标、维度及两个时间段。
- 调用获取时间段A的维度拆分数据(限制50条)。
runReport - 使用相同参数调用获取时间段B的数据。
runReport - 按维度值合并数据;计算delta、pctChange和status。
- 分类排序:Top10涨幅项、Top10跌幅项、新增项、已消失项。
- 在线生成HTML报告,写入。
/tmp/cja_top_movers_report_<YYYY-MM-DD_HHMMSS>.html - 执行打开报告。
open /tmp/cja_top_movers_report_<YYYY-MM-DD_HHMMSS>.html - 提供3行在线摘要:"涨幅Top项:X(+Y%),跌幅Top项:Z(−W%)。有N个新项目进入Top50;M个项目已消失。"
Important Guardrails
重要约束规则
- Read-only monitoring. Never modify segments, metrics, or project definitions.
- Confirm metric and segment scope before running. Vague requests ("watch everything") need narrowing — ask which dimensions and metrics matter most.
- Use consistent comparison windows. "Top movers" must compare equal-length periods; mismatched windows produce false signals.
- Distinguish noise from signal. Very low-traffic dimension values (e.g., a segment with 5 visits) can show 500% swings — apply a minimum threshold (e.g., at least 100 sessions) before flagging as a mover.
- Cap the watchlist size. Surface the top 10–20 movers by default; overwhelming users with 100 dimension items defeats the purpose.
- Attribute changes to context. When possible, note known business events (campaigns, releases, outages) that could explain movements.
- 只读监控。切勿修改细分群体、指标或项目定义。
- 运行前确认指标和细分范围。模糊的需求(如“监控所有内容”)需要明确——询问哪些维度和指标最为重要。
- 使用一致的对比窗口。“异动项”必须对比长度相同的时间段;不匹配的窗口会产生错误信号。
- 区分噪声与有效信号。流量极低的维度值(例如,仅5次访问的细分群体)可能出现500%的波动——在标记为异动项前,需设置最低阈值(例如,至少100次会话)。
- 限制监控清单规模。默认展示Top10-20异动项;向用户展示100个维度项会适得其反。
- 结合上下文解释变动。若可能,注明已知的业务事件(营销活动、版本发布、故障)以解释变动原因。
Example Interaction
示例交互
"Which marketing channels moved the most last week vs the week before?"
- Metric: Sessions (top usage default)
- Dimension: Marketing Channel
- Period A: last week, Period B: the week before
- Run both reports, join results
- Gainers: Paid Social +32%, Organic Search +8%
- Decliners: Email −18%, Direct −5%
- New: Affiliate (not in top 50 prior week)
- Generate and open report
- Inline summary: "Paid Social drove the biggest gain (+32%). Email was the top decliner (−18%). Affiliate channel appeared for the first time in the top rankings."
"上周与前一周相比,哪些营销渠道的变动最大?"
- 指标:会话数(Sessions,默认常用指标)
- 维度:营销渠道(Marketing Channel)
- 时间段A:上周,时间段B:前一周
- 运行两份报告,合并结果
- 涨幅项:付费社交(+32%)、自然搜索(+8%)
- 跌幅项:邮件(−18%)、直接访问(−5%)
- 新增项:联盟渠道(之前未进入Top50)
- 生成并打开报告
- 在线摘要:"付费社交的涨幅最大(+32%)。邮件是跌幅最大的渠道(−18%)。联盟渠道首次进入Top榜单。"