cja-top-movers-watchlist

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Top 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工具

  • describeCja(DATAVIEW_CONTEXT_GUIDE)
    — load data view calendar/timezone
  • findMetrics
    — resolve the metric being watched
  • findCalculatedMetrics
    — if the metric is a custom KPI
  • findDimensions
    — resolve the dimension to break down by
  • runReport
    — pull dimension-metric data for both periods
  • searchDimensionItems
    — validate dimension values if user specifies names

  • describeCja(DATAVIEW_CONTEXT_GUIDE)
    — 加载数据视图的日历/时区信息
  • findMetrics
    — 确定要监控的指标
  • findCalculatedMetrics
    — 若指标为自定义KPI时使用
  • findDimensions
    — 确定用于拆分的维度
  • runReport
    — 拉取两个时间段的维度-指标数据
  • searchDimensionItems
    — 若用户指定维度名称,验证维度值

Phase 0 — Setup

阶段0 — 准备工作

  1. Call
    findDataViews
    and
    setDefaultSessionDataViewId
    as needed.
  2. Call
    describeCja("DATAVIEW_CONTEXT_GUIDE")
    to load data view context. Record the first-day-of-week as
    WEEK_START_DOW
    and timezone as
    TIMEZONE
    . If the context guide does not return a week-start value, default to Monday (ISO 8601). You will use both in Phase 1.3.

  1. 根据需要调用
    findDataViews
    setDefaultSessionDataViewId
  2. 调用
    describeCja("DATAVIEW_CONTEXT_GUIDE")
    加载数据视图上下文。记录一周起始日为
    WEEK_START_DOW
    ,时区为
    TIMEZONE
    。若上下文未返回一周起始值,默认使用周一(ISO 8601标准)。这两个参数将在阶段1.3中使用。

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:
DimensionUse 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
findDimensions(search: "<dimension keyword>")
to resolve the dimension ID.
常见维度选择及其典型使用场景:
维度使用场景
Marketing Channel"哪些渠道发生了变动?"
Page Name"哪些页面呈趋势性变化?"
Campaign"哪些营销活动表现提升?"
Product"哪些产品的关注度上升/下降?"
Country / Region"哪些市场发生了变动?"
Device Type"移动端或桌面端的表现是否有变化?"
Referring Domain"哪些引荐域名的流量发生了变化?"
若用户未指定,询问:
"我应该按哪个维度进行拆分?例如,营销渠道、页面、营销活动、国家/地区或产品。"
调用
findDimensions(search: "<维度关键词>")
解析维度ID。

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
WEEK_START_DOW
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'
startDate
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.
Sanity check before calling
runReport
:
confirm
periodA.startDate
and
periodB.startDate
are the same day-of-week and that
periodA.startDate - periodB.endDate == 1 day
. If not, recompute.

定义时间段A(当前)和时间段B(对比)。默认设置:
  • 时间段A:本周(或过去7天)
  • 时间段B:上周(或之前的7天)
若用户指定“本月 vs 上月”或自定义时间范围,相应调整。在生成报告前务必确认时间段:
"我将对比本周(3月13日-19日)上周(3月6日-12日)。是否正确?"
日历规则(强制要求):
使用阶段0中获取的
WEEK_START_DOW
定义“周”的范围。时间段A和时间段B必须使用相同的一周起始日——即两个时间段的
startDate
为同一星期几,长度完全相同,且时间段B结束后立即开始时间段A。切勿混合使用不同规则(例如,时间段A为周一至周日,而时间段B为周日至周六)。一旦确定边界,两个时间段均需基于此边界推导。对于自定义时间范围,时间段B为与时间段A长度相同、且在时间段A开始前结束的窗口。
**调用
runReport
前的合理性检查:**确认
periodA.startDate
periodB.startDate
为同一星期几,且
periodA.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
limit: 50
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.
Row data is in the
rows
array — each row has
value
(dimension item name) and
data[0]
(the metric value). There is no limit on dimension cardinality but results default to sorted by metric descending, which is what you want.
Always verify the dimension ID with
findDimensions(searchQuery: "<name>")
before running — dimension IDs can vary from what you might guess (e.g.,
variables/marketing_channel
not
variables/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
)
使用
limit: 50
捕获足够多的项目,以展示有意义的异动项。若用户选择的维度包含数千个值(例如页面名称),则限制为时间段A中Top100的项目,以确保对比的有效性。
行数据位于
rows
数组中——每行包含
value
(维度项名称)和
data[0]
(指标值)。维度基数无限制,但结果默认按指标降序排列,这正是我们需要的。
运行报告前务必通过
findDimensions(searchQuery: "<名称>")
验证维度ID——维度ID可能与预期不同(例如
variables/marketing_channel
而非
variables/marketingchannel
)。

