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
This commit is contained in:
parent
4acad9a25c
commit
5d82f7b7f3
@ -94,14 +94,20 @@ select.txt-input { cursor: pointer; background: #fff; }
|
||||
.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; }
|
||||
.cmp { margin-top: 14px; display: flex; flex-direction: column; gap: 7px; }
|
||||
.cmp-row { display: grid; grid-template-columns: 38px 1fr auto; align-items: center; gap: 8px; }
|
||||
.cmp-year { font-size: 11px; color: var(--color-text-secondary); font-weight: 600; font-family: var(--font-mono); }
|
||||
.cmp-track { height: 8px; background: var(--color-bg); border-radius: 8px; overflow: hidden; }
|
||||
.cmp-fill { height: 100%; border-radius: 8px; transition: width 0.6s ease; min-width: 2px; }
|
||||
.cmp-fill.cur { background: var(--color-brand); }
|
||||
.cmp-fill.prev { background: #C7D2E8; }
|
||||
.cmp-num { font-size: 11px; font-family: var(--font-mono); color: var(--color-text-secondary); white-space: nowrap; }
|
||||
.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 */
|
||||
@ -132,7 +138,7 @@ select.txt-input { cursor: pointer; background: #fff; }
|
||||
<div class="header-logo">KT</div>
|
||||
<div>
|
||||
<h1>📱 Метрики МП</h1>
|
||||
<p>Использование функций мобильного приложения</p>
|
||||
<p>Использование функций МП · нарастающим итогом, год к году</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
@ -168,7 +174,7 @@ const DATA_URL = 'app_metrics.json';
|
||||
|
||||
// Резервные данные (на случай file:// или недоступности файла). Обновляются скриптом updater.
|
||||
const EMBEDDED_METRICS = /*__DATA__*/{
|
||||
"generated_at": "2026-06-16T17:30:19",
|
||||
"generated_at": "2026-06-16T17:55:07",
|
||||
"cur_year": 2026,
|
||||
"prev_year": 2025,
|
||||
"period_label": "с 1 января по 15 июня",
|
||||
@ -176,230 +182,695 @@ const EMBEDDED_METRICS = /*__DATA__*/{
|
||||
"start": "2026-01-01",
|
||||
"end": "2026-06-15"
|
||||
},
|
||||
"months": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6
|
||||
],
|
||||
"month_labels": [
|
||||
"Янв",
|
||||
"Фев",
|
||||
"Мар",
|
||||
"Апр",
|
||||
"Май",
|
||||
"Июн"
|
||||
],
|
||||
"cumulative": true,
|
||||
"metrics": [
|
||||
{
|
||||
"key": "my_services",
|
||||
"label": "Мои услуги",
|
||||
"prev": 1459571,
|
||||
"cur": 1769470,
|
||||
"growth": 0.21232197680003234,
|
||||
"is_new": false
|
||||
"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": "Детализация трафика",
|
||||
"prev": 1079736,
|
||||
"cur": 1271563,
|
||||
"growth": 0.17766102084213178,
|
||||
"is_new": false
|
||||
"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": "Платежи",
|
||||
"prev": 553808,
|
||||
"cur": 730185,
|
||||
"growth": 0.3184804119839367,
|
||||
"is_new": false
|
||||
"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": "Заявки",
|
||||
"prev": 635621,
|
||||
"cur": 826255,
|
||||
"growth": 0.2999177182629271,
|
||||
"is_new": false
|
||||
"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": "Лояльность",
|
||||
"prev": 464365,
|
||||
"cur": 470969,
|
||||
"growth": 0.014221571393192856,
|
||||
"is_new": false
|
||||
"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": "Оплата",
|
||||
"prev": 302510,
|
||||
"cur": 337103,
|
||||
"growth": 0.11435324452084229,
|
||||
"is_new": false
|
||||
"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": "Детали счета",
|
||||
"prev": 358290,
|
||||
"cur": 496915,
|
||||
"growth": 0.3869072539004717,
|
||||
"is_new": false
|
||||
"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",
|
||||
"prev": 298475,
|
||||
"cur": 213879,
|
||||
"prev": 298475,
|
||||
"growth": -0.28342742273222216,
|
||||
"is_new": false
|
||||
"is_new": false,
|
||||
"cur_cum": [
|
||||
0,
|
||||
1,
|
||||
118526,
|
||||
213879,
|
||||
213879,
|
||||
213879
|
||||
],
|
||||
"prev_cum": [
|
||||
0,
|
||||
0,
|
||||
49414,
|
||||
298475,
|
||||
298475,
|
||||
298475
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "partners",
|
||||
"label": "Акции партнеров",
|
||||
"prev": 94639,
|
||||
"cur": 197009,
|
||||
"growth": 1.08168936696288,
|
||||
"is_new": false
|
||||
"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+",
|
||||
"prev": 95647,
|
||||
"cur": 64104,
|
||||
"growth": -0.32978556567377965,
|
||||
"is_new": false
|
||||
"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": "Бустеры",
|
||||
"prev": 53649,
|
||||
"cur": 121065,
|
||||
"growth": 1.2566124252082984,
|
||||
"is_new": false
|
||||
"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": "Роуминг",
|
||||
"prev": 39200,
|
||||
"cur": 22160,
|
||||
"growth": -0.4346938775510204,
|
||||
"is_new": false
|
||||
"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": "Переоформление",
|
||||
"prev": 23537,
|
||||
"cur": 34570,
|
||||
"growth": 0.46875132769681777,
|
||||
"is_new": false
|
||||
"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",
|
||||
"prev": 0,
|
||||
"cur": 8651,
|
||||
"prev": 0,
|
||||
"growth": null,
|
||||
"is_new": true
|
||||
"is_new": true,
|
||||
"cur_cum": [
|
||||
0,
|
||||
1048,
|
||||
4000,
|
||||
5553,
|
||||
7270,
|
||||
8651
|
||||
],
|
||||
"prev_cum": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "online_booking",
|
||||
"label": "Онлайн очередь",
|
||||
"prev": 5421,
|
||||
"cur": 22144,
|
||||
"growth": 3.084855192768862,
|
||||
"is_new": false
|
||||
"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": "Мои документы",
|
||||
"prev": 0,
|
||||
"cur": 53376,
|
||||
"prev": 0,
|
||||
"growth": null,
|
||||
"is_new": true
|
||||
"is_new": true,
|
||||
"cur_cum": [
|
||||
14980,
|
||||
24658,
|
||||
33590,
|
||||
41765,
|
||||
49552,
|
||||
53376
|
||||
],
|
||||
"prev_cum": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "dz_statement",
|
||||
"label": "Справка о ДЗ",
|
||||
"prev": 0,
|
||||
"cur": 132795,
|
||||
"prev": 0,
|
||||
"growth": null,
|
||||
"is_new": true
|
||||
"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": "Новая линейка бустеров и роумингов Кселл",
|
||||
"prev": 0,
|
||||
"cur": 28626,
|
||||
"prev": 0,
|
||||
"growth": null,
|
||||
"is_new": true
|
||||
"is_new": true,
|
||||
"cur_cum": [
|
||||
1927,
|
||||
4523,
|
||||
13318,
|
||||
21056,
|
||||
28239,
|
||||
28626
|
||||
],
|
||||
"prev_cum": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "adsl",
|
||||
"label": "ADSL отключение услуги",
|
||||
"prev": 0,
|
||||
"cur": 69,
|
||||
"prev": 0,
|
||||
"growth": null,
|
||||
"is_new": true
|
||||
"is_new": true,
|
||||
"cur_cum": [
|
||||
20,
|
||||
33,
|
||||
44,
|
||||
55,
|
||||
65,
|
||||
69
|
||||
],
|
||||
"prev_cum": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "law_and_order",
|
||||
"label": "Закон и порядок",
|
||||
"prev": 0,
|
||||
"cur": 1555,
|
||||
"prev": 0,
|
||||
"growth": null,
|
||||
"is_new": true
|
||||
"is_new": true,
|
||||
"cur_cum": [
|
||||
0,
|
||||
232,
|
||||
671,
|
||||
1034,
|
||||
1377,
|
||||
1555
|
||||
],
|
||||
"prev_cum": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "acs",
|
||||
"label": "ACS",
|
||||
"prev": 0,
|
||||
"cur": 9154,
|
||||
"prev": 0,
|
||||
"growth": null,
|
||||
"is_new": true
|
||||
"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",
|
||||
"prev": 0,
|
||||
"cur": 61481,
|
||||
"prev": 0,
|
||||
"growth": null,
|
||||
"is_new": true
|
||||
"is_new": true,
|
||||
"cur_cum": [
|
||||
0,
|
||||
8427,
|
||||
24816,
|
||||
39893,
|
||||
53444,
|
||||
61481
|
||||
],
|
||||
"prev_cum": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "csat",
|
||||
"label": "CSAT",
|
||||
"prev": 0,
|
||||
"cur": 2486,
|
||||
"prev": 0,
|
||||
"growth": null,
|
||||
"is_new": true
|
||||
"is_new": true,
|
||||
"cur_cum": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2486
|
||||
],
|
||||
"prev_cum": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "multicustomer",
|
||||
"label": "Мультикастомер",
|
||||
"prev": 0,
|
||||
"cur": 164,
|
||||
"prev": 0,
|
||||
"growth": null,
|
||||
"is_new": true
|
||||
"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+",
|
||||
"prev": 0,
|
||||
"cur": 4545,
|
||||
"prev": 0,
|
||||
"growth": null,
|
||||
"is_new": true
|
||||
"is_new": true,
|
||||
"cur_cum": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2628,
|
||||
4545
|
||||
],
|
||||
"prev_cum": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "static_ip",
|
||||
"label": "Статический IP",
|
||||
"prev": 0,
|
||||
"cur": 108,
|
||||
"prev": 0,
|
||||
"growth": null,
|
||||
"is_new": true
|
||||
"is_new": true,
|
||||
"cur_cum": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
35,
|
||||
75,
|
||||
108
|
||||
],
|
||||
"prev_cum": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "turbo_button",
|
||||
"label": "Turbo кнопка",
|
||||
"prev": 0,
|
||||
"cur": 4312,
|
||||
"prev": 0,
|
||||
"growth": null,
|
||||
"is_new": true
|
||||
"is_new": true,
|
||||
"cur_cum": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
498,
|
||||
2789,
|
||||
4312
|
||||
],
|
||||
"prev_cum": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "real_estate_docs",
|
||||
"label": "Справка о недвижимости",
|
||||
"prev": 0,
|
||||
"cur": 78,
|
||||
"prev": 0,
|
||||
"growth": null,
|
||||
"is_new": true
|
||||
"is_new": true,
|
||||
"cur_cum": [
|
||||
0,
|
||||
23,
|
||||
44,
|
||||
52,
|
||||
70,
|
||||
78
|
||||
],
|
||||
"prev_cum": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -477,34 +948,47 @@ function renderCards() {
|
||||
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 max = Math.max(m.cur, m.prev, 1);
|
||||
const curW = (m.cur / max * 100).toFixed(1);
|
||||
const prevW = (m.prev / max * 100).toFixed(1);
|
||||
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>
|
||||
<div class="cmp">
|
||||
<div class="cmp-row">
|
||||
<span class="cmp-year">${d.cur_year}</span>
|
||||
<span class="cmp-track"><span class="cmp-fill cur" style="width:${curW}%"></span></span>
|
||||
<span class="cmp-num">${fmtInt(m.cur)}</span>
|
||||
</div>
|
||||
<div class="cmp-row">
|
||||
<span class="cmp-year">${d.prev_year}</span>
|
||||
<span class="cmp-track"><span class="cmp-fill prev" style="width:${prevW}%"></span></span>
|
||||
<span class="cmp-num">${fmtInt(m.prev)}</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';
|
||||
|
||||
@ -3,11 +3,17 @@
|
||||
"""
|
||||
Ежедневное обновление статистики «Метрики МП» из Impala.
|
||||
|
||||
Сравнивает использование функций мобильного приложения за текущий год
|
||||
(с 1 января по ВЧЕРАШНИЙ день) с аналогичным периодом прошлого года.
|
||||
Результат пишется в ../app_stats/app_metrics.json и пушится в ветку pages —
|
||||
его читает страница app_stats/index.html.
|
||||
Модель данных:
|
||||
• Грузим помесячно (группировка по report_period_id = YYYYmm), начиная с января
|
||||
прошлого года и до текущего месяца текущего года.
|
||||
• Текущий (незавершённый) месяц обрезаем по entry_date до ВЧЕРАШНЕГО дня —
|
||||
симметрично для обоих лет (например, на 16 июня берём данные по 15 июня
|
||||
включительно и в 2026, и в 2025), чтобы сравнение было «равный период».
|
||||
• Внутри каждого года считаем нарастающий итог (кумулятив) по месяцам; на старте
|
||||
нового года накопление сбрасывается.
|
||||
• Сравнение в карточке = кумулятив-на-дату текущего года vs прошлого года.
|
||||
|
||||
Результат пишется в ../app_stats/app_metrics.json и пушится в ветку pages.
|
||||
Подключение к Impala, конфиг и git-push переиспользуются из update_kpi.py.
|
||||
"""
|
||||
|
||||
@ -54,31 +60,43 @@ METRICS = [
|
||||
("turbo_button", "Turbo кнопка"),
|
||||
("real_estate_docs", "Справка о недвижимости"),
|
||||
]
|
||||
METRIC_KEYS = [k for k, _ in METRICS]
|
||||
|
||||
_MONTHS_RU = ["", "января", "февраля", "марта", "апреля", "мая", "июня",
|
||||
_MONTHS_RU_GEN = ["", "января", "февраля", "марта", "апреля", "мая", "июня",
|
||||
"июля", "августа", "сентября", "октября", "ноября", "декабря"]
|
||||
_MONTHS_RU_SHORT = ["", "Янв", "Фев", "Мар", "Апр", "Май", "Июн",
|
||||
"Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"]
|
||||
|
||||
|
||||
def date_range(today: dt.date | None = None):
|
||||
"""Возвращает (cur_year, prev_year, start_cur, end_cur, start_prev, end_prev) как date."""
|
||||
def date_bounds(today: dt.date | None = None):
|
||||
"""Границы выборки. Текущий месяц обрезается по вчерашнему дню (симметрично по годам)."""
|
||||
today = today or dt.date.today()
|
||||
end_cur = today - dt.timedelta(days=1) # вчера
|
||||
cur_year = today.year
|
||||
prev_year = cur_year - 1
|
||||
cap_month, cap_day = end_cur.month, end_cur.day
|
||||
|
||||
start_cur = dt.date(cur_year, 1, 1)
|
||||
start_prev = dt.date(prev_year, 1, 1)
|
||||
# та же дата прошлого года; подстраховка на 29 февраля
|
||||
try:
|
||||
end_prev = dt.date(prev_year, end_cur.month, end_cur.day)
|
||||
except ValueError:
|
||||
end_prev = dt.date(prev_year, end_cur.month, 28)
|
||||
return cur_year, prev_year, start_cur, end_cur, start_prev, end_prev
|
||||
end_prev = dt.date(prev_year, cap_month, cap_day)
|
||||
except ValueError: # 29 февраля в невисокосный год
|
||||
end_prev = dt.date(prev_year, cap_month, 28)
|
||||
return {
|
||||
"cur_year": cur_year, "prev_year": prev_year,
|
||||
"cap_month": cap_month, "cap_day": cap_day,
|
||||
"start_cur": start_cur, "end_cur": end_cur,
|
||||
"start_prev": start_prev, "end_prev": end_prev,
|
||||
}
|
||||
|
||||
|
||||
def build_sql(start_cur, end_cur, start_prev, end_prev) -> str:
|
||||
def build_sql(b: dict) -> str:
|
||||
# created_at — timestamp, поэтому верхнюю границу берём как «< следующий день»
|
||||
cur_end_excl = b["end_cur"] + dt.timedelta(days=1)
|
||||
prev_end_excl = b["end_prev"] + dt.timedelta(days=1)
|
||||
return f"""
|
||||
with t as (
|
||||
select round(report_period_id/100) as report_year,
|
||||
select report_period_id,
|
||||
count(case when event_type = 'OPENWSCREENMYSERVICES' then 1 end) as my_services,
|
||||
count(case when event_type = 'OPENWINDOWDETALIZTION' then 1 end) as traffic,
|
||||
count(case when event_type = 'OPENWINDOWPAYMENT' then 1 end) as payments,
|
||||
@ -107,77 +125,104 @@ with t as (
|
||||
count(case when event_type = 'turbo_activation_success_viewed' then 1 end) as turbo_button,
|
||||
count(case when event_type = 'real_estate_docs_screen_shown' then 1 end) as real_estate_docs
|
||||
from drb.drb_iliyas_amplitude_metrics_full
|
||||
where entry_date between '{start_cur:%Y-%m-%d}' and '{end_cur:%Y-%m-%d}'
|
||||
or entry_date between '{start_prev:%Y-%m-%d}' and '{end_prev:%Y-%m-%d}'
|
||||
group by 1
|
||||
where entry_date between '{b['start_cur']:%Y-%m-%d}' and '{b['end_cur']:%Y-%m-%d}'
|
||||
or entry_date between '{b['start_prev']:%Y-%m-%d}' and '{b['end_prev']:%Y-%m-%d}'
|
||||
group by report_period_id
|
||||
)
|
||||
, a as (
|
||||
select year(created_at) as report_year, count(order_id) as adsl
|
||||
select (year(created_at) * 100 + month(created_at)) as report_period_id,
|
||||
count(order_id) as adsl
|
||||
from telecomkz.telecomkz_retention_service_prod_tariff_change_validations
|
||||
where (created_at >= '{b['start_cur']:%Y-%m-%d}' and created_at < '{cur_end_excl:%Y-%m-%d}')
|
||||
or (created_at >= '{b['start_prev']:%Y-%m-%d}' and created_at < '{prev_end_excl:%Y-%m-%d}')
|
||||
group by 1
|
||||
)
|
||||
select t.report_year, my_services, traffic, payments, orders, loyalty, pay, billing_detail, viktorina, partners, tv_plus, boosters, roaming, pereoform, aitu_music, online_booking, my_docs, dz_statement, new_boosters_roaming_kcell, adsl, law_and_order, acs, kaspi_freedom_pay, csat, multicustomer,
|
||||
select t.report_period_id, my_services, traffic, payments, orders, loyalty, pay, billing_detail, viktorina, partners, tv_plus, boosters, roaming, pereoform, aitu_music, online_booking, my_docs, dz_statement, new_boosters_roaming_kcell, adsl, law_and_order, acs, kaspi_freedom_pay, csat, multicustomer,
|
||||
tv_plus_setup, static_ip, turbo_button, real_estate_docs
|
||||
from t
|
||||
left join a on t.report_year = a.report_year
|
||||
order by t.report_year
|
||||
left join a on t.report_period_id = a.report_period_id
|
||||
order by t.report_period_id
|
||||
""".strip()
|
||||
|
||||
|
||||
def fetch_by_year(conn, sql):
|
||||
def fetch_monthly(conn, sql):
|
||||
cur = conn.cursor()
|
||||
log.info("Выполнение запроса метрик МП...")
|
||||
log.info("Выполнение запроса метрик МП (помесячно)...")
|
||||
cur.execute(sql)
|
||||
rows = cur.fetchall()
|
||||
names = [d[0].lower() for d in cur.description]
|
||||
cur.close()
|
||||
idx = {n: i for i, n in enumerate(names)}
|
||||
by_year = {}
|
||||
out = []
|
||||
for r in rows:
|
||||
year = int(round(float(r[idx["report_year"]])))
|
||||
by_year[year] = {name: r[idx[name]] for name in idx}
|
||||
log.info("Получено годовых строк: %s", sorted(by_year))
|
||||
return by_year, idx
|
||||
rec = {"report_period_id": int(r[idx["report_period_id"]])}
|
||||
for key in METRIC_KEYS:
|
||||
v = r[idx[key]] if key in idx else None
|
||||
rec[key] = int(v) if v is not None else 0
|
||||
out.append(rec)
|
||||
log.info("Получено помесячных строк: %d (периоды: %s)",
|
||||
len(out), ", ".join(str(x["report_period_id"]) for x in out))
|
||||
return out
|
||||
|
||||
|
||||
def _num(v):
|
||||
return int(v) if v is not None else 0
|
||||
def _cumsum(arr):
|
||||
acc, out = 0, []
|
||||
for v in arr:
|
||||
acc += v
|
||||
out.append(acc)
|
||||
return out
|
||||
|
||||
|
||||
def build_payload(by_year, cur_year, prev_year, start_cur, end_cur):
|
||||
cur_row = by_year.get(cur_year, {})
|
||||
prev_row = by_year.get(prev_year, {})
|
||||
def build_payload(rows, b: dict):
|
||||
cur_year, prev_year, cap_month = b["cur_year"], b["prev_year"], b["cap_month"]
|
||||
months = list(range(1, cap_month + 1))
|
||||
month_labels = [_MONTHS_RU_SHORT[m] for m in months]
|
||||
|
||||
# key -> {year -> [monthly values по месяцам months]}
|
||||
monthly = {k: {cur_year: [0] * len(months), prev_year: [0] * len(months)} for k in METRIC_KEYS}
|
||||
for rec in rows:
|
||||
rpid = rec["report_period_id"]
|
||||
y, m = rpid // 100, rpid % 100
|
||||
if y not in (cur_year, prev_year) or m not in months:
|
||||
continue
|
||||
i = months.index(m)
|
||||
for k in METRIC_KEYS:
|
||||
monthly[k][y][i] = rec[k]
|
||||
|
||||
metrics = []
|
||||
for key, label in METRICS:
|
||||
cur_v = _num(cur_row.get(key))
|
||||
prev_v = _num(prev_row.get(key))
|
||||
cur_cum = _cumsum(monthly[key][cur_year])
|
||||
prev_cum = _cumsum(monthly[key][prev_year])
|
||||
cur_v = cur_cum[-1] if cur_cum else 0
|
||||
prev_v = prev_cum[-1] if prev_cum else 0
|
||||
is_new = prev_v == 0
|
||||
growth = None if is_new else (cur_v - prev_v) / prev_v
|
||||
metrics.append({
|
||||
"key": key, "label": label,
|
||||
"prev": prev_v, "cur": cur_v,
|
||||
"cur": cur_v, "prev": prev_v,
|
||||
"growth": growth, "is_new": is_new,
|
||||
"cur_cum": cur_cum, "prev_cum": prev_cum,
|
||||
})
|
||||
|
||||
period_label = (f"с 1 января по {end_cur.day} {_MONTHS_RU[end_cur.month]}")
|
||||
end_cur = b["end_cur"]
|
||||
period_label = f"с 1 января по {end_cur.day} {_MONTHS_RU_GEN[end_cur.month]}"
|
||||
return {
|
||||
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
|
||||
"cur_year": cur_year,
|
||||
"prev_year": prev_year,
|
||||
"period_label": period_label,
|
||||
"range": {"start": f"{start_cur:%Y-%m-%d}", "end": f"{end_cur:%Y-%m-%d}"},
|
||||
"range": {"start": f"{b['start_cur']:%Y-%m-%d}", "end": f"{end_cur:%Y-%m-%d}"},
|
||||
"months": months,
|
||||
"month_labels": month_labels,
|
||||
"cumulative": True,
|
||||
"metrics": metrics,
|
||||
}
|
||||
|
||||
|
||||
def write_if_changed(payload) -> bool:
|
||||
text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
||||
old = ""
|
||||
if OUT_PATH.exists():
|
||||
old = OUT_PATH.read_text(encoding="utf-8")
|
||||
old = OUT_PATH.read_text(encoding="utf-8") if OUT_PATH.exists() else ""
|
||||
|
||||
# Сравниваем без учёта generated_at (чтобы не коммитить, если данные те же)
|
||||
def strip_ts(s):
|
||||
try:
|
||||
d = json.loads(s)
|
||||
@ -192,7 +237,8 @@ def write_if_changed(payload) -> bool:
|
||||
|
||||
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT_PATH.write_text(text, encoding="utf-8")
|
||||
log.info("app_metrics.json обновлён (%d метрик).", len(payload["metrics"]))
|
||||
log.info("app_metrics.json обновлён (%d метрик, %d мес.).",
|
||||
len(payload["metrics"]), len(payload["months"]))
|
||||
return True
|
||||
|
||||
|
||||
@ -201,26 +247,27 @@ def main() -> int:
|
||||
log.info("Старт обновления Метрик МП")
|
||||
cfg = base.load_config()
|
||||
|
||||
cur_year, prev_year, start_cur, end_cur, start_prev, end_prev = date_range()
|
||||
log.info("Период: %s..%s (тек.) и %s..%s (пред.)",
|
||||
start_cur, end_cur, start_prev, end_prev)
|
||||
sql = build_sql(start_cur, end_cur, start_prev, end_prev)
|
||||
b = date_bounds()
|
||||
log.info("Период (тек.): %s..%s | (пред.): %s..%s | месяцев: %d",
|
||||
b["start_cur"], b["end_cur"], b["start_prev"], b["end_prev"], b["cap_month"])
|
||||
sql = build_sql(b)
|
||||
|
||||
base.patch_thrift_ssl()
|
||||
conn = base.connect_impala(cfg)
|
||||
try:
|
||||
by_year, _ = fetch_by_year(conn, sql)
|
||||
rows = fetch_monthly(conn, sql)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if cur_year not in by_year:
|
||||
cur_year = b["cur_year"]
|
||||
if not any(r["report_period_id"] // 100 == cur_year for r in rows):
|
||||
log.error("В ответе нет данных за %s — JSON НЕ перезаписан.", cur_year)
|
||||
return 1
|
||||
|
||||
payload = build_payload(by_year, cur_year, prev_year, start_cur, end_cur)
|
||||
payload = build_payload(rows, b)
|
||||
if write_if_changed(payload):
|
||||
base.git_commit_push(cfg, [OUT_REL],
|
||||
f"data: update app metrics {dt.date.today():%Y-%m-%d}")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user