octane-website/public/js/calendar.js
Nathan FONTEYNE d0e9dfb62e
Some checks failed
CI/CD / test (push) Has been cancelled
CI/CD / build-and-push (push) Has been cancelled
update: better colors for calendar light mode and see rejected rehersal in calendar
2026-08-11 11:59:38 +02:00

802 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

let state = {
people: [],
slots: [],
personIds: [],
rehearsals: [],
upcomingConcerts: [],
concertHours: { start: '19:00', end: '22:00' },
viewYear: null,
viewMonth: null, // 0-indexed, like Date#getMonth()
};
let me = null;
let currentModalDate = null;
async function loadPeople() {
state.people = await api.get('/api/calendar/people');
state.personIds = state.people.map((p) => p.id);
}
async function loadSlots() {
state.slots = await api.get('/api/calendar/slots?weeks=14');
renderCalendar();
}
// Navigation is bounded to the current month through 2 months ahead — that
// range is exactly what loadSlots() fetches (14 weeks from today), so every
// navigable month always has data without an extra fetch per month change.
function monthNavBounds() {
const today = new Date();
return {
minYear: today.getFullYear(),
minMonth: today.getMonth(),
maxYear: today.getFullYear(),
maxMonth: today.getMonth() + 2,
};
}
function changeMonth(delta) {
let month = state.viewMonth + delta;
let year = state.viewYear;
while (month < 0) { month += 12; year -= 1; }
while (month > 11) { month -= 12; year += 1; }
const { minYear, minMonth, maxYear, maxMonth } = monthNavBounds();
const key = year * 12 + month;
if (key < minYear * 12 + minMonth || key > maxYear * 12 + maxMonth) return;
state.viewYear = year;
state.viewMonth = month;
renderCalendar();
}
function updateMonthNav() {
const { minYear, minMonth, maxYear, maxMonth } = monthNavBounds();
const key = state.viewYear * 12 + state.viewMonth;
document.getElementById('btn-month-prev').disabled = key <= minYear * 12 + minMonth;
document.getElementById('btn-month-next').disabled = key >= maxYear * 12 + maxMonth;
const label = new Date(state.viewYear, state.viewMonth, 1).toLocaleDateString('fr-FR', {
month: 'long', year: 'numeric',
});
document.getElementById('month-label').textContent = label;
}
async function loadRehearsals() {
state.rehearsals = await api.get('/api/rehearsals');
renderRehearsals();
renderCalendar();
}
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">
${calendarLinksMenuHtml(linkArgs, 'concert.ics')}
</div>
</div>
`;
}
function rehearsalStatusLabel(r) {
if (r.status === 'confirmed') return 'Répétition confirmée';
if (r.status === 'rejected') return 'Répétition refusée';
const accepted = r.votes.filter((v) => v.vote === 'accept').length;
return `Suggestion de répétition (${accepted}/${r.confirmThreshold} votes)`;
}
function rehearsalInfoHtml(r) {
const linkArgs = { uid: `rehearsal-${r.id}`, title: 'Répétition Octane', startISO: r.startsAt, endISO: r.endsAt, location: r.location };
return `
<div class="modal-section">
<div class="modal-section-title">${escapeHtml(rehearsalStatusLabel(r))}</div>
<p class="note" style="margin:0.25rem 0 0.5rem">${r.location ? escapeHtml(r.location) + ' · ' : ''}${formatTime(r.startsAt)} ${formatTime(r.endsAt)} · Proposée par ${escapeHtml(r.proposedByName)}</p>
${rehearsalAcceptedByHtml(r)}
<div class="rehearsal-actions">
${calendarLinksMenuHtml(linkArgs, 'repetition.ics')}
</div>
</div>
`;
}
async function loadLastChecked() {
const { last_checked } = await api.get('/api/calendar/last-checked');
const el = document.getElementById('last-checked');
el.textContent = last_checked
? 'Dernière mise à jour : ' + formatDatetime(last_checked)
: 'Aucune donnée pour le moment — cliquez sur Actualiser';
}
function isoDate(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function shortMonth(d) {
return d.toLocaleDateString('fr-FR', { month: 'short' });
}
function formatTime(iso) {
return new Date(iso).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
}
function formatDatetime(iso) {
return new Date(iso).toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
function renderCalendar() {
const grid = document.getElementById('calendar-grid');
grid.innerHTML = '';
const today = new Date();
today.setHours(0, 0, 0, 0);
const selectedSet = new Set(state.personIds);
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);
const concertsByDate = new Map();
for (const c of state.upcomingConcerts) concertsByDate.set(isoDate(new Date(c.concert_date)), c);
updateMonthNav();
const viewYear = state.viewYear;
const viewMonth = state.viewMonth;
// Grid starts on the Monday on/before the 1st so columns align Mon->Sun,
// and spans full weeks so trailing/leading days of neighboring months
// fill out the last/first row — same visual as Google Calendar's month view.
const firstOfMonth = new Date(viewYear, viewMonth, 1);
const firstDow = firstOfMonth.getDay();
const leadingDays = firstDow === 0 ? 6 : firstDow - 1;
const start = new Date(viewYear, viewMonth, 1 - leadingDays);
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const totalDays = Math.ceil((leadingDays + daysInMonth) / 7) * 7;
for (let i = 0; i < totalDays; i++) {
const date = new Date(start);
date.setDate(start.getDate() + i);
const isOutsideMonth = date.getMonth() !== viewMonth;
const isPastDay = date < today;
const isToday = date.getTime() === today.getTime();
const slot = slotMap.get(isoDate(date));
const cell = document.createElement('div');
cell.className = 'cal-cell' + (isToday ? ' today' : '');
if (isOutsideMonth) {
cell.classList.add('empty', 'outside-month');
const dateLabel = document.createElement('div');
dateLabel.className = 'cell-date';
dateLabel.innerHTML = `<span class="day-num">${date.getDate()}</span>`;
cell.appendChild(dateLabel);
grid.appendChild(cell);
continue;
}
const dateLabel = document.createElement('div');
dateLabel.className = 'cell-date';
dateLabel.innerHTML = `<span class="day-num">${date.getDate()}</span>${shortMonth(date)}`;
cell.appendChild(dateLabel);
const badges = document.createElement('div');
badges.className = 'cell-badges';
const rehearsal = rehearsalsByDate.get(isoDate(date));
if (rehearsal) {
const statusTitles = {
confirmed: 'Répétition confirmée',
rejected: 'Répétition refusée',
suggested: 'Répétition proposée (en attente de votes)',
};
cell.classList.add('has-rehearsal', `rehearsal-${rehearsal.status}`);
const badge = document.createElement('span');
badge.title = statusTitles[rehearsal.status] || statusTitles.suggested;
badge.textContent = '🎸';
badges.appendChild(badge);
}
const concert = concertsByDate.get(isoDate(date));
if (concert) {
cell.classList.add('has-concert');
const badge = document.createElement('span');
badge.title = `Concert${concert.name ? ' : ' + concert.name : ''}${concert.venue ? ' (' + concert.venue + ')' : ''}`;
badge.textContent = '🎤';
badges.appendChild(badge);
}
if (badges.childElementCount) cell.appendChild(badges);
if (isPastDay) {
cell.classList.add('empty');
const lbl = document.createElement('div');
lbl.className = 'no-slot-label';
lbl.textContent = 'passé';
cell.appendChild(lbl);
} else if (slot) {
const visible = slot.people.filter((p) => selectedSet.has(p.id));
const avail = visible.filter((p) => p.is_available);
cell.classList.add('has-slot');
if (visible.length > 0) {
if (avail.length === visible.length) {
cell.classList.add('all-available');
} else {
const unavailRatio = (visible.length - avail.length) / visible.length;
if (unavailRatio <= 0.2) cell.classList.add('heat-1');
else if (unavailRatio <= 0.4) cell.classList.add('heat-2');
else if (unavailRatio <= 0.6) cell.classList.add('heat-3');
else if (unavailRatio <= 0.8) cell.classList.add('heat-4');
else cell.classList.add('heat-5');
}
}
const timeEl = document.createElement('div');
timeEl.className = 'cell-time';
timeEl.textContent = formatTime(slot.lower) + '' + formatTime(slot.upper);
cell.appendChild(timeEl);
const dotsEl = document.createElement('div');
dotsEl.className = 'cell-dots';
for (const person of visible) {
const dot = document.createElement('div');
dot.className = 'cell-dot' + (person.is_available ? '' : ' busy');
dot.style.backgroundColor = person.color;
dot.title = person.name + (person.is_available ? ' ✓' : ' ✗');
dotsEl.appendChild(dot);
}
cell.appendChild(dotsEl);
cell.addEventListener('click', () => openModal(date, slot, visible, concert, rehearsal));
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, rehearsal));
} else if (rehearsal) {
cell.addEventListener('click', () => openModal(date, null, [], null, rehearsal));
} else {
cell.classList.add('empty');
const lbl = document.createElement('div');
lbl.className = 'no-slot-label';
lbl.textContent = 'aucune donnée';
cell.appendChild(lbl);
}
grid.appendChild(cell);
}
}
function buildFilters() {
const listEl = document.getElementById('people-list');
listEl.innerHTML = '';
for (const person of state.people) {
const row = document.createElement('label');
row.className = 'person-toggle';
row.dataset.id = person.id;
const dot = document.createElement('span');
dot.className = 'person-dot';
dot.style.backgroundColor = person.color;
row.appendChild(dot);
row.appendChild(document.createTextNode(person.name));
row.addEventListener('click', () => {
const id = person.id;
if (state.personIds.includes(id)) {
if (state.personIds.length === 1) return;
state.personIds = state.personIds.filter((x) => x !== id);
row.classList.add('excluded');
} else {
state.personIds.push(id);
row.classList.remove('excluded');
}
renderCalendar();
});
listEl.appendChild(row);
}
buildLegend();
}
function resetRefreshButton(btn) {
btn.disabled = false;
btn.textContent = 'Actualiser les disponibilités';
}
function pollWorkflowStatus(btn, maxMs = 180000, intervalMs = 4000) {
const started = Date.now();
const timer = setInterval(async () => {
if (Date.now() - started > maxMs) {
clearInterval(timer);
showToast('La synchronisation a expiré — aucun résultat après 3 minutes', true);
resetRefreshButton(btn);
return;
}
try {
const data = await api.get('/api/calendar/workflow-status');
if (data.status === 'success') {
clearInterval(timer);
showToast('Calendrier mis à jour !' + (data.message ? ' ' + data.message : ''), false, 10000);
resetRefreshButton(btn);
await loadSlots();
await loadLastChecked();
} else if (data.status === 'error') {
clearInterval(timer);
showToast('Erreur lors de la synchronisation : ' + (data.message || 'inconnue'), true);
resetRefreshButton(btn);
}
} catch (err) {
/* transient, keep polling */
}
}, intervalMs);
}
function openModal(date, slot, visible, concert, rehearsal) {
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');
const rehearsalConfirmed = rehearsal && rehearsal.status === 'confirmed';
if (slot) {
availabilitySection.style.display = rehearsalConfirmed ? 'none' : '';
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',
});
const concertSection = document.getElementById('modal-concert-section');
concertSection.innerHTML = concert ? concertLinksHtml(concert) : '';
const rehearsalSection = document.getElementById('modal-rehearsal-section');
rehearsalSection.innerHTML = rehearsal ? rehearsalInfoHtml(rehearsal) : '';
const renderPeople = (list, containerId) => {
const el = document.getElementById(containerId);
el.innerHTML = '';
if (!list.length) {
const em = document.createElement('div');
em.className = 'modal-empty empty';
em.textContent = 'Aucune';
el.appendChild(em);
return;
}
for (const person of list) {
const row = document.createElement('div');
row.className = 'modal-person';
const dot = document.createElement('span');
dot.className = 'person-dot';
dot.style.backgroundColor = person.color;
row.appendChild(dot);
row.appendChild(document.createTextNode(person.name));
el.appendChild(row);
}
};
if (slot) {
renderPeople(avail, 'modal-available');
renderPeople(busy, 'modal-busy');
}
document.getElementById('modal-overlay').classList.remove('hidden');
}
function closeModal() {
document.getElementById('modal-overlay').classList.add('hidden');
}
function openFilters() {
document.getElementById('filters-panel').classList.add('open');
document.getElementById('filters-backdrop').classList.add('open');
}
function closeFilters() {
document.getElementById('filters-panel').classList.remove('open');
document.getElementById('filters-backdrop').classList.remove('open');
}
function buildLegend() {
const el = document.getElementById('legend');
el.innerHTML = '';
for (const person of state.people) {
const item = document.createElement('div');
item.className = 'legend-item';
const dot = document.createElement('span');
dot.className = 'legend-dot';
dot.style.backgroundColor = person.color;
item.appendChild(dot);
item.appendChild(document.createTextNode(person.name));
el.appendChild(item);
}
const allItem = document.createElement('div');
allItem.className = 'legend-item';
allItem.innerHTML = '<span class="legend-dot legend-dot-all"></span> Tout le monde libre';
el.appendChild(allItem);
}
function showToast(msg, isError = false, duration = 5000) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.remove('hidden', 'error');
if (isError) el.classList.add('error');
clearTimeout(el._timer);
el._timer = setTimeout(() => el.classList.add('hidden'), duration);
}
function showError(message) {
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
}
// ---------- Répétitions proposées ----------
function rehearsalVoteActionsHtml(r) {
const myVote = me ? r.votes.find((v) => v.userId === me.id) : null;
if (!myVote) {
return `
<button type="button" class="secondary accept-rehearsal-btn" data-rehearsal-id="${r.id}">✔ Accepter</button>
<button type="button" class="secondary reject-rehearsal-btn" data-rehearsal-id="${r.id}">✘ Refuser</button>
`;
}
const label = myVote.vote === 'accept' ? '✔ Vous avez accepté' : '✘ Vous avez refusé';
return `
<span class="vote-badge ${myVote.vote}">${label}</span>
<button type="button" class="secondary undo-rehearsal-vote-btn" data-rehearsal-id="${r.id}">Annuler</button>
`;
}
function rehearsalVoterHtml(v, r) {
const isHost = r.hostUserId && v.userId === r.hostUserId;
const name = isHost ? `${v.name}` : v.name;
return `${avatarHtml({ name: v.name, avatarUrl: v.avatarUrl }, 'avatar-sm')}<span>${escapeHtml(name)}</span>`;
}
function rehearsalAcceptedByHtml(r) {
const accepted = r.votes.filter((v) => v.vote === 'accept');
const refused = r.votes.filter((v) => v.vote === 'reject');
if (!accepted.length && !refused.length) return '';
return `
${accepted.length ? `
<div class="rehearsal-accepted-by">
<span class="note">Ont accepté :</span>
${accepted.map((v) => rehearsalVoterHtml(v, r)).join('')}
</div>
` : ''}
${refused.length ? `
<div class="rehearsal-accepted-by rehearsal-refused-by">
<span class="note">Ont refusé :</span>
${refused.map((v) => rehearsalVoterHtml(v, r)).join('')}
</div>
` : ''}
`;
}
function rehearsalRowTemplate(r) {
const title = 'Répétition Octane';
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}">
<div>
<span class="vote-badge rehearsal-status-${r.status}">${escapeHtml(rehearsalStatusLabel(r))}</span>
<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>
${rehearsalAcceptedByHtml(r)}
</div>
<div class="rehearsal-actions">
${rehearsalVoteActionsHtml(r)}
${calendarLinksMenuHtml(linkArgs, 'repetition.ics')}
${canRemove ? `<button type="button" class="danger icon-btn remove-rehearsal-btn" data-rehearsal-id="${r.id}">Retirer</button>` : ''}
</div>
</div>
`;
}
function wireRehearsalActions(container) {
container.querySelectorAll('.remove-rehearsal-btn').forEach((btn) => {
btn.addEventListener('click', () => onRemoveRehearsal(parseInt(btn.dataset.rehearsalId, 10)));
});
container.querySelectorAll('.accept-rehearsal-btn').forEach((btn) => {
btn.addEventListener('click', () => onVoteRehearsal(parseInt(btn.dataset.rehearsalId, 10), 'accept'));
});
container.querySelectorAll('.reject-rehearsal-btn').forEach((btn) => {
btn.addEventListener('click', () => onVoteRehearsal(parseInt(btn.dataset.rehearsalId, 10), 'reject'));
});
container.querySelectorAll('.undo-rehearsal-vote-btn').forEach((btn) => {
btn.addEventListener('click', () => onUndoRehearsalVote(parseInt(btn.dataset.rehearsalId, 10)));
});
}
function renderRehearsals() {
const section = document.getElementById('rehearsals-section');
const title = document.getElementById('rehearsals-title');
const container = document.getElementById('rehearsals-list');
const votedSection = document.getElementById('rehearsals-voted-section');
const votedSummary = document.getElementById('rehearsals-voted-summary');
const votedContainer = document.getElementById('rehearsals-voted-list');
if (!state.rehearsals.length) {
section.style.display = 'none';
return;
}
section.style.display = '';
const pending = state.rehearsals.filter(
(r) => !me || !r.votes.some((v) => v.userId === me.id)
);
const voted = state.rehearsals.filter(
(r) => me && r.votes.some((v) => v.userId === me.id)
);
title.style.display = pending.length ? '' : 'none';
container.style.display = pending.length ? '' : 'none';
container.innerHTML = pending.map(rehearsalRowTemplate).join('');
wireRehearsalActions(container);
if (voted.length) {
votedSection.style.display = '';
votedSummary.textContent = `Répétitions traitées (${voted.length})`;
votedContainer.innerHTML = voted.map(rehearsalRowTemplate).join('');
wireRehearsalActions(votedContainer);
} else {
votedSection.style.display = 'none';
votedContainer.innerHTML = '';
}
}
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);
}
}
async function onVoteRehearsal(id, vote) {
try {
await api.post(`/api/rehearsals/${id}/vote`, { vote });
await loadRehearsals();
} catch (err) {
showError(err.message);
}
}
async function onUndoRehearsalVote(id) {
try {
await api.del(`/api/rehearsals/${id}/vote`);
await loadRehearsals();
} catch (err) {
showError(err.message);
}
}
// ---------- Mes calendriers (self-service ICS feeds) ----------
let myFeeds = [];
// Shown for a registered feed instead of its full URL — consistent with the
// admin/profile calendar-management panels.
function maskIcsUrl(url) {
try {
const u = new URL(url);
return `${u.hostname}/••••`;
} catch {
return '••••';
}
}
function myFeedRowTemplate(feed) {
return `
<div class="feed-row" data-feed-id="${feed.id}">
<span class="feed-label">${feed.label ? `${escapeHtml(feed.label)}` : ''}${escapeHtml(maskIcsUrl(feed.icsUrl))}</span>
<button type="button" class="secondary icon-btn remove-my-feed-btn" data-feed-id="${feed.id}">Supprimer</button>
</div>
`;
}
async function loadMyFeeds() {
myFeeds = await api.get('/api/calendar/my-feeds');
renderMyFeedBanner();
}
function renderMyFeedBanner() {
document.getElementById('my-feed-banner').style.display = myFeeds.length ? 'none' : '';
}
function renderMyCalendarsModal() {
const container = document.getElementById('my-calendars-list');
container.innerHTML = myFeeds.length
? myFeeds.map(myFeedRowTemplate).join('')
: '<p class="empty">Aucun calendrier configuré.</p>';
container.querySelectorAll('.remove-my-feed-btn').forEach((btn) => {
btn.addEventListener('click', () => onRemoveMyFeed(parseInt(btn.dataset.feedId, 10)));
});
}
function openMyCalendarsModal() {
renderMyCalendarsModal();
document.getElementById('my-calendars-modal-overlay').classList.remove('hidden');
}
function closeMyCalendarsModal() {
document.getElementById('my-calendars-modal-overlay').classList.add('hidden');
}
async function onAddMyFeed(e) {
e.preventDefault();
const form = e.target;
const label = form.label.value.trim();
const icsUrl = form.icsUrl.value.trim();
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Vérification…';
try {
await api.post('/api/calendar/my-feeds', { label, icsUrl });
form.reset();
await loadMyFeeds();
renderMyCalendarsModal();
await loadPeople();
buildFilters();
} catch (err) {
showError(err.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = '+ Ajouter';
}
}
async function onRemoveMyFeed(feedId) {
try {
await api.del(`/api/calendar/my-feeds/${feedId}`);
await loadMyFeeds();
renderMyCalendarsModal();
await loadPeople();
buildFilters();
} catch (err) {
showError(err.message);
}
}
(async function init() {
me = await initNav('calendar');
const today = new Date();
state.viewYear = today.getFullYear();
state.viewMonth = today.getMonth();
document.getElementById('btn-month-prev').addEventListener('click', () => changeMonth(-1));
document.getElementById('btn-month-next').addEventListener('click', () => changeMonth(1));
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();
});
document.getElementById('my-calendars-add-form').addEventListener('submit', onAddMyFeed);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
closeFilters();
closeMyCalendarsModal();
}
});
document.getElementById('btn-filters-toggle').addEventListener('click', openFilters);
document.getElementById('btn-filters-close').addEventListener('click', closeFilters);
document.getElementById('filters-backdrop').addEventListener('click', closeFilters);
document.getElementById('btn-my-calendars').addEventListener('click', openMyCalendarsModal);
document.getElementById('btn-my-feed-banner-add').addEventListener('click', openMyCalendarsModal);
document.getElementById('btn-refresh').addEventListener('click', async () => {
const btn = document.getElementById('btn-refresh');
btn.disabled = true;
btn.textContent = 'Actualisation…';
showToast('Synchronisation des calendriers…');
try {
await api.post('/api/calendar/refresh', {});
showToast('Synchronisation en cours — en attente des résultats…');
pollWorkflowStatus(btn);
} catch (err) {
const message = err.message === 'no_feeds_configured'
? "Aucun calendrier n'est configuré — voir la page Administration."
: err.message;
showToast(message, true);
resetRefreshButton(btn);
}
});
try {
await loadPeople();
buildFilters();
await loadConcertHours();
await loadRehearsals();
await loadUpcomingConcerts();
await loadSlots();
await loadLastChecked();
await loadMyFeeds();
jumpToRehearsalFromUrl();
} catch (err) {
showError(err.message);
}
})();
// Supports the "Votez ici" link embedded in the ICS feed (calendar.html?rehearsalId=N):
// scroll straight to that rehearsal's row in the list and highlight it briefly.
function jumpToRehearsalFromUrl() {
const rehearsalId = new URLSearchParams(window.location.search).get('rehearsalId');
if (!rehearsalId) return;
const row = document.querySelector(`.rehearsal-row[data-rehearsal-id="${rehearsalId}"]`);
if (!row) return;
const details = row.closest('details');
if (details) details.open = true;
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
row.classList.add('highlight');
setTimeout(() => row.classList.remove('highlight'), 3000);
}