import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { useWorkspace } from '@/context' import { usePlannedDays, useShowWeekNumbers, getLastLeaveType, setLastLeaveType, useCalendarDays, useLeaveWishes, useWorkspaceMembers, usePlanSync, usePlannedDayExclusions, } from '@/hooks' import type { PlannedLeaveDay, CalendarDayEntry, LeaveWish, LeaveWishCreateRequest, LeaveWishScope, LeaveWishUpdateRequest, PersonalData, PlanChange, PlanSyncResponse, WindowOutcome, } from '@/types' import { Layout } from '@/components/layout/Layout' import { PageSkeleton } from '@/components/common/PageSkeleton' import { CalendarWeek, computeWeekLaneCount, type BandClickInfo } from '@/components/common/CalendarWeek' import { PlannedDayModal } from '@/components/modals/PlannedDayModal' import { PeriodPopover, type PeriodPopoverSavePayload, type PeriodDeleteScope } from '@/components/calendar/PeriodPopover' import { PresetRail, type SeasonPreset } from '@/components/calendar/PresetRail' import { getColorForType, getTypeModifier } from '@/utils/leaveTypes' import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName' import { datesInRange, formatDateForDisplay, formatDateLocalISO, isValidDate, parseInputDate } from '@/utils/dates' import { renderRejection, type Translate } from '@/utils/rejectionMessages' import { useTranslation } from '@/i18n' import './calendar.css' const WEEKDAY_KEYS = [ 'calendar.weekdayMon', 'calendar.weekdayTue', 'calendar.weekdayWed', 'calendar.weekdayThu', 'calendar.weekdayFri', 'calendar.weekdaySat', 'calendar.weekdaySun', ] as const /** Small "what changed" toast + optional undo — see Calendar.tsx's usage. */ interface CalendarToast { message: string undo: { label: string; run: () => Promise } | null } function splitISODate(date: string): { year: number; month: number; day: number } { const [year, month, day] = date.split('-').map(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 * (restore every field to its pre-edit value; §Task7 "undo reverts the * wish edit, not the days"). * * A wish row is ONE year now (see `LeaveWish.seriesId`), so this recreates * exactly that year as a standalone occurrence. `seriesId`, when passed, * re-attaches it to the series it came from instead — undoing "remove this * year" has to put the year back as part of the repeating wish, not as a * detached one-off a later "every year" edit would miss. */ function createRequestFromWish(w: LeaveWish, seriesId?: string | null): LeaveWishCreateRequest { return { workspaceId: w.workspaceId, childId: w.childId, name: w.name, nameMessageKey: w.nameMessageKey, windowStartMonth: w.windowStartMonth, windowStartDay: w.windowStartDay, windowEndMonth: w.windowEndMonth, windowEndDay: w.windowEndDay, targetWeeks: w.targetWeeks, targetDays: w.targetDays ?? null, recurrence: 'once', firstYear: w.firstYear, seriesId: seriesId ?? null, priority: w.priority, parentMode: w.parentMode, parentPersonalDataUuid: w.parentPersonalDataUuid, windowMode: w.windowMode, wantsDubbeldag: w.wantsDubbeldag, } } /** The patch that restores one wish row's pre-edit fields, applied at the same * scope the edit used. * * `recurrence` is restored from whether the row belonged to a series, so * undoing "make this repeat" (or "stop repeating") really undoes it. * `lastYear` is series-level and only sent where the backend accepts it: when * the row was standalone (so the patch is what creates the series) or when the * undo is series-wide. */ function patchFromWish(w: LeaveWish, scope: LeaveWishScope): LeaveWishUpdateRequest { const seriesWide = w.seriesId === null || scope === 'series' return { ...patchFieldsFromWish(w), recurrence: w.seriesId !== null ? 'yearly' : 'once', ...(seriesWide ? { lastYear: w.lastYear } : {}), } } function patchFieldsFromWish(w: LeaveWish): LeaveWishUpdateRequest { return { name: w.name, nameMessageKey: w.nameMessageKey, windowStartMonth: w.windowStartMonth, windowStartDay: w.windowStartDay, windowEndMonth: w.windowEndMonth, windowEndDay: w.windowEndDay, targetWeeks: w.targetWeeks, targetDays: w.targetDays ?? null, firstYear: w.firstYear, priority: w.priority, parentMode: w.parentMode, parentPersonalDataUuid: w.parentPersonalDataUuid, windowMode: w.windowMode, wantsDubbeldag: w.wantsDubbeldag, } } /** Groups a sync's `changes` by the wish (or "adhoc") they belong to and * renders one "{name}: N dagar ändrades" fragment per group — house style * is structured data in, composed copy out; the backend never sends prose. */ function summarizeChanges(changes: PlanChange[], wishes: LeaveWish[], t: Translate): string | null { if (changes.length === 0) return null const counts = new Map() for (const change of changes) { const key = change.newWishId ?? change.oldWishId ?? 'adhoc' counts.set(key, (counts.get(key) ?? 0) + 1) } const parts: string[] = [] for (const [wishId, count] of counts) { const wish = wishes.find((w) => w.id === wishId) const name = wish ? resolveWishName(wish, t) : t('calendar.toastGenericPeriod') parts.push(t('calendar.toastChangedDays', { name, count })) } return parts.join(' · ') } /** Renders a `WindowOutcome`'s shortfall for the touched wish, from * `outcome.messageKey`/`data` (today always `planOutcomes.windowExplanation`, * carrying `wish_name`/`wish_name_message_key`) — falling back to the plain * `shortfallDays` count when there's nothing richer to say. `funded` * distinguishes "nothing could be placed" from "only part of it fit", the * two phrasings the "sync that changes nothing must say why" fix needs. * * `wish` is the touched wish itself (not just the outcome), because the * outcome only ever carries `weeksWished`/`weeksFunded` — the backend's * week-rounded view. A calendar-drawn period sets `targetDays` (an exact * day count); `targetWeeks` on that same wish is just `Math.ceil(days / 7)`, * a leftover the API still requires, never what the user actually asked * for. Reporting `weeksWished` for those wishes says "3 week(s)" for a * 7-day drag. When `targetDays` is set, report days instead — the figure * the user actually asked for — and only fall back to weeks for wishes that * never had a day-granular target (e.g. the preset rail, which only ever * sends `targetWeeks`). */ function summarizeOutcomeShortfall( outcome: WindowOutcome, funded: boolean, wish: LeaveWish | undefined, t: Translate, ): string { const name = resolveWishNameFromData(outcome.data, t) || t('calendar.toastGenericPeriod') const base = { name, start: formatDateForDisplay(outcome.startDate), end: formatDateForDisplay(outcome.endDate), shortfallDays: outcome.shortfallDays, } if (wish?.targetDays != null) { const params = { ...base, daysWished: wish.targetDays, daysFunded: wish.targetDays - outcome.shortfallDays, } return t(funded ? 'calendar.toastShortfallPartialDays' : 'calendar.toastShortfallNoneDays', params) } const params = { ...base, weeksWished: outcome.weeksWished, weeksFunded: outcome.weeksFunded, } return t(funded ? 'calendar.toastShortfallPartial' : 'calendar.toastShortfallNone', params) } /** Builds the full sync toast message: what changed (existing summary), why * the wish just touched (if any) fell short of what was wished, and any * rejections the apply step returned — a genuine no-op sync (nothing to * report on any of the three) still renders `null`, so it stays silent. * * `watchWish`, when given, is used in place of looking `watchWishId` up in * `wishes` — needed right after creating a wish, where `wishes` is whatever * `wishesHook.data` was as of this closure's own render (captured before * the create's background refetch lands) and so never actually contains a * wish created moments ago. Passing the just-created wish object directly * sidesteps that; every other caller (editing/re-syncing an *existing* * wish) can leave it out and rely on the `wishes` lookup as before. */ function buildSyncToastMessage( result: PlanSyncResponse, wishes: LeaveWish[], members: PersonalData[], watchWishId: string | undefined, t: Translate, watchWish?: LeaveWish, ): string | null { const parts: string[] = [] const changesMessage = summarizeChanges(result.changes, wishes, t) if (changesMessage) parts.push(changesMessage) if (watchWishId) { const outcome = result.proposal.outcomes.find((o) => o.wishId === watchWishId) if (outcome && outcome.shortfallDays > 0) { const funded = result.changes.some((c) => c.newWishId === watchWishId || c.oldWishId === watchWishId) const wish = watchWish ?? wishes.find((w) => w.id === watchWishId) parts.push(summarizeOutcomeShortfall(outcome, funded, wish, t)) } } for (const rejection of result.applyResult.rejections) { parts.push(renderRejection(rejection, t, members)) } return parts.length > 0 ? parts.join(' · ') : null } export function Calendar(): ReactNode { const { t, locale } = useTranslation() const { isLoading: wsLoading, selectedChildId, workspace, personalDataUuid, parentName } = useWorkspace() const plannedDaysHook = usePlannedDays(selectedChildId ?? undefined) const [showWeekNumbers] = useShowWeekNumbers() 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>(() => { const fromParam = parseDateParam(searchParams) return fromParam ? new Set([fromParam.iso]) : new Set() }) const [dragAnchor, setDragAnchor] = useState(null) const [dragCurrent, setDragCurrent] = useState(null) const [isDragging, setIsDragging] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false) const [isSaving, setIsSaving] = useState(false) const [modalError, setModalError] = useState(null) // §todo14 — which parent the day popover's "Advanced" (pin exactly) path // targets. Lifted here (rather than kept as PlannedDayModal-local state) // so `existingDay`/save/delete all resolve against the SAME parent the // modal is currently showing — see `getExistingDay`'s doc comment for the // bug this fixes. Reset to the logged-in parent whenever a fresh // date/range is selected; updated live via the modal's `onParentChange` // while it stays open. const [advancedParentId, setAdvancedParentId] = useState(personalDataUuid ?? '') const [activeBand, setActiveBand] = useState(null) const [isPeriodOpen, setIsPeriodOpen] = useState(false) const [isPeriodSaving, setIsPeriodSaving] = useState(false) const [periodError, setPeriodError] = useState(null) const [isCreatingPeriod, setIsCreatingPeriod] = useState(false) const [flashWishId, setFlashWishId] = useState(null) const [toast, setToast] = useState(null) const [isUndoing, setIsUndoing] = useState(false) const isLoading = wsLoading || plannedDaysHook.isLoading // §3.4/§3.6 — the visible grid can spill into the previous year (January's // leading days) or the next year (December's trailing days), so fetch // whichever 1-2 calendar years the current month view actually touches. const viewYear = currentMonth.getFullYear() const viewMonth = currentMonth.getMonth() const calendarMinYear = viewMonth === 0 ? viewYear - 1 : viewYear const calendarMaxYear = viewMonth === 11 ? viewYear + 1 : viewYear const calendarYearsSpan = calendarMaxYear - calendarMinYear + 1 const calendarHook = useCalendarDays(workspace?.id ?? '', calendarMinYear, calendarYearsSpan) const wishesHook = useLeaveWishes(workspace?.id ?? null) const membersHook = useWorkspaceMembers(workspace?.id ?? null) const syncHook = usePlanSync() // The days the user removed by hand. Read for the calendar's badges, and // written by the popover's "this day" remove — see `excludeDay`. const exclusionsHook = usePlannedDayExclusions(workspace?.id ?? null) const calendarDaysByDate = useMemo(() => { const map = new Map() for (const day of calendarHook.data?.days ?? []) { map.set(day.date, day) } return map }, [calendarHook.data]) // Any parent's removal marks the date: the mark says "someone deliberately // took a day out here", which is what stops the hole reading as a bug. Whose // day it was is shown in the day popover, where there is room to say it. const excludedDates = useMemo( () => new Set(exclusionsHook.data.map((e) => e.date)), [exclusionsHook.data], ) /** The display name for a band's owner. Used wherever the UI has to say * WHOSE day something is — above all the "this day" remove, which takes one * parent's day and leaves the partner's. Falls back the same way * CalendarWeek's band labels do. */ const resolveParentName = (uuid: string): string => membersHook.data.find((m) => m.uuid === uuid)?.name ?? (uuid === personalDataUuid ? (parentName ?? t('calendar.you')) : t('calendar.partner')) const showToast = useCallback((message: string, undo: CalendarToast['undo']) => { setToast({ message, undo }) }, []) // Auto-dismiss the toast after a while so it never lingers indefinitely. useEffect(() => { if (!toast) return const timer = setTimeout(() => setToast(null), 8000) return () => clearTimeout(timer) }, [toast]) // Clear a flashed band's highlight shortly after it's applied. useEffect(() => { if (!flashWishId) return const timer = setTimeout(() => setFlashWishId(null), 1600) return () => clearTimeout(timer) }, [flashWishId]) // §Task7 — the calendar IS the plan: recompute + persist from the // workspace's LeaveWishes in one call, then refresh the two views that // depend on the result (planned days for the bands, calendar metadata for // shading). `silent` suppresses the "what changed" toast (used for the // initial load and for undo's re-sync) without suppressing error // reporting — a broken sync should always be visible. const runSync = useCallback( async ( workspaceId: string, options?: { silent?: boolean undo?: CalendarToast['undo'] watchWishId?: string // Used right after creating a wish, where `wishesHook.data` is // whatever it was as of this closure's own render (captured before // the create's background refetch lands) and so never actually // contains a wish created moments ago. Passing the just-created // wish object directly sidesteps that — see `buildSyncToastMessage`. watchWish?: LeaveWish }, ): Promise => { try { const result = await syncHook.sync(workspaceId) await Promise.all([plannedDaysHook.refresh(), calendarHook.refetch()]) if (!options?.silent) { const message = buildSyncToastMessage( result, wishesHook.data, membersHook.data, options?.watchWishId, t, options?.watchWish, ) if (message) showToast(message, options?.undo ?? null) } return result } catch (err) { showToast(err instanceof Error ? err.message : t('calendar.syncError'), null) return undefined } }, [syncHook, plannedDaysHook, calendarHook, wishesHook.data, membersHook.data, t, showToast], ) // Sync once per workspace as soon as it's known, so wishes seeded/created // elsewhere show up as bands without requiring a manual edit first. const didAutoSyncRef = useRef(null) useEffect(() => { if (!workspace?.id) return if (didAutoSyncRef.current === workspace.id) return didAutoSyncRef.current = workspace.id void runSync(workspace.id, { silent: true }) }, [workspace?.id, runSync]) useEffect(() => { const handlePointerUp = () => { if (isDragging && dragAnchor && dragCurrent) { const dates = datesInRange(dragAnchor, dragCurrent) 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 by itself — the default path // now creates a fixed one-off wish and lets the planner fund it // (§todo10/11); the "Advanced" disclosure still writes exact // pinned days, same as before. setSelectedDates(new Set(dates)) setAdvancedParentId(personalDataUuid ?? '') setIsModalOpen(true) } } setIsDragging(false) setDragAnchor(null) setDragCurrent(null) } if (isDragging) { window.addEventListener('pointerup', handlePointerUp) return () => window.removeEventListener('pointerup', handlePointerUp) } }, [isDragging, dragAnchor, dragCurrent, personalDataUuid]) if (isLoading) { return ( ) } const year = currentMonth.getFullYear() const month = currentMonth.getMonth() // The year `'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((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1)) } const handleNextMonth = () => { setCurrentMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1)) } const handleMonthChange = (e: React.ChangeEvent) => { const newMonth = Number(e.target.value) setCurrentMonth((prev) => new Date(prev.getFullYear(), newMonth, 1)) } const handleYearChange = (e: React.ChangeEvent) => { const newYear = Number(e.target.value) setCurrentMonth((prev) => new Date(newYear, prev.getMonth(), 1)) } const handlePointerDown = (date: string) => { setDragAnchor(date) setDragCurrent(date) setIsDragging(true) } const handlePointerEnter = (date: string) => { if (isDragging) { setDragCurrent(date) } } const handleDayClick = (date: string) => { setSelectedDates(new Set([date])) setAdvancedParentId(personalDataUuid ?? '') setIsModalOpen(true) } // §todo14 fix — takes the parent explicitly rather than assuming the // logged-in one. The old version always resolved against // `personalDataUuid`, so saving parent B's day on a date where parent A // already had one found (and silently overwrote) A's row instead of // creating B's. Every caller below now passes the parent the save/delete // is actually FOR — the modal-selected one, never the logged-in default. const getExistingDay = (date: string, parentId: string): PlannedLeaveDay | undefined => { return (plannedDaysHook.data ?? []).find( (pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === parentId, ) } // The "Advanced" escape hatch — pins an exact day type for an exact // parent, writing `planned_leave_day` rows directly (today's pre-existing // behaviour, unchanged except for the §todo14 lookup fix above). const handleSavePinnedDay = async (payload: { dayType: 'sjukpenning' | 'lagsta' | 'vacation' parentId: string isDubbeldag: boolean }) => { if (selectedDates.size === 0 || !selectedChildId || !workspace?.id) return try { setIsSaving(true) setModalError(null) setLastLeaveType(payload.dayType) for (const date of selectedDates) { const existingDay = getExistingDay(date, payload.parentId) if (existingDay) { // §Task7 — a hand-edit through the day popover pins the day so a // later `POST /api/plan/sync` never silently retypes it. await plannedDaysHook.update(existingDay.id, { day_type: payload.dayType, is_dubbeldag: payload.isDubbeldag, is_pinned: true, }) } else { await plannedDaysHook.add({ workspace_id: workspace.id, child_id: selectedChildId, parent_personal_data_uuid: payload.parentId, date, day_type: payload.dayType, is_dubbeldag: payload.isDubbeldag, is_pinned: true, }) } } setIsModalOpen(false) setSelectedDates(new Set()) } catch (err) { setModalError(err instanceof Error ? err.message : t('calendar.saveError')) } finally { setIsSaving(false) } } // §todo10/11 default path — the planner decides funding. Builds a fixed, // one-off `LeaveWish` spanning every selected date and lets the existing // sync machinery place it, exactly like `handleSelectPreset` below (same // add-then-sync-then-undo shape). const handleSaveWish = async (payload: { parentMode: 'either' | 'both' | 'specific' parentPersonalDataUuid: string | null wantsDubbeldag: boolean }) => { if (selectedDates.size === 0 || !selectedChildId || !workspace?.id) return const orderedDates = Array.from(selectedDates).sort() const first = orderedDates[0] const last = orderedDates[orderedDates.length - 1] if (!first || !last) return const start = splitISODate(first) const end = splitISODate(last) const dayCount = orderedDates.length try { setIsSaving(true) setModalError(null) const created = await wishesHook.add({ workspaceId: workspace.id, childId: selectedChildId, name: t('wishEditor.manualPeriodName'), nameMessageKey: 'wishEditor.manualPeriodName', windowStartMonth: start.month, windowStartDay: start.day, windowEndMonth: end.month, windowEndDay: end.day, targetWeeks: Math.max(1, Math.ceil(dayCount / 7)), targetDays: dayCount, recurrence: 'once', firstYear: start.year, priority: wishesHook.data.length + 1, parentMode: payload.parentMode, parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null, windowMode: 'fixed', wantsDubbeldag: payload.parentMode === 'both' ? payload.wantsDubbeldag : false, }) setIsModalOpen(false) setSelectedDates(new Set()) 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) { setModalError(err instanceof Error ? err.message : t('calendar.saveError')) } finally { setIsSaving(false) } } const handleDeletePlannedDay = async () => { if (selectedDates.size !== 1) return try { setIsSaving(true) setModalError(null) const dates = Array.from(selectedDates) const date = dates[0] if (!date) return const existingDay = getExistingDay(date, advancedParentId) if (existingDay) { await plannedDaysHook.remove(existingDay.id) } setIsModalOpen(false) setSelectedDates(new Set()) } catch (err) { setModalError(err instanceof Error ? err.message : t('calendar.deleteError')) } finally { setIsSaving(false) } } const handleBandClick = (info: BandClickInfo): void => { setActiveBand(info) setIsPeriodOpen(true) setPeriodError(null) } const closePeriodPopover = () => { setIsPeriodOpen(false) setActiveBand(null) setPeriodError(null) } const handleSavePeriod = async (payload: PeriodPopoverSavePayload) => { if (!activeBand?.wishId || !workspace?.id) return const previousWish = wishesHook.data.find((w) => w.id === activeBand.wishId) if (!previousWish) return const start = splitISODate(payload.startDate) const end = splitISODate(payload.endDate) // One wish row is one year, so the edited dates ARE this row's window — // year included. The old shape had to keep the wish's anchor year and // rewrite the shared month/day pattern for every year, because writing // the clicked year into `firstYear` would have dropped the earlier // occurrences; there is nothing to preserve now. const patch: LeaveWishUpdateRequest = { name: payload.name, nameMessageKey: null, windowStartMonth: start.month, windowStartDay: start.day, windowEndMonth: end.month, windowEndDay: end.day, firstYear: start.year, recurrence: payload.recurrence, parentMode: payload.parentMode, parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null, wantsDubbeldag: payload.wantsDubbeldag, } // `lastYear` belongs to the whole series, so only send it where the // backend will take it: when this occurrence is becoming a series, or // when the user explicitly chose "every year". const isSeries = previousWish.seriesId !== null if (payload.recurrence === 'yearly' && (!isSeries || payload.scope === 'series')) { patch.lastYear = payload.lastYear } try { setIsPeriodSaving(true) setPeriodError(null) await wishesHook.update(previousWish.id, patch, payload.scope) closePeriodPopover() await runSync(workspace.id, { watchWishId: previousWish.id, undo: { label: t('calendar.undoButton'), run: async () => { // Restores this row's own fields at the same scope the edit used. // A series-wide edit's undo re-projects the siblings from these // values, which is as faithful as the shape allows: per-year // divergence the user had before an "every year" save is gone // once that save lands. await wishesHook.update( previousWish.id, patchFromWish(previousWish, payload.scope), payload.scope, ) await runSync(workspace.id, { silent: true }) }, }, }) } catch (err) { setPeriodError(err instanceof Error ? err.message : t('calendar.periodCreateError')) } finally { setIsPeriodSaving(false) } } // §todo9 "this period, every year" — removes every row of the series (or // just the one row, when the period does not repeat). Undo recreates the // repeating wish from the clicked occurrence's fields; the rows get fresh // ids, exactly as before. const deleteWishSeries = async (wishId: string, workspaceId: string) => { const previousWish = wishesHook.data.find((w) => w.id === wishId) const siblingYears = previousWish?.seriesId ? wishesHook.data.filter((w) => w.seriesId === previousWish.seriesId).map((w) => w.firstYear) : [] await wishesHook.remove(wishId, 'series') closePeriodPopover() await runSync(workspaceId, { undo: previousWish ? { label: t('calendar.undoButton'), run: async () => { const restore = createRequestFromWish(previousWish) if (siblingYears.length > 0) { restore.recurrence = 'yearly' restore.firstYear = Math.min(...siblingYears) restore.lastYear = previousWish.lastYear ?? Math.max(...siblingYears) } await wishesHook.add(restore) await runSync(workspaceId, { silent: true }) }, } : null, }) } // §todo9 — the ad-hoc "this period" delete (unchanged behaviour, and also // what ad-hoc's "this day" falls back to for a single-day band). const deleteAdhocRange = async (start: string, end: string, parentUuid: string) => { const bandDates = new Set(datesInRange(start, end)) const toDelete = (plannedDaysHook.data ?? []).filter( (pd) => pd.parentPersonalDataUuid === parentUuid && pd.wishId === null && bandDates.has(pd.date), ) const snapshot = toDelete.map((pd) => ({ ...pd })) for (const pd of toDelete) { await plannedDaysHook.remove(pd.id) } closePeriodPopover() if (snapshot.length > 0) { showToast(t('calendar.toastChangedDays', { name: t('calendar.toastGenericPeriod'), count: snapshot.length }), { label: t('calendar.undoButton'), run: async () => { for (const pd of snapshot) { await plannedDaysHook.add({ workspace_id: pd.workspaceId, child_id: pd.childId, parent_personal_data_uuid: pd.parentPersonalDataUuid, date: pd.date, day_type: pd.dayType, is_dubbeldag: pd.isDubbeldag, is_pinned: pd.isPinned, }) } }, }) } } // §todo9 "this period" (this year only). A year of a repeating wish is a // real row now, so this is one plain delete — no narrowing `firstYear`/ // `lastYear`, and no splitting the wish in two around a middle year. const deleteWishOccurrence = async (wish: LeaveWish, workspaceId: string) => { await wishesHook.remove(wish.id, 'occurrence') closePeriodPopover() await runSync(workspaceId, { undo: { label: t('calendar.undoButton'), run: async () => { // `seriesId` puts the year back INTO its repeating wish rather than // beside it, so a later "every year" edit still reaches it. await wishesHook.add(createRequestFromWish(wish, wish.seriesId)) await runSync(workspaceId, { silent: true }) }, }, }) } /** §"removing a single day should stay permanent" — the planner-placed * counterpart of `deleteAdhocRange`. An auto-placed day cannot simply be * deleted: the wish behind it still asks for the whole window, so the next * sync would re-derive it. Recording a `PlannedDayExclusion` is what makes * the removal outlive the sync. * * Keyed on the band's own parent, so a both-parents period loses only the * band that was clicked and the partner keeps their day — the toast names * the parent so that choice is never a silent one. Undo deletes the * exclusion and re-syncs, which puts the day back where it was. */ const excludeDay = async ( workspaceId: string, band: BandClickInfo, parentLabel: string, ) => { const childId = (plannedDaysHook.data ?? []).find( (pd) => pd.date === band.clickedDate && pd.parentPersonalDataUuid === band.parentPersonalDataUuid, )?.childId ?? selectedChildId if (!childId) { closePeriodPopover() return } const created = await exclusionsHook.exclude({ workspaceId, childId, parentPersonalDataUuid: band.parentPersonalDataUuid, date: band.clickedDate, }) closePeriodPopover() await runSync(workspaceId, { silent: true }) const dateLabel = formatDateForDisplay(band.clickedDate, locale) showToast(t('calendar.toastDayExcluded', { date: dateLabel, parent: parentLabel }), { label: t('calendar.undoButton'), run: async () => { await exclusionsHook.restore(created.id) await runSync(workspaceId, { silent: true }) showToast(t('calendar.toastDayRestored', { date: dateLabel, parent: parentLabel }), null) }, }) } /** The day cell's way back, for when the undo toast is long gone: the day * popover lists this date's removals (one per parent) with a restore * button. See `excludeDay` for the other direction. */ const handleRestoreExcluded = async (exclusionId: string) => { if (!workspace?.id) return const entry = exclusionsHook.data.find((e) => e.id === exclusionId) await exclusionsHook.restore(exclusionId) setIsModalOpen(false) setSelectedDates(new Set()) await runSync(workspace.id, { silent: true }) if (entry) { showToast( t('calendar.toastDayRestored', { date: formatDateForDisplay(entry.date, locale), parent: resolveParentName(entry.parentPersonalDataUuid), }), null, ) } } const handleDeletePeriod = async (scope: PeriodDeleteScope) => { if (!workspace?.id || !activeBand) return try { setIsPeriodSaving(true) setPeriodError(null) if (scope === 'allYears') { if (activeBand.wishId) await deleteWishSeries(activeBand.wishId, workspace.id) else closePeriodPopover() // disabled in the UI for ad-hoc bands return } if (scope === 'day') { if (activeBand.wishId) { // A planner-placed day: deleting the row would not survive the next // sync, so record the removal instead. await excludeDay( workspace.id, activeBand, resolveParentName(activeBand.parentPersonalDataUuid), ) return } await deleteAdhocRange( activeBand.clickedDate, activeBand.clickedDate, activeBand.parentPersonalDataUuid, ) return } // scope === 'year' if (activeBand.wishId) { const previousWish = wishesHook.data.find((w) => w.id === activeBand.wishId) if (!previousWish) { closePeriodPopover() return } await deleteWishOccurrence(previousWish, workspace.id) } else { await deleteAdhocRange(activeBand.startDate, activeBand.endDate, activeBand.parentPersonalDataUuid) } } catch (err) { setPeriodError(err instanceof Error ? err.message : t('calendar.periodCreateError')) } finally { setIsPeriodSaving(false) } } const handleSelectPreset = async (preset: SeasonPreset) => { if (!workspace?.id || !selectedChildId) return try { setIsCreatingPeriod(true) const created = await wishesHook.add({ workspaceId: workspace.id, childId: selectedChildId, name: t(preset.labelKey), nameMessageKey: preset.labelKey, windowStartMonth: preset.startMonth, windowStartDay: preset.startDay, windowEndMonth: preset.endMonth, windowEndDay: preset.endDay, targetWeeks: preset.defaultWeeks, recurrence: 'yearly', firstYear: new Date().getFullYear(), priority: wishesHook.data.length + 1, parentMode: membersHook.data.length >= 2 ? 'both' : 'either', windowMode: 'flexible', }) const result = await runSync(workspace.id, { watchWishId: created.id, watchWish: created, undo: { label: t('calendar.undoButton'), run: async () => { // The preset is a repeating wish, so `created` is only its first // year — undo has to take the whole series with it. await wishesHook.remove(created.id, 'series') await runSync(workspace.id, { silent: true }) }, }, }) if (result) { const placedDates = result.changes .filter((c) => c.newWishId === created.id) .map((c) => c.date) .sort() const earliest = placedDates[0] if (earliest) { const { year: placedYear, month: placedMonth } = splitISODate(earliest) setCurrentMonth(new Date(placedYear, placedMonth - 1, 1)) setFlashWishId(created.id) const calendarEl = document.querySelector('[data-testid="calendar"]') if (calendarEl && typeof calendarEl.scrollIntoView === 'function') { calendarEl.scrollIntoView({ behavior: 'smooth', block: 'start' }) } } } } catch (err) { showToast(err instanceof Error ? err.message : t('calendar.periodCreateError'), null) } finally { setIsCreatingPeriod(false) } } const handleUndoClick = async () => { const undo = toast?.undo if (!undo) return try { setIsUndoing(true) await undo.run() setToast(null) } catch (err) { showToast(err instanceof Error ? err.message : t('calendar.undoError'), null) } finally { setIsUndoing(false) } } const defaultLeaveType = getLastLeaveType() ?? 'vacation' const firstSelectedDate = Array.from(selectedDates).sort()[0] // Dates to highlight in the grid. While dragging, show a live preview of the // range under the cursor; otherwise show the committed selection. const highlightedDates = isDragging && dragAnchor && dragCurrent ? new Set(datesInRange(dragAnchor, dragCurrent)) : selectedDates // §Task7 — feed every workspace member, not just the logged-in parent, so // couple mode can plan for either parent from the calendar's popovers. // Falls back to the logged-in parent alone while members haven't loaded // yet (or in solo mode, where there is only one). const parentsForModals = membersHook.data.length > 0 ? 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 return (

{(syncHook.isSyncing || isCreatingPeriod) && (

{t('calendar.syncingLabel')}

)} {toast && (
{toast.message} {toast.undo && ( )}
)}
{showWeekNumbers &&
} {WEEKDAY_KEYS.map((key) => (
{t(key)}
))}
{/* Each week is its own small CSS Grid (see CalendarWeek.tsx/ calendar.css) so a multi-day band can span several day columns as one element — that only works if each week is a separate grid, not a flat row of the header grid above. */}
{weeks.map((week, weekIdx) => ( ))}

{t('calendar.legendTitle')}

{t('calendar.legendVacation')}
{t('calendar.legendIncomeBased')}
{t('calendar.legendMinimumRate')}
{t('calendar.legendRedDay')}
{t('calendar.legendKlamdag')}
{t('calendar.legendOverride')}
2× {t('calendar.legendDoubleDay')}
{firstSelectedDate && ( e.date === firstSelectedDate) .map((e) => ({ id: e.id, parentName: resolveParentName(e.parentPersonalDataUuid) }))} onRestoreExcluded={handleRestoreExcluded} onClose={() => { setIsModalOpen(false) setSelectedDates(new Set()) }} isSaving={isSaving} error={modalError} defaultDayType={defaultLeaveType} isRangeMode={selectedDates.size > 1} dayCount={selectedDates.size} /> )} {activeBand && ( )}
) }