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>
)
}
+11 -4
View File
@@ -1,4 +1,7 @@
export const dashboard = {
'age4Bars.expiringStat': '{expiring} days expiring',
'age4Bars.savableStat': '{left} of {cap} days can be saved',
'age4Bars.title': 'Days left at age 4, per parent',
'dashboard.addChildButton': 'Add child',
'dashboard.addChildIntro': 'You have no children registered yet. Add your first child to get started.',
'dashboard.addChildTitle': 'Add child',
@@ -13,11 +16,16 @@ export const dashboard = {
'dashboard.expiringAtB4ParentFallback': 'Parent {number}',
'dashboard.fetchFromFk': 'Fetch from Försäkringskassan',
'dashboard.fillAllFields': 'Fill in all fields',
'dashboard.hero.hasPlanMessage': '{atRisk} days are at risk of disappearing when {childName} turns 4 ({date}).',
'dashboard.hero.noPlanMessage': 'You have {days} days to plan. Without a plan, {atRisk} of them will disappear when {childName} turns 4.',
'dashboard.loading': 'Loading...',
'dashboard.moreDetailsSummary': 'More details',
'dashboard.namePlaceholder': 'Name',
'dashboard.overviewTitle': 'Overview',
'dashboard.planFold.noWishesHint': 'Add wishes under Planning to see a proposal here.',
'dashboard.planFold.summary': 'Plan proposal for the years ahead',
'dashboard.planFold.unknownParent': 'Unknown parent',
'dashboard.plannedDaysTitle': 'Planned days',
'dashboard.remainingUntilB4Title': 'Remaining until age 4',
'dashboard.saveManualFetchError': 'Could not save the details',
'dashboard.usedDays': 'Used days',
'daysBreakdown.daysHeader': 'Days',
@@ -35,7 +43,6 @@ export const dashboard = {
'parentalLeaveBreakdown.totalHeader': 'Total',
'parentalLeaveBreakdown.transferableDays': 'Transferable days',
'parentalLeaveBreakdown.typeHeader': 'Type',
'progressChart.age': 'Age: {age}',
'progressChart.title': 'Parental leave — progress',
'progressChart.weeksStat': '{current} / {target} weeks',
'yearTimeline.bandLabel': '{dayType}, {startDate} – {endDate}',
'yearTimeline.title': 'The years ahead',
} as const
+3 -1
View File
@@ -52,12 +52,13 @@ export const plan = {
'planResults.previewBanner': 'This is a suggestion — nothing is saved automatically. You can adjust your wishes above and suggest a new plan as many times as you like.',
'planResults.projectedLeftHeader': 'Projected left',
'planResults.rejectionCollisionsNeedReplace': '{count} day(s) already planned differently; pass replace=true to overwrite',
'planResults.rejectionConflictingDuplicate': 'Conflicting duplicate entries for {date} (child {child_id}, parent {parent_personal_data_uuid}) in the same batch',
'planResults.rejectionConflictingDuplicate': 'Conflicting duplicate entries for {date} (child {child_id}, parent {parent_name}) in the same batch',
'planResults.rejectionDubbeldagBatchQuotaExceeded': 'This batch would result in {projected_total} double days, the max is {max_dubbeldagar}',
'planResults.rejectionDubbeldagInvalid': 'The double day for {date} could not be approved',
'planResults.rejectionDubbeldagQuotaReached': 'The double-days quota ({max_dubbeldagar} days) has already been reached',
'planResults.rejectionDubbeldagWindowExceeded': 'Double days are only allowed within the child\'s first {months_limit} months (deadline {deadline})',
'planResults.rejectionInsufficientBalance': 'This batch needs {needed} more "{tier}" day(s) but only {remaining} remain',
'planResults.rejectionInsufficientVacationBalance': '{parent_name} would need {needed} more vacation day(s) in {year}, but only {remaining} remain',
'planResults.rejectionInvalidDayType': 'Day {index} ({date}): invalid day type "{day_type}"',
'planResults.rejectionOutsideEligibleWindow': '{date}: outside the child\'s eligible window ({birth_date} – {hard_expiry})',
'planResults.rejectionsClause': '. Rejected: {rejections}',
@@ -88,6 +89,7 @@ export const plan = {
'planResults.surplusAdviceUseLessVacation': 'Save fewer vacation days',
'planResults.surplusAdviceWishMoreLeave': 'Wish for more leave',
'planResults.totalAvailableHeader': 'Total available',
'planResults.unknownParent': 'Unknown parent',
'planResults.usedHeader': 'Used',
'planResults.vacationAssumedBadge': 'Assumed',
'planResults.vacationAssumedPrompt': 'No value is recorded for this year — the statutory 25 days has been assumed. Confirm or edit it in Settings.',
+11 -4
View File
@@ -1,4 +1,7 @@
export const dashboard = {
'age4Bars.expiringStat': '{expiring} dagar förfaller',
'age4Bars.savableStat': '{left} av {cap} dagar kan sparas',
'age4Bars.title': 'Dagar kvar vid 4 års ålder, per förälder',
'dashboard.addChildButton': 'Lägg till barn',
'dashboard.addChildIntro': 'Du har inga barn registrerade ännu. Lägg till ditt första barn för att börja.',
'dashboard.addChildTitle': 'Lägg till barn',
@@ -13,11 +16,16 @@ export const dashboard = {
'dashboard.expiringAtB4ParentFallback': 'Förälder {number}',
'dashboard.fetchFromFk': 'Hämta från Försäkringskassan',
'dashboard.fillAllFields': 'Fyll i alla fält',
'dashboard.hero.hasPlanMessage': '{atRisk} dagar riskerar att försvinna när {childName} fyller 4 ({date}).',
'dashboard.hero.noPlanMessage': 'Du har {days} dagar att planera. Utan en plan försvinner {atRisk} av dem när {childName} fyller 4.',
'dashboard.loading': 'Laddar...',
'dashboard.moreDetailsSummary': 'Fler detaljer',
'dashboard.namePlaceholder': 'Namn',
'dashboard.overviewTitle': 'Översikt',
'dashboard.planFold.noWishesHint': 'Lägg till önskemål under Planering för att se ett förslag här.',
'dashboard.planFold.summary': 'Planförslag för de kommande åren',
'dashboard.planFold.unknownParent': 'Okänd förälder',
'dashboard.plannedDaysTitle': 'Planerade dagar',
'dashboard.remainingUntilB4Title': 'Återstående till 4 års ålder',
'dashboard.saveManualFetchError': 'Kunde inte spara uppgifterna',
'dashboard.usedDays': 'Använda dagar',
'daysBreakdown.daysHeader': 'Dagar',
@@ -35,7 +43,6 @@ export const dashboard = {
'parentalLeaveBreakdown.totalHeader': 'Totalt',
'parentalLeaveBreakdown.transferableDays': 'Överlåtbara dagar',
'parentalLeaveBreakdown.typeHeader': 'Typ',
'progressChart.age': 'Ålder: {age}',
'progressChart.title': 'Föräldraledighet – förlopp',
'progressChart.weeksStat': '{current} / {target} veckor',
'yearTimeline.bandLabel': '{dayType}, {startDate} – {endDate}',
'yearTimeline.title': 'De kommande åren',
} as const
+3 -1
View File
@@ -52,12 +52,13 @@ export const plan = {
'planResults.previewBanner': 'Detta är ett förslag — inget sparas automatiskt. Du kan justera dina önskemål ovan och föreslå en ny plan så många gånger du vill.',
'planResults.projectedLeftHeader': 'Beräknat kvar',
'planResults.rejectionCollisionsNeedReplace': '{count} dag(ar) är redan planerade på annat sätt; skicka replace=true för att skriva över',
'planResults.rejectionConflictingDuplicate': 'Motstridiga dubbletter för {date} (barn {child_id}, förälder {parent_personal_data_uuid}) i samma omgång',
'planResults.rejectionConflictingDuplicate': 'Motstridiga dubbletter för {date} (barn {child_id}, förälder {parent_name}) i samma omgång',
'planResults.rejectionDubbeldagBatchQuotaExceeded': 'Omgången skulle resultera i {projected_total} dubbeldagar, max är {max_dubbeldagar}',
'planResults.rejectionDubbeldagInvalid': 'Dubbeldag för {date} kunde inte godkännas',
'planResults.rejectionDubbeldagQuotaReached': 'Kvoten för dubbeldagar ({max_dubbeldagar} dagar) är redan nådd',
'planResults.rejectionDubbeldagWindowExceeded': 'Dubbeldagar är endast tillåtna inom barnets första {months_limit} månader (senast {deadline})',
'planResults.rejectionInsufficientBalance': 'Omgången behöver {needed} fler "{tier}"-dagar men endast {remaining} finns kvar',
'planResults.rejectionInsufficientVacationBalance': '{parent_name} skulle behöva {needed} fler semesterdag(ar) {year}, men endast {remaining} finns kvar',
'planResults.rejectionInvalidDayType': 'Dag {index} ({date}): ogiltig dagtyp "{day_type}"',
'planResults.rejectionOutsideEligibleWindow': '{date}: utanför barnets tillåtna period ({birth_date} – {hard_expiry})',
'planResults.rejectionsClause': '. Avslag: {rejections}',
@@ -88,6 +89,7 @@ export const plan = {
'planResults.surplusAdviceUseLessVacation': 'Spara färre semesterdagar',
'planResults.surplusAdviceWishMoreLeave': 'Önska mer ledighet',
'planResults.totalAvailableHeader': 'Totalt tillgängliga',
'planResults.unknownParent': 'Okänd förälder',
'planResults.usedHeader': 'Använda',
'planResults.vacationAssumedBadge': 'Antaget',
'planResults.vacationAssumedPrompt': 'Inget värde är registrerat för detta år — 25 dagar (lagstadgat) har antagits. Bekräfta eller ändra i Inställningar.',
+137 -16
View File
@@ -14,11 +14,22 @@ import {
makeScraperFetchResult,
makeWorkspaceMembersResult,
makePlannedLeaveDay,
makeOptimizationTipsResult,
makeLeaveWishesResult,
makePlanProposalResult,
makePlanApplyResult,
makeTip,
} from '../../tests/fixtures'
const mockNavigate = vi.fn()
vi.mock('@/hooks')
vi.mock('@/context')
vi.mock('@/services/api')
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
return { ...actual, useNavigate: () => mockNavigate }
})
// Layout pulls in useAuth/useNavigate, which need a full AuthProvider/Router tree
// to work. Dashboard.tsx only cares that its content renders inside Layout, so
@@ -103,6 +114,10 @@ describe('Dashboard', () => {
],
})
)
vi.mocked(hooks.useOptimizationTips).mockReturnValue(makeOptimizationTipsResult())
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult())
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult())
vi.mocked(hooks.usePlanApply).mockReturnValue(makePlanApplyResult())
})
it('renders loading spinner initially', () => {
@@ -137,8 +152,120 @@ describe('Dashboard', () => {
expect(screen.getByLabelText(/Barnets namn/)).toBeInTheDocument()
})
it('shows overview metrics when children exist', () => {
describe('Hero', () => {
it('uses the forward-looking framing when there are no planned days yet', () => {
vi.mocked(hooks.usePlannedDays).mockReturnValue(makePlannedDaysResult({ data: [] }))
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent(
'Du har 270 dagar att planera. Utan en plan försvinner 100 av dem när Child 1 fyller 4.',
)
})
it('uses the warning framing once days are planned, with the 4th-birthday date', () => {
// selectedChild birth_date is 2022-01-01 and there is no parent
// projection in this test's default mock, so the 4th birthday is
// derived client-side as plain date arithmetic (not a business rule).
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent(
'100 dagar riskerar att försvinna när Child 1 fyller 4 (1 januari 2026).',
)
})
it('uses the backend-provided 4th birthday when a parent projection is available', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue(
makeAnalyticsResult({
data: {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2029-06-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
],
combinedDaysUsed: 30,
combinedDaysLeft: 210,
combinedExpiringAtB4: 60,
},
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent('1 juni 2029')
})
})
describe('Age4Bars', () => {
it('renders each parent bar using the backend-resolved savableCapAtB4, never re-derived', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue(
makeAnalyticsResult({
data: {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 40, savableAfterB4: 40, expiringAtB4: 0, savableCapAtB4: 48 },
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 1, plannedDaysBeforeB4: 0, projectedLeftAtB4: 239, savableAfterB4: 48, expiringAtB4: 191, savableCapAtB4: 48 },
],
combinedDaysUsed: 31,
combinedDaysLeft: 279,
combinedExpiringAtB4: 191,
},
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('age4-bar-savable-pd-1')).toHaveTextContent('40 av 48 dagar kan sparas')
expect(screen.getByTestId('age4-bar-pd-1')).toHaveTextContent('Alex')
expect(screen.getByTestId('age4-bar-expiring-pd-2')).toHaveTextContent('191 dagar förfaller')
expect(screen.getByTestId('age4-bar-pd-2')).toHaveTextContent('Sam')
})
it('is not rendered when there are no parent projections yet', () => {
renderWithRouter(<Dashboard />)
expect(screen.queryByTestId('age4-bars')).not.toBeInTheDocument()
})
})
describe('YearTimeline', () => {
it('renders one row per year from the child\'s birth to the 4th birthday', () => {
renderWithRouter(<Dashboard />)
for (const year of [2022, 2023, 2024, 2025, 2026]) {
expect(screen.getByTestId(`year-timeline-row-${year}`)).toBeInTheDocument()
}
})
it('navigates to the calendar at the clicked band\'s date', async () => {
const user = userEvent.setup()
vi.mocked(hooks.usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: '1', date: '2024-06-15', dayType: 'vacation' })],
})
)
renderWithRouter(<Dashboard />)
await user.click(screen.getByTestId('year-timeline-band-2024-0'))
expect(mockNavigate).toHaveBeenCalledWith('/calendar?date=2024-06-15')
})
})
describe('Tips', () => {
it('is hidden entirely when there are no tips', () => {
renderWithRouter(<Dashboard />)
expect(screen.queryByTestId('dashboard-tips')).not.toBeInTheDocument()
})
it('renders tip cards when tips exist', () => {
vi.mocked(hooks.useOptimizationTips).mockReturnValue(
makeOptimizationTipsResult({ data: [makeTip({ id: 'tip-1', messageKey: 'tips.use_vacation_before_expiry' })] })
)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('dashboard-tips')).toBeInTheDocument()
expect(screen.getAllByTestId('tip-card')).toHaveLength(1)
})
})
it('renders the folded plan-proposal section', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('plan-fold')).toBeInTheDocument()
})
it('shows overview metrics inside the collapsed "more details" section', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('dashboard-more-details')).toBeInTheDocument()
expect(screen.getByTestId('overview-metrics')).toBeInTheDocument()
expect(screen.getByText(/Använda dagar/)).toBeInTheDocument()
})
@@ -185,16 +312,10 @@ describe('Dashboard', () => {
expect(screen.getByTestId('days-breakdown')).toBeInTheDocument()
})
it('shows progress chart with real data', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('progress-chart')).toBeInTheDocument()
})
it('shows Swedish section headings', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('metrics-title')).toHaveTextContent(/Översikt/)
expect(screen.getAllByText(/Planerade dagar/)[0]).toBeInTheDocument()
expect(screen.getByText(/Återstående till 4 års ålder/)).toBeInTheDocument()
})
// The combined expiring-at-4 figure reflects the currently saved plan, while
@@ -215,8 +336,8 @@ describe('Dashboard', () => {
workspaceId: 'ws-1',
childId: 'c-1',
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 },
{ 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, savableCapAtB4: 96 },
{ 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, savableCapAtB4: 96 },
],
combinedDaysUsed: 50,
combinedDaysLeft: 190,
@@ -515,7 +636,7 @@ describe('Dashboard', () => {
workspaceId: 'ws-1',
childId: 'c-1',
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 },
{ 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, savableCapAtB4: 96 },
],
combinedDaysUsed: 30,
combinedDaysLeft: 210,
@@ -536,8 +657,8 @@ describe('Dashboard', () => {
workspaceId: 'ws-1',
childId: 'c-1',
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 },
{ 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, savableCapAtB4: 96 },
{ 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, savableCapAtB4: 96 },
],
combinedDaysUsed: 50,
combinedDaysLeft: 190,
@@ -570,8 +691,8 @@ describe('Dashboard', () => {
workspaceId: 'ws-1',
childId: 'c-1',
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 },
{ 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, savableCapAtB4: 96 },
{ 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, savableCapAtB4: 96 },
],
combinedDaysUsed: 50,
combinedDaysLeft: 190,
@@ -592,8 +713,8 @@ describe('Dashboard', () => {
workspaceId: 'ws-1',
childId: 'c-1',
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 },
{ 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, savableCapAtB4: 96 },
{ 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, savableCapAtB4: 96 },
],
combinedDaysUsed: 50,
combinedDaysLeft: 190,
+92 -26
View File
@@ -1,6 +1,14 @@
import { ReactNode, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useWorkspace } from '@/context'
import { useAnalytics, usePlannedDays, useScraperFetch, useParentalLeave, useWorkspaceMembers } from '@/hooks'
import {
useAnalytics,
usePlannedDays,
useScraperFetch,
useParentalLeave,
useWorkspaceMembers,
useOptimizationTips,
} from '@/hooks'
import type { PlannedLeaveDay } from '@/types'
import * as api from '@/services/api'
import { getLabelForType } from '@/utils/leaveTypes'
@@ -10,14 +18,28 @@ import { Layout } from '@/components/layout/Layout'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { OverviewMetrics } from '@/components/dashboard/OverviewMetrics'
import { DaysBreakdown } from '@/components/dashboard/DaysBreakdown'
import { ProgressChart } from '@/components/dashboard/ProgressChart'
import { Hero } from '@/components/dashboard/Hero'
import { Age4Bars } from '@/components/dashboard/Age4Bars'
import { YearTimeline } from '@/components/dashboard/YearTimeline'
import { PlanFold } from '@/components/dashboard/PlanFold'
import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
import { TipCard } from '@/components/inputs/TipCard'
import { QRCodeModal } from '@/components/modals/QRCodeModal'
import { ManualFetchModal } from '@/components/modals/ManualFetchModal'
/** 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)
}
export function Dashboard(): ReactNode {
const { t } = useTranslation()
const navigate = useNavigate()
const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext } = useWorkspace()
const { data: analytics, isLoading: analyticsLoading, error: analyticsError, refresh: refreshAnalytics } = useAnalytics(
workspace?.id ?? null,
@@ -26,6 +48,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 { data: tips, dismiss: dismissTip } = useOptimizationTips(workspace?.id ?? null)
const scraper = useScraperFetch()
const [childName, setChildName] = useState('')
@@ -120,15 +143,16 @@ export function Dashboard(): ReactNode {
await Promise.all([refreshAnalytics(), refreshParentalLeave()])
}
const handleBandClick = (date: string) => {
navigate(`/calendar?date=${date}`)
}
const breakdown = [
{ type: getLabelForType('vacation', t), count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'vacation').length ?? 0 },
{ type: getLabelForType('sjukpenning', t), count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'sjukpenning').length ?? 0 },
{ type: getLabelForType('lagsta', t), count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'lagsta').length ?? 0 },
]
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.
@@ -206,6 +230,18 @@ export function Dashboard(): ReactNode {
)
}
const selectedChild = children.find((c) => c.id === selectedChildId)
const hasPlan = (plannedDays?.length ?? 0) > 0
const fourthBirthday =
parentProjections[0]?.fourthBirthday ?? (selectedChild ? computeFourthBirthday(selectedChild.birth_date) : '')
const age4Bars = parentProjections.map((projection, index) => ({
personalDataUuid: projection.personalDataUuid,
name: parentDisplayName(projection.personalDataUuid, index),
projectedLeftAtB4: projection.projectedLeftAtB4,
savableCapAtB4: projection.savableCapAtB4,
expiringAtB4: projection.expiringAtB4,
}))
return (
<Layout>
<div data-testid="dashboard" className="dashboard">
@@ -215,6 +251,18 @@ export function Dashboard(): ReactNode {
</div>
)}
{analytics && selectedChild && (
<Hero
hasPlan={hasPlan}
daysLeft={analytics.combinedDaysLeft}
daysAtRisk={analytics.combinedExpiringAtB4}
childName={selectedChild.name}
fourthBirthday={fourthBirthday}
/>
)}
{age4Bars.length > 0 && <Age4Bars bars={age4Bars} />}
<section className="dashboard__section">
<div style={{ display: 'flex', gap: '10px' }}>
<button
@@ -234,28 +282,46 @@ export function Dashboard(): ReactNode {
</div>
</section>
<section className="dashboard__section">
{analytics && (
<OverviewMetrics
title={t('dashboard.overviewTitle')}
metrics={overviewMetrics}
/>
)}
<ParentalLeaveBreakdown data={parentalLeave} />
</section>
<section className="dashboard__section">
<h2>{t('dashboard.plannedDaysTitle')}</h2>
<DaysBreakdown breakdown={breakdown} />
</section>
<section className="dashboard__section">
<h2>{t('dashboard.remainingUntilB4Title')}</h2>
<ProgressChart
currentWeeks={currentWeeks}
targetWeeks={targetWeeks}
{selectedChild && (
<YearTimeline
plannedDays={plannedDays ?? []}
startDate={selectedChild.birth_date}
endDate={fourthBirthday}
onBandClick={handleBandClick}
/>
</section>
)}
{tips && tips.length > 0 && (
<section className="dashboard__section" data-testid="dashboard-tips">
<h2>{t('tips.title')}</h2>
<div className="dashboard__tips-list">
{tips.map((tip) => (
<TipCard key={tip.id} tip={tip} onDismiss={() => dismissTip(tip.id)} />
))}
</div>
</section>
)}
{workspace && <PlanFold workspaceId={workspace.id} members={members} />}
<details className="dashboard__more-details" data-testid="dashboard-more-details">
<summary>{t('dashboard.moreDetailsSummary')}</summary>
<section className="dashboard__section">
{analytics && (
<OverviewMetrics
title={t('dashboard.overviewTitle')}
metrics={overviewMetrics}
/>
)}
<ParentalLeaveBreakdown data={parentalLeave} />
</section>
<section className="dashboard__section">
<h2>{t('dashboard.plannedDaysTitle')}</h2>
<DaysBreakdown breakdown={breakdown} />
</section>
</details>
<QRCodeModal
isOpen={showModal}
+142
View File
@@ -32,6 +32,7 @@ describe('API response mappers (snake_case → camelCase)', () => {
projected_left_at_b4: 230,
savable_after_b4: 96,
expiring_at_b4: 134,
savable_cap_at_b4: 96,
},
],
combined_days_used: 100,
@@ -59,6 +60,7 @@ describe('API response mappers (snake_case → camelCase)', () => {
expect(result.parentProjections[0]!.projectedLeftAtB4).toBe(230)
expect(result.parentProjections[0]!.savableAfterB4).toBe(96)
expect(result.parentProjections[0]!.expiringAtB4).toBe(134)
expect(result.parentProjections[0]!.savableCapAtB4).toBe(96)
})
})
@@ -351,6 +353,60 @@ describe('API response mappers (snake_case → camelCase)', () => {
expect(body.name_message_key).toBe('wishEditor.seasonJul')
expect(result.nameMessageKey).toBe('wishEditor.seasonJul')
})
it('sends target_days for a calendar-drawn period and maps it back', async () => {
const rawResponse = {
id: 'lw1',
workspace_id: 'ws1',
child_id: null,
name: 'Ny period',
window_start_month: 6,
window_start_day: 1,
window_end_month: 6,
window_end_day: 5,
target_weeks: 1,
target_days: 5,
recurrence: 'once',
first_year: 2026,
last_year: null,
priority: 1,
parent_mode: 'specific',
parent_personal_data_uuid: 'pd1',
window_mode: 'fixed',
created_at: '2026-06-14T17:09:47',
updated_at: '2026-06-14T17:09:47',
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => rawResponse,
})
const result = await api.createLeaveWish({
workspaceId: 'ws1',
name: 'Ny period',
windowStartMonth: 6,
windowStartDay: 1,
windowEndMonth: 6,
windowEndDay: 5,
targetWeeks: 1,
targetDays: 5,
recurrence: 'once',
firstYear: 2026,
parentMode: 'specific',
parentPersonalDataUuid: 'pd1',
windowMode: 'fixed',
})
const calls = vi.mocked(fetchMock).mock.calls
if (calls.length === 0) throw new Error('No fetch calls made')
const [, options] = calls[0]!
const body = JSON.parse(options.body)
expect(body.target_days).toBe(5)
expect(result.targetDays).toBe(5)
})
})
describe('getWorkspace', () => {
@@ -1278,6 +1334,92 @@ describe('endpoint functions (representative coverage)', () => {
])
})
it('syncPlan posts the workspace id and maps the nested proposal/applyResult/changes', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
proposal: {
days: [],
outcomes: [],
warnings: [],
pool_balances: [],
vacation_balances: [],
surplus_advice: [],
age4_projections: [],
},
apply_result: {
accepted: true,
created_count: 2,
skipped_identical_count: 0,
skipped_free_count: 0,
replaced_count: 1,
collisions: [],
rejections: [],
created_days: [],
},
changes: [
{
kind: 'retyped',
date: '2028-06-20',
parent_personal_data_uuid: 'pd1',
child_id: 'child1',
old_day_type: 'vacation',
new_day_type: 'sjukpenning',
old_is_dubbeldag: false,
new_is_dubbeldag: false,
old_wish_id: 'lw1',
new_wish_id: 'lw1',
},
{
kind: 'added',
date: '2028-06-21',
parent_personal_data_uuid: 'pd1',
child_id: 'child1',
old_day_type: null,
new_day_type: 'vacation',
old_is_dubbeldag: null,
new_is_dubbeldag: false,
old_wish_id: null,
new_wish_id: 'lw1',
},
],
}),
})
const result = await api.syncPlan('ws1')
expect(lastRequest()).toEqual({ url: '/api/plan/sync', method: 'POST' })
expect(lastRequestBody()).toEqual({ workspace_id: 'ws1' })
expect(result.applyResult.createdCount).toBe(2)
expect(result.applyResult.replacedCount).toBe(1)
expect(result.changes).toHaveLength(2)
expect(result.changes[0]).toEqual({
kind: 'retyped',
date: '2028-06-20',
parentPersonalDataUuid: 'pd1',
childId: 'child1',
oldDayType: 'vacation',
newDayType: 'sjukpenning',
oldIsDubbeldag: false,
newIsDubbeldag: false,
oldWishId: 'lw1',
newWishId: 'lw1',
})
expect(result.changes[1]).toEqual({
kind: 'added',
date: '2028-06-21',
parentPersonalDataUuid: 'pd1',
childId: 'child1',
oldDayType: null,
newDayType: 'vacation',
oldIsDubbeldag: null,
newIsDubbeldag: false,
oldWishId: null,
newWishId: 'lw1',
})
})
it('listWorkspaceMembers maps snake_case members to camelCase', async () => {
fetchMock.mockResolvedValue({
ok: true,
+67
View File
@@ -21,11 +21,13 @@ import type {
WorkspaceCreateRequest,
WorkspaceUpdateRequest,
WorkspaceOverview,
PlanSyncResponse,
} from '@/types'
import type {
Workspace,
LeaveWish,
PlanProposal,
PlanChange,
CostBreakdown,
Tip,
PlannedLeaveDay,
@@ -114,6 +116,7 @@ export interface RawAge4Projection {
projected_left_at_b4: number
savable_after_b4: number
expiring_at_b4: number
savable_cap_at_b4: number
}
function toAge4Projection(raw: RawAge4Projection): Age4Projection {
@@ -128,6 +131,7 @@ function toAge4Projection(raw: RawAge4Projection): Age4Projection {
projectedLeftAtB4: raw.projected_left_at_b4,
savableAfterB4: raw.savable_after_b4,
expiringAtB4: raw.expiring_at_b4,
savableCapAtB4: raw.savable_cap_at_b4,
}
}
@@ -232,6 +236,7 @@ interface RawLeaveWish {
window_end_month: number
window_end_day: number
target_weeks: number
target_days?: number | null
recurrence: 'yearly' | 'once'
first_year: number
last_year: number | null
@@ -255,6 +260,7 @@ function toLeaveWish(raw: RawLeaveWish): LeaveWish {
windowEndMonth: raw.window_end_month,
windowEndDay: raw.window_end_day,
targetWeeks: raw.target_weeks,
targetDays: raw.target_days ?? null,
recurrence: raw.recurrence,
firstYear: raw.first_year,
lastYear: raw.last_year,
@@ -782,6 +788,7 @@ export async function createLeaveWish(payload: LeaveWishCreateRequest): Promise<
window_end_month: payload.windowEndMonth,
window_end_day: payload.windowEndDay,
target_weeks: payload.targetWeeks,
target_days: payload.targetDays ?? null,
recurrence: payload.recurrence,
first_year: payload.firstYear,
last_year: payload.lastYear ?? null,
@@ -807,6 +814,7 @@ export async function updateLeaveWish(
if (payload.windowEndMonth !== undefined) body.window_end_month = payload.windowEndMonth
if (payload.windowEndDay !== undefined) body.window_end_day = payload.windowEndDay
if (payload.targetWeeks !== undefined) body.target_weeks = payload.targetWeeks
if (payload.targetDays !== undefined) body.target_days = payload.targetDays
if (payload.recurrence !== undefined) body.recurrence = payload.recurrence
if (payload.firstYear !== undefined) body.first_year = payload.firstYear
if (payload.lastYear !== undefined) body.last_year = payload.lastYear
@@ -902,6 +910,59 @@ export async function applyPlan(
return toPlanApplyResponse(raw)
}
interface RawPlanChange {
kind: string
date: string
parent_personal_data_uuid: string
child_id: string
old_day_type: string | null
new_day_type: string | null
old_is_dubbeldag: boolean | null
new_is_dubbeldag: boolean | null
old_wish_id: string | null
new_wish_id: string | null
}
function toPlanChange(raw: RawPlanChange): PlanChange {
return {
kind: raw.kind as PlanChange['kind'],
date: raw.date,
parentPersonalDataUuid: raw.parent_personal_data_uuid,
childId: raw.child_id,
oldDayType: raw.old_day_type as PlanChange['oldDayType'],
newDayType: raw.new_day_type as PlanChange['newDayType'],
oldIsDubbeldag: raw.old_is_dubbeldag,
newIsDubbeldag: raw.new_is_dubbeldag,
oldWishId: raw.old_wish_id,
newWishId: raw.new_wish_id,
}
}
interface RawPlanSyncResponse {
proposal: RawPlanProposal
apply_result: RawPlanApplyResponse
changes: RawPlanChange[]
}
/** POST /api/plan/sync — the direct-manipulation calendar's one call:
* recompute the plan from the workspace's `LeaveWish`es and persist it in
* one step (no separate preview), never retyping/overwriting a pinned day,
* and reporting exactly what changed via `changes` so the UI can render a
* toast ("Sommar 2028 flyttades 2 dagar") instead of silently rewriting the
* calendar underneath the user.
*/
export async function syncPlan(workspaceId: string): Promise<PlanSyncResponse> {
const raw = (await apiFetch('/plan/sync', {
method: 'POST',
body: JSON.stringify({ workspace_id: workspaceId }),
})) as RawPlanSyncResponse
return {
proposal: toPlanProposal(raw.proposal),
applyResult: toPlanApplyResponse(raw.apply_result),
changes: raw.changes.map(toPlanChange),
}
}
// ---------------------------------------------------------------------------
// Workspace members (used to resolve/select which parent a wish belongs to)
// ---------------------------------------------------------------------------
@@ -975,6 +1036,10 @@ export interface CreatePlannedDayPayload {
date: string
day_type: 'sjukpenning' | 'lagsta' | 'vacation'
is_dubbeldag?: boolean
// True for a day hand-edited through the calendar's day popover — the
// planner (POST /api/plan/sync) must not silently retype it. See
// `PlannedLeaveDay.isPinned`.
is_pinned?: boolean
}
export async function createPlannedDay(payload: CreatePlannedDayPayload): Promise<PlannedLeaveDay> {
@@ -988,6 +1053,8 @@ export async function createPlannedDay(payload: CreatePlannedDayPayload): Promis
export interface UpdatePlannedDayPayload {
day_type?: 'sjukpenning' | 'lagsta' | 'vacation'
is_dubbeldag?: boolean
// See `CreatePlannedDayPayload.is_pinned`.
is_pinned?: boolean
}
export async function updatePlannedDay(id: string, payload: UpdatePlannedDayPayload): Promise<PlannedLeaveDay> {
+106 -20
View File
@@ -157,55 +157,141 @@
color: var(--c-text-muted);
}
/* Progress Chart */
.progress-chart {
/* Hero */
.dashboard-hero {
padding: var(--sp-5);
border-radius: var(--radius);
border-left: 4px solid var(--c-accent);
background-color: var(--c-accent-weak);
}
.dashboard-hero--warning {
border-left-color: var(--c-warning);
background-color: var(--c-danger-weak);
}
.dashboard-hero__message {
margin: 0;
font-size: var(--fs-xl);
font-weight: 600;
color: var(--c-text);
}
/* Age4 Bars (per-parent days left at the 4th birthday) */
.age4-bars {
display: flex;
flex-direction: column;
gap: var(--sp-4);
}
.progress-chart__header {
.age4-bars__row {
display: flex;
justify-content: space-between;
align-items: baseline;
margin: 0 0 var(--sp-3) 0;
flex-direction: column;
gap: var(--sp-2);
}
.progress-chart__header h3 {
margin: 0;
.age4-bars__label {
font-weight: 600;
}
.progress-chart__age {
font-size: var(--fs-sm);
color: var(--c-text-muted);
}
.progress-chart__bar {
.age4-bars__track {
width: 100%;
height: 24px;
height: 20px;
background-color: var(--c-bg);
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--c-border);
}
.progress-chart__fill {
.age4-bars__fill {
height: 100%;
background-color: var(--c-accent);
transition: width 0.3s ease;
}
.progress-chart__stats {
.age4-bars__stats {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--fs-sm);
color: var(--c-text-muted);
}
.progress-chart__percentage {
.age4-bars__expiring {
color: var(--c-danger);
font-weight: 600;
color: var(--c-accent);
}
/* Year Timeline */
.year-timeline {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.year-timeline__row {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.year-timeline__year-label {
width: 3rem;
flex-shrink: 0;
font-weight: 600;
color: var(--c-text-muted);
}
.year-timeline__track {
position: relative;
flex: 1;
height: 28px;
background-color: var(--c-bg);
border-radius: var(--radius);
border: 1px solid var(--c-border);
overflow: hidden;
}
.year-timeline__band {
position: absolute;
top: 0;
height: 100%;
min-width: 2px;
border: none;
cursor: pointer;
padding: 0;
}
.year-timeline__band:hover,
.year-timeline__band:focus-visible {
outline: 2px solid var(--c-text);
outline-offset: -2px;
}
/* Plan Fold (collapsed plan-proposal summary) */
.plan-fold,
.dashboard__more-details {
border: 1px solid var(--c-border);
border-radius: var(--radius);
padding: var(--sp-4);
background-color: var(--c-surface);
}
.plan-fold__summary,
.dashboard__more-details summary {
cursor: pointer;
font-weight: 600;
}
.plan-fold__section {
margin-top: var(--sp-4);
}
.plan-fold__error {
color: var(--c-danger);
}
.plan-fold__success {
color: var(--c-success);
}
/* Button Styles */
+12
View File
@@ -79,6 +79,10 @@ export interface LeaveWishCreateRequest {
windowEndMonth: number
windowEndDay: number
targetWeeks: number
// A calendar-drawn period (dragging a range on the calendar) is not a
// whole number of weeks — omitted/`undefined` falls back to
// `targetWeeks * 7` server-side. See `LeaveWish.targetDays`.
targetDays?: number | null
recurrence: 'yearly' | 'once'
firstYear: number
lastYear?: number | null
@@ -152,6 +156,14 @@ export interface PlanApplyResponse {
createdDays: object[]
}
// POST /api/plan/sync — the direct-manipulation calendar's one call:
// recompute + persist in one step, reporting exactly what changed.
export interface PlanSyncResponse {
proposal: import('./entities').PlanProposal
applyResult: PlanApplyResponse
changes: import('./entities').PlanChange[]
}
// Day overrides (calendar exceptions)
export interface DayOverrideCreateRequest {
workspaceId: string
+41
View File
@@ -43,6 +43,15 @@ export interface LeaveWish {
windowEndMonth: number
windowEndDay: number
targetWeeks: number
// A calendar-drawn period (dragging a band on the calendar UI) is not a
// whole number of weeks. `null`/absent (every wish specified in whole
// weeks, e.g. via a preset) falls back to `targetWeeks * 7` on the
// backend — see `LeaveWish.target_span_days()`
// (backend/app/entities/leave_wish.py). Optional (not just nullable) so
// an existing hand-rolled `LeaveWish` object literal elsewhere in the
// codebase (predating this field) still type-checks without every call
// site needing an update.
targetDays?: number | null
recurrence: 'yearly' | 'once'
firstYear: number
lastYear: number | null
@@ -57,6 +66,33 @@ export interface LeaveWish {
updatedAt: string | null
}
/** One (child, parent, date) slot's delta from a `POST /api/plan/sync` call
* — see `PlanChange` (backend/app/services/planning_service.py) for the
* `kind` vocabulary:
*
* - 'added' — no row existed for (child, parent, date); one does now.
* - 'removed' — a row existed and no longer does (old_* populated, new_*
* all `null`).
* - 'retyped' — the row survived but `dayType`/`isDubbeldag` changed.
* - 'reattributed' — same day and type, but `wishId` changed (the day now
* belongs to a different period).
*
* Structured data only, house style — the frontend composes the toast copy
* ("Sommar 2028 flyttades 2 dagar") itself rather than rendering backend prose.
*/
export interface PlanChange {
kind: 'added' | 'removed' | 'retyped' | 'reattributed'
date: string // ISO-8601 date
parentPersonalDataUuid: string
childId: string
oldDayType: 'sjukpenning' | 'lagsta' | 'vacation' | null
newDayType: 'sjukpenning' | 'lagsta' | 'vacation' | null
oldIsDubbeldag: boolean | null
newIsDubbeldag: boolean | null
oldWishId: string | null
newWishId: string | null
}
export interface TierData {
sjukpenningTotal: number
sjukpenningUsed: number
@@ -159,6 +195,11 @@ export interface Age4Projection {
projectedLeftAtB4: number
savableAfterB4: number
expiringAtB4: number
// The cap actually applied to `savableAfterB4`/`expiringAtB4` — 96 (per
// child) normally, or 48 (per parent) for a `couple` workspace with two
// parents. See `resolve_age4_savable_cap` (backend). Render the per-parent
// bar against this field directly; never re-derive the 48-vs-96 rule here.
savableCapAtB4: number
}
export interface WorkspaceOverview {
@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest'
import { renderRejection } from './rejectionMessages'
import { translate } from '@/i18n'
import type { PersonalData, Rejection } from '@/types'
const t = (key: Parameters<typeof translate>[1], params?: Parameters<typeof translate>[2]) =>
translate('sv', key, params)
const member = (overrides: Partial<PersonalData> = {}): PersonalData => ({
uuid: 'pd-a',
workspaceId: 'ws-1',
name: 'Alex',
salaryType: 'monthly',
employmentStatus: 'employed',
createdAt: '2026-01-01',
...overrides,
})
const rejection = (overrides: Partial<Rejection> = {}): Rejection => ({
messageKey: 'planApply.rejection.insufficientBalance',
data: {},
...overrides,
})
describe('renderRejection', () => {
it('renders every known message_key to a real (non-raw-key) string', () => {
const knownKeys = [
'planApply.rejection.invalidDayType',
'planApply.rejection.conflictingDuplicate',
'planApply.rejection.collisionsNeedReplace',
'planApply.rejection.dubbeldagWindowExceeded',
'planApply.rejection.dubbeldagQuotaReached',
'planApply.rejection.dubbeldagInvalid',
'planApply.rejection.dubbeldagBatchQuotaExceeded',
'planApply.rejection.outsideEligibleWindow',
'planApply.rejection.insufficientBalance',
'planApply.rejection.insufficientVacationBalance',
]
for (const messageKey of knownKeys) {
const rendered = renderRejection(rejection({ messageKey }), t, [])
expect(rendered).not.toBe(messageKey)
}
})
it('falls back to the raw message_key when the backend sends one this build does not recognise', () => {
expect(renderRejection(rejection({ messageKey: 'planApply.rejection.somethingNew' }), t, [])).toBe(
'planApply.rejection.somethingNew',
)
})
it('resolves insufficientVacationBalance with a real parent name, never a raw uuid', () => {
const rendered = renderRejection(
rejection({
messageKey: 'planApply.rejection.insufficientVacationBalance',
data: { parent_personal_data_uuid: 'pd-a', year: 2026, needed: 3, remaining: 1 },
}),
t,
[member({ uuid: 'pd-a', name: 'Alex' })],
)
expect(rendered).toContain('Alex')
expect(rendered).not.toContain('pd-a')
expect(rendered).toContain('3')
expect(rendered).toContain('2026')
expect(rendered).toContain('1')
})
it('falls back to a generic label, never the raw uuid, when no member matches', () => {
const rendered = renderRejection(
rejection({
messageKey: 'planApply.rejection.insufficientVacationBalance',
data: { parent_personal_data_uuid: 'pd-unknown', year: 2026, needed: 3, remaining: 1 },
}),
t,
[member({ uuid: 'pd-a', name: 'Alex' })],
)
expect(rendered).not.toContain('pd-unknown')
expect(rendered).toContain('Okänd förälder')
})
it('resolves the parent uuid in conflictingDuplicate to a name, not the raw uuid', () => {
const rendered = renderRejection(
rejection({
messageKey: 'planApply.rejection.conflictingDuplicate',
data: { date: '2026-06-15', child_id: 'child-1', parent_personal_data_uuid: 'pd-a' },
}),
t,
[member({ uuid: 'pd-a', name: 'Alex' })],
)
expect(rendered).toContain('Alex')
expect(rendered).not.toContain('pd-a')
})
it('leaves data with no parent uuid untouched', () => {
const rendered = renderRejection(
rejection({
messageKey: 'planApply.rejection.insufficientBalance',
data: { needed: 2, tier: 'sjukpenning', remaining: 1 },
}),
t,
[],
)
expect(rendered).toContain('2')
expect(rendered).toContain('sjukpenning')
})
})
+61
View File
@@ -0,0 +1,61 @@
/**
* Single source of truth for rendering a `Rejection` (§3.5 — a backend
* `message_key` + opaque `data` bag, same idiom as `Tip`/`PlanWarning`) in
* the caller's active locale.
*
* Every rejection `message_key` `POST /api/plan/apply` and
* `POST /api/plan/sync` (`app/services/planning_service.py`) can return,
* including `insufficientVacationBalance`
* (`data: {parent_personal_data_uuid, year, needed, remaining}`).
*
* Previously duplicated between `PlanFold.tsx` and
* `frontend/src/components/planning/PlanResults.tsx` (the latter scheduled
* for deletion, and missing `insufficientVacationBalance` entirely). Moved
* here so there is exactly one copy, and so the `/api/plan/sync` toast
* (`Calendar.tsx`) can render rejections too:
*
* import { renderRejection } from '@/utils/rejectionMessages'
* renderRejection(rejection, t, members)
*
* where `t` is `useTranslation()`'s translate function and `members` is the
* workspace's `PersonalData[]` (e.g. from `useWorkspaceMembers`).
*/
import type { PersonalData, Rejection } from '@/types'
import { toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n'
import { nameResolver } from '@/utils/parentNames'
export type Translate = (key: TranslationKey, params?: TranslationParams) => string
const REJECTION_KEYS: Record<string, TranslationKey> = {
'planApply.rejection.invalidDayType': 'planResults.rejectionInvalidDayType',
'planApply.rejection.conflictingDuplicate': 'planResults.rejectionConflictingDuplicate',
'planApply.rejection.collisionsNeedReplace': 'planResults.rejectionCollisionsNeedReplace',
'planApply.rejection.dubbeldagWindowExceeded': 'planResults.rejectionDubbeldagWindowExceeded',
'planApply.rejection.dubbeldagQuotaReached': 'planResults.rejectionDubbeldagQuotaReached',
'planApply.rejection.dubbeldagInvalid': 'planResults.rejectionDubbeldagInvalid',
'planApply.rejection.dubbeldagBatchQuotaExceeded': 'planResults.rejectionDubbeldagBatchQuotaExceeded',
'planApply.rejection.outsideEligibleWindow': 'planResults.rejectionOutsideEligibleWindow',
'planApply.rejection.insufficientBalance': 'planResults.rejectionInsufficientBalance',
'planApply.rejection.insufficientVacationBalance': 'planResults.rejectionInsufficientVacationBalance',
}
/** Renders one rejection, never leaking `data.parent_personal_data_uuid` as
* a raw uuid: whenever that field is present, it's resolved to a display
* name via `members` (falling back to `planResults.unknownParent` rather
* than the uuid itself, e.g. while members are still loading or for a
* removed member) and added to the template params as `parent_name`. An
* unrecognised `messageKey` falls back to showing the raw key, same idiom
* as `TipCard.tsx`'s `TIP_TRANSLATION_KEYS`.
*/
export function renderRejection(rejection: Rejection, t: Translate, members: PersonalData[]): string {
const key = REJECTION_KEYS[rejection.messageKey]
if (!key) return rejection.messageKey
const params = toTranslationParams(rejection.data)
const uuid = rejection.data.parent_personal_data_uuid
if (typeof uuid === 'string') {
const resolve = nameResolver(members)
const name = resolve(uuid)
params.parent_name = name === uuid ? t('planResults.unknownParent') : name
}
return t(key, params)
}
+1
View File
@@ -14,6 +14,7 @@ export function makeAge4Projection(overrides: Partial<Age4Projection> = {}): Age
projectedLeftAtB4: 200,
savableAfterB4: 96,
expiringAtB4: 60,
savableCapAtB4: 96,
...overrides,
}
}
+13
View File
@@ -22,6 +22,7 @@ import type { useDayOverrides } from '@/hooks/useDayOverrides'
import type { useLeaveWishes } from '@/hooks/useLeaveWishes'
import type { usePlanProposal } from '@/hooks/usePlanProposal'
import type { usePlanApply } from '@/hooks/usePlanApply'
import type { usePlanSync } from '@/hooks/usePlanSync'
import type { useVacationDays } from '@/hooks/useVacationDays'
import type { useChildSettings } from '@/hooks/useChildSettings'
import type { useCalendarDays } from '@/hooks/useCalendarDays'
@@ -209,6 +210,18 @@ export function makePlanApplyResult(
}
}
export function makePlanSyncResult(
overrides: Partial<ReturnType<typeof usePlanSync>> = {},
): ReturnType<typeof usePlanSync> {
return {
isSyncing: false,
error: null,
lastChanges: null,
sync: vi.fn(),
...overrides,
}
}
export function makeVacationDaysResult(
overrides: Partial<ReturnType<typeof useVacationDays>> = {},
): ReturnType<typeof useVacationDays> {
+1
View File
@@ -140,6 +140,7 @@ export function makeLeaveWish(overrides: Partial<LeaveWish> = {}): LeaveWish {
windowEndMonth: 7,
windowEndDay: 26,
targetWeeks: 6,
targetDays: null,
recurrence: 'yearly',
firstYear: 2026,
lastYear: null,
+29
View File
@@ -8,7 +8,9 @@ import type {
DayCollision,
PlanAge4Projection,
PlanApplyResponse,
PlanChange,
PlanProposal,
PlanSyncResponse,
PlanWarning,
PoolBalance,
ProposedDay,
@@ -180,3 +182,30 @@ export function makePlanApplyResponse(overrides: Partial<PlanApplyResponse> = {}
...overrides,
}
}
/** `POST /api/plan/sync` — one (child, parent, date) slot's delta. See
* `PlanChange`'s docstring (src/types/entities.ts) for the `kind` vocabulary. */
export function makePlanChange(overrides: Partial<PlanChange> = {}): PlanChange {
return {
kind: 'added',
date: '2028-06-20',
parentPersonalDataUuid: 'pd-a',
childId: 'child-1',
oldDayType: null,
newDayType: 'vacation',
oldIsDubbeldag: null,
newIsDubbeldag: false,
oldWishId: null,
newWishId: 'lw-1',
...overrides,
}
}
export function makePlanSyncResponse(overrides: Partial<PlanSyncResponse> = {}): PlanSyncResponse {
return {
proposal: makePlanProposal(),
applyResult: makePlanApplyResponse(),
changes: [],
...overrides,
}
}