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
This commit is contained in:
2026-09-20 20:10:58 +02:00
co-authored by Claude Opus 5
parent 7d23b14ad8
commit c430205bf2
8 changed files with 787 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest';
import {
countDays,
findOverlap,
MAX_PERIOD_DAYS,
periodsOverlap,
upcomingPeriods,
validatePeriod,
type NamedPeriod,
} from './vacations';
const period = (startDate: string, endDate: string): { startDate: string; endDate: string } => ({
startDate,
endDate,
});
const named = (id: string, startDate: string, endDate: string): NamedPeriod => ({
id,
labelFr: id,
startDate,
endDate,
});
describe('countDays', () => {
it('counts both ends', () => {
// The shop reads "closed from the 20th to the 22nd" as three days.
expect(countDays(period('2026-09-20', '2026-09-22'))).toBe(3);
});
it('counts a single day as one', () => {
expect(countDays(period('2026-09-20', '2026-09-20'))).toBe(1);
});
it('crosses a year boundary', () => {
expect(countDays(period('2026-12-30', '2027-01-02'))).toBe(4);
});
});
describe('periodsOverlap', () => {
it('detects a full overlap', () => {
expect(periodsOverlap(period('2026-09-20', '2026-09-30'), period('2026-09-22', '2026-09-24'))).toBe(
true,
);
});
it('detects a partial overlap from either side', () => {
expect(periodsOverlap(period('2026-09-20', '2026-09-25'), period('2026-09-24', '2026-09-28'))).toBe(
true,
);
expect(periodsOverlap(period('2026-09-24', '2026-09-28'), period('2026-09-20', '2026-09-25'))).toBe(
true,
);
});
it('counts a single shared day as an overlap', () => {
expect(periodsOverlap(period('2026-09-20', '2026-09-22'), period('2026-09-22', '2026-09-24'))).toBe(
true,
);
});
it('leaves adjacent periods alone', () => {
// Ending on the 22nd and starting on the 23rd share nothing.
expect(periodsOverlap(period('2026-09-20', '2026-09-22'), period('2026-09-23', '2026-09-25'))).toBe(
false,
);
});
it('leaves distant periods alone', () => {
expect(periodsOverlap(period('2026-01-01', '2026-01-10'), period('2026-08-01', '2026-08-10'))).toBe(
false,
);
});
});
describe('findOverlap', () => {
const existing = [named('été', '2026-07-20', '2026-08-10'), named('noël', '2026-12-24', '2027-01-02')];
it('names the period that collides', () => {
expect(findOverlap(period('2026-08-05', '2026-08-15'), existing)?.id).toBe('été');
});
it('returns nothing when the candidate is free', () => {
expect(findOverlap(period('2026-09-01', '2026-09-10'), existing)).toBeNull();
});
it('returns nothing against an empty list', () => {
expect(findOverlap(period('2026-09-01', '2026-09-10'), [])).toBeNull();
});
});
describe('validatePeriod', () => {
it('accepts a sound period', () => {
expect(validatePeriod({ startDate: '2026-07-20', endDate: '2026-08-10', labelFr: 'Congés' })).toEqual(
[],
);
});
it('refuses missing dates', () => {
expect(validatePeriod({ startDate: '', endDate: '2026-08-10', labelFr: 'Congés' })).toHaveLength(1);
});
it('refuses a reversed range', () => {
expect(
validatePeriod({ startDate: '2026-08-10', endDate: '2026-07-20', labelFr: 'Congés' }).join(' '),
).toMatch(/précède/);
});
it('refuses a period longer than a year', () => {
expect(
validatePeriod({ startDate: '2026-01-01', endDate: '2027-06-01', labelFr: 'Congés' }).join(' '),
).toMatch(String(MAX_PERIOD_DAYS));
});
it('requires a label, because it is shown on the screen', () => {
expect(
validatePeriod({ startDate: '2026-07-20', endDate: '2026-08-10', labelFr: ' ' }).join(' '),
).toMatch(/libellé/);
});
it('reports several problems at once', () => {
expect(
validatePeriod({ startDate: '2026-08-10', endDate: '2026-07-20', labelFr: '' }),
).toHaveLength(2);
});
});
describe('upcomingPeriods', () => {
const periods = [
named('noël', '2026-12-24', '2027-01-02'),
named('passé', '2026-02-01', '2026-02-10'),
named('été', '2026-07-20', '2026-08-10'),
];
it('drops periods that have finished', () => {
expect(upcomingPeriods(periods, '2026-09-20').map((p) => p.id)).toEqual(['noël']);
});
it('keeps a period currently in progress', () => {
expect(upcomingPeriods(periods, '2026-08-01').map((p) => p.id)).toEqual(['été', 'noël']);
});
it('keeps a period ending today', () => {
expect(upcomingPeriods(periods, '2026-08-10').map((p) => p.id)).toContain('été');
});
it('sorts by start date', () => {
expect(upcomingPeriods(periods, '2026-01-01').map((p) => p.id)).toEqual(['passé', 'été', 'noël']);
});
});