From c8235763b065a7af581e481aa89fd70e667cfde5 Mon Sep 17 00:00:00 2001 From: vl Date: Sun, 20 Sep 2026 19:04:42 +0200 Subject: [PATCH] refactor: make the reference week a dense one-row-per-day table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One card per day pushed the week to roughly 1100px, so checking a change meant scrolling back up past the day you had just edited. All seven days now fit on one phone screen. Each row carries the day, an open switch, its slots inline and its two actions. The per-row "duplicate onto the other open days" button was repeated five times in prose; it is now a short labelled control on the row itself, with the full sentence kept as its accessible name. Unsaved days are marked with a dot rather than a badge, and the footer still names which days are about to change — the point of the preview is that it is readable without leaving the week. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd --- app/admin/horaires/WeekEditor.tsx | 282 ++++++++++++++++-------------- app/admin/horaires/page.tsx | 8 +- lib/schedule/week.ts | 11 ++ 3 files changed, 164 insertions(+), 137 deletions(-) diff --git a/app/admin/horaires/WeekEditor.tsx b/app/admin/horaires/WeekEditor.tsx index 7c0d3f0..5be8671 100644 --- a/app/admin/horaires/WeekEditor.tsx +++ b/app/admin/horaires/WeekEditor.tsx @@ -6,6 +6,7 @@ import { MAX_SLOTS_PER_DAY } from '@/lib/schedule/validate'; import { changedDays, DAY_NAMES_FR, + DAY_SHORT_FR, duplicateToOpenDays, validateWeek, type WeekDraft, @@ -19,11 +20,11 @@ type Props = { }; /** - * The reference week. + * The reference week, as one row per day. * - * Built for a phone held in one hand behind the counter: one card per day, - * native time inputs so the platform keyboard does the work, and the - * consequences of a change shown before it is saved rather than after. + * Density is the point: all seven days have to be readable at a glance on a + * phone held behind the counter, so the whole week fits on one screen and a + * change can be checked without scrolling back up. */ export default function WeekEditor({ initial, canEdit }: Props) { const [week, setWeek] = useState(initial); @@ -36,15 +37,20 @@ export default function WeekEditor({ initial, canEdit }: Props) { const pendingChanges = useMemo(() => changedDays(saved, week), [saved, week]); const blocked = Object.keys(errors).length > 0; - function update(dayOfWeek: number, patch: Partial) { + function touch() { setMessage(null); setServerErrors({}); + } + + function update(dayOfWeek: number, patch: Partial) { + touch(); setWeek((current) => current.map((day) => (day.dayOfWeek === dayOfWeek ? { ...day, ...patch } : day)), ); } function updateSlot(dayOfWeek: number, index: number, field: 'open' | 'close', value: string) { + touch(); setWeek((current) => current.map((day) => day.dayOfWeek === dayOfWeek @@ -57,36 +63,21 @@ export default function WeekEditor({ initial, canEdit }: Props) { : day, ), ); - setMessage(null); - } - - function addSlot(dayOfWeek: number) { - update(dayOfWeek, { - slots: [ - ...(week.find((day) => day.dayOfWeek === dayOfWeek)?.slots ?? []), - { open: '14:00', close: '18:30' }, - ], - }); - } - - function removeSlot(dayOfWeek: number, index: number) { - const day = week.find((entry) => entry.dayOfWeek === dayOfWeek); - update(dayOfWeek, { slots: (day?.slots ?? []).filter((_, position) => position !== index) }); } function submit() { - setMessage(null); - setServerErrors({}); + touch(); startTransition(async () => { const result = await saveWeek(week); if (result.ok) { setSaved(week); + const count = result.changed.length; setMessage({ kind: 'ok', text: - result.changed.length === 0 + count === 0 ? 'Aucun changement à enregistrer.' - : `Horaires enregistrés (${result.changed.length} jour${result.changed.length > 1 ? 's' : ''} modifié${result.changed.length > 1 ? 's' : ''}).`, + : `Enregistré — ${count} jour${count > 1 ? 's' : ''} modifié${count > 1 ? 's' : ''}.`, }); } else { setServerErrors(result.dayErrors ?? {}); @@ -96,138 +87,166 @@ export default function WeekEditor({ initial, canEdit }: Props) { } return ( -
+
{!canEdit ? ( -

- Ce compte est en lecture seule : les horaires sont consultables mais pas modifiables. +

+ Compte en lecture seule : consultation uniquement.

) : null} -
- {week.map((day) => { - const dayErrors = [...(errors[day.dayOfWeek] ?? []), ...(serverErrors[day.dayOfWeek] ?? [])]; +
+ {week.map((day, position) => { + const dayErrors = [ + ...(errors[day.dayOfWeek] ?? []), + ...(serverErrors[day.dayOfWeek] ?? []), + ]; const isChanged = pendingChanges.includes(day.dayOfWeek); + const fullName = DAY_NAMES_FR[day.dayOfWeek] ?? ''; return ( -
0 ? 'border-[var(--danger)]' : 'border-[var(--line)]' + className={`${position > 0 ? 'border-t border-[var(--line)]' : ''} ${ + dayErrors.length > 0 ? 'bg-[var(--danger-tint)]' : '' }`} > -
-

{DAY_NAMES_FR[day.dayOfWeek]}

+
+ + {DAY_SHORT_FR[day.dayOfWeek]} + {fullName} + - + + update(day.dayOfWeek, { + isClosed: !event.target.checked, + slots: + event.target.checked && day.slots.length === 0 + ? [{ open: '10:00', close: '18:30' }] + : day.slots, + }) + } + /> + + {day.isClosed ? ( + Fermé + ) : ( +
+ {day.slots.map((slot, index) => ( + + + updateSlot(day.dayOfWeek, index, 'open', event.target.value) + } + className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-1.5 py-1 text-sm tabular-nums" + /> + + – + + + updateSlot(day.dayOfWeek, index, 'close', event.target.value) + } + className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-1.5 py-1 text-sm tabular-nums" + /> + {canEdit && day.slots.length > 1 ? ( + + ) : null} + + ))} +
+ )} + + {canEdit && !day.isClosed ? ( + + {day.slots.length < MAX_SLOTS_PER_DAY ? ( + + ) : null} + + + ) : null} {isChanged ? ( - - modifié - + ) : null}
- {day.isClosed ? ( -

Fermé toute la journée.

- ) : ( -
- {day.slots.map((slot, index) => ( -
- - updateSlot(day.dayOfWeek, index, 'open', event.target.value) - } - className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-1.5 text-base tabular-nums" - /> - - – - - - updateSlot(day.dayOfWeek, index, 'close', event.target.value) - } - className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-1.5 text-base tabular-nums" - /> - {canEdit && day.slots.length > 1 ? ( - - ) : null} -
- ))} - - {canEdit && day.slots.length < MAX_SLOTS_PER_DAY ? ( - - ) : null} - - {canEdit ? ( -
- -
- ) : null} -
- )} - {dayErrors.length > 0 ? ( -
    +
      {dayErrors.map((error) => (
    • {error}
    • ))}
    ) : null} -
+
); })}
{canEdit ? ( -
+
@@ -237,23 +256,20 @@ export default function WeekEditor({ initial, canEdit }: Props) { type="button" onClick={() => { setWeek(saved); - setMessage(null); - setServerErrors({}); + touch(); }} className="text-sm text-[var(--ink-muted)] underline-offset-2 hover:underline" > - Annuler les modifications + Annuler ) : null}

{blocked - ? 'Corrigez les erreurs ci-dessus.' + ? 'Corrigez les erreurs signalées.' : pendingChanges.length === 0 ? 'Aucune modification.' - : `${pendingChanges.length} jour${pendingChanges.length > 1 ? 's' : ''} sera modifié : ${pendingChanges - .map((dayOfWeek) => DAY_NAMES_FR[dayOfWeek]) - .join(', ')}.`} + : `Sera modifié : ${pendingChanges.map((d) => DAY_SHORT_FR[d]).join(', ')}.`}

) : null} @@ -261,7 +277,7 @@ export default function WeekEditor({ initial, canEdit }: Props) { {message ? (

+

Horaires fixes

-

- La semaine de référence. Les fermetures ponctuelles, les vacances et les jours fériés se - règlent ailleurs et priment sur ces horaires. +

+ Semaine de référence. Fermetures ponctuelles, vacances et jours fériés priment sur ces + horaires.

diff --git a/lib/schedule/week.ts b/lib/schedule/week.ts index 857b613..8853355 100644 --- a/lib/schedule/week.ts +++ b/lib/schedule/week.ts @@ -12,6 +12,17 @@ import { validateSlots } from './validate'; /** Monday first: how the week is read, and how the screen draws it. */ export const WEEK_ORDER = [1, 2, 3, 4, 5, 6, 0] as const; +/** Three letters, for the dense week table. */ +export const DAY_SHORT_FR: Record = { + 0: 'Dim', + 1: 'Lun', + 2: 'Mar', + 3: 'Mer', + 4: 'Jeu', + 5: 'Ven', + 6: 'Sam', +}; + export const DAY_NAMES_FR: Record = { 0: 'Dimanche', 1: 'Lundi',