diff --git a/backend/app/config.py b/backend/app/config.py index 84a160b..1ed8951 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -88,10 +88,11 @@ class Settings(BaseSettings): log_level: str = "DEBUG" - # FK scraper — legal/ToS review completed (2026-06). Default is False (conservative). - # Set APP_SCRAPER_ENABLED=true in the environment or backend/.env to enable. + # FK scraper — legal/ToS review completed (2026-06); cleared for use. + # Default is True. Set APP_SCRAPER_ENABLED=false to disable (e.g. an + # environment without Playwright chromium installed). # Playwright chromium binaries must be installed: `playwright install chromium`. - scraper_enabled: bool = False + scraper_enabled: bool = True scraper_headless: bool = True scraper_session_ttl_seconds: int = 180 # auto-cancel awaiting_scan/authenticating sessions # Set to None to disable debug artifact saving. Files may contain personal data. diff --git a/backend/tests/unit/test_fetch_data_router.py b/backend/tests/unit/test_fetch_data_router.py index ab48e60..f09bb92 100644 --- a/backend/tests/unit/test_fetch_data_router.py +++ b/backend/tests/unit/test_fetch_data_router.py @@ -8,23 +8,21 @@ primary onboarding action. Follows the `test_calendar_router.py` / graph, not just the service, so status codes and wire-shape regressions are caught. -`settings.scraper_enabled` defaults to `False` in `app/config.py` (the -checked-in, CI-facing configuration -- no `APP_SCRAPER_ENABLED` is set in -CI). On at least one contributor's machine, an untracked, gitignored -`backend/.env` sets `APP_SCRAPER_ENABLED=true` for manual scraper testing -(see `AGENTS.md`'s `npm run login` note) -- so `settings.scraper_enabled` at -*import time* is genuinely ambient and must not be asserted on directly (it -was `True` when this suite was drafted, on the machine that drafted it, -despite the shipped default being `False`). +`settings.scraper_enabled` defaults to `True` in `app/config.py` (the FK +legal/ToS review completed 2026-06; see the field's docstring) but a +contributor's untracked, gitignored `backend/.env` can still override it +either way -- so `settings.scraper_enabled` at *import time* is genuinely +ambient and must not be asserted on directly. The scraper-session tests below therefore use `monkeypatch` to pin `app.routers.fetch_data.settings.scraper_enabled` to a known value for the duration of each test, rather than asserting on (or branching on) whatever a contributor's local `.env` happens to set. This is deliberately more robust than reading the live value once and assuming it holds — the disabled path -is what both the shipped default and CI exercise; the enabled path is -exercised here only far enough to prove the guard flips (`start_session` is -faked out, never a real Playwright session -- see the module docstring in +is exercised here to prove the guard still works for an environment that +opts back out (e.g. one without Playwright chromium installed); the enabled +path is exercised only far enough to prove the guard flips (`start_session` +is faked out, never a real Playwright session -- see the module docstring in `app/scrapers/fk_scraper.py`; actually driving it here would launch a real, non-headless browser as a fire-and-forget background task, which is unsafe to do from a router test). @@ -181,9 +179,10 @@ class TestGetLatestAndHistory: class TestScraperEndpointsDisabled: - """`settings.scraper_enabled = False` is the shipped default and what CI - exercises (see the module docstring for why this is pinned via - `monkeypatch` rather than asserted on ambiently). + """Covers the disabled path (an environment that opts out via + `APP_SCRAPER_ENABLED=false`, e.g. one without Playwright chromium + installed) — see the module docstring for why this is pinned via + `monkeypatch` rather than asserted on ambiently. """ async def test_start_returns_501_when_scraper_disabled( diff --git a/frontend/package.json b/frontend/package.json index b59554b..936c3e8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,7 @@ "e2e": "playwright test", "e2e:ui": "playwright test --ui", "login": "concurrently -k -n backend,web \"npm:login:backend\" \"npm:login:web\"", - "login:backend": "cd ../backend && APP_SCRAPER_ENABLED=true APP_SCRAPER_HEADLESS=true uv run uvicorn app.main:app --reload --port 8000", + "login:backend": "cd ../backend && APP_SCRAPER_HEADLESS=true uv run uvicorn app.main:app --reload --port 8000", "login:web": "vite --open /dev/fetch" }, "dependencies": { diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 6f98098..f375172 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -30,10 +30,6 @@ vi.mock('@/pages/Settings', () => ({ Settings: () =>
Settings Page
, })) -vi.mock('@/pages/Tips', () => ({ - Tips: () =>
Tips Page
, -})) - vi.mock('@/pages/DevFetch', () => ({ DevFetch: () =>
DevFetch Page
, })) @@ -126,6 +122,40 @@ describe('App', () => { }) }) + describe('retired /plan and /tips routes', () => { + // The Plan and Tips tabs were removed (leave is now authored on the + // calendar, and Översikt carries the headline figures) — neither route + // is registered any more, so both now fall through to the catch-all + // and redirect to "/" instead of rendering a page or 404ing. + it('redirects /plan to the dashboard when authenticated', async () => { + vi.mocked(api.refreshToken).mockResolvedValue({ + access_token: 'test-token', + token_type: 'bearer', + expires_in: 3600, + }) + vi.mocked(api.setAccessToken).mockImplementation(() => {}) + + renderWithRoute('/plan') + await waitFor(() => { + expect(screen.getByTestId('dashboard-page')).toBeInTheDocument() + }) + }) + + it('redirects /tips to the dashboard when authenticated', async () => { + vi.mocked(api.refreshToken).mockResolvedValue({ + access_token: 'test-token', + token_type: 'bearer', + expires_in: 3600, + }) + vi.mocked(api.setAccessToken).mockImplementation(() => {}) + + renderWithRoute('/tips') + await waitFor(() => { + expect(screen.getByTestId('dashboard-page')).toBeInTheDocument() + }) + }) + }) + describe('app structure', () => { it('wraps app with AuthProvider and WorkspaceProvider', async () => { // Verify the provider wrappers are in place by checking that auth flow works diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f528abd..e5141e7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,9 +5,7 @@ import { LanguageProvider, useTranslation } from '@/i18n' import { Auth } from '@/pages/Auth' import { Calendar } from '@/pages/Calendar' import { Dashboard } from '@/pages/Dashboard' -import { Plan } from '@/pages/Plan' import { Settings } from '@/pages/Settings' -import { Tips } from '@/pages/Tips' import { DevFetch } from '@/pages/DevFetch' function RequireAuth({ children }: { children: React.ReactNode }) { @@ -38,22 +36,6 @@ function AppRoutes() { } /> - - - - } - /> - - - - } - />
- {formatDateForDisplay(override.date)} + {formatDateForDisplay(override.date, locale)}
diff --git a/frontend/src/components/layout/Header.test.tsx b/frontend/src/components/layout/Header.test.tsx index 63d4d3d..05be753 100644 --- a/frontend/src/components/layout/Header.test.tsx +++ b/frontend/src/components/layout/Header.test.tsx @@ -59,11 +59,29 @@ describe('Header', () => { renderWithRouter(
) expect(screen.getByText('Översikt')).toBeInTheDocument() expect(screen.getByText('Kalender')).toBeInTheDocument() - expect(screen.getByText('Planering')).toBeInTheDocument() - expect(screen.getByText('Tips')).toBeInTheDocument() expect(screen.getByText('Inställningar')).toBeInTheDocument() }) + it('does not render the retired Plan/Tips nav entries', () => { + renderWithRouter(
) + expect(screen.queryByText('Planering')).not.toBeInTheDocument() + expect(screen.queryByText('Tips')).not.toBeInTheDocument() + }) + + it('sets an aria-label on the child select for accessibility', () => { + const childrenList = [ + { id: '1', name: 'Emma' }, + { id: '2', name: 'Oscar' }, + ] + renderWithRouter(
) + expect(screen.getByTestId('child-select')).toHaveAttribute('aria-label', 'Välj barn') + }) + + it('sets an aria-label on the logout button for accessibility', () => { + renderWithRouter(
) + expect(screen.getByTestId('logout-button')).toHaveAttribute('aria-label', 'Logga ut') + }) + it('renders logout button when onLogout provided', () => { const onLogout = vi.fn() renderWithRouter(
) @@ -103,16 +121,6 @@ describe('Header', () => { expect(screen.getByText('Översikt')).not.toHaveClass('header__nav-link--active') }) - it('marks the plan link active on the plan route', () => { - renderAtPath(
, '/plan') - expect(screen.getByText('Planering')).toHaveClass('header__nav-link--active') - }) - - it('marks the tips link active on the tips route', () => { - renderAtPath(
, '/tips') - expect(screen.getByText('Tips')).toHaveClass('header__nav-link--active') - }) - it('marks the settings link active on the settings route, and the overview link inactive', () => { renderAtPath(
, '/settings') expect(screen.getByText('Inställningar')).toHaveClass('header__nav-link--active') diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 3ae363e..25f506a 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -68,18 +68,6 @@ export function Header({ > {t('nav.calendar')} - `header__nav-link ${isActive ? 'header__nav-link--active' : ''}`} - > - {t('nav.plan')} - - `header__nav-link ${isActive ? 'header__nav-link--active' : ''}`} - > - {t('nav.tips')} - `header__nav-link ${isActive ? 'header__nav-link--active' : ''}`} diff --git a/frontend/src/components/planning/PlanResults.test.tsx b/frontend/src/components/planning/PlanResults.test.tsx deleted file mode 100644 index 78c7f8a..0000000 --- a/frontend/src/components/planning/PlanResults.test.tsx +++ /dev/null @@ -1,608 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { render, screen } from '@testing-library/react' -import { PlanResults } from './PlanResults' -import { LanguageProvider } from '@/i18n' -import type { PersonalData } from '@/types' -import { makePersonalData, makePlanProposal, makeWindowOutcome, makePlanWarning, makeSurplusAdvice, makePlanAge4Projection, makeVacationBalance } from '../../../tests/fixtures' - -const mockMembers: PersonalData[] = [ - makePersonalData({ uuid: 'pd1', name: 'Alex' }), - makePersonalData({ uuid: 'pd2', name: 'Sam' }), -] - -// Node's own experimental `localStorage` global shadows jsdom's working -// implementation in this sandbox (§4.2) — stub a real in-memory Storage, -// same idiom as LanguageContext.test.tsx / WorkspaceContext.test.tsx. -function createStorageMock(): Storage { - let store: Record = {} - return { - getItem: (key: string) => (key in store ? store[key]! : null), - setItem: (key: string, value: string) => { - store[key] = value - }, - removeItem: (key: string) => { - delete store[key] - }, - clear: () => { - store = {} - }, - key: (index: number) => Object.keys(store)[index] ?? null, - get length() { - return Object.keys(store).length - }, - } -} - -function setNavigatorLanguage(lang: string) { - Object.defineProperty(window.navigator, 'language', { value: lang, configurable: true }) -} - -/** Render under a real `` resolved to `locale` via browser - * detection (no explicit preference stored) — same pattern - * LanguageContext.test.tsx uses for its provider tests. - */ -function renderLocalized(ui: React.ReactElement, locale: 'sv' | 'en') { - setNavigatorLanguage(locale === 'sv' ? 'sv-SE' : 'en-US') - return render({ui}) -} - - -describe('PlanResults', () => { - const originalLanguage = window.navigator.language - - beforeEach(() => { - vi.stubGlobal('localStorage', createStorageMock()) - }) - - afterEach(() => { - setNavigatorLanguage(originalLanguage) - vi.unstubAllGlobals() - }) - - it('shows the "this is a preview" banner', () => { - renderLocalized(, 'sv') - expect(screen.getByTestId('plan-preview-banner')).toHaveTextContent(/förslag/i) - }) - - it('renders a multi-year overview row per year with each outcome as a badge', () => { - const proposal = makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw1', - wishName: 'Sommar', - year: 2026, - startDate: '2026-06-15', - endDate: '2026-07-26', - weeksWished: 6, - weeksFunded: 6, - costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 }, - }), - makeWindowOutcome({ - wishId: 'lw2', - wishName: 'Jul', - year: 2027, - startDate: '2027-12-20', - endDate: '2028-01-16', - weeksWished: 4, - weeksFunded: 4, - costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 }, - strategy: 'solo', - }), - ], - }) - renderLocalized(, 'sv') - - expect(screen.getByTestId('plan-overview-year-2026')).toBeInTheDocument() - expect(screen.getByTestId('plan-overview-year-2027')).toBeInTheDocument() - expect(screen.getByText(/Sommar: 6\/6 veckor/)).toBeInTheDocument() - expect(screen.getByText(/Jul: 4\/4 veckor/)).toBeInTheDocument() - }) - - it('renders a card per window outcome with the day breakdown and explanation', () => { - const proposal = makePlanProposal({ - poolBalances: [{ parentPersonalDataUuid: 'pd1', childId: 'child1', sjukpenningLeft: 87, lagstaLeft: 0, dubbeldagarLeft: 60 }], - vacationBalances: [{ parentPersonalDataUuid: 'pd1', year: 2026, totalDays: 25, savedDaysIn: 0, alreadyUsed: 6, isAssumed: false }], - outcomes: [ - makeWindowOutcome({ - wishId: 'lw1', - wishName: 'Sommar', - year: 2026, - startDate: '2026-06-15', - endDate: '2026-07-26', - weeksWished: 6, - weeksFunded: 6, - costBreakdown: { free: 12, vacation: 19, sjukpenning: 11, lagsta: 0 }, - data: { wish_name: 'Sommar', year: 2026, weeks_wished: 6, weeks_funded: 6, shortfall_days: 0 }, - }), - ], - }) - renderLocalized(, 'sv') - - const card = screen.getByTestId('plan-outcome-lw1-2026') - expect(card).toHaveTextContent('Sommar 2026') - expect(card).toHaveTextContent('6 veckor önskade') - expect(card).toHaveTextContent('12 fria dagar') - expect(card).toHaveTextContent('19 semesterdagar') - expect(card).toHaveTextContent('11 FP-dagar (sjukpenningnivå)') - expect(card).toHaveTextContent('Sommar 2026 — 6 veckor önskade, 6 veckor finansierade') - expect(screen.getByText('Fullt finansierad')).toBeInTheDocument() - }) - - it('renders the window explanation in English when the locale is English', () => { - const proposal = makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw1', - wishName: 'Sommar', - year: 2026, - startDate: '2026-06-15', - endDate: '2026-07-26', - weeksWished: 6, - weeksFunded: 6, - costBreakdown: { free: 12, vacation: 19, sjukpenning: 11, lagsta: 0 }, - data: { wish_name: 'Sommar', year: 2026, weeks_wished: 6, weeks_funded: 6, shortfall_days: 0 }, - }), - ], - }) - renderLocalized(, 'en') - - const card = screen.getByTestId('plan-outcome-lw1-2026') - expect(card).toHaveTextContent('Sommar 2026 — 6 weeks wished, 6 weeks funded') - expect(card).toHaveTextContent('19 vacation days') - expect(card).toHaveTextContent('11 parental-benefit days (sjukpenningnivå, income-based)') - expect(screen.getByText('Fully funded')).toBeInTheDocument() - }) - - it('shows a shortfall banner with its ranked remedies when a window is underfunded', () => { - const proposal = makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw1', - wishName: 'Sommar', - year: 2026, - startDate: '2026-06-15', - endDate: '2026-07-26', - weeksWished: 6, - weeksFunded: 4, - shortfallDays: 14, - strategy: 'solo', - costBreakdown: { free: 12, vacation: 10, sjukpenning: 6, lagsta: 0 }, - remedies: [ - { - kind: 'carry_vacation_forward', - messageKey: 'planRemedies.carry_vacation_forward', - data: { spare_total: 5 }, - daysFreed: 5, - }, - { - kind: 'lower_priority_wish', - messageKey: 'planRemedies.lower_priority_wish', - data: { wish_name: 'Jul', from_weeks: 4, to_weeks: 2, freed_days: 6 }, - daysFreed: 6, - }, - ], - }), - ], - }) - renderLocalized(, 'sv') - - const shortfall = screen.getByTestId('plan-shortfall-lw1-2026') - expect(shortfall).toHaveTextContent('14 dagar kunde inte finansieras') - - const remedies = screen.getByTestId('plan-remedies-lw1-2026') - expect(remedies).toHaveTextContent('5 semesterdagar från tidigare år kan sparas framåt') - expect(remedies).toHaveTextContent('Sänk Jul från 4 till 2 veckor frigör 6 dagar') - }) - - it('renders remedies in English when the locale is English', () => { - const proposal = makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw1', - wishName: 'Sommar', - year: 2026, - startDate: '2026-06-15', - endDate: '2026-07-26', - weeksWished: 6, - weeksFunded: 4, - shortfallDays: 14, - strategy: 'solo', - costBreakdown: { free: 12, vacation: 10, sjukpenning: 6, lagsta: 0 }, - remedies: [ - { - kind: 'carry_vacation_forward', - messageKey: 'planRemedies.carry_vacation_forward', - data: { spare_total: 5 }, - daysFreed: 5, - }, - ], - }), - ], - }) - renderLocalized(, 'en') - - const remedies = screen.getByTestId('plan-remedies-lw1-2026') - expect(remedies).toHaveTextContent('5 vacation days from previous years can be carried forward') - }) - - it('displays the days-lost-at-age-4 warning prominently', () => { - const proposal = makePlanProposal({ - warnings: [ - makePlanWarning({ - data: { - parent_personal_data_uuid: 'pd1', - expiring_days: 117, - fourth_birthday: '2030-03-01', - }, - }), - ], - }) - renderLocalized(, 'sv') - - const warning = screen.getByTestId('plan-warning-days_lost_at_age_4-0') - expect(warning).toHaveTextContent('Alex riskerar att förlora 117 dagar') - }) - - it('renders the warning body in English, resolving the parent name, when the locale is English', () => { - const proposal = makePlanProposal({ - warnings: [ - makePlanWarning({ - data: { - parent_personal_data_uuid: 'pd1', - expiring_days: 117, - fourth_birthday: '2030-03-01', - }, - }), - ], - }) - renderLocalized(, 'en') - - const warning = screen.getByTestId('plan-warning-days_lost_at_age_4-0') - expect(warning).toHaveTextContent("Alex risks losing 117 days at the child's 4th birthday") - }) - - it('resolves parent uuids to names in the balances tables', () => { - const proposal = makePlanProposal({ - poolBalances: [{ parentPersonalDataUuid: 'pd1', childId: 'child1', sjukpenningLeft: 87, lagstaLeft: 0, dubbeldagarLeft: 60 }], - vacationBalances: [{ parentPersonalDataUuid: 'pd1', year: 2026, totalDays: 25, savedDaysIn: 0, alreadyUsed: 6, isAssumed: false }], - }) - renderLocalized(, 'sv') - - expect(screen.getByTestId('plan-pool-balances')).toHaveTextContent('Alex') - expect(screen.getByTestId('plan-vacation-balances')).toHaveTextContent('Alex') - }) - - it('does not render the surplus advice or age-4 projection sections when absent', () => { - renderLocalized(, 'sv') - - expect(screen.queryByTestId('plan-surplus-advice')).not.toBeInTheDocument() - expect(screen.queryByTestId('plan-age4-projections')).not.toBeInTheDocument() - }) - - it('renders ranked surplus advice when the plan forecasts a surplus (§9.2)', () => { - const proposal = makePlanProposal({ - surplusAdvice: [ - makeSurplusAdvice({ - kind: 'wish_more_leave', - messageKey: 'planSurplusAdvice.wish_more_leave', - daysInvolved: 10, - data: { parent_personal_data_uuid: 'pd1', child_id: 'child1', weeks: 2 }, - }), - makeSurplusAdvice({ - kind: 'use_less_vacation', - messageKey: 'planSurplusAdvice.use_less_vacation', - daysInvolved: 10, - data: { - parent_personal_data_uuid: 'pd1', - child_id: 'child1', - years_text: '2026, 2027', - min_vacation_days_per_year: 20, - }, - }), - ], - }) - renderLocalized(, 'sv') - - const section = screen.getByTestId('plan-surplus-advice') - expect(section).toHaveTextContent('Alex har råd med ytterligare 2 veckor ledigt') - expect(section).toHaveTextContent('Alex kan ta ut 10 färre semesterdagar (2026, 2027)') - expect(screen.getByTestId('plan-surplus-advice-wish_more_leave-0')).toBeInTheDocument() - expect(screen.getByTestId('plan-surplus-advice-use_less_vacation-1')).toBeInTheDocument() - }) - - it('renders surplus advice in English, resolving the parent name, when the locale is English', () => { - const proposal = makePlanProposal({ - surplusAdvice: [ - makeSurplusAdvice({ - kind: 'wish_more_leave', - messageKey: 'planSurplusAdvice.wish_more_leave', - daysInvolved: 10, - data: { parent_personal_data_uuid: 'pd1', child_id: 'child1', weeks: 2 }, - }), - ], - }) - renderLocalized(, 'en') - - const section = screen.getByTestId('plan-surplus-advice') - expect(section).toHaveTextContent('Alex can afford 2 more week(s) of leave') - expect(section).toHaveTextContent('10 parental-benefit days') - }) - - it('renders the plan-aware age-4 projection table (§3.3 part 2)', () => { - const proposal = makePlanProposal({ - age4Projections: [ - makePlanAge4Projection({ - parentPersonalDataUuid: 'pd1', - childId: 'child1', - fourthBirthday: '2030-03-01', - projectedLeftAtB4: 106, - savableAfterB4: 96, - expiringAtB4: 10, - }), - ], - }) - renderLocalized(, 'sv') - - const table = screen.getByTestId('plan-age4-projections-table') - expect(table).toHaveTextContent('Alex') - expect(table).toHaveTextContent('2030-03-01') - expect(table).toHaveTextContent('106') - expect(table).toHaveTextContent('96') - expect(table).toHaveTextContent('10') - }) - - // The Dashboard's own expiring-at-4 figure reflects the currently saved - // plan; this table reflects an unsaved proposal and can legitimately show - // a different number. The title/intro must say so, so the two screens are - // never mistaken for showing the same thing (types/entities.ts: - // PlanAge4Projection vs Age4Projection). - it('labels the age-4 projection section as previewing this unsaved proposal, in Swedish', () => { - const proposal = makePlanProposal({ - age4Projections: [ - makePlanAge4Projection({ parentPersonalDataUuid: 'pd1', childId: 'child1', fourthBirthday: '2030-03-01', projectedLeftAtB4: 106, savableAfterB4: 96, expiringAtB4: 10 }), - ], - }) - renderLocalized(, 'sv') - - const section = screen.getByTestId('plan-age4-projections') - expect(section).toHaveTextContent('Vid barnets 4-årsdag, om detta förslag tillämpas') - expect(section).toHaveTextContent('om just detta förslag tillämpas') - expect(section).toHaveTextContent('Din sparade plan') - }) - - it('labels the age-4 projection section as previewing this unsaved proposal, in English', () => { - const proposal = makePlanProposal({ - age4Projections: [ - makePlanAge4Projection({ parentPersonalDataUuid: 'pd1', childId: 'child1', fourthBirthday: '2030-03-01', projectedLeftAtB4: 106, savableAfterB4: 96, expiringAtB4: 10 }), - ], - }) - renderLocalized(, 'en') - - const section = screen.getByTestId('plan-age4-projections') - expect(section).toHaveTextContent("At the child's 4th birthday, if this proposal is applied") - expect(section).toHaveTextContent('if this proposal is applied') - expect(section).toHaveTextContent('Your saved plan') - }) - - it('shows an assumed badge for a vacation year with no entered data (§3.2)', () => { - const proposal = makePlanProposal({ - vacationBalances: [ - makeVacationBalance({ parentPersonalDataUuid: 'pd1', year: 2026, totalDays: 25, savedDaysIn: 0, alreadyUsed: 6, isAssumed: true }), - ], - }) - renderLocalized(, 'sv') - - expect(screen.getByTestId('vacation-assumed-badge-pd1-2026')).toBeInTheDocument() - }) - - // PLANNING_ENGINE_SPEC.md §10.4 — a flexible wish that could not be placed - // at all: bound long enough, but every full-length candidate collided with - // another wish's claimed dates. Reuses the existing shortfall vocabulary - // (weeksFunded 0, shortfallDays = the full calendar-day duration, an - // all-zero costBreakdown) plus the same windowExplanation message_key — the - // only addition is the 'unplaced' strategy label. - - it('renders an unplaced flexible wish as a 0%-funded shortfall card with the "unplaced" strategy label (sv)', () => { - const proposal = makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw3', - wishName: 'Vår', - year: 2027, - startDate: '2027-03-01', - endDate: '2027-05-31', - weeksWished: 6, - weeksFunded: 0, - shortfallDays: 42, - strategy: 'unplaced', - costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 }, - remedies: [ - { - kind: 'lower_priority_wish', - messageKey: 'planRemedies.lower_priority_wish', - data: { wish_name: 'Sommar', from_weeks: 6, to_weeks: 4, freed_days: 14 }, - daysFreed: 14, - }, - ], - }), - ], - }) - renderLocalized(, 'sv') - - const card = screen.getByTestId('plan-outcome-lw3-2027') - expect(card).toHaveTextContent('Kunde inte placeras') - expect(card).toHaveTextContent('0 veckor finansierade') - - const shortfall = screen.getByTestId('plan-shortfall-lw3-2027') - expect(shortfall).toHaveTextContent('42 dagar kunde inte finansieras') - }) - - it('renders an unplaced flexible wish with the "unplaced" strategy label in English', () => { - const proposal = makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw3', - wishName: 'Vår', - year: 2027, - startDate: '2027-03-01', - endDate: '2027-05-31', - weeksWished: 6, - weeksFunded: 0, - shortfallDays: 42, - strategy: 'unplaced', - costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 }, - remedies: [ - { - kind: 'lower_priority_wish', - messageKey: 'planRemedies.lower_priority_wish', - data: { wish_name: 'Sommar', from_weeks: 6, to_weeks: 4, freed_days: 14 }, - daysFreed: 14, - }, - ], - }), - ], - }) - renderLocalized(, 'en') - - const card = screen.getByTestId('plan-outcome-lw3-2027') - expect(card).toHaveTextContent('Could not be placed') - expect(card).toHaveTextContent('0 weeks funded') - - const shortfall = screen.getByTestId('plan-shortfall-lw3-2027') - expect(shortfall).toHaveTextContent('42 days could not be funded') - }) - - // The hole this closes: a preset-created wish's name used to reach the - // proposal only as a pre-translated literal (`wishName`/`data.wish_name`), - // so it stayed in whatever language the UI was in when the wish was - // created. `wishNameMessageKey` (mirroring `LeaveWish.nameMessageKey`, - // see src/utils/wishName.ts) lets every surface re-translate it, exactly - // like WishEditor already does for the wish list itself. - describe('wishNameMessageKey (preset wish names follow the UI language)', () => { - it('re-translates the name through the badge, card heading, and explanation in Swedish, ignoring the stale stored name', () => { - const proposal = makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw1', - wishName: 'STALE_SWEDISH_TEXT', - wishNameMessageKey: 'wishEditor.presetSommar', - costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 }, - data: { wish_name: 'STALE_SWEDISH_TEXT', wish_name_message_key: 'wishEditor.presetSommar', year: 2026, weeks_wished: 6, weeks_funded: 6, shortfall_days: 0 }, - }), - ], - }) - renderLocalized(, 'sv') - - expect(screen.getByText(/Sommar: 6\/6 veckor/)).toBeInTheDocument() - const card = screen.getByTestId('plan-outcome-lw1-2026') - expect(card).toHaveTextContent('Sommar 2026') - expect(card).toHaveTextContent('Sommar 2026 — 6 veckor önskade, 6 veckor finansierade') - expect(card).not.toHaveTextContent('STALE_SWEDISH_TEXT') - }) - - it('re-translates the same name to English when the locale is English', () => { - const proposal = makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw1', - wishName: 'STALE_SWEDISH_TEXT', - wishNameMessageKey: 'wishEditor.presetSommar', - costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 }, - data: { wish_name: 'STALE_SWEDISH_TEXT', wish_name_message_key: 'wishEditor.presetSommar', year: 2026, weeks_wished: 6, weeks_funded: 6, shortfall_days: 0 }, - }), - ], - }) - renderLocalized(, 'en') - - expect(screen.getByText(/Summer: 6\/6 weeks/)).toBeInTheDocument() - const card = screen.getByTestId('plan-outcome-lw1-2026') - expect(card).toHaveTextContent('Summer 2026') - expect(card).toHaveTextContent('Summer 2026 — 6 weeks wished, 6 weeks funded') - expect(card).not.toHaveTextContent('STALE_SWEDISH_TEXT') - }) - - it('shows the literal name verbatim when wishNameMessageKey is null (free-typed/renamed wish)', () => { - const proposal = makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw1', - wishName: 'Resa till mormor', - wishNameMessageKey: null, - year: 2026, - startDate: '2026-06-15', - endDate: '2026-07-26', - weeksWished: 6, - weeksFunded: 6, - costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 }, - }), - ], - }) - renderLocalized(, 'sv') - - const card = screen.getByTestId('plan-outcome-lw1-2026') - expect(card).toHaveTextContent('Resa till mormor 2026') - }) - - it('falls back to the literal name for a key this build does not recognise', () => { - const proposal = makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw1', - wishName: 'Sommar', - wishNameMessageKey: 'wishEditor.thisKeyDoesNotExist', - year: 2026, - startDate: '2026-06-15', - endDate: '2026-07-26', - weeksWished: 6, - weeksFunded: 6, - costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 }, - }), - ], - }) - renderLocalized(, 'sv') - - const card = screen.getByTestId('plan-outcome-lw1-2026') - expect(card).toHaveTextContent('Sommar 2026') - expect(card).not.toHaveTextContent('wishEditor.thisKeyDoesNotExist') - }) - - it('re-translates a lower_priority_wish remedy\'s referenced sibling name independently of the outcome\'s own wish', () => { - const proposal = makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw1', - wishName: 'STALE_SWEDISH_TEXT', - wishNameMessageKey: 'wishEditor.presetSommar', - year: 2026, - startDate: '2026-06-15', - endDate: '2026-07-26', - weeksWished: 6, - weeksFunded: 4, - shortfallDays: 14, - strategy: 'parent_swap', - costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 }, - remedies: [ - { - kind: 'lower_priority_wish', - messageKey: 'planRemedies.lower_priority_wish', - data: { - wish_name: 'STALE_JUL_TEXT', - wish_name_message_key: 'wishEditor.seasonJul', - from_weeks: 4, - to_weeks: 2, - freed_days: 6, - }, - daysFreed: 6, - }, - ], - }), - ], - }) - renderLocalized(, 'sv') - - const remedies = screen.getByTestId('plan-remedies-lw1-2026') - expect(remedies).toHaveTextContent('Sänk Jul från 4 till 2 veckor frigör 6 dagar') - expect(remedies).not.toHaveTextContent('STALE_JUL_TEXT') - }) - }) -}) diff --git a/frontend/src/components/planning/PlanResults.tsx b/frontend/src/components/planning/PlanResults.tsx deleted file mode 100644 index f810aa5..0000000 --- a/frontend/src/components/planning/PlanResults.tsx +++ /dev/null @@ -1,651 +0,0 @@ -/** - * Renders a `PlanProposal` from `POST /api/plan/propose`. - * - * Shows an "Använd plan" button to apply the proposal. On collision, - * shows the clashing dates and prompts user to confirm overwrite. - * Once applied, shows the result (created/skipped/replaced counts). - */ -import { ReactNode, useState, useEffect } from 'react' -import type { - PersonalData, - PlanProposal, - WindowOutcome, - ShortfallRemedy, - PlanWarning, - SurplusAdvice, - Rejection, - ProposedDayApplyItem, - PlanApplyResponse, -} from '@/types' -import { useTranslation, toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n' -import { nameResolver } from '@/utils/parentNames' -import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName' - -export interface PlanResultsProps { - proposal: PlanProposal - members: PersonalData[] - isApplying?: boolean - onApply?: (days: ProposedDayApplyItem[], replace: boolean) => Promise -} - -// §3.5 — `outcome.messageKey`/`data`, `remedy.messageKey`/`data`, -// `warning.messageKey`/`data`, `advice.messageKey`/`data` and -// `rejection.messageKey`/`data` all follow the pattern `tips_service.py` -// established for `Tip`: the backend hands back an opaque i18n key plus a -// params bag instead of pre-rendered Swedish, and the lookup tables below -// map each known key to a real `TranslationKey` — same idiom as -// `TipCard.tsx`'s `TIP_TRANSLATION_KEYS`. An unrecognised key falls back to -// showing the raw key, same as `TipCard`. -const DAY_TYPE_KEYS: Record = { - free: 'planResults.dayTypeFree', - vacation: 'planResults.dayTypeVacation', - sjukpenning: 'planResults.dayTypeSjukpenning', - lagsta: 'planResults.dayTypeLagsta', -} - -const STRATEGY_KEYS: Record = { - dubbeldag: 'planResults.strategyDubbeldag', - parent_swap: 'planResults.strategyParentSwap', - solo: 'planResults.strategySolo', - // PLANNING_ENGINE_SPEC.md §10.4 — a flexible wish that could not be placed - // at all (bound long enough, but every full-length candidate collided with - // another wish's claimed dates). Renders like a 0%-funded shortfall card — - // no other change needed here, `outcome.shortfallDays` already drives that. - unplaced: 'planResults.strategyUnplaced', -} - -const WARNING_KEYS: Record = { - days_lost_at_age_4: 'planResults.warningDaysLostAtAge4', - sgi_risk: 'planResults.warningSgiRisk', - vacation_expiring: 'planResults.warningVacationExpiring', -} - -// §9.2 — surplus advice kinds, the mirror of the shortfall remedy kinds 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', -} - -// §3.5 — body-text lookups, keyed by the backend's opaque `message_key` -// (not by `kind` — a warning/remedy/advice's `kind` only picks its title; -// the body sentence is a separate, longer translation with its own params). -const WARNING_BODY_KEYS: Record = { - 'planWarnings.days_lost_at_age_4': 'planResults.warningBodyDaysLostAtAge4', - 'planWarnings.sgi_risk': 'planResults.warningBodySgiRisk', - 'planWarnings.vacation_expiring': 'planResults.warningBodyVacationExpiring', -} - -const REMEDY_BODY_KEYS: Record = { - 'planRemedies.reduce_preschool_reserve': 'planResults.remedyReducePreschoolReserve', - 'planRemedies.carry_vacation_forward': 'planResults.remedyCarryVacationForward', - 'planRemedies.lower_priority_wish': 'planResults.remedyLowerPriorityWish', - 'planRemedies.shift_window_into_free_days': 'planResults.remedyShiftWindowIntoFreeDays', -} - -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', -} - -const REJECTION_KEYS: Record = { - 'planApply.rejection.invalidDayType': 'planResults.rejectionInvalidDayType', - 'planApply.rejection.conflictingDuplicate': 'planResults.rejectionConflictingDuplicate', - 'planApply.rejection.collisionsNeedReplace': 'planResults.rejectionCollisionsNeedReplace', - 'planApply.rejection.dubbeldagWindowExceeded': 'planResults.rejectionDubbeldagWindowExceeded', - 'planApply.rejection.dubbeldagQuotaReached': 'planResults.rejectionDubbeldagQuotaReached', - 'planApply.rejection.dubbeldagInvalid': 'planResults.rejectionDubbeldagInvalid', - 'planApply.rejection.dubbeldagBatchQuotaExceeded': - 'planResults.rejectionDubbeldagBatchQuotaExceeded', - 'planApply.rejection.outsideEligibleWindow': 'planResults.rejectionOutsideEligibleWindow', - 'planApply.rejection.insufficientBalance': 'planResults.rejectionInsufficientBalance', -} - -/** `data` plus the resolved parent name (when the surface carries a - * `parent_personal_data_uuid`) — every warning/advice body template below - * references `{parent_name}`, and the backend only ever sends the uuid. - */ -function withParentName( - data: Record, - nameFor: (uuid: string) => string, -): TranslationParams { - const params = toTranslationParams(data) - const uuid = data.parent_personal_data_uuid - if (typeof uuid === 'string') { - params.parent_name = nameFor(uuid) - } - return params -} - -type Translate = (key: TranslationKey, params?: TranslationParams) => string - -/** Surface #1 — `outcome.explanation`. Composes the same summary + - * shortfall + cost-breakdown sentence the allocator used to render in - * Swedish (backend/app/services/allocation_service.py, `_build_explanation` - * before §3.5), but from `outcome.messageKey`/`data` (the summary/shortfall - * clause) and `outcome.costBreakdown` (the detail clause — already a - * separately-typed, already-translated-elsewhere field, so it isn't - * duplicated into `data`). - */ -function buildWindowExplanation(outcome: WindowOutcome, t: Translate): string { - const params = toTranslationParams(outcome.data) - if (typeof outcome.data.wish_name === 'string') { - params.wish_name = resolveWishNameFromData(outcome.data, t) - } - let text = t('planResults.explanationSummary', params) - const shortfallDays = outcome.data.shortfall_days - if (typeof shortfallDays === 'number' && shortfallDays > 0) { - text += t('planResults.explanationShortfallSuffix', params) - } - - const { vacation, sjukpenning, lagsta, free } = outcome.costBreakdown - const fpDays = sjukpenning + lagsta - const bits: string[] = [] - if (vacation > 0 && fpDays > 0) { - bits.push( - t('planResults.explanationDetailVacationAndFp', { vacation_days: vacation, fp_days: fpDays }), - ) - } else if (vacation > 0) { - bits.push(t('planResults.explanationDetailVacationOnly', { vacation_days: vacation })) - } - if (sjukpenning > 0) { - bits.push(t('planResults.explanationDetailSjukpenning', { sjukpenning_days: sjukpenning })) - } - if (lagsta > 0) { - bits.push(t('planResults.explanationDetailLagsta', { lagsta_days: lagsta })) - } - if (free > 0) { - bits.push(t('planResults.explanationDetailFree', { free_days: free })) - } - if (bits.length > 0) { - text += ': ' + bits.join(', ') - } - return text -} - -/** Surface #2 — `remedy.description`. */ -function renderRemedy(remedy: ShortfallRemedy, t: Translate): string { - const key = REMEDY_BODY_KEYS[remedy.messageKey] - if (!key) return remedy.messageKey - const params = toTranslationParams(remedy.data) - if (typeof remedy.data.wish_name === 'string') { - params.wish_name = resolveWishNameFromData(remedy.data, t) - } - return t(key, params) -} - -/** Surface #3 — `warning.message`. */ -function renderWarningBody( - warning: PlanWarning, - t: Translate, - nameFor: (uuid: string) => string, -): string { - const key = WARNING_BODY_KEYS[warning.messageKey] - return key ? t(key, withParentName(warning.data, nameFor)) : warning.messageKey -} - -/** The now-fifth surface — `advice.description` (`surplus_advice`). */ -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 = withParentName(advice.data, nameFor) - params.days_involved = advice.daysInvolved - return t(key, params) -} - -/** Surface #4 — `applyResult.rejections`. */ -function renderRejection(rejection: Rejection, t: Translate): string { - const key = REJECTION_KEYS[rejection.messageKey] - return key ? t(key, toTranslationParams(rejection.data)) : rejection.messageKey -} - -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 function PlanResults({ proposal, members, isApplying = false, onApply }: PlanResultsProps): ReactNode { - const { t } = useTranslation() - const nameFor = nameResolver(members) - const sortedOutcomes = [...proposal.outcomes].sort( - (a, b) => a.year - b.year || a.startDate.localeCompare(b.startDate), - ) - const yearRows = groupByYear(proposal.outcomes) - - const [applyResult, setApplyResult] = useState(null) - const [collisionPrompt, setCollisionPrompt] = useState(false) - const [pendingCollisions, setPendingCollisions] = useState(null) - - // Reset apply state when a new proposal arrives - useEffect(() => { - setApplyResult(null) - setCollisionPrompt(false) - setPendingCollisions(null) - }, [proposal]) - - const handleApply = async () => { - if (!onApply) return - const days = proposal.days - .filter((d) => d.dayType !== 'free') - .map((d) => ({ - date: d.date, - parentPersonalDataUuid: d.parentPersonalDataUuid, - childId: d.childId ?? '', - dayType: d.dayType as 'sjukpenning' | 'lagsta' | 'vacation', - isDubbeldag: d.isDubbeldag, - })) - - try { - const result = await onApply(days, false) - if (!result.accepted && result.collisions.length > 0) { - setPendingCollisions(result) - setCollisionPrompt(true) - } else { - setApplyResult(result) - setCollisionPrompt(false) - } - } catch { - // error is handled by the component calling onApply - } - } - - const handleConfirmOverwrite = async () => { - if (!onApply || !pendingCollisions) return - const days = proposal.days - .filter((d) => d.dayType !== 'free') - .map((d) => ({ - date: d.date, - parentPersonalDataUuid: d.parentPersonalDataUuid, - childId: d.childId ?? '', - dayType: d.dayType as 'sjukpenning' | 'lagsta' | 'vacation', - isDubbeldag: d.isDubbeldag, - })) - - try { - const result = await onApply(days, true) - setApplyResult(result) - setCollisionPrompt(false) - setPendingCollisions(null) - } catch { - // error is handled by the component calling onApply - } - } - - const handleCancelOverwrite = () => { - setCollisionPrompt(false) - setPendingCollisions(null) - } - - return ( -
- {applyResult && applyResult.accepted ? ( -

- {t('planResults.applySavedPrefix')}{' '} - {t('planResults.createdClause', { - count: applyResult.createdCount, - dayWord: t(applyResult.createdCount === 1 ? 'planResults.daySingular' : 'planResults.daysPlural'), - })} - {applyResult.replacedCount > 0 && - t('planResults.replacedClause', { - count: applyResult.replacedCount, - dayWord: t(applyResult.replacedCount === 1 ? 'planResults.daySingular' : 'planResults.daysPlural'), - })} - {applyResult.skippedIdenticalCount > 0 && - t('planResults.skippedIdenticalClause', { - count: applyResult.skippedIdenticalCount, - dayWord: t( - applyResult.skippedIdenticalCount === 1 ? 'planResults.daySingular' : 'planResults.daysPlural', - ), - })} - {applyResult.skippedFreeCount > 0 && - t('planResults.skippedFreeClause', { - count: applyResult.skippedFreeCount, - dayWord: t( - applyResult.skippedFreeCount === 1 ? 'planResults.freeDaySingular' : 'planResults.freeDaysPlural', - ), - })} - {applyResult.rejections.length > 0 && - t('planResults.rejectionsClause', { - rejections: applyResult.rejections.map((r) => renderRejection(r, t)).join('; '), - })} -

- ) : ( -

- {t('planResults.previewBanner')} -

- )} - - {!applyResult?.accepted && !collisionPrompt && ( -
- -
- )} - - {collisionPrompt && pendingCollisions && pendingCollisions.collisions.length > 0 && ( -
-

{t('planResults.confirmOverwriteTitle')}

-

{t('planResults.confirmOverwriteIntro')}

- - - - - - - - - - {pendingCollisions.collisions.map((collision, idx) => ( - - - - - - ))} - -
{t('planResults.collisionDateHeader')}{t('planResults.collisionExistingHeader')}{t('planResults.collisionProposedHeader')}
{collision.date}{collision.existingDayType}{collision.proposedDayType}
-
- - -
-
- )} - - {proposal.warnings.length > 0 && ( -
-

{t('planResults.warningsTitle')}

-
    - {proposal.warnings.map((warning, idx) => { - const warningKey = WARNING_KEYS[warning.kind] - return ( -
  • - {warningKey ? t(warningKey) : warning.kind} -

    {renderWarningBody(warning, t, nameFor)}

    -
  • - ) - })} -
-
- )} - - {proposal.surplusAdvice.length > 0 && ( -
-

{t('planResults.surplusAdviceTitle')}

-

{t('planResults.surplusAdviceIntro')}

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

    {renderSurplusAdviceBody(advice, t, nameFor)}

    -
  • - ) - })} -
-
- )} - - {proposal.age4Projections.length > 0 && ( -
-

{t('planResults.age4ProjectionsTitle')}

-

{t('planResults.age4ProjectionsIntro')}

- - - - - - - - - - - - {proposal.age4Projections.map((projection) => ( - - - - - - - - ))} - -
{t('planResults.parentHeader')}{t('planResults.fourthBirthdayHeader')}{t('planResults.projectedLeftHeader')}{t('planResults.savableAfterB4Header')}{t('planResults.expiringAtB4Header')}
{nameFor(projection.parentPersonalDataUuid)}{projection.fourthBirthday}{projection.projectedLeftAtB4}{projection.savableAfterB4} 0 ? 'plan-age4-projections__expiring--nonzero' : undefined - } - > - {projection.expiringAtB4} -
-
- )} - -
-

