From 1434e906fdaec5bf2771bc682dc3a3797610b94e Mon Sep 17 00:00:00 2001
From: Greg
Date: Sun, 8 Mar 2026 13:39:52 +0100
Subject: [PATCH] feat: add season-aware views on main page and reports
- Main page: filter table by current season by default; show Next Season button when next season dates exist in DB
- Reports page: filter charts to current season; add Last Season toggle button when previous season data exists
- Admin archive: export all dates/players as CSV (no season filter)
Co-Authored-By: Claude Sonnet 4.6
---
static/app.js | 163 +++++++++++++++++++++--------------------
templates/admin.html | 46 ++++++++++++
templates/index.html | 2 +-
templates/reports.html | 106 +++++++++++++++++++++------
4 files changed, 215 insertions(+), 102 deletions(-)
diff --git a/static/app.js b/static/app.js
index 522ea6c..bb954fd 100644
--- a/static/app.js
+++ b/static/app.js
@@ -1,12 +1,39 @@
let data = {};
+let showAllMatches = false;
+let viewingNextSeason = false;
+let initialLoad = true;
+
+function parseDate(str) {
+ const [d, m, y] = str.split('/').map(Number);
+ return new Date(2000 + y, m - 1, d);
+}
+
+function getSeasonYear(dateStr) {
+ const [d, m, y] = dateStr.split('/').map(Number);
+ return m >= 4 ? 2000 + y : 1999 + y;
+}
+
+function currentSeasonYear() {
+ const now = new Date();
+ const m = now.getMonth() + 1;
+ return m >= 4 ? now.getFullYear() : now.getFullYear() - 1;
+}
function fetchData() {
fetch('/api/data').then(r => r.json()).then(d => {
data = d;
+ updateSeasonButton();
renderTable();
});
}
+function updateSeasonButton() {
+ const thisSeason = currentSeasonYear();
+ const hasNextSeason = (data.dates || []).some(d => getSeasonYear(d) === thisSeason + 1);
+ const btn = document.getElementById('next-season-btn');
+ if (btn) btn.style.display = hasNextSeason ? '' : 'none';
+}
+
function saveData() {
fetch('/api/data', {
method: 'POST',
@@ -15,17 +42,12 @@ function saveData() {
});
}
-
-
-let showAllMatches = false; // false = future matches only; true = all matches
-
-let initialLoad = true;
function renderTable() {
const container = document.getElementById('attendance-table');
- // Save scroll position of the container
const scrollTop = window.scrollY;
container.innerHTML = '';
const table = document.createElement('table');
+
// Header row
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
@@ -36,50 +58,44 @@ function renderTable() {
dateTh.style.maxWidth = '140px';
dateTh.style.textAlign = 'left';
headRow.appendChild(dateTh);
- data.players.forEach((name, i) => {
+ data.players.forEach(name => {
const th = document.createElement('th');
th.innerText = name;
th.classList.add('name-col');
headRow.appendChild(th);
});
- // Guest Name column (per date)
const guestNameTh = document.createElement('th');
guestNameTh.innerText = 'Guest Name';
guestNameTh.classList.add('name-col');
headRow.appendChild(guestNameTh);
thead.appendChild(headRow);
table.appendChild(thead);
+
// Body rows
const tbody = document.createElement('tbody');
if (!data.guestNames) data.guestNames = {};
- // Custom order: last played date (<= today) on top, next date (> today) second, others after
+
const today = new Date();
- // Parse dates as DD/MM/YY
- function parseDate(str) {
- const [d, m, y] = str.split('/').map(Number);
- // Assume 20xx for years < 100
- return new Date(2000 + y, m - 1, d);
+ const thisSeason = currentSeasonYear();
+ const targetSeason = viewingNextSeason ? thisSeason + 1 : thisSeason;
+
+ let filteredDates = (data.dates || []).filter(d => getSeasonYear(d) === targetSeason);
+ if (!viewingNextSeason && !showAllMatches) {
+ filteredDates = filteredDates.filter(date => parseDate(date) > today);
}
- // Filter dates based on toggle
- let filteredDates = [];
- if (showAllMatches) {
- filteredDates = data.dates;
- } else {
- filteredDates = data.dates.filter(date => parseDate(date) > today);
- }
- // Find closest date for scroll (from filtered)
+
+ // Find closest date for auto-scroll
let closestIdx = 0;
let minDiff = Infinity;
filteredDates.forEach((date, rowIdx) => {
const diff = Math.abs(parseDate(date) - today);
- if (diff < minDiff) {
- minDiff = diff;
- closestIdx = rowIdx;
- }
+ if (diff < minDiff) { minDiff = diff; closestIdx = rowIdx; }
});
+
filteredDates.forEach((date, rowIdx) => {
const tr = document.createElement('tr');
if (rowIdx === closestIdx) tr.id = 'current-match-row';
+
// Date cell
const dateTd = document.createElement('td');
dateTd.style.width = '120px';
@@ -95,59 +111,38 @@ function renderTable() {
dateTd.innerText = date;
}
tr.appendChild(dateTd);
- // Player attendance
+
+ // Player attendance cells
data.players.forEach((player, colIdx) => {
const td = document.createElement('td');
- td.className = 'clickable name-col';
const key = `${date}|${colIdx}`;
- if (data.attendance[key] === true) {
- td.innerText = 'Yes';
- td.classList.add('yes');
- td.classList.remove('no', 'maybe');
- } else if (data.attendance[key] === 'no') {
- td.innerText = 'No';
- td.classList.add('no');
- td.classList.remove('yes', 'maybe');
- } else if (data.attendance[key] === 'maybe') {
- td.innerText = 'Maybe';
- td.classList.add('maybe');
- td.classList.remove('yes', 'no');
- } else {
- td.innerText = '';
- td.classList.remove('yes', 'no', 'maybe');
- }
- td.onclick = () => {
- if (!data.attendance[key]) {
- data.attendance[key] = true;
- } else if (data.attendance[key] === true) {
- data.attendance[key] = 'no';
- } else if (data.attendance[key] === 'no') {
- data.attendance[key] = 'maybe';
- } else if (data.attendance[key] === 'maybe') {
- delete data.attendance[key];
- }
- // Immediately update the cell UI
- if (data.attendance[key] === true) {
- td.innerText = 'Yes';
- td.classList.add('yes');
- td.classList.remove('no', 'maybe');
- } else if (data.attendance[key] === 'no') {
- td.innerText = 'No';
- td.classList.add('no');
- td.classList.remove('yes', 'maybe');
- } else if (data.attendance[key] === 'maybe') {
- td.innerText = 'Maybe';
- td.classList.add('maybe');
- td.classList.remove('yes', 'no');
+
+ const setCell = val => {
+ if (val === true) {
+ td.innerText = 'Yes'; td.className = 'clickable name-col yes';
+ } else if (val === 'no') {
+ td.innerText = 'No'; td.className = 'clickable name-col no';
+ } else if (val === 'maybe') {
+ td.innerText = 'Maybe'; td.className = 'clickable name-col maybe';
} else {
- td.innerText = '';
- td.classList.remove('yes', 'no', 'maybe');
+ td.innerText = ''; td.className = 'clickable name-col';
}
+ };
+ setCell(data.attendance[key]);
+
+ td.onclick = () => {
+ const cur = data.attendance[key];
+ if (!cur) data.attendance[key] = true;
+ else if (cur === true) data.attendance[key] = 'no';
+ else if (cur === 'no') data.attendance[key] = 'maybe';
+ else delete data.attendance[key];
+ setCell(data.attendance[key]);
saveData();
};
tr.appendChild(td);
});
- // Guest Name column (input per date)
+
+ // Guest Name column
const guestNameTd = document.createElement('td');
guestNameTd.classList.add('name-col');
const guestNameInput = document.createElement('input');
@@ -157,28 +152,24 @@ function renderTable() {
guestNameInput.maxLength = 50;
guestNameInput.onchange = e => {
let value = e.target.value;
- // Allow clearing the guest name (empty string is valid)
- if (value === "") {
+ if (value === '') {
delete data.guestNames[date];
saveData();
return;
}
- // Only allow plain text, disallow HTML/script tags, max 50 chars
if (//.test(value) || /["'`\\]/.test(value)) {
- alert("Guest name cannot contain code or special characters like <, >, \", \\, or backticks.");
+ alert('Guest name cannot contain special characters like <, >, ", \\, or backticks.');
guestNameInput.value = data.guestNames[date] || '';
return;
}
- // Only allow a-z, A-Z, 0-9, spaces, hyphens, periods
if (!/^([a-zA-Z0-9 .-]+)$/.test(value)) {
alert('Guest name can only contain letters, numbers, spaces, hyphens, and periods.');
guestNameInput.value = data.guestNames[date] || '';
return;
}
if (value.length > 50) {
- alert('Guest name cannot be longer than 50 characters.');
- guestNameInput.value = value.slice(0, 50);
value = value.slice(0, 50);
+ guestNameInput.value = value;
}
data.guestNames[date] = value;
saveData();
@@ -187,16 +178,16 @@ function renderTable() {
tr.appendChild(guestNameTd);
tbody.appendChild(tr);
});
+
table.appendChild(tbody);
container.appendChild(table);
- // Scroll to the most current match row after rendering
+
setTimeout(() => {
if (initialLoad) {
const row = document.getElementById('current-match-row');
if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' });
initialLoad = false;
} else {
- // Restore previous scroll position
window.scrollTo({ top: scrollTop });
}
}, 0);
@@ -204,6 +195,7 @@ function renderTable() {
document.addEventListener('DOMContentLoaded', () => {
fetchData();
+
const toggleBtn = document.getElementById('toggle-matches-btn');
if (toggleBtn) {
toggleBtn.textContent = 'Show All Matches';
@@ -213,4 +205,17 @@ document.addEventListener('DOMContentLoaded', () => {
renderTable();
};
}
+
+ const nextSeasonBtn = document.getElementById('next-season-btn');
+ if (nextSeasonBtn) {
+ nextSeasonBtn.onclick = () => {
+ viewingNextSeason = !viewingNextSeason;
+ nextSeasonBtn.textContent = viewingNextSeason ? 'Current Season' : 'Next Season';
+ // Reset "show all" toggle when switching seasons
+ showAllMatches = false;
+ if (toggleBtn) toggleBtn.textContent = 'Show All Matches';
+ initialLoad = true;
+ renderTable();
+ };
+ }
});
diff --git a/templates/admin.html b/templates/admin.html
index 5dea50c..9d183c7 100644
--- a/templates/admin.html
+++ b/templates/admin.html
@@ -154,6 +154,14 @@
Reports
+
+
+
Archive
+
Download all attendance data (all seasons, all players) as a CSV file.
+
+
+
+
Date Management
@@ -440,6 +448,44 @@
renderPlayers();
}
+ async function downloadAllCSV() {
+ const msg = document.getElementById('archive-msg');
+ msg.textContent = '';
+ const res = await fetch('/api/data');
+ const data = await res.json();
+
+ const allDates = data.dates || [];
+ const players = data.players || [];
+ const guest = data.guest || 'Guest';
+ const columns = [...players, guest];
+
+ const header = ['Date', ...columns];
+ const rows = allDates.map(date => {
+ const cells = columns.map((col, idx) => {
+ const key = `${date}|${idx}`;
+ const val = data.attendance[key];
+ if (val === true || val === 'yes') return 'yes';
+ if (val === 'no') return 'no';
+ if (val === 'maybe') return 'maybe';
+ if (idx === players.length) return (data.guestNames || {})[date] || '';
+ return '';
+ });
+ return [date, ...cells];
+ });
+
+ const csv = [header, ...rows].map(r => r.map(c => `"${c}"`).join(',')).join('\n');
+ const blob = new Blob([csv], {type: 'text/csv'});
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'padel-nivelles-all.csv';
+ a.click();
+ URL.revokeObjectURL(url);
+
+ msg.className = 'msg ok';
+ msg.textContent = `Downloaded ${allDates.length} dates.`;
+ }
+
// Allow Enter key on add-player-name
document.getElementById('add-player-name').addEventListener('keydown', e => {
if (e.key === 'Enter') addPlayer();
diff --git a/templates/index.html b/templates/index.html
index 7396034..460030c 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -119,7 +119,7 @@
-