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 <noreply@anthropic.com>
This commit is contained in:
Greg 2026-03-08 13:39:52 +01:00
parent abb3f47a57
commit 1434e906fd
4 changed files with 215 additions and 102 deletions

View File

@ -1,12 +1,39 @@
let data = {}; 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() { function fetchData() {
fetch('/api/data').then(r => r.json()).then(d => { fetch('/api/data').then(r => r.json()).then(d => {
data = d; data = d;
updateSeasonButton();
renderTable(); 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() { function saveData() {
fetch('/api/data', { fetch('/api/data', {
method: 'POST', method: 'POST',
@ -15,17 +42,12 @@ function saveData() {
}); });
} }
let showAllMatches = false; // false = future matches only; true = all matches
let initialLoad = true;
function renderTable() { function renderTable() {
const container = document.getElementById('attendance-table'); const container = document.getElementById('attendance-table');
// Save scroll position of the container
const scrollTop = window.scrollY; const scrollTop = window.scrollY;
container.innerHTML = ''; container.innerHTML = '';
const table = document.createElement('table'); const table = document.createElement('table');
// Header row // Header row
const thead = document.createElement('thead'); const thead = document.createElement('thead');
const headRow = document.createElement('tr'); const headRow = document.createElement('tr');
@ -36,50 +58,44 @@ function renderTable() {
dateTh.style.maxWidth = '140px'; dateTh.style.maxWidth = '140px';
dateTh.style.textAlign = 'left'; dateTh.style.textAlign = 'left';
headRow.appendChild(dateTh); headRow.appendChild(dateTh);
data.players.forEach((name, i) => { data.players.forEach(name => {
const th = document.createElement('th'); const th = document.createElement('th');
th.innerText = name; th.innerText = name;
th.classList.add('name-col'); th.classList.add('name-col');
headRow.appendChild(th); headRow.appendChild(th);
}); });
// Guest Name column (per date)
const guestNameTh = document.createElement('th'); const guestNameTh = document.createElement('th');
guestNameTh.innerText = 'Guest Name'; guestNameTh.innerText = 'Guest Name';
guestNameTh.classList.add('name-col'); guestNameTh.classList.add('name-col');
headRow.appendChild(guestNameTh); headRow.appendChild(guestNameTh);
thead.appendChild(headRow); thead.appendChild(headRow);
table.appendChild(thead); table.appendChild(thead);
// Body rows // Body rows
const tbody = document.createElement('tbody'); const tbody = document.createElement('tbody');
if (!data.guestNames) data.guestNames = {}; if (!data.guestNames) data.guestNames = {};
// Custom order: last played date (<= today) on top, next date (> today) second, others after
const today = new Date(); const today = new Date();
// Parse dates as DD/MM/YY const thisSeason = currentSeasonYear();
function parseDate(str) { const targetSeason = viewingNextSeason ? thisSeason + 1 : thisSeason;
const [d, m, y] = str.split('/').map(Number);
// Assume 20xx for years < 100 let filteredDates = (data.dates || []).filter(d => getSeasonYear(d) === targetSeason);
return new Date(2000 + y, m - 1, d); if (!viewingNextSeason && !showAllMatches) {
filteredDates = filteredDates.filter(date => parseDate(date) > today);
} }
// Filter dates based on toggle
let filteredDates = []; // Find closest date for auto-scroll
if (showAllMatches) {
filteredDates = data.dates;
} else {
filteredDates = data.dates.filter(date => parseDate(date) > today);
}
// Find closest date for scroll (from filtered)
let closestIdx = 0; let closestIdx = 0;
let minDiff = Infinity; let minDiff = Infinity;
filteredDates.forEach((date, rowIdx) => { filteredDates.forEach((date, rowIdx) => {
const diff = Math.abs(parseDate(date) - today); const diff = Math.abs(parseDate(date) - today);
if (diff < minDiff) { if (diff < minDiff) { minDiff = diff; closestIdx = rowIdx; }
minDiff = diff;
closestIdx = rowIdx;
}
}); });
filteredDates.forEach((date, rowIdx) => { filteredDates.forEach((date, rowIdx) => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
if (rowIdx === closestIdx) tr.id = 'current-match-row'; if (rowIdx === closestIdx) tr.id = 'current-match-row';
// Date cell // Date cell
const dateTd = document.createElement('td'); const dateTd = document.createElement('td');
dateTd.style.width = '120px'; dateTd.style.width = '120px';
@ -95,59 +111,38 @@ function renderTable() {
dateTd.innerText = date; dateTd.innerText = date;
} }
tr.appendChild(dateTd); tr.appendChild(dateTd);
// Player attendance
// Player attendance cells
data.players.forEach((player, colIdx) => { data.players.forEach((player, colIdx) => {
const td = document.createElement('td'); const td = document.createElement('td');
td.className = 'clickable name-col';
const key = `${date}|${colIdx}`; const key = `${date}|${colIdx}`;
if (data.attendance[key] === true) {
td.innerText = 'Yes'; const setCell = val => {
td.classList.add('yes'); if (val === true) {
td.classList.remove('no', 'maybe'); td.innerText = 'Yes'; td.className = 'clickable name-col yes';
} else if (data.attendance[key] === 'no') { } else if (val === 'no') {
td.innerText = 'No'; td.innerText = 'No'; td.className = 'clickable name-col no';
td.classList.add('no'); } else if (val === 'maybe') {
td.classList.remove('yes', 'maybe'); td.innerText = 'Maybe'; td.className = 'clickable name-col 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');
} else { } else {
td.innerText = ''; td.innerText = ''; td.className = 'clickable name-col';
td.classList.remove('yes', 'no', 'maybe');
} }
};
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(); saveData();
}; };
tr.appendChild(td); tr.appendChild(td);
}); });
// Guest Name column (input per date)
// Guest Name column
const guestNameTd = document.createElement('td'); const guestNameTd = document.createElement('td');
guestNameTd.classList.add('name-col'); guestNameTd.classList.add('name-col');
const guestNameInput = document.createElement('input'); const guestNameInput = document.createElement('input');
@ -157,28 +152,24 @@ function renderTable() {
guestNameInput.maxLength = 50; guestNameInput.maxLength = 50;
guestNameInput.onchange = e => { guestNameInput.onchange = e => {
let value = e.target.value; let value = e.target.value;
// Allow clearing the guest name (empty string is valid) if (value === '') {
if (value === "") {
delete data.guestNames[date]; delete data.guestNames[date];
saveData(); saveData();
return; return;
} }
// Only allow plain text, disallow HTML/script tags, max 50 chars
if (/</.test(value) || />/.test(value) || /["'`\\]/.test(value)) { if (/</.test(value) || />/.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] || ''; guestNameInput.value = data.guestNames[date] || '';
return; return;
} }
// Only allow a-z, A-Z, 0-9, spaces, hyphens, periods
if (!/^([a-zA-Z0-9 .-]+)$/.test(value)) { if (!/^([a-zA-Z0-9 .-]+)$/.test(value)) {
alert('Guest name can only contain letters, numbers, spaces, hyphens, and periods.'); alert('Guest name can only contain letters, numbers, spaces, hyphens, and periods.');
guestNameInput.value = data.guestNames[date] || ''; guestNameInput.value = data.guestNames[date] || '';
return; return;
} }
if (value.length > 50) { if (value.length > 50) {
alert('Guest name cannot be longer than 50 characters.');
guestNameInput.value = value.slice(0, 50);
value = value.slice(0, 50); value = value.slice(0, 50);
guestNameInput.value = value;
} }
data.guestNames[date] = value; data.guestNames[date] = value;
saveData(); saveData();
@ -187,16 +178,16 @@ function renderTable() {
tr.appendChild(guestNameTd); tr.appendChild(guestNameTd);
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
table.appendChild(tbody); table.appendChild(tbody);
container.appendChild(table); container.appendChild(table);
// Scroll to the most current match row after rendering
setTimeout(() => { setTimeout(() => {
if (initialLoad) { if (initialLoad) {
const row = document.getElementById('current-match-row'); const row = document.getElementById('current-match-row');
if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' }); if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' });
initialLoad = false; initialLoad = false;
} else { } else {
// Restore previous scroll position
window.scrollTo({ top: scrollTop }); window.scrollTo({ top: scrollTop });
} }
}, 0); }, 0);
@ -204,6 +195,7 @@ function renderTable() {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
fetchData(); fetchData();
const toggleBtn = document.getElementById('toggle-matches-btn'); const toggleBtn = document.getElementById('toggle-matches-btn');
if (toggleBtn) { if (toggleBtn) {
toggleBtn.textContent = 'Show All Matches'; toggleBtn.textContent = 'Show All Matches';
@ -213,4 +205,17 @@ document.addEventListener('DOMContentLoaded', () => {
renderTable(); 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();
};
}
}); });

View File

@ -154,6 +154,14 @@
<a href="/reports">Reports</a> <a href="/reports">Reports</a>
</div> </div>
<!-- ARCHIVE -->
<div class="section">
<h2>Archive</h2>
<p style="margin: 0 0 0.8em 0; font-size: 0.95em;">Download all attendance data (all seasons, all players) as a CSV file.</p>
<button class="btn" onclick="downloadAllCSV()">Download CSV</button>
<div class="msg" id="archive-msg"></div>
</div>
<!-- DATE MANAGEMENT --> <!-- DATE MANAGEMENT -->
<div class="section"> <div class="section">
<h2>Date Management</h2> <h2>Date Management</h2>
@ -440,6 +448,44 @@
renderPlayers(); 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 // Allow Enter key on add-player-name
document.getElementById('add-player-name').addEventListener('keydown', e => { document.getElementById('add-player-name').addEventListener('keydown', e => {
if (e.key === 'Enter') addPlayer(); if (e.key === 'Enter') addPlayer();

View File

@ -119,7 +119,7 @@
<div style="display: flex; justify-content: flex-start; gap: 1em; margin-top: 1em;"> <div style="display: flex; justify-content: flex-start; gap: 1em; margin-top: 1em;">
<button id="toggle-matches-btn" style="background: #948979; color: #222831; border: none; padding: 0.7em 1.5em; border-radius: 4px; font-weight: bold; font-size: 1em; transition: background 0.2s; cursor: pointer;">Show All Matches</button> <button id="toggle-matches-btn" style="background: #948979; color: #222831; border: none; padding: 0.7em 1.5em; border-radius: 4px; font-weight: bold; font-size: 1em; transition: background 0.2s; cursor: pointer;">Show All Matches</button>
<a href="/reports" id="reports-btn" style="background: #948979; color: #222831; border: none; padding: 0.7em 1.5em; border-radius: 4px; font-weight: bold; font-size: 1em; transition: background 0.2s; cursor: pointer; text-decoration: none;">Reports</a> <a href="/reports" id="reports-btn" style="background: #948979; color: #222831; border: none; padding: 0.7em 1.5em; border-radius: 4px; font-weight: bold; font-size: 1em; transition: background 0.2s; cursor: pointer; text-decoration: none;">Reports</a>
<a href="/admin" style="background: #393E46; color: #DFD0B8; border: 1px solid #948979; padding: 0.7em 1.5em; border-radius: 18px; font-weight: bold; font-size: 1em; transition: background 0.2s; cursor: pointer; text-decoration: none;">Admin</a> <button id="next-season-btn" style="display:none; background: #393E46; color: #DFD0B8; border: 1px solid #948979; padding: 0.7em 1.5em; border-radius: 18px; font-weight: bold; font-size: 1em; transition: background 0.2s; cursor: pointer;">Next Season</button>
</div> </div>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>

View File

@ -23,24 +23,43 @@
flex: 1 1 400px; flex: 1 1 400px;
box-sizing: border-box; box-sizing: border-box;
} }
.chart-container.large-chart canvas {
display: block;
margin: 0 auto;
/* Remove forced width: 100% to preserve aspect ratio */
}
h2 { color: #948979; font-size: 1.1em; margin-bottom: 0.5em; } h2 { color: #948979; font-size: 1.1em; margin-bottom: 0.5em; }
a, button { color: #222831; background: #948979; border: none; padding: 0.7em 1.5em; border-radius: 18px; font-weight: bold; font-size: 1em; transition: background 0.2s; cursor: pointer; text-decoration: none; } .btn {
a:hover, button:hover { background: #7c765c; } color: #222831;
background: #948979;
border: none;
padding: 0.7em 1.5em;
border-radius: 18px;
font-weight: bold;
font-size: 1em;
transition: background 0.2s;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.btn:hover { background: #7c765c; }
.btn-secondary {
background: #393E46;
color: #DFD0B8;
border: 1px solid #948979;
}
.btn-secondary:hover { background: #948979; color: #222831; }
#season-label { color: #948979; font-size: 0.95em; }
@media (max-width: 900px) { @media (max-width: 900px) {
.charts-flex { flex-direction: column; align-items: stretch; } .charts-flex { flex-direction: column; align-items: stretch; }
.chart-container.small-chart { max-width: 100%; min-width: 0; } .chart-container.large-chart { max-width: 100%; min-width: 0; }
}
@media (max-width: 600px) {
body { margin: 0.7em; }
} }
</style> </style>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head> </head>
<body> <body>
<div style="display: flex; gap: 1em; align-items: center; margin-bottom: 2em;"> <div style="display: flex; gap: 1em; align-items: center; margin-bottom: 1.5em; flex-wrap: wrap;">
<a href="/" style="display: inline-block;">Back to Attendance</a> <a href="/" class="btn">&#8592; Attendance</a>
<button id="last-season-btn" class="btn btn-secondary" style="display:none;"></button>
<span id="season-label"></span>
</div> </div>
<div class="charts-flex"> <div class="charts-flex">
<div class="chart-container large-chart"> <div class="chart-container large-chart">
@ -53,20 +72,43 @@
</div> </div>
</div> </div>
<script> <script>
fetch('/api/data').then(r => r.json()).then(data => { 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 seasonLabel(y) { return `Season ${y}/${y + 1}`; }
let barChart, pieChart;
function renderCharts(data, targetYear) {
const seasonDates = new Set((data.dates || []).filter(d => getSeasonYear(d) === targetYear));
const players = data.players; const players = data.players;
const attendance = data.attendance; const attendance = data.attendance;
// Calculate Yes counts per player
const yesCounts = Array(players.length).fill(0); const yesCounts = Array(players.length).fill(0);
Object.entries(attendance).forEach(([key, value]) => { Object.entries(attendance).forEach(([key, value]) => {
if (value === true) { if (value === true) {
const [date, colIdx] = key.split('|'); const [date, colIdx] = key.split('|');
const idx = parseInt(colIdx); if (seasonDates.has(date)) {
if (idx < players.length) yesCounts[idx]++; const idx = parseInt(colIdx);
if (idx < players.length) yesCounts[idx]++;
}
} }
}); });
// Bar chart
new Chart(document.getElementById('barChart').getContext('2d'), { document.getElementById('season-label').textContent = seasonLabel(targetYear);
if (barChart) barChart.destroy();
if (pieChart) pieChart.destroy();
barChart = new Chart(document.getElementById('barChart').getContext('2d'), {
type: 'bar', type: 'bar',
data: { data: {
labels: players, labels: players,
@ -86,17 +128,15 @@
} }
} }
}); });
// Pie chart
const totalYes = yesCounts.reduce((a, b) => a + b, 0); const totalYes = yesCounts.reduce((a, b) => a + b, 0);
new Chart(document.getElementById('pieChart').getContext('2d'), { pieChart = new Chart(document.getElementById('pieChart').getContext('2d'), {
type: 'pie', type: 'pie',
data: { data: {
labels: players, labels: players,
datasets: [{ datasets: [{
data: yesCounts, data: yesCounts,
backgroundColor: [ backgroundColor: ['#948979', '#DFD0B8', '#7c765c', '#393E46', '#222831', '#bfa181', '#b7b7a4', '#a7c957']
'#948979', '#DFD0B8', '#7c765c', '#393E46', '#222831', '#bfa181', '#b7b7a4', '#a7c957'
]
}] }]
}, },
options: { options: {
@ -115,8 +155,30 @@
} }
} }
}); });
}
fetch('/api/data').then(r => r.json()).then(data => {
const thisSeason = currentSeasonYear();
const seasons = [...new Set((data.dates || []).map(getSeasonYear))].sort();
const hasLastSeason = seasons.includes(thisSeason - 1);
let viewingLastSeason = false;
renderCharts(data, thisSeason);
const lastSeasonBtn = document.getElementById('last-season-btn');
if (hasLastSeason) {
lastSeasonBtn.style.display = '';
lastSeasonBtn.textContent = `Last Season (${thisSeason - 1}/${thisSeason})`;
lastSeasonBtn.onclick = () => {
viewingLastSeason = !viewingLastSeason;
const target = viewingLastSeason ? thisSeason - 1 : thisSeason;
renderCharts(data, target);
lastSeasonBtn.textContent = viewingLastSeason
? `Current Season (${thisSeason}/${thisSeason + 1})`
: `Last Season (${thisSeason - 1}/${thisSeason})`;
};
}
}); });
</script> </script>
</body> </body>
</html> </html>