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

1097 lines
44 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, viewport-fit=cover">
<title>Бинго «Баланс 2.0»</title>
<meta name="claude:viewport-fit-sides" content="cover">
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<meta name="theme-color" content="#12233D">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%2312233D'/%3E%3Crect x='6' y='10' width='17' height='12' rx='3' fill='none' stroke='%23C6F432' stroke-width='2.2'/%3E%3Crect x='24' y='13.5' width='2.5' height='5' rx='1' fill='%23C6F432'/%3E%3Crect x='9' y='13' width='7' height='6' rx='1' fill='%23C6F432'/%3E%3C/svg%3E">
<!--FONTS-->
<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+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
@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+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
</style>
<!--/FONTS-->
<style>
/* Layout: одна колонка — заголовок и правила, строка недели, опрос, карточка 4×4 с подписями рядов слева, батарейка внизу. */
:root {
color-scheme: dark;
--bg: #12233D;
--cell: #1B3150;
--line: #2A4468;
--fg: #F2F5FA;
--muted: #A9B8CC;
--ink: #12233D;
--accent: #C6F432;
--c-body: #FF8A65;
--c-focus: #7CC4FF;
--c-bounds: #B9A6FF;
--c-people: #C6F432;
--danger: #FF8A8A;
--display: 'Unbounded', 'Arial Black', 'Segoe UI', system-ui, sans-serif;
--text: 'Manrope', 'Segoe UI', system-ui, -apple-system, sans-serif;
--radius: 20px;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--text);
font-size: 16px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
}
button { font: inherit; color: inherit; }
:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
.page {
max-width: 1100px;
margin: 0 auto;
padding-block: 40px 56px;
padding-left: max(16px, env(safe-area-inset-left, 0px));
padding-right: max(16px, env(safe-area-inset-right, 0px));
display: grid;
gap: 28px;
}
@media (min-width: 720px) {
.page {
padding-left: max(32px, env(safe-area-inset-left, 0px));
padding-right: max(32px, env(safe-area-inset-right, 0px));
padding-block: 56px 72px;
gap: 32px;
}
}
/* ---------- шапка ---------- */
.hero { display: grid; gap: 18px; }
.eyebrow {
margin: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
color: var(--muted);
}
h1 {
margin: 0;
font-family: var(--display);
font-weight: 800;
font-size: clamp(34px, 7vw, 68px);
line-height: 1.02;
letter-spacing: -0.02em;
text-wrap: balance;
}
h1 .ver { color: var(--accent); }
.rules {
list-style: none;
margin: 4px 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 10px 26px;
font-weight: 600;
font-size: 15px;
}
.rules li { display: flex; align-items: center; gap: 10px; }
.rules .n {
flex: none;
width: 28px; height: 28px;
border-radius: 50%;
display: grid; place-items: center;
background: var(--accent);
color: var(--ink);
font-family: var(--display);
font-size: 13px;
font-weight: 700;
}
/* ---------- строка недели и уведомления ---------- */
.status {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 20px;
font-size: 14px;
color: var(--muted);
}
.status-left { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
.chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 5px 12px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--fg);
font-weight: 700;
font-size: 13px;
letter-spacing: 0.02em;
white-space: nowrap;
}
.my-steps { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
.my-steps b { font-family: var(--display); font-weight: 700; }
.notice {
margin: 0;
padding: 12px 16px;
border-radius: 14px;
border: 1px dashed var(--line);
color: var(--muted);
font-size: 14px;
}
.notice strong { color: var(--fg); }
/* ---------- мини-опрос ---------- */
.pulse {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 18px;
padding: 14px 16px 14px 20px;
border-radius: var(--radius);
background: var(--cell);
border: 1px solid var(--line);
}
.pulse-q { margin: 0; font-weight: 700; flex: 1 1 220px; min-width: 0; }
.pulse-q small { display: block; font-weight: 500; color: var(--muted); font-size: 13px; margin-top: 2px; }
.pulse-opts { display: flex; flex-wrap: wrap; gap: 8px; }
.pulse-opt {
cursor: pointer;
padding: 9px 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
font-weight: 700;
font-size: 14px;
transition: background-color .15s, border-color .15s, color .15s;
}
.pulse-opt:hover { border-color: var(--muted); }
.pulse-opt[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--ink); }
/* ---------- карточка ---------- */
.card { display: grid; gap: 16px; }
.row {
--row: var(--c-body);
display: grid;
grid-template-columns: 164px minmax(0, 1fr);
gap: 12px;
align-items: stretch;
padding: 8px;
margin: -8px;
border-radius: calc(var(--radius) + 8px);
transition: background-color .3s, box-shadow .3s;
}
.row.is-complete {
background: color-mix(in srgb, var(--row) 12%, transparent);
box-shadow: inset 0 0 0 1.5px var(--row), 0 0 36px -6px color-mix(in srgb, var(--row) 45%, transparent);
}
.row.just-completed { animation: row-pop .7s cubic-bezier(.3,1.6,.5,1); }
@keyframes row-pop { 0% { transform: scale(1); } 35% { transform: scale(1.015); } 100% { transform: scale(1); } }
.row-head {
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
padding: 4px 4px 4px 8px;
color: var(--row);
min-width: 0;
}
.row-icon { width: 30px; height: 30px; flex: none; }
.row-name {
font-family: var(--display);
font-weight: 700;
font-size: 15px;
line-height: 1.2;
text-wrap: balance;
}
.row-bonus {
align-self: flex-start;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.04em;
padding: 3px 9px;
border-radius: 999px;
background: var(--row);
color: var(--ink);
white-space: nowrap;
}
.row:not(.is-complete) .row-bonus { visibility: hidden; }
.row-cells { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.cell {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 14px;
min-height: 132px;
padding: 16px 16px 18px;
text-align: left;
background: var(--cell);
border: 1.5px solid var(--line);
border-radius: var(--radius);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background-color .18s, border-color .18s, color .18s, transform .12s;
}
.cell:hover { border-color: color-mix(in srgb, var(--row) 70%, var(--line)); }
.cell:active { transform: scale(.975); }
.cell-num {
font-family: var(--display);
font-weight: 700;
font-size: 14px;
color: var(--row);
font-variant-numeric: tabular-nums;
}
.cell-text { font-weight: 700; font-size: 15.5px; line-height: 1.28; padding-right: 2px; overflow-wrap: anywhere; hyphens: auto; }
.cell-check {
position: absolute;
top: 13px; right: 13px;
width: 28px; height: 28px;
border-radius: 50%;
border: 1.5px solid var(--line);
display: grid; place-items: center;
transition: background-color .18s, border-color .18s;
}
.cell-check svg { width: 16px; height: 16px; opacity: 0; transform: scale(.4); transition: opacity .18s, transform .25s cubic-bezier(.3,1.7,.5,1); }
.cell[aria-pressed="true"] { background: var(--row); border-color: var(--row); color: var(--ink); }
.cell[aria-pressed="true"] .cell-num { color: var(--ink); opacity: .72; }
.cell[aria-pressed="true"] .cell-check { background: var(--ink); border-color: var(--ink); color: var(--row); }
.cell[aria-pressed="true"] .cell-check svg { opacity: 1; transform: scale(1); }
.cell[aria-busy="true"] { cursor: progress; }
/* Клетка собранного столбца или диагонали: светлое кольцо поверх заливки ряда. */
.cell.on-line {
box-shadow: 0 0 0 3px var(--bg), 0 0 0 5.5px var(--fg), 0 0 28px 2px color-mix(in srgb, var(--fg) 30%, transparent);
}
.cell.just-line { animation: diag-pop .7s cubic-bezier(.3,1.6,.5,1); }
@keyframes diag-pop { 0% { transform: scale(1); } 35% { transform: scale(1.04); } 100% { transform: scale(1); } }
/* ---------- батарейка ---------- */
.battery-wrap {
display: grid;
gap: 18px;
padding: 24px;
border-radius: calc(var(--radius) + 6px);
background: var(--cell);
border: 1px solid var(--line);
}
.battery-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 10px 24px; }
.battery-title { display: flex; align-items: center; gap: 10px; margin: 0; font-family: var(--display); font-weight: 700; font-size: clamp(18px, 2.6vw, 22px); }
.battery-title svg { width: 24px; height: 24px; color: var(--accent); flex: none; }
.battery-sub { margin: 6px 0 0; color: var(--muted); font-size: 15px; font-variant-numeric: tabular-nums; }
.battery-sub b { color: var(--fg); font-weight: 800; }
.battery-pct {
display: flex; align-items: baseline; gap: 12px;
font-family: var(--display);
font-weight: 800;
font-size: clamp(40px, 7vw, 60px);
line-height: 1;
color: var(--accent);
font-variant-numeric: tabular-nums;
}
.battery-pct small { font-size: .45em; margin-left: 2px; }
.overdrive {
font-family: var(--display);
font-size: 13px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 6px 10px;
border-radius: 999px;
background: var(--accent);
color: var(--ink);
align-self: center;
}
.battery { display: flex; align-items: center; gap: 0; }
.battery-body {
flex: 1;
display: grid;
grid-template-columns: repeat(10, minmax(0, 1fr));
gap: 6px;
padding: 6px;
height: 76px;
border: 3px solid var(--accent);
border-radius: 18px;
}
.seg { position: relative; border-radius: 8px; background: color-mix(in srgb, var(--accent) 9%, transparent); overflow: hidden; }
.seg i {
position: absolute; inset: 0;
background: var(--accent);
transform-origin: left center;
transform: scaleX(var(--fill, 0));
transition: transform .8s cubic-bezier(.2,.8,.2,1);
}
.battery-cap { width: 10px; height: 30px; margin-left: 4px; border-radius: 0 6px 6px 0; background: var(--accent); flex: none; }
.battery.is-overdrive .battery-body { animation: overdrive 1.8s ease-in-out infinite; }
@keyframes overdrive {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
50% { box-shadow: 0 0 34px 2px color-mix(in srgb, var(--accent) 45%, transparent); }
}
.battery-scale {
display: grid; grid-template-columns: repeat(10, minmax(0, 1fr));
gap: 6px; padding: 0 23px 0 9px;
margin-top: -8px;
font-size: 11px; font-weight: 700; color: var(--muted);
font-variant-numeric: tabular-nums;
}
.battery-scale span { text-align: right; }
.foot { margin: 0; font-size: 13px; color: var(--muted); max-width: 70ch; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.toast {
position: fixed;
left: 50%;
bottom: calc(20px + env(safe-area-inset-bottom, 0px));
transform: translate(-50%, 20px);
max-width: min(520px, calc(100vw - 32px));
padding: 12px 18px;
border-radius: 14px;
background: var(--fg);
color: var(--ink);
font-weight: 700;
font-size: 14px;
box-shadow: 0 12px 40px rgba(0,0,0,.35);
opacity: 0;
pointer-events: none;
transition: opacity .2s, transform .2s;
z-index: 30;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
#confetti { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 20; }
/* ---------- телефон: подпись ряда сверху, клетки 2×2 ---------- */
@media (max-width: 760px) {
.card { gap: 22px; }
.row { grid-template-columns: minmax(0, 1fr); gap: 10px; }
.row-head { flex-direction: row; align-items: center; justify-content: flex-start; padding: 0 2px; gap: 10px; }
.row-icon { width: 24px; height: 24px; }
.row-bonus { align-self: center; margin-left: auto; }
.row-cells { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.pulse { padding: 16px; }
.pulse-opts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); width: 100%; gap: 6px; }
.pulse-opt { padding: 9px 6px; font-size: 13px; white-space: nowrap; }
.cell { min-height: 116px; padding: 14px 14px 16px; }
.cell-text { font-size: 15px; }
.battery-wrap { padding: 20px 16px; }
.battery-body { height: 60px; gap: 4px; padding: 5px; border-radius: 15px; }
.seg { border-radius: 6px; }
.battery-scale { gap: 4px; padding: 0 22px 0 8px; font-size: 10px; }
.battery-scale span:nth-child(odd) { visibility: hidden; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
</style>
</head>
<body>
<div class="page">
<header class="hero">
<p class="eyebrow">PROJECT: BALANCE · СЕЗОН 2 · 2 НОЯБРЯ — 21 ДЕКАБРЯ</p>
<h1>Бинго «Баланс&nbsp;<span class="ver">2.0</span>»</h1>
<ol class="rules">
<li><span class="n">1</span>Делай маленькие шаги</li>
<li><span class="n">2</span>Отмечай клетки за неделю</li>
<li><span class="n">3</span>Собрал ряд, столбец или диагональ — +5 в батарейку</li>
</ol>
</header>
<div class="status">
<div class="status-left">
<span class="chip" id="week-chip">Неделя —</span>
<span id="reset-note">Карточка обнулится в понедельник в 00:00</span>
</div>
<span class="my-steps" id="my-steps"></span>
</div>
<p class="notice" id="notice" hidden></p>
<section class="pulse" id="pulse" aria-labelledby="pulse-q" hidden>
<p class="pulse-q" id="pulse-q">Твоя батарейка сейчас?<small id="pulse-hint">Один анонимный ответ в неделю — сравним «до» и «после» в финале.</small></p>
<div class="pulse-opts" role="group" aria-labelledby="pulse-q">
<button type="button" class="pulse-opt" data-answer="full" aria-pressed="false">⚡ Полная</button>
<button type="button" class="pulse-opt" data-answer="mid" aria-pressed="false">🔋 Средне</button>
<button type="button" class="pulse-opt" data-answer="low" aria-pressed="false">🪫 На нуле</button>
</div>
</section>
<section class="card" id="card" aria-label="Моя карточка на эту неделю"></section>
<section class="battery-wrap" aria-labelledby="battery-title">
<div class="battery-head">
<div>
<h2 class="battery-title" id="battery-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 2.5 5 13.5h6l-1 8 8-11h-6z"/></svg>
Батарейка команды
</h2>
<p class="battery-sub" id="battery-sub"><b>…</b> из 500 шагов · заполняем вместе до 21 декабря</p>
</div>
<div class="battery-pct"><span id="battery-pct">0<small>%</small></span><span class="overdrive" id="overdrive" hidden>Овердрайв!</span></div>
</div>
<div class="battery" id="battery" role="progressbar" aria-labelledby="battery-title" aria-valuemin="0" aria-valuemax="500" aria-valuenow="0">
<div class="battery-body" id="battery-body"></div>
<div class="battery-cap"></div>
</div>
<div class="battery-scale" id="battery-scale" aria-hidden="true"></div>
</section>
<p class="foot">Анонимно: без имён, почты и логинов. Твоя карточка хранится только в этом браузере, на сервер уходит лишь «клетка отмечена или снята» со случайным номером устройства. Батарейка обновляется сама каждые 20 секунд.</p>
</div>
<div class="sr-only" aria-live="polite" id="live"></div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<canvas id="confetti" aria-hidden="true"></canvas>
<script>
(() => {
'use strict';
// ---------- настройки ----------
const API = 'api'; // относительный путь: работает и на своём домене, и в подпапке за прокси
const STORE_KEY = 'balance2-bingo-v1';
const POLL_MS = 20000;
const LINE_BONUS = 5;
const DEFAULTS = { tz: 'Europe/Moscow', start: '2026-11-02', end: '2026-12-21', goal: 500 };
const FORCE_DEMO = window.BINGO_FORCE_DEMO === true || location.hash === '#demo';
const ICONS = {
heart: '<path d="M12 20s-7.5-4.6-7.5-10.1A4.2 4.2 0 0 1 12 7.4a4.2 4.2 0 0 1 7.5 2.5C19.5 15.4 12 20 12 20z"/>',
target: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.4"/>',
shield: '<path d="M12 3.2 19 5.9v5.7c0 4.4-3 7.9-7 9.3-4-1.4-7-4.9-7-9.3V5.9z"/><path d="m9 12.2 2.1 2.1L15.2 10"/>',
cup: '<path d="M4.5 10h11v3.6a5.5 5.5 0 0 1-11 0z"/><path d="M15.5 11.2h1.6a2.4 2.4 0 0 1 0 4.8h-1.9"/><path d="M8 3.6c-.7.9.7 1.6 0 2.6M11.8 3.6c-.7.9.7 1.6 0 2.6"/>',
};
const CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.3 4.3L19 7.2"/></svg>';
const ROWS = [
{ name: 'Тело', color: 'var(--c-body)', icon: 'heart',
cells: ['Обед без экрана', 'Разминка между созвонами', 'Гимнастика для глаз', 'Сон до полуночи'] },
{ name: 'Фокус', color: 'var(--c-focus)', icon: 'target',
cells: ['Фокус-блок на одну задачу', '3 главных дела на день', 'Встреча превратилась в сообщение', 'Время залогировано в тот же день'] },
{ name: 'Границы', color: 'var(--c-bounds)', icon: 'shield',
cells: ['Сказать «нет» или «не сейчас»', 'Вечер без рабочих сообщений', 'Попросить о помощи', 'Ноутбук закрыт вовремя'] },
{ name: 'Люди и радость', color: 'var(--c-people)', icon: 'cup',
cells: ['Офлайн с друзьями или семьёй', 'Хобби, спорт или что-то руками', '«Спасибо» или помощь коллеге', 'Мем в чат Мемопятницы'] },
];
const MONTHS = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
// ---------- утилиты ----------
const $ = (id) => document.getElementById(id);
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
function plural(n, one, few, many) {
const a = Math.abs(n) % 100, b = a % 10;
if (a > 10 && a < 20) return many;
if (b > 1 && b < 5) return few;
if (b === 1) return one;
return many;
}
const steps = (n) => plural(n, 'шаг', 'шага', 'шагов');
const fmtNum = (n) => n.toLocaleString('ru-RU');
const fmtDay = (iso) => `${+iso.slice(8, 10)} ${MONTHS[+iso.slice(5, 7) - 1]}`;
const tzLabel = (tz) => (tz === 'Europe/Moscow' ? 'МСК' : tz.split('/').pop().replace(/_/g, ' '));
function newId() {
if (crypto.randomUUID) return crypto.randomUUID();
const b = crypto.getRandomValues(new Uint8Array(16));
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
}
// Даты: всё считается в часовом поясе сезона, как на сервере.
function todayIn(tz, now = new Date()) {
try {
const parts = new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(now);
const get = (t) => parts.find((p) => p.type === t).value;
return `${get('year')}-${get('month')}-${get('day')}`;
} catch {
return now.toISOString().slice(0, 10);
}
}
const dayNum = (iso) => Date.UTC(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10)) / 864e5;
const isoOf = (n) => new Date(n * 864e5).toISOString().slice(0, 10);
function mondayOf(iso) {
const n = dayNum(iso);
return isoOf(n - ((new Date(n * 864e5).getUTCDay() + 6) % 7));
}
const weekNoOf = (week, start) => Math.floor((dayNum(week) - dayNum(mondayOf(start))) / 7) + 1;
function localSeason(cfg) {
const today = todayIn(cfg.tz);
const week = mondayOf(today);
const phase = today < cfg.start ? 'pre' : today > cfg.end ? 'post' : 'active';
return {
...cfg, week, phase,
weekNo: phase === 'active' ? weekNoOf(week, cfg.start) : null,
weeks: weekNoOf(mondayOf(cfg.end), cfg.start),
};
}
// ---------- состояние ----------
function loadSaved() {
try { return JSON.parse(localStorage.getItem(STORE_KEY)) || {}; } catch { return {}; }
}
const saved = loadSaved();
const S = {
device: typeof saved.device === 'string' && /^[A-Za-z0-9-]{16,64}$/.test(saved.device) ? saved.device : newId(),
cfg: { ...DEFAULTS, ...(saved.cfg || {}) },
week: saved.week || null,
cells: new Set(Array.isArray(saved.cells) ? saved.cells.filter((c) => Number.isInteger(c) && c >= 1 && c <= 16) : []),
pulse: saved.pulse && typeof saved.pulse === 'object' ? saved.pulse : {},
mode: FORCE_DEMO ? 'demo' : 'connecting', // 'live' | 'demo'
online: true,
phase: 'active', weekNo: null, weeks: 8,
total: null,
shownTotal: 0,
demoOthers: 0,
pending: new Set(),
};
function persist() {
try {
localStorage.setItem(STORE_KEY, JSON.stringify({
device: S.device, cfg: S.cfg, week: S.week, cells: [...S.cells], pulse: S.pulse,
}));
} catch { /* приватный режим или запрет хранения — карточка живёт до закрытия вкладки */ }
}
// Линии, за которые даётся бонус: 4 ряда, 4 столбца и 2 диагонали.
const LINES = [
...ROWS.map((row, r) => ({ kind: 'row', row: r, name: `Ряд «${row.name}»`, cells: [1, 2, 3, 4].map((k) => r * 4 + k) })),
...[1, 2, 3, 4].map((k) => ({ kind: 'col', name: `Столбец ${String(k).padStart(2, '0')}–${k + 12}`, cells: [k, k + 4, k + 8, k + 12] })),
{ kind: 'diag', name: 'Диагональ 01–16', cells: [1, 6, 11, 16] },
{ kind: 'diag', name: 'Диагональ 04–13', cells: [4, 7, 10, 13] },
];
const lineComplete = (i) => LINES[i].cells.every((c) => S.cells.has(c));
const rowComplete = (r) => lineComplete(r);
const completeLines = () => LINES.reduce((n, _, i) => n + (lineComplete(i) ? 1 : 0), 0);
const myScore = () => S.cells.size + LINE_BONUS * completeLines();
// Новая неделя (или первый запуск): карточка начинается заново. Батарейку это не трогает.
function setWeek(week) {
if (S.week === week) return false;
const hadMarks = S.week !== null && S.cells.size > 0;
S.week = week;
S.cells.clear();
persist();
return hadMarks;
}
function applySeason(info) {
S.cfg = {
tz: info.tz || S.cfg.tz,
start: info.start || S.cfg.start,
end: info.end || S.cfg.end,
goal: Number(info.goal) > 0 ? Number(info.goal) : S.cfg.goal,
};
S.phase = info.phase;
S.weekNo = info.weekNo;
S.weeks = info.weeks;
const rolled = setWeek(info.week);
persist();
return rolled;
}
// ---------- сеть ----------
class ApiError extends Error {
constructor(status, data) { super(data?.error || `HTTP ${status}`); this.status = status; this.data = data; }
}
async function api(path, body) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 8000);
try {
const res = await fetch(API + path, {
method: body ? 'POST' : 'GET',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
cache: 'no-store',
credentials: 'omit',
signal: ctrl.signal,
});
const isJson = (res.headers.get('Content-Type') || '').includes('application/json');
const data = isJson ? await res.json() : null;
if (!res.ok || !isJson) throw new ApiError(res.status, data);
return data;
} finally {
clearTimeout(timer);
}
}
async function refreshTotal() {
if (S.mode !== 'live') return;
try {
const t = await api('/total');
S.online = true;
const rolled = applySeason(t);
S.total = t.total;
if (rolled) toast('Началась новая неделя — карточка чистая. Батарейка команды сохранилась.');
if (rolled || S.week !== t.week) await syncMine();
renderAll();
} catch {
S.online = false;
renderNotice();
}
}
// Сверяем карточку с сервером: если отметка не дошла, показываем как есть на сервере.
async function syncMine() {
if (S.mode !== 'live' || S.phase !== 'active') return;
try {
const me = await api(`/me?d=${encodeURIComponent(S.device)}`);
if (me.week !== S.week || S.pending.size) return;
S.cells = new Set(me.cells);
if (me.pulse) S.pulse[S.week] = me.pulse;
persist();
} catch { /* останемся с локальной карточкой */ }
}
// ---------- построение карточки ----------
const cellEls = new Map();
const rowEls = [];
function buildCard() {
const card = $('card');
ROWS.forEach((row, r) => {
const rowEl = document.createElement('div');
rowEl.className = 'row';
rowEl.style.setProperty('--row', row.color);
rowEl.setAttribute('role', 'group');
rowEl.setAttribute('aria-labelledby', `row-name-${r}`);
rowEl.innerHTML = `
<div class="row-head">
<svg class="row-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[row.icon]}</svg>
<span class="row-name" id="row-name-${r}">${row.name}</span>
<span class="row-bonus">Ряд собран · +5</span>
</div>
<div class="row-cells"></div>`;
const cellsWrap = rowEl.querySelector('.row-cells');
row.cells.forEach((label, k) => {
const n = r * 4 + k + 1;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'cell';
btn.setAttribute('aria-pressed', 'false');
btn.dataset.cell = String(n);
btn.innerHTML = `<span class="cell-num">${String(n).padStart(2, '0')}</span><span class="cell-text">${label}</span><span class="cell-check" aria-hidden="true">${CHECK}</span>`;
btn.addEventListener('click', () => toggle(n));
cellsWrap.appendChild(btn);
cellEls.set(n, btn);
});
card.appendChild(rowEl);
rowEls.push(rowEl);
});
}
function buildBattery() {
const body = $('battery-body');
for (let i = 0; i < 10; i++) {
const seg = document.createElement('span');
seg.className = 'seg';
seg.innerHTML = '<i></i>';
body.appendChild(seg);
}
const scale = $('battery-scale');
for (let i = 1; i <= 10; i++) {
const s = document.createElement('span');
s.textContent = `${i * 10}%`;
scale.appendChild(s);
}
}
// ---------- отрисовка ----------
function renderCard() {
const lineCells = new Set();
LINES.forEach((line, i) => { if (line.kind !== 'row' && lineComplete(i)) line.cells.forEach((c) => lineCells.add(c)); });
cellEls.forEach((btn, n) => {
btn.setAttribute('aria-pressed', S.cells.has(n) ? 'true' : 'false');
btn.classList.toggle('on-line', lineCells.has(n));
});
rowEls.forEach((el, r) => el.classList.toggle('is-complete', rowComplete(r)));
}
function renderStatus() {
const chip = $('week-chip');
if (S.mode === 'demo') chip.textContent = 'Демо';
else if (S.phase === 'pre') chip.textContent = `Старт ${fmtDay(S.cfg.start)}`;
else if (S.phase === 'post') chip.textContent = 'Сезон завершён';
else chip.textContent = S.weekNo ? `Неделя ${S.weekNo} из ${S.weeks}` : 'Неделя —';
$('reset-note').textContent = `Карточка обнулится в понедельник в 00:00 (${tzLabel(S.cfg.tz)})`;
const score = myScore();
const got = { row: 0, col: 0, diag: 0 };
LINES.forEach((line, i) => { if (lineComplete(i)) got[line.kind]++; });
const bonus = LINE_BONUS * (got.row + got.col + got.diag);
const part = (n, f) => (n ? `${n > 1 ? n + ' ' : ''}${plural(n, ...f)}` : '');
const parts = [
part(got.row, ['ряд', 'ряда', 'рядов']),
part(got.col, ['столбец', 'столбца', 'столбцов']),
part(got.diag, ['диагональ', 'диагонали', 'диагоналей']),
].filter(Boolean);
const what = parts.length > 1 ? `${parts.slice(0, -1).join(', ')} и ${parts.at(-1)}` : parts[0];
$('my-steps').innerHTML = `Мои шаги за неделю: <b>${score}</b>${bonus ? ` <span style="color:var(--muted);font-weight:600">(из них +${bonus} за ${what})</span>` : ''}`;
}
function renderNotice() {
const el = $('notice');
let html = '';
if (S.mode === 'demo') {
html = '<strong>Демо-режим.</strong> Сервер не подключён: батарейка показывает пример, а отметки сохраняются только в этом браузере.';
} else if (S.mode === 'live' && !S.online) {
html = '<strong>Нет связи с сервером.</strong> Батарейка обновится, когда связь вернётся.';
} else if (S.phase === 'pre') {
html = `<strong>Сезон стартует ${fmtDay(S.cfg.start)}.</strong> Пока можно потренироваться: отметки до старта не идут в батарейку и сотрутся в понедельник.`;
} else if (S.phase === 'post') {
html = '<strong>Сезон завершён.</strong> Батарейка команды зафиксирована — спасибо всем! Отметки больше не идут в общий счёт.';
}
el.innerHTML = html;
el.hidden = !html;
}
function renderPulse() {
const box = $('pulse');
const show = S.mode === 'demo' || (S.mode === 'live' && S.phase === 'active');
box.hidden = !show;
if (!show) return;
const answer = S.pulse[S.week] || null;
box.querySelectorAll('.pulse-opt').forEach((b) => b.setAttribute('aria-pressed', b.dataset.answer === answer ? 'true' : 'false'));
$('pulse-hint').textContent = answer
? 'Спасибо, ответ учтён анонимно. До конца недели можно поменять.'
: 'Один анонимный ответ в неделю — сравним «до» и «после» в финале.';
}
let countAnim = 0;
function renderBattery() {
const goal = S.cfg.goal;
const total = S.total ?? 0;
const ratio = total / goal;
const segs = $('battery-body').children;
for (let i = 0; i < segs.length; i++) {
segs[i].firstChild.style.setProperty('--fill', String(Math.max(0, Math.min(1, ratio * 10 - i))));
}
const over = total > goal;
$('battery').classList.toggle('is-overdrive', over);
$('overdrive').hidden = !over;
const bat = $('battery');
bat.setAttribute('aria-valuemax', String(goal));
bat.setAttribute('aria-valuenow', String(Math.min(total, goal)));
bat.setAttribute('aria-valuetext', `${total} из ${goal} ${steps(goal)}, ${Math.floor(ratio * 100)}%${over ? ', овердрайв' : ''}`);
// Плавный пересчёт числа
cancelAnimationFrame(countAnim);
const from = S.shownTotal;
const to = total;
const draw = (v) => {
const pct = Math.floor((v / goal) * 100);
$('battery-pct').innerHTML = `${pct}<small>%</small>`;
$('battery-sub').innerHTML = `<b>${S.total === null ? '…' : fmtNum(v)}</b> из ${fmtNum(goal)} ${steps(goal)} · заполняем вместе до ${fmtDay(S.cfg.end)}`;
};
if (from === to || reducedMotion.matches) { S.shownTotal = to; draw(to); return; }
const t0 = performance.now();
const dur = Math.min(900, 200 + Math.abs(to - from) * 12);
const step = (now) => {
const k = Math.min(1, (now - t0) / dur);
const v = Math.round(from + (to - from) * (1 - Math.pow(1 - k, 3)));
S.shownTotal = v;
draw(v);
if (k < 1) countAnim = requestAnimationFrame(step);
};
countAnim = requestAnimationFrame(step);
}
function renderAll() {
renderCard();
renderStatus();
renderNotice();
renderPulse();
renderBattery();
}
// ---------- действия ----------
async function toggle(cell) {
if (S.pending.has(cell)) return;
const wasOn = S.cells.has(cell);
const touched = LINES.map((line, i) => i).filter((i) => LINES[i].cells.includes(cell));
const wasComplete = touched.map(lineComplete);
const before = myScore();
if (wasOn) S.cells.delete(cell); else S.cells.add(cell);
persist();
const delta = myScore() - before;
renderCard();
renderStatus();
const made = touched.filter((i, k) => !wasComplete[k] && lineComplete(i));
const broken = touched.filter((i, k) => wasComplete[k] && !lineComplete(i));
if (made.length) celebrate(made);
else if (broken.length) announce(`${broken.map((i) => LINES[i].name).join(' и ')} — больше не собрано: −${LINE_BONUS * broken.length} из батарейки.`);
else announce(`${ROWS[Math.floor((cell - 1) / 4)].cells[(cell - 1) % 4]}: ${wasOn ? 'отметка снята' : 'отмечено'}.`);
if (S.mode === 'demo') {
S.total = S.demoOthers + myScore();
renderBattery();
return;
}
if (S.mode !== 'live' || S.phase !== 'active') return;
S.total = (S.total ?? 0) + delta;
renderBattery();
const btn = cellEls.get(cell);
S.pending.add(cell);
btn.setAttribute('aria-busy', 'true');
try {
const res = await api('/mark', { d: S.device, week: S.week, cell, on: !wasOn });
S.online = true;
S.total = res.total;
if (S.pending.size === 1 && res.week === S.week) {
S.cells = new Set(res.cells);
persist();
}
renderAll();
} catch (err) {
if (err.status === 409 && err.data?.week) {
await refreshTotal();
toast('Началась новая неделя — карточка обнулилась. Отметь заново, что сделал на этой неделе.');
} else if (err.status === 403) {
await refreshTotal();
toast('Сезон сейчас закрыт — отметки не идут в батарейку.');
} else {
if (wasOn) S.cells.add(cell); else S.cells.delete(cell);
persist();
S.total = (S.total ?? 0) - delta;
renderAll();
toast('Не получилось сохранить отметку. Проверь интернет и нажми ещё раз.');
}
} finally {
S.pending.delete(cell);
btn.removeAttribute('aria-busy');
}
}
async function answerPulse(answer) {
const prev = S.pulse[S.week] || null;
if (prev === answer) return;
S.pulse[S.week] = answer;
persist();
renderPulse();
if (S.mode !== 'live') return;
try {
await api('/pulse', { d: S.device, week: S.week, answer });
} catch (err) {
if (prev) S.pulse[S.week] = prev; else delete S.pulse[S.week];
persist();
renderPulse();
if (err.status === 409) await refreshTotal();
toast('Ответ не сохранился. Проверь интернет и попробуй ещё раз.');
}
}
// ---------- сообщения ----------
let toastTimer = 0;
function toast(text) {
const el = $('toast');
el.textContent = text;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 4200);
}
function announce(text) {
const el = $('live');
el.textContent = '';
setTimeout(() => { el.textContent = text; }, 30);
}
// ---------- конфетти ----------
const canvas = $('confetti');
const ctx = canvas.getContext('2d');
let parts = [];
let confettiRaf = 0;
const replay = (el, cls) => { el.classList.remove(cls); void el.offsetWidth; el.classList.add(cls); };
function celebrate(lineIdxs) {
const total = LINE_BONUS * lineIdxs.length;
announce(`${lineIdxs.map((i) => LINES[i].name).join(' и ')} — собрано! +${total} в батарейку.`);
for (const i of lineIdxs) {
const line = LINES[i];
if (line.kind === 'row') replay(rowEls[line.row], 'just-completed');
else line.cells.forEach((c) => replay(cellEls.get(c), 'just-line'));
}
if (reducedMotion.matches) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = innerWidth * dpr;
canvas.height = innerHeight * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const css = getComputedStyle(document.documentElement);
const tok = (n) => css.getPropertyValue(n).trim();
for (const i of lineIdxs) {
const line = LINES[i];
if (line.kind === 'row') {
const rowEl = rowEls[line.row];
const own = tok(ROWS[line.row].color.slice(4, -1));
burst(rowEl.getBoundingClientRect(), [own, own, tok('--accent'), tok('--fg'), tok('--c-focus'), tok('--c-bounds'), tok('--c-body')]);
} else {
// Столбец или диагональ: по залпу из каждой клетки, во всех цветах рядов.
const pal = [tok('--fg'), tok('--accent'), tok('--c-body'), tok('--c-focus'), tok('--c-bounds')];
line.cells.forEach((c) => burst(cellEls.get(c).getBoundingClientRect(), pal, 0.5));
}
}
if (!confettiRaf) confettiRaf = requestAnimationFrame(tick);
}
function burst(box, palette, scale = 1) {
const count = Math.round(Math.min(170, 70 + box.width / 6) * scale);
for (let i = 0; i < count; i++) {
const x = box.left + Math.random() * box.width;
const y = box.top + box.height * (0.3 + Math.random() * 0.4);
const angle = -Math.PI / 2 + (Math.random() - 0.5) * 1.5;
const speed = 7 + Math.random() * 9;
parts.push({
x, y,
vx: Math.cos(angle) * speed + (x - (box.left + box.width / 2)) / box.width * 4,
vy: Math.sin(angle) * speed,
w: 6 + Math.random() * 6,
h: 8 + Math.random() * 10,
rot: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 0.35,
tilt: Math.random() * Math.PI,
color: palette[(Math.random() * palette.length) | 0],
round: Math.random() < 0.25,
life: 0,
});
}
}
let last = 0;
function tick(now) {
const dt = last ? Math.min(2.5, (now - last) / 16.67) : 1;
last = now;
ctx.clearRect(0, 0, innerWidth, innerHeight);
parts = parts.filter((p) => p.y < innerHeight + 40 && p.life < 260);
for (const p of parts) {
p.life += dt;
p.vy += 0.32 * dt;
p.vx *= Math.pow(0.985, dt);
p.vy *= Math.pow(0.99, dt);
p.x += p.vx * dt;
p.y += p.vy * dt;
p.rot += p.vr * dt;
p.tilt += 0.12 * dt;
ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(1, (260 - p.life) / 60));
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = p.color;
if (p.round) {
ctx.beginPath();
ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.fillRect(-p.w / 2, (-p.h / 2) * Math.cos(p.tilt), p.w, p.h * Math.cos(p.tilt));
}
ctx.restore();
}
if (parts.length) {
confettiRaf = requestAnimationFrame(tick);
} else {
ctx.clearRect(0, 0, innerWidth, innerHeight);
confettiRaf = 0;
last = 0;
}
}
// ---------- демо ----------
function startDemo() {
const s = localSeason(S.cfg);
setWeek(s.week);
S.phase = 'active';
S.weekNo = 3;
S.weeks = s.weeks;
S.demoOthers = 212;
S.total = S.demoOthers + myScore();
// «Коллеги» иногда что-то отмечают, чтобы было видно, как батарейка растёт.
setInterval(() => {
if (document.hidden) return;
const r = Math.random();
if (r < 0.45) S.demoOthers += 1;
else if (r < 0.52) S.demoOthers += 1 + LINE_BONUS;
else return;
S.total = S.demoOthers + myScore();
renderBattery();
}, 7000);
}
// ---------- запуск ----------
async function start() {
document.documentElement.lang = 'ru';
buildCard();
buildBattery();
$('pulse').addEventListener('click', (e) => {
const b = e.target.closest('.pulse-opt');
if (b) answerPulse(b.dataset.answer);
});
// Пока сервер не ответил — показываем карточку по локальному календарю.
const s = localSeason(S.cfg);
S.phase = s.phase; S.weekNo = s.weekNo; S.weeks = s.weeks;
setWeek(s.week);
renderAll();
if (S.mode !== 'demo') {
try {
const t = await api('/total');
S.mode = 'live';
applySeason(t);
S.total = t.total;
await syncMine();
} catch {
S.mode = 'demo';
}
}
if (S.mode === 'demo') startDemo();
persist();
renderAll();
setInterval(() => {
if (document.hidden) return;
if (S.mode === 'live') refreshTotal();
else {
const ls = localSeason(S.cfg);
if (setWeek(ls.week)) { renderAll(); toast('Началась новая неделя — карточка чистая.'); }
}
}, POLL_MS);
document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshTotal(); });
window.addEventListener('online', refreshTotal);
}
start();
})();
</script>
</body>
</html>