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
This commit is contained in:
2026-09-22 23:24:28 +02:00
co-authored by Claude Opus 5
parent 7312a121df
commit 743a3778cc
4 changed files with 168 additions and 57 deletions
+81 -56
View File
@@ -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}`}
</button>
{isOpenThatDay ? (
<>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={pending || lateResult.length === 0}
onClick={() =>
apply(lateResult, `Ouverture à ${openFrom}`, `Ouverture repoussée à ${openFrom}.`)
}
className="rounded-[var(--radius-md)] border border-[var(--line-strong)] px-4 py-2.5 transition-colors hover:bg-[var(--surface-muted)] disabled:opacity-40"
>
Ouvre plus tard, à
</button>
<input
type="time"
aria-label="Nouvelle heure d’ouverture"
value={openFrom}
onChange={(event) => 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"
/>
<span className="text-sm text-[var(--ink-muted)]">
→ {lateResult.length === 0 ? 'fermé' : summarise(lateResult)}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={pending || earlyResult.length === 0}
onClick={() =>
apply(earlyResult, `Fermeture à ${closeAt}`, `Fermeture avancée à ${closeAt}.`)
}
className="rounded-[var(--radius-md)] border border-[var(--line-strong)] px-4 py-2.5 transition-colors hover:bg-[var(--surface-muted)] disabled:opacity-40"
>
Ferme plus tôt, à
</button>
<input
type="time"
aria-label="Nouvelle heure de fermeture"
value={closeAt}
onChange={(event) => 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"
/>
<span className="text-sm text-[var(--ink-muted)]">
→ {earlyResult.length === 0 ? 'fermé' : summarise(earlyResult)}
</span>
</div>
</>
{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.
<AdjustHours key={day.date} day={day} pending={pending} apply={apply} />
) : (
<p className="text-sm text-[var(--ink-muted)]">
La boutique est déjà fermée {when}&nbsp;: 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 (
<>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={pending || lateResult.length === 0}
onClick={() =>
apply(lateResult, `Ouverture à ${openFrom}`, `Ouverture repoussée à ${openFrom}.`)
}
className="rounded-[var(--radius-md)] border border-[var(--line-strong)] px-4 py-2.5 transition-colors hover:bg-[var(--surface-muted)] disabled:opacity-40"
>
Ouvre plus tard, à
</button>
<input
type="time"
aria-label="Nouvelle heure d’ouverture"
value={openFrom}
onChange={(event) => 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"
/>
<span className="text-sm text-[var(--ink-muted)]">
→ {lateResult.length === 0 ? 'fermé' : summarise(lateResult)}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={pending || earlyResult.length === 0}
onClick={() =>
apply(earlyResult, `Fermeture à ${closeAt}`, `Fermeture avancée à ${closeAt}.`)
}
className="rounded-[var(--radius-md)] border border-[var(--line-strong)] px-4 py-2.5 transition-colors hover:bg-[var(--surface-muted)] disabled:opacity-40"
>
Ferme plus tôt, à
</button>
<input
type="time"
aria-label="Nouvelle heure de fermeture"
value={closeAt}
onChange={(event) => 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"
/>
<span className="text-sm text-[var(--ink-muted)]">
→ {earlyResult.length === 0 ? 'fermé' : summarise(earlyResult)}
</span>
</div>
</>
);
}
+29
View File
@@ -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');
+37 -1
View File
@@ -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' });
});
});
+21
View File
@@ -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.
*