Files
vliaudatandClaude Opus 5 c430205bf2 feat: add the closure periods page
Create a period with a start, an end and a label, see the twelve months
ahead at a glance, delete one. The label is required and trimmed: it
goes straight onto the shop window.

Overlapping periods are refused, and the message names the one they
collide with. Two rows covering the same day would both be "in effect"
with no way to say which, and silently merging them would lose whichever
label the owner meant. Periods that merely touch — one ending the 10th,
the next starting the 11th — are fine.

The year view exists because a list of date ranges is precise and hard
to picture, while "have I left a gap in August?" is the question people
actually ask. It renders on the server; it only changes when the data
does.

A vacation period stays a source of truth and is never expanded into
exception rows, so a one-off exception placed inside one still wins and
nothing is overwritten. The page says so rather than leaving it to be
discovered.

The first version of the calendar built the months but never applied the
periods to them, so no closure would ever have shown. Caught before
commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 20:10:58 +02:00

118 lines
3.5 KiB
TypeScript

import { addCivilDays, civilDayOfWeek } from '@/lib/schedule/civil';
import { periodsOverlap, type Period } from '@/lib/schedule/vacations';
const MONTHS_FR = [
'Janvier',
'Février',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Août',
'Septembre',
'Octobre',
'Novembre',
'Décembre',
];
type LabelledPeriod = Period & { labelFr: string };
type Day = { date: string; isWeekend: boolean; label: string | null };
type Month = { key: string; label: string; days: Day[] };
/**
* Twelve months from today, with closure days filled in.
*
* A list of date ranges is precise and hard to picture; a year at a glance is
* how anyone actually checks "have I left a gap in August?". Rendered on the
* server: it only changes when the data does.
*/
export default function YearCalendar({
from,
periods,
}: {
from: string;
periods: LabelledPeriod[];
}) {
const months = buildMonths(from, periods);
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[34rem] border-separate border-spacing-y-1 text-sm">
<caption className="sr-only">
Calendrier des douze prochains mois, jours de fermeture marqués
</caption>
<tbody>
{months.map((month) => (
<tr key={month.key}>
<th
scope="row"
className="w-28 pr-3 text-left align-middle font-normal text-[var(--ink-muted)]"
>
{month.label}
</th>
<td>
<div className="flex gap-[2px]">
{month.days.map((day) => (
<span
key={day.date}
title={day.label ? `${day.date} — ${day.label}` : day.date}
className={[
'h-5 flex-1 rounded-[2px]',
day.label
? 'bg-[var(--accent)]'
: day.isWeekend
? 'bg-[var(--surface-muted)]'
: 'bg-[var(--line)]',
].join(' ')}
/>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
<p className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]">
<span className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-[2px] bg-[var(--accent)]" /> fermeture
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-[2px] bg-[var(--surface-muted)]" /> week-end
</span>
</p>
</div>
);
}
function buildMonths(from: string, periods: LabelledPeriod[]): Month[] {
const months: Month[] = [];
let cursor = `${from.slice(0, 7)}-01`;
for (let index = 0; index < 12; index += 1) {
const prefix = cursor.slice(0, 7);
const [year, month] = prefix.split('-').map(Number);
const days: Day[] = [];
let day = cursor;
while (day.startsWith(prefix)) {
const covering = periods.find((period) =>
periodsOverlap({ startDate: day, endDate: day }, period),
);
days.push({
date: day,
isWeekend: [0, 6].includes(civilDayOfWeek(day)),
label: covering?.labelFr ?? null,
});
day = addCivilDays(day, 1);
}
months.push({ key: prefix, label: `${MONTHS_FR[(month ?? 1) - 1]} ${year}`, days });
cursor = day;
}
return months;
}