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)) |
| `/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 |
| `/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 |
| `/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).
@ -287,7 +287,7 @@ Un bouton clair/sombre dans la barre de navigation permet de forcer un thème (m
## 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.
- **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).
@ -314,7 +314,7 @@ Une vraie **playlist Spotify** (persistante, sur un compte Spotify) nécessitera
## 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
@ -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.
### 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.
### Fonctionnement

View file

@ -62,6 +62,8 @@
<div class="modal card">
<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 id="modal-concert-section"></div>
<div id="modal-availability-section">
<div class="modal-time note" id="modal-time"></div>
<div class="modal-section">
<div class="modal-section-title available">Disponibles</div>
@ -71,6 +73,7 @@
<div class="modal-section-title busy">Indisponibles</div>
<div class="modal-people" id="modal-busy"></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;">
Proposer cette date pour la prochaine répétition
</button>

View file

@ -125,6 +125,8 @@ async function loadSlotSettingsForm() {
form.weekendStart.value = settings.weekendStart;
form.weekendEnd.value = settings.weekendEnd;
form.marginMinutes.value = settings.marginMinutes;
form.concertStart.value = settings.concertStart;
form.concertEnd.value = settings.concertEnd;
}
async function onSaveSlotSettings(e) {
@ -139,6 +141,8 @@ async function onSaveSlotSettings(e) {
weekendStart: form.weekendStart.value,
weekendEnd: form.weekendEnd.value,
marginMinutes: parseInt(form.marginMinutes.value, 10),
concertStart: form.concertStart.value,
concertEnd: form.concertEnd.value,
});
statusEl.textContent = 'Horaires enregistrés.';
} 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),
pour tenir compte du temps de trajet entre deux évènements le créneau affiché ne change pas.
</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>
<button type="submit">Enregistrer</button>
</form>

View file

@ -3,7 +3,8 @@ let state = {
slots: [],
personIds: [],
rehearsals: [],
nextConcert: null,
upcomingConcerts: [],
concertHours: { start: '19:00', end: '22:00' },
};
let me = null;
let currentModalDate = null;
@ -24,11 +25,44 @@ async function loadRehearsals() {
renderCalendar();
}
async function loadNextConcert() {
state.nextConcert = await api.get('/api/setlists/next');
async function loadUpcomingConcerts() {
state.upcomingConcerts = await api.get('/api/setlists/upcoming');
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() {
const { last_checked } = await api.get('/api/calendar/last-checked');
const el = document.getElementById('last-checked');
@ -70,7 +104,8 @@ function renderCalendar() {
const rehearsalsByDate = new Map();
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.
const start = new Date(today);
@ -115,10 +150,11 @@ function renderCalendar() {
badges.appendChild(badge);
}
if (nextConcertDate && isoDate(date) === nextConcertDate) {
const concert = concertsByDate.get(isoDate(date));
if (concert) {
cell.classList.add('has-concert');
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 = '🎤';
badges.appendChild(badge);
}
@ -165,12 +201,14 @@ function renderCalendar() {
}
cell.appendChild(dotsEl);
cell.addEventListener('click', () => openModal(date, slot, visible));
cell.addEventListener('click', () => openModal(date, slot, visible, concert));
const countEl = document.createElement('div');
countEl.className = 'cell-count';
countEl.textContent = avail.length + '/' + visible.length;
cell.appendChild(countEl);
} else if (concert) {
cell.addEventListener('click', () => openModal(date, null, [], concert));
} else {
cell.classList.add('empty');
const lbl = document.createElement('div');
@ -250,22 +288,33 @@ function pollWorkflowStatus(btn, maxMs = 180000, intervalMs = 4000) {
}, intervalMs);
}
function openModal(date, slot, visible) {
function openModal(date, slot, visible, concert) {
const avail = visible.filter((p) => p.is_available);
const busy = visible.filter((p) => !p.is_available);
currentModalDate = { date, slot };
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));
proposeBtn.disabled = alreadyProposed;
proposeBtn.textContent = alreadyProposed
? 'Déjà proposée pour cette date'
: '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', {
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 el = document.getElementById(containerId);
@ -289,8 +338,10 @@ function openModal(date, slot, visible) {
}
};
if (slot) {
renderPeople(avail, 'modal-available');
renderPeople(busy, 'modal-busy');
}
document.getElementById('modal-overlay').classList.remove('hidden');
}
@ -343,59 +394,9 @@ function showError(message) {
// ---------- 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) {
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);
return `
<div class="rehearsal-row" data-rehearsal-id="${r.id}">
@ -584,8 +585,9 @@ async function onRemoveMyFeed(feedId) {
try {
await loadPeople();
buildFilters();
await loadConcertHours();
await loadRehearsals();
await loadNextConcert();
await loadUpcomingConcerts();
await loadSlots();
await loadLastChecked();
await loadMyFeeds();

View file

@ -169,10 +169,17 @@ function showUpcomingList() {
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-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({
containerId: 'next-detail-content',
allSongs,
@ -185,6 +192,8 @@ function showNextDetail(id) {
},
emptyMessage: 'Concert introuvable.',
allowDelete: true,
showAddToCalendar: true,
concertHours,
onDeleted: async () => {
showUpcomingList();
await loadUpcoming();

View file

@ -66,3 +66,55 @@ function avatarHtml(user, extraClass) {
}
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.',
allowDelete = false,
onDeleted = null,
showAddToCalendar = false,
concertHours = { start: '19:00', end: '22:00' },
}) {
let setlist = null;
let mainRows = [];
@ -33,6 +35,32 @@ function createSetlistEditor({
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() {
const main = 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-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>` : ''}
${showAddToCalendar ? addToCalendarHtml() : ''}
</div>
<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>

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() {
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 weekdayStart = parseTime(row.weekday_start);
const weekdayEnd = parseTime(row.weekday_end);
const weekendStart = parseTime(row.weekend_start);
const weekendEnd = parseTime(row.weekend_end);
const concertStart = parseTime(row.concert_start);
const concertEnd = parseTime(row.concert_end);
return {
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 },
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(
`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
RETURNING weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes`,
[weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes]
RETURNING weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end`,
[weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd]
);
return rows[0];
}

View file

@ -184,6 +184,22 @@ router.get(
weekendStart: formatTime(settings.weekend.startHour, settings.weekend.startMinute),
weekendEnd: formatTime(settings.weekend.endHour, settings.weekend.endMinute),
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',
requireAdmin,
asyncHandler(async (req, res) => {
const { weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes } = req.body || {};
const times = { weekdayStart, weekdayEnd, weekendStart, weekendEnd };
const { weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd } = req.body || {};
const times = { weekdayStart, weekdayEnd, weekendStart, weekendEnd, concertStart, concertEnd };
for (const [key, value] of Object.entries(times)) {
if (!TIME_RE.test(value || '')) {
return res.status(400).json({ error: 'invalid_time', field: key });
@ -209,6 +225,9 @@ router.patch(
if (weekendStart >= weekendEnd) {
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);
if (!Number.isInteger(margin) || margin < 0 || margin > 180) {
return res.status(400).json({ error: 'invalid_margin_minutes' });