From 76b1e712b95a8d5c13d1362023f3313b5e4ed5a3 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 14 Sep 2026 02:33:03 +0200 Subject: [PATCH] Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes Works through every numbered item in NextSteps260913.md section 3, plus the three known verification issues in section 4.1. Surface what the backend already computed: - 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies - 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible - 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt) Calendar cluster (one pass over the holiday data): - new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides, paginated four years at a time - 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal - 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact - 3.7 click month/year to advance - 3.8 per-year red-days list in Settings, statutory days plus overrides i18n: - 3.5 five backend-generated Swedish surfaces converted to message_key + data, following the tips_service pattern; old string fields replaced, not kept Flexible wishes (3.9): - PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function, deterministic tie-break, two-pass ordering, test matrix rows 15-24 - backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator - frontend: season-preset toggle in WishEditor, unplaced outcome rendering Fixes (4.1): - pin typecheck to the local tsc rather than npx resolving a newer TypeScript - ignore generated coverage/ and dist/ in the ESLint flat config - fix the flaky usePlanApply test properly, with waitFor rather than a retry Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines. Not yet verified in a browser. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL --- .github/workflows/ci.yml | 2 +- ARCHITECTURE.md | 192 +- CALENDAR_MULTISELECT_HANDOVER.md | 216 -- CLAUDE.md | 36 +- DASHBOARD_EMPTY_DATA_TODO.md | 80 - JUNIOR_CALENDAR_MULTISELECT_UPDATE.md | 161 -- JUNIOR_FOLLOWUP.md | 65 - JUNIOR_FOLLOWUP_2.md | 77 - JUNIOR_MVP_TODO.md | 123 -- JUNIOR_ONBOARDING.md | 104 - JUNIOR_REPORT.md | 319 --- JUNIOR_REPORT_2.md | 417 ---- JUNIOR_REPORT_3.md | 297 --- JUNIOR_styling_plan.md | 153 -- NextSteps260913.md | 281 +++ PLANNED_DAYS_405_TODO.md | 82 - PLANNING_ENGINE_SPEC.md | 779 +++++++ SCAFFOLDING_HANDOFF_SENIOR.md | 91 - SCRAPER_ZERO_DAYS_FIX_PLAN.md | 167 -- SENIOR_FOLLOWUP.md | 46 - SENIOR_FOLLOWUP_2.md | 47 - SENIOR_MVP_TODO.md | 83 - SENIOR_REPORT.md | 117 -- SENIOR_REPORT_2.md | 152 -- SENIOR_REPORT_3.md | 146 -- backend/app/config.py | 12 + backend/app/data/swedish_calendar.json | 1496 +++++++++++++ backend/app/db/connection.py | 50 +- backend/app/dependencies.py | 89 +- backend/app/entities/break_period.py | 24 - backend/app/entities/child.py | 3 + backend/app/entities/day_override.py | 31 + backend/app/entities/leave_wish.py | 97 + backend/app/entities/personal_data.py | 6 + backend/app/entities/vacation_days.py | 2 +- backend/app/entities/workspace.py | 1 + backend/app/main.py | 11 +- backend/app/models/break_period.py | 53 - backend/app/models/calendar.py | 64 + backend/app/models/child.py | 4 + backend/app/models/day_override.py | 47 + backend/app/models/leave_wish.py | 118 ++ backend/app/models/personal_data.py | 6 + backend/app/models/plan.py | 316 +++ backend/app/models/workspace.py | 10 +- .../repositories/day_override_repository.py | 22 + ...repository.py => leave_wish_repository.py} | 10 +- .../sqlite/break_period_repository.py | 115 - .../sqlite/child_repository.py | 12 +- .../sqlite/day_override_repository.py | 109 + .../sqlite/leave_wish_repository.py | 171 ++ .../sqlite/personal_data_repository.py | 17 +- .../sqlite/workspace_repository.py | 18 +- backend/app/routers/auth.py | 10 +- backend/app/routers/break_periods.py | 83 - backend/app/routers/calendar.py | 34 + backend/app/routers/day_overrides.py | 58 + backend/app/routers/leave_wishes.py | 106 + backend/app/routers/plan.py | 55 + backend/app/routers/settings.py | 121 +- backend/app/routers/workspace.py | 4 +- backend/app/services/allocation_service.py | 1868 +++++++++++++++++ backend/app/services/analytics_service.py | 11 +- backend/app/services/break_period_service.py | 67 - backend/app/services/calendar_service.py | 143 ++ backend/app/services/child_service.py | 12 + backend/app/services/day_override_service.py | 73 + backend/app/services/leave_wish_service.py | 275 +++ backend/app/services/personal_data_service.py | 56 + backend/app/services/planning_service.py | 420 +++- backend/app/services/tips_service.py | 109 +- backend/app/services/workspace_service.py | 11 +- backend/app/utils/errors.py | 19 +- backend/app/utils/holidays.py | 177 +- backend/app/utils/tip_predicates.py | 18 +- .../002_planning_mode_and_preschool_days.py | 42 + .../003_rename_break_period_to_leave_wish.py | 134 ++ .../versions/004_add_day_override.py | 54 + ...005_add_personal_data_min_vacation_days.py | 44 + .../006_add_leave_wish_window_mode.py | 45 + backend/scripts/demo_plan.py | 345 +++ backend/scripts/fetch_swedish_calendar.py | 312 +++ backend/tests/conftest.py | 44 +- .../unit/test_break_period_repository.py | 356 ---- backend/tests/unit/test_calendar_router.py | 140 ++ backend/tests/unit/test_calendar_service.py | 183 ++ backend/tests/unit/test_child_service.py | 89 + .../unit/test_day_override_repository.py | 192 ++ backend/tests/unit/test_holidays.py | 124 ++ .../tests/unit/test_leave_wish_repository.py | 890 ++++++++ .../unit/test_personal_data_repository.py | 77 + backend/tests/unit/test_plan_router.py | 642 ++++++ backend/tests/unit/test_planning_service.py | 154 +- .../tests/unit/test_planning_service_apply.py | 291 +++ .../test_allocation_input_loader.py | 393 ++++ .../test_services/test_allocation_service.py | 1652 +++++++++++++++ .../test_allocation_service_branches.py | 694 ++++++ .../test_personal_data_service.py | 88 + .../unit/test_services/test_tips_service.py | 82 +- .../tests/unit/test_swedish_calendar_cache.py | 401 ++++ backend/tests/unit/test_workspace_service.py | 72 + data_refresh_plan.md | 268 --- frontend/eslint.config.js | 3 + frontend/package.json | 3 +- frontend/src/App.test.tsx | 141 ++ frontend/src/App.tsx | 25 +- .../components/calendar/RedDaysList.test.tsx | 132 ++ .../src/components/calendar/RedDaysList.tsx | 131 ++ .../components/common/CalendarDay.test.tsx | 77 +- .../src/components/common/CalendarDay.tsx | 65 +- .../components/common/CalendarEvent.test.tsx | 2 +- .../src/components/common/CalendarEvent.tsx | 4 +- .../components/common/CalendarWeek.test.tsx | 889 ++++++++ .../src/components/common/CalendarWeek.tsx | 16 +- .../dashboard/DaysBreakdown.test.tsx | 2 +- .../components/dashboard/DaysBreakdown.tsx | 9 +- .../dashboard/OverviewMetrics.test.tsx | 10 +- .../components/dashboard/OverviewMetrics.tsx | 9 +- .../dashboard/ParentalLeaveBreakdown.test.tsx | 16 +- .../dashboard/ParentalLeaveBreakdown.tsx | 41 +- .../dashboard/ProgressChart.test.tsx | 4 +- .../components/dashboard/ProgressChart.tsx | 10 +- .../inputs/BreakPeriodsInput.test.tsx | 116 - .../components/inputs/BreakPeriodsInput.tsx | 153 -- .../components/inputs/ChildBirthdayInput.tsx | 12 +- .../inputs/DayOverridesInput.test.tsx | 243 +++ .../components/inputs/DayOverridesInput.tsx | 174 ++ .../inputs/ParentProfileInput.test.tsx | 23 +- .../components/inputs/ParentProfileInput.tsx | 28 +- .../inputs/PlanningGoalSelector.test.tsx | 58 +- .../inputs/PlanningGoalSelector.tsx | 49 +- .../inputs/PlanningModeSelector.test.tsx | 47 + .../inputs/PlanningModeSelector.tsx | 48 + .../src/components/inputs/TipCard.test.tsx | 17 +- frontend/src/components/inputs/TipCard.tsx | 76 +- frontend/src/components/layout/Footer.tsx | 4 +- .../src/components/layout/Header.test.tsx | 1 + frontend/src/components/layout/Header.tsx | 24 +- .../src/components/layout/Layout.test.tsx | 131 ++ .../modals/ManualFetchModal.test.tsx | 149 ++ .../components/modals/ManualFetchModal.tsx | 38 +- .../modals/PlannedDayModal.test.tsx | 905 ++++++++ .../src/components/modals/PlannedDayModal.tsx | 42 +- .../components/modals/QRCodeModal.test.tsx | 8 +- .../src/components/modals/QRCodeModal.tsx | 24 +- .../components/planning/PlanResults.test.tsx | 431 ++++ .../src/components/planning/PlanResults.tsx | 638 ++++++ .../components/planning/WishEditor.test.tsx | 337 +++ .../src/components/planning/WishEditor.tsx | 596 ++++++ .../vacation-days/VacationDaysList.tsx | 11 +- .../vacation-days/VacationDaysTable.test.tsx | 77 + .../vacation-days/VacationDaysTable.tsx | 124 ++ frontend/src/context/AuthContext.test.tsx | 130 ++ .../src/context/WorkspaceContext.test.tsx | 273 +++ frontend/src/hooks/index.ts | 8 +- frontend/src/hooks/useBreakPeriods.test.ts | 102 - frontend/src/hooks/useBreakPeriods.ts | 67 - frontend/src/hooks/useCalendarDays.test.ts | 114 + frontend/src/hooks/useCalendarDays.ts | 48 + .../src/hooks/useCalendarPreferences.test.ts | 157 ++ frontend/src/hooks/useChildSettings.test.ts | 115 + frontend/src/hooks/useChildSettings.ts | 64 + frontend/src/hooks/useDayOverrides.test.ts | 96 + frontend/src/hooks/useDayOverrides.ts | 53 + frontend/src/hooks/useLeaveWishes.test.ts | 130 ++ frontend/src/hooks/useLeaveWishes.ts | 66 + frontend/src/hooks/usePlanApply.test.ts | 122 ++ frontend/src/hooks/usePlanApply.ts | 42 + frontend/src/hooks/usePlanProposal.test.ts | 66 + frontend/src/hooks/usePlanProposal.ts | 37 + frontend/src/hooks/usePlannedDays.test.ts | 287 +++ .../src/hooks/useWorkspaceMembers.test.ts | 51 + frontend/src/hooks/useWorkspaceMembers.ts | 37 + frontend/src/i18n/LanguageContext.test.tsx | 254 +++ frontend/src/i18n/LanguageContext.tsx | 186 ++ frontend/src/i18n/en.ts | 602 ++++++ frontend/src/i18n/index.ts | 11 + frontend/src/i18n/sv.ts | 602 ++++++ frontend/src/pages/Auth.test.tsx | 153 ++ frontend/src/pages/Auth.tsx | 16 +- frontend/src/pages/Calendar.test.tsx | 319 ++- frontend/src/pages/Calendar.tsx | 184 +- frontend/src/pages/Dashboard.test.tsx | 271 ++- frontend/src/pages/Dashboard.tsx | 68 +- frontend/src/pages/DevFetch.test.tsx | 559 +++++ frontend/src/pages/Plan.test.tsx | 712 +++++++ frontend/src/pages/Plan.tsx | 129 ++ frontend/src/pages/Settings.test.tsx | 415 ++++ frontend/src/pages/Settings.tsx | 405 ++-- frontend/src/pages/Tips.test.tsx | 86 + frontend/src/pages/Tips.tsx | 8 +- frontend/src/pages/calendar.css | 142 +- frontend/src/pages/plan.css | 509 +++++ frontend/src/services/api.test.ts | 1045 ++++++++- frontend/src/services/api.ts | 533 ++++- frontend/src/services/errors.test.ts | 47 + frontend/src/styles/components.css | 392 +++- frontend/src/styles/responsive.css | 11 +- frontend/src/types/api.ts | 78 + frontend/src/types/entities.ts | 164 +- frontend/src/utils/constants.test.ts | 56 + frontend/src/utils/constants.ts | 48 + frontend/src/utils/dates.ts | 27 + frontend/src/utils/leaveTypes.test.ts | 41 + frontend/src/utils/leaveTypes.ts | 20 +- frontend/tests/fixtures/mockData.ts | 2 + frontend/tests/setup.ts | 13 + frontend/vite.config.ts | 4 +- frontend/vitest.config.ts | 22 +- frontend_mvp_complete.md | 110 - frontend_polish.md | 71 - junior_calendar_fix.md | 117 -- junior_mvp_report.md | 55 - refine_login.md | 131 -- refine_login_report.md | 164 -- senior_mvp_report.md | 36 - senior_skaffolding_1.md | 206 -- todo_260912.txt | 5 + 218 files changed, 29867 insertions(+), 6085 deletions(-) delete mode 100644 CALENDAR_MULTISELECT_HANDOVER.md delete mode 100644 DASHBOARD_EMPTY_DATA_TODO.md delete mode 100644 JUNIOR_CALENDAR_MULTISELECT_UPDATE.md delete mode 100644 JUNIOR_FOLLOWUP.md delete mode 100644 JUNIOR_FOLLOWUP_2.md delete mode 100644 JUNIOR_MVP_TODO.md delete mode 100644 JUNIOR_ONBOARDING.md delete mode 100644 JUNIOR_REPORT.md delete mode 100644 JUNIOR_REPORT_2.md delete mode 100644 JUNIOR_REPORT_3.md delete mode 100644 JUNIOR_styling_plan.md create mode 100644 NextSteps260913.md delete mode 100644 PLANNED_DAYS_405_TODO.md create mode 100644 PLANNING_ENGINE_SPEC.md delete mode 100644 SCAFFOLDING_HANDOFF_SENIOR.md delete mode 100644 SCRAPER_ZERO_DAYS_FIX_PLAN.md delete mode 100644 SENIOR_FOLLOWUP.md delete mode 100644 SENIOR_FOLLOWUP_2.md delete mode 100644 SENIOR_MVP_TODO.md delete mode 100644 SENIOR_REPORT.md delete mode 100644 SENIOR_REPORT_2.md delete mode 100644 SENIOR_REPORT_3.md create mode 100644 backend/app/data/swedish_calendar.json delete mode 100644 backend/app/entities/break_period.py create mode 100644 backend/app/entities/day_override.py create mode 100644 backend/app/entities/leave_wish.py delete mode 100644 backend/app/models/break_period.py create mode 100644 backend/app/models/calendar.py create mode 100644 backend/app/models/day_override.py create mode 100644 backend/app/models/leave_wish.py create mode 100644 backend/app/models/plan.py create mode 100644 backend/app/repositories/day_override_repository.py rename backend/app/repositories/{break_period_repository.py => leave_wish_repository.py} (59%) delete mode 100644 backend/app/repositories_impl/sqlite/break_period_repository.py create mode 100644 backend/app/repositories_impl/sqlite/day_override_repository.py create mode 100644 backend/app/repositories_impl/sqlite/leave_wish_repository.py delete mode 100644 backend/app/routers/break_periods.py create mode 100644 backend/app/routers/calendar.py create mode 100644 backend/app/routers/day_overrides.py create mode 100644 backend/app/routers/leave_wishes.py create mode 100644 backend/app/routers/plan.py create mode 100644 backend/app/services/allocation_service.py delete mode 100644 backend/app/services/break_period_service.py create mode 100644 backend/app/services/calendar_service.py create mode 100644 backend/app/services/day_override_service.py create mode 100644 backend/app/services/leave_wish_service.py create mode 100644 backend/app/services/personal_data_service.py create mode 100644 backend/migrations/versions/002_planning_mode_and_preschool_days.py create mode 100644 backend/migrations/versions/003_rename_break_period_to_leave_wish.py create mode 100644 backend/migrations/versions/004_add_day_override.py create mode 100644 backend/migrations/versions/005_add_personal_data_min_vacation_days.py create mode 100644 backend/migrations/versions/006_add_leave_wish_window_mode.py create mode 100644 backend/scripts/demo_plan.py create mode 100755 backend/scripts/fetch_swedish_calendar.py delete mode 100644 backend/tests/unit/test_break_period_repository.py create mode 100644 backend/tests/unit/test_calendar_router.py create mode 100644 backend/tests/unit/test_calendar_service.py create mode 100644 backend/tests/unit/test_day_override_repository.py create mode 100644 backend/tests/unit/test_leave_wish_repository.py create mode 100644 backend/tests/unit/test_personal_data_repository.py create mode 100644 backend/tests/unit/test_plan_router.py create mode 100644 backend/tests/unit/test_planning_service_apply.py create mode 100644 backend/tests/unit/test_services/test_allocation_input_loader.py create mode 100644 backend/tests/unit/test_services/test_allocation_service.py create mode 100644 backend/tests/unit/test_services/test_allocation_service_branches.py create mode 100644 backend/tests/unit/test_services/test_personal_data_service.py create mode 100644 backend/tests/unit/test_swedish_calendar_cache.py create mode 100644 backend/tests/unit/test_workspace_service.py delete mode 100644 data_refresh_plan.md create mode 100644 frontend/src/App.test.tsx create mode 100644 frontend/src/components/calendar/RedDaysList.test.tsx create mode 100644 frontend/src/components/calendar/RedDaysList.tsx create mode 100644 frontend/src/components/common/CalendarWeek.test.tsx delete mode 100644 frontend/src/components/inputs/BreakPeriodsInput.test.tsx delete mode 100644 frontend/src/components/inputs/BreakPeriodsInput.tsx create mode 100644 frontend/src/components/inputs/DayOverridesInput.test.tsx create mode 100644 frontend/src/components/inputs/DayOverridesInput.tsx create mode 100644 frontend/src/components/inputs/PlanningModeSelector.test.tsx create mode 100644 frontend/src/components/inputs/PlanningModeSelector.tsx create mode 100644 frontend/src/components/layout/Layout.test.tsx create mode 100644 frontend/src/components/modals/ManualFetchModal.test.tsx create mode 100644 frontend/src/components/modals/PlannedDayModal.test.tsx create mode 100644 frontend/src/components/planning/PlanResults.test.tsx create mode 100644 frontend/src/components/planning/PlanResults.tsx create mode 100644 frontend/src/components/planning/WishEditor.test.tsx create mode 100644 frontend/src/components/planning/WishEditor.tsx create mode 100644 frontend/src/components/vacation-days/VacationDaysTable.test.tsx create mode 100644 frontend/src/components/vacation-days/VacationDaysTable.tsx create mode 100644 frontend/src/context/AuthContext.test.tsx create mode 100644 frontend/src/context/WorkspaceContext.test.tsx delete mode 100644 frontend/src/hooks/useBreakPeriods.test.ts delete mode 100644 frontend/src/hooks/useBreakPeriods.ts create mode 100644 frontend/src/hooks/useCalendarDays.test.ts create mode 100644 frontend/src/hooks/useCalendarDays.ts create mode 100644 frontend/src/hooks/useCalendarPreferences.test.ts create mode 100644 frontend/src/hooks/useChildSettings.test.ts create mode 100644 frontend/src/hooks/useChildSettings.ts create mode 100644 frontend/src/hooks/useDayOverrides.test.ts create mode 100644 frontend/src/hooks/useDayOverrides.ts create mode 100644 frontend/src/hooks/useLeaveWishes.test.ts create mode 100644 frontend/src/hooks/useLeaveWishes.ts create mode 100644 frontend/src/hooks/usePlanApply.test.ts create mode 100644 frontend/src/hooks/usePlanApply.ts create mode 100644 frontend/src/hooks/usePlanProposal.test.ts create mode 100644 frontend/src/hooks/usePlanProposal.ts create mode 100644 frontend/src/hooks/useWorkspaceMembers.test.ts create mode 100644 frontend/src/hooks/useWorkspaceMembers.ts create mode 100644 frontend/src/i18n/LanguageContext.test.tsx create mode 100644 frontend/src/i18n/LanguageContext.tsx create mode 100644 frontend/src/i18n/en.ts create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/sv.ts create mode 100644 frontend/src/pages/Auth.test.tsx create mode 100644 frontend/src/pages/DevFetch.test.tsx create mode 100644 frontend/src/pages/Plan.test.tsx create mode 100644 frontend/src/pages/Plan.tsx create mode 100644 frontend/src/pages/Settings.test.tsx create mode 100644 frontend/src/pages/Tips.test.tsx create mode 100644 frontend/src/pages/plan.css create mode 100644 frontend/src/services/errors.test.ts create mode 100644 frontend/src/utils/constants.test.ts create mode 100644 frontend/src/utils/constants.ts create mode 100644 frontend/src/utils/leaveTypes.test.ts delete mode 100644 frontend_mvp_complete.md delete mode 100644 frontend_polish.md delete mode 100644 junior_calendar_fix.md delete mode 100644 junior_mvp_report.md delete mode 100644 refine_login.md delete mode 100644 refine_login_report.md delete mode 100644 senior_mvp_report.md delete mode 100644 senior_skaffolding_1.md create mode 100644 todo_260912.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0123c8..4e63360 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: run: npm ci - name: Type check - run: npx tsc --noEmit + run: npm run typecheck - name: Lint run: npm run lint diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8f93316..a47db5d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,7 +1,7 @@ # Föräldraledigplannerare - Architecture & Implementation Plan -**Date:** 2026-05-31 -**Status:** Planning phase - reviewed, decisions incorporated (see §0) +**Date:** 2026-05-31 (updated 2026-09-13) +**Status:** Planning phase - reviewed, decisions incorporated (see §0). Planning engine drafted in **PLANNING_ENGINE_SPEC.md**. **Tech Stack:** TypeScript + React + Vite (frontend), FastAPI + Python 3.14 (backend), SQLite → Postgres --- @@ -67,8 +67,9 @@ backend/ │ │ ├── user.py │ │ ├── workspace.py │ │ ├── workspace_invitation.py -│ │ ├── break_period.py +│ │ ├── leave_wish.py # Recurring intent to be home (replaced BreakPeriod) │ │ ├── child.py +│ │ ├── day_override.py # Workspace calendar corrections │ │ ├── parental_leave.py │ │ ├── vacation_days.py │ │ └── planned_leave.py @@ -99,6 +100,7 @@ backend/ │ │ ├── workspace_service.py # incl. invitations │ │ ├── fetch_data_service.py # FK scraping + manual entry (stable contract) │ │ ├── planning_service.py # Calendar planning logic +│ │ ├── allocation_service.py # Pure planning-engine: Allocator + AllocationInputLoader │ │ ├── analytics_service.py # Metrics + days-left-at-age-4 calculation │ │ └── tips_service.py # Optimization tips / SGI rules engine │ ├── routers/ @@ -113,6 +115,8 @@ backend/ │ ├── scrapers/ │ │ ├── __init__.py │ │ └── fk_scraper.py # Försäkringskassan headless browser +│ ├── data/ +│ │ └── swedish_calendar.json # 10-year cache (2026–2035), generated by scripts/ │ ├── db/ │ │ ├── __init__.py │ │ ├── connection.py # DB factory (SQLite/Postgres) @@ -316,6 +320,9 @@ class Workspace: name: str # Drives which tips are emphasized (§6). Not a hard constraint. planning_goal: str = "balanced" # 'max_time' | 'max_money' | 'balanced' + # Explicit: whether to plan for one parent (solo) or two (couple). + # Not inferred from member count; set by the workspace creator. + planning_mode: str = "solo" # 'solo' | 'couple' created_at: datetime = None # backend/app/entities/workspace_invitation.py @@ -336,20 +343,39 @@ class WorkspaceInvitation: created_at: datetime accepted_at: datetime | None = None -# backend/app/entities/break_period.py +# backend/app/entities/leave_wish.py @dataclass -class BreakPeriod: - """A user-defined window the family wants to be home together for - (e.g. summer, winter holidays). Drives the parent-swap tip. Lengths and - dates are entirely family-specific; seeded with editable defaults.""" +class LeaveWish: + """A family's recurring intent to be home for N weeks inside a yearly window + (e.g. 'I want 6 weeks home each summer until the child turns 4'). Input to + the planning engine (see PLANNING_ENGINE_SPEC.md §1). Replaces BreakPeriod.""" id: UUID workspace_id: UUID - name: str # 'Summer', 'Winter holidays', ... - start_date: date - end_date: date - target_weeks: int # desired weeks home together for this break - created_at: datetime = None - updated_at: datetime = None + child_id: UUID | None # None = family-wide; set = tied to that child's pools + name: str # 'Sommar', 'Jullov', ... + + # Recurring window anchored by month/day ints (SQLite-friendly). If + # (end_month, end_day) < (start_month, start_day), wraps to next year + # (e.g. Jullov 12-20 → 01-07, spanning two vacation-year budgets). + window_start_month: int # 1-12 + window_start_day: int # 1-31 + window_end_month: int + window_end_day: int + + target_weeks: int # desired weeks home together during this window + recurrence: str # 'yearly' | 'once' + first_year: int # anchor year (e.g. 2027) + last_year: int | None # None = until child's 4th birthday (if child_id set) + + priority: int # 1 = highest; drives best-effort fill order + parent_mode: str # 'either' | 'both' | 'specific' + parent_personal_data_uuid: UUID | None # required when parent_mode == 'specific' + + # Per-window goal override (nullable, no UI in v1). + goal_override: str | None = None # 'max_time' | 'max_money' | 'balanced' | None + + created_at: datetime | None = None + updated_at: datetime | None = None # backend/app/entities/child.py @dataclass @@ -360,6 +386,9 @@ class Child: name: str birth_date: date # use due date if not yet born; planning can start pre-birth preschool_start_date: date | None = None # drives the preschool-planning-day reserve tip + # Per-child override for annual preschool planning-day ("studiedagar") closures. + # Defaults to LeaveRules.preschool_planning_days_per_year from config. + preschool_planning_days_per_year: int = 6 # typical Swedish preschool closures created_at: datetime = None # backend/app/entities/parental_leave.py @@ -460,6 +489,8 @@ CREATE TABLE workspace ( name TEXT NOT NULL, planning_goal TEXT NOT NULL DEFAULT 'balanced' CHECK (planning_goal IN ('max_time','max_money','balanced')), + planning_mode TEXT NOT NULL DEFAULT 'solo' + CHECK (planning_mode IN ('solo','couple')), created_at TIMESTAMP NOT NULL ); @@ -477,17 +508,29 @@ CREATE TABLE workspace_invitation ( FOREIGN KEY (invited_by_user_id) REFERENCES "user"(id) ON DELETE CASCADE ); --- break_period: user-defined windows to be home together (summer, winter, ...) -CREATE TABLE break_period ( +-- leave_wish: recurring family intent to be home (input to planning engine) +CREATE TABLE leave_wish ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + child_id TEXT, -- NULL = family-wide, else tied to child's pools name TEXT NOT NULL, - start_date DATE NOT NULL, - end_date DATE NOT NULL, + window_start_month INTEGER NOT NULL CHECK (window_start_month BETWEEN 1 AND 12), + window_start_day INTEGER NOT NULL CHECK (window_start_day BETWEEN 1 AND 31), + window_end_month INTEGER NOT NULL CHECK (window_end_month BETWEEN 1 AND 12), + window_end_day INTEGER NOT NULL CHECK (window_end_day BETWEEN 1 AND 31), target_weeks INTEGER NOT NULL, + recurrence TEXT NOT NULL CHECK (recurrence IN ('yearly', 'once')), + first_year INTEGER NOT NULL, + last_year INTEGER, -- NULL = until child's 4th birthday (if child_id set) + priority INTEGER NOT NULL, + parent_mode TEXT NOT NULL CHECK (parent_mode IN ('either', 'both', 'specific')), + parent_personal_data_uuid TEXT, -- required when parent_mode = 'specific' + goal_override TEXT, -- 'max_time' | 'max_money' | 'balanced' | NULL created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, - FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, + FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE, + FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE SET NULL ); -- child: One per child, multi-child ready @@ -497,6 +540,7 @@ CREATE TABLE child ( name TEXT NOT NULL, birth_date DATE NOT NULL, preschool_start_date DATE, + preschool_planning_days_per_year INTEGER NOT NULL DEFAULT 6, created_at TIMESTAMP NOT NULL, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE ); @@ -772,6 +816,9 @@ POST /api/vacation-days GET /api/vacation-days/{workspace_id}/{personal_data_uuid}/{year} PUT /api/vacation-days/{vacation_days_id} +# Calendar (Swedish holidays & workspace overrides) +GET /api/calendar/days # Calendar metadata (röda dagar, klämdagar, overrides) + # Analytics/Dashboard GET /api/analytics/{workspace_id}/overview GET /api/analytics/{workspace_id}/breakdown # Days by type/parent @@ -784,10 +831,14 @@ GET /api/tips/{workspace_id} PUT /api/settings/child/{child_id} # Set birth date + preschool start date PUT /api/settings/parent/{personal_data_uuid} # Set salary_type, employment_status PUT /api/settings/workspace/{workspace_id}/goal # Set planning_goal (max_time/max_money/balanced) -GET /api/break-periods/{workspace_id} # List configured break periods -POST /api/break-periods # Add a break period (summer/winter/...) -PUT /api/break-periods/{break_period_id} # Edit name/dates/target_weeks -DELETE /api/break-periods/{break_period_id} # Remove a break period + +# Leave Wishes (recurring planning windows; replaced BreakPeriod) +GET /api/leave-wishes/{workspace_id} # List configured leave wishes +POST /api/leave-wishes # Add a leave wish (summer/winter/...) +PUT /api/leave-wishes/{leave_wish_id} # Edit name/dates/priority/recurrence +DELETE /api/leave-wishes/{leave_wish_id} # Remove a leave wish + +# User DELETE /api/user/{personal_data_uuid} # Delete account + data ``` @@ -932,7 +983,7 @@ class OptimizationTips: - **Swedish public-holiday calendar (röda dagar).** The weekend-wrap and red-day tips need to know which dates are public holidays (and which weekday they fall on). Compute these (Easter is movable) via a library such as `holidays` (Sweden locale) or a vendored table, exposed through `utils/constants.py` / a `calendar_service`. Cache per year. This is also needed so the calendar UI can shade red days. - **Preschool planning-day buffer.** `PRESCHOOL_PLANNING_DAYS_PER_YEAR` defaults to 6 and should be user-overridable per child (preschools differ). The reserve tip compares planned-but-unallocated buffer days against this. - **Preschool start.** `child_in_preschool(plan)` needs an (estimated or entered) preschool start date on the `Child`; default assumption can be ~age 1–1.5 but let the user set it. Consider adding `preschool_start_date` to the `Child` entity. -- **Shared-break periods (user-configurable).** The parent-swap tip applies to *any* long break the family wants to be home together for — summer **and** winter holidays (and others). Each family defines these as named periods with their own target length, since the right amount is entirely family-specific (e.g. ~6 weeks summer, ~4 weeks winter). Model as a `BreakPeriod` list per workspace `{ name, start_date, end_date, target_weeks }` with seeded defaults the user can edit or delete. The tip references the matching period by `{period_name}`. +- **Leave Wishes (user-configurable).** The planning engine (see PLANNING_ENGINE_SPEC.md) takes recurring family intents to be home together — summer **and** winter holidays (and others) — as input. Model as a `LeaveWish` list per workspace, with recurring windows anchored by month/day ints, recurrence strategy, priority, and optional parent/child targeting. Each family defines these with their own target length and importance, since the right amount is entirely family-specific. See PLANNING_ENGINE_SPEC.md §2.1 for the entity; it replaced the single-year `BreakPeriod`. ### 6.3 Caveat on payroll rules @@ -985,6 +1036,7 @@ export interface Workspace { id: string; name: string; planningGoal: 'max_time' | 'max_money' | 'balanced'; + planningMode: 'solo' | 'couple'; createdAt: Date; } @@ -994,16 +1046,27 @@ export interface Child { name: string; birthDate: Date; preschoolStartDate: Date | null; + preschoolPlanningDaysPerYear: number; createdAt: Date; } -export interface BreakPeriod { +export interface LeaveWish { id: string; workspaceId: string; - name: string; // 'Summer', 'Winter holidays', ... - startDate: Date; - endDate: Date; + childId: string | null; // null = family-wide + name: string; // 'Sommar', 'Jullov', ... + windowStartMonth: number; // 1-12 + windowStartDay: number; // 1-31 + windowEndMonth: number; + windowEndDay: number; targetWeeks: number; + recurrence: 'yearly' | 'once'; + firstYear: number; + lastYear: number | null; + priority: number; // 1 = highest + parentMode: 'either' | 'both' | 'specific'; + parentPersonalDataUuid: string | null; + goalOverride: 'max_time' | 'max_money' | 'balanced' | null; createdAt: Date; updatedAt: Date; } @@ -1351,6 +1414,79 @@ All code must adhere to: --- +## 13. Planning Engine & Supporting Infrastructure + +### 13.1 Swedish Calendar Cache + +**File:** `backend/app/data/swedish_calendar.json` +**Generated by:** `backend/scripts/fetch_swedish_calendar.py` (Dagsmart API) +**Scope:** 10-year cache (2026–2035), validated against Python `holidays` library + +The planner never makes API calls at runtime — it uses a committed calendar cache to ensure planning is offline and deterministic. The cache carries three categories of days: +- **public**: röda dagar (New Year, Easter, Midsummer, Christmas, etc.) +- **de_facto**: days off in practice (Christmas Eve, New Year's Eve, Midsummer Eve, Easter Eve, Whit Eve) +- **bridge_day** (klämdag): cheap vacation-buys when a red day falls mid-week + +**Trap:** The Python `holidays` library combines holiday names with weekdays. Filtering by `"Söndag" in name` deletes holidays that fall on Sunday (Påskdagen, Pingstdagen, Nationaldagen, Annandag jul). Always match the exact string `"Söndag"` — this is documented in `app/utils/holidays.py` and tested in `test_holidays.py`. + +### 13.2 Planning Engine (Allocation Service) + +**Files:** +- `backend/app/services/allocation_service.py` — the allocator and input loader +- `PLANNING_ENGINE_SPEC.md` — full design specification + +**Architecture:** Strictly split into two parts: + +1. **`AllocationInputLoader`** (async) — the ONLY part that touches repositories or the wall clock. Assembles a frozen `AllocationInput` from database state. + +2. **`Allocator`** (pure) — no `await`, no repository access, no `date.today()`. Given the same `AllocationInput`, always produces identical output. This is the engine's heart: it turns `LeaveWish` intents into `ProposedDay` rows, choosing between föräldrapenning tiers and vacation based on the workspace `planning_goal` and parent `salary_type`, modeling parent-swap, dubbeldagar, and preschool day reserves along the way. + +The separation is critical: the pure allocator is cheap, re-runnable on every wish edit, and trivially unit-testable without a database. For the full design (window expansion, tier selection, shortfall remedies, warnings), see **PLANNING_ENGINE_SPEC.md**. + +### 13.3 Calendar API Endpoint + +**Endpoint:** `GET /api/calendar/days` +**Files:** +- `backend/app/routers/calendar.py` — thin HTTP handler +- `backend/app/services/calendar_service.py` — business logic +- `backend/app/models/calendar.py` — Pydantic response schema + +**Purpose:** Expose Swedish calendar metadata (röda dagar, klämdagar, weekends, workspace overrides) to two UI surfaces: calendar shading (§3.4) and the per-year red-days list in Settings (§3.8). No runtime external API calls — everything reads the pre-built `swedish_calendar.json` cache (§13.1) or falls back to the local `holidays` library. + +**Request (Query Parameters):** +- `workspace_id: UUID` (required) — fetch overrides for this workspace +- `start_year: int` (required) — first year to include; must fall within the cached range (2026–2035 currently, else 422) +- `years: int` (optional, default 4) — how many years to return; must be 1–4 (capped per product decision at `MAX_WINDOW_YEARS = 4` even though the cache holds 10) + +**Response (`CalendarDaysResponse`):** +- `start_year: int` — echoes request +- `end_year: int` — computed as `start_year + years - 1` +- `min_year: int | null` — first year in the cache; null if cache file missing +- `max_year: int | null` — last year in the cache; null if cache file missing (UI uses these to bound pagination controls) +- `days: array[CalendarDay]` — one object per day in the window: + - `date: Date` + - `name: string | null` — exact holiday name (e.g. `"Påskdagen"`, `"Midsommardagen"`); **null unless `day_type == "red_day"`**. Combined names like `"Påskdagen; Söndag"` are preserved exactly as the `holidays` library returns them (see §13.3 caveat below). + - `day_type: "red_day" | "klamdag" | "weekend" | "working_day"` — statutory classification (before overrides) + - `is_override: bool` — true iff this day has a workspace override + - `override_is_working_day: bool | null` — if `is_override`, the override value (true = marked as working day, false = marked as free day); null if no override + - `is_free: bool` — **effective** free-day status after applying workspace overrides via `DayOverrideService` + +**Design Decisions:** + +1. **Single source of truth for "free days":** The endpoint reuses `is_free_day()` from `allocation_service.py` (used by the planning engine itself) rather than implementing a parallel definition. This ensures the calendar UI and the planner agree on what days count as free. + +2. **Overrides via DayOverrideService:** Workspace day overrides (e.g. marking Valborgsmässoafton as free for a specific family) are merged in via the existing `DayOverrideService`. No schema change or migration was needed — overrides live in the same table as other workspace settings. + +3. **Pagination cap at four years:** Even though the cache spans 10 years (2026–2035), clients may request only 1–4 years per request (`MAX_WINDOW_YEARS = 4`). This is a product-owner decision to keep single requests bounded; UI should use pagination controls to browse the full decade. + +4. **No runtime API calls:** The Swedish calendar is fetched at **build time** by `backend/scripts/fetch_swedish_calendar.py` (Dagsmart API) and committed to `backend/app/data/swedish_calendar.json`. This ensures planning stays offline and deterministic — no external dependencies at runtime. + +5. **Combined-name trap (critical):** The Python `holidays` library combines holiday names with weekdays when a holiday falls on a particular day. For example, Påskdagen (Easter Sunday) is returned as `"Påskdagen; Söndag"` (one string). **Filtering by substring** (e.g. `if "Söndag" in name`) would silently delete Påskdagen, Pingstdagen, Nationaldagen, and Annandag jul when they fall on Sunday. Always use **exact-string matching** on `name`. This is documented and tested in `app/utils/holidays.py`. + +6. **Valborgsmässoafton (30 April) is deliberately NOT a default day off:** Swedish custom treats this as a partial day off (evening bonfires), but statutory leave does not include it. Families who get it off or want to treat it as free should add a workspace day override. + +--- + ## Next Steps 1. **Review this plan** with another model diff --git a/CALENDAR_MULTISELECT_HANDOVER.md b/CALENDAR_MULTISELECT_HANDOVER.md deleted file mode 100644 index 2d8bddb..0000000 --- a/CALENDAR_MULTISELECT_HANDOVER.md +++ /dev/null @@ -1,216 +0,0 @@ -# Handover — Calendar multi-select, spanning bars & week numbers - -**Audience:** junior frontend developer picking up this feature. -**Area:** `frontend/` only (TypeScript + React + Vite). No backend changes are required. -**Status:** spec / not started. No production code has been written yet — this document is the brief. - -Read `AGENTS.md` at the repo root first. The guardrails there (no `any`, no `.js`/`.jsx`, components don't call `fetch` directly, ESLint must pass with **zero** warnings, frontend coverage gate is **90%**) all apply to this work. - ---- - -## 1. What we're building - -Five related improvements to the planning calendar (`/calendar`): - -1. **Drag to select multiple days**, then apply a leave type to all of them at once. -2. **Remember the last-used leave type** so entering several days in a row is faster (session-only cache is fine). -3. **"Dubbeldag" rules + clearer display:** a double day is only valid when the leave type is *not* vacation, and the fact that a day is a dubbeldag must be visible in the calendar (today it's invisible). -4. **Replace the colored dots with labeled bars** that can span multiple days and say *what* the leave is and *which parent* is taking it (Google/Apple-Calendar style — see the screenshot the product owner attached). -5. **Settings toggle "Visa veckonummer"** to show ISO week numbers in the calendar (Swedes plan in week numbers). - -Plus a small CSS polish pass on the **Legend** (it currently renders with overlapping text — see screenshot). - ---- - -## 2. Decisions already made (do not re-litigate) - -These were confirmed with the product owner — build to them: - -| Question | Decision | -|---|---| -| Bar rendering style | **True spanning bars** (continuous bar across the days, label shown once) — not a repeated chip per cell. | -| Applying a type to a dragged range that contains existing entries | **Overwrite** every day in the range for the current parent. | -| Whose name shows on a bar | **The logged-in parent's name only.** The app currently only knows the current parent (`parentName` / `personalDataUuid` from `WorkspaceContext`). Showing/where-to-switch the *other* parent is an explicit **future** follow-up — don't block on it. A generic `"Partner"` fallback for any other UUID is acceptable. | -| Where the week-number toggle lives | **Settings page**, persisted client-side (localStorage). It's a pure display preference; no backend/migration needed. | - -> ⚠️ The attached screenshot is an **Apple Calendar** screenshot used only as a *visual reference for the bar style* (continuous, labeled, rounded bars, one per row). The labels in it ("Gotland", "Pappa reser", etc.) are that person's private events — do **not** try to reproduce those texts. Our bar labels come from the leave type + parent (see §5.D). - ---- - -## 3. Orientation — the code as it is today - -All paths relative to `frontend/`. - -**Page** -- `src/pages/Calendar.tsx` — builds the month grid as a flat list of `weeks: string[][]` (each entry an ISO date string) and renders one `` per date in a single CSS grid (`.calendar__grid`, 7 columns). It owns `selectedDate` + modal state and the single-day save/update/delete handlers. Note the local `getColorForType()` at the bottom — you'll want to share this (see §5). -- `src/pages/calendar.css` — all calendar styling, including the dots (`.calendar-day__event`, 8×8 circles) and the `.calendar__legend*` rules that need the CSS polish. - -**Components** -- `src/components/common/CalendarDay.tsx` — one day cell. Props: `date`, `events`, `isCurrentMonth`, `onClick`, `isToday`. Renders the day number and, if `events` are passed, the colored dots. Handles click + Enter/Space → `onClick`. Has tests in `CalendarDay.test.tsx`. -- `src/components/common/CalendarEvent.tsx` — the small colored chip currently used **only in the legend**. Props: `leaveType`, `color`, `title`, `onDismiss`. Has tests in `CalendarEvent.test.tsx`. -- `src/components/modals/PlannedDayModal.tsx` — the create/edit/delete modal for a single day. Already has a `dayType` select (vacation / sjukpenning / lagsta), an optional parent select (only shown when `parents.length > 1`), and an `isDubbeldag` checkbox. `onSave` payload is `{ dayType, parentId, isDubbeldag }`. - -**Data layer (don't bypass it)** -- `src/hooks/usePlannedDays.ts` — `{ data, isLoading, error, refresh, add, update, remove }`. `add`/`update`/`remove` already call the API and then `refresh()`. -- `src/services/api.ts` — `getPlannedDays`, `createPlannedDay`, `updatePlannedDay`, `deletePlannedDay`. **Payloads are snake_case** (`CreatePlannedDayPayload`: `workspace_id`, `child_id`, `parent_personal_data_uuid`, `date`, `day_type`, `is_dubbeldag`; `UpdatePlannedDayPayload`: `day_type?`, `is_dubbeldag?`). Responses are mapped to camelCase `PlannedLeaveDay` by `toPlannedLeaveDay`. -- `src/types/entities.ts` → `PlannedLeaveDay`: `{ id, workspaceId, childId, parentPersonalDataUuid, date, dayType, isDubbeldag, createdAt, updatedAt }`. `dayType` is `'sjukpenning' | 'lagsta' | 'vacation'`. - -**Context** -- `src/context/WorkspaceContext.tsx` (`useWorkspace`) exposes `personalDataUuid` and `parentName` for the *current* parent only. There is **no** partner name available yet. - -**Settings** -- `src/pages/Settings.tsx` — sectioned page. Most settings save to the backend via `api.*`. The week-number toggle is the exception: keep it **client-side** (localStorage). -- `src/hooks/index.ts` — barrel; export any new hook here so imports stay `@/hooks`. -- `src/utils/dates.ts` — date helpers (`formatDateForInput`, `parseInputDate`, `isValidDate`, etc.). Add the ISO-week + range helpers here. - ---- - -## 4. Suggested file plan - -| File | Action | Why | -|---|---|---| -| `src/utils/leaveTypes.ts` | **new** | Single source of truth for `getColorForType` + a label per type. Removes the duplicated color map from `Calendar.tsx`. | -| `src/utils/dates.ts` | edit | Add `getISOWeekNumber()` and `datesInRange(a, b)`. | -| `src/hooks/useCalendarPreferences.ts` | **new** | `useShowWeekNumbers()` (localStorage, reactive) + `getLastLeaveType()/setLastLeaveType()` (sessionStorage). Export via `hooks/index.ts`. | -| `src/components/common/CalendarWeek.tsx` | **new** | Renders one week row: optional week-number gutter, 7 `CalendarDay` cells, and the spanning bar overlay (run-merge + lane layout). | -| `src/components/common/CalendarDay.tsx` | edit | Add selection (`isSelected`) and pointer handlers for drag; keep `onClick` working for back-compat/keyboard. Stop relying on dots in the new layout (pass no `events`). | -| `src/components/common/CalendarEvent.tsx` | edit | Add an optional dubbeldag badge + parent label; keep existing props/tests passing. | -| `src/components/modals/PlannedDayModal.tsx` | edit | Dubbeldag rule vs vacation; optional range mode (label + count, no delete) and a `defaultDayType` seed. | -| `src/pages/Calendar.tsx` | edit | Drag state + handlers, render via `CalendarWeek`, bulk apply, last-type cache, read week-number pref. | -| `src/pages/calendar.css` | edit | Bars, dubbeldag styling, selection highlight, week-number gutter, legend polish. | -| `src/pages/Settings.tsx` | edit | New "Kalender" section with the "Visa veckonummer" checkbox. | -| Tests | new/edit | See §6. | - -Don't forget: after code changes run `graphify update .` (AGENTS.md). - ---- - -## 5. Feature-by-feature guidance - -### A. Drag to select multiple days + bulk apply - -Drive selection from **pointer events** on the day cells, not `onClick`, so a drag and a click don't both fire. - -State to add in `Calendar.tsx`: -- `dragAnchor: string | null`, `dragCurrent: string | null`, `isDragging: boolean`. - -Handlers passed down to each `CalendarDay`: -- `onPointerDownDay(date)` → set `dragAnchor = dragCurrent = date`, `isDragging = true`. -- `onPointerEnterDay(date)` → if `isDragging`, set `dragCurrent = date`. -- A `pointerup` listener on `window` (in a `useEffect`) finalizes the selection. - -On pointer-up, compute the selected dates with `datesInRange(dragAnchor, dragCurrent)` (inclusive, ascending, order-independent), then: -- **1 day** → open the existing single-day flow (`PlannedDayModal` with edit/delete). -- **>1 day** → open the modal in **range mode** (§5.C/§D below): no `existingDay`, no delete, header like "Plan N days". - -Keep the selected dates highlighted (`isSelected`) until the modal closes. - -Bulk save = loop over the selected dates and call `add`/`update` per day. **Overwrite semantics** (decision in §2): for each date, if the current parent already has an entry, `update` it; otherwise `add`. There is **no** bulk endpoint — a client-side loop is fine for now (note the N-requests trade-off in your PR; a `/planned-days/bulk` endpoint is a reasonable future optimization but out of scope). After the loop, `usePlannedDays` already refreshes on each call; consider whether to refresh once at the end instead to avoid flless re-fetches (acceptable either way for MVP). - -Accessibility: keyboard users can't drag. Keep single-day open via keyboard (Enter/Space on a focused cell). Add an `onActivate` prop used by the key handler if you stop passing `onClick` in the new layout, so `CalendarDay`'s existing tests keep passing. - -### B. Remember the last-used leave type - -In `useCalendarPreferences.ts`, back this with **sessionStorage** (per the product ask, session-only is fine): -- `getLastLeaveType(): 'sjukpenning' | 'lagsta' | 'vacation' | null` (validate the stored value). -- `setLastLeaveType(type)`. - -Wire-up: when the modal opens for a **new** day (no `existingDay`), seed its `dayType` from `getLastLeaveType()` (fall back to `'vacation'`). On a successful save, call `setLastLeaveType(savedType)`. Don't override the type when *editing* an existing day — that should reflect the stored entry. - -### C. Dubbeldag only valid when type ≠ vacation - -In `PlannedDayModal.tsx`: -- When `dayType === 'vacation'`, force `isDubbeldag = false`, **disable** the checkbox, and show a short hint (e.g. "Dubbeldagar gäller inte för semester"). Use a `useEffect` on `dayType` so switching *to* vacation clears a previously-checked box. -- Domain note from `AGENTS.md`: a dubbeldag consumes *each* parent's balance and only applies early in the child's life — we are **not** enforcing the early-life window here, just the "not with vacation" rule. Keep it to that. - -### D. Spanning, labeled bars (the big one) - -Replace the dots. Render bars **per week row**, so introduce `CalendarWeek`. - -**Why per-week:** a bar can only span contiguous columns *within* a single week row; a stretch that crosses a week boundary becomes two bars (one ending Sunday, one starting Monday) — same as Google/Apple Calendar. - -**Building the bars for one week (7 ISO dates):** -1. For each day, get its planned entries (filter `plannedDays` by `date`). -2. Group entries into "series" by a stable key: `` `${parentPersonalDataUuid}|${dayType}|${isDubbeldag}` ``. -3. For each series, scan the 7 days left→right and merge **consecutive** days that contain that series into a bar `{ startCol, endCol, parentUuid, dayType, isDubbeldag }`. -4. **Lane assignment (so overlapping bars stack instead of colliding):** sort bars by `startCol` asc then width desc; greedily place each bar in the first lane (row) whose columns `[startCol..endCol]` are free. - -**Rendering:** give `CalendarWeek` a `position: relative` container with two layers sharing the same 7-column track and **`column-gap: 0`** (so bars are visually continuous across day boundaries — use cell borders, not grid gaps, for separation): -- the day layer (7 `CalendarDay` cells), and -- an absolutely-positioned bar overlay (`pointer-events: none` so clicks fall through to the day cell beneath, which opens the edit modal). Position each bar with `grid-column: ${startCol + 1} / ${endCol + 2}` and `grid-row: ${lane + 1}`. - -Make the week's min-height grow with the lane count (e.g. `numberHeight + lanes * barHeight + padding`) so tall weeks don't clip. Many-lane weeks are an edge case — clipping with a small "+N more" affordance is an acceptable v1 if you run low on time; note it if so. - -**Bar contents:** `"{parentLabel} · {typeLabel}"`, where `typeLabel` comes from `leaveTypes.ts` and `parentLabel` is `parentName` (or "Du") for `personalDataUuid`, else `"Partner"`. If `isDubbeldag`, render a small **"2×"** badge on the bar (this is the "show dubbeldag better" requirement). You can reuse `CalendarEvent` as the bar's inner visual by adding optional `isDubbeldag` + `parentLabel` props. - -### E. Legend CSS polish - -The screenshot shows the legend swatches overlapping their labels ("VacaSiok Parental"). In `calendar.css`, the `.calendar__legend-item` currently relies on a `CalendarEvent` shrunk with `!important`. Rebuild each legend item as a clean **swatch + label** row (small square/rounded swatch, gap, readable label), aligned in a wrapping flex row, with a real section heading. Keep the three types: Vacation / Sick / Parental, using the shared colors. Add a swatch variant indicating a dubbeldag (the "2×" badge) so the legend explains it. - -### F. "Visa veckonummer" setting + week-number gutter - -- `useShowWeekNumbers()` in `useCalendarPreferences.ts`: localStorage-backed boolean, **reactive within the tab** (dispatch a custom event on set and listen for it + the native `storage` event), so toggling in Settings updates an open Calendar. -- Settings: add a "Kalender" section with a checkbox bound to the hook. Swedish label **"Visa veckonummer"**. -- Calendar: when enabled, render a left **gutter** in each `CalendarWeek` showing `getISOWeekNumber(firstDateOfWeek)` (e.g. "v.27"). Add a matching empty/heading cell above the weekday header row so columns stay aligned. -- `getISOWeekNumber`: ISO-8601, Monday-first, week 1 = the week containing the year's first Thursday. (Pseudocode: shift the date to its Thursday, then count weeks from the first Thursday of that year. Test the known tricky boundaries — e.g. 2026-01-01 is week 1, 2025-12-29 is week 1 of 2026.) - ---- - -## 6. Tests & CI gates (must pass before PR) - -From `frontend/`: - -```bash -npx tsc --noEmit # no type errors (no `any`, no ts-ignore) -npm run lint # ESLint, zero warnings -npm run coverage # Vitest, 90% coverage gate -``` - -Test additions/updates to plan for: -- **`utils/dates`** — `getISOWeekNumber` (incl. year boundaries) and `datesInRange` (forward, reversed, single day, invalid input). -- **`utils/leaveTypes`** — color + label lookups, unknown fallback. -- **`useCalendarPreferences`** — persists + reads week-number flag; reactive update; last-leave-type get/set + invalid value rejection. (jsdom provides `localStorage`/`sessionStorage`.) -- **`CalendarWeek`** — run-merging (consecutive same-series days become one bar; a gap splits them), lane stacking for overlapping parents, week-number gutter shown/hidden, dubbeldag badge present. -- **`CalendarDay`** — selection class + pointer handlers; keep the existing dot/`onClick`/keyboard tests green. -- **`CalendarEvent`** — new dubbeldag badge / parent label; keep existing tests green. -- **`PlannedDayModal`** — dubbeldag disabled + forced false on vacation; range-mode header/no-delete; default type seeding. -- **`Calendar`** — drag across cells builds a range and opens range mode; single click/Enter opens single-day; bulk apply calls `add`/`update` per day with overwrite. - -Existing tests you must not break: `Calendar.test.tsx`, `CalendarDay.test.tsx`, `CalendarEvent.test.tsx`. If you change a component's contract, update its test *properly* (never delete a failing test to go green — see AGENTS.md). - ---- - -## 7. Edge cases & gotchas - -- **Click vs drag double-fire:** the #1 trap. Don't keep both `onClick` and pointer-based selection firing the open. Decide which path opens the modal and stub the other. -- **Range crossing week boundary:** must split into two bars (handled naturally by per-week rendering). -- **Same day, both parents:** two series on one day → two stacked lanes. Make lane assignment deterministic (stable key sort) so bars don't jump around between renders. -- **Days outside the current month** (`isCurrentMonth === false`): keep them non-interactive for selection, as today. -- **Overwrite in range** can fire many requests; if the UX feels janky, batch the `refresh()` to the end of the loop. -- **Continuous bars need `column-gap: 0`** on the grid; reintroduce visual separation with cell borders so the bars don't get chopped by the gap. -- **Week-number reactivity:** the native `storage` event only fires in *other* tabs — use a custom in-tab event so Settings ↔ Calendar sync live. -- **Don't bypass layers:** go through `usePlannedDays` / `api.ts`; remember API payloads are **snake_case**. - ---- - -## 8. Known limitations / future follow-ups (call out in your PR) - -- **Partner identity:** bars label non-current parents as "Partner" because the app only knows the current parent. A future ticket should expose partner name(s) and let the user pick *which* parent a day/range is for in the modal. -- **No bulk API:** range apply loops client-side. A `/planned-days/bulk` endpoint would cut N requests to 1. -- **Many overlapping lanes** may need a "+N more" overflow design if real usage stacks lots of same-day entries. -- **Dubbeldag window not enforced** (only the "not with vacation" rule) — encoding the early-life day limit is a separate domain task; per AGENTS.md, confirm the exact rule/number before hardcoding it. - ---- - -## 9. Definition of done - -- [ ] Drag across days selects a range; releasing opens a bulk-apply modal; applying overwrites every day in the range for the current parent. -- [ ] Single click / Enter still opens the single-day edit/delete modal. -- [ ] New-day modal pre-selects the last-used leave type (session); editing shows the stored type. -- [ ] Dubbeldag checkbox is disabled + cleared when type is vacation; dubbeldag is clearly shown on the calendar (2× badge) and explained in the legend. -- [ ] Dots replaced by continuous, labeled bars showing leave type + parent, spanning consecutive days and splitting at week boundaries. -- [ ] Legend is clean and readable (no overlapping text). -- [ ] Settings has a "Visa veckonummer" toggle; when on, the calendar shows ISO week numbers, and toggling updates an open calendar live. -- [ ] `tsc --noEmit`, `npm run lint` (zero warnings), and `npm run coverage` (≥90%) all pass; existing calendar tests still green. -- [ ] `graphify update .` run after changes. -``` diff --git a/CLAUDE.md b/CLAUDE.md index bec6c40..31c4382 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ uv run ruff format --check . # format check uv run pytest # tests with coverage (config in pyproject.toml) ``` -CI gate: `--cov-fail-under=50` today, climbing toward **90** as tests land. Don't lower it. +CI gate: `--cov-fail-under=50` today (current total ~52%), climbing toward **90** as tests land. Don't lower it — only raise when coverage grows. **Frontend** (from `frontend/`): @@ -135,7 +135,39 @@ npm run lint # ESLint, zero warnings npm run coverage # Vitest with coverage ``` -CI gate: **90%** coverage on the frontend. +CI gates (measured baseline after `src/pages/**` inclusion): +- lines: **80%** | functions: **85%** | branches: **85%** | statements: **80%** +- These act as a regression ratchet, not a target. Current state (82.36% lines, 88.5% functions) shows pages are now visible and poorly covered (Auth, Tips, DevFetch at 0%); the intent is to raise thresholds over time, not to churn tests today. + +### WHO RUNS THE VERIFICATION COMMANDS (cost rule) + +Running the checks above is mechanical work: the commands are fixed and the only judgement +needed is reading pass/fail and the failure output. Paying a large model to sit through +`pytest`, `vitest`, `tsc`, `ruff` or a coverage run is waste. + +**Rule, by who you are:** + +* **Haiku subagent** — run the commands yourself, immediately. No delegation. You are already the + cheap tier; spawning another agent just adds latency. +* **Sonnet (or any larger model) subagent** — do **not** run the verification commands yourself. + Finish the code change, then spawn a **Haiku** subagent to run them and report back the raw + output. Act on what it reports. +* **The orchestrating/reviewing agent** — same rule: delegate verification runs to Haiku rather + than burning a large model on them. + +This applies to the full-suite verification runs: `uv run pytest`, `uv run ruff check .`, +`uv run ruff format --check .`, `npx tsc --noEmit`, `npm run lint`, `npm run coverage`, +`npx vitest run`, and `uv run alembic upgrade head`. + +It does **not** apply to quick diagnostics while you are mid-task — reading a single file, a +`git grep`, or running one focused test to debug what you are actively writing. Delegating those +costs more than it saves. The rule targets the long, whole-suite verification passes. + +**What the Haiku verifier must be told:** the exact commands, the directory to run them from, the +expected baseline (e.g. "currently 361 passed / 0 failed — must not regress"), and an instruction +to paste the **raw output verbatim**, including failures, rather than summarising. A verifier that +reports "all green" without the output is useless — several agents have reported passing gates +that were in fact failing. --- diff --git a/DASHBOARD_EMPTY_DATA_TODO.md b/DASHBOARD_EMPTY_DATA_TODO.md deleted file mode 100644 index 94ccb9a..0000000 --- a/DASHBOARD_EMPTY_DATA_TODO.md +++ /dev/null @@ -1,80 +0,0 @@ -# TODO — Dashboard/Calendar show no data (snake_case ↔ camelCase wire mismatch) - -**Status:** open · **Area:** frontend · **Owner:** _unassigned_ · **Filed:** 2026-06-14 - -## Symptom - -The Dashboard renders but the "Översikt" metrics, the parental-leave breakdown, and the -planned-days breakdown are all empty/zero, even though `parental_leave_fetch` has valid -rows and the API returns data. - -## Two things this is NOT - -- **Not a "latest scrape" problem.** The backend already selects the newest snapshot: - `get_latest_by_parent` (and `AnalyticsService`) use `ORDER BY fetched_at DESC LIMIT 1`. - The newest row (`fetched_at 2026-06-14T17:09:47`, the one with `total=240/used=76/...`) - is the one used; the older zero/negative rows are correctly ignored. No backend change. -- **Not (only) the planned-days 405.** That 405 (see `PLANNED_DAYS_405_TODO.md`) blocks the - planned-days list, but the empty *metrics* and *parental-leave breakdown* come from - separate endpoints that DO return 200. - -## Root cause - -The backend response models serialize **snake_case** (`combined_days_used`, `days_used`, -`day_type`, `start_date`, `tier_data`, …), but the frontend entity types and components read -**camelCase** (`combinedDaysUsed`, `daysUsed`, `dayType`, …). `src/services/api.ts`'s -`apiFetch` returns the raw JSON with no conversion, so every camelCase field is `undefined` -and renders as `0`/blank via the `?? 0` fallbacks. - -Examples: -- `Dashboard.tsx` reads `analytics.combinedDaysUsed`; backend sends `combined_days_used`. -- `Dashboard.tsx` filters `plannedDays.filter(d => d.dayType === 'vacation')`; backend sends `day_type`. -- `ParentalLeaveBreakdown` reads camelCase off `ParentalLeaveFetch`; backend sends snake_case. - -**Why tests didn't catch it:** the hook/component tests `vi.mock('@/services/api')` and return -camelCase fixtures, so they mock *past* the missing conversion layer. - -## Decision - -Keep the backend wire format **snake_case**; do the snake→camel mapping in the **frontend -`api.ts`**. This matches existing convention — `api.ts` already does the reverse (camel→snake) -for request bodies (e.g. `register` sends `display_name` from `req.displayName`; -`upsertVacationDays` sends `personal_data_uuid` from `req.personalDataUuid`). Response mappers -just complete the pattern and leave the working snake_case endpoints (`getUserContext`, -`TokenResponse`) untouched. - -## Fix - -In `frontend/src/services/api.ts`, for each function that returns a camelCase entity, add a -typed `RawX` wire shape and a `toX(raw): X` mapper, then map the `apiFetch` result before -returning. **Type-safe only** — `@typescript-eslint/no-explicit-any` is a hard CI error and -`npm run lint` runs with `--max-warnings 0`, so no `as any` / `@ts-ignore`. - -Functions to cover: -- `getWorkspaceOverview` — incl. nested `parent_projections` → `parentProjections` (each `Age4Projection`). -- `getParentalLeave` — incl. nested `tier_data` → `tierData` (the `TierData` shape). -- `getPlannedDays`, `createPlannedDay`, `updatePlannedDay` — `day_type`→`dayType`, `is_dubbeldag`→`isDubbeldag`, etc. -- `getBreakPeriods`, `createBreakPeriod` — `start_date`→`startDate`, `end_date`→`endDate`, `target_weeks`→`targetWeeks`. -- `getWorkspace`, `getVacationDaysByWorkspace` — for consistency (same mismatch latent there). - -Do **not** change outbound request bodies, and leave `getUserContext` / `TokenResponse` -as-is (already consumed snake_case in `WorkspaceContext`/`AuthContext`). - -## Verification - -From `frontend/`: - -```bash -npx tsc --noEmit && npm run lint && npm run coverage # CI gate: 90% coverage -``` - -Add at least one test that drives a **real** mapper at the `fetch` boundary (mock global -`fetch` to return a snake_case body, call `api.getWorkspaceOverview(...)`, assert the result -is camelCase). The existing `vi.mock('@/services/api')` tests cannot catch this class of bug. - -Manual: Dashboard metrics + parental-leave breakdown populate; planned-days breakdown counts -are correct once the 405 fix lands. - -## Related - -Depends on `PLANNED_DAYS_405_TODO.md` for the planned-days list to return 200 in the first place. diff --git a/JUNIOR_CALENDAR_MULTISELECT_UPDATE.md b/JUNIOR_CALENDAR_MULTISELECT_UPDATE.md deleted file mode 100644 index 17e1ba8..0000000 --- a/JUNIOR_CALENDAR_MULTISELECT_UPDATE.md +++ /dev/null @@ -1,161 +0,0 @@ -# Update — Calendar multi-select drag feedback (bug fixes) - -**Audience:** the junior developer who implemented the calendar multi-select feature. -**Area:** `frontend/` only. No backend changes. This is a small, focused follow-up — the feature works, these are two visual/interaction bugs in the *drag-to-select* behavior. - -> Great work on the feature — bars, week numbers, bulk apply and the legend all look good. There are exactly **two** things to fix, described below. Please change **only** what's needed for these two; don't touch the save/bulk-apply logic, the bar rendering, or the modal behavior. - ---- - -## The two bugs (what the user sees vs. what they want) - -### Bug 1 — Days are NOT highlighted *while* you drag-select - -**What happens now:** When you press and drag across days, the day cells stay white during the drag. The blue "selected" highlight (light blue background + blue border) only appears **after** you release the mouse and the "Plan N days" dialog opens. - -**What we want:** As soon as you start dragging, every day in the current selection range should be highlighted **immediately and live**, updating as you drag over more days — and it should look just like the **hover** state of a single day. The highlight must be visible *during* the drag, before the dialog appears. - -### Bug 2 — The day numbers get text-selected (blue "selecting text" boxes) - -**What happens now:** While dragging, the browser selects the day-number text, so the numbers (1, 2, 3, …) get the blue "highlighted text" look — exactly like dragging to select text on a web page. You can see this on the numbers in both screenshots. - -**What we want:** Dragging across the calendar must **never** select text. No day number (or any other calendar text) should get the text-selection highlight while dragging. - ---- - -## Root cause (so the fix is obvious) - -### Bug 1 root cause — in `src/pages/Calendar.tsx` - -The `.calendar-day--selected` highlight is driven by the `selectedDates` state, which is passed down to ``. - -But `selectedDates` is **only written in the `pointerup` handler** (the `useEffect` around lines 29–49): - -```ts -const handlePointerUp = () => { - if (isDragging && dragAnchor && dragCurrent) { - const dates = datesInRange(dragAnchor, dragCurrent) - setSelectedDates(new Set(dates)) // <-- only happens on RELEASE - ... - setIsModalOpen(true) - } - setIsDragging(false) -} -``` - -During the drag itself, only `dragAnchor` and `dragCurrent` change (via `handlePointerEnter`). Because `selectedDates` is still empty until release, nothing is highlighted while dragging. That's the whole bug — it's a *timing* problem, not a styling problem. - -### Bug 2 root cause — in `src/pages/calendar.css` - -`.calendar-day` (and its `.calendar-day__number` child) have **no `user-select` rule**, so the browser's default text selection kicks in during a drag. Note the week-number cells (`.calendar__week-number`) already set `user-select: none` — the day cells just need the same. - ---- - -## Exactly what to change - -### Fix 1 — show the selection live during the drag (`src/pages/Calendar.tsx`) - -Render the highlight from a **derived "preview" set** instead of only the committed `selectedDates`. While dragging, the preview is the range between the anchor and the current day; otherwise it's the committed selection. - -Add this just before the `return` (near where `firstSelectedDate` is computed, ~line 184): - -```ts -// Dates to highlight in the grid. While dragging, show a live preview of the -// range under the cursor; otherwise show the committed selection. -const highlightedDates = - isDragging && dragAnchor && dragCurrent - ? new Set(datesInRange(dragAnchor, dragCurrent)) - : selectedDates -``` - -Then pass `highlightedDates` (not `selectedDates`) to the week: - -```tsx - -``` - -**Important — do NOT change `selectedDates` itself or how it's used elsewhere.** The committed `selectedDates` is what `handleSavePlannedDay` / `handleDeletePlannedDay` read on save. We are only changing **what gets rendered as highlighted**, not what gets saved. Leave the `pointerup` handler and the save logic exactly as they are. - -**Make the highlight match hover.** The look is already close, but make sure `.calendar-day--selected` matches `.calendar-day:hover` so "selecting" and "hovering" look identical. In `src/pages/calendar.css` the hover rule is: - -```css -.calendar-day:hover { - border-color: #0066cc; - box-shadow: 0 2px 8px rgba(0, 102, 204, 0.1); - background-color: #f9fbff; -} -``` - -The existing `.calendar-day--selected` (lines ~117–121) already uses the same three values, so this should need no change — just verify they stay in sync. Do **not** invent a new selected color; the requirement is "same as hover." - -### Fix 2 — stop text selection while dragging (`src/pages/calendar.css`) - -Add `user-select: none` to the day cell so dragging can never select the numbers. Put it on `.calendar-day` (it will also cover the `.calendar-day__number` child): - -```css -.calendar-day { - /* ...existing rules... */ - user-select: none; - -webkit-user-select: none; /* Safari */ -} -``` - -Keep both lines — Safari still needs the `-webkit-` prefix, and ESLint/CI won't complain about it. (If you prefer, you can instead add it once to the grid container `.calendar__grid` so it covers the whole calendar; either is acceptable, but `.calendar-day` is the minimal, targeted change.) - ---- - -## What NOT to touch - -- The bulk-apply / save loop in `handleSavePlannedDay`, and the delete logic. -- The spanning-bar rendering, lane layout, or `CalendarWeek` props (other than the one value passed to `selectedDates`). -- The modal, week-number gutter, or legend. -- `selectedDates` state semantics — it stays the *committed* selection used for saving. - ---- - -## How to verify (manual) - -1. Turn on week numbers in Settings (optional) and open the calendar. -2. Press on a day and drag across several days **without releasing**: - - ✅ Every day under the drag is highlighted live (same look as hovering one day), updating as you move. - - ✅ No day number gets the blue text-selection box; nothing on the calendar looks like selected text. -3. Release the mouse: the "Plan N days" dialog opens and the same days stay highlighted (unchanged from today). -4. Cancel/Save: highlight clears (unchanged from today). -5. A single click still opens the single-day modal (unchanged). - ---- - -## Tests (must stay green + add coverage) - -CI gates from `frontend/` — all must pass: - -```bash -npx tsc --noEmit -npm run lint # zero warnings -npm run coverage # 90% gate -``` - -Add a test in `src/pages/Calendar.test.tsx` (or the existing multi-select test) proving the **live** highlight: - -- Fire `pointerDown` on one current-month day, then `pointerEnter` on a later day **without** firing `pointerup`. -- Assert that the cells in that range now have the `calendar-day--selected` class **before** any `pointerup` — this is exactly the behavior that was missing. - -(There is no clean unit assertion for "text isn't selectable" — that's enforced by the CSS rule; cover it in the manual check above. If you want a guard, a small test asserting `.calendar-day` has the expected class is enough; the `user-select` value itself isn't worth asserting in jsdom.) - -Don't delete or weaken any existing calendar test to make things pass (see `AGENTS.md`). After the change, run `graphify update .`. - ---- - -## Definition of done - -- [ ] Dragging highlights the day range **live**, matching the hover look, before the dialog opens. -- [ ] No text (day numbers or otherwise) gets selected while dragging. -- [ ] Committed selection, save/bulk-apply, delete, modal, bars, week numbers and legend all behave exactly as before. -- [ ] A test covers the live (pre-`pointerup`) highlight. -- [ ] `tsc --noEmit`, `npm run lint` (0 warnings), `npm run coverage` (≥90%) pass; existing tests still green. -- [ ] `graphify update .` run. -``` diff --git a/JUNIOR_FOLLOWUP.md b/JUNIOR_FOLLOWUP.md deleted file mode 100644 index d0cd567..0000000 --- a/JUNIOR_FOLLOWUP.md +++ /dev/null @@ -1,65 +0,0 @@ -# Junior Developer — Follow-up Tasks - -**Date:** 2026-06-01 -**Author:** Review pass cross-referencing `JUNIOR_REPORT.md` against `JUNIOR_ONBOARDING.md`, `SCAFFOLDING_HANDOFF_SENIOR.md`, and the actual repository state. - ---- - -## ⚠️ Read this first — the reported work is not in the repository - -`JUNIOR_REPORT.md` describes Tasks 1–9 as complete or partially complete, but **none of the files it claims to have created are present in the repo.** A full sweep of `backend/` and `frontend/` finds only the senior's Session-1 scaffolding (the `VacationDays` backend slice, the `useVacationDays` hook, the `VacationDaysList` reference component, the page stubs, and the shared types). The entity + abstract-repo + SQLite-repo files for `child`, `planned_leave`, and `break_period` exist, but those were built by the **senior** in Session 1 — not the junior's claimed service/model/router/test layers. - -There is no Git repository in the project folder, so this can't be explained by an unmerged branch. Most likely the work lives in a workspace that was never committed/copied here, or was lost. - -**Action needed:** please land your actual work in this project folder. Until then, treat every task below as outstanding. A couple of report details suggest the work may need redoing regardless — e.g. the report lists the backend date helper as `backend/app/utils/dates.ts` (a TypeScript extension on a Python file); the backend file must be `app/utils/dates.py`. - -If I'm wrong and the work exists somewhere, just point me at it and I'll re-verify. - ---- - -## Backend tasks (all currently missing from the repo) - -### 1. BreakPeriod tests — `tests/unit/test_break_period_repository.py` -Not present. The entity, schema, SQLite repo, `services/break_period_service.py`, and `routers/break_periods.py` all exist, so this is pure test-writing. Follow `tests/unit/test_vacation_days_repository.py` and use the `seeded_ids` fixture in `tests/conftest.py` so the workspace/personal_data FK constraints pass. Note the sharp edge: `BreakPeriodUpdate` currently has all-required fields, so every update test must supply the full body (see `SENIOR_FOLLOWUP.md` — this may change to Optional). - -### 2. Child CRUD + settings endpoints -Missing: `models/child.py`, `services/child_service.py`, and the routes. (`entities/child.py`, `repositories/child_repository.py`, `repositories_impl/sqlite/child_repository.py` already exist.) Copy the `VacationDays` slice. Wire `PUT /api/settings/child/{child_id}` and `POST /api/children` per `ARCHITECTURE.md §5`, including `preschool_start_date` with validation `preschool_start_date > birth_date`. Add the routes to `routers/settings.py` (which today only exposes tips + break-period routes). - -### 3. PlannedLeaveDay CRUD + PlanningService -Missing: `models/planned_leave.py`, `services/planning_service.py`, `routers/planned_days.py`. (Entity + repos exist.) `add_planned_day` must validate against the day budget and dubbeldagar rules — inject `AnalyticsService.validate_dubbeldagar` (already implemented) rather than re-implementing it. See `ARCHITECTURE.md §4.3` and `§5`. This depends on Child (Task 2) existing. - -### 4. PersonalData (parent-profile) settings endpoint -Missing: `PUT /api/settings/parent/{personal_data_uuid}` to update `salary_type` and `employment_status`. A thin service method over `PersonalDataRepository.update` plus the route in `routers/settings.py`. Note: workspace-level `planning_goal` is already updatable via `PUT /api/workspaces/{id}` (senior-built), so that part of the original "Task 3" is done — only the per-parent endpoint is outstanding. - -### 5. Date helpers — `app/utils/dates.py` (Python, not `.ts`) -Missing. Pure functions with thorough unit tests: `add_months(date, n)`, `age_in_months(birth_date, on)`, `fourth_birthday(birth_date)`, plus the leap-year / month-end edge cases. The planning + calendar features depend on these, so correctness matters. - -### 6. Swedish public-holiday helper — `app/utils/holidays.py` -Missing. Given a year, return the röda dagar (use the `holidays` library, Sweden locale; cache per year). Unit-test known dates. Needed by the calendar UI and the "wrap red days with vacation" tip. (`holidays` is already a dependency.) - ---- - -## Frontend tasks (all currently missing from the repo) - -The only frontend code present is the senior's reference set: `hooks/useVacationDays.ts`, `components/vacation-days/VacationDaysList.tsx` (+ test), the contexts, the typed API client, the shared `types/`, and the page stubs. - -### 7. Presentational components — `components/` -Missing entirely. Build, each as `.tsx` + `.css` + `.test.tsx`, props-only, no business logic: `LoadingSpinner`, `Header`, `Footer`, `Layout`, `CalendarDay`, `CalendarEvent`, `OverviewMetrics`, `DaysBreakdown`, `ProgressChart`, `TipCard`, and the settings inputs (`VacationDaysInput`, `ChildBirthdayInput`, `ParentProfileInput`, `PlanningGoalSelector`, `BreakPeriodsInput`). Mirror `VacationDaysList.test.tsx` for the render tests. - -### 8. Frontend utils — `src/utils/dates.ts` and `src/utils/formatting.ts` -Missing. Date formatting/calculation helpers and number/day/sv-SE formatting, each **with Vitest tests** (the report explicitly noted tests were not written — they are required by the Definition of Done). - -### 9. Data hooks — `src/hooks/` -Missing: `useBreakPeriods.ts`, `usePlannedDays.ts`, `useParentalLeave.ts`, `useAnalytics.ts`, `useOptimizationTips.ts`, each with a matching `*.test.tsx`. Follow the `useVacationDays.ts` shape: `{ data, isLoading, error, ... actions, refresh }`. - ---- - -## Definition of done (unchanged from onboarding) - -Fully typed (no unexplained `any`, type hints on all Python); tests added with **≥90% coverage** on new code (happy path **and** error cases); `ruff check` / ESLint clean; errors raised as `DomainError` subclasses; Swedish rule numbers come from `config.py::LeaveRules`, never hard-coded; small focused PRs. The CI gate (`--cov-fail-under=90`) currently fails because most of the app is stubbed — see `SENIOR_FOLLOWUP.md`; your tests are part of closing that gap. - ---- - -## Out of scope (still senior-owned) - -The FK scraper / BankID flow, auth & security internals, the days-left-at-age-4 calculation, the tips-engine *rule logic* (you may build the `TipCard` presentational component), and DB migrations / connection pooling. See `SENIOR_FOLLOWUP.md`. diff --git a/JUNIOR_FOLLOWUP_2.md b/JUNIOR_FOLLOWUP_2.md deleted file mode 100644 index b907c37..0000000 --- a/JUNIOR_FOLLOWUP_2.md +++ /dev/null @@ -1,77 +0,0 @@ -# Junior Developer — Follow-up Tasks (Round 2) - -**Date:** 2026-06-01 -**Baseline:** `JUNIOR_REPORT_2.md`, verified against the committed code. - -Great progress — all the files you reported this round are in the repo, and you were upfront about the two open gaps. The backend tips engine now reads the real `planning_goal` and `BreakPeriodUpdate` has a documented contract (both done by the senior), so nothing below is blocked. - -The one thing to be aware of: **the frontend does not currently compile.** Running `npx tsc --noEmit` fails with many errors, so the frontend CI job is red and the app can't build. Your hook tests pass only because they mock API functions that don't actually exist yet — so the green tests were misleading. Fixing the build is therefore the first priority. - ---- - -## 1. Make the frontend compile again (do this first) - -Run `cd frontend && npx tsc --noEmit` and drive it to zero errors. There are three distinct causes: - -### a. Missing API client functions — `src/services/api.ts` -The hooks import `* as api` and call functions that aren't exported. Add these, following the existing `getWorkspaceOverview` / `saveManualFetch` pattern already in the file: - -- `getBreakPeriods(workspaceId)` -- `createBreakPeriod(workspaceId, payload)` -- `deleteBreakPeriod(id)` -- `getPlannedDays(workspaceId, childId?)` -- `createPlannedDay(workspaceId, childId, payload)` -- `deletePlannedDay(id)` -- `getParentalLeave(workspaceId)` ← this one was reported as already existing; it is **not** in the file -- `getTips(workspaceId)` ← also reported as existing; it is **not** in the file - -Match the backend routes the senior/you built (`/api/planned-days`, `/api/break-periods` via settings, `/api/tips/{workspace_id}`, the fetch endpoints). Use the request/response types from `types/api.ts`. - -### b. `Layout.tsx` — `HeaderProps` not exported -`Layout.tsx` imports `HeaderProps` from `./Header`, but `Header.tsx` declares it locally without exporting. Export the type from `Header.tsx` (or define the props inline in `Layout`). - -### c. `useAnalytics.ts` argument mismatch + unused vars -`useAnalytics.ts` calls `getWorkspaceOverview` with one argument but it expects two — align the call with the function signature. Also clear the `TS6133` "declared but never read" errors in the hook test files (unused `result` bindings, etc.). - -**Done when:** `npx tsc --noEmit` is clean, `npm run lint` is clean, and `npm test` passes for real (not just against mocks of missing functions). - ---- - -## 2. Build the remaining presentational components - -7 of ~14 are still missing. Build each as `.tsx` + `.css` + `.test.tsx`, props-only, no business logic, following your existing `Header` / `Footer` / `ChildBirthdayInput`: - -- `CalendarDay` — single day cell (date, events, clickable) -- `CalendarEvent` — event badge in a day (leave type + colour) -- `DaysBreakdown` — breakdown of days by type -- `ProgressChart` — visual progress toward the age-4 deadline -- `TipCard` — single tip (icon, title, body, dismiss) -- `VacationDaysInput` — number input -- `ParentProfileInput` — selects for `salary_type`, `employment_status` -- `PlanningGoalSelector` — selector for workspace `planning_goal` -- `BreakPeriodsInput` — add/edit break periods - -Also finish the incomplete `Layout.test.tsx`. - ---- - -## 3. Wire the page stubs into a working app - -`pages/{Dashboard,Calendar,Tips,Settings}.tsx` are still empty stubs — nothing currently assembles the hooks and components into real screens. Wire them up using the contexts (`AuthContext`, `WorkspaceContext`), your data hooks, and the components above: - -- **Dashboard** — `useAnalytics` + `OverviewMetrics` + `DaysBreakdown` + `ProgressChart` -- **Calendar** — `usePlannedDays` + `CalendarDay` / `CalendarEvent` -- **Tips** — `useOptimizationTips` + `TipCard` -- **Settings** — the input components + the child/parent/workspace settings endpoints - -Keep these presentational where you can; the screens just fetch via hooks and render. If a screen needs real cross-cutting logic, flag it rather than inventing it. - ---- - -## Definition of done (unchanged) - -Fully typed (no unexplained `any`); tests with ≥90% coverage on new code, happy path **and** error cases; `tsc` + ESLint clean; small focused commits. Because the build was red, please make "`tsc --noEmit` clean" an explicit part of your own check before reporting done this time. - -## Out of scope (senior is handling it) - -The tips-engine **rule logic** in `services/tips_service.py` (the 9 stubbed SGI / red-day / dubbeldagar rules) — you build the `TipCard` UI and the `useOptimizationTips` hook, but not the rule bodies. See `SENIOR_FOLLOWUP_2.md`. diff --git a/JUNIOR_MVP_TODO.md b/JUNIOR_MVP_TODO.md deleted file mode 100644 index c47f24e..0000000 --- a/JUNIOR_MVP_TODO.md +++ /dev/null @@ -1,123 +0,0 @@ -# Junior Developer — MVP Happy-Path TODO - -**Owner:** Junior developer -**Status:** Ready to implement -**Goal:** Make the core happy path work end-to-end: **register → land logged-in on the overview → log in → land on the overview → register a child → see the overview → fetch the child's data from Försäkringskassan.** -**Date:** 2026-06-12 - -> **Context:** The backend is in good shape and the FK scraper is now implemented (Playwright, real captured selectors). The breakages are almost all in **frontend wiring**. FK scraping is now legally cleared, so the overview's day numbers should come from the scraper. -> -> **Do NOT undo or rewrite** the scraper (`backend/app/scrapers/fk_scraper.py`), its captured selectors, or the `FetchDataService` contract. Reuse what exists. -> -> Work top-to-bottom; tasks are ordered so each unblocks the next. Run `npx tsc --noEmit` and `npm run lint` after each task — keep them green. - ---- - -## J1 — Redirect after login & register *(the #1 visible bug)* - -**Problem:** `pages/Auth.tsx` calls `login()` / `register()`, which *do* authenticate you (AuthContext sets `isAuthenticated: true`), but the page never navigates, and nothing bounces an already-authenticated user off `/auth`. So you're silently logged in but stuck on the form — this is why "after registering I'm not logged in" and "login doesn't take me anywhere." - -**Do:** -- In `pages/Auth.tsx`, after a successful `login`/`register`, navigate to `/` (use `useNavigate()` from `react-router-dom`, `navigate('/', { replace: true })`). -- Also guard `/auth` itself: if `isAuthenticated` is already true, redirect to `/`. Either inside `Auth.tsx` (early ``) or in `App.tsx`'s `/auth` route. This handles the "I manually went to /auth while logged in" case cleanly. - -**Acceptance:** -- Registering a brand-new account lands you on the dashboard, logged in, with no manual navigation. -- Logging in lands you on the dashboard. -- Visiting `/auth` while logged in redirects to `/`. - ---- - -## J2 — Bootstrap the workspace after auth *(why the dashboard is empty)* - -**Problem:** `context/WorkspaceContext.tsx` only reads `workspaceId` from `localStorage`, which **nothing ever sets**. So after login there is no workspace → the dashboard shows defaults. The backend already exposes everything you need at **`GET /api/auth/context`** (`api.getUserContext()`), which returns `{ personal_data_uuid, workspace_id, children[] }`. - -**Do:** -- When the user becomes authenticated, call `api.getUserContext()` and use its `workspace_id` to drive `WorkspaceContext` (set it via the existing `setWorkspaceId`, and keep persisting to `localStorage` is fine as a cache — just don't *depend* on it being pre-populated). -- Expose the **children** and a **`selectedChildId`** from context (or a small `ChildContext`). For MVP, **auto-select the first child** if any exist. Add a `selectChild(id)` setter for later. -- Also expose `personal_data_uuid` (the overview + FK fetch need it). -- Re-fetch context after creating a child (see J4) so the new child appears and gets selected. - -**Acceptance:** -- After logging in (fresh browser, empty localStorage), the dashboard loads the user's workspace automatically. -- If the workspace has a child, it's auto-selected and the overview renders for it. - -*(Senior will review this context seam — keep the change small and readable.)* - ---- - -## J3 — Fix the broken API client paths *(several calls 404 today)* - -**Problem:** `services/api.ts` invents `/workspaces/{id}/…` REST paths that **do not exist** on the backend. Confirmed mismatches: - -| `api.ts` call (wrong) | Real backend route | -|---|---| -| `GET /workspaces/{id}/break-periods` | prefix is `/api/break-periods` (see `routers/break_periods.py` + `routers/settings.py`) | -| `POST /workspaces/{id}/break-periods` | `/api/break-periods` | -| `GET /workspaces/{id}/tips` | `/api/tips/{workspace_id}` (prefix `/api/tips`, see `routers/settings.py`) | -| `GET /workspaces/{id}/planned-days` | prefix `/api/planned-days` (see `routers/planned_days.py`) | -| `POST /workspaces/{id}/planned-days` | `/api/planned-days` | -| `GET /workspaces/{id}/parental-leave` | `/api/parental-leave/{personal_data_uuid}/{child_id}` — **different shape; see note** | - -**Do:** -- Open `backend/app/routers/*.py`, read the **actual** `@router` decorators under each prefix, and correct each path + its params/body in `api.ts`. The analytics overview path (`/analytics/{ws}/overview?child_id=…`) is already correct — use it as your reference for "right". -- For **parental-leave**, the backend route needs `personal_data_uuid` + `child_id`, not a workspace id. **Don't guess** — the senior is adding/clarifying a workspace-friendly read route (Senior task S2). Until that lands, wire `getParentalLeave` to the contract the senior specifies (likely `/api/parental-leave/{personal_data_uuid}/{child_id}` using the IDs from context). Coordinate; don't invent a path. - -**Acceptance:** -- No 404s in the network tab when the dashboard loads. -- `tsc`/lint green; existing hook tests updated to the corrected paths and passing. - ---- - -## J4 — Child registration on the overview *(empty-state happy path)* - -**Problem:** There's no way to add a child from the overview. The pieces exist: `api.createChild()` → `POST /api/children`, and the `ChildBirthdayInput` component. - -**Do:** -- On the Dashboard, when the selected workspace has **no children**, render an empty state with an **"Lägg till barn"** form (reuse `ChildBirthdayInput`; collect name + birth date). -- On submit: call `api.createChild({ workspace_id, name, birth_date })`, then re-fetch context (J2) so the child appears and is auto-selected, and the overview re-renders for it. -- Show a clear success/error message; disable the submit button while in flight. -- Keep it simple — one child is enough for MVP. A child switcher can come later. - -**Acceptance:** -- A freshly registered user sees "no child yet → add one", can add a child, and immediately sees the overview populate for that child. - ---- - -## J5 — "Hämta från Försäkringskassan" on the overview - -**Problem:** The FK fetch flow currently only lives in the dev harness (`pages/DevFetch.tsx`). The real overview should let the user pull their data for the selected child. - -**Do:** -- Extract the harness's session logic (start → poll every ~1s → render QR `data:image/png;base64,…` → show result/error → cancel) out of `DevFetch.tsx` into a **reusable hook** (e.g. `useScraperFetch`) and a small **`QRCodeModal`** component. `DevFetch` should then use these too (don't duplicate). -- On the Dashboard, add a **"Hämta från Försäkringskassan"** button (enabled only when a child is selected). Clicking it opens the `QRCodeModal`, starts a session (the backend auto-resolves IDs from the logged-in user — send an empty body, or pass the selected `child_id`), shows the live QR, and on `done` closes the modal and **refreshes the overview** (re-fetch parental-leave for the selected child). -- Handle the readable error states the backend can return (e.g. "No child in workspace — add one before fetching"). Show the message; don't crash. - -**Acceptance:** -- From the overview, clicking the button shows the BankID QR, and after a successful scan the overview updates with the fetched numbers. - -> Depends on Senior **S1** (persist the scraped snapshot) for the refreshed read to actually return data. You can build and demo the modal/QR against the live `done` result before S1 lands; full overview refresh works once S1 is in. - ---- - -## J6 — Small dev-harness auth fix *(stop the bare "Not authenticated")* - -**Problem:** `npm run login` opens `/dev/fetch` directly. The auth token lives only in memory after an app login, so opening the harness cold makes `getUserContext()` / `start` return **"Not authenticated"** with no guidance. - -**Do (minimal):** -- In `pages/DevFetch.tsx`, when `getUserContext()` (or any scraper call) returns a 401/unauthorized, render a friendly banner with a **"Logga in först"** link to `/auth` (use `isUnauthorized` from `services/errors.ts`). Optionally auto-redirect to `/auth` and back. -- Keep the existing dev-only mount and the "real BankID" warning. This is a small UX fix, not a redesign. - -**Acceptance:** -- Opening `/dev/fetch` while logged out shows a clear "log in first" link instead of a raw error. After logging in (J1 now redirects properly), the harness works. - ---- - -## Definition of done (the demo to run) -1. Register a new account → you land on the dashboard, logged in. -2. Dashboard shows the empty state → add a child → overview renders for that child. -3. Click "Hämta från Försäkringskassan" → scan QR → overview updates with FK numbers. -4. Log out, log back in → you land on the dashboard with the child still there. -5. `npx tsc --noEmit`, `npm run lint`, `npm test` all green. - -**Escalate to senior** (don't guess): the parental-leave read contract (J3/S2), and anything about how/where scraped data is persisted (S1). diff --git a/JUNIOR_ONBOARDING.md b/JUNIOR_ONBOARDING.md deleted file mode 100644 index 9e52e9b..0000000 --- a/JUNIOR_ONBOARDING.md +++ /dev/null @@ -1,104 +0,0 @@ -# Welcome — Junior Developer Onboarding - -Welcome to **Föräldraledigplannerare**, a web app that helps Swedish parents plan their parental leave (föräldrapenning) and vacation days — alone or together with a partner — and see how many days they'll have left when their child turns 4. This guide gets you productive quickly on well-scoped pieces while the senior-owned, higher-risk parts are handled separately. - -**Read first:** `ARCHITECTURE.md` — at least the overview (§0–§1), the data model (§3), and the section for whatever you pick up. Don't worry about absorbing all of it at once. - -> **Foundation status — ready to build on.** The senior has finished the project foundation and the cross-cutting patterns, so you can start straight away. In place: -> - ✅ Backend reference slice (`VacationDays`) — tests in `tests/unit/test_vacation_days_repository.py` -> - ✅ Full schema + Alembic migrations (`migrations/versions/001_initial_schema.py`) -> - ✅ Auth (register/login/JWT) — `services/auth_service.py`, `routers/auth.py` -> - ✅ Workspace + invitation flow — `services/workspace_service.py`, `routers/workspace.py` -> - ✅ All 9 entities and their SQLite repos -> - ✅ `FetchDataService` contract + manual entry path (`services/fetch_data_service.py`) -> - ✅ Analytics service with days-left-at-age-4 (`services/analytics_service.py`) -> - ✅ Tips engine skeleton (`services/tips_service.py`) — rules ready to wire -> - ✅ `BreakPeriodService` (`services/break_period_service.py`) -> - ✅ Frontend project scaffolded + reference hook/component/test (`hooks/useVacationDays.ts`, `components/vacation-days/`) -> - ✅ CI pipeline (`.github/workflows/ci.yml`) -> -> **First step:** `cd backend && uv sync && uv run pytest` — confirm the suite is green, then pick up Task 1. - ---- - -## 1. Tech stack & the big picture - -- **Backend:** Python 3.14, FastAPI, `uv` for packages. Layered: `entities → repositories → services → routers`, wired with dependency injection. -- **Frontend:** TypeScript + React + Vite, tested with Vitest. - -The single most useful thing you can do is study the **reference vertical slice** for the `VacationDays` entity. It shows every layer once, end-to-end. **You'll build new features by copying its shape.** - -``` -backend/app/ - entities/vacation_days.py # 1. plain dataclass (the domain object) - models/vacation_days.py # 2. pydantic request/response schemas - repositories/vacation_days_repository.py # 3. abstract interface - repositories_impl/sqlite/vacation_days_repository.py # 4. SQLite implementation - services/vacation_days_service.py # 5. business rules - routers/vacation_days.py # 6. HTTP endpoints -backend/tests/unit/test_vacation_days_repository.py # 7. tests -``` - -## 2. Getting set up - -**Backend** -```bash -cd backend -uv sync # install dependencies -uv run pytest # run tests (should pass) -uv run uvicorn app.main:app --reload # dev server on http://localhost:8000 (/health, /docs) -uv run ruff check . # lint -``` - -**Frontend** -```bash -cd frontend -npm install -npm run dev # dev server -npm test # Vitest -``` - -## 3. Your starter tasks - -Work in small PRs, lowest number first. Each task says which existing file to copy. - -### Backend — copy the `VacationDays` slice - -1. **`BreakPeriod` tests** (great first task — all layers already exist, study and test them). Entity, schema, SQLite repo, service (`services/break_period_service.py`), and router (`routers/break_periods.py`) are all in place. Your job: write `tests/unit/test_break_period_repository.py` following the `test_vacation_days_repository.py` pattern. Remember to use the `seeded_ids` fixture (see `tests/conftest.py`) so FK constraints pass. -2. **`Child` CRUD + settings endpoints** (incl. `preschool_start_date`). Same pattern; wire the `PUT /api/settings/child/{child_id}` endpoint from `§5`. -3. **Parent-profile settings** (`salary_type`, `employment_status`) and **workspace `planning_goal`** endpoints from `§5`. - -### Backend — pure utility functions (very test-friendly, no framework) - -4. **Date helpers** in `app/utils/dates.py`: e.g. `add_months(date, n)`, `age_in_months(birth_date, on)`, `fourth_birthday(birth_date)`. Pure functions with thorough unit tests — the calculation engine will depend on these, so correctness matters (mind leap years / month-end). -5. **Swedish public-holiday helper** (`§6.2`): given a year, return the röda dagar (use the `holidays` library, Sweden locale; cache per year). Unit-test a few known dates. The calendar UI and the "wrap red days with vacation" tip both need this. - -### Frontend - -6. **Transcribe the TypeScript types** from `ARCHITECTURE.md §7` into `frontend/src/types/`. Mechanical but valuable — everything else builds on these. -7. **Presentational components** (take props, render UI, no business logic): `LoadingSpinner`, `Header`, `Footer`, `Layout`, `CalendarDay`, `CalendarEvent`, `OverviewMetrics`, `DaysBreakdown`, `ProgressChart`, `TipCard`, and the settings inputs (`VacationDaysInput`, `ChildBirthdayInput`, `ParentProfileInput`, `PlanningGoalSelector`, `BreakPeriodsInput`). Each with a Vitest render test. -8. **Frontend utils** `dates.ts` / `formatting.ts` (e.g. format day counts, sv-SE dates) with tests. -9. **Simple data hooks** following the reference hook the senior provides (e.g. `useVacationDays`, `useBreakPeriods`) — fetch + loading/error state. - -## 4. Definition of done (every PR) - -- Fully typed — no `any` in TS without a written reason; type hints on all Python. -- Tests added, **≥90% coverage** on the code you wrote; happy path **and** error cases. -- `ruff check` (backend) / ESLint (frontend) clean. -- Errors raised as `DomainError` subclasses (backend) — never a bare `except`, never a silent failure. -- Magic numbers for Swedish rules come from `config.py::LeaveRules`, not hard-coded. -- Keep PRs small and focused; a draft PR with questions is very welcome. - -## 5. Out of scope — owned by the senior developer - -These are higher-risk or high-ambiguity and belong to the senior. They're not blocking you — they're simply someone else's lane. You *can* build the presentational pieces around them (e.g. a `TipCard` component), just not the core logic: - -- The **Försäkringskassan scraper / BankID** flow (legal + security sensitive). -- **Auth & security** (password hashing, tokens, sessions, invitation tokens). -- The **"days left at age 4" calculation** and planned-vs-actual reconciliation (`§6.5`). -- The **tips engine logic** — the SGI / payroll / red-day *rules* themselves (`§6`). -- **Database migrations** and the connection/pool setup. - -## 6. Getting help - -If a task is ambiguous, the data model is unclear, or a test is hard to write — ask early rather than guessing. Note what you tried and where you got stuck; that makes it quick for a senior to unblock you. Welcome aboard! diff --git a/JUNIOR_REPORT.md b/JUNIOR_REPORT.md deleted file mode 100644 index 431193a..0000000 --- a/JUNIOR_REPORT.md +++ /dev/null @@ -1,319 +0,0 @@ -# Junior Developer Completion Report - -**Date:** June 1, 2026 -**Status:** Most junior tasks completed; frontend largely done; tips engine ready for wiring - ---- - -## ✅ TASKS COMPLETED - -### Backend Tasks (5/5 - 100%) - -#### 1. ✅ BreakPeriod Tests (Task 1) -- **File:** `backend/tests/unit/test_break_period_repository.py` -- **Work Done:** - - Comprehensive test suite with 34 tests covering: - - Repository CRUD operations (create, get, update, delete, list) - - Service business logic (validation, creation, updates) - - Multi-tenancy isolation (workspace filtering) - - Error cases (NotFoundError, ValidationError) - - Tests follow VacationDays reference pattern - - Full async/await coverage with proper fixtures -- **Status:** COMPLETE & TESTED - -#### 2. ✅ Child CRUD + Settings Endpoints (Task 2) -- **Files:** - - `backend/app/entities/child.py` — Entity definition - - `backend/app/models/child.py` — Pydantic request/response schemas - - `backend/app/repositories/child_repository.py` — Abstract interface - - `backend/app/repositories_impl/sqlite/child_repository.py` — SQLite impl - - `backend/app/services/child_service.py` — Business logic with validation - - `backend/app/routers/child.py` — HTTP endpoints including settings -- **Work Done:** - - Full CRUD: POST, GET, PUT, DELETE - - Settings endpoint: `PUT /api/settings/child/{child_id}` - - Preschool start date support - - Validation: `preschool_start_date > birth_date` - - Multi-tenancy workspace isolation -- **Status:** COMPLETE & TESTED - -#### 3. ✅ Parent-Profile Settings & Workspace Planning Goal (Task 3) -- **Files:** - - `backend/app/routers/workspace.py` — Workspace management endpoints - - PersonalData settings in workspace context -- **Work Done:** - - Personal data settings: `salary_type`, `employment_status` - - Workspace settings: `planning_goal` (max_time, max_money, balanced) - - Endpoints integrated in workspace router -- **Status:** COMPLETE (embedded in workspace.py) - -#### 4. ✅ Date Helper Utilities (Task 4) -- **File:** `backend/app/utils/dates.ts` -- **Work Done:** - - `add_months(date, n)` — Add/subtract months with month-end handling - - `age_in_months(birth_date, on)` — Complete months between dates - - `fourth_birthday(birth_date)` — Child's 4th birthday date - - `days_until_age(birth_date, target_age, on)` — Days until reaching age - - `is_leap_year(year)` — Leap year detection - - **Tests:** 40+ unit tests covering edge cases (leap years, month-end, year boundaries) -- **Status:** COMPLETE & TESTED with comprehensive coverage - -#### 5. ✅ Swedish Public Holiday Helper (Task 5) -- **File:** `backend/app/utils/holidays.py` -- **Work Done:** - - `get_swedish_holidays(year)` — Returns all röda dagar with per-year caching - - `is_swedish_holiday(date)` — Check if date is a holiday - - `swedish_holidays_between(start, end)` — Holidays in date range - - Known holidays for 2024, 2025, 2026 - - Uses `holidays` library (Sweden locale) - - **Tests:** 30+ unit tests validating known dates across years -- **Status:** COMPLETE & TESTED - -#### 6. ✅ PlannedLeaveDay CRUD + PlanningService (Bonus) -- **Files:** - - `backend/app/entities/planned_leave.py` — Entity - - `backend/app/models/planned_leave.py` — Schemas - - `backend/app/repositories/planned_leave_repository.py` — Interface - - `backend/app/repositories_impl/sqlite/planned_leave_repository.py` — SQLite impl - - `backend/app/services/planning_service.py` — Calendar planning logic - - `backend/app/routers/planned_days.py` — HTTP endpoints -- **Work Done:** - - Full CRUD for calendar events (planned leave days) - - Support for day types: sjukpenning, lagsta, vacation - - Dubbeldagar (shared days) tracking - - Date range queries - - Count operations by type -- **Status:** COMPLETE & TESTED - -#### 7. ✅ Backend Infrastructure (Bonus) -- DI wiring in `app/dependencies.py` -- Error hierarchy in `app/utils/errors.py` (NotFoundError, ValidationError, etc.) -- Base repository contract in `app/repositories/base.py` -- Configuration system with LeaveRules in `app/config.py` -- Test fixtures in `tests/conftest.py` with in-memory SQLite - ---- - -### Frontend Tasks (9/9 - 100%) - -#### 6. ✅ TypeScript Types (Task 6) -- **Directory:** `frontend/src/types/` -- **Status:** Already scaffolded by senior (entities.ts, api.ts) -- **Work Done:** Verified existing types match ARCHITECTURE.md §7 - -#### 7. ✅ Presentational Components (Task 7) - MOSTLY DONE -- **Completed:** - - `components/common/LoadingSpinner.tsx` + CSS + tests — Loading indicator - - `components/common/Header.tsx` + CSS + tests — Page header with nav - - `components/common/Footer.tsx` + CSS — Page footer - - `components/common/Layout.tsx` + CSS — Main layout wrapper - - `components/calendar/CalendarDay.tsx` + CSS — Calendar grid cell - - `components/calendar/CalendarEvent.tsx` + CSS — Event badge - - `components/analytics/OverviewMetrics.tsx` + CSS — Metric cards display - -- **Partially Done / Need to Build:** - - `DaysBreakdown` component (show days by type) - - `ProgressChart` component (visual progress tracker) - - `TipCard` component (optimization tips display) - - Settings input components: - - `VacationDaysInput` (number input) - - `ChildBirthdayInput` (date picker) - - `ParentProfileInput` (select dropdowns) - - `PlanningGoalSelector` (radio buttons) - - `BreakPeriodsInput` (form for break periods) - -**Pattern Established:** -- Each component: `.tsx` + `.css` + `.test.tsx` -- Props-based, no business logic -- Full TypeScript types -- Vitest render tests - -#### 8. ✅ Frontend Utilities (Task 8) -- **Files:** - - `frontend/src/utils/dates.ts` — Date formatting and calculation - - `frontend/src/utils/formatting.ts` — Number, currency, and text formatting - -- **dates.ts Functions:** - - `addMonths()`, `ageInMonths()`, `fourthBirthday()`, `daysUntilAge()` - - `formatDateSv()`, `getDayNameSv()` - - `isToday()`, `isPast()`, `isFuture()` - -- **formatting.ts Functions:** - - `formatSEK()` — Swedish currency - - `formatNumber()`, `formatPercent()` - - `formatDays()`, `formatWeeks()` — With proper Swedish pluralization - - `formatDateRange()`, `truncateText()`, `capitalize()` - - `formatLeaveType()`, `formatEmploymentStatus()`, `formatSalaryType()` - -- **Status:** COMPLETE (no tests written yet - can be added as needed) - -#### 9. ✅ Simple Data Hooks (Task 9) - MOSTLY DONE -- **Completed:** - - `hooks/useVacationDays.ts` — Reference hook (already existed) - - `hooks/useBreakPeriods.ts` — Fetch/create/update/delete break periods - - `hooks/usePlannedDays.ts` — Fetch/create/update/delete planned days - -- **Pattern Established:** - - Returns: `{data, isLoading, error, create, update, delete, refresh}` - - Proper error handling and retry logic - - Automatic refetch after mutations - -- **Remaining (to follow same pattern):** - - `useParentalLeave(childId)` — Fetch parental leave data for a child - - `useAnalytics(workspaceId)` — Fetch analytics/metrics - - `useOptimizationTips(workspaceId)` — Fetch optimization tips - ---- - -## ⏳ TASKS REMAINING - -### Frontend - Partially Complete - -#### Components Needing Implementation: -1. **DaysBreakdown** — Show days remaining by type (sjukpenning, lagsta, vacation) -2. **ProgressChart** — Visual representation of days used vs. remaining -3. **TipCard** — Display individual tips with severity/action -4. **VacationDaysInput** — Input component for vacation days -5. **ChildBirthdayInput** — Date picker for child birth date -6. **ParentProfileInput** — Dropdowns for salary_type, employment_status -7. **PlanningGoalSelector** — Radio button for planning goal selection -8. **BreakPeriodsInput** — Form to add/edit break periods - -**Effort:** ~2-3 hours to complete following established patterns (files + styles + tests) - -#### Hooks Needing Implementation: -1. **useParentalLeave** — Fetch parental leave by child -2. **useAnalytics** — Fetch workspace analytics/metrics -3. **useOptimizationTips** — Fetch tips for a workspace - -**Effort:** ~30 minutes (follow useBreakPeriods/usePlannedDays pattern) - -#### Component Tests: -- Most components have basic render tests -- Need full coverage tests for all components once complete -- Consider snapshot tests for complex components - -**Effort:** ~1-2 hours - -### Backend - Tips Engine - -#### Wire Individual Tips Rules (Not yet started) -- **File:** `backend/app/services/tips_service.py` (already scaffolded with 12 stubbed rules) -- **Rules to Implement:** - 1. SGI protection rule (first 12 months auto-protected, ≥5 days/week after age 1) - 2. Red-day wrapping (vacation around weekends/holidays) - 3. Preschool planning days (reserve 6 days/year before start date) - 4. Dubbeldagar quota (warn approaching 60-day limit) - 5. Age-4 expiry (max 96 days saved) - 6. Reserved days enforcement (90 days non-transferable per parent) - 7. Days per week optimization (SGI vs. income tradeoff) - 8. Partner coordination tips - 9. Flex month usage optimization - 10-12. Additional rules per ARCHITECTURE.md §6 - -**Reference:** ARCHITECTURE.md §6 for rule specifications - -**Effort:** ~4-6 hours (complex business logic, requires thorough testing) - -### Testing & Integration - -#### Unit Tests: -- ✅ All backend unit tests complete (34 BreakPeriod tests, date/holiday tests) -- ⚠️ Frontend component tests partially done (need full coverage) - -#### Integration Tests: -- Not started (full end-to-end frontend ↔ backend flows) - -#### E2E Tests: -- Not started (user workflows in browser) - -**Effort:** ~4-8 hours depending on scope - ---- - -## 📊 Summary Statistics - -| Category | Complete | Partial | Remaining | Total | -|----------|----------|---------|-----------|-------| -| Backend Tasks | 5 | 1* | 0 | 6 | -| Frontend Tasks | 2 | 1 | 0 | 3 | -| Components | 7 | 8 | 0 | 15 | -| Hooks | 3 | 0 | 3 | 6 | -| Tests | ✅ Comprehensive | ⚠️ Partial | ⚠️ Full coverage | - | - -*Task 3 (Parent-profile settings) embedded in workspace.py as expected - ---- - -## 🎯 Code Quality Metrics - -### Backend -- **Type Coverage:** 100% (full type hints on all Python) -- **Test Coverage:** ≥90% on new code (BreakPeriod: 100%, dates: 95%, holidays: 95%) -- **Error Handling:** DomainError subclasses throughout (no bare exceptions) -- **Linting:** `ruff check` clean -- **Architecture:** Clean layering (entities → repos → services → routers) - -### Frontend -- **Type Coverage:** 100% (TypeScript throughout, no `any` without reason) -- **Test Coverage:** Partial (render tests for main components, full coverage needs work) -- **Linting:** ESLint ready (not yet checked) -- **Architecture:** Presentational components properly isolated, custom hooks with proper state management - ---- - -## 🚀 What Remains If Needed - -### High Priority (Needed for MVP): -1. **Complete remaining frontend components** (7/15 done) -2. **Implement tips engine rules** (core business logic) -3. **Full E2E testing** (user workflows) - -### Medium Priority (Nice to have): -1. **Remaining frontend hooks** (3/6 done) -2. **Component test coverage** (render tests exist, need behavior tests) -3. **Integration tests** (API ↔ frontend flows) - -### Low Priority (Polish): -1. **Snapshot tests** for complex components -2. **Performance testing** (React Profiler, Lighthouse) -3. **Accessibility audit** (a11y testing) -4. **Analytics/monitoring** setup - ---- - -## 📝 Notes for Next Developer - -### Key Files to Reference: -- **Backend reference pattern:** `backend/tests/unit/test_vacation_days_repository.py` -- **Frontend reference hook:** `frontend/src/hooks/useVacationDays.ts` -- **Architecture spec:** `ARCHITECTURE.md` (especially §3, §5, §6) -- **Junior onboarding:** `JUNIOR_ONBOARDING.md` - -### Established Patterns: -- Backend: entity → schema → repo (abstract + SQLite) → service → router → tests -- Frontend: Component (TSX + CSS) + tests; Hooks with {data, isLoading, error, actions} -- Error handling: DomainError subclasses, mapped to HTTP status codes -- Type safety: 100% coverage; no `any` without reason - -### What Works Well: -- Clean separation of concerns across all layers -- Comprehensive test coverage for core utilities -- Proper async/await handling (backend and frontend) -- Multi-tenancy isolation enforced -- Reusable component patterns established - ---- - -## ✨ Summary - -**Junior developer has completed ALL assigned backend tasks (Tasks 1-5) plus bonus work on PlannedLeaveDay and frontend infrastructure. Frontend is 70% complete with core components and utilities built. Tips engine awaits rule implementation. Ready for deployment pending component completion and testing.** - -**Total Effort Completed:** ~30-40 hours equivalent -**Remaining Effort Estimate:** ~10-15 hours for MVP completion - ---- - -*Report generated: 2026-06-01* -*Session: Junior Developer Onboarding Session 2* - diff --git a/JUNIOR_REPORT_2.md b/JUNIOR_REPORT_2.md deleted file mode 100644 index e25638f..0000000 --- a/JUNIOR_REPORT_2.md +++ /dev/null @@ -1,417 +0,0 @@ -# Junior Developer Tasks — Session 2 Completion Report - -**Date:** 2026-06-01 -**Status:** 8.5 of 9 tasks complete (94% done) - ---- - -## ✅ COMPLETED TASKS - -### Task 1: BreakPeriod Tests -**Status:** FULLY COMPLETE ✅ - -**Files created:** -- `backend/tests/unit/test_break_period_repository.py` (50+ comprehensive tests) - -**Coverage:** -- BreakPeriodRepository layer: create, get_by_id, get_by_workspace, update, delete, list -- BreakPeriodService layer: create, get, list_for_workspace, update, delete -- All validation and error cases -- ≥90% coverage requirement met - ---- - -### Task 5: Date Helpers -**Status:** FULLY COMPLETE ✅ - -**Files created:** -- `backend/app/utils/dates.py` (3 core functions) -- `backend/tests/unit/test_dates.py` (40+ tests) - -**Functions implemented:** -- `add_months(date, n)` — handles month-end and leap year edge cases -- `age_in_months(birth_date, on)` — calculates age in months between dates -- `fourth_birthday(birth_date)` — returns child's 4th birthday date - -**Test coverage:** -- Leap year handling (Feb 29, century years) -- Month-end dates (Jan 31 → Feb 28/29) -- Negative months, same-day calculations -- ≥90% coverage met - ---- - -### Task 6: Swedish Holiday Helper -**Status:** FULLY COMPLETE ✅ - -**Files created:** -- `backend/app/utils/holidays.py` (3 functions) -- `backend/tests/unit/test_holidays.py` (15+ tests) - -**Functions implemented:** -- `get_swedish_holidays(year)` — returns röda dagar for Sweden with caching -- `is_swedish_holiday(date)` — checks if date is a holiday -- `get_holiday_name(date)` — returns holiday name in Swedish - -**Features:** -- Per-year caching (in-memory Dict) -- Uses `holidays` library with Sweden locale -- Swedish language holiday names -- ≥90% coverage met - ---- - -### Task 2: Child CRUD + Settings Endpoints -**Status:** FULLY COMPLETE ✅ - -**Files created:** -- `backend/app/models/child.py` (Pydantic schemas with validation) -- `backend/app/services/child_service.py` (business logic) -- Routes added to `backend/app/routers/settings.py` -- `backend/tests/unit/test_child_service.py` (25+ tests) - -**API Endpoints:** -- `POST /api/children` — create child -- `GET /api/children/{child_id}` — fetch by ID -- `GET /api/children/workspace/{workspace_id}` — list by workspace -- `PUT /api/settings/child/{child_id}` — update child -- `DELETE /api/settings/{child_id}` — delete child - -**Validation:** -- `preschool_start_date > birth_date` enforced -- Both create and update support partial updates -- Error handling via DomainError subclasses - -**Integration:** -- Added to `app/dependencies.py` (ChildServiceDep) -- Registered in `app/main.py` -- Tests use seeded_ids fixture -- ≥90% coverage met - ---- - -### Task 3: PlannedLeaveDay CRUD + PlanningService -**Status:** FULLY COMPLETE ✅ - -**Files created:** -- `backend/app/models/planned_leave.py` (Pydantic schemas) -- `backend/app/services/planning_service.py` (business logic with AnalyticsService integration) -- `backend/app/routers/planned_days.py` (HTTP endpoints) -- `backend/tests/unit/test_planning_service.py` (20+ tests) - -**API Endpoints:** -- `POST /api/planned-days` — add planned day -- `GET /api/planned-days/{planned_leave_day_id}` — fetch by ID -- `GET /api/planned-days/workspace/{workspace_id}` — list by workspace -- `DELETE /api/planned-days/{planned_leave_day_id}` — remove planned day - -**Business Logic:** -- Validation 1: Dubbeldagar quota check via `AnalyticsService.validate_dubbeldagar()` -- Validation 2: No double-booking on same date -- Support for day_type enum (sjukpenning, lagsta, vacation) -- is_dubbeldag flag for special leave types - -**Integration:** -- Added PlanningService and AnalyticsService to `app/dependencies.py` -- Registered in `app/main.py` -- Tests mock analytics validation -- ≥90% coverage met - ---- - -### Task 4: PersonalData Settings Endpoint -**Status:** FULLY COMPLETE ✅ - -**Files created:** -- `backend/app/models/personal_data.py` (Pydantic schemas) -- Route added to `backend/app/routers/settings.py` - -**API Endpoint:** -- `PUT /api/settings/parent/{personal_data_uuid}` — update parent settings - -**Fields updateable:** -- `salary_type` (monthly | hourly) -- `employment_status` (employed | jobseeking | none) - -**Implementation:** -- Thin wrapper over existing PersonalDataRepository.update() -- Partial update support (only update provided fields) -- Error handling: NotFoundError → 404 - ---- - -### Task 8: Frontend Utilities -**Status:** FULLY COMPLETE ✅ - -**Files created:** -- `frontend/src/utils/dates.ts` (6 functions) -- `frontend/src/utils/formatting.ts` (2 functions) -- `frontend/tests/unit/utils/dates.test.ts` (30+ tests) -- `frontend/tests/unit/utils/formatting.test.ts` (15+ tests) - -**Date functions (sv-SE locale):** -- `formatDateForDisplay(date)` → "15 juni 2026" -- `formatDateForInput(date)` → "2026-06-15" -- `parseInputDate(value)` → Date object -- `isValidDate(date)` → boolean type guard -- `getDayName(date)` → "Fredag" -- `getMonthName(date)` → "Juni" - -**Formatting functions:** -- `formatDayCount(days)` → "1 dag" / "25 dagar" (Swedish plurals) -- `formatPercentage(numerator, denominator)` → "75%" (rounded) - -**Test coverage:** -- Edge cases (leap years, month boundaries, invalid inputs) -- All 12 months tested -- ≥90% coverage met for both files - ---- - -### Task 9: Data Hooks -**Status:** FULLY COMPLETE ✅ - -**Files created:** -- `frontend/src/hooks/useBreakPeriods.ts` + test -- `frontend/src/hooks/usePlannedDays.ts` + test -- `frontend/src/hooks/useAnalytics.ts` + test -- `frontend/src/hooks/useParentalLeave.ts` + test -- `frontend/src/hooks/useOptimizationTips.ts` + test - -**Hook: useBreakPeriods(workspaceId)** -- State: `data`, `isLoading`, `error` -- Actions: `add(startDate, endDate, name, targetWeeks)`, `remove(id)`, `refresh()` -- Auto-fetches on mount, refetches after mutations -- Mocks: api.getBreakPeriods, api.createBreakPeriod, api.deleteBreakPeriod - -**Hook: usePlannedDays(workspaceId, childId?)** -- State: `data`, `isLoading`, `error` -- Actions: `add(date, childId, leaveType)`, `remove(id)`, `refresh()` -- Mocks: api.getPlannedDays, api.createPlannedDay, api.deletePlannedDay - -**Hook: useAnalytics(workspaceId)** -- State: `data` (WorkspaceOverview), `isLoading`, `error` -- Actions: `refresh()` -- Mocks: api.getWorkspaceOverview - -**Hook: useParentalLeave(workspaceId)** -- State: `data` (ParentalLeaveFetch), `isLoading`, `error` -- Actions: `refresh()` -- Mocks: api.getParentalLeave - -**Hook: useOptimizationTips(workspaceId)** -- State: `data` (filtered Tips[]), `isLoading`, `error` -- Actions: `refresh()`, `dismiss(tipId)` (client-side filtering) -- Mocks: api.getTips - -**All hooks:** -- Follow useVacationDays pattern exactly -- Full TypeScript type safety -- Comprehensive Vitest test suites -- ≥90% coverage met for all hooks - ---- - -### Task 7: Presentational Components -**Status:** 50% COMPLETE (7 of 14 components built) ⚠️ - -**Completed components (with tests):** -1. ✅ `Header.tsx` + test — navigation, logout, workspace selector -2. ✅ `Footer.tsx` + test — copyright footer with year -3. ✅ `Layout.tsx` — wrapper combining Header/Footer (test incomplete) -4. ✅ `LoadingSpinner.tsx` + test — centered spinner with message -5. ✅ `OverviewMetrics.tsx` + test — metrics display with role="list" -6. ✅ `ChildBirthdayInput.tsx` + test — date inputs for child birth/preschool -7. ✅ Layout.test.tsx (partial) - -**Missing components (7 of 14):** - -| Component | Purpose | Status | -|-----------|---------|--------| -| `CalendarDay.tsx` | Single day cell (date, events, clickable) | ❌ Not built | -| `CalendarEvent.tsx` | Single event in day (leave type, color) | ❌ Not built | -| `DaysBreakdown.tsx` | Breakdown table (vacation, parental, unpaid) | ❌ Not built | -| `ProgressChart.tsx` | Visual progress bar to day 4 deadline | ❌ Not built | -| `TipCard.tsx` | Single tip display (icon, title, dismiss) | ❌ Not built | -| `VacationDaysInput.tsx` | Number input for vacation days | ❌ Not built | -| `ParentProfileInput.tsx` | Selects for salary_type, employment_status | ❌ Not built | -| `PlanningGoalSelector.tsx` | Selector for workspace planning_goal | ❌ Not built | -| `BreakPeriodsInput.tsx` | Add/edit break periods (dates, type, notes) | ❌ Not built | - ---- - -## ⚠️ PARTIALLY COMPLETE TASKS - -### Task 7: Presentational Components -**Status:** 50% complete — 7 of 14 components built - -**What's built:** -- Core layout components (Header, Footer, Layout) -- Common utilities (LoadingSpinner) -- Dashboard metrics display (OverviewMetrics) -- First input component (ChildBirthdayInput) - -**What's missing:** -- 7 calendar/metrics/settings input components -- All tests for Layout.tsx component (file exists but incomplete) - -**Effort to complete:** -- ~4 hours: ~20 minutes per component (+ ~10 min per test) -- Follow existing pattern exactly (Header/Footer/ChildBirthdayInput) -- All components will be pure functional, props-only, no business logic -- All tests will use Vitest + @testing-library/react - ---- - -## ❌ BLOCKING ISSUES (Require Backend API Implementation) - -The frontend hooks are **complete and tested**, but they depend on API client functions that don't yet exist: - -**Missing API client functions in `frontend/src/services/api.ts`:** - -| Function | Used by | Status | -|----------|---------|--------| -| `getBreakPeriods(workspaceId)` | useBreakPeriods | ❌ Needs implementation | -| `createBreakPeriod(workspaceId, payload)` | useBreakPeriods.add | ❌ Needs implementation | -| `deleteBreakPeriod(id)` | useBreakPeriods.remove | ❌ Needs implementation | -| `getPlannedDays(workspaceId, childId)` | usePlannedDays | ❌ Needs implementation | -| `createPlannedDay(workspaceId, childId, payload)` | usePlannedDays.add | ❌ Needs implementation | -| `deletePlannedDay(id)` | usePlannedDays.remove | ❌ Needs implementation | -| `getParentalLeave(workspaceId)` | useParentalLeave | ❌ Needs implementation | -| `getWorkspaceOverview(workspaceId)` | useAnalytics | ✅ Already exists | -| `getTips(workspaceId)` | useOptimizationTips | ✅ Already exists | - -**Effort to implement:** -- ~30 minutes: Follow existing pattern from `getWorkspaceOverview` (already in the file) -- All functions follow standard: `apiFetch(path, method, body?)` -- Enum/type constants will come from `types/api.ts` - ---- - -## 📊 COMPLETION SUMMARY - -| Category | Status | Count | -|----------|--------|-------| -| Backend utilities | ✅ Complete | 2/2 tasks | -| Backend tests | ✅ Complete | 1/1 task | -| Backend CRUD + services | ✅ Complete | 3/3 tasks | -| Backend settings endpoints | ✅ Complete | 1/1 task | -| Frontend utilities | ✅ Complete | 1/1 task | -| Frontend data hooks | ✅ Complete | 1/1 task | -| Frontend components | ⚠️ 50% complete | 0.5/1 task | -| **TOTALS** | **94% done** | **8.5/9 tasks** | - ---- - -## 🎯 NEXT STEPS TO SHIP - -### Immediate (High Priority) -1. **Add 7 missing presentational components** (Task 7) - - Use existing Header/Footer/ChildBirthdayInput as templates - - Estimated: 4 hours total - - No new patterns needed — all are pure props-only components - -2. **Implement missing API client functions** (frontend) - - Add 6 functions to `frontend/src/services/api.ts` - - Copy pattern from existing `getWorkspaceOverview`, `getTips` - - Estimated: 30 minutes - -### Verification -3. **Run full test suite:** - - Backend: `cd backend && uv run pytest` — should be ≥90% coverage - - Frontend: `cd frontend && npm test` — should be ≥90% coverage - -4. **Manual smoke test** (optional): - - Start backend dev server: `uv run uvicorn app.main:app --reload` - - Start frontend dev server: `npm run dev` - - Walk through create child → plan days → view analytics flow - -### Definition of Done -All tasks meet: -- ✅ Full type safety (no `any` in TS, type hints in Python) -- ✅ ≥90% test coverage (all tasks meet this) -- ✅ Linting clean (`ruff check` / `npm run lint`) -- ✅ Errors as DomainError subclasses (backend) -- ✅ No hard-coded Swedish rule numbers -- ✅ Small focused commits - ---- - -## 📁 FILES CREATED THIS SESSION - -### Backend (22 files) - -**Utilities:** -- `backend/app/utils/dates.py` -- `backend/app/utils/holidays.py` - -**Services:** -- `backend/app/services/child_service.py` -- `backend/app/services/planning_service.py` - -**Models:** -- `backend/app/models/child.py` -- `backend/app/models/planned_leave.py` -- `backend/app/models/personal_data.py` - -**Routers:** -- `backend/app/routers/planned_days.py` -- Routes added to `backend/app/routers/settings.py` - -**Tests (9 files):** -- `backend/tests/unit/test_dates.py` -- `backend/tests/unit/test_holidays.py` -- `backend/tests/unit/test_break_period_repository.py` -- `backend/tests/unit/test_child_service.py` -- `backend/tests/unit/test_planning_service.py` - -**Updated files:** -- `backend/app/dependencies.py` (added ChildServiceDep, PlanningServiceDep, AnalyticsServiceDep) -- `backend/app/main.py` (registered new routers) -- `backend/tests/conftest.py` (added fixtures, updated seeded_ids) - -### Frontend (16 files) - -**Utilities:** -- `frontend/src/utils/dates.ts` -- `frontend/src/utils/formatting.ts` - -**Components (7):** -- `frontend/src/components/layout/Header.tsx` -- `frontend/src/components/layout/Footer.tsx` -- `frontend/src/components/layout/Layout.tsx` -- `frontend/src/components/common/LoadingSpinner.tsx` -- `frontend/src/components/dashboard/OverviewMetrics.tsx` -- `frontend/src/components/inputs/ChildBirthdayInput.tsx` - -**Hooks (5):** -- `frontend/src/hooks/useBreakPeriods.ts` -- `frontend/src/hooks/usePlannedDays.ts` -- `frontend/src/hooks/useAnalytics.ts` -- `frontend/src/hooks/useParentalLeave.ts` -- `frontend/src/hooks/useOptimizationTips.ts` - -**Tests (9):** -- Component tests (6): Header, Footer, LoadingSpinner, OverviewMetrics, ChildBirthdayInput, Layout (partial) -- Utility tests (2): dates.test.ts, formatting.test.ts -- Hook tests (5): one for each hook - ---- - -## 🚀 CONFIDENCE LEVEL - -**8.5/9 tasks complete — 94% ready to ship** - -All foundational work is solid. Remaining work is **straightforward component-building** with no new patterns or architectural decisions. Missing API client functions follow an established pattern in the codebase. - -**Risk assessment:** LOW -- All patterns are proven and tested -- No blockers in existing code -- All dependencies wired correctly -- Clear path to completion: 4–5 hours of work remaining - ---- - -**Report generated:** 2026-06-01 -**Session duration:** Full implementation of planned Tasks 1–9 -**Status:** Ready for final sprint to completion diff --git a/JUNIOR_REPORT_3.md b/JUNIOR_REPORT_3.md deleted file mode 100644 index 0868af4..0000000 --- a/JUNIOR_REPORT_3.md +++ /dev/null @@ -1,297 +0,0 @@ -# Junior Developer Report — Round 3 - -**Date:** 2026-06-01 -**Status:** ✅ Complete -**Baseline:** `JUNIOR_REPORT_2.md`, `JUNIOR_FOLLOWUP_2.md` - ---- - -## Overview - -This round focused on **fixing the broken frontend build and implementing all remaining presentational components and page screens**. The baseline was: - -- Frontend build was **red**: `npx tsc --noEmit` had 35+ errors -- 4 core domain hooks were stubbed but calling non-existent API functions -- 4 page stubs (Dashboard, Calendar, Tips, Settings) were empty -- 7 of ~14 presentational components were missing -- Hook tests passed only because they mocked non-existent API functions (misleading green) - ---- - -## Phase 1: Fixed TypeScript Compilation ✅ - -**Goal:** Make `npx tsc --noEmit` pass cleanly. This unblocks all downstream work. - -### Changes Made - -1. **Added 8 Missing API Client Functions** (`src/services/api.ts`) - - `getBreakPeriods(workspaceId)` → `GET /api/workspaces/{id}/break-periods` - - `createBreakPeriod(workspaceId, payload)` → `POST /api/workspaces/{id}/break-periods` - - `deleteBreakPeriod(id)` → `DELETE /api/break-periods/{id}` - - `getTips(workspaceId)` → `GET /api/workspaces/{id}/tips` - - `getParentalLeave(workspaceId)` → `GET /api/workspaces/{id}/parental-leave` - - `getPlannedDays(workspaceId, childId?)` → `GET /api/workspaces/{id}/planned-days` - - `createPlannedDay(workspaceId, childId, payload)` → `POST /api/workspaces/{id}/planned-days` - - `deletePlannedDay(id)` → `DELETE /api/planned-days/{id}` - -2. **Fixed `useAnalytics.ts` Hook** - - Made `childId` optional parameter (matches pattern of `usePlannedDays`) - - Now handles cases where child hasn't been selected yet - -3. **Fixed `ApiError` Strict Optional Properties** - - Changed from `details: undefined` to conditionally omitting the field - - Complies with `exactOptionalPropertyTypes: true` in tsconfig - -4. **Exported `HeaderProps`** from `Header.tsx` - - Was declared but not exported; import in `Layout.tsx` now works - -5. **Updated Hook Tests** - - Fixed mock data to use correct camelCase field names (startDate, endDate, etc.) - - Added childId parameter to useAnalytics tests - - Fixed unused variable warnings - -### Verification -- ✅ `npx tsc --noEmit`: 0 errors (was 35+) -- ✅ `npm run lint`: 0 errors -- ✅ `npm test`: 13/22 test files passing (hooks + existing components) - ---- - -## Phase 2: Built 9 Presentational Components ✅ - -**Goal:** Create all remaining UI components, fully tested, following existing patterns. - -### Components Built - -| Component | Type | Status | Coverage | -|-----------|------|--------|----------| -| `CalendarDay` | common | ✅ | 9 tests | -| `CalendarEvent` | common | ✅ | 8 tests | -| `DaysBreakdown` | dashboard | ✅ | 5 tests | -| `ProgressChart` | dashboard | ✅ | 8 tests | -| `TipCard` | inputs | ✅ | 8 tests | -| `VacationDaysInput` | inputs | ✅ | 8 tests | -| `ParentProfileInput` | inputs | ✅ | 8 tests | -| `PlanningGoalSelector` | inputs | ✅ | 7 tests | -| `BreakPeriodsInput` | inputs | ✅ | 9 tests | - -### Pattern Followed - -All components: -- **Typed props interface** (exported, no `any`) -- **Semantic HTML** with ARIA attributes and `data-testid` -- **Presentational only** — no business logic, no API calls, no state management -- **Comprehensive tests** covering: - - Default rendering - - Custom props - - Event handlers - - Edge cases (empty state, validation, etc.) - - Accessibility attributes -- **No CSS files** (assume global/Tailwind styling) - -### Key Implementations - -- **CalendarDay/CalendarEvent**: Event rendering with color coding -- **ProgressChart**: Percentage calculation with visual bar -- **BreakPeriodsInput**: Inline add/edit form with validation -- **VacationDaysInput**: Number input with min/max enforcement -- **ParentProfileInput**: Dual selects with predefined options - -### Verification -- ✅ `npx tsc --noEmit`: 0 errors -- ✅ `npm run lint`: 0 errors (added test-file exception in eslint.config.js) -- ✅ `npm test`: 22/22 test files passing, 167 tests total, all green - ---- - -## Phase 3: Wired Page Stubs into Working Screens ✅ - -**Goal:** Implement 4 page stubs as integration layers connecting hooks → components → rendering. - -### Pages Implemented - -#### 1. **Dashboard** (`src/pages/Dashboard.tsx`) -- **Hooks:** `useAnalytics`, `usePlannedDays`, `useBreakPeriods` -- **Components:** `OverviewMetrics`, `DaysBreakdown`, `ProgressChart` -- **Features:** - - Displays workspace overview metrics - - Shows breakdown of planned days by type - - Renders progress bar toward age-4 deadline - - Lists active break periods - -#### 2. **Calendar** (`src/pages/Calendar.tsx`) -- **Hooks:** `usePlannedDays`, `useVacationDays` -- **Components:** `CalendarDay`, `CalendarEvent` -- **Features:** - - Month-based calendar grid (7-column layout) - - Renders event badges by leave type with color coding - - Month navigation (prev/next buttons) - - Day-of-week headers - - Legend showing event colors - - Handles current/non-current month styling - -#### 3. **Tips** (`src/pages/Tips.tsx`) -- **Hooks:** `useOptimizationTips` -- **Components:** `TipCard` -- **Features:** - - Lists optimization tips with severity badges - - Dismiss functionality (client-side, not persisted) - - Empty state message when no tips - -#### 4. **Settings** (`src/pages/Settings.tsx`) -- **Hooks:** `useVacationDays`, `useBreakPeriods` -- **Components:** `ChildBirthdayInput`, `ParentProfileInput`, `PlanningGoalSelector`, `VacationDaysInput`, `BreakPeriodsInput` -- **Features:** - - Workspace configuration panels - - Save button for vacation days - - Add/remove break periods - - Success/error message feedback - - Form state management (useState for local changes) - -### Infrastructure Added - -1. **`src/context/index.ts`** — Re-exports `useAuth`, `useWorkspace` hooks -2. **`src/hooks/index.ts`** — Re-exports all domain hooks (useAnalytics, useBreakPeriods, etc.) -3. **ESLint Config** — Added exception for `@typescript-eslint/no-explicit-any` in test files and pages (temporary types) - -### Pattern Used Across Pages - -```typescript -export function PageName(): ReactNode { - const { workspace, isLoading: wsLoading } = useWorkspace() - const { data, isLoading: dataLoading, error } = useDataHook(workspace?.id ?? null) - - if (isLoading) return - - return ( - -
- {error && } - -
- -
-
-
- ) -} -``` - -### Verification -- ✅ `npx tsc --noEmit`: 0 errors -- ✅ `npm run lint`: 0 errors -- ✅ `npm test`: 22/22 test files passing, 167 tests total -- ✅ All pages render without console errors - ---- - -## Final Status - -### Metrics - -| Metric | Before | After | Status | -|--------|--------|-------|--------| -| TypeScript Errors | 35+ | 0 | ✅ | -| ESLint Errors | 15+ | 0 | ✅ | -| Test Files | 13 | 22 | ✅ | -| Tests Passing | 95 | 167 | ✅ | -| Components | 7 | 16 | ✅ | -| Pages (Implemented) | 0 | 4 | ✅ | -| API Functions | 10 | 18 | ✅ | - -### Code Quality - -- ✅ **Type Safety:** No unexplained `any` (except deliberate in pages/tests) -- ✅ **Test Coverage:** ≥90% on all new components -- ✅ **Linting:** Zero errors, follows existing patterns -- ✅ **Architecture:** Hooks → Components → Pages separation maintained -- ✅ **Accessibility:** Semantic HTML, ARIA labels, data-testid on all elements -- ✅ **Commits:** Small, focused commits per logical change (3 total: Phase 1, Phase 2, Phase 3) - ---- - -## What's Left To Do - -### Out of Scope (Senior Handles) - -Per the plan, these were explicitly marked as senior work: - -1. **Tips Engine Rule Logic** (`backend/services/tips_service.py`) - - The 9 stubbed rules: SGI, red-day wrapping, dubbeldagar optimization, etc. - - Frontend TipCard UI is ready; backend rules still need implementation - - See `SENIOR_FOLLOWUP_2.md` for details - -2. **API Route Implementations** on backend - - All routes were assumed to exist and work - - Frontend is ready to call them - -### No Further Frontend Work Required - -The frontend is **feature-complete for this round**: -- ✅ All TypeScript compilation errors fixed -- ✅ All presentational components built -- ✅ All page screens wired and rendering -- ✅ All hooks properly integrated -- ✅ Full test coverage (167 tests, all passing) -- ✅ Zero build errors or warnings - -### Future Work (Not This Round) - -If/when continuing: - -1. **Settings Form Submission** — Currently Settings accepts input but doesn't fully submit changes. The backend endpoints would need to be called when forms are submitted (currently partial stub). - -2. **Calendar Interactivity** — Day clicks don't do anything yet; could add a day detail modal or inline editing. - -3. **Real-Time Updates** — Pages could refresh on interval or use WebSocket for live tip updates. - -4. **Error Recovery** — Add retry buttons for failed API calls. - -5. **Responsive Design** — Test on mobile; components may need CSS adjustments. - -6. **Accessibility Audit** — Full a11y pass (keyboard navigation, screen reader testing, etc.). - ---- - -## Commits This Round - -1. **Phase 1:** `fbb7a04` — Fix TypeScript compilation (8 API functions, hook fixes) -2. **ESLint:** `faebcce` — Fix ESLint warnings (test exception, `unknown` type) -3. **Phase 2:** `425e29e` — Build 9 presentational components (18 files, 2048 lines) -4. **Phase 3:** `719da83` — Wire 4 pages and add hook/context exports (415 lines) - ---- - -## How to Continue - -### If You're the Next Junior Developer - -1. **Read the plan file:** `/Users/jhag/.claude/plans/junior-developer-virtual-hopper.md` -2. **Understand the pattern:** Look at Dashboard, Calendar, Tips, Settings to see how pages integrate hooks + components -3. **Next logical step:** Wire up form submissions in Settings (currently no-op) to call the backend upsert/add/remove methods -4. **Then:** Add interactivity to Calendar (day click handling, maybe a modal) -5. **Then:** Implement the tips engine rule logic (backend, but frontend is ready to display results) - -### Testing - -To verify everything still works: -```bash -cd frontend -npx tsc --noEmit # Should be clean -npm run lint # Should be clean -npm test # Should have 167 passing tests -``` - ---- - -## Summary - -**All goals for Round 3 achieved:** - -✅ Frontend build is **unblocked** (TypeScript clean) -✅ All **9 presentational components** built with full test coverage -✅ All **4 page screens** implemented and wired to hooks/components -✅ Code is **type-safe**, **fully tested**, **properly linted** -✅ Frontend is **ready for integration** with backend services - -The codebase is in excellent shape for handoff or future extension. No blockers remain. diff --git a/JUNIOR_styling_plan.md b/JUNIOR_styling_plan.md deleted file mode 100644 index 23cf743..0000000 --- a/JUNIOR_styling_plan.md +++ /dev/null @@ -1,153 +0,0 @@ -# Junior Developer — Styling Plan - -**Owner:** Junior developer (the filename says it — this is frontend craft, not backend) -**Status:** Ready to implement (best **after** `frontend_mvp_complete.md` Tier 1, so the header/nav exist to style; the foundation in S1 can start anytime) -**Goal:** Turn the unstyled MVP into a calm, trustworthy, readable app — without rewriting components. - -> **Big win you already have:** every component uses consistent BEM-style class names (`dashboard__section`, `calendar__grid`, `progress-chart__fill`, `settings-page__message--success`, …) but there is **no CSS at all** and nothing is imported. So you can style the *entire* app from stylesheets keyed to those existing classes with **almost zero JSX changes**. Don't refactor markup to style it — write CSS that targets what's there. - ---- - -## S0 — Decisions (don't introduce a new build dependency) - -- **Use plain CSS with design tokens (CSS custom properties).** ARCHITECTURE already anticipates `src/styles/global.css` + `src/styles/components.module.css`. Match that. -- **Do NOT add Tailwind / styled-components / a UI kit** mid-MVP. They'd mean rewriting every `className` and adding build config — high churn, no payoff right now. (If the team later wants a framework, that's a separate, deliberate migration.) -- Keep it light: one global stylesheet for tokens + base + layout + shared component classes. Reach for CSS Modules (`*.module.css`) only if a component needs scoping that the BEM names don't already give you. -- **Tone:** this app touches family + money, so aim for *calm and reassuring*, not flashy — generous whitespace, soft neutral background, one trustworthy accent color, clear hierarchy. ARCHITECTURE calls the calendar "Google Calendar-like," so use that as the visual north star for the calendar. - ---- - -## S1 — Foundation: tokens, reset, typography - -**Create `src/styles/global.css` and import it once** at the top of `src/main.tsx` (`import './styles/global.css'`). - -**Define design tokens** as CSS variables on `:root` so everything else references them (change once, update everywhere). Starter set — tune the palette to taste: - -```css -:root { - /* Color */ - --c-bg: #f7f8fa; - --c-surface: #ffffff; - --c-border: #e3e6eb; - --c-text: #1c2733; - --c-text-muted: #5b6675; - --c-accent: #2f6f4f; /* calm green — trustworthy, on-brand for "leave/time at home" */ - --c-accent-weak: #e8f1ec; - --c-danger: #b3261e; - --c-danger-weak: #fdeceb; - --c-success: #1e7d4f; - --c-success-weak: #e7f4ec; - --c-warning: #8a6d1f; - - /* Spacing scale (8px base) */ - --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; - --sp-5: 24px; --sp-6: 32px; --sp-8: 48px; - - /* Type scale */ - --fs-sm: 0.875rem; --fs-base: 1rem; --fs-lg: 1.25rem; - --fs-xl: 1.5rem; --fs-2xl: 2rem; - --lh: 1.5; - - /* Shape & depth */ - --radius: 8px; - --shadow-sm: 0 1px 2px rgba(16,24,40,0.06); - --shadow-md: 0 4px 12px rgba(16,24,40,0.08); - - --maxw: 960px; /* content column width */ - color-scheme: light; -} -``` - -Then a small reset + base: `*, *::before, *::after { box-sizing: border-box }`, zero default margins, set `body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; background: var(--c-bg); color: var(--c-text); line-height: var(--lh); }`, a sensible type scale on `h1–h3`, and a centered content container. - -**Acceptance:** importing one file visibly transforms the whole app — proper font, background, spacing, readable headings — with no component edits. - ---- - -## S2 — App shell: header, nav, footer, page container - -Targets the layout classes (`[data-testid="layout"]`, `[data-testid="header"]`, `header-nav`, `[data-testid="footer"]`, `.dashboard`, `.settings-page`, etc.) and the header built in `frontend_mvp_complete.md` T1.1. - -**Do:** -- Sticky top header: app name/title left, primary nav center/right (Översikt / Kalender / Tips / Inställningar), and the user + child selector + "Logga ut" on the far right. Give the **active nav link** a clear state (use `NavLink`'s active class). -- Style the **child selector** as a clean dropdown so "which child am I viewing" reads at a glance. -- Constrain main content to `--maxw`, centered, with comfortable page padding. -- Quiet footer (muted text, top border). -- Make every page sit inside this shell consistently (Layout already wraps them). - -**Acceptance:** A coherent top bar on every page with obvious current-page, current-child, and logout; content is centered and comfortably padded. - ---- - -## S3 — Cards, sections, metrics, tables - -Targets `.dashboard__section`, `[data-testid="overview-metrics"]`, `.days-breakdown__table`, `.dashboard__break-list/__break-item`. - -**Do:** -- Turn each `dashboard__section` / `settings-page__section` into a **card**: `background: var(--c-surface)`, `border: 1px solid var(--c-border)`, `border-radius: var(--radius)`, padding `--sp-5`, `box-shadow: var(--shadow-sm)`, vertical rhythm between cards. -- **Overview metrics:** lay the metric list out as a responsive row of stat blocks — big number (`--fs-2xl`, accent or bold), small muted label beneath. This is the hero of the dashboard; make the numbers legible. -- **Tables** (`days-breakdown__table`): full width, subtle row separators, padded cells, muted bold header row, right-align numeric columns. -- Style the **provenance/freshness line** from polish P1 as small muted text. - -**Acceptance:** The dashboard reads as a set of clean cards; the key day numbers stand out; tables are tidy. - ---- - -## S4 — Buttons, forms, inputs, messages - -Targets `.form-group`, `.error-message`, `.settings-page__message--success/--error`, `.parent-profile-input*`, `.planning-goal-selector*`, `.break-periods-input*`, `.vacation-days-input` (and the bare ` + + {startYear}–{endYear} + + + + + {isLoading &&

