mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: 4 weeks -> 2 months calendar
This commit is contained in:
parent
c124f3ce73
commit
5dce6eb89b
8 changed files with 115 additions and 25 deletions
|
|
@ -51,6 +51,11 @@
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="calendar-main">
|
<div class="calendar-main">
|
||||||
|
<div class="calendar-month-nav">
|
||||||
|
<button type="button" class="secondary icon-btn" id="btn-month-prev" aria-label="Mois précédent">‹</button>
|
||||||
|
<span class="calendar-month-label" id="month-label"></span>
|
||||||
|
<button type="button" class="secondary icon-btn" id="btn-month-next" aria-label="Mois suivant">›</button>
|
||||||
|
</div>
|
||||||
<div class="calendar-header-row">
|
<div class="calendar-header-row">
|
||||||
<span>Lun</span><span>Mar</span><span>Mer</span><span>Jeu</span><span>Ven</span><span>Sam</span><span>Dim</span>
|
<span>Lun</span><span>Mar</span><span>Mer</span><span>Jeu</span><span>Ven</span><span>Sam</span><span>Dim</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -986,6 +986,24 @@ button.calendar-filters-close { display: none; }
|
||||||
|
|
||||||
.calendar-main { min-width: 0; }
|
.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 {
|
.calendar-header-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
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.empty { background: transparent; border-style: dashed; color: var(--muted); }
|
||||||
.cal-cell.has-slot { cursor: pointer; }
|
.cal-cell.has-slot { cursor: pointer; }
|
||||||
.cal-cell.has-slot:hover { border-color: var(--accent); }
|
.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 { font-weight: 600; color: var(--muted); }
|
||||||
.cell-date .day-num { color: var(--text); font-size: 0.95rem; margin-right: 0.25rem; }
|
.cell-date .day-num { color: var(--text); font-size: 0.95rem; margin-right: 0.25rem; }
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ let state = {
|
||||||
rehearsals: [],
|
rehearsals: [],
|
||||||
upcomingConcerts: [],
|
upcomingConcerts: [],
|
||||||
concertHours: { start: '19:00', end: '22:00' },
|
concertHours: { start: '19:00', end: '22:00' },
|
||||||
|
viewYear: null,
|
||||||
|
viewMonth: null, // 0-indexed, like Date#getMonth()
|
||||||
};
|
};
|
||||||
let me = null;
|
let me = null;
|
||||||
let currentModalDate = null;
|
let currentModalDate = null;
|
||||||
|
|
@ -15,10 +17,51 @@ async function loadPeople() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSlots() {
|
async function loadSlots() {
|
||||||
state.slots = await api.get('/api/calendar/slots?weeks=4');
|
state.slots = await api.get('/api/calendar/slots?weeks=14');
|
||||||
renderCalendar();
|
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() {
|
async function loadRehearsals() {
|
||||||
state.rehearsals = await api.get('/api/rehearsals');
|
state.rehearsals = await api.get('/api/rehearsals');
|
||||||
renderRehearsals();
|
renderRehearsals();
|
||||||
|
|
@ -119,28 +162,40 @@ function renderCalendar() {
|
||||||
const concertsByDate = new Map();
|
const concertsByDate = new Map();
|
||||||
for (const c of state.upcomingConcerts) concertsByDate.set(isoDate(new Date(c.concert_date)), c);
|
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.
|
updateMonthNav();
|
||||||
const start = new Date(today);
|
|
||||||
const dow = start.getDay();
|
|
||||||
start.setDate(start.getDate() + (dow === 0 ? -6 : 1 - dow));
|
|
||||||
|
|
||||||
const daysBeforeToday = Math.round((today - start) / 86400000);
|
const viewYear = state.viewYear;
|
||||||
const totalDays = Math.ceil((daysBeforeToday + 28) / 7) * 7;
|
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++) {
|
for (let i = 0; i < totalDays; i++) {
|
||||||
const date = new Date(start);
|
const date = new Date(start);
|
||||||
date.setDate(start.getDate() + i);
|
date.setDate(start.getDate() + i);
|
||||||
|
|
||||||
|
const isOutsideMonth = date.getMonth() !== viewMonth;
|
||||||
const isPastDay = date < today;
|
const isPastDay = date < today;
|
||||||
const isBeyondRange = i >= daysBeforeToday + 28;
|
|
||||||
const isToday = date.getTime() === today.getTime();
|
const isToday = date.getTime() === today.getTime();
|
||||||
const slot = slotMap.get(isoDate(date));
|
const slot = slotMap.get(isoDate(date));
|
||||||
|
|
||||||
const cell = document.createElement('div');
|
const cell = document.createElement('div');
|
||||||
cell.className = 'cal-cell' + (isToday ? ' today' : '');
|
cell.className = 'cal-cell' + (isToday ? ' today' : '');
|
||||||
|
|
||||||
if (isBeyondRange) {
|
if (isOutsideMonth) {
|
||||||
cell.classList.add('empty');
|
cell.classList.add('empty', 'outside-month');
|
||||||
|
const dateLabel = document.createElement('div');
|
||||||
|
dateLabel.className = 'cell-date';
|
||||||
|
dateLabel.innerHTML = `<span class="day-num">${date.getDate()}</span>`;
|
||||||
|
cell.appendChild(dateLabel);
|
||||||
grid.appendChild(cell);
|
grid.appendChild(cell);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -639,6 +694,13 @@ async function onRemoveMyFeed(feedId) {
|
||||||
(async function init() {
|
(async function init() {
|
||||||
me = await initNav('calendar');
|
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-close').addEventListener('click', closeModal);
|
||||||
document.getElementById('modal-overlay').addEventListener('click', (e) => {
|
document.getElementById('modal-overlay').addEventListener('click', (e) => {
|
||||||
if (e.target === e.currentTarget) closeModal();
|
if (e.target === e.currentTarget) closeModal();
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,15 @@ const DEFAULT_SLOT_CONFIG = {
|
||||||
weekday: { startHour: 18, startMinute: 30, endHour: 21, endMinute: 0 },
|
weekday: { startHour: 18, startMinute: 30, endHour: 21, endMinute: 0 },
|
||||||
weekend: { startHour: 15, startMinute: 0, endHour: 19, 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
|
// One slot per day for the next `weeks` weeks (capped at MAX_WEEKS, same as
|
||||||
// of the calendar feature), starting from today's Paris-local calendar date
|
// the rest of the calendar feature), starting from today's Paris-local
|
||||||
// — not the server's own timezone, which may not be Europe/Paris.
|
// calendar date — not the server's own timezone, which may not be Europe/Paris.
|
||||||
function generateSlots(weeks = MAX_WEEKS, slotConfig = DEFAULT_SLOT_CONFIG) {
|
function generateSlots(weeks = MAX_WEEKS, slotConfig = DEFAULT_SLOT_CONFIG) {
|
||||||
const days = Math.min(weeks || MAX_WEEKS, MAX_WEEKS) * 7;
|
const days = Math.min(weeks || MAX_WEEKS, MAX_WEEKS) * 7;
|
||||||
const parisToday = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Paris' }).format(new Date());
|
const parisToday = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Paris' }).format(new Date());
|
||||||
|
|
|
||||||
|
|
@ -84,8 +84,8 @@ async function ingestSlots(slots) {
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSlots({ minPeople = 0, personIds = null, weeks = 4 } = {}) {
|
async function getSlots({ minPeople = 0, personIds = null, weeks = 14 } = {}) {
|
||||||
const cappedWeeks = Math.min(weeks || 4, 4);
|
const cappedWeeks = Math.min(weeks || 14, 14);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const end = new Date(now);
|
const end = new Date(now);
|
||||||
end.setDate(end.getDate() + cappedWeeks * 7);
|
end.setDate(end.getDate() + cappedWeeks * 7);
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ router.get(
|
||||||
// ingested slot (heat-colored by availability ratio on the frontend),
|
// ingested slot (heat-colored by availability ratio on the frontend),
|
||||||
// not just slots where at least one person happens to be free.
|
// 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 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
|
const personIds = req.query.person_ids
|
||||||
? req.query.person_ids.split(',').map(Number).filter((n) => !Number.isNaN(n))
|
? req.query.person_ids.split(',').map(Number).filter((n) => !Number.isNaN(n))
|
||||||
: null;
|
: null;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ const calendarRepo = require('../repositories/calendarRepo');
|
||||||
const { generateSlots, isBusyDuring, widenWindow } = require('../lib/calendarAvailability');
|
const { generateSlots, isBusyDuring, widenWindow } = require('../lib/calendarAvailability');
|
||||||
const { parisWallClockToUTC } = require('../lib/calendarDates');
|
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;
|
const FETCH_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
// Fetches one ICS feed and parses it into node-ical's raw component map.
|
// 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
|
// 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
|
// back to calendarAvailability's defaults if none are set), and ingests the
|
||||||
// result via calendarRepo.ingestSlots — the same sink the old n8n-webhook flow
|
// 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
|
// 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));
|
intervalsByUser.set(feed.user_id, existing.concat(result.value));
|
||||||
});
|
});
|
||||||
|
|
||||||
const slots = generateSlots(4, slotConfig);
|
const slots = generateSlots(14, slotConfig);
|
||||||
const slotPayload = slots.map((slot) => {
|
const slotPayload = slots.map((slot) => {
|
||||||
// The margin only widens the *check* window (to account for travel time
|
// The margin only widens the *check* window (to account for travel time
|
||||||
// between back-to-back calendar events) — the slot itself, as stored and
|
// between back-to-back calendar events) — the slot itself, as stored and
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,14 @@ const { test } = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const { generateSlots, isBusyDuring, widenWindow } = require('../src/lib/calendarAvailability');
|
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();
|
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', () => {
|
test('generateSlots: caps weeks at 14 even if a larger value is requested', () => {
|
||||||
const slots = generateSlots(10);
|
const slots = generateSlots(20);
|
||||||
assert.equal(slots.length, 28);
|
assert.equal(slots.length, 98);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('generateSlots: every slot has upper strictly after lower', () => {
|
test('generateSlots: every slot has upper strictly after lower', () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue