From 80b4f1cb52f5b45b8a7be1ca8ad5d0840e661b9c Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 20 Sep 2026 06:30:46 +0200 Subject: [PATCH] Make the overview answer the year-by-year question The dashboard now leads with a typographic headline, three key figures, per-parent age-4 bars and one row per year until the fourth birthday. Each year row shows the timeline bands beside each parent's parental-leave days left at year end and vacation days left, carried over or expiring, and opens into a per-parent table with that year's wish outcomes. Tips become a plain list and absorb the planner's surplus advice; "Detaljer" keeps the metrics and a balance snapshot for every parent. The separate plan-proposal fold and the type-count table are gone. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H --- .../components/calendar/RedDaysList.test.tsx | 328 ------ .../src/components/calendar/RedDaysList.tsx | 204 ---- .../dashboard/DaysBreakdown.test.tsx | 57 -- .../components/dashboard/DaysBreakdown.tsx | 39 - .../components/dashboard/KeyFigures.test.tsx | 29 + .../src/components/dashboard/KeyFigures.tsx | 37 + .../dashboard/ParentalLeaveBreakdown.test.tsx | 135 +-- .../dashboard/ParentalLeaveBreakdown.tsx | 129 ++- .../components/dashboard/PlanFold.test.tsx | 344 ------- .../src/components/dashboard/PlanFold.tsx | 274 ----- .../dashboard/SurplusAdviceTip.test.tsx | 64 ++ .../components/dashboard/SurplusAdviceTip.tsx | 66 ++ .../components/dashboard/YearRows.test.tsx | 372 +++++++ .../src/components/dashboard/YearRows.tsx | 463 +++++++++ .../dashboard/YearTimeline.test.tsx | 133 --- .../src/components/dashboard/YearTimeline.tsx | 183 ---- .../inputs/DayOverridesInput.test.tsx | 282 ------ .../components/inputs/DayOverridesInput.tsx | 178 ---- frontend/src/hooks/useParentalLeaves.test.ts | 125 +++ frontend/src/hooks/useParentalLeaves.ts | 73 ++ frontend/src/i18n/LanguageContext.test.tsx | 2 +- frontend/src/i18n/en/dashboard.ts | 55 +- frontend/src/i18n/en/plan.ts | 22 +- frontend/src/i18n/sv/dashboard.ts | 61 +- frontend/src/i18n/sv/plan.ts | 22 +- frontend/src/pages/Dashboard.test.tsx | 891 ++++++---------- frontend/src/pages/Dashboard.tsx | 339 +++---- frontend/src/styles/components.css | 948 ++++++++++-------- frontend/src/styles/global.css | 3 + frontend/src/styles/responsive.css | 63 +- 30 files changed, 2444 insertions(+), 3477 deletions(-) delete mode 100644 frontend/src/components/calendar/RedDaysList.test.tsx delete mode 100644 frontend/src/components/calendar/RedDaysList.tsx delete mode 100644 frontend/src/components/dashboard/DaysBreakdown.test.tsx delete mode 100644 frontend/src/components/dashboard/DaysBreakdown.tsx create mode 100644 frontend/src/components/dashboard/KeyFigures.test.tsx create mode 100644 frontend/src/components/dashboard/KeyFigures.tsx delete mode 100644 frontend/src/components/dashboard/PlanFold.test.tsx delete mode 100644 frontend/src/components/dashboard/PlanFold.tsx create mode 100644 frontend/src/components/dashboard/SurplusAdviceTip.test.tsx create mode 100644 frontend/src/components/dashboard/SurplusAdviceTip.tsx create mode 100644 frontend/src/components/dashboard/YearRows.test.tsx create mode 100644 frontend/src/components/dashboard/YearRows.tsx delete mode 100644 frontend/src/components/dashboard/YearTimeline.test.tsx delete mode 100644 frontend/src/components/dashboard/YearTimeline.tsx delete mode 100644 frontend/src/components/inputs/DayOverridesInput.test.tsx delete mode 100644 frontend/src/components/inputs/DayOverridesInput.tsx create mode 100644 frontend/src/hooks/useParentalLeaves.test.ts create mode 100644 frontend/src/hooks/useParentalLeaves.ts diff --git a/frontend/src/components/calendar/RedDaysList.test.tsx b/frontend/src/components/calendar/RedDaysList.test.tsx deleted file mode 100644 index 0d65866..0000000 --- a/frontend/src/components/calendar/RedDaysList.test.tsx +++ /dev/null @@ -1,328 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { render, screen, waitFor, fireEvent } from '@testing-library/react' -import { RedDaysList } from './RedDaysList' -import type { CalendarDayEntry, DayOverride } from '@/types' - -const baseDays: CalendarDayEntry[] = [ - { - date: '2026-01-01', - name: 'Nyårsdagen', - dayType: 'red_day', - isOverride: false, - overrideIsWorkingDay: null, - isFree: true, - }, - { - date: '2026-04-05', - name: 'Påskdagen; Söndag', - dayType: 'red_day', - isOverride: false, - overrideIsWorkingDay: null, - isFree: true, - }, - { - date: '2026-12-24', - name: null, - dayType: 'working_day', - isOverride: true, - overrideIsWorkingDay: false, - isFree: true, - }, - { - date: '2027-01-06', - name: 'Trettondedag jul', - dayType: 'red_day', - isOverride: false, - overrideIsWorkingDay: null, - isFree: true, - }, - { - date: '2026-05-04', - name: null, - dayType: 'working_day', - isOverride: false, - overrideIsWorkingDay: null, - isFree: true, - }, -] - -const baseOverrides: DayOverride[] = [ - { - id: 'do-2026-12-24', - workspaceId: 'ws-1', - date: '2026-12-24', - isWorkingDay: false, - note: null, - createdAt: '2026-01-01T00:00:00', - updatedAt: '2026-01-01T00:00:00', - }, -] - -describe('RedDaysList', () => { - const defaultProps = { - days: baseDays, - startYear: 2026, - years: 4, - minYear: 2026, - maxYear: 2035, - onPrevWindow: vi.fn(), - onNextWindow: vi.fn(), - overrides: baseOverrides, - workspaceId: 'ws-1', - onUpsertOverride: vi.fn().mockResolvedValue(undefined), - onRemoveOverride: vi.fn().mockResolvedValue(undefined), - } - - it('renders one year group per year in the window, including years with no matches', () => { - render() - expect(screen.getByTestId('red-days-year-2026')).toBeInTheDocument() - expect(screen.getByTestId('red-days-year-2027')).toBeInTheDocument() - expect(screen.getByTestId('red-days-year-2028')).toBeInTheDocument() - expect(screen.getByTestId('red-days-year-2029')).toBeInTheDocument() - expect(screen.queryByTestId('red-days-year-2030')).not.toBeInTheDocument() - }) - - it('shows statutory red days with a cleaned-up name', () => { - render() - expect(screen.getByTestId('red-days-item-2026-01-01')).toHaveTextContent('Nyårsdagen') - // "; Söndag" is stripped, not dumped raw - const easter = screen.getByTestId('red-days-item-2026-04-05') - expect(easter).toHaveTextContent('Påskdagen') - expect(easter).not.toHaveTextContent('Söndag') - }) - - it('shows a workspace override even on a non-red day', () => { - render() - const overrideItem = screen.getByTestId('red-days-item-2026-12-24') - expect(overrideItem).toBeInTheDocument() - expect(screen.getByTestId('red-days-override-badge-2026-12-24')).toBeInTheDocument() - }) - - it('shows an empty-year message for a year with no red days or overrides', () => { - render() - const year2028 = screen.getByTestId('red-days-year-2028') - expect(year2028).toHaveTextContent(/Inga röda dagar eller undantag/) - }) - - it('skips a plain working day that is neither a red day nor an override', () => { - render() - // 2026-05-04 is a plain working_day, non-override entry in baseDays — it - // must not show up as a list item under its year. - expect(screen.queryByTestId('red-days-item-2026-05-04')).not.toBeInTheDocument() - }) - - it('labels a working-day override distinctly from a free-day override', () => { - const workingOverrideDays = [ - { - date: '2026-03-02', - name: null, - dayType: 'working_day' as const, - isOverride: true, - overrideIsWorkingDay: true, - isFree: false, - }, - ] - render() - const badge = screen.getByTestId('red-days-override-badge-2026-03-02') - expect(badge).toHaveClass('red-days-list__override-badge--working') - expect(badge).toHaveTextContent('Undantag: arbetsdag') - }) - - it('shows the effective-working label for a day that is not free', () => { - const workingDays = [ - { - date: '2026-03-02', - name: null, - dayType: 'working_day' as const, - isOverride: true, - overrideIsWorkingDay: true, - isFree: false, - }, - ] - render() - const item = screen.getByTestId('red-days-item-2026-03-02') - expect(item.querySelector('.red-days-list__effective--working')).toBeInTheDocument() - expect(item.querySelector('.red-days-list__effective--free')).not.toBeInTheDocument() - }) - - it('disables the previous button at minYear', () => { - render() - expect(screen.getByTestId('red-days-prev')).toBeDisabled() - }) - - it('disables the next button when the window reaches maxYear', () => { - render() - expect(screen.getByTestId('red-days-next')).toBeDisabled() - }) - - it('enables both buttons in the middle of the range', () => { - render() - expect(screen.getByTestId('red-days-prev')).not.toBeDisabled() - expect(screen.getByTestId('red-days-next')).not.toBeDisabled() - }) - - it('calls onPrevWindow / onNextWindow when the pagination buttons are clicked', () => { - const onPrevWindow = vi.fn() - const onNextWindow = vi.fn() - // Neither button disabled: startYear sits strictly between minYear and maxYear. - render( - , - ) - - screen.getByTestId('red-days-prev').click() - screen.getByTestId('red-days-next').click() - - expect(onPrevWindow).toHaveBeenCalledTimes(1) - expect(onNextWindow).toHaveBeenCalledTimes(1) - }) - - it('uses the actual endYear from the backend instead of startYear + years - 1 when the window was clamped', () => { - // Regression test: the backend clamps a window whose requested end - // exceeds the cached range (e.g. paging to the last page) and reports - // the ACTUAL end year via `endYear`. Without honoring it, this component - // would render extra year headers (2036, 2037) the backend never - // returned data for, showing a false "no red days" for years that are - // simply outside the cache rather than genuinely holiday-free. - render( - , - ) - expect(screen.getByTestId('red-days-range')).toHaveTextContent('2034–2035') - expect(screen.queryByTestId('red-days-year-2036')).not.toBeInTheDocument() - expect(screen.queryByTestId('red-days-year-2037')).not.toBeInTheDocument() - }) - - it('shows a loading message and hides year groups while loading', () => { - render() - expect(screen.getByText(/Laddar röda dagar/)).toBeInTheDocument() - expect(screen.queryByTestId('red-days-year-2026')).not.toBeInTheDocument() - }) - - it('shows an error message and hides year groups on error', () => { - render() - expect(screen.getByRole('alert')).toHaveTextContent('Something broke') - expect(screen.queryByTestId('red-days-year-2026')).not.toBeInTheDocument() - }) - - describe('toggling a day (§Task8)', () => { - it('clicking a statutory red day upserts an override making that exact date a working day', async () => { - const onUpsertOverride = vi.fn().mockResolvedValue(undefined) - render() - - fireEvent.click(screen.getByTestId('red-days-toggle-2026-01-01')) - - await waitFor(() => { - expect(onUpsertOverride).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - date: '2026-01-01', - isWorkingDay: true, - note: null, - }) - }) - }) - - it('clicking an overridden day back to its statutory value removes the override by id', async () => { - const onRemoveOverride = vi.fn().mockResolvedValue(undefined) - render() - - // 2026-12-24 carries the override from `baseOverrides` (id: do-2026-12-24). - fireEvent.click(screen.getByTestId('red-days-toggle-2026-12-24')) - - await waitFor(() => { - expect(onRemoveOverride).toHaveBeenCalledWith('do-2026-12-24') - }) - }) - - it('only touches the exact date clicked, not the same month/day in another year', async () => { - const sameMonthDayAcrossYears: CalendarDayEntry[] = [ - { - date: '2026-01-01', - name: 'Nyårsdagen', - dayType: 'red_day', - isOverride: false, - overrideIsWorkingDay: null, - isFree: true, - }, - { - date: '2027-01-01', - name: 'Nyårsdagen', - dayType: 'red_day', - isOverride: false, - overrideIsWorkingDay: null, - isFree: true, - }, - ] - const onUpsertOverride = vi.fn().mockResolvedValue(undefined) - render( - , - ) - - fireEvent.click(screen.getByTestId('red-days-toggle-2026-01-01')) - - await waitFor(() => { - expect(onUpsertOverride).toHaveBeenCalledTimes(1) - }) - expect(onUpsertOverride).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - date: '2026-01-01', - isWorkingDay: true, - note: null, - }) - // The 2027 New Year's Day button was never touched by the 2026 click. - expect(screen.getByTestId('red-days-toggle-2027-01-01')).toHaveTextContent('Ledig') - }) - - it('disables the clicked button while the request is in flight', async () => { - let resolveUpsert: () => void = () => {} - const onUpsertOverride = vi.fn( - () => - new Promise((resolve) => { - resolveUpsert = resolve - }), - ) - render() - - const button = screen.getByTestId('red-days-toggle-2026-01-01') - fireEvent.click(button) - expect(button).toBeDisabled() - - resolveUpsert() - await waitFor(() => expect(button).not.toBeDisabled()) - }) - - it('surfaces a failed toggle instead of swallowing it', async () => { - const onUpsertOverride = vi.fn().mockRejectedValue(new Error('network blew up')) - render() - - fireEvent.click(screen.getByTestId('red-days-toggle-2026-01-01')) - - await waitFor(() => { - expect(screen.getByTestId('red-days-toggle-error')).toHaveTextContent('network blew up') - }) - }) - - it('is keyboard accessible with an aria-label naming the date and action', () => { - render() - const button = screen.getByTestId('red-days-toggle-2026-01-01') - expect(button.tagName).toBe('BUTTON') - expect(button).toHaveAccessibleName(/2026|januari/i) - }) - }) -}) diff --git a/frontend/src/components/calendar/RedDaysList.tsx b/frontend/src/components/calendar/RedDaysList.tsx deleted file mode 100644 index cbb8441..0000000 --- a/frontend/src/components/calendar/RedDaysList.tsx +++ /dev/null @@ -1,204 +0,0 @@ -/** - * §3.8 — a per-year list of röda dagar (statutory red days) plus the - * workspace's own day overrides, four years at a time, paginated across the - * full cached range using `minYear`/`maxYear` from `GET /api/calendar/days`. - * - * Product-owner decision: show BOTH the statutory red days AND the user's - * day overrides (worked/off) — not just the statutory calendar — so the - * list matches what the app actually treats as free. - */ -import { ReactNode, useMemo, useState } from 'react' -import type { CalendarDayEntry, DayOverride, DayOverrideCreateRequest } from '@/types' -import { formatDateForDisplay, formatHolidayName } from '@/utils/dates' -import { useTranslation } from '@/i18n' - -export interface RedDaysListProps { - /** All days for the current window (any day_type); the component filters to red days + overrides. */ - days: CalendarDayEntry[] - startYear: number - years?: number - /** - * The window's ACTUAL end year, as reported by `GET /api/calendar/days` - * (`CalendarWindow.endYear`). The backend clamps its window to the cached - * range, so the last page can span fewer than `years` years — pass this - * through so the header/year-groups never claim years the backend didn't - * return (which would otherwise render as false "no red days" for years - * past the cache, e.g. once paging reaches `maxYear`). Falls back to the - * naive `startYear + years - 1` when omitted, e.g. before the first fetch - * resolves and there's no response to read it from yet. - */ - endYear?: number | undefined - minYear: number | null - maxYear: number | null - isLoading?: boolean - error?: string | null - onPrevWindow: () => void - onNextWindow: () => void - /** The workspace's own day overrides — used to find an existing override's id so a click back to the statutory value can `remove` it rather than leave a redundant row. Not scoped to the visible window: dates are absolute, so this can be the full workspace list. */ - overrides: DayOverride[] - workspaceId: string - /** Creates/replaces the override for one absolute date — never affects the same month/day in another year. */ - onUpsertOverride: (req: DayOverrideCreateRequest) => Promise - /** Deletes an existing override by id, reverting that one date to its statutory value. */ - onRemoveOverride: (id: string) => Promise -} - -export function RedDaysList({ - days, - startYear, - years = 4, - endYear: endYearProp, - minYear, - maxYear, - isLoading = false, - error = null, - onPrevWindow, - onNextWindow, - overrides, - workspaceId, - onUpsertOverride, - onRemoveOverride, -}: RedDaysListProps): ReactNode { - const { t } = useTranslation() - const endYear = endYearProp ?? startYear + years - 1 - const [pendingDate, setPendingDate] = useState(null) - const [toggleError, setToggleError] = useState(null) - - const overrideIdByDate = useMemo(() => { - const map = new Map() - for (const override of overrides) { - map.set(override.date, override.id) - } - return map - }, [overrides]) - - const handleToggle = async (day: CalendarDayEntry) => { - setToggleError(null) - setPendingDate(day.date) - try { - if (day.isOverride) { - // Any existing override, in either direction, reverts to the - // statutory value on click — there is no third state. - const overrideId = overrideIdByDate.get(day.date) - if (!overrideId) { - setToggleError(t('redDaysList.toggleError')) - return - } - await onRemoveOverride(overrideId) - } else { - // Only statutory red days (always effectively free) reach this - // branch — see the year-group filter below — so the flip is always - // "make this one date a working day". - await onUpsertOverride({ workspaceId, date: day.date, isWorkingDay: true, note: null }) - } - } catch (err) { - setToggleError(err instanceof Error ? err.message : t('redDaysList.toggleError')) - } finally { - setPendingDate(null) - } - } - - const byYear = useMemo(() => { - const map = new Map() - for (let y = startYear; y <= endYear; y++) { - map.set(y, []) - } - for (const day of days) { - if (day.dayType !== 'red_day' && !day.isOverride) continue - const year = Number(day.date.slice(0, 4)) - const list = map.get(year) - if (list) list.push(day) - } - for (const list of map.values()) { - list.sort((a, b) => a.date.localeCompare(b.date)) - } - return map - }, [days, startYear, endYear]) - - const prevDisabled = minYear !== null && startYear <= minYear - const nextDisabled = maxYear !== null && endYear >= maxYear - - return ( -
-
- - - {startYear}–{endYear} - - -
- - {isLoading &&

{t('redDaysList.loading')}

} - {error && ( -

- {error} -

- )} - {toggleError && ( -

- {toggleError} -

- )} - - {!isLoading && - !error && - Array.from(byYear.entries()).map(([year, items]) => ( -
-

{year}

- {items.length === 0 ? ( -

{t('redDaysList.emptyYear')}

- ) : ( -
    - {items.map((day) => ( -
  • - {formatDateForDisplay(day.date)} - {day.dayType === 'red_day' && day.name && ( - {formatHolidayName(day.name)} - )} - {day.isOverride && ( - - {day.overrideIsWorkingDay ? t('redDaysList.overrideWorking') : t('redDaysList.overrideFree')} - - )} - -
  • - ))} -
- )} -
- ))} -
- ) -} diff --git a/frontend/src/components/dashboard/DaysBreakdown.test.tsx b/frontend/src/components/dashboard/DaysBreakdown.test.tsx deleted file mode 100644 index 0f64bfa..0000000 --- a/frontend/src/components/dashboard/DaysBreakdown.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { render } from '@testing-library/react' -import { DaysBreakdown, type DaysBreakdownProps } from './DaysBreakdown' - -describe('DaysBreakdown', () => { - it('renders empty state when no breakdown provided', () => { - const { getByTestId } = render() - expect(getByTestId('days-breakdown').textContent).toContain('Inga dagar registrerade än') - }) - - it('renders empty state when breakdown itself is missing (defensive null/undefined guard)', () => { - const { getByTestId } = render( - - ) - expect(getByTestId('days-breakdown').textContent).toContain('Inga dagar registrerade än') - }) - - it('renders table with breakdown data', () => { - const breakdown = [ - { type: 'vacation', count: 10 }, - { type: 'sick leave', count: 3 }, - { type: 'parental', count: 50 }, - ] - const { getByTestId } = render() - const table = getByTestId('days-breakdown') - expect(table.querySelector('table')).toBeInTheDocument() - expect(table.textContent).toContain('vacation') - expect(table.textContent).toContain('10') - }) - - it('renders correct number of rows', () => { - const breakdown = [ - { type: 'vacation', count: 10 }, - { type: 'sick leave', count: 3 }, - { type: 'parental', count: 50 }, - ] - const { getByTestId } = render() - expect(getByTestId('breakdown-row-vacation')).toBeInTheDocument() - expect(getByTestId('breakdown-row-sick leave')).toBeInTheDocument() - expect(getByTestId('breakdown-row-parental')).toBeInTheDocument() - }) - - it('displays correct values in table', () => { - const breakdown = [{ type: 'vacation', count: 25 }] - const { getByTestId } = render() - const row = getByTestId('breakdown-row-vacation') - expect(row.textContent).toContain('vacation') - expect(row.textContent).toContain('25') - }) - - it('renders table headers', () => { - const breakdown = [{ type: 'vacation', count: 10 }] - const { getByTestId } = render() - const table = getByTestId('days-breakdown').querySelector('table') - expect(table?.querySelector('thead')).toBeInTheDocument() - }) -}) diff --git a/frontend/src/components/dashboard/DaysBreakdown.tsx b/frontend/src/components/dashboard/DaysBreakdown.tsx deleted file mode 100644 index 80eac8b..0000000 --- a/frontend/src/components/dashboard/DaysBreakdown.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { ReactNode } from 'react' -import { useTranslation } from '@/i18n' - -export interface DaysBreakdownProps { - breakdown: Array<{ type: string; count: number }> -} - -export function DaysBreakdown({ breakdown }: DaysBreakdownProps): ReactNode { - const { t } = useTranslation() - - if (!breakdown || breakdown.length === 0) { - return ( -
-

{t('daysBreakdown.empty')}

-
- ) - } - - return ( -
- - - - - - - - - {breakdown.map((item, idx) => ( - - - - - ))} - -
{t('daysBreakdown.typeHeader')}{t('daysBreakdown.daysHeader')}
{item.type}{item.count}
-
- ) -} diff --git a/frontend/src/components/dashboard/KeyFigures.test.tsx b/frontend/src/components/dashboard/KeyFigures.test.tsx new file mode 100644 index 0000000..7a27ae3 --- /dev/null +++ b/frontend/src/components/dashboard/KeyFigures.test.tsx @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { KeyFigures } from './KeyFigures' + +describe('KeyFigures', () => { + it('renders the three combined figures with their labels', () => { + render() + expect(screen.getByTestId('dashboard-figure-left')).toHaveTextContent('270') + expect(screen.getByTestId('dashboard-figure-left')).toHaveTextContent('Dagar kvar') + expect(screen.getByTestId('dashboard-figure-used')).toHaveTextContent('30') + expect(screen.getByTestId('dashboard-figure-used')).toHaveTextContent('Använda') + expect(screen.getByTestId('dashboard-figure-expiring')).toHaveTextContent('100') + expect(screen.getByTestId('dashboard-figure-expiring')).toHaveTextContent('Förfaller vid 4 år') + }) + + it('wears danger ink on the expiring figure only when it is non-zero', () => { + const { rerender } = render() + expect(screen.getByTestId('dashboard-figure-expiring').querySelector('dd')).toHaveClass( + 'key-figures__value--danger', + ) + rerender() + expect(screen.getByTestId('dashboard-figure-expiring').querySelector('dd')).not.toHaveClass( + 'key-figures__value--danger', + ) + expect(screen.getByTestId('dashboard-figure-left').querySelector('dd')).not.toHaveClass( + 'key-figures__value--danger', + ) + }) +}) diff --git a/frontend/src/components/dashboard/KeyFigures.tsx b/frontend/src/components/dashboard/KeyFigures.tsx new file mode 100644 index 0000000..8b236a2 --- /dev/null +++ b/frontend/src/components/dashboard/KeyFigures.tsx @@ -0,0 +1,37 @@ +import { ReactNode } from 'react' +import { useTranslation } from '@/i18n' + +export interface KeyFiguresProps { + /** `combinedDaysLeft` — days still available to plan, all parents. */ + daysLeft: number + /** `combinedDaysUsed`. */ + daysUsed: number + /** `combinedExpiringAtB4` — projected to be lost at the 4th birthday. */ + expiringAtB4: number +} + +/** + * The three headline figures under the hero — large tabular numerals with + * a 14px label each, no tiles. The expiring figure wears danger ink only + * when it is non-zero: a zero is good news and should read as quiet. + */ +export function KeyFigures({ daysLeft, daysUsed, expiringAtB4 }: KeyFiguresProps): ReactNode { + const { t } = useTranslation() + const figures = [ + { key: 'left', label: t('dashboard.figureLeft'), value: daysLeft, danger: false }, + { key: 'used', label: t('dashboard.figureUsed'), value: daysUsed, danger: false }, + { key: 'expiring', label: t('dashboard.figureExpiring'), value: expiringAtB4, danger: expiringAtB4 > 0 }, + ] + return ( +
+ {figures.map((figure) => ( +
+
{figure.label}
+
+ {figure.value} +
+
+ ))} +
+ ) +} diff --git a/frontend/src/components/dashboard/ParentalLeaveBreakdown.test.tsx b/frontend/src/components/dashboard/ParentalLeaveBreakdown.test.tsx index e98d30e..9340496 100644 --- a/frontend/src/components/dashboard/ParentalLeaveBreakdown.test.tsx +++ b/frontend/src/components/dashboard/ParentalLeaveBreakdown.test.tsx @@ -4,103 +4,67 @@ import { ParentalLeaveBreakdown } from './ParentalLeaveBreakdown' import { LEAVE_RULES } from '@/utils/constants' import { LanguageProvider } from '@/i18n' import type { ParentalLeaveFetch } from '@/types' +import { makeParentalLeaveFetch, makeTierData } from '../../../tests/fixtures' + +const alexData = makeParentalLeaveFetch({ + personalDataUuid: 'pd-a', + source: 'scraped', + fetchedAt: '2026-06-13T10:00:00Z', + transferableDaysLeft: 50, + dubbeldagarLeft: 60, + tierData: makeTierData({ sjukpenningLeft: 80, sjukpenningTotal: 100, lagstaNivaLeft: 170, lagstaNivaTotal: 200 }), +}) + +const alex = { personalDataUuid: 'pd-a', name: 'Alex', data: alexData } +const samWithout = { personalDataUuid: 'pd-b', name: 'Sam', data: null } describe('ParentalLeaveBreakdown', () => { - const mockData: ParentalLeaveFetch = { - id: 'pf1', - personalDataUuid: 'pd1', - workspaceId: 'ws1', - childId: 'c1', - source: 'scraped', - fetchedAt: '2026-06-13T10:00:00Z', - totalDays: 300, - daysUsed: 50, - daysLeft: 250, - reservedDaysTotal: 90, - reservedDaysLeft: 80, - transferableDaysLeft: 50, - dubbeldagarLeft: 60, - tierData: { - sjukpenningTotal: 100, - sjukpenningUsed: 20, - sjukpenningLeft: 80, - lagstaNivaTotal: 200, - lagstaNivaUsed: 30, - lagstaNivaLeft: 170, - }, - createdAt: '2026-06-13T10:00:00Z', - updatedAt: '2026-06-13T10:00:00Z', - } - - it('renders empty state when data is null', () => { - render() - expect(screen.getByText(/Ingen data hämtad ännu/)).toBeInTheDocument() + it('renders an empty state when there are no parents', () => { + render() + expect(screen.getByTestId('parental-leave-breakdown')).toHaveTextContent('Inga saldon hämtade än.') }) - it('omits the tier rows when tierData is absent', () => { - const dataWithoutTiers = { ...mockData, tierData: undefined } as unknown as ParentalLeaveFetch - render() - expect(screen.queryByTestId('breakdown-sjukpenning')).not.toBeInTheDocument() - expect(screen.queryByTestId('breakdown-lagsta-niva')).not.toBeInTheDocument() - // The rows that don't depend on tierData still render. - expect(screen.getByTestId('breakdown-transferable')).toBeInTheDocument() + it('renders one column per parent with each tier as left-of-total', () => { + render() + expect(screen.getByRole('columnheader', { name: 'Alex' })).toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: 'Sam' })).toBeInTheDocument() + expect(screen.getByTestId('breakdown-sjukpenning-pd-a')).toHaveTextContent('80 av 100') + expect(screen.getByTestId('breakdown-lagsta-niva-pd-a')).toHaveTextContent('170 av 200') + expect(screen.getByTestId('breakdown-transferable-pd-a')).toHaveTextContent('50') + expect(screen.getByTestId('breakdown-dubbeldagar-pd-a')).toHaveTextContent('60') }) - it('renders breakdown table with data', () => { - render() - expect(screen.getByTestId('parental-leave-breakdown')).toBeInTheDocument() - expect(screen.getByTestId('breakdown-transferable')).toBeInTheDocument() + it('renders dashes and a "no balance yet" note for a parent without a snapshot', () => { + render() + expect(screen.getByTestId('breakdown-sjukpenning-pd-b')).toHaveTextContent('–') + expect(screen.getByTestId('breakdown-dubbeldagar-pd-b')).toHaveTextContent('–') + expect(screen.getByTestId('breakdown-provenance-pd-b')).toHaveTextContent('Sam Inget saldo än') }) - it('does not render a reserved-days countdown row', () => { - render() - expect(screen.queryByTestId('breakdown-reserved')).not.toBeInTheDocument() + it('renders dashes for the tier rows when tierData is absent', () => { + const noTiers = { ...alexData, tierData: undefined } as unknown as ParentalLeaveFetch // deliberately malformed wire data + render() + expect(screen.getByTestId('breakdown-sjukpenning-pd-a')).toHaveTextContent('–') + expect(screen.getByTestId('breakdown-lagsta-niva-pd-a')).toHaveTextContent('–') + expect(screen.getByTestId('breakdown-transferable-pd-a')).toHaveTextContent('50') }) - it('displays a reserved-days explanatory note instead of a countdown', () => { - render() - const note = screen.getByTestId('reserved-days-note') - expect(note).toHaveTextContent(`${LEAVE_RULES.reservedDaysPerParent} av dina dagar är reserverade`) - expect(note).toHaveTextContent(`max ${LEAVE_RULES.transferableDaysPerParent} dagar kan överlåtas`) + it('displays the reserved-days note once, not per parent', () => { + render() + const notes = screen.getAllByTestId('reserved-days-note') + expect(notes).toHaveLength(1) + expect(notes[0]).toHaveTextContent(`${LEAVE_RULES.reservedDaysPerParent} av varje förälders dagar är reserverade`) + expect(notes[0]).toHaveTextContent(`max ${LEAVE_RULES.transferableDaysPerParent} dagar kan överlåtas`) }) - it('displays transferable days', () => { - render() - const row = screen.getByTestId('breakdown-transferable') - expect(row).toHaveTextContent('50') + it('shows the scraped source and date per parent', () => { + render() + expect(screen.getByTestId('breakdown-provenance-pd-a')).toHaveTextContent('Alex Hämtad från Försäkringskassan 2026-06-13') }) - it('displays dubbeldagar left', () => { - render() - const row = screen.getByTestId('breakdown-dubbeldagar') - expect(row).toHaveTextContent('60') - }) - - it('displays tier data', () => { - render() - const sjukRow = screen.getByTestId('breakdown-sjukpenning') - expect(sjukRow).toHaveTextContent('80') - expect(sjukRow).toHaveTextContent('100') - - const lagstaRow = screen.getByTestId('breakdown-lagsta-niva') - expect(lagstaRow).toHaveTextContent('170') - expect(lagstaRow).toHaveTextContent('200') - }) - - it('displays scraped source label', () => { - render() - expect(screen.getByText(/Hämtad från Försäkringskassan/)).toBeInTheDocument() - }) - - it('displays manual source label', () => { - const manualData = { ...mockData, source: 'manual' as const } - render() - expect(screen.getByText(/Manuellt angiven/)).toBeInTheDocument() - }) - - it('displays formatted date', () => { - render() - expect(screen.getByText(/2026-06-13/)).toBeInTheDocument() + it('shows the manual source label', () => { + render() + expect(screen.getByTestId('breakdown-provenance-pd-a')).toHaveTextContent('Manuellt angiven') }) it('formats the provenance date using the en-US locale when the language is English', () => { @@ -114,11 +78,12 @@ describe('ParentalLeaveBreakdown', () => { try { render( - + , ) // en-US formats as M/D/YYYY, distinct from the sv-SE "2026-06-13" form. - expect(screen.getByText(/6\/13\/2026/)).toBeInTheDocument() + expect(screen.getByTestId('breakdown-provenance-pd-a')).toHaveTextContent('6/13/2026') + expect(screen.getByTestId('breakdown-sjukpenning-pd-a')).toHaveTextContent('80 of 100') } finally { Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true }) vi.unstubAllGlobals() diff --git a/frontend/src/components/dashboard/ParentalLeaveBreakdown.tsx b/frontend/src/components/dashboard/ParentalLeaveBreakdown.tsx index 2b563d9..5d7f948 100644 --- a/frontend/src/components/dashboard/ParentalLeaveBreakdown.tsx +++ b/frontend/src/components/dashboard/ParentalLeaveBreakdown.tsx @@ -3,14 +3,27 @@ import type { ParentalLeaveFetch } from '@/types' import { LEAVE_RULES } from '@/utils/constants' import { useTranslation } from '@/i18n' -export interface ParentalLeaveBreakdownProps { +/** One parent's balance snapshot — `data` is `null` when nothing has been + * fetched or entered for them yet. */ +export interface ParentalLeaveBreakdownParent { + personalDataUuid: string + name: string data: ParentalLeaveFetch | null } -export function ParentalLeaveBreakdown({ data }: ParentalLeaveBreakdownProps): ReactNode { +export interface ParentalLeaveBreakdownProps { + parents: ParentalLeaveBreakdownParent[] +} + +/** + * The raw balance snapshot per parent (one column each): what is left of + * each tier, transferable and double days, and where the figures came from. + * Lives inside the dashboard's "Detaljer" disclosure. + */ +export function ParentalLeaveBreakdown({ parents }: ParentalLeaveBreakdownProps): ReactNode { const { t, locale } = useTranslation() - if (!data) { + if (parents.length === 0) { return (

{t('parentalLeaveBreakdown.empty')}

@@ -18,54 +31,72 @@ export function ParentalLeaveBreakdown({ data }: ParentalLeaveBreakdownProps): R ) } - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US') - } + const formatDate = (dateString: string) => + new Date(dateString).toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US') - const sourceLabel = - data.source === 'scraped' - ? t('parentalLeaveBreakdown.sourceScraped') - : t('parentalLeaveBreakdown.sourceManual') + const cell = (parent: ParentalLeaveBreakdownParent, testId: string, render: (data: ParentalLeaveFetch) => ReactNode) => ( + + {parent.data ? render(parent.data) : '–'} + + ) + + const leftOfTotal = (left: number, total: number) => t('parentalLeaveBreakdown.leftOfTotal', { left, total }) return (
-
- - - - - - - - - - - - - - - - - - - - {data.tierData && ( - <> - - - - - - - - - - - +
{t('parentalLeaveBreakdown.typeHeader')}{t('parentalLeaveBreakdown.remainingHeader')}{t('parentalLeaveBreakdown.totalHeader')}
{t('parentalLeaveBreakdown.transferableDays')}{data.transferableDaysLeft}—
{t('parentalLeaveBreakdown.dubbeldagarLeft')}{data.dubbeldagarLeft}—
{t('parentalLeaveBreakdown.sjukpenning')}{data.tierData.sjukpenningLeft}{data.tierData.sjukpenningTotal}
{t('parentalLeaveBreakdown.lagstaNiva')}{data.tierData.lagstaNivaLeft}{data.tierData.lagstaNivaTotal}
+ + + + ))} + + + + + + {parents.map((parent) => + cell(parent, 'breakdown-sjukpenning', (d) => + d.tierData ? leftOfTotal(d.tierData.sjukpenningLeft, d.tierData.sjukpenningTotal) : '–', + ), )} - -
+ {parents.map((parent) => ( + + {parent.name} +
{t('parentalLeaveBreakdown.sjukpenning')}
-
+ + + {t('parentalLeaveBreakdown.lagstaNiva')} + {parents.map((parent) => + cell(parent, 'breakdown-lagsta-niva', (d) => + d.tierData ? leftOfTotal(d.tierData.lagstaNivaLeft, d.tierData.lagstaNivaTotal) : '–', + ), + )} + + + {t('parentalLeaveBreakdown.transferableDays')} + {parents.map((parent) => cell(parent, 'breakdown-transferable', (d) => d.transferableDaysLeft))} + + + {t('parentalLeaveBreakdown.dubbeldagarLeft')} + {parents.map((parent) => cell(parent, 'breakdown-dubbeldagar', (d) => d.dubbeldagarLeft))} + + + + +
    + {parents.map((parent) => ( +
  • + {parent.name}{' '} + {parent.data + ? `${ + parent.data.source === 'scraped' + ? t('parentalLeaveBreakdown.sourceScraped') + : t('parentalLeaveBreakdown.sourceManual') + } ${formatDate(parent.data.fetchedAt)}` + : t('parentalLeaveBreakdown.noBalance')} +
  • + ))} +

{t('parentalLeaveBreakdown.reservedNote', { @@ -73,12 +104,6 @@ export function ParentalLeaveBreakdown({ data }: ParentalLeaveBreakdownProps): R transferable: LEAVE_RULES.transferableDaysPerParent, })}

- -
- - {sourceLabel} {formatDate(data.fetchedAt)} - -
) } diff --git a/frontend/src/components/dashboard/PlanFold.test.tsx b/frontend/src/components/dashboard/PlanFold.test.tsx deleted file mode 100644 index 921ea8f..0000000 --- a/frontend/src/components/dashboard/PlanFold.test.tsx +++ /dev/null @@ -1,344 +0,0 @@ -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, - makeShortfallRemedy, - 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 Kalender 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('does not render a shortfalls section when no window has a shortfall', () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: makePlanProposal({ outcomes: [makeWindowOutcome({ shortfallDays: 0, remedies: [] })] }), - }), - ) - render() - expect(screen.queryByTestId('plan-fold-shortfalls')).not.toBeInTheDocument() - }) - - it('renders the shortfall line and its remedies for a window that ran short', () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw-1', - wishName: 'Sportlov', - year: 2030, - shortfallDays: 2, - remedies: [ - makeShortfallRemedy({ - kind: 'carry_vacation_forward', - messageKey: 'planRemedies.carry_vacation_forward', - data: { spare_total: 5 }, - daysFreed: 5, - }), - ], - }), - ], - }), - }), - ) - render() - const shortfall = screen.getByTestId('plan-fold-shortfall-lw-1-2030') - expect(shortfall).toHaveTextContent('Sportlov 2030') - expect(shortfall).toHaveTextContent('2 dag(ar) kunde inte finansieras') - expect(screen.getByTestId('plan-fold-remedy-lw-1-2030-0')).toHaveTextContent( - '5 semesterdagar från tidigare år kan sparas framåt', - ) - }) - - it('renders the widened shift-window remedy with its concrete dates', () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw-2', - wishName: 'Vårvecka', - year: 2028, - shortfallDays: 5, - remedies: [ - makeShortfallRemedy({ - kind: 'shift_window_into_free_days', - messageKey: 'planRemedies.shift_window_into_free_days', - data: { - wish_name: 'Vårvecka', - new_start_date: '2028-04-11', - new_end_date: '2028-04-17', - shift_gain: 2, - }, - daysFreed: 2, - }), - ], - }), - ], - }), - }), - ) - render() - expect(screen.getByTestId('plan-fold-remedy-lw-2-2028-0')).toHaveTextContent( - '2028-04-11 – 2028-04-17', - ) - }) - - it('renders a window with multiple remedies as one line each', () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw-3', - year: 2027, - shortfallDays: 4, - remedies: [ - makeShortfallRemedy({ - kind: 'reduce_preschool_reserve', - messageKey: 'planRemedies.reduce_preschool_reserve', - data: { reserved: 3 }, - daysFreed: 3, - }), - makeShortfallRemedy({ - kind: 'lower_priority_wish', - messageKey: 'planRemedies.lower_priority_wish', - data: { wish_name: 'Höstlov', from_weeks: 2, to_weeks: 1 }, - daysFreed: 4, - }), - ], - }), - ], - }), - }), - ) - render() - expect(screen.getByTestId('plan-fold-remedy-lw-3-2027-0')).toHaveTextContent( - 'Minska förskolans planeringsdagar (3 reserverade) frigör 3 dagar', - ) - expect(screen.getByTestId('plan-fold-remedy-lw-3-2027-1')).toHaveTextContent( - 'Sänk Höstlov från 2 till 1 veckor frigör 4 dagar', - ) - }) - - it('falls back to the raw messageKey for an unrecognised remedy kind', () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw-4', - year: 2027, - shortfallDays: 1, - remedies: [ - makeShortfallRemedy({ - kind: 'brand_new_kind', - messageKey: 'planRemedies.brand_new_kind', - data: {}, - daysFreed: 1, - }), - ], - }), - ], - }), - }), - ) - render() - expect(screen.getByTestId('plan-fold-remedy-lw-4-2027-0')).toHaveTextContent( - 'planRemedies.brand_new_kind', - ) - }) - - 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 deleted file mode 100644 index 47ef085..0000000 --- a/frontend/src/components/dashboard/PlanFold.tsx +++ /dev/null @@ -1,274 +0,0 @@ -/** - * Folds the wished-vs-funded/surplus/balance content that used to live only - * on the Plan tab's `PlanResults.tsx` (since deleted) into Översikt, - * collapsed below the fold. This is now the sole implementation of that - * content — a trimmed-down component, not a shared import. - * - * 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 the old `PlanResults.tsx` (content not duplicated here - * since the Hero and `Age4Bars` already cover it): the warnings section and - * the age4Projections table. The per-window shortfall + remedies section - * (§todo item 13) is NOT dropped — see the "plan-fold-shortfalls" section - * below, a from-scratch render (not a port of the old 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, ShortfallRemedy, 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 - -// §todo item 13, part 3 — the shortfall remedies `_build_remedies` -// (allocation_service.py) already computes and puts on the wire were never -// rendered anywhere (ideas.md §3's "the engine can't explain itself" -// complaint). Mirrors `SURPLUS_ADVICE_BODY_KEYS` below: `messageKey` -> -// the body template that interpolates the remedy's own `data`. -const REMEDY_BODY_KEYS: Record = { - 'planRemedies.reduce_preschool_reserve': 'planResults.remedyBodyReducePreschoolReserve', - 'planRemedies.carry_vacation_forward': 'planResults.remedyBodyCarryVacationForward', - 'planRemedies.lower_priority_wish': 'planResults.remedyBodyLowerPriorityWish', - 'planRemedies.shift_window_into_free_days': 'planResults.remedyBodyShiftWindowIntoFreeDays', -} - -function renderRemedyLine(remedy: ShortfallRemedy, t: Translate): string { - const key = REMEDY_BODY_KEYS[remedy.messageKey] - if (!key) return remedy.messageKey - const params = toTranslationParams(remedy.data) - params.days_freed = remedy.daysFreed - return t(key, params) -} - -// §9.2 surplus-advice kinds — originally mirrored the deleted `PlanResults.tsx`'s -// `SURPLUS_ADVICE_KEYS`/`SURPLUS_ADVICE_BODY_KEYS`; this is now the sole copy -// (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.outcomes.some((o) => o.shortfallDays > 0) && ( -
-

{t('planResults.shortfallsTitle')}

-
    - {proposal.outcomes - .filter((outcome) => outcome.shortfallDays > 0) - .map((outcome) => ( -
  • - - {resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)}{' '} - {outcome.year} - -

    {t('planResults.shortfallLine', { shortfall_days: outcome.shortfallDays })}

    - {outcome.remedies.length > 0 && ( -
      - {outcome.remedies.map((remedy, idx) => ( -
    • - {renderRemedyLine(remedy, t)} -
    • - ))} -
    - )} -
  • - ))} -
-
- )} - - {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/SurplusAdviceTip.test.tsx b/frontend/src/components/dashboard/SurplusAdviceTip.test.tsx new file mode 100644 index 0000000..1e9a703 --- /dev/null +++ b/frontend/src/components/dashboard/SurplusAdviceTip.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { SurplusAdviceTip } from './SurplusAdviceTip' +import { makeSurplusAdvice } from '../../../tests/fixtures' + +const nameFor = (uuid: string) => (uuid === 'pd-b' ? 'Sam' : 'Okänd förälder') + +describe('SurplusAdviceTip', () => { + it('renders a known kind with its translated title and body, resolving the parent name', () => { + render( + , + ) + const tip = screen.getByTestId('dashboard-surplus-advice-0') + expect(tip).toHaveClass('tip-card', 'tip-card--action') + expect(tip).toHaveTextContent('Önska mer ledighet') + expect(tip).toHaveTextContent('Sam har råd med ytterligare 2 veckor ledigt (12 FP-dagar') + expect(tip).not.toHaveTextContent('pd-b') + }) + + it('leaves the parent name unresolved when the advice carries no uuid', () => { + render( + , + ) + const tip = screen.getByTestId('dashboard-surplus-advice-1') + expect(tip).toHaveTextContent('Spara färre semesterdagar') + expect(tip).toHaveTextContent('{parent_name} kan ta ut 3 färre semesterdagar (2027)') + }) + + it('falls back to the raw kind and messageKey for an unrecognised advice', () => { + render( + , + ) + const tip = screen.getByTestId('dashboard-surplus-advice-2') + expect(tip).toHaveTextContent('brand_new_kind') + expect(tip).toHaveTextContent('planSurplusAdvice.brand_new_kind') + }) + + it('has no dismiss control', () => { + render() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/dashboard/SurplusAdviceTip.tsx b/frontend/src/components/dashboard/SurplusAdviceTip.tsx new file mode 100644 index 0000000..af1af68 --- /dev/null +++ b/frontend/src/components/dashboard/SurplusAdviceTip.tsx @@ -0,0 +1,66 @@ +import { ReactNode } from 'react' +import type { SurplusAdvice } from '@/types' +import { useTranslation, toTranslationParams, type TranslationKey } from '@/i18n' + +// §9.2 surplus-advice kinds — `kind` -> title, `messageKey` -> the body +// template that interpolates the advice's own `data`. An unrecognised kind +// (a newer backend rule) falls back to the raw value rather than crashing. +const SURPLUS_ADVICE_TITLE_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', +} + +export interface SurplusAdviceTipProps { + advice: SurplusAdvice + /** Resolves a parent uuid to a display name (never a raw uuid). */ + nameFor: (uuid: string) => string + /** Position in the advice list — only used for the test id. */ + index: number +} + +/** + * One plan-proposal surplus advice rendered in the same visual as a + * backend `TipCard` (same `tip-card` classes) so the dashboard's tips read + * as one list. Advice has no dismiss — it is derived from the plan and goes + * away when the plan changes. + */ +export function SurplusAdviceTip({ advice, nameFor, index }: SurplusAdviceTipProps): ReactNode { + const { t } = useTranslation() + const titleKey = SURPLUS_ADVICE_TITLE_KEYS[advice.kind] + const bodyKey = SURPLUS_ADVICE_BODY_KEYS[advice.messageKey] + const title = titleKey ? t(titleKey) : advice.kind + + let body = advice.messageKey + if (bodyKey) { + 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 + body = t(bodyKey, params) + } + + return ( +
+
+

{title}

+
+

{body}

+
+ ) +} diff --git a/frontend/src/components/dashboard/YearRows.test.tsx b/frontend/src/components/dashboard/YearRows.test.tsx new file mode 100644 index 0000000..2720b66 --- /dev/null +++ b/frontend/src/components/dashboard/YearRows.test.tsx @@ -0,0 +1,372 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { YearRows, type YearRowsParent } from './YearRows' +import { + makeParentYearBalance, + makeParentalLeaveYearBalance, + makePlannedLeaveDay, + makeShortfallRemedy, + makeVacationYearBalance, + makeWindowOutcome, +} from '../../../tests/fixtures' + +const HORIZON = { startDate: '2025-06-01', endDate: '2029-06-01' } + +function balancesFor(uuid: string, years: number[], overrides: Partial> = {}) { + return years.map((year) => + makeParentYearBalance({ + personalDataUuid: uuid, + year, + vacation: makeVacationYearBalance({ year, ...(overrides.vacation ?? {}) }), + parentalLeave: makeParentalLeaveYearBalance({ year, ...(overrides.parentalLeave ?? {}) }), + }), + ) +} + +const YEARS = [2025, 2026, 2027, 2028, 2029] + +const alex: YearRowsParent = { + personalDataUuid: 'pd-a', + name: 'Alex', + yearBalances: balancesFor('pd-a', YEARS, { + vacation: makeVacationYearBalance({ left: 12, carriedOut: 5, expiring: 0 }), + parentalLeave: makeParentalLeaveYearBalance({ sjukpenningLeft: 300, lagstaLeft: 12, sjukpenningUsed: 40, lagstaUsed: 3 }), + }), +} + +const sam: YearRowsParent = { + personalDataUuid: 'pd-b', + name: 'Sam', + yearBalances: balancesFor('pd-b', YEARS, { + vacation: makeVacationYearBalance({ left: 20, carriedOut: 0, expiring: 3, isAssumed: false, earned: 30 }), + parentalLeave: makeParentalLeaveYearBalance({ sjukpenningLeft: 200, lagstaLeft: 40 }), + }), +} + +function renderRows(props: Partial[0]> = {}) { + const onBandClick = vi.fn() + const utils = render( + , + ) + return { ...utils, onBandClick } +} + +describe('YearRows', () => { + describe('rows', () => { + it('renders one collapsed
row per year across the horizon', () => { + renderRows() + for (const year of YEARS) { + const row = screen.getByTestId(`year-row-${year}`) + expect(row.tagName).toBe('DETAILS') + expect(row).not.toHaveAttribute('open') + expect(row).toHaveClass('disclosure') + } + }) + + it('renders no rows when the horizon is inverted', () => { + renderRows({ startDate: '2029-06-01', endDate: '2025-06-01' }) + expect(screen.queryByTestId(/^year-row-\d{4}$/)).not.toBeInTheDocument() + }) + + it('renders a legend with one entry per day type', () => { + renderRows() + const legend = screen.getByTestId('year-rows-legend') + expect(legend).toHaveTextContent('Sjukpenningnivå') + expect(legend).toHaveTextContent('Lägstanivå') + expect(legend).toHaveTextContent('Semester') + }) + }) + + describe('per-parent figures (couple)', () => { + it('shows each parent\'s FP left (sjukpenning + lägsta) and vacation left · saved for the year', () => { + renderRows() + expect(screen.getByTestId('year-row-2026-fp-pd-a')).toHaveTextContent('FP kvar 312') + expect(screen.getByTestId('year-row-2026-vacation-pd-a')).toHaveTextContent('Semester kvar 12 · sparas 5') + expect(screen.getByTestId('year-row-2026-fp-pd-b')).toHaveTextContent('FP kvar 240') + expect(screen.getByTestId('year-row-2026-vacation-pd-b')).toHaveTextContent('Semester kvar 20 · sparas 0') + }) + + it('names each parent in the summary and the open table for a couple', () => { + renderRows() + const row = screen.getByTestId('year-row-2026') + expect(within(row).getByTestId('year-row-2026-parent-pd-a')).toHaveTextContent('Alex') + expect(within(row).getByTestId('year-row-2026-parent-pd-b')).toHaveTextContent('Sam') + const table = within(row).getByTestId('year-row-2026-table') + expect(within(table).getByRole('columnheader', { name: 'Alex' })).toBeInTheDocument() + expect(within(table).getByRole('columnheader', { name: 'Sam' })).toBeInTheDocument() + }) + + it('shows an expiring figure in the summary only when days actually expire', () => { + renderRows() + expect(screen.getByTestId('year-row-2026-expiring-pd-b')).toHaveTextContent('förfaller 3') + expect(screen.queryByTestId('year-row-2026-expiring-pd-a')).not.toBeInTheDocument() + }) + + it('renders a dash for a year the parent has no balance entry for', () => { + const partial: YearRowsParent = { ...alex, yearBalances: balancesFor('pd-a', [2025]) } + renderRows({ parents: [partial, sam] }) + expect(screen.getByTestId('year-row-2027-fp-pd-a')).toHaveTextContent('FP kvar –') + expect(screen.getByTestId('year-row-2027-vacation-pd-a')).toHaveTextContent('Semester kvar –') + expect(screen.getByTestId('year-row-2027-vacation-pd-a')).not.toHaveTextContent('sparas') + const table = screen.getByTestId('year-row-2027-table') + expect(within(table).getAllByText('–').length).toBeGreaterThan(0) + }) + + it('gives the lanes self/partner caps and one lane per parent, self first', () => { + renderRows() + const laneA = screen.getByTestId('year-row-2026-lane-pd-a') + const laneB = screen.getByTestId('year-row-2026-lane-pd-b') + expect(laneA).toHaveClass('year-row__lane--self') + expect(laneB).toHaveClass('year-row__lane--partner') + expect(laneA.compareDocumentPosition(laneB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + }) + + describe('solo parent', () => { + it('omits the parent name and the table header, and gives the single lane no cap', () => { + renderRows({ parents: [alex] }) + const row = screen.getByTestId('year-row-2026') + expect(within(row).getByTestId('year-row-2026-parent-pd-a')).not.toHaveTextContent('Alex') + expect(within(row).queryByRole('columnheader')).not.toBeInTheDocument() + const lane = screen.getByTestId('year-row-2026-lane-pd-a') + expect(lane).not.toHaveClass('year-row__lane--self') + expect(lane).not.toHaveClass('year-row__lane--partner') + }) + }) + + describe('no parent projections yet', () => { + it('still draws every planned day in one anonymous lane, without figures or a table', () => { + renderRows({ + parents: [], + plannedDays: [makePlannedLeaveDay({ id: '1', date: '2026-06-15', parentPersonalDataUuid: 'pd-a' })], + }) + expect(screen.getByTestId('year-row-2026-lane-all')).toBeInTheDocument() + expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveAttribute( + 'title', + 'Semester, 15 juni 2026 – 15 juni 2026 (1 dag). Klicka för att öppna i kalendern.', + ) + expect(screen.queryByTestId('year-row-2026-fp-pd-a')).not.toBeInTheDocument() + expect(screen.queryByTestId('year-row-2026-table')).not.toBeInTheDocument() + }) + }) + + describe('open body table', () => { + it('lists the parental-leave and vacation balances per parent', () => { + renderRows() + const table = screen.getByTestId('year-row-2026-table') + const rowFor = (label: string) => within(table).getByRole('row', { name: new RegExp(label) }) + expect(rowFor('Använda i år · Sjukpenningnivå')).toHaveTextContent('40') + expect(rowFor('Använda i år · Lägstanivå')).toHaveTextContent('3') + expect(rowFor('Kvar vid årets slut · Sjukpenningnivå')).toHaveTextContent('300') + expect(rowFor('Kvar vid årets slut · Lägstanivå')).toHaveTextContent('12') + expect(rowFor('^Intjänade')).toHaveTextContent('25') + expect(rowFor('^Intjänade')).toHaveTextContent('30') + expect(rowFor('Sparade in')).toBeInTheDocument() + expect(rowFor('Planerade')).toBeInTheDocument() + expect(rowFor('^Kvar \\d')).toHaveTextContent('12') + expect(rowFor('Sparas till nästa år')).toHaveTextContent('5') + expect(rowFor('^Förfaller')).toHaveTextContent('3') + }) + + it('badges an assumed vacation entitlement and colours a non-zero expiring value', () => { + renderRows() + const table = screen.getByTestId('year-row-2026-table') + const earned = within(table).getByRole('row', { name: /^Intjänade/ }) + // Alex's entitlement is the statutory default (assumed); Sam's was entered. + expect(within(earned).getAllByText('antaget')).toHaveLength(1) + const expiring = within(table).getByRole('row', { name: /^Förfaller/ }) + const cells = within(expiring).getAllByRole('cell') + expect(cells[0]).not.toHaveClass('year-row__cell--danger') + expect(cells[1]).toHaveClass('year-row__cell--danger') + }) + + it('opens and closes as a native disclosure', async () => { + const user = userEvent.setup() + renderRows() + const row = screen.getByTestId('year-row-2026') + await user.click(row.querySelector('summary') as HTMLElement) + expect(row).toHaveAttribute('open') + await user.click(row.querySelector('summary') as HTMLElement) + expect(row).not.toHaveAttribute('open') + }) + }) + + describe('bands', () => { + it('groups a contiguous run of one parent\'s planned days into a single band in that parent\'s lane', () => { + renderRows({ + plannedDays: [ + makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'vacation', parentPersonalDataUuid: 'pd-a' }), + makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'vacation', parentPersonalDataUuid: 'pd-a' }), + makePlannedLeaveDay({ id: '3', date: '2026-06-17', dayType: 'sjukpenning', parentPersonalDataUuid: 'pd-a' }), + ], + }) + expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(1) + const band = screen.getByTestId('year-timeline-band-2026-0') + expect(band).toHaveAttribute( + 'title', + 'Alex · Semester, 15 juni 2026 – 17 juni 2026 (3 dagar). Klicka för att öppna i kalendern.', + ) + expect(band).toHaveClass('year-row__band--vacation') + expect(screen.getByTestId('year-row-2026-lane-pd-a')).toContainElement(band) + }) + + it('numbers bands across lanes in lane order (self first)', () => { + renderRows({ + plannedDays: [ + makePlannedLeaveDay({ id: '1', date: '2026-08-01', dayType: 'lagsta', parentPersonalDataUuid: 'pd-b' }), + makePlannedLeaveDay({ id: '2', date: '2026-01-05', dayType: 'sjukpenning', parentPersonalDataUuid: 'pd-a' }), + ], + }) + expect(screen.getByTestId('year-row-2026-lane-pd-a')).toContainElement(screen.getByTestId('year-timeline-band-2026-0')) + expect(screen.getByTestId('year-row-2026-lane-pd-b')).toContainElement(screen.getByTestId('year-timeline-band-2026-1')) + expect(screen.getByTestId('year-timeline-band-2026-1')).toHaveClass('year-row__band--lagsta') + }) + + it('splits into separate bands across a gap bigger than a bridged weekend', () => { + renderRows({ + plannedDays: [ + makePlannedLeaveDay({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'pd-a' }), + makePlannedLeaveDay({ id: '2', date: '2026-06-15', parentPersonalDataUuid: 'pd-a' }), + ], + }) + expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(2) + }) + + it('never lets a band span a calendar-year boundary', () => { + renderRows({ + plannedDays: [ + makePlannedLeaveDay({ id: '1', date: '2026-12-30', parentPersonalDataUuid: 'pd-a' }), + makePlannedLeaveDay({ id: '2', date: '2027-01-02', parentPersonalDataUuid: 'pd-a' }), + ], + }) + 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 and days of an unknown parent', () => { + renderRows({ + plannedDays: [ + makePlannedLeaveDay({ id: '1', date: '2030-01-01', parentPersonalDataUuid: 'pd-a' }), + makePlannedLeaveDay({ id: '2', date: '2026-01-01', parentPersonalDataUuid: 'pd-zzz' }), + ], + }) + expect(screen.queryByTestId(/^year-timeline-band-/)).not.toBeInTheDocument() + }) + + it('breaks a tie between types in sjukpenning > lägsta > semester order', () => { + renderRows({ + plannedDays: [ + makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'vacation', parentPersonalDataUuid: 'pd-a' }), + makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'lagsta', parentPersonalDataUuid: 'pd-a' }), + ], + }) + expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveClass('year-row__band--lagsta') + }) + + it('calls onBandClick with the band start date and does not toggle the row', async () => { + const user = userEvent.setup() + const { onBandClick } = renderRows({ + plannedDays: [makePlannedLeaveDay({ id: '1', date: '2026-06-15', parentPersonalDataUuid: 'pd-a' })], + }) + await user.click(screen.getByTestId('year-timeline-band-2026-0')) + expect(onBandClick).toHaveBeenCalledWith('2026-06-15') + expect(screen.getByTestId('year-row-2026')).not.toHaveAttribute('open') + }) + }) + + describe('wish outcomes', () => { + it('renders nothing about wishes when there are no outcomes', () => { + renderRows() + expect(screen.queryByTestId('year-row-2026-wishes')).not.toBeInTheDocument() + }) + + it('lists the year\'s outcomes in start-date order under the table, fully funded ones without a shortfall', () => { + renderRows({ + outcomes: [ + makeWindowOutcome({ wishId: 'lw-2', wishName: 'Höstlov', year: 2026, startDate: '2026-10-26', weeksWished: 1, weeksFunded: 1 }), + makeWindowOutcome({ wishId: 'lw-1', wishName: 'Sommar', year: 2026, weeksWished: 6, weeksFunded: 6 }), + makeWindowOutcome({ wishId: 'lw-3', wishName: 'Sommar', year: 2027, weeksWished: 6, weeksFunded: 4, shortfallDays: 10 }), + ], + }) + const wishes = screen.getByTestId('year-row-2026-wishes') + const items = within(wishes).getAllByRole('listitem') + expect(items[0]).toHaveTextContent('Sommar') + expect(items[0]).toHaveTextContent('6 av 6 veckor') + expect(items[1]).toHaveTextContent('Höstlov') + expect(screen.queryByTestId('year-row-shortfall-lw-1-2026')).not.toBeInTheDocument() + expect(screen.queryByTestId('year-row-wish-lw-3-2026')).not.toBeInTheDocument() + expect(screen.getByTestId('year-row-wish-lw-3-2027')).toBeInTheDocument() + }) + + it('rounds fractional funded weeks to one decimal in the locale notation', () => { + renderRows({ + outcomes: [makeWindowOutcome({ wishId: 'lw-1', year: 2026, weeksWished: 6, weeksFunded: 4.2857 })], + }) + expect(screen.getByTestId('year-row-wish-lw-1-2026')).toHaveTextContent('4,3 av 6 veckor') + }) + + it('resolves a preset wish name through its message key', () => { + renderRows({ + outcomes: [makeWindowOutcome({ wishId: 'lw-1', wishName: 'stale', wishNameMessageKey: 'wishEditor.seasonSommar', year: 2026 })], + }) + expect(screen.getByTestId('year-row-wish-lw-1-2026')).not.toHaveTextContent('stale') + expect(screen.getByTestId('year-row-wish-lw-1-2026')).toHaveTextContent('Sommar') + }) + + it('renders the shortfall line and each remedy for a window that ran short', () => { + renderRows({ + outcomes: [ + makeWindowOutcome({ + wishId: 'lw-1', + wishName: 'Sportlov', + year: 2027, + weeksWished: 2, + weeksFunded: 1, + shortfallDays: 2, + remedies: [ + makeShortfallRemedy({ + kind: 'carry_vacation_forward', + messageKey: 'planRemedies.carry_vacation_forward', + data: { spare_total: 5 }, + daysFreed: 5, + }), + makeShortfallRemedy({ + kind: 'lower_priority_wish', + messageKey: 'planRemedies.lower_priority_wish', + data: { wish_name: 'Höstlov', from_weeks: 2, to_weeks: 1 }, + daysFreed: 4, + }), + makeShortfallRemedy({ kind: 'novel', messageKey: 'planRemedies.novel', data: {}, daysFreed: 1 }), + ], + }), + ], + }) + const wish = screen.getByTestId('year-row-wish-lw-1-2027') + expect(wish).toHaveTextContent('1 av 2 veckor') + expect(screen.getByTestId('year-row-shortfall-lw-1-2027')).toHaveTextContent('2 dag(ar) kunde inte finansieras') + expect(screen.getByTestId('year-row-remedy-lw-1-2027-0')).toHaveTextContent( + '5 semesterdagar från tidigare år kan sparas framåt', + ) + expect(screen.getByTestId('year-row-remedy-lw-1-2027-1')).toHaveTextContent( + 'Sänk Höstlov från 2 till 1 veckor frigör 4 dagar', + ) + expect(screen.getByTestId('year-row-remedy-lw-1-2027-2')).toHaveTextContent('planRemedies.novel') + }) + + it('shows the shortfall line without a remedy list when the engine has none', () => { + renderRows({ + outcomes: [makeWindowOutcome({ wishId: 'lw-1', year: 2026, shortfallDays: 3, remedies: [] })], + }) + expect(screen.getByTestId('year-row-shortfall-lw-1-2026')).toBeInTheDocument() + expect(screen.queryByTestId('year-row-remedy-lw-1-2026-0')).not.toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/components/dashboard/YearRows.tsx b/frontend/src/components/dashboard/YearRows.tsx new file mode 100644 index 0000000..7ed31d7 --- /dev/null +++ b/frontend/src/components/dashboard/YearRows.tsx @@ -0,0 +1,463 @@ +import { MouseEvent, ReactNode } from 'react' +import type { ParentYearBalance, PlannedLeaveDay, ShortfallRemedy, WindowOutcome } from '@/types' +import { useTranslation, toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n' +import { getLabelForType, getTypeKey } from '@/utils/leaveTypes' +import { formatDateForDisplay } from '@/utils/dates' +import { resolveWishName } from '@/utils/wishName' + +type Translate = (key: TranslationKey, params?: TranslationParams) => string + +/** One parent's lane in the rows — already resolved to a display name + * (never a raw uuid) and ordered self-first by the caller. */ +export interface YearRowsParent { + personalDataUuid: string + name: string + /** `Age4Projection.yearBalances` for this parent. */ + yearBalances: ParentYearBalance[] +} + +export interface YearRowsProps { + parents: YearRowsParent[] + 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 + /** The plan proposal's per-wish outcomes (empty when there are no wishes). */ + outcomes: WindowOutcome[] + /** Called with a band's ISO-8601 start date when clicked. */ + onBandClick: (date: string) => void +} + +interface Band { + startDate: string + endDate: string + dominantType: PlannedLeaveDay['dayType'] + /** Planned days actually in the band — a bridged weekend inside it is not one. */ + dayCount: number +} + +interface Lane { + personalDataUuid: string | null + name: string | null + bands: Band[] +} + +// 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 + +// Legend order, and the tie-break order when a band's days split evenly. +const TYPE_ORDER: PlannedLeaveDay['dayType'][] = ['sjukpenning', 'lagsta', 'vacation'] + +// The shortfall remedies `_build_remedies` (allocation_service.py) puts on +// the wire: `messageKey` -> the body template interpolating the remedy's +// own `data`. +const REMEDY_BODY_KEYS: Record = { + 'planRemedies.reduce_preschool_reserve': 'planResults.remedyBodyReducePreschoolReserve', + 'planRemedies.carry_vacation_forward': 'planResults.remedyBodyCarryVacationForward', + 'planRemedies.lower_priority_wish': 'planResults.remedyBodyLowerPriorityWish', + 'planRemedies.shift_window_into_free_days': 'planResults.remedyBodyShiftWindowIntoFreeDays', +} + +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_ORDER[0]! + let bestCount = -1 + for (const type of TYPE_ORDER) { + 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), + dayCount: current.length, + }) + 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 lane per parent (self first, as given); with no parent projections + * yet, a single anonymous lane carries every planned day so the timeline + * still shows the plan. */ +function buildLanes(parents: YearRowsParent[], plannedDays: PlannedLeaveDay[], startDate: string, endDate: string): Lane[] { + if (parents.length === 0) { + return [{ personalDataUuid: null, name: null, bands: buildBands(plannedDays, startDate, endDate) }] + } + return parents.map((parent) => ({ + personalDataUuid: parent.personalDataUuid, + name: parent.name, + bands: buildBands( + plannedDays.filter((d) => d.parentPersonalDataUuid === parent.personalDataUuid), + startDate, + endDate, + ), + })) +} + +function renderRemedyLine(remedy: ShortfallRemedy, t: Translate): string { + const key = REMEDY_BODY_KEYS[remedy.messageKey] + if (!key) return remedy.messageKey + const params = toTranslationParams(remedy.data) + params.days_freed = remedy.daysFreed + return t(key, params) +} + +/** + * One disclosure row per calendar year from the child's birth to their 4th + * birthday: the year, a thin per-parent timeline of planned leave (click a + * band to open the calendar there), and each parent's "what is left at the + * end of this year" figures for both parental-leave days and vacation. + * Opening a row shows the full per-parent balance table for that year plus + * the plan proposal's wish outcomes. + */ +export function YearRows({ parents, plannedDays, startDate, endDate, outcomes, onBandClick }: YearRowsProps): ReactNode { + const { t, locale } = useTranslation() + const startYear = toUtcDate(startDate).getUTCFullYear() + const endYear = toUtcDate(endDate).getUTCFullYear() + const years = startYear <= endYear ? Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i) : [] + const lanes = buildLanes(parents, plannedDays, startDate, endDate) + const couple = parents.length > 1 + + const balanceFor = (parent: YearRowsParent, year: number): ParentYearBalance | undefined => + parent.yearBalances.find((b) => b.year === year) + + // The engine reports funded weeks as a fraction of the wish (4.29 of 6); + // one decimal in the locale's own notation is all a glance needs. + const formatWeeks = (weeks: number): string => + weeks.toLocaleString(locale === 'sv' ? 'sv-SE' : 'en-GB', { maximumFractionDigits: 1 }) + + const handleBandClick = (event: MouseEvent, date: string) => { + // The band sits inside the row's ; cancelling the click keeps + // the row from toggling on top of the navigation. + event.preventDefault() + onBandClick(date) + } + + return ( +
+
    + {TYPE_ORDER.map((type) => ( +
  • +
  • + ))} +
+ + {years.map((year) => { + const yearLength = daysInYear(year) + const outcomesForYear = outcomes + .filter((o) => o.year === year) + .sort((a, b) => a.startDate.localeCompare(b.startDate)) + let bandIndex = 0 + return ( +
+ + {year} + + + {lanes.map((lane, laneIndex) => ( + + {lane.bands + .filter((band) => toUtcDate(band.startDate).getUTCFullYear() === year) + .map((band) => { + const idx = bandIndex++ + const left = ((dayOfYearUtc(toUtcDate(band.startDate)) - 1) / yearLength) * 100 + const width = + ((dayOfYearUtc(toUtcDate(band.endDate)) - dayOfYearUtc(toUtcDate(band.startDate)) + 1) / yearLength) * 100 + const text = t(band.dayCount === 1 ? 'yearRow.bandLabelOneDay' : 'yearRow.bandLabel', { + dayType: getLabelForType(band.dominantType, t), + startDate: formatDateForDisplay(band.startDate, locale), + endDate: formatDateForDisplay(band.endDate, locale), + dayCount: band.dayCount, + }) + const label = lane.name ? `${lane.name} · ${text}` : text + return ( + + +
+ {parents.length > 0 && ( + + {couple && ( + + + + ))} + + + )} + + + + + b.parentalLeave.sjukpenningUsed} + /> + b.parentalLeave.lagstaUsed} + /> + b.parentalLeave.sjukpenningLeft} + emphasis + /> + b.parentalLeave.lagstaLeft} + emphasis + /> + + + + b.vacation.earned} + badge={(b) => (b.vacation.isAssumed ? t('yearRow.assumed') : null)} + /> + b.vacation.carriedIn} /> + b.vacation.plannedUse} /> + b.vacation.left} emphasis /> + b.vacation.carriedOut} + /> + b.vacation.expiring} + danger={(b) => b.vacation.expiring > 0} + /> + +
+ {parents.map((parent) => ( + + {parent.name} +
+ {t('yearRow.parentalLeaveTitle')} +
+ {t('yearRow.vacationTitle')} +
+ )} + + {outcomesForYear.length > 0 && ( +
+

{t('yearRow.wishesTitle')}

+
    + {outcomesForYear.map((outcome) => ( +
  • + + {resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)} + + 0 ? 'year-row__wish-weeks year-row__wish-weeks--short' : 'year-row__wish-weeks'}> + {t('yearRow.weeksFunded', { + funded: formatWeeks(outcome.weeksFunded), + wished: formatWeeks(outcome.weeksWished), + })} + + {outcome.shortfallDays > 0 && ( + + {t('planResults.shortfallLine', { shortfall_days: outcome.shortfallDays })} + + )} + {outcome.shortfallDays > 0 && outcome.remedies.length > 0 && ( +
      + {outcome.remedies.map((remedy, idx) => ( +
    • + {renderRemedyLine(remedy, t)} +
    • + ))} +
    + )} +
  • + ))} +
+
+ )} +
+
+ ) + })} +
+ ) +} + +interface BalanceRowProps { + label: string + parents: YearRowsParent[] + year: number + pick: (balance: ParentYearBalance) => number + /** Optional small badge text after the value (e.g. "antaget"). */ + badge?: (balance: ParentYearBalance) => string | null + /** Danger ink on the value when true for that parent. */ + danger?: (balance: ParentYearBalance) => boolean + /** A "what is left" row — heavier value weight. */ + emphasis?: boolean +} + +function BalanceRow({ label, parents, year, pick, badge, danger, emphasis }: BalanceRowProps): ReactNode { + return ( + + + {label} + + {parents.map((parent) => { + const balance = parent.yearBalances.find((b) => b.year === year) + const badgeText = balance && badge ? badge(balance) : null + const isDanger = balance && danger ? danger(balance) : false + return ( + + {badgeText && {badgeText}} + {balance ? pick(balance) : '–'} + + ) + })} + + ) +} diff --git a/frontend/src/components/dashboard/YearTimeline.test.tsx b/frontend/src/components/dashboard/YearTimeline.test.tsx deleted file mode 100644 index e7f0c7f..0000000 --- a/frontend/src/components/dashboard/YearTimeline.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -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, 15 juni 2026 – 17 juni 2026 (3 dagar). Klicka för att öppna denna månad i kalendern.', - ) - }) - - 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') - }) - - it('renders an explanatory caption below the title', () => { - render( - , - ) - expect(screen.getByText(/Varje rad är ett år/)).toBeInTheDocument() - }) - - it('renders a colour legend with one entry per day type', () => { - render( - , - ) - const legend = screen.getByTestId('year-timeline-legend') - expect(legend).toHaveTextContent('Sjukpenningnivå') - expect(legend).toHaveTextContent('Lägstanivå') - expect(legend).toHaveTextContent('Semester') - }) - - it('includes the day count in a band\'s label', () => { - render( - , - ) - expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveAttribute( - 'title', - 'Semester, 15 juni 2026 – 15 juni 2026 (1 dag). Klicka för att öppna denna månad i kalendern.', - ) - }) -}) diff --git a/frontend/src/components/dashboard/YearTimeline.tsx b/frontend/src/components/dashboard/YearTimeline.tsx deleted file mode 100644 index 8e21f6f..0000000 --- a/frontend/src/components/dashboard/YearTimeline.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { ReactNode } from 'react' -import type { PlannedLeaveDay } from '@/types' -import { useTranslation } from '@/i18n' -import { getColorForType, getLabelForType, getTypeModifier } from '@/utils/leaveTypes' -import { formatDateForDisplay } from '@/utils/dates' - -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'] - /** Number of planned days actually in this band (not the calendar span — - * a bridged weekend/röd dag inside the band isn't a planned day). */ - dayCount: number -} - -// 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), - dayCount: current.length, - }) - 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, locale } = 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')}

