+
${escapeHtml(rehearsalStatusLabel(r))}
${formatDatetime(r.startsAt)} – ${formatTime(r.endsAt)}
${r.location ? `${escapeHtml(r.location)} · ` : ''}Proposée par ${escapeHtml(r.proposedByName)}
${rehearsalAcceptedByHtml(r)}
@@ -752,7 +759,22 @@ async function onRemoveMyFeed(feedId) {
await loadSlots();
await loadLastChecked();
await loadMyFeeds();
+ jumpToRehearsalFromUrl();
} catch (err) {
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);
+}
diff --git a/src/db/migrations/019_rehearsal_confirm_threshold.sql b/src/db/migrations/019_rehearsal_confirm_threshold.sql
new file mode 100644
index 0000000..63d408f
--- /dev/null
+++ b/src/db/migrations/019_rehearsal_confirm_threshold.sql
@@ -0,0 +1 @@
+ALTER TABLE calendar_settings ADD COLUMN IF NOT EXISTS rehearsal_confirm_threshold SMALLINT NOT NULL DEFAULT 4;
diff --git a/src/lib/icsFeed.js b/src/lib/icsFeed.js
index 45467c4..3020b39 100644
--- a/src/lib/icsFeed.js
+++ b/src/lib/icsFeed.js
@@ -1,3 +1,5 @@
+const { computeRehearsalStatus } = require('./rehearsalStatus');
+
// Builds a text/calendar feed of rehearsals for subscription (webcal://) in
// external calendar apps. Timestamps are emitted in UTC (`...Z`) so no
// VTIMEZONE block is needed — every client renders them in its own zone.
@@ -15,16 +17,17 @@ function escapeText(text) {
.replace(/\r?\n/g, '\\n');
}
-function rehearsalStatus(rehearsal) {
- const hasReject = rehearsal.votes.some((v) => v.vote === 'reject');
- const hasAccept = rehearsal.votes.some((v) => v.vote === 'accept');
- if (hasAccept && !hasReject) return 'CONFIRMED';
- return 'TENTATIVE';
-}
+function rehearsalToEvent(rehearsal, { threshold, baseUrl }) {
+ const status = computeRehearsalStatus(rehearsal.votes, threshold);
+ 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}`;
+ 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 = [
'BEGIN:VEVENT',
`UID:rehearsal-${rehearsal.id}@octane`,
@@ -32,14 +35,16 @@ function rehearsalToEvent(rehearsal) {
`DTSTART:${formatDateUTC(rehearsal.starts_at)}`,
`DTEND:${formatDateUTC(rehearsal.ends_at)}`,
`SUMMARY:${escapeText(summary)}`,
- `STATUS:${status}`,
+ `STATUS:${icsStatus}`,
+ `DESCRIPTION:${escapeText(description)}`,
+ `URL:${voteUrl}`,
];
if (rehearsal.location) lines.push(`LOCATION:${escapeText(rehearsal.location)}`);
lines.push('END:VEVENT');
return lines;
}
-function buildRehearsalsFeed(rehearsals) {
+function buildRehearsalsFeed(rehearsals, { threshold, baseUrl }) {
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
@@ -47,7 +52,7 @@ function buildRehearsalsFeed(rehearsals) {
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:Répétitions Octane',
- ...rehearsals.flatMap(rehearsalToEvent),
+ ...rehearsals.flatMap((r) => rehearsalToEvent(r, { threshold, baseUrl })),
'END:VCALENDAR',
];
return lines.join('\r\n');
diff --git a/src/lib/rehearsalStatus.js b/src/lib/rehearsalStatus.js
new file mode 100644
index 0000000..4f908e1
--- /dev/null
+++ b/src/lib/rehearsalStatus.js
@@ -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 };
diff --git a/src/repositories/calendarRepo.js b/src/repositories/calendarRepo.js
index 7a656ee..8d5f9ef 100644
--- a/src/repositories/calendarRepo.js
+++ b/src/repositories/calendarRepo.js
@@ -205,7 +205,7 @@ 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 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 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 },
marginMinutes: row.margin_minutes,
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(
`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
+ concert_start = $6, concert_end = $7, rehearsal_confirm_threshold = $8
WHERE id = 1
- RETURNING weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end`,
- [weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd]
+ 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]
);
return rows[0];
}
diff --git a/src/routes/calendar.js b/src/routes/calendar.js
index 0754023..d2ddca8 100644
--- a/src/routes/calendar.js
+++ b/src/routes/calendar.js
@@ -187,6 +187,7 @@ router.get(
marginMinutes: settings.marginMinutes,
concertStart: formatTime(settings.concert.startHour, settings.concert.startMinute),
concertEnd: formatTime(settings.concert.endHour, settings.concert.endMinute),
+ rehearsalConfirmThreshold: settings.rehearsalConfirmThreshold,
});
})
);
@@ -213,7 +214,7 @@ router.patch(
'/settings',
requireAdmin,
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 };
for (const [key, value] of Object.entries(times)) {
if (!TIME_RE.test(value || '')) {
@@ -233,8 +234,12 @@ router.patch(
if (!Number.isInteger(margin) || margin < 0 || margin > 180) {
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);
res.json(values);
})
diff --git a/src/routes/calendarFeed.js b/src/routes/calendarFeed.js
index d0fd290..b0cd094 100644
--- a/src/routes/calendarFeed.js
+++ b/src/routes/calendarFeed.js
@@ -5,6 +5,7 @@
const express = require('express');
const usersRepo = require('../repositories/usersRepo');
const rehearsalsRepo = require('../repositories/rehearsalsRepo');
+const calendarRepo = require('../repositories/calendarRepo');
const { buildRehearsalsFeed } = require('../lib/icsFeed');
const asyncHandler = require('../lib/asyncHandler');
@@ -16,10 +17,12 @@ router.get(
const user = await usersRepo.findByIcsToken(req.params.token);
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
.type('text/calendar; charset=utf-8')
- .send(buildRehearsalsFeed(rehearsals));
+ .send(buildRehearsalsFeed(rehearsals, { threshold: settings.rehearsalConfirmThreshold, baseUrl }));
})
);
diff --git a/src/routes/rehearsals.js b/src/routes/rehearsals.js
index d8b9835..7d58d7a 100644
--- a/src/routes/rehearsals.js
+++ b/src/routes/rehearsals.js
@@ -1,14 +1,17 @@
const express = require('express');
const rehearsalsRepo = require('../repositories/rehearsalsRepo');
+const calendarRepo = require('../repositories/calendarRepo');
const discord = require('../lib/discord');
const asyncHandler = require('../lib/asyncHandler');
+const { computeRehearsalStatus } = require('../lib/rehearsalStatus');
const router = express.Router();
router.get(
'/',
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(
rehearsals.map((r) => ({
id: r.id,
@@ -18,6 +21,8 @@ router.get(
proposedBy: r.proposed_by,
proposedByName: r.proposed_by_name,
votes: r.votes,
+ status: computeRehearsalStatus(r.votes, threshold),
+ confirmThreshold: threshold,
}))
);
})