Phase 3 — Compute Rankings

阶段3 — 计算排名

Build a unified table joining both result sets on dimension value:
For each dimension value present in either period:
  • valueA
    = metric value in Period A (0 if not present)
  • valueB
    = metric value in Period B (0 if not present)
  • delta
    = valueA − valueB
  • pctChange
    = (delta / valueB) × 100 if valueB > 0, else "New Entry"
  • 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:
  1. Top Gainers: sort by delta descending (biggest absolute gains first)
  2. Top Decliners: sort by delta ascending (biggest absolute drops first)
  3. % Gainers: sort by pctChange descending
  4. % 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).

构建一个统一表格,按维度值合并两个结果集:
对于任一时间段中存在的每个维度值:
  • valueA
    = 时间段A中的指标值(若不存在则为0)
  • valueB
    = 时间段B中的指标值(若不存在则为0)
  • delta
    = valueA − valueB
  • pctChange
    = (delta / valueB) × 100(若valueB > 0),否则为"新增项"
  • status
    :
    • 存在于A但不存在于B → 新增项(本时间段首次出现)
    • 存在于B但不存在于A → 已消失(本时间段不再出现)
    • 两个时间段均存在 → 常规异动项
排序规则:
  1. Top涨幅项:按delta降序排列(绝对涨幅最大的在前)
  2. Top跌幅项:按delta升序排列(绝对跌幅最大的在前)
  3. 涨幅百分比Top:按pctChange降序排列
  4. 跌幅百分比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>.html

