From 7312a121df7ce57d0c3b96cf350618e0bdce7638 Mon Sep 17 00:00:00 2001
From: vl
Date: Tue, 22 Sep 2026 23:17:26 +0200
Subject: [PATCH] feat: let the quick schedule changes target any day, not just
today
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
---
app/admin/exceptions/ExceptionsManager.tsx | 118 ++++++++++++---------
app/admin/exceptions/page.tsx | 42 ++++++--
e2e/schedule.spec.ts | 22 +++-
3 files changed, 118 insertions(+), 64 deletions(-)
diff --git a/app/admin/exceptions/ExceptionsManager.tsx b/app/admin/exceptions/ExceptionsManager.tsx
index a8c8d93..8a47e19 100644
--- a/app/admin/exceptions/ExceptionsManager.tsx
+++ b/app/admin/exceptions/ExceptionsManager.tsx
@@ -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 = {
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({
) : null}
- {canEdit ? (
-
- ) : null}
+ {canEdit ? : null}
{canEdit ? : 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 (
-
- La boutique est déjà fermée aujourd’hui : pour une ouverture exceptionnelle,
- utilisez le formulaire ci-dessous.
+ La boutique est déjà fermée {when} : pour une ouverture exceptionnelle, utilisez le
+ formulaire ci-dessous.
)}
diff --git a/app/admin/exceptions/page.tsx b/app/admin/exceptions/page.tsx
index 376836d..189ffc7 100644
--- a/app/admin/exceptions/page.tsx
+++ b/app/admin/exceptions/page.tsx
@@ -6,7 +6,7 @@ import { formatDateFr, formatHoursText } from '@/lib/schedule/format';
import { resolveCivilDay } from '@/lib/schedule/resolver';
import type { ScheduleContext } from '@/lib/schedule/types';
-import ExceptionsManager, { type UpcomingDay } from './ExceptionsManager';
+import ExceptionsManager, { type SelectableDay, type UpcomingDay } from './ExceptionsManager';
export const metadata = { title: 'Changements ponctuels — ITA ITO' };
@@ -26,11 +26,7 @@ export default async function ExceptionsPage() {
const deletable = new Set(stored.map((row) => row.date.toISOString().slice(0, 10)));
const upcoming = collectUpcoming(today, context, deletable);
-
- // What today would normally be, ignoring any exception already recorded:
- // that is what "opening late" is late relative to.
- const usual = resolveCivilDay(today, { ...context, exceptions: [] });
- const effective = resolveCivilDay(today, context);
+ const days = collectSelectableDays(today, context);
return (
@@ -43,17 +39,41 @@ export default async function ExceptionsPage() {
0 ? formatHoursText(usual.slots) : 'Fermé'}
- effectiveText={effective.slots.length > 0 ? formatHoursText(effective.slots) : 'Fermé'}
- todayIsException={effective.isException}
+ days={days}
upcoming={upcoming}
/>
);
}
+/**
+ * Each day the quick actions can target, with the hours it would normally
+ * have.
+ *
+ * "Opens later" is late relative to the day's usual hours, and those differ
+ * from one weekday to the next — so the whole window is resolved here rather
+ * than asking the server again every time the date changes. Sixty small
+ * entries cost less than a round trip per keystroke.
+ */
+function collectSelectableDays(today: string, context: ScheduleContext): SelectableDay[] {
+ const withoutExceptions: ScheduleContext = { ...context, exceptions: [] };
+
+ return Array.from({ length: HORIZON_DAYS + 1 }, (_, offset) => {
+ const date = addCivilDays(today, offset);
+ const usual = resolveCivilDay(date, withoutExceptions);
+ const effective = resolveCivilDay(date, context);
+
+ return {
+ date,
+ label: formatDateFr(date),
+ usualSlots: usual.slots,
+ usualText: usual.slots.length > 0 ? formatHoursText(usual.slots) : 'Fermé',
+ effectiveText: effective.slots.length > 0 ? formatHoursText(effective.slots) : 'Fermé',
+ isException: effective.isException,
+ };
+ });
+}
+
/**
* Every day ahead that departs from the reference week, whatever the cause.
*
diff --git a/e2e/schedule.spec.ts b/e2e/schedule.spec.ts
index df46658..c66b1eb 100644
--- a/e2e/schedule.spec.ts
+++ b/e2e/schedule.spec.ts
@@ -18,7 +18,7 @@ test('changing today’s hours reaches the panel', async ({ page, request }) =>
await page.goto('/admin/exceptions');
await page.getByRole('button', { name: /Fermé aujourd’hui/ }).click();
- await expect(page.getByText(/La boutique est fermée aujourd’hui/)).toBeVisible();
+ await expect(page.getByRole('status')).toContainText(/sera fermée aujourd’hui/);
// Stored as a manual exception for today, in the shop's timezone.
// Read as text: node-postgres parses a DATE column into a local-midnight
@@ -37,6 +37,26 @@ test('changing today’s hours reaches the panel', async ({ page, request }) =>
await expect(page.getByText(/aujourd’hui/).first()).toBeVisible();
});
+test('a quick change can target a day other than today', async ({ page }) => {
+ const target = today(3);
+
+ await signIn(page, 'admin');
+ await page.goto('/admin/exceptions');
+
+ // The date defaults to today; closing next Thursday must not cost more
+ // clicks than closing this afternoon.
+ await page.getByLabel('Jour à modifier').selectOption(target);
+ await page.getByRole('button', { name: /^Fermé le / }).click();
+
+ await expect(page.getByRole('status')).toContainText(/sera fermée/);
+
+ const [exception] = await query<{ date: string; isClosed: boolean }>(
+ 'SELECT date::text AS date, "isClosed" FROM schedule_exceptions',
+ );
+ expect(exception?.date).toBe(target);
+ expect(exception?.isClosed).toBe(true);
+});
+
test('the reference week can be edited and shows what will change', async ({ page }) => {
await signIn(page, 'admin');
await page.goto('/admin/horaires');