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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
2026-09-14 18:54:50 +02:00
parent f685b739f8
commit cf378288f0
5 changed files with 186 additions and 10 deletions
@@ -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<string, TranslationKey> = {
'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.
+100
View File
@@ -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(<Dashboard />)
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(<Dashboard />)
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(<Dashboard />)
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(<Dashboard />)
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(<Dashboard />)
expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Alex')
expect(screen.getByTestId('metric-expiring-parent-pd-unknown')).toHaveTextContent('Förälder 2')
})
})
+39 -6
View File
@@ -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 (
<Layout>
@@ -201,11 +238,7 @@ export function Dashboard(): ReactNode {
{analytics && (
<OverviewMetrics
title={t('dashboard.overviewTitle')}
metrics={[
{ label: t('dashboard.usedDays'), value: analytics.combinedDaysUsed },
{ label: t('dashboard.daysLeft'), value: analytics.combinedDaysLeft },
{ label: t('dashboard.expiringAtB4'), value: analytics.combinedExpiringAtB4 },
]}
metrics={overviewMetrics}
/>
)}
<ParentalLeaveBreakdown data={parentalLeave} />
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { nameResolver } from './parentNames'
import type { PersonalData } from '@/types'
const member = (overrides: Partial<PersonalData> = {}): 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')
})
})
+15
View File
@@ -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
}