Rendering 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
    ,
    1,250,000
    ). Do NOT use SI suffixes like
    K
    or
    M
    , even for large values. Stakeholders want exact numbers, not abbreviations.
  • Percent change (in pills and narrative bullets) — always one decimal place, rounded half-away-from-zero. For example,
    −23.55%
    displays as
    −23.6%
    , never
    −23.5%
    . Compute on full-precision values; round only at display time.
  • Percentage-point change (for already-percentage metrics like Conversion Rate or Bounce Rate) — same rounding, suffix
    pp
    . Example:
    +0.40 pp
    .
  • Currency
    $
    prefix with thousands separators and no decimals for values ≥ $100 (
    $1,240,000
    ); cents only when value < $100 (
    $45.20
    ).
  • KPI值(每个汇总卡片中的大数字,以及每行异动项的数值)——使用带千位分隔符的完整数字(
    8,160
    77,584
    1,250,000
    )。即使数值很大,切勿使用
    K
    M
    等国际单位制后缀。相关人员需要精确数字,而非缩写。
  • 百分比变化(在标签和叙述性项目符号中)——始终保留一位小数,采用四舍五入(远离零方向)。例如,
    −23.55%
    显示为
    −23.6%
    ,而非
    −23.5%
    。基于全精度数值计算;仅在展示时进行四舍五入。
  • 百分点变化(针对已为百分比的指标,如转化率或跳出率)——四舍五入规则相同,后缀为
    pp
    。示例:
    +0.40 pp
  • 货币——添加
    $
    前缀,带千位分隔符;数值≥$100时不保留小数(
    $1,240,000
    );数值<$100时保留分位(
    $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
    =
    Data unavailable
    , pill class
    flat
    , pill text
    ⚠ N/A
    , and
    prior
    text =
    Both periods returned no data — validate instrumentation
    . The tile stays in the grid; do not omit it.
  • One period returns valid data, the other 0 / NULL: render the tile with the valid value as
    kpi-value
    , pill class
    flat
    , pill text
    ⚠ N/A
    , and
    prior
    text =
    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 &mdash; {ORG_NAME} &mdash; {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">&#128197;</span> {PERIOD_A_LABEL}</span>
      <span><span class="icon">&#128202;</span> {DATA_VIEW}</span>
      <span><span class="icon">&#128340;</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 &amp; 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">&#9650; {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">&#9660; {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>&#9650; Top 10 Gainers</h2>
        <span id="gain-body-icon">&#9662;</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>&#9660; Top 10 Decliners</h2>
        <span id="dec-body-icon">&#9662;</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">&minus;{DELTA}</td>
              <td><span class="badge red">&minus;{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 &amp; Disappeared Items</h2>
      <span id="nd-body-icon">&#9662;</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">&#9662;</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'})">&uarr;</button>
<footer>Top Movers &mdash; {ORG_NAME} &mdash; 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 &mdash; {ORG_NAME} &mdash; {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">&#128197;</span> {PERIOD_A_LABEL}</span>
      <span><span class="icon">&#128202;</span> {DATA_VIEW}</span>
      <span><span class="icon">&#128340;</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 &amp; 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">&#9650; {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">&#9660; {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>&#9650; Top 10 Gainers</h2>
        <span id="gain-body-icon">&#9662;</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>&#9660; Top 10 Decliners</h2>
        <span id="dec-body-icon">&#9662;</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">&minus;{DELTA}</td>
              <td><span class="badge red">&minus;{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 &amp; Disappeared Items</h2>
      <span id="nd-body-icon">&#9662;</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">&#9662;</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'})">&uarr;</button>
<footer>Top Movers &mdash; {ORG_NAME} &mdash; 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

工作流程总结

  1. Confirm metric, dimension, and both time periods.
  2. Run
    runReport
    for Period A with dimension breakdown (limit 50).
  3. Run
    runReport
    for Period B with same parameters.
  4. Join on dimension value; compute delta, pctChange, and status.
  5. Sort into: Top 10 Gainers, Top 10 Decliners, New Entries, Disappeared.
  6. Generate HTML report inline, write to
    /tmp/cja_top_movers_report_<YYYY-MM-DD_HHMMSS>.html
    .
  7. Open with
    open /tmp/cja_top_movers_report_<YYYY-MM-DD_HHMMSS>.html
    .
  8. Deliver a 3-line inline summary: "Top gainer: X (+Y%), Top decliner: Z (−W%). N new items entered the top 50; M items disappeared."

  1. 确认指标、维度及两个时间段。
  2. 调用
    runReport
    获取时间段A的维度拆分数据(限制50条)。
  3. 使用相同参数调用
    runReport
    获取时间段B的数据。
  4. 按维度值合并数据;计算delta、pctChange和status。
  5. 分类排序:Top10涨幅项、Top10跌幅项、新增项、已消失项。
  6. 在线生成HTML报告,写入
    /tmp/cja_top_movers_report_<YYYY-MM-DD_HHMMSS>.html
  7. 执行
    open /tmp/cja_top_movers_report_<YYYY-MM-DD_HHMMSS>.html
    打开报告。
  8. 提供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?"
  1. Metric: Sessions (top usage default)
  2. Dimension: Marketing Channel
  3. Period A: last week, Period B: the week before
  4. Run both reports, join results
  5. Gainers: Paid Social +32%, Organic Search +8%
  6. Decliners: Email −18%, Direct −5%
  7. New: Affiliate (not in top 50 prior week)
  8. Generate and open report
  9. 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."
"上周与前一周相比,哪些营销渠道的变动最大?"
  1. 指标:会话数(Sessions,默认常用指标)
  2. 维度:营销渠道(Marketing Channel)
  3. 时间段A:上周,时间段B:前一周
  4. 运行两份报告,合并结果
  5. 涨幅项:付费社交(+32%)、自然搜索(+8%)
  6. 跌幅项:邮件(−18%)、直接访问(−5%)
  7. 新增项:联盟渠道(之前未进入Top50)
  8. 生成并打开报告
  9. 在线摘要:"付费社交的涨幅最大(+32%)。邮件是跌幅最大的渠道(−18%)。联盟渠道首次进入Top榜单。"