From a3d62200ceb4eb684f329e0f836f1886abef34c0 Mon Sep 17 00:00:00 2001 From: Nathan FONTEYNE Date: Tue, 11 Aug 2026 11:44:42 +0200 Subject: [PATCH 1/2] update: different colors for suggested rehersal and validated rehersal --- public/css/style.css | 7 ++++++ public/js/admin.js | 21 +++++++++++++++-- public/js/calendar.js | 29 +++++++++++++++++++----- src/db/migrations/020_rehearsal_host.sql | 2 ++ src/lib/icsFeed.js | 8 +++---- src/lib/rehearsalStatus.js | 12 +++++++--- src/repositories/calendarRepo.js | 17 ++++++++++---- src/routes/calendar.js | 18 +++++++++++++-- src/routes/calendarFeed.js | 7 +++++- src/routes/rehearsals.js | 5 +++- 10 files changed, 102 insertions(+), 24 deletions(-) create mode 100644 src/db/migrations/020_rehearsal_host.sql diff --git a/public/css/style.css b/public/css/style.css index 903b119..1c62a8c 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1554,6 +1554,8 @@ button.calendar-filters-close { display: none; } .modal-section .rehearsal-actions { margin-top: 0.5rem; } .rehearsal-accepted-by .avatar-sm { width: 1.3rem; height: 1.3rem; font-size: 0.65rem; } +.rehearsal-accepted-by .note { margin: 0; } +.rehearsal-refused-by { opacity: 0.75; } .vote-badge { font-size: 0.85rem; @@ -1595,10 +1597,15 @@ button.calendar-filters-close { display: none; } above (same specificity, later in the cascade) — a scheduled date matters more than who's free that day. */ .cal-cell.has-rehearsal { background: color-mix(in srgb, var(--accent) 20%, var(--surface)); border-color: var(--accent); } +.cal-cell.rehearsal-confirmed { background: color-mix(in srgb, var(--success) 20%, var(--surface)); border-color: var(--success); } +.cal-cell.rehearsal-suggested { background: color-mix(in srgb, var(--accent) 20%, var(--surface)); border-color: var(--accent); } .cal-cell.has-concert { background: color-mix(in srgb, var(--accent-2) 20%, var(--surface)); border-color: var(--accent-2); } .cal-cell.has-rehearsal.has-concert { background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 20%, var(--surface)), color-mix(in srgb, var(--accent-2) 20%, var(--surface))); } +.cal-cell.rehearsal-confirmed.has-concert { + background: linear-gradient(135deg, color-mix(in srgb, var(--success) 20%, var(--surface)), color-mix(in srgb, var(--accent-2) 20%, var(--surface))); +} /* ---------- Concerts: upcoming list + detail actions ---------- */ diff --git a/public/js/admin.js b/public/js/admin.js index 5ed8796..ae8b6c9 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -179,7 +179,14 @@ async function onRemoveFeed(feedId) { } } -async function loadSlotSettingsForm() { +function hostOptionsHtml(users, selectedId) { + const options = users.map( + (u) => `` + ); + return `${options.join('')}`; +} + +async function loadSlotSettingsForm(users) { const settings = await api.get('/api/calendar/settings'); const form = document.getElementById('slot-settings-form'); form.weekdayStart.value = settings.weekdayStart; @@ -190,6 +197,7 @@ async function loadSlotSettingsForm() { form.concertStart.value = settings.concertStart; form.concertEnd.value = settings.concertEnd; form.rehearsalConfirmThreshold.value = settings.rehearsalConfirmThreshold; + form.rehearsalHostUserId.innerHTML = hostOptionsHtml(users, settings.rehearsalHostUserId); } async function onSaveSlotSettings(e) { @@ -207,6 +215,7 @@ async function onSaveSlotSettings(e) { concertStart: form.concertStart.value, concertEnd: form.concertEnd.value, rehearsalConfirmThreshold: parseInt(form.rehearsalConfirmThreshold.value, 10), + rehearsalHostUserId: form.rehearsalHostUserId.value || null, }); statusEl.textContent = 'Horaires enregistrés.'; } catch (err) { @@ -277,6 +286,14 @@ async function onSaveSlotSettings(e) { n'est pas atteint ; elle passe ensuite en répétition confirmée (dans l'application et dans le flux ICS).

+ +

+ Si un hôte est désigné, une répétition ne peut être confirmée que si cette personne a elle-même + accepté le créneau — sans elle, pas de lieu pour répéter. Choisissez « (aucun) » pour désactiver + cette contrainte. +

@@ -310,7 +327,7 @@ async function onSaveSlotSettings(e) { `; document.getElementById('slot-settings-form').addEventListener('submit', onSaveSlotSettings); document.getElementById('add-discord-webhook-form').addEventListener('submit', onAddDiscordWebhook); - await loadSlotSettingsForm(); + await loadSlotSettingsForm(stats.users); await loadCalendarUsers(); await loadDiscordWebhooks(); } catch (err) { diff --git a/public/js/calendar.js b/public/js/calendar.js index 70becf3..a6d6537 100644 --- a/public/js/calendar.js +++ b/public/js/calendar.js @@ -216,9 +216,10 @@ function renderCalendar() { const rehearsal = rehearsalsByDate.get(isoDate(date)); if (rehearsal) { - cell.classList.add('has-rehearsal'); + const isConfirmed = rehearsal.status === 'confirmed'; + cell.classList.add('has-rehearsal', isConfirmed ? 'rehearsal-confirmed' : 'rehearsal-suggested'); const badge = document.createElement('span'); - badge.title = 'Répétition proposée'; + badge.title = isConfirmed ? 'Répétition confirmée' : 'Répétition proposée (en attente de votes)'; badge.textContent = '🎸'; badges.appendChild(badge); } @@ -488,13 +489,29 @@ function rehearsalVoteActionsHtml(r) { `; } +function rehearsalVoterHtml(v, r) { + const isHost = r.hostUserId && v.userId === r.hostUserId; + const name = isHost ? `${v.name} ⭐` : v.name; + return `${avatarHtml({ name: v.name, avatarUrl: v.avatarUrl }, 'avatar-sm')}${escapeHtml(name)}`; +} + function rehearsalAcceptedByHtml(r) { const accepted = r.votes.filter((v) => v.vote === 'accept'); - if (!accepted.length) return ''; + const refused = r.votes.filter((v) => v.vote === 'reject'); + if (!accepted.length && !refused.length) return ''; return ` -
- ${accepted.map((v) => `${avatarHtml({ name: v.name, avatarUrl: v.avatarUrl }, 'avatar-sm')}${escapeHtml(v.name)}`).join('')} -
+ ${accepted.length ? ` +
+ Ont accepté : + ${accepted.map((v) => rehearsalVoterHtml(v, r)).join('')} +
+ ` : ''} + ${refused.length ? ` +
+ Ont refusé : + ${refused.map((v) => rehearsalVoterHtml(v, r)).join('')} +
+ ` : ''} `; } diff --git a/src/db/migrations/020_rehearsal_host.sql b/src/db/migrations/020_rehearsal_host.sql new file mode 100644 index 0000000..476bf7d --- /dev/null +++ b/src/db/migrations/020_rehearsal_host.sql @@ -0,0 +1,2 @@ +ALTER TABLE calendar_settings + ADD COLUMN IF NOT EXISTS rehearsal_host_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL; diff --git a/src/lib/icsFeed.js b/src/lib/icsFeed.js index 0420071..d52fbc4 100644 --- a/src/lib/icsFeed.js +++ b/src/lib/icsFeed.js @@ -40,8 +40,8 @@ function foldLine(line) { return chunks.map((chunk, i) => (i === 0 ? chunk : ` ${chunk}`)).join('\r\n'); } -function rehearsalToEvent(rehearsal, { threshold, baseUrl, allUsers }) { - const status = computeRehearsalStatus(rehearsal.votes, threshold); +function rehearsalToEvent(rehearsal, { threshold, hostUserId, baseUrl, allUsers }) { + const status = computeRehearsalStatus(rehearsal.votes, threshold, hostUserId); const icsStatus = status === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE'; const summary = status === 'confirmed' ? 'Répétition' : 'Répétition (proposition)'; const voteUrl = `${baseUrl}/calendar.html?rehearsalId=${rehearsal.id}`; @@ -74,7 +74,7 @@ function rehearsalToEvent(rehearsal, { threshold, baseUrl, allUsers }) { return lines; } -function buildRehearsalsFeed(rehearsals, { threshold, baseUrl, allUsers }) { +function buildRehearsalsFeed(rehearsals, { threshold, hostUserId, baseUrl, allUsers }) { const lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', @@ -82,7 +82,7 @@ function buildRehearsalsFeed(rehearsals, { threshold, baseUrl, allUsers }) { 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'X-WR-CALNAME:Répétitions Octane', - ...rehearsals.flatMap((r) => rehearsalToEvent(r, { threshold, baseUrl, allUsers })), + ...rehearsals.flatMap((r) => rehearsalToEvent(r, { threshold, hostUserId, baseUrl, allUsers })), 'END:VCALENDAR', ]; return lines.map(foldLine).join('\r\n'); diff --git a/src/lib/rehearsalStatus.js b/src/lib/rehearsalStatus.js index 4f908e1..f91ee0d 100644 --- a/src/lib/rehearsalStatus.js +++ b/src/lib/rehearsalStatus.js @@ -1,8 +1,14 @@ // Shared between the API (rehearsals list) and the ICS feed so both agree on -// when a proposed rehearsal counts as confirmed. -function computeRehearsalStatus(votes, threshold) { +// when a proposed rehearsal counts as confirmed. When a host is configured, +// their acceptance is mandatory: without the host there is nowhere to play, +// so a rehearsal can never be "confirmed" on vote count alone. +function computeRehearsalStatus(votes, threshold, hostUserId) { const acceptCount = votes.filter((v) => v.vote === 'accept').length; - return acceptCount >= threshold ? 'confirmed' : 'suggested'; + if (acceptCount < threshold) return 'suggested'; + if (hostUserId && !votes.some((v) => v.userId === hostUserId && v.vote === 'accept')) { + return 'suggested'; + } + return 'confirmed'; } module.exports = { computeRehearsalStatus }; diff --git a/src/repositories/calendarRepo.js b/src/repositories/calendarRepo.js index 8d5f9ef..2ebef4e 100644 --- a/src/repositories/calendarRepo.js +++ b/src/repositories/calendarRepo.js @@ -205,7 +205,12 @@ function parseTime(hhmmss) { async function getSlotSettings() { const { rows } = await pool.query( - 'SELECT weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end, rehearsal_confirm_threshold FROM calendar_settings WHERE id = 1' + `SELECT s.weekday_start, s.weekday_end, s.weekend_start, s.weekend_end, s.margin_minutes, + s.concert_start, s.concert_end, s.rehearsal_confirm_threshold, s.rehearsal_host_user_id, + h.name AS rehearsal_host_name + FROM calendar_settings s + LEFT JOIN users h ON h.id = s.rehearsal_host_user_id + WHERE s.id = 1` ); const row = rows[0]; const weekdayStart = parseTime(row.weekday_start); @@ -220,17 +225,19 @@ async function getSlotSettings() { marginMinutes: row.margin_minutes, concert: { startHour: concertStart.hour, startMinute: concertStart.minute, endHour: concertEnd.hour, endMinute: concertEnd.minute }, rehearsalConfirmThreshold: row.rehearsal_confirm_threshold, + rehearsalHostUserId: row.rehearsal_host_user_id, + rehearsalHostName: row.rehearsal_host_name, }; } -async function updateSlotSettings({ weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold }) { +async function updateSlotSettings({ weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold, rehearsalHostUserId }) { const { rows } = await pool.query( `UPDATE calendar_settings SET weekday_start = $1, weekday_end = $2, weekend_start = $3, weekend_end = $4, margin_minutes = $5, - concert_start = $6, concert_end = $7, rehearsal_confirm_threshold = $8 + concert_start = $6, concert_end = $7, rehearsal_confirm_threshold = $8, rehearsal_host_user_id = $9 WHERE id = 1 - RETURNING weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end, rehearsal_confirm_threshold`, - [weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold] + RETURNING weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end, rehearsal_confirm_threshold, rehearsal_host_user_id`, + [weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold, rehearsalHostUserId || null] ); return rows[0]; } diff --git a/src/routes/calendar.js b/src/routes/calendar.js index d2ddca8..071b160 100644 --- a/src/routes/calendar.js +++ b/src/routes/calendar.js @@ -188,6 +188,8 @@ router.get( concertStart: formatTime(settings.concert.startHour, settings.concert.startMinute), concertEnd: formatTime(settings.concert.endHour, settings.concert.endMinute), rehearsalConfirmThreshold: settings.rehearsalConfirmThreshold, + rehearsalHostUserId: settings.rehearsalHostUserId, + rehearsalHostName: settings.rehearsalHostName, }); }) ); @@ -214,7 +216,7 @@ router.patch( '/settings', requireAdmin, asyncHandler(async (req, res) => { - const { weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold } = req.body || {}; + const { weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold, rehearsalHostUserId } = req.body || {}; const times = { weekdayStart, weekdayEnd, weekendStart, weekendEnd, concertStart, concertEnd }; for (const [key, value] of Object.entries(times)) { if (!TIME_RE.test(value || '')) { @@ -239,7 +241,19 @@ router.patch( return res.status(400).json({ error: 'invalid_rehearsal_confirm_threshold' }); } - const values = { ...times, marginMinutes: margin, rehearsalConfirmThreshold: threshold }; + let hostUserId = null; + if (rehearsalHostUserId !== null && rehearsalHostUserId !== undefined && rehearsalHostUserId !== '') { + hostUserId = Number(rehearsalHostUserId); + if (!Number.isInteger(hostUserId)) { + return res.status(400).json({ error: 'invalid_rehearsal_host_user_id' }); + } + const host = await usersRepo.findById(hostUserId); + if (!host) { + return res.status(400).json({ error: 'rehearsal_host_user_not_found' }); + } + } + + const values = { ...times, marginMinutes: margin, rehearsalConfirmThreshold: threshold, rehearsalHostUserId: hostUserId }; await calendarRepo.updateSlotSettings(values); res.json(values); }) diff --git a/src/routes/calendarFeed.js b/src/routes/calendarFeed.js index 98ad226..a3daf42 100644 --- a/src/routes/calendarFeed.js +++ b/src/routes/calendarFeed.js @@ -26,7 +26,12 @@ router.get( const baseUrl = `${req.protocol}://${req.get('host')}`; res .type('text/calendar; charset=utf-8') - .send(buildRehearsalsFeed(rehearsals, { threshold: settings.rehearsalConfirmThreshold, baseUrl, allUsers })); + .send(buildRehearsalsFeed(rehearsals, { + threshold: settings.rehearsalConfirmThreshold, + hostUserId: settings.rehearsalHostUserId, + baseUrl, + allUsers, + })); }) ); diff --git a/src/routes/rehearsals.js b/src/routes/rehearsals.js index 7d58d7a..8255789 100644 --- a/src/routes/rehearsals.js +++ b/src/routes/rehearsals.js @@ -12,6 +12,7 @@ router.get( asyncHandler(async (req, res) => { const [rehearsals, settings] = await Promise.all([rehearsalsRepo.findUpcoming(), calendarRepo.getSlotSettings()]); const threshold = settings.rehearsalConfirmThreshold; + const hostUserId = settings.rehearsalHostUserId; res.json( rehearsals.map((r) => ({ id: r.id, @@ -21,8 +22,10 @@ router.get( proposedBy: r.proposed_by, proposedByName: r.proposed_by_name, votes: r.votes, - status: computeRehearsalStatus(r.votes, threshold), + status: computeRehearsalStatus(r.votes, threshold, hostUserId), confirmThreshold: threshold, + hostUserId, + hostName: settings.rehearsalHostName, })) ); }) From d0e9dfb62ea42ab0a9fcbea9ad8560beef74aac5 Mon Sep 17 00:00:00 2001 From: Nathan FONTEYNE Date: Tue, 11 Aug 2026 11:59:38 +0200 Subject: [PATCH 2/2] update: better colors for calendar light mode and see rejected rehersal in calendar --- public/css/style.css | 36 +++++++++++++++++++++++++++--------- public/js/calendar.js | 13 +++++++++---- src/lib/icsFeed.js | 8 +++++++- src/lib/rehearsalStatus.js | 16 ++++++++++++---- 4 files changed, 55 insertions(+), 18 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 1c62a8c..6bc7af9 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -15,6 +15,11 @@ --radius: 14px; --radius-sm: 8px; --shadow: 0 1px 2px rgba(32, 31, 43, 0.04), 0 4px 16px rgba(32, 31, 43, 0.06); + /* Higher than the dark-mode value below — pale accent tints on a white + surface read as barely-there in light mode, so light needs a stronger mix + to hit the same perceived contrast dark mode gets "for free" against a + dark surface. */ + --cal-marker-mix: 38%; font-family: "Segoe UI", system-ui, -apple-system, Roboto, sans-serif; } @@ -34,6 +39,7 @@ --success: #4fd190; --success-bg: #163828; --shadow: 0 1px 2px rgba(0, 0, 0, 0.2), 0 8px 24px rgba(0, 0, 0, 0.35); + --cal-marker-mix: 24%; } } @@ -51,6 +57,7 @@ --danger-bg: #3a201f; --success: #4fd190; --success-bg: #163828; + --cal-marker-mix: 24%; } :root[data-theme="light"] { @@ -67,6 +74,7 @@ --danger-bg: #fdecea; --success: #1e8e5a; --success-bg: #e8f7ef; + --cal-marker-mix: 38%; } * { box-sizing: border-box; } @@ -1567,6 +1575,10 @@ button.calendar-filters-close { display: none; } .vote-badge.accept { border-left: 3px solid var(--success); } .vote-badge.reject { border-left: 3px solid var(--danger); } +.vote-badge.rehearsal-status-confirmed { border-left: 3px solid var(--accent); } +.vote-badge.rehearsal-status-suggested { border-left: 3px solid var(--success); } +.vote-badge.rehearsal-status-rejected { border-left: 3px solid var(--danger); } + .rehearsal-voted-section { margin-top: 0.75rem; } .rehearsal-voted-section > summary { @@ -1595,16 +1607,22 @@ button.calendar-filters-close { display: none; } /* Concert/rehearsal markers take priority over the availability heat colors above (same specificity, later in the cascade) — a scheduled date matters - more than who's free that day. */ -.cal-cell.has-rehearsal { background: color-mix(in srgb, var(--accent) 20%, var(--surface)); border-color: var(--accent); } -.cal-cell.rehearsal-confirmed { background: color-mix(in srgb, var(--success) 20%, var(--surface)); border-color: var(--success); } -.cal-cell.rehearsal-suggested { background: color-mix(in srgb, var(--accent) 20%, var(--surface)); border-color: var(--accent); } -.cal-cell.has-concert { background: color-mix(in srgb, var(--accent-2) 20%, var(--surface)); border-color: var(--accent-2); } -.cal-cell.has-rehearsal.has-concert { - background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 20%, var(--surface)), color-mix(in srgb, var(--accent-2) 20%, var(--surface))); -} + more than who's free that day. + Colors: confirmed rehearsals are purple (accent) — the "settled, book it" + state; still-pending suggestions are green — an invitation to vote, not yet + settled; a rejected rehearsal (host said no, or too many refusals) is red. */ +.cal-cell.rehearsal-confirmed { background: color-mix(in srgb, var(--accent) var(--cal-marker-mix), var(--surface)); border-color: var(--accent); } +.cal-cell.rehearsal-suggested { background: color-mix(in srgb, var(--success) var(--cal-marker-mix), var(--surface)); border-color: var(--success); } +.cal-cell.rehearsal-rejected { background: color-mix(in srgb, var(--danger) var(--cal-marker-mix), var(--surface)); border-color: var(--danger); } +.cal-cell.has-concert { background: color-mix(in srgb, var(--accent-2) var(--cal-marker-mix), var(--surface)); border-color: var(--accent-2); } .cal-cell.rehearsal-confirmed.has-concert { - background: linear-gradient(135deg, color-mix(in srgb, var(--success) 20%, var(--surface)), color-mix(in srgb, var(--accent-2) 20%, var(--surface))); + background: linear-gradient(135deg, color-mix(in srgb, var(--accent) var(--cal-marker-mix), var(--surface)), color-mix(in srgb, var(--accent-2) var(--cal-marker-mix), var(--surface))); +} +.cal-cell.rehearsal-suggested.has-concert { + background: linear-gradient(135deg, color-mix(in srgb, var(--success) var(--cal-marker-mix), var(--surface)), color-mix(in srgb, var(--accent-2) var(--cal-marker-mix), var(--surface))); +} +.cal-cell.rehearsal-rejected.has-concert { + background: linear-gradient(135deg, color-mix(in srgb, var(--danger) var(--cal-marker-mix), var(--surface)), color-mix(in srgb, var(--accent-2) var(--cal-marker-mix), var(--surface))); } /* ---------- Concerts: upcoming list + detail actions ---------- */ diff --git a/public/js/calendar.js b/public/js/calendar.js index a6d6537..cc1bb98 100644 --- a/public/js/calendar.js +++ b/public/js/calendar.js @@ -106,6 +106,7 @@ function concertLinksHtml(concert) { function rehearsalStatusLabel(r) { if (r.status === 'confirmed') return 'Répétition confirmée'; + if (r.status === 'rejected') return 'Répétition refusée'; const accepted = r.votes.filter((v) => v.vote === 'accept').length; return `Suggestion de répétition (${accepted}/${r.confirmThreshold} votes)`; } @@ -216,10 +217,14 @@ function renderCalendar() { const rehearsal = rehearsalsByDate.get(isoDate(date)); if (rehearsal) { - const isConfirmed = rehearsal.status === 'confirmed'; - cell.classList.add('has-rehearsal', isConfirmed ? 'rehearsal-confirmed' : 'rehearsal-suggested'); + const statusTitles = { + confirmed: 'Répétition confirmée', + rejected: 'Répétition refusée', + suggested: 'Répétition proposée (en attente de votes)', + }; + cell.classList.add('has-rehearsal', `rehearsal-${rehearsal.status}`); const badge = document.createElement('span'); - badge.title = isConfirmed ? 'Répétition confirmée' : 'Répétition proposée (en attente de votes)'; + badge.title = statusTitles[rehearsal.status] || statusTitles.suggested; badge.textContent = '🎸'; badges.appendChild(badge); } @@ -522,7 +527,7 @@ function rehearsalRowTemplate(r) { return `
- ${escapeHtml(rehearsalStatusLabel(r))} + ${escapeHtml(rehearsalStatusLabel(r))}
${formatDatetime(r.startsAt)} – ${formatTime(r.endsAt)}
${r.location ? `${escapeHtml(r.location)} · ` : ''}Proposée par ${escapeHtml(r.proposedByName)}
${rehearsalAcceptedByHtml(r)} diff --git a/src/lib/icsFeed.js b/src/lib/icsFeed.js index d52fbc4..bafc5b1 100644 --- a/src/lib/icsFeed.js +++ b/src/lib/icsFeed.js @@ -40,8 +40,11 @@ function foldLine(line) { return chunks.map((chunk, i) => (i === 0 ? chunk : ` ${chunk}`)).join('\r\n'); } +// Returns null for a rejected rehearsal — those are dropped entirely from the +// shared feed (see buildRehearsalsFeed) rather than shown as tentative/cancelled. function rehearsalToEvent(rehearsal, { threshold, hostUserId, baseUrl, allUsers }) { const status = computeRehearsalStatus(rehearsal.votes, threshold, hostUserId); + if (status === 'rejected') return null; const icsStatus = status === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE'; const summary = status === 'confirmed' ? 'Répétition' : 'Répétition (proposition)'; const voteUrl = `${baseUrl}/calendar.html?rehearsalId=${rehearsal.id}`; @@ -82,7 +85,10 @@ function buildRehearsalsFeed(rehearsals, { threshold, hostUserId, baseUrl, allUs 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'X-WR-CALNAME:Répétitions Octane', - ...rehearsals.flatMap((r) => rehearsalToEvent(r, { threshold, hostUserId, baseUrl, allUsers })), + ...rehearsals + .map((r) => rehearsalToEvent(r, { threshold, hostUserId, baseUrl, allUsers })) + .filter(Boolean) + .flat(), 'END:VCALENDAR', ]; return lines.map(foldLine).join('\r\n'); diff --git a/src/lib/rehearsalStatus.js b/src/lib/rehearsalStatus.js index f91ee0d..bb1ac91 100644 --- a/src/lib/rehearsalStatus.js +++ b/src/lib/rehearsalStatus.js @@ -1,11 +1,19 @@ // Shared between the API (rehearsals list) and the ICS feed so both agree on -// when a proposed rehearsal counts as confirmed. When a host is configured, -// their acceptance is mandatory: without the host there is nowhere to play, -// so a rehearsal can never be "confirmed" on vote count alone. +// when a proposed rehearsal counts as confirmed, rejected, or still a +// suggestion. When a host is configured, their acceptance is mandatory: +// without the host there is nowhere to play, so an explicit host refusal (or +// too many "reject" votes) kills the rehearsal outright rather than leaving +// it as a pending suggestion. function computeRehearsalStatus(votes, threshold, hostUserId) { const acceptCount = votes.filter((v) => v.vote === 'accept').length; + const rejectCount = votes.filter((v) => v.vote === 'reject').length; + const hostVote = hostUserId ? votes.find((v) => v.userId === hostUserId) : null; + + if ((hostVote && hostVote.vote === 'reject') || rejectCount >= threshold) { + return 'rejected'; + } if (acceptCount < threshold) return 'suggested'; - if (hostUserId && !votes.some((v) => v.userId === hostUserId && v.vote === 'accept')) { + if (hostUserId && !(hostVote && hostVote.vote === 'accept')) { return 'suggested'; } return 'confirmed';