Files
balance2/bingo/public/admin.html
T
2026-10-30 17:20:47 +01:00

286 lines
17 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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, 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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>