kpi-dashboard/app_stats/index.html
Iliyas 5d82f7b7f3 feat(Метрики МП): monthly cumulative model + YoY trend sparklines
- updater: group by report_period_id (YYYYmm), current month capped to
  yesterday symmetrically for both years; per-year cumulative reset each Jan;
  adsl CTE now date-bounded for equal-period comparison
- JSON adds months/month_labels + per-metric cur_cum/prev_cum
- page: per-card cumulative sparkline (2025 dashed vs 2026 solid) + legend
2026-06-16 18:00:51 +05:00

1045 lines
28 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">
<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; }
/* loading / error */
#status-msg { text-align:center; padding: 60px 20px; color: var(--color-text-secondary); }
@media (max-width: 900px) { .summary-grid { grid-template-columns: repeat(2,1fr); } }
@media (max-width: 560px) { .summary-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="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>
<div class="cards" id="cards"></div>
</div>
</div>
</div>
<script>
const LOGIN_PASSWORD = 'KTdash1';
const DATA_URL = 'app_metrics.json';
// Резервные данные (на случай file:// или недоступности файла). Обновляются скриптом updater.
const EMBEDDED_METRICS = /*__DATA__*/{
"generated_at": "2026-06-16T17:55:07",
"cur_year": 2026,
"prev_year": 2025,
"period_label": "с 1 января по 15 июня",
"range": {
"start": "2026-01-01",
"end": "2026-06-15"
},
"months": [
1,
2,
3,
4,
5,
6
],
"month_labels": [
"Янв",
"Фев",
"Мар",
"Апр",
"Май",
"Июн"
],
"cumulative": true,
"metrics": [
{
"key": "my_services",
"label": "Мои услуги",
"cur": 1769470,
"prev": 1464715,
"growth": 0.2080643674708049,
"is_new": false,
"cur_cum": [
321989,
621597,
980877,
1327884,
1676590,
1769470
],
"prev_cum": [
308264,
594530,
857453,
1108674,
1341165,
1464715
]
},
{
"key": "traffic",
"label": "Детализация трафика",
"cur": 1271563,
"prev": 1083912,
"growth": 0.17312383293108666,
"is_new": false,
"cur_cum": [
246604,
463349,
727262,
978019,
1179021,
1271563
],
"prev_cum": [
214948,
425004,
634594,
827602,
1001634,
1083912
]
},
{
"key": "payments",
"label": "Платежи",
"cur": 730185,
"prev": 555155,
"growth": 0.31528131783015556,
"is_new": false,
"cur_cum": [
111523,
203960,
358189,
523887,
653394,
730185
],
"prev_cum": [
120062,
232395,
331976,
427765,
512345,
555155
]
},
{
"key": "orders",
"label": "Заявки",
"cur": 826255,
"prev": 638026,
"growth": 0.2950177578970136,
"is_new": false,
"cur_cum": [
173075,
312944,
461361,
601607,
742766,
826255
],
"prev_cum": [
153918,
301147,
409051,
500862,
587480,
638026
]
},
{
"key": "loyalty",
"label": "Лояльность",
"cur": 470969,
"prev": 465354,
"growth": 0.012066083024965939,
"is_new": false,
"cur_cum": [
86429,
152587,
264979,
351819,
432695,
470969
],
"prev_cum": [
88583,
196751,
284932,
371536,
431175,
465354
]
},
{
"key": "pay",
"label": "Оплата",
"cur": 337103,
"prev": 303542,
"growth": 0.11056460061540084,
"is_new": false,
"cur_cum": [
73119,
141842,
198550,
248838,
304803,
337103
],
"prev_cum": [
50344,
101847,
150536,
205493,
269251,
303542
]
},
{
"key": "billing_detail",
"label": "Детали счета",
"cur": 496915,
"prev": 358855,
"growth": 0.38472363489431666,
"is_new": false,
"cur_cum": [
81176,
147137,
241789,
343223,
431096,
496915
],
"prev_cum": [
62482,
134395,
200427,
260559,
315518,
358855
]
},
{
"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": 197009,
"prev": 94796,
"growth": 1.0782416979619394,
"is_new": false,
"cur_cum": [
40566,
65001,
117646,
149938,
185887,
197009
],
"prev_cum": [
27730,
49011,
65023,
75359,
89282,
94796
]
},
{
"key": "tv_plus",
"label": "TV+",
"cur": 64104,
"prev": 95883,
"growth": -0.33143518663370986,
"is_new": false,
"cur_cum": [
11947,
20939,
33969,
45372,
56323,
64104
],
"prev_cum": [
24830,
44479,
61671,
76824,
90692,
95883
]
},
{
"key": "boosters",
"label": "Бустеры",
"cur": 121065,
"prev": 54053,
"growth": 1.2397461750504135,
"is_new": false,
"cur_cum": [
15882,
33486,
61530,
88553,
114321,
121065
],
"prev_cum": [
7244,
12487,
18093,
31284,
49552,
54053
]
},
{
"key": "roaming",
"label": "Роуминг",
"cur": 22160,
"prev": 39403,
"growth": -0.43760627363398724,
"is_new": false,
"cur_cum": [
2924,
5494,
8974,
11894,
18915,
22160
],
"prev_cum": [
6678,
13021,
19649,
27201,
35474,
39403
]
},
{
"key": "pereoform",
"label": "Переоформление",
"cur": 34570,
"prev": 23644,
"growth": 0.46210455083742175,
"is_new": false,
"cur_cum": [
7556,
12691,
18718,
24710,
30986,
34570
],
"prev_cum": [
4876,
9244,
13556,
17422,
21572,
23644
]
},
{
"key": "aitu_music",
"label": "Aitu Music",
"cur": 8651,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
1048,
4000,
5553,
7270,
8651
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "online_booking",
"label": "Онлайн очередь",
"cur": 22144,
"prev": 5449,
"growth": 3.063864929344834,
"is_new": false,
"cur_cum": [
5636,
10095,
13892,
17262,
20514,
22144
],
"prev_cum": [
0,
0,
0,
1848,
4407,
5449
]
},
{
"key": "my_docs",
"label": "Мои документы",
"cur": 53376,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
14980,
24658,
33590,
41765,
49552,
53376
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "dz_statement",
"label": "Справка о ДЗ",
"cur": 132795,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
35454,
64672,
85004,
100574,
119910,
132795
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "new_boosters_roaming_kcell",
"label": "Новая линейка бустеров и роумингов Кселл",
"cur": 28626,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
1927,
4523,
13318,
21056,
28239,
28626
],
"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": 1555,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
232,
671,
1034,
1377,
1555
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "acs",
"label": "ACS",
"cur": 9154,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
2329,
7499,
7800,
8174,
9154
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "kaspi_freedom_pay",
"label": "Прием платежей через Freedom и Kaspi",
"cur": 61481,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
8427,
24816,
39893,
53444,
61481
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "csat",
"label": "CSAT",
"cur": 2486,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
0,
0,
0,
0,
2486
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "multicustomer",
"label": "Мультикастомер",
"cur": 164,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
0,
0,
0,
99,
164
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "tv_plus_setup",
"label": "Настройка TV+",
"cur": 4545,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
0,
0,
0,
2628,
4545
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "static_ip",
"label": "Статический IP",
"cur": 108,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
0,
0,
35,
75,
108
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "turbo_button",
"label": "Turbo кнопка",
"cur": 4312,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
0,
0,
498,
2789,
4312
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "real_estate_docs",
"label": "Справка о недвижимости",
"cur": 78,
"prev": 0,
"growth": null,
"is_new": true,
"cur_cum": [
0,
23,
44,
52,
70,
78
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
}
]
};
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}">
<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>`;
}
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') : '—');
renderSummary();
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).';
}
['search', 'sort', 'only-new'].forEach(id =>
document.addEventListener('input', e => { if (e.target.id === id) renderCards(); }));
// ───────── 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>