kpi-dashboard/app_stats/index.html
Iliyas b3ec72ca34 feat(Метрики МП): top KPI cards, grid/list toggle, clickable cards with chart modal
- 4 top KPI cards (registrations, downloads by OS+total, MAU, DAU) from
  drb_iliyas_telecomkz_daily_info + telecomkz_mau_stats; JSON gets top{} block
- grid/list view toggle (list = full-width cards), persisted in localStorage
- click any card -> modal with Chart.js line chart + per-month table; tooltip
  shows monthly delta for both years; ESC/overlay/✕ to close
2026-06-17 13:09:00 +05:00

1353 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Метрики МП — Казахтелеком 2026</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<style>
:root {
--color-bg: #F4F6F9;
--color-surface: #FFFFFF;
--color-border: #E5E7EB;
--color-text-primary: #111827;
--color-text-secondary: #6B7280;
--color-brand: #0052CC;
--color-brand-light: #EFF6FF;
--color-green: #10B981;
--color-green-bg: #ECFDF5;
--color-yellow: #F59E0B;
--color-yellow-bg: #FFFBEB;
--color-red: #EF4444;
--color-red-bg: #FEF2F2;
--color-purple: #8B5CF6;
--color-purple-bg: #F5F3FF;
--radius-card: 12px;
--radius-btn: 8px;
--shadow-card: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
--shadow-card-hover: 0 4px 12px rgba(0,0,0,0.1);
--font-base: 'Inter', system-ui, sans-serif;
--font-mono: 'Roboto Mono', monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-base); background: var(--color-bg); color: var(--color-text-primary); font-size: 14px; line-height: 1.5; }
@keyframes spin { to { transform: rotate(360deg); } }
/* LOGIN */
#login-screen { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #0052CC 0%, #0070F3 100%); }
.login-box { background: #fff; border-radius: 20px; padding: 48px 56px; text-align: center; max-width: 420px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
.login-logo { display: inline-flex; align-items: center; justify-content: center; width: 64px; height: 64px; background: var(--color-brand); color: #fff; font-size: 24px; font-weight: 700; border-radius: 16px; margin-bottom: 20px; }
.login-box h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
.login-box > p { color: var(--color-text-secondary); margin-bottom: 28px; font-size: 14px; }
.txt-input { width: 100%; padding: 11px 14px; border: 1px solid var(--color-border); border-radius: var(--radius-btn); font-size: 15px; font-family: var(--font-base); outline: none; transition: border-color 0.15s; }
.txt-input:focus { border-color: var(--color-brand); }
.btn-primary { padding: 11px 40px; background: var(--color-brand); color: #fff; border: none; border-radius: var(--radius-btn); font-size: 14px; font-weight: 600; cursor: pointer; font-family: var(--font-base); transition: background 0.15s; }
.btn-primary:hover { background: #003fa3; }
#login-error { color: var(--color-red); font-size: 13px; margin-top: 10px; min-height: 18px; }
/* HEADER */
#app { display: none; }
.header { background: var(--color-brand); color: #fff; padding: 0 24px; height: 64px; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 8px rgba(0,82,204,0.3); }
.header-left { display: flex; align-items: center; gap: 14px; }
.header-logo { width: 40px; height: 40px; background: rgba(255,255,255,0.2); border-radius: 10px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 14px; flex-shrink: 0; }
.header-left h1 { font-size: 16px; font-weight: 700; line-height: 1.2; }
.header-left p { font-size: 12px; opacity: 0.75; }
.header-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
#generated-at { font-size: 12px; opacity: 0.85; }
.btn { padding: 7px 14px; border-radius: var(--radius-btn); font-size: 13px; font-weight: 500; cursor: pointer; border: none; font-family: var(--font-base); transition: all 0.15s; text-decoration: none; display: inline-block; }
.btn-ghost { background: rgba(255,255,255,0.15); color: #fff; }
.btn-ghost:hover { background: rgba(255,255,255,0.25); }
/* SUMMARY */
.wrap { padding: 20px 24px 40px; max-width: 1400px; margin: 0 auto; }
.summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 22px; }
.sum-card { background: var(--color-surface); border-radius: var(--radius-card); padding: 16px 18px; box-shadow: var(--shadow-card); border-top: 4px solid var(--color-brand); }
.sum-card.accent-green { border-top-color: var(--color-green); }
.sum-card.accent-purple { border-top-color: var(--color-purple); }
.sum-card.accent-yellow { border-top-color: var(--color-yellow); }
.sum-label { font-size: 11px; font-weight: 600; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 8px; }
.sum-value { font-size: 26px; font-weight: 700; font-family: var(--font-mono); line-height: 1.1; }
.sum-sub { font-size: 12px; color: var(--color-text-secondary); margin-top: 4px; }
/* CONTROLS */
.controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; }
.controls .grow { flex: 1 1 220px; }
.controls label { font-size: 13px; color: var(--color-text-secondary); display: flex; align-items: center; gap: 6px; cursor: pointer; }
select.txt-input, input.txt-input { font-size: 13px; padding: 9px 12px; }
select.txt-input { cursor: pointer; background: #fff; }
/* METRIC CARDS */
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.card { background: var(--color-surface); border-radius: var(--radius-card); padding: 16px 18px; box-shadow: var(--shadow-card); border-left: 4px solid var(--color-border); transition: box-shadow 0.2s, transform 0.2s; }
.card:hover { box-shadow: var(--shadow-card-hover); transform: translateY(-2px); }
.card.up { border-left-color: var(--color-green); }
.card.down { border-left-color: var(--color-red); }
.card.flat { border-left-color: var(--color-yellow); }
.card.new { border-left-color: var(--color-purple); }
.card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; margin-bottom: 12px; }
.card-title { font-size: 13px; font-weight: 600; line-height: 1.35; }
.badge { font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 20px; white-space: nowrap; flex-shrink: 0; }
.badge.up { background: var(--color-green-bg); color: var(--color-green); }
.badge.down { background: var(--color-red-bg); color: var(--color-red); }
.badge.flat { background: var(--color-yellow-bg); color: #92400E; }
.badge.new { background: var(--color-purple-bg); color: var(--color-purple); }
.card-value { font-size: 30px; font-weight: 700; font-family: var(--font-mono); line-height: 1; }
.card-value-year { font-size: 11px; color: var(--color-text-secondary); font-weight: 600; margin-left: 2px; }
.card-sub { font-size: 11px; color: var(--color-text-secondary); margin-top: 2px; }
/* накопительный мини-график (нарастающим итогом по месяцам) */
.spark { width: 100%; height: 52px; display: block; margin-top: 12px; overflow: visible; }
.spark polyline { fill: none; stroke-width: 2; vector-effect: non-scaling-stroke; stroke-linecap: round; stroke-linejoin: round; }
.spark .sp-cur { stroke: var(--color-brand); }
.spark .sp-prev { stroke: #C7D2E8; stroke-dasharray: 3 3; }
.spark .dot-cur { fill: var(--color-brand); }
.spark-x { display: flex; justify-content: space-between; font-size: 9px; color: var(--color-text-secondary); font-family: var(--font-mono); margin-top: 2px; }
.legend { display: flex; gap: 14px; margin-top: 10px; font-size: 11px; }
.legend span { display: flex; align-items: center; gap: 5px; color: var(--color-text-secondary); }
.legend b { font-family: var(--font-mono); color: var(--color-text-primary); font-weight: 600; }
.legend .dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.legend .dot.cur { background: var(--color-brand); }
.legend .dot.prev { background: #C7D2E8; }
.empty-note { text-align: center; color: var(--color-text-secondary); padding: 40px; grid-column: 1 / -1; }
/* ── TOP KPI CARDS ── */
.section-title { font-size: 13px; font-weight: 700; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; margin: 4px 0 12px; }
.top-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 24px; }
.kpi-stat { background: var(--color-surface); border-radius: var(--radius-card); padding: 16px 18px; box-shadow: var(--shadow-card); border-top: 4px solid var(--color-brand); cursor: pointer; transition: box-shadow 0.2s, transform 0.2s; }
.kpi-stat:hover { box-shadow: var(--shadow-card-hover); transform: translateY(-2px); }
.kpi-stat.accent-green { border-top-color: var(--color-green); }
.kpi-stat.accent-purple { border-top-color: var(--color-purple); }
.kpi-stat.accent-yellow { border-top-color: var(--color-orange); }
.kpi-stat-value { font-size: 26px; font-weight: 700; font-family: var(--font-mono); line-height: 1.1; margin-top: 2px; }
.kpi-stat-sub { font-size: 12px; color: var(--color-text-secondary); margin-top: 4px; min-height: 16px; }
/* ── VIEW TOGGLE ── */
.view-toggle { display: flex; gap: 4px; }
.view-toggle button { padding: 8px 11px; border: 1px solid var(--color-border); background: #fff; cursor: pointer; border-radius: var(--radius-btn); color: var(--color-text-secondary); font-family: var(--font-base); font-size: 14px; line-height: 1; transition: all 0.15s; }
.view-toggle button:hover { border-color: var(--color-brand); color: var(--color-brand); }
.view-toggle button.active { background: var(--color-brand); color: #fff; border-color: var(--color-brand); }
/* ── LIST VIEW ── */
.card { cursor: pointer; }
.cards.list { grid-template-columns: 1fr; }
.cards.list .card { display: grid; grid-template-columns: minmax(190px, 1.1fr) 130px minmax(160px, 2fr) minmax(150px, auto); align-items: center; gap: 22px; }
.cards.list .card-head { margin-bottom: 0; }
.cards.list .card-value { font-size: 24px; }
.cards.list .spark { margin-top: 0; height: 44px; }
.cards.list .spark-x { display: none; }
.cards.list .legend { margin-top: 0; flex-direction: column; gap: 5px; }
/* ── MODAL ── */
.modal-overlay { position: fixed; inset: 0; background: rgba(17,24,39,0.55); display: none; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
.modal-overlay.open { display: flex; }
.modal-box { background: #fff; border-radius: 16px; max-width: 880px; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.25); }
.modal-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; padding: 20px 24px; border-bottom: 1px solid var(--color-border); position: sticky; top: 0; background: #fff; z-index: 1; }
.modal-title { font-size: 18px; font-weight: 700; }
.modal-sub { font-size: 12px; color: var(--color-text-secondary); margin-top: 3px; }
.modal-close { background: var(--color-bg); border: none; width: 32px; height: 32px; border-radius: 8px; cursor: pointer; font-size: 16px; color: var(--color-text-secondary); flex-shrink: 0; }
.modal-close:hover { background: var(--color-border); }
.modal-body { padding: 20px 24px; }
.modal-chart-wrap { position: relative; height: 300px; margin-bottom: 20px; }
.mtable { width: 100%; border-collapse: collapse; font-size: 13px; }
.mtable th, .mtable td { padding: 7px 10px; text-align: right; border-bottom: 1px solid var(--color-border); font-family: var(--font-mono); white-space: nowrap; }
.mtable th:first-child, .mtable td:first-child { text-align: left; font-family: var(--font-base); }
.mtable th { font-weight: 600; color: var(--color-text-secondary); font-size: 11px; text-transform: uppercase; letter-spacing: 0.02em; }
.mtable tbody tr:last-child td { font-weight: 700; }
/* loading / error */
#status-msg { text-align:center; padding: 60px 20px; color: var(--color-text-secondary); }
@media (max-width: 900px) { .summary-grid, .top-grid { grid-template-columns: repeat(2,1fr); } .cards.list .card { grid-template-columns: 1fr; gap: 10px; } }
@media (max-width: 560px) { .summary-grid, .top-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<!-- LOGIN -->
<div id="login-screen">
<div class="login-box">
<div class="login-logo">KT</div>
<h1>Метрики МП</h1>
<p>Казахтелеком · 2026</p>
<input type="password" id="login-password" class="txt-input" placeholder="Введите пароль" style="text-align:center;margin-bottom:12px;">
<button class="btn-primary" id="btn-login">Войти</button>
<p id="login-error"></p>
</div>
</div>
<!-- APP -->
<div id="app">
<header class="header">
<div class="header-left">
<div class="header-logo">KT</div>
<div>
<h1>📱 Метрики МП</h1>
<p>Использование функций МП · нарастающим итогом, год к году</p>
</div>
</div>
<div class="header-right">
<span id="generated-at"></span>
<a class="btn btn-ghost" href="../index.html">← KPI Dashboard</a>
</div>
</header>
<div class="wrap">
<div id="status-msg">Загрузка данных…</div>
<div id="content" style="display:none">
<div class="section-title">Ключевые показатели приложения</div>
<div class="top-grid" id="top-grid"></div>
<div class="section-title">Сравнение функций · год к году</div>
<div class="summary-grid" id="summary-grid"></div>
<div class="controls">
<input type="text" id="search" class="txt-input grow" placeholder="🔍 Поиск метрики…">
<select id="sort" class="txt-input">
<option value="cur">Сортировка: по использованию ↓</option>
<option value="growth">по приросту ↓</option>
<option value="label">по названию (А→Я)</option>
</select>
<label><input type="checkbox" id="only-new"> только новые</label>
<div class="view-toggle">
<button data-view="grid" title="Сетка"></button>
<button data-view="list" title="Список"></button>
</div>
</div>
<div class="cards" id="cards"></div>
</div>
</div>
</div>
<!-- MODAL -->
<div class="modal-overlay" id="modal">
<div class="modal-box">
<div class="modal-head">
<div>
<div class="modal-title" id="modal-title"></div>
<div class="modal-sub" id="modal-sub"></div>
</div>
<button class="modal-close" id="modal-close" title="Закрыть"></button>
</div>
<div class="modal-body" id="modal-body"></div>
</div>
</div>
<script>
const LOGIN_PASSWORD = 'KTdash1';
const DATA_URL = 'app_metrics.json';
// Резервные данные (на случай file:// или недоступности файла). Обновляются скриптом updater.
const EMBEDDED_METRICS = /*__DATA__*/{
"generated_at": "2026-06-17T13:02:59",
"cur_year": 2026,
"prev_year": 2025,
"period_label": "с 1 января по 16 июня",
"range": {
"start": "2026-01-01",
"end": "2026-06-16"
},
"months": [
1,
2,
3,
4,
5,
6
],
"month_labels": [
"Янв",
"Фев",
"Мар",
"Апр",
"Май",
"Июн"
],
"cumulative": true,
"metrics": [
{
"key": "my_services",
"label": "Мои услуги",
"cur": 1796228,
"prev": 1472562,
"growth": 0.21979787608263693,
"is_new": false,
"cur_cum": [
321989,
621597,
980877,
1327890,
1676682,
1796228
],
"prev_cum": [
308264,
594530,
857453,
1108674,
1341165,
1472562
]
},
{
"key": "traffic",
"label": "Детализация трафика",
"cur": 1290610,
"prev": 1089617,
"growth": 0.18446206327544448,
"is_new": false,
"cur_cum": [
246604,
463349,
727262,
978030,
1179069,
1290610
],
"prev_cum": [
214948,
425004,
634594,
827602,
1001634,
1089617
]
},
{
"key": "payments",
"label": "Платежи",
"cur": 741888,
"prev": 557497,
"growth": 0.3307479681505013,
"is_new": false,
"cur_cum": [
111523,
203960,
358189,
523890,
653416,
741888
],
"prev_cum": [
120062,
232395,
331976,
427765,
512345,
557497
]
},
{
"key": "orders",
"label": "Заявки",
"cur": 845340,
"prev": 642020,
"growth": 0.31668795364630387,
"is_new": false,
"cur_cum": [
173075,
312944,
461361,
601613,
742794,
845340
],
"prev_cum": [
153918,
301147,
409051,
500862,
587480,
642020
]
},
{
"key": "loyalty",
"label": "Лояльность",
"cur": 474289,
"prev": 466803,
"growth": 0.016036743551348213,
"is_new": false,
"cur_cum": [
86429,
152587,
264979,
351823,
432724,
474289
],
"prev_cum": [
88583,
196751,
284932,
371536,
431175,
466803
]
},
{
"key": "pay",
"label": "Оплата",
"cur": 341470,
"prev": 305025,
"growth": 0.11948200967133842,
"is_new": false,
"cur_cum": [
73119,
141842,
198550,
248839,
304810,
341470
],
"prev_cum": [
50344,
101847,
150536,
205493,
269251,
305025
]
},
{
"key": "billing_detail",
"label": "Детали счета",
"cur": 502390,
"prev": 359719,
"growth": 0.39661791565082744,
"is_new": false,
"cur_cum": [
81176,
147137,
241789,
343225,
431112,
502390
],
"prev_cum": [
62482,
134395,
200427,
260559,
315518,
359719
]
},
{
"key": "viktorina",
"label": "Викторина KT Club",
"cur": 213879,
"prev": 298475,
"growth": -0.28342742273222216,
"is_new": false,
"cur_cum": [
0,
1,
118526,
213879,
213879,
213879
],
"prev_cum": [
0,
0,
49414,
298475,
298475,
298475
]
},
{
"key": "partners",
"label": "Акции партнеров",
"cur": 197777,
"prev": 95016,
"growth": 1.0815125873537088,
"is_new": false,
"cur_cum": [
40566,
65001,
117646,
149940,
185907,
197777
],
"prev_cum": [
27730,
49011,
65023,
75359,
89282,
95016
]
},
{
"key": "tv_plus",
"label": "TV+",
"cur": 65461,
"prev": 96208,
"growth": -0.31958880758356895,
"is_new": false,
"cur_cum": [
11947,
20939,
33969,
45372,
56326,
65461
],
"prev_cum": [
24830,
44479,
61671,
76824,
90692,
96208
]
},
{
"key": "boosters",
"label": "Бустеры",
"cur": 123015,
"prev": 54525,
"growth": 1.256121045392022,
"is_new": false,
"cur_cum": [
15882,
33486,
61530,
88555,
114328,
123015
],
"prev_cum": [
7244,
12487,
18093,
31284,
49552,
54525
]
},
{
"key": "roaming",
"label": "Роуминг",
"cur": 22826,
"prev": 39835,
"growth": -0.4269863185640768,
"is_new": false,
"cur_cum": [
2924,
5494,
8974,
11894,
18923,
22826
],
"prev_cum": [
6678,
13021,
19649,
27201,
35474,
39835
]
},
{
"key": "pereoform",
"label": "Переоформление",
"cur": 35385,
"prev": 23802,
"growth": 0.48663977816990167,
"is_new": false,
"cur_cum": [
7556,
12691,
18718,
24711,
30988,
35385
],
"prev_cum": [
4876,
9244,
13556,
17422,
21572,
23802
]
},
{
"key": "aitu_music",
"label": "Aitu Music",
"cur": 8872,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
1048,
4000,
5553,
7271,
8872
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "online_booking",
"label": "Онлайн очередь",
"cur": 22529,
"prev": 5523,
"growth": 3.0791236646749955,
"is_new": false,
"cur_cum": [
5636,
10095,
13892,
17262,
20514,
22529
],
"prev_cum": [
0,
0,
0,
1848,
4407,
5523
]
},
{
"key": "my_docs",
"label": "Мои документы",
"cur": 54148,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
14980,
24658,
33590,
41765,
49555,
54148
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "dz_statement",
"label": "Справка о ДЗ",
"cur": 134463,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
35454,
64672,
85004,
100575,
119914,
134463
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "new_boosters_roaming_kcell",
"label": "Новая линейка бустеров и роумингов Кселл",
"cur": 28858,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
1927,
4523,
13318,
21056,
28240,
28858
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "adsl",
"label": "ADSL отключение услуги",
"cur": 69,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
20,
33,
44,
55,
65,
69
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "law_and_order",
"label": "Закон и порядок",
"cur": 1598,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
232,
671,
1034,
1377,
1598
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "acs",
"label": "ACS",
"cur": 9447,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
2329,
7499,
7800,
8174,
9447
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "kaspi_freedom_pay",
"label": "Прием платежей через Freedom и Kaspi",
"cur": 62724,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
8427,
24816,
39893,
53445,
62724
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "csat",
"label": "CSAT",
"cur": 4057,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
0,
0,
0,
0,
4057
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "multicustomer",
"label": "Мультикастомер",
"cur": 169,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
0,
0,
0,
99,
169
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "tv_plus_setup",
"label": "Настройка TV+",
"cur": 4908,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
0,
0,
0,
2630,
4908
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "static_ip",
"label": "Статический IP",
"cur": 119,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
0,
0,
35,
75,
119
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "turbo_button",
"label": "Turbo кнопка",
"cur": 4638,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
0,
0,
498,
2789,
4638
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "real_estate_docs",
"label": "Справка о недвижимости",
"cur": 79,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
23,
44,
52,
70,
79
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
}
],
"top": {
"as_of": "2026-06-16",
"registered_total": 1078231,
"installs_total": 2307490,
"installs_ios": 924083,
"installs_android": 1383407,
"mau": 185155,
"dau": 17757,
"trend_labels": [
"Июн 25",
"Июл 25",
"Авг 25",
"Сен 25",
"Окт 25",
"Ноя 25",
"Дек 25",
"Янв 26",
"Фев 26",
"Мар 26",
"Апр 26",
"Май 26",
"Июн 26"
],
"registered_series": [
925945,
937312,
949404,
961772,
973331,
985194,
1000768,
1016923,
1030968,
1043887,
1056398,
1068794,
1078231
],
"installs_series": [
1485273,
1519017,
1552922,
1722057,
1803173,
1879957,
1973234,
2053773,
2131472,
2176215,
2215453,
2260248,
2307490
],
"mau_series": [
203025,
137297,
147812,
123950,
92999,
92384,
117661,
174283,
165292,
182520,
178778,
162540,
121894
],
"dau_series": [
35538,
18569,
19517,
15670,
11862,
12247,
17946,
27955,
28326,
29972,
28007,
25391,
26288
]
}
};
const App = { data: null };
// ───────── helpers ─────────
const fmtInt = n => (n || 0).toLocaleString('ru-RU');
function fmtPct(g) {
if (g === null || g === undefined) return '';
const s = (g * 100);
return (s >= 0 ? '+' : '') + s.toFixed(1) + '%';
}
function classify(m) {
if (m.is_new) return 'new';
if (m.growth === null) return 'flat';
if (m.growth > 0.0005) return 'up';
if (m.growth < -0.0005) return 'down';
return 'flat';
}
function badgeText(m, cls) {
if (cls === 'new') return 'NEW';
if (cls === 'flat') return '≈ 0%';
return fmtPct(m.growth);
}
// ───────── render ─────────
function renderSummary() {
const d = App.data;
const totCur = d.metrics.reduce((s, m) => s + m.cur, 0);
const totPrev = d.metrics.reduce((s, m) => s + m.prev, 0);
const overall = totPrev > 0 ? (totCur - totPrev) / totPrev : null;
const nNew = d.metrics.filter(m => m.is_new).length;
const nUp = d.metrics.filter(m => !m.is_new && m.growth > 0).length;
const nDown = d.metrics.filter(m => !m.is_new && m.growth < 0).length;
const cards = [
{ cls: '', label: 'Период', value: d.cur_year + ' / ' + d.prev_year, sub: d.period_label },
{ cls: 'accent-green', label: 'Обращений · ' + d.cur_year, value: fmtInt(totCur),
sub: 'против ' + fmtInt(totPrev) + ' в ' + d.prev_year + (overall !== null ? ' · ' + fmtPct(overall) : '') },
{ cls: 'accent-yellow', label: 'Динамика', value: '▲ ' + nUp + ' ▼ ' + nDown,
sub: nUp + ' выросли, ' + nDown + ' снизились' },
{ cls: 'accent-purple', label: 'Новых функций', value: String(nNew),
sub: 'появились в ' + d.cur_year + ' году' },
];
document.getElementById('summary-grid').innerHTML = cards.map(c => `
<div class="sum-card ${c.cls}">
<div class="sum-label">${c.label}</div>
<div class="sum-value">${c.value}</div>
<div class="sum-sub">${c.sub}</div>
</div>`).join('');
}
function renderCards() {
const d = App.data;
const q = document.getElementById('search').value.trim().toLowerCase();
const sort = document.getElementById('sort').value;
const onlyNew = document.getElementById('only-new').checked;
let list = d.metrics.slice();
if (onlyNew) list = list.filter(m => m.is_new);
if (q) list = list.filter(m => m.label.toLowerCase().includes(q));
list.sort((a, b) => {
if (sort === 'label') return a.label.localeCompare(b.label, 'ru');
if (sort === 'growth') {
// новые — в конце при сортировке по приросту (у них нет базы сравнения)
const ga = a.is_new ? -Infinity : (a.growth ?? -Infinity);
const gb = b.is_new ? -Infinity : (b.growth ?? -Infinity);
return gb - ga;
}
return b.cur - a.cur; // по использованию
});
const el = document.getElementById('cards');
if (!list.length) { el.innerHTML = '<div class="empty-note">Ничего не найдено</div>'; return; }
const labels = d.month_labels || [];
el.innerHTML = list.map(m => {
const cls = classify(m);
const curCum = m.cur_cum || [m.cur];
const prevCum = m.prev_cum || [m.prev];
return `
<div class="card ${cls}" data-key="${m.key}">
<div class="card-head">
<div>
<div class="card-title">${m.label}</div>
<div class="card-sub">нарастающим итогом · ${d.cur_year} vs ${d.prev_year}</div>
</div>
<span class="badge ${cls}">${badgeText(m, cls)}</span>
</div>
<div class="card-value">${fmtInt(m.cur)}<span class="card-value-year"> · ${d.cur_year}</span></div>
${sparkline(curCum, prevCum)}
<div class="spark-x"><span>${labels[0] || ''}</span><span>${labels[labels.length - 1] || ''}</span></div>
<div class="legend">
<span><i class="dot cur"></i>${d.cur_year}: <b>${fmtInt(m.cur)}</b></span>
<span><i class="dot prev"></i>${d.prev_year}: <b>${fmtInt(m.prev)}</b></span>
</div>
</div>`;
}).join('');
}
// Накопительный мини-график: две кривые (пунктир — прошлый год, сплошная — текущий)
function sparkline(curCum, prevCum) {
const W = 100, H = 34, pad = 3;
const n = Math.max(curCum.length, prevCum.length, 1);
const max = Math.max(1, ...curCum, ...prevCum);
const x = i => n <= 1 ? W / 2 : pad + i * (W - 2 * pad) / (n - 1);
const y = v => H - pad - (v / max) * (H - 2 * pad);
const pts = arr => arr.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
const lastI = curCum.length - 1;
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
<polyline class="sp-prev" points="${pts(prevCum)}"/>
<polyline class="sp-cur" points="${pts(curCum)}"/>
<circle class="dot-cur" cx="${x(lastI).toFixed(1)}" cy="${y(curCum[lastI] || 0).toFixed(1)}" r="2.2"/>
</svg>`;
}
// ───────── верхние KPI ─────────
const ACCENT_COLOR = { '': '#0052CC', 'accent-green': '#10B981', 'accent-purple': '#8B5CF6', 'accent-yellow': '#F97316' };
function fmtDate(s) {
if (!s) return '—';
const p = String(s).slice(0, 10).split('-');
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : s;
}
function delta(cum) { return cum.map((v, i) => i === 0 ? v : v - cum[i - 1]); }
// одиночный мини-график (одна линия)
function miniline(series, color) {
if (!series || !series.length) return '';
const W = 100, H = 30, pad = 3, n = series.length;
const max = Math.max(...series), min = Math.min(...series), span = (max - min) || 1;
const x = i => n <= 1 ? W / 2 : pad + i * (W - 2 * pad) / (n - 1);
const y = v => H - pad - ((v - min) / span) * (H - 2 * pad);
const pts = series.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
<polyline points="${pts}" style="fill:none;stroke:${color};stroke-width:2;vector-effect:non-scaling-stroke;stroke-linecap:round;stroke-linejoin:round"/></svg>`;
}
function renderTop() {
const t = App.data.top;
const grid = document.getElementById('top-grid');
const title = grid.previousElementSibling;
if (!t) { grid.style.display = 'none'; if (title) title.style.display = 'none'; return; }
const cards = [
{ key: 'registered', accent: '', label: 'Регистрации', value: fmtInt(t.registered_total), sub: 'всего зарегистрировано', series: t.registered_series },
{ key: 'installs', accent: 'accent-green', label: 'Скачивания', value: fmtInt(t.installs_total), sub: `iOS ${fmtInt(t.installs_ios)} · Android ${fmtInt(t.installs_android)}`, series: t.installs_series },
{ key: 'mau', accent: 'accent-purple', label: 'MAU', value: fmtInt(t.mau), sub: 'на ' + fmtDate(t.as_of), series: t.mau_series },
{ key: 'dau', accent: 'accent-yellow', label: 'DAU', value: fmtInt(t.dau), sub: 'на ' + fmtDate(t.as_of), series: t.dau_series },
];
grid.innerHTML = cards.map(c => `
<div class="kpi-stat ${c.accent}" data-top="${c.key}">
<div class="sum-label">${c.label}</div>
<div class="kpi-stat-value">${c.value}</div>
<div class="kpi-stat-sub">${c.sub}</div>
${miniline(c.series, ACCENT_COLOR[c.accent])}
</div>`).join('');
}
// ───────── вид: сетка / список ─────────
function setView(v) {
App.view = v;
try { localStorage.setItem('mp_view', v); } catch (e) {}
document.getElementById('cards').classList.toggle('list', v === 'list');
document.querySelectorAll('.view-toggle button').forEach(b => b.classList.toggle('active', b.dataset.view === v));
}
// ───────── модалка ─────────
let modalChart = null;
function closeModal() {
document.getElementById('modal').classList.remove('open');
if (modalChart) { modalChart.destroy(); modalChart = null; }
}
function openModal(title, sub, bodyHTML) {
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-sub').textContent = sub;
document.getElementById('modal-body').innerHTML = bodyHTML;
document.getElementById('modal').classList.add('open');
}
function drawChart(labels, datasets, monthlyMap) {
if (typeof Chart === 'undefined') return;
const ctx = document.getElementById('modal-canvas');
if (!ctx) return;
modalChart = new Chart(ctx, {
type: 'line',
data: { labels, datasets: datasets.map(s => ({
label: s.label, data: s.data, borderColor: s.color, backgroundColor: s.color,
borderWidth: 2.5, tension: 0.3, pointRadius: 3, pointHoverRadius: 5, fill: false,
borderDash: s.dash || [],
})) },
options: {
responsive: true, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 8 } },
tooltip: { callbacks: { label: (c) => {
const base = `${c.dataset.label}: ${fmtInt(c.parsed.y)}`;
const mo = monthlyMap && monthlyMap[c.dataset.label];
return mo ? `${base} (за месяц +${fmtInt(mo[c.dataIndex])})` : base;
} } },
},
scales: { y: { ticks: { callback: v => fmtInt(v) }, grid: { color: '#F0F2F5' } }, x: { grid: { display: false } } },
},
});
}
function openMetricModal(key) {
const d = App.data, m = d.metrics.find(x => x.key === key);
if (!m) return;
const labels = d.month_labels || [];
const curCum = m.cur_cum || [m.cur], prevCum = m.prev_cum || [m.prev];
const curMo = delta(curCum), prevMo = delta(prevCum);
const cy = String(d.cur_year), py = String(d.prev_year);
let rows = '';
for (let i = 0; i < labels.length; i++) {
rows += `<tr><td>${labels[i]}</td><td>${fmtInt(curCum[i])}</td><td>+${fmtInt(curMo[i])}</td><td>${fmtInt(prevCum[i])}</td><td>+${fmtInt(prevMo[i])}</td></tr>`;
}
const body = `
<div class="modal-chart-wrap"><canvas id="modal-canvas"></canvas></div>
<table class="mtable">
<thead><tr><th>Месяц</th><th>${cy} ∑</th><th>${cy} /мес</th><th>${py} ∑</th><th>${py} /мес</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
openModal(m.label, `Нарастающим итогом · ${d.period_label}`, body);
drawChart(labels, [
{ label: cy, data: curCum, color: '#0052CC' },
{ label: py, data: prevCum, color: '#9CB4D9', dash: [5, 4] },
], { [cy]: curMo, [py]: prevMo });
}
function openTopModal(key) {
const t = App.data.top;
if (!t) return;
const labels = t.trend_labels || [];
const conf = {
registered: { title: 'Регистрации', color: '#0052CC', data: t.registered_series, note: 'Накопительно (всего зарегистрировано)' },
installs: { title: 'Скачивания', color: '#10B981', data: t.installs_series, note: `Накопительно · iOS ${fmtInt(t.installs_ios)} · Android ${fmtInt(t.installs_android)}` },
mau: { title: 'MAU', color: '#8B5CF6', data: t.mau_series, note: 'Среднемесячное число активных за месяц' },
dau: { title: 'DAU', color: '#F97316', data: t.dau_series, note: 'Среднесуточное число активных за месяц' },
}[key];
if (!conf) return;
let rows = '';
for (let i = 0; i < labels.length; i++) rows += `<tr><td>${labels[i]}</td><td>${fmtInt(conf.data[i])}</td></tr>`;
const body = `
<div class="modal-chart-wrap"><canvas id="modal-canvas"></canvas></div>
<table class="mtable">
<thead><tr><th>Месяц</th><th>${conf.title}</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
openModal(conf.title, conf.note + ' · по ' + fmtDate(t.as_of), body);
drawChart(labels, [{ label: conf.title, data: conf.data, color: conf.color }], null);
}
function renderAll() {
document.getElementById('status-msg').style.display = 'none';
document.getElementById('content').style.display = 'block';
const gen = App.data.generated_at ? new Date(App.data.generated_at) : null;
document.getElementById('generated-at').textContent =
'Обновлено: ' + (gen ? gen.toLocaleDateString('ru-RU') : '—');
renderTop();
renderSummary();
let v = 'grid';
try { v = localStorage.getItem('mp_view') || 'grid'; } catch (e) {}
setView(v);
renderCards();
}
// ───────── load ─────────
async function loadData() {
if (window.location.protocol !== 'file:') {
try {
const r = await fetch(DATA_URL, { cache: 'no-cache' });
if (r.ok) { App.data = await r.json(); renderAll(); return; }
} catch (e) { /* fall through */ }
}
if (EMBEDDED_METRICS && EMBEDDED_METRICS.metrics) {
App.data = EMBEDDED_METRICS; renderAll(); return;
}
document.getElementById('status-msg').textContent =
'Не удалось загрузить данные (app_metrics.json).';
}
document.addEventListener('input', e => {
if (['search', 'sort', 'only-new'].includes(e.target.id)) renderCards();
});
// переключатель вида
document.addEventListener('click', e => {
const vb = e.target.closest('.view-toggle button');
if (vb) { setView(vb.dataset.view); return; }
// клик по карточке метрики или верхней KPI
const top = e.target.closest('.kpi-stat');
if (top) { openTopModal(top.dataset.top); return; }
const card = e.target.closest('.card[data-key]');
if (card) { openMetricModal(card.dataset.key); return; }
});
// закрытие модалки
document.getElementById('modal-close').addEventListener('click', closeModal);
document.getElementById('modal').addEventListener('click', e => { if (e.target.id === 'modal') closeModal(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
// ───────── login ─────────
function showApp() {
document.getElementById('login-screen').style.display = 'none';
document.getElementById('app').style.display = 'block';
loadData();
}
function handleLogin() {
const inp = document.getElementById('login-password');
if (inp.value === LOGIN_PASSWORD) {
if (window.sessionStorage) sessionStorage.setItem('kpi_auth', '1');
showApp();
} else {
document.getElementById('login-error').textContent = 'Неверный пароль';
inp.value = '';
}
}
document.getElementById('btn-login').addEventListener('click', handleLogin);
document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') handleLogin(); });
document.addEventListener('DOMContentLoaded', () => {
if (window.sessionStorage && sessionStorage.getItem('kpi_auth') === '1') showApp();
});
</script>
</body>
</html>