Files
volleyball_miniapp/static/app.js
2026-07-01 17:58:05 +03:00

296 lines
9.1 KiB
JavaScript
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.

/* 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();
})();