2026-10-30 17:20:47 +01:00
<!doctype html>
< html lang = "ru" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1, viewport-fit=cover" >
< title > Бинго «Баланс 2.0» · статистика</ title >
< meta name = "robots" content = "noindex, nofollow" >
< meta name = "referrer" content = "no-referrer" >
< style >
@ font-face { font-family : 'Unbounded' ; font-style : normal ; font-display : swap ; font-weight : 200 900 ;
src : url ( fonts / unbounded-cyrillic-wght-normal . woff2 ) format ( 'woff2' ); unicode-range : U + 0301 , U + 0400-045F , U + 0490-0491 , U + 04B0-04B1 , U + 2116 ; }
@ font-face { font-family : 'Unbounded' ; font-style : normal ; font-display : swap ; font-weight : 200 900 ;
src : url ( fonts / unbounded-latin-wght-normal . woff2 ) format ( 'woff2' ); unicode-range : U + 0000-00FF , U + 2000-206F ; }
@ font-face { font-family : 'Manrope' ; font-style : normal ; font-display : swap ; font-weight : 200 800 ;
src : url ( fonts / manrope-cyrillic-wght-normal . woff2 ) format ( 'woff2' ); unicode-range : U + 0301 , U + 0400-045F , U + 0490-0491 , U + 04B0-04B1 , U + 2116 ; }
@ font-face { font-family : 'Manrope' ; font-style : normal ; font-display : swap ; font-weight : 200 800 ;
src : url ( fonts / manrope-latin-wght-normal . woff2 ) format ( 'woff2' ); unicode-range : U + 0000-00FF , U + 2000-206F ; }
/* Layout: сводка сверху, затем недели (таблица), опрос (полосы по неделям), клетки (полосы по рядам). */
: root {
color-scheme : dark ;
--bg : #12233D ; --panel : #1B3150 ; --line : #2A4468 ; --fg : #F2F5FA ; --muted : #A9B8CC ; --ink : #12233D ; --accent : #C6F432 ;
--c-body : #FF8A65 ; --c-focus : #7CC4FF ; --c-bounds : #B9A6FF ; --c-people : #C6F432 ;
--p-full : #C6F432 ; --p-mid : #7CC4FF ; --p-low : #FF8A65 ; --danger : #FF8A8A ;
--display : 'Unbounded' , 'Arial Black' , system-ui , sans-serif ;
--text : 'Manrope' , 'Segoe UI' , system-ui , sans-serif ;
}
* { box-sizing : border-box ; }
[ hidden ] { display : none !important ; }
body { margin : 0 ; background : var ( -- bg ); color : var ( -- fg ); font : 15 px / 1.45 var ( -- text ); -webkit- font-smoothing : antialiased ; }
button , input { font : inherit ; color : inherit ; }
: focus-visible { outline : 3 px solid var ( -- accent ); outline-offset : 2 px ; }
. page {
max-width : 1100 px ; margin : 0 auto ; display : grid ; gap : 28 px ;
padding-block : 40 px 64 px ;
padding-left : max ( 16 px , env ( safe - area - inset - left , 0 px ));
padding-right : max ( 16 px , env ( safe - area - inset - right , 0 px ));
}
. eyebrow { margin : 0 ; font-size : 12 px ; font-weight : 700 ; letter-spacing : .14 em ; color : var ( -- muted ); text-transform : uppercase ; }
h1 { margin : 6 px 0 0 ; font-family : var ( -- display ); font-weight : 800 ; font-size : clamp ( 26 px , 5 vw , 40 px ); line-height : 1.1 ; }
h1 span { color : var ( -- accent ); }
h2 { margin : 0 ; font-family : var ( -- display ); font-weight : 700 ; font-size : 18 px ; }
. head { display : flex ; flex-wrap : wrap ; justify-content : space-between ; align-items : flex-end ; gap : 16 px ; }
. actions { display : flex ; flex-wrap : wrap ; gap : 8 px ; }
. btn {
cursor : pointer ; padding : 9 px 14 px ; border-radius : 999 px ; border : 1 px solid var ( -- line );
background : transparent ; font-weight : 700 ; font-size : 14 px ;
}
. btn : hover { border-color : var ( -- muted ); }
. btn . primary { background : var ( -- accent ); border-color : var ( -- accent ); color : var ( -- ink ); }
. panel { background : var ( -- panel ); border : 1 px solid var ( -- line ); border-radius : 20 px ; padding : 22 px ; display : grid ; gap : 16 px ; min-width : 0 ; }
. panel-head { display : flex ; flex-wrap : wrap ; justify-content : space-between ; align-items : baseline ; gap : 8 px 16 px ; }
. note { margin : 0 ; color : var ( -- muted ); font-size : 13 px ; }
. kpis { display : grid ; grid-template-columns : repeat ( auto - fit , minmax ( 180 px , 1 fr )); gap : 12 px ; }
. kpi { background : var ( -- panel ); border : 1 px solid var ( -- line ); border-radius : 18 px ; padding : 16 px 18 px ; }
. kpi dt { color : var ( -- muted ); font-size : 13 px ; font-weight : 600 ; }
. kpi dd { margin : 4 px 0 0 ; font-family : var ( -- display ); font-weight : 700 ; font-size : 28 px ; font-variant-numeric : tabular-nums ; }
. kpi dd small { font-family : var ( -- text ); font-size : 13 px ; color : var ( -- muted ); font-weight : 600 ; }
. table-wrap { overflow-x : auto ; }
table { width : 100 % ; border-collapse : collapse ; font-variant-numeric : tabular-nums ; font-size : 14 px ; }
th , td { padding : 10 px 12 px ; text-align : right ; border-bottom : 1 px solid var ( -- line ); white-space : nowrap ; }
th : first-child , td : first-child { text-align : left ; }
th { color : var ( -- muted ); font-weight : 700 ; font-size : 12 px ; letter-spacing : .04 em ; text-transform : uppercase ; }
tbody tr : last-child td { border-bottom : 0 ; }
td . strong { font-weight : 800 ; }
. legend { display : flex ; flex-wrap : wrap ; gap : 14 px ; font-size : 13 px ; color : var ( -- muted ); }
. legend i { display : inline-block ; width : 10 px ; height : 10 px ; border-radius : 3 px ; margin-right : 6 px ; vertical-align : -1 px ; }
. pulse-rows { display : grid ; gap : 10 px ; }
. pulse-row { display : grid ; grid-template-columns : 110 px minmax ( 0 , 1 fr ) 60 px ; gap : 12 px ; align-items : center ; font-size : 14 px ; }
. pulse-row . n { text-align : right ; color : var ( -- muted ); font-variant-numeric : tabular-nums ; }
. stack { display : flex ; height : 26 px ; border-radius : 8 px ; overflow : hidden ; background : color-mix ( in srgb , var ( -- fg ) 6 % , transparent ); gap : 2 px ; }
. stack span { display : grid ; place-items : center ; font-size : 12 px ; font-weight : 800 ; color : var ( -- ink ); min-width : 0 ; overflow : hidden ; }
. cells { display : grid ; gap : 18 px ; }
. cell-group h3 { margin : 0 0 8 px ; font-size : 13 px ; font-weight : 800 ; letter-spacing : .04 em ; text-transform : uppercase ; }
. bar-row { display : grid ; grid-template-columns : minmax ( 0 , 260 px ) minmax ( 0 , 1 fr ) 44 px ; gap : 12 px ; align-items : center ; padding : 4 px 0 ; font-size : 14 px ; }
. bar-row . label { overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ; }
. bar-row . label b { font-variant-numeric : tabular-nums ; margin-right : 6 px ; }
. bar { height : 12 px ; border-radius : 6 px ; background : color-mix ( in srgb , var ( -- fg ) 6 % , transparent ); overflow : hidden ; }
. bar i { display : block ; height : 100 % ; border-radius : 6 px ; }
. bar-row . n { text-align : right ; font-weight : 800 ; font-variant-numeric : tabular-nums ; }
. login { max-width : 460 px ; }
. login form { display : flex ; gap : 8 px ; flex-wrap : wrap ; }
. login input { flex : 1 1 220 px ; padding : 10 px 14 px ; border-radius : 12 px ; border : 1 px solid var ( -- line ); background : var ( -- bg ); }
. error { color : var ( -- danger ); font-weight : 700 ; margin : 0 ; }
@ media ( max-width : 640px ) {
. bar-row { grid-template-columns : minmax ( 0 , 1 fr ) 44 px ; }
. bar-row . bar { grid-column : 1 / -1 ; grid-row : 2 ; }
. pulse-row { grid-template-columns : 80 px minmax ( 0 , 1 fr ) 40 px ; }
}
</ style >
</ head >
< body >
< div class = "page" >
< header class = "head" >
< div >
< p class = "eyebrow" > Project: Balance · сезон 2 · только для организаторов</ p >
< h1 > Статистика бинго < span > 2.0</ span ></ h1 >
</ div >
< div class = "actions" id = "actions" hidden >
< button class = "btn" type = "button" id = "reload" > Обновить</ button >
< button class = "btn primary" type = "button" data-csv = "weeks" > CSV: недели</ button >
< button class = "btn primary" type = "button" data-csv = "cells" > CSV: клетки</ button >
< button class = "btn primary" type = "button" data-csv = "pulse" > CSV: опрос</ button >
2026-10-30 20:53:04 +01:00
< button class = "btn" type = "button" id = "logout" > Выйти</ button >
2026-10-30 17:20:47 +01:00
</ div >
</ header >
< section class = "panel login" id = "login" hidden >
2026-10-30 20:53:04 +01:00
< h2 > Вход для организаторов</ h2 >
< p class = "note" > Вставьте ключ администратора или откройте ссылку для входа. После входа ключ убирается из адреса, а браузер помнит вход ограниченное время.</ p >
2026-10-30 17:20:47 +01:00
< form id = "login-form" >
2026-10-30 20:53:04 +01:00
< input id = "key-input" type = "password" autocomplete = "current-password" placeholder = "Ключ администратора" aria-label = "Ключ администратора" >
< button class = "btn primary" type = "submit" id = "login-btn" > Войти</ button >
2026-10-30 17:20:47 +01:00
</ form >
< p class = "error" id = "login-error" hidden ></ p >
</ section >
< div id = "content" hidden >
< div style = "display:grid;gap:28px" >
< dl class = "kpis" id = "kpis" ></ dl >
< section class = "panel" >
< div class = "panel-head" >
< h2 > Шаги по неделям</ h2 >
< p class = "note" > Шаги = отмеченные клетки + 5 за каждую собранную линию (ряд, столбец, диагональ). Устройства — сколько браузеров отметили хоть одну клетку.</ p >
</ div >
< div class = "table-wrap" >< table id = "weeks-table" ></ table ></ div >
</ section >
< section class = "panel" >
< div class = "panel-head" >
< h2 > «Твоя батарейка сейчас?»</ h2 >
< div class = "legend" >< span >< i style = "background:var(--p-full)" ></ i > ⚡ Полная</ span >< span >< i style = "background:var(--p-mid)" ></ i > 🔋 Средне</ span >< span >< i style = "background:var(--p-low)" ></ i > 🪫 На нуле</ span ></ div >
</ div >
< div class = "pulse-rows" id = "pulse-rows" ></ div >
</ section >
< section class = "panel" >
< div class = "panel-head" >
< h2 > Популярность клеток</ h2 >
< p class = "note" > Сколько раз клетку отметили за весь сезон.</ p >
</ div >
< div class = "cells" id = "cells" ></ div >
</ section >
< p class = "note" id = "generated" ></ p >
</ div >
</ div >
</ div >
< script >
(() => {
'use strict' ;
const ROWS = [
{ name : 'Тело' , color : 'var(--c-body)' , cells : [ 'Обед без экрана' , 'Разминка между созвонами' , 'Гимнастика для глаз' , 'Сон до полуночи' ] },
{ name : 'Фокус' , color : 'var(--c-focus)' , cells : [ 'Фокус-блок на одну задачу' , '3 главных дела на день' , 'Встреча превратилась в сообщение' , 'Время залогировано в тот же день' ] },
{ name : 'Границы' , color : 'var(--c-bounds)' , cells : [ 'Сказать «нет» или «не сейчас»' , 'Вечер без рабочих сообщений' , 'Попросить о помощи' , 'Ноутбук закрыт вовремя' ] },
{ name : 'Люди и радость' , color : 'var(--c-people)' , cells : [ 'Офлайн с друзьями или семьёй' , 'Хобби, спорт или что-то руками' , '«Спасибо» или помощь коллеге' , 'Мем в чат Мемопятницы' ] },
];
const MONTHS = [ 'янв' , 'фев' , 'мар' , 'апр' , 'мая' , 'июн' , 'июл' , 'авг' , 'сен' , 'окт' , 'ноя' , 'дек' ];
const $ = ( id ) => document . getElementById ( id );
const esc = ( s ) => String ( s ). replace ( /[&<>"]/g , ( c ) => ({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' }[ c ]));
const cellName = ( n ) => ROWS [ Math . floor (( n - 1 ) / 4 )]. cells [( n - 1 ) % 4 ];
const rowName = ( n ) => ROWS [ Math . floor (( n - 1 ) / 4 )]. name ;
const dayLabel = ( iso ) => ` ${ + iso . slice ( 8 , 10 ) } ${ MONTHS [ + iso . slice ( 5 , 7 ) - 1 ] } ` ;
function weekRange ( iso ) {
const d = new Date ( iso + 'T00:00:00Z' );
d . setUTCDate ( d . getUTCDate () + 6 );
return ` ${ dayLabel ( iso ) } — ${ dayLabel ( d . toISOString (). slice ( 0 , 10 )) } ` ;
}
const weekTitle = ( w ) => ( w . weekNo >= 1 ? `Неделя ${ w . weekNo } ` : 'До старта' );
let data = null ;
2026-10-30 20:53:04 +01:00
function showLogin ( message ) {
data = null ;
$ ( 'login' ). hidden = false ;
$ ( 'content' ). hidden = true ;
$ ( 'actions' ). hidden = true ;
$ ( 'login-error' ). textContent = message || '' ;
$ ( 'login-error' ). hidden = ! message ;
if ( ! message ) $ ( 'key-input' ). focus ();
}
async function postJson ( url , body ) {
const res = await fetch ( url , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ( body || {}),
credentials : 'same-origin' ,
cache : 'no-store' ,
});
return { status : res . status , data : await res . json (). catch (() => null ) };
}
// Ключ обменивается на сессию (cookie ставит сервер); сам ключ нигде не сохраняется.
async function login ( key ) {
$ ( 'login-btn' ). disabled = true ;
2026-10-30 17:20:47 +01:00
try {
2026-10-30 20:53:04 +01:00
const { status , data : body } = await postJson ( 'api/admin/login' , { key });
if ( status === 200 ) { $ ( 'key-input' ). value = '' ; return load (); }
if ( body ? . error === 'admin_disabled' ) return showLogin ( 'Админка выключена: на сервере не задан ADMIN_KEY.' );
if ( status === 401 ) return showLogin ( 'Ключ не подошёл. Проверьте его и попробуйте ещё раз.' );
return showLogin ( `Сервер ответил ошибкой ${ status } . Попробуйте через минуту.` );
} catch {
return showLogin ( 'Нет связи с сервером. Проверьте интернет и попробуйте ещё раз.' );
} finally {
$ ( 'login-btn' ). disabled = false ;
}
}
async function load ( expiredNotice ) {
try {
const res = await fetch ( 'api/admin/stats' , { credentials : 'same-origin' , cache : 'no-store' });
if ( res . status === 401 ) {
const body = await res . json (). catch (() => null );
if ( body ? . error === 'admin_disabled' ) return showLogin ( 'Админка выключена: на сервере не задан ADMIN_KEY.' );
return showLogin ( expiredNotice ? 'Вход закончился. Введите ключ ещё раз.' : '' );
}
2026-10-30 17:20:47 +01:00
if ( ! res . ok ) throw new Error ( `Сервер ответил ошибкой ${ res . status } . Попробуйте обновить через минуту.` );
data = await res . json ();
$ ( 'login' ). hidden = true ;
$ ( 'content' ). hidden = false ;
$ ( 'actions' ). hidden = false ;
render ();
} catch ( err ) {
2026-10-30 20:53:04 +01:00
showLogin ( err . message . startsWith ( 'Сервер' ) ? err . message : 'Нет связи с сервером. Попробуйте обновить через минуту.' );
2026-10-30 17:20:47 +01:00
}
}
function render () {
const s = data . season ;
const cur = data . weeks . find (( w ) => w . week === s . week );
const pulseTotal = data . weeks . reduce (( n , w ) => n + w . pulse . full + w . pulse . mid + w . pulse . low , 0 );
const pct = Math . floor (( data . total / s . goal ) * 100 );
$ ( 'kpis' ). innerHTML = [
[ 'Батарейка' , ` ${ data . total } <small>из ${ s . goal } · ${ pct } %</small>` ],
[ 'Сейчас' , s . phase === 'active' ? `Неделя ${ s . weekNo } <small>из ${ s . weeks } </small>` : s . phase === 'pre' ? 'До старта' : 'Сезон завершён' ],
[ 'Устройств на этой неделе' , cur ? cur . devices : 0 ],
[ 'Ответов на опрос' , pulseTotal ],
]. map (([ t , v ]) => `<div class="kpi"><dt> ${ t } </dt><dd> ${ v } </dd></div>` ). join ( '' );
const wrows = data . weeks . map (( w ) => `<tr>
<td><b> ${ weekTitle ( w ) } </b> <span style="color:var(--muted)"> ${ weekRange ( w . week ) } </span></td>
<td class="strong"> ${ w . steps } </td><td> ${ w . cells } </td><td> ${ w . rows } </td><td> ${ w . cols } </td><td> ${ w . diags } </td><td> ${ w . devices } </td>
<td> ${ w . pulse . full } / ${ w . pulse . mid } / ${ w . pulse . low } </td></tr>` ). join ( '' );
$ ( 'weeks-table' ). innerHTML = `<thead><tr><th>Неделя</th><th>Шаги</th><th>Клетки</th><th>Ряды</th><th>Столбцы</th><th>Диагонали</th><th>Устройства</th><th>⚡ / 🔋 / 🪫</th></tr></thead>
<tbody> ${ wrows || '<tr><td colspan="8" style="color:var(--muted)">Пока нет ни одной отметки.</td></tr>' } </tbody>` ;
const pulseWeeks = data . weeks . filter (( w ) => w . pulse . full + w . pulse . mid + w . pulse . low > 0 );
$ ( 'pulse-rows' ). innerHTML = pulseWeeks . length ? pulseWeeks . map (( w ) => {
const n = w . pulse . full + w . pulse . mid + w . pulse . low ;
const seg = ( v , c ) => v ? `<span style="flex: ${ v } ;background:var( ${ c } )" title=" ${ Math . round ( v / n * 100 ) } %"> ${ v / n >= 0.12 ? Math . round ( v / n * 100 ) + '%' : '' } </span>` : '' ;
return `<div class="pulse-row"><span> ${ weekTitle ( w ) } </span><div class="stack"> ${ seg ( w . pulse . full , '--p-full' ) }${ seg ( w . pulse . mid , '--p-mid' ) }${ seg ( w . pulse . low , '--p-low' ) } </div><span class="n"> ${ n } </span></div>` ;
}). join ( '' ) : '<p class="note">Ответов пока нет.</p>' ;
const max = Math . max ( 1 , ... data . cells . map (( c ) => c . total ));
$ ( 'cells' ). innerHTML = ROWS . map (( row , r ) => `<div class="cell-group"><h3 style="color: ${ row . color } "> ${ row . name } </h3> ${
[ 1 , 2 , 3 , 4 ]. map (( k ) => {
const c = data . cells [ r * 4 + k - 1 ];
return `<div class="bar-row"><span class="label" title=" ${ esc ( cellName ( c . cell )) } "><b> ${ String ( c . cell ). padStart ( 2 , '0' ) } </b> ${ esc ( cellName ( c . cell )) } </span><div class="bar"><i style="width: ${ c . total / max * 100 } %;background: ${ row . color } "></i></div><span class="n"> ${ c . total } </span></div>` ;
} ).join('')}</div>` ). join ( '' );
2026-10-30 20:53:04 +01:00
const until = data . session ? . expiresAt
? ` Вход действует до ${ new Date ( data . session . expiresAt ). toLocaleString ( 'ru-RU' , { day : 'numeric' , month : 'long' , hour : '2-digit' , minute : '2-digit' } )}.`
: '' ;
$ ( 'generated' ). textContent = `Данные на ${ new Date ( data . generatedAt ). toLocaleString ( 'ru-RU' ) } . Часовой пояс сезона: ${ s . tz } . ${ until } ` ;
2026-10-30 17:20:47 +01:00
}
// CSV с разделителем «;» и BOM — открывается в русском Excel без мастера импорта.
function csv ( rows ) {
return ' ' + rows . map (( r ) => r . map (( v ) => {
const s = String ( v ?? '' );
return /[;"\n]/ . test ( s ) ? `" ${ s . replace ( /"/g , '""' ) } "` : s ;
}). join ( ';' )). join ( '\r\n' );
}
function download ( name , text ) {
const url = URL . createObjectURL ( new Blob ([ text ], { type : 'text/csv;charset=utf-8' }));
const a = document . createElement ( 'a' );
a . href = url ; a . download = name ;
document . body . appendChild ( a ); a . click (); a . remove ();
setTimeout (() => URL . revokeObjectURL ( url ), 1000 );
}
const exporters = {
weeks : () => csv ([
[ 'Неделя' , 'Понедельник' , 'Шаги' , 'Клетки' , 'Собранные ряды' , 'Собранные столбцы' , 'Собранные диагонали' , 'Устройства' , 'Полная' , 'Средне' , 'На нуле' ],
... data . weeks . map (( w ) => [ w . weekNo , w . week , w . steps , w . cells , w . rows , w . cols , w . diags , w . devices , w . pulse . full , w . pulse . mid , w . pulse . low ]),
]),
cells : () => {
const weeks = data . weeks . map (( w ) => w . week );
return csv ([
[ 'Клетка' , 'Ряд' , 'Название' , 'Всего' , ... data . weeks . map (( w ) => `Неделя ${ w . weekNo } ( ${ w . week } )` )],
... data . cells . map (( c ) => [ c . cell , rowName ( c . cell ), cellName ( c . cell ), c . total , ... weeks . map (( w ) => c . byWeek [ w ] || 0 )]),
]);
},
pulse : () => csv ([
[ 'Неделя' , 'Понедельник' , 'Полная' , 'Средне' , 'На нуле' , 'Всего ответов' ],
... data . weeks . map (( w ) => [ w . weekNo , w . week , w . pulse . full , w . pulse . mid , w . pulse . low , w . pulse . full + w . pulse . mid + w . pulse . low ]),
]),
};
document . addEventListener ( 'click' , ( e ) => {
const b = e . target . closest ( '[data-csv]' );
if ( b && data ) download ( `balance-bingo- ${ b . dataset . csv } - ${ new Date (). toISOString (). slice ( 0 , 10 ) } .csv` , exporters [ b . dataset . csv ]());
});
2026-10-30 20:53:04 +01:00
$ ( 'reload' ). addEventListener ( 'click' , () => load ( true ));
$ ( 'logout' ). addEventListener ( 'click' , async () => {
try { await postJson ( 'api/admin/logout' ); } catch { /* сессия всё равно истечёт сама */ }
showLogin ( '' );
});
2026-10-30 17:20:47 +01:00
$ ( 'login-form' ). addEventListener ( 'submit' , ( e ) => {
e . preventDefault ();
2026-10-30 20:53:04 +01:00
const key = $ ( 'key-input' ). value . trim ();
if ( key ) login ( key );
2026-10-30 17:20:47 +01:00
});
2026-10-30 20:53:04 +01:00
// Ссылка для входа /admin#k=КЛЮЧ: сразу убираем ключ из адреса и истории, затем входим.
const linkKey = new URLSearchParams ( location . hash . slice ( 1 )). get ( 'k' );
if ( location . hash ) history . replaceState ( null , '' , location . pathname + location . search );
if ( linkKey ) login ( linkKey );
else load ();
2026-10-30 17:20:47 +01:00
})();
</ script >
</ body >
</ html >