feat: let the quick schedule changes target any day, not just today

Opening later and closing earlier were fixed to today, which was an
assumption rather than a requirement: closing next Thursday afternoon is
the same decision as closing this one, and the shop knows about it in
advance more often than not.

The date is a single control and still defaults to today, so the
two-click path for the most frequent case is unchanged.

The usual hours differ from one weekday to the next, and "opens later"
is late relative to them, so the whole sixty-day window is resolved on
the server and handed over at once. Sixty small entries cost less than a
round trip per change of date.

The date control's accessible name is explicit: "du" alone collided with
the "Du" field of the range form below it, which the end-to-end run
caught as an ambiguous locator — and an ambiguous locator is an
ambiguous label for anyone using a screen reader.

The end-to-end suite ran before this was pushed, unlike last time.

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:17:26 +02:00
co-authored by Claude Opus 5
parent 3146e29ffb
commit 7312a121df
3 changed files with 118 additions and 64 deletions
+66 -52
View File
@@ -19,14 +19,20 @@ export type UpcomingDay = {
canDelete: boolean;
};
type Props = {
canEdit: boolean;
today: string;
todayLabel: string;
/** A day the quick actions can target, with the hours it would normally have. */
export type SelectableDay = {
date: string;
label: string;
usualSlots: Slot[];
usualText: string;
effectiveText: string;
todayIsException: boolean;
isException: boolean;
};
type Props = {
canEdit: boolean;
today: string;
days: SelectableDay[];
upcoming: UpcomingDay[];
};
@@ -36,16 +42,7 @@ const KIND_LABELS: Record<string, { text: string; className: string }> = {
VACATION: { text: 'vacances', className: 'bg-[var(--surface-muted)] text-[var(--ink-muted)]' },
};
export default function ExceptionsManager({
canEdit,
today,
todayLabel,
usualSlots,
usualText,
effectiveText,
todayIsException,
upcoming,
}: Props) {
export default function ExceptionsManager({ canEdit, today, days, upcoming }: Props) {
const [pending, startTransition] = useTransition();
const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null);
@@ -72,18 +69,7 @@ export default function ExceptionsManager({
</p>
) : null}
{canEdit ? (
<TodayPanel
today={today}
todayLabel={todayLabel}
usualSlots={usualSlots}
usualText={usualText}
effectiveText={effectiveText}
todayIsException={todayIsException}
pending={pending}
run={run}
/>
) : null}
{canEdit ? <QuickPanel today={today} days={days} pending={pending} run={run} /> : null}
{canEdit ? <RangePanel pending={pending} run={run} /> : null}
@@ -138,41 +124,45 @@ export default function ExceptionsManager({
}
/**
* The two-click path. This is the gesture the shop actually makes, from a
* phone, standing behind the counter — so it sits at the top and asks for as
* little as possible.
* The quick path.
*
* This is the gesture the shop actually makes, from a phone, standing behind
* the counter — so it sits at the top and asks for as little as possible.
* The date defaults to today and changing it costs one control: closing this
* afternoon and closing next Thursday are the same decision, and neither
* should cost more clicks than the other.
*/
function TodayPanel({
function QuickPanel({
today,
todayLabel,
usualSlots,
usualText,
effectiveText,
todayIsException,
days,
pending,
run,
}: {
today: string;
todayLabel: string;
usualSlots: Slot[];
usualText: string;
effectiveText: string;
todayIsException: boolean;
days: SelectableDay[];
pending: boolean;
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 isOpenToday = usualSlots.length > 0;
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);
function apply(slots: Slot[], note: string, success: string) {
if (!day) {
return;
}
run(
() =>
saveException({
date: today,
date: day.date,
isClosed: slots.length === 0,
slots,
reason: 'TEMPORARY',
@@ -182,25 +172,49 @@ function TodayPanel({
);
}
const when = isToday ? 'aujourd’hui' : `le ${day?.label.toLowerCase() ?? ''}`;
return (
<section className="rounded-[var(--radius-md)] border-2 border-[var(--accent)] bg-[var(--surface)] p-5">
<h2 className="text-lg">Modifier les horaires d’aujourd’hui</h2>
<p className="mt-1 text-sm text-[var(--ink-muted)]">
{todayLabel} — habituellement {usualText}.
{todayIsException ? ` Actuellement : ${effectiveText}.` : ''}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<h2 className="text-lg">Modifier les horaires</h2>
<label className="flex items-center gap-2 text-sm">
<span className="text-[var(--ink-muted)]">du</span>
<select
// "du" alone collides with the range form's "Du" field below;
// the accessible name has to say which control this is.
aria-label="Jour à modifier"
value={date}
onChange={(event) => setDate(event.target.value)}
className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-1.5 text-base"
>
{days.map((candidate) => (
<option key={candidate.date} value={candidate.date}>
{candidate.date === today ? `${candidate.label} (aujourd’hui)` : candidate.label}
</option>
))}
</select>
</label>
</div>
<p className="mt-2 text-sm text-[var(--ink-muted)]">
Habituellement {day?.usualText ?? 'Fermé'}.
{day?.isException ? ` Actuellement : ${day.effectiveText}.` : ''}
</p>
<div className="mt-4 space-y-3">
<button
type="button"
disabled={pending}
onClick={() => apply([], 'Fermeture exceptionnelle', 'La boutique est fermée aujourd’hui.')}
onClick={() =>
apply([], 'Fermeture exceptionnelle', `La boutique sera fermée ${when}.`)
}
className="w-full rounded-[var(--radius-md)] bg-[var(--accent)] px-4 py-3 font-medium text-[var(--on-accent)] transition-colors hover:bg-[var(--accent-hover)] disabled:opacity-40 sm:w-auto"
>
Fermé aujourd’hui
{isToday ? 'Fermé aujourd’hui' : `Fermé ${when}`}
</button>
{isOpenToday ? (
{isOpenThatDay ? (
<>
<div className="flex flex-wrap items-center gap-2">
<button
@@ -250,8 +264,8 @@ function TodayPanel({
</>
) : (
<p className="text-sm text-[var(--ink-muted)]">
La boutique est déjà fermée aujourd’hui&nbsp;: pour une ouverture exceptionnelle,
utilisez le formulaire ci-dessous.
La boutique est déjà fermée {when}&nbsp;: pour une ouverture exceptionnelle, utilisez le
formulaire ci-dessous.
</p>
)}
</div>