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 @@
Reports - Admin +
diff --git a/templates/reports.html b/templates/reports.html index d20c5df..76ede44 100644 --- a/templates/reports.html +++ b/templates/reports.html @@ -23,24 +23,43 @@ flex: 1 1 400px; 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; } - 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; } - a:hover, button:hover { background: #7c765c; } + .btn { + 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) { .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; } } -
- Back to Attendance +
+ ← Attendance + +
@@ -53,20 +72,43 @@
-