Compare commits
No commits in common. "main" and "feature/security" have entirely different histories.
main
...
feature/se
55
app.py
55
app.py
@ -22,8 +22,7 @@ csp = {
|
|||||||
'script-src': [
|
'script-src': [
|
||||||
"'self'",
|
"'self'",
|
||||||
'https://cdn.jsdelivr.net/npm/chart.js',
|
'https://cdn.jsdelivr.net/npm/chart.js',
|
||||||
"'unsafe-inline'",
|
"'unsafe-inline'"
|
||||||
'https://umami-ikow84gco0wcw8cgsc8o08g8.reflectonai.com'
|
|
||||||
],
|
],
|
||||||
'style-src': [
|
'style-src': [
|
||||||
"'self'",
|
"'self'",
|
||||||
@ -32,10 +31,6 @@ csp = {
|
|||||||
'img-src': [
|
'img-src': [
|
||||||
"'self'",
|
"'self'",
|
||||||
'data:'
|
'data:'
|
||||||
],
|
|
||||||
'connect-src': [
|
|
||||||
"'self'",
|
|
||||||
'https://umami-ikow84gco0wcw8cgsc8o08g8.reflectonai.com'
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Talisman(app, content_security_policy=csp)
|
Talisman(app, content_security_policy=csp)
|
||||||
@ -50,7 +45,6 @@ db = SQLAlchemy(app)
|
|||||||
class Player(db.Model):
|
class Player(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
name = db.Column(db.String(50), nullable=False, unique=True)
|
name = db.Column(db.String(50), nullable=False, unique=True)
|
||||||
active = db.Column(db.Boolean, nullable=False, default=True)
|
|
||||||
|
|
||||||
class Date(db.Model):
|
class Date(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
@ -76,12 +70,6 @@ class GuestName(db.Model):
|
|||||||
def get_initial_data():
|
def get_initial_data():
|
||||||
# Ensure tables exist
|
# Ensure tables exist
|
||||||
db.create_all()
|
db.create_all()
|
||||||
# Safe migration: add 'active' column if it doesn't exist yet
|
|
||||||
with db.engine.connect() as conn:
|
|
||||||
conn.execute(db.text(
|
|
||||||
"ALTER TABLE player ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT TRUE"
|
|
||||||
))
|
|
||||||
conn.commit()
|
|
||||||
# If no players, insert defaults
|
# If no players, insert defaults
|
||||||
if Player.query.count() == 0:
|
if Player.query.count() == 0:
|
||||||
for name in ["Alice", "Bob", "Charlie", "David", "Eve", "Frank", "Grace", "Hannah"]:
|
for name in ["Alice", "Bob", "Charlie", "David", "Eve", "Frank", "Grace", "Hannah"]:
|
||||||
@ -95,7 +83,7 @@ def parse_date(date_str):
|
|||||||
return datetime.strptime(date_str, '%d/%m/%y')
|
return datetime.strptime(date_str, '%d/%m/%y')
|
||||||
|
|
||||||
def db_to_json():
|
def db_to_json():
|
||||||
players = [p.name for p in Player.query.filter_by(active=True).order_by(Player.name)]
|
players = [p.name for p in Player.query.order_by(Player.id)]
|
||||||
guest = "Guest"
|
guest = "Guest"
|
||||||
dates = sorted([d.date_str for d in Date.query.all()], key=parse_date)
|
dates = sorted([d.date_str for d in Date.query.all()], key=parse_date)
|
||||||
attendance = {}
|
attendance = {}
|
||||||
@ -122,10 +110,12 @@ def db_to_json():
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
def validate_player_name(name):
|
def validate_player_name(name):
|
||||||
return bool(re.fullmatch(r'[a-zA-Z0-9\u00C0-\u024F .-]{1,50}', name))
|
# Only allow a-z, A-Z, 0-9, spaces, hyphens, and periods, max 50 chars
|
||||||
|
return bool(re.fullmatch(r'[a-zA-Z0-9 .-]{1,50}', name))
|
||||||
|
|
||||||
def validate_guest_name(name):
|
def validate_guest_name(name):
|
||||||
return bool(re.fullmatch(r'[a-zA-Z0-9\u00C0-\u024F .-]{1,50}', name))
|
# Only allow a-z, A-Z, 0-9, spaces, hyphens, and periods, max 50 chars
|
||||||
|
return bool(re.fullmatch(r'[a-zA-Z0-9 .-]{1,50}', name))
|
||||||
|
|
||||||
def validate_date_str(date_str):
|
def validate_date_str(date_str):
|
||||||
# Format: DD/MM/YY
|
# Format: DD/MM/YY
|
||||||
@ -236,39 +226,6 @@ def export_data():
|
|||||||
headers={'Content-Disposition': 'attachment;filename=attendance_data.json'}
|
headers={'Content-Disposition': 'attachment;filename=attendance_data.json'}
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route('/admin')
|
|
||||||
def admin():
|
|
||||||
return render_template('admin.html')
|
|
||||||
|
|
||||||
@app.route('/api/players', methods=['GET'])
|
|
||||||
def get_players():
|
|
||||||
players = Player.query.order_by(Player.id).all()
|
|
||||||
return jsonify([{'id': p.id, 'name': p.name, 'active': p.active} for p in players])
|
|
||||||
|
|
||||||
@app.route('/api/players', methods=['POST'])
|
|
||||||
@csrf.exempt
|
|
||||||
def add_player():
|
|
||||||
data = request.json
|
|
||||||
name = (data.get('name') or '').strip()
|
|
||||||
if not re.fullmatch(r'[a-zA-Z0-9\u00C0-\u024F .\-]{1,50}', name):
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid name'}), 400
|
|
||||||
if Player.query.filter_by(name=name).first():
|
|
||||||
return jsonify({'status': 'error', 'message': 'Player already exists'}), 400
|
|
||||||
db.session.add(Player(name=name, active=True))
|
|
||||||
db.session.commit()
|
|
||||||
return jsonify({'status': 'success'})
|
|
||||||
|
|
||||||
@app.route('/api/players/<int:player_id>', methods=['PATCH'])
|
|
||||||
@csrf.exempt
|
|
||||||
def update_player(player_id):
|
|
||||||
player = Player.query.get_or_404(player_id)
|
|
||||||
data = request.json
|
|
||||||
if 'active' not in data:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Missing active field'}), 400
|
|
||||||
player.active = bool(data['active'])
|
|
||||||
db.session.commit()
|
|
||||||
return jsonify({'status': 'success'})
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
import os
|
import os
|
||||||
port = int(os.environ.get('PORT', 5000))
|
port = int(os.environ.get('PORT', 5000))
|
||||||
|
|||||||
183
static/app.js
183
static/app.js
@ -1,55 +1,12 @@
|
|||||||
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;
|
||||||
updateSeasonUI();
|
|
||||||
renderTable();
|
renderTable();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSeasonUI() {
|
|
||||||
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';
|
|
||||||
btn.classList.toggle('active', viewingNextSeason);
|
|
||||||
btn.textContent = viewingNextSeason ? 'Current Season' : 'Next Season';
|
|
||||||
}
|
|
||||||
|
|
||||||
const banner = document.getElementById('season-banner');
|
|
||||||
if (banner) {
|
|
||||||
if (viewingNextSeason) {
|
|
||||||
banner.className = 'next';
|
|
||||||
banner.textContent = `\u26a1 Next Season \u2014 ${thisSeason + 1}/${thisSeason + 2}`;
|
|
||||||
} else {
|
|
||||||
banner.className = 'current';
|
|
||||||
banner.textContent = `Season ${thisSeason}/${thisSeason + 1}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveData() {
|
function saveData() {
|
||||||
fetch('/api/data', {
|
fetch('/api/data', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -58,12 +15,17 @@ 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');
|
||||||
@ -74,44 +36,50 @@ 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 => {
|
data.players.forEach((name, i) => {
|
||||||
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();
|
||||||
const thisSeason = currentSeasonYear();
|
// Parse dates as DD/MM/YY
|
||||||
const targetSeason = viewingNextSeason ? thisSeason + 1 : thisSeason;
|
function parseDate(str) {
|
||||||
|
const [d, m, y] = str.split('/').map(Number);
|
||||||
let filteredDates = (data.dates || []).filter(d => getSeasonYear(d) === targetSeason);
|
// Assume 20xx for years < 100
|
||||||
if (!viewingNextSeason && !showAllMatches) {
|
return new Date(2000 + y, m - 1, d);
|
||||||
filteredDates = filteredDates.filter(date => parseDate(date) > today);
|
|
||||||
}
|
}
|
||||||
|
// Filter dates based on toggle
|
||||||
// Find closest date for auto-scroll
|
let filteredDates = [];
|
||||||
|
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) { minDiff = diff; closestIdx = rowIdx; }
|
if (diff < minDiff) {
|
||||||
|
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';
|
||||||
@ -127,38 +95,59 @@ 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) {
|
||||||
const setCell = val => {
|
td.innerText = 'Yes';
|
||||||
if (val === true) {
|
td.classList.add('yes');
|
||||||
td.innerText = 'Yes'; td.className = 'clickable name-col yes';
|
td.classList.remove('no', 'maybe');
|
||||||
} else if (val === 'no') {
|
} else if (data.attendance[key] === 'no') {
|
||||||
td.innerText = 'No'; td.className = 'clickable name-col no';
|
td.innerText = 'No';
|
||||||
} else if (val === 'maybe') {
|
td.classList.add('no');
|
||||||
td.innerText = 'Maybe'; td.className = 'clickable name-col maybe';
|
td.classList.remove('yes', 'maybe');
|
||||||
} else {
|
} else if (data.attendance[key] === 'maybe') {
|
||||||
td.innerText = ''; td.className = 'clickable name-col';
|
td.innerText = 'Maybe';
|
||||||
}
|
td.classList.add('maybe');
|
||||||
};
|
td.classList.remove('yes', 'no');
|
||||||
setCell(data.attendance[key]);
|
} else {
|
||||||
|
td.innerText = '';
|
||||||
|
td.classList.remove('yes', 'no', 'maybe');
|
||||||
|
}
|
||||||
td.onclick = () => {
|
td.onclick = () => {
|
||||||
const cur = data.attendance[key];
|
if (!data.attendance[key]) {
|
||||||
if (!cur) data.attendance[key] = true;
|
data.attendance[key] = true;
|
||||||
else if (cur === true) data.attendance[key] = 'no';
|
} else if (data.attendance[key] === true) {
|
||||||
else if (cur === 'no') data.attendance[key] = 'maybe';
|
data.attendance[key] = 'no';
|
||||||
else delete data.attendance[key];
|
} else if (data.attendance[key] === 'no') {
|
||||||
setCell(data.attendance[key]);
|
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 {
|
||||||
|
td.innerText = '';
|
||||||
|
td.classList.remove('yes', 'no', 'maybe');
|
||||||
|
}
|
||||||
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');
|
||||||
@ -168,24 +157,28 @@ function renderTable() {
|
|||||||
guestNameInput.maxLength = 50;
|
guestNameInput.maxLength = 50;
|
||||||
guestNameInput.onchange = e => {
|
guestNameInput.onchange = e => {
|
||||||
let value = e.target.value;
|
let value = e.target.value;
|
||||||
if (value === '') {
|
// Allow clearing the guest name (empty string is valid)
|
||||||
|
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 special characters like <, >, ", \\, or backticks.');
|
alert("Guest name cannot contain code or special characters like <, >, \", \\, or backticks.");
|
||||||
guestNameInput.value = data.guestNames[date] || '';
|
guestNameInput.value = data.guestNames[date] || '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!/^[a-zA-Z0-9\u00C0-\u024F .-]+$/.test(value)) {
|
// 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.');
|
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();
|
||||||
@ -194,16 +187,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);
|
||||||
@ -211,7 +204,6 @@ 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';
|
||||||
@ -221,17 +213,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
renderTable();
|
renderTable();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSeasonBtn = document.getElementById('next-season-btn');
|
|
||||||
if (nextSeasonBtn) {
|
|
||||||
nextSeasonBtn.onclick = () => {
|
|
||||||
viewingNextSeason = !viewingNextSeason;
|
|
||||||
// Reset "show all" toggle when switching seasons
|
|
||||||
showAllMatches = false;
|
|
||||||
if (toggleBtn) toggleBtn.textContent = 'Show All Matches';
|
|
||||||
initialLoad = true;
|
|
||||||
updateSeasonUI();
|
|
||||||
renderTable();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,471 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
|
||||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Admin — Padel Nivelles</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
margin: 2em;
|
|
||||||
background: #222831;
|
|
||||||
color: #DFD0B8;
|
|
||||||
}
|
|
||||||
h1 { margin: 0 0 0.2em 0; }
|
|
||||||
h2 { color: #948979; margin: 0 0 0.7em 0; font-size: 1.1em; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
||||||
.nav { display: flex; gap: 1em; margin-bottom: 1.5em; }
|
|
||||||
.nav a {
|
|
||||||
background: #393E46;
|
|
||||||
color: #DFD0B8;
|
|
||||||
border: 1px solid #948979;
|
|
||||||
padding: 0.5em 1.2em;
|
|
||||||
border-radius: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 0.95em;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
.nav a:hover { background: #948979; color: #222831; }
|
|
||||||
.section {
|
|
||||||
background: #393E46;
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 1.5em;
|
|
||||||
margin-bottom: 1.5em;
|
|
||||||
}
|
|
||||||
.warning {
|
|
||||||
background: #4a3a00;
|
|
||||||
border: 1px solid #948979;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 0.7em 1em;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
font-size: 0.95em;
|
|
||||||
}
|
|
||||||
label { display: block; margin-bottom: 0.3em; font-size: 0.9em; color: #948979; }
|
|
||||||
input[type="text"], input[type="number"], select {
|
|
||||||
background: #222831;
|
|
||||||
color: #DFD0B8;
|
|
||||||
border: 1px solid #948979;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 0.4em 0.8em;
|
|
||||||
font-size: 1em;
|
|
||||||
margin-bottom: 0.8em;
|
|
||||||
}
|
|
||||||
input[type="text"]:focus, input[type="number"]:focus, select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #DFD0B8;
|
|
||||||
}
|
|
||||||
.form-row { display: flex; gap: 1em; flex-wrap: wrap; align-items: flex-end; }
|
|
||||||
.form-group { display: flex; flex-direction: column; }
|
|
||||||
.btn {
|
|
||||||
background: #948979;
|
|
||||||
color: #222831;
|
|
||||||
border: none;
|
|
||||||
padding: 0.5em 1.2em;
|
|
||||||
border-radius: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 0.95em;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
margin-bottom: 0.8em;
|
|
||||||
}
|
|
||||||
.btn:hover { background: #7c765c; }
|
|
||||||
.btn-secondary {
|
|
||||||
background: #393E46;
|
|
||||||
color: #DFD0B8;
|
|
||||||
border: 1px solid #948979;
|
|
||||||
}
|
|
||||||
.btn-secondary:hover { background: #948979; color: #222831; }
|
|
||||||
.btn-danger {
|
|
||||||
background: transparent;
|
|
||||||
color: #948979;
|
|
||||||
border: 1px solid #948979;
|
|
||||||
padding: 0.3em 0.8em;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.btn-danger:hover { background: #948979; color: #222831; }
|
|
||||||
.btn-success {
|
|
||||||
background: transparent;
|
|
||||||
color: #6aab69;
|
|
||||||
border: 1px solid #6aab69;
|
|
||||||
padding: 0.3em 0.8em;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.btn-success:hover { background: #6aab69; color: #222831; }
|
|
||||||
#preview-box {
|
|
||||||
background: #222831;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 1em;
|
|
||||||
margin: 0.8em 0;
|
|
||||||
font-size: 0.9em;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
#preview-dates {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.4em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
.date-chip {
|
|
||||||
background: #393E46;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.2em 0.5em;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
.date-chip.new { border: 1px solid #6aab69; }
|
|
||||||
.date-chip.dup { border: 1px solid #555; color: #888; }
|
|
||||||
#preview-summary { color: #948979; margin-bottom: 0.5em; }
|
|
||||||
.player-list { list-style: none; padding: 0; margin: 0.5em 0 1em 0; }
|
|
||||||
.player-list li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.4em 0;
|
|
||||||
border-bottom: 1px solid #22283133;
|
|
||||||
}
|
|
||||||
.player-list li:last-child { border-bottom: none; }
|
|
||||||
.player-name { font-size: 0.95em; }
|
|
||||||
.add-player-row { display: flex; gap: 0.7em; align-items: center; margin-top: 0.5em; flex-wrap: wrap; }
|
|
||||||
#add-player-name { margin-bottom: 0; }
|
|
||||||
.msg { margin-top: 0.5em; font-size: 0.9em; min-height: 1.2em; }
|
|
||||||
.msg.ok { color: #6aab69; }
|
|
||||||
.msg.err { color: #e07070; }
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
body { margin: 0.7em; }
|
|
||||||
.form-row { flex-direction: column; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div style="margin-bottom: 1em;">
|
|
||||||
<h1>Padel Nivelles — Admin</h1>
|
|
||||||
</div>
|
|
||||||
<div class="nav">
|
|
||||||
<a href="/">← Attendance</a>
|
|
||||||
<a href="/reports">Reports</a>
|
|
||||||
</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 -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>Date Management</h2>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="start-date">Start date (DD/MM/YY)</label>
|
|
||||||
<input type="text" id="start-date" placeholder="02/04/26" maxlength="8" style="width: 110px;">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="weeks">Weeks</label>
|
|
||||||
<input type="number" id="weeks" value="52" min="1" max="104" style="width: 70px;">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="day-of-week">Day</label>
|
|
||||||
<select id="day-of-week">
|
|
||||||
<option value="1">Monday</option>
|
|
||||||
<option value="2">Tuesday</option>
|
|
||||||
<option value="3">Wednesday</option>
|
|
||||||
<option value="4" selected>Thursday</option>
|
|
||||||
<option value="5">Friday</option>
|
|
||||||
<option value="6">Saturday</option>
|
|
||||||
<option value="0">Sunday</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="justify-content: flex-end;">
|
|
||||||
<button class="btn" onclick="createSeason()">Create Season</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="msg" id="dates-msg"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- PLAYER MANAGEMENT -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>Player Management</h2>
|
|
||||||
<div class="warning">
|
|
||||||
⚠ Player changes should only be made at season start (April). Mid-season changes may cause inconsistencies in historical data display.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<strong>Active Players</strong>
|
|
||||||
<ul class="player-list" id="active-players"></ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top: 1em;">
|
|
||||||
<strong>Add New Player</strong>
|
|
||||||
<div class="add-player-row">
|
|
||||||
<input type="text" id="add-player-name" placeholder="Player name" maxlength="50" style="width: 180px;">
|
|
||||||
<button class="btn" onclick="addPlayer()">Add Player</button>
|
|
||||||
</div>
|
|
||||||
<div class="msg" id="add-player-msg"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="inactive-section" style="margin-top: 1.2em; display:none;">
|
|
||||||
<strong>Inactive Players</strong>
|
|
||||||
<ul class="player-list" id="inactive-players"></ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let existingDates = [];
|
|
||||||
let allPlayers = [];
|
|
||||||
|
|
||||||
// Block XSS chars in text inputs
|
|
||||||
document.querySelectorAll('input[type="text"]').forEach(inp => {
|
|
||||||
inp.addEventListener('input', () => {
|
|
||||||
inp.value = inp.value.replace(/[<>"'`]/g, '');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
const [dataRes, playersRes] = await Promise.all([
|
|
||||||
fetch('/api/data'),
|
|
||||||
fetch('/api/players')
|
|
||||||
]);
|
|
||||||
const data = await dataRes.json();
|
|
||||||
existingDates = data.dates || [];
|
|
||||||
allPlayers = await playersRes.json();
|
|
||||||
renderPlayers();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPlayers() {
|
|
||||||
const active = allPlayers.filter(p => p.active);
|
|
||||||
const inactive = allPlayers.filter(p => !p.active);
|
|
||||||
|
|
||||||
const activeList = document.getElementById('active-players');
|
|
||||||
activeList.innerHTML = active.map(p => `
|
|
||||||
<li>
|
|
||||||
<span class="player-name">${escHtml(p.name)}</span>
|
|
||||||
<button class="btn-danger" onclick="togglePlayer(${p.id}, false)">Deactivate</button>
|
|
||||||
</li>
|
|
||||||
`).join('') || '<li style="color:#888;font-size:0.9em;">No active players</li>';
|
|
||||||
|
|
||||||
const inactiveSection = document.getElementById('inactive-section');
|
|
||||||
const inactiveList = document.getElementById('inactive-players');
|
|
||||||
if (inactive.length > 0) {
|
|
||||||
inactiveSection.style.display = '';
|
|
||||||
inactiveList.innerHTML = inactive.map(p => `
|
|
||||||
<li>
|
|
||||||
<span class="player-name" style="color:#888;">${escHtml(p.name)}</span>
|
|
||||||
<button class="btn-success" onclick="togglePlayer(${p.id}, true)">Reactivate</button>
|
|
||||||
</li>
|
|
||||||
`).join('');
|
|
||||||
} else {
|
|
||||||
inactiveSection.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(str) {
|
|
||||||
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDate(str) {
|
|
||||||
// DD/MM/YY
|
|
||||||
const parts = str.split('/');
|
|
||||||
if (parts.length !== 3) return null;
|
|
||||||
const year = parseInt(parts[2]) + 2000;
|
|
||||||
return new Date(year, parseInt(parts[1]) - 1, parseInt(parts[0]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(d) {
|
|
||||||
const dd = String(d.getDate()).padStart(2, '0');
|
|
||||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
||||||
const yy = String(d.getFullYear()).slice(-2);
|
|
||||||
return `${dd}/${mm}/${yy}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateDates(startStr, weeks, dayOfWeek) {
|
|
||||||
const start = parseDate(startStr);
|
|
||||||
if (!start || isNaN(start)) return null;
|
|
||||||
// Advance to the first occurrence of dayOfWeek on or after start
|
|
||||||
let current = new Date(start);
|
|
||||||
while (current.getDay() !== dayOfWeek) {
|
|
||||||
current.setDate(current.getDate() + 1);
|
|
||||||
}
|
|
||||||
const dates = [];
|
|
||||||
for (let i = 0; i < weeks; i++) {
|
|
||||||
dates.push(formatDate(new Date(current)));
|
|
||||||
current.setDate(current.getDate() + 7);
|
|
||||||
}
|
|
||||||
return dates;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSeason() {
|
|
||||||
const startStr = document.getElementById('start-date').value.trim();
|
|
||||||
const weeks = parseInt(document.getElementById('weeks').value);
|
|
||||||
const dayOfWeek = parseInt(document.getElementById('day-of-week').value);
|
|
||||||
const datesMsg = document.getElementById('dates-msg');
|
|
||||||
datesMsg.textContent = '';
|
|
||||||
datesMsg.className = 'msg';
|
|
||||||
|
|
||||||
if (!/^\d{2}\/\d{2}\/\d{2}$/.test(startStr)) {
|
|
||||||
datesMsg.className = 'msg err';
|
|
||||||
datesMsg.textContent = 'Invalid start date. Use DD/MM/YY format (e.g. 02/04/26).';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!weeks || weeks < 1 || weeks > 104) {
|
|
||||||
datesMsg.className = 'msg err';
|
|
||||||
datesMsg.textContent = 'Weeks must be between 1 and 104.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const generated = generateDates(startStr, weeks, dayOfWeek);
|
|
||||||
if (!generated) {
|
|
||||||
datesMsg.className = 'msg err';
|
|
||||||
datesMsg.textContent = 'Could not parse start date.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch current data and skip existing dates
|
|
||||||
const res = await fetch('/api/data');
|
|
||||||
const data = await res.json();
|
|
||||||
const existingSet = new Set(data.dates || []);
|
|
||||||
const newDates = generated.filter(d => !existingSet.has(d));
|
|
||||||
const skipped = generated.length - newDates.length;
|
|
||||||
|
|
||||||
if (newDates.length === 0) {
|
|
||||||
datesMsg.className = 'msg ok';
|
|
||||||
datesMsg.textContent = `All ${generated.length} dates already exist in the database — nothing to add.`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedDates = [...(data.dates || []), ...newDates];
|
|
||||||
const payload = {
|
|
||||||
players: data.players || [],
|
|
||||||
guest: data.guest || 'Guest',
|
|
||||||
dates: mergedDates,
|
|
||||||
attendance: data.attendance || {},
|
|
||||||
guestNames: data.guestNames || {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const postRes = await fetch('/api/data', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
const result = await postRes.json();
|
|
||||||
|
|
||||||
if (result.status === 'success') {
|
|
||||||
existingDates = mergedDates;
|
|
||||||
datesMsg.className = 'msg ok';
|
|
||||||
datesMsg.textContent = `Done — ${newDates.length} date${newDates.length !== 1 ? 's' : ''} added${skipped ? `, ${skipped} already existed (skipped)` : ''}.`;
|
|
||||||
} else {
|
|
||||||
datesMsg.className = 'msg err';
|
|
||||||
datesMsg.textContent = result.message || 'Error saving dates.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addPlayer() {
|
|
||||||
const nameInput = document.getElementById('add-player-name');
|
|
||||||
const msg = document.getElementById('add-player-msg');
|
|
||||||
const name = nameInput.value.trim();
|
|
||||||
msg.textContent = '';
|
|
||||||
|
|
||||||
if (!/^[a-zA-Z0-9\u00C0-\u024F .\-]{1,50}$/.test(name)) {
|
|
||||||
msg.className = 'msg err';
|
|
||||||
msg.textContent = 'Invalid name. Use letters, numbers, spaces, hyphens, or periods (max 50 chars).';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch('/api/players', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({name})
|
|
||||||
});
|
|
||||||
const result = await res.json();
|
|
||||||
|
|
||||||
if (result.status === 'success') {
|
|
||||||
msg.className = 'msg ok';
|
|
||||||
msg.textContent = `Player "${name}" added.`;
|
|
||||||
nameInput.value = '';
|
|
||||||
await refreshPlayers();
|
|
||||||
} else {
|
|
||||||
msg.className = 'msg err';
|
|
||||||
msg.textContent = result.message || 'Error adding player.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function togglePlayer(id, active) {
|
|
||||||
const res = await fetch(`/api/players/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({active})
|
|
||||||
});
|
|
||||||
const result = await res.json();
|
|
||||||
if (result.status === 'success') {
|
|
||||||
await refreshPlayers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshPlayers() {
|
|
||||||
const res = await fetch('/api/players');
|
|
||||||
allPlayers = await res.json();
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Allow Enter key on start-date and weeks
|
|
||||||
document.getElementById('start-date').addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Enter') previewDates();
|
|
||||||
});
|
|
||||||
|
|
||||||
init();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -92,25 +92,6 @@
|
|||||||
background: #948979;
|
background: #948979;
|
||||||
color: #222831;
|
color: #222831;
|
||||||
}
|
}
|
||||||
#season-banner {
|
|
||||||
margin-bottom: 0.7em;
|
|
||||||
font-size: 0.95em;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
#season-banner.current { color: #948979; }
|
|
||||||
#season-banner.next {
|
|
||||||
display: inline-block;
|
|
||||||
background: #1e2e1e;
|
|
||||||
border: 1px solid #6aab69;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 0.4em 1em;
|
|
||||||
color: #6aab69;
|
|
||||||
}
|
|
||||||
#next-season-btn.active {
|
|
||||||
background: #6aab69;
|
|
||||||
color: #222831;
|
|
||||||
border-color: #6aab69;
|
|
||||||
}
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
body { margin: 1em; }
|
body { margin: 1em; }
|
||||||
.name-col { width: 110px; min-width: 80px; max-width: 140px; font-size: 0.95em; }
|
.name-col { width: 110px; min-width: 80px; max-width: 140px; font-size: 0.95em; }
|
||||||
@ -125,13 +106,11 @@
|
|||||||
table { min-width: 400px; }
|
table { min-width: 400px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script async defer src="https://umami-ikow84gco0wcw8cgsc8o08g8.reflectonai.com/script.js" data-website-id="1e85082c-2652-4d6c-a0b2-fa55082982a7"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div style="text-align: left; margin-bottom: 0.5em;">
|
<div style="text-align: left; margin-bottom: 1em;">
|
||||||
<h1 style="margin: 0;">Padel Nivelles</h1>
|
<h1 style="margin: 0;">Padel Nivelles</h1>
|
||||||
</div>
|
</div>
|
||||||
<div id="season-banner" class="current"></div>
|
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<div id="attendance-table"></div>
|
<div id="attendance-table"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -139,7 +118,6 @@
|
|||||||
<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>
|
||||||
<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>
|
||||||
|
|||||||
@ -23,43 +23,24 @@
|
|||||||
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; }
|
||||||
.btn {
|
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; }
|
||||||
color: #222831;
|
a:hover, button:hover { background: #7c765c; }
|
||||||
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.large-chart { max-width: 100%; min-width: 0; }
|
.chart-container.small-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: 1.5em; flex-wrap: wrap;">
|
<div style="display: flex; gap: 1em; align-items: center; margin-bottom: 2em;">
|
||||||
<a href="/" class="btn">← Attendance</a>
|
<a href="/" style="display: inline-block;">Back to 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">
|
||||||
@ -72,43 +53,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
function getSeasonYear(dateStr) {
|
fetch('/api/data').then(r => r.json()).then(data => {
|
||||||
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('|');
|
||||||
if (seasonDates.has(date)) {
|
const idx = parseInt(colIdx);
|
||||||
const idx = parseInt(colIdx);
|
if (idx < players.length) yesCounts[idx]++;
|
||||||
if (idx < players.length) yesCounts[idx]++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Bar chart
|
||||||
document.getElementById('season-label').textContent = seasonLabel(targetYear);
|
new Chart(document.getElementById('barChart').getContext('2d'), {
|
||||||
|
|
||||||
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,
|
||||||
@ -128,15 +86,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Pie chart
|
||||||
const totalYes = yesCounts.reduce((a, b) => a + b, 0);
|
const totalYes = yesCounts.reduce((a, b) => a + b, 0);
|
||||||
pieChart = new Chart(document.getElementById('pieChart').getContext('2d'), {
|
new Chart(document.getElementById('pieChart').getContext('2d'), {
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
data: {
|
data: {
|
||||||
labels: players,
|
labels: players,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: yesCounts,
|
data: yesCounts,
|
||||||
backgroundColor: ['#948979', '#DFD0B8', '#7c765c', '#393E46', '#222831', '#bfa181', '#b7b7a4', '#a7c957']
|
backgroundColor: [
|
||||||
|
'#948979', '#DFD0B8', '#7c765c', '#393E46', '#222831', '#bfa181', '#b7b7a4', '#a7c957'
|
||||||
|
]
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
@ -155,30 +115,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user