diff --git a/README.md b/README.md index 1bd5aa7..f68d635 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ erDiagram USERS ||--o{ CALENDAR_FEEDS : "enregistre" USERS ||--o{ CALENDAR_AVAILABILITY : "disponibilite" CALENDAR_SLOTS ||--o{ CALENDAR_AVAILABILITY : "contient" + USERS ||--o{ REHEARSALS : "propose" USERS { int id PK @@ -258,6 +259,13 @@ erDiagram time weekend_start smallint margin_minutes } + REHEARSALS { + int id PK + timestamptz starts_at + timestamptz ends_at + text location + int proposed_by FK + } ``` ## 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 | | `/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 | -| `/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 | | `/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. - 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. ### Fonctionnement diff --git a/public/calendar.html b/public/calendar.html index 59b8a9e..d585e32 100644 --- a/public/calendar.html +++ b/public/calendar.html @@ -30,6 +30,11 @@ + +
+ diff --git a/public/css/style.css b/public/css/style.css index 0735a76..287b81a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -937,6 +937,7 @@ a.back-link:hover { color: var(--accent); } } .cal-cell { + position: relative; min-height: 92px; border: 1px solid var(--border); border-radius: var(--radius-sm); @@ -1399,3 +1400,34 @@ a.back-link:hover { color: var(--accent); } .add-feed-form input { 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); } diff --git a/public/js/calendar.js b/public/js/calendar.js index ad6c5f0..0913dc2 100644 --- a/public/js/calendar.js +++ b/public/js/calendar.js @@ -2,7 +2,10 @@ let state = { people: [], slots: [], personIds: [], + rehearsals: [], }; +let me = null; +let currentModalDate = null; async function loadPeople() { state.people = await api.get('/api/calendar/people'); @@ -14,6 +17,12 @@ async function loadSlots() { renderCalendar(); } +async function loadRehearsals() { + state.rehearsals = await api.get('/api/rehearsals'); + renderRehearsals(); + renderCalendar(); +} + async function loadLastChecked() { const { last_checked } = await api.get('/api/calendar/last-checked'); const el = document.getElementById('last-checked'); @@ -52,6 +61,9 @@ function renderCalendar() { const slotMap = new Map(); 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. const start = new Date(today); const dow = start.getDay(); @@ -83,6 +95,16 @@ function renderCalendar() { dateLabel.innerHTML = `${date.getDate()}${shortMonth(date)}`; 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) { cell.classList.add('empty'); const lbl = document.createElement('div'); @@ -212,6 +234,14 @@ function openModal(date, slot, visible) { 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 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', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Paris', }); @@ -291,6 +321,114 @@ function showError(message) { document.getElementById('error').innerHTML = `
${escapeHtml(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 canRemove = me && (me.id === r.proposedBy || me.isAdmin); + return ` +
+
+
${formatDatetime(r.startsAt)} – ${formatTime(r.endsAt)}
+
${r.location ? `${escapeHtml(r.location)} · ` : ''}Proposée par ${escapeHtml(r.proposedByName)}
+
+
+ + Google + + Outlook + + Apple / autre + ${canRemove ? `` : ''} +
+
+ `; +} + +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) ---------- let myFeeds = []; @@ -379,12 +517,13 @@ async function onRemoveMyFeed(feedId) { } (async function init() { - await initNav('calendar'); + me = await initNav('calendar'); document.getElementById('modal-close').addEventListener('click', closeModal); document.getElementById('modal-overlay').addEventListener('click', (e) => { 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-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) closeMyCalendarsModal(); @@ -425,6 +564,7 @@ async function onRemoveMyFeed(feedId) { try { await loadPeople(); buildFilters(); + await loadRehearsals(); await loadSlots(); await loadLastChecked(); await loadMyFeeds(); diff --git a/src/db/migrations/013_rehearsals.sql b/src/db/migrations/013_rehearsals.sql new file mode 100644 index 0000000..bc7c1dd --- /dev/null +++ b/src/db/migrations/013_rehearsals.sql @@ -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); diff --git a/src/repositories/rehearsalsRepo.js b/src/repositories/rehearsalsRepo.js new file mode 100644 index 0000000..abf52b0 --- /dev/null +++ b/src/repositories/rehearsalsRepo.js @@ -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 }; diff --git a/src/routes/index.js b/src/routes/index.js index 428185c..8687984 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -7,6 +7,7 @@ const setlistsRoutes = require('./setlists'); const musicSearchRoutes = require('./musicSearch'); const calendarRoutes = require('./calendar'); const adminRoutes = require('./admin'); +const rehearsalsRoutes = require('./rehearsals'); const router = express.Router(); @@ -18,5 +19,6 @@ router.use('/setlists', setlistsRoutes); router.use('/music-search', musicSearchRoutes); router.use('/calendar', calendarRoutes); router.use('/admin', adminRoutes); +router.use('/rehearsals', rehearsalsRoutes); module.exports = router; diff --git a/src/routes/rehearsals.js b/src/routes/rehearsals.js new file mode 100644 index 0000000..212260e --- /dev/null +++ b/src/routes/rehearsals.js @@ -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;