From 8e1cf3524c2701ec6d09e5378b960a81e7cc8cfb Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Thu, 17 Sep 2026 21:32:15 +0200 Subject: [PATCH] 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. --- .../components/dashboard/Age4Bars.test.tsx | 74 ++++++ .../src/components/dashboard/Age4Bars.tsx | 65 +++++ .../src/components/dashboard/Hero.test.tsx | 37 +++ frontend/src/components/dashboard/Hero.tsx | 51 ++++ .../components/dashboard/PlanFold.test.tsx | 202 ++++++++++++++++ .../src/components/dashboard/PlanFold.tsx | 223 ++++++++++++++++++ .../dashboard/ProgressChart.test.tsx | 63 ----- .../components/dashboard/ProgressChart.tsx | 43 ---- .../dashboard/YearTimeline.test.tsx | 101 ++++++++ .../src/components/dashboard/YearTimeline.tsx | 161 +++++++++++++ frontend/src/i18n/en/dashboard.ts | 15 +- frontend/src/i18n/en/plan.ts | 4 +- frontend/src/i18n/sv/dashboard.ts | 15 +- frontend/src/i18n/sv/plan.ts | 4 +- frontend/src/pages/Dashboard.test.tsx | 153 ++++++++++-- frontend/src/pages/Dashboard.tsx | 118 +++++++-- frontend/src/services/api.test.ts | 142 +++++++++++ frontend/src/services/api.ts | 67 ++++++ frontend/src/styles/components.css | 126 ++++++++-- frontend/src/types/api.ts | 12 + frontend/src/types/entities.ts | 41 ++++ frontend/src/utils/rejectionMessages.test.ts | 105 +++++++++ frontend/src/utils/rejectionMessages.ts | 61 +++++ frontend/tests/fixtures/analytics.ts | 1 + frontend/tests/fixtures/hooks.ts | 13 + frontend/tests/fixtures/mockData.ts | 1 + frontend/tests/fixtures/plan.ts | 29 +++ 27 files changed, 1749 insertions(+), 178 deletions(-) create mode 100644 frontend/src/components/dashboard/Age4Bars.test.tsx create mode 100644 frontend/src/components/dashboard/Age4Bars.tsx create mode 100644 frontend/src/components/dashboard/Hero.test.tsx create mode 100644 frontend/src/components/dashboard/Hero.tsx create mode 100644 frontend/src/components/dashboard/PlanFold.test.tsx create mode 100644 frontend/src/components/dashboard/PlanFold.tsx delete mode 100644 frontend/src/components/dashboard/ProgressChart.test.tsx delete mode 100644 frontend/src/components/dashboard/ProgressChart.tsx create mode 100644 frontend/src/components/dashboard/YearTimeline.test.tsx create mode 100644 frontend/src/components/dashboard/YearTimeline.tsx create mode 100644 frontend/src/utils/rejectionMessages.test.ts create mode 100644 frontend/src/utils/rejectionMessages.ts 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 ( +
+

+ {message} +

+
+ ) +} 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')}

+ ) : ( + + + + + + + + + {groupByYear(proposal.outcomes).map(({ year, outcomes }) => ( + + + + + ))} + +
{t('planResults.yearHeader')}{t('planResults.leavesHeader')}
{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')}

+
    + {proposal.surplusAdvice.map((advice, idx) => { + const titleKey = SURPLUS_ADVICE_KEYS[advice.kind] + return ( +
  • + {titleKey ? t(titleKey) : advice.kind} +

    {renderSurplusAdviceBody(advice, t, nameFor)}

    +
  • + ) + })} +
+
+ )} + +
+

{t('planResults.balancesTitle')}

+ + + + + + + + + + + {proposal.poolBalances.map((pool) => ( + + + + + + + ))} + +
{t('planResults.parentHeader')}{t('planResults.sjukpenningLeftHeader')}{t('planResults.lagstaLeftHeader')}{t('planResults.dubbeldagarLeftHeader')}
{nameFor(pool.parentPersonalDataUuid)}{pool.sjukpenningLeft}{pool.lagstaLeft}{pool.dubbeldagarLeft}
+ + + + + + + + + + + + {proposal.vacationBalances.map((budget) => ( + + + + + + + ))} + +
{t('planResults.parentHeader')}{t('planResults.yearHeader')}{t('planResults.usedHeader')}{t('planResults.totalAvailableHeader')}
{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 ( +
+
+ ) + })} +
+ ) +} 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, + } +}