import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useWorkspace } from '@/context' import { usePlannedDays, useShowWeekNumbers, getLastLeaveType, setLastLeaveType, useCalendarDays, useDayOverrides, useLeaveWishes, useWorkspaceMembers, usePlanSync, } from '@/hooks' import type { PlannedLeaveDay, CalendarDayEntry, LeaveWish, LeaveWishCreateRequest, LeaveWishUpdateRequest, PersonalData, PlanChange, PlanSyncResponse, WindowOutcome, } from '@/types' import { Layout } from '@/components/layout/Layout' import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { CalendarWeek, type BandClickInfo } from '@/components/common/CalendarWeek' import { PlannedDayModal } from '@/components/modals/PlannedDayModal' import { PeriodPopover, type PeriodPopoverSavePayload } from '@/components/calendar/PeriodPopover' 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 { 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 } } /** 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"). */ function createRequestFromWish(w: LeaveWish): 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: w.recurrence, firstYear: w.firstYear, lastYear: w.lastYear, priority: w.priority, parentMode: w.parentMode, parentPersonalDataUuid: w.parentPersonalDataUuid, windowMode: w.windowMode, } } function patchFromWish(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, recurrence: w.recurrence, firstYear: w.firstYear, lastYear: w.lastYear, priority: w.priority, parentMode: w.parentMode, parentPersonalDataUuid: w.parentPersonalDataUuid, windowMode: w.windowMode, } } /** 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 [currentMonth, setCurrentMonth] = useState(new Date()) const [selectedDates, setSelectedDates] = useState>(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) const [overrideError, setOverrideError] = useState(null) 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 dayOverridesHook = useDayOverrides(workspace?.id ?? '') const wishesHook = useLeaveWishes(workspace?.id ?? null) const membersHook = useWorkspaceMembers(workspace?.id ?? null) const syncHook = usePlanSync() const calendarDaysByDate = useMemo(() => { const map = new Map() for (const day of calendarHook.data?.days ?? []) { map.set(day.date, day) } return map }, [calendarHook.data]) 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]) 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) { setSelectedDates(new Set(dates)) setIsModalOpen(true) } else if (dates.length > 1) { void handleCreatePeriodFromDrag(dates) } } setIsDragging(false) setDragAnchor(null) setDragCurrent(null) } if (isDragging) { window.addEventListener('pointerup', handlePointerUp) return () => window.removeEventListener('pointerup', handlePointerUp) } }, [isDragging, dragAnchor, dragCurrent, handleCreatePeriodFromDrag]) if (isLoading) { return } const year = currentMonth.getFullYear() const month = currentMonth.getMonth() // The year {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((m) => { const date = new Date(year, m, 1) const monthName = date.toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US', { month: 'long' }) 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) => { const firstDate = week[0] const weekMonth = firstDate ? new Date(firstDate + 'T00:00:00Z').getMonth() : month return ( ) })}

{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 && ( { setIsModalOpen(false) setSelectedDates(new Set()) }} isSaving={isSaving} error={modalError} defaultDayType={defaultLeaveType} dayInfo={calendarDaysByDate.get(firstSelectedDate) ?? null} onToggleOverride={() => handleOverrideToggle(firstSelectedDate)} overrideError={overrideError} /> )} {activeBand && ( )}
) }