286 lines
17 KiB
HTML
286 lines
17 KiB
HTML
<!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: 15px/1.45 var(--text); -webkit-font-smoothing: antialiased; }
|
||
button, input { font: inherit; color: inherit; }
|
||
:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
||
.page {
|
||
max-width: 1100px; margin: 0 auto; display: grid; gap: 28px;
|
||
padding-block: 40px 64px;
|
||
padding-left: max(16px, env(safe-area-inset-left, 0px));
|
||
padding-right: max(16px, env(safe-area-inset-right, 0px));
|
||
}
|
||
.eyebrow { margin: 0; font-size: 12px; font-weight: 700; letter-spacing: .14em; color: var(--muted); text-transform: uppercase; }
|
||
h1 { margin: 6px 0 0; font-family: var(--display); font-weight: 800; font-size: clamp(26px, 5vw, 40px); line-height: 1.1; }
|
||
h1 span { color: var(--accent); }
|
||
h2 { margin: 0; font-family: var(--display); font-weight: 700; font-size: 18px; }
|
||
.head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-end; gap: 16px; }
|
||
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||
.btn {
|
||
cursor: pointer; padding: 9px 14px; border-radius: 999px; border: 1px solid var(--line);
|
||
background: transparent; font-weight: 700; font-size: 14px;
|
||
}
|
||
.btn:hover { border-color: var(--muted); }
|
||
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--ink); }
|
||
|
||
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 20px; padding: 22px; display: grid; gap: 16px; min-width: 0; }
|
||
.panel-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 8px 16px; }
|
||
.note { margin: 0; color: var(--muted); font-size: 13px; }
|
||
|
||
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
||
.kpi { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; padding: 16px 18px; }
|
||
.kpi dt { color: var(--muted); font-size: 13px; font-weight: 600; }
|
||
.kpi dd { margin: 4px 0 0; font-family: var(--display); font-weight: 700; font-size: 28px; font-variant-numeric: tabular-nums; }
|
||
.kpi dd small { font-family: var(--text); font-size: 13px; color: var(--muted); font-weight: 600; }
|
||
|
||
.table-wrap { overflow-x: auto; }
|
||
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; font-size: 14px; }
|
||
th, td { padding: 10px 12px; text-align: right; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||
th:first-child, td:first-child { text-align: left; }
|
||
th { color: var(--muted); font-weight: 700; font-size: 12px; letter-spacing: .04em; text-transform: uppercase; }
|
||
tbody tr:last-child td { border-bottom: 0; }
|
||
td.strong { font-weight: 800; }
|
||
|
||
.legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: 13px; color: var(--muted); }
|
||
.legend i { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }
|
||
.pulse-rows { display: grid; gap: 10px; }
|
||
.pulse-row { display: grid; grid-template-columns: 110px minmax(0, 1fr) 60px; gap: 12px; align-items: center; font-size: 14px; }
|
||
.pulse-row .n { text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||
.stack { display: flex; height: 26px; border-radius: 8px; overflow: hidden; background: color-mix(in srgb, var(--fg) 6%, transparent); gap: 2px; }
|
||
.stack span { display: grid; place-items: center; font-size: 12px; font-weight: 800; color: var(--ink); min-width: 0; overflow: hidden; }
|
||
|
||
.cells { display: grid; gap: 18px; }
|
||
.cell-group h3 { margin: 0 0 8px; font-size: 13px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }
|
||
.bar-row { display: grid; grid-template-columns: minmax(0, 260px) minmax(0, 1fr) 44px; gap: 12px; align-items: center; padding: 4px 0; font-size: 14px; }
|
||
.bar-row .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.bar-row .label b { font-variant-numeric: tabular-nums; margin-right: 6px; }
|
||
.bar { height: 12px; border-radius: 6px; background: color-mix(in srgb, var(--fg) 6%, transparent); overflow: hidden; }
|
||
.bar i { display: block; height: 100%; border-radius: 6px; }
|
||
.bar-row .n { text-align: right; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||
|
||
.login { max-width: 460px; }
|
||
.login form { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.login input { flex: 1 1 220px; padding: 10px 14px; border-radius: 12px; border: 1px 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, 1fr) 44px; }
|
||
.bar-row .bar { grid-column: 1 / -1; grid-row: 2; }
|
||
.pulse-row { grid-template-columns: 80px minmax(0, 1fr) 40px; }
|
||
}
|
||
</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>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="panel login" id="login" hidden>
|
||
<h2>Нужен ключ</h2>
|
||
<p class="note">Откройте секретную ссылку целиком (с частью после #) или вставьте ключ сюда. Ключ не уходит никуда, кроме запроса к этому сайту.</p>
|
||
<form id="login-form">
|
||
<input id="key-input" type="password" autocomplete="off" placeholder="Ключ администратора" aria-label="Ключ администратора">
|
||
<button class="btn primary" type="submit">Открыть</button>
|
||
</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 key = new URLSearchParams(location.hash.slice(1)).get('k') || '';
|
||
let data = null;
|
||
|
||
async function load() {
|
||
$('login-error').hidden = true;
|
||
try {
|
||
const res = await fetch('api/admin/stats', { headers: { Authorization: `Bearer ${key}` }, cache: 'no-store' });
|
||
if (res.status === 403) throw new Error('Ключ не подошёл. Проверьте ссылку или задайте ADMIN_KEY заново.');
|
||
if (!res.ok) throw new Error(`Сервер ответил ошибкой ${res.status}. Попробуйте обновить через минуту.`);
|
||
data = await res.json();
|
||
$('login').hidden = true;
|
||
$('content').hidden = false;
|
||
$('actions').hidden = false;
|
||
render();
|
||
} catch (err) {
|
||
$('login').hidden = false;
|
||
$('content').hidden = true;
|
||
$('actions').hidden = true;
|
||
if (key) { $('login-error').textContent = err.message; $('login-error').hidden = false; }
|
||
}
|
||
}
|
||
|
||
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('');
|
||
|
||
$('generated').textContent = `Данные на ${new Date(data.generatedAt).toLocaleString('ru-RU')}. Часовой пояс сезона: ${s.tz}.`;
|
||
}
|
||
|
||
// 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]());
|
||
});
|
||
$('reload').addEventListener('click', load);
|
||
$('login-form').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
key = $('key-input').value.trim();
|
||
history.replaceState(null, '', `#k=${encodeURIComponent(key)}`);
|
||
load();
|
||
});
|
||
|
||
load();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|