update: description in ics shared calendar, and realtive display depending on the votes

This commit is contained in:
Nathan FONTEYNE 2026-07-31 11:51:05 +02:00
parent 5759a1f248
commit 212016e88f
10 changed files with 92 additions and 25 deletions

View file

@ -1528,6 +1528,13 @@ button.calendar-filters-close { display: none; }
.rehearsal-row:last-child { border-bottom: none; } .rehearsal-row:last-child { border-bottom: none; }
.rehearsal-row.highlight {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 4px;
transition: outline-color 3s ease;
}
.rehearsal-actions { .rehearsal-actions {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -189,6 +189,7 @@ async function loadSlotSettingsForm() {
form.marginMinutes.value = settings.marginMinutes; form.marginMinutes.value = settings.marginMinutes;
form.concertStart.value = settings.concertStart; form.concertStart.value = settings.concertStart;
form.concertEnd.value = settings.concertEnd; form.concertEnd.value = settings.concertEnd;
form.rehearsalConfirmThreshold.value = settings.rehearsalConfirmThreshold;
} }
async function onSaveSlotSettings(e) { async function onSaveSlotSettings(e) {
@ -205,6 +206,7 @@ async function onSaveSlotSettings(e) {
marginMinutes: parseInt(form.marginMinutes.value, 10), marginMinutes: parseInt(form.marginMinutes.value, 10),
concertStart: form.concertStart.value, concertStart: form.concertStart.value,
concertEnd: form.concertEnd.value, concertEnd: form.concertEnd.value,
rehearsalConfirmThreshold: parseInt(form.rehearsalConfirmThreshold.value, 10),
}); });
statusEl.textContent = 'Horaires enregistrés.'; statusEl.textContent = 'Horaires enregistrés.';
} catch (err) { } catch (err) {
@ -267,6 +269,14 @@ async function onSaveSlotSettings(e) {
Comme la date d'un concert n'a pas d'heure enregistrée, ces horaires sont utilisés pour Comme la date d'un concert n'a pas d'heure enregistrée, ces horaires sont utilisés pour
pré-remplir les liens « ajouter à mon agenda » sur <code>/calendar.html</code> et <code>/concerts.html</code>. pré-remplir les liens « ajouter à mon agenda » sur <code>/calendar.html</code> et <code>/concerts.html</code>.
</p> </p>
<label>Seuil de confirmation d'une répétition (votes « accepter »)
<input type="number" name="rehearsalConfirmThreshold" min="1" max="50" required>
</label>
<p class="note">
Une répétition proposée reste une « suggestion » tant que ce nombre de votes « accepter »
n'est pas atteint ; elle passe ensuite en répétition confirmée (dans l'application et dans le
flux ICS).
</p>
<p class="note" id="slot-settings-status"></p> <p class="note" id="slot-settings-status"></p>
<button type="submit">Enregistrer</button> <button type="submit">Enregistrer</button>
</form> </form>

View file

@ -104,11 +104,17 @@ function concertLinksHtml(concert) {
`; `;
} }
function rehearsalStatusLabel(r) {
if (r.status === 'confirmed') return 'Répétition confirmée';
const accepted = r.votes.filter((v) => v.vote === 'accept').length;
return `Suggestion de répétition (${accepted}/${r.confirmThreshold} votes)`;
}
function rehearsalInfoHtml(r) { function rehearsalInfoHtml(r) {
const linkArgs = { uid: `rehearsal-${r.id}`, title: 'Répétition Octane', startISO: r.startsAt, endISO: r.endsAt, location: r.location }; const linkArgs = { uid: `rehearsal-${r.id}`, title: 'Répétition Octane', startISO: r.startsAt, endISO: r.endsAt, location: r.location };
return ` return `
<div class="modal-section"> <div class="modal-section">
<div class="modal-section-title">Répétition proposée</div> <div class="modal-section-title">${escapeHtml(rehearsalStatusLabel(r))}</div>
<p class="note" style="margin:0.25rem 0 0.5rem">${r.location ? escapeHtml(r.location) + ' · ' : ''}${formatTime(r.startsAt)} ${formatTime(r.endsAt)} · Proposée par ${escapeHtml(r.proposedByName)}</p> <p class="note" style="margin:0.25rem 0 0.5rem">${r.location ? escapeHtml(r.location) + ' · ' : ''}${formatTime(r.startsAt)} ${formatTime(r.endsAt)} · Proposée par ${escapeHtml(r.proposedByName)}</p>
${rehearsalAcceptedByHtml(r)} ${rehearsalAcceptedByHtml(r)}
<div class="rehearsal-actions"> <div class="rehearsal-actions">
@ -364,9 +370,9 @@ function openModal(date, slot, visible, concert, rehearsal) {
currentModalDate = { date, slot }; currentModalDate = { date, slot };
const proposeBtn = document.getElementById('modal-propose-rehearsal-btn'); const proposeBtn = document.getElementById('modal-propose-rehearsal-btn');
const availabilitySection = document.getElementById('modal-availability-section'); const availabilitySection = document.getElementById('modal-availability-section');
const rehearsalAccepted = rehearsal && rehearsal.votes.some((v) => v.vote === 'accept'); const rehearsalConfirmed = rehearsal && rehearsal.status === 'confirmed';
if (slot) { if (slot) {
availabilitySection.style.display = rehearsalAccepted ? 'none' : ''; availabilitySection.style.display = rehearsalConfirmed ? 'none' : '';
proposeBtn.style.display = ''; proposeBtn.style.display = '';
const alreadyProposed = state.rehearsals.some((r) => isoDate(new Date(r.startsAt)) === isoDate(date)); const alreadyProposed = state.rehearsals.some((r) => isoDate(new Date(r.startsAt)) === isoDate(date));
proposeBtn.disabled = alreadyProposed; proposeBtn.disabled = alreadyProposed;
@ -499,6 +505,7 @@ function rehearsalRowTemplate(r) {
return ` return `
<div class="rehearsal-row" data-rehearsal-id="${r.id}"> <div class="rehearsal-row" data-rehearsal-id="${r.id}">
<div> <div>
<span class="vote-badge ${r.status === 'confirmed' ? 'accept' : ''}">${escapeHtml(rehearsalStatusLabel(r))}</span>
<div class="card-title">${formatDatetime(r.startsAt)} ${formatTime(r.endsAt)}</div> <div class="card-title">${formatDatetime(r.startsAt)} ${formatTime(r.endsAt)}</div>
<div class="card-subtitle">${r.location ? `${escapeHtml(r.location)} · ` : ''}Proposée par ${escapeHtml(r.proposedByName)}</div> <div class="card-subtitle">${r.location ? `${escapeHtml(r.location)} · ` : ''}Proposée par ${escapeHtml(r.proposedByName)}</div>
${rehearsalAcceptedByHtml(r)} ${rehearsalAcceptedByHtml(r)}
@ -752,7 +759,22 @@ async function onRemoveMyFeed(feedId) {
await loadSlots(); await loadSlots();
await loadLastChecked(); await loadLastChecked();
await loadMyFeeds(); await loadMyFeeds();
jumpToRehearsalFromUrl();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
})(); })();
// Supports the "Votez ici" link embedded in the ICS feed (calendar.html?rehearsalId=N):
// scroll straight to that rehearsal's row in the list and highlight it briefly.
function jumpToRehearsalFromUrl() {
const rehearsalId = new URLSearchParams(window.location.search).get('rehearsalId');
if (!rehearsalId) return;
const row = document.querySelector(`.rehearsal-row[data-rehearsal-id="${rehearsalId}"]`);
if (!row) return;
const details = row.closest('details');
if (details) details.open = true;
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
row.classList.add('highlight');
setTimeout(() => row.classList.remove('highlight'), 3000);
}

View file

@ -0,0 +1 @@
ALTER TABLE calendar_settings ADD COLUMN IF NOT EXISTS rehearsal_confirm_threshold SMALLINT NOT NULL DEFAULT 4;

View file

@ -1,3 +1,5 @@
const { computeRehearsalStatus } = require('./rehearsalStatus');
// Builds a text/calendar feed of rehearsals for subscription (webcal://) in // Builds a text/calendar feed of rehearsals for subscription (webcal://) in
// external calendar apps. Timestamps are emitted in UTC (`...Z`) so no // external calendar apps. Timestamps are emitted in UTC (`...Z`) so no
// VTIMEZONE block is needed — every client renders them in its own zone. // VTIMEZONE block is needed — every client renders them in its own zone.
@ -15,16 +17,17 @@ function escapeText(text) {
.replace(/\r?\n/g, '\\n'); .replace(/\r?\n/g, '\\n');
} }
function rehearsalStatus(rehearsal) { function rehearsalToEvent(rehearsal, { threshold, baseUrl }) {
const hasReject = rehearsal.votes.some((v) => v.vote === 'reject'); const status = computeRehearsalStatus(rehearsal.votes, threshold);
const hasAccept = rehearsal.votes.some((v) => v.vote === 'accept'); const icsStatus = status === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE';
if (hasAccept && !hasReject) return 'CONFIRMED'; const summary = status === 'confirmed' ? 'Répétition' : 'Répétition (proposition)';
return 'TENTATIVE'; const voteUrl = `${baseUrl}/calendar.html?rehearsalId=${rehearsal.id}`;
} const accepted = rehearsal.votes.filter((v) => v.vote === 'accept').map((v) => v.name);
const description = [
`Votez ici : ${voteUrl}`,
accepted.length ? `Ont accepté : ${accepted.join(', ')}` : "Personne n'a encore accepté.",
].join('\n');
function rehearsalToEvent(rehearsal) {
const status = rehearsalStatus(rehearsal);
const summary = status === 'CONFIRMED' ? 'Répétition' : 'Répétition (proposition)';
const lines = [ const lines = [
'BEGIN:VEVENT', 'BEGIN:VEVENT',
`UID:rehearsal-${rehearsal.id}@octane`, `UID:rehearsal-${rehearsal.id}@octane`,
@ -32,14 +35,16 @@ function rehearsalToEvent(rehearsal) {
`DTSTART:${formatDateUTC(rehearsal.starts_at)}`, `DTSTART:${formatDateUTC(rehearsal.starts_at)}`,
`DTEND:${formatDateUTC(rehearsal.ends_at)}`, `DTEND:${formatDateUTC(rehearsal.ends_at)}`,
`SUMMARY:${escapeText(summary)}`, `SUMMARY:${escapeText(summary)}`,
`STATUS:${status}`, `STATUS:${icsStatus}`,
`DESCRIPTION:${escapeText(description)}`,
`URL:${voteUrl}`,
]; ];
if (rehearsal.location) lines.push(`LOCATION:${escapeText(rehearsal.location)}`); if (rehearsal.location) lines.push(`LOCATION:${escapeText(rehearsal.location)}`);
lines.push('END:VEVENT'); lines.push('END:VEVENT');
return lines; return lines;
} }
function buildRehearsalsFeed(rehearsals) { function buildRehearsalsFeed(rehearsals, { threshold, baseUrl }) {
const lines = [ const lines = [
'BEGIN:VCALENDAR', 'BEGIN:VCALENDAR',
'VERSION:2.0', 'VERSION:2.0',
@ -47,7 +52,7 @@ function buildRehearsalsFeed(rehearsals) {
'CALSCALE:GREGORIAN', 'CALSCALE:GREGORIAN',
'METHOD:PUBLISH', 'METHOD:PUBLISH',
'X-WR-CALNAME:Répétitions Octane', 'X-WR-CALNAME:Répétitions Octane',
...rehearsals.flatMap(rehearsalToEvent), ...rehearsals.flatMap((r) => rehearsalToEvent(r, { threshold, baseUrl })),
'END:VCALENDAR', 'END:VCALENDAR',
]; ];
return lines.join('\r\n'); return lines.join('\r\n');

View file

@ -0,0 +1,8 @@
// 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) {
const acceptCount = votes.filter((v) => v.vote === 'accept').length;
return acceptCount >= threshold ? 'confirmed' : 'suggested';
}
module.exports = { computeRehearsalStatus };

View file

@ -205,7 +205,7 @@ function parseTime(hhmmss) {
async function getSlotSettings() { async function getSlotSettings() {
const { rows } = await pool.query( const { rows } = await pool.query(
'SELECT weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end FROM calendar_settings WHERE id = 1' 'SELECT weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end, rehearsal_confirm_threshold FROM calendar_settings WHERE id = 1'
); );
const row = rows[0]; const row = rows[0];
const weekdayStart = parseTime(row.weekday_start); const weekdayStart = parseTime(row.weekday_start);
@ -219,17 +219,18 @@ async function getSlotSettings() {
weekend: { startHour: weekendStart.hour, startMinute: weekendStart.minute, endHour: weekendEnd.hour, endMinute: weekendEnd.minute }, weekend: { startHour: weekendStart.hour, startMinute: weekendStart.minute, endHour: weekendEnd.hour, endMinute: weekendEnd.minute },
marginMinutes: row.margin_minutes, marginMinutes: row.margin_minutes,
concert: { startHour: concertStart.hour, startMinute: concertStart.minute, endHour: concertEnd.hour, endMinute: concertEnd.minute }, concert: { startHour: concertStart.hour, startMinute: concertStart.minute, endHour: concertEnd.hour, endMinute: concertEnd.minute },
rehearsalConfirmThreshold: row.rehearsal_confirm_threshold,
}; };
} }
async function updateSlotSettings({ weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd }) { async function updateSlotSettings({ weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold }) {
const { rows } = await pool.query( const { rows } = await pool.query(
`UPDATE calendar_settings `UPDATE calendar_settings
SET weekday_start = $1, weekday_end = $2, weekend_start = $3, weekend_end = $4, margin_minutes = $5, SET weekday_start = $1, weekday_end = $2, weekend_start = $3, weekend_end = $4, margin_minutes = $5,
concert_start = $6, concert_end = $7 concert_start = $6, concert_end = $7, rehearsal_confirm_threshold = $8
WHERE id = 1 WHERE id = 1
RETURNING weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end`, 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] [weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold]
); );
return rows[0]; return rows[0];
} }

View file

@ -187,6 +187,7 @@ router.get(
marginMinutes: settings.marginMinutes, marginMinutes: settings.marginMinutes,
concertStart: formatTime(settings.concert.startHour, settings.concert.startMinute), concertStart: formatTime(settings.concert.startHour, settings.concert.startMinute),
concertEnd: formatTime(settings.concert.endHour, settings.concert.endMinute), concertEnd: formatTime(settings.concert.endHour, settings.concert.endMinute),
rehearsalConfirmThreshold: settings.rehearsalConfirmThreshold,
}); });
}) })
); );
@ -213,7 +214,7 @@ router.patch(
'/settings', '/settings',
requireAdmin, requireAdmin,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd } = req.body || {}; const { weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold } = req.body || {};
const times = { weekdayStart, weekdayEnd, weekendStart, weekendEnd, concertStart, concertEnd }; const times = { weekdayStart, weekdayEnd, weekendStart, weekendEnd, concertStart, concertEnd };
for (const [key, value] of Object.entries(times)) { for (const [key, value] of Object.entries(times)) {
if (!TIME_RE.test(value || '')) { if (!TIME_RE.test(value || '')) {
@ -233,8 +234,12 @@ router.patch(
if (!Number.isInteger(margin) || margin < 0 || margin > 180) { if (!Number.isInteger(margin) || margin < 0 || margin > 180) {
return res.status(400).json({ error: 'invalid_margin_minutes' }); return res.status(400).json({ error: 'invalid_margin_minutes' });
} }
const threshold = Number(rehearsalConfirmThreshold);
if (!Number.isInteger(threshold) || threshold < 1 || threshold > 50) {
return res.status(400).json({ error: 'invalid_rehearsal_confirm_threshold' });
}
const values = { ...times, marginMinutes: margin }; const values = { ...times, marginMinutes: margin, rehearsalConfirmThreshold: threshold };
await calendarRepo.updateSlotSettings(values); await calendarRepo.updateSlotSettings(values);
res.json(values); res.json(values);
}) })

View file

@ -5,6 +5,7 @@
const express = require('express'); const express = require('express');
const usersRepo = require('../repositories/usersRepo'); const usersRepo = require('../repositories/usersRepo');
const rehearsalsRepo = require('../repositories/rehearsalsRepo'); const rehearsalsRepo = require('../repositories/rehearsalsRepo');
const calendarRepo = require('../repositories/calendarRepo');
const { buildRehearsalsFeed } = require('../lib/icsFeed'); const { buildRehearsalsFeed } = require('../lib/icsFeed');
const asyncHandler = require('../lib/asyncHandler'); const asyncHandler = require('../lib/asyncHandler');
@ -16,10 +17,12 @@ router.get(
const user = await usersRepo.findByIcsToken(req.params.token); const user = await usersRepo.findByIcsToken(req.params.token);
if (!user) return res.status(404).send('Not found'); if (!user) return res.status(404).send('Not found');
const rehearsals = await rehearsalsRepo.findUpcoming(); const [rehearsals, settings] = await Promise.all([rehearsalsRepo.findUpcoming(), calendarRepo.getSlotSettings()]);
// req.protocol honors X-Forwarded-Proto here — see app.set('trust proxy', 1) in app.js.
const baseUrl = `${req.protocol}://${req.get('host')}`;
res res
.type('text/calendar; charset=utf-8') .type('text/calendar; charset=utf-8')
.send(buildRehearsalsFeed(rehearsals)); .send(buildRehearsalsFeed(rehearsals, { threshold: settings.rehearsalConfirmThreshold, baseUrl }));
}) })
); );

View file

@ -1,14 +1,17 @@
const express = require('express'); const express = require('express');
const rehearsalsRepo = require('../repositories/rehearsalsRepo'); const rehearsalsRepo = require('../repositories/rehearsalsRepo');
const calendarRepo = require('../repositories/calendarRepo');
const discord = require('../lib/discord'); const discord = require('../lib/discord');
const asyncHandler = require('../lib/asyncHandler'); const asyncHandler = require('../lib/asyncHandler');
const { computeRehearsalStatus } = require('../lib/rehearsalStatus');
const router = express.Router(); const router = express.Router();
router.get( router.get(
'/', '/',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const rehearsals = await rehearsalsRepo.findUpcoming(); const [rehearsals, settings] = await Promise.all([rehearsalsRepo.findUpcoming(), calendarRepo.getSlotSettings()]);
const threshold = settings.rehearsalConfirmThreshold;
res.json( res.json(
rehearsals.map((r) => ({ rehearsals.map((r) => ({
id: r.id, id: r.id,
@ -18,6 +21,8 @@ router.get(
proposedBy: r.proposed_by, proposedBy: r.proposed_by,
proposedByName: r.proposed_by_name, proposedByName: r.proposed_by_name,
votes: r.votes, votes: r.votes,
status: computeRehearsalStatus(r.votes, threshold),
confirmThreshold: threshold,
})) }))
); );
}) })