From 5dce6eb89b7ea085e736b7ab1c36d8139a6babc4 Mon Sep 17 00:00:00 2001 From: Nathan FONTEYNE Date: Thu, 23 Jul 2026 11:40:59 +0200 Subject: [PATCH] update: 4 weeks -> 2 months calendar --- public/calendar.html | 5 ++ public/css/style.css | 19 +++++++ public/js/calendar.js | 82 +++++++++++++++++++++++++++---- src/lib/calendarAvailability.js | 12 +++-- src/repositories/calendarRepo.js | 4 +- src/routes/calendar.js | 2 +- src/services/calendarSync.js | 6 +-- test/calendarAvailability.test.js | 10 ++-- 8 files changed, 115 insertions(+), 25 deletions(-) diff --git a/public/calendar.html b/public/calendar.html index d0e0512..6967fcf 100644 --- a/public/calendar.html +++ b/public/calendar.html @@ -51,6 +51,11 @@
+
+ + + +
LunMarMerJeuVenSamDim
diff --git a/public/css/style.css b/public/css/style.css index 5dba1f7..769fd43 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -986,6 +986,24 @@ button.calendar-filters-close { display: none; } .calendar-main { min-width: 0; } +.calendar-month-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin-bottom: 0.75rem; +} + +.calendar-month-label { + font-weight: 600; + font-size: 1.05rem; + min-width: 10ch; + text-align: center; + text-transform: capitalize; +} + +.calendar-month-nav button:disabled { opacity: 0.35; cursor: default; } + .calendar-header-row { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); @@ -1022,6 +1040,7 @@ button.calendar-filters-close { display: none; } .cal-cell.empty { background: transparent; border-style: dashed; color: var(--muted); } .cal-cell.has-slot { cursor: pointer; } .cal-cell.has-slot:hover { border-color: var(--accent); } +.cal-cell.outside-month { border-style: none; opacity: 0.35; } .cell-date { font-weight: 600; color: var(--muted); } .cell-date .day-num { color: var(--text); font-size: 0.95rem; margin-right: 0.25rem; } diff --git a/public/js/calendar.js b/public/js/calendar.js index 74b7d7d..564a3ce 100644 --- a/public/js/calendar.js +++ b/public/js/calendar.js @@ -5,6 +5,8 @@ let state = { rehearsals: [], upcomingConcerts: [], concertHours: { start: '19:00', end: '22:00' }, + viewYear: null, + viewMonth: null, // 0-indexed, like Date#getMonth() }; let me = null; let currentModalDate = null; @@ -15,10 +17,51 @@ async function loadPeople() { } async function loadSlots() { - state.slots = await api.get('/api/calendar/slots?weeks=4'); + state.slots = await api.get('/api/calendar/slots?weeks=14'); renderCalendar(); } +// Navigation is bounded to the current month through 2 months ahead — that +// range is exactly what loadSlots() fetches (14 weeks from today), so every +// navigable month always has data without an extra fetch per month change. +function monthNavBounds() { + const today = new Date(); + return { + minYear: today.getFullYear(), + minMonth: today.getMonth(), + maxYear: today.getFullYear(), + maxMonth: today.getMonth() + 2, + }; +} + +function changeMonth(delta) { + let month = state.viewMonth + delta; + let year = state.viewYear; + while (month < 0) { month += 12; year -= 1; } + while (month > 11) { month -= 12; year += 1; } + + const { minYear, minMonth, maxYear, maxMonth } = monthNavBounds(); + const key = year * 12 + month; + if (key < minYear * 12 + minMonth || key > maxYear * 12 + maxMonth) return; + + state.viewYear = year; + state.viewMonth = month; + renderCalendar(); +} + +function updateMonthNav() { + const { minYear, minMonth, maxYear, maxMonth } = monthNavBounds(); + const key = state.viewYear * 12 + state.viewMonth; + + document.getElementById('btn-month-prev').disabled = key <= minYear * 12 + minMonth; + document.getElementById('btn-month-next').disabled = key >= maxYear * 12 + maxMonth; + + const label = new Date(state.viewYear, state.viewMonth, 1).toLocaleDateString('fr-FR', { + month: 'long', year: 'numeric', + }); + document.getElementById('month-label').textContent = label; +} + async function loadRehearsals() { state.rehearsals = await api.get('/api/rehearsals'); renderRehearsals(); @@ -119,28 +162,40 @@ function renderCalendar() { const concertsByDate = new Map(); for (const c of state.upcomingConcerts) concertsByDate.set(isoDate(new Date(c.concert_date)), c); - // Grid starts on the Monday of the current week so columns align Mon->Sun. - const start = new Date(today); - const dow = start.getDay(); - start.setDate(start.getDate() + (dow === 0 ? -6 : 1 - dow)); + updateMonthNav(); - const daysBeforeToday = Math.round((today - start) / 86400000); - const totalDays = Math.ceil((daysBeforeToday + 28) / 7) * 7; + const viewYear = state.viewYear; + const viewMonth = state.viewMonth; + + // Grid starts on the Monday on/before the 1st so columns align Mon->Sun, + // and spans full weeks so trailing/leading days of neighboring months + // fill out the last/first row — same visual as Google Calendar's month view. + const firstOfMonth = new Date(viewYear, viewMonth, 1); + const firstDow = firstOfMonth.getDay(); + const leadingDays = firstDow === 0 ? 6 : firstDow - 1; + const start = new Date(viewYear, viewMonth, 1 - leadingDays); + + const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); + const totalDays = Math.ceil((leadingDays + daysInMonth) / 7) * 7; for (let i = 0; i < totalDays; i++) { const date = new Date(start); date.setDate(start.getDate() + i); + const isOutsideMonth = date.getMonth() !== viewMonth; const isPastDay = date < today; - const isBeyondRange = i >= daysBeforeToday + 28; const isToday = date.getTime() === today.getTime(); const slot = slotMap.get(isoDate(date)); const cell = document.createElement('div'); cell.className = 'cal-cell' + (isToday ? ' today' : ''); - if (isBeyondRange) { - cell.classList.add('empty'); + if (isOutsideMonth) { + cell.classList.add('empty', 'outside-month'); + const dateLabel = document.createElement('div'); + dateLabel.className = 'cell-date'; + dateLabel.innerHTML = `${date.getDate()}`; + cell.appendChild(dateLabel); grid.appendChild(cell); continue; } @@ -639,6 +694,13 @@ async function onRemoveMyFeed(feedId) { (async function init() { me = await initNav('calendar'); + const today = new Date(); + state.viewYear = today.getFullYear(); + state.viewMonth = today.getMonth(); + + document.getElementById('btn-month-prev').addEventListener('click', () => changeMonth(-1)); + document.getElementById('btn-month-next').addEventListener('click', () => changeMonth(1)); + document.getElementById('modal-close').addEventListener('click', closeModal); document.getElementById('modal-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) closeModal(); diff --git a/src/lib/calendarAvailability.js b/src/lib/calendarAvailability.js index 66404b4..e66ea6f 100644 --- a/src/lib/calendarAvailability.js +++ b/src/lib/calendarAvailability.js @@ -8,11 +8,15 @@ const DEFAULT_SLOT_CONFIG = { weekday: { startHour: 18, startMinute: 30, endHour: 21, endMinute: 0 }, weekend: { startHour: 15, startMinute: 0, endHour: 19, endMinute: 0 }, }; -const MAX_WEEKS = 4; +// 14 weeks (98 days) comfortably covers the calendar page's month view, which +// lets users navigate up to 2 calendar months ahead of the current one — the +// worst case (today is the 1st of a month, followed by two 31-day months) is +// ~92 days. +const MAX_WEEKS = 14; -// One slot per day for the next `weeks` weeks (capped at 4, same as the rest -// of the calendar feature), starting from today's Paris-local calendar date -// — not the server's own timezone, which may not be Europe/Paris. +// One slot per day for the next `weeks` weeks (capped at MAX_WEEKS, same as +// the rest of the calendar feature), starting from today's Paris-local +// calendar date — not the server's own timezone, which may not be Europe/Paris. function generateSlots(weeks = MAX_WEEKS, slotConfig = DEFAULT_SLOT_CONFIG) { const days = Math.min(weeks || MAX_WEEKS, MAX_WEEKS) * 7; const parisToday = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Paris' }).format(new Date()); diff --git a/src/repositories/calendarRepo.js b/src/repositories/calendarRepo.js index 7613254..7a656ee 100644 --- a/src/repositories/calendarRepo.js +++ b/src/repositories/calendarRepo.js @@ -84,8 +84,8 @@ async function ingestSlots(slots) { return summary; } -async function getSlots({ minPeople = 0, personIds = null, weeks = 4 } = {}) { - const cappedWeeks = Math.min(weeks || 4, 4); +async function getSlots({ minPeople = 0, personIds = null, weeks = 14 } = {}) { + const cappedWeeks = Math.min(weeks || 14, 14); const now = new Date(); const end = new Date(now); end.setDate(end.getDate() + cappedWeeks * 7); diff --git a/src/routes/calendar.js b/src/routes/calendar.js index 8e5a1e0..1750795 100644 --- a/src/routes/calendar.js +++ b/src/routes/calendar.js @@ -22,7 +22,7 @@ router.get( // ingested slot (heat-colored by availability ratio on the frontend), // not just slots where at least one person happens to be free. const minPeople = req.query.min_people !== undefined ? parseInt(req.query.min_people, 10) : 0; - const weeks = req.query.weeks !== undefined ? parseInt(req.query.weeks, 10) : 4; + const weeks = req.query.weeks !== undefined ? parseInt(req.query.weeks, 10) : 14; const personIds = req.query.person_ids ? req.query.person_ids.split(',').map(Number).filter((n) => !Number.isNaN(n)) : null; diff --git a/src/services/calendarSync.js b/src/services/calendarSync.js index 8038f85..e6cc92c 100644 --- a/src/services/calendarSync.js +++ b/src/services/calendarSync.js @@ -3,7 +3,7 @@ const calendarRepo = require('../repositories/calendarRepo'); const { generateSlots, isBusyDuring, widenWindow } = require('../lib/calendarAvailability'); const { parisWallClockToUTC } = require('../lib/calendarDates'); -const RANGE_DAYS = 29; // slightly more than the 4 weeks generateSlots() covers +const RANGE_DAYS = 105; // slightly more than the 14 weeks generateSlots() covers const FETCH_TIMEOUT_MS = 15000; // Fetches one ICS feed and parses it into node-ical's raw component map. @@ -84,7 +84,7 @@ function dateOnlyToParisSpan(dateOnly) { } // Fetches every registered feed, derives per-person busy/free for each of the -// next 4 weeks' slots (using the admin-configured rehearsal hours, falling +// next 14 weeks' slots (using the admin-configured rehearsal hours, falling // back to calendarAvailability's defaults if none are set), and ingests the // result via calendarRepo.ingestSlots — the same sink the old n8n-webhook flow // used to feed. A feed that fails to fetch is logged by id only (never its @@ -113,7 +113,7 @@ async function syncAvailability() { intervalsByUser.set(feed.user_id, existing.concat(result.value)); }); - const slots = generateSlots(4, slotConfig); + const slots = generateSlots(14, slotConfig); const slotPayload = slots.map((slot) => { // The margin only widens the *check* window (to account for travel time // between back-to-back calendar events) — the slot itself, as stored and diff --git a/test/calendarAvailability.test.js b/test/calendarAvailability.test.js index f645e0f..49a65b2 100644 --- a/test/calendarAvailability.test.js +++ b/test/calendarAvailability.test.js @@ -2,14 +2,14 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { generateSlots, isBusyDuring, widenWindow } = require('../src/lib/calendarAvailability'); -test('generateSlots: default 4 weeks produces one slot per day for 28 days', () => { +test('generateSlots: default 14 weeks produces one slot per day for 98 days', () => { const slots = generateSlots(); - assert.equal(slots.length, 28); + assert.equal(slots.length, 98); }); -test('generateSlots: caps weeks at 4 even if a larger value is requested', () => { - const slots = generateSlots(10); - assert.equal(slots.length, 28); +test('generateSlots: caps weeks at 14 even if a larger value is requested', () => { + const slots = generateSlots(20); + assert.equal(slots.length, 98); }); test('generateSlots: every slot has upper strictly after lower', () => {