-

{t('yearTimeline.caption')}

-
    - {TYPE_PRIORITY.map((type) => ( -
  • -
  • - ))} -
- {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( - band.dayCount === 1 ? 'yearTimeline.bandLabelOneDay' : 'yearTimeline.bandLabel', - { - dayType: getLabelForType(band.dominantType, t), - startDate: formatDateForDisplay(band.startDate, locale), - endDate: formatDateForDisplay(band.endDate, locale), - dayCount: band.dayCount, - }, - ) - return ( -
-
- ) - })} -
- ) -} diff --git a/frontend/src/components/inputs/DayOverridesInput.test.tsx b/frontend/src/components/inputs/DayOverridesInput.test.tsx deleted file mode 100644 index 2f9d165..0000000 --- a/frontend/src/components/inputs/DayOverridesInput.test.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { DayOverridesInput } from './DayOverridesInput' - -describe('DayOverridesInput', () => { - const mockOverrides = [ - { - id: 'do-1', - workspaceId: 'ws-1', - date: '2026-12-24', - isWorkingDay: true, - note: 'Jobbade på juldagen', - createdAt: '2026-01-01T00:00:00', - updatedAt: '2026-01-01T00:00:00', - }, - { - id: 'do-2', - workspaceId: 'ws-1', - date: '2026-06-20', - isWorkingDay: false, - note: null, - createdAt: '2026-01-01T00:00:00', - updatedAt: '2026-01-01T00:00:00', - }, - ] - - let mockOnAdd: ReturnType - let mockOnRemove: ReturnType - - beforeEach(() => { - mockOnAdd = vi.fn().mockResolvedValue(undefined) - mockOnRemove = vi.fn().mockResolvedValue(undefined) - }) - - describe('rendering', () => { - it('renders list of existing overrides', () => { - render( - - ) - - expect(screen.getByText('24 december 2026')).toBeInTheDocument() - expect(screen.getByText('20 juni 2026')).toBeInTheDocument() - }) - - it('renders working day override with correct status', () => { - render( - - ) - - expect(screen.getByTestId('override-item-do-1').textContent).toContain('Jag jobbar den här dagen') - }) - - it('renders off day override with correct status', () => { - render( - - ) - - expect(screen.getByTestId('override-item-do-2').textContent).toContain('Jag är ledig den här dagen') - }) - - it('renders note when present', () => { - render( - - ) - - expect(screen.getByText('Anteckning: Jobbade på juldagen')).toBeInTheDocument() - }) - - it('renders empty state when no overrides', () => { - render( - - ) - - expect(screen.getByText('Du har inga undantag i kalendern ännu.')).toBeInTheDocument() - }) - - it('renders add form with all fields', () => { - render( - - ) - - expect(screen.getByTestId('override-date-input')).toBeInTheDocument() - expect(screen.getByTestId('override-working-radio')).toBeInTheDocument() - expect(screen.getByTestId('override-off-radio')).toBeInTheDocument() - expect(screen.getByTestId('override-note-input')).toBeInTheDocument() - expect(screen.getByTestId('add-override-button')).toBeInTheDocument() - }) - }) - - describe('adding override', () => { - it('calls onAdd with correct payload when working day is selected', async () => { - render( - - ) - - fireEvent.change(screen.getByTestId('override-date-input'), { target: { value: '2026-12-24' } }) - fireEvent.click(screen.getByTestId('override-working-radio')) - fireEvent.change(screen.getByTestId('override-note-input'), { target: { value: 'Jobbar denna dag' } }) - fireEvent.click(screen.getByTestId('add-override-button')) - - await waitFor(() => { - expect(mockOnAdd).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - date: '2026-12-24', - isWorkingDay: true, - note: 'Jobbar denna dag', - }) - }) - }) - - it('calls onAdd with correct payload when off day is selected', async () => { - render( - - ) - - fireEvent.change(screen.getByTestId('override-date-input'), { target: { value: '2026-06-20' } }) - fireEvent.click(screen.getByTestId('override-off-radio')) - fireEvent.click(screen.getByTestId('add-override-button')) - - await waitFor(() => { - expect(mockOnAdd).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - date: '2026-06-20', - isWorkingDay: false, - note: null, - }) - }) - }) - - it('clears form after successful add', async () => { - render( - - ) - - const dateInput = screen.getByTestId('override-date-input') as HTMLInputElement - const noteInput = screen.getByTestId('override-note-input') as HTMLInputElement - - fireEvent.change(dateInput, { target: { value: '2026-12-24' } }) - fireEvent.change(noteInput, { target: { value: 'Test note' } }) - fireEvent.click(screen.getByTestId('add-override-button')) - - await waitFor(() => { - expect(dateInput.value).toBe('') - expect(noteInput.value).toBe('') - }) - }) - - it('disables submit button when date is empty', () => { - render( - - ) - - const submitButton = screen.getByTestId('add-override-button') as HTMLButtonElement - expect(submitButton.disabled).toBe(true) - }) - - it('does not call onAdd when the form is submitted with no date entered', async () => { - render( - - ) - - // Submit the form directly (bypassing the disabled submit button) to - // exercise the handler's own empty-date guard. - fireEvent.submit(screen.getByTestId('add-override-form')) - - await waitFor(() => { - expect(mockOnAdd).not.toHaveBeenCalled() - }) - }) - }) - - describe('removing override', () => { - it('calls onRemove with correct id', async () => { - render( - - ) - - fireEvent.click(screen.getByTestId('remove-override-do-1')) - - await waitFor(() => { - expect(mockOnRemove).toHaveBeenCalledWith('do-1') - }) - }) - - it('swallows an onRemove failure instead of throwing (handled by the parent)', async () => { - mockOnRemove.mockRejectedValueOnce(new Error('delete failed')) - render( - - ) - - fireEvent.click(screen.getByTestId('remove-override-do-1')) - - await waitFor(() => { - expect(mockOnRemove).toHaveBeenCalledWith('do-1') - }) - // The item is still rendered — the component didn't crash on the rejection. - expect(screen.getByTestId('override-item-do-1')).toBeInTheDocument() - }) - }) - - describe('loading state', () => { - it('disables form during loading', () => { - render( - - ) - - expect((screen.getByTestId('override-date-input') as HTMLInputElement).disabled).toBe(true) - expect((screen.getByTestId('add-override-button') as HTMLButtonElement).disabled).toBe(true) - }) - }) -}) diff --git a/frontend/src/components/inputs/DayOverridesInput.tsx b/frontend/src/components/inputs/DayOverridesInput.tsx deleted file mode 100644 index f03a35d..0000000 --- a/frontend/src/components/inputs/DayOverridesInput.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { ReactNode, useState } from 'react' -import { formatDateForDisplay } from '@/utils/dates' -import { useTranslation } from '@/i18n' -import type { DayOverride, DayOverrideCreateRequest } from '@/types' - -export interface DayOverridesInputProps { - overrides: DayOverride[] - isLoading?: boolean - onAdd: (req: DayOverrideCreateRequest) => Promise - onRemove: (id: string) => Promise - workspaceId: string -} - -export function DayOverridesInput({ - overrides, - isLoading = false, - onAdd, - onRemove, - workspaceId, -}: DayOverridesInputProps): ReactNode { - const { t, locale } = useTranslation() - const [date, setDate] = useState('') - const [isWorkingDay, setIsWorkingDay] = useState(true) - const [note, setNote] = useState('') - const [isSubmitting, setIsSubmitting] = useState(false) - - const handleAddOverride = async (e: React.FormEvent) => { - e.preventDefault() - if (!date) return - - try { - setIsSubmitting(true) - await onAdd({ - workspaceId, - date, - isWorkingDay, - note: note || null, - }) - setDate('') - setIsWorkingDay(true) - setNote('') - } finally { - setIsSubmitting(false) - } - } - - const handleRemove = async (id: string) => { - try { - await onRemove(id) - } catch { - // Error handling is done in parent - } - } - - const sortedOverrides = [...overrides].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) - - return ( -
- {overrides.length === 0 ? ( -
-

{t('dayOverridesInput.empty')}

-
- ) : ( -
-
    - {sortedOverrides.map((override) => ( -
  • -
    -
    - {formatDateForDisplay(override.date, locale)} -
    -
    - - {override.isWorkingDay ? t('dayOverridesInput.workingStatus') : t('dayOverridesInput.offStatus')} - -
    -
    - {override.note && ( -
    - {t('dayOverridesInput.noteLabel', { note: override.note })} -
    - )} - -
  • - ))} -
-
- )} - -
-

{t('dayOverridesInput.addSectionTitle')}

- -
- - setDate(e.target.value)} - required - disabled={isSubmitting || isLoading} - data-testid="override-date-input" - className="field__control" - /> -
- -
-
- {t('dayOverridesInput.statusLegend')} - - -
-
- -
- - setNote(e.target.value)} - maxLength={280} - disabled={isSubmitting || isLoading} - data-testid="override-note-input" - className="field__control" - /> - - {t('dayOverridesInput.charCount', { count: note.length })} - -
- - -
-
- ) -} diff --git a/frontend/src/hooks/useParentalLeaves.test.ts b/frontend/src/hooks/useParentalLeaves.test.ts new file mode 100644 index 0000000..7993bf5 --- /dev/null +++ b/frontend/src/hooks/useParentalLeaves.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useParentalLeaves } from './useParentalLeaves' +import * as api from '@/services/api' +import { makeParentalLeaveFetch } from '../../tests/fixtures' + +vi.mock('@/services/api') + +const flush = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + +describe('useParentalLeaves', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('starts empty and does not fetch without a child or members', () => { + const { result } = renderHook(() => useParentalLeaves([], null)) + expect(result.current.data).toEqual({}) + expect(result.current.isLoading).toBe(false) + expect(api.getParentalLeave).not.toHaveBeenCalled() + }) + + it('does not fetch when there are members but no child', () => { + renderHook(() => useParentalLeaves(['pd-a'], null)) + expect(api.getParentalLeave).not.toHaveBeenCalled() + }) + + it('fetches one snapshot per member and keys the result by uuid, keeping null for a parent with none', async () => { + const alex = makeParentalLeaveFetch({ personalDataUuid: 'pd-a' }) + vi.mocked(api.getParentalLeave).mockImplementation(async (uuid) => (uuid === 'pd-a' ? alex : null)) + + const { result } = renderHook(() => useParentalLeaves(['pd-a', 'pd-b'], 'child-1')) + await flush() + + expect(api.getParentalLeave).toHaveBeenCalledWith('pd-a', 'child-1') + expect(api.getParentalLeave).toHaveBeenCalledWith('pd-b', 'child-1') + expect(result.current.data).toEqual({ 'pd-a': alex, 'pd-b': null }) + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBeNull() + }) + + it('surfaces a fetch error and clears the map', async () => { + vi.mocked(api.getParentalLeave).mockRejectedValue(new Error('Fetch failed')) + const { result } = renderHook(() => useParentalLeaves(['pd-a'], 'child-1')) + await flush() + expect(result.current.error).toBe('Fetch failed') + expect(result.current.data).toEqual({}) + }) + + it('falls back to a generic message when a non-Error value is thrown', async () => { + vi.mocked(api.getParentalLeave).mockRejectedValue('boom') + const { result } = renderHook(() => useParentalLeaves(['pd-a'], 'child-1')) + await flush() + expect(result.current.error).toBe('Failed to fetch parental leave data') + }) + + it('refetches when the membership changes, not on a mere array identity change', async () => { + vi.mocked(api.getParentalLeave).mockResolvedValue(null) + const { rerender } = renderHook(({ uuids }: { uuids: string[] }) => useParentalLeaves(uuids, 'child-1'), { + initialProps: { uuids: ['pd-a'] }, + }) + await flush() + expect(api.getParentalLeave).toHaveBeenCalledTimes(1) + + rerender({ uuids: ['pd-a'] }) + await flush() + expect(api.getParentalLeave).toHaveBeenCalledTimes(1) + + rerender({ uuids: ['pd-a', 'pd-b'] }) + await flush() + expect(api.getParentalLeave).toHaveBeenCalledTimes(3) + }) + + it('clears the map when the child identity is lost', async () => { + vi.mocked(api.getParentalLeave).mockResolvedValue(makeParentalLeaveFetch()) + const { result, rerender } = renderHook( + ({ childId }: { childId: string | null }) => useParentalLeaves(['pd-a'], childId), + { initialProps: { childId: 'child-1' as string | null } }, + ) + await flush() + expect(Object.keys(result.current.data)).toEqual(['pd-a']) + + rerender({ childId: null }) + await flush() + expect(result.current.data).toEqual({}) + }) + + it('exposes refresh, which fetches again', async () => { + vi.mocked(api.getParentalLeave).mockResolvedValue(null) + const { result } = renderHook(() => useParentalLeaves(['pd-a'], 'child-1')) + await flush() + await act(async () => { + await result.current.refresh() + }) + expect(api.getParentalLeave).toHaveBeenCalledTimes(2) + }) + + it('ignores a stale response that lands after a newer request started', async () => { + let resolveFirst: (value: null) => void = () => undefined + vi.mocked(api.getParentalLeave) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve + }), + ) + .mockResolvedValue(makeParentalLeaveFetch({ personalDataUuid: 'pd-b' })) + + const { result, rerender } = renderHook(({ uuids }: { uuids: string[] }) => useParentalLeaves(uuids, 'child-1'), { + initialProps: { uuids: ['pd-a'] }, + }) + rerender({ uuids: ['pd-b'] }) + await flush() + expect(Object.keys(result.current.data)).toEqual(['pd-b']) + + await act(async () => { + resolveFirst(null) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(Object.keys(result.current.data)).toEqual(['pd-b']) + }) +}) diff --git a/frontend/src/hooks/useParentalLeaves.ts b/frontend/src/hooks/useParentalLeaves.ts new file mode 100644 index 0000000..16df08c --- /dev/null +++ b/frontend/src/hooks/useParentalLeaves.ts @@ -0,0 +1,73 @@ +import { useState, useEffect, useCallback } from 'react' +import * as api from '@/services/api' +import { useLatestRequest } from './useLatestRequest' +import type { ParentalLeaveFetch } from '@/types' + +/** uuid → that parent's latest balance snapshot, or `null` when nothing has + * been saved for them yet. A uuid that is absent (not `null`) has not been + * fetched yet — callers that gate an "estimated" hint must treat the two + * differently. */ +export type ParentalLeaveByMember = Record + +interface UseParentalLeavesResult { + data: ParentalLeaveByMember + isLoading: boolean + error: string | null + refresh: () => Promise +} + +/** + * `useParentalLeave` for every member of a workspace at once — one + * `GET /api/parental-leave/{uuid}/{childId}` per member, in parallel. Feeds + * the dashboard's per-parent balance snapshot, where a single-parent fetch + * left the partner's figures invisible. + * + * Deliberately not exported from the `@/hooks` barrel: `Dashboard.tsx` + * imports it directly so its page test can mock this one module without + * touching the barrel's automock. + */ +export function useParentalLeaves(memberUuids: string[], childId: string | null): UseParentalLeavesResult { + const [data, setData] = useState({}) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const { start, isCurrent } = useLatestRequest() + + // A fresh array identity every render would re-run the effect below on + // every render; the joined key only changes when the membership does. + const memberKey = memberUuids.join('\n') + + const fetch = useCallback(async () => { + const requestId = start() + const uuids = memberKey === '' ? [] : memberKey.split('\n') + if (!childId || uuids.length === 0) { + // Identity gone (logout, no child yet) — clear rather than keep the + // previous account's figures around until new ids arrive. + setData({}) + setError(null) + return + } + setIsLoading(true) + setError(null) + try { + const results = await Promise.all(uuids.map((uuid) => api.getParentalLeave(uuid, childId))) + if (!isCurrent(requestId)) return + const next: ParentalLeaveByMember = {} + uuids.forEach((uuid, index) => { + next[uuid] = results[index] ?? null + }) + setData(next) + } catch (err) { + if (!isCurrent(requestId)) return + setError(err instanceof Error ? err.message : 'Failed to fetch parental leave data') + setData({}) + } finally { + if (isCurrent(requestId)) setIsLoading(false) + } + }, [memberKey, childId, start, isCurrent]) + + useEffect(() => { + void fetch() + }, [fetch]) + + return { data, isLoading, error, refresh: fetch } +} diff --git a/frontend/src/i18n/LanguageContext.test.tsx b/frontend/src/i18n/LanguageContext.test.tsx index d0c7f9f..1ec4bff 100644 --- a/frontend/src/i18n/LanguageContext.test.tsx +++ b/frontend/src/i18n/LanguageContext.test.tsx @@ -109,7 +109,7 @@ describe('LanguageContext', () => { expect( translate('en', 'parentalLeaveBreakdown.reservedNote', { reserved: 90, transferable: 45 }), ).toBe( - '90 of your days are reserved and cannot be transferred to the other parent (up to 45 days can be transferred).', + '90 of each parent\'s days are reserved and cannot be transferred to the other parent (up to 45 days can be transferred).', ) }) diff --git a/frontend/src/i18n/en/dashboard.ts b/frontend/src/i18n/en/dashboard.ts index 198f0e0..8a670d9 100644 --- a/frontend/src/i18n/en/dashboard.ts +++ b/frontend/src/i18n/en/dashboard.ts @@ -2,44 +2,55 @@ 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.balancePickerPrompt': 'Whose balance is this?', + 'dashboard.balanceLink': 'Update balances', 'dashboard.daysLeft': 'Days left', - 'dashboard.enterManually': 'Enter details manually', 'dashboard.estimatedBadge': 'Estimated', - 'dashboard.estimatedBadgeCaption': 'No real balances have been fetched yet — these figures use the statutory assumption (480 days, none used). Fetch from Försäkringskassan or enter details manually for exact figures.', + 'dashboard.estimatedBadgeCaption': 'No real balances have been fetched yet — these figures assume 480 days, none used. Update balances under Settings.', 'dashboard.expiringAtB4': 'Expiring at age 4 if the current plan is followed (all parents)', 'dashboard.expiringAtB4ForParent': 'Expiring at age 4 if the current plan is followed – {name}', 'dashboard.expiringAtB4ParentFallback': 'Parent {number}', + // Still referenced by `Onboarding.tsx`'s balances step; the balance flow + // is moving to a `balance.*` namespace — drop this key once nothing else + // reads it. 'dashboard.fetchFromFk': 'Fetch from Försäkringskassan', + 'dashboard.figureExpiring': 'Expiring at age 4', + 'dashboard.figureLeft': 'Days left', + 'dashboard.figureUsed': 'Used', 'dashboard.hero.hasPlanMessage': '{atRisk} days are at risk of disappearing when {childName} turns 4 ({date}).', 'dashboard.hero.noneAtRiskMessage': 'No days will be lost — your plan uses them all before {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.leaveOverviewTitle': 'Until age 4', + 'dashboard.moreDetailsSummary': 'Details', 'dashboard.overviewTitle': 'Overview', - 'dashboard.planFold.noWishesHint': 'Add wishes under Calendar to see a proposal here.', - 'dashboard.planFold.summary': 'Plan proposal for the years ahead', - 'dashboard.planFold.unknownParent': 'Unknown parent', - 'dashboard.plannedDaysTitle': 'Planned days', - 'dashboard.saveManualFetchError': 'Could not save the details', 'dashboard.usedDays': 'Used days', - 'daysBreakdown.daysHeader': 'Days', - 'daysBreakdown.empty': 'No days registered yet', - 'daysBreakdown.typeHeader': 'Leave type', 'overviewMetrics.defaultTitle': 'Overview', 'parentalLeaveBreakdown.dubbeldagarLeft': 'Double days left', - 'parentalLeaveBreakdown.empty': 'No data fetched yet — press Fetch from Försäkringskassan.', + 'parentalLeaveBreakdown.empty': 'No balances fetched yet.', 'parentalLeaveBreakdown.lagstaNiva': 'Minimum rate', - 'parentalLeaveBreakdown.remainingHeader': 'Remaining', - 'parentalLeaveBreakdown.reservedNote': '{reserved} of your days are reserved and cannot be transferred to the other parent (up to {transferable} days can be transferred).', + 'parentalLeaveBreakdown.leftOfTotal': '{left} of {total}', + 'parentalLeaveBreakdown.noBalance': 'No balance yet', + 'parentalLeaveBreakdown.reservedNote': '{reserved} of each parent\'s days are reserved and cannot be transferred to the other parent (up to {transferable} days can be transferred).', 'parentalLeaveBreakdown.sjukpenning': 'Sjukpenningnivå (income-based)', 'parentalLeaveBreakdown.sourceManual': 'Manually entered', 'parentalLeaveBreakdown.sourceScraped': 'Fetched from Försäkringskassan', - 'parentalLeaveBreakdown.totalHeader': 'Total', 'parentalLeaveBreakdown.transferableDays': 'Transferable days', - 'parentalLeaveBreakdown.typeHeader': 'Type', - 'yearTimeline.bandLabel': '{dayType}, {startDate} – {endDate} ({dayCount} days). Click to open this month in the calendar.', - 'yearTimeline.bandLabelOneDay': '{dayType}, {startDate} – {endDate} (1 day). Click to open this month in the calendar.', - 'yearTimeline.caption': 'Each row is one year; each colored band is a planned leave period. Click a band to open that month in the calendar.', - 'yearTimeline.title': 'The years ahead', + 'yearRow.assumed': 'assumed', + 'yearRow.bandLabel': '{dayType}, {startDate} – {endDate} ({dayCount} days). Click to open in the calendar.', + 'yearRow.bandLabelOneDay': '{dayType}, {startDate} – {endDate} (1 day). Click to open in the calendar.', + 'yearRow.carriedIn': 'Carried in', + 'yearRow.carriedOut': 'saved {days}', + 'yearRow.carriedOutRow': 'Saved for next year', + 'yearRow.earned': 'Earned', + 'yearRow.expiring': 'expiring {days}', + 'yearRow.expiringRow': 'Expiring', + 'yearRow.fpLeft': 'PL left', + 'yearRow.left': 'Left', + 'yearRow.leftAtYearEnd': 'Left at year end', + 'yearRow.parentalLeaveTitle': 'Parental leave', + 'yearRow.plannedUse': 'Planned', + 'yearRow.usedThisYear': 'Used this year', + 'yearRow.vacationLeft': 'Vacation left', + 'yearRow.vacationTitle': 'Vacation', + 'yearRow.weeksFunded': '{funded} of {wished} weeks', + 'yearRow.wishesTitle': 'Leave periods', } as const diff --git a/frontend/src/i18n/en/plan.ts b/frontend/src/i18n/en/plan.ts index 242dd51..7e07fca 100644 --- a/frontend/src/i18n/en/plan.ts +++ b/frontend/src/i18n/en/plan.ts @@ -1,16 +1,11 @@ // `plan.*` (the standalone Plan tab) and most `planResults.*` keys were // retired with the Plan/Tips tabs (leave is now authored on the calendar and // Översikt carries the headline figures — see PLANNING_ENGINE_SPEC.md's -// superseding note). What's left here is the subset `planResults.*` -// `PlanFold.tsx` (the read-only plan-proposal fold on Översikt) and -// `frontend/src/utils/rejectionMessages.ts` still render. +// superseding note). What's left here is the subset the dashboard's year +// rows (`YearRows.tsx`: shortfall + remedy lines), its surplus-advice tips +// (`SurplusAdviceTip.tsx`) and `frontend/src/utils/rejectionMessages.ts` +// still render. export const plan = { - 'planResults.balancesTitle': 'Remaining balances after the plan', - 'planResults.dubbeldagarLeftHeader': 'Double days left', - 'planResults.lagstaLeftHeader': 'Lägstanivå days left', - 'planResults.leavesHeader': 'Leave periods', - 'planResults.noWindowsInHorizon': 'No windows within the planning horizon.', - 'planResults.parentHeader': 'Parent', 'planResults.remedyBodyCarryVacationForward': '{spare_total} vacation day(s) from earlier years can be carried forward', 'planResults.remedyBodyLowerPriorityWish': 'Lowering {wish_name} from {from_weeks} to {to_weeks} week(s) frees {days_freed} day(s)', 'planResults.remedyBodyReducePreschoolReserve': 'Reducing the preschool planning-day reserve ({reserved} reserved) frees {days_freed} day(s)', @@ -26,22 +21,13 @@ export const plan = { 'planResults.rejectionInvalidDayType': 'Day {index} ({date}): invalid day type "{day_type}"', 'planResults.rejectionOutsideEligibleWindow': '{date}: outside the child\'s eligible window ({birth_date} – {hard_expiry})', 'planResults.shortfallLine': '{shortfall_days} day(s) could not be funded', - 'planResults.shortfallsTitle': 'Periods with a shortfall', - 'planResults.sjukpenningLeftHeader': 'Sjukpenningnivå days left', 'planResults.surplusAdviceBodyFrontLoadEarlyYears': '{parent_name} has a large surplus ({days_involved} parental-benefit days) — take more days now while the child is still small and you are home anyway, rather than spreading them thinly until the 4th birthday.', 'planResults.surplusAdviceBodyShiftMixToFp': '{parent_name} could swap {days_involved} vacation day(s) for parental-benefit days in this plan — the parental-benefit days would otherwise be forfeited at the 4th birthday, while the vacation days would not.', 'planResults.surplusAdviceBodyUseLessVacation': '{parent_name} could take {days_involved} fewer vacation day(s) ({years_text}) without going below the statutory minimum ({min_vacation_days_per_year} days/year) — save them instead to cover parental-benefit days that would otherwise be forfeited.', 'planResults.surplusAdviceBodyWishMoreLeave': '{parent_name} can afford {weeks} more week(s) of leave ({days_involved} parental-benefit days would otherwise be forfeited at the child\'s 4th birthday) — add a week in the summer or an autumn break.', 'planResults.surplusAdviceFrontLoadEarlyYears': 'Take more leave while the child is small', - 'planResults.surplusAdviceIntro': 'This plan leaves days unused that would otherwise be lost at the child\'s 4th birthday. A few suggestions:', 'planResults.surplusAdviceShiftMixToFp': 'Spend parental-benefit days instead of vacation', - 'planResults.surplusAdviceTitle': 'You have unused days', 'planResults.surplusAdviceUseLessVacation': 'Save fewer vacation days', 'planResults.surplusAdviceWishMoreLeave': 'Wish for more leave', - 'planResults.totalAvailableHeader': 'Total available', 'planResults.unknownParent': 'Unknown parent', - 'planResults.usedHeader': 'Used', - 'planResults.weeksUnit': 'weeks', - 'planResults.yearHeader': 'Year', - 'planResults.yearOverviewTitle': 'Overview by year', } as const diff --git a/frontend/src/i18n/sv/dashboard.ts b/frontend/src/i18n/sv/dashboard.ts index 9d24b90..2cdc325 100644 --- a/frontend/src/i18n/sv/dashboard.ts +++ b/frontend/src/i18n/sv/dashboard.ts @@ -2,44 +2,55 @@ 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.balancePickerPrompt': 'Vem gäller det?', + 'dashboard.balanceLink': 'Uppdatera saldon', 'dashboard.daysLeft': 'Kvarvarande dagar', - 'dashboard.enterManually': 'Ange uppgifter manuellt', 'dashboard.estimatedBadge': 'Uppskattat', - 'dashboard.estimatedBadgeCaption': 'Inga verkliga saldon är hämtade än — siffrorna bygger på det lagstadgade antagandet (480 dagar, inget uttaget). Hämta från Försäkringskassan eller ange uppgifter manuellt för exakta siffror.', + 'dashboard.estimatedBadgeCaption': 'Inga verkliga saldon är hämtade än — siffrorna utgår från 480 dagar, inget uttaget. Uppdatera saldon under Inställningar.', 'dashboard.expiringAtB4': 'Förfaller vid 4 år om nuvarande plan följs (alla föräldrar)', 'dashboard.expiringAtB4ForParent': 'Förfaller vid 4 år om nuvarande plan följs – {name}', 'dashboard.expiringAtB4ParentFallback': 'Förälder {number}', + // Still referenced by `Onboarding.tsx`'s balances step; the balance flow + // is moving to a `balance.*` namespace — drop this key once nothing else + // reads it. 'dashboard.fetchFromFk': 'Hämta från Försäkringskassan', + 'dashboard.figureExpiring': 'Förfaller vid 4 år', + 'dashboard.figureLeft': 'Dagar kvar', + 'dashboard.figureUsed': 'Använda', 'dashboard.hero.hasPlanMessage': '{atRisk} dagar riskerar att försvinna när {childName} fyller 4 ({date}).', 'dashboard.hero.noneAtRiskMessage': 'Inga dagar går förlorade — er plan hinner använda alla innan {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.leaveOverviewTitle': 'Fram till 4 år', + 'dashboard.moreDetailsSummary': 'Detaljer', 'dashboard.overviewTitle': 'Översikt', - 'dashboard.planFold.noWishesHint': 'Lägg till önskemål under Kalender 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.saveManualFetchError': 'Kunde inte spara uppgifterna', 'dashboard.usedDays': 'Använda dagar', - 'daysBreakdown.daysHeader': 'Dagar', - 'daysBreakdown.empty': 'Inga dagar registrerade än', - 'daysBreakdown.typeHeader': 'Typ av ledighet', 'overviewMetrics.defaultTitle': 'Översikt', 'parentalLeaveBreakdown.dubbeldagarLeft': 'Dubbeldagar kvar', - 'parentalLeaveBreakdown.empty': 'Ingen data hämtad ännu — tryck på Hämta från Försäkringskassan.', - 'parentalLeaveBreakdown.lagstaNiva': 'Lägsta nivå', - 'parentalLeaveBreakdown.remainingHeader': 'Återstående', - 'parentalLeaveBreakdown.reservedNote': '{reserved} av dina dagar är reserverade och kan inte överlåtas till den andra föräldern (max {transferable} dagar kan överlåtas).', - 'parentalLeaveBreakdown.sjukpenning': 'Sjukpenning', + 'parentalLeaveBreakdown.empty': 'Inga saldon hämtade än.', + 'parentalLeaveBreakdown.lagstaNiva': 'Lägstanivå', + 'parentalLeaveBreakdown.leftOfTotal': '{left} av {total}', + 'parentalLeaveBreakdown.noBalance': 'Inget saldo än', + 'parentalLeaveBreakdown.reservedNote': '{reserved} av varje förälders dagar är reserverade och kan inte överlåtas till den andra föräldern (max {transferable} dagar kan överlåtas).', + 'parentalLeaveBreakdown.sjukpenning': 'Sjukpenningnivå', 'parentalLeaveBreakdown.sourceManual': 'Manuellt angiven', 'parentalLeaveBreakdown.sourceScraped': 'Hämtad från Försäkringskassan', - 'parentalLeaveBreakdown.totalHeader': 'Totalt', 'parentalLeaveBreakdown.transferableDays': 'Överlåtbara dagar', - 'parentalLeaveBreakdown.typeHeader': 'Typ', - 'yearTimeline.bandLabel': '{dayType}, {startDate} – {endDate} ({dayCount} dagar). Klicka för att öppna denna månad i kalendern.', - 'yearTimeline.bandLabelOneDay': '{dayType}, {startDate} – {endDate} (1 dag). Klicka för att öppna denna månad i kalendern.', - 'yearTimeline.caption': 'Varje rad är ett år och varje färgad stapel en planerad ledighetsperiod. Klicka på en stapel för att öppna den månaden i kalendern.', - 'yearTimeline.title': 'De kommande åren', -} as const \ No newline at end of file + 'yearRow.assumed': 'antaget', + 'yearRow.bandLabel': '{dayType}, {startDate} – {endDate} ({dayCount} dagar). Klicka för att öppna i kalendern.', + 'yearRow.bandLabelOneDay': '{dayType}, {startDate} – {endDate} (1 dag). Klicka för att öppna i kalendern.', + 'yearRow.carriedIn': 'Sparade in', + 'yearRow.carriedOut': 'sparas {days}', + 'yearRow.carriedOutRow': 'Sparas till nästa år', + 'yearRow.earned': 'Intjänade', + 'yearRow.expiring': 'förfaller {days}', + 'yearRow.expiringRow': 'Förfaller', + 'yearRow.fpLeft': 'FP kvar', + 'yearRow.left': 'Kvar', + 'yearRow.leftAtYearEnd': 'Kvar vid årets slut', + 'yearRow.parentalLeaveTitle': 'Föräldrapenning', + 'yearRow.plannedUse': 'Planerade', + 'yearRow.usedThisYear': 'Använda i år', + 'yearRow.vacationLeft': 'Semester kvar', + 'yearRow.vacationTitle': 'Semester', + 'yearRow.weeksFunded': '{funded} av {wished} veckor', + 'yearRow.wishesTitle': 'Ledigheter', +} as const diff --git a/frontend/src/i18n/sv/plan.ts b/frontend/src/i18n/sv/plan.ts index cbbb8db..1bcb4dc 100644 --- a/frontend/src/i18n/sv/plan.ts +++ b/frontend/src/i18n/sv/plan.ts @@ -1,16 +1,11 @@ // `plan.*` (the standalone Plan tab) and most `planResults.*` keys were // retired with the Plan/Tips tabs (leave is now authored on the calendar and // Översikt carries the headline figures — see PLANNING_ENGINE_SPEC.md's -// superseding note). What's left here is the subset `planResults.*` -// `PlanFold.tsx` (the read-only plan-proposal fold on Översikt) and -// `frontend/src/utils/rejectionMessages.ts` still render. +// superseding note). What's left here is the subset the dashboard's year +// rows (`YearRows.tsx`: shortfall + remedy lines), its surplus-advice tips +// (`SurplusAdviceTip.tsx`) and `frontend/src/utils/rejectionMessages.ts` +// still render. export const plan = { - 'planResults.balancesTitle': 'Kvarvarande saldon efter planen', - 'planResults.dubbeldagarLeftHeader': 'Dubbeldagar kvar', - 'planResults.lagstaLeftHeader': 'Lägstanivådagar kvar', - 'planResults.leavesHeader': 'Ledigheter', - 'planResults.noWindowsInHorizon': 'Inga fönster inom planeringshorisonten.', - 'planResults.parentHeader': 'Förälder', 'planResults.remedyBodyCarryVacationForward': '{spare_total} semesterdagar från tidigare år kan sparas framåt', 'planResults.remedyBodyLowerPriorityWish': 'Sänk {wish_name} från {from_weeks} till {to_weeks} veckor frigör {days_freed} dagar', 'planResults.remedyBodyReducePreschoolReserve': 'Minska förskolans planeringsdagar ({reserved} reserverade) frigör {days_freed} dagar', @@ -26,22 +21,13 @@ export const plan = { 'planResults.rejectionInvalidDayType': 'Dag {index} ({date}): ogiltig dagtyp "{day_type}"', 'planResults.rejectionOutsideEligibleWindow': '{date}: utanför barnets tillåtna period ({birth_date} – {hard_expiry})', 'planResults.shortfallLine': '{shortfall_days} dag(ar) kunde inte finansieras', - 'planResults.shortfallsTitle': 'Perioder med underskott', - 'planResults.sjukpenningLeftHeader': 'Sjukpenningdagar kvar', 'planResults.surplusAdviceBodyFrontLoadEarlyYears': '{parent_name} har ett stort överskott ({days_involved} FP-dagar) — ta ut fler dagar nu medan barnet är litet och ni ändå är hemma, istället för att sprida ut dem tunt fram till 4-årsdagen.', 'planResults.surplusAdviceBodyShiftMixToFp': '{parent_name} kan byta {days_involved} semesterdagar mot föräldrapenningdagar under planen — FP-dagarna går annars förlorade vid 4-årsdagen, medan semestern inte gör det.', 'planResults.surplusAdviceBodyUseLessVacation': '{parent_name} kan ta ut {days_involved} färre semesterdagar ({years_text}) utan att gå under lägsta uttaget ({min_vacation_days_per_year} dagar/år) — spara dem istället för att täcka FP-dagar som annars går förlorade.', 'planResults.surplusAdviceBodyWishMoreLeave': '{parent_name} har råd med ytterligare {weeks} veckor ledigt ({days_involved} FP-dagar går annars förlorade vid barnets 4-årsdag) — lägg till en vecka på sommaren eller ett höstlov.', 'planResults.surplusAdviceFrontLoadEarlyYears': 'Ta ut mer ledighet medan barnet är litet', - 'planResults.surplusAdviceIntro': 'Planen lämnar dagar outnyttjade som annars går förlorade vid barnets 4-årsdag. Några förslag:', 'planResults.surplusAdviceShiftMixToFp': 'Använd föräldradagar istället för semester', - 'planResults.surplusAdviceTitle': 'Ni har outnyttjade dagar', '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.weeksUnit': 'veckor', - 'planResults.yearHeader': 'År', - 'planResults.yearOverviewTitle': 'Översikt över åren', } as const diff --git a/frontend/src/pages/Dashboard.test.tsx b/frontend/src/pages/Dashboard.test.tsx index 3d7477d..af2fe2b 100644 --- a/frontend/src/pages/Dashboard.test.tsx +++ b/frontend/src/pages/Dashboard.test.tsx @@ -1,33 +1,40 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { BrowserRouter } from 'react-router-dom' import { Dashboard } from './Dashboard' import * as hooks from '@/hooks' -import * as api from '@/services/api' +import { useParentalLeaves } from '@/hooks/useParentalLeaves' import { useWorkspace, useAuth } from '@/context' import { makeAuthContextValue, makeWorkspaceContextValue, makeAnalyticsResult, - makeParentalLeaveResult, makePlannedDaysResult, - makeScraperFetchResult, makeWorkspaceMembersResult, makePlannedLeaveDay, makeOptimizationTipsResult, makeLeaveWishesResult, makePlanProposalResult, + makePlanProposal, + makeSurplusAdvice, + makeWindowOutcome, makeParentalLeaveFetch, - makeTierData, + makeParentYearBalance, + makeVacationYearBalance, + makeParentalLeaveYearBalance, + makeAge4Projection, + makeWorkspaceOverview, + makeLeaveWish, + makePersonalData, makeTip, } from '../../tests/fixtures' const mockNavigate = vi.fn() vi.mock('@/hooks') +vi.mock('@/hooks/useParentalLeaves') 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 } @@ -51,116 +58,124 @@ const renderWithRouter = (component: React.ReactElement) => { return render({component}) } +// `useParentalLeaves` is not in the fixtures barrel (it is deliberately kept +// out of the `@/hooks` barrel too) — build its result locally. +function makeParentalLeavesResult( + overrides: Partial> = {}, +): ReturnType { + return { data: {}, isLoading: false, error: null, refresh: vi.fn(), ...overrides } +} + +const workspaceValue = (overrides: Partial> = {}) => + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, + workspaceId: 'ws-1', + children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], + selectedChildId: 'c-1', + personalDataUuid: 'pd-1', + parentName: 'Test Parent', + ...overrides, + }) + +const alex = makePersonalData({ uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex' }) +const sam = makePersonalData({ uuid: 'pd-2', workspaceId: 'ws-1', name: 'Sam' }) + +function projection(uuid: string, overrides: Partial> = {}) { + return makeAge4Projection({ + personalDataUuid: uuid, + childId: 'c-1', + birthDate: '2022-01-01', + fourthBirthday: '2026-01-01', + yearBalances: [2022, 2023, 2024, 2025, 2026].map((year) => + makeParentYearBalance({ + personalDataUuid: uuid, + childId: 'c-1', + year, + vacation: makeVacationYearBalance({ year, left: uuid === 'pd-1' ? 12 : 20, carriedOut: 5 }), + parentalLeave: makeParentalLeaveYearBalance({ year, sjukpenningLeft: 100, lagstaLeft: 20 }), + }), + ), + ...overrides, + }) +} + +function coupleAnalytics(overrides: Partial> = {}) { + return makeAnalyticsResult({ + data: makeWorkspaceOverview({ + workspaceId: 'ws-1', + childId: 'c-1', + parentProjections: [ + projection('pd-1', { daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 }), + projection('pd-2', { daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 }), + ], + combinedDaysUsed: 50, + combinedDaysLeft: 190, + combinedExpiringAtB4: 104, + ...overrides, + }), + }) +} + describe('Dashboard', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(useAuth).mockReturnValue(makeAuthContextValue()) - vi.mocked(useWorkspace).mockReturnValue( - makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, - workspaceId: 'ws-1', - children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], - selectedChildId: 'c-1', - personalDataUuid: 'pd-1', - parentName: 'Test Parent', - }) - ) + vi.mocked(useWorkspace).mockReturnValue(workspaceValue()) vi.mocked(hooks.useAnalytics).mockReturnValue( makeAnalyticsResult({ - data: { + data: makeWorkspaceOverview({ workspaceId: 'ws-1', childId: 'c-1', parentProjections: [], combinedDaysUsed: 30, combinedDaysLeft: 270, combinedExpiringAtB4: 100, - yearTotals: [], - }, - }) - ) - vi.mocked(hooks.useParentalLeave).mockReturnValue( - makeParentalLeaveResult({ - data: { - id: 'pf-1', - personalDataUuid: 'pd-1', - workspaceId: 'ws-1', - childId: 'c-1', - source: 'scraped', - fetchedAt: '2026-06-13T10:00:00Z', - totalDays: 300, - daysUsed: 30, - daysLeft: 270, - reservedDaysTotal: 90, - reservedDaysLeft: 80, - transferableDaysLeft: 50, - dubbeldagarLeft: 60, - tierData: { - sjukpenningTotal: 100, - sjukpenningUsed: 10, - sjukpenningLeft: 90, - lagstaNivaTotal: 200, - lagstaNivaUsed: 20, - lagstaNivaLeft: 180, - }, - createdAt: '2026-06-13T10:00:00Z', - updatedAt: '2026-06-13T10:00:00Z', - }, + }), }) ) vi.mocked(hooks.usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [ - makePlannedLeaveDay({ id: '1', date: '2026-06-15' }), - makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'sjukpenning' }), - ], - }) - ) - vi.mocked(hooks.useScraperFetch).mockReturnValue(makeScraperFetchResult()) - vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( - makeWorkspaceMembersResult({ - data: [ - { uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, - { uuid: 'pd-2', workspaceId: 'ws-1', name: 'Sam', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, + makePlannedLeaveDay({ id: '1', date: '2026-06-15', parentPersonalDataUuid: 'pd-1' }), + makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'sjukpenning', parentPersonalDataUuid: 'pd-1' }), ], }) ) + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(makeWorkspaceMembersResult({ data: [alex, sam] })) vi.mocked(hooks.useOptimizationTips).mockReturnValue(makeOptimizationTipsResult()) vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult()) vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult()) - // Default: nothing saved yet for whoever's balance form gets opened — - // `useLatestBalance` (a real hook, imported directly by Dashboard.tsx to - // bypass the `vi.mock('@/hooks')` above) falls back to the form's - // preset. Tests that care about a saved balance override this. - vi.mocked(api.getParentalLeave).mockResolvedValue(null) + // Default: both parents have a saved balance, so nothing is "estimated". + vi.mocked(useParentalLeaves).mockReturnValue( + makeParentalLeavesResult({ + data: { + 'pd-1': makeParentalLeaveFetch({ personalDataUuid: 'pd-1', childId: 'c-1', source: 'scraped', fetchedAt: '2026-06-13T10:00:00Z' }), + 'pd-2': makeParentalLeaveFetch({ personalDataUuid: 'pd-2', childId: 'c-1', source: 'manual', fetchedAt: '2026-06-14T10:00:00Z' }), + }, + }), + ) }) - it('renders loading spinner initially', () => { - vi.mocked(useWorkspace).mockReturnValue( - makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, - workspaceId: 'ws-1', - children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], - selectedChildId: 'c-1', - personalDataUuid: 'pd-1', - parentName: 'Test Parent', - isLoading: true, - }) - ) + it('renders loading skeleton initially', () => { + vi.mocked(useWorkspace).mockReturnValue(workspaceValue({ isLoading: true })) + renderWithRouter() + expect(screen.getByTestId('page-skeleton')).toBeInTheDocument() + }) + + it('shows the skeleton while analytics or planned days load', () => { + vi.mocked(hooks.useAnalytics).mockReturnValue(makeAnalyticsResult({ data: null, isLoading: true })) + const { unmount } = renderWithRouter() + expect(screen.getByTestId('page-skeleton')).toBeInTheDocument() + unmount() + + vi.mocked(hooks.useAnalytics).mockReturnValue(makeAnalyticsResult()) + vi.mocked(hooks.usePlannedDays).mockReturnValue(makePlannedDaysResult({ data: null, isLoading: true })) renderWithRouter() expect(screen.getByTestId('page-skeleton')).toBeInTheDocument() }) it('hands off to Onboarding when there are no children yet', () => { - vi.mocked(useWorkspace).mockReturnValue( - makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, - workspaceId: 'ws-1', - children: [], - selectedChildId: null, - personalDataUuid: 'pd-1', - parentName: 'Test Parent', - }) - ) + vi.mocked(useWorkspace).mockReturnValue(workspaceValue({ children: [], selectedChildId: null })) renderWithRouter() expect(screen.getByTestId('onboarding-page')).toBeInTheDocument() expect(screen.queryByTestId('dashboard')).not.toBeInTheDocument() @@ -175,17 +190,8 @@ describe('Dashboard', () => { // the only externally-observable signal this component gets that the // member list it already fetched might now be stale. const refreshMembers = vi.fn().mockResolvedValue(undefined) - vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( - makeWorkspaceMembersResult({ refresh: refreshMembers, data: [] }), - ) - const soloWorkspace = makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, - workspaceId: 'ws-1', - children: [], - selectedChildId: null, - personalDataUuid: 'pd-1', - parentName: 'Test Parent', - }) + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(makeWorkspaceMembersResult({ refresh: refreshMembers, data: [] })) + const soloWorkspace = workspaceValue({ children: [], selectedChildId: null }) vi.mocked(useWorkspace).mockReturnValue(soloWorkspace) const { rerender } = renderWithRouter() @@ -209,17 +215,38 @@ describe('Dashboard', () => { await waitFor(() => expect(refreshMembers).toHaveBeenCalledTimes(1)) }) - describe('estimated-figures badge', () => { - it('is hidden once real parental-leave balances have been fetched (the default mock)', () => { + it('displays error message if analytics fails', () => { + vi.mocked(hooks.useAnalytics).mockReturnValue(makeAnalyticsResult({ data: null, error: 'Failed to load analytics' })) + renderWithRouter() + expect(screen.getByTestId('error-banner')).toHaveTextContent('Failed to load analytics') + expect(screen.queryByTestId('key-figures')).not.toBeInTheDocument() + }) + + describe('estimated-figures line', () => { + it('is hidden once every parent has a saved balance (the default mock)', () => { renderWithRouter() expect(screen.queryByTestId('dashboard-estimate-badge')).not.toBeInTheDocument() }) - it('is shown when no parental-leave balance has ever been fetched, labelling the figures as estimates', () => { - vi.mocked(hooks.useParentalLeave).mockReturnValue(makeParentalLeaveResult({ data: null })) + it('is shown, labelled "Uppskattat", when any parent has no saved balance yet', () => { + vi.mocked(useParentalLeaves).mockReturnValue( + makeParentalLeavesResult({ data: { 'pd-1': makeParentalLeaveFetch(), 'pd-2': null } }), + ) renderWithRouter() expect(screen.getByTestId('dashboard-estimate-badge')).toHaveTextContent('Uppskattat') }) + + it('never flashes while the balances are still being fetched', () => { + vi.mocked(useParentalLeaves).mockReturnValue(makeParentalLeavesResult({ data: {}, isLoading: true })) + renderWithRouter() + expect(screen.queryByTestId('dashboard-estimate-badge')).not.toBeInTheDocument() + }) + + it('is not shown for a member whose balance has not been fetched at all (absent, not null)', () => { + vi.mocked(useParentalLeaves).mockReturnValue(makeParentalLeavesResult({ data: {} })) + renderWithRouter() + expect(screen.queryByTestId('dashboard-estimate-badge')).not.toBeInTheDocument() + }) }) describe('Hero', () => { @@ -243,532 +270,252 @@ describe('Dashboard', () => { 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, yearBalances: [] }, - ], - combinedDaysUsed: 30, - combinedDaysLeft: 210, - combinedExpiringAtB4: 60, - yearTotals: [], - }, - }) + coupleAnalytics({ parentProjections: [projection('pd-1', { fourthBirthday: '2029-06-01' })] }), ) 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', () => { + it('renders the three key figures from the combined analytics', () => { + renderWithRouter() + expect(screen.getByTestId('dashboard-figure-left')).toHaveTextContent('270') + expect(screen.getByTestId('dashboard-figure-used')).toHaveTextContent('30') + expect(screen.getByTestId('dashboard-figure-expiring')).toHaveTextContent('100') + }) + + describe('"Fram till 4 år" section', () => { + it('renders each parent bar (self first) using the backend-resolved savableCapAtB4, never re-derived', () => { + vi.mocked(useWorkspace).mockReturnValue(workspaceValue({ personalDataUuid: 'pd-2' })) 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, yearBalances: [] }, - { 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, yearBalances: [] }, - ], - combinedDaysUsed: 31, - combinedDaysLeft: 279, - combinedExpiringAtB4: 191, - yearTotals: [], - }, + coupleAnalytics({ + parentProjections: [ + projection('pd-1', { projectedLeftAtB4: 40, savableAfterB4: 40, expiringAtB4: 0, savableCapAtB4: 48 }), + projection('pd-2', { projectedLeftAtB4: 239, savableAfterB4: 48, expiringAtB4: 191, savableCapAtB4: 48 }), + ], }) ) renderWithRouter() + const section = screen.getByTestId('year-overview') + expect(within(section).getByRole('heading', { level: 2 })).toHaveTextContent('Fram till 4 år') 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') + // Sam is the signed-in user here, so Sam's bar comes first. + const bars = screen.getAllByTestId(/^age4-bar-pd-/) + expect(bars[0]).toHaveAttribute('data-testid', 'age4-bar-pd-2') }) - it('is not rendered when there are no parent projections yet', () => { + it('renders no bars but still the year rows when there are no parent projections yet', () => { + vi.mocked(hooks.usePlannedDays).mockReturnValue( + makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: '1', date: '2024-06-15', parentPersonalDataUuid: 'pd-1' })] }), + ) 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() + expect(screen.getByTestId(`year-row-${year}`)).toBeInTheDocument() } + expect(screen.getByTestId('year-row-2024-lane-all')).toContainElement(screen.getByTestId('year-timeline-band-2024-0')) + }) + + it('feeds each parent\'s yearBalances into the year rows', () => { + vi.mocked(hooks.useAnalytics).mockReturnValue(coupleAnalytics()) + renderWithRouter() + expect(screen.getByTestId('year-row-2024-fp-pd-1')).toHaveTextContent('FP kvar 120') + expect(screen.getByTestId('year-row-2024-vacation-pd-1')).toHaveTextContent('Semester kvar 12 · sparas 5') + expect(screen.getByTestId('year-row-2024-vacation-pd-2')).toHaveTextContent('Semester kvar 20 · sparas 5') }) 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' })], + data: [makePlannedLeaveDay({ id: '1', date: '2024-06-15', dayType: 'vacation', parentPersonalDataUuid: 'pd-1' })], }) ) renderWithRouter() await user.click(screen.getByTestId('year-timeline-band-2024-0')) expect(mockNavigate).toHaveBeenCalledWith('/calendar?date=2024-06-15') }) + + it('falls back to a numbered parent label while members are still loading', () => { + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(makeWorkspaceMembersResult({ data: [], isLoading: true })) + vi.mocked(hooks.useAnalytics).mockReturnValue(coupleAnalytics()) + renderWithRouter() + expect(screen.getByTestId('age4-bar-pd-1')).toHaveTextContent('Förälder 1') + expect(screen.getByTestId('age4-bar-pd-2')).toHaveTextContent('Förälder 2') + }) + + it('falls back to a numbered parent label for a projection with no matching member', () => { + vi.mocked(hooks.useAnalytics).mockReturnValue( + coupleAnalytics({ parentProjections: [projection('pd-1'), projection('pd-unknown')] }), + ) + renderWithRouter() + expect(screen.getByTestId('age4-bar-pd-1')).toHaveTextContent('Alex') + expect(screen.getByTestId('age4-bar-pd-unknown')).toHaveTextContent('Förälder 2') + }) + }) + + describe('plan proposal', () => { + it('auto-proposes once wishes are loaded and none is in flight', () => { + const propose = vi.fn() + vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [makeLeaveWish()] })) + vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose })) + renderWithRouter() + expect(propose).toHaveBeenCalledTimes(1) + }) + + it('does not propose without wishes, while wishes load, while proposing, once proposed, or after a failure', () => { + const propose = vi.fn() + const cases: Array<[ReturnType, ReturnType]> = [ + [makeLeaveWishesResult({ data: [] }), makePlanProposalResult({ propose })], + [makeLeaveWishesResult({ data: [], isLoading: true }), makePlanProposalResult({ propose })], + [makeLeaveWishesResult({ data: [makeLeaveWish()] }), makePlanProposalResult({ propose, isLoading: true })], + [makeLeaveWishesResult({ data: [makeLeaveWish()] }), makePlanProposalResult({ propose, data: makePlanProposal() })], + [makeLeaveWishesResult({ data: [makeLeaveWish()] }), makePlanProposalResult({ propose, error: 'nope' })], + ] + for (const [wishes, proposal] of cases) { + vi.mocked(hooks.useLeaveWishes).mockReturnValue(wishes) + vi.mocked(hooks.usePlanProposal).mockReturnValue(proposal) + const { unmount } = renderWithRouter() + unmount() + } + expect(propose).not.toHaveBeenCalled() + }) + + it('surfaces a proposal error inside the section', () => { + vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ error: 'Kunde inte räkna' })) + renderWithRouter() + expect(screen.getByTestId('dashboard-proposal-error')).toHaveTextContent('Kunde inte räkna') + }) + + it('passes the proposal\'s outcomes to the matching year row', () => { + vi.mocked(hooks.usePlanProposal).mockReturnValue( + makePlanProposalResult({ + data: makePlanProposal({ + outcomes: [makeWindowOutcome({ wishId: 'lw-1', wishName: 'Sommar', year: 2024, weeksWished: 6, weeksFunded: 4, shortfallDays: 10 })], + }), + }), + ) + renderWithRouter() + expect(screen.getByTestId('year-row-wish-lw-1-2024')).toHaveTextContent('Sommar') + expect(screen.getByTestId('year-row-wish-lw-1-2024')).toHaveTextContent('4 av 6 veckor') + }) }) describe('Tips', () => { - it('is hidden entirely when there are no tips', () => { + it('is hidden entirely when there are neither tips nor surplus advice', () => { renderWithRouter() expect(screen.queryByTestId('dashboard-tips')).not.toBeInTheDocument() }) - it('renders tip cards when tips exist', () => { + it('renders tip cards and dismisses through the hook', async () => { + const user = userEvent.setup() + const dismiss = vi.fn() vi.mocked(hooks.useOptimizationTips).mockReturnValue( - makeOptimizationTipsResult({ data: [makeTip({ id: 'tip-1', messageKey: 'tips.use_vacation_before_expiry' })] }) + makeOptimizationTipsResult({ data: [makeTip({ id: 'tip-1', messageKey: 'tips.use_vacation_before_expiry' })], dismiss }) ) 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() - }) - - it('shows parental leave breakdown when data exists', () => { - renderWithRouter() - expect(screen.getByTestId('parental-leave-breakdown')).toBeInTheDocument() - // ParentalLeaveBreakdown shows an explanatory note about reserved days - // rather than a "Reserverade dagar" heading/row — see - // ParentalLeaveBreakdown.test.tsx ("displays a reserved-days explanatory - // note instead of a countdown"). - expect(screen.getByTestId('reserved-days-note')).toHaveTextContent(/reserverade/i) - }) - - it('renders FK fetch button', () => { - renderWithRouter() - expect(screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })).toBeInTheDocument() - }) - - it('disables FK fetch button when no child selected', () => { - vi.mocked(useWorkspace).mockReturnValue( - makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, - workspaceId: 'ws-1', - children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], - selectedChildId: null, - personalDataUuid: 'pd-1', - parentName: 'Test Parent', - }) - ) - renderWithRouter() - const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ }) - expect(button).toBeDisabled() - }) - - it('enables FK fetch button when child selected', () => { - renderWithRouter() - const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ }) - expect(button).not.toBeDisabled() - }) - - it('shows planned days breakdown', () => { - renderWithRouter() - expect(screen.getByTestId('days-breakdown')).toBeInTheDocument() - }) - - it('shows Swedish section headings', () => { - renderWithRouter() - expect(screen.getByTestId('metrics-title')).toHaveTextContent(/Översikt/) - expect(screen.getAllByText(/Planerade dagar/)[0]).toBeInTheDocument() - }) - - // The combined expiring-at-4 figure reflects the currently saved plan, while - // the Plan tab's own figure (PlanResults.tsx) reflects an unsaved proposal — - // the label must say so, so the two screens are never confused for showing - // the same thing. See PlanAge4Projection vs Age4Projection in types/entities.ts. - it('labels the combined expiring-at-4 metric as reflecting the current saved plan', () => { - renderWithRouter() - expect(screen.getByTestId('metric-expiring-combined')).toHaveTextContent( - 'Förfaller vid 4 år om nuvarande plan följs (alla föräldrar)', - ) - }) - - it('labels the per-parent expiring-at-4 metric as reflecting the current saved plan', () => { - 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: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] }, - { 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, yearBalances: [] }, - ], - combinedDaysUsed: 50, - combinedDaysLeft: 190, - combinedExpiringAtB4: 104, - yearTotals: [], - }, - }) - ) - - renderWithRouter() - expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent( - 'Förfaller vid 4 år om nuvarande plan följs – Alex', - ) - }) - - it('displays error message if analytics fails', () => { - vi.mocked(hooks.useAnalytics).mockReturnValue( - makeAnalyticsResult({ - data: null, - error: 'Failed to load analytics', - }) - ) - renderWithRouter() - expect(screen.getByText(/Failed to load analytics/)).toBeInTheDocument() - }) - - it('shows empty state for parental leave when data is null', () => { - vi.mocked(hooks.useParentalLeave).mockReturnValue( - makeParentalLeaveResult({ - data: null, - }) - ) - renderWithRouter() - expect(screen.getByText(/Ingen data hämtad ännu/)).toBeInTheDocument() - }) - - it('opens QR code modal on FK fetch button click', async () => { - const user = userEvent.setup() - const mockStart = vi.fn() - vi.mocked(hooks.useScraperFetch).mockReturnValue( - makeScraperFetchResult({ - start: mockStart, - }) - ) - - renderWithRouter() - const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ }) - await user.click(button) - - expect(mockStart).toHaveBeenCalledWith('c-1') - }) - - it('shows FK button is not disabled by default with child selected', () => { - renderWithRouter() - const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ }) - expect(button).toBeInTheDocument() - expect(button).not.toBeDisabled() - }) - - it('has manual fetch button enabled when child selected', () => { - renderWithRouter() - - const manualButton = screen.getByRole('button', { name: /Ange uppgifter manuellt/ }) - expect(manualButton).toBeInTheDocument() - expect(manualButton).not.toBeDisabled() - }) - - it('disables manual fetch button when no child selected', () => { - vi.mocked(useWorkspace).mockReturnValue( - makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, - workspaceId: 'ws-1', - children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], - selectedChildId: null, - personalDataUuid: 'pd-1', - parentName: 'Test Parent', - }) - ) - renderWithRouter() - - const manualButton = screen.getByRole('button', { name: /Ange uppgifter manuellt/ }) - expect(manualButton).toBeDisabled() - }) - - it('renders QRCodeModal when scraper is active', () => { - vi.mocked(hooks.useScraperFetch).mockReturnValue( - makeScraperFetchResult({ - sessionId: 'session1', - pollData: { status: 'authenticating' }, - isRunning: true, - }) - ) - - renderWithRouter() - // QRCodeModal is rendered inside Dashboard - // Verify it was constructed with proper props - expect(screen.getByText(/Hämta från Försäkringskassan/)).toBeInTheDocument() - }) - - it('renders planned days breakdown with multiple day types', () => { - vi.mocked(hooks.usePlannedDays).mockReturnValue( - makePlannedDaysResult({ - data: [ - makePlannedLeaveDay({ id: '1', date: '2026-06-15' }), - makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'sjukpenning' }), - makePlannedLeaveDay({ id: '3', date: '2026-06-17', dayType: 'lagsta' }), - ], - }) - ) - - renderWithRouter() - expect(screen.getByTestId('days-breakdown')).toBeInTheDocument() - }) - - it('shows analytics loading state', () => { - vi.mocked(hooks.useAnalytics).mockReturnValue( - makeAnalyticsResult({ - data: null, - isLoading: true, - }) - ) - renderWithRouter() - expect(screen.getByTestId('page-skeleton')).toBeInTheDocument() - }) - - it('shows planned days loading state', () => { - vi.mocked(hooks.usePlannedDays).mockReturnValue( - makePlannedDaysResult({ - data: null, - isLoading: true, - }) - ) - renderWithRouter() - expect(screen.getByTestId('page-skeleton')).toBeInTheDocument() - }) - - it('renders with zero planned days', () => { - vi.mocked(hooks.usePlannedDays).mockReturnValue( - makePlannedDaysResult({ - data: [], - }) - ) - - renderWithRouter() - expect(screen.getByTestId('days-breakdown')).toBeInTheDocument() - }) - - it('displays metrics with expiring days at B4', () => { - vi.mocked(hooks.useAnalytics).mockReturnValue( - makeAnalyticsResult({ - data: { - workspaceId: 'ws-1', - childId: 'c-1', - parentProjections: [], - combinedDaysUsed: 50, - combinedDaysLeft: 200, - combinedExpiringAtB4: 104, - yearTotals: [], - }, - }) - ) - - renderWithRouter() - expect(screen.getByTestId('overview-metrics')).toBeInTheDocument() - }) - - it('shows only the combined expiring figure for a single-parent workspace', () => { - 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: 200, savableAfterB4: 96, expiringAtB4: 104, savableCapAtB4: 96, yearBalances: [] }, - ], - combinedDaysUsed: 30, - combinedDaysLeft: 210, - combinedExpiringAtB4: 104, - yearTotals: [], - }, - }) - ) - - renderWithRouter() - expect(screen.getByTestId('metric-expiring-combined')).toBeInTheDocument() - expect(screen.queryByTestId('metric-expiring-parent-pd-1')).not.toBeInTheDocument() - }) - - it('breaks down expiring days per parent alongside the combined total', () => { - 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: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] }, - { 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, yearBalances: [] }, - ], - combinedDaysUsed: 50, - combinedDaysLeft: 190, - combinedExpiringAtB4: 104, - yearTotals: [], - }, - }) - ) - - renderWithRouter() - const alexMetric = screen.getByTestId('metric-expiring-parent-pd-1') - const samMetric = screen.getByTestId('metric-expiring-parent-pd-2') - const combinedMetric = screen.getByTestId('metric-expiring-combined') - expect(alexMetric).toHaveTextContent('Alex') - expect(alexMetric).toHaveTextContent('60') - expect(samMetric).toHaveTextContent('Sam') - expect(samMetric).toHaveTextContent('44') - expect(combinedMetric).toHaveTextContent('104') - }) - - it('falls back to a numbered parent label while members are still loading', () => { - vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( - makeWorkspaceMembersResult({ - data: [], - isLoading: true, - }) - ) - 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: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] }, - { 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, yearBalances: [] }, - ], - combinedDaysUsed: 50, - combinedDaysLeft: 190, - combinedExpiringAtB4: 104, - yearTotals: [], - }, - }) - ) - - renderWithRouter() - expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Förälder 1') - expect(screen.getByTestId('metric-expiring-parent-pd-2')).toHaveTextContent('Förälder 2') - }) - - it('falls back to a numbered parent label for a projection with no matching member', () => { - 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: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] }, - { 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, yearBalances: [] }, - ], - combinedDaysUsed: 50, - combinedDaysLeft: 190, - combinedExpiringAtB4: 104, - yearTotals: [], - }, - }) - ) - - renderWithRouter() - expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Alex') - expect(screen.getByTestId('metric-expiring-parent-pd-unknown')).toHaveTextContent('Förälder 2') - }) - - describe('manual balance entry — parent picker', () => { - it('shows a parent picker before the form when the workspace has two members', async () => { - const user = userEvent.setup() - renderWithRouter() - - await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ })) - - expect(screen.getByTestId('dashboard-balance-parent-picker')).toBeInTheDocument() - expect(screen.getByTestId('dashboard-balance-parent-pd-1')).toHaveTextContent('Alex') - expect(screen.getByTestId('dashboard-balance-parent-pd-2')).toHaveTextContent('Sam') + await user.click(screen.getByTestId('dismiss-button')) + expect(dismiss).toHaveBeenCalledWith('tip-1') }) - it('opens the form directly, with no picker, for a solo workspace', async () => { - vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( - makeWorkspaceMembersResult({ - data: [{ uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }], - }) - ) - const user = userEvent.setup() - renderWithRouter() - - await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ })) - - expect(screen.queryByTestId('dashboard-balance-parent-picker')).not.toBeInTheDocument() - expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument() - }) - - it('posts the chosen parent\'s uuid to saveManualFetch', async () => { - const user = userEvent.setup() - renderWithRouter() - - await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ })) - await user.click(screen.getByTestId('dashboard-balance-parent-pd-2')) - expect(screen.queryByTestId('dashboard-balance-parent-picker')).not.toBeInTheDocument() - - await user.click(await screen.findByRole('button', { name: 'Spara' })) - - await waitFor(() => - expect(api.saveManualFetch).toHaveBeenCalledWith( - expect.objectContaining({ personalDataUuid: 'pd-2', workspaceId: 'ws-1', childId: 'c-1', source: 'manual' }) - ) - ) - }) - - it('shows an error when saving a manual balance fails', async () => { - const user = userEvent.setup() - vi.mocked(api.saveManualFetch).mockRejectedValue(new Error('save failed')) - renderWithRouter() - - await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ })) - await user.click(screen.getByTestId('dashboard-balance-parent-pd-1')) - await user.click(await screen.findByRole('button', { name: 'Spara' })) - - expect(await screen.findByText('save failed')).toBeInTheDocument() - }) - - it('prefills the manual-entry form with the parent\'s saved balance', async () => { - vi.mocked(api.getParentalLeave).mockResolvedValue( - makeParentalLeaveFetch({ - personalDataUuid: 'pd-2', - childId: 'c-1', - dubbeldagarLeft: 7, - tierData: makeTierData({ sjukpenningLeft: 65, lagstaNivaLeft: 8 }), + it('appends the proposal\'s surplus advice as tips with resolved parent names', () => { + 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-2', weeks: 2 }, + }), + makeSurplusAdvice({ + kind: 'wish_more_leave', + messageKey: 'planSurplusAdvice.wish_more_leave', + daysInvolved: 3, + data: { parent_personal_data_uuid: 'pd-nobody', weeks: 1 }, + }), + ], + }), }), ) - const user = userEvent.setup() renderWithRouter() + expect(screen.getByTestId('dashboard-tips')).toBeInTheDocument() + expect(screen.getByTestId('dashboard-surplus-advice-0')).toHaveTextContent('Sam har råd med ytterligare 2 veckor') + expect(screen.getByTestId('dashboard-surplus-advice-1')).toHaveTextContent('Okänd förälder har råd') + }) + }) - await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ })) - await user.click(screen.getByTestId('dashboard-balance-parent-pd-2')) - - expect(await screen.findByText('Sams dagar kvar')).toBeInTheDocument() - expect((screen.getByLabelText(/Sjukpenningnivå/) as HTMLInputElement).value).toBe('65') - expect((screen.getByLabelText(/Lägstanivå/) as HTMLInputElement).value).toBe('8') - expect((screen.getByLabelText(/Dubbeldagar/) as HTMLInputElement).value).toBe('7') + describe('"Detaljer" disclosure', () => { + it('is a collapsed native disclosure holding the overview metrics', () => { + renderWithRouter() + const details = screen.getByTestId('dashboard-more-details') + expect(details.tagName).toBe('DETAILS') + expect(details).not.toHaveAttribute('open') + expect(details.querySelector('summary')).toHaveTextContent('Detaljer') + expect(screen.getByTestId('overview-metrics')).toBeInTheDocument() + expect(screen.getByTestId('metrics-title')).toHaveTextContent('Översikt') + expect(screen.getByText('Använda dagar:')).toBeInTheDocument() }) - it('falls back to the preset when the parent has no saved balance yet', async () => { - vi.mocked(api.getParentalLeave).mockResolvedValue(null) - const user = userEvent.setup() + it('shows every parent\'s balance snapshot, self first, with a dash for one that has none', () => { + vi.mocked(useWorkspace).mockReturnValue(workspaceValue({ personalDataUuid: 'pd-2' })) + vi.mocked(useParentalLeaves).mockReturnValue( + makeParentalLeavesResult({ data: { 'pd-1': null, 'pd-2': makeParentalLeaveFetch({ dubbeldagarLeft: 7 }) } }), + ) renderWithRouter() + expect(screen.getByTestId('parental-leave-breakdown')).toBeInTheDocument() + expect(screen.getByTestId('breakdown-dubbeldagar-pd-2')).toHaveTextContent('7') + expect(screen.getByTestId('breakdown-dubbeldagar-pd-1')).toHaveTextContent('–') + expect(screen.getByTestId('reserved-days-note')).toHaveTextContent(/reserverade/i) + const headers = screen.getAllByRole('columnheader').map((h) => h.textContent) + expect(headers.indexOf('Sam')).toBeLessThan(headers.indexOf('Alex')) + }) - await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ })) - await user.click(screen.getByTestId('dashboard-balance-parent-pd-1')) + it('links to the balances panel in Settings instead of fetching or entering balances here', () => { + renderWithRouter() + const link = screen.getByTestId('dashboard-balance-link') + expect(link).toHaveAttribute('href', '/settings#saldon') + expect(link).toHaveTextContent('Uppdatera saldon') + expect(screen.queryByRole('button', { name: /Hämta från Försäkringskassan/ })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Ange uppgifter manuellt/ })).not.toBeInTheDocument() + }) - expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument() - // Couple workspace -> 240-day preset, split 195:45. - expect((screen.getByLabelText(/Sjukpenningnivå/) as HTMLInputElement).value).toBe('195') - expect((screen.getByLabelText(/Lägstanivå/) as HTMLInputElement).value).toBe('45') - expect((screen.getByLabelText(/Dubbeldagar/) as HTMLInputElement).value).toBe('60') + // The combined expiring-at-4 figure reflects the currently saved plan, while + // the proposal's own figure reflects an unsaved plan — the label must say + // so. See PlanAge4Projection vs Age4Projection in types/entities.ts. + it('labels the combined expiring-at-4 metric as reflecting the current saved plan', () => { + renderWithRouter() + expect(screen.getByTestId('metric-expiring-combined')).toHaveTextContent( + 'Förfaller vid 4 år om nuvarande plan följs (alla föräldrar)', + ) + }) + + it('shows only the combined expiring figure for a single-parent workspace', () => { + vi.mocked(hooks.useAnalytics).mockReturnValue(coupleAnalytics({ parentProjections: [projection('pd-1')] })) + renderWithRouter() + expect(screen.getByTestId('metric-expiring-combined')).toBeInTheDocument() + expect(screen.getByTestId('metric-expiring-combined')).not.toHaveClass('overview-metrics__item--summary') + expect(screen.queryByTestId('metric-expiring-parent-pd-1')).not.toBeInTheDocument() + }) + + it('breaks down expiring days per parent alongside the combined total, labelled by name', () => { + vi.mocked(hooks.useAnalytics).mockReturnValue(coupleAnalytics()) + renderWithRouter() + const alexMetric = screen.getByTestId('metric-expiring-parent-pd-1') + const samMetric = screen.getByTestId('metric-expiring-parent-pd-2') + const combinedMetric = screen.getByTestId('metric-expiring-combined') + expect(alexMetric).toHaveTextContent('Förfaller vid 4 år om nuvarande plan följs – Alex') + expect(alexMetric).toHaveTextContent('60') + expect(samMetric).toHaveTextContent('Sam') + expect(samMetric).toHaveTextContent('44') + expect(combinedMetric).toHaveTextContent('104') + expect(combinedMetric).toHaveClass('overview-metrics__item--summary') }) }) }) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index a2882dc..f656edc 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,37 +1,30 @@ -import { ReactNode, useEffect, useRef, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { ReactNode, useEffect, useRef } from 'react' +import { Link, useNavigate } from 'react-router-dom' import { useWorkspace } from '@/context' import { useAnalytics, + useLeaveWishes, + usePlanProposal, usePlannedDays, - useScraperFetch, - useParentalLeave, useWorkspaceMembers, useOptimizationTips, } from '@/hooks' -// Imported directly (not via the `@/hooks` barrel) so this page's tests can -// drive it through a mocked `api.getParentalLeave` — see the hook's doc -// comment for why. -import { useLatestBalance } from '@/hooks/useLatestBalance' -import type { PlannedLeaveDay } from '@/types' -import * as api from '@/services/api' -import { getLabelForType } from '@/utils/leaveTypes' +// Imported directly (not via the `@/hooks` barrel) so this page's test can +// mock this one module while the barrel stays automocked. +import { useParentalLeaves } from '@/hooks/useParentalLeaves' import { nameResolver } from '@/utils/parentNames' -import { isSelf, orderSelfFirst } from '@/utils/members' +import { orderSelfFirst } from '@/utils/members' import { useTranslation } from '@/i18n' import { Layout } from '@/components/layout/Layout' -import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { PageSkeleton } from '@/components/common/PageSkeleton' import { OverviewMetrics } from '@/components/dashboard/OverviewMetrics' -import { DaysBreakdown } from '@/components/dashboard/DaysBreakdown' import { Hero } from '@/components/dashboard/Hero' +import { KeyFigures } from '@/components/dashboard/KeyFigures' import { Age4Bars } from '@/components/dashboard/Age4Bars' -import { YearTimeline } from '@/components/dashboard/YearTimeline' -import { PlanFold } from '@/components/dashboard/PlanFold' +import { YearRows } from '@/components/dashboard/YearRows' +import { SurplusAdviceTip } from '@/components/dashboard/SurplusAdviceTip' import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown' import { TipCard } from '@/components/inputs/TipCard' -import { QRCodeModal } from '@/components/modals/QRCodeModal' -import { ParentBalanceForm } from '@/components/modals/ParentBalanceForm' import { Onboarding } from '@/pages/Onboarding' /** Fallback horizon end when the analytics projection isn't available yet @@ -47,30 +40,21 @@ export function Dashboard(): ReactNode { const { t } = useTranslation() const navigate = useNavigate() const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid } = useWorkspace() - const { data: analytics, isLoading: analyticsLoading, error: analyticsError, refresh: refreshAnalytics } = useAnalytics( + const { data: analytics, isLoading: analyticsLoading, error: analyticsError } = useAnalytics( workspace?.id ?? null, selectedChildId ?? '' ) const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(selectedChildId ?? undefined) - const { data: parentalLeave, refresh: refreshParentalLeave } = useParentalLeave(personalDataUuid, selectedChildId ?? null) const { data: members, isLoading: membersLoading, refresh: refreshMembers } = useWorkspaceMembers(workspace?.id ?? null) const { data: tips, dismiss: dismissTip } = useOptimizationTips(workspace?.id ?? null) - const scraper = useScraperFetch() + const { data: wishes, isLoading: wishesLoading } = useLeaveWishes(workspace?.id ?? null) + const { data: proposal, isLoading: isProposing, error: proposalError, propose } = usePlanProposal(workspace?.id ?? null) - const [showModal, setShowModal] = useState(false) - const [showBalancePicker, setShowBalancePicker] = useState(false) - const [showManualFetchModal, setShowManualFetchModal] = useState(false) - const [manualFetchTargetUuid, setManualFetchTargetUuid] = useState(null) - const [isManualFetchSaving, setIsManualFetchSaving] = useState(false) - - // Reopening the balance form for a parent should show what was actually - // saved for them, not always the 195/45/60 preset — `manualFetchTargetUuid` - // is null except while the form is open/opening, so this no-ops the rest - // of the time. - const { - initial: manualFetchInitial, - isLoading: manualFetchInitialLoading, - } = useLatestBalance(manualFetchTargetUuid, selectedChildId ?? null) + const orderedMembers = orderSelfFirst(members, personalDataUuid) + const { data: leaves, isLoading: leavesLoading } = useParentalLeaves( + orderedMembers.map((m) => m.uuid), + selectedChildId ?? null, + ) // `useWorkspaceMembers` fetches once per `workspace.id`, which never // changes across onboarding — so a couple's partner, added mid-wizard via @@ -89,6 +73,16 @@ export function Dashboard(): ReactNode { wasAwaitingFirstChildRef.current = children.length === 0 }, [children.length, refreshMembers]) + // The plan proposal (`POST /api/plan/propose`, read-only — persists + // nothing) is the only source for wished-vs-funded per year and the + // surplus advice, so it is requested automatically once wishes exist. + // Stops after one failure rather than retrying on every render. + useEffect(() => { + if (!wishesLoading && wishes.length > 0 && proposal === null && !isProposing && proposalError === null) { + void propose() + } + }, [wishesLoading, wishes.length, proposal, isProposing, proposalError, propose]) + const isLoading = wsLoading || analyticsLoading || pdLoading if (isLoading) { @@ -99,96 +93,15 @@ export function Dashboard(): ReactNode { ) } - const handleFetchFK = async () => { - setShowModal(true) - await scraper.start(selectedChildId ?? undefined) - } - - const handleSaveManualFetch = async (data: { - sjukpenningLeft: number - lagstaNivaLeft: number - dubbeldagarLeft: number - reservedDaysLeft: number | null - }) => { - // `manualFetchTargetUuid` is always set to a real uuid by - // `handleOpenManualFetch`/`handlePickManualFetchTarget` before the form - // is ever shown — there is no path that calls this with it unset. - const targetUuid = manualFetchTargetUuid - if (!workspace?.id || !selectedChildId || !targetUuid) return - - // No try/catch here on purpose — a rejection propagates back to - // `ParentBalanceForm`'s own `onSave` handler, which owns showing it - // (inline under the offending field for a mapped 422, or its own top - // banner otherwise). See the audit note on `ParentBalanceForm.tsx`. - try { - setIsManualFetchSaving(true) - - await api.saveManualFetch({ - personalDataUuid: targetUuid, - workspaceId: workspace.id, - childId: selectedChildId, - sjukpenningLeft: data.sjukpenningLeft, - lagstaNivaLeft: data.lagstaNivaLeft, - dubbeldagarLeft: data.dubbeldagarLeft, - reservedDaysLeft: data.reservedDaysLeft, - source: 'manual', - }) - - await refreshParentalLeave() - await refreshAnalytics() - setShowManualFetchModal(false) - // Reset so the next open of this same target refetches instead of - // reusing the (now stale) balance `useLatestBalance` already cached. - setManualFetchTargetUuid(null) - } finally { - setIsManualFetchSaving(false) - } - } - - /** "Ange manuellt" — for a solo workspace this opens the form straight for - * self; for a couple it shows a small parent picker first (see - * `dashboard-balance-parent-picker` below) since either parent's balance - * can be entered from here. */ - const handleOpenManualFetch = () => { - if (members.length >= 2) { - setShowBalancePicker(true) - return - } - setManualFetchTargetUuid(personalDataUuid) - setShowManualFetchModal(true) - } - - const handlePickManualFetchTarget = (uuid: string) => { - setManualFetchTargetUuid(uuid) - setShowBalancePicker(false) - setShowManualFetchModal(true) - } - - const handleModalClose = () => { - if (scraper.isRunning) return - setShowModal(false) - } - - const handleFetchDone = async () => { - setShowModal(false) - 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 }, - ] - - // 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. const nameFor = nameResolver(members) const parentProjections = analytics?.parentProjections ?? [] + // 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, so skip the breakdown in that case. const showPerParentExpiring = parentProjections.length > 1 const parentDisplayName = (uuid: string, index: number): string => { @@ -197,6 +110,10 @@ export function Dashboard(): ReactNode { ? t('dashboard.expiringAtB4ParentFallback', { number: index + 1 }) : resolved } + const nameForAdvice = (uuid: string): string => { + const resolved = nameFor(uuid) + return resolved === uuid ? t('planResults.unknownParent') : resolved + } const overviewMetrics = analytics ? [ @@ -216,9 +133,7 @@ export function Dashboard(): ReactNode { value: analytics.combinedExpiringAtB4, testId: 'metric-expiring-combined', // Only actually a distinct "all parents" summary when there was - // a per-parent breakdown above it to summarize — for a single - // parent it's just this workspace's one figure, so it renders as - // an ordinary grid cell instead of a footer row. + // a per-parent breakdown above it to summarize. isSummary: showPerParentExpiring, }, ] @@ -237,13 +152,43 @@ export function Dashboard(): ReactNode { const hasPlan = (plannedDays?.length ?? 0) > 0 const fourthBirthday = parentProjections[0]?.fourthBirthday ?? (selectedChild ? computeFourthBirthday(selectedChild.birth_date) : '') - const age4Bars = parentProjections.map((projection, index) => ({ + + // Self first, matching the order of every per-parent list on the page. + const orderedProjections = [ + ...parentProjections.filter((p) => p.personalDataUuid === personalDataUuid), + ...parentProjections.filter((p) => p.personalDataUuid !== personalDataUuid), + ] + const projectionName = (uuid: string) => + parentDisplayName(uuid, parentProjections.findIndex((p) => p.personalDataUuid === uuid)) + + const age4Bars = orderedProjections.map((projection) => ({ personalDataUuid: projection.personalDataUuid, - name: parentDisplayName(projection.personalDataUuid, index), + name: projectionName(projection.personalDataUuid), projectedLeftAtB4: projection.projectedLeftAtB4, savableCapAtB4: projection.savableCapAtB4, expiringAtB4: projection.expiringAtB4, })) + const yearRowParents = orderedProjections.map((projection) => ({ + personalDataUuid: projection.personalDataUuid, + name: projectionName(projection.personalDataUuid), + yearBalances: projection.yearBalances, + })) + + // "Estimated" once the members are known and at least one of them has no + // saved balance — the headline figures then rest on the statutory + // assumption for that parent. Absent from the map = not fetched yet, so + // the line never flashes before the first response. + const showEstimate = + !leavesLoading && orderedMembers.length > 0 && orderedMembers.some((m) => leaves[m.uuid] === null) + + const breakdownParents = orderedMembers.map((member) => ({ + personalDataUuid: member.uuid, + name: member.name, + data: leaves[member.uuid] ?? null, + })) + + const surplusAdvice = proposal?.surplusAdvice ?? [] + const hasTips = (tips?.length ?? 0) > 0 || surplusAdvice.length > 0 return ( @@ -264,127 +209,67 @@ export function Dashboard(): ReactNode { /> )} - {!parentalLeave && ( -

- {t('dashboard.estimatedBadge')} + {showEstimate && ( +

+ + {t('dashboard.estimatedBadge')} + {' '} + {t('dashboard.estimatedBadgeCaption')}

)} - {age4Bars.length > 0 && } - -
- - -
- - {showBalancePicker && ( -
-

{t('dashboard.balancePickerPrompt')}

-
- {orderSelfFirst(members, personalDataUuid).map((member) => ( - - ))} -
-
+ {analytics && ( + )} {selectedChild && ( - +
+

{t('dashboard.leaveOverviewTitle')}

+ {age4Bars.length > 0 && } + {proposalError && ( +
+ {proposalError} +
+ )} + +
)} - {tips && tips.length > 0 && ( + {hasTips && (

{t('tips.title')}

- {tips.map((tip) => ( + {(tips ?? []).map((tip) => ( dismissTip(tip.id)} /> ))} + {surplusAdvice.map((advice, index) => ( + + ))}
)} - {workspace && } - -
- {t('dashboard.moreDetailsSummary')} - -
- {analytics && ( - - )} - -
- -
-

{t('dashboard.plannedDaysTitle')}

- -
-
- - - - {showManualFetchModal && manualFetchInitialLoading && ( -
- +
+ {t('dashboard.moreDetailsSummary')} +
+ {analytics && } + + + {t('dashboard.balanceLink')} +
- )} - - {showManualFetchModal && !manualFetchInitialLoading && ( - m.uuid === manualFetchTargetUuid)?.name ?? t('calendar.you')} - isSelf={manualFetchTargetUuid === personalDataUuid} - totalDays={members.length >= 2 ? 240 : 480} - initial={manualFetchInitial} - onSave={handleSaveManualFetch} - onClose={() => { - setShowManualFetchModal(false) - setManualFetchTargetUuid(null) - }} - isSaving={isManualFetchSaving} - error={null} - /> - )} +
) diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index e353920..c638ef1 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1,213 +1,36 @@ -/* Overview Metrics */ -.overview-metrics { - display: grid; - gap: var(--sp-4); -} +/* Dashboard (Översikt) + * ------------------------------------------------------------------------ + * Pure typography and whitespace: the hero is a headline, the key figures + * are three numerals, and the only hairlines on the page sit between the + * year rows. Nothing here is a tile or a card. */ -.overview-metrics__title { - margin: 0 0 var(--sp-4) 0; -} - -/* `auto-fit` only ever governs the desktop case (>1024px) — the tablet/ - * mobile breakpoints in responsive.css force an explicit 2-column/1-column - * layout regardless of this basis. At desktop widths the container is - * capped by `--maxw` (960px) minus the fixed chrome around it (`layout-main` - * + `.dashboard__more-details` padding), landing at ~864px regardless of - * how much wider the viewport itself gets — so the basis only needs to let - * FOUR cards (the two-parent "expiring at 4" case) fit that one width: four - * columns at 13rem plus three 16px gaps (880px) doesn't quite fit 864px and - * `auto-fit` drops to 3, stranding the 4th card alone on its own dangling - * row. 12.5rem keeps that same one-row-of-four for the two-parent overview - * metrics/expiring-at-4 lists while still comfortably wrapping to 2 columns - * for anything narrower than that. */ -.overview-metrics__list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(12.5rem, 1fr)); - gap: var(--sp-4); -} - -.overview-metrics__item { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--sp-5); - background-color: var(--c-surface); - border-radius: var(--radius); -} - -/* The "all parents" combined total — a footer row spanning every column - * instead of just another grid cell, so it never leaves a dangling gap - * when the per-parent metrics above it don't fill a whole row. Tinted so - * it reads as "the sum of the above", not one more figure among equals. */ -.overview-metrics__item--summary { - grid-column: 1 / -1; - flex-direction: row; - justify-content: center; - gap: var(--sp-3); - background-color: var(--c-accent-weak); -} - -.overview-metrics__item--summary .overview-metrics__value { - margin-bottom: 0; -} - -.overview-metrics__value { - display: block; - font-size: var(--fs-2xl); - font-weight: 700; - color: var(--c-accent); - margin-bottom: var(--sp-2); - line-height: 1; -} - -.overview-metrics__label { - font-size: var(--fs-sm); - color: var(--c-text-muted); - text-align: center; -} - -/* Days Breakdown */ -.days-breakdown { - width: 100%; -} - -.days-breakdown__empty { - color: var(--c-text-muted); - font-size: var(--fs-sm); - text-align: center; - padding: var(--sp-5); - margin: 0; -} - -.days-breakdown__table { - width: 100%; - border-collapse: collapse; - margin: 0; -} - -.days-breakdown__table th { - padding: var(--sp-4); - background-color: var(--c-bg); - border-bottom: 2px solid var(--c-border); - font-weight: 600; - color: var(--c-text-muted); - text-align: left; - font-size: var(--fs-sm); -} - -.days-breakdown__table td { - padding: var(--sp-3) var(--sp-4); - border-bottom: 1px solid var(--c-border); - font-size: var(--fs-base); -} - -.days-breakdown__table tbody tr:hover { - background-color: var(--c-row-hover); -} - -.days-breakdown__table tbody tr:last-child td { - border-bottom: none; -} - -.days-breakdown__table td:last-child { - text-align: right; - font-weight: 600; - color: var(--c-accent); -} - -/* Parental Leave Breakdown */ -.parental-leave-breakdown { - display: flex; - flex-direction: column; - gap: var(--sp-4); -} - -.parental-leave-breakdown__empty { - color: var(--c-text-muted); - font-size: var(--fs-sm); - padding: var(--sp-4); - margin: 0; -} - -.parental-leave-breakdown__section { - width: 100%; - overflow-x: auto; -} - -.parental-leave-breakdown__table { - width: 100%; - border-collapse: collapse; - margin: 0; -} - -.parental-leave-breakdown__table th { - padding: var(--sp-3) var(--sp-4); - background-color: var(--c-bg); - border-bottom: 2px solid var(--c-border); - font-weight: 600; - color: var(--c-text-muted); - text-align: left; - font-size: var(--fs-sm); -} - -.parental-leave-breakdown__table td { - padding: var(--sp-3) var(--sp-4); - border-bottom: 1px solid var(--c-border); - font-size: var(--fs-sm); -} - -.parental-leave-breakdown__table tbody tr:hover { - background-color: var(--c-row-hover); -} - -.parental-leave-breakdown__table tbody tr:last-child td { - border-bottom: none; -} - -.parental-leave-breakdown__table td:nth-child(2), -.parental-leave-breakdown__table td:nth-child(3) { - text-align: right; - font-weight: 600; -} - -.parental-leave-breakdown__reserved-note { - margin: 0; - font-size: var(--fs-sm); - color: var(--c-text-muted); -} - -.parental-leave-breakdown__provenance { - padding-top: var(--sp-3); - border-top: 1px solid var(--c-border); - font-size: var(--fs-sm); - color: var(--c-text-muted); -} - -/* Hero — the headline figure. This is the one element on Översikt allowed - * a tinted background; everything below it gives up cards/borders in favor - * of whitespace precisely so this keeps reading as the most important thing - * on the page (see AGENTS.md's "clear hierarchy per screen"). */ +/* Hero — the headline. Its framing (motivate / warning / reassure) is a + * short coloured rule above the text, never a tinted box. */ .dashboard-hero { - padding: var(--sp-6) var(--sp-6); - margin-bottom: var(--sp-6); - border-radius: var(--radius); - border-left: 3px solid var(--c-accent); - background-color: var(--c-accent-weak); + margin-bottom: var(--sp-4); } -.dashboard-hero--warning { - border-left-color: var(--c-danger); - background-color: var(--c-danger-weak); +.dashboard-hero::before { + content: ''; + display: block; + width: var(--sp-6); + height: 4px; + margin-bottom: var(--sp-4); + border-radius: var(--radius-sm); + background-color: var(--c-accent); } -.dashboard-hero--reassure { - border-left-color: var(--c-success); - background-color: var(--c-success-weak); +.dashboard-hero--warning::before { + background-color: var(--c-danger); +} + +.dashboard-hero--reassure::before { + background-color: var(--c-success); } .dashboard-hero__message { margin: 0; + max-width: 24ch; font-size: var(--fs-2xl); font-weight: 600; line-height: var(--lh-tight); @@ -215,215 +38,576 @@ color: var(--c-text); } -/* Shown while headline figures are still the statutory assumption (no FK - * fetch or manual entry has happened yet) — see `Dashboard.tsx`. */ -.dashboard__fetch-actions { - display: flex; - gap: var(--sp-3); - flex-wrap: wrap; -} - -/* "Ange manuellt" for a two-parent workspace asks which parent first — - * see `Dashboard.tsx`'s `handleOpenManualFetch`. `.card` gives it the same - * chrome as the rest of the dashboard's boxed content instead of a bare, - * floating row of buttons. */ -.dashboard__balance-picker { - margin-top: var(--sp-2); -} - -.dashboard__balance-picker-label { - margin: 0 0 var(--sp-3) 0; +/* One muted line under the hero while the figures still rest on the + * statutory assumption for at least one parent. The lead word (the e2e + * hook) is the only emphasised part. */ +.dashboard__estimate { + margin: 0 0 var(--sp-5); + max-width: 64ch; font-size: var(--fs-sm); - font-weight: 500; color: var(--c-text-muted); } -.dashboard__balance-picker-actions { - display: flex; - gap: var(--sp-3); - flex-wrap: wrap; -} - .dashboard__estimate-badge { - margin: var(--sp-3) 0; - cursor: help; + font-weight: 500; + color: var(--c-warning); } -/* Age4 Bars (per-parent days left at the 4th birthday) */ +/* Key figures — three tabular numerals, 14px labels, no tiles. */ +.key-figures { + display: flex; + flex-wrap: wrap; + gap: var(--sp-4) var(--sp-7); + margin: var(--sp-6) 0 var(--sp-7); +} + +.key-figures__item { + display: flex; + flex-direction: column-reverse; + gap: var(--sp-1); +} + +.key-figures__value { + margin: 0; + font-size: var(--fs-2xl); + font-weight: 600; + line-height: 1; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + color: var(--c-text); +} + +.key-figures__value--danger { + color: var(--c-danger); +} + +.key-figures__label { + font-size: var(--fs-sm); + color: var(--c-text-muted); +} + +/* Age4 bars — 6px meters, one per parent: the fill is the accent, the + * unfilled track a lighter step of the same hue. */ .age4-bars { display: flex; flex-direction: column; - gap: var(--sp-4); + gap: var(--sp-3); margin-bottom: var(--sp-6); } .age4-bars__title { + margin: 0; font-size: var(--fs-sm); font-weight: 500; color: var(--c-text-muted); - margin-bottom: 0; } .age4-bars__row { - display: flex; - flex-direction: column; - gap: var(--sp-2); + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: baseline; + column-gap: var(--sp-3); + row-gap: var(--sp-1); } .age4-bars__label { - font-weight: 600; -} - -.age4-bars__track { - width: 100%; - height: 10px; - background-color: var(--c-surface); - border-radius: var(--radius-full); - overflow: hidden; - box-shadow: inset 0 0 0 1px var(--c-border); -} - -.age4-bars__fill { - height: 100%; - background-color: var(--c-accent); - border-radius: var(--radius-full); - transition: width 0.3s ease; + order: 1; + font-size: var(--fs-sm); + font-weight: 500; } .age4-bars__stats { + order: 2; display: flex; - justify-content: space-between; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--sp-3); font-size: var(--fs-sm); + font-variant-numeric: tabular-nums; color: var(--c-text-muted); } .age4-bars__expiring { - color: var(--c-danger); - font-weight: 600; -} - -/* Year Timeline */ -.year-timeline { - display: flex; - flex-direction: column; - gap: var(--sp-3); - margin-bottom: var(--sp-6); -} - -.year-timeline__title { - font-size: var(--fs-sm); font-weight: 500; - color: var(--c-text-muted); - margin-bottom: var(--sp-1); + color: var(--c-danger); } -.year-timeline__caption { - font-size: var(--fs-sm); - color: var(--c-text-muted); - margin: 0; -} - -.year-timeline__legend { - display: flex; - flex-wrap: wrap; - gap: var(--sp-4); - list-style: none; - margin: 0; - padding: 0; - font-size: var(--fs-sm); - color: var(--c-text-muted); -} - -.year-timeline__legend-item { - display: flex; - align-items: center; - gap: var(--sp-1); -} - -.year-timeline__legend-swatch { - display: inline-block; - width: 10px; - height: 10px; +.age4-bars__track { + order: 3; + grid-column: 1 / -1; + height: 6px; border-radius: var(--radius-full); - flex-shrink: 0; - box-shadow: inset 0 0 0 1px var(--c-border); -} - -/* Lägsta is a white fill told apart by its outline, not a hue (see the - * --c-leave-* tokens) — the outline is what makes it visible at all. */ -.year-timeline__legend-swatch--type-lagsta, -.year-timeline__band--type-lagsta { - box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline); -} - -.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: 24px; - background-color: var(--c-surface); - border-radius: var(--radius-sm); - box-shadow: inset 0 0 0 1px var(--c-border); + background-color: var(--c-accent-weak); overflow: hidden; } -.year-timeline__band { +.age4-bars__fill { + height: 100%; + border-radius: var(--radius-full); + background-color: var(--c-accent); + transition: width 0.3s ease; +} + +/* Year rows — one native
per calendar year: the year, a thin + * per-parent timeline of planned leave, and each parent's end-of-year + * figures. Hairlines between rows come from `.disclosure`. */ +.year-rows { + display: flex; + flex-direction: column; +} + +/* The section after the rows opens with its own hairline. */ +.year-rows > .disclosure:last-child { + border-bottom: none; +} + +.year-rows__legend { + display: flex; + flex-wrap: wrap; + gap: var(--sp-4); + margin: 0 0 var(--sp-2); + padding: 0; + list-style: none; + font-size: var(--fs-sm); + color: var(--c-text-muted); +} + +.year-rows__legend-item { + display: flex; + align-items: center; + gap: var(--sp-2); + margin: 0; +} + +.year-rows__swatch { + display: inline-block; + width: 14px; + height: 8px; + border-radius: 2px; + flex-shrink: 0; +} + +.year-rows__swatch--vacation, +.year-row__band--vacation { + background-color: var(--c-leave-vacation-bar); +} + +.year-rows__swatch--sjukpenning, +.year-row__band--sjukpenning { + background-color: var(--c-leave-sjukpenning); +} + +/* Lägsta is a white fill told apart by a 1px dark outline and a hatch, not + * a hue (see the --c-leave-* tokens). */ +.year-rows__swatch--lagsta, +.year-row__band--lagsta { + background-color: var(--c-leave-lagsta); + background-image: repeating-linear-gradient( + 135deg, + transparent 0 3px, + var(--c-leave-lagsta-hatch) 3px 4px + ); + box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline); +} + +.year-row__summary { + display: grid; + grid-template-columns: 3.5rem minmax(0, 1fr) auto var(--icon-sm); + align-items: center; + column-gap: var(--sp-4); + row-gap: var(--sp-2); + padding: var(--sp-3) 0; + font-weight: 400; +} + +.year-row__summary:hover { + background-color: var(--c-row-hover); +} + +.year-row__summary::after { + grid-row: 1; + grid-column: 4; +} + +.year-row__year { + grid-row: 1; + grid-column: 1; + font-size: var(--fs-base); + font-weight: 500; + color: var(--c-text); +} + +.year-row__track { + grid-row: 1; + grid-column: 2; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.year-row__lane { + position: relative; + height: 8px; + border-radius: 2px; + background-color: var(--c-band-gap); +} + +/* In a couple the lanes run in the same order as the figures beside them + * (you first), and every band names its parent on hover — a cap mark at + * the start of a lane read as a band on 1 January, so there is none. */ + +.year-row__band { position: absolute; top: 0; height: 100%; min-width: 3px; - border: none; - cursor: pointer; - padding: 0; + border-radius: 2px; transition: opacity 0.15s; } -.year-timeline__band:hover { - opacity: 0.85; +.year-row__band:hover { + opacity: 0.8; } -.year-timeline__band:focus-visible { - outline: 2px solid var(--c-text); - outline-offset: -2px; +.year-row__band:focus-visible { + outline: 2px solid var(--c-accent); + outline-offset: 2px; } -/* Plan Fold (collapsed plan-proposal summary) — these stay bordered even - * though the plain content sections around them dropped their cards: a - *
disclosure control needs a visible boundary so it reads as - * something to click, not another paragraph of content. */ -.plan-fold, -.dashboard__more-details { - border: 1px solid var(--c-border); - border-radius: var(--radius); - padding: var(--sp-4) var(--sp-5); - background-color: var(--c-surface); +/* Per-parent figures: name · FP kvar N · Semester kvar N · sparas N. Laid + * out as one grid across parents so the columns line up. */ +.year-row__figures { + grid-row: 1; + grid-column: 3; + display: grid; + grid-template-columns: auto auto auto; + align-items: baseline; + column-gap: var(--sp-4); + row-gap: 2px; + font-size: var(--fs-sm); + white-space: nowrap; } -.plan-fold + .plan-fold, -.dashboard__more-details + .dashboard__more-details { - margin-top: var(--sp-4); +.year-row__parent { + display: contents; } -.plan-fold__summary, -.dashboard__more-details summary { - cursor: pointer; +.year-row__name { + color: var(--c-text-muted); +} + +.year-row__figure-label { + color: var(--c-text-muted); +} + +.year-row__figure-value { + font-weight: 500; + color: var(--c-text); +} + +.year-row__figure-sub { + color: var(--c-text-muted); +} + +.year-row__figure-danger { + font-weight: 500; + color: var(--c-danger); +} + +.year-row__body { + padding: var(--sp-2) 0 var(--sp-5) 3.5rem; +} + +.year-row__table { + width: auto; + margin-bottom: var(--sp-4); + font-size: var(--fs-sm); +} + +.year-row__table th, +.year-row__table td { + padding: var(--sp-1) 0 var(--sp-1) var(--sp-5); + border-bottom: none; +} + +.year-row__table th:first-child, +.year-row__table td:first-child { + padding-left: 0; +} + +.year-row__table thead th { + padding-bottom: var(--sp-2); + font-weight: 500; + color: var(--c-text); +} + +.year-row__table tr:hover td { + background-color: transparent; +} + +.year-row__group th { + padding-top: var(--sp-3); + font-weight: 500; + color: var(--c-text); +} + +.year-row__cell-label { + font-weight: 400; + color: var(--c-text-muted); +} + +.year-row__cell--num { + text-align: right; +} + +.year-row__cell--danger { + color: var(--c-danger); +} + +.year-row__row--emphasis td { + font-weight: 500; +} + +.year-row__badge { + margin-right: var(--sp-2); +} + +.year-row__wishes-title { + margin: 0 0 var(--sp-2); + font-size: var(--fs-sm); + font-weight: 500; + color: var(--c-text); +} + +.year-row__wish-list, +.year-row__remedies { + margin: 0; + padding: 0; + list-style: none; + font-size: var(--fs-sm); +} + +.year-row__wish { + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: var(--sp-3); + margin: 0 0 var(--sp-1); +} + +.year-row__wish-name { + font-weight: 500; +} + +.year-row__wish-weeks { + color: var(--c-text-muted); +} + +.year-row__wish-weeks--short, +.year-row__shortfall { + color: var(--c-danger); +} + +.year-row__remedies { + flex-basis: 100%; + padding-left: var(--sp-4); + color: var(--c-text-muted); +} + +.year-row__remedies li { + margin: 0; +} + +/* Tips — a plain list. Severity is a small dot before the title; the + * dismiss is a bare glyph button. */ +.dashboard__tips-list { + display: flex; + flex-direction: column; + gap: var(--sp-4); +} + +.tip-card { + padding: 0 0 0 var(--sp-5); + position: relative; +} + +.tip-card::before { + content: ''; + position: absolute; + left: 0; + top: 8px; + width: 8px; + height: 8px; + border-radius: var(--radius-full); + background-color: var(--c-text-faint); +} + +.tip-card--warning::before { + background-color: var(--c-warning); +} + +.tip-card--action::before { + background-color: var(--c-accent); +} + +.tip-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-3); +} + +.tip-card__title { + margin: 0; + font-size: var(--fs-base); + font-weight: 500; + color: var(--c-text); +} + +.tip-card__dismiss { + flex-shrink: 0; + width: var(--icon-lg); + height: var(--icon-lg); + border-radius: var(--radius-sm); + font-size: var(--fs-lg); + line-height: 1; +} + +.tip-card__body { + margin: var(--sp-1) 0 0; + max-width: 72ch; + font-size: var(--fs-sm); + color: var(--c-text-muted); +} + +/* "Detaljer" — one disclosure row at the foot of the page. */ +.dashboard__details { + margin-top: var(--sp-6); +} + +.dashboard__details-body { + display: flex; + flex-direction: column; + gap: var(--sp-5); + align-items: flex-start; +} + +/* Overview metrics — an unboxed label/value list. */ +.overview-metrics { + width: 100%; +} + +.overview-metrics__title { + margin: 0 0 var(--sp-2); + font-size: var(--fs-sm); + font-weight: 500; + color: var(--c-text-muted); +} + +.overview-metrics__list { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + column-gap: var(--sp-4); + row-gap: var(--sp-1); + max-width: 56ch; + font-size: var(--fs-sm); +} + +.overview-metrics__item { + display: contents; +} + +.overview-metrics__label { + color: var(--c-text-muted); +} + +.overview-metrics__value { + text-align: right; + font-variant-numeric: tabular-nums; + font-weight: 500; + color: var(--c-text); +} + +.overview-metrics__item--summary .overview-metrics__label, +.overview-metrics__item--summary .overview-metrics__value { + padding-top: var(--sp-1); font-weight: 600; + color: var(--c-text); } -.plan-fold__section { - margin-top: var(--sp-4); +/* Parental-leave snapshot — one numeric column per parent. */ +.parental-leave-breakdown { + display: flex; + flex-direction: column; + gap: var(--sp-3); + width: 100%; +} + +.parental-leave-breakdown__empty { + margin: 0; + font-size: var(--fs-sm); + color: var(--c-text-muted); +} + +.parental-leave-breakdown__table { + width: auto; + font-size: var(--fs-sm); +} + +.parental-leave-breakdown__table th, +.parental-leave-breakdown__table td { + padding: var(--sp-1) 0 var(--sp-1) var(--sp-5); + border-bottom: none; +} + +.parental-leave-breakdown__table th:first-child, +.parental-leave-breakdown__table td:first-child { + padding-left: 0; +} + +.parental-leave-breakdown__table tbody th { + font-weight: 400; + color: var(--c-text-muted); +} + +.parental-leave-breakdown__table thead th { + font-weight: 500; + color: var(--c-text); +} + +.parental-leave-breakdown__table tr:hover td { + background-color: transparent; +} + +.parental-leave-breakdown__num { + text-align: right; + font-weight: 500; + color: var(--c-text); +} + +.parental-leave-breakdown__provenance { + margin: 0; + padding: 0; + list-style: none; + font-size: var(--fs-sm); + color: var(--c-text-muted); +} + +.parental-leave-breakdown__provenance li { + margin: 0; +} + +.parental-leave-breakdown__provenance-name { + font-weight: 500; + color: var(--c-text); +} + +.parental-leave-breakdown__reserved-note { + margin: 0; + max-width: 64ch; + font-size: var(--fs-sm); + color: var(--c-text-muted); } /* Form styles */ @@ -872,63 +1056,3 @@ legend { color: var(--c-danger); margin: var(--sp-2) 0 0; } - -/* Tip Card (Optimization Tips, Översikt) — previously had no CSS at all, - * so every tip fell back to a plain `