- >
+ {isOpenThatDay && day ? (
+ // Keyed on the date: changing the day changes the hours these times
+ // are relative to, and React resets the fields by remounting rather
+ // than by an effect writing state back.
+
) : (
La boutique est déjà fermée {when} : pour une ouverture exceptionnelle, utilisez le
@@ -350,3 +301,77 @@ function RangePanel({
function summarise(slots: Slot[]): string {
return slots.map((slot) => `${slot.open} – ${slot.close}`).join(' · ');
}
+
+/**
+ * "Opens later at" and "closes earlier at", for one day.
+ *
+ * The fields start from the day's own hours. An arbitrary 14:00 is wrong on
+ * most days and leaves the preview showing hours nobody asked for until both
+ * have been corrected.
+ */
+function AdjustHours({
+ day,
+ pending,
+ apply,
+}: {
+ day: SelectableDay;
+ pending: boolean;
+ apply: (slots: Slot[], note: string, success: string) => void;
+}) {
+ const bounds = usualBounds(day.usualSlots);
+ const [openFrom, setOpenFrom] = useState(bounds.open);
+ const [closeAt, setCloseAt] = useState(bounds.close);
+
+ const lateResult = lateOpening(day.usualSlots, openFrom);
+ const earlyResult = earlyClosing(day.usualSlots, closeAt);
+
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/e2e/schedule.spec.ts b/e2e/schedule.spec.ts
index c66b1eb..4c87eb5 100644
--- a/e2e/schedule.spec.ts
+++ b/e2e/schedule.spec.ts
@@ -6,6 +6,18 @@ async function panelFilename(request: import('@playwright/test').APIRequestConte
return ((await response.json()) as { filename: string }).filename;
}
+/** The seeded week trades Tuesday to Saturday; Sunday and Monday are closed. */
+function nextTradingDay(): string {
+ for (let offset = 0; offset < 7; offset += 1) {
+ const date = today(offset);
+ const weekday = new Date(`${date}T00:00:00.000Z`).getUTCDay();
+ if (weekday >= 2) {
+ return date;
+ }
+ }
+ throw new Error('aucun jour ouvré dans les sept prochains jours');
+}
+
test.beforeEach(async () => {
await resetDatabase();
});
@@ -57,6 +69,23 @@ test('a quick change can target a day other than today', async ({ page }) => {
expect(exception?.isClosed).toBe(true);
});
+test('the quick controls start from the selected day’s own hours', async ({ page }) => {
+ // The seeded week is closed on Sunday and Monday, so the day is chosen
+ // rather than assumed: a conditional assertion would pass in silence on the
+ // days it matters least.
+ const tradingDay = nextTradingDay();
+
+ await signIn(page, 'admin');
+ await page.goto('/admin/exceptions');
+ await page.getByLabel('Jour à modifier').selectOption(tradingDay);
+
+ // An arbitrary 14:00 would be wrong on most days and leave the preview
+ // showing hours nobody asked for.
+ await expect(page.getByLabel('Nouvelle heure d’ouverture')).toHaveValue('10:00');
+ await expect(page.getByLabel('Nouvelle heure de fermeture')).toHaveValue('18:30');
+ await expect(page.getByText('Habituellement 10:00 – 18:30.')).toBeVisible();
+});
+
test('the reference week can be edited and shows what will change', async ({ page }) => {
await signIn(page, 'admin');
await page.goto('/admin/horaires');
diff --git a/lib/schedule/exceptions.test.ts b/lib/schedule/exceptions.test.ts
index f24f9c9..6f58ab8 100644
--- a/lib/schedule/exceptions.test.ts
+++ b/lib/schedule/exceptions.test.ts
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest';
-import { checkRange, earlyClosing, expandRange, lateOpening, MAX_RANGE_DAYS } from './exceptions';
+import {
+ checkRange,
+ earlyClosing,
+ expandRange,
+ lateOpening,
+ MAX_RANGE_DAYS,
+ usualBounds,
+} from './exceptions';
import type { Slot } from './types';
const FULL: Slot[] = [{ open: '10:00', close: '18:30' }];
@@ -130,3 +137,32 @@ describe('checkRange', () => {
expect(checkRange('2026-01-01', '2030-01-01')).toBe('TOO_LONG');
});
});
+
+describe('usualBounds', () => {
+ it('takes the times of a single period', () => {
+ expect(usualBounds([{ open: '11:00', close: '18:00' }])).toEqual({
+ open: '11:00',
+ close: '18:00',
+ });
+ });
+
+ it('spans a day with a lunch break from first opening to last closing', () => {
+ // "Opens later" and "closes earlier" act on the day as a whole.
+ expect(usualBounds(SPLIT)).toEqual({ open: '10:00', close: '18:30' });
+ });
+
+ it('falls back to usable times on a closed day', () => {
+ // The controls are hidden there, but the inputs must still hold a value.
+ expect(usualBounds([])).toEqual({ open: '10:00', close: '18:00' });
+ });
+
+ it('handles three periods', () => {
+ expect(
+ usualBounds([
+ { open: '09:30', close: '12:00' },
+ { open: '13:30', close: '16:00' },
+ { open: '17:00', close: '19:30' },
+ ]),
+ ).toEqual({ open: '09:30', close: '19:30' });
+ });
+});
diff --git a/lib/schedule/exceptions.ts b/lib/schedule/exceptions.ts
index 3d701c4..cbb27c0 100644
--- a/lib/schedule/exceptions.ts
+++ b/lib/schedule/exceptions.ts
@@ -40,6 +40,27 @@ export function earlyClosing(slots: readonly Slot[], until: string): Slot[] {
.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.
*