Files
ita-ito-horaires/lib/schedule/week.ts
T
vliaudatandClaude Opus 5 eb23eb650b feat: edit the reference week from the admin
The first administration page, built for the actual use case: a phone
held in one hand behind the counter. One card per day, native time
inputs so the platform keyboard does the work, and the consequences
shown before the save rather than after — the footer names which days
are about to change, and the button stays disabled until something
actually has.

"Duplicate onto the other open days" leaves closed days closed. Someone
copying Tuesday's hours means "the days I open, I open like this", not
"open seven days a week".

Validation runs in the browser for the feedback and again in the action
before the write: the client is a convenience, not a guarantee, and this
is the schedule the shop window shows. A day being closed drops its
leftover slots rather than failing on them.

A save that changes nothing writes nothing — no rows, no audit entry,
and so no needless panel redraw. Reordering slots does not count as a
change. The audit diff stores one readable line per day in French, so
the log can be read without cross-referencing the schema.

The editing helpers are pure and tested, and the write path is tested
against a real database including the read-only refusal.

Test files now run sequentially: the integration files share one
database and each truncates it, so parallel files raced. The suite takes
six seconds; giving every file its own database would buy nothing.

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

112 lines
3.3 KiB
TypeScript

/**
* Editing helpers for the reference week.
*
* Pure, so the behaviour the shop owner actually touches — adding a lunch
* break, copying one day onto the others — is tested without a browser.
*/
import { minutesOfTime } from './civil';
import type { Slot, WeeklyScheduleEntry } from './types';
import { validateSlots } from './validate';
/** Monday first: how the week is read, and how the screen draws it. */
export const WEEK_ORDER = [1, 2, 3, 4, 5, 6, 0] as const;
export const DAY_NAMES_FR: Record<number, string> = {
0: 'Dimanche',
1: 'Lundi',
2: 'Mardi',
3: 'Mercredi',
4: 'Jeudi',
5: 'Vendredi',
6: 'Samedi',
};
export type WeekDraft = WeeklyScheduleEntry[];
/** A complete week, in display order, filling in any day the database lacks. */
export function toDraft(entries: readonly WeeklyScheduleEntry[]): WeekDraft {
return WEEK_ORDER.map((dayOfWeek) => {
const found = entries.find((entry) => entry.dayOfWeek === dayOfWeek);
return {
dayOfWeek,
isClosed: found?.isClosed ?? true,
slots: sortSlots(found?.slots ?? []),
};
});
}
/**
* Copies one day's hours onto every other day that is currently open.
*
* Closed days stay closed: someone duplicating Tuesday's hours means "the days
* I open, I open like this", not "open seven days a week".
*/
export function duplicateToOpenDays(week: WeekDraft, sourceDayOfWeek: number): WeekDraft {
const source = week.find((day) => day.dayOfWeek === sourceDayOfWeek);
if (!source || source.isClosed) {
return week;
}
return week.map((day) =>
day.dayOfWeek === sourceDayOfWeek || day.isClosed
? day
: { ...day, slots: source.slots.map((slot) => ({ ...slot })) },
);
}
export type WeekErrors = Record<number, string[]>;
/** Validates every day, keyed by day so the message lands next to its row. */
export function validateWeek(week: WeekDraft): WeekErrors {
const errors: WeekErrors = {};
for (const day of week) {
if (day.isClosed) {
continue;
}
const dayErrors = validateSlots(day.slots);
if (day.slots.length === 0) {
dayErrors.push('Un jour ouvert doit avoir au moins une plage horaire.');
}
if (dayErrors.length > 0) {
errors[day.dayOfWeek] = dayErrors;
}
}
return errors;
}
export function hasErrors(errors: WeekErrors): boolean {
return Object.keys(errors).length > 0;
}
/**
* Cleans a draft for storage: closed days lose their slots, open days keep
* them in chronological order.
*/
export function normaliseWeek(week: WeekDraft): WeekDraft {
return week.map((day) => ({
dayOfWeek: day.dayOfWeek,
isClosed: day.isClosed,
slots: day.isClosed ? [] : sortSlots(day.slots),
}));
}
/** Which days differ between two drafts, so the audit entry names only those. */
export function changedDays(before: WeekDraft, after: WeekDraft): number[] {
return after
.filter((day) => {
const previous = before.find((entry) => entry.dayOfWeek === day.dayOfWeek);
if (!previous) {
return true;
}
return (
previous.isClosed !== day.isClosed ||
JSON.stringify(sortSlots(previous.slots)) !== JSON.stringify(sortSlots(day.slots))
);
})
.map((day) => day.dayOfWeek);
}
function sortSlots(slots: readonly Slot[]): Slot[] {
return [...slots].sort((a, b) => minutesOfTime(a.open) - minutesOfTime(b.open));
}