{t('redDaysList.loading')}

} + {error && ( +

+ {error} +

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

{year}

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

{t('redDaysList.emptyYear')}

+ ) : ( +
    + {items.map((day) => ( +
  • + {formatDateForDisplay(day.date)} + {day.dayType === 'red_day' && day.name && ( + {formatHolidayName(day.name)} + )} + {day.isOverride && ( + + {day.overrideIsWorkingDay ? t('redDaysList.overrideWorking') : t('redDaysList.overrideFree')} + + )} + + {day.isFree ? t('redDaysList.effectiveFree') : t('redDaysList.effectiveWorking')} + +
  • + ))} +
+ )} +
+ ))} + + ) +} diff --git a/frontend/src/components/common/CalendarDay.test.tsx b/frontend/src/components/common/CalendarDay.test.tsx index 85574d6..3572812 100644 --- a/frontend/src/components/common/CalendarDay.test.tsx +++ b/frontend/src/components/common/CalendarDay.test.tsx @@ -85,7 +85,7 @@ describe('CalendarDay', () => { /> ) const day = getByTestId('calendar-day') - expect(day).toHaveAttribute('aria-label', '2026-06-15 with 2 event(s)') + expect(day).toHaveAttribute('aria-label', '2026-06-15 med 2 händelse(r)') }) it('renders without events by default', () => { @@ -109,4 +109,79 @@ describe('CalendarDay', () => { const day = getByTestId('calendar-day') expect(day).not.toHaveClass('calendar-day--today') }) + + describe('§3.4 red day / klämdag shading', () => { + it('applies the red-day class and shows a cleaned-up holiday name', () => { + const { getByTestId } = render( + + ) + const day = getByTestId('calendar-day') + expect(day).toHaveClass('calendar-day--red-day') + // the "; Söndag" weekday suffix is stripped, not dumped raw + expect(day).toHaveAttribute('title', 'Påskdagen') + expect(day.getAttribute('aria-label')).toContain('Påskdagen') + expect(day.getAttribute('aria-label')).not.toContain('Söndag') + expect(getByTestId('calendar-day-type-badge')).toHaveTextContent('R') + }) + + it('applies the klämdag class and a non-color aria signal', () => { + const { getByTestId } = render() + const day = getByTestId('calendar-day') + expect(day).toHaveClass('calendar-day--klamdag') + expect(day.getAttribute('aria-label')).toContain('klämdag') + expect(getByTestId('calendar-day-type-badge')).toHaveTextContent('K') + }) + + it('does not shade a plain working day', () => { + const { getByTestId } = render() + const day = getByTestId('calendar-day') + expect(day).not.toHaveClass('calendar-day--red-day') + expect(day).not.toHaveClass('calendar-day--klamdag') + }) + + it('keeps the original aria-label formula when no calendar metadata is supplied (backward compatible)', () => { + const { getByTestId } = render() + const day = getByTestId('calendar-day') + expect(day).toHaveAttribute('aria-label', '2026-06-15') + expect(day).not.toHaveAttribute('title') + }) + }) + + describe('§3.6 workspace day overrides', () => { + it('marks an override with a distinct class and badge, working-day variant', () => { + const { getByTestId } = render( + + ) + const day = getByTestId('calendar-day') + expect(day).toHaveClass('calendar-day--override') + expect(day.getAttribute('aria-label')).toContain('arbetsdag') + expect(getByTestId('calendar-day-override-badge')).toBeInTheDocument() + }) + + it('marks an override with a distinct class and badge, free-day variant', () => { + const { getByTestId } = render( + + ) + const day = getByTestId('calendar-day') + expect(day).toHaveClass('calendar-day--override') + expect(day.getAttribute('aria-label')).toContain('ledig dag') + }) + + it('combines a statutory red day with an override distinctly', () => { + const { getByTestId } = render( + + ) + const day = getByTestId('calendar-day') + expect(day).toHaveClass('calendar-day--red-day') + expect(day).toHaveClass('calendar-day--override') + expect(day.getAttribute('aria-label')).toContain('Midsommardagen') + expect(day.getAttribute('aria-label')).toContain('arbetsdag') + }) + }) }) diff --git a/frontend/src/components/common/CalendarDay.tsx b/frontend/src/components/common/CalendarDay.tsx index 34cc845..8198deb 100644 --- a/frontend/src/components/common/CalendarDay.tsx +++ b/frontend/src/components/common/CalendarDay.tsx @@ -1,4 +1,7 @@ import { ReactNode } from 'react' +import { useTranslation } from '@/i18n' +import { formatHolidayName } from '@/utils/dates' +import type { CalendarDayType } from '@/types' export interface CalendarDayProps { date: string @@ -10,6 +13,14 @@ export interface CalendarDayProps { onPointerDown?: ((date: string) => void) | undefined onPointerEnter?: ((date: string) => void) | undefined onActivate?: (() => void) | undefined + /** §3.4 — statutory calendar classification for this date, from `GET /api/calendar/days`. */ + dayType?: CalendarDayType | undefined + /** Exact holiday name (possibly the combined "X; Söndag" form); only meaningful when `dayType === 'red_day'`. */ + holidayName?: string | null | undefined + /** §3.6 — true iff this date has a workspace day override. */ + isOverride?: boolean | undefined + /** The override's value when `isOverride` is true (true = marked as a working day). */ + overrideIsWorkingDay?: boolean | null | undefined } export function CalendarDay({ @@ -22,7 +33,12 @@ export function CalendarDay({ onPointerDown, onPointerEnter, onActivate, + dayType, + holidayName, + isOverride = false, + overrideIsWorkingDay = null, }: CalendarDayProps): ReactNode { + const { t } = useTranslation() const dayNum = new Date(date + 'T00:00:00Z').getUTCDate() const handleKeyDown = (e: React.KeyboardEvent) => { @@ -35,19 +51,64 @@ export function CalendarDay({ } } + const cleanHolidayName = holidayName ? formatHolidayName(holidayName) : null + + // Non-color signals describing the day's calendar status, appended to the + // existing aria-label formula so unrelated call sites (no dayType/override + // props supplied) keep their exact previous aria-label. + const descriptors: string[] = [] + if (cleanHolidayName) { + descriptors.push(cleanHolidayName) + } else if (dayType === 'klamdag') { + descriptors.push(t('calendarDay.klamdagAria')) + } + if (isOverride) { + descriptors.push( + overrideIsWorkingDay ? t('calendarDay.overrideWorkingAria') : t('calendarDay.overrideFreeAria'), + ) + } + + const baseLabel = events.length > 0 ? t('calendarDay.ariaWithEvents', { date, count: events.length }) : date + const ariaLabel = descriptors.length > 0 ? `${baseLabel}, ${descriptors.join(', ')}` : baseLabel + const title = descriptors.length > 0 ? descriptors.join(' · ') : undefined + + const className = [ + 'calendar-day', + !isCurrentMonth ? 'calendar-day--disabled' : '', + events.length > 0 ? 'calendar-day--has-events' : '', + isToday ? 'calendar-day--today' : '', + isSelected ? 'calendar-day--selected' : '', + dayType === 'red_day' ? 'calendar-day--red-day' : '', + dayType === 'klamdag' ? 'calendar-day--klamdag' : '', + isOverride ? 'calendar-day--override' : '', + ] + .filter(Boolean) + .join(' ') + return (
0 ? ` with ${events.length} event(s)` : ''}`} - className={`calendar-day ${!isCurrentMonth ? 'calendar-day--disabled' : ''} ${events.length > 0 ? 'calendar-day--has-events' : ''} ${isToday ? 'calendar-day--today' : ''} ${isSelected ? 'calendar-day--selected' : ''}`} + aria-label={ariaLabel} + title={title} + className={className} onKeyDown={handleKeyDown} onPointerDown={() => onPointerDown?.(date)} onPointerEnter={() => onPointerEnter?.(date)} >
{dayNum}
+ {(dayType === 'red_day' || dayType === 'klamdag') && ( + + )} + {isOverride && ( + + )} {events.length > 0 && (
{events.map((event, idx) => ( diff --git a/frontend/src/components/common/CalendarEvent.test.tsx b/frontend/src/components/common/CalendarEvent.test.tsx index 56c295b..e2aeec8 100644 --- a/frontend/src/components/common/CalendarEvent.test.tsx +++ b/frontend/src/components/common/CalendarEvent.test.tsx @@ -70,6 +70,6 @@ describe('CalendarEvent', () => { {}} /> ) const button = getByTestId('dismiss-button') - expect(button).toHaveAttribute('aria-label', 'Dismiss vacation') + expect(button).toHaveAttribute('aria-label', 'Avfärda vacation') }) }) diff --git a/frontend/src/components/common/CalendarEvent.tsx b/frontend/src/components/common/CalendarEvent.tsx index d6c3d89..f58c150 100644 --- a/frontend/src/components/common/CalendarEvent.tsx +++ b/frontend/src/components/common/CalendarEvent.tsx @@ -1,4 +1,5 @@ import { ReactNode } from 'react' +import { useTranslation } from '@/i18n' export interface CalendarEventProps { leaveType: string @@ -17,6 +18,7 @@ export function CalendarEvent({ isDubbeldag = false, parentLabel, }: CalendarEventProps): ReactNode { + const { t } = useTranslation() const displayLabel = parentLabel ? `${parentLabel} · ${leaveType}` : leaveType return ( @@ -36,7 +38,7 @@ export function CalendarEvent({ e.stopPropagation() onDismiss() }} - aria-label={`Dismiss ${leaveType}`} + aria-label={t('calendarEvent.dismissAria', { leaveType })} data-testid="dismiss-button" > × diff --git a/frontend/src/components/common/CalendarWeek.test.tsx b/frontend/src/components/common/CalendarWeek.test.tsx new file mode 100644 index 0000000..39aa076 --- /dev/null +++ b/frontend/src/components/common/CalendarWeek.test.tsx @@ -0,0 +1,889 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { CalendarWeek } from './CalendarWeek' +import type { PlannedLeaveDay } from '@/types' + +vi.mock('@/i18n', () => ({ + useTranslation: () => ({ + t: (key: string, _data?: Record) => { + const translations: Record = { + 'calendar.partner': 'Partner', + 'leaveType.vacation': 'Semester', + 'leaveType.sjukpenning': 'Sjukpenning', + 'leaveType.lagsta': 'Lägsta nivå', + } + return translations[key] ?? key + }, + }), +})) + +vi.mock('./CalendarDay', () => ({ + CalendarDay: ({ + date, + isSelected, + isCurrentMonth, + isToday, + onClick, + onPointerDown, + onPointerEnter, + dayType, + holidayName, + isOverride, + overrideIsWorkingDay, + }: any) => ( +
onPointerDown?.(date)} + onPointerEnter={() => onPointerEnter?.(date)} + > + {date} +
+ ), +})) + +vi.mock('./CalendarEvent', () => ({ + CalendarEvent: ({ leaveType, isDubbeldag, parentLabel }: any) => ( +
+ {parentLabel} · {leaveType} +
+ ), +})) + +describe('CalendarWeek', () => { + const defaultProps = { + weekDates: ['2026-01-05', '2026-01-06', '2026-01-07', '2026-01-08', '2026-01-09', '2026-01-10', '2026-01-11'], + plannedDays: [], + selectedDates: new Set(), + parentName: 'Test Parent', + personalDataUuid: 'parent-1', + isCurrentMonth: true, + showWeekNumbers: false, + onDayClick: vi.fn(), + onPointerDown: vi.fn(), + onPointerEnter: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('week number display', () => { + it('shows week number when showWeekNumbers is true', () => { + render() + // Week containing 2026-01-05 is week 2 + expect(screen.getByText(/v\.\d+/)).toBeInTheDocument() + }) + + it('hides week number when showWeekNumbers is false', () => { + render() + const weekNumberElement = screen.queryByText(/^v\.\d+$/) + // The week number is rendered only inside a specific class; when showWeekNumbers is false, + // the conditional doesn't render it + expect(weekNumberElement).not.toBeInTheDocument() + }) + + it('displays correct week number for a given week', () => { + // 2026-01-05 is a Monday in week 2 + render() + const weekNumberText = screen.getByText('v.2') + expect(weekNumberText).toBeInTheDocument() + }) + }) + + describe('day rendering', () => { + it('renders all 7 days of the week', () => { + render() + defaultProps.weekDates.forEach((date) => { + expect(screen.getByTestId(`calendar-day-${date}`)).toBeInTheDocument() + }) + }) + + it('renders each day with correct date string', () => { + render() + defaultProps.weekDates.forEach((date) => { + expect(screen.getByText(date)).toBeInTheDocument() + }) + }) + + it('marks all days as current month when isCurrentMonth is true', () => { + render() + defaultProps.weekDates.forEach((date) => { + const dayElement = screen.getByTestId(`calendar-day-${date}`) + expect(dayElement).toHaveClass('current-month') + }) + }) + + it('marks days outside the current month with current-month class set to false', () => { + // When a week spans month boundaries, days are marked based on their month vs the first day's month + // 2025-12-29 is first, so 12/29-31 are marked as current (Dec), 01/01-04 are marked as outside (Jan) + const prevMonthDates = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04'] + render( + , + ) + // Days from previous month (Dec 2025) should have current-month class (first date determines the month) + const decDayElement = screen.getByTestId('calendar-day-2025-12-29') + expect(decDayElement).toHaveClass('current-month') + + // Days from next month should NOT have current-month class + const janDayElement = screen.getByTestId('calendar-day-2026-01-01') + expect(janDayElement).not.toHaveClass('current-month') + }) + + it('highlights today when date matches current date', () => { + const today = new Date() + const todayString = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}` + const weekDates = [ + '2026-01-05', + '2026-01-06', + '2026-01-07', + '2026-01-08', + '2026-01-09', + '2026-01-10', + '2026-01-11', + ] + // Only test if today happens to fall in the week we're rendering + if (weekDates.includes(todayString)) { + render( + , + ) + const todayElement = screen.getByTestId(`calendar-day-${todayString}`) + expect(todayElement).toHaveClass('today') + } + }) + }) + + describe('selected dates', () => { + it('marks selected dates in the week', () => { + const selectedDates = new Set(['2026-01-06', '2026-01-07']) + render() + expect(screen.getByTestId('calendar-day-2026-01-06')).toHaveClass('selected') + expect(screen.getByTestId('calendar-day-2026-01-07')).toHaveClass('selected') + }) + + it('does not mark unselected dates', () => { + const selectedDates = new Set(['2026-01-06']) + render() + expect(screen.getByTestId('calendar-day-2026-01-05')).not.toHaveClass('selected') + }) + + it('handles empty selection set', () => { + render() + defaultProps.weekDates.forEach((date) => { + expect(screen.getByTestId(`calendar-day-${date}`)).not.toHaveClass('selected') + }) + }) + }) + + describe('planned days and bars', () => { + it('renders bars for planned days in the week', () => { + const plannedDays: PlannedLeaveDay[] = [ + { + id: '1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-05', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ] + render() + expect(screen.getByTestId('calendar-event')).toBeInTheDocument() + }) + + it('does not render bars for planned days outside the week', () => { + const plannedDays: PlannedLeaveDay[] = [ + { + id: '1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-02-05', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ] + render() + expect(screen.queryByTestId('calendar-event')).not.toBeInTheDocument() + }) + + it('renders parent name for own planned days', () => { + const plannedDays: PlannedLeaveDay[] = [ + { + id: '1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-05', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ] + render() + expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument() + }) + + it('renders "Partner" label for partner planned days', () => { + const plannedDays: PlannedLeaveDay[] = [ + { + id: '1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'other-parent', + date: '2026-01-05', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ] + render() + expect(screen.getByText(/Partner · Semester/)).toBeInTheDocument() + }) + + it('renders different day types with localized labels', () => { + const plannedDays: PlannedLeaveDay[] = [ + { + id: '1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-05', + dayType: 'sjukpenning', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + { + id: '2', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-06', + dayType: 'lagsta', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ] + render() + expect(screen.getByText(/Sjukpenning/)).toBeInTheDocument() + expect(screen.getByText(/Lägsta nivå/)).toBeInTheDocument() + }) + + it('marks dubbeldag days with dubbeldag class', () => { + const plannedDays: PlannedLeaveDay[] = [ + { + id: '1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-05', + dayType: 'vacation', + isDubbeldag: true, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ] + render() + const eventElement = screen.getByTestId('calendar-event') + expect(eventElement).toHaveClass('dubbeldag') + }) + + it('renders continuous bars spanning multiple days', () => { + const plannedDays: PlannedLeaveDay[] = [ + { + id: '1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-05', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + { + id: '2', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-06', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + { + id: '3', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-07', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ] + render() + // Should render at least one bar for the continuous period + const events = screen.getAllByTestId('calendar-event') + expect(events.length).toBeGreaterThan(0) + }) + + it('renders separate bars for different day types on the same day', () => { + const plannedDays: PlannedLeaveDay[] = [ + { + id: '1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-05', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + { + id: '2', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'other-parent', + date: '2026-01-05', + dayType: 'sjukpenning', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ] + render() + const events = screen.getAllByTestId('calendar-event') + // Should have separate bars for the two different (parent, dayType) combinations + expect(events.length).toBeGreaterThanOrEqual(2) + }) + + it('renders separate bars for same day type but different dubbeldag flag', () => { + const plannedDays: PlannedLeaveDay[] = [ + { + id: '1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-05', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + { + id: '2', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-05', + dayType: 'vacation', + isDubbeldag: true, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ] + render() + const events = screen.getAllByTestId('calendar-event') + expect(events.length).toBeGreaterThanOrEqual(2) + }) + + it('handles empty planned days', () => { + render() + expect(screen.queryByTestId('calendar-event')).not.toBeInTheDocument() + }) + }) + + describe('event handlers', () => { + it('calls onDayClick when clicking a day in current month', () => { + render() + const dayElement = screen.getByTestId('calendar-day-2026-01-05') + fireEvent.click(dayElement) + expect(defaultProps.onDayClick).toHaveBeenCalledWith('2026-01-05') + }) + + it('does not call onDayClick for days outside the week\'s reference month', () => { + // When a week spans month boundaries, only days in the same month as the first date + // will trigger handlers. 2025-12-29 is first (Dec), so 01/01+ will not trigger handlers. + const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04'] + const onDayClick = vi.fn() + render( + , + ) + // Click a day outside the reference month (January when December is reference) + const janDayElement = screen.getByTestId('calendar-day-2026-01-01') + fireEvent.click(janDayElement) + expect(onDayClick).not.toHaveBeenCalled() + }) + + it('does not call onDayClick when isCurrentMonth is false', () => { + const onDayClick = vi.fn() + render( + , + ) + const dayElement = screen.getByTestId('calendar-day-2026-01-05') + fireEvent.click(dayElement) + expect(onDayClick).not.toHaveBeenCalled() + }) + + it('calls onPointerDown when starting drag on a current-month day', () => { + render() + const dayElement = screen.getByTestId('calendar-day-2026-01-05') + fireEvent.pointerDown(dayElement) + expect(defaultProps.onPointerDown).toHaveBeenCalledWith('2026-01-05') + }) + + it('does not call onPointerDown for days outside the week\'s reference month', () => { + const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04'] + const onPointerDown = vi.fn() + render( + , + ) + const janDayElement = screen.getByTestId('calendar-day-2026-01-01') + fireEvent.pointerDown(janDayElement) + expect(onPointerDown).not.toHaveBeenCalled() + }) + + it('calls onPointerEnter when dragging through a current-month day', () => { + render() + const dayElement = screen.getByTestId('calendar-day-2026-01-06') + fireEvent.pointerEnter(dayElement) + expect(defaultProps.onPointerEnter).toHaveBeenCalledWith('2026-01-06') + }) + + it('does not call onPointerEnter for days outside the week\'s reference month', () => { + const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04'] + const onPointerEnter = vi.fn() + render( + , + ) + const janDayElement = screen.getByTestId('calendar-day-2026-01-01') + fireEvent.pointerEnter(janDayElement) + expect(onPointerEnter).not.toHaveBeenCalled() + }) + + it('does not call event handlers when isCurrentMonth is false', () => { + const onDayClick = vi.fn() + const onPointerDown = vi.fn() + const onPointerEnter = vi.fn() + render( + , + ) + const dayElement = screen.getByTestId('calendar-day-2026-01-05') + fireEvent.click(dayElement) + fireEvent.pointerDown(dayElement) + fireEvent.pointerEnter(dayElement) + expect(onDayClick).not.toHaveBeenCalled() + expect(onPointerDown).not.toHaveBeenCalled() + expect(onPointerEnter).not.toHaveBeenCalled() + }) + }) + + describe('complex scenarios', () => { + it('handles a week with mixed planned days from both parents', () => { + const plannedDays: PlannedLeaveDay[] = [ + { + id: '1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-05', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + { + id: '2', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-2', + date: '2026-01-05', + dayType: 'sjukpenning', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + { + id: '3', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-06', + dayType: 'vacation', + isDubbeldag: true, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ] + render( + , + ) + expect(screen.getAllByText(/Alice · Semester/)).toHaveLength(2) + expect(screen.getByText(/Partner · Sjukpenning/)).toBeInTheDocument() + const dubbeldagElements = screen.getAllByTestId('calendar-event').filter((el) => el.classList.contains('dubbeldag')) + expect(dubbeldagElements.length).toBeGreaterThan(0) + }) + + it('handles broken bar rendering with gaps in consecutive days', () => { + const plannedDays: PlannedLeaveDay[] = [ + { + id: '1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-05', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + { + id: '2', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-06', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + // Gap on 2026-01-07 + { + id: '3', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-08', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ] + render() + const events = screen.getAllByTestId('calendar-event') + // Should have at least 2 bars: one for 05-06 and one for 08 + expect(events.length).toBeGreaterThanOrEqual(2) + }) + + it('renders all day types with correct colors', () => { + const plannedDays: PlannedLeaveDay[] = [ + { + id: '1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-05', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + { + id: '2', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-06', + dayType: 'sjukpenning', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + { + id: '3', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-07', + dayType: 'lagsta', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ] + render() + const events = screen.getAllByTestId('calendar-event') + expect(events.length).toBeGreaterThanOrEqual(3) + }) + + it('filters bars to only show those spanning the current day cell', () => { + const plannedDays: PlannedLeaveDay[] = [ + { + id: '1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-05', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + { + id: '2', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-06', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ] + render() + // Should render bars for both 2026-01-05 and 2026-01-06 + // The bars represent a continuous period, so they should be visible + const events = screen.getAllByTestId('calendar-event') + expect(events.length).toBeGreaterThan(0) + }) + }) + + describe('month boundary edge cases', () => { + it('correctly identifies days at month boundaries', () => { + // Jan 31 and Feb 1 in the same week + const weekDates = ['2026-01-26', '2026-01-27', '2026-01-28', '2026-01-29', '2026-01-30', '2026-01-31', '2026-02-01'] + render( + , + ) + // Jan 31 (index 5) should be in current month + const jan31 = screen.getByTestId('calendar-day-2026-01-31') + expect(jan31).toHaveClass('current-month') + + // Feb 1 (index 6) should NOT be in current month (isCurrentMonth only marks first week's month ref) + const feb1 = screen.getByTestId('calendar-day-2026-02-01') + expect(feb1).not.toHaveClass('current-month') + }) + + it('handles start of month week', () => { + const weekDates = ['2026-01-26', '2026-01-27', '2026-01-28', '2026-01-29', '2026-01-30', '2026-01-31', '2026-02-01'] + render( + , + ) + // All dates should render + weekDates.forEach((date) => { + expect(screen.getByTestId(`calendar-day-${date}`)).toBeInTheDocument() + }) + }) + + it('handles end of month week', () => { + const weekDates = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04'] + render( + , + ) + // All dates should render + weekDates.forEach((date) => { + expect(screen.getByTestId(`calendar-day-${date}`)).toBeInTheDocument() + }) + }) + }) + + describe('memoization and performance', () => { + it('memoizes bar computation based on weekDates and plannedDays', () => { + const plannedDays: PlannedLeaveDay[] = [ + { + id: '1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-05', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ] + const { rerender } = render( + , + ) + expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument() + + // Change unrelated prop (onDayClick) - bars should remain the same + const newOnDayClick = vi.fn() + rerender( + , + ) + expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument() + }) + + it('recomputes bars when plannedDays change', () => { + const plannedDays1: PlannedLeaveDay[] = [ + { + id: '1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-05', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ] + const plannedDays2: PlannedLeaveDay[] = [ + ...plannedDays1, + { + id: '2', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2026-01-06', + dayType: 'sjukpenning', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ] + const { rerender } = render( + , + ) + expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument() + expect(screen.queryByText(/Sjukpenning/)).not.toBeInTheDocument() + + rerender( + , + ) + expect(screen.getByText(/Sjukpenning/)).toBeInTheDocument() + }) + }) + + describe('§3.4/§3.6 calendarDaysByDate pass-through', () => { + it('passes matching calendar metadata down to the CalendarDay for that date', () => { + const calendarDaysByDate = new Map([ + [ + '2026-01-06', + { + date: '2026-01-06', + name: 'Trettondedag jul', + dayType: 'red_day' as const, + isOverride: false, + overrideIsWorkingDay: null, + isFree: true, + }, + ], + ]) + + render() + + const cell = screen.getByTestId('calendar-day-2026-01-06') + expect(cell).toHaveAttribute('data-day-type', 'red_day') + expect(cell).toHaveAttribute('data-holiday-name', 'Trettondedag jul') + expect(cell).toHaveAttribute('data-is-override', 'false') + }) + + it('passes override metadata down for an overridden date', () => { + const calendarDaysByDate = new Map([ + [ + '2026-01-07', + { + date: '2026-01-07', + name: null, + dayType: 'working_day' as const, + isOverride: true, + overrideIsWorkingDay: false, + isFree: true, + }, + ], + ]) + + render() + + const cell = screen.getByTestId('calendar-day-2026-01-07') + expect(cell).toHaveAttribute('data-is-override', 'true') + expect(cell).toHaveAttribute('data-override-is-working-day', 'false') + }) + + it('leaves days with no matching entry unaffected when calendarDaysByDate is omitted', () => { + render() + + const cell = screen.getByTestId('calendar-day-2026-01-05') + expect(cell).toHaveAttribute('data-day-type', '') + expect(cell).toHaveAttribute('data-is-override', 'false') + }) + }) +}) diff --git a/frontend/src/components/common/CalendarWeek.tsx b/frontend/src/components/common/CalendarWeek.tsx index 45cb5c7..32de3dd 100644 --- a/frontend/src/components/common/CalendarWeek.tsx +++ b/frontend/src/components/common/CalendarWeek.tsx @@ -1,7 +1,8 @@ import { ReactNode, useMemo } from 'react' -import type { PlannedLeaveDay } from '@/types' +import type { PlannedLeaveDay, CalendarDayEntry } from '@/types' import { getColorForType, getLabelForType } from '@/utils/leaveTypes' import { getISOWeekNumber } from '@/utils/dates' +import { useTranslation } from '@/i18n' import { CalendarDay } from './CalendarDay' import { CalendarEvent } from './CalendarEvent' @@ -16,6 +17,8 @@ export interface CalendarWeekProps { onDayClick: (date: string) => void onPointerDown: (date: string) => void onPointerEnter: (date: string) => void + /** §3.4/§3.6 — calendar metadata (röda dagar/klämdagar/overrides) keyed by ISO date. */ + calendarDaysByDate?: Map | undefined } interface BarData { @@ -164,7 +167,9 @@ export function CalendarWeek({ onDayClick, onPointerDown, onPointerEnter, + calendarDaysByDate, }: CalendarWeekProps): ReactNode { + const { t } = useTranslation() const bars = useMemo(() => buildBarsForWeek(weekDates, plannedDays), [weekDates, plannedDays]) const maxLane = bars.length > 0 ? Math.max(...bars.map((b) => b.lane)) : 0 @@ -193,6 +198,7 @@ export function CalendarWeek({ const weekDateObj = new Date(weekDates[0] + 'T00:00:00Z') const dateObj = new Date(date + 'T00:00:00Z') const dayIsCurrentMonth = dateObj.getMonth() === weekDateObj.getMonth() + const calendarInfo = calendarDaysByDate?.get(date) return (
@@ -201,6 +207,10 @@ export function CalendarWeek({ isCurrentMonth={isCurrentMonth && dayIsCurrentMonth} isToday={date === getTodayString()} isSelected={selectedDates.has(date)} + dayType={calendarInfo?.dayType} + holidayName={calendarInfo?.name ?? null} + isOverride={calendarInfo?.isOverride ?? false} + overrideIsWorkingDay={calendarInfo?.overrideIsWorkingDay ?? null} onClick={() => { if (isCurrentMonth && dayIsCurrentMonth) { onDayClick(date) @@ -236,8 +246,8 @@ export function CalendarWeek({ }) .map((bar, idx) => { const parentUuid = bar.parentUuid - const label = parentUuid === personalDataUuid ? parentName : 'Partner' - const typeLabel = getLabelForType(bar.dayType) + const label = parentUuid === personalDataUuid ? parentName : t('calendar.partner') + const typeLabel = getLabelForType(bar.dayType, t) const color = getColorForType(bar.dayType) return ( diff --git a/frontend/src/components/dashboard/DaysBreakdown.test.tsx b/frontend/src/components/dashboard/DaysBreakdown.test.tsx index dc71f70..7bcd257 100644 --- a/frontend/src/components/dashboard/DaysBreakdown.test.tsx +++ b/frontend/src/components/dashboard/DaysBreakdown.test.tsx @@ -5,7 +5,7 @@ import { DaysBreakdown } from './DaysBreakdown' describe('DaysBreakdown', () => { it('renders empty state when no breakdown provided', () => { const { getByTestId } = render() - expect(getByTestId('days-breakdown').textContent).toContain('No days recorded yet') + expect(getByTestId('days-breakdown').textContent).toContain('Inga dagar registrerade än') }) it('renders table with breakdown data', () => { diff --git a/frontend/src/components/dashboard/DaysBreakdown.tsx b/frontend/src/components/dashboard/DaysBreakdown.tsx index 7e126cc..80eac8b 100644 --- a/frontend/src/components/dashboard/DaysBreakdown.tsx +++ b/frontend/src/components/dashboard/DaysBreakdown.tsx @@ -1,14 +1,17 @@ import { ReactNode } from 'react' +import { useTranslation } from '@/i18n' export interface DaysBreakdownProps { breakdown: Array<{ type: string; count: number }> } export function DaysBreakdown({ breakdown }: DaysBreakdownProps): ReactNode { + const { t } = useTranslation() + if (!breakdown || breakdown.length === 0) { return (
-

No days recorded yet

+

{t('daysBreakdown.empty')}

) } @@ -18,8 +21,8 @@ export function DaysBreakdown({ breakdown }: DaysBreakdownProps): ReactNode { - - + + diff --git a/frontend/src/components/dashboard/OverviewMetrics.test.tsx b/frontend/src/components/dashboard/OverviewMetrics.test.tsx index f1f23b3..3d44d1e 100644 --- a/frontend/src/components/dashboard/OverviewMetrics.test.tsx +++ b/frontend/src/components/dashboard/OverviewMetrics.test.tsx @@ -10,7 +10,7 @@ describe('OverviewMetrics', () => { it('renders default title', () => { render() - expect(screen.getByTestId('metrics-title')).toHaveTextContent('Overview') + expect(screen.getByTestId('metrics-title')).toHaveTextContent('Översikt') }) it('renders custom title', () => { @@ -20,14 +20,14 @@ describe('OverviewMetrics', () => { it('renders metrics list with items', () => { const metrics = [ - { label: 'Days Used', value: '10', testId: 'days-used' }, - { label: 'Days Left', value: '15', testId: 'days-left' }, + { label: 'Använda dagar', value: '10', testId: 'days-used' }, + { label: 'Kvarvarande dagar', value: '15', testId: 'days-left' }, ] render() - expect(screen.getByTestId('metric-label-0')).toHaveTextContent('Days Used:') + expect(screen.getByTestId('metric-label-0')).toHaveTextContent('Använda dagar:') expect(screen.getByTestId('metric-value-0')).toHaveTextContent('10') - expect(screen.getByTestId('metric-label-1')).toHaveTextContent('Days Left:') + expect(screen.getByTestId('metric-label-1')).toHaveTextContent('Kvarvarande dagar:') expect(screen.getByTestId('metric-value-1')).toHaveTextContent('15') }) diff --git a/frontend/src/components/dashboard/OverviewMetrics.tsx b/frontend/src/components/dashboard/OverviewMetrics.tsx index 8ff63e4..a715092 100644 --- a/frontend/src/components/dashboard/OverviewMetrics.tsx +++ b/frontend/src/components/dashboard/OverviewMetrics.tsx @@ -1,4 +1,5 @@ import { ReactNode } from 'react' +import { useTranslation } from '@/i18n' interface Metric { label: string @@ -11,10 +12,12 @@ interface OverviewMetricsProps { title?: string } -export function OverviewMetrics({ metrics, title = 'Overview' }: OverviewMetricsProps): ReactNode { +export function OverviewMetrics({ metrics, title }: OverviewMetricsProps): ReactNode { + const { t } = useTranslation() + const resolvedTitle = title ?? t('overviewMetrics.defaultTitle') return ( -
-

{title}

+
+

{resolvedTitle}

{metrics.map((metric, index) => (
{ @@ -38,14 +39,19 @@ describe('ParentalLeaveBreakdown', () => { it('renders breakdown table with data', () => { render() expect(screen.getByTestId('parental-leave-breakdown')).toBeInTheDocument() - expect(screen.getByText(/Reserverade dagar/)).toBeInTheDocument() + expect(screen.getByTestId('breakdown-transferable')).toBeInTheDocument() }) - it('displays reserved days', () => { + it('does not render a reserved-days countdown row', () => { render() - const row = screen.getByTestId('breakdown-reserved') - expect(row).toHaveTextContent('80') - expect(row).toHaveTextContent('90') + expect(screen.queryByTestId('breakdown-reserved')).not.toBeInTheDocument() + }) + + it('displays a reserved-days explanatory note instead of a countdown', () => { + render() + const note = screen.getByTestId('reserved-days-note') + expect(note).toHaveTextContent(`${LEAVE_RULES.reservedDaysPerParent} av dina dagar är reserverade`) + expect(note).toHaveTextContent(`max ${LEAVE_RULES.transferableDaysPerParent} dagar kan överlåtas`) }) it('displays transferable days', () => { diff --git a/frontend/src/components/dashboard/ParentalLeaveBreakdown.tsx b/frontend/src/components/dashboard/ParentalLeaveBreakdown.tsx index 2108ff9..2b563d9 100644 --- a/frontend/src/components/dashboard/ParentalLeaveBreakdown.tsx +++ b/frontend/src/components/dashboard/ParentalLeaveBreakdown.tsx @@ -1,26 +1,31 @@ import { ReactNode } from 'react' import type { ParentalLeaveFetch } from '@/types' +import { LEAVE_RULES } from '@/utils/constants' +import { useTranslation } from '@/i18n' export interface ParentalLeaveBreakdownProps { data: ParentalLeaveFetch | null } export function ParentalLeaveBreakdown({ data }: ParentalLeaveBreakdownProps): ReactNode { + const { t, locale } = useTranslation() + if (!data) { return (
-

- Ingen data hämtad ännu — tryck på Hämta från Försäkringskassan. -

+

{t('parentalLeaveBreakdown.empty')}

) } const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString('sv-SE') + return new Date(dateString).toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US') } - const sourceLabel = data.source === 'scraped' ? 'Hämtad från Försäkringskassan' : 'Manuellt angiven' + const sourceLabel = + data.source === 'scraped' + ? t('parentalLeaveBreakdown.sourceScraped') + : t('parentalLeaveBreakdown.sourceManual') return (
@@ -28,36 +33,31 @@ export function ParentalLeaveBreakdown({ data }: ParentalLeaveBreakdownProps): R
Leave TypeDays{t('daysBreakdown.typeHeader')}{t('daysBreakdown.daysHeader')}
- - - + + + - - - - - - + - + {data.tierData && ( <> - + - + @@ -67,6 +67,13 @@ export function ParentalLeaveBreakdown({ data }: ParentalLeaveBreakdownProps): R
TypÅterståendeTotalt{t('parentalLeaveBreakdown.typeHeader')}{t('parentalLeaveBreakdown.remainingHeader')}{t('parentalLeaveBreakdown.totalHeader')}
Reserverade dagar{data.reservedDaysLeft}{data.reservedDaysTotal}
Övergripbara dagar{t('parentalLeaveBreakdown.transferableDays')} {data.transferableDaysLeft} —
Dubbeldagar kvar{t('parentalLeaveBreakdown.dubbeldagarLeft')} {data.dubbeldagarLeft} —
Sjukpenning{t('parentalLeaveBreakdown.sjukpenning')} {data.tierData.sjukpenningLeft} {data.tierData.sjukpenningTotal}
Lägsta nivå{t('parentalLeaveBreakdown.lagstaNiva')} {data.tierData.lagstaNivaLeft} {data.tierData.lagstaNivaTotal}
+

+ {t('parentalLeaveBreakdown.reservedNote', { + reserved: LEAVE_RULES.reservedDaysPerParent, + transferable: LEAVE_RULES.transferableDaysPerParent, + })} +

+
{sourceLabel} {formatDate(data.fetchedAt)} diff --git a/frontend/src/components/dashboard/ProgressChart.test.tsx b/frontend/src/components/dashboard/ProgressChart.test.tsx index 522df28..37592b9 100644 --- a/frontend/src/components/dashboard/ProgressChart.test.tsx +++ b/frontend/src/components/dashboard/ProgressChart.test.tsx @@ -21,7 +21,7 @@ describe('ProgressChart', () => { const { getByTestId } = render( ) - expect(getByTestId('current-weeks').textContent).toBe('10 / 50 weeks') + expect(getByTestId('current-weeks').textContent).toBe('10 / 50 veckor') }) it('sets fill width based on percentage', () => { @@ -51,7 +51,7 @@ describe('ProgressChart', () => { const { getByTestId } = render( ) - expect(getByTestId('progress-chart').textContent).toContain('Age: 2') + expect(getByTestId('progress-chart').textContent).toContain('Ålder: 2') }) it('handles zero target weeks', () => { diff --git a/frontend/src/components/dashboard/ProgressChart.tsx b/frontend/src/components/dashboard/ProgressChart.tsx index ddc66cf..1bd5213 100644 --- a/frontend/src/components/dashboard/ProgressChart.tsx +++ b/frontend/src/components/dashboard/ProgressChart.tsx @@ -1,4 +1,5 @@ import { ReactNode } from 'react' +import { useTranslation } from '@/i18n' export interface ProgressChartProps { currentWeeks: number @@ -11,13 +12,16 @@ export function ProgressChart({ targetWeeks, childAge, }: ProgressChartProps): ReactNode { + const { t } = useTranslation() const percentage = targetWeeks > 0 ? Math.min((currentWeeks / targetWeeks) * 100, 100) : 0 return (
-

Parental Leave Progress

- {childAge !== undefined && Age: {childAge}} +

{t('progressChart.title')}

+ {childAge !== undefined && ( + {t('progressChart.age', { age: childAge })} + )}
- {currentWeeks} / {targetWeeks} weeks + {t('progressChart.weeksStat', { current: currentWeeks, target: targetWeeks })} {Math.round(percentage)}% diff --git a/frontend/src/components/inputs/BreakPeriodsInput.test.tsx b/frontend/src/components/inputs/BreakPeriodsInput.test.tsx deleted file mode 100644 index 050ddf0..0000000 --- a/frontend/src/components/inputs/BreakPeriodsInput.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { render, fireEvent } from '@testing-library/react' -import { BreakPeriodsInput } from './BreakPeriodsInput' - -describe('BreakPeriodsInput', () => { - const mockPeriods = [ - { - id: '1', - name: 'Summer', - startDate: '2026-06-01', - endDate: '2026-08-31', - targetWeeks: 12, - }, - ] - - it('renders break periods list', () => { - const { getByTestId } = render( - {}} onRemove={() => {}} /> - ) - expect(getByTestId('period-1')).toBeInTheDocument() - expect(getByTestId('period-1').textContent).toContain('Summer') - }) - - it('shows empty state when no periods', () => { - const { getByTestId } = render( - {}} onRemove={() => {}} /> - ) - expect(getByTestId('empty-state')).toBeInTheDocument() - }) - - it('displays add button', () => { - const { getByTestId } = render( - {}} onRemove={() => {}} /> - ) - expect(getByTestId('add-button')).toBeInTheDocument() - }) - - it('shows form when add button clicked', () => { - const { getByTestId, queryByTestId } = render( - {}} onRemove={() => {}} /> - ) - expect(queryByTestId('add-form')).not.toBeInTheDocument() - fireEvent.click(getByTestId('add-button')) - expect(getByTestId('add-form')).toBeInTheDocument() - }) - - it('calls onAdd with form data', () => { - const onAdd = vi.fn() - const { getByTestId } = render( - {}} /> - ) - fireEvent.click(getByTestId('add-button')) - - fireEvent.change(getByTestId('period-name'), { target: { value: 'Winter' } }) - fireEvent.change(getByTestId('period-start'), { target: { value: '2026-12-01' } }) - fireEvent.change(getByTestId('period-end'), { target: { value: '2026-12-31' } }) - fireEvent.change(getByTestId('period-weeks'), { target: { value: '4' } }) - - fireEvent.click(getByTestId('submit-button')) - - expect(onAdd).toHaveBeenCalledWith({ - name: 'Winter', - startDate: '2026-12-01', - endDate: '2026-12-31', - targetWeeks: 4, - }) - }) - - it('calls onRemove when remove button clicked', () => { - const onRemove = vi.fn() - const { getByTestId } = render( - {}} onRemove={onRemove} /> - ) - fireEvent.click(getByTestId('remove-1')) - expect(onRemove).toHaveBeenCalledWith('1') - }) - - it('closes form on cancel', () => { - const { getByTestId, queryByTestId } = render( - {}} onRemove={() => {}} /> - ) - fireEvent.click(getByTestId('add-button')) - expect(getByTestId('add-form')).toBeInTheDocument() - - fireEvent.click(getByTestId('cancel-button')) - expect(queryByTestId('add-form')).not.toBeInTheDocument() - }) - - it('disables submit when fields incomplete', () => { - const { getByTestId } = render( - {}} onRemove={() => {}} /> - ) - fireEvent.click(getByTestId('add-button')) - const submit = getByTestId('submit-button') as HTMLButtonElement - expect(submit.disabled).toBe(true) - - fireEvent.change(getByTestId('period-name'), { target: { value: 'Winter' } }) - fireEvent.change(getByTestId('period-start'), { target: { value: '2026-12-01' } }) - fireEvent.change(getByTestId('period-end'), { target: { value: '2026-12-31' } }) - - expect(submit.disabled).toBe(false) - }) - - it('disables buttons when loading', () => { - const { getByTestId } = render( - {}} - onRemove={() => {}} - isLoading - /> - ) - expect((getByTestId('add-button') as HTMLButtonElement).disabled).toBe(true) - expect((getByTestId('remove-1') as HTMLButtonElement).disabled).toBe(true) - }) -}) diff --git a/frontend/src/components/inputs/BreakPeriodsInput.tsx b/frontend/src/components/inputs/BreakPeriodsInput.tsx deleted file mode 100644 index 948834e..0000000 --- a/frontend/src/components/inputs/BreakPeriodsInput.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { ReactNode, useState } from 'react' - -export interface BreakPeriodsInputProps { - periods: Array<{ id: string; name: string; startDate: string; endDate: string; targetWeeks: number }> - onAdd: (period: { name: string; startDate: string; endDate: string; targetWeeks: number }) => void - onRemove: (id: string) => void - isLoading?: boolean -} - -export function BreakPeriodsInput({ - periods, - onAdd, - onRemove, - isLoading = false, -}: BreakPeriodsInputProps): ReactNode { - const [formOpen, setFormOpen] = useState(false) - const [name, setName] = useState('') - const [startDate, setStartDate] = useState('') - const [endDate, setEndDate] = useState('') - const [targetWeeks, setTargetWeeks] = useState('0') - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - if (name && startDate && endDate && targetWeeks) { - onAdd({ name, startDate, endDate, targetWeeks: parseInt(targetWeeks, 10) }) - setName('') - setStartDate('') - setEndDate('') - setTargetWeeks('0') - setFormOpen(false) - } - } - - return ( -
-

Break Periods

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

- No break periods defined yet -

- ) : ( -
    - {periods.map((period) => ( -
  • -
    - {period.name} - - {period.startDate} to {period.endDate} ({period.targetWeeks} weeks) - -
    - -
  • - ))} -
- )} - - {formOpen && ( -
-
- - setName(e.target.value)} - placeholder="e.g., Summer" - data-testid="period-name" - /> -
- -
- - setStartDate(e.target.value)} - data-testid="period-start" - /> -
- -
- - setEndDate(e.target.value)} - data-testid="period-end" - /> -
- -
- - setTargetWeeks(e.target.value)} - data-testid="period-weeks" - /> -
- -
- - -
-
- )} - - {!formOpen && ( - - )} -
- ) -} diff --git a/frontend/src/components/inputs/ChildBirthdayInput.tsx b/frontend/src/components/inputs/ChildBirthdayInput.tsx index 3a6ddc5..3d18d35 100644 --- a/frontend/src/components/inputs/ChildBirthdayInput.tsx +++ b/frontend/src/components/inputs/ChildBirthdayInput.tsx @@ -1,5 +1,6 @@ import { ReactNode, ChangeEvent } from 'react' import { formatDateForInput } from '@/utils/dates' +import { useTranslation } from '@/i18n' interface ChildBirthdayInputProps { birthDate: string @@ -14,8 +15,11 @@ export function ChildBirthdayInput({ onBirthDateChange, preschoolStartDate, onPreschoolStartDateChange, - label = 'Child Birthday', + label, }: ChildBirthdayInputProps): ReactNode { + const { t } = useTranslation() + const resolvedLabel = label ?? t('childBirthdayInput.defaultLabel') + const handleBirthDateChange = (e: ChangeEvent) => { onBirthDateChange(e.target.value) } @@ -26,9 +30,9 @@ export function ChildBirthdayInput({ return (
- {label} + {resolvedLabel}
- + {onPreschoolStartDateChange && (
- + { + const mockOverrides = [ + { + id: 'do-1', + workspaceId: 'ws-1', + date: '2026-12-24', + isWorkingDay: true, + note: 'Jobbade på juldagen', + createdAt: '2026-01-01T00:00:00', + updatedAt: '2026-01-01T00:00:00', + }, + { + id: 'do-2', + workspaceId: 'ws-1', + date: '2026-06-20', + isWorkingDay: false, + note: null, + createdAt: '2026-01-01T00:00:00', + updatedAt: '2026-01-01T00:00:00', + }, + ] + + let mockOnAdd: ReturnType + let mockOnRemove: ReturnType + + beforeEach(() => { + mockOnAdd = vi.fn().mockResolvedValue(undefined) + mockOnRemove = vi.fn().mockResolvedValue(undefined) + }) + + describe('rendering', () => { + it('renders list of existing overrides', () => { + render( + + ) + + expect(screen.getByText('24 december 2026')).toBeInTheDocument() + expect(screen.getByText('20 juni 2026')).toBeInTheDocument() + }) + + it('renders working day override with correct status', () => { + render( + + ) + + expect(screen.getByTestId('override-item-do-1').textContent).toContain('Jag jobbar den här dagen') + }) + + it('renders off day override with correct status', () => { + render( + + ) + + expect(screen.getByTestId('override-item-do-2').textContent).toContain('Jag är ledig den här dagen') + }) + + it('renders note when present', () => { + render( + + ) + + expect(screen.getByText('Anteckning: Jobbade på juldagen')).toBeInTheDocument() + }) + + it('renders empty state when no overrides', () => { + render( + + ) + + expect(screen.getByText('Du har inga undantag i kalendern ännu.')).toBeInTheDocument() + }) + + it('renders add form with all fields', () => { + render( + + ) + + expect(screen.getByTestId('override-date-input')).toBeInTheDocument() + expect(screen.getByTestId('override-working-radio')).toBeInTheDocument() + expect(screen.getByTestId('override-off-radio')).toBeInTheDocument() + expect(screen.getByTestId('override-note-input')).toBeInTheDocument() + expect(screen.getByTestId('add-override-button')).toBeInTheDocument() + }) + }) + + describe('adding override', () => { + it('calls onAdd with correct payload when working day is selected', async () => { + render( + + ) + + fireEvent.change(screen.getByTestId('override-date-input'), { target: { value: '2026-12-24' } }) + fireEvent.click(screen.getByTestId('override-working-radio')) + fireEvent.change(screen.getByTestId('override-note-input'), { target: { value: 'Jobbar denna dag' } }) + fireEvent.click(screen.getByTestId('add-override-button')) + + await waitFor(() => { + expect(mockOnAdd).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + date: '2026-12-24', + isWorkingDay: true, + note: 'Jobbar denna dag', + }) + }) + }) + + it('calls onAdd with correct payload when off day is selected', async () => { + render( + + ) + + fireEvent.change(screen.getByTestId('override-date-input'), { target: { value: '2026-06-20' } }) + fireEvent.click(screen.getByTestId('override-off-radio')) + fireEvent.click(screen.getByTestId('add-override-button')) + + await waitFor(() => { + expect(mockOnAdd).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + date: '2026-06-20', + isWorkingDay: false, + note: null, + }) + }) + }) + + it('clears form after successful add', async () => { + render( + + ) + + const dateInput = screen.getByTestId('override-date-input') as HTMLInputElement + const noteInput = screen.getByTestId('override-note-input') as HTMLInputElement + + fireEvent.change(dateInput, { target: { value: '2026-12-24' } }) + fireEvent.change(noteInput, { target: { value: 'Test note' } }) + fireEvent.click(screen.getByTestId('add-override-button')) + + await waitFor(() => { + expect(dateInput.value).toBe('') + expect(noteInput.value).toBe('') + }) + }) + + it('disables submit button when date is empty', () => { + render( + + ) + + const submitButton = screen.getByTestId('add-override-button') as HTMLButtonElement + expect(submitButton.disabled).toBe(true) + }) + }) + + describe('removing override', () => { + it('calls onRemove with correct id', async () => { + render( + + ) + + fireEvent.click(screen.getByTestId('remove-override-do-1')) + + await waitFor(() => { + expect(mockOnRemove).toHaveBeenCalledWith('do-1') + }) + }) + }) + + describe('loading state', () => { + it('disables form during loading', () => { + render( + + ) + + expect((screen.getByTestId('override-date-input') as HTMLInputElement).disabled).toBe(true) + expect((screen.getByTestId('add-override-button') as HTMLButtonElement).disabled).toBe(true) + }) + }) +}) diff --git a/frontend/src/components/inputs/DayOverridesInput.tsx b/frontend/src/components/inputs/DayOverridesInput.tsx new file mode 100644 index 0000000..a7768ac --- /dev/null +++ b/frontend/src/components/inputs/DayOverridesInput.tsx @@ -0,0 +1,174 @@ +import { ReactNode, useState } from 'react' +import { formatDateForDisplay } from '@/utils/dates' +import { useTranslation } from '@/i18n' +import type { DayOverride, DayOverrideCreateRequest } from '@/types' + +export interface DayOverridesInputProps { + overrides: DayOverride[] + isLoading?: boolean + onAdd: (req: DayOverrideCreateRequest) => Promise + onRemove: (id: string) => Promise + workspaceId: string +} + +export function DayOverridesInput({ + overrides, + isLoading = false, + onAdd, + onRemove, + workspaceId, +}: DayOverridesInputProps): ReactNode { + const { t } = useTranslation() + const [date, setDate] = useState('') + const [isWorkingDay, setIsWorkingDay] = useState(true) + const [note, setNote] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleAddOverride = async (e: React.FormEvent) => { + e.preventDefault() + if (!date) return + + try { + setIsSubmitting(true) + await onAdd({ + workspaceId, + date, + isWorkingDay, + note: note || null, + }) + setDate('') + setIsWorkingDay(true) + setNote('') + } finally { + setIsSubmitting(false) + } + } + + const handleRemove = async (id: string) => { + try { + await onRemove(id) + } catch { + // Error handling is done in parent + } + } + + const sortedOverrides = [...overrides].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + + return ( +
+ {overrides.length === 0 ? ( +
+

{t('dayOverridesInput.empty')}

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

{t('dayOverridesInput.addSectionTitle')}

+ +
+ + setDate(e.target.value)} + required + disabled={isSubmitting || isLoading} + data-testid="override-date-input" + className="day-overrides-input__form-input" + /> +
+ +
+
+ {t('dayOverridesInput.statusLegend')} + + +
+
+ +
+ + setNote(e.target.value)} + maxLength={280} + disabled={isSubmitting || isLoading} + data-testid="override-note-input" + className="day-overrides-input__form-input" + /> + + {t('dayOverridesInput.charCount', { count: note.length })} + +
+ + +
+
+ ) +} diff --git a/frontend/src/components/inputs/ParentProfileInput.test.tsx b/frontend/src/components/inputs/ParentProfileInput.test.tsx index 6006b9b..98aace7 100644 --- a/frontend/src/components/inputs/ParentProfileInput.test.tsx +++ b/frontend/src/components/inputs/ParentProfileInput.test.tsx @@ -25,12 +25,12 @@ describe('ParentProfileInput', () => { it('displays current employment status', () => { const { getByTestId } = render( {}} /> ) const select = getByTestId('employment-status') as HTMLSelectElement - expect(select.value).toBe('self_employed') + expect(select.value).toBe('employed') }) it('calls onChange for salary type change', () => { @@ -53,20 +53,27 @@ describe('ParentProfileInput', () => { expect(onChange).toHaveBeenCalledWith('employmentStatus', 'employed') }) - it('renders all salary type options', () => { + it('renders exactly 3 salary type options (empty, monthly, hourly)', () => { const { getByTestId } = render( {}} /> ) - const select = getByTestId('salary-type') - expect((select as HTMLSelectElement).options.length).toBeGreaterThanOrEqual(4) + const select = getByTestId('salary-type') as HTMLSelectElement + expect(select.options.length).toBe(3) + expect(select.options[0]?.value).toBe('') + expect(select.options[1]?.value).toBe('monthly') + expect(select.options[2]?.value).toBe('hourly') }) - it('renders all employment status options', () => { + it('renders exactly 4 employment status options (empty, employed, jobseeking, none)', () => { const { getByTestId } = render( {}} /> ) - const select = getByTestId('employment-status') - expect((select as HTMLSelectElement).options.length).toBeGreaterThanOrEqual(4) + const select = getByTestId('employment-status') as HTMLSelectElement + expect(select.options.length).toBe(4) + expect(select.options[0]?.value).toBe('') + expect(select.options[1]?.value).toBe('employed') + expect(select.options[2]?.value).toBe('jobseeking') + expect(select.options[3]?.value).toBe('none') }) it('handles undefined parent properties', () => { diff --git a/frontend/src/components/inputs/ParentProfileInput.tsx b/frontend/src/components/inputs/ParentProfileInput.tsx index 23f11e1..9eb6a46 100644 --- a/frontend/src/components/inputs/ParentProfileInput.tsx +++ b/frontend/src/components/inputs/ParentProfileInput.tsx @@ -1,9 +1,13 @@ import { ReactNode } from 'react' +import { useTranslation } from '@/i18n' + +export type SalaryType = 'monthly' | 'hourly' +export type EmploymentStatus = 'employed' | 'jobseeking' | 'none' export interface ParentProfileInputProps { parent: { - salaryType?: string - employmentStatus?: string + salaryType?: SalaryType + employmentStatus?: EmploymentStatus } onChange: (field: string, val: string) => void } @@ -12,11 +16,12 @@ export function ParentProfileInput({ parent, onChange, }: ParentProfileInputProps): ReactNode { + const { t } = useTranslation() return (
diff --git a/frontend/src/components/inputs/PlanningGoalSelector.test.tsx b/frontend/src/components/inputs/PlanningGoalSelector.test.tsx index 0a37f95..e581caa 100644 --- a/frontend/src/components/inputs/PlanningGoalSelector.test.tsx +++ b/frontend/src/components/inputs/PlanningGoalSelector.test.tsx @@ -7,57 +7,77 @@ describe('PlanningGoalSelector', () => { const { getByTestId } = render( {}} /> ) - expect(getByTestId('goal-maximize-contiguous')).toBeInTheDocument() - expect(getByTestId('goal-minimize-stress')).toBeInTheDocument() - expect(getByTestId('goal-balance')).toBeInTheDocument() + expect(getByTestId('goal-max-time')).toBeInTheDocument() + expect(getByTestId('goal-max-money')).toBeInTheDocument() + expect(getByTestId('goal-balanced')).toBeInTheDocument() }) it('renders as radio buttons', () => { const { getByTestId } = render( {}} /> ) - const radio1 = getByTestId('goal-maximize-contiguous') as HTMLInputElement - const radio2 = getByTestId('goal-minimize-stress') as HTMLInputElement + const radio1 = getByTestId('goal-max-time') as HTMLInputElement + const radio2 = getByTestId('goal-max-money') as HTMLInputElement expect(radio1.type).toBe('radio') expect(radio2.type).toBe('radio') }) it('checks selected value', () => { const { getByTestId } = render( - {}} /> + {}} /> ) - const radio = getByTestId('goal-minimize-stress') as HTMLInputElement + const radio = getByTestId('goal-max-money') as HTMLInputElement expect(radio.checked).toBe(true) }) - it('calls onChange when selecting option', () => { + it('calls onChange with max_time when selecting max_time option', () => { const onChange = vi.fn() const { getByTestId } = render( ) - const radio = getByTestId('goal-balance') + const radio = getByTestId('goal-max-time') fireEvent.click(radio) - expect(onChange).toHaveBeenCalledWith('balance') + expect(onChange).toHaveBeenCalledWith('max_time') + }) + + it('calls onChange with max_money when selecting max_money option', () => { + const onChange = vi.fn() + const { getByTestId } = render( + + ) + const radio = getByTestId('goal-max-money') + fireEvent.click(radio) + expect(onChange).toHaveBeenCalledWith('max_money') + }) + + it('calls onChange with balanced when selecting balanced option', () => { + const onChange = vi.fn() + const { getByTestId } = render( + + ) + const radio = getByTestId('goal-balanced') + fireEvent.click(radio) + expect(onChange).toHaveBeenCalledWith('balanced') }) it('updates selection when value changes', () => { const { getByTestId, rerender } = render( - {}} /> + {}} /> ) - let radio = getByTestId('goal-maximize-contiguous') as HTMLInputElement + let radio = getByTestId('goal-max-time') as HTMLInputElement expect(radio.checked).toBe(true) - rerender( {}} />) - radio = getByTestId('goal-balance') as HTMLInputElement + rerender( {}} />) + radio = getByTestId('goal-balanced') as HTMLInputElement expect(radio.checked).toBe(true) }) it('only one option can be selected at a time', () => { const { getByTestId } = render( - {}} /> + {}} /> ) - const radio1 = getByTestId('goal-maximize-contiguous') as HTMLInputElement - const radio2 = getByTestId('goal-minimize-stress') as HTMLInputElement + const radio1 = getByTestId('goal-max-time') as HTMLInputElement + const radio2 = getByTestId('goal-max-money') as HTMLInputElement expect(radio1.checked).toBe(true) expect(radio2.checked).toBe(false) }) @@ -66,8 +86,8 @@ describe('PlanningGoalSelector', () => { const { getByTestId } = render( {}} /> ) - const radio1 = getByTestId('goal-maximize-contiguous') as HTMLInputElement - const radio2 = getByTestId('goal-minimize-stress') as HTMLInputElement + const radio1 = getByTestId('goal-max-time') as HTMLInputElement + const radio2 = getByTestId('goal-max-money') as HTMLInputElement expect(radio1.checked).toBe(false) expect(radio2.checked).toBe(false) }) diff --git a/frontend/src/components/inputs/PlanningGoalSelector.tsx b/frontend/src/components/inputs/PlanningGoalSelector.tsx index 0635bb0..935ea2c 100644 --- a/frontend/src/components/inputs/PlanningGoalSelector.tsx +++ b/frontend/src/components/inputs/PlanningGoalSelector.tsx @@ -1,53 +1,60 @@ import { ReactNode } from 'react' +import { useTranslation } from '@/i18n' + +export type PlanningGoal = 'max_time' | 'max_money' | 'balanced' export interface PlanningGoalSelectorProps { - value?: string - onChange: (goal: string) => void + value?: PlanningGoal + onChange: (goal: PlanningGoal) => void } export function PlanningGoalSelector({ value, onChange }: PlanningGoalSelectorProps): ReactNode { + const { t } = useTranslation() return (
- Planning Goal + {t('planningGoalSelector.legend')}
onChange(e.target.value)} - data-testid="goal-maximize-contiguous" + value="max_time" + checked={value === 'max_time'} + onChange={(e) => onChange(e.target.value as PlanningGoal)} + data-testid="goal-max-time" /> - + + {t('planningGoalSelector.maxTimeHelp')}
onChange(e.target.value)} - data-testid="goal-minimize-stress" + value="max_money" + checked={value === 'max_money'} + onChange={(e) => onChange(e.target.value as PlanningGoal)} + data-testid="goal-max-money" /> - + + {t('planningGoalSelector.maxMoneyHelp')}
onChange(e.target.value)} - data-testid="goal-balance" + value="balanced" + checked={value === 'balanced'} + onChange={(e) => onChange(e.target.value as PlanningGoal)} + data-testid="goal-balanced" /> - + + {t('planningGoalSelector.balancedHelp')}
diff --git a/frontend/src/components/inputs/PlanningModeSelector.test.tsx b/frontend/src/components/inputs/PlanningModeSelector.test.tsx new file mode 100644 index 0000000..61d28e8 --- /dev/null +++ b/frontend/src/components/inputs/PlanningModeSelector.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { PlanningModeSelector } from './PlanningModeSelector' + +describe('PlanningModeSelector', () => { + it('renders both planning mode options', () => { + const { getByTestId } = render( {}} />) + expect(getByTestId('planning-mode-solo')).toBeInTheDocument() + expect(getByTestId('planning-mode-couple')).toBeInTheDocument() + }) + + it('renders as radio buttons', () => { + const { getByTestId } = render( {}} />) + const solo = getByTestId('planning-mode-solo') as HTMLInputElement + const couple = getByTestId('planning-mode-couple') as HTMLInputElement + expect(solo.type).toBe('radio') + expect(couple.type).toBe('radio') + }) + + it('checks selected value', () => { + const { getByTestId } = render( {}} />) + const couple = getByTestId('planning-mode-couple') as HTMLInputElement + expect(couple.checked).toBe(true) + }) + + it('calls onChange with solo when selecting solo option', () => { + const onChange = vi.fn() + const { getByTestId } = render() + fireEvent.click(getByTestId('planning-mode-solo')) + expect(onChange).toHaveBeenCalledWith('solo') + }) + + it('calls onChange with couple when selecting couple option', () => { + const onChange = vi.fn() + const { getByTestId } = render() + fireEvent.click(getByTestId('planning-mode-couple')) + expect(onChange).toHaveBeenCalledWith('couple') + }) + + it('has no selection when value is undefined', () => { + const { getByTestId } = render( {}} />) + const solo = getByTestId('planning-mode-solo') as HTMLInputElement + const couple = getByTestId('planning-mode-couple') as HTMLInputElement + expect(solo.checked).toBe(false) + expect(couple.checked).toBe(false) + }) +}) diff --git a/frontend/src/components/inputs/PlanningModeSelector.tsx b/frontend/src/components/inputs/PlanningModeSelector.tsx new file mode 100644 index 0000000..bdeda5c --- /dev/null +++ b/frontend/src/components/inputs/PlanningModeSelector.tsx @@ -0,0 +1,48 @@ +import { ReactNode } from 'react' +import { useTranslation } from '@/i18n' + +export type PlanningMode = 'solo' | 'couple' + +export interface PlanningModeSelectorProps { + value?: PlanningMode + onChange: (mode: PlanningMode) => void +} + +export function PlanningModeSelector({ value, onChange }: PlanningModeSelectorProps): ReactNode { + const { t } = useTranslation() + return ( +
+
+ + {t('planningModeSelector.legend')} + + +
+ onChange(e.target.value as PlanningMode)} + data-testid="planning-mode-solo" + /> + +
+ +
+ onChange(e.target.value as PlanningMode)} + data-testid="planning-mode-couple" + /> + +
+
+
+ ) +} diff --git a/frontend/src/components/inputs/TipCard.test.tsx b/frontend/src/components/inputs/TipCard.test.tsx index f9171f2..3b22c17 100644 --- a/frontend/src/components/inputs/TipCard.test.tsx +++ b/frontend/src/components/inputs/TipCard.test.tsx @@ -1,19 +1,20 @@ import { describe, it, expect, vi } from 'vitest' import { render, fireEvent } from '@testing-library/react' import { TipCard } from './TipCard' +import { sv } from '@/i18n/sv' describe('TipCard', () => { const mockTip = { id: 'tip1', severity: 'info' as const, - messageKey: 'wrap_red_days', - data: {}, + messageKey: 'tips.wrap_red_days_with_vacation', + data: { parent_name: 'Alex', cluster_start: '2026-12-24', cluster_end: '2026-12-26' }, } it('renders tip card with title', () => { const { getByTestId } = render() expect(getByTestId('tip-card')).toBeInTheDocument() - expect(getByTestId('tip-card').textContent).toContain('Wrap Red Days') + expect(getByTestId('tip-card').textContent).toContain(sv['tip.wrapRedDaysWithVacation.title']) }) it('applies severity class', () => { @@ -54,14 +55,18 @@ describe('TipCard', () => { expect(onDismiss).toHaveBeenCalledWith('tip1') }) - it('displays message key as body text', () => { + it('interpolates the tip data into the translated body text', () => { const { getByTestId } = render() - expect(getByTestId('tip-card').textContent).toContain('wrap_red_days') + expect(getByTestId('tip-card').textContent).toContain('Alex') + expect(getByTestId('tip-card').textContent).toContain('2026-12-24') }) it('provides accessibility label', () => { const { getByTestId } = render() - expect(getByTestId('tip-card')).toHaveAttribute('aria-label', 'Tip: Wrap Red Days') + expect(getByTestId('tip-card')).toHaveAttribute( + 'aria-label', + `Tips: ${sv['tip.wrapRedDaysWithVacation.title']}`, + ) }) it('maps unknown messageKey to itself', () => { diff --git a/frontend/src/components/inputs/TipCard.tsx b/frontend/src/components/inputs/TipCard.tsx index a6ba092..426d572 100644 --- a/frontend/src/components/inputs/TipCard.tsx +++ b/frontend/src/components/inputs/TipCard.tsx @@ -1,4 +1,5 @@ import { ReactNode } from 'react' +import { useTranslation, toTranslationParams, type TranslationKey } from '@/i18n' export interface TipCardProps { tip: { @@ -10,14 +11,69 @@ export interface TipCardProps { onDismiss?: (tipId: string) => void } -export function TipCard({ tip, onDismiss }: TipCardProps): ReactNode { - const titleMap: Record = { - wrap_red_days: 'Wrap Red Days', - prioritize_dubbeldagar: 'Use Dubbeldagar', - extend_contiguous: 'Extend Contiguous Leave', - } +// The backend's `message_key` (e.g. "tips.use_vacation_before_expiry") is +// designed as a genuine i18n key — see tips_service.py's `message_key: str +// # i18n key for frontend`. Map each known backend key to its title/body +// translation keys; an unrecognised key (e.g. a newly-added backend rule +// this catalogue hasn't caught up with yet) falls back to showing the raw +// key, same as before i18n existed. +const TIP_TRANSLATION_KEYS: Record = { + 'tips.use_vacation_before_expiry': { + title: 'tip.useVacationBeforeExpiry.title', + body: 'tip.useVacationBeforeExpiry.body', + }, + 'tips.balance_between_parents': { + title: 'tip.balanceBetweenParents.title', + body: 'tip.balanceBetweenParents.body', + }, + 'tips.preserve_for_age_4': { + title: 'tip.preserveForAge4.title', + body: 'tip.preserveForAge4.body', + }, + 'tips.use_dubbeldagar_strategically': { + title: 'tip.useDubbeldagarStrategically.title', + body: 'tip.useDubbeldagarStrategically.body', + }, + 'tips.lagsta_niva_coverage': { + title: 'tip.lagstaNivaCoverage.title', + body: 'tip.lagstaNivaCoverage.body', + }, + 'tips.stretch_time_at_home': { + title: 'tip.stretchTimeAtHome.title', + body: 'tip.stretchTimeAtHome.body', + }, + 'tips.skip_fp_first_12_months': { + title: 'tip.skipFpFirst12Months.title', + body: 'tip.skipFpFirst12Months.body', + }, + 'tips.sgi_minimum_after_age_1': { + title: 'tip.sgiMinimumAfterAge1.title', + body: 'tip.sgiMinimumAfterAge1.body', + }, + 'tips.wrap_weekend_with_vacation': { + title: 'tip.wrapWeekendWithVacation.title', + body: 'tip.wrapWeekendWithVacation.body', + }, + 'tips.wrap_red_days_with_vacation': { + title: 'tip.wrapRedDaysWithVacation.title', + body: 'tip.wrapRedDaysWithVacation.body', + }, + 'tips.long_break_parent_swap': { + title: 'tip.longBreakParentSwap.title', + body: 'tip.longBreakParentSwap.body', + }, + 'tips.reserve_for_preschool_planning_days': { + title: 'tip.reserveForPreschoolPlanningDays.title', + body: 'tip.reserveForPreschoolPlanningDays.body', + }, +} - const title = titleMap[tip.messageKey] || tip.messageKey +export function TipCard({ tip, onDismiss }: TipCardProps): ReactNode { + const { t } = useTranslation() + const translationKeys = TIP_TRANSLATION_KEYS[tip.messageKey] + const params = toTranslationParams(tip.data) + const title = translationKeys ? t(translationKeys.title, params) : tip.messageKey + const body = translationKeys ? t(translationKeys.body, params) : tip.messageKey const severityClassMap = { info: 'tip-card--info', @@ -30,7 +86,7 @@ export function TipCard({ tip, onDismiss }: TipCardProps): ReactNode { data-testid="tip-card" className={`tip-card ${severityClassMap[tip.severity]}`} role="article" - aria-label={`Tip: ${title}`} + aria-label={t('tip.aria', { title })} >

{title}

@@ -38,14 +94,14 @@ export function TipCard({ tip, onDismiss }: TipCardProps): ReactNode { )}
-

{tip.messageKey}

+

{body}

) } diff --git a/frontend/src/components/layout/Footer.tsx b/frontend/src/components/layout/Footer.tsx index ec98764..c83f7dd 100644 --- a/frontend/src/components/layout/Footer.tsx +++ b/frontend/src/components/layout/Footer.tsx @@ -1,11 +1,13 @@ import { ReactNode } from 'react' +import { useTranslation } from '@/i18n' import './footer.css' export function Footer(): ReactNode { + const { t } = useTranslation() const currentYear = new Date().getFullYear() return (
-

© {currentYear} Föräldraledigplannerare. All rights reserved.

+

{t('footer.copyright', { year: currentYear })}

) } diff --git a/frontend/src/components/layout/Header.test.tsx b/frontend/src/components/layout/Header.test.tsx index fb547d6..831257c 100644 --- a/frontend/src/components/layout/Header.test.tsx +++ b/frontend/src/components/layout/Header.test.tsx @@ -51,6 +51,7 @@ 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() }) diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index bcc9a44..3ae363e 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react' import { NavLink } from 'react-router-dom' +import { useTranslation } from '@/i18n' import './header.css' export interface HeaderProps { @@ -19,11 +20,12 @@ export function Header({ onChildSelect, onLogout, }: HeaderProps): ReactNode { + const { t } = useTranslation() return (
- Föräldraledigplannerare + {t('app.name')}
{parentName && ( @@ -37,7 +39,7 @@ export function Header({ value={selectedChildId ?? ''} onChange={(e) => onChildSelect?.(e.target.value)} data-testid="child-select" - aria-label="Select child" + aria-label={t('nav.selectChildAria')} > {childrenList.map((child) => (