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
66 lines
2.3 KiB
TypeScript
66 lines
2.3 KiB
TypeScript
/**
|
||
* Closure periods.
|
||
*
|
||
* A vacation period is a source of truth, never expanded into exception rows:
|
||
* the resolver reads it directly. That is why editing one can never orphan
|
||
* anything, and why overlapping periods are worth refusing rather than
|
||
* silently merging — two rows covering the same day would both be "in effect"
|
||
* with no way to say which.
|
||
*/
|
||
|
||
import { civilDaysBetween, compareCivil, type CivilDate } from './civil';
|
||
|
||
/** Longer than a year is a data-entry accident, not a closure. */
|
||
export const MAX_PERIOD_DAYS = 366;
|
||
|
||
export type Period = {
|
||
startDate: CivilDate;
|
||
endDate: CivilDate;
|
||
};
|
||
|
||
export type NamedPeriod = Period & { id: string; labelFr: string };
|
||
|
||
/** Inclusive on both ends, as the shop would read it off a note on the door. */
|
||
export function countDays(period: Period): number {
|
||
return civilDaysBetween(period.startDate, period.endDate) + 1;
|
||
}
|
||
|
||
export function periodsOverlap(a: Period, b: Period): boolean {
|
||
return compareCivil(a.startDate, b.endDate) <= 0 && compareCivil(b.startDate, a.endDate) <= 0;
|
||
}
|
||
|
||
/** The first existing period a candidate would collide with, if any. */
|
||
export function findOverlap(candidate: Period, existing: readonly NamedPeriod[]): NamedPeriod | null {
|
||
return existing.find((period) => periodsOverlap(candidate, period)) ?? null;
|
||
}
|
||
|
||
export function validatePeriod(input: {
|
||
startDate: CivilDate;
|
||
endDate: CivilDate;
|
||
labelFr: string;
|
||
}): string[] {
|
||
const errors: string[] = [];
|
||
|
||
if (!input.startDate || !input.endDate) {
|
||
errors.push('Indiquez une date de début et une date de fin.');
|
||
return errors;
|
||
}
|
||
if (compareCivil(input.startDate, input.endDate) > 0) {
|
||
errors.push('La date de fin précède la date de début.');
|
||
} else if (countDays(input) > MAX_PERIOD_DAYS) {
|
||
errors.push(`Une période ne peut pas dépasser ${MAX_PERIOD_DAYS} jours.`);
|
||
}
|
||
if (input.labelFr.trim().length === 0) {
|
||
errors.push('Donnez un libellé à cette période (il s’affiche sur l’écran).');
|
||
}
|
||
|
||
return errors;
|
||
}
|
||
|
||
/** Periods still to come or in progress, soonest first. */
|
||
export function upcomingPeriods<T extends Period>(periods: readonly T[], today: CivilDate): T[] {
|
||
return periods
|
||
.filter((period) => compareCivil(period.endDate, today) >= 0)
|
||
.sort((a, b) => compareCivil(a.startDate, b.startDate));
|
||
}
|