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:
+106
-144
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user