import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { useWorkspace } from '@/context' import { useAnalytics, useLeaveWishes, usePlanProposal, usePlannedDays, usePreschoolPlanningDaysList, useWorkspaceMembers, useOptimizationTips, } from '@/hooks' // Imported directly (not via the `@/hooks` barrel) so this page's test can // mock this one module while the barrel stays automocked. import { useParentalLeaves } from '@/hooks/useParentalLeaves' import { nameResolver } from '@/utils/parentNames' import { orderSelfFirst } from '@/utils/members' import { useTranslation } from '@/i18n' import { Layout } from '@/components/layout/Layout' import { PageSkeleton } from '@/components/common/PageSkeleton' import { OverviewMetrics } from '@/components/dashboard/OverviewMetrics' import { Hero } from '@/components/dashboard/Hero' import { KeyFigures } from '@/components/dashboard/KeyFigures' import { Age4Bars } from '@/components/dashboard/Age4Bars' import { YearRows } from '@/components/dashboard/YearRows' import { SurplusAdviceTip } from '@/components/dashboard/SurplusAdviceTip' import { PlanWarningCard } from '@/components/dashboard/PlanWarningCard' import { VacationRescueTip } from '@/components/dashboard/VacationRescueTip' import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown' import { TipCard } from '@/components/inputs/TipCard' import { Onboarding } from '@/pages/Onboarding' import { Alert } from '@/components/common/Alert' import type { RescueDay, Tip } from '@/types' /** Key matching `rescueDayGroups`' own `Map` key below — shared so a * locally-dismissed group (Defect 2, §vacation-rescue) can be filtered back * out by the same identity it was grouped by. */ function rescueGroupKey(parentPersonalDataUuid: string, year: number): string { return `${parentPersonalDataUuid}:${year}` } /** Whether a `use_vacation_before_expiry` tip (`TipsService`, backend) is * superseded by an existing vacation-rescue group for the same parent and * year (Defect 3, §vacation-rescue) — otherwise the dashboard shows three * different numbers for what a user reads as one problem. The tip's `data` * only carries a resolved display name (`parent_name`), never a parent * uuid (see `_rule_use_vacation_before_expiry` in `tips_service.py`), so * matching goes through the same `nameFor` resolver both cards already use * to render a name, rather than inventing a backend field to compare * uuids directly. */ function isSupersededByRescueGroup( tip: Tip, rescueDayGroups: RescueDay[][], nameFor: (uuid: string) => string, ): boolean { if (tip.messageKey !== 'tips.use_vacation_before_expiry') return false const { parent_name, year } = tip.data if (typeof parent_name !== 'string' || typeof year !== 'number') return false return rescueDayGroups.some((group) => { const first = group[0] return first !== undefined && first.year === year && nameFor(first.parentPersonalDataUuid) === parent_name }) } /** Fallback horizon end when the analytics projection isn't available yet * (e.g. mid-load) — plain date arithmetic, not a business rule, so deriving * it here does not duplicate the age-4 savable-cap logic the backend owns. */ function computeFourthBirthday(birthDateIso: string): string { const d = new Date(birthDateIso + 'T00:00:00Z') d.setUTCFullYear(d.getUTCFullYear() + 4) return d.toISOString().slice(0, 10) } interface HeroRemedy { transferDays: number fromParent: string toParent: string } /** Surfaces the exact age-4 remedy in the hero, derived from the * `tips.age4_pooling_loss_exact` tip's `data` — no separate API call, no * new backend field. `Tip['data']` is `Record`, so every * field is narrowed by hand rather than cast. */ function deriveHeroRemedy(tips: Tip[] | null): HeroRemedy | null { const tip = tips?.find((t) => t.messageKey === 'tips.age4_pooling_loss_exact') if (!tip) return null const { transfer_days, surplus_parent, lagging_parent } = tip.data if (typeof transfer_days !== 'number' || !Number.isFinite(transfer_days) || transfer_days <= 0) return null if (typeof surplus_parent !== 'string' || surplus_parent.length === 0) return null if (typeof lagging_parent !== 'string' || lagging_parent.length === 0) return null return { transferDays: transfer_days, fromParent: surplus_parent, toParent: lagging_parent } } export function Dashboard(): ReactNode { const { t } = useTranslation() const navigate = useNavigate() const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid } = useWorkspace() const { data: analytics, isLoading: analyticsLoading, error: analyticsError } = useAnalytics( workspace?.id ?? null, selectedChildId ?? '' ) const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(selectedChildId ?? undefined) // Supplementary to the page's main figures — never gates the loading // skeleton; an empty array while it loads renders the same "–" as an // absent year, which is the correct fallback either way. const { data: preschoolPlanningDays } = usePreschoolPlanningDaysList(selectedChildId ?? null) const { data: members, isLoading: membersLoading, refresh: refreshMembers } = useWorkspaceMembers(workspace?.id ?? null) // Unlike `usePlanProposal` below, `useOptimizationTips` still only fetches // once (no `planVersion` dependency) — a plan-changing write elsewhere // (Settings, a bumped partner add/remove, PlanSyncProvider's own // background sync) never refreshes the tips shown here. Same staleness // shape as the bug `usePlanProposal` was just fixed for; left alone here, // deliberately out of scope for this change. const { data: tips, dismiss: dismissTip } = useOptimizationTips(workspace?.id ?? null) const { data: wishes, isLoading: wishesLoading } = useLeaveWishes(workspace?.id ?? null) // `proposal` (`data` from `usePlanProposal`) is already the freshest // proposal available — that hook itself prefers `PlanSyncProvider`'s // latest synced proposal over its own one-shot `propose()` result once a // sync has completed (see `usePlanProposal.ts`'s doc comment). No // `planVersion` dependency is needed *here* the way `useAnalytics.ts` // has one: `usePlanProposal` calls `usePlanSyncContextOptional()` // internally, a React Context read, so this component re-renders // whenever `PlanSyncProvider`'s state changes — the same plumbing // `planVersion` deps elsewhere rely on, just via Context instead of a // dependency array. const { data: proposal, isLoading: isProposing, error: proposalError, propose } = usePlanProposal(workspace?.id ?? null) // §vacation-rescue — group the proposal's suggest-only rescue days by // (parent, year); each group renders as one `VacationRescueTip` card // below. A `Map` keeps groups in first-seen order, which is already date // order since the backend returns `rescue_days` sorted by (date, parent) // — see `RescueDay`'s docstring (allocation_service.py). Computed above // every early return (Rules of Hooks — this page returns early for both // the loading skeleton and onboarding). // §vacation-rescue dismissal (Defect 2) — a successful dismiss writes no // plan data (just a `tip_dismissal` row server-side), so it must never // trigger a plan re-sync (`bumpPlanVersion`) the way accept does. Instead // the dismissed group is removed from view here, optimistically, in local // state — keyed the same way `rescueDayGroups` itself groups, so it // survives that memo re-running on any unrelated proposal refresh. const [dismissedRescueGroupKeys, setDismissedRescueGroupKeys] = useState>(new Set()) const handleRescueDismissed = useCallback((parentPersonalDataUuid: string, year: number) => { setDismissedRescueGroupKeys((prev) => { const next = new Set(prev) next.add(rescueGroupKey(parentPersonalDataUuid, year)) return next }) }, []) const rescueDayGroups = useMemo(() => { const groups = new Map() for (const day of proposal?.rescueDays ?? []) { const key = rescueGroupKey(day.parentPersonalDataUuid, day.year) if (dismissedRescueGroupKeys.has(key)) continue const group = groups.get(key) if (group) { group.push(day) } else { groups.set(key, [day]) } } return Array.from(groups.values()) }, [proposal, dismissedRescueGroupKeys]) const orderedMembers = orderSelfFirst(members, personalDataUuid) const { data: leaves, isLoading: leavesLoading } = useParentalLeaves( orderedMembers.map((m) => m.uuid), selectedChildId ?? null, ) // `useWorkspaceMembers` fetches once per `workspace.id`, which never // changes across onboarding — so a couple's partner, added mid-wizard via // `Onboarding.tsx`'s own direct `api.addPartner` call, is invisible to // this hook's already-fetched `data` until something explicitly refetches // it. `Onboarding` only refreshes `WorkspaceContext` (which flips // `children` from empty to non-empty) when it finishes — it has no way to // reach into this component's `useWorkspaceMembers` instance. Watching // that same empty-to-non-empty transition here is what tells this // component "onboarding just finished, the members list might be stale." const wasAwaitingFirstChildRef = useRef(children.length === 0) useEffect(() => { if (wasAwaitingFirstChildRef.current && children.length > 0) { void refreshMembers() } wasAwaitingFirstChildRef.current = children.length === 0 }, [children.length, refreshMembers]) // Initial-load-only fetch: this is what supplies `proposal` before // `PlanSyncProvider`'s own background sync has completed even once (e.g. // right after login). Once that first sync lands, `usePlanProposal` // switches to its result and this effect's own `propose()` calls stop // mattering — see `usePlanProposal.ts`. `proposalError === null` still // guards it so a failed `propose()` never becomes a per-render retry loop. useEffect(() => { if (!wishesLoading && wishes.length > 0 && proposal === null && !isProposing && proposalError === null) { void propose() } }, [wishesLoading, wishes.length, proposal, isProposing, proposalError, propose]) const isLoading = wsLoading || analyticsLoading || pdLoading if (isLoading) { return ( ) } const handleBandClick = (date: string) => { navigate(`/calendar?date=${date}`) } const nameFor = nameResolver(members) const parentProjections = analytics?.parentProjections ?? [] // Per-parent expiring-day figures are only informative once there's more // than one parent to compare — with a single parent they'd just repeat // the combined total, so skip the breakdown in that case. const showPerParentExpiring = parentProjections.length > 1 // Vacation days forecast lost (age-out or over the carry-forward cap), // across every parent, from this year onward — a past year's loss already // happened and is no longer actionable (same guard `YearRows` uses for // its own per-year expiring figure). const currentYear = new Date().getFullYear() const vacationAtRisk = parentProjections.reduce( (sum, projection) => sum + projection.yearBalances .filter((balance) => balance.year >= currentYear) .reduce((yearSum, balance) => yearSum + balance.vacation.expiring, 0), 0, ) const parentDisplayName = (uuid: string, index: number): string => { const resolved = membersLoading ? uuid : nameFor(uuid) return resolved === uuid ? t('dashboard.expiringAtB4ParentFallback', { number: index + 1 }) : resolved } const nameForAdvice = (uuid: string): string => { const resolved = nameFor(uuid) return resolved === uuid ? t('planResults.unknownParent') : resolved } const overviewMetrics = analytics ? [ { label: t('dashboard.usedDays'), value: analytics.combinedDaysUsed }, { label: t('dashboard.daysLeft'), value: analytics.combinedDaysLeft }, ...(showPerParentExpiring ? parentProjections.map((projection, index) => ({ label: t('dashboard.expiringAtB4ForParent', { name: parentDisplayName(projection.personalDataUuid, index), }), value: projection.expiringAtB4, testId: `metric-expiring-parent-${projection.personalDataUuid}`, })) : []), { label: t('dashboard.expiringAtB4'), value: analytics.combinedExpiringAtB4, testId: 'metric-expiring-combined', // Only actually a distinct "all parents" summary when there was // a per-parent breakdown above it to summarize. isSummary: showPerParentExpiring, }, ] : [] // A new account has no child yet — hand off to the onboarding wizard // (add child, solo/couple, balances ladder) instead of rendering the // dashboard itself. `Onboarding` only calls `refreshContext()` once the // whole wizard is done, at which point `children` here becomes non-empty // and this component naturally re-renders past this branch. if (children.length === 0) { return } const selectedChild = children.find((c) => c.id === selectedChildId) const hasPlan = (plannedDays?.length ?? 0) > 0 const fourthBirthday = parentProjections[0]?.fourthBirthday ?? (selectedChild ? computeFourthBirthday(selectedChild.birth_date) : '') // Self first, matching the order of every per-parent list on the page. const orderedProjections = [ ...parentProjections.filter((p) => p.personalDataUuid === personalDataUuid), ...parentProjections.filter((p) => p.personalDataUuid !== personalDataUuid), ] const projectionName = (uuid: string) => parentDisplayName(uuid, parentProjections.findIndex((p) => p.personalDataUuid === uuid)) const age4Bars = orderedProjections.map((projection) => ({ personalDataUuid: projection.personalDataUuid, name: projectionName(projection.personalDataUuid), projectedLeftAtB4: projection.projectedLeftAtB4, savableCapAtB4: projection.savableCapAtB4, expiringAtB4: projection.expiringAtB4, })) const yearRowParents = orderedProjections.map((projection) => ({ personalDataUuid: projection.personalDataUuid, name: projectionName(projection.personalDataUuid), yearBalances: projection.yearBalances, })) // "Estimated" once the members are known and at least one of them has no // saved balance — the headline figures then rest on the statutory // assumption for that parent. Absent from the map = not fetched yet, so // the line never flashes before the first response. const showEstimate = !leavesLoading && orderedMembers.length > 0 && orderedMembers.some((m) => leaves[m.uuid] === null) const breakdownParents = orderedMembers.map((member) => ({ personalDataUuid: member.uuid, name: member.name, data: leaves[member.uuid] ?? null, })) // `proposal` (and therefore these, and `outcomes` passed to `YearRows` // below) is always the freshest one available — see the comment by // `usePlanProposal(...)` above. A partner add/remove, a Settings save, or // the new manual recompute button (Settings.tsx) all eventually bump // `planVersion`, which `PlanSyncProvider` turns into a background sync, // which lands here without this component doing anything sync-specific. const surplusAdvice = proposal?.surplusAdvice ?? [] const warnings = proposal?.warnings ?? [] const hasTips = (tips?.length ?? 0) > 0 || surplusAdvice.length > 0 || rescueDayGroups.length > 0 const heroRemedy = deriveHeroRemedy(tips) return (
{analyticsError && ( {analyticsError} )} {analytics && selectedChild && ( )} {showEstimate && (

{t('dashboard.estimatedBadge')} {' '} {t('dashboard.estimatedBadgeCaption')}

)} {analytics && ( )} {selectedChild && (

{t('dashboard.leaveOverviewTitle')}

{age4Bars.length > 0 && } {proposalError && ( {proposalError} )}
)} {warnings.length > 0 && (

{t('planWarnings.sectionTitle')}

{warnings.map((warning, index) => ( ))}
)} {hasTips && (

{t('tips.title')}

{(tips ?? []) // Defect 3 — a `use_vacation_before_expiry` tip for a // parent/year that already has a rescue card is redundant // (three different numbers for one problem); the rescue // card supersedes it, being both actionable and current. .filter((tip) => !isSupersededByRescueGroup(tip, rescueDayGroups, nameForAdvice)) .map((tip) => ( dismissTip(tip.id)} /> ))} {surplusAdvice.map((advice, index) => ( ))} {rescueDayGroups.map((group, index) => ( ))}
)}
{t('dashboard.moreDetailsSummary')}
{analytics && } {t('dashboard.balanceLink')}
) }