diff --git a/app/admin/exceptions/ExceptionsManager.tsx b/app/admin/exceptions/ExceptionsManager.tsx index 8a47e19..71f5548 100644 --- a/app/admin/exceptions/ExceptionsManager.tsx +++ b/app/admin/exceptions/ExceptionsManager.tsx @@ -2,7 +2,7 @@ import { useState, useTransition } from 'react'; -import { earlyClosing, lateOpening } from '@/lib/schedule/exceptions'; +import { earlyClosing, lateOpening, usualBounds } from '@/lib/schedule/exceptions'; import type { ExceptionKind, Slot } from '@/lib/schedule/types'; import { deleteException, saveException, saveExceptionRange } from './actions'; @@ -144,16 +144,10 @@ function QuickPanel({ run: (work: () => Promise<{ ok: true } | { ok: false; error: string }>, success: string) => void; }) { const [date, setDate] = useState(today); - const [openFrom, setOpenFrom] = useState('14:00'); - const [closeAt, setCloseAt] = useState('16:00'); const day = days.find((candidate) => candidate.date === date) ?? days[0]; const isToday = day?.date === today; - const usualSlots = day?.usualSlots ?? []; - const isOpenThatDay = usualSlots.length > 0; - - const lateResult = lateOpening(usualSlots, openFrom); - const earlyResult = earlyClosing(usualSlots, closeAt); + const isOpenThatDay = (day?.usualSlots.length ?? 0) > 0; function apply(slots: Slot[], note: string, success: string) { if (!day) { @@ -214,54 +208,11 @@ function QuickPanel({ {isToday ? 'Fermé aujourd’hui' : `Fermé ${when}`} - {isOpenThatDay ? ( - <> -
- - setOpenFrom(event.target.value)} - className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base tabular-nums" - /> - - → {lateResult.length === 0 ? 'fermé' : summarise(lateResult)} - -
- -
- - setCloseAt(event.target.value)} - className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base tabular-nums" - /> - - → {earlyResult.length === 0 ? 'fermé' : summarise(earlyResult)} - -
- + {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 ( + <> +

+ + setOpenFrom(event.target.value)} + className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base tabular-nums" + /> + + → {lateResult.length === 0 ? 'fermé' : summarise(lateResult)} + +
+ +
+ + setCloseAt(event.target.value)} + className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base tabular-nums" + /> + + → {earlyResult.length === 0 ? 'fermé' : summarise(earlyResult)} + +
+ + ); +} 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. *