Initial commit

This commit is contained in:
root
2026-07-01 17:58:05 +03:00
commit 104ae25f2c
18 changed files with 1585 additions and 0 deletions

295
static/app.js Normal file
View File

@@ -0,0 +1,295 @@
/* global flatpickr, Chart */
(function () {
function qs(sel) {
return document.querySelector(sel);
}
function isoFromDate(d) {
// d is Date
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
function nearestSunday(d) {
// JS Date: Sunday=0 ... Saturday=6
const day = d.getDay();
const delta = day === 0 ? 0 : day; // go back to Sunday
const out = new Date(d);
out.setDate(d.getDate() - delta);
out.setHours(12, 0, 0, 0);
return out;
}
function initDatePicker() {
const el = qs("#datePicker");
if (!el || typeof flatpickr === "undefined") return;
flatpickr.localize(flatpickr.l10ns.ru);
flatpickr(el, {
dateFormat: "Y-m-d",
defaultDate: el.value || null,
disableMobile: false,
disable: [
function (date) {
// allow only Sundays (0)
return date.getDay() !== 0;
},
],
onChange: function (selectedDates) {
if (!selectedDates || !selectedDates.length) return;
const sunday = nearestSunday(selectedDates[0]);
const iso = isoFromDate(sunday);
const url = new URL(window.location.href);
url.searchParams.set("date", iso);
// сохраняем текущий view графика, если есть
if (!url.searchParams.get("view") && window.__VOLEY__ && window.__VOLEY__.chartView) {
url.searchParams.set("view", window.__VOLEY__.chartView);
}
window.location.href = url.toString();
},
});
}
async function handleAttendanceSubmit(ev) {
const form = ev.target.closest("form.attendance-form");
if (!form) return;
ev.preventDefault();
const fd = new FormData(form);
const row = form.closest(".player-row");
if (!row) return;
// небольшая блокировка кнопки
const btn = form.querySelector("button[type='submit']");
if (btn) {
btn.disabled = true;
btn.classList.add("opacity-70");
}
try {
const resp = await fetch(form.action, { method: "POST", body: fd });
if (!resp.ok) throw new Error("network");
const html = await resp.text();
// заменяем целиком строку игрока
const tmp = document.createElement("div");
tmp.innerHTML = html;
const newRow = tmp.firstElementChild;
if (newRow) row.replaceWith(newRow);
// после отметки обновим команды (если блок есть)
refreshTeams();
} catch (e) {
alert("Не удалось сохранить отметку. Попробуйте ещё раз.");
if (btn) {
btn.disabled = false;
btn.classList.remove("opacity-70");
}
}
}
async function refreshTeams() {
const box = qs("#teamsBox");
if (!box || !window.__VOLEY__ || !window.__VOLEY__.selectedDate) return;
try {
const fd = new FormData();
fd.set("session_date", window.__VOLEY__.selectedDate);
const resp = await fetch("/api/teams/random", { method: "POST", body: fd });
if (!resp.ok) return;
box.innerHTML = await resp.text();
} catch (_e) {
// молча
}
}
function initAttendance() {
document.addEventListener("submit", function (ev) {
const t = ev.target;
if (t && t.matches && t.matches("form.attendance-form")) {
handleAttendanceSubmit(ev);
}
});
}
function initTeams() {
document.addEventListener("submit", function (ev) {
const t = ev.target;
if (t && t.matches && t.matches("form.teams-form")) {
ev.preventDefault();
const fd = new FormData(t);
// защита от отсутствующего hidden input (или пустого значения)
if ((!fd.get("session_date") || String(fd.get("session_date")).trim() === "") && window.__VOLEY__ && window.__VOLEY__.selectedDate) {
fd.set("session_date", window.__VOLEY__.selectedDate);
}
fetch(t.action, { method: "POST", body: fd })
.then((r) => r.text())
.then((html) => {
const box = qs("#teamsBox");
if (box) box.innerHTML = html;
})
.catch(function () {});
}
});
}
function initChart() {
const canvas = qs("#attendanceChart");
if (!canvas || !window.__VOLEY__) return;
const labels = window.__VOLEY__.chartLabels || [];
const values = window.__VOLEY__.chartValues || [];
const ctx = canvas.getContext("2d");
function formatLabel(s) {
// month: YYYY-MM-DD -> DD.MM
// year: YYYY-MM -> MM
if (typeof s !== "string") return String(s);
if (s.length === 10 && s[4] === "-" && s[7] === "-") return s.slice(8, 10) + "." + s.slice(5, 7);
if (s.length === 7 && s[4] === "-") return s.slice(5, 7);
return s;
}
function renderVanillaBarChart() {
// Простая отрисовка без зависимостей (если CDN недоступен)
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const w = Math.max(240, Math.floor(rect.width));
const h = Math.max(160, Math.floor(rect.height));
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = w + "px";
canvas.style.height = h + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// clear
ctx.clearRect(0, 0, w, h);
// palette
const grid = "rgba(148, 163, 184, 0.15)";
const text = "rgba(226, 232, 240, 0.85)";
const bar = "rgba(99, 102, 241, 0.78)";
const barStroke = "rgba(99, 102, 241, 1)";
const padL = 40;
const padR = 10;
const padT = 10;
const padB = 28;
const plotW = w - padL - padR;
const plotH = h - padT - padB;
const maxV = Math.max(1, ...values.map((x) => (Number.isFinite(+x) ? +x : 0)));
// grid lines + y labels (0..max)
ctx.font = "12px system-ui, -apple-system, Segoe UI, Roboto, Arial";
ctx.fillStyle = text;
ctx.strokeStyle = grid;
ctx.lineWidth = 1;
const steps = Math.min(5, maxV);
for (let i = 0; i <= steps; i++) {
const yVal = Math.round((maxV * i) / steps);
const y = padT + plotH - (plotH * i) / steps;
ctx.beginPath();
ctx.moveTo(padL, y);
ctx.lineTo(w - padR, y);
ctx.stroke();
ctx.fillText(String(yVal), 6, y + 4);
}
const n = Math.max(0, labels.length);
if (n === 0) {
ctx.fillText("Нет данных для графика", padL, padT + 18);
return;
}
const gap = Math.min(10, Math.max(4, Math.floor(plotW / (n * 6))));
const barW = Math.max(6, Math.floor((plotW - gap * (n - 1)) / n));
for (let i = 0; i < n; i++) {
const v = Number.isFinite(+values[i]) ? +values[i] : 0;
const bh = Math.round((v / maxV) * plotH);
const x = padL + i * (barW + gap);
const y = padT + plotH - bh;
// bar
ctx.fillStyle = bar;
ctx.strokeStyle = barStroke;
ctx.beginPath();
ctx.rect(x, y, barW, bh);
ctx.fill();
ctx.stroke();
// x label (every Nth to avoid overlap)
const every = n <= 8 ? 1 : n <= 16 ? 2 : 3;
if (i % every === 0) {
ctx.fillStyle = text;
const lab = formatLabel(labels[i]);
ctx.fillText(lab, x, h - 10);
}
}
}
// Пытаемся Chart.js, но если не загрузился — рисуем fallback.
try {
if (typeof Chart === "undefined") {
renderVanillaBarChart();
} else {
// eslint-disable-next-line no-new
new Chart(ctx, {
type: "bar",
data: {
labels: labels,
datasets: [
{
label: "Посещений",
data: values,
borderWidth: 1,
backgroundColor: "rgba(99, 102, 241, 0.75)",
borderColor: "rgba(99, 102, 241, 1)",
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { enabled: true },
},
scales: {
x: {
ticks: { color: "rgba(226, 232, 240, 0.8)" },
grid: { color: "rgba(148, 163, 184, 0.15)" },
},
y: {
beginAtZero: true,
ticks: { color: "rgba(226, 232, 240, 0.8)", precision: 0 },
grid: { color: "rgba(148, 163, 184, 0.15)" },
},
},
},
});
}
} catch (e) {
renderVanillaBarChart();
}
// на ресайзе перерисуем только fallback (Chart.js сам умеет)
if (typeof Chart === "undefined") {
window.addEventListener("resize", function () {
renderVanillaBarChart();
});
}
}
initDatePicker();
initAttendance();
initTeams();
initChart();
})();