From cf378288f0992f5f953f3d40b1fc60334e497d1a Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 14 Sep 2026 18:54:50 +0200 Subject: [PATCH] Break the Dashboard's age-4 stat down per parent The Dashboard summed both parents' expiring days into one figure while the Plan tab already showed them separately. Each parent's own count is now shown alongside the combined total, which stays and is relabelled as a sum. The per-parent numbers were already on the wire as parentProjections[].expiringAtB4 -- no backend change, and no arithmetic here. Both figures are rendered exactly as the backend computed them, since re-deriving them is what previously doubled a parent's entitlement (87b3eb5). A single-parent workspace shows only the combined figure, where a breakdown would just repeat it. nameResolver moves out of PlanResults.tsx into src/utils/parentNames.ts so both screens resolve uuid -> name the same way. PlanResults.test.tsx passes untouched, which is the evidence the extraction preserved behaviour. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL --- .../src/components/planning/PlanResults.tsx | 5 +- frontend/src/pages/Dashboard.test.tsx | 100 ++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 45 ++++++-- frontend/src/utils/parentNames.test.ts | 31 ++++++ frontend/src/utils/parentNames.ts | 15 +++ 5 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 frontend/src/utils/parentNames.test.ts create mode 100644 frontend/src/utils/parentNames.ts diff --git a/frontend/src/components/planning/PlanResults.tsx b/frontend/src/components/planning/PlanResults.tsx index 4c3c46e..4a870e7 100644 --- a/frontend/src/components/planning/PlanResults.tsx +++ b/frontend/src/components/planning/PlanResults.tsx @@ -18,6 +18,7 @@ import type { PlanApplyResponse, } from '@/types' import { useTranslation, toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n' +import { nameResolver } from '@/utils/parentNames' export interface PlanResultsProps { proposal: PlanProposal @@ -102,10 +103,6 @@ const REJECTION_KEYS: Record = { 'planApply.rejection.insufficientBalance': 'planResults.rejectionInsufficientBalance', } -function nameResolver(members: PersonalData[]): (uuid: string) => string { - return (uuid: string) => members.find((m) => m.uuid === uuid)?.name ?? uuid -} - /** `data` plus the resolved parent name (when the surface carries a * `parent_personal_data_uuid`) — every warning/advice body template below * references `{parent_name}`, and the backend only ever sends the uuid. diff --git a/frontend/src/pages/Dashboard.test.tsx b/frontend/src/pages/Dashboard.test.tsx index 07b2e82..050cff6 100644 --- a/frontend/src/pages/Dashboard.test.tsx +++ b/frontend/src/pages/Dashboard.test.tsx @@ -116,6 +116,15 @@ describe('Dashboard', () => { isRunning: false, isTerminal: false, } as any) + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue({ + data: [ + { uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, + { uuid: 'pd-2', workspaceId: 'ws-1', name: 'Sam', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, + ], + isLoading: false, + error: null, + refresh: vi.fn(), + } as any) }) it('renders loading spinner initially', () => { @@ -465,4 +474,95 @@ describe('Dashboard', () => { renderWithRouter() expect(screen.getByTestId('overview-metrics')).toBeInTheDocument() }) + + it('shows only the combined expiring figure for a single-parent workspace', () => { + vi.mocked(hooks.useAnalytics).mockReturnValue({ + data: { + ...mockAnalyticsData, + parentProjections: [ + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 104 }, + ], + combinedExpiringAtB4: 104, + }, + isLoading: false, + error: null, + refresh: vi.fn(), + } as any) + + renderWithRouter() + expect(screen.getByTestId('metric-expiring-combined')).toBeInTheDocument() + expect(screen.queryByTestId('metric-expiring-parent-pd-1')).not.toBeInTheDocument() + }) + + it('breaks down expiring days per parent alongside the combined total', () => { + vi.mocked(hooks.useAnalytics).mockReturnValue({ + data: { + ...mockAnalyticsData, + parentProjections: [ + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 }, + { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 }, + ], + combinedExpiringAtB4: 104, + }, + isLoading: false, + error: null, + refresh: vi.fn(), + } as any) + + renderWithRouter() + const alexMetric = screen.getByTestId('metric-expiring-parent-pd-1') + const samMetric = screen.getByTestId('metric-expiring-parent-pd-2') + const combinedMetric = screen.getByTestId('metric-expiring-combined') + expect(alexMetric).toHaveTextContent('Alex') + expect(alexMetric).toHaveTextContent('60') + expect(samMetric).toHaveTextContent('Sam') + expect(samMetric).toHaveTextContent('44') + expect(combinedMetric).toHaveTextContent('104') + }) + + it('falls back to a numbered parent label while members are still loading', () => { + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue({ + data: [], + isLoading: true, + error: null, + refresh: vi.fn(), + } as any) + vi.mocked(hooks.useAnalytics).mockReturnValue({ + data: { + ...mockAnalyticsData, + parentProjections: [ + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 }, + { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 }, + ], + combinedExpiringAtB4: 104, + }, + isLoading: false, + error: null, + refresh: vi.fn(), + } as any) + + renderWithRouter() + expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Förälder 1') + expect(screen.getByTestId('metric-expiring-parent-pd-2')).toHaveTextContent('Förälder 2') + }) + + it('falls back to a numbered parent label for a projection with no matching member', () => { + vi.mocked(hooks.useAnalytics).mockReturnValue({ + data: { + ...mockAnalyticsData, + parentProjections: [ + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 }, + { personalDataUuid: 'pd-unknown', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 }, + ], + combinedExpiringAtB4: 104, + }, + isLoading: false, + error: null, + refresh: vi.fn(), + } as any) + + renderWithRouter() + expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Alex') + expect(screen.getByTestId('metric-expiring-parent-pd-unknown')).toHaveTextContent('Förälder 2') + }) }) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 7d33012..3f5e342 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,9 +1,10 @@ import { ReactNode, useState } from 'react' import { useWorkspace } from '@/context' -import { useAnalytics, usePlannedDays, useScraperFetch, useParentalLeave } from '@/hooks' +import { useAnalytics, usePlannedDays, useScraperFetch, useParentalLeave, useWorkspaceMembers } from '@/hooks' import type { PlannedLeaveDay } from '@/types' import * as api from '@/services/api' import { getLabelForType } from '@/utils/leaveTypes' +import { nameResolver } from '@/utils/parentNames' import { useTranslation } from '@/i18n' import { Layout } from '@/components/layout/Layout' import { LoadingSpinner } from '@/components/common/LoadingSpinner' @@ -24,6 +25,7 @@ export function Dashboard(): ReactNode { ) const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(selectedChildId ?? undefined) const { data: parentalLeave, refresh: refreshParentalLeave } = useParentalLeave(personalDataUuid, selectedChildId ?? null) + const { data: members, isLoading: membersLoading } = useWorkspaceMembers(workspace?.id ?? null) const scraper = useScraperFetch() const [childName, setChildName] = useState('') @@ -127,6 +129,41 @@ export function Dashboard(): ReactNode { const currentWeeks = Math.floor((analytics?.combinedDaysUsed ?? 0) / 5) const targetWeeks = Math.floor(((analytics?.combinedDaysUsed ?? 0) + (analytics?.combinedDaysLeft ?? 0)) / 5) + // 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 below, so skip the breakdown in that case. + const nameFor = nameResolver(members) + const parentProjections = analytics?.parentProjections ?? [] + const showPerParentExpiring = parentProjections.length > 1 + + const parentDisplayName = (uuid: string, index: number): string => { + const resolved = membersLoading ? uuid : nameFor(uuid) + return resolved === uuid + ? t('dashboard.expiringAtB4ParentFallback', { number: index + 1 }) + : 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', + }, + ] + : [] + if (children.length === 0) { return ( @@ -201,11 +238,7 @@ export function Dashboard(): ReactNode { {analytics && ( )} diff --git a/frontend/src/utils/parentNames.test.ts b/frontend/src/utils/parentNames.test.ts new file mode 100644 index 0000000..4f49d50 --- /dev/null +++ b/frontend/src/utils/parentNames.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { nameResolver } from './parentNames' +import type { PersonalData } from '@/types' + +const member = (overrides: Partial = {}): PersonalData => ({ + uuid: 'pd-1', + workspaceId: 'ws-1', + name: 'Alex', + salaryType: 'monthly', + employmentStatus: 'employed', + createdAt: '2026-01-01', + ...overrides, +}) + +describe('nameResolver', () => { + it('resolves a uuid to the matching member name', () => { + const nameFor = nameResolver([member(), member({ uuid: 'pd-2', name: 'Sam' })]) + expect(nameFor('pd-1')).toBe('Alex') + expect(nameFor('pd-2')).toBe('Sam') + }) + + it('falls back to the uuid itself when no member matches', () => { + const nameFor = nameResolver([member()]) + expect(nameFor('unknown-uuid')).toBe('unknown-uuid') + }) + + it('falls back to the uuid for an empty members list', () => { + const nameFor = nameResolver([]) + expect(nameFor('pd-1')).toBe('pd-1') + }) +}) diff --git a/frontend/src/utils/parentNames.ts b/frontend/src/utils/parentNames.ts new file mode 100644 index 0000000..77fd6a3 --- /dev/null +++ b/frontend/src/utils/parentNames.ts @@ -0,0 +1,15 @@ +import type { PersonalData } from '@/types' + +/** + * Builds a uuid → display-name lookup from a workspace's members. + * + * Falls back to returning the uuid itself when no member matches — callers + * that need a friendlier fallback (e.g. while members are still loading) + * should detect that case themselves rather than this helper guessing. + * + * Shared by `PlanResults.tsx` and `Dashboard.tsx` — keep behaviour identical + * for both call sites. + */ +export function nameResolver(members: PersonalData[]): (uuid: string) => string { + return (uuid: string) => members.find((m) => m.uuid === uuid)?.name ?? uuid +}