diff --git a/frontend/src/components/dashboard/Age4Bars.test.tsx b/frontend/src/components/dashboard/Age4Bars.test.tsx
new file mode 100644
index 0000000..158b63a
--- /dev/null
+++ b/frontend/src/components/dashboard/Age4Bars.test.tsx
@@ -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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+ 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(
+ ,
+ )
+ 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(
+ ,
+ )
+ expect(screen.getAllByTestId(/^age4-bar-pd-/)).toHaveLength(2)
+ })
+
+ it('renders nothing but the title when there are no bars', () => {
+ render( )
+ 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(
+ ,
+ )
+ 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')
+ })
+})
diff --git a/frontend/src/components/dashboard/Age4Bars.tsx b/frontend/src/components/dashboard/Age4Bars.tsx
new file mode 100644
index 0000000..9e68864
--- /dev/null
+++ b/frontend/src/components/dashboard/Age4Bars.tsx
@@ -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 (
+
+
{t('age4Bars.title')}
+ {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 (
+
+
{bar.name}
+
+
+
+ {t('age4Bars.savableStat', { left: savable, cap: bar.savableCapAtB4 })}
+
+ {bar.expiringAtB4 > 0 && (
+
+ {t('age4Bars.expiringStat', { expiring: bar.expiringAtB4 })}
+
+ )}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/src/components/dashboard/Hero.test.tsx b/frontend/src/components/dashboard/Hero.test.tsx
new file mode 100644
index 0000000..4686524
--- /dev/null
+++ b/frontend/src/components/dashboard/Hero.test.tsx
@@ -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(
+ ,
+ )
+ 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(
+ ,
+ )
+ 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(
+ ,
+ )
+ expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent('104')
+
+ rerender( )
+ expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent('104')
+ })
+})
diff --git a/frontend/src/components/dashboard/Hero.tsx b/frontend/src/components/dashboard/Hero.tsx
new file mode 100644
index 0000000..ad57f7d
--- /dev/null
+++ b/frontend/src/components/dashboard/Hero.tsx
@@ -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 (
+
+ )
+}
diff --git a/frontend/src/components/dashboard/PlanFold.test.tsx b/frontend/src/components/dashboard/PlanFold.test.tsx
new file mode 100644
index 0000000..78a56ff
--- /dev/null
+++ b/frontend/src/components/dashboard/PlanFold.test.tsx
@@ -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 element)', () => {
+ render( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ expect(screen.queryByTestId('plan-fold-overview')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('plan-fold-balances')).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/dashboard/PlanFold.tsx b/frontend/src/components/dashboard/PlanFold.tsx
new file mode 100644
index 0000000..1e7a765
--- /dev/null
+++ b/frontend/src/components/dashboard/PlanFold.tsx
@@ -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 = {
+ 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 = {
+ '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 (
+
+
+ {t('dashboard.planFold.summary')}
+
+
+
+ {!wishesLoading && wishes.length === 0 && (
+
+ {t('dashboard.planFold.noWishesHint')}
+
+ )}
+
+ {proposalError && (
+
+ {proposalError}
+
+ )}
+
+ {proposal && (
+ <>
+
+ {t('planResults.yearOverviewTitle')}
+ {proposal.outcomes.length === 0 ? (
+ {t('planResults.noWindowsInHorizon')}
+ ) : (
+
+
+
+ {t('planResults.yearHeader')}
+ {t('planResults.leavesHeader')}
+
+
+
+ {groupByYear(proposal.outcomes).map(({ year, outcomes }) => (
+
+ {year}
+
+ {outcomes.map((outcome) => (
+
+ {resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)}
+ : {outcome.weeksFunded}/{outcome.weeksWished} {t('planResults.weeksUnit')}
+
+ ))}
+
+
+ ))}
+
+
+ )}
+
+
+ {proposal.surplusAdvice.length > 0 && (
+
+ {t('planResults.surplusAdviceTitle')}
+ {t('planResults.surplusAdviceIntro')}
+
+
+ )}
+
+
+ {t('planResults.balancesTitle')}
+
+
+
+ {t('planResults.parentHeader')}
+ {t('planResults.sjukpenningLeftHeader')}
+ {t('planResults.lagstaLeftHeader')}
+ {t('planResults.dubbeldagarLeftHeader')}
+
+
+
+ {proposal.poolBalances.map((pool) => (
+
+ {nameFor(pool.parentPersonalDataUuid)}
+ {pool.sjukpenningLeft}
+ {pool.lagstaLeft}
+ {pool.dubbeldagarLeft}
+
+ ))}
+
+
+
+
+
+
+ {t('planResults.parentHeader')}
+ {t('planResults.yearHeader')}
+ {t('planResults.usedHeader')}
+ {t('planResults.totalAvailableHeader')}
+
+
+
+ {proposal.vacationBalances.map((budget) => (
+
+ {nameFor(budget.parentPersonalDataUuid)}
+ {budget.year}
+ {budget.alreadyUsed}
+ {budget.totalDays + budget.savedDaysIn}
+
+ ))}
+
+
+
+ >
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/dashboard/ProgressChart.test.tsx b/frontend/src/components/dashboard/ProgressChart.test.tsx
deleted file mode 100644
index 37592b9..0000000
--- a/frontend/src/components/dashboard/ProgressChart.test.tsx
+++ /dev/null
@@ -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(
-
- )
- expect(getByTestId('progress-chart')).toBeInTheDocument()
- })
-
- it('calculates percentage correctly', () => {
- const { getByTestId } = render(
-
- )
- expect(getByTestId('percentage').textContent).toBe('25%')
- })
-
- it('displays weeks correctly', () => {
- const { getByTestId } = render(
-
- )
- expect(getByTestId('current-weeks').textContent).toBe('10 / 50 veckor')
- })
-
- it('sets fill width based on percentage', () => {
- const { getByTestId } = render(
-
- )
- const fill = getByTestId('progress-fill')
- expect(fill).toHaveStyle({ width: '50%' })
- })
-
- it('caps percentage at 100%', () => {
- const { getByTestId } = render(
-
- )
- expect(getByTestId('percentage').textContent).toBe('100%')
- expect(getByTestId('progress-fill')).toHaveStyle({ width: '100%' })
- })
-
- it('shows 0% when no progress', () => {
- const { getByTestId } = render(
-
- )
- expect(getByTestId('percentage').textContent).toBe('0%')
- })
-
- it('displays child age when provided', () => {
- const { getByTestId } = render(
-
- )
- expect(getByTestId('progress-chart').textContent).toContain('Ålder: 2')
- })
-
- it('handles zero target weeks', () => {
- const { getByTestId } = render(
-
- )
- expect(getByTestId('percentage').textContent).toBe('0%')
- })
-})
diff --git a/frontend/src/components/dashboard/ProgressChart.tsx b/frontend/src/components/dashboard/ProgressChart.tsx
deleted file mode 100644
index 1bd5213..0000000
--- a/frontend/src/components/dashboard/ProgressChart.tsx
+++ /dev/null
@@ -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 (
-
-
-
{t('progressChart.title')}
- {childAge !== undefined && (
- {t('progressChart.age', { age: childAge })}
- )}
-
-
-
-
- {t('progressChart.weeksStat', { current: currentWeeks, target: targetWeeks })}
-
-
- {Math.round(percentage)}%
-
-
-
- )
-}
diff --git a/frontend/src/components/dashboard/YearTimeline.test.tsx b/frontend/src/components/dashboard/YearTimeline.test.tsx
new file mode 100644
index 0000000..a0828c8
--- /dev/null
+++ b/frontend/src/components/dashboard/YearTimeline.test.tsx
@@ -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(
+ ,
+ )
+ 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(
+ ,
+ )
+ // 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(
+ ,
+ )
+ expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(2)
+ })
+
+ it('never lets a band span a calendar-year boundary', () => {
+ render(
+ ,
+ )
+ 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(
+ ,
+ )
+ 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(
+ ,
+ )
+ await user.click(screen.getByTestId('year-timeline-band-2026-0'))
+ expect(onBandClick).toHaveBeenCalledWith('2026-06-15')
+ })
+})
diff --git a/frontend/src/components/dashboard/YearTimeline.tsx b/frontend/src/components/dashboard/YearTimeline.tsx
new file mode 100644
index 0000000..c5f544a
--- /dev/null
+++ b/frontend/src/components/dashboard/YearTimeline.tsx
@@ -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 = {}
+ 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 (
+
+
{t('yearTimeline.title')}
+ {years.map((year) => {
+ const yearLength = daysInYear(year)
+ const bandsForYear = bands.filter((b) => toUtcDate(b.startDate).getUTCFullYear() === year)
+ return (
+
+
{year}
+
+ {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 (
+ onBandClick(band.startDate)}
+ data-testid={`year-timeline-band-${year}-${idx}`}
+ title={label}
+ aria-label={label}
+ />
+ )
+ })}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/src/i18n/en/dashboard.ts b/frontend/src/i18n/en/dashboard.ts
index 23b161d..6d5c2a7 100644
--- a/frontend/src/i18n/en/dashboard.ts
+++ b/frontend/src/i18n/en/dashboard.ts
@@ -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
diff --git a/frontend/src/i18n/en/plan.ts b/frontend/src/i18n/en/plan.ts
index 2e50c1d..8ad2276 100644
--- a/frontend/src/i18n/en/plan.ts
+++ b/frontend/src/i18n/en/plan.ts
@@ -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.',
diff --git a/frontend/src/i18n/sv/dashboard.ts b/frontend/src/i18n/sv/dashboard.ts
index bf51f53..0a7ebcd 100644
--- a/frontend/src/i18n/sv/dashboard.ts
+++ b/frontend/src/i18n/sv/dashboard.ts
@@ -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
\ No newline at end of file
diff --git a/frontend/src/i18n/sv/plan.ts b/frontend/src/i18n/sv/plan.ts
index 5878914..e4f6caa 100644
--- a/frontend/src/i18n/sv/plan.ts
+++ b/frontend/src/i18n/sv/plan.ts
@@ -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.',
diff --git a/frontend/src/pages/Dashboard.test.tsx b/frontend/src/pages/Dashboard.test.tsx
index c5a4740..8075ffc 100644
--- a/frontend/src/pages/Dashboard.test.tsx
+++ b/frontend/src/pages/Dashboard.test.tsx
@@ -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('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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ expect(screen.getByTestId('dashboard-tips')).toBeInTheDocument()
+ expect(screen.getAllByTestId('tip-card')).toHaveLength(1)
+ })
+ })
+
+ it('renders the folded plan-proposal section', () => {
renderWithRouter( )
+ expect(screen.getByTestId('plan-fold')).toBeInTheDocument()
+ })
+
+ it('shows overview metrics inside the collapsed "more details" section', () => {
+ renderWithRouter( )
+ 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( )
- expect(screen.getByTestId('progress-chart')).toBeInTheDocument()
- })
-
it('shows Swedish section headings', () => {
renderWithRouter( )
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,
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index 3f5e342..b770f28 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -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 (
@@ -215,6 +251,18 @@ export function Dashboard(): ReactNode {
)}
+ {analytics && selectedChild && (
+
+ )}
+
+ {age4Bars.length > 0 && }
+
-
- {analytics && (
-
- )}
-
-
-
-
- {t('dashboard.plannedDaysTitle')}
-
-
-
-
- {t('dashboard.remainingUntilB4Title')}
-
-
+ )}
+
+ {tips && tips.length > 0 && (
+
+ {t('tips.title')}
+
+ {tips.map((tip) => (
+ dismissTip(tip.id)} />
+ ))}
+
+
+ )}
+
+ {workspace && }
+
+
+ {t('dashboard.moreDetailsSummary')}
+
+
+ {analytics && (
+
+ )}
+
+
+
+
+ {t('dashboard.plannedDaysTitle')}
+
+
+
{
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,
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 79646c8..7ee95ac 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -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 {
+ 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 {
@@ -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 {
diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css
index 91961f1..62f6161 100644
--- a/frontend/src/styles/components.css
+++ b/frontend/src/styles/components.css
@@ -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 */
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index e401e5e..2d93f2d 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -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
diff --git a/frontend/src/types/entities.ts b/frontend/src/types/entities.ts
index bdcef1b..864d391 100644
--- a/frontend/src/types/entities.ts
+++ b/frontend/src/types/entities.ts
@@ -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 {
diff --git a/frontend/src/utils/rejectionMessages.test.ts b/frontend/src/utils/rejectionMessages.test.ts
new file mode 100644
index 0000000..7a74351
--- /dev/null
+++ b/frontend/src/utils/rejectionMessages.test.ts
@@ -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[1], params?: Parameters[2]) =>
+ translate('sv', key, params)
+
+const member = (overrides: Partial = {}): PersonalData => ({
+ uuid: 'pd-a',
+ workspaceId: 'ws-1',
+ name: 'Alex',
+ salaryType: 'monthly',
+ employmentStatus: 'employed',
+ createdAt: '2026-01-01',
+ ...overrides,
+})
+
+const rejection = (overrides: Partial = {}): 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')
+ })
+})
diff --git a/frontend/src/utils/rejectionMessages.ts b/frontend/src/utils/rejectionMessages.ts
new file mode 100644
index 0000000..e29646b
--- /dev/null
+++ b/frontend/src/utils/rejectionMessages.ts
@@ -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 = {
+ '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)
+}
diff --git a/frontend/tests/fixtures/analytics.ts b/frontend/tests/fixtures/analytics.ts
index 6ccb52a..de8ed85 100644
--- a/frontend/tests/fixtures/analytics.ts
+++ b/frontend/tests/fixtures/analytics.ts
@@ -14,6 +14,7 @@ export function makeAge4Projection(overrides: Partial = {}): Age
projectedLeftAtB4: 200,
savableAfterB4: 96,
expiringAtB4: 60,
+ savableCapAtB4: 96,
...overrides,
}
}
diff --git a/frontend/tests/fixtures/hooks.ts b/frontend/tests/fixtures/hooks.ts
index 06b8bfa..ad99e60 100644
--- a/frontend/tests/fixtures/hooks.ts
+++ b/frontend/tests/fixtures/hooks.ts
@@ -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 {
+ return {
+ isSyncing: false,
+ error: null,
+ lastChanges: null,
+ sync: vi.fn(),
+ ...overrides,
+ }
+}
+
export function makeVacationDaysResult(
overrides: Partial> = {},
): ReturnType {
diff --git a/frontend/tests/fixtures/mockData.ts b/frontend/tests/fixtures/mockData.ts
index f00c2f3..c1487c3 100644
--- a/frontend/tests/fixtures/mockData.ts
+++ b/frontend/tests/fixtures/mockData.ts
@@ -140,6 +140,7 @@ export function makeLeaveWish(overrides: Partial = {}): LeaveWish {
windowEndMonth: 7,
windowEndDay: 26,
targetWeeks: 6,
+ targetDays: null,
recurrence: 'yearly',
firstYear: 2026,
lastYear: null,
diff --git a/frontend/tests/fixtures/plan.ts b/frontend/tests/fixtures/plan.ts
index a54323f..e44f516 100644
--- a/frontend/tests/fixtures/plan.ts
+++ b/frontend/tests/fixtures/plan.ts
@@ -8,7 +8,9 @@ import type {
DayCollision,
PlanAge4Projection,
PlanApplyResponse,
+ PlanChange,
PlanProposal,
+ PlanSyncResponse,
PlanWarning,
PoolBalance,
ProposedDay,
@@ -180,3 +182,30 @@ export function makePlanApplyResponse(overrides: Partial = {}
...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 {
+ 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 {
+ return {
+ proposal: makePlanProposal(),
+ applyResult: makePlanApplyResponse(),
+ changes: [],
+ ...overrides,
+ }
+}