Redesign the setup flow and consolidate the CSS into one primitives layer

Onboarding asks explicitly for one or two parents and shows one card per
parent for balances; the balance form is per parent with inline
validation; the partner can be filled from the FK deduction or added
from it. Settings and Dashboard reuse the same form.

CSS: bare buttons carry no colour any more; buttons, cards, choice
cards, fields, alerts, badges, eyebrow and modal chrome are defined once
in primitives.css. Media queries live only in responsive.css. A style
guard test blocks hex, !important and stray media queries. Bilingual
fixes: calendar selects size by content, header no longer wraps. Vitest
is capped at four workers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 12:27:29 +02:00
parent e4719cb9ae
commit ae6db7e16f
105 changed files with 5683 additions and 3690 deletions
+106 -144
View File
@@ -1,4 +1,5 @@
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useWorkspace } from '@/context'
import {
usePlannedDays,
@@ -6,7 +7,6 @@ import {
getLastLeaveType,
setLastLeaveType,
useCalendarDays,
useDayOverrides,
useLeaveWishes,
useWorkspaceMembers,
usePlanSync,
@@ -30,7 +30,7 @@ import { PeriodPopover, type PeriodPopoverSavePayload } from '@/components/calen
import { PresetRail, type SeasonPreset } from '@/components/calendar/PresetRail'
import { getColorForType } from '@/utils/leaveTypes'
import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName'
import { datesInRange, formatDateForDisplay, formatDateLocalISO } from '@/utils/dates'
import { datesInRange, formatDateForDisplay, formatDateLocalISO, isValidDate, parseInputDate } from '@/utils/dates'
import { renderRejection, type Translate } from '@/utils/rejectionMessages'
import { useTranslation } from '@/i18n'
import './calendar.css'
@@ -56,6 +56,27 @@ function splitISODate(date: string): { year: number; month: number; day: number
return { year: year ?? 1970, month: month ?? 1, day: day ?? 1 }
}
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
/** Reads the `?date=` search param the dashboard's "years ahead" bands
* navigate here with (see Dashboard.tsx's `handleBandClick`) and, when it's
* a valid ISO-8601 date, returns both the month to land on (as a local Date,
* built from the parsed value's UTC components so the calendar view — which
* reads `currentMonth` via local getters throughout — never drifts a day in
* timezones behind UTC) and the ISO string itself, to pre-select the day.
* Returns `null` for a missing or invalid param, so callers fall back to
* today. */
function parseDateParam(searchParams: URLSearchParams): { month: Date; iso: string } | null {
const dateParam = searchParams.get('date')
if (!dateParam || !ISO_DATE_PATTERN.test(dateParam)) return null
const parsed = parseInputDate(dateParam)
if (!isValidDate(parsed)) return null
return {
month: new Date(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate()),
iso: dateParam,
}
}
/** Builds the full `LeaveWishCreateRequest` a wish's current fields would
* recreate — used both to "undo" a period delete (recreate the wish) and,
* minus `workspaceId`/`childId`, as the patch that "undoes" a period edit
@@ -214,15 +235,22 @@ export function Calendar(): ReactNode {
const { isLoading: wsLoading, selectedChildId, workspace, personalDataUuid, parentName } = useWorkspace()
const plannedDaysHook = usePlannedDays(selectedChildId ?? undefined)
const [showWeekNumbers] = useShowWeekNumbers()
const [currentMonth, setCurrentMonth] = useState(new Date())
const [selectedDates, setSelectedDates] = useState<Set<string>>(new Set())
const [searchParams] = useSearchParams()
// Seeded once from `?date=` on arrival (see `parseDateParam`) — later
// in-page navigation (prev/next month, the month/year selects) only ever
// calls `setCurrentMonth`/`setSelectedDates` directly, so it's never
// fought by the param once mounted.
const [currentMonth, setCurrentMonth] = useState(() => parseDateParam(searchParams)?.month ?? new Date())
const [selectedDates, setSelectedDates] = useState<Set<string>>(() => {
const fromParam = parseDateParam(searchParams)
return fromParam ? new Set([fromParam.iso]) : new Set()
})
const [dragAnchor, setDragAnchor] = useState<string | null>(null)
const [dragCurrent, setDragCurrent] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [overrideError, setOverrideError] = useState<string | null>(null)
const [activeBand, setActiveBand] = useState<BandClickInfo | null>(null)
const [isPeriodOpen, setIsPeriodOpen] = useState(false)
@@ -245,7 +273,6 @@ export function Calendar(): ReactNode {
const calendarYearsSpan = calendarMaxYear - calendarMinYear + 1
const calendarHook = useCalendarDays(workspace?.id ?? '', calendarMinYear, calendarYearsSpan)
const dayOverridesHook = useDayOverrides(workspace?.id ?? '')
const wishesHook = useLeaveWishes(workspace?.id ?? null)
const membersHook = useWorkspaceMembers(workspace?.id ?? null)
const syncHook = usePlanSync()
@@ -330,81 +357,17 @@ export function Calendar(): ReactNode {
void runSync(workspace.id, { silent: true })
}, [workspace?.id, runSync])
const handleCreatePeriodFromDrag = useCallback(
async (dates: string[]) => {
setSelectedDates(new Set())
if (!workspace?.id || !selectedChildId || !personalDataUuid) return
const sorted = [...dates].sort()
const start = sorted[0]
const end = sorted[sorted.length - 1]
if (!start || !end) return
const { year: startYear, month: startMonth, day: startDay } = splitISODate(start)
const { month: endMonth, day: endDay } = splitISODate(end)
const dayCount = sorted.length
const targetWeeks = Math.min(52, Math.max(1, Math.ceil(dayCount / 7)))
try {
setIsCreatingPeriod(true)
const created = await wishesHook.add({
workspaceId: workspace.id,
childId: selectedChildId,
name: t('calendar.newPeriodName'),
nameMessageKey: 'calendar.newPeriodName',
windowStartMonth: startMonth,
windowStartDay: startDay,
windowEndMonth: endMonth,
windowEndDay: endDay,
targetWeeks,
targetDays: dayCount,
recurrence: 'once',
firstYear: startYear,
priority: wishesHook.data.length + 1,
parentMode: 'specific',
parentPersonalDataUuid: personalDataUuid,
windowMode: 'fixed',
})
// Undo is just "delete the wish, re-sync": the backend deletes every
// `planned_leave_day` row the wish produced as part of deleting the
// wish (`LeaveWishService.delete()`), so there is nothing left here
// for the frontend to individually track or clean up.
await runSync(workspace.id, {
watchWishId: created.id,
watchWish: created,
undo: {
label: t('calendar.undoButton'),
run: async () => {
await wishesHook.remove(created.id)
await runSync(workspace.id, { silent: true })
},
},
})
} catch (err) {
showToast(err instanceof Error ? err.message : t('calendar.periodCreateError'), null)
} finally {
setIsCreatingPeriod(false)
}
},
[
workspace?.id,
selectedChildId,
personalDataUuid,
wishesHook,
t,
runSync,
showToast,
],
)
useEffect(() => {
const handlePointerUp = () => {
if (isDragging && dragAnchor && dragCurrent) {
const dates = datesInRange(dragAnchor, dragCurrent)
if (dates.length === 1) {
if (dates.length > 0) {
// §Task7 revision — a drag (single- or multi-day) always opens the
// day popover on the exact dragged dates; it never creates a
// LeaveWish/synced period on its own. The user decides the leave
// type/parent for every one of those days in the popover.
setSelectedDates(new Set(dates))
setIsModalOpen(true)
} else if (dates.length > 1) {
void handleCreatePeriodFromDrag(dates)
}
}
setIsDragging(false)
@@ -416,7 +379,7 @@ export function Calendar(): ReactNode {
window.addEventListener('pointerup', handlePointerUp)
return () => window.removeEventListener('pointerup', handlePointerUp)
}
}, [isDragging, dragAnchor, dragCurrent, handleCreatePeriodFromDrag])
}, [isDragging, dragAnchor, dragCurrent])
if (isLoading) {
return <LoadingSpinner message={t('calendar.loading')} />
@@ -457,28 +420,49 @@ export function Calendar(): ReactNode {
}
}
const remaining = 7 - currentWeek.length
for (let i = 0; i < remaining; i++) {
currentWeek.push(formatDateLocalISO(new Date(year, month + 1, i + 1)))
// Only pad and push a trailing week if the month's days actually left one
// in progress — a month that ends exactly on a Sunday has `currentWeek`
// already empty here, and pushing anyway would add a phantom row of 7
// disabled next-month cells.
if (currentWeek.length > 0) {
const remaining = 7 - currentWeek.length
for (let i = 0; i < remaining; i++) {
currentWeek.push(formatDateLocalISO(new Date(year, month + 1, i + 1)))
}
weeks.push(currentWeek)
}
weeks.push(currentWeek)
// These four all derive the *other* axis (year when the month changes, and
// vice versa) from live state rather than the `year`/`month` consts closed
// over by this render. Two selects changed in quick succession (as
// `goToCalendarMonth` in the e2e suite does: year, then month) can each
// fire their own native `change` event before React has committed the
// re-render from the first one — the month `<select>`'s onChange handler
// then still closes over the PRE-year-change `year` const, so
// `setCurrentMonth(new Date(year, newMonth, 1))` would silently overwrite
// the just-applied year with the stale one. The functional updater form
// reads the true latest (possibly not-yet-committed but already queued)
// state, which React always threads correctly regardless of how many
// renders separate the two events — see the flaky Easter-2027 red-day e2e
// failure this fixed (the year select's fetch would lose a race against a
// second, stale-closured fetch for the old year, so `calendarDaysByDate`
// never got 2027's data even though the grid itself displayed 2027).
const handlePrevMonth = () => {
setCurrentMonth(new Date(year, month - 1, 1))
setCurrentMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1))
}
const handleNextMonth = () => {
setCurrentMonth(new Date(year, month + 1, 1))
setCurrentMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1))
}
const handleMonthChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newMonth = Number(e.target.value)
setCurrentMonth(new Date(year, newMonth, 1))
setCurrentMonth((prev) => new Date(prev.getFullYear(), newMonth, 1))
}
const handleYearChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newYear = Number(e.target.value)
setCurrentMonth(new Date(newYear, month, 1))
setCurrentMonth((prev) => new Date(newYear, prev.getMonth(), 1))
}
const handlePointerDown = (date: string) => {
@@ -493,39 +477,6 @@ export function Calendar(): ReactNode {
}
}
const handleOverrideToggle = async (date: string) => {
if (!workspace?.id) return
const info = calendarDaysByDate.get(date)
if (!info) return
try {
setOverrideError(null)
if (info.isOverride) {
const existing = (dayOverridesHook.data ?? []).find((o) => o.date === date)
if (existing) {
await dayOverridesHook.remove(existing.id)
}
} else {
// Toggle the effective status: a currently-free day becomes an
// override marking it a working day, and vice versa.
await dayOverridesHook.upsert({
workspaceId: workspace.id,
date,
isWorkingDay: info.isFree,
note: null,
})
}
// The grid's shading/badges come from GET /api/calendar/days, a
// separate fetch from the override list above — without this
// explicit refetch the day-overrides list would be up to date but the
// calendar cell would keep showing the pre-mutation state until a
// full page reload.
await calendarHook.refetch()
} catch (err) {
setOverrideError(err instanceof Error ? err.message : t('calendar.overrideError'))
}
}
const handleDayClick = (date: string) => {
setSelectedDates(new Set([date]))
setIsModalOpen(true)
@@ -817,6 +768,10 @@ export function Calendar(): ReactNode {
? membersHook.data.map((m) => ({ id: m.uuid, name: m.name }))
: [{ id: personalDataUuid ?? '', name: parentName ?? t('calendar.you') }]
// Same members, keyed for CalendarWeek's band labels — a band owned by a
// partner must show their actual name, not the generic "Partner" string.
const parentNamesByUuid = new Map(parentsForModals.map((p) => [p.id, p.name]))
const activeWish = activeBand?.wishId
? wishesHook.data.find((w) => w.id === activeBand.wishId) ?? null
: null
@@ -825,7 +780,12 @@ export function Calendar(): ReactNode {
<Layout>
<div data-testid="calendar" className="calendar">
<div className="calendar__header">
<button onClick={handlePrevMonth} aria-label={t('calendar.prevMonthAria')}>
<button
type="button"
className="btn btn--icon"
onClick={handlePrevMonth}
aria-label={t('calendar.prevMonthAria')}
>
←
</button>
<h2 className="calendar__title">
@@ -858,7 +818,12 @@ export function Calendar(): ReactNode {
))}
</select>
</h2>
<button onClick={handleNextMonth} aria-label={t('calendar.nextMonthAria')}>
<button
type="button"
className="btn btn--icon"
onClick={handleNextMonth}
aria-label={t('calendar.nextMonthAria')}
>
→
</button>
</div>
@@ -911,28 +876,26 @@ export function Calendar(): ReactNode {
one element — that only works if each week is a separate grid,
not a flat row of the header grid above. */}
<div className="calendar__weeks">
{weeks.map((week, weekIdx) => {
const firstDate = week[0]
const weekMonth = firstDate ? new Date(firstDate + 'T00:00:00Z').getMonth() : month
return (
<CalendarWeek
key={weekIdx}
weekDates={week}
plannedDays={plannedDaysHook.data ?? []}
selectedDates={highlightedDates}
parentName={parentName ?? t('calendar.you')}
personalDataUuid={personalDataUuid ?? ''}
isCurrentMonth={weekMonth === month}
showWeekNumbers={showWeekNumbers}
onDayClick={handleDayClick}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
calendarDaysByDate={calendarDaysByDate}
onBandClick={handleBandClick}
flashWishId={flashWishId}
/>
)
})}
{weeks.map((week, weekIdx) => (
<CalendarWeek
key={weekIdx}
weekDates={week}
plannedDays={plannedDaysHook.data ?? []}
selectedDates={highlightedDates}
parentName={parentName ?? t('calendar.you')}
personalDataUuid={personalDataUuid ?? ''}
parentNames={parentNamesByUuid}
displayedYear={year}
displayedMonth={month}
showWeekNumbers={showWeekNumbers}
onDayClick={handleDayClick}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
calendarDaysByDate={calendarDaysByDate}
onBandClick={handleBandClick}
flashWishId={flashWishId}
/>
))}
</div>
<div className="calendar__legend">
@@ -989,9 +952,8 @@ export function Calendar(): ReactNode {
isSaving={isSaving}
error={modalError}
defaultDayType={defaultLeaveType}
dayInfo={calendarDaysByDate.get(firstSelectedDate) ?? null}
onToggleOverride={() => handleOverrideToggle(firstSelectedDate)}
overrideError={overrideError}
isRangeMode={selectedDates.size > 1}
dayCount={selectedDates.size}
/>
)}