diff --git a/app/local_postgres.py b/app/local_postgres.py index 1793d6a..ecf02c3 100644 --- a/app/local_postgres.py +++ b/app/local_postgres.py @@ -324,8 +324,9 @@ def _create_database(): def _stop(*_args): """Stop the PostgreSQL server with a fast shutdown. - Registered as ``atexit`` and signal handler so it fires on Ctrl+C, - normal exit, and terminal close. + Registered as an ``atexit`` handler so it fires on normal exit. Signal + handling goes through ``_handle_stop_signal``, which also stops the + server but then re-raises so the process actually exits. """ global _started global _stopped_system_service_name @@ -349,6 +350,19 @@ def _stop(*_args): _start_system_service(srv) +def _handle_stop_signal(signum, frame): + """Stop PostgreSQL, then let the interpreter shut down normally. + + ``_stop`` alone would swallow Ctrl+C: it stops the server and restores the + system service, then returns without exiting, leaving the dev server + running against a stopped database and the terminal frozen. Re-raising + ``KeyboardInterrupt`` hands control back to the framework, which exits; + the ``atexit`` registration then runs ``_stop`` again (idempotent). + """ + _stop() + raise KeyboardInterrupt + + # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @@ -420,9 +434,9 @@ def start() -> str: _started = True atexit.register(_stop) with contextlib.suppress(ValueError, OSError): - signal.signal(signal.SIGINT, _stop) + signal.signal(signal.SIGINT, _handle_stop_signal) with contextlib.suppress(AttributeError, ValueError, OSError): - signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined] + signal.signal(signal.SIGBREAK, _handle_stop_signal) # type: ignore[attr-defined] logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL) return DATABASE_URL diff --git a/app/routes/matches.py b/app/routes/matches.py index b282015..2469482 100644 --- a/app/routes/matches.py +++ b/app/routes/matches.py @@ -224,7 +224,6 @@ def api_events(): for match in matches: tryout = tryouts_by_id[match.tryout_id] - match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b' # 'description' used to be participants_str + '
' + description. # Building presentation markup inside a JSON field is what carried # the stored XSS: the browser dropped it straight into innerHTML, @@ -255,7 +254,6 @@ def api_events(): 'id': f'match_{match.id}', 'title': match.title, 'type': 'match', - 'color': match_color, 'extendedProps': { 'location': match.location or tryout.location or 'TBD', 'status': match.status, @@ -301,7 +299,6 @@ def api_events(): 'id': f'team_match_{team_match.id}', 'title': team_match.title, 'type': 'team_match', - 'color': '#3b82f6', 'extendedProps': { 'location': team_match.location or 'TBD', 'status': team_match.status, @@ -334,7 +331,6 @@ def api_events(): 'id': f'one_on_one_{ooo.id}', 'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}', 'type': 'one_on_one', - 'color': '#8b5cf6', 'extendedProps': { 'location': 'Discord / Voice Chat', 'status': 'approved', diff --git a/app/static/css/style.css b/app/static/css/style.css index 04e172e..394b5cd 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -1043,6 +1043,10 @@ a:hover { color: var(--primary-dark); } .fc { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --fc-border-color: var(--gray-200); + --fc-page-bg-color: transparent; + --fc-neutral-bg-color: var(--gray-50); + --fc-today-bg-color: var(--info-light); } .fc-toolbar { @@ -1055,24 +1059,29 @@ a:hover { color: var(--primary-dark); } color: var(--gray-900); } -.fc-button { - background: var(--primary) !important; - border: none !important; +.fc .fc-button { + background: var(--gray-50) !important; + border: 1px solid var(--gray-200) !important; + color: var(--gray-700) !important; border-radius: var(--radius-sm) !important; padding: 6px 12px !important; font-size: 0.85rem !important; } -.fc-button:hover { - background: var(--primary-dark) !important; +.fc .fc-button:hover { + background: var(--gray-100) !important; + color: var(--gray-900) !important; } -.fc-button-primary:not(:disabled).fc-button-active, -.fc-button-primary:not(:disabled):active { - background: var(--primary-dark) !important; +.fc .fc-button-primary:not(:disabled).fc-button-active, +.fc .fc-button-primary:not(:disabled):active { + background: var(--primary) !important; + border-color: var(--primary) !important; + color: #fff !important; } -.fc-daygrid-day { +.fc-daygrid-day, +.fc-timegrid-slot { transition: var(--transition); } @@ -1080,16 +1089,111 @@ a:hover { color: var(--primary-dark); } background: var(--gray-50); } -.fc-event { - border-radius: var(--radius-sm); - padding: 2px 4px; - font-size: 0.8rem; +/* Event cards (scoped to the main calendar: the tryout mini-calendar keeps + its inline event colors) */ +#calendar .fc-event { + border: none !important; + border-radius: var(--radius-sm) !important; + padding: 3px 6px !important; + font-size: 0.8rem !important; + background-color: var(--fc-event-bg, var(--primary)) !important; + color: #fff !important; + box-shadow: var(--shadow); } -.fc-event-title { - font-weight: 600; +#calendar .fc-event-body { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; } +#calendar .fc-event-body i { + flex: 0 0 auto; + font-size: 0.7rem; + opacity: 0.9; +} + +#calendar .fc-event-text { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0 4px; + min-width: 0; +} + +#calendar .fc-event-time { + font-size: 0.72rem; + font-weight: 700; +} + +#calendar .fc-event-title { + font-weight: 600 !important; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Event type colors (semantic, light theme) */ +#calendar .fc-event--team { --fc-event-bg: #10b981; } +#calendar .fc-event--scrim { --fc-event-bg: #f59e0b; } +#calendar .fc-event--team-match { --fc-event-bg: #3b82f6; } +#calendar .fc-event--one-on-one { --fc-event-bg: #8b5cf6; } + +#calendar .fc-now-indicator { + border-color: var(--danger); +} + +/* Dark theme: the calendar chrome reacts to the light/dark switch */ +[data-theme="dark"] .fc { + --fc-border-color: var(--border-color); + --fc-neutral-bg-color: var(--bg-secondary); + --fc-today-bg-color: rgba(59, 130, 246, 0.15); +} + +[data-theme="dark"] .fc-toolbar-title { + color: var(--text-primary); +} + +[data-theme="dark"] .fc .fc-button { + background: var(--bg-tertiary) !important; + border-color: var(--border-color) !important; + color: var(--text-secondary) !important; +} + +[data-theme="dark"] .fc .fc-button:hover { + background: var(--bg-secondary) !important; + color: var(--text-primary) !important; +} + +[data-theme="dark"] .fc .fc-button-primary:not(:disabled).fc-button-active, +[data-theme="dark"] .fc .fc-button-primary:not(:disabled):active { + background: var(--primary) !important; + border-color: var(--primary) !important; + color: #fff !important; +} + +[data-theme="dark"] .fc-daygrid-day:hover { + background: var(--bg-tertiary); +} + +[data-theme="dark"] .fc-daygrid-day-number, +[data-theme="dark"] .fc-col-header-cell-cushion, +[data-theme="dark"] .fc-timegrid-axis-cushion, +[data-theme="dark"] .fc-timegrid-slot-label-cushion { + color: var(--text-secondary); +} + +/* Pastel event chips with dark text keep contrast on a dark background. */ +[data-theme="dark"] #calendar .fc-event { + color: var(--gray-900) !important; +} + +[data-theme="dark"] #calendar .fc-event--team { --fc-event-bg: #d1fae5; } +[data-theme="dark"] #calendar .fc-event--scrim { --fc-event-bg: #fef3c7; } +[data-theme="dark"] #calendar .fc-event--team-match { --fc-event-bg: #dbeafe; } +[data-theme="dark"] #calendar .fc-event--one-on-one { --fc-event-bg: #ede9fe; } + .btn-group { display: flex; gap: 4px; diff --git a/app/templates/pages/calendar.html b/app/templates/pages/calendar.html index 5ba62f7..93e97d9 100644 --- a/app/templates/pages/calendar.html +++ b/app/templates/pages/calendar.html @@ -137,12 +137,18 @@ document.addEventListener('DOMContentLoaded', function() { headerToolbar: { left: 'prev,next today', center: 'title', - right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth' + right: '' }, events: '/matches/api/events', eventClick: function(info) { showEventModal(info.event); }, + eventClassNames: function(arg) { + return [eventClassName(arg.event)]; + }, + eventContent: function(arg) { + return renderEventContent(arg); + }, dateClick: function(info) { if (canScheduleMatches) { var dateStr = info.dateStr; @@ -158,7 +164,11 @@ document.addEventListener('DOMContentLoaded', function() { } }, slotMinTime: '12:00:00', - slotMaxTime: '24:00:00' + slotMaxTime: '24:00:00', + slotEventOverlap: true, + dayMaxEvents: false, + nowIndicator: true, + height: 'auto' }); calendar.render(); @@ -177,6 +187,41 @@ function changeView(viewName) { } } +function eventClassName(event) { + var props = event.extendedProps || {}; + if (props.type === 'team_match') { return 'fc-event--team-match'; } + if (props.type === 'one_on_one') { return 'fc-event--one-on-one'; } + return props.match_type === 'team_vs_team' ? 'fc-event--team' : 'fc-event--scrim'; +} + +function eventIcon(type, matchType) { + if (type === 'team_match') { return 'fa-trophy'; } + if (type === 'one_on_one') { return 'fa-user-friends'; } + if (matchType === 'team_vs_team') { return 'fa-shield-alt'; } + if (matchType === 'player_vs_player') { return 'fa-users'; } + return 'fa-gamepad'; +} + +function renderEventContent(arg) { + // Built with DOM nodes + textContent: the title comes from a JSON endpoint + // and has never been HTML-escaped, so it must not reach innerHTML. + var props = arg.event.extendedProps || {}; + var wrap = makeEl('div', 'fc-event-body'); + + var icon = makeEl('i', 'fas ' + eventIcon(props.type, props.match_type)); + icon.setAttribute('aria-hidden', 'true'); + wrap.appendChild(icon); + + var text = makeEl('span', 'fc-event-text'); + if (!arg.event.allDay && arg.timeText && arg.view.type.indexOf('dayGrid') === 0) { + text.appendChild(makeEl('span', 'fc-event-time', arg.timeText)); + } + text.appendChild(makeEl('span', 'fc-event-title', arg.event.title)); + wrap.appendChild(text); + + return { domNodes: [wrap] }; +} + // --- Create Event Modal --- function showCreateEventModal(dateStr) { document.getElementById('createEventDate').textContent = dateStr;