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, + } +}