Initial commit
This commit is contained in:
295
static/app.js
Normal file
295
static/app.js
Normal 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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user