Files
vliaudatandClaude Opus 5 743a3778cc fix: start the quick controls from the selected day's own hours
"Opens later at" and "closes earlier at" were prefilled with 14:00 and
16:00 — arbitrary values, wrong on most days, which left the preview
showing hours nobody had asked for until both fields were corrected.
They now start from the day's real opening and closing, so any
adjustment is relative to what that day actually is.

A day with a lunch break is bounded by its first opening and its last
closing, which is what these two actions act on.

Changing the date changes the hours the times are relative to, so a
value typed for Tuesday must not survive into Thursday. The fields live
in a component keyed on the date and React resets them by remounting —
the React compiler rightly refuses an effect that writes state back, and
it is the clearer arrangement anyway.

The end-to-end test picks a trading day rather than assuming today is
one. The first version guarded the assertions behind a visibility check,
which would have passed in silence on exactly the days the controls
matter least.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-22 23:24:28 +02:00

100 lines
3.3 KiB
TypeScript

/**
* Deriving a one-off day from the usual one.
*
* "Opening late today" and "closing early today" are the two changes the shop
* actually makes, and both are relative to whatever that day normally does —
* including days with a lunch break, where clipping the wrong slot produces
* hours nobody meant. Pure, so every shape is tested.
*/
import { addCivilDays, compareCivil, minutesOfTime, type CivilDate } from './civil';
import type { Slot } from './types';
/** A range longer than this is a holiday period, not an exception. */
export const MAX_RANGE_DAYS = 92;
/**
* The day's hours if it opens at `from` instead of its usual time.
*
* Slots that finish before the new opening disappear — a shop opening at 14:00
* does not keep its 10:00-13:00 morning — and the slot straddling it is
* trimmed rather than dropped.
*/
export function lateOpening(slots: readonly Slot[], from: string): Slot[] {
const opensAt = minutesOfTime(from);
return slots
.filter((slot) => minutesOfTime(slot.close) > opensAt)
.map((slot) => (minutesOfTime(slot.open) < opensAt ? { ...slot, open: from } : { ...slot }));
}
/**
* The day's hours if it closes at `until` instead of its usual time.
*
* The mirror of `lateOpening`: slots starting after the new closing time go,
* and the one straddling it is trimmed.
*/
export function earlyClosing(slots: readonly Slot[], until: string): Slot[] {
const closesAt = minutesOfTime(until);
return slots
.filter((slot) => minutesOfTime(slot.open) < closesAt)
.map((slot) => (minutesOfTime(slot.close) > closesAt ? { ...slot, close: until } : { ...slot }));
}
/**
* The day's own opening and closing times, for the quick-change controls.
*
* Prefilling them with the day's real hours anchors the adjustment in what
* that day actually is: pushing the opening back starts from when it opens,
* not from an arbitrary 14:00 that happens to be wrong on most days. The
* preview beside the control then shows the consequence immediately.
*
* A day with a lunch break is bounded by its first opening and its last
* closing, which is what "opens later" and "closes earlier" act on.
*/
export function usualBounds(slots: readonly Slot[]): { open: string; close: string } {
const first = slots[0];
const last = slots[slots.length - 1];
if (!first || !last) {
// The controls are hidden on a closed day; these keep the inputs valid.
return { open: '10:00', close: '18:00' };
}
return { open: first.open, close: last.close };
}
/**
* Every date in `[start, end]`, inclusive.
*
* Bounded: a typo in a year field should produce an error, not thirty thousand
* rows.
*/
export function expandRange(start: CivilDate, end: CivilDate): CivilDate[] {
if (compareCivil(start, end) > 0) {
return [];
}
const dates: CivilDate[] = [];
let cursor = start;
while (compareCivil(cursor, end) <= 0) {
dates.push(cursor);
if (dates.length > MAX_RANGE_DAYS) {
return [];
}
cursor = addCivilDays(cursor, 1);
}
return dates;
}
export type RangeProblem = 'EMPTY' | 'REVERSED' | 'TOO_LONG' | null;
export function checkRange(start: CivilDate, end: CivilDate): RangeProblem {
if (!start || !end) {
return 'EMPTY';
}
if (compareCivil(start, end) > 0) {
return 'REVERSED';
}
if (expandRange(start, end).length === 0) {
return 'TOO_LONG';
}
return null;
}