fix: show all upcomming concerts in calendar add url to export the slot

This commit is contained in:
Nathan FONTEYNE 2026-07-10 15:27:07 +02:00
parent fea5c30d69
commit 16110d8ad3
10 changed files with 227 additions and 89 deletions

View file

@ -274,11 +274,11 @@ erDiagram
|---|---|---| |---|---|---|
| `/index.html` | Tous (lecture et écriture) | Répertoire des morceaux travaillés, avec recherche instantanée (titre/artiste), liens/vignettes YouTube et Spotify, tutos embarqués par morceau et par instrument. Ajout avec autocomplete titre/artiste + liens auto-trouvés ([détails](#recherche-automatique-de-morceaux)) | | `/index.html` | Tous (lecture et écriture) | Répertoire des morceaux travaillés, avec recherche instantanée (titre/artiste), liens/vignettes YouTube et Spotify, tutos embarqués par morceau et par instrument. Ajout avec autocomplete titre/artiste + liens auto-trouvés ([détails](#recherche-automatique-de-morceaux)) |
| `/suggestions.html` | Tous | Proposer un morceau (liens YouTube et Spotify + note libre), voter approuver/rejeter avec commentaire (attribué nominativement), ajouter une suggestion au répertoire | | `/suggestions.html` | Tous | Proposer un morceau (liens YouTube et Spotify + note libre), voter approuver/rejeter avec commentaire (attribué nominativement), ajouter une suggestion au répertoire |
| `/concerts.html` | Tous (lecture et écriture) | Deux onglets sur une même page. **Prochain concert** : liste de tous les concerts à venir (pas seulement le plus proche), créer autant de concerts à venir que nécessaire ; chaque ligne a des actions Modifier/Supprimer directement visibles à droite (icône seule sur mobile) ; ouvrir un concert donne accès au choix des morceaux du répertoire, ordre, notes, section rappel, lien "Écouter la setlist sur YouTube". **Historique** : concerts passés, modifiables (date, morceaux, ordre, rappel) et supprimables, avec le même lien playlist ; deux vues, chronologique (par défaut, tous les concerts détaillés avec YouTube embarqué par morceau, du plus récent au plus ancien) et réduite (liste compacte) ; ouvrir un concert passé (`?tab=history&id=...`) reste partageable en lien direct | | `/concerts.html` | Tous (lecture et écriture) | Deux onglets sur une même page. **Prochain concert** : liste de tous les concerts à venir (pas seulement le plus proche), créer autant de concerts à venir que nécessaire ; chaque ligne a des actions Modifier/Supprimer directement visibles à droite (icône seule sur mobile) ; ouvrir un concert donne accès au choix des morceaux du répertoire, ordre, notes, section rappel, lien "Écouter la setlist sur YouTube", et aux liens "ajouter à mon calendrier" (Google/Outlook/Apple). **Historique** : concerts passés, modifiables (date, morceaux, ordre, rappel) et supprimables, avec le même lien playlist ; deux vues, chronologique (par défaut, tous les concerts détaillés avec YouTube embarqué par morceau, du plus récent au plus ancien) et réduite (liste compacte) ; ouvrir un concert passé (`?tab=history&id=...`) reste partageable en lien direct |
| `/profile.html` | Chacun voit le sien | Profil issu d'Authentik (nom, avatar, groupes) + activité personnelle (morceaux ajoutés, suggestions, votes) ; nom affiché personnalisable (par défaut celui d'Authentik) ; gestion de ses calendriers ICS pour les disponibilités | | `/profile.html` | Chacun voit le sien | Profil issu d'Authentik (nom, avatar, groupes) + activité personnelle (morceaux ajoutés, suggestions, votes) ; nom affiché personnalisable (par défaut celui d'Authentik) ; gestion de ses calendriers ICS pour les disponibilités |
| `/calendar.html` | Tous (lecture et écriture) | Disponibilités du groupe pour les 3 prochaines semaines (calendrier, filtres par personne, modale par jour) ; gestion de ses propres calendriers ICS directement depuis la page ; proposer une date de répétition et l'ajouter en un clic à son calendrier (Google/Outlook/Apple) — [détails](#disponibilités-calendrier) | | `/calendar.html` | Tous (lecture et écriture) | Disponibilités du groupe pour les 3 prochaines semaines (calendrier, filtres par personne, modale par jour) ; gestion de ses propres calendriers ICS directement depuis la page ; proposer une date de répétition et l'ajouter en un clic à son calendrier (Google/Outlook/Apple) ; tous les concerts à venir situés dans les 3 semaines affichées sont aussi repérés sur la grille, avec les mêmes liens "ajouter à mon calendrier" en cliquant sur la date — [détails](#disponibilités-calendrier) |
| `/calendar-ics-help.html` | Tous | Page d'aide : qu'est-ce qu'un lien ICS, comment le trouver sur Google/Outlook/Apple, comment le révoquer/régénérer | | `/calendar-ics-help.html` | Tous | Page d'aide : qu'est-ce qu'un lien ICS, comment le trouver sur Google/Outlook/Apple, comment le révoquer/régénérer |
| `/admin.html` | Admins uniquement | Tableau de bord : statistiques globales (répertoire, suggestions, concerts) et activité par utilisateur ; horaires de répétition et marge de transport pour les disponibilités ; gestion des calendriers ICS de n'importe quel utilisateur | | `/admin.html` | Admins uniquement | Tableau de bord : statistiques globales (répertoire, suggestions, concerts) et activité par utilisateur ; horaires de répétition, marge de transport et horaire par défaut d'un concert pour les disponibilités ; gestion des calendriers ICS de n'importe quel utilisateur |
Le mode par défaut est la consultation ; les pages Répertoire, Concerts et Suggestions sont interactives pour toute personne connectée (chaque action reste attribuée nominativement via Authentik). Le mode par défaut est la consultation ; les pages Répertoire, Concerts et Suggestions sont interactives pour toute personne connectée (chaque action reste attribuée nominativement via Authentik).
@ -287,7 +287,7 @@ Un bouton clair/sombre dans la barre de navigation permet de forcer un thème (m
## Rôles ## Rôles
- **Membre** : tout le monde — consulte, ajoute/modifie/supprime des morceaux du répertoire et leurs tutos, crée/modifie/supprime des concerts (à venir ou passés) et leur setlist, propose des suggestions, vote/commente, ajoute une suggestion au répertoire, gère ses propres calendriers ICS pour les disponibilités. - **Membre** : tout le monde — consulte, ajoute/modifie/supprime des morceaux du répertoire et leurs tutos, crée/modifie/supprime des concerts (à venir ou passés) et leur setlist, propose des suggestions, vote/commente, ajoute une suggestion au répertoire, gère ses propres calendriers ICS pour les disponibilités.
- **Admin** : en plus, rejette ou supprime une suggestion (modération), accède au tableau de bord (`/admin.html` — statistiques, activité par utilisateur), configure les horaires/marge de répétition, et gère les calendriers ICS de n'importe quel utilisateur. - **Admin** : en plus, rejette ou supprime une suggestion (modération), accède au tableau de bord (`/admin.html` — statistiques, activité par utilisateur), configure les horaires/marge de répétition et l'horaire par défaut d'un concert, et gère les calendriers ICS de n'importe quel utilisateur.
Il n'y a pas de gestion des comptes utilisateurs dans l'application elle-même — Authentik reste la seule source de vérité pour qui a accès et qui est admin (claim `groups`, recalculé à chaque connexion). Il n'y a pas de gestion des comptes utilisateurs dans l'application elle-même — Authentik reste la seule source de vérité pour qui a accès et qui est admin (claim `groups`, recalculé à chaque connexion).
@ -314,7 +314,7 @@ Une vraie **playlist Spotify** (persistante, sur un compte Spotify) nécessitera
## Disponibilités (calendrier) ## Disponibilités (calendrier)
La page `/calendar.html` montre, pour les 3 prochaines semaines, les créneaux de répétition où chaque membre est disponible (par défaut lunven 18h3021h, samdim 15h19h — modifiable par un admin depuis `/admin.html`, section "Créneaux de répétition"). Cette section permet aussi de définir une **marge de transport** (en minutes) : la vérification de disponibilité regarde alors un peu avant et un peu après le créneau affiché, pour tenir compte du temps de trajet entre deux évènements du calendrier d'une personne — le créneau lui-même, tel qu'affiché, ne change pas. La disponibilité est déduite directement par l'application, sans service externe : chaque personne peut enregistrer un ou plusieurs calendriers (Google, Outlook, Apple, ou tout autre service exposant un flux ICS/iCal). La page `/calendar.html` montre, pour les 3 prochaines semaines, les créneaux de répétition où chaque membre est disponible (par défaut lunven 18h3021h, samdim 15h19h — modifiable par un admin depuis `/admin.html`, section "Créneaux de répétition"). Cette section permet aussi de définir une **marge de transport** (en minutes) : la vérification de disponibilité regarde alors un peu avant et un peu après le créneau affiché, pour tenir compte du temps de trajet entre deux évènements du calendrier d'une personne — le créneau lui-même, tel qu'affiché, ne change pas. La même section permet aussi de définir l'**horaire par défaut d'un concert** (19h22h par défaut) : comme la date d'un concert n'a pas d'heure enregistrée, ces horaires servent uniquement à pré-remplir les liens "ajouter à mon calendrier" décrits plus bas. La disponibilité est déduite directement par l'application, sans service externe : chaque personne peut enregistrer un ou plusieurs calendriers (Google, Outlook, Apple, ou tout autre service exposant un flux ICS/iCal).
### Gérer ses calendriers ### Gérer ses calendriers
@ -331,6 +331,14 @@ En cliquant sur un jour du calendrier, n'importe qui peut proposer ce créneau c
Seul l'auteur d'une proposition (ou un admin) peut la retirer. Il n'y a pas de vote/confirmation : plusieurs propositions peuvent coexister, c'est volontairement simple pour l'instant. Seul l'auteur d'une proposition (ou un admin) peut la retirer. Il n'y a pas de vote/confirmation : plusieurs propositions peuvent coexister, c'est volontairement simple pour l'instant.
### Ajouter un concert à son agenda
Les mêmes trois liens "ajouter à mon calendrier" (Google, Outlook, Apple/`.ics`) sont proposés pour un **concert** :
- Sur `/calendar.html`, en cliquant sur une date qui affiche le badge 🎤 (le jour est cliquable même sans donnée de disponibilité ce jour-là).
- Sur `/concerts.html`, en cliquant sur un concert à venir (onglet "Prochain concert") — les liens apparaissent dans le détail du concert, pas sur un concert passé (historique).
Comme `concert_date` n'enregistre qu'une date (pas d'heure), l'horaire de l'évènement ajouté est synthétisé à partir de l'horaire par défaut configuré par un admin (voir ci-dessus, 19h22h par défaut).
Dans les trois cas, un lien renvoie vers `/calendar-ics-help.html` (qu'est-ce qu'un lien ICS, comment le trouver sur Google/Outlook/Apple, comment le révoquer/régénérer en cas de doute). À l'ajout, l'application **teste immédiatement** le lien (récupère et tente de parser le flux) et refuse de l'enregistrer si aucune donnée de calendrier n'y est trouvée — pour éviter d'enregistrer silencieusement un lien invalide ou erroné qui ne remonterait qu'à la prochaine synchronisation. Dans les trois cas, un lien renvoie vers `/calendar-ics-help.html` (qu'est-ce qu'un lien ICS, comment le trouver sur Google/Outlook/Apple, comment le révoquer/régénérer en cas de doute). À l'ajout, l'application **teste immédiatement** le lien (récupère et tente de parser le flux) et refuse de l'enregistrer si aucune donnée de calendrier n'y est trouvée — pour éviter d'enregistrer silencieusement un lien invalide ou erroné qui ne remonterait qu'à la prochaine synchronisation.
### Fonctionnement ### Fonctionnement

View file

@ -62,6 +62,8 @@
<div class="modal card"> <div class="modal card">
<button type="button" class="icon-btn secondary modal-close" id="modal-close">&times;</button> <button type="button" class="icon-btn secondary modal-close" id="modal-close">&times;</button>
<div class="modal-date card-title" id="modal-date"></div> <div class="modal-date card-title" id="modal-date"></div>
<div id="modal-concert-section"></div>
<div id="modal-availability-section">
<div class="modal-time note" id="modal-time"></div> <div class="modal-time note" id="modal-time"></div>
<div class="modal-section"> <div class="modal-section">
<div class="modal-section-title available">Disponibles</div> <div class="modal-section-title available">Disponibles</div>
@ -71,6 +73,7 @@
<div class="modal-section-title busy">Indisponibles</div> <div class="modal-section-title busy">Indisponibles</div>
<div class="modal-people" id="modal-busy"></div> <div class="modal-people" id="modal-busy"></div>
</div> </div>
</div>
<button type="button" class="secondary" id="modal-propose-rehearsal-btn" style="margin-top:1rem; width:100%; white-space:normal; line-height:1.3;"> <button type="button" class="secondary" id="modal-propose-rehearsal-btn" style="margin-top:1rem; width:100%; white-space:normal; line-height:1.3;">
Proposer cette date pour la prochaine répétition Proposer cette date pour la prochaine répétition
</button> </button>

View file

@ -125,6 +125,8 @@ async function loadSlotSettingsForm() {
form.weekendStart.value = settings.weekendStart; form.weekendStart.value = settings.weekendStart;
form.weekendEnd.value = settings.weekendEnd; form.weekendEnd.value = settings.weekendEnd;
form.marginMinutes.value = settings.marginMinutes; form.marginMinutes.value = settings.marginMinutes;
form.concertStart.value = settings.concertStart;
form.concertEnd.value = settings.concertEnd;
} }
async function onSaveSlotSettings(e) { async function onSaveSlotSettings(e) {
@ -139,6 +141,8 @@ async function onSaveSlotSettings(e) {
weekendStart: form.weekendStart.value, weekendStart: form.weekendStart.value,
weekendEnd: form.weekendEnd.value, weekendEnd: form.weekendEnd.value,
marginMinutes: parseInt(form.marginMinutes.value, 10), marginMinutes: parseInt(form.marginMinutes.value, 10),
concertStart: form.concertStart.value,
concertEnd: form.concertEnd.value,
}); });
statusEl.textContent = 'Horaires enregistrés.'; statusEl.textContent = 'Horaires enregistrés.';
} catch (err) { } catch (err) {
@ -195,6 +199,12 @@ async function onSaveSlotSettings(e) {
La marge élargit uniquement la vérification de disponibilité (avant/après le créneau), La marge élargit uniquement la vérification de disponibilité (avant/après le créneau),
pour tenir compte du temps de trajet entre deux évènements le créneau affiché ne change pas. pour tenir compte du temps de trajet entre deux évènements le créneau affiché ne change pas.
</p> </p>
<label>Concert début par défaut <input type="time" name="concertStart" required></label>
<label>Concert fin par défaut <input type="time" name="concertEnd" required></label>
<p class="note">
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>.
</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

@ -3,7 +3,8 @@ let state = {
slots: [], slots: [],
personIds: [], personIds: [],
rehearsals: [], rehearsals: [],
nextConcert: null, upcomingConcerts: [],
concertHours: { start: '19:00', end: '22:00' },
}; };
let me = null; let me = null;
let currentModalDate = null; let currentModalDate = null;
@ -24,11 +25,44 @@ async function loadRehearsals() {
renderCalendar(); renderCalendar();
} }
async function loadNextConcert() { async function loadUpcomingConcerts() {
state.nextConcert = await api.get('/api/setlists/next'); state.upcomingConcerts = await api.get('/api/setlists/upcoming');
renderCalendar(); renderCalendar();
} }
async function loadConcertHours() {
state.concertHours = await api.get('/api/calendar/concert-hours');
}
// concert_date has no time-of-day (a plain DATE column) — synthesize a
// start/end using the admin-configured default concert hours, assuming the
// browser's local timezone is close enough to Europe/Paris (same convention
// used elsewhere on this page for matching concert_date to grid cells).
function concertTimesFor(concertDateStr) {
const d = new Date(concertDateStr);
const [startHour, startMinute] = state.concertHours.start.split(':').map(Number);
const [endHour, endMinute] = state.concertHours.end.split(':').map(Number);
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate(), startHour, startMinute);
const end = new Date(d.getFullYear(), d.getMonth(), d.getDate(), endHour, endMinute);
return { startISO: start.toISOString(), endISO: end.toISOString() };
}
function concertLinksHtml(concert) {
const { startISO, endISO } = concertTimesFor(concert.concert_date);
const linkArgs = { uid: `concert-${concert.id}`, title: concert.name || 'Concert Octane', startISO, endISO, location: concert.venue };
return `
<div class="modal-section">
<div class="modal-section-title">Concert${concert.name ? ' : ' + escapeHtml(concert.name) : ''}</div>
<p class="note" style="margin:0.25rem 0 0.5rem">${concert.venue ? escapeHtml(concert.venue) + ' · ' : ''}${formatTime(startISO)} ${formatTime(endISO)}</p>
<div class="rehearsal-actions">
<a class="pill-link" href="${googleCalendarLink(linkArgs)}" target="_blank" rel="noopener">+ Google</a>
<a class="pill-link" href="${outlookCalendarLink(linkArgs)}" target="_blank" rel="noopener">+ Outlook</a>
<a class="pill-link" href="${icsDataUrl(linkArgs)}" download="concert.ics">+ Apple / autre</a>
</div>
</div>
`;
}
async function loadLastChecked() { async function loadLastChecked() {
const { last_checked } = await api.get('/api/calendar/last-checked'); const { last_checked } = await api.get('/api/calendar/last-checked');
const el = document.getElementById('last-checked'); const el = document.getElementById('last-checked');
@ -70,7 +104,8 @@ function renderCalendar() {
const rehearsalsByDate = new Map(); const rehearsalsByDate = new Map();
for (const r of state.rehearsals) rehearsalsByDate.set(isoDate(new Date(r.startsAt)), r); for (const r of state.rehearsals) rehearsalsByDate.set(isoDate(new Date(r.startsAt)), r);
const nextConcertDate = state.nextConcert ? isoDate(new Date(state.nextConcert.concert_date)) : null; const concertsByDate = new Map();
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. // Grid starts on the Monday of the current week so columns align Mon->Sun.
const start = new Date(today); const start = new Date(today);
@ -115,10 +150,11 @@ function renderCalendar() {
badges.appendChild(badge); badges.appendChild(badge);
} }
if (nextConcertDate && isoDate(date) === nextConcertDate) { const concert = concertsByDate.get(isoDate(date));
if (concert) {
cell.classList.add('has-concert'); cell.classList.add('has-concert');
const badge = document.createElement('span'); const badge = document.createElement('span');
badge.title = `Prochain concert${state.nextConcert.name ? ' : ' + state.nextConcert.name : ''}${state.nextConcert.venue ? ' (' + state.nextConcert.venue + ')' : ''}`; badge.title = `Concert${concert.name ? ' : ' + concert.name : ''}${concert.venue ? ' (' + concert.venue + ')' : ''}`;
badge.textContent = '🎤'; badge.textContent = '🎤';
badges.appendChild(badge); badges.appendChild(badge);
} }
@ -165,12 +201,14 @@ function renderCalendar() {
} }
cell.appendChild(dotsEl); cell.appendChild(dotsEl);
cell.addEventListener('click', () => openModal(date, slot, visible)); cell.addEventListener('click', () => openModal(date, slot, visible, concert));
const countEl = document.createElement('div'); const countEl = document.createElement('div');
countEl.className = 'cell-count'; countEl.className = 'cell-count';
countEl.textContent = avail.length + '/' + visible.length; countEl.textContent = avail.length + '/' + visible.length;
cell.appendChild(countEl); cell.appendChild(countEl);
} else if (concert) {
cell.addEventListener('click', () => openModal(date, null, [], concert));
} else { } else {
cell.classList.add('empty'); cell.classList.add('empty');
const lbl = document.createElement('div'); const lbl = document.createElement('div');
@ -250,22 +288,33 @@ function pollWorkflowStatus(btn, maxMs = 180000, intervalMs = 4000) {
}, intervalMs); }, intervalMs);
} }
function openModal(date, slot, visible) { function openModal(date, slot, visible, concert) {
const avail = visible.filter((p) => p.is_available); const avail = visible.filter((p) => p.is_available);
const busy = visible.filter((p) => !p.is_available); const busy = visible.filter((p) => !p.is_available);
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');
if (slot) {
availabilitySection.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;
proposeBtn.textContent = alreadyProposed proposeBtn.textContent = alreadyProposed
? 'Déjà proposée pour cette date' ? 'Déjà proposée pour cette date'
: 'Proposer cette date pour la prochaine répétition'; : 'Proposer cette date pour la prochaine répétition';
document.getElementById('modal-time').textContent = formatTime(slot.lower) + ' ' + formatTime(slot.upper);
} else {
availabilitySection.style.display = 'none';
proposeBtn.style.display = 'none';
}
document.getElementById('modal-date').textContent = date.toLocaleDateString('fr-FR', { document.getElementById('modal-date').textContent = date.toLocaleDateString('fr-FR', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Paris', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Paris',
}); });
document.getElementById('modal-time').textContent = formatTime(slot.lower) + ' ' + formatTime(slot.upper);
const concertSection = document.getElementById('modal-concert-section');
concertSection.innerHTML = concert ? concertLinksHtml(concert) : '';
const renderPeople = (list, containerId) => { const renderPeople = (list, containerId) => {
const el = document.getElementById(containerId); const el = document.getElementById(containerId);
@ -289,8 +338,10 @@ function openModal(date, slot, visible) {
} }
}; };
if (slot) {
renderPeople(avail, 'modal-available'); renderPeople(avail, 'modal-available');
renderPeople(busy, 'modal-busy'); renderPeople(busy, 'modal-busy');
}
document.getElementById('modal-overlay').classList.remove('hidden'); document.getElementById('modal-overlay').classList.remove('hidden');
} }
@ -343,59 +394,9 @@ function showError(message) {
// ---------- Répétitions proposées ---------- // ---------- Répétitions proposées ----------
// Google's "render" template link — well-documented, stable format. Opens
// directly in the browser with the event pre-filled.
function googleCalendarLink({ title, startISO, endISO, location }) {
const fmt = (iso) => new Date(iso).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
const params = new URLSearchParams({
action: 'TEMPLATE',
text: title,
dates: `${fmt(startISO)}/${fmt(endISO)}`,
});
if (location) params.set('location', location);
return `https://calendar.google.com/calendar/render?${params.toString()}`;
}
// Outlook's compose deep-link — same idea, direct web link, no download.
function outlookCalendarLink({ title, startISO, endISO, location }) {
const params = new URLSearchParams({
path: '/calendar/action/compose',
rru: 'addevent',
subject: title,
startdt: startISO,
enddt: endISO,
});
if (location) params.set('location', location);
return `https://outlook.live.com/calendar/0/deeplink/compose?${params.toString()}`;
}
// Apple has no equivalent public compose-URL — build a real .ics file
// client-side instead and expose it as a data: link. Works as a universal
// fallback too (Android, desktop imports, etc.), not just Apple.
function icsDataUrl({ title, startISO, endISO, location }) {
const fmt = (iso) => new Date(iso).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
const escapeIcs = (s) => String(s || '').replace(/([,;])/g, '\\$1').replace(/\n/g, '\\n');
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Octane//Rehearsal//FR',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
`UID:rehearsal-${Date.now()}@octane`,
`DTSTAMP:${fmt(new Date().toISOString())}`,
`DTSTART:${fmt(startISO)}`,
`DTEND:${fmt(endISO)}`,
`SUMMARY:${escapeIcs(title)}`,
];
if (location) lines.push(`LOCATION:${escapeIcs(location)}`);
lines.push('END:VEVENT', 'END:VCALENDAR');
return `data:text/calendar;charset=utf-8,${encodeURIComponent(lines.join('\r\n'))}`;
}
function rehearsalRowTemplate(r) { function rehearsalRowTemplate(r) {
const title = 'Répétition Octane'; const title = 'Répétition Octane';
const linkArgs = { title, startISO: r.startsAt, endISO: r.endsAt, location: r.location }; const linkArgs = { uid: `rehearsal-${r.id}`, title, startISO: r.startsAt, endISO: r.endsAt, location: r.location };
const canRemove = me && (me.id === r.proposedBy || me.isAdmin); const canRemove = me && (me.id === r.proposedBy || me.isAdmin);
return ` return `
<div class="rehearsal-row" data-rehearsal-id="${r.id}"> <div class="rehearsal-row" data-rehearsal-id="${r.id}">
@ -584,8 +585,9 @@ async function onRemoveMyFeed(feedId) {
try { try {
await loadPeople(); await loadPeople();
buildFilters(); buildFilters();
await loadConcertHours();
await loadRehearsals(); await loadRehearsals();
await loadNextConcert(); await loadUpcomingConcerts();
await loadSlots(); await loadSlots();
await loadLastChecked(); await loadLastChecked();
await loadMyFeeds(); await loadMyFeeds();

View file

@ -169,10 +169,17 @@ function showUpcomingList() {
document.getElementById('next-list-view').style.display = 'block'; document.getElementById('next-list-view').style.display = 'block';
} }
function showNextDetail(id) { async function showNextDetail(id) {
document.getElementById('next-list-view').style.display = 'none'; document.getElementById('next-list-view').style.display = 'none';
document.getElementById('next-detail-view').style.display = 'block'; document.getElementById('next-detail-view').style.display = 'block';
let concertHours = { start: '19:00', end: '22:00' };
try {
concertHours = await api.get('/api/calendar/concert-hours');
} catch (err) {
/* fall back to the defaults above */
}
const editor = createSetlistEditor({ const editor = createSetlistEditor({
containerId: 'next-detail-content', containerId: 'next-detail-content',
allSongs, allSongs,
@ -185,6 +192,8 @@ function showNextDetail(id) {
}, },
emptyMessage: 'Concert introuvable.', emptyMessage: 'Concert introuvable.',
allowDelete: true, allowDelete: true,
showAddToCalendar: true,
concertHours,
onDeleted: async () => { onDeleted: async () => {
showUpcomingList(); showUpcomingList();
await loadUpcoming(); await loadUpcoming();

View file

@ -66,3 +66,55 @@ function avatarHtml(user, extraClass) {
} }
return `<span class="${cls} avatar-initials">${escapeHtml(initials(user.name))}</span>`; return `<span class="${cls} avatar-initials">${escapeHtml(initials(user.name))}</span>`;
} }
// ---------- "Add to my calendar" links (rehearsals, concerts) ----------
// Google's "render" template link — well-documented, stable format. Opens
// directly in the browser with the event pre-filled.
function googleCalendarLink({ title, startISO, endISO, location }) {
const fmt = (iso) => new Date(iso).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
const params = new URLSearchParams({
action: 'TEMPLATE',
text: title,
dates: `${fmt(startISO)}/${fmt(endISO)}`,
});
if (location) params.set('location', location);
return `https://calendar.google.com/calendar/render?${params.toString()}`;
}
// Outlook's compose deep-link — same idea, direct web link, no download.
function outlookCalendarLink({ title, startISO, endISO, location }) {
const params = new URLSearchParams({
path: '/calendar/action/compose',
rru: 'addevent',
subject: title,
startdt: startISO,
enddt: endISO,
});
if (location) params.set('location', location);
return `https://outlook.live.com/calendar/0/deeplink/compose?${params.toString()}`;
}
// Apple has no equivalent public compose-URL — build a real .ics file
// client-side instead and expose it as a data: link. Works as a universal
// fallback too (Android, desktop imports, etc.), not just Apple.
function icsDataUrl({ uid, title, startISO, endISO, location }) {
const fmt = (iso) => new Date(iso).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
const escapeIcs = (s) => String(s || '').replace(/([,;])/g, '\\$1').replace(/\n/g, '\\n');
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Octane//Event//FR',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
`UID:${uid || Date.now()}@octane`,
`DTSTAMP:${fmt(new Date().toISOString())}`,
`DTSTART:${fmt(startISO)}`,
`DTEND:${fmt(endISO)}`,
`SUMMARY:${escapeIcs(title)}`,
];
if (location) lines.push(`LOCATION:${escapeIcs(location)}`);
lines.push('END:VEVENT', 'END:VCALENDAR');
return `data:text/calendar;charset=utf-8,${encodeURIComponent(lines.join('\r\n'))}`;
}

View file

@ -15,6 +15,8 @@ function createSetlistEditor({
emptyMessage = 'Aucun concert.', emptyMessage = 'Aucun concert.',
allowDelete = false, allowDelete = false,
onDeleted = null, onDeleted = null,
showAddToCalendar = false,
concertHours = { start: '19:00', end: '22:00' },
}) { }) {
let setlist = null; let setlist = null;
let mainRows = []; let mainRows = [];
@ -33,6 +35,32 @@ function createSetlistEditor({
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`; document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
} }
// concert_date has no time-of-day (plain DATE column) — synthesize a
// start/end from the admin-configured default concert hours, assuming the
// browser's local timezone is close enough to Europe/Paris (same
// convention used on calendar.js for matching concert_date to grid cells).
function addToCalendarHtml() {
const d = new Date(setlist.concert_date);
const [startHour, startMinute] = concertHours.start.split(':').map(Number);
const [endHour, endMinute] = concertHours.end.split(':').map(Number);
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate(), startHour, startMinute);
const end = new Date(d.getFullYear(), d.getMonth(), d.getDate(), endHour, endMinute);
const linkArgs = {
uid: `concert-${setlist.id}`,
title: setlist.name || 'Concert Octane',
startISO: start.toISOString(),
endISO: end.toISOString(),
location: setlist.venue,
};
return `
<div class="rehearsal-actions" style="margin-top:0.5rem">
<a class="pill-link" href="${googleCalendarLink(linkArgs)}" target="_blank" rel="noopener">+ Google</a>
<a class="pill-link" href="${outlookCalendarLink(linkArgs)}" target="_blank" rel="noopener">+ Outlook</a>
<a class="pill-link" href="${icsDataUrl(linkArgs)}" download="concert.ics">+ Apple / autre</a>
</div>
`;
}
function readOnlyView() { function readOnlyView() {
const main = setlist.songs.filter((s) => !s.is_encore); const main = setlist.songs.filter((s) => !s.is_encore);
const encore = setlist.songs.filter((s) => s.is_encore); const encore = setlist.songs.filter((s) => s.is_encore);
@ -44,6 +72,7 @@ function createSetlistEditor({
<div class="card-title">${escapeHtml(setlist.name || 'Concert')}</div> <div class="card-title">${escapeHtml(setlist.name || 'Concert')}</div>
<div class="card-subtitle">${escapeHtml(setlist.venue || '')} · ${formatDate(setlist.concert_date)}</div> <div class="card-subtitle">${escapeHtml(setlist.venue || '')} · ${formatDate(setlist.concert_date)}</div>
${playlistUrl ? `<div class="song-links"><a class="pill-link youtube" href="${playlistUrl}" target="_blank" rel="noopener">&#9658; Écouter la setlist sur YouTube</a></div>` : ''} ${playlistUrl ? `<div class="song-links"><a class="pill-link youtube" href="${playlistUrl}" target="_blank" rel="noopener">&#9658; Écouter la setlist sur YouTube</a></div>` : ''}
${showAddToCalendar ? addToCalendarHtml() : ''}
</div> </div>
<div class="concert-row-actions"> <div class="concert-row-actions">
<button id="edit-btn" type="button" class="secondary icon-btn" title="Modifier">${CONCERT_EDIT_ICON}<span class="btn-label"> Modifier</span></button> <button id="edit-btn" type="button" class="secondary icon-btn" title="Modifier">${CONCERT_EDIT_ICON}<span class="btn-label"> Modifier</span></button>

View file

@ -0,0 +1,2 @@
ALTER TABLE calendar_settings ADD COLUMN IF NOT EXISTS concert_start TIME NOT NULL DEFAULT '19:00';
ALTER TABLE calendar_settings ADD COLUMN IF NOT EXISTS concert_end TIME NOT NULL DEFAULT '22:00';

View file

@ -205,27 +205,31 @@ 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 FROM calendar_settings WHERE id = 1' 'SELECT weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end 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);
const weekdayEnd = parseTime(row.weekday_end); const weekdayEnd = parseTime(row.weekday_end);
const weekendStart = parseTime(row.weekend_start); const weekendStart = parseTime(row.weekend_start);
const weekendEnd = parseTime(row.weekend_end); const weekendEnd = parseTime(row.weekend_end);
const concertStart = parseTime(row.concert_start);
const concertEnd = parseTime(row.concert_end);
return { return {
weekday: { startHour: weekdayStart.hour, startMinute: weekdayStart.minute, endHour: weekdayEnd.hour, endMinute: weekdayEnd.minute }, weekday: { startHour: weekdayStart.hour, startMinute: weekdayStart.minute, endHour: weekdayEnd.hour, endMinute: weekdayEnd.minute },
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 },
}; };
} }
async function updateSlotSettings({ weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes }) { async function updateSlotSettings({ weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd }) {
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
WHERE id = 1 WHERE id = 1
RETURNING weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes`, RETURNING weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end`,
[weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes] [weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd]
); );
return rows[0]; return rows[0];
} }

View file

@ -184,6 +184,22 @@ router.get(
weekendStart: formatTime(settings.weekend.startHour, settings.weekend.startMinute), weekendStart: formatTime(settings.weekend.startHour, settings.weekend.startMinute),
weekendEnd: formatTime(settings.weekend.endHour, settings.weekend.endMinute), weekendEnd: formatTime(settings.weekend.endHour, settings.weekend.endMinute),
marginMinutes: settings.marginMinutes, marginMinutes: settings.marginMinutes,
concertStart: formatTime(settings.concert.startHour, settings.concert.startMinute),
concertEnd: formatTime(settings.concert.endHour, settings.concert.endMinute),
});
})
);
// Public (any authenticated user): the default concert start/end times are
// needed client-side on calendar.html and concerts.html to build "add to my
// calendar" links, but only an admin may change them (see PATCH /settings).
router.get(
'/concert-hours',
asyncHandler(async (req, res) => {
const settings = await calendarRepo.getSlotSettings();
res.json({
start: formatTime(settings.concert.startHour, settings.concert.startMinute),
end: formatTime(settings.concert.endHour, settings.concert.endMinute),
}); });
}) })
); );
@ -196,8 +212,8 @@ router.patch(
'/settings', '/settings',
requireAdmin, requireAdmin,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes } = req.body || {}; const { weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd } = req.body || {};
const times = { weekdayStart, weekdayEnd, weekendStart, weekendEnd }; 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 || '')) {
return res.status(400).json({ error: 'invalid_time', field: key }); return res.status(400).json({ error: 'invalid_time', field: key });
@ -209,6 +225,9 @@ router.patch(
if (weekendStart >= weekendEnd) { if (weekendStart >= weekendEnd) {
return res.status(400).json({ error: 'weekend_start_must_be_before_end' }); return res.status(400).json({ error: 'weekend_start_must_be_before_end' });
} }
if (concertStart >= concertEnd) {
return res.status(400).json({ error: 'concert_start_must_be_before_end' });
}
const margin = Number(marginMinutes); const margin = Number(marginMinutes);
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' });