refactor: make the reference week a dense one-row-per-day table

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
2026-09-20 19:04:42 +02:00
co-authored by Claude Opus 5
parent eb23eb650b
commit c8235763b0
3 changed files with 164 additions and 137 deletions
+149 -133
View File
@@ -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<WeekDraft>(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<WeekDraft[number]>) {
function touch() {
setMessage(null);
setServerErrors({});
}
function update(dayOfWeek: number, patch: Partial<WeekDraft[number]>) {
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 (
<div className="mt-8">
<div className="mt-6">
{!canEdit ? (
<p className="mb-6 rounded-[var(--radius-md)] border border-[var(--line-strong)] bg-[var(--surface-muted)] px-4 py-3 text-sm text-[var(--ink-muted)]">
Ce compte est en lecture seule&nbsp;: les horaires sont consultables mais pas modifiables.
<p className="mb-4 rounded-[var(--radius-md)] border border-[var(--line-strong)] bg-[var(--surface-muted)] px-3 py-2 text-sm text-[var(--ink-muted)]">
Compte en lecture seule&nbsp;: consultation uniquement.
</p>
) : null}
<div className="space-y-3">
{week.map((day) => {
const dayErrors = [...(errors[day.dayOfWeek] ?? []), ...(serverErrors[day.dayOfWeek] ?? [])];
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)]">
{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 (
<section
<div
key={day.dayOfWeek}
className={`rounded-[var(--radius-md)] border bg-[var(--surface)] p-4 ${
dayErrors.length > 0 ? 'border-[var(--danger)]' : 'border-[var(--line)]'
className={`${position > 0 ? 'border-t border-[var(--line)]' : ''} ${
dayErrors.length > 0 ? 'bg-[var(--danger-tint)]' : ''
}`}
>
<div className="flex flex-wrap items-center gap-3">
<h2 className="min-w-28 text-base font-medium">{DAY_NAMES_FR[day.dayOfWeek]}</h2>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2">
<span
className={`w-9 shrink-0 text-sm font-medium tabular-nums ${
day.isClosed ? 'text-[var(--ink-muted)]' : ''
}`}
>
<span aria-hidden>{DAY_SHORT_FR[day.dayOfWeek]}</span>
<span className="sr-only">{fullName}</span>
</span>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4 accent-[var(--accent)]"
checked={!day.isClosed}
disabled={!canEdit}
onChange={(event) =>
update(day.dayOfWeek, {
isClosed: !event.target.checked,
slots:
event.target.checked && day.slots.length === 0
? [{ open: '10:00', close: '18:30' }]
: day.slots,
})
}
/>
Ouvert
</label>
<input
type="checkbox"
role="switch"
aria-label={`Ouvert le ${fullName.toLowerCase()}`}
className="size-4 shrink-0 accent-[var(--accent)]"
checked={!day.isClosed}
disabled={!canEdit}
onChange={(event) =>
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 ? (
<span className="text-sm text-[var(--ink-muted)]">Fermé</span>
) : (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{day.slots.map((slot, index) => (
<span key={index} className="flex items-center gap-1">
<input
type="time"
aria-label={`${fullName} — ouverture ${index + 1}`}
value={slot.open}
disabled={!canEdit}
onChange={(event) =>
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"
/>
<span aria-hidden className="text-[var(--ink-muted)]">
–
</span>
<input
type="time"
aria-label={`${fullName} — fermeture ${index + 1}`}
value={slot.close}
disabled={!canEdit}
onChange={(event) =>
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 ? (
<button
type="button"
title={`Retirer cette plage du ${fullName.toLowerCase()}`}
aria-label={`Retirer la plage ${index + 1} du ${fullName.toLowerCase()}`}
onClick={() =>
update(day.dayOfWeek, {
slots: day.slots.filter((_, at) => at !== index),
})
}
className="rounded-[var(--radius-sm)] px-1 text-[var(--ink-muted)] hover:text-[var(--danger)]"
>
×
</button>
) : null}
</span>
))}
</div>
)}
{canEdit && !day.isClosed ? (
<span className="ml-auto flex shrink-0 items-center gap-1">
{day.slots.length < MAX_SLOTS_PER_DAY ? (
<button
type="button"
title="Ajouter une plage (pause de midi)"
aria-label={`Ajouter une plage le ${fullName.toLowerCase()}`}
onClick={() =>
update(day.dayOfWeek, {
slots: [...day.slots, { open: '14:00', close: '18:30' }],
})
}
className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] px-2 py-0.5 text-sm text-[var(--ink-muted)] hover:bg-[var(--surface-muted)] hover:text-[var(--ink)]"
>
+&nbsp;plage
</button>
) : null}
<button
type="button"
title="Appliquer ces horaires aux autres jours ouverts"
aria-label={`Appliquer les horaires du ${fullName.toLowerCase()} aux autres jours ouverts`}
onClick={() => {
touch();
setWeek(duplicateToOpenDays(week, day.dayOfWeek));
}}
className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] px-2 py-0.5 text-sm text-[var(--ink-muted)] hover:bg-[var(--surface-muted)] hover:text-[var(--ink)]"
>
copier
</button>
</span>
) : null}
{isChanged ? (
<span className="rounded-[var(--radius-sm)] bg-[var(--accent-tint)] px-2 py-0.5 text-xs text-[var(--accent-ink)]">
modifié
</span>
<span
aria-label="modifié"
title="Modifié, pas encore enregistré"
className="size-1.5 shrink-0 rounded-full bg-[var(--accent)]"
/>
) : null}
</div>
{day.isClosed ? (
<p className="mt-3 text-sm text-[var(--ink-muted)]">Fermé toute la journée.</p>
) : (
<div className="mt-3 space-y-2">
{day.slots.map((slot, index) => (
<div key={index} className="flex flex-wrap items-center gap-2">
<input
type="time"
aria-label={`${DAY_NAMES_FR[day.dayOfWeek]} — ouverture ${index + 1}`}
value={slot.open}
disabled={!canEdit}
onChange={(event) =>
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"
/>
<span aria-hidden className="text-[var(--ink-muted)]">
–
</span>
<input
type="time"
aria-label={`${DAY_NAMES_FR[day.dayOfWeek]} — fermeture ${index + 1}`}
value={slot.close}
disabled={!canEdit}
onChange={(event) =>
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 ? (
<button
type="button"
onClick={() => removeSlot(day.dayOfWeek, index)}
className="rounded-[var(--radius-sm)] px-2 py-1.5 text-sm text-[var(--ink-muted)] underline-offset-2 hover:underline"
>
Retirer
</button>
) : null}
</div>
))}
{canEdit && day.slots.length < MAX_SLOTS_PER_DAY ? (
<button
type="button"
onClick={() => addSlot(day.dayOfWeek)}
className="text-sm text-[var(--accent-ink)] underline-offset-2 hover:underline"
>
+ Ajouter une plage (pause de midi)
</button>
) : null}
{canEdit ? (
<div>
<button
type="button"
onClick={() => setWeek(duplicateToOpenDays(week, day.dayOfWeek))}
className="text-sm text-[var(--ink-muted)] underline-offset-2 hover:underline"
>
Dupliquer sur les autres jours ouverts
</button>
</div>
) : null}
</div>
)}
{dayErrors.length > 0 ? (
<ul role="alert" className="mt-3 space-y-1 text-sm text-[var(--danger)]">
<ul role="alert" className="px-3 pb-2 text-sm text-[var(--danger)]">
{dayErrors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
) : null}
</section>
</div>
);
})}
</div>
{canEdit ? (
<div className="sticky bottom-0 mt-6 flex flex-wrap items-center gap-3 border-t border-[var(--line)] bg-[var(--canvas)] py-4">
<div className="sticky bottom-0 mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-[var(--line)] bg-[var(--canvas)] py-3">
<button
type="button"
onClick={submit}
disabled={pending || blocked || pendingChanges.length === 0}
className="rounded-[var(--radius-md)] bg-[var(--accent)] px-5 py-2.5 font-medium text-[var(--on-accent)] transition-colors hover:bg-[var(--accent-hover)] disabled:cursor-not-allowed disabled:opacity-40"
className="rounded-[var(--radius-md)] bg-[var(--accent)] px-4 py-2 font-medium text-[var(--on-accent)] transition-colors hover:bg-[var(--accent-hover)] disabled:cursor-not-allowed disabled:opacity-40"
>
{pending ? 'Enregistrement…' : 'Enregistrer'}
</button>
@@ -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
</button>
) : null}
<p className="text-sm text-[var(--ink-muted)]" aria-live="polite">
{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(', ')}.`}
</p>
</div>
) : null}
@@ -261,7 +277,7 @@ export default function WeekEditor({ initial, canEdit }: Props) {
{message ? (
<p
role="status"
className={`mt-4 rounded-[var(--radius-md)] px-4 py-3 text-sm ${
className={`mt-3 rounded-[var(--radius-md)] px-3 py-2 text-sm ${
message.kind === 'ok'
? 'bg-[var(--success-tint)] text-[var(--success)]'
: 'bg-[var(--danger-tint)] text-[var(--danger)]'