Lead the overview with the days about to be lost

The page opened with generic tiles and a bar reading forty-eight of ninety-six
weeks, which was neither weeks nor connected to the age-four ceiling it appeared
to describe. It is replaced by one headline and a bar per parent, in days,
against each parent's own savable limit as the server reports it.

The headline has two framings on the same arithmetic. Before anything is
planned it says what there is to plan and what will be lost without a plan,
because on the first day almost the whole entitlement is technically at risk
and saying so bluntly reads as an accusation. Once a plan exists it warns about
the days genuinely at stake.

A timeline of the years to the fourth birthday answers what the next four years
look like, which a month-at-a-time calendar cannot. Shortfalls, surplus advice
and balances sit below the fold, read-only: applying is automatic now, so a
second apply button would be a way to get two different answers.

Rejection messages move to one shared place that resolves a parent's name, so
an identifier can no longer surface in their place.
This commit is contained in:
2026-09-17 21:32:15 +02:00
parent c2e64b504d
commit 8e1cf3524c
27 changed files with 1749 additions and 178 deletions
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Age4Bars } from './Age4Bars'
describe('Age4Bars', () => {
it('renders days (never weeks) against each parent\'s own savable cap', () => {
render(
<Age4Bars
bars={[
{ personalDataUuid: 'pd-1', name: 'Alex', projectedLeftAtB4: 40, savableCapAtB4: 48, expiringAtB4: 0 },
{ personalDataUuid: 'pd-2', name: 'Sam', projectedLeftAtB4: 239, savableCapAtB4: 48, expiringAtB4: 191 },
]}
/>,
)
expect(screen.getByTestId('age4-bar-savable-pd-1')).toHaveTextContent('40 av 48 dagar kan sparas')
expect(screen.getByTestId('age4-bar-savable-pd-2')).toHaveTextContent('48 av 48 dagar kan sparas')
expect(screen.getByTestId('age4-bar-expiring-pd-2')).toHaveTextContent('191 dagar förfaller')
expect(screen.queryByTestId('age4-bar-expiring-pd-1')).not.toBeInTheDocument()
})
it('renders a resolved parent name, never a raw uuid', () => {
render(
<Age4Bars
bars={[
{ personalDataUuid: 'pd-1', name: 'Förälder 1', projectedLeftAtB4: 10, savableCapAtB4: 96, expiringAtB4: 0 },
]}
/>,
)
expect(screen.getByTestId('age4-bar-pd-1')).toHaveTextContent('Förälder 1')
expect(screen.getByTestId('age4-bar-pd-1')).not.toHaveTextContent('pd-1')
})
it('sets fill width as a percentage of the savable cap, capped at 100%', () => {
render(
<Age4Bars
bars={[
{ personalDataUuid: 'pd-1', name: 'Alex', projectedLeftAtB4: 24, savableCapAtB4: 48, expiringAtB4: 0 },
{ personalDataUuid: 'pd-2', name: 'Sam', projectedLeftAtB4: 300, savableCapAtB4: 96, expiringAtB4: 204 },
]}
/>,
)
expect(screen.getByTestId('age4-bar-fill-pd-1')).toHaveStyle({ width: '50%' })
expect(screen.getByTestId('age4-bar-fill-pd-2')).toHaveStyle({ width: '100%' })
})
it('renders one row per bar, in the given order', () => {
render(
<Age4Bars
bars={[
{ personalDataUuid: 'pd-1', name: 'Alex', projectedLeftAtB4: 10, savableCapAtB4: 48, expiringAtB4: 0 },
{ personalDataUuid: 'pd-2', name: 'Sam', projectedLeftAtB4: 10, savableCapAtB4: 48, expiringAtB4: 0 },
]}
/>,
)
expect(screen.getAllByTestId(/^age4-bar-pd-/)).toHaveLength(2)
})
it('renders nothing but the title when there are no bars', () => {
render(<Age4Bars bars={[]} />)
expect(screen.getByTestId('age4-bars')).toBeInTheDocument()
expect(screen.queryByText(/av .* dagar kan sparas/)).not.toBeInTheDocument()
})
it('shows a zero-width fill instead of dividing by zero when the savable cap is 0', () => {
render(
<Age4Bars
bars={[{ personalDataUuid: 'pd-1', name: 'Alex', projectedLeftAtB4: 10, savableCapAtB4: 0, expiringAtB4: 10 }]}
/>,
)
expect(screen.getByTestId('age4-bar-fill-pd-1')).toHaveStyle({ width: '0%' })
expect(screen.getByTestId('age4-bar-savable-pd-1')).toHaveTextContent('0 av 0 dagar kan sparas')
})
})
@@ -0,0 +1,65 @@
import { ReactNode } from 'react'
import { useTranslation } from '@/i18n'
/** One parent's bar — already resolved to a display name (never a raw
* UUID; see `nameResolver`/the "Förälder N" fallback in `Dashboard.tsx`) and
* carrying the backend-resolved `savableCapAtB4` (48 per parent in a
* `couple` workspace with two parents, 96 otherwise — see
* `Age4Projection.savableCapAtB4`). Never re-derive that cap here.
*/
export interface Age4BarDatum {
personalDataUuid: string
name: string
projectedLeftAtB4: number
savableCapAtB4: number
expiringAtB4: number
}
export interface Age4BarsProps {
bars: Age4BarDatum[]
}
/**
* Per-parent "days left at the 4th birthday" bars — measured in **days**
* against that parent's own savable cap, replacing the old `ProgressChart`
* (which rendered `Math.floor(days / 5)` as "weeks" and was never wired to
* the age-4 cap at all).
*/
export function Age4Bars({ bars }: Age4BarsProps): ReactNode {
const { t } = useTranslation()
return (
<div data-testid="age4-bars" className="age4-bars">
<h3 className="age4-bars__title">{t('age4Bars.title')}</h3>
{bars.map((bar) => {
const savable = Math.min(bar.projectedLeftAtB4, bar.savableCapAtB4)
const percentage = bar.savableCapAtB4 > 0 ? Math.min((savable / bar.savableCapAtB4) * 100, 100) : 0
return (
<div key={bar.personalDataUuid} className="age4-bars__row" data-testid={`age4-bar-${bar.personalDataUuid}`}>
<div className="age4-bars__label">{bar.name}</div>
<div className="age4-bars__track">
<div
className="age4-bars__fill"
style={{ width: `${percentage}%` }}
data-testid={`age4-bar-fill-${bar.personalDataUuid}`}
/>
</div>
<div className="age4-bars__stats">
<span data-testid={`age4-bar-savable-${bar.personalDataUuid}`}>
{t('age4Bars.savableStat', { left: savable, cap: bar.savableCapAtB4 })}
</span>
{bar.expiringAtB4 > 0 && (
<span
className="age4-bars__expiring"
data-testid={`age4-bar-expiring-${bar.personalDataUuid}`}
>
{t('age4Bars.expiringStat', { expiring: bar.expiringAtB4 })}
</span>
)}
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Hero } from './Hero'
describe('Hero', () => {
it('uses the forward-looking framing when no plan exists yet', () => {
render(
<Hero hasPlan={false} daysLeft={480} daysAtRisk={384} childName="Nova" fourthBirthday="2029-06-01" />,
)
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent(
'Du har 480 dagar att planera. Utan en plan försvinner 384 av dem när Nova fyller 4.',
)
expect(screen.getByTestId('dashboard-hero')).toHaveClass('dashboard-hero--motivate')
expect(screen.getByTestId('dashboard-hero')).not.toHaveAttribute('role', 'alert')
})
it('uses the warning framing once a plan exists', () => {
render(
<Hero hasPlan={true} daysLeft={200} daysAtRisk={191} childName="Nova" fourthBirthday="2029-06-01" />,
)
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent(
'191 dagar riskerar att försvinna när Nova fyller 4 (1 juni 2029).',
)
expect(screen.getByTestId('dashboard-hero')).toHaveClass('dashboard-hero--warning')
expect(screen.getByTestId('dashboard-hero')).toHaveAttribute('role', 'alert')
})
it('renders the same daysAtRisk figure in both framings without recomputing it', () => {
const { rerender } = render(
<Hero hasPlan={false} daysLeft={480} daysAtRisk={104} childName="Nova" fourthBirthday="2029-06-01" />,
)
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent('104')
rerender(<Hero hasPlan={true} daysLeft={480} daysAtRisk={104} childName="Nova" fourthBirthday="2029-06-01" />)
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent('104')
})
})
@@ -0,0 +1,51 @@
import { ReactNode } from 'react'
import { useTranslation } from '@/i18n'
import { formatDateForDisplay } from '@/utils/dates'
export interface HeroProps {
/** True once at least one day has actually been planned for this child. */
hasPlan: boolean
/** Days still available to plan (total minus used) — the forward-looking framing's "N". */
daysLeft: number
/** `combinedExpiringAtB4` — days projected to expire at the 4th birthday
* given the current plan (zero planned days, in the no-plan framing). */
daysAtRisk: number
childName: string
/** ISO-8601 date. */
fourthBirthday: string
}
/**
* The headline: same arithmetic (`daysAtRisk` = `combinedExpiringAtB4`), two
* framings depending on whether anything has been planned yet — a forward-
* looking nudge with nothing planned (the entire entitlement is technically
* "at risk" on day one, which reads as an accusation before the user has
* done anything), a plain warning once a plan exists.
*/
export function Hero({ hasPlan, daysLeft, daysAtRisk, childName, fourthBirthday }: HeroProps): ReactNode {
const { t } = useTranslation()
const message = hasPlan
? t('dashboard.hero.hasPlanMessage', {
atRisk: daysAtRisk,
childName,
date: formatDateForDisplay(fourthBirthday),
})
: t('dashboard.hero.noPlanMessage', {
days: daysLeft,
atRisk: daysAtRisk,
childName,
})
return (
<section
data-testid="dashboard-hero"
className={`dashboard-hero ${hasPlan ? 'dashboard-hero--warning' : 'dashboard-hero--motivate'}`}
role={hasPlan ? 'alert' : undefined}
>
<p className="dashboard-hero__message" data-testid="dashboard-hero-message">
{message}
</p>
</section>
)
}
@@ -0,0 +1,202 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { PlanFold } from './PlanFold'
import * as hooks from '@/hooks'
import {
makeLeaveWishesResult,
makePlanProposalResult,
makePersonalData,
makeLeaveWish,
makePlanProposal,
makeWindowOutcome,
makeSurplusAdvice,
makePoolBalance,
makeVacationBalance,
} from '../../../tests/fixtures'
vi.mock('@/hooks')
const members = [
makePersonalData({ uuid: 'pd-a', name: 'Alex' }),
makePersonalData({ uuid: 'pd-b', name: 'Sam' }),
]
describe('PlanFold', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [makeLeaveWish()] }))
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult())
})
it('is collapsed by default (a native <details> element)', () => {
render(<PlanFold workspaceId="ws-1" members={members} />)
const details = screen.getByTestId('plan-fold')
expect(details.tagName).toBe('DETAILS')
expect(details).not.toHaveAttribute('open')
})
it('shows a hint instead of a proposal when there are no wishes', () => {
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] }))
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-no-wishes')).toHaveTextContent(
'Lägg till önskemål under Planering för att se ett förslag här.',
)
})
it('shows the proposal error when the read-only propose call fails', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({ error: 'Kunde inte beräkna förslag' }),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-proposal-error')).toHaveTextContent('Kunde inte beräkna förslag')
})
it('auto-proposes once wishes are loaded, without a manual button press', () => {
const propose = vi.fn()
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose }))
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(propose).toHaveBeenCalled()
})
it('does not propose again once a proposal is already loaded', () => {
const propose = vi.fn()
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({ propose, data: makePlanProposal() }),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(propose).not.toHaveBeenCalled()
})
it('does not propose while there are no wishes yet', () => {
const propose = vi.fn()
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] }))
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose }))
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(propose).not.toHaveBeenCalled()
})
it('does not propose while wishes are still loading', () => {
const propose = vi.fn()
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ isLoading: true, data: [] }))
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose }))
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(propose).not.toHaveBeenCalled()
})
it('does not propose again while a propose call is already in flight', () => {
const propose = vi.fn()
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose, isLoading: true }))
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(propose).not.toHaveBeenCalled()
})
it('renders per-period wished-vs-funded from the proposal', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
outcomes: [makeWindowOutcome({ wishId: 'lw-1', wishName: 'Sommar', year: 2026, weeksWished: 6, weeksFunded: 4 })],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-overview-year-2026')).toHaveTextContent('Sommar: 4/6 veckor')
})
it('shows the empty-horizon message when the proposal has no outcomes', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({ data: makePlanProposal({ outcomes: [] }) }),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-overview')).toHaveTextContent('Inga fönster inom planeringshorisonten.')
})
it('renders surplus advice with a resolved parent name, never a raw uuid', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
surplusAdvice: [
makeSurplusAdvice({
kind: 'wish_more_leave',
messageKey: 'planSurplusAdvice.wish_more_leave',
daysInvolved: 12,
data: { parent_personal_data_uuid: 'pd-b', child_id: 'child-1', weeks: 2 },
}),
],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
const advice = screen.getByTestId('plan-fold-surplus-advice-0')
expect(advice).toHaveTextContent('Sam')
expect(advice).not.toHaveTextContent('pd-b')
})
it('falls back to a generic label for surplus advice when no member matches', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
surplusAdvice: [
makeSurplusAdvice({
kind: 'wish_more_leave',
messageKey: 'planSurplusAdvice.wish_more_leave',
daysInvolved: 12,
data: { parent_personal_data_uuid: 'pd-unknown', child_id: 'child-1', weeks: 2 },
}),
],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
const advice = screen.getByTestId('plan-fold-surplus-advice-0')
expect(advice).not.toHaveTextContent('pd-unknown')
})
it('renders an unrecognised surplus advice kind/messageKey by falling back to the raw value', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
surplusAdvice: [
makeSurplusAdvice({
kind: 'brand_new_kind',
messageKey: 'planSurplusAdvice.brand_new_kind',
data: {},
}),
],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
const advice = screen.getByTestId('plan-fold-surplus-advice-0')
expect(advice).toHaveTextContent('brand_new_kind')
})
it('does not render a surplus-advice section when there is none', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({ data: makePlanProposal({ surplusAdvice: [] }) }),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.queryByTestId('plan-fold-surplus-advice')).not.toBeInTheDocument()
})
it('renders pool and vacation balance tables', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
poolBalances: [makePoolBalance({ parentPersonalDataUuid: 'pd-a', sjukpenningLeft: 300, lagstaLeft: 80, dubbeldagarLeft: 50 })],
vacationBalances: [makeVacationBalance({ parentPersonalDataUuid: 'pd-a', year: 2026, alreadyUsed: 10, totalDays: 25, savedDaysIn: 0 })],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-pool-balances')).toHaveTextContent('Alex')
expect(screen.getByTestId('plan-fold-pool-balances')).toHaveTextContent('300')
expect(screen.getByTestId('plan-fold-vacation-balances')).toHaveTextContent('25')
})
it('renders nothing beyond the summary/hint when there is no proposal yet', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ data: null }))
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.queryByTestId('plan-fold-overview')).not.toBeInTheDocument()
expect(screen.queryByTestId('plan-fold-balances')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,223 @@
/**
* Folds the wished-vs-funded/surplus/balance content that used to live only
* on the Plan tab's `PlanResults.tsx` into Översikt, collapsed below the
* fold. Read `PlanResults.tsx` for the reference implementation; this is a
* separate, trimmed-down component (not a shared import) because
* `PlanResults.tsx` is scheduled for deletion by another task and must not
* be edited here.
*
* Read-only. The calendar's `POST /api/plan/sync` (see `Calendar.tsx`) is
* now the only way a plan is written — it proposes and persists in one
* step, so there is no separate "propose, review, then apply" flow left
* anywhere in the app, and this fold never mutates anything. It still calls
* `usePlanProposal` (`POST /api/plan/propose`) to get the wished-vs-funded
* figures, surplus advice and balance tables below, because that data has
* no other source: there is no GET endpoint for "the current plan" shaped
* like a proposal, and `/api/plan/propose` is documented read-only —
* "persists nothing, safe to spam" (`backend/app/routers/plan.py`).
*
* Dropped relative to `PlanResults.tsx` (kept there / on the Plan tab
* instead, to avoid duplicating content the Hero and `Age4Bars` already
* cover): the warnings section, the age4Projections table, and the
* per-window explanation/remedy detail cards. Also dropped, as of the move
* to `/api/plan/sync`: the apply button, collision-confirmation UI, and
* rejection rendering — applying is no longer a thing this fold does (see
* `frontend/src/utils/rejectionMessages.ts` for where rejection rendering
* now lives, for the sync toast in `Calendar.tsx`).
*/
import { ReactNode, useEffect } from 'react'
import type { PersonalData, SurplusAdvice, WindowOutcome } from '@/types'
import { useLeaveWishes, usePlanProposal } from '@/hooks'
import { useTranslation, toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n'
import { nameResolver } from '@/utils/parentNames'
import { resolveWishName } from '@/utils/wishName'
type Translate = (key: TranslationKey, params?: TranslationParams) => string
// §9.2 surplus-advice kinds — mirrors `PlanResults.tsx`'s `SURPLUS_ADVICE_KEYS`/
// `SURPLUS_ADVICE_BODY_KEYS` (the two catalogues are independent copies, not
// a shared import — see the file banner above).
const SURPLUS_ADVICE_KEYS: Record<string, TranslationKey> = {
wish_more_leave: 'planResults.surplusAdviceWishMoreLeave',
shift_mix_to_fp: 'planResults.surplusAdviceShiftMixToFp',
front_load_early_years: 'planResults.surplusAdviceFrontLoadEarlyYears',
use_less_vacation: 'planResults.surplusAdviceUseLessVacation',
}
const SURPLUS_ADVICE_BODY_KEYS: Record<string, TranslationKey> = {
'planSurplusAdvice.wish_more_leave': 'planResults.surplusAdviceBodyWishMoreLeave',
'planSurplusAdvice.shift_mix_to_fp': 'planResults.surplusAdviceBodyShiftMixToFp',
'planSurplusAdvice.front_load_early_years': 'planResults.surplusAdviceBodyFrontLoadEarlyYears',
'planSurplusAdvice.use_less_vacation': 'planResults.surplusAdviceBodyUseLessVacation',
}
function nameForSafe(members: PersonalData[], t: Translate): (uuid: string) => string {
const resolve = nameResolver(members)
return (uuid: string) => {
const name = resolve(uuid)
return name === uuid ? t('dashboard.planFold.unknownParent') : name
}
}
function renderSurplusAdviceBody(advice: SurplusAdvice, t: Translate, nameFor: (uuid: string) => string): string {
const key = SURPLUS_ADVICE_BODY_KEYS[advice.messageKey]
if (!key) return advice.messageKey
const params = toTranslationParams(advice.data)
const uuid = advice.data.parent_personal_data_uuid
if (typeof uuid === 'string') {
params.parent_name = nameFor(uuid)
}
params.days_involved = advice.daysInvolved
return t(key, params)
}
function groupByYear(outcomes: WindowOutcome[]): Array<{ year: number; outcomes: WindowOutcome[] }> {
const years = Array.from(new Set(outcomes.map((o) => o.year))).sort((a, b) => a - b)
return years.map((year) => ({
year,
outcomes: outcomes.filter((o) => o.year === year).sort((a, b) => a.startDate.localeCompare(b.startDate)),
}))
}
export interface PlanFoldProps {
workspaceId: string
members: PersonalData[]
}
export function PlanFold({ workspaceId, members }: PlanFoldProps): ReactNode {
const { t } = useTranslation()
const { data: wishes, isLoading: wishesLoading } = useLeaveWishes(workspaceId)
const { data: proposal, isLoading: isProposing, error: proposalError, propose } = usePlanProposal(workspaceId)
const nameFor = nameForSafe(members, t)
// Auto-propose once wishes are available — this section is read-only-by-
// default (collapsed), so nothing should require the user to first find
// and press a separate "suggest" button just to see it. `propose` calls
// the read-only `/api/plan/propose` endpoint — it persists nothing.
useEffect(() => {
if (!wishesLoading && wishes.length > 0 && proposal === null && !isProposing) {
void propose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wishesLoading, wishes.length, proposal])
return (
<details className="plan-fold" data-testid="plan-fold">
<summary className="plan-fold__summary" data-testid="plan-fold-summary">
{t('dashboard.planFold.summary')}
</summary>
<div className="plan-fold__content">
{!wishesLoading && wishes.length === 0 && (
<p className="plan-fold__hint" data-testid="plan-fold-no-wishes">
{t('dashboard.planFold.noWishesHint')}
</p>
)}
{proposalError && (
<div className="plan-fold__error" role="alert" data-testid="plan-fold-proposal-error">
{proposalError}
</div>
)}
{proposal && (
<>
<section className="plan-fold__section" data-testid="plan-fold-overview">
<h4>{t('planResults.yearOverviewTitle')}</h4>
{proposal.outcomes.length === 0 ? (
<p>{t('planResults.noWindowsInHorizon')}</p>
) : (
<table className="plan-overview-table" data-testid="plan-fold-overview-table">
<thead>
<tr>
<th>{t('planResults.yearHeader')}</th>
<th>{t('planResults.leavesHeader')}</th>
</tr>
</thead>
<tbody>
{groupByYear(proposal.outcomes).map(({ year, outcomes }) => (
<tr key={year} data-testid={`plan-fold-overview-year-${year}`}>
<td>{year}</td>
<td>
{outcomes.map((outcome) => (
<span key={outcome.wishId} className="plan-overview-badge">
{resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)}
: {outcome.weeksFunded}/{outcome.weeksWished} {t('planResults.weeksUnit')}
</span>
))}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{proposal.surplusAdvice.length > 0 && (
<section className="plan-fold__section" data-testid="plan-fold-surplus-advice">
<h4>{t('planResults.surplusAdviceTitle')}</h4>
<p>{t('planResults.surplusAdviceIntro')}</p>
<ul>
{proposal.surplusAdvice.map((advice, idx) => {
const titleKey = SURPLUS_ADVICE_KEYS[advice.kind]
return (
<li key={`${advice.kind}-${idx}`} data-testid={`plan-fold-surplus-advice-${idx}`}>
<strong>{titleKey ? t(titleKey) : advice.kind}</strong>
<p>{renderSurplusAdviceBody(advice, t, nameFor)}</p>
</li>
)
})}
</ul>
</section>
)}
<section className="plan-fold__section" data-testid="plan-fold-balances">
<h4>{t('planResults.balancesTitle')}</h4>
<table className="plan-balances-table" data-testid="plan-fold-pool-balances">
<thead>
<tr>
<th>{t('planResults.parentHeader')}</th>
<th>{t('planResults.sjukpenningLeftHeader')}</th>
<th>{t('planResults.lagstaLeftHeader')}</th>
<th>{t('planResults.dubbeldagarLeftHeader')}</th>
</tr>
</thead>
<tbody>
{proposal.poolBalances.map((pool) => (
<tr key={`${pool.parentPersonalDataUuid}-${pool.childId}`}>
<td>{nameFor(pool.parentPersonalDataUuid)}</td>
<td>{pool.sjukpenningLeft}</td>
<td>{pool.lagstaLeft}</td>
<td>{pool.dubbeldagarLeft}</td>
</tr>
))}
</tbody>
</table>
<table className="plan-balances-table" data-testid="plan-fold-vacation-balances">
<thead>
<tr>
<th>{t('planResults.parentHeader')}</th>
<th>{t('planResults.yearHeader')}</th>
<th>{t('planResults.usedHeader')}</th>
<th>{t('planResults.totalAvailableHeader')}</th>
</tr>
</thead>
<tbody>
{proposal.vacationBalances.map((budget) => (
<tr key={`${budget.parentPersonalDataUuid}-${budget.year}`}>
<td>{nameFor(budget.parentPersonalDataUuid)}</td>
<td>{budget.year}</td>
<td>{budget.alreadyUsed}</td>
<td>{budget.totalDays + budget.savedDaysIn}</td>
</tr>
))}
</tbody>
</table>
</section>
</>
)}
</div>
</details>
)
}
@@ -1,63 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { ProgressChart } from './ProgressChart'
describe('ProgressChart', () => {
it('renders progress chart', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={10} targetWeeks={50} />
)
expect(getByTestId('progress-chart')).toBeInTheDocument()
})
it('calculates percentage correctly', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={25} targetWeeks={100} />
)
expect(getByTestId('percentage').textContent).toBe('25%')
})
it('displays weeks correctly', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={10} targetWeeks={50} />
)
expect(getByTestId('current-weeks').textContent).toBe('10 / 50 veckor')
})
it('sets fill width based on percentage', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={50} targetWeeks={100} />
)
const fill = getByTestId('progress-fill')
expect(fill).toHaveStyle({ width: '50%' })
})
it('caps percentage at 100%', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={150} targetWeeks={100} />
)
expect(getByTestId('percentage').textContent).toBe('100%')
expect(getByTestId('progress-fill')).toHaveStyle({ width: '100%' })
})
it('shows 0% when no progress', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={0} targetWeeks={50} />
)
expect(getByTestId('percentage').textContent).toBe('0%')
})
it('displays child age when provided', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={10} targetWeeks={50} childAge={2} />
)
expect(getByTestId('progress-chart').textContent).toContain('Ålder: 2')
})
it('handles zero target weeks', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={10} targetWeeks={0} />
)
expect(getByTestId('percentage').textContent).toBe('0%')
})
})
@@ -1,43 +0,0 @@
import { ReactNode } from 'react'
import { useTranslation } from '@/i18n'
export interface ProgressChartProps {
currentWeeks: number
targetWeeks: number
childAge?: number
}
export function ProgressChart({
currentWeeks,
targetWeeks,
childAge,
}: ProgressChartProps): ReactNode {
const { t } = useTranslation()
const percentage = targetWeeks > 0 ? Math.min((currentWeeks / targetWeeks) * 100, 100) : 0
return (
<div data-testid="progress-chart" className="progress-chart">
<div className="progress-chart__header">
<h3>{t('progressChart.title')}</h3>
{childAge !== undefined && (
<span className="progress-chart__age">{t('progressChart.age', { age: childAge })}</span>
)}
</div>
<div className="progress-chart__bar">
<div
className="progress-chart__fill"
style={{ width: `${percentage}%` }}
data-testid="progress-fill"
/>
</div>
<div className="progress-chart__stats">
<span data-testid="current-weeks">
{t('progressChart.weeksStat', { current: currentWeeks, target: targetWeeks })}
</span>
<span data-testid="percentage" className="progress-chart__percentage">
{Math.round(percentage)}%
</span>
</div>
</div>
)
}
@@ -0,0 +1,101 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { YearTimeline } from './YearTimeline'
import { makePlannedLeaveDay } from '../../../tests/fixtures'
describe('YearTimeline', () => {
it('renders one row per year across the horizon', () => {
render(
<YearTimeline
plannedDays={[]}
startDate="2025-06-01"
endDate="2029-06-01"
onBandClick={vi.fn()}
/>,
)
for (const year of [2025, 2026, 2027, 2028, 2029]) {
expect(screen.getByTestId(`year-timeline-row-${year}`)).toBeInTheDocument()
}
})
it('groups a contiguous run of planned days into a single band', () => {
render(
<YearTimeline
plannedDays={[
makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'vacation' }),
makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'vacation' }),
makePlannedLeaveDay({ id: '3', date: '2026-06-17', dayType: 'sjukpenning' }),
]}
startDate="2025-06-01"
endDate="2029-06-01"
onBandClick={vi.fn()}
/>,
)
// Three consecutive days -> exactly one band for 2026, dominant type
// vacation (2 of 3 days).
expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(1)
expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveAttribute(
'title',
'Semester, 2026-06-15 – 2026-06-17',
)
})
it('splits into separate bands across a gap bigger than a bridged weekend', () => {
render(
<YearTimeline
plannedDays={[
makePlannedLeaveDay({ id: '1', date: '2026-01-05', dayType: 'vacation' }),
makePlannedLeaveDay({ id: '2', date: '2026-06-15', dayType: 'vacation' }),
]}
startDate="2025-06-01"
endDate="2029-06-01"
onBandClick={vi.fn()}
/>,
)
expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(2)
})
it('never lets a band span a calendar-year boundary', () => {
render(
<YearTimeline
plannedDays={[
makePlannedLeaveDay({ id: '1', date: '2026-12-30', dayType: 'vacation' }),
makePlannedLeaveDay({ id: '2', date: '2027-01-02', dayType: 'vacation' }),
]}
startDate="2025-06-01"
endDate="2029-06-01"
onBandClick={vi.fn()}
/>,
)
expect(screen.getByTestId('year-timeline-band-2026-0')).toBeInTheDocument()
expect(screen.getByTestId('year-timeline-band-2027-0')).toBeInTheDocument()
})
it('excludes planned days outside the horizon', () => {
render(
<YearTimeline
plannedDays={[makePlannedLeaveDay({ id: '1', date: '2030-01-01', dayType: 'vacation' })]}
startDate="2025-06-01"
endDate="2029-06-01"
onBandClick={vi.fn()}
/>,
)
expect(screen.queryByTestId(/^year-timeline-band-/)).not.toBeInTheDocument()
})
it('calls onBandClick with the band start date when clicked', async () => {
const user = userEvent.setup()
const onBandClick = vi.fn()
render(
<YearTimeline
plannedDays={[makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'sjukpenning' })]}
startDate="2025-06-01"
endDate="2029-06-01"
onBandClick={onBandClick}
/>,
)
await user.click(screen.getByTestId('year-timeline-band-2026-0'))
expect(onBandClick).toHaveBeenCalledWith('2026-06-15')
})
})
@@ -0,0 +1,161 @@
import { ReactNode } from 'react'
import type { PlannedLeaveDay } from '@/types'
import { useTranslation } from '@/i18n'
import { getColorForType, getLabelForType } from '@/utils/leaveTypes'
export interface YearTimelineProps {
plannedDays: PlannedLeaveDay[]
/** ISO-8601 date — horizon start (the child's birth date). */
startDate: string
/** ISO-8601 date — horizon end (the child's 4th birthday). */
endDate: string
/** Called with a band's ISO-8601 start date when clicked. */
onBandClick: (date: string) => void
}
interface Band {
startDate: string
endDate: string
dominantType: PlannedLeaveDay['dayType']
}
// A run of planned days separates into a new band once the gap between
// consecutive dates exceeds this many days — bridges a weekend (2 days) or
// a weekend plus a single röd dag (3 days) without splitting one leave
// period into several bands.
const GAP_DAYS = 3
// Tie-break order when a band's days split evenly across types.
const TYPE_PRIORITY: PlannedLeaveDay['dayType'][] = ['sjukpenning', 'lagsta', 'vacation']
function toUtcDate(iso: string): Date {
return new Date(iso + 'T00:00:00Z')
}
function dayOfYearUtc(d: Date): number {
const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1)
return Math.floor((d.getTime() - yearStart) / 86_400_000) + 1
}
function daysInYear(year: number): number {
return Math.floor((Date.UTC(year + 1, 0, 1) - Date.UTC(year, 0, 1)) / 86_400_000)
}
function dominantType(days: PlannedLeaveDay[]): PlannedLeaveDay['dayType'] {
const counts: Record<string, number> = {}
for (const d of days) {
counts[d.dayType] = (counts[d.dayType] ?? 0) + 1
}
let best = TYPE_PRIORITY[0]!
let bestCount = -1
for (const type of TYPE_PRIORITY) {
const count = counts[type] ?? 0
if (count > bestCount) {
bestCount = count
best = type
}
}
return best
}
/** Groups sorted planned days into contiguous bands (one leave period each),
* never spanning a calendar-year boundary — each year gets its own row. */
function buildBands(plannedDays: PlannedLeaveDay[], startDate: string, endDate: string): Band[] {
const start = toUtcDate(startDate)
const end = toUtcDate(endDate)
const inHorizon = plannedDays
.filter((d) => {
const date = toUtcDate(d.date)
return date >= start && date <= end
})
.sort((a, b) => a.date.localeCompare(b.date))
const bands: Band[] = []
let current: PlannedLeaveDay[] = []
const closeCurrent = () => {
if (current.length === 0) return
bands.push({
startDate: current[0]!.date,
endDate: current[current.length - 1]!.date,
dominantType: dominantType(current),
})
current = []
}
for (const day of inHorizon) {
if (current.length === 0) {
current.push(day)
continue
}
const prev = toUtcDate(current[current.length - 1]!.date)
const next = toUtcDate(day.date)
const gapDays = Math.round((next.getTime() - prev.getTime()) / 86_400_000)
const crossesYear = prev.getUTCFullYear() !== next.getUTCFullYear()
if (gapDays > GAP_DAYS || crossesYear) {
closeCurrent()
}
current.push(day)
}
closeCurrent()
return bands
}
/**
* One row per calendar year from the child's birth to their 4th birthday,
* with a colored band per contiguous planned-leave period ("what do the
* next four years look like"). Clicking a band navigates to the calendar at
* that date — the reason this view exists alongside (not instead of) the
* per-parent day bars above it.
*/
export function YearTimeline({ plannedDays, startDate, endDate, onBandClick }: YearTimelineProps): ReactNode {
const { t } = useTranslation()
const start = toUtcDate(startDate)
const end = toUtcDate(endDate)
const startYear = start.getUTCFullYear()
const endYear = end.getUTCFullYear()
const years = startYear <= endYear
? Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i)
: []
const bands = buildBands(plannedDays, startDate, endDate)
return (
<div data-testid="year-timeline" className="year-timeline">
<h3 className="year-timeline__title">{t('yearTimeline.title')}</h3>
{years.map((year) => {
const yearLength = daysInYear(year)
const bandsForYear = bands.filter((b) => toUtcDate(b.startDate).getUTCFullYear() === year)
return (
<div key={year} className="year-timeline__row" data-testid={`year-timeline-row-${year}`}>
<span className="year-timeline__year-label">{year}</span>
<div className="year-timeline__track">
{bandsForYear.map((band, idx) => {
const left = ((dayOfYearUtc(toUtcDate(band.startDate)) - 1) / yearLength) * 100
const width = ((dayOfYearUtc(toUtcDate(band.endDate)) - dayOfYearUtc(toUtcDate(band.startDate)) + 1) / yearLength) * 100
const label = t('yearTimeline.bandLabel', {
dayType: getLabelForType(band.dominantType, t),
startDate: band.startDate,
endDate: band.endDate,
})
return (
<button
key={`${year}-${idx}`}
type="button"
className="year-timeline__band"
style={{ left: `${left}%`, width: `${width}%`, backgroundColor: getColorForType(band.dominantType) }}
onClick={() => onBandClick(band.startDate)}
data-testid={`year-timeline-band-${year}-${idx}`}
title={label}
aria-label={label}
/>
)
})}
</div>
</div>
)
})}
</div>
)
}