{t('planResults.yearOverviewTitle')}

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

{t('planResults.noWindowsInHorizon')}

- ) : ( - - - - - - - - - {yearRows.map(({ year, outcomes }) => ( - - - - - ))} - -
{t('planResults.yearHeader')}{t('planResults.leavesHeader')}
{year} -
- {outcomes.map((outcome) => ( - 0 - ? 'plan-overview-badge--shortfall' - : 'plan-overview-badge--ok' - }`} - > - {resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)}:{' '} - {outcome.weeksFunded}/{outcome.weeksWished} {t('planResults.weeksUnit')} - - ))} -
-
- )} -
- -
-

{t('planResults.detailTitle')}

- {sortedOutcomes.map((outcome) => { - const resolvedWishName = resolveWishName( - { name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, - t, - ) - return ( -
-

- {resolvedWishName} {outcome.year} - - {' '} - ({outcome.startDate} → {outcome.endDate}) - -

-

- {outcome.weeksWished} {t(outcome.weeksWished === 1 ? 'planResults.weekSingular' : 'planResults.weeksUnit')}{' '} - {t('planResults.wished')} - {' → '} - {outcome.weeksFunded} {t(outcome.weeksFunded === 1 ? 'planResults.weekSingular' : 'planResults.weeksUnit')}{' '} - {t('planResults.funded')} -

- -
    - {(['free', 'vacation', 'sjukpenning', 'lagsta'] as const).map((key) => { - if (outcome.costBreakdown[key] <= 0) return null - const dayTypeKey = DAY_TYPE_KEYS[key] - return ( -
  • - {outcome.costBreakdown[key]} {dayTypeKey ? t(dayTypeKey) : key} -
  • - ) - })} -
- -

- {t('planResults.strategyPrefix')}{' '} - {(() => { - const strategyKey = STRATEGY_KEYS[outcome.strategy] - return strategyKey ? t(strategyKey) : outcome.strategy - })()} -

-

{buildWindowExplanation(outcome, t)}

- - {outcome.shortfallDays > 0 ? ( -
-

- {t('planResults.shortfallLabel')}{' '} - {t('planResults.shortfallCouldNotBeFunded', { - days: outcome.shortfallDays, - dayWord: t(outcome.shortfallDays === 1 ? 'planResults.daySingular' : 'planResults.daysPlural'), - })} -

- {outcome.remedies.length > 0 && ( -
    - {outcome.remedies.map((remedy, idx) => ( -
  • - {renderRemedy(remedy, t)} -
  • - ))} -
- )} -
- ) : ( -

{t('planResults.fullyFunded')}

- )} -
- ) - })} -
- -
-

{t('planResults.balancesTitle')}

- -

{t('planResults.parentalDaysTitle')}

- - - - - - - - - - - {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}
- -

{t('planResults.vacationDaysTitle')}

- - - - - - - - - - - {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} - {budget.isAssumed && ( - - {t('planResults.vacationAssumedBadge')} - - )} -
-
-
- ) -} diff --git a/frontend/src/components/planning/WishEditor.test.tsx b/frontend/src/components/planning/WishEditor.test.tsx deleted file mode 100644 index f1df55d..0000000 --- a/frontend/src/components/planning/WishEditor.test.tsx +++ /dev/null @@ -1,721 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, fireEvent, act } from '@testing-library/react' -import { WishEditor } from './WishEditor' -import type { LeaveWish, PersonalData } from '@/types' - -const mockWish: LeaveWish = { - id: 'lw1', - workspaceId: 'ws1', - childId: null, - name: 'Sommar', - nameMessageKey: null, - windowStartMonth: 6, - windowStartDay: 15, - windowEndMonth: 7, - windowEndDay: 26, - targetWeeks: 6, - recurrence: 'yearly', - firstYear: 2026, - lastYear: null, - priority: 1, - parentMode: 'either', - parentPersonalDataUuid: null, - windowMode: 'fixed', - createdAt: '2026-01-01', - updatedAt: '2026-01-01', -} - -const mockMembers: PersonalData[] = [ - { uuid: 'pd1', workspaceId: 'ws1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, - { uuid: 'pd2', workspaceId: 'ws1', name: 'Sam', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, -] - -function setup(overrides: Partial[0]> = {}) { - const onAdd = vi.fn() - const onUpdate = vi.fn() - const onRemove = vi.fn() - const utils = render( - , - ) - const rerender = (nextOverrides: Partial[0]> = {}) => - utils.rerender( - , - ) - return { onAdd, onUpdate, onRemove, rerender, unmount: utils.unmount } -} - -const AUTOSAVE_MS = 800 - -function advanceAutosave(ms = AUTOSAVE_MS) { - act(() => { - vi.advanceTimersByTime(ms) - }) -} - -describe('WishEditor', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - act(() => { - vi.runOnlyPendingTimers() - }) - vi.useRealTimers() - }) - - it('shows the empty state when there are no wishes yet', () => { - setup() - expect(screen.getByTestId('wish-empty')).toBeInTheDocument() - }) - - it('renders one preset button per season plus a custom option', () => { - setup() - expect(screen.getByTestId('wish-preset-sommar')).toBeInTheDocument() - expect(screen.getByTestId('wish-preset-jullov')).toBeInTheDocument() - expect(screen.getByTestId('wish-preset-hostlov')).toBeInTheDocument() - expect(screen.getByTestId('wish-preset-custom')).toBeInTheDocument() - }) - - it('adds a wish from the Sommar preset with a sensible default window and week count', () => { - const { onAdd } = setup() - - fireEvent.click(screen.getByTestId('wish-preset-sommar')) - - expect(onAdd).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'ws1', - name: 'Sommar', - // The fix for the "Jul"/"Höstlov" always-Swedish bug: a preset wish - // carries the i18n key that produced its name, so switching locale - // later can re-translate it instead of the persisted text being - // stuck in whatever language the UI happened to be in. - nameMessageKey: 'wishEditor.presetSommar', - windowStartMonth: 6, - windowStartDay: 15, - windowEndMonth: 7, - windowEndDay: 26, - targetWeeks: 6, - recurrence: 'yearly', - parentMode: 'either', - }), - ) - }) - - it('adds a wish from the Jullov preset with a window that wraps into January', () => { - const { onAdd } = setup() - - fireEvent.click(screen.getByTestId('wish-preset-jullov')) - - expect(onAdd).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'Jullov', - nameMessageKey: 'wishEditor.presetJullov', - windowStartMonth: 12, - windowStartDay: 20, - windowEndMonth: 1, - windowEndDay: 16, - targetWeeks: 4, - }), - ) - }) - - it('adds a custom wish when "Egen ledighet" is clicked', () => { - const { onAdd } = setup() - - fireEvent.click(screen.getByTestId('wish-preset-custom')) - - expect(onAdd).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'ws1', - nameMessageKey: 'wishEditor.customLeaveName', - }), - ) - }) - - it('renders an existing wish as a card', () => { - setup({ wishes: [mockWish] }) - expect(screen.getByTestId('wish-card-lw1')).toBeInTheDocument() - expect(screen.getByTestId('wish-weeks-lw1')).toHaveValue(6) - }) - - it('auto-saves an edited week count after the debounce delay', () => { - const { onUpdate } = setup({ wishes: [mockWish] }) - - fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '8' } }) - expect(onUpdate).not.toHaveBeenCalled() - - advanceAutosave() - - expect(onUpdate).toHaveBeenCalledTimes(1) - expect(onUpdate).toHaveBeenCalledWith( - 'lw1', - expect.objectContaining({ targetWeeks: 8 }), - ) - }) - - it('auto-saves a specific parent selection', () => { - const { onUpdate } = setup({ wishes: [mockWish] }) - - fireEvent.change(screen.getByTestId('wish-parent-lw1'), { target: { value: 'pd2' } }) - advanceAutosave() - - expect(onUpdate).toHaveBeenCalledWith( - 'lw1', - expect.objectContaining({ parentMode: 'specific', parentPersonalDataUuid: 'pd2' }), - ) - }) - - it('removes a wish', () => { - const { onRemove } = setup({ wishes: [mockWish] }) - - fireEvent.click(screen.getByTestId('wish-remove-lw1')) - - expect(onRemove).toHaveBeenCalledWith('lw1') - }) - - it('hides the exact date fields until "Justera datum" is opened', () => { - setup({ wishes: [mockWish] }) - expect(screen.queryByTestId('wish-dates-lw1')).not.toBeInTheDocument() - - fireEvent.click(screen.getByTestId('wish-toggle-dates-lw1')) - - expect(screen.getByTestId('wish-dates-lw1')).toBeInTheDocument() - expect(screen.getByTestId('wish-start-day-lw1')).toHaveValue('15') - }) - - it('auto-saves an adjusted end date', () => { - const { onUpdate } = setup({ wishes: [mockWish] }) - fireEvent.click(screen.getByTestId('wish-toggle-dates-lw1')) - - fireEvent.change(screen.getByTestId('wish-end-day-lw1'), { target: { value: '20' } }) - advanceAutosave() - - expect(onUpdate).toHaveBeenCalledWith('lw1', expect.objectContaining({ windowEndDay: 20 })) - }) - - it('clearing "Tills barnet fyller 4 år" reveals a last-year input and auto-saves it', () => { - const { onUpdate } = setup({ wishes: [mockWish] }) - fireEvent.click(screen.getByTestId('wish-toggle-dates-lw1')) - - fireEvent.click(screen.getByTestId('wish-forever-lw1')) - expect(screen.getByTestId('wish-last-year-lw1')).toBeInTheDocument() - - fireEvent.change(screen.getByTestId('wish-last-year-lw1'), { target: { value: '2029' } }) - advanceAutosave() - - expect(onUpdate).toHaveBeenCalledWith('lw1', expect.objectContaining({ lastYear: 2029 })) - }) - - it('disables preset and remove actions while isSaving is true', () => { - setup({ wishes: [mockWish], isSaving: true }) - expect(screen.getByTestId('wish-remove-lw1')).toBeDisabled() - expect(screen.getByTestId('wish-preset-sommar')).toBeDisabled() - expect(screen.getByTestId('wish-preset-custom-flex')).toBeDisabled() - }) - - describe('fixed vs "när det passar bäst" mode', () => { - it('defaults an existing wish to the fixed radio checked', () => { - setup({ wishes: [mockWish] }) - expect(screen.getByTestId('wish-mode-fixed-lw1')).toBeChecked() - expect(screen.getByTestId('wish-mode-flexible-lw1')).not.toBeChecked() - }) - - it('is a real, keyboard-operable radio group with two options', () => { - setup({ wishes: [mockWish] }) - const fixedRadio = screen.getByTestId('wish-mode-fixed-lw1') - const flexibleRadio = screen.getByTestId('wish-mode-flexible-lw1') - expect(fixedRadio).toHaveAttribute('type', 'radio') - expect(flexibleRadio).toHaveAttribute('type', 'radio') - expect(fixedRadio).toHaveAttribute('name', flexibleRadio.getAttribute('name')) - }) - - it('switching to flexible shows the plain-language explainer and auto-saves windowMode', () => { - const { onUpdate } = setup({ wishes: [mockWish] }) - - fireEvent.click(screen.getByTestId('wish-mode-flexible-lw1')) - expect(screen.getByTestId('wish-mode-explainer-lw1')).toBeInTheDocument() - - advanceAutosave() - expect(onUpdate).toHaveBeenCalledWith( - 'lw1', - expect.objectContaining({ windowMode: 'flexible' }), - ) - }) - - it('relabels the date-adjustment button to reflect a bounding range once flexible', () => { - setup({ wishes: [mockWish] }) - fireEvent.click(screen.getByTestId('wish-mode-flexible-lw1')) - expect(screen.getByTestId('wish-toggle-dates-lw1')).toHaveTextContent(/sökintervall/i) - }) - - it('auto-saves windowMode fixed by default when an unrelated field is edited', () => { - const { onUpdate } = setup({ wishes: [mockWish] }) - fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '3' } }) - advanceAutosave() - expect(onUpdate).toHaveBeenCalledWith('lw1', expect.objectContaining({ windowMode: 'fixed' })) - }) - }) - - describe('season quick-picks (flexible wishes)', () => { - it('renders one flexible preset button per season plus a custom bound option', () => { - setup() - expect(screen.getByTestId('wish-preset-season-var')).toBeInTheDocument() - expect(screen.getByTestId('wish-preset-season-sommar')).toBeInTheDocument() - expect(screen.getByTestId('wish-preset-season-host')).toBeInTheDocument() - expect(screen.getByTestId('wish-preset-season-jul')).toBeInTheDocument() - expect(screen.getByTestId('wish-preset-custom-flex')).toBeInTheDocument() - }) - - it('adds a flexible "vår" wish with the season as the bounding range', () => { - const { onAdd } = setup() - fireEvent.click(screen.getByTestId('wish-preset-season-var')) - - expect(onAdd).toHaveBeenCalledWith( - expect.objectContaining({ - windowMode: 'flexible', - nameMessageKey: 'wishEditor.seasonVar', - windowStartMonth: 3, - windowStartDay: 1, - windowEndMonth: 5, - windowEndDay: 31, - recurrence: 'yearly', - }), - ) - }) - - it('adds a flexible "sommar" wish spanning the whole summer bounding range', () => { - const { onAdd } = setup() - fireEvent.click(screen.getByTestId('wish-preset-season-sommar')) - - expect(onAdd).toHaveBeenCalledWith( - expect.objectContaining({ - windowMode: 'flexible', - windowStartMonth: 6, - windowStartDay: 1, - windowEndMonth: 8, - windowEndDay: 31, - }), - ) - }) - - it('adds a flexible "höst" wish', () => { - const { onAdd } = setup() - fireEvent.click(screen.getByTestId('wish-preset-season-host')) - - expect(onAdd).toHaveBeenCalledWith( - expect.objectContaining({ - windowMode: 'flexible', - windowStartMonth: 9, - windowStartDay: 1, - windowEndMonth: 10, - windowEndDay: 31, - }), - ) - }) - - it('adds a flexible "jul" wish whose bounding range wraps into January', () => { - const { onAdd } = setup() - fireEvent.click(screen.getByTestId('wish-preset-season-jul')) - - expect(onAdd).toHaveBeenCalledWith( - expect.objectContaining({ - windowMode: 'flexible', - windowStartMonth: 12, - windowStartDay: 1, - windowEndMonth: 1, - windowEndDay: 31, - }), - ) - }) - - it('adds a fully custom flexible wish via the "Eget sökintervall" button', () => { - const { onAdd } = setup() - fireEvent.click(screen.getByTestId('wish-preset-custom-flex')) - - expect(onAdd).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'ws1', - windowMode: 'flexible', - nameMessageKey: 'wishEditor.customFlexName', - }), - ) - }) - - it('lets a custom flexible wish be re-bounded to any arbitrary, non-preset month/day range', () => { - const customFlexWish: LeaveWish = { - ...mockWish, - id: 'lw2', - windowMode: 'flexible', - targetWeeks: 1, - windowStartMonth: 6, - windowStartDay: 1, - windowEndMonth: 6, - windowEndDay: 14, - } - const { onUpdate } = setup({ wishes: [customFlexWish] }) - fireEvent.click(screen.getByTestId('wish-toggle-dates-lw2')) - - fireEvent.change(screen.getByTestId('wish-start-month-lw2'), { target: { value: '4' } }) - fireEvent.change(screen.getByTestId('wish-start-day-lw2'), { target: { value: '9' } }) - fireEvent.change(screen.getByTestId('wish-end-month-lw2'), { target: { value: '10' } }) - fireEvent.change(screen.getByTestId('wish-end-day-lw2'), { target: { value: '23' } }) - advanceAutosave() - - expect(onUpdate).toHaveBeenCalledWith( - 'lw2', - expect.objectContaining({ - windowStartMonth: 4, - windowStartDay: 9, - windowEndMonth: 10, - windowEndDay: 23, - }), - ) - }) - }) - - describe('flexible bound validation', () => { - it('shows an inline error and never fires a save while the bound is shorter than target_weeks * 7', () => { - const shortBoundWish: LeaveWish = { - ...mockWish, - windowMode: 'flexible', - targetWeeks: 6, - windowStartMonth: 3, - windowStartDay: 1, - windowEndMonth: 3, - windowEndDay: 10, // 10-day bound, needs 42 days for 6 weeks - } - const { onUpdate } = setup({ wishes: [shortBoundWish] }) - - expect(screen.getByTestId('wish-bound-error-lw1')).toBeInTheDocument() - - // Touch an unrelated field — the invalid bound must still block the save. - fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '6' } }) - advanceAutosave(5000) - - expect(onUpdate).not.toHaveBeenCalled() - expect(screen.queryByTestId('wish-save-status-lw1')).not.toBeInTheDocument() - expect(screen.getByTestId('wish-bound-error-lw1')).toBeInTheDocument() - }) - - it('does not show a bound error for a fixed wish regardless of window length', () => { - const shortFixedWish: LeaveWish = { - ...mockWish, - windowMode: 'fixed', - targetWeeks: 6, - windowStartMonth: 3, - windowStartDay: 1, - windowEndMonth: 3, - windowEndDay: 10, - } - setup({ wishes: [shortFixedWish] }) - expect(screen.queryByTestId('wish-bound-error-lw1')).not.toBeInTheDocument() - }) - }) - - describe('day-of-month options respect the selected month', () => { - it('offers only 28 days for February (no 31 February)', () => { - setup({ wishes: [mockWish] }) - fireEvent.click(screen.getByTestId('wish-toggle-dates-lw1')) - - fireEvent.change(screen.getByTestId('wish-start-month-lw1'), { target: { value: '2' } }) - - const daySelect = screen.getByTestId('wish-start-day-lw1') as HTMLSelectElement - const values = Array.from(daySelect.querySelectorAll('option')).map((opt) => opt.value) - expect(values).toEqual(Array.from({ length: 28 }, (_, i) => String(i + 1))) - }) - - it('offers only 30 days for April', () => { - setup({ wishes: [mockWish] }) - fireEvent.click(screen.getByTestId('wish-toggle-dates-lw1')) - - fireEvent.change(screen.getByTestId('wish-end-month-lw1'), { target: { value: '4' } }) - - const daySelect = screen.getByTestId('wish-end-day-lw1') as HTMLSelectElement - const values = Array.from(daySelect.querySelectorAll('option')).map((opt) => opt.value) - expect(values).toEqual(Array.from({ length: 30 }, (_, i) => String(i + 1))) - }) - - it('clamps a selected day down when switching to a shorter month', () => { - const janWish: LeaveWish = { ...mockWish, windowStartMonth: 1, windowStartDay: 31 } - setup({ wishes: [janWish] }) - fireEvent.click(screen.getByTestId('wish-toggle-dates-lw1')) - expect(screen.getByTestId('wish-start-day-lw1')).toHaveValue('31') - - fireEvent.change(screen.getByTestId('wish-start-month-lw1'), { target: { value: '2' } }) - - expect(screen.getByTestId('wish-start-day-lw1')).toHaveValue('28') - }) - }) - - describe('wrapping bounding ranges', () => { - it('shows a wrap note for a wrapping custom range, like season-jul', () => { - const wrappingWish: LeaveWish = { - ...mockWish, - windowMode: 'flexible', - targetWeeks: 1, - windowStartMonth: 11, - windowStartDay: 10, - windowEndMonth: 2, - windowEndDay: 20, - } - setup({ wishes: [wrappingWish] }) - fireEvent.click(screen.getByTestId('wish-toggle-dates-lw1')) - expect(screen.getByTestId('wish-wrap-note-lw1')).toBeInTheDocument() - }) - - it('does not show a wrap note for a non-wrapping range', () => { - setup({ wishes: [mockWish] }) - fireEvent.click(screen.getByTestId('wish-toggle-dates-lw1')) - expect(screen.queryByTestId('wish-wrap-note-lw1')).not.toBeInTheDocument() - }) - }) - - describe('debounced auto-save', () => { - it('collapses rapid successive edits into a single save', () => { - const { onUpdate } = setup({ wishes: [mockWish] }) - - fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '7' } }) - advanceAutosave(400) - fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '8' } }) - advanceAutosave(400) - expect(onUpdate).not.toHaveBeenCalled() - - advanceAutosave(400) - expect(onUpdate).toHaveBeenCalledTimes(1) - expect(onUpdate).toHaveBeenCalledWith('lw1', expect.objectContaining({ targetWeeks: 8 })) - }) - - it('does not save on mount, before any edit', () => { - const { onUpdate } = setup({ wishes: [mockWish] }) - advanceAutosave(5000) - expect(onUpdate).not.toHaveBeenCalled() - }) - - it('does not save when a field is focused and blurred without changing its value', () => { - const { onUpdate } = setup({ wishes: [mockWish] }) - const input = screen.getByTestId('wish-weeks-lw1') - fireEvent.focus(input) - fireEvent.blur(input) - advanceAutosave(5000) - expect(onUpdate).not.toHaveBeenCalled() - }) - - it('does not enter a save -> refetch -> save loop when the wishes prop refreshes with the saved value', () => { - const { onUpdate, rerender } = setup({ wishes: [mockWish] }) - - fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '8' } }) - advanceAutosave() - expect(onUpdate).toHaveBeenCalledTimes(1) - - // Simulate the parent's refetch after a successful save landing with - // a brand-new wishes array reflecting the saved value. - rerender({ wishes: [{ ...mockWish, targetWeeks: 8 }] }) - advanceAutosave(5000) - - expect(onUpdate).toHaveBeenCalledTimes(1) - }) - - it('does not re-save when the wishes prop refreshes without any real change (identity-only refresh)', () => { - const { onUpdate, rerender } = setup({ wishes: [mockWish] }) - - rerender({ wishes: [{ ...mockWish }] }) - advanceAutosave(5000) - - expect(onUpdate).not.toHaveBeenCalled() - }) - - it('shows "Sparar…" while a save is in flight and "Sparat" once onUpdate resolves', async () => { - const { onUpdate } = setup({ wishes: [mockWish] }) - let resolveUpdate: (() => void) | undefined - onUpdate.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveUpdate = resolve - }), - ) - - fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '9' } }) - advanceAutosave() - expect(screen.getByTestId('wish-save-status-lw1')).toHaveTextContent('Sparar') - - await act(async () => { - resolveUpdate?.() - await Promise.resolve() - }) - expect(screen.getByTestId('wish-save-status-lw1')).toHaveTextContent('Sparat') - - advanceAutosave(2000) - expect(screen.queryByTestId('wish-save-status-lw1')).not.toBeInTheDocument() - }) - - it('shows a failure status and keeps the edit when onUpdate rejects', async () => { - const { onUpdate } = setup({ wishes: [mockWish] }) - onUpdate.mockImplementationOnce(() => Promise.reject(new Error('network error'))) - - fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '9' } }) - advanceAutosave() - expect(onUpdate).toHaveBeenCalledTimes(1) - - await act(async () => { - await Promise.resolve() - await Promise.resolve() - }) - - expect(screen.getByTestId('wish-save-status-lw1')).toHaveTextContent('Kunde inte spara') - // The edit itself must not be discarded from the input. - expect(screen.getByTestId('wish-weeks-lw1')).toHaveValue(9) - }) - - it('treats a void-returning onUpdate (no promise) as an immediate success — backward compatibility', async () => { - // setup()'s default onUpdate is a plain `vi.fn()`, which returns - // `undefined` — exactly what a caller that hasn't adopted the - // `Promise` return would produce. - setup({ wishes: [mockWish] }) - - fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '9' } }) - advanceAutosave() - - await act(async () => { - await Promise.resolve() - }) - - expect(screen.getByTestId('wish-save-status-lw1')).toHaveTextContent('Sparat') - }) - - describe('retry', () => { - it('re-sends the still-pending draft and clears the error once it succeeds', async () => { - const { onUpdate } = setup({ wishes: [mockWish] }) - onUpdate.mockImplementationOnce(() => Promise.reject(new Error('boom'))) - - fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '9' } }) - advanceAutosave() - - await act(async () => { - await Promise.resolve() - await Promise.resolve() - }) - expect(screen.getByTestId('wish-save-status-lw1')).toHaveTextContent('Kunde inte spara') - expect(screen.queryByTestId('wish-retry-lw1')).toBeInTheDocument() - - onUpdate.mockImplementationOnce(() => Promise.resolve()) - fireEvent.click(screen.getByTestId('wish-retry-lw1')) - expect(screen.getByTestId('wish-save-status-lw1')).toHaveTextContent('Sparar') - - await act(async () => { - await Promise.resolve() - }) - - expect(onUpdate).toHaveBeenCalledTimes(2) - expect(onUpdate).toHaveBeenLastCalledWith('lw1', expect.objectContaining({ targetWeeks: 9 })) - expect(screen.getByTestId('wish-save-status-lw1')).toHaveTextContent('Sparat') - expect(screen.queryByTestId('wish-retry-lw1')).not.toBeInTheDocument() - }) - - it('does not show a retry button while a row is saving or has no error', () => { - setup({ wishes: [mockWish] }) - expect(screen.queryByTestId('wish-retry-lw1')).not.toBeInTheDocument() - }) - }) - - it('cleans up a pending debounce timer on unmount so it never fires', () => { - const { onUpdate, unmount } = setup({ wishes: [mockWish] }) - - fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '11' } }) - unmount() - advanceAutosave(5000) - - expect(onUpdate).not.toHaveBeenCalled() - }) - }) - - describe('nameMessageKey (preset name follows the UI language)', () => { - it('shows the translated name when nameMessageKey is set, even if the stored name is stale', () => { - // Simulates the bug scenario: `name` was persisted in whatever - // language the wish was created in ("STALE_ENGLISH_NAME" here stands - // in for e.g. "Christmas" saved while the UI was English), but the - // test environment is pinned to Swedish (tests/setup.ts) — the input - // must show the current-locale translation of the key, not the - // stored, possibly-stale `name`. - const presetWish: LeaveWish = { - ...mockWish, - name: 'STALE_ENGLISH_NAME', - nameMessageKey: 'wishEditor.seasonJul', - } - setup({ wishes: [presetWish] }) - - expect(screen.getByTestId('wish-name-lw1')).toHaveValue('Jul') - }) - - it('shows the literal name verbatim when nameMessageKey is null (free-typed or pre-existing wish)', () => { - const customWish: LeaveWish = { ...mockWish, name: 'Resa till mormor', nameMessageKey: null } - setup({ wishes: [customWish] }) - - expect(screen.getByTestId('wish-name-lw1')).toHaveValue('Resa till mormor') - }) - - it('falls back to the literal name for an nameMessageKey this build does not recognise', () => { - // A stale/future key (older or newer server, a renamed catalogue - // entry, ...) must never crash the row or render a raw key. - const staleKeyWish: LeaveWish = { - ...mockWish, - name: 'Sommar', - nameMessageKey: 'wishEditor.noLongerExists', - } - setup({ wishes: [staleKeyWish] }) - - expect(screen.getByTestId('wish-name-lw1')).toHaveValue('Sommar') - }) - - it('clears nameMessageKey the moment the user edits the name, and auto-saves the literal text', () => { - const presetWish: LeaveWish = { - ...mockWish, - name: 'Jul', - nameMessageKey: 'wishEditor.seasonJul', - } - const { onUpdate } = setup({ wishes: [presetWish] }) - - fireEvent.change(screen.getByTestId('wish-name-lw1'), { target: { value: 'Vinterresa' } }) - advanceAutosave() - - expect(onUpdate).toHaveBeenCalledWith( - 'lw1', - expect.objectContaining({ name: 'Vinterresa', nameMessageKey: null }), - ) - }) - - it('does not re-save a preset wish on mount just because its resolved name differs from the stored name', () => { - // Guards against a regression where seeding the draft with the - // *resolved* display name (instead of the raw `name`) would make the - // dirty-check think an untouched row had been edited, firing a bogus - // rename save on every load. - const presetWish: LeaveWish = { - ...mockWish, - name: 'Jul', - nameMessageKey: 'wishEditor.seasonJul', - } - const { onUpdate } = setup({ wishes: [presetWish] }) - - advanceAutosave(5000) - - expect(onUpdate).not.toHaveBeenCalled() - }) - }) -}) diff --git a/frontend/src/components/planning/WishEditor.tsx b/frontend/src/components/planning/WishEditor.tsx deleted file mode 100644 index a9a6d38..0000000 --- a/frontend/src/components/planning/WishEditor.tsx +++ /dev/null @@ -1,862 +0,0 @@ -/** - * The leave-wish editor: "I want 6 weeks off each summer and 4 weeks each - * Christmas" translated into concrete, editable `LeaveWish` records. - * - * Presets (Sommar / Jullov / Höstlov) are one-click — clicking a preset adds - * a fully-formed yearly wish with a sensible default window and week count - * immediately. Nobody has to touch a month/day picker to get started. Every - * added wish still shows up below as a card where weeks, priority, and which - * parent can be adjusted directly; the exact recurring window only appears - * once "Justera datum" is opened, for people who want that control. - * - * PLANNING_ENGINE_SPEC.md §10 — every wish also has an explicit fixed vs - * "när det passar bäst" (flexible) toggle. A flexible wish reuses the exact - * same window-start/window-end fields as a BOUNDING RANGE the engine - * searches inside for the best-costing placement, rather than as the final - * dates. v1 offered four season quick-picks (vår/sommar/höst/jul) plus, now, - * a fully custom bounding range: any two month/day pairs, any span, - * including a wrapping range (end sorts before start, e.g. Nov -> Feb) — the - * "Eget sökintervall" button seeds one, immediately editable via "Justera - * sökintervall" like any other flexible wish. - * - * Edits auto-save per row (800ms debounce, collapsing rapid edits into one - * call) instead of requiring an explicit Save click — see `scheduleSave`/ - * `performSave` below. `onUpdate` may return a promise (see - * `WishEditorProps`); `performSave` awaits it directly and drives the row's - * `rowStatus` off the real outcome — a resolved promise (or a synchronous, - * non-throwing `void` return, for a caller that hasn't adopted the promise - * form) means success, a rejection means failure. A failed row keeps its - * draft (nothing is discarded) and gets an explicit retry action that - * re-sends the same still-pending draft via `performSave` directly, - * bypassing the debounce. - */ -import { ReactNode, useEffect, useRef, useState } from 'react' -import type { - LeaveWish, - LeaveWishCreateRequest, - LeaveWishUpdateRequest, - PersonalData, -} from '@/types' -import { useTranslation, type TranslationKey } from '@/i18n' -import { resolveWishName } from '@/utils/wishName' - -export interface WishEditorProps { - workspaceId: string - wishes: LeaveWish[] - members: PersonalData[] - isSaving?: boolean - onAdd: (payload: LeaveWishCreateRequest) => void | Promise - onUpdate: (id: string, patch: LeaveWishUpdateRequest) => void | Promise - onRemove: (id: string) => void | Promise -} - -type WindowMode = 'fixed' | 'flexible' - -/** Debounce delay for per-row auto-save (§ "Debounced auto-save"). */ -const AUTOSAVE_DEBOUNCE_MS = 800 -/** How long the "Sparat" confirmation stays visible after a successful save. */ -const SAVED_STATUS_TTL_MS = 2000 - -interface Preset { - key: string - labelKey: TranslationKey - startMonth: number - startDay: number - endMonth: number - endDay: number - defaultWeeks: number -} - -// Default windows mirror the ones used in backend/scripts/demo_plan.py, plus -// a Höstlov (autumn break, Swedish school week 44) preset. -const PRESETS: Preset[] = [ - { key: 'sommar', labelKey: 'wishEditor.presetSommar', startMonth: 6, startDay: 15, endMonth: 7, endDay: 26, defaultWeeks: 6 }, - { key: 'jullov', labelKey: 'wishEditor.presetJullov', startMonth: 12, startDay: 20, endMonth: 1, endDay: 16, defaultWeeks: 4 }, - { key: 'hostlov', labelKey: 'wishEditor.presetHostlov', startMonth: 10, startDay: 25, endMonth: 11, endDay: 1, defaultWeeks: 1 }, -] - -// PLANNING_ENGINE_SPEC.md §10.7 — v1 ships season presets for flexible -// ("när det passar bäst") wishes, plus a fully custom bounding range (see -// `addCustomFlex` below). Picking one of these fills the SAME window_start_*/ -// window_end_* fields used by fixed wishes, but as the bounding range the -// engine searches inside (§10.1) — not the final placement. "jul" -// deliberately wraps the year end (Dec -> Jan), exactly like the existing -// Jullov fixed preset above; window_wraps()/window_for_year() on the backend -// handle that without any special-casing, and so does a custom wrapping -// range entered by hand. -const SEASON_PRESETS: Preset[] = [ - { key: 'season-var', labelKey: 'wishEditor.seasonVar', startMonth: 3, startDay: 1, endMonth: 5, endDay: 31, defaultWeeks: 1 }, - { key: 'season-sommar', labelKey: 'wishEditor.seasonSommar', startMonth: 6, startDay: 1, endMonth: 8, endDay: 31, defaultWeeks: 6 }, - { key: 'season-host', labelKey: 'wishEditor.seasonHost', startMonth: 9, startDay: 1, endMonth: 10, endDay: 31, defaultWeeks: 1 }, - { key: 'season-jul', labelKey: 'wishEditor.seasonJul', startMonth: 12, startDay: 1, endMonth: 1, endDay: 31, defaultWeeks: 4 }, -] - -/** Mirrors `LeaveWish.window_wraps()`/`window_for_year()` (backend/app/entities/ - * leave_wish.py): whether (end_month, end_day) sorts before (start_month, - * start_day) — a wrapping window's end lands in the year after `anchorYear`. */ -function windowWraps(startMonth: number, startDay: number, endMonth: number, endDay: number): boolean { - return endMonth < startMonth || (endMonth === startMonth && endDay < startDay) -} - -/** Inclusive day count of a (possibly wrapping) month/day window anchored at - * `anchorYear` — the client-side mirror of §10.1's service-level validation - * ("the bounding range's raw length ... before any horizon clipping"). Used - * only to validate a flexible wish's bound against `target_weeks * 7`. */ -function windowLengthDays( - startMonth: number, - startDay: number, - endMonth: number, - endDay: number, - anchorYear: number, -): number { - const wraps = windowWraps(startMonth, startDay, endMonth, endDay) - const start = Date.UTC(anchorYear, startMonth - 1, startDay) - const end = Date.UTC(wraps ? anchorYear + 1 : anchorYear, endMonth - 1, endDay) - return Math.round((end - start) / 86_400_000) + 1 -} - -// Calendar day counts per 1-12 month. February is deliberately capped at 28 -// (never 29): `LeaveWish.window_for_year()` builds a concrete -// `date(year, month, day)` per anchor year on the backend, which raises for -// Feb 29 in any non-leap anchor year — a recurring wish must stay valid -// every year it applies to, so Feb 29 is never offered as an option. -const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - -/** How many day-of-month options a 1-12 month has, for populating the day - * ` - // Editing the name detaches it from any preset: it is - // now the user's own text and must never be silently - // re-translated back to a stale preset label. - updateDraft(wish.id, { name: e.target.value, nameMessageKey: null }) - } - className="wish-editor__name-input" - data-testid={`wish-name-${wish.id}`} - aria-label={t('wishEditor.nameAria', { name: resolveWishName(wish, t) })} - /> - - - - - - -
- -
- - {t('wishEditor.modeSectionLabel')} - - - -
- - {draft.windowMode === 'flexible' && ( -

- {t('wishEditor.modeFlexibleExplainer')} -

- )} - - - - {isExpanded && ( -
- - - - - {wraps && ( -

- {t('wishEditor.wrapsNote')} -

- )} - - - - - - {draft.recurrence === 'yearly' && ( - <> - - - {draft.lastYear !== null && ( - - )} - - )} -
- )} - - {boundTooShort && ( -

- {t('wishEditor.boundTooShortError', { weeks: draft.targetWeeks })} -

- )} - -
- {status && ( - - {status === 'saving' && t('common.saving')} - {status === 'saved' && t('wishEditor.savedStatus')} - {status === 'error' && t('wishEditor.saveFailedStatus')} - - )} - {status === 'error' && ( - - )} - -
- - ) - })} - - )} -
- ) -} diff --git a/frontend/src/i18n/en/common.ts b/frontend/src/i18n/en/common.ts index cb8f61b..8128bbc 100644 --- a/frontend/src/i18n/en/common.ts +++ b/frontend/src/i18n/en/common.ts @@ -16,8 +16,6 @@ export const common = { 'nav.logout': 'Log out', 'nav.logoutAria': 'Log out', 'nav.overview': 'Overview', - 'nav.plan': 'Plan', 'nav.selectChildAria': 'Select child', 'nav.settings': 'Settings', - 'nav.tips': 'Tips', } as const diff --git a/frontend/src/i18n/en/dashboard.ts b/frontend/src/i18n/en/dashboard.ts index 6d5c2a7..d63fa65 100644 --- a/frontend/src/i18n/en/dashboard.ts +++ b/frontend/src/i18n/en/dashboard.ts @@ -2,25 +2,18 @@ export const dashboard = { 'age4Bars.expiringStat': '{expiring} days expiring', 'age4Bars.savableStat': '{left} of {cap} days can be saved', 'age4Bars.title': 'Days left at age 4, per parent', - 'dashboard.addChildButton': 'Add child', - 'dashboard.addChildIntro': 'You have no children registered yet. Add your first child to get started.', - 'dashboard.addChildTitle': 'Add child', - 'dashboard.addingChild': 'Adding...', - 'dashboard.birthdayLabel': 'Birthday', - 'dashboard.childNameLabel': 'Child\'s name', - 'dashboard.createChildError': 'Could not create child', '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.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}', 'dashboard.fetchFromFk': 'Fetch from Försäkringskassan', - 'dashboard.fillAllFields': 'Fill in all fields', 'dashboard.hero.hasPlanMessage': '{atRisk} days are at risk of disappearing when {childName} turns 4 ({date}).', 'dashboard.hero.noPlanMessage': 'You have {days} days to plan. Without a plan, {atRisk} of them will disappear when {childName} turns 4.', 'dashboard.loading': 'Loading...', 'dashboard.moreDetailsSummary': 'More details', - 'dashboard.namePlaceholder': 'Name', 'dashboard.overviewTitle': 'Overview', 'dashboard.planFold.noWishesHint': 'Add wishes under Planning to see a proposal here.', 'dashboard.planFold.summary': 'Plan proposal for the years ahead', diff --git a/frontend/src/i18n/en/index.ts b/frontend/src/i18n/en/index.ts index 2a2ccff..5b7074f 100644 --- a/frontend/src/i18n/en/index.ts +++ b/frontend/src/i18n/en/index.ts @@ -3,6 +3,7 @@ import { common } from './common' import { auth } from './auth' import { calendar } from './calendar' import { dashboard } from './dashboard' +import { onboarding } from './onboarding' import { settings } from './settings' import { plan } from './plan' import { wishEditor } from './wishEditor' @@ -26,6 +27,7 @@ export const en = { ...auth, ...calendar, ...dashboard, + ...onboarding, ...settings, ...plan, ...wishEditor, diff --git a/frontend/src/i18n/en/onboarding.ts b/frontend/src/i18n/en/onboarding.ts new file mode 100644 index 0000000..dd9271a --- /dev/null +++ b/frontend/src/i18n/en/onboarding.ts @@ -0,0 +1,29 @@ +export const onboarding = { + 'onboarding.addChildButton': 'Add child', + 'onboarding.addingChild': 'Adding...', + 'onboarding.balancesLegend': 'Parental-leave days', + 'onboarding.birthdayLabel': 'Birthday', + 'onboarding.childNameLabel': 'Child\'s name', + 'onboarding.continueButton': 'Continue', + 'onboarding.createChildError': 'Could not create child', + 'onboarding.familyIntro': 'This controls how the planner splits parental-leave days.', + 'onboarding.familyTitle': 'Are you planning alone or together with a partner?', + 'onboarding.fillAllFields': 'Fill in all fields', + 'onboarding.finishError': 'Something went wrong. Please try again.', + 'onboarding.manualFetchDescription': 'Enter your balances manually if you already know them.', + 'onboarding.manualFetchTitle': 'Enter manually', + 'onboarding.namePlaceholder': 'Name', + 'onboarding.partnerAddedStatus': 'Partner added: {name}', + 'onboarding.partnerIntro': 'Add your partner now, or do it later under Settings.', + 'onboarding.partnerNameLabel': 'Partner\'s name', + 'onboarding.savingFamilyError': 'Could not save the planning mode', + 'onboarding.scrapeFetchDescription': 'Fastest and most accurate — fetches your exact balances automatically via BankID.', + 'onboarding.scrapeFetchTitle': 'Fetch from Försäkringskassan', + 'onboarding.skipButton': 'Skip for now', + 'onboarding.skipHint': 'We\'ll use the statutory assumption (480 days, none used) until you enter your own figures.', + 'onboarding.stepBalancesIntro': 'This tells us how many days are left to plan. You can always update this later.', + 'onboarding.stepBalancesTitle': 'Fetch your parental-leave days', + 'onboarding.stepChildIntro': 'Add your first child to start planning your parental leave.', + 'onboarding.stepChildTitle': 'Welcome! Let\'s add your child', + 'onboarding.stepIndicator': 'Step {step} of {total}', +} as const diff --git a/frontend/src/i18n/en/plan.ts b/frontend/src/i18n/en/plan.ts index 8ad2276..90687dc 100644 --- a/frontend/src/i18n/en/plan.ts +++ b/frontend/src/i18n/en/plan.ts @@ -1,56 +1,18 @@ +// `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, plus +// `planningModeSelector.*` (`PlanningModeSelector.tsx`, used by both +// Settings and Onboarding). export const plan = { - 'plan.addAtLeastOneWish': 'Add at least one wish above to suggest a plan.', - 'plan.addWishError': 'Could not add wish', - 'plan.calculating': 'Calculating...', - 'plan.intro': 'Describe how you want to be on leave — for example 6 weeks every summer and 4 weeks every Christmas — and get a suggested plan showing exactly which days are already free, which cost a vacation day, and which cost a parental-benefit day.', - 'plan.loading': 'Loading planning...', - 'plan.proposeButton': 'Suggest plan', - 'plan.removeWishError': 'Could not remove wish', - 'plan.title': 'Planning', - 'plan.updateWishError': 'Could not save wish', - 'plan.wishesTitle': 'Your wishes', - 'planResults.age4ProjectionsIntro': 'Here\'s what the 4th birthday looks like if this proposal is applied, including future recurring wishes. Your saved plan (under Overview) may show a different result.', - 'planResults.age4ProjectionsTitle': 'At the child\'s 4th birthday, if this proposal is applied', - 'planResults.applyButton': 'Use plan', - 'planResults.applySavedPrefix': 'The plan has been saved!', - 'planResults.applyingButton': 'Saving...', 'planResults.balancesTitle': 'Remaining balances after the plan', - 'planResults.cancelButton': 'Cancel', - 'planResults.collisionDateHeader': 'Date', - 'planResults.collisionExistingHeader': 'Existing type', - 'planResults.collisionProposedHeader': 'Proposed type', - 'planResults.confirmOverwriteButton': 'Yes, replace the existing days', - 'planResults.confirmOverwriteIntro': 'The following days are already planned and will be replaced:', - 'planResults.confirmOverwriteTitle': 'Conflict with existing days', - 'planResults.createdClause': '{count} new {dayWord} created', - 'planResults.daySingular': 'day', - 'planResults.dayTypeFree': 'free days (weekend/public holiday)', - 'planResults.dayTypeLagsta': 'parental-benefit days (lägstanivå, minimum rate)', - 'planResults.dayTypeSjukpenning': 'parental-benefit days (sjukpenningnivå, income-based)', - 'planResults.dayTypeVacation': 'vacation days', - 'planResults.daysPlural': 'days', - 'planResults.detailTitle': 'Leave periods in detail', 'planResults.dubbeldagarLeftHeader': 'Double days left', - 'planResults.expiringAtB4Header': 'Expiring', - 'planResults.explanationDetailFree': '{free_days} fully free days (weekend/public holiday)', - 'planResults.explanationDetailLagsta': '{lagsta_days} parental-benefit days (lägstanivå, minimum rate)', - 'planResults.explanationDetailSjukpenning': '{sjukpenning_days} parental-benefit days (sjukpenningnivå, income-based)', - 'planResults.explanationDetailVacationAndFp': 'you keep your salary for {vacation_days} days and save {fp_days} parental-benefit days', - 'planResults.explanationDetailVacationOnly': '{vacation_days} vacation days', - 'planResults.explanationShortfallSuffix': ' ({shortfall_days} days short)', - 'planResults.explanationSummary': '{wish_name} {year} — {weeks_wished} weeks wished, {weeks_funded} weeks funded', - 'planResults.fourthBirthdayHeader': '4th birthday', - 'planResults.freeDaySingular': 'free day', - 'planResults.freeDaysPlural': 'free days', - 'planResults.fullyFunded': 'Fully funded', - 'planResults.funded': 'funded', 'planResults.lagstaLeftHeader': 'Lägstanivå days left', 'planResults.leavesHeader': 'Leave periods', 'planResults.noWindowsInHorizon': 'No windows within the planning horizon.', 'planResults.parentHeader': 'Parent', - 'planResults.parentalDaysTitle': 'Parental-benefit days', - 'planResults.previewBanner': 'This is a suggestion — nothing is saved automatically. You can adjust your wishes above and suggest a new plan as many times as you like.', - 'planResults.projectedLeftHeader': 'Projected left', 'planResults.rejectionCollisionsNeedReplace': '{count} day(s) already planned differently; pass replace=true to overwrite', 'planResults.rejectionConflictingDuplicate': 'Conflicting duplicate entries for {date} (child {child_id}, parent {parent_name}) in the same batch', 'planResults.rejectionDubbeldagBatchQuotaExceeded': 'This batch would result in {projected_total} double days, the max is {max_dubbeldagar}', @@ -61,23 +23,7 @@ export const plan = { 'planResults.rejectionInsufficientVacationBalance': '{parent_name} would need {needed} more vacation day(s) in {year}, but only {remaining} remain', 'planResults.rejectionInvalidDayType': 'Day {index} ({date}): invalid day type "{day_type}"', 'planResults.rejectionOutsideEligibleWindow': '{date}: outside the child\'s eligible window ({birth_date} – {hard_expiry})', - 'planResults.rejectionsClause': '. Rejected: {rejections}', - 'planResults.remedyCarryVacationForward': '{spare_total} vacation days from previous years can be carried forward', - 'planResults.remedyLowerPriorityWish': 'Lowering {wish_name} from {from_weeks} to {to_weeks} weeks frees up {freed_days} days', - 'planResults.remedyReducePreschoolReserve': 'Reducing the preschool planning-day reserve ({reserved} reserved) frees up {reserved} days', - 'planResults.remedyShiftWindowIntoFreeDays': 'Moving {wish_name} closer to a weekend frees up {shift_gain} days', - 'planResults.replacedClause': ', {count} {dayWord} replaced', - 'planResults.savableAfterB4Header': 'Savable', - 'planResults.shortfallCouldNotBeFunded': '{days} {dayWord} could not be funded.', - 'planResults.shortfallLabel': 'Shortfall:', 'planResults.sjukpenningLeftHeader': 'Sjukpenningnivå days left', - 'planResults.skippedFreeClause': ', {count} {dayWord} skipped', - 'planResults.skippedIdenticalClause': ', {count} {dayWord} skipped (already identical)', - 'planResults.strategyDubbeldag': 'Double days — both parents home at the same time', - 'planResults.strategyParentSwap': 'Parents alternate', - 'planResults.strategyPrefix': 'Strategy:', - 'planResults.strategySolo': 'One parent home', - 'planResults.strategyUnplaced': 'Could not be placed — no free slot was found', '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.', @@ -91,19 +37,7 @@ export const plan = { 'planResults.totalAvailableHeader': 'Total available', 'planResults.unknownParent': 'Unknown parent', 'planResults.usedHeader': 'Used', - 'planResults.vacationAssumedBadge': 'Assumed', - 'planResults.vacationAssumedPrompt': 'No value is recorded for this year — the statutory 25 days has been assumed. Confirm or edit it in Settings.', - 'planResults.vacationDaysTitle': 'Vacation days', - 'planResults.warningBodyDaysLostAtAge4': '{parent_name} risks losing {expiring_days} days at the child\'s 4th birthday ({fourth_birthday}) unless more days are used before then.', - 'planResults.warningBodySgiRisk': '{parent_name} risks their SGI protection for the week starting {week_start} ({days_planned} day(s) planned, at least {min_days_per_week} required).', - 'planResults.warningBodyVacationExpiring': '{parent_name} has {saved_days_in} saved vacation day(s) from {year} at risk of being forfeited.', - 'planResults.warningDaysLostAtAge4': 'Days at risk of being lost at age 4', - 'planResults.warningSgiRisk': 'Risk to SGI protection', - 'planResults.warningVacationExpiring': 'Vacation days at risk of being lost', - 'planResults.warningsTitle': 'Warnings', - 'planResults.weekSingular': 'week', 'planResults.weeksUnit': 'weeks', - 'planResults.wished': 'wished', 'planResults.yearHeader': 'Year', 'planResults.yearOverviewTitle': 'Overview by year', 'planningModeSelector.couple': 'Me and my partner', diff --git a/frontend/src/i18n/en/tips.ts b/frontend/src/i18n/en/tips.ts index eb7b3a5..7d1656a 100644 --- a/frontend/src/i18n/en/tips.ts +++ b/frontend/src/i18n/en/tips.ts @@ -23,7 +23,5 @@ export const tips = { 'tip.wrapRedDaysWithVacation.title': 'Make the most of public holidays', 'tip.wrapWeekendWithVacation.body': '{parent_name} has parental-benefit leave planned on Friday {friday} and Monday {monday}. A vacation day in between makes for a longer break.', 'tip.wrapWeekendWithVacation.title': 'Make the most of the weekend', - 'tips.empty': 'No tips yet. Great job planning your parental leave!', - 'tips.loading': 'Loading tips...', 'tips.title': 'Optimization Tips', } as const diff --git a/frontend/src/i18n/en/wishEditor.ts b/frontend/src/i18n/en/wishEditor.ts index 4f4dc86..8b77e4d 100644 --- a/frontend/src/i18n/en/wishEditor.ts +++ b/frontend/src/i18n/en/wishEditor.ts @@ -1,57 +1,18 @@ +// The `WishEditor.tsx`/`Plan.tsx` flow it was written for is gone (leave is +// now authored directly on the calendar). What's left here is the subset of +// keys `PeriodPopover.tsx` and `PresetRail.tsx` (both `components/calendar/*`, +// owned by another task) still render for the calendar's own flexible/season +// wish authoring — see those files before removing anything further. export const wishEditor = { - 'wishEditor.adjustBound': 'Adjust search range', - 'wishEditor.adjustDates': 'Adjust dates', 'wishEditor.bothParents': 'Both parents', - 'wishEditor.boundEndsLabel': 'to', - 'wishEditor.boundStartsLabel': 'Search range from', - 'wishEditor.boundTooShortError': 'The search range is shorter than the {weeks} weeks wished — choose a longer range or fewer weeks.', - 'wishEditor.customFlexButton': '+ Custom search range', - 'wishEditor.customFlexName': 'Custom search range', - 'wishEditor.customLeaveButton': '+ Custom leave', - 'wishEditor.customLeaveName': 'Custom leave', 'wishEditor.eitherParent': 'Either parent', - 'wishEditor.emptyState': 'No leave wishes yet. Choose a suggestion above to get started.', - 'wishEditor.endsLabel': 'Ends', - 'wishEditor.firstYearLabel': 'First year', 'wishEditor.flexBadge': 'whenever it fits best', - 'wishEditor.flexPresetsLabel': 'Quick picks — search within a season ("whenever it fits best"):', - 'wishEditor.hideDates': 'Hide dates', - 'wishEditor.lastYearLabel': 'Last year', - 'wishEditor.modeFixed': 'Fixed dates', - 'wishEditor.modeFlexible': 'Whenever it fits best', - 'wishEditor.modeFlexibleExplainer': 'The engine picks the best week inside the search range below by itself — it looks for the period with the most röda dagar (public holidays) and klämdagar (bridge days), since that costs the fewest semesterdagar (vacation days). A recurring wish is re-picked every year (Easter moves, for example), and the suggestion always needs confirming in the Plan tab before it is saved, just like any other plan.', - 'wishEditor.modeSectionLabel': 'How should this wish be placed?', - 'wishEditor.month1': 'January', - 'wishEditor.month10': 'October', - 'wishEditor.month11': 'November', - 'wishEditor.month12': 'December', - 'wishEditor.month2': 'February', - 'wishEditor.month3': 'March', - 'wishEditor.month4': 'April', - 'wishEditor.month5': 'May', - 'wishEditor.month6': 'June', - 'wishEditor.month7': 'July', - 'wishEditor.month8': 'August', - 'wishEditor.month9': 'September', - 'wishEditor.nameAria': 'Name for {name}', 'wishEditor.parentLabel': 'Parent', - 'wishEditor.presetHostlov': 'Autumn break (höstlov)', - 'wishEditor.presetJullov': 'Christmas break', - 'wishEditor.presetSommar': 'Summer', - 'wishEditor.priorityLabel': 'Priority', - 'wishEditor.recurEveryYear': 'Repeats every year', - 'wishEditor.removeButton': 'Remove', - 'wishEditor.retryButton': 'Try again', - 'wishEditor.saveFailedStatus': 'Could not save — your edit is kept, try again.', - 'wishEditor.savedStatus': 'Saved', 'wishEditor.seasonHost': 'Autumn', 'wishEditor.seasonJul': 'Christmas', 'wishEditor.seasonSommar': 'Summer', 'wishEditor.seasonVar': 'Spring', - 'wishEditor.startsLabel': 'Starts', 'wishEditor.untilAge4': 'Until the child turns 4', 'wishEditor.weekUnit': 'week', - 'wishEditor.weeksLabel': 'Weeks', 'wishEditor.weeksUnit': 'weeks', - 'wishEditor.wrapsNote': 'Extends into the next year.', } as const diff --git a/frontend/src/i18n/sv/common.ts b/frontend/src/i18n/sv/common.ts index 87d3897..a751f8b 100644 --- a/frontend/src/i18n/sv/common.ts +++ b/frontend/src/i18n/sv/common.ts @@ -16,8 +16,6 @@ export const common = { 'nav.logout': 'Logga ut', 'nav.logoutAria': 'Logga ut', 'nav.overview': 'Översikt', - 'nav.plan': 'Planering', 'nav.selectChildAria': 'Välj barn', 'nav.settings': 'Inställningar', - 'nav.tips': 'Tips', } as const \ No newline at end of file diff --git a/frontend/src/i18n/sv/dashboard.ts b/frontend/src/i18n/sv/dashboard.ts index 0a7ebcd..67682a8 100644 --- a/frontend/src/i18n/sv/dashboard.ts +++ b/frontend/src/i18n/sv/dashboard.ts @@ -2,25 +2,18 @@ export const dashboard = { 'age4Bars.expiringStat': '{expiring} dagar förfaller', 'age4Bars.savableStat': '{left} av {cap} dagar kan sparas', 'age4Bars.title': 'Dagar kvar vid 4 års ålder, per förälder', - 'dashboard.addChildButton': 'Lägg till barn', - 'dashboard.addChildIntro': 'Du har inga barn registrerade ännu. Lägg till ditt första barn för att börja.', - 'dashboard.addChildTitle': 'Lägg till barn', - 'dashboard.addingChild': 'Lägger till...', - 'dashboard.birthdayLabel': 'Födelsedag', - 'dashboard.childNameLabel': 'Barnets namn', - 'dashboard.createChildError': 'Kunde inte skapa barn', '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.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}', 'dashboard.fetchFromFk': 'Hämta från Försäkringskassan', - 'dashboard.fillAllFields': 'Fyll i alla fält', 'dashboard.hero.hasPlanMessage': '{atRisk} dagar riskerar att försvinna när {childName} fyller 4 ({date}).', 'dashboard.hero.noPlanMessage': 'Du har {days} dagar att planera. Utan en plan försvinner {atRisk} av dem när {childName} fyller 4.', 'dashboard.loading': 'Laddar...', 'dashboard.moreDetailsSummary': 'Fler detaljer', - 'dashboard.namePlaceholder': 'Namn', 'dashboard.overviewTitle': 'Översikt', 'dashboard.planFold.noWishesHint': 'Lägg till önskemål under Planering för att se ett förslag här.', 'dashboard.planFold.summary': 'Planförslag för de kommande åren', diff --git a/frontend/src/i18n/sv/index.ts b/frontend/src/i18n/sv/index.ts index 4f132f5..23ca745 100644 --- a/frontend/src/i18n/sv/index.ts +++ b/frontend/src/i18n/sv/index.ts @@ -2,6 +2,7 @@ import { common } from './common' import { auth } from './auth' import { calendar } from './calendar' import { dashboard } from './dashboard' +import { onboarding } from './onboarding' import { settings } from './settings' import { plan } from './plan' import { wishEditor } from './wishEditor' @@ -23,6 +24,7 @@ export const sv = { ...auth, ...calendar, ...dashboard, + ...onboarding, ...settings, ...plan, ...wishEditor, diff --git a/frontend/src/i18n/sv/onboarding.ts b/frontend/src/i18n/sv/onboarding.ts new file mode 100644 index 0000000..e3b9533 --- /dev/null +++ b/frontend/src/i18n/sv/onboarding.ts @@ -0,0 +1,29 @@ +export const onboarding = { + 'onboarding.addChildButton': 'Lägg till barn', + 'onboarding.addingChild': 'Lägger till...', + 'onboarding.balancesLegend': 'Föräldradagar', + 'onboarding.birthdayLabel': 'Födelsedag', + 'onboarding.childNameLabel': 'Barnets namn', + 'onboarding.continueButton': 'Fortsätt', + 'onboarding.createChildError': 'Kunde inte skapa barn', + 'onboarding.familyIntro': 'Det här styr hur planeraren fördelar föräldradagarna.', + 'onboarding.familyTitle': 'Planerar du ensam eller tillsammans med en partner?', + 'onboarding.fillAllFields': 'Fyll i alla fält', + 'onboarding.finishError': 'Något gick fel. Försök igen.', + 'onboarding.manualFetchDescription': 'Ange dina saldon manuellt om du redan vet dem.', + 'onboarding.manualFetchTitle': 'Ange manuellt', + 'onboarding.namePlaceholder': 'Namn', + 'onboarding.partnerAddedStatus': 'Partner tillagd: {name}', + 'onboarding.partnerIntro': 'Lägg till din partner nu, eller gör det senare under Inställningar.', + 'onboarding.partnerNameLabel': 'Partnerns namn', + 'onboarding.savingFamilyError': 'Kunde inte spara planeringsläget', + 'onboarding.scrapeFetchDescription': 'Snabbast och säkrast — hämtar dina exakta saldon automatiskt via BankID.', + 'onboarding.scrapeFetchTitle': 'Hämta från Försäkringskassan', + 'onboarding.skipButton': 'Hoppa över för nu', + 'onboarding.skipHint': 'Vi använder det lagstadgade antagandet (480 dagar, inget uttaget) tills du fyller i dina egna uppgifter.', + 'onboarding.stepBalancesIntro': 'Så vet vi hur många dagar som finns kvar att planera. Du kan alltid uppdatera detta senare.', + 'onboarding.stepBalancesTitle': 'Hämta dina föräldradagar', + 'onboarding.stepChildIntro': 'Lägg till ditt första barn för att börja planera föräldraledigheten.', + 'onboarding.stepChildTitle': 'Välkommen! Låt oss lägga till ditt barn', + 'onboarding.stepIndicator': 'Steg {step} av {total}', +} as const diff --git a/frontend/src/i18n/sv/plan.ts b/frontend/src/i18n/sv/plan.ts index e4f6caa..8c29ee4 100644 --- a/frontend/src/i18n/sv/plan.ts +++ b/frontend/src/i18n/sv/plan.ts @@ -1,56 +1,18 @@ +// `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, plus +// `planningModeSelector.*` (`PlanningModeSelector.tsx`, used by both +// Settings and Onboarding). export const plan = { - 'plan.addAtLeastOneWish': 'Lägg till minst ett önskemål ovan för att föreslå en plan.', - 'plan.addWishError': 'Kunde inte lägga till önskemål', - 'plan.calculating': 'Beräknar...', - 'plan.intro': 'Beskriv hur du vill vara ledig — till exempel 6 veckor varje sommar och 4 veckor varje jul — och föreslå en plan som visar exakt vilka dagar som redan är lediga, vilka som kostar en semesterdag och vilka som kostar en föräldrapenningdag.', - 'plan.loading': 'Laddar planering...', - 'plan.proposeButton': 'Föreslå plan', - 'plan.removeWishError': 'Kunde inte ta bort önskemål', - 'plan.title': 'Planering', - 'plan.updateWishError': 'Kunde inte spara önskemål', - 'plan.wishesTitle': 'Dina önskemål', - 'planResults.age4ProjectionsIntro': 'Så här ser det ut vid 4-årsdagen om just detta förslag tillämpas, inklusive kommande års återkommande önskemål. Din sparade plan (under Översikt) kan visa ett annat resultat.', - 'planResults.age4ProjectionsTitle': 'Vid barnets 4-årsdag, om detta förslag tillämpas', - 'planResults.applyButton': 'Använd plan', - 'planResults.applySavedPrefix': 'Planen har sparats!', - 'planResults.applyingButton': 'Sparar...', 'planResults.balancesTitle': 'Kvarvarande saldon efter planen', - 'planResults.cancelButton': 'Avbryt', - 'planResults.collisionDateHeader': 'Datum', - 'planResults.collisionExistingHeader': 'Befintlig typ', - 'planResults.collisionProposedHeader': 'Föreslagen typ', - 'planResults.confirmOverwriteButton': 'Ja, ersätt de befintliga dagarna', - 'planResults.confirmOverwriteIntro': 'Följande dagar är redan planerade och kommer att ersättas:', - 'planResults.confirmOverwriteTitle': 'Konflikt med befintliga dagar', - 'planResults.createdClause': '{count} nya {dayWord} skapades', - 'planResults.daySingular': 'dag', - 'planResults.dayTypeFree': 'fria dagar (helg/röd dag)', - 'planResults.dayTypeLagsta': 'FP-dagar (lägstanivå)', - 'planResults.dayTypeSjukpenning': 'FP-dagar (sjukpenningnivå)', - 'planResults.dayTypeVacation': 'semesterdagar', - 'planResults.daysPlural': 'dagar', - 'planResults.detailTitle': 'Ledigheter i detalj', 'planResults.dubbeldagarLeftHeader': 'Dubbeldagar kvar', - 'planResults.expiringAtB4Header': 'Förfaller', - 'planResults.explanationDetailFree': '{free_days} helt fria dagar (helg/röd dag)', - 'planResults.explanationDetailLagsta': '{lagsta_days} föräldrapenningdagar (lägstanivå)', - 'planResults.explanationDetailSjukpenning': '{sjukpenning_days} föräldrapenningdagar (sjukpenningnivå)', - 'planResults.explanationDetailVacationAndFp': 'du behåller lönen {vacation_days} dagar och sparar {fp_days} FP-dagar', - 'planResults.explanationDetailVacationOnly': '{vacation_days} semesterdagar', - 'planResults.explanationShortfallSuffix': ' ({shortfall_days} dagar saknas)', - 'planResults.explanationSummary': '{wish_name} {year} — {weeks_wished} veckor önskade, {weeks_funded} veckor finansierade', - 'planResults.fourthBirthdayHeader': '4-årsdag', - 'planResults.freeDaySingular': 'fri dag', - 'planResults.freeDaysPlural': 'fria dagar', - 'planResults.fullyFunded': 'Fullt finansierad', - 'planResults.funded': 'finansierade', 'planResults.lagstaLeftHeader': 'Lägstanivådagar kvar', 'planResults.leavesHeader': 'Ledigheter', 'planResults.noWindowsInHorizon': 'Inga fönster inom planeringshorisonten.', 'planResults.parentHeader': 'Förälder', - 'planResults.parentalDaysTitle': 'Föräldrapenningdagar', - 'planResults.previewBanner': 'Detta är ett förslag — inget sparas automatiskt. Du kan justera dina önskemål ovan och föreslå en ny plan så många gånger du vill.', - 'planResults.projectedLeftHeader': 'Beräknat kvar', 'planResults.rejectionCollisionsNeedReplace': '{count} dag(ar) är redan planerade på annat sätt; skicka replace=true för att skriva över', 'planResults.rejectionConflictingDuplicate': 'Motstridiga dubbletter för {date} (barn {child_id}, förälder {parent_name}) i samma omgång', 'planResults.rejectionDubbeldagBatchQuotaExceeded': 'Omgången skulle resultera i {projected_total} dubbeldagar, max är {max_dubbeldagar}', @@ -61,23 +23,7 @@ export const plan = { 'planResults.rejectionInsufficientVacationBalance': '{parent_name} skulle behöva {needed} fler semesterdag(ar) {year}, men endast {remaining} finns kvar', 'planResults.rejectionInvalidDayType': 'Dag {index} ({date}): ogiltig dagtyp "{day_type}"', 'planResults.rejectionOutsideEligibleWindow': '{date}: utanför barnets tillåtna period ({birth_date} – {hard_expiry})', - 'planResults.rejectionsClause': '. Avslag: {rejections}', - 'planResults.remedyCarryVacationForward': '{spare_total} semesterdagar från tidigare år kan sparas framåt', - 'planResults.remedyLowerPriorityWish': 'Sänk {wish_name} från {from_weeks} till {to_weeks} veckor frigör {freed_days} dagar', - 'planResults.remedyReducePreschoolReserve': 'Minska förskolans planeringsdagar ({reserved} reserverade) frigör {reserved} dagar', - 'planResults.remedyShiftWindowIntoFreeDays': 'Flytta {wish_name} närmare en helg frigör {shift_gain} dagar', - 'planResults.replacedClause': ', {count} {dayWord} ersattes', - 'planResults.savableAfterB4Header': 'Kan sparas', - 'planResults.shortfallCouldNotBeFunded': '{days} {dayWord} kunde inte finansieras.', - 'planResults.shortfallLabel': 'Underskott:', 'planResults.sjukpenningLeftHeader': 'Sjukpenningdagar kvar', - 'planResults.skippedFreeClause': ', {count} {dayWord} hoppades över', - 'planResults.skippedIdenticalClause': ', {count} {dayWord} hoppades över (redan likadana)', - 'planResults.strategyDubbeldag': 'Dubbeldagar — båda föräldrarna hemma samtidigt', - 'planResults.strategyParentSwap': 'Föräldrarna växlar av varandra', - 'planResults.strategyPrefix': 'Strategi:', - 'planResults.strategySolo': 'En förälder hemma', - 'planResults.strategyUnplaced': 'Kunde inte placeras — ingen ledig plats hittades', '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.', @@ -91,22 +37,10 @@ export const plan = { 'planResults.totalAvailableHeader': 'Totalt tillgängliga', 'planResults.unknownParent': 'Okänd förälder', 'planResults.usedHeader': 'Använda', - 'planResults.vacationAssumedBadge': 'Antaget', - 'planResults.vacationAssumedPrompt': 'Inget värde är registrerat för detta år — 25 dagar (lagstadgat) har antagits. Bekräfta eller ändra i Inställningar.', - 'planResults.vacationDaysTitle': 'Semesterdagar', - 'planResults.warningBodyDaysLostAtAge4': '{parent_name} riskerar att förlora {expiring_days} dagar vid barnets 4-årsdag ({fourth_birthday}) om inte fler dagar tas ut innan dess.', - 'planResults.warningBodySgiRisk': '{parent_name} riskerar SGI-skyddet veckan som börjar {week_start} ({days_planned} dagar planerade, minst {min_days_per_week} krävs).', - 'planResults.warningBodyVacationExpiring': '{parent_name} har {saved_days_in} sparade semesterdagar från {year} som riskerar att gå förlorade.', - 'planResults.warningDaysLostAtAge4': 'Dagar som riskerar att gå förlorade vid 4 år', - 'planResults.warningSgiRisk': 'Risk för SGI-skydd', - 'planResults.warningVacationExpiring': 'Semesterdagar som riskerar att gå förlorade', - 'planResults.warningsTitle': 'Varningar', - 'planResults.weekSingular': 'vecka', 'planResults.weeksUnit': 'veckor', - 'planResults.wished': 'önskade', 'planResults.yearHeader': 'År', 'planResults.yearOverviewTitle': 'Översikt över åren', 'planningModeSelector.couple': 'Jag och min partner', 'planningModeSelector.legend': 'Planerar du själv eller tillsammans med en partner?', 'planningModeSelector.solo': 'Bara jag', -} as const \ No newline at end of file +} as const diff --git a/frontend/src/i18n/sv/tips.ts b/frontend/src/i18n/sv/tips.ts index 25b1284..8f22d28 100644 --- a/frontend/src/i18n/sv/tips.ts +++ b/frontend/src/i18n/sv/tips.ts @@ -23,7 +23,5 @@ export const tips = { 'tip.wrapRedDaysWithVacation.title': 'Utnyttja röda dagar', 'tip.wrapWeekendWithVacation.body': '{parent_name} har föräldrapenning planerad fredagen {friday} och måndagen {monday}. En semesterdag mellan dem ger en längre ledighet.', 'tip.wrapWeekendWithVacation.title': 'Utnyttja helgen', - 'tips.empty': 'Inga tips än. Bra jobbat med planeringen av din föräldraledighet!', - 'tips.loading': 'Laddar tips...', 'tips.title': 'Optimeringstips', } as const \ No newline at end of file diff --git a/frontend/src/i18n/sv/wishEditor.ts b/frontend/src/i18n/sv/wishEditor.ts index 884c2fe..a663460 100644 --- a/frontend/src/i18n/sv/wishEditor.ts +++ b/frontend/src/i18n/sv/wishEditor.ts @@ -1,57 +1,18 @@ +// The `WishEditor.tsx`/`Plan.tsx` flow it was written for is gone (leave is +// now authored directly on the calendar). What's left here is the subset of +// keys `PeriodPopover.tsx` and `PresetRail.tsx` (both `components/calendar/*`, +// owned by another task) still render for the calendar's own flexible/season +// wish authoring — see those files before removing anything further. export const wishEditor = { - 'wishEditor.adjustBound': 'Justera sökintervall', - 'wishEditor.adjustDates': 'Justera datum', 'wishEditor.bothParents': 'Båda föräldrarna', - 'wishEditor.boundEndsLabel': 'till', - 'wishEditor.boundStartsLabel': 'Sökintervall från', - 'wishEditor.boundTooShortError': 'Sökintervallet är kortare än de {weeks} veckor som önskas — välj ett längre intervall eller minska antalet veckor.', - 'wishEditor.customFlexButton': '+ Eget sökintervall', - 'wishEditor.customFlexName': 'Eget sökintervall', - 'wishEditor.customLeaveButton': '+ Egen ledighet', - 'wishEditor.customLeaveName': 'Egen ledighet', 'wishEditor.eitherParent': 'Endera förälder', - 'wishEditor.emptyState': 'Inga ledighetsönskemål ännu. Välj ett förslag ovan för att komma igång.', - 'wishEditor.endsLabel': 'Slutar', - 'wishEditor.firstYearLabel': 'Första året', 'wishEditor.flexBadge': 'när det passar bäst', - 'wishEditor.flexPresetsLabel': 'Snabbval — sök inom en säsong ("när det passar bäst"):', - 'wishEditor.hideDates': 'Dölj datum', - 'wishEditor.lastYearLabel': 'Sista året', - 'wishEditor.modeFixed': 'Bestämda datum', - 'wishEditor.modeFlexible': 'När det passar bäst', - 'wishEditor.modeFlexibleExplainer': 'Motorn väljer själv den bästa veckan inom sökintervallet nedan — den letar efter perioden med flest röda dagar och klämdagar, eftersom det kostar minst antal semesterdagar. Vid ett återkommande önskemål väljs ett nytt fönster varje år (påsken flyttar sig till exempel), och förslaget måste alltid bekräftas i planeringsfliken innan det sparas, precis som alla andra planer.', - 'wishEditor.modeSectionLabel': 'Hur ska önskemålet placeras?', - 'wishEditor.month1': 'Januari', - 'wishEditor.month10': 'Oktober', - 'wishEditor.month11': 'November', - 'wishEditor.month12': 'December', - 'wishEditor.month2': 'Februari', - 'wishEditor.month3': 'Mars', - 'wishEditor.month4': 'April', - 'wishEditor.month5': 'Maj', - 'wishEditor.month6': 'Juni', - 'wishEditor.month7': 'Juli', - 'wishEditor.month8': 'Augusti', - 'wishEditor.month9': 'September', - 'wishEditor.nameAria': 'Namn för {name}', 'wishEditor.parentLabel': 'Förälder', - 'wishEditor.presetHostlov': 'Höstlov', - 'wishEditor.presetJullov': 'Jullov', - 'wishEditor.presetSommar': 'Sommar', - 'wishEditor.priorityLabel': 'Prioritet', - 'wishEditor.recurEveryYear': 'Återkommer varje år', - 'wishEditor.removeButton': 'Ta bort', - 'wishEditor.retryButton': 'Försök igen', - 'wishEditor.saveFailedStatus': 'Kunde inte spara — ändringen finns kvar, försök igen.', - 'wishEditor.savedStatus': 'Sparat', 'wishEditor.seasonHost': 'Höst', 'wishEditor.seasonJul': 'Jul', 'wishEditor.seasonSommar': 'Sommar', 'wishEditor.seasonVar': 'Vår', - 'wishEditor.startsLabel': 'Startar', 'wishEditor.untilAge4': 'Tills barnet fyller 4 år', 'wishEditor.weekUnit': 'vecka', - 'wishEditor.weeksLabel': 'Veckor', 'wishEditor.weeksUnit': 'veckor', - 'wishEditor.wrapsNote': 'Sträcker sig in i nästa år.', -} as const \ No newline at end of file +} as const diff --git a/frontend/src/pages/Dashboard.test.tsx b/frontend/src/pages/Dashboard.test.tsx index 8075ffc..72b21f8 100644 --- a/frontend/src/pages/Dashboard.test.tsx +++ b/frontend/src/pages/Dashboard.test.tsx @@ -38,6 +38,13 @@ vi.mock('@/components/layout/Layout', () => ({ Layout: ({ children }: { children: React.ReactNode }) =>
{children}
, })) +// Dashboard hands off to Onboarding entirely when there are no children yet — +// stub it out so this file only asserts the hand-off happens, not +// Onboarding's own behaviour (covered by Onboarding.test.tsx). +vi.mock('@/pages/Onboarding', () => ({ + Onboarding: () =>
Onboarding
, +})) + const renderWithRouter = (component: React.ReactElement) => { return render({component}) } @@ -136,7 +143,7 @@ describe('Dashboard', () => { expect(screen.getByText(/Laddar/)).toBeInTheDocument() }) - it('shows add child form when no children', () => { + it('hands off to Onboarding when there are no children yet', () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, @@ -148,8 +155,21 @@ describe('Dashboard', () => { }) ) renderWithRouter() - expect(screen.getByText(/Du har inga barn registrerade ännu/)).toBeInTheDocument() - expect(screen.getByLabelText(/Barnets namn/)).toBeInTheDocument() + expect(screen.getByTestId('onboarding-page')).toBeInTheDocument() + expect(screen.queryByTestId('dashboard')).not.toBeInTheDocument() + }) + + describe('estimated-figures badge', () => { + it('is hidden once real parental-leave balances have been fetched (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 })) + renderWithRouter() + expect(screen.getByTestId('dashboard-estimate-badge')).toHaveTextContent('Uppskattat') + }) }) describe('Hero', () => { @@ -396,133 +416,6 @@ describe('Dashboard', () => { expect(button).not.toBeDisabled() }) - it('submits add child form with valid data', async () => { - const user = userEvent.setup() - const mockCreateChild = vi.fn().mockResolvedValue({ id: 'c-2', name: 'New Child', birth_date: '2024-01-15' }) - const mockRefreshContext = vi.fn().mockResolvedValue(undefined) - - vi.mocked(useWorkspace).mockReturnValue( - makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, - workspaceId: 'ws-1', - children: [], - selectedChildId: null, - personalDataUuid: 'pd-1', - parentName: 'Test Parent', - refreshContext: mockRefreshContext, - }) - ) - - const mockApi = await import('@/services/api') - vi.mocked(mockApi.createChild).mockImplementation(mockCreateChild) - - renderWithRouter() - - const nameInput = screen.getByLabelText(/Barnets namn/) - const birthDateInput = screen.getByTestId('birth-date-input') - const submitButton = screen.getByRole('button', { name: /Lägg till barn/ }) - - await user.type(nameInput, 'Test Child') - await user.type(birthDateInput, '2024-01-15') - await user.click(submitButton) - - expect(mockCreateChild).toHaveBeenCalledWith({ - workspace_id: 'ws-1', - name: 'Test Child', - birth_date: '2024-01-15', - }) - expect(mockRefreshContext).toHaveBeenCalled() - }) - - it('shows error when add child form submission fails', async () => { - const user = userEvent.setup() - const mockCreateChild = vi.fn().mockRejectedValue(new Error('API error')) - - vi.mocked(useWorkspace).mockReturnValue( - makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, - workspaceId: 'ws-1', - children: [], - selectedChildId: null, - personalDataUuid: 'pd-1', - parentName: 'Test Parent', - }) - ) - - const mockApi = await import('@/services/api') - vi.mocked(mockApi.createChild).mockImplementation(mockCreateChild) - - renderWithRouter() - - const nameInput = screen.getByLabelText(/Barnets namn/) - const birthDateInput = screen.getByTestId('birth-date-input') - const submitButton = screen.getByRole('button', { name: /Lägg till barn/ }) - - await user.type(nameInput, 'Test Child') - await user.type(birthDateInput, '2024-01-15') - await user.click(submitButton) - - expect(screen.getByText(/API error/)).toBeInTheDocument() - }) - - it('disables submit button during form submission', async () => { - const user = userEvent.setup() - const mockCreateChild = vi.fn().mockImplementation( - () => new Promise(resolve => setTimeout(() => resolve({ id: 'c-2' }), 100)) - ) - - vi.mocked(useWorkspace).mockReturnValue( - makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, - workspaceId: 'ws-1', - children: [], - selectedChildId: null, - personalDataUuid: 'pd-1', - parentName: 'Test Parent', - refreshContext: vi.fn().mockResolvedValue(undefined), - }) - ) - - const mockApi = await import('@/services/api') - vi.mocked(mockApi.createChild).mockImplementation(mockCreateChild) - - renderWithRouter() - - const nameInput = screen.getByLabelText(/Barnets namn/) - const birthDateInput = screen.getByTestId('birth-date-input') - const submitButton = screen.getByRole('button', { name: /Lägg till barn/ }) - - await user.type(nameInput, 'Test Child') - await user.type(birthDateInput, '2024-01-15') - await user.click(submitButton) - - expect(submitButton).toBeDisabled() - }) - - it('validates add child form with workspace check', async () => { - vi.mocked(useWorkspace).mockReturnValue( - makeWorkspaceContextValue({ - workspace: null, - children: [], - selectedChildId: null, - }) - ) - - renderWithRouter() - - const nameInput = screen.getByLabelText(/Barnets namn/) - const birthDateInput = screen.getByTestId('birth-date-input') - - const user = userEvent.setup() - await user.type(nameInput, 'Test Child') - await user.type(birthDateInput, '2024-01-15') - - const submitButton = screen.getByRole('button', { name: /Lägg till barn/ }) - await user.click(submitButton) - - expect(screen.getByText(/Fyll i alla fält/)).toBeInTheDocument() - }) - it('has manual fetch button enabled when child selected', () => { renderWithRouter() diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index b770f28..36d4f6f 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -23,10 +23,10 @@ import { Age4Bars } from '@/components/dashboard/Age4Bars' import { YearTimeline } from '@/components/dashboard/YearTimeline' import { PlanFold } from '@/components/dashboard/PlanFold' import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown' -import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput' import { TipCard } from '@/components/inputs/TipCard' import { QRCodeModal } from '@/components/modals/QRCodeModal' import { ManualFetchModal } from '@/components/modals/ManualFetchModal' +import { Onboarding } from '@/pages/Onboarding' /** Fallback horizon end when the analytics projection isn't available yet * (e.g. mid-load) — plain date arithmetic, not a business rule, so deriving @@ -40,7 +40,7 @@ function computeFourthBirthday(birthDateIso: string): string { export function Dashboard(): ReactNode { const { t } = useTranslation() const navigate = useNavigate() - const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext } = useWorkspace() + const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid } = useWorkspace() const { data: analytics, isLoading: analyticsLoading, error: analyticsError, refresh: refreshAnalytics } = useAnalytics( workspace?.id ?? null, selectedChildId ?? '' @@ -51,10 +51,6 @@ export function Dashboard(): ReactNode { const { data: tips, dismiss: dismissTip } = useOptimizationTips(workspace?.id ?? null) const scraper = useScraperFetch() - const [childName, setChildName] = useState('') - const [childBirthDate, setChildBirthDate] = useState('') - const [isSubmitting, setIsSubmitting] = useState(false) - const [submitError, setSubmitError] = useState(null) const [showModal, setShowModal] = useState(false) const [showManualFetchModal, setShowManualFetchModal] = useState(false) const [isManualFetchSaving, setIsManualFetchSaving] = useState(false) @@ -66,30 +62,6 @@ export function Dashboard(): ReactNode { return } - const handleAddChild = async (e: React.FormEvent) => { - e.preventDefault() - setSubmitError(null) - if (!workspace || !childName || !childBirthDate) { - setSubmitError(t('dashboard.fillAllFields')) - return - } - setIsSubmitting(true) - try { - await api.createChild({ - workspace_id: workspace.id, - name: childName, - birth_date: childBirthDate, - }) - setChildName('') - setChildBirthDate('') - await refreshContext() - } catch (err: unknown) { - setSubmitError(err instanceof Error ? err.message : t('dashboard.createChildError')) - } finally { - setIsSubmitting(false) - } - } - const handleFetchFK = async () => { setShowModal(true) await scraper.start(selectedChildId ?? undefined) @@ -188,46 +160,13 @@ export function Dashboard(): ReactNode { ] : [] + // A new account has no child yet — hand off to the onboarding wizard + // (add child, solo/couple, balances ladder) instead of rendering the + // dashboard itself. `Onboarding` only calls `refreshContext()` once the + // whole wizard is done, at which point `children` here becomes non-empty + // and this component naturally re-renders past this branch. if (children.length === 0) { - return ( - -
-
-

{t('dashboard.addChildTitle')}

-

{t('dashboard.addChildIntro')}

-
void handleAddChild(e)}> -
- - setChildName(e.target.value)} - disabled={isSubmitting} - required - /> -
-
- -
- {submitError && ( -
- {submitError} -
- )} - -
-
-
-
- ) + return } const selectedChild = children.find((c) => c.id === selectedChildId) @@ -261,6 +200,16 @@ export function Dashboard(): ReactNode { /> )} + {!parentalLeave && ( +

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

+ )} + {age4Bars.length > 0 && }
diff --git a/frontend/src/pages/Onboarding.test.tsx b/frontend/src/pages/Onboarding.test.tsx new file mode 100644 index 0000000..d59d013 --- /dev/null +++ b/frontend/src/pages/Onboarding.test.tsx @@ -0,0 +1,392 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { BrowserRouter } from 'react-router-dom' +import { Onboarding } from './Onboarding' +import * as hooks from '@/hooks' +import { useWorkspace, useAuth } from '@/context' +import { ApiRequestError } from '@/services/errors' +import { + makeAuthContextValue, + makeWorkspaceContextValue, + makeScraperFetchResult, + makePollResponse, + makePartnerMemberResponse, +} from '../../tests/fixtures' + +vi.mock('@/hooks') +vi.mock('@/context') +vi.mock('@/services/api') + +// Layout pulls in useAuth/useNavigate, which need a full AuthProvider/Router +// tree to work — same stub Dashboard.test.tsx/Calendar.test.tsx use. +vi.mock('@/components/layout/Layout', () => ({ + Layout: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +const renderWithRouter = (component: React.ReactElement) => { + return render({component}) +} + +describe('Onboarding', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useAuth).mockReturnValue(makeAuthContextValue()) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, + workspaceId: 'ws-1', + children: [], + selectedChildId: null, + personalDataUuid: 'pd-1', + parentName: 'Test Parent', + }) + ) + vi.mocked(hooks.useScraperFetch).mockReturnValue(makeScraperFetchResult()) + }) + + describe('step 1 — child', () => { + it('shows the step indicator and the child form', () => { + renderWithRouter() + expect(screen.getByTestId('onboarding-step-indicator')).toHaveTextContent('Steg 1 av 3') + expect(screen.getByTestId('onboarding-step-child')).toBeInTheDocument() + expect(screen.getByLabelText(/Barnets namn/)).toBeInTheDocument() + }) + + it('shows an error and does not advance when the workspace is missing', async () => { + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ workspace: null, children: [] }) + ) + const user = userEvent.setup() + renderWithRouter() + + await user.type(screen.getByLabelText(/Barnets namn/), 'Barnet') + await user.type(screen.getByTestId('birth-date-input'), '2024-01-15') + await user.click(screen.getByRole('button', { name: /Lägg till barn/ })) + + expect(screen.getByText('Fyll i alla fält')).toBeInTheDocument() + expect(screen.getByTestId('onboarding-step-child')).toBeInTheDocument() + }) + + it('creates the child and advances to the family step on success', async () => { + const mockCreateChild = vi.fn().mockResolvedValue({ id: 'child-9', name: 'Barnet' }) + const api = await import('@/services/api') + vi.mocked(api.createChild).mockImplementation(mockCreateChild) + + const user = userEvent.setup() + renderWithRouter() + + await user.type(screen.getByLabelText(/Barnets namn/), 'Barnet') + await user.type(screen.getByTestId('birth-date-input'), '2024-01-15') + await user.click(screen.getByRole('button', { name: /Lägg till barn/ })) + + await waitFor(() => { + expect(screen.getByTestId('onboarding-step-family')).toBeInTheDocument() + }) + expect(mockCreateChild).toHaveBeenCalledWith({ + workspace_id: 'ws-1', + name: 'Barnet', + birth_date: '2024-01-15', + }) + }) + + it('shows an error and stays on step 1 when child creation fails', async () => { + const api = await import('@/services/api') + vi.mocked(api.createChild).mockRejectedValue(new Error('boom')) + + const user = userEvent.setup() + renderWithRouter() + + await user.type(screen.getByLabelText(/Barnets namn/), 'Barnet') + await user.type(screen.getByTestId('birth-date-input'), '2024-01-15') + await user.click(screen.getByRole('button', { name: /Lägg till barn/ })) + + expect(await screen.findByText('boom')).toBeInTheDocument() + expect(screen.getByTestId('onboarding-step-child')).toBeInTheDocument() + }) + + it('falls back to a generic error message for a non-Error rejection', async () => { + const api = await import('@/services/api') + vi.mocked(api.createChild).mockRejectedValue('not an Error instance') + + const user = userEvent.setup() + renderWithRouter() + + await user.type(screen.getByLabelText(/Barnets namn/), 'Barnet') + await user.type(screen.getByTestId('birth-date-input'), '2024-01-15') + await user.click(screen.getByRole('button', { name: /Lägg till barn/ })) + + expect(await screen.findByText('Kunde inte skapa barn')).toBeInTheDocument() + }) + }) + + describe('step 2 — solo/couple + partner', () => { + async function advanceToFamilyStep(): Promise> { + const api = await import('@/services/api') + vi.mocked(api.createChild).mockResolvedValue({ id: 'child-9', name: 'Barnet' }) + const user = userEvent.setup() + renderWithRouter() + await user.type(screen.getByLabelText(/Barnets namn/), 'Barnet') + await user.type(screen.getByTestId('birth-date-input'), '2024-01-15') + await user.click(screen.getByRole('button', { name: /Lägg till barn/ })) + await waitFor(() => expect(screen.getByTestId('onboarding-step-family')).toBeInTheDocument()) + return user + } + + it('defaults to solo mode with no partner section shown', async () => { + await advanceToFamilyStep() + expect(screen.getByTestId('planning-mode-solo')).toBeChecked() + expect(screen.queryByTestId('onboarding-partner-section')).not.toBeInTheDocument() + }) + + it('reveals the partner section when couple is selected', async () => { + const user = await advanceToFamilyStep() + await user.click(screen.getByTestId('planning-mode-couple')) + expect(screen.getByTestId('onboarding-partner-section')).toBeInTheDocument() + }) + + it('adds a partner by name', async () => { + const user = await advanceToFamilyStep() + await user.click(screen.getByTestId('planning-mode-couple')) + + const api = await import('@/services/api') + vi.mocked(api.addPartner).mockResolvedValue(makePartnerMemberResponse({ name: 'Sam' })) + + await user.type(screen.getByLabelText(/Partnerns namn/), 'Sam') + await user.click(screen.getByRole('button', { name: 'Lägg till partner' })) + + expect(await screen.findByTestId('onboarding-partner-added')).toHaveTextContent('Sam') + expect(api.addPartner).toHaveBeenCalledWith('ws-1', 'Sam') + }) + + it('shows a translated message for a recognised add-partner error', async () => { + const user = await advanceToFamilyStep() + await user.click(screen.getByTestId('planning-mode-couple')) + + const api = await import('@/services/api') + vi.mocked(api.addPartner).mockRejectedValue( + new ApiRequestError(422, { + code: 'workspace.parents.invalidName', + message: 'invalid', + details: { messageKey: 'workspace.parents.invalidName' }, + }) + ) + + await user.type(screen.getByLabelText(/Partnerns namn/), '!!!') + await user.click(screen.getByRole('button', { name: 'Lägg till partner' })) + + expect(await screen.findByText('Partnerns namn är ogiltigt.')).toBeInTheDocument() + }) + + it('falls back to the generic add-partner error for an unrecognised structured error', async () => { + const user = await advanceToFamilyStep() + await user.click(screen.getByTestId('planning-mode-couple')) + + const api = await import('@/services/api') + vi.mocked(api.addPartner).mockRejectedValue( + new ApiRequestError(500, { + code: 'something.else', + message: 'oops', + details: { messageKey: 'something.else' }, + }) + ) + + await user.type(screen.getByLabelText(/Partnerns namn/), 'Sam') + await user.click(screen.getByRole('button', { name: 'Lägg till partner' })) + + expect(await screen.findByText('Kunde inte lägga till partner')).toBeInTheDocument() + }) + + it('continues to the balances step, persisting the chosen planning mode', async () => { + const user = await advanceToFamilyStep() + await user.click(screen.getByTestId('planning-mode-couple')) + + const api = await import('@/services/api') + vi.mocked(api.updateWorkspace).mockResolvedValue({ + id: 'ws-1', + name: 'Test Workspace', + planningMode: 'couple', + createdAt: '2026-01-01', + }) + + await user.click(screen.getByRole('button', { name: 'Fortsätt' })) + + await waitFor(() => expect(screen.getByTestId('onboarding-step-balances')).toBeInTheDocument()) + expect(api.updateWorkspace).toHaveBeenCalledWith('ws-1', { planningMode: 'couple' }) + }) + + it('shows an error and stays on step 2 when saving the planning mode fails', async () => { + const user = await advanceToFamilyStep() + + const api = await import('@/services/api') + vi.mocked(api.updateWorkspace).mockRejectedValue(new Error('save failed')) + + await user.click(screen.getByRole('button', { name: 'Fortsätt' })) + + expect(await screen.findByText('save failed')).toBeInTheDocument() + expect(screen.getByTestId('onboarding-step-family')).toBeInTheDocument() + }) + }) + + describe('step 3 — balances ladder', () => { + async function advanceToBalancesStep(): Promise> { + const api = await import('@/services/api') + vi.mocked(api.createChild).mockResolvedValue({ id: 'child-9', name: 'Barnet' }) + vi.mocked(api.updateWorkspace).mockResolvedValue({ + id: 'ws-1', + name: 'Test Workspace', + planningMode: 'solo', + createdAt: '2026-01-01', + }) + const user = userEvent.setup() + renderWithRouter() + await user.type(screen.getByLabelText(/Barnets namn/), 'Barnet') + await user.type(screen.getByTestId('birth-date-input'), '2024-01-15') + await user.click(screen.getByRole('button', { name: /Lägg till barn/ })) + await waitFor(() => expect(screen.getByTestId('onboarding-step-family')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'Fortsätt' })) + await waitFor(() => expect(screen.getByTestId('onboarding-step-balances')).toBeInTheDocument()) + return user + } + + it('renders all three rungs of the ladder', async () => { + await advanceToBalancesStep() + expect(screen.getByTestId('onboarding-rung-scrape')).toBeInTheDocument() + expect(screen.getByTestId('onboarding-rung-manual')).toBeInTheDocument() + expect(screen.getByTestId('onboarding-rung-skip')).toBeInTheDocument() + }) + + it('starts the FK scraper with the newly created child id and opens the QR modal', async () => { + const startMock = vi.fn().mockResolvedValue(undefined) + vi.mocked(hooks.useScraperFetch).mockReturnValue( + makeScraperFetchResult({ start: startMock, pollData: makePollResponse({ status: 'launching' }) }) + ) + const user = await advanceToBalancesStep() + + await user.click(screen.getByRole('button', { name: 'Hämta från Försäkringskassan' })) + + await waitFor(() => expect(startMock).toHaveBeenCalledWith('child-9')) + // The modal's own status row ("Status:") only renders once it's open. + expect(await screen.findByText('Status:')).toBeInTheDocument() + }) + + it('finishes onboarding (refreshing context) once the FK fetch is done and the modal is closed', async () => { + const refreshContext = vi.fn().mockResolvedValue(undefined) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, + children: [], + personalDataUuid: 'pd-1', + refreshContext, + }) + ) + vi.mocked(hooks.useScraperFetch).mockReturnValue( + makeScraperFetchResult({ + pollData: makePollResponse({ status: 'done', result: {} }), + isRunning: false, + }) + ) + const user = await advanceToBalancesStep() + await user.click(screen.getByRole('button', { name: 'Hämta från Försäkringskassan' })) + + await user.click(screen.getByRole('button', { name: 'Stäng' })) + + await waitFor(() => expect(refreshContext).toHaveBeenCalled()) + }) + + it('saves manual balances and finishes onboarding', async () => { + const refreshContext = vi.fn().mockResolvedValue(undefined) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, + children: [], + personalDataUuid: 'pd-1', + refreshContext, + }) + ) + const user = await advanceToBalancesStep() + + const api = await import('@/services/api') + vi.mocked(api.saveManualFetch).mockResolvedValue({ + id: 'plf-1', + personalDataUuid: 'pd-1', + workspaceId: 'ws-1', + childId: 'child-9', + source: 'manual', + fetchedAt: '2026-01-01', + totalDays: 480, + daysUsed: 0, + daysLeft: 480, + reservedDaysTotal: 90, + reservedDaysLeft: 90, + transferableDaysLeft: 0, + dubbeldagarLeft: 60, + tierData: { + sjukpenningTotal: 390, + sjukpenningUsed: 0, + sjukpenningLeft: 390, + lagstaNivaTotal: 90, + lagstaNivaUsed: 0, + lagstaNivaLeft: 90, + }, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }) + + await user.click(screen.getByRole('button', { name: 'Ange uppgifter manuellt' })) + await user.click(screen.getByRole('button', { name: 'Spara' })) + + await waitFor(() => expect(api.saveManualFetch).toHaveBeenCalled()) + expect(api.saveManualFetch).toHaveBeenCalledWith( + expect.objectContaining({ personalDataUuid: 'pd-1', workspaceId: 'ws-1', childId: 'child-9' }) + ) + await waitFor(() => expect(refreshContext).toHaveBeenCalled()) + }) + + it('shows an error when saving manual balances fails', async () => { + const user = await advanceToBalancesStep() + + const api = await import('@/services/api') + vi.mocked(api.saveManualFetch).mockRejectedValue(new Error('manual save failed')) + + await user.click(screen.getByRole('button', { name: 'Ange uppgifter manuellt' })) + await user.click(screen.getByRole('button', { name: 'Spara' })) + + expect(await screen.findByText('manual save failed')).toBeInTheDocument() + }) + + it('skips the balances step and finishes onboarding on the statutory assumption', async () => { + const refreshContext = vi.fn().mockResolvedValue(undefined) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, + children: [], + personalDataUuid: 'pd-1', + refreshContext, + }) + ) + const user = await advanceToBalancesStep() + + await user.click(screen.getByRole('button', { name: 'Hoppa över för nu' })) + + await waitFor(() => expect(refreshContext).toHaveBeenCalled()) + }) + + it('shows an error when the final refreshContext call fails', async () => { + const refreshContext = vi.fn().mockRejectedValue(new Error('refresh failed')) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, + children: [], + personalDataUuid: 'pd-1', + refreshContext, + }) + ) + const user = await advanceToBalancesStep() + + await user.click(screen.getByRole('button', { name: 'Hoppa över för nu' })) + + expect(await screen.findByText('refresh failed')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/pages/Onboarding.tsx b/frontend/src/pages/Onboarding.tsx new file mode 100644 index 0000000..3c24d26 --- /dev/null +++ b/frontend/src/pages/Onboarding.tsx @@ -0,0 +1,346 @@ +import { FormEvent, ReactNode, useState } from 'react' +import * as api from '@/services/api' +import { isApiError } from '@/services/errors' +import { useWorkspace } from '@/context' +import { useScraperFetch } from '@/hooks' +import { useTranslation, type TranslationKey, type TranslationParams } from '@/i18n' +import { Layout } from '@/components/layout/Layout' +import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput' +import { PlanningModeSelector, type PlanningMode } from '@/components/inputs/PlanningModeSelector' +import { QRCodeModal } from '@/components/modals/QRCodeModal' +import { ManualFetchModal, type ManualFetchModalProps } from '@/components/modals/ManualFetchModal' +import './onboarding.css' + +type Step = 'child' | 'family' | 'balances' + +type Translate = (key: TranslationKey, params?: TranslationParams) => string + +/** Mirrors `Settings.tsx`'s `describePartnerError` (kept as its own small + * copy rather than an import — that function is private to Settings.tsx, + * and Settings.tsx is outside this task's owned files). Only the two error + * keys an onboarding-time add-partner call can plausibly hit are mapped; + * everything else — including the dependent-data removal errors, which + * can't happen here since nothing has been removed yet — falls back. */ +const ADD_PARTNER_ERROR_KEYS: Record = { + 'workspace.notFound': 'settings.partnerActionErrorNotFound', + 'workspace.parents.invalidName': 'settings.partnerNameInvalid', +} + +function describeAddPartnerError(err: unknown, t: Translate, fallback: string): string { + if (isApiError(err)) { + const details = err.error.details as { messageKey?: string } | undefined + const messageKey = details?.messageKey + if (messageKey && ADD_PARTNER_ERROR_KEYS[messageKey]) { + return t(ADD_PARTNER_ERROR_KEYS[messageKey]) + } + if (messageKey) return fallback + } + return err instanceof Error ? err.message : fallback +} + +/** + * First-run wizard shown by `Dashboard.tsx` whenever `children.length === 0`. + * Three steps: add the child, choose solo/couple (+ optionally add a + * partner), then a balances "ladder" — FK fetch, manual entry, or skip. + * + * Deliberately does not call `refreshContext()` (which would flip the + * `WorkspaceContext`'s `children` to non-empty and cause `Dashboard` to swap + * back to its normal render) until the very last step resolves — the + * `createdChildId` returned by `createChild` is threaded through local state + * instead, so steps 2 and 3 have what they need without an early context + * refresh cutting the wizard short. + */ +export function Onboarding(): ReactNode { + const { t } = useTranslation() + const { workspace, personalDataUuid, refreshContext } = useWorkspace() + const scraper = useScraperFetch() + + const [step, setStep] = useState('child') + + // Step 1 — child + const [childName, setChildName] = useState('') + const [childBirthDate, setChildBirthDate] = useState('') + const [isCreatingChild, setIsCreatingChild] = useState(false) + const [childError, setChildError] = useState(null) + const [createdChildId, setCreatedChildId] = useState(null) + + // Step 2 — solo/couple + optional partner + const [planningMode, setPlanningMode] = useState('solo') + const [partnerName, setPartnerName] = useState('') + const [isAddingPartner, setIsAddingPartner] = useState(false) + const [addedPartnerName, setAddedPartnerName] = useState(null) + const [isSavingFamily, setIsSavingFamily] = useState(false) + const [familyError, setFamilyError] = useState(null) + + // Step 3 — balances ladder + const [showScrapeModal, setShowScrapeModal] = useState(false) + const [showManualModal, setShowManualModal] = useState(false) + const [isSavingManual, setIsSavingManual] = useState(false) + const [manualError, setManualError] = useState(null) + const [isFinishing, setIsFinishing] = useState(false) + const [finishError, setFinishError] = useState(null) + + const handleCreateChild = async (e: FormEvent) => { + e.preventDefault() + setChildError(null) + if (!workspace || !childName || !childBirthDate) { + setChildError(t('onboarding.fillAllFields')) + return + } + setIsCreatingChild(true) + try { + const created = await api.createChild({ + workspace_id: workspace.id, + name: childName, + birth_date: childBirthDate, + }) + setCreatedChildId(created.id) + setStep('family') + } catch (err) { + setChildError(err instanceof Error ? err.message : t('onboarding.createChildError')) + } finally { + setIsCreatingChild(false) + } + } + + const handleAddPartner = async () => { + if (!workspace || !partnerName.trim()) return + setIsAddingPartner(true) + setFamilyError(null) + try { + const added = await api.addPartner(workspace.id, partnerName.trim()) + setAddedPartnerName(added.name) + setPartnerName('') + } catch (err) { + setFamilyError(describeAddPartnerError(err, t, t('settings.addPartnerError'))) + } finally { + setIsAddingPartner(false) + } + } + + const handleContinueFamily = async () => { + if (!workspace) return + setIsSavingFamily(true) + setFamilyError(null) + try { + await api.updateWorkspace(workspace.id, { planningMode }) + setStep('balances') + } catch (err) { + setFamilyError(err instanceof Error ? err.message : t('onboarding.savingFamilyError')) + } finally { + setIsSavingFamily(false) + } + } + + /** The wizard's only exit: pulls the newly created child into + * `WorkspaceContext` so `Dashboard` renders its normal content next + * render. Shared by all three balances-step outcomes (FK fetch done, + * manual save done, skip). */ + const finish = async () => { + setIsFinishing(true) + setFinishError(null) + try { + await refreshContext() + } catch (err) { + setFinishError(err instanceof Error ? err.message : t('onboarding.finishError')) + } finally { + setIsFinishing(false) + } + } + + const handleStartScrapeFetch = async () => { + setShowScrapeModal(true) + await scraper.start(createdChildId ?? undefined) + } + + const handleScrapeModalClose = () => { + if (scraper.isRunning) return + setShowScrapeModal(false) + } + + const handleScrapeDone = async () => { + setShowScrapeModal(false) + await finish() + } + + const handleSaveManualFetch: ManualFetchModalProps['onSave'] = async (data) => { + if (!workspace?.id || !createdChildId || !personalDataUuid) return + setIsSavingManual(true) + setManualError(null) + try { + await api.saveManualFetch({ + personalDataUuid, + workspaceId: workspace.id, + childId: createdChildId, + totalDays: data.totalDays, + daysUsed: data.daysUsed, + reservedDaysLeft: data.reservedDaysLeft, + transferableDaysLeft: data.transferableDaysLeft, + dubbeldagarLeft: data.dubbeldagarLeft, + sjukpenningUsed: data.sjukpenningUsed, + lagstaNivaUsed: data.lagstaNivaUsed, + }) + setShowManualModal(false) + await finish() + } catch (err) { + setManualError(err instanceof Error ? err.message : t('dashboard.saveManualFetchError')) + } finally { + setIsSavingManual(false) + } + } + + const handleSkip = () => { + void finish() + } + + const stepNumber = step === 'child' ? 1 : step === 'family' ? 2 : 3 + + return ( + +
+

+ {t('onboarding.stepIndicator', { step: stepNumber, total: 3 })} +

+ + {step === 'child' && ( +
+

{t('onboarding.stepChildTitle')}

+

{t('onboarding.stepChildIntro')}

+
void handleCreateChild(e)}> +
+ + setChildName(e.target.value)} + disabled={isCreatingChild} + required + /> +
+
+ +
+ {childError && ( +
+ {childError} +
+ )} + +
+
+ )} + + {step === 'family' && ( +
+

{t('onboarding.familyTitle')}

+

{t('onboarding.familyIntro')}

+ + + + {planningMode === 'couple' && ( +
+

{t('onboarding.partnerIntro')}

+
+ + setPartnerName(e.target.value)} + disabled={isAddingPartner} + /> +
+ + {addedPartnerName && ( +

+ {t('onboarding.partnerAddedStatus', { name: addedPartnerName })} +

+ )} +
+ )} + + {familyError && ( +
+ {familyError} +
+ )} + + +
+ )} + + {step === 'balances' && ( +
+

{t('onboarding.stepBalancesTitle')}

+

{t('onboarding.stepBalancesIntro')}

+ +
    +
  • +

    {t('onboarding.scrapeFetchTitle')}

    +

    {t('onboarding.scrapeFetchDescription')}

    + +
  • + +
  • +

    {t('onboarding.manualFetchTitle')}

    +

    {t('onboarding.manualFetchDescription')}

    + +
  • + +
  • +

    {t('onboarding.skipHint')}

    + +
  • +
+ + {finishError && ( +
+ {finishError} +
+ )} +
+ )} + + void handleScrapeDone()} + status={scraper.pollData?.status ?? null} + qrData={scraper.pollData?.qr_data} + result={scraper.pollData?.result} + message={scraper.pollData?.message} + error={scraper.error} + isRunning={scraper.isRunning} + onCancel={scraper.cancel} + /> + + setShowManualModal(false)} + isSaving={isSavingManual} + error={manualError} + /> +
+
+ ) +} diff --git a/frontend/src/pages/Plan.test.tsx b/frontend/src/pages/Plan.test.tsx deleted file mode 100644 index e929c4c..0000000 --- a/frontend/src/pages/Plan.test.tsx +++ /dev/null @@ -1,775 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' -import { Plan } from './Plan' -import * as hooks from '@/hooks' -import { useWorkspace } from '@/context' -import { LanguageProvider } from '@/i18n' -import { - makeWorkspaceContextValue, - makeLeaveWishesResult, - makeWorkspaceMembersResult, - makePlanProposalResult, - makePlanApplyResult, - makeLeaveWish, - makePlanProposal, - makeProposedDay, - makeWindowOutcome, - makePlanWarning, - makeDayCollision, - makeRejection, - makePlanApplyResponse, -} from '../../tests/fixtures' - -vi.mock('@/hooks') -vi.mock('@/context') - -// Layout pulls in useAuth/useNavigate, which need a full AuthProvider/Router tree -// to work. Plan.tsx only cares that its content renders inside Layout, so stub -// Layout out — same pattern used by Settings.test.tsx / Dashboard.test.tsx. -vi.mock('@/components/layout/Layout', () => ({ - Layout: ({ children }: { children: React.ReactNode }) =>
{children}
, -})) - -function renderPlan() { - return render() -} - -/** Render under a real `` resolved to English via browser - * detection — the global test-setup (tests/setup.ts) pins `navigator.language` - * to Swedish for the unwrapped default case, so an English-mode assertion - * needs its own provider + locale override, same idiom as - * PlanResults.test.tsx / LanguageContext.test.tsx. - */ -function renderPlanInEnglish() { - Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) - return render( - - - , - ) -} - - -const mockWish = makeLeaveWish({ - id: 'lw1', - workspaceId: 'ws-1', - name: 'Sommar', - windowStartMonth: 6, - windowStartDay: 15, - windowEndMonth: 7, - windowEndDay: 26, - targetWeeks: 6, -}) - -const mockProposal = makePlanProposal({ - days: [ - makeProposedDay({ - date: '2026-06-15', - parentPersonalDataUuid: 'pd-1', - childId: 'c-1', - dayType: 'sjukpenning', - wishId: 'lw1', - reason: 'Sommar', - }), - makeProposedDay({ - date: '2026-06-16', - parentPersonalDataUuid: 'pd-1', - childId: 'c-1', - dayType: 'vacation', - wishId: 'lw1', - reason: 'Sommar', - }), - ], - outcomes: [ - makeWindowOutcome({ - wishId: 'lw1', - wishName: 'Sommar', - wishNameMessageKey: 'presets.sommar', - year: 2026, - startDate: '2026-06-15', - endDate: '2026-07-26', - weeksWished: 6, - weeksFunded: 6, - costBreakdown: { free: 12, vacation: 19, sjukpenning: 11, lagsta: 0 }, - }), - ], - warnings: [ - makePlanWarning({ - data: { parent_personal_data_uuid: 'pd-1', expiring_days: 117, fourth_birthday: '2030-03-01' }, - }), - ], -}) - -describe('Plan', () => { - let mockAddWish: ReturnType - let mockUpdateWish: ReturnType - let mockRemoveWish: ReturnType - let mockPropose: ReturnType - let mockApply: ReturnType - - beforeEach(() => { - vi.clearAllMocks() - mockAddWish = vi.fn().mockResolvedValue(mockWish) - mockUpdateWish = vi.fn().mockResolvedValue(mockWish) - mockRemoveWish = vi.fn().mockResolvedValue(undefined) - mockPropose = vi.fn().mockResolvedValue(undefined) - mockApply = vi.fn().mockResolvedValue( - makePlanApplyResponse({ - accepted: true, - createdCount: 2, - }) - ) - - vi.mocked(useWorkspace).mockReturnValue( - makeWorkspaceContextValue({ - workspace: { - id: 'ws-1', - name: 'Test Workspace', - planningMode: 'couple', - createdAt: '2026-01-01', - }, - workspaceId: 'ws-1', - children: [{ id: 'c-1', name: 'Child 1', birth_date: '2026-03-01' }], - selectedChildId: 'c-1', - personalDataUuid: 'pd-1', - parentName: 'Alex', - }) - ) - vi.mocked(hooks.useLeaveWishes).mockReturnValue( - makeLeaveWishesResult({ - data: [mockWish], - add: mockAddWish, - update: mockUpdateWish, - remove: mockRemoveWish, - }) - ) - vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( - makeWorkspaceMembersResult({ - data: [{ uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }], - }) - ) - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - propose: mockPropose, - }) - ) - vi.mocked(hooks.usePlanApply).mockReturnValue( - makePlanApplyResult({ - apply: mockApply, - }) - ) - }) - - it('renders loading spinner while the workspace loads', () => { - vi.mocked(useWorkspace).mockReturnValue( - makeWorkspaceContextValue({ - workspace: { - id: 'ws-1', - name: 'Test Workspace', - planningMode: 'couple', - createdAt: '2026-01-01', - }, - workspaceId: 'ws-1', - children: [{ id: 'c-1', name: 'Child 1', birth_date: '2026-03-01' }], - selectedChildId: 'c-1', - personalDataUuid: 'pd-1', - parentName: 'Alex', - isLoading: true, - }) - ) - renderPlan() - expect(screen.getByText(/Laddar planering/)).toBeInTheDocument() - }) - - it('renders the loading spinner during the initial wishes load, before any data has arrived', () => { - vi.mocked(hooks.useLeaveWishes).mockReturnValue( - makeLeaveWishesResult({ - data: [], - isLoading: true, - add: mockAddWish, - update: mockUpdateWish, - remove: mockRemoveWish, - }) - ) - renderPlan() - expect(screen.getByText(/Laddar planering/)).toBeInTheDocument() - expect(screen.queryByTestId('wish-editor')).not.toBeInTheDocument() - }) - - it('does not show the full-page spinner or unmount the wish editor during a mutation background refresh', () => { - // useLeaveWishes flips isRefreshing (not isLoading) while it refetches - // after add/update/remove. Plan must keep rendering the wish editor - // (and whatever expanded/save-status state it holds) through that. - vi.mocked(hooks.useLeaveWishes).mockReturnValue( - makeLeaveWishesResult({ - data: [mockWish], - isRefreshing: true, - add: mockAddWish, - update: mockUpdateWish, - remove: mockRemoveWish, - }) - ) - renderPlan() - expect(screen.queryByText(/Laddar planering/)).not.toBeInTheDocument() - expect(screen.getByTestId('wish-editor')).toBeInTheDocument() - expect(screen.getByTestId('wish-card-lw1')).toBeInTheDocument() - }) - - it('renders the page title and the wish editor', () => { - renderPlan() - expect(screen.getByRole('heading', { name: 'Planering', level: 1 })).toBeInTheDocument() - expect(screen.getByTestId('wish-editor')).toBeInTheDocument() - expect(screen.getByTestId('wish-card-lw1')).toBeInTheDocument() - }) - - it('adds a wish from a preset via the wish editor', async () => { - renderPlan() - fireEvent.click(screen.getByTestId('wish-preset-sommar')) - - await waitFor(() => expect(mockAddWish).toHaveBeenCalled()) - expect(mockAddWish.mock.calls[0]![0]).toMatchObject({ workspaceId: 'ws-1', name: 'Sommar' }) - }) - - it('calls propose when "Föreslå plan" is clicked', async () => { - renderPlan() - fireEvent.click(screen.getByTestId('propose-plan-button')) - await waitFor(() => expect(mockPropose).toHaveBeenCalled()) - }) - - it('disables "Föreslå plan" when there are no wishes yet', () => { - vi.mocked(hooks.useLeaveWishes).mockReturnValue( - makeLeaveWishesResult({ - data: [], - add: mockAddWish, - update: mockUpdateWish, - remove: mockRemoveWish, - }) - ) - renderPlan() - expect(screen.getByTestId('propose-plan-button')).toBeDisabled() - }) - - it('renders the proposal, including the age-4 warning, once it is available', () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: mockProposal, - propose: mockPropose, - }) - ) - renderPlan() - - expect(screen.getByTestId('plan-results')).toBeInTheDocument() - expect(screen.getByTestId('plan-warning-days_lost_at_age_4-0')).toHaveTextContent( - 'Alex riskerar att förlora 117 dagar', - ) - }) - - it('does not render results before a proposal has been requested', () => { - renderPlan() - expect(screen.queryByTestId('plan-results')).not.toBeInTheDocument() - }) - - it('edits and removes a wish through the wish editor', async () => { - renderPlan() - - // WishEditor auto-saves edits after an 800ms debounce (no per-row Save - // button anymore) — advance fake timers to let it fire. - vi.useFakeTimers() - fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '8' } }) - act(() => { - vi.advanceTimersByTime(800) - }) - expect(mockUpdateWish).toHaveBeenCalledWith('lw1', expect.objectContaining({ targetWeeks: 8 })) - // Flush the microtask queue so Plan's `.finally(() => setIsSaving(false))` - // runs and re-enables the remove button before it's clicked below. - await act(async () => { - await Promise.resolve() - }) - vi.useRealTimers() - - fireEvent.click(screen.getByTestId('wish-remove-lw1')) - await waitFor(() => expect(mockRemoveWish).toHaveBeenCalledWith('lw1')) - }) - - it('shows an error banner when adding a wish fails', async () => { - mockAddWish.mockRejectedValue(new Error('nätverksfel')) - renderPlan() - - fireEvent.click(screen.getByTestId('wish-preset-sommar')) - - await waitFor(() => expect(screen.getByTestId('wish-error')).toHaveTextContent('nätverksfel')) - }) - - it('shows a generic error banner when adding a wish fails with a non-Error value', async () => { - mockAddWish.mockRejectedValue('boom') - renderPlan() - - fireEvent.click(screen.getByTestId('wish-preset-sommar')) - - await waitFor(() => - expect(screen.getByTestId('wish-error')).toHaveTextContent('Kunde inte lägga till önskemål'), - ) - }) - - it('shows a calculating label on the propose button while a proposal is in flight', () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: null, - isLoading: true, - propose: mockPropose, - }) - ) - renderPlan() - expect(screen.getByTestId('propose-plan-button')).toHaveTextContent('Beräknar') - }) - - it('treats a missing workspace as having no workspace id: skips the wish editor and refuses to apply', async () => { - vi.mocked(useWorkspace).mockReturnValue( - makeWorkspaceContextValue({ - workspace: null, - }) - ) - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: mockProposal, - propose: mockPropose, - }) - ) - renderPlan() - - // workspace?.id ?? null falls back to null, so the wish editor (guarded - // on workspaceId) must not render even though wishes/members data exist. - expect(screen.queryByTestId('wish-editor')).not.toBeInTheDocument() - - fireEvent.click(screen.getByTestId('plan-apply-button')) - - await waitFor(() => { - // handleApplyPlan's `if (!workspaceId) throw` fires before ever - // reaching the real apply() call. - expect(mockApply).not.toHaveBeenCalled() - }) - }) - - it('shows an error banner when the proposal fails', () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: null, - error: 'Kunde inte beräkna plan', - propose: mockPropose, - }) - ) - renderPlan() - - expect(screen.getByTestId('proposal-error')).toHaveTextContent('Kunde inte beräkna plan') - }) - - it('shows the apply button when a proposal exists', () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: mockProposal, - propose: mockPropose, - }) - ) - renderPlan() - - expect(screen.getByTestId('plan-apply-button')).toBeInTheDocument() - expect(screen.getByTestId('plan-apply-button')).toHaveTextContent('Använd plan') - }) - - it('calls apply when the apply button is clicked and shows success message', async () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: mockProposal, - propose: mockPropose, - }) - ) - renderPlan() - - fireEvent.click(screen.getByTestId('plan-apply-button')) - - await waitFor(() => { - expect(mockApply).toHaveBeenCalledWith('ws-1', expect.any(Array), false) - expect(screen.getByTestId('plan-apply-success')).toHaveTextContent('2 nya dagar skapades') - }) - }) - - it('shows collision prompt when apply returns collisions and replace=false', async () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: mockProposal, - propose: mockPropose, - }) - ) - - mockApply.mockResolvedValue( - makePlanApplyResponse({ - accepted: false, - collisions: [ - makeDayCollision({ - date: '2026-06-15', - parentPersonalDataUuid: 'pd-1', - childId: 'c-1', - existingDayType: 'vacation', - proposedDayType: 'sjukpenning', - }), - ], - }) - ) - - renderPlan() - - fireEvent.click(screen.getByTestId('plan-apply-button')) - - await waitFor(() => { - expect(screen.getByTestId('plan-collision-prompt')).toBeInTheDocument() - expect(screen.getByText('Konflikt med befintliga dagar')).toBeInTheDocument() - expect(screen.getByTestId('plan-collisions-table')).toBeInTheDocument() - }) - }) - - it('re-sends with replace=true when user confirms overwrite', async () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: mockProposal, - propose: mockPropose, - }) - ) - - mockApply - .mockResolvedValueOnce({ - accepted: false, - createdCount: 0, - skippedIdenticalCount: 0, - skippedFreeCount: 0, - replacedCount: 0, - collisions: [ - { - date: '2026-06-15', - parentPersonalDataUuid: 'pd-1', - childId: 'c-1', - existingDayType: 'vacation', - proposedDayType: 'sjukpenning', - }, - ], - rejections: [], - createdDays: [], - }) - .mockResolvedValueOnce( - makePlanApplyResponse({ - accepted: true, - createdCount: 2, - replacedCount: 1, - }) - ) - - renderPlan() - - fireEvent.click(screen.getByTestId('plan-apply-button')) - - await waitFor(() => { - expect(screen.getByTestId('plan-collision-prompt')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByTestId('confirm-overwrite-button')) - - await waitFor(() => { - expect(mockApply).toHaveBeenLastCalledWith('ws-1', expect.any(Array), true) - expect(screen.getByTestId('plan-apply-success')).toHaveTextContent('2 nya dagar skapades') - expect(screen.getByTestId('plan-apply-success')).toHaveTextContent('1 dag ersattes') - }) - }) - - it('hides collision prompt when user cancels', async () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: mockProposal, - propose: mockPropose, - }) - ) - - mockApply.mockResolvedValue( - makePlanApplyResponse({ - accepted: false, - collisions: [ - makeDayCollision({ - date: '2026-06-15', - parentPersonalDataUuid: 'pd-1', - childId: 'c-1', - existingDayType: 'vacation', - proposedDayType: 'sjukpenning', - }), - ], - }) - ) - - renderPlan() - - fireEvent.click(screen.getByTestId('plan-apply-button')) - - await waitFor(() => { - expect(screen.getByTestId('plan-collision-prompt')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByTestId('cancel-overwrite-button')) - - expect(screen.queryByTestId('plan-collision-prompt')).not.toBeInTheDocument() - expect(screen.getByTestId('plan-apply-button')).toBeInTheDocument() - }) - - it('shows rejections in the success message', async () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: mockProposal, - propose: mockPropose, - }) - ) - - mockApply.mockResolvedValue( - makePlanApplyResponse({ - accepted: true, - createdCount: 1, - rejections: [ - makeRejection({ - messageKey: 'planApply.rejection.insufficientBalance', - data: { tier: 'sjukpenning', needed: 2, remaining: 1 }, - }), - ], - }) - ) - - renderPlan() - - fireEvent.click(screen.getByTestId('plan-apply-button')) - - await waitFor(() => { - expect(screen.getByTestId('plan-apply-success')).toHaveTextContent( - 'Avslag: Omgången behöver 2 fler "sjukpenning"-dagar men endast 1 finns kvar', - ) - }) - }) - - it('shows rejections in English when the locale is English (§3.5)', async () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: mockProposal, - propose: mockPropose, - }) - ) - - mockApply.mockResolvedValue( - makePlanApplyResponse({ - accepted: true, - createdCount: 1, - rejections: [ - makeRejection({ - messageKey: 'planApply.rejection.insufficientBalance', - data: { tier: 'sjukpenning', needed: 2, remaining: 1 }, - }), - ], - }) - ) - - vi.stubGlobal('localStorage', { - getItem: () => null, - setItem: () => undefined, - removeItem: () => undefined, - }) - - try { - renderPlanInEnglish() - - fireEvent.click(screen.getByTestId('plan-apply-button')) - - await waitFor(() => { - expect(screen.getByTestId('plan-apply-success')).toHaveTextContent( - 'Rejected: This batch needs 2 more "sjukpenning" day(s) but only 1 remain', - ) - }) - } finally { - Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true }) - vi.unstubAllGlobals() - } - }) - - it('shows an error banner when apply fails', async () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: mockProposal, - propose: mockPropose, - }) - ) - - vi.mocked(hooks.usePlanApply).mockReturnValue( - makePlanApplyResult({ - error: 'Kunde inte spara plan', - apply: mockApply, - }) - ) - - renderPlan() - - expect(screen.getByTestId('apply-error')).toHaveTextContent('Kunde inte spara plan') - }) - - it('clears success message when user proposes again after successful apply', async () => { - // Create two different proposals - const mockProposal1 = mockProposal - const mockProposal2 = makePlanProposal({ - days: [ - ...mockProposal.days, - makeProposedDay({ - date: '2026-07-01', - parentPersonalDataUuid: 'pd-1', - childId: 'c-1', - dayType: 'sjukpenning', - wishId: 'lw1', - reason: 'Sommar', - }), - ], - outcomes: mockProposal.outcomes, - warnings: mockProposal.warnings, - }) - - // Track proposal state changes - let proposalData = mockProposal1 - const proposeHandler = vi.fn(async () => { - proposalData = mockProposal2 - }) - - vi.mocked(hooks.usePlanProposal).mockImplementation(() => - makePlanProposalResult({ - data: proposalData, - propose: proposeHandler, - }) - ) - - const { rerender } = renderPlan() - - // Apply the plan - fireEvent.click(screen.getByTestId('plan-apply-button')) - - await waitFor(() => { - expect(screen.getByTestId('plan-apply-success')).toHaveTextContent('2 nya dagar skapades') - }) - - // User proposes again - fireEvent.click(screen.getByTestId('propose-plan-button')) - - await waitFor(() => { - expect(proposeHandler).toHaveBeenCalled() - }) - - // Force re-render with the new proposal - rerender() - - // The success message should be gone - expect(screen.queryByTestId('plan-apply-success')).not.toBeInTheDocument() - - // The apply button should be visible again - expect(screen.getByTestId('plan-apply-button')).toBeInTheDocument() - expect(screen.getByTestId('plan-apply-button')).toHaveTextContent('Använd plan') - }) - - it('clears collision prompt when user proposes again after collision', async () => { - // Create two different proposals - const mockProposal1 = mockProposal - const mockProposal2 = { - ...mockProposal, - days: [ - ...mockProposal.days, - { - date: '2026-07-01', - parentPersonalDataUuid: 'pd-1', - childId: 'c-1', - dayType: 'sjukpenning' as const, - isDubbeldag: false, - wishId: 'lw1', - reason: 'Sommar', - }, - ], - } - - // Track proposal state changes - let proposalData = mockProposal1 - const proposeHandler = vi.fn(async () => { - proposalData = mockProposal2 - }) - - vi.mocked(hooks.usePlanProposal).mockImplementation(() => - makePlanProposalResult({ - data: proposalData, - propose: proposeHandler, - }) - ) - - // Mock apply to return a collision on first call - mockApply.mockResolvedValue( - makePlanApplyResponse({ - accepted: false, - collisions: [ - makeDayCollision({ - date: '2026-06-15', - parentPersonalDataUuid: 'pd-1', - childId: 'c-1', - existingDayType: 'vacation', - proposedDayType: 'sjukpenning', - }), - ], - }) - ) - - const { rerender } = renderPlan() - - // Apply the plan (triggers collision) - fireEvent.click(screen.getByTestId('plan-apply-button')) - - await waitFor(() => { - expect(screen.getByTestId('plan-collision-prompt')).toBeInTheDocument() - }) - - // User proposes again - fireEvent.click(screen.getByTestId('propose-plan-button')) - - await waitFor(() => { - expect(proposeHandler).toHaveBeenCalled() - }) - - // Force re-render with the new proposal - rerender() - - // The collision prompt should be gone - expect(screen.queryByTestId('plan-collision-prompt')).not.toBeInTheDocument() - - // The apply button should be visible again - expect(screen.getByTestId('plan-apply-button')).toBeInTheDocument() - expect(screen.getByTestId('plan-apply-button')).toHaveTextContent('Använd plan') - }) - - it('success message is present immediately after apply succeeds', async () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue( - makePlanProposalResult({ - data: mockProposal, - propose: mockPropose, - }) - ) - - mockApply.mockResolvedValue( - makePlanApplyResponse({ - accepted: true, - createdCount: 5, - }) - ) - - renderPlan() - - fireEvent.click(screen.getByTestId('plan-apply-button')) - - await waitFor(() => { - expect(screen.getByTestId('plan-apply-success')).toBeInTheDocument() - expect(screen.getByTestId('plan-apply-success')).toHaveTextContent('5 nya dagar skapades') - }) - - // Verify the button is not visible - expect(screen.queryByTestId('plan-apply-button')).not.toBeInTheDocument() - }) -}) diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx deleted file mode 100644 index 0c3ee96..0000000 --- a/frontend/src/pages/Plan.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { ReactNode, useState } from 'react' -import { useWorkspace } from '@/context' -import { useLeaveWishes, usePlanProposal, useWorkspaceMembers, usePlanApply } from '@/hooks' -import type { LeaveWishCreateRequest, LeaveWishUpdateRequest } from '@/types' -import { useTranslation } from '@/i18n' -import { Layout } from '@/components/layout/Layout' -import { LoadingSpinner } from '@/components/common/LoadingSpinner' -import { WishEditor } from '@/components/planning/WishEditor' -import { PlanResults } from '@/components/planning/PlanResults' -import './plan.css' - -export function Plan(): ReactNode { - const { t } = useTranslation() - const { workspace, isLoading: wsLoading } = useWorkspace() - const workspaceId = workspace?.id ?? null - const { - data: wishes, - isLoading: wishesLoading, - add: addWish, - update: updateWish, - remove: removeWish, - } = useLeaveWishes(workspaceId) - const { data: members } = useWorkspaceMembers(workspaceId) - const { data: proposal, isLoading: isProposing, error: proposalError, propose } = - usePlanProposal(workspaceId) - const { isLoading: isApplying, error: applyError, apply } = usePlanApply() - - const [isSaving, setIsSaving] = useState(false) - const [wishError, setWishError] = useState(null) - - const isLoading = wsLoading || wishesLoading - - if (isLoading) { - return - } - - const handleAdd = (payload: LeaveWishCreateRequest): Promise => { - setWishError(null) - setIsSaving(true) - return addWish(payload) - .then(() => undefined) - .catch((err: unknown) => setWishError(err instanceof Error ? err.message : t('plan.addWishError'))) - .finally(() => setIsSaving(false)) - } - - // Unlike handleAdd/handleRemove, this does NOT catch the rejection here: - // WishEditor now awaits the promise it returns directly and drives that - // row's own inline save-status/retry off the real outcome, so surfacing - // the same failure again as the page-level `wishError` banner would be - // double-reporting one failure. `isSaving` bookkeeping (which only gates - // disabling the preset/remove buttons, never a full-page spinner) still - // runs via `.finally`. - const handleUpdate = (id: string, patch: LeaveWishUpdateRequest): Promise => { - setIsSaving(true) - return updateWish(id, patch) - .then(() => undefined) - .finally(() => setIsSaving(false)) - } - - const handleRemove = (id: string): Promise => { - setWishError(null) - setIsSaving(true) - return removeWish(id) - .catch((err: unknown) => setWishError(err instanceof Error ? err.message : t('plan.removeWishError'))) - .finally(() => setIsSaving(false)) - } - - const handleApplyPlan = async (days: import('@/types').ProposedDayApplyItem[], replace: boolean) => { - if (!workspaceId) throw new Error('Workspace not available') - return apply(workspaceId, days, replace) - } - - return ( - -
-

{t('plan.title')}

-

{t('plan.intro')}

- - {wishError && ( -
- {wishError} -
- )} - -
-

{t('plan.wishesTitle')}

- {workspaceId && ( - - )} -
- -
- - {wishes.length === 0 && ( -

{t('plan.addAtLeastOneWish')}

- )} - {proposalError && ( -
- {proposalError} -
- )} -
- - {applyError && ( -
- {applyError} -
- )} - - {proposal && ( - - )} -
-
- ) -} diff --git a/frontend/src/pages/Tips.test.tsx b/frontend/src/pages/Tips.test.tsx deleted file mode 100644 index 51a70a8..0000000 --- a/frontend/src/pages/Tips.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' -import { Tips } from './Tips' -import { useWorkspace } from '@/context' -import { useOptimizationTips } from '@/hooks' -import { makeWorkspaceContextValue, makeOptimizationTipsResult } from '../../tests/fixtures' - -vi.mock('@/context', () => ({ - useWorkspace: vi.fn(), -})) - -vi.mock('@/hooks', () => ({ - useOptimizationTips: vi.fn(), -})) - -vi.mock('@/components/layout/Layout', () => ({ - Layout: ({ children }: { children: React.ReactNode }) =>
{children}
, -})) - -describe('Tips', () => { - const mockDismiss = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(useWorkspace).mockReturnValue( - makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, - }) - ) - }) - - it('passes null to useOptimizationTips when there is no workspace yet', () => { - vi.mocked(useWorkspace).mockReturnValue( - makeWorkspaceContextValue({ - workspace: null, - }) - ) - vi.mocked(useOptimizationTips).mockReturnValue( - makeOptimizationTipsResult({ - data: [], - dismiss: mockDismiss, - }) - ) - render() - expect(useOptimizationTips).toHaveBeenCalledWith(null) - }) - - it('shows the loading spinner while the workspace or tips are loading', () => { - vi.mocked(useOptimizationTips).mockReturnValue( - makeOptimizationTipsResult({ - data: null, - isLoading: true, - dismiss: mockDismiss, - }) - ) - render() - expect(screen.getByText(/Laddar tips/)).toBeInTheDocument() - }) - - it('shows the empty state when there are no tips', () => { - vi.mocked(useOptimizationTips).mockReturnValue( - makeOptimizationTipsResult({ - data: [], - dismiss: mockDismiss, - }) - ) - render() - expect(screen.getByTestId('empty-state')).toHaveTextContent('Bra jobbat') - }) - - it('shows an error banner when tips failed to load', () => { - vi.mocked(useOptimizationTips).mockReturnValue( - makeOptimizationTipsResult({ - data: null, - error: 'Kunde inte hämta tips', - dismiss: mockDismiss, - }) - ) - render() - expect(screen.getByTestId('error-banner')).toHaveTextContent('Kunde inte hämta tips') - }) - - it('renders a tip card per tip and dismisses it on click', () => { - vi.mocked(useOptimizationTips).mockReturnValue( - makeOptimizationTipsResult({ - data: [ - { - id: 'tip-1', - severity: 'info', - messageKey: 'tips.balance_between_parents', - data: { leading_parent: 'Alex', lagging_parent: 'Sam', gap_days: 40 }, - }, - ], - dismiss: mockDismiss, - }) - ) - render() - - const tipCard = screen.getByTestId('tip-card') - expect(tipCard).toHaveTextContent('Obalans mellan föräldrarna') - - fireEvent.click(screen.getByTestId('dismiss-button')) - expect(mockDismiss).toHaveBeenCalledWith('tip-1') - }) -}) diff --git a/frontend/src/pages/Tips.tsx b/frontend/src/pages/Tips.tsx deleted file mode 100644 index d74036b..0000000 --- a/frontend/src/pages/Tips.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { ReactNode } from 'react' -import { useWorkspace } from '@/context' -import { useOptimizationTips } from '@/hooks' -import { useTranslation } from '@/i18n' -import { Layout } from '@/components/layout/Layout' -import { LoadingSpinner } from '@/components/common/LoadingSpinner' -import { TipCard } from '@/components/inputs/TipCard' - -export function Tips(): ReactNode { - const { t } = useTranslation() - const { workspace, isLoading: wsLoading } = useWorkspace() - const { data: tips, isLoading: tipsLoading, error: tipsError, dismiss } = useOptimizationTips( - workspace?.id ?? null - ) - - const isLoading = wsLoading || tipsLoading - - if (isLoading) { - return - } - - return ( - -
-

{t('tips.title')}

- - {tipsError && ( -
- {tipsError} -
- )} - - {!tips || tips.length === 0 ? ( -
-

{t('tips.empty')}

-
- ) : ( -
- {tips.map((tip) => ( - dismiss(tip.id)} - /> - ))} -
- )} -
-
- ) -} diff --git a/frontend/src/pages/onboarding.css b/frontend/src/pages/onboarding.css new file mode 100644 index 0000000..d108268 --- /dev/null +++ b/frontend/src/pages/onboarding.css @@ -0,0 +1,59 @@ +/* Onboarding wizard (first-run: add child, solo/couple, balances ladder) */ +.onboarding { + max-width: 60ch; +} + +.onboarding__indicator { + color: var(--c-text-muted); + font-size: var(--fs-sm); + margin-bottom: var(--sp-4); +} + +.onboarding__step h1 { + margin-bottom: var(--sp-2); +} + +.onboarding__step > p { + color: var(--c-text-muted); + margin-bottom: var(--sp-5); +} + +.onboarding__partner { + margin-top: var(--sp-4); + margin-bottom: var(--sp-4); +} + +.onboarding__partner-status { + color: var(--c-success); + font-size: var(--fs-sm); +} + +.onboarding__ladder { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--sp-4); +} + +.onboarding__ladder-item { + border: 1px solid var(--c-border); + border-radius: var(--radius); + background-color: var(--c-surface); + padding: var(--sp-4); +} + +.onboarding__ladder-item h2 { + margin: 0 0 var(--sp-1) 0; + font-size: var(--fs-lg); +} + +.onboarding__ladder-item p { + color: var(--c-text-muted); + margin-bottom: var(--sp-3); +} + +.onboarding__hint { + font-size: var(--fs-sm); +} diff --git a/frontend/src/pages/plan.css b/frontend/src/pages/plan.css deleted file mode 100644 index b2c8ecd..0000000 --- a/frontend/src/pages/plan.css +++ /dev/null @@ -1,540 +0,0 @@ -/* Plan page */ -.plan-page__intro { - max-width: 70ch; - color: var(--c-text-muted); -} - -.plan-page__section { - margin-bottom: var(--sp-6); -} - -.plan-page__error { - padding: var(--sp-3) var(--sp-4); - background-color: var(--c-danger-weak); - color: var(--c-danger); - border-left: 4px solid var(--c-danger); - border-radius: var(--radius); - margin-bottom: var(--sp-4); -} - -.plan-page__help { - font-size: var(--fs-sm); - color: var(--c-text-muted); - margin-top: var(--sp-2); -} - -/* Wish editor */ -.wish-editor__presets { - display: flex; - flex-wrap: wrap; - gap: var(--sp-3); - margin-bottom: var(--sp-5); -} - -.wish-editor__preset-button { - background-color: var(--c-surface); - color: var(--c-accent); - border: 1px solid var(--c-accent); -} - -.wish-editor__preset-button:hover:not(:disabled) { - background-color: var(--c-accent-weak); -} - -.wish-editor__preset-button--custom { - background-color: transparent; - color: var(--c-text-muted); - border: 1px dashed var(--c-border); -} - -.wish-editor__preset-button--flexible { - border-style: dashed; -} - -.wish-editor__flex-presets { - margin-bottom: var(--sp-5); - padding: var(--sp-3) var(--sp-4); - background-color: var(--c-bg); - border-radius: var(--radius); -} - -.wish-editor__flex-presets-label { - font-size: var(--fs-sm); - font-weight: 500; - margin: 0 0 var(--sp-2) 0; -} - -.wish-editor__flex-presets-buttons { - display: flex; - flex-wrap: wrap; - gap: var(--sp-3); - margin-bottom: var(--sp-2); -} - -.wish-editor__flex-explainer { - font-size: var(--fs-sm); - color: var(--c-text-muted); - margin: 0; -} - -.wish-editor__empty { - color: var(--c-text-muted); - font-size: var(--fs-sm); -} - -.wish-editor__list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--sp-4); -} - -.wish-editor__card { - background-color: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius); - padding: var(--sp-4); - margin-bottom: 0; -} - -.wish-editor__row { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--sp-4); - margin-bottom: var(--sp-3); -} - -.wish-editor__name-input { - font-weight: 600; - font-size: var(--fs-lg); - border: none; - padding: var(--sp-2) 0; - max-width: 240px; -} - -.wish-editor__field { - display: flex; - flex-direction: column; - gap: var(--sp-1); - font-size: var(--fs-sm); - font-weight: 500; - color: var(--c-text); - margin-bottom: 0; -} - -.wish-editor__field input, -.wish-editor__field select { - width: auto; - min-width: 90px; -} - -.wish-editor__mode-toggle { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--sp-4); - border: none; - padding: 0; - margin: 0 0 var(--sp-2) 0; -} - -.wish-editor__mode-toggle-legend { - font-size: var(--fs-sm); - font-weight: 500; - padding: 0; -} - -.wish-editor__mode-option { - display: flex; - align-items: center; - gap: var(--sp-2); - font-size: var(--fs-sm); - font-weight: 400; - cursor: pointer; - margin-bottom: 0; -} - -.wish-editor__mode-explainer { - font-size: var(--fs-sm); - color: var(--c-text-muted); - margin: 0 0 var(--sp-3) 0; -} - -.wish-editor__bound-error { - color: var(--c-danger); - font-size: var(--fs-sm); - margin: 0 0 var(--sp-3) 0; -} - -.wish-editor__toggle-dates { - background: transparent; - color: var(--c-accent); - border: none; - padding: 0; - font-size: var(--fs-sm); - text-decoration: underline; - margin-bottom: var(--sp-3); -} - -.wish-editor__toggle-dates:hover { - background: transparent; -} - -.wish-editor__dates { - display: flex; - flex-wrap: wrap; - gap: var(--sp-4); - align-items: flex-end; - padding: var(--sp-4); - background-color: var(--c-bg); - border-radius: var(--radius); - margin-bottom: var(--sp-3); -} - -.wish-editor__date-pair { - display: flex; - gap: var(--sp-2); -} - -.wish-editor__checkbox-label { - display: flex; - align-items: center; - gap: var(--sp-2); - font-size: var(--fs-sm); - cursor: pointer; - margin-bottom: 0; -} - -.wish-editor__actions { - display: flex; - align-items: center; - gap: var(--sp-3); -} - -.wish-editor__wrap-note { - flex-basis: 100%; - font-size: var(--fs-sm); - color: var(--c-text-muted); - margin: 0; -} - -.wish-editor__save-status { - font-size: var(--fs-sm); -} - -.wish-editor__save-status--saving { - color: var(--c-text-muted); -} - -.wish-editor__save-status--saved { - color: var(--c-success); -} - -.wish-editor__save-status--error { - color: var(--c-danger); -} - -.wish-editor__retry-button { - color: var(--c-danger); - border-color: var(--c-danger); - font-size: var(--fs-sm); - padding: var(--sp-1) var(--sp-3); -} - -/* Plan results */ -.plan-results__preview-banner { - background-color: var(--c-accent-weak); - color: var(--c-accent); - border-left: 4px solid var(--c-accent); - border-radius: var(--radius); - padding: var(--sp-3) var(--sp-4); - font-size: var(--fs-sm); -} - -.plan-results__section { - margin-bottom: var(--sp-6); -} - -.plan-warnings { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--sp-3); -} - -.plan-warnings__item { - padding: var(--sp-3) var(--sp-4); - border-radius: var(--radius); - border-left: 4px solid var(--c-warning); - background-color: #fef8e7; -} - -.plan-warnings__item--days_lost_at_age_4 { - border-left-color: var(--c-danger); - background-color: var(--c-danger-weak); -} - -.plan-warnings__item--days_lost_at_age_4 strong { - color: var(--c-danger); -} - -.plan-warnings__item p { - margin: var(--sp-1) 0 0 0; -} - -.plan-overview-table { - width: 100%; - border-collapse: collapse; -} - -.plan-overview-table__year { - font-weight: 600; - white-space: nowrap; -} - -.plan-overview-table__badges { - display: flex; - flex-wrap: wrap; - gap: var(--sp-2); -} - -.plan-overview-badge { - display: inline-block; - padding: var(--sp-1) var(--sp-3); - border-radius: 999px; - font-size: var(--fs-sm); - white-space: nowrap; -} - -.plan-overview-badge--ok { - background-color: var(--c-success-weak); - color: var(--c-success); -} - -.plan-overview-badge--shortfall { - background-color: var(--c-danger-weak); - color: var(--c-danger); -} - -.plan-outcome-card { - background-color: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius); - padding: var(--sp-4); - margin-bottom: var(--sp-4); -} - -.plan-outcome-card__dates { - font-weight: 400; - font-size: var(--fs-sm); - color: var(--c-text-muted); -} - -.plan-outcome-card__weeks { - font-weight: 600; -} - -.plan-outcome-card__breakdown { - margin: 0 0 var(--sp-3) 0; -} - -.plan-outcome-card__strategy { - font-size: var(--fs-sm); - color: var(--c-text-muted); - margin-bottom: var(--sp-2); -} - -.plan-outcome-card__shortfall { - background-color: var(--c-danger-weak); - border-radius: var(--radius); - padding: var(--sp-3); -} - -.plan-outcome-card__shortfall p { - margin: 0; -} - -.plan-outcome-card__remedies { - margin: var(--sp-2) 0 0 0; -} - -.plan-outcome-card__ok { - color: var(--c-success); - font-weight: 600; - margin: 0; -} - -.plan-balances-table { - width: 100%; - border-collapse: collapse; - margin-bottom: var(--sp-5); -} - -/* §9.2 — surplus advice, the mirror of .plan-warnings / .plan-outcome-card__remedies */ -.plan-surplus-advice { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--sp-3); -} - -.plan-surplus-advice__item { - padding: var(--sp-3) var(--sp-4); - border-radius: var(--radius); - border-left: 4px solid var(--c-success); - background-color: var(--c-success-weak); -} - -.plan-surplus-advice__item strong { - color: var(--c-success); -} - -.plan-surplus-advice__item p { - margin: var(--sp-1) 0 0 0; -} - -/* §3.3 part 2 — plan-aware age-4 projection */ -.plan-age4-projections__intro { - color: var(--c-text-muted); - font-size: var(--fs-sm); - margin-bottom: var(--sp-3); -} - -.plan-age4-projections__expiring--nonzero { - color: var(--c-danger); - font-weight: 600; -} - -/* §3.2 — "assumed vacation year" badge on the propose-time balances table */ -.plan-vacation-balances__assumed-badge { - display: inline-block; - padding: var(--sp-1) var(--sp-2); - border-radius: 999px; - font-size: var(--fs-sm); - background-color: #fef8e7; - color: var(--c-warning); - cursor: help; -} - -/* Apply button and collision prompt */ -.plan-results__apply-section { - margin-bottom: var(--sp-6); -} - -.plan-results__apply-button { - background-color: var(--c-success); - color: var(--c-bg); - padding: var(--sp-3) var(--sp-5); - border: none; - border-radius: var(--radius); - font-weight: 600; - cursor: pointer; - font-size: var(--fs-base); -} - -.plan-results__apply-button:hover:not(:disabled) { - background-color: var(--c-success-dark, #0d7a3d); - opacity: 0.9; -} - -.plan-results__apply-button:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.plan-results__success-banner { - background-color: var(--c-success-weak); - color: var(--c-success); - border-left: 4px solid var(--c-success); - border-radius: var(--radius); - padding: var(--sp-3) var(--sp-4); - font-size: var(--fs-sm); -} - -.plan-results__collision-section { - background-color: var(--c-danger-weak); - border-radius: var(--radius); - padding: var(--sp-4); - margin-bottom: var(--sp-6); -} - -.plan-results__collision-section h2 { - color: var(--c-danger); - margin-top: 0; -} - -.plan-results__collision-section p { - margin-bottom: var(--sp-3); -} - -.plan-collisions-table { - width: 100%; - border-collapse: collapse; - margin-bottom: var(--sp-4); - background-color: var(--c-bg); -} - -.plan-collisions-table th, -.plan-collisions-table td { - padding: var(--sp-2) var(--sp-3); - text-align: left; - border-bottom: 1px solid var(--c-border); -} - -.plan-collisions-table th { - font-weight: 600; - background-color: var(--c-surface); -} - -.plan-collision-actions { - display: flex; - gap: var(--sp-3); - flex-wrap: wrap; -} - -.plan-collision-actions__confirm { - background-color: var(--c-danger); - color: var(--c-bg); - padding: var(--sp-2) var(--sp-4); - border: none; - border-radius: var(--radius); - font-weight: 600; - cursor: pointer; - font-size: var(--fs-sm); -} - -.plan-collision-actions__confirm:hover:not(:disabled) { - opacity: 0.9; -} - -.plan-collision-actions__confirm:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.plan-collision-actions__cancel { - background-color: var(--c-surface); - color: var(--c-text); - border: 1px solid var(--c-border); - padding: var(--sp-2) var(--sp-4); - border-radius: var(--radius); - font-weight: 600; - cursor: pointer; - font-size: var(--fs-sm); -} - -.plan-collision-actions__cancel:hover:not(:disabled) { - background-color: var(--c-bg); -} - -.plan-collision-actions__cancel:disabled { - opacity: 0.6; - cursor: not-allowed; -} diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 62f6161..4807cd7 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -177,6 +177,20 @@ 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__estimate-badge { + display: inline-block; + margin: var(--sp-3) 0; + padding: var(--sp-1) var(--sp-3); + border-radius: 999px; + font-size: var(--fs-sm); + font-weight: 600; + background-color: #fef8e7; + color: var(--c-warning); + cursor: help; +} + /* Age4 Bars (per-parent days left at the 4th birthday) */ .age4-bars { display: flex; diff --git a/frontend/src/utils/dates.ts b/frontend/src/utils/dates.ts index fa1e332..fe66840 100644 --- a/frontend/src/utils/dates.ts +++ b/frontend/src/utils/dates.ts @@ -1,16 +1,30 @@ /** - * Date formatting and calculation utilities for Swedish locale (sv-SE). - * All functions use ISO 8601 format for input (YYYY-MM-DD) and Intl API for display. + * Date formatting and calculation utilities. Most functions here are + * intentionally Swedish-only (weekday/month names, ISO week numbers) since + * they back Swedish-specific domain concepts (röd dag names, calendar week + * numbers) that don't have an English-locale equivalent in this app. + * `formatDateForDisplay` is the exception — it's used for plain "long date" + * captions that should follow whichever locale the UI is in, so it takes an + * explicit `Locale` (see `@/i18n`) rather than hardcoding `sv-SE`. All + * functions use ISO 8601 format for input (YYYY-MM-DD) and Intl API for + * display. */ +import type { Locale } from '@/i18n' + +const DISPLAY_INTL_LOCALE: Record = { + sv: 'sv-SE', + en: 'en-US', +} /** - * Format a date for display (e.g. "1 juni 2026" in Swedish). - * Accepts ISO string or Date object. + * Format a date for display (e.g. "1 juni 2026" in Swedish, "June 1, 2026" + * in English). Accepts ISO string or Date object. Defaults to Swedish for + * callers that haven't been updated to pass the active locale. */ -export function formatDateForDisplay(date: string | Date): string { +export function formatDateForDisplay(date: string | Date, locale: Locale = 'sv'): string { const d = typeof date === 'string' ? new Date(date + 'T00:00:00Z') : date if (!isValidDate(d)) return '' - const formatter = new Intl.DateTimeFormat('sv-SE', { + const formatter = new Intl.DateTimeFormat(DISPLAY_INTL_LOCALE[locale], { year: 'numeric', month: 'long', day: 'numeric', diff --git a/frontend/tests/unit/utils/dates.test.ts b/frontend/tests/unit/utils/dates.test.ts index 43d8e91..0b6732d 100644 --- a/frontend/tests/unit/utils/dates.test.ts +++ b/frontend/tests/unit/utils/dates.test.ts @@ -38,6 +38,24 @@ describe('dates.ts utilities', () => { const result = formatDateForDisplay('invalid') expect(result).toBe('') }) + + it('defaults to Swedish when no locale is passed', () => { + expect(formatDateForDisplay('2026-06-15')).toBe(formatDateForDisplay('2026-06-15', 'sv')) + }) + + it('formats in English when the "en" locale is passed', () => { + const result = formatDateForDisplay('2026-06-15', 'en') + expect(result).toBe('June 15, 2026') + }) + + it('formats in Swedish when the "sv" locale is explicitly passed', () => { + const result = formatDateForDisplay('2026-06-15', 'sv') + expect(result).toBe('15 juni 2026') + }) + + it('returns empty string for invalid date in the "en" locale too', () => { + expect(formatDateForDisplay('invalid', 'en')).toBe('') + }) }) describe('formatDateForInput', () => {