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) => `${escapeHtml(u.name)} `
+ );
+ return `(aucun) ${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).
+ Hôte des répétitions (⭐)
+
+
+
+ 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.
+
Enregistrer
@@ -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';