# Редизайн KPI-полосы и состава чартов дашборда «E-commerce Analytics»

Дата: 2026-06-06
Статус: Implemented (`superset/create_dashboard.py`, `docs/SUPERSET_DASHBOARD.md`,
`docs/course/lessons/06_superset_bi.md`)
Связано: [`CONTEXT.md`](../../CONTEXT.md), [ADR-0002](../adr/0002-specs-as-durable-design-docs.md),
урок [`docs/course/lessons/06_superset_bi.md`](../course/lessons/06_superset_bi.md)

## Problem

Дашборд-эталон показывает две KPI-плитки — `Unique Users` и `Unique Sessions` — с
одинаковым числом (26 на срезе, 99 на полных данных), потому что в датасете
`user_domain_id ↔ click_id` строго 1:1. Две одинаковые цифры читаются как баг
расчёта и ничему не учат менти, который копирует дашборд как образец. Нужен
честный, копируемый состав KPI и чартов на полных данных.

## Context

- Дашборд — учебный эталон для менти. Требований обратной совместимости нет:
  состав можно менять свободно, критерий — учебная ценность.
- Доменные термины зафиксированы в `CONTEXT.md`: **пользователь** =
  `user_domain_id`, **визит/сессия** = `click_id`, **событие** = `event_id`.
- Источник данных — статический сид `data/*.jsonl`. Синтетический генератор
  (ветка `feature/data-generator`) источником **не** является и семантику ломает
  (см. `generator/KNOWN_ISSUES.md` на той ветке).

## Goals

- KPI-полоса из 4 различающихся честных метрик (без дубля одинаковых чисел).
- Превратить слабые места данных в учебные объекты, а не маскировать их.
- Консистентность с `CONTEXT.md` и уроком 6.

## Non-goals

- Не модернизируем **типы** виджетов (`pie`/геовизуализация/`dist_bar` → ECharts) —
  отдельный косметический заход. Геоблок позже заменён задачей 10
  `generator-model-time-startup-history`.
- Не трогаем эмодзи в тайтлах, секции-заголовки, языковой винегрет.
- Не трогаем генератор.
- Не правим код `create_dashboard.py` в рамках этой спеки — это реализация.

## Current state

Полный датасет (`make data` без `LIMIT`):

```
события: 1000 | визиты (click_id): 99 | пользователи (user_domain_id): 99 (1:1)
avg событий на визит: 10.10 | bounce (визиты с 1 событием): 4/99 = 4.0%
event_type: 100% pageview (других типов НЕТ)
page_url_path — воронка: /home 426 → /product_a 236 + /product_b 157 →
                          /cart 93 → /payment 53 → /confirmation 35 (~8% в конец)
geo_country: 40 | device_type: 2 (Mobile/Computer) | utm_source: 5 | utm_medium: 4
```

Текущие чарты и триаж по учебной ценности:

| Чарт | Тип | Вердикт |
|---|---|---|
| Total Events, Unique Users | big_number_total | оставить (KPI) |
| Unique Sessions | big_number_total | **убрать** (дубль Users) |
| Avg Events/Session | big_number_total | оставить, переименовать `/Visit` |
| Top Pages | dist_bar | **апгрейд в Funnel** (центральный учебный объект) |
| UTM Effectiveness | table | оставить; **выкинуть колонки purchases/add_to_cart** (всегда 0) |
| Geography Map | прежняя геовизуализация | позже заменена на `Top Countries by Events` |
| Traffic by Device | pie | оставить |
| Events by Hour | line | **проверить на пустоту**, иначе дропнуть |
| Data Quality Summary | dist_bar | оставить (⚠️ позже пересмотрено — см. ниже) |

## Options considered

- **A — честная воронка вовлечённости (стандартный KPI-strip).** 4 плитки
  объём→охват→глубина→качество; различие user/session уходит в текст.
- **B — оставить обе плитки + Markdown-панель**, объясняющая равенство 1:1.
- **C — strip как форма модели:** воронка `Events → Visits → Users` вместо
  бизнес-KPI; вырожденное 1:1 становится наглядным.

## Recommended approach

**A для KPI-полосы + идея C отдельным чартом-воронкой.** KPI-полоса —
стандартная и чистая (копируемый паттерн); иерархия модели подаётся отдельным
чартом, а `Top Pages` превращается в **Funnel** по страницам.

KPI-полоса:

```
📊 Total Events 1000 · 👤 Unique Users 99 · 📈 Avg Events/Visit 10.1 · 🎯 Conversion to /confirmation ~8%
```

## Rationale

- Конверсия через `page_url_path` честнее блёклого bounce (4%) и превращает
  «всё pageview» из слабости в сильный урок: воронка реально затухает.
- Различие user/session учим текстом (`CONTEXT.md` + урок 6), а не двумя
  одинаковыми числами — это убирает сигнал «баг».
