update: propose repetion dates on the interface

This commit is contained in:
Nathan FONTEYNE 2026-07-10 14:07:30 +02:00
parent 324cc3c977
commit 21ba87d5ba
8 changed files with 314 additions and 2 deletions

View file

@ -185,6 +185,7 @@ erDiagram
USERS ||--o{ CALENDAR_FEEDS : "enregistre" USERS ||--o{ CALENDAR_FEEDS : "enregistre"
USERS ||--o{ CALENDAR_AVAILABILITY : "disponibilite" USERS ||--o{ CALENDAR_AVAILABILITY : "disponibilite"
CALENDAR_SLOTS ||--o{ CALENDAR_AVAILABILITY : "contient" CALENDAR_SLOTS ||--o{ CALENDAR_AVAILABILITY : "contient"
USERS ||--o{ REHEARSALS : "propose"
USERS { USERS {
int id PK int id PK
@ -258,6 +259,13 @@ erDiagram
time weekend_start time weekend_start
smallint margin_minutes smallint margin_minutes
} }
REHEARSALS {
int id PK
timestamptz starts_at
timestamptz ends_at
text location
int proposed_by FK
}
``` ```
## Fonctionnalités ## Fonctionnalités
@ -268,7 +276,7 @@ erDiagram
| `/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** : 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** : 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 |
| `/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 — [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) — [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 et marge de transport pour les disponibilités ; gestion des calendriers ICS de n'importe quel utilisateur |
@ -315,6 +323,14 @@ Chacun gère ses propres flux ICS en libre-service, sans passer par un admin :
- Depuis `/calendar.html` directement : un bandeau invite à ajouter un calendrier si aucun n'est encore configuré, et un bouton "Mes calendriers" ouvre à tout moment la même gestion (ajout/suppression) en modale. - Depuis `/calendar.html` directement : un bandeau invite à ajouter un calendrier si aucun n'est encore configuré, et un bouton "Mes calendriers" ouvre à tout moment la même gestion (ajout/suppression) en modale.
- Un admin peut aussi ajouter/retirer un flux pour le compte d'un autre utilisateur depuis `/admin.html` ("Calendriers des membres"). - Un admin peut aussi ajouter/retirer un flux pour le compte d'un autre utilisateur depuis `/admin.html` ("Calendriers des membres").
### Proposer une répétition
En cliquant sur un jour du calendrier, n'importe qui peut proposer ce créneau comme prochaine répétition (bouton dans la modale du jour). La proposition apparaît aussitôt pour tout le monde dans un panneau "Répétitions proposées" en haut de `/calendar.html`, et le jour concerné est repéré sur la grille — pas d'e-mail ni de notification, tout se passe dans l'application. Chaque proposition offre trois liens "ajouter à mon calendrier" :
- **Google** et **Outlook** : lien qui ouvre directement l'évènement pré-rempli dans le calendrier web du fournisseur, en un clic.
- **Apple / autre** : un fichier `.ics` généré à la volée (aucun format de lien "ajouter" public n'existe côté Apple) — fonctionne aussi comme solution universelle pour tout autre client calendrier.
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.
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

@ -30,6 +30,11 @@
<button type="button" id="btn-my-feed-banner-add">Ajouter mon calendrier</button> <button type="button" id="btn-my-feed-banner-add">Ajouter mon calendrier</button>
</div> </div>
<div id="rehearsals-section" style="display:none">
<h2 style="margin:1.5rem 0 0.5rem">Répétitions proposées</h2>
<div class="panel" id="rehearsals-list"></div>
</div>
<div class="calendar-layout"> <div class="calendar-layout">
<div class="calendar-filters-backdrop" id="filters-backdrop"></div> <div class="calendar-filters-backdrop" id="filters-backdrop"></div>
<aside class="calendar-filters panel" id="filters-panel"> <aside class="calendar-filters panel" id="filters-panel">
@ -66,6 +71,9 @@
<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>
<button type="button" class="secondary" id="modal-propose-rehearsal-btn" style="margin-top:1rem; width:100%;">
Proposer cette date pour la prochaine répétition
</button>
</div> </div>
</div> </div>

View file

@ -937,6 +937,7 @@ a.back-link:hover { color: var(--accent); }
} }
.cal-cell { .cal-cell {
position: relative;
min-height: 92px; min-height: 92px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@ -1399,3 +1400,34 @@ a.back-link:hover { color: var(--accent); }
.add-feed-form input { .add-feed-form input {
font-size: 0.85rem; font-size: 0.85rem;
} }
/* ---------- Répétitions proposées ---------- */
.rehearsal-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
border-bottom: 1px solid var(--border);
padding: 0.6rem 0;
}
.rehearsal-row:last-child { border-bottom: none; }
.rehearsal-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.cell-rehearsal-badge {
position: absolute;
top: 0.3rem;
right: 0.4rem;
font-size: 0.85rem;
line-height: 1;
}
.cal-cell.has-rehearsal { border-color: var(--accent); }

View file

@ -2,7 +2,10 @@ let state = {
people: [], people: [],
slots: [], slots: [],
personIds: [], personIds: [],
rehearsals: [],
}; };
let me = null;
let currentModalDate = null;
async function loadPeople() { async function loadPeople() {
state.people = await api.get('/api/calendar/people'); state.people = await api.get('/api/calendar/people');
@ -14,6 +17,12 @@ async function loadSlots() {
renderCalendar(); renderCalendar();
} }
async function loadRehearsals() {
state.rehearsals = await api.get('/api/rehearsals');
renderRehearsals();
renderCalendar();
}
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');
@ -52,6 +61,9 @@ function renderCalendar() {
const slotMap = new Map(); const slotMap = new Map();
for (const slot of state.slots) slotMap.set(slot.slot_date, slot); for (const slot of state.slots) slotMap.set(slot.slot_date, slot);
const rehearsalsByDate = new Map();
for (const r of state.rehearsals) rehearsalsByDate.set(isoDate(new Date(r.startsAt)), r);
// 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);
const dow = start.getDay(); const dow = start.getDay();
@ -83,6 +95,16 @@ function renderCalendar() {
dateLabel.innerHTML = `<span class="day-num">${date.getDate()}</span>${shortMonth(date)}`; dateLabel.innerHTML = `<span class="day-num">${date.getDate()}</span>${shortMonth(date)}`;
cell.appendChild(dateLabel); cell.appendChild(dateLabel);
const rehearsal = rehearsalsByDate.get(isoDate(date));
if (rehearsal) {
cell.classList.add('has-rehearsal');
const badge = document.createElement('div');
badge.className = 'cell-rehearsal-badge';
badge.title = 'Répétition proposée';
badge.textContent = '🎸';
cell.appendChild(badge);
}
if (isPastDay) { if (isPastDay) {
cell.classList.add('empty'); cell.classList.add('empty');
const lbl = document.createElement('div'); const lbl = document.createElement('div');
@ -212,6 +234,14 @@ function openModal(date, slot, visible) {
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 };
const proposeBtn = document.getElementById('modal-propose-rehearsal-btn');
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-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',
}); });
@ -291,6 +321,114 @@ function showError(message) {
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`; document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
} }
// ---------- 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 canRemove = me && (me.id === r.proposedBy || me.isAdmin);
return `
<div class="rehearsal-row" data-rehearsal-id="${r.id}">
<div>
<div class="card-title">${formatDatetime(r.startsAt)} ${formatTime(r.endsAt)}</div>
<div class="card-subtitle">${r.location ? `${escapeHtml(r.location)} · ` : ''}Proposée par ${escapeHtml(r.proposedByName)}</div>
</div>
<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="repetition.ics">+ Apple / autre</a>
${canRemove ? `<button type="button" class="danger icon-btn remove-rehearsal-btn" data-rehearsal-id="${r.id}">Retirer</button>` : ''}
</div>
</div>
`;
}
function renderRehearsals() {
const section = document.getElementById('rehearsals-section');
const container = document.getElementById('rehearsals-list');
if (!state.rehearsals.length) {
section.style.display = 'none';
return;
}
section.style.display = '';
container.innerHTML = state.rehearsals.map(rehearsalRowTemplate).join('');
container.querySelectorAll('.remove-rehearsal-btn').forEach((btn) => {
btn.addEventListener('click', () => onRemoveRehearsal(parseInt(btn.dataset.rehearsalId, 10)));
});
}
async function onProposeRehearsal() {
if (!currentModalDate) return;
const { slot } = currentModalDate;
try {
await api.post('/api/rehearsals', { startsAt: slot.lower, endsAt: slot.upper });
closeModal();
await loadRehearsals();
showToast('Répétition proposée.');
} catch (err) {
showError(err.message);
}
}
async function onRemoveRehearsal(id) {
try {
await api.del(`/api/rehearsals/${id}`);
await loadRehearsals();
} catch (err) {
showError(err.message);
}
}
// ---------- Mes calendriers (self-service ICS feeds) ---------- // ---------- Mes calendriers (self-service ICS feeds) ----------
let myFeeds = []; let myFeeds = [];
@ -379,12 +517,13 @@ async function onRemoveMyFeed(feedId) {
} }
(async function init() { (async function init() {
await initNav('calendar'); me = await initNav('calendar');
document.getElementById('modal-close').addEventListener('click', closeModal); document.getElementById('modal-close').addEventListener('click', closeModal);
document.getElementById('modal-overlay').addEventListener('click', (e) => { document.getElementById('modal-overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeModal(); if (e.target === e.currentTarget) closeModal();
}); });
document.getElementById('modal-propose-rehearsal-btn').addEventListener('click', onProposeRehearsal);
document.getElementById('my-calendars-modal-close').addEventListener('click', closeMyCalendarsModal); document.getElementById('my-calendars-modal-close').addEventListener('click', closeMyCalendarsModal);
document.getElementById('my-calendars-modal-overlay').addEventListener('click', (e) => { document.getElementById('my-calendars-modal-overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeMyCalendarsModal(); if (e.target === e.currentTarget) closeMyCalendarsModal();
@ -425,6 +564,7 @@ async function onRemoveMyFeed(feedId) {
try { try {
await loadPeople(); await loadPeople();
buildFilters(); buildFilters();
await loadRehearsals();
await loadSlots(); await loadSlots();
await loadLastChecked(); await loadLastChecked();
await loadMyFeeds(); await loadMyFeeds();

View file

@ -0,0 +1,10 @@
CREATE TABLE rehearsals (
id SERIAL PRIMARY KEY,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
location TEXT,
proposed_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_rehearsals_starts_at ON rehearsals (starts_at);

View file

@ -0,0 +1,33 @@
const pool = require('../db/pool');
async function findUpcoming() {
const { rows } = await pool.query(`
SELECT r.id, r.starts_at, r.ends_at, r.location, r.proposed_by, u.name AS proposed_by_name, r.created_at
FROM rehearsals r
JOIN users u ON u.id = r.proposed_by
WHERE r.ends_at >= now()
ORDER BY r.starts_at
`);
return rows;
}
async function findById(id) {
const { rows } = await pool.query('SELECT id, starts_at, ends_at, location, proposed_by FROM rehearsals WHERE id = $1', [id]);
return rows[0] || null;
}
async function create({ startsAt, endsAt, location, proposedBy }) {
const { rows } = await pool.query(
`INSERT INTO rehearsals (starts_at, ends_at, location, proposed_by)
VALUES ($1, $2, $3, $4)
RETURNING id, starts_at, ends_at, location, proposed_by, created_at`,
[startsAt, endsAt, location || null, proposedBy]
);
return rows[0];
}
async function remove(id) {
await pool.query('DELETE FROM rehearsals WHERE id = $1', [id]);
}
module.exports = { findUpcoming, findById, create, remove };

View file

@ -7,6 +7,7 @@ const setlistsRoutes = require('./setlists');
const musicSearchRoutes = require('./musicSearch'); const musicSearchRoutes = require('./musicSearch');
const calendarRoutes = require('./calendar'); const calendarRoutes = require('./calendar');
const adminRoutes = require('./admin'); const adminRoutes = require('./admin');
const rehearsalsRoutes = require('./rehearsals');
const router = express.Router(); const router = express.Router();
@ -18,5 +19,6 @@ router.use('/setlists', setlistsRoutes);
router.use('/music-search', musicSearchRoutes); router.use('/music-search', musicSearchRoutes);
router.use('/calendar', calendarRoutes); router.use('/calendar', calendarRoutes);
router.use('/admin', adminRoutes); router.use('/admin', adminRoutes);
router.use('/rehearsals', rehearsalsRoutes);
module.exports = router; module.exports = router;

71
src/routes/rehearsals.js Normal file
View file

@ -0,0 +1,71 @@
const express = require('express');
const rehearsalsRepo = require('../repositories/rehearsalsRepo');
const asyncHandler = require('../lib/asyncHandler');
const router = express.Router();
router.get(
'/',
asyncHandler(async (req, res) => {
const rehearsals = await rehearsalsRepo.findUpcoming();
res.json(
rehearsals.map((r) => ({
id: r.id,
startsAt: r.starts_at,
endsAt: r.ends_at,
location: r.location,
proposedBy: r.proposed_by,
proposedByName: r.proposed_by_name,
}))
);
})
);
router.post(
'/',
asyncHandler(async (req, res) => {
const { startsAt, endsAt, location } = req.body || {};
if (!startsAt || !endsAt) {
return res.status(400).json({ error: 'starts_at_and_ends_at_required' });
}
const start = new Date(startsAt);
const end = new Date(endsAt);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return res.status(400).json({ error: 'invalid_date' });
}
if (end <= start) {
return res.status(400).json({ error: 'ends_at_must_be_after_starts_at' });
}
if (start < new Date()) {
return res.status(400).json({ error: 'starts_at_must_be_in_the_future' });
}
const rehearsal = await rehearsalsRepo.create({
startsAt: start,
endsAt: end,
location: location ? location.trim() : null,
proposedBy: req.user.id,
});
res.status(201).json({
id: rehearsal.id,
startsAt: rehearsal.starts_at,
endsAt: rehearsal.ends_at,
location: rehearsal.location,
proposedBy: rehearsal.proposed_by,
});
})
);
router.delete(
'/:id',
asyncHandler(async (req, res) => {
const rehearsal = await rehearsalsRepo.findById(req.params.id);
if (!rehearsal) return res.status(404).json({ error: 'not_found' });
if (rehearsal.proposed_by !== req.user.id && !req.user.is_admin) {
return res.status(403).json({ error: 'forbidden' });
}
await rehearsalsRepo.remove(req.params.id);
res.status(204).end();
})
);
module.exports = router;