296 lines
9.1 KiB
JavaScript
296 lines
9.1 KiB
JavaScript
/* 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();
|
||
})();
|
||
|