- Стандартный KPI-strip ценнее для копирования, чем нестандартный вариант C;
  но идею C сохраняем как отдельный наглядный чарт.

## Decisions and rejected alternatives

- **Данные: полный датасет** и для дашборда, и для урока 6. Срез `LIMIT=50`
  отклонён: числа эталона разойдутся с тем, что менти получит у себя. Урок 6 —
  убрать `LIMIT=50 make data`. (Срез `LIMIT` оставить в доке как явный приём
  отладки, не как основной прогон. Замечание: ограничение `head -n` исходно
  возникло, чтобы агентский контекст не давился полным jsonl — это ограничение
  для агента, не для пайплайна.)
- **Убрать `Unique Sessions` из KPI** — дубль `Unique Users` при 1:1.
- **4-я KPI = Conversion**, не bounce. Bounce 4% отклонён как блёклый.
- **Top Pages → Funnel.** Воронка по страницам — главный учебный объект.
- **UTM-таблица: убрать колонки purchases/add_to_cart** — всегда 0, т.к.
  `event_type` только `pageview`.
- **Гео/device/DQ не удаляем** — данные оправдывают ценность; модернизация их
  типов виджетов — отдельно (non-goal здесь).
- **«Снести всё legacy»** на практике = убрать дубль-KPI + мёртвые колонки +
  проверить Events by Hour. Инсайты не вырезаем — у большинства есть данные.

## Risks and mitigations

- **Funnel в Superset 4.1.2** — проверить доступность `viz_type` воронки до
  реализации; при отсутствии — упорядоченный bar.
- **Формула Conversion** — определить точно и валидировать SQL (см. Open
  questions).
- **Идемпотентность `create_dashboard.py`** при смене состава (чарты по
  `slice_name`, дашборд по `slug`) — переименование `Unique Sessions`/`Top Pages`
  не должно плодить дубли; проверить апдейт на месте.

## Validation

- KPI-полоса: 4 разных числа, дубля нет.
- `GET /api/v1/dashboard/<id>/datasets` → 200; DQ Summary без
  `Columns missing in datasource`.
- Визуальная проверка через playwright-cli (скриншот в `/tmp`).
- Числа на дашборде сходятся с прямым запросом в ClickHouse.

## Documentation impact

- `docs/SUPERSET_DASHBOARD.md` — обновить раздел «Структура дашборда»
  (состав чартов/KPI/фильтров) **в момент реализации**, чтобы пользовательская
  дока не описывала несуществующие чарты.
- `docs/course/lessons/06_superset_bi.md` — убрать `LIMIT=50`, синхронизировать
  состав чартов и скриншоты.
- `CONTEXT.md` — уже отражает user/visit/event; правок не требует.

## Open questions

- **Resolved: точная формула Conversion.** Используем page-funnel conversion:
  `countIf(page_url_path = '/confirmation') / countIf(page_url_path = '/home')`.
  На полном датасете это `35 / 426 = 8.2%`. Визитовая формула даёт 25.3% и
  отклонена, потому что KPI должен совпадать с логикой chart `Page Funnel`.
- **Resolved: Events by Hour.** Оставляем: на полном датасете есть два часовых
  бакета (`20 → 256`, `21 → 744`), график не пустой.
- **Resolved: Funnel в Superset 4.1.2.** MCP Context7 по `/apache/superset` не
  дал точной строки `viz_type`; установленный Superset 4.1.2 проверен по bundled
  example `Featured Charts/Funnel.yaml` и frontend assets. Используем
  `viz_type: funnel`.
- **Требования к будущему генератору**, вытекающие из упёртостей этих данных
  (разнообразие `event_type`, возвраты пользователей → sessions>users,
  реалистичная воронка) — при возврате к генератору перенести в его
  `KNOWN_ISSUES.md`.

## Пересмотр после приёмки (2026-06-06)

Решение «Data Quality Summary | dist_bar | оставить» **пересмотрено** при визуальной
проверке дашборда. Чарт суммировал `total_rows` по всем таблицам слоя, складывая
таблицы разного зерна (события 1000 + визиты 99 + пустые error-таблицы) в один столбец,
и рисовал убывающую «воронку потерь» (stg≈4250 → ods≈2198 → dds≈1099), которой в данных
нет. Для учебного стенда это активно вводит в заблуждение.

Чарт переделан в честный row-lineage **одного event-зерна**
(`browser_raw → browser_event → event → v_events_enriched`) и переименован в
`🧱 Rows by Layer (event)`. Теперь убывание настоящее: шаг `1050 → 1000` — это
дедупликация at-least-once потока по `event_id` в ODS (`ReplacingMergeTree`).
В `dm.dq_summary` добавлена строка для слоя `dm` (`sql/dm/40_dds_to_dm.sql`), чтобы
цепочка замыкалась до витрины. Настоящие сигналы качества (`rows_with_errors`,
`orphan_events`) на чистых демо-данных = 0 и остаются отдельными `check_name`.
