mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
fix: show all upcomming concerts in calendar add url to export the slot
This commit is contained in:
parent
fea5c30d69
commit
16110d8ad3
10 changed files with 227 additions and 89 deletions
18
README.md
18
README.md
|
|
@ -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 lun–ven 18h30–21h, sam–dim 15h–19h — 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 lun–ven 18h30–21h, sam–dim 15h–19h — 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** (19h–22h 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, 19h–22h 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
|
||||||
|
|
|
||||||
|
|
@ -62,14 +62,17 @@
|
||||||
<div class="modal card">
|
<div class="modal card">
|
||||||
<button type="button" class="icon-btn secondary modal-close" id="modal-close">×</button>
|
<button type="button" class="icon-btn secondary modal-close" id="modal-close">×</button>
|
||||||
<div class="modal-date card-title" id="modal-date"></div>
|
<div class="modal-date card-title" id="modal-date"></div>
|
||||||
<div class="modal-time note" id="modal-time"></div>
|
<div id="modal-concert-section"></div>
|
||||||
<div class="modal-section">
|
<div id="modal-availability-section">
|
||||||
<div class="modal-section-title available">Disponibles</div>
|
<div class="modal-time note" id="modal-time"></div>
|
||||||
<div class="modal-people" id="modal-available"></div>
|
<div class="modal-section">
|
||||||
</div>
|
<div class="modal-section-title available">Disponibles</div>
|
||||||
<div class="modal-section">
|
<div class="modal-people" id="modal-available"></div>
|
||||||
<div class="modal-section-title busy">Indisponibles</div>
|
</div>
|
||||||
<div class="modal-people" id="modal-busy"></div>
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title busy">Indisponibles</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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 alreadyProposed = state.rehearsals.some((r) => isoDate(new Date(r.startsAt)) === isoDate(date));
|
const availabilitySection = document.getElementById('modal-availability-section');
|
||||||
proposeBtn.disabled = alreadyProposed;
|
if (slot) {
|
||||||
proposeBtn.textContent = alreadyProposed
|
availabilitySection.style.display = '';
|
||||||
? 'Déjà proposée pour cette date'
|
proposeBtn.style.display = '';
|
||||||
: 'Proposer cette date pour la prochaine répétition';
|
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', {
|
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) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
renderPeople(avail, 'modal-available');
|
if (slot) {
|
||||||
renderPeople(busy, 'modal-busy');
|
renderPeople(avail, 'modal-available');
|
||||||
|
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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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'))}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">► Écouter la setlist sur YouTube</a></div>` : ''}
|
${playlistUrl ? `<div class="song-links"><a class="pill-link youtube" href="${playlistUrl}" target="_blank" rel="noopener">► É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>
|
||||||
|
|
|
||||||
2
src/db/migrations/014_concert_default_hours.sql
Normal file
2
src/db/migrations/014_concert_default_hours.sql
Normal 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';
|
||||||
|
|
@ -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];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue