mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
Compare commits
2 commits
b056af78ac
...
d0e9dfb62e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0e9dfb62e | ||
|
|
a3d62200ce |
10 changed files with 145 additions and 30 deletions
|
|
@ -15,6 +15,11 @@
|
||||||
--radius: 14px;
|
--radius: 14px;
|
||||||
--radius-sm: 8px;
|
--radius-sm: 8px;
|
||||||
--shadow: 0 1px 2px rgba(32, 31, 43, 0.04), 0 4px 16px rgba(32, 31, 43, 0.06);
|
--shadow: 0 1px 2px rgba(32, 31, 43, 0.04), 0 4px 16px rgba(32, 31, 43, 0.06);
|
||||||
|
/* Higher than the dark-mode value below — pale accent tints on a white
|
||||||
|
surface read as barely-there in light mode, so light needs a stronger mix
|
||||||
|
to hit the same perceived contrast dark mode gets "for free" against a
|
||||||
|
dark surface. */
|
||||||
|
--cal-marker-mix: 38%;
|
||||||
font-family: "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
|
font-family: "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,6 +39,7 @@
|
||||||
--success: #4fd190;
|
--success: #4fd190;
|
||||||
--success-bg: #163828;
|
--success-bg: #163828;
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.2), 0 8px 24px rgba(0, 0, 0, 0.35);
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.2), 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
--cal-marker-mix: 24%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,6 +57,7 @@
|
||||||
--danger-bg: #3a201f;
|
--danger-bg: #3a201f;
|
||||||
--success: #4fd190;
|
--success: #4fd190;
|
||||||
--success-bg: #163828;
|
--success-bg: #163828;
|
||||||
|
--cal-marker-mix: 24%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] {
|
:root[data-theme="light"] {
|
||||||
|
|
@ -67,6 +74,7 @@
|
||||||
--danger-bg: #fdecea;
|
--danger-bg: #fdecea;
|
||||||
--success: #1e8e5a;
|
--success: #1e8e5a;
|
||||||
--success-bg: #e8f7ef;
|
--success-bg: #e8f7ef;
|
||||||
|
--cal-marker-mix: 38%;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
@ -1554,6 +1562,8 @@ button.calendar-filters-close { display: none; }
|
||||||
.modal-section .rehearsal-actions { margin-top: 0.5rem; }
|
.modal-section .rehearsal-actions { margin-top: 0.5rem; }
|
||||||
|
|
||||||
.rehearsal-accepted-by .avatar-sm { width: 1.3rem; height: 1.3rem; font-size: 0.65rem; }
|
.rehearsal-accepted-by .avatar-sm { width: 1.3rem; height: 1.3rem; font-size: 0.65rem; }
|
||||||
|
.rehearsal-accepted-by .note { margin: 0; }
|
||||||
|
.rehearsal-refused-by { opacity: 0.75; }
|
||||||
|
|
||||||
.vote-badge {
|
.vote-badge {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|
@ -1565,6 +1575,10 @@ button.calendar-filters-close { display: none; }
|
||||||
.vote-badge.accept { border-left: 3px solid var(--success); }
|
.vote-badge.accept { border-left: 3px solid var(--success); }
|
||||||
.vote-badge.reject { border-left: 3px solid var(--danger); }
|
.vote-badge.reject { border-left: 3px solid var(--danger); }
|
||||||
|
|
||||||
|
.vote-badge.rehearsal-status-confirmed { border-left: 3px solid var(--accent); }
|
||||||
|
.vote-badge.rehearsal-status-suggested { border-left: 3px solid var(--success); }
|
||||||
|
.vote-badge.rehearsal-status-rejected { border-left: 3px solid var(--danger); }
|
||||||
|
|
||||||
.rehearsal-voted-section { margin-top: 0.75rem; }
|
.rehearsal-voted-section { margin-top: 0.75rem; }
|
||||||
|
|
||||||
.rehearsal-voted-section > summary {
|
.rehearsal-voted-section > summary {
|
||||||
|
|
@ -1593,11 +1607,22 @@ button.calendar-filters-close { display: none; }
|
||||||
|
|
||||||
/* Concert/rehearsal markers take priority over the availability heat colors
|
/* Concert/rehearsal markers take priority over the availability heat colors
|
||||||
above (same specificity, later in the cascade) — a scheduled date matters
|
above (same specificity, later in the cascade) — a scheduled date matters
|
||||||
more than who's free that day. */
|
more than who's free that day.
|
||||||
.cal-cell.has-rehearsal { background: color-mix(in srgb, var(--accent) 20%, var(--surface)); border-color: var(--accent); }
|
Colors: confirmed rehearsals are purple (accent) — the "settled, book it"
|
||||||
.cal-cell.has-concert { background: color-mix(in srgb, var(--accent-2) 20%, var(--surface)); border-color: var(--accent-2); }
|
state; still-pending suggestions are green — an invitation to vote, not yet
|
||||||
.cal-cell.has-rehearsal.has-concert {
|
settled; a rejected rehearsal (host said no, or too many refusals) is red. */
|
||||||
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 20%, var(--surface)), color-mix(in srgb, var(--accent-2) 20%, var(--surface)));
|
.cal-cell.rehearsal-confirmed { background: color-mix(in srgb, var(--accent) var(--cal-marker-mix), var(--surface)); border-color: var(--accent); }
|
||||||
|
.cal-cell.rehearsal-suggested { background: color-mix(in srgb, var(--success) var(--cal-marker-mix), var(--surface)); border-color: var(--success); }
|
||||||
|
.cal-cell.rehearsal-rejected { background: color-mix(in srgb, var(--danger) var(--cal-marker-mix), var(--surface)); border-color: var(--danger); }
|
||||||
|
.cal-cell.has-concert { background: color-mix(in srgb, var(--accent-2) var(--cal-marker-mix), var(--surface)); border-color: var(--accent-2); }
|
||||||
|
.cal-cell.rehearsal-confirmed.has-concert {
|
||||||
|
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) var(--cal-marker-mix), var(--surface)), color-mix(in srgb, var(--accent-2) var(--cal-marker-mix), var(--surface)));
|
||||||
|
}
|
||||||
|
.cal-cell.rehearsal-suggested.has-concert {
|
||||||
|
background: linear-gradient(135deg, color-mix(in srgb, var(--success) var(--cal-marker-mix), var(--surface)), color-mix(in srgb, var(--accent-2) var(--cal-marker-mix), var(--surface)));
|
||||||
|
}
|
||||||
|
.cal-cell.rehearsal-rejected.has-concert {
|
||||||
|
background: linear-gradient(135deg, color-mix(in srgb, var(--danger) var(--cal-marker-mix), var(--surface)), color-mix(in srgb, var(--accent-2) var(--cal-marker-mix), var(--surface)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Concerts: upcoming list + detail actions ---------- */
|
/* ---------- Concerts: upcoming list + detail actions ---------- */
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,14 @@ async function onRemoveFeed(feedId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSlotSettingsForm() {
|
function hostOptionsHtml(users, selectedId) {
|
||||||
|
const options = users.map(
|
||||||
|
(u) => `<option value="${u.id}"${u.id === selectedId ? ' selected' : ''}>${escapeHtml(u.name)}</option>`
|
||||||
|
);
|
||||||
|
return `<option value=""${selectedId ? '' : ' selected'}>(aucun)</option>${options.join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSlotSettingsForm(users) {
|
||||||
const settings = await api.get('/api/calendar/settings');
|
const settings = await api.get('/api/calendar/settings');
|
||||||
const form = document.getElementById('slot-settings-form');
|
const form = document.getElementById('slot-settings-form');
|
||||||
form.weekdayStart.value = settings.weekdayStart;
|
form.weekdayStart.value = settings.weekdayStart;
|
||||||
|
|
@ -190,6 +197,7 @@ async function loadSlotSettingsForm() {
|
||||||
form.concertStart.value = settings.concertStart;
|
form.concertStart.value = settings.concertStart;
|
||||||
form.concertEnd.value = settings.concertEnd;
|
form.concertEnd.value = settings.concertEnd;
|
||||||
form.rehearsalConfirmThreshold.value = settings.rehearsalConfirmThreshold;
|
form.rehearsalConfirmThreshold.value = settings.rehearsalConfirmThreshold;
|
||||||
|
form.rehearsalHostUserId.innerHTML = hostOptionsHtml(users, settings.rehearsalHostUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSaveSlotSettings(e) {
|
async function onSaveSlotSettings(e) {
|
||||||
|
|
@ -207,6 +215,7 @@ async function onSaveSlotSettings(e) {
|
||||||
concertStart: form.concertStart.value,
|
concertStart: form.concertStart.value,
|
||||||
concertEnd: form.concertEnd.value,
|
concertEnd: form.concertEnd.value,
|
||||||
rehearsalConfirmThreshold: parseInt(form.rehearsalConfirmThreshold.value, 10),
|
rehearsalConfirmThreshold: parseInt(form.rehearsalConfirmThreshold.value, 10),
|
||||||
|
rehearsalHostUserId: form.rehearsalHostUserId.value || null,
|
||||||
});
|
});
|
||||||
statusEl.textContent = 'Horaires enregistrés.';
|
statusEl.textContent = 'Horaires enregistrés.';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -277,6 +286,14 @@ async function onSaveSlotSettings(e) {
|
||||||
n'est pas atteint ; elle passe ensuite en répétition confirmée (dans l'application et dans le
|
n'est pas atteint ; elle passe ensuite en répétition confirmée (dans l'application et dans le
|
||||||
flux ICS).
|
flux ICS).
|
||||||
</p>
|
</p>
|
||||||
|
<label>Hôte des répétitions (⭐)
|
||||||
|
<select name="rehearsalHostUserId"></select>
|
||||||
|
</label>
|
||||||
|
<p class="note">
|
||||||
|
Si un hôte est désigné, une répétition ne peut être confirmée que si cette personne a elle-même
|
||||||
|
accepté le créneau — sans elle, pas de lieu pour répéter. Choisissez « (aucun) » pour désactiver
|
||||||
|
cette contrainte.
|
||||||
|
</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>
|
||||||
|
|
@ -310,7 +327,7 @@ async function onSaveSlotSettings(e) {
|
||||||
`;
|
`;
|
||||||
document.getElementById('slot-settings-form').addEventListener('submit', onSaveSlotSettings);
|
document.getElementById('slot-settings-form').addEventListener('submit', onSaveSlotSettings);
|
||||||
document.getElementById('add-discord-webhook-form').addEventListener('submit', onAddDiscordWebhook);
|
document.getElementById('add-discord-webhook-form').addEventListener('submit', onAddDiscordWebhook);
|
||||||
await loadSlotSettingsForm();
|
await loadSlotSettingsForm(stats.users);
|
||||||
await loadCalendarUsers();
|
await loadCalendarUsers();
|
||||||
await loadDiscordWebhooks();
|
await loadDiscordWebhooks();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ function concertLinksHtml(concert) {
|
||||||
|
|
||||||
function rehearsalStatusLabel(r) {
|
function rehearsalStatusLabel(r) {
|
||||||
if (r.status === 'confirmed') return 'Répétition confirmée';
|
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;
|
const accepted = r.votes.filter((v) => v.vote === 'accept').length;
|
||||||
return `Suggestion de répétition (${accepted}/${r.confirmThreshold} votes)`;
|
return `Suggestion de répétition (${accepted}/${r.confirmThreshold} votes)`;
|
||||||
}
|
}
|
||||||
|
|
@ -216,9 +217,14 @@ function renderCalendar() {
|
||||||
|
|
||||||
const rehearsal = rehearsalsByDate.get(isoDate(date));
|
const rehearsal = rehearsalsByDate.get(isoDate(date));
|
||||||
if (rehearsal) {
|
if (rehearsal) {
|
||||||
cell.classList.add('has-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');
|
const badge = document.createElement('span');
|
||||||
badge.title = 'Répétition proposée';
|
badge.title = statusTitles[rehearsal.status] || statusTitles.suggested;
|
||||||
badge.textContent = '🎸';
|
badge.textContent = '🎸';
|
||||||
badges.appendChild(badge);
|
badges.appendChild(badge);
|
||||||
}
|
}
|
||||||
|
|
@ -488,13 +494,29 @@ function rehearsalVoteActionsHtml(r) {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function rehearsalAcceptedByHtml(r) {
|
||||||
const accepted = r.votes.filter((v) => v.vote === 'accept');
|
const accepted = r.votes.filter((v) => v.vote === 'accept');
|
||||||
if (!accepted.length) return '';
|
const refused = r.votes.filter((v) => v.vote === 'reject');
|
||||||
|
if (!accepted.length && !refused.length) return '';
|
||||||
return `
|
return `
|
||||||
|
${accepted.length ? `
|
||||||
<div class="rehearsal-accepted-by">
|
<div class="rehearsal-accepted-by">
|
||||||
${accepted.map((v) => `${avatarHtml({ name: v.name, avatarUrl: v.avatarUrl }, 'avatar-sm')}<span>${escapeHtml(v.name)}</span>`).join('')}
|
<span class="note">Ont accepté :</span>
|
||||||
|
${accepted.map((v) => rehearsalVoterHtml(v, r)).join('')}
|
||||||
</div>
|
</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>
|
||||||
|
` : ''}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -505,7 +527,7 @@ function rehearsalRowTemplate(r) {
|
||||||
return `
|
return `
|
||||||
<div class="rehearsal-row" data-rehearsal-id="${r.id}">
|
<div class="rehearsal-row" data-rehearsal-id="${r.id}">
|
||||||
<div>
|
<div>
|
||||||
<span class="vote-badge ${r.status === 'confirmed' ? 'accept' : ''}">${escapeHtml(rehearsalStatusLabel(r))}</span>
|
<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-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 class="card-subtitle">${r.location ? `${escapeHtml(r.location)} · ` : ''}Proposée par ${escapeHtml(r.proposedByName)}</div>
|
||||||
${rehearsalAcceptedByHtml(r)}
|
${rehearsalAcceptedByHtml(r)}
|
||||||
|
|
|
||||||
2
src/db/migrations/020_rehearsal_host.sql
Normal file
2
src/db/migrations/020_rehearsal_host.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE calendar_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS rehearsal_host_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|
@ -40,8 +40,11 @@ function foldLine(line) {
|
||||||
return chunks.map((chunk, i) => (i === 0 ? chunk : ` ${chunk}`)).join('\r\n');
|
return chunks.map((chunk, i) => (i === 0 ? chunk : ` ${chunk}`)).join('\r\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function rehearsalToEvent(rehearsal, { threshold, baseUrl, allUsers }) {
|
// Returns null for a rejected rehearsal — those are dropped entirely from the
|
||||||
const status = computeRehearsalStatus(rehearsal.votes, threshold);
|
// shared feed (see buildRehearsalsFeed) rather than shown as tentative/cancelled.
|
||||||
|
function rehearsalToEvent(rehearsal, { threshold, hostUserId, baseUrl, allUsers }) {
|
||||||
|
const status = computeRehearsalStatus(rehearsal.votes, threshold, hostUserId);
|
||||||
|
if (status === 'rejected') return null;
|
||||||
const icsStatus = status === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE';
|
const icsStatus = status === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE';
|
||||||
const summary = status === 'confirmed' ? 'Répétition' : 'Répétition (proposition)';
|
const summary = status === 'confirmed' ? 'Répétition' : 'Répétition (proposition)';
|
||||||
const voteUrl = `${baseUrl}/calendar.html?rehearsalId=${rehearsal.id}`;
|
const voteUrl = `${baseUrl}/calendar.html?rehearsalId=${rehearsal.id}`;
|
||||||
|
|
@ -74,7 +77,7 @@ function rehearsalToEvent(rehearsal, { threshold, baseUrl, allUsers }) {
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRehearsalsFeed(rehearsals, { threshold, baseUrl, allUsers }) {
|
function buildRehearsalsFeed(rehearsals, { threshold, hostUserId, baseUrl, allUsers }) {
|
||||||
const lines = [
|
const lines = [
|
||||||
'BEGIN:VCALENDAR',
|
'BEGIN:VCALENDAR',
|
||||||
'VERSION:2.0',
|
'VERSION:2.0',
|
||||||
|
|
@ -82,7 +85,10 @@ function buildRehearsalsFeed(rehearsals, { threshold, baseUrl, allUsers }) {
|
||||||
'CALSCALE:GREGORIAN',
|
'CALSCALE:GREGORIAN',
|
||||||
'METHOD:PUBLISH',
|
'METHOD:PUBLISH',
|
||||||
'X-WR-CALNAME:Répétitions Octane',
|
'X-WR-CALNAME:Répétitions Octane',
|
||||||
...rehearsals.flatMap((r) => rehearsalToEvent(r, { threshold, baseUrl, allUsers })),
|
...rehearsals
|
||||||
|
.map((r) => rehearsalToEvent(r, { threshold, hostUserId, baseUrl, allUsers }))
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat(),
|
||||||
'END:VCALENDAR',
|
'END:VCALENDAR',
|
||||||
];
|
];
|
||||||
return lines.map(foldLine).join('\r\n');
|
return lines.map(foldLine).join('\r\n');
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,22 @@
|
||||||
// Shared between the API (rehearsals list) and the ICS feed so both agree on
|
// Shared between the API (rehearsals list) and the ICS feed so both agree on
|
||||||
// when a proposed rehearsal counts as confirmed.
|
// when a proposed rehearsal counts as confirmed, rejected, or still a
|
||||||
function computeRehearsalStatus(votes, threshold) {
|
// suggestion. When a host is configured, their acceptance is mandatory:
|
||||||
|
// without the host there is nowhere to play, so an explicit host refusal (or
|
||||||
|
// too many "reject" votes) kills the rehearsal outright rather than leaving
|
||||||
|
// it as a pending suggestion.
|
||||||
|
function computeRehearsalStatus(votes, threshold, hostUserId) {
|
||||||
const acceptCount = votes.filter((v) => v.vote === 'accept').length;
|
const acceptCount = votes.filter((v) => v.vote === 'accept').length;
|
||||||
return acceptCount >= threshold ? 'confirmed' : 'suggested';
|
const rejectCount = votes.filter((v) => v.vote === 'reject').length;
|
||||||
|
const hostVote = hostUserId ? votes.find((v) => v.userId === hostUserId) : null;
|
||||||
|
|
||||||
|
if ((hostVote && hostVote.vote === 'reject') || rejectCount >= threshold) {
|
||||||
|
return 'rejected';
|
||||||
|
}
|
||||||
|
if (acceptCount < threshold) return 'suggested';
|
||||||
|
if (hostUserId && !(hostVote && hostVote.vote === 'accept')) {
|
||||||
|
return 'suggested';
|
||||||
|
}
|
||||||
|
return 'confirmed';
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { computeRehearsalStatus };
|
module.exports = { computeRehearsalStatus };
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,12 @@ 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, concert_start, concert_end, rehearsal_confirm_threshold FROM calendar_settings WHERE id = 1'
|
`SELECT s.weekday_start, s.weekday_end, s.weekend_start, s.weekend_end, s.margin_minutes,
|
||||||
|
s.concert_start, s.concert_end, s.rehearsal_confirm_threshold, s.rehearsal_host_user_id,
|
||||||
|
h.name AS rehearsal_host_name
|
||||||
|
FROM calendar_settings s
|
||||||
|
LEFT JOIN users h ON h.id = s.rehearsal_host_user_id
|
||||||
|
WHERE s.id = 1`
|
||||||
);
|
);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
const weekdayStart = parseTime(row.weekday_start);
|
const weekdayStart = parseTime(row.weekday_start);
|
||||||
|
|
@ -220,17 +225,19 @@ async function getSlotSettings() {
|
||||||
marginMinutes: row.margin_minutes,
|
marginMinutes: row.margin_minutes,
|
||||||
concert: { startHour: concertStart.hour, startMinute: concertStart.minute, endHour: concertEnd.hour, endMinute: concertEnd.minute },
|
concert: { startHour: concertStart.hour, startMinute: concertStart.minute, endHour: concertEnd.hour, endMinute: concertEnd.minute },
|
||||||
rehearsalConfirmThreshold: row.rehearsal_confirm_threshold,
|
rehearsalConfirmThreshold: row.rehearsal_confirm_threshold,
|
||||||
|
rehearsalHostUserId: row.rehearsal_host_user_id,
|
||||||
|
rehearsalHostName: row.rehearsal_host_name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateSlotSettings({ weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold }) {
|
async function updateSlotSettings({ weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold, rehearsalHostUserId }) {
|
||||||
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, rehearsal_confirm_threshold = $8
|
concert_start = $6, concert_end = $7, rehearsal_confirm_threshold = $8, rehearsal_host_user_id = $9
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
RETURNING weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end, rehearsal_confirm_threshold`,
|
RETURNING weekday_start, weekday_end, weekend_start, weekend_end, margin_minutes, concert_start, concert_end, rehearsal_confirm_threshold, rehearsal_host_user_id`,
|
||||||
[weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold]
|
[weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold, rehearsalHostUserId || null]
|
||||||
);
|
);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,8 @@ router.get(
|
||||||
concertStart: formatTime(settings.concert.startHour, settings.concert.startMinute),
|
concertStart: formatTime(settings.concert.startHour, settings.concert.startMinute),
|
||||||
concertEnd: formatTime(settings.concert.endHour, settings.concert.endMinute),
|
concertEnd: formatTime(settings.concert.endHour, settings.concert.endMinute),
|
||||||
rehearsalConfirmThreshold: settings.rehearsalConfirmThreshold,
|
rehearsalConfirmThreshold: settings.rehearsalConfirmThreshold,
|
||||||
|
rehearsalHostUserId: settings.rehearsalHostUserId,
|
||||||
|
rehearsalHostName: settings.rehearsalHostName,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -214,7 +216,7 @@ router.patch(
|
||||||
'/settings',
|
'/settings',
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold } = req.body || {};
|
const { weekdayStart, weekdayEnd, weekendStart, weekendEnd, marginMinutes, concertStart, concertEnd, rehearsalConfirmThreshold, rehearsalHostUserId } = req.body || {};
|
||||||
const times = { weekdayStart, weekdayEnd, weekendStart, weekendEnd, concertStart, concertEnd };
|
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 || '')) {
|
||||||
|
|
@ -239,7 +241,19 @@ router.patch(
|
||||||
return res.status(400).json({ error: 'invalid_rehearsal_confirm_threshold' });
|
return res.status(400).json({ error: 'invalid_rehearsal_confirm_threshold' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const values = { ...times, marginMinutes: margin, rehearsalConfirmThreshold: threshold };
|
let hostUserId = null;
|
||||||
|
if (rehearsalHostUserId !== null && rehearsalHostUserId !== undefined && rehearsalHostUserId !== '') {
|
||||||
|
hostUserId = Number(rehearsalHostUserId);
|
||||||
|
if (!Number.isInteger(hostUserId)) {
|
||||||
|
return res.status(400).json({ error: 'invalid_rehearsal_host_user_id' });
|
||||||
|
}
|
||||||
|
const host = await usersRepo.findById(hostUserId);
|
||||||
|
if (!host) {
|
||||||
|
return res.status(400).json({ error: 'rehearsal_host_user_not_found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = { ...times, marginMinutes: margin, rehearsalConfirmThreshold: threshold, rehearsalHostUserId: hostUserId };
|
||||||
await calendarRepo.updateSlotSettings(values);
|
await calendarRepo.updateSlotSettings(values);
|
||||||
res.json(values);
|
res.json(values);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,12 @@ router.get(
|
||||||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
||||||
res
|
res
|
||||||
.type('text/calendar; charset=utf-8')
|
.type('text/calendar; charset=utf-8')
|
||||||
.send(buildRehearsalsFeed(rehearsals, { threshold: settings.rehearsalConfirmThreshold, baseUrl, allUsers }));
|
.send(buildRehearsalsFeed(rehearsals, {
|
||||||
|
threshold: settings.rehearsalConfirmThreshold,
|
||||||
|
hostUserId: settings.rehearsalHostUserId,
|
||||||
|
baseUrl,
|
||||||
|
allUsers,
|
||||||
|
}));
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ router.get(
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const [rehearsals, settings] = await Promise.all([rehearsalsRepo.findUpcoming(), calendarRepo.getSlotSettings()]);
|
const [rehearsals, settings] = await Promise.all([rehearsalsRepo.findUpcoming(), calendarRepo.getSlotSettings()]);
|
||||||
const threshold = settings.rehearsalConfirmThreshold;
|
const threshold = settings.rehearsalConfirmThreshold;
|
||||||
|
const hostUserId = settings.rehearsalHostUserId;
|
||||||
res.json(
|
res.json(
|
||||||
rehearsals.map((r) => ({
|
rehearsals.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
|
@ -21,8 +22,10 @@ router.get(
|
||||||
proposedBy: r.proposed_by,
|
proposedBy: r.proposed_by,
|
||||||
proposedByName: r.proposed_by_name,
|
proposedByName: r.proposed_by_name,
|
||||||
votes: r.votes,
|
votes: r.votes,
|
||||||
status: computeRehearsalStatus(r.votes, threshold),
|
status: computeRehearsalStatus(r.votes, threshold, hostUserId),
|
||||||
confirmThreshold: threshold,
|
confirmThreshold: threshold,
|
||||||
|
hostUserId,
|
||||||
|
hostName: settings.rehearsalHostName,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue