From 0e9b20ec3780b7bc0bab82402ccfd28ec4b08060 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 20 Sep 2026 11:22:07 +0200 Subject: [PATCH] Track preschool planning days, partner invites and real vacation forfeiture Three strands of work that grew together across the same settings screen and API client, so they land as one change. Per-year preschool planning days ("studiedagar"): new table, repository (SQLite + Postgres), service, router and migration 007, plus the hooks and Settings UI to plan and record them per child and year. Allocation now reserves the recorded figure for a year where one exists and only falls back to the child-level estimate otherwise, marking that fallback as assumed. Partner invitations: workspaces issue and list invitations, registration accepts an invite token to join the inviter's workspace instead of creating a personal one, and a /join/:token page plus the settings invite panel drive it. Vacation forfeiture: tips and the dashboard now distinguish days a parent genuinely loses from days that merely age, so the warning stops firing on balances that were never at risk. Co-Authored-By: Claude Opus 5 --- backend/app/db/connection.py | 14 + backend/app/dependencies.py | 36 ++- .../app/entities/preschool_planning_days.py | 37 +++ backend/app/main.py | 2 + backend/app/models/analytics.py | 4 + backend/app/models/auth.py | 5 + backend/app/models/preschool_planning_days.py | 66 +++++ .../preschool_planning_days_repository.py | 17 ++ backend/app/repositories/user_repository.py | 8 + .../preschool_planning_days_repository.py | 37 +++ .../preschool_planning_days_repository.py | 115 ++++++++ .../sqlite/user_repository.py | 29 ++- backend/app/routers/auth.py | 5 +- .../app/routers/preschool_planning_days.py | 77 ++++++ backend/app/routers/workspace.py | 16 ++ backend/app/services/allocation_service.py | 210 +++++++++++++-- backend/app/services/analytics_service.py | 41 ++- backend/app/services/auth_service.py | 123 +++++++-- .../preschool_planning_days_service.py | 93 +++++++ backend/app/services/tips_service.py | 85 ++++-- backend/app/services/workspace_service.py | 98 ++++++- backend/app/utils/tip_predicates.py | 17 ++ .../versions/007_preschool_planning_days.py | 67 +++++ backend/tests/conftest.py | 21 +- backend/tests/unit/test_analytics_router.py | 2 + backend/tests/unit/test_authz.py | 41 +++ .../tests/unit/test_planning_service_sync.py | 23 +- ...test_preschool_planning_days_repository.py | 239 +++++++++++++++++ .../test_preschool_planning_days_router.py | 88 +++++++ .../test_allocation_input_loader.py | 121 +++++++++ .../test_services/test_allocation_service.py | 4 + .../test_allocation_service_branches.py | 82 +++++- .../test_services/test_analytics_service.py | 72 ++++- .../unit/test_services/test_auth_service.py | 132 +++++++++- .../unit/test_services/test_tips_service.py | 101 ++++++++ backend/tests/unit/test_workspace_service.py | 111 +++++++- frontend/src/App.tsx | 2 + .../calendar/CalendarExceptions.test.tsx | 30 +++ .../calendar/CalendarExceptions.tsx | 16 +- .../components/dashboard/YearRows.test.tsx | 71 +++++ .../src/components/dashboard/YearRows.tsx | 77 +++++- .../settings/PartnerInvitePanel.test.tsx | 95 +++++++ .../settings/PartnerInvitePanel.tsx | 194 ++++++++++++++ .../settings/partner-invite-panel.css | 83 ++++++ .../vacation-days/VacationDaysTable.test.tsx | 174 ++++++++++--- .../vacation-days/VacationDaysTable.tsx | 219 ++++++++++------ .../vacation-days/vacation-rows.css | 13 +- frontend/src/hooks/index.ts | 3 + frontend/src/hooks/useCalendarDays.test.ts | 67 +++++ frontend/src/hooks/useCalendarDays.ts | 79 ++++-- frontend/src/hooks/useChildSettings.test.ts | 33 +++ frontend/src/hooks/useChildSettings.ts | 58 +++-- frontend/src/hooks/useDayOverrides.test.ts | 53 +++- frontend/src/hooks/useDayOverrides.ts | 50 ++-- .../src/hooks/useDebouncedCallback.test.ts | 103 ++++++++ frontend/src/hooks/useDebouncedCallback.ts | 58 +++++ .../hooks/usePreschoolPlanningDays.test.ts | 154 +++++++++++ .../src/hooks/usePreschoolPlanningDays.ts | 98 +++++++ .../usePreschoolPlanningDaysList.test.ts | 110 ++++++++ .../src/hooks/usePreschoolPlanningDaysList.ts | 78 ++++++ frontend/src/hooks/useVacationDays.test.ts | 36 +++ frontend/src/hooks/useVacationDays.ts | 63 +++-- frontend/src/i18n/en/dashboard.ts | 6 + frontend/src/i18n/en/index.ts | 2 + frontend/src/i18n/en/invite.ts | 53 ++++ frontend/src/i18n/en/settings.ts | 8 + frontend/src/i18n/en/tips.ts | 2 +- frontend/src/i18n/sv/dashboard.ts | 6 + frontend/src/i18n/sv/index.ts | 2 + frontend/src/i18n/sv/invite.ts | 59 +++++ frontend/src/i18n/sv/settings.ts | 8 + frontend/src/i18n/sv/tips.ts | 2 +- frontend/src/pages/Dashboard.test.tsx | 18 ++ frontend/src/pages/Dashboard.tsx | 6 + frontend/src/pages/Join.test.tsx | 181 +++++++++++++ frontend/src/pages/Join.tsx | 242 +++++++++++++++++ frontend/src/pages/Settings.test.tsx | 159 +++++++++++- frontend/src/pages/Settings.tsx | 245 +++++++++++++++--- frontend/src/pages/join.css | 71 +++++ frontend/src/pages/settings.css | 56 ++++ frontend/src/services/api.test.ts | 2 + frontend/src/services/api.ts | 129 +++++++++ frontend/src/styles/components.css | 11 + frontend/src/types/api.ts | 23 ++ frontend/src/types/entities.ts | 38 ++- frontend/tests/fixtures/analytics.ts | 2 + frontend/tests/fixtures/hooks.ts | 34 +++ frontend/tests/fixtures/workspace.ts | 28 ++ 88 files changed, 5301 insertions(+), 348 deletions(-) create mode 100644 backend/app/entities/preschool_planning_days.py create mode 100644 backend/app/models/preschool_planning_days.py create mode 100644 backend/app/repositories/preschool_planning_days_repository.py create mode 100644 backend/app/repositories_impl/postgres/preschool_planning_days_repository.py create mode 100644 backend/app/repositories_impl/sqlite/preschool_planning_days_repository.py create mode 100644 backend/app/routers/preschool_planning_days.py create mode 100644 backend/app/services/preschool_planning_days_service.py create mode 100644 backend/migrations/versions/007_preschool_planning_days.py create mode 100644 backend/tests/unit/test_preschool_planning_days_repository.py create mode 100644 backend/tests/unit/test_preschool_planning_days_router.py create mode 100644 frontend/src/components/settings/PartnerInvitePanel.test.tsx create mode 100644 frontend/src/components/settings/PartnerInvitePanel.tsx create mode 100644 frontend/src/components/settings/partner-invite-panel.css create mode 100644 frontend/src/hooks/useDebouncedCallback.test.ts create mode 100644 frontend/src/hooks/useDebouncedCallback.ts create mode 100644 frontend/src/hooks/usePreschoolPlanningDays.test.ts create mode 100644 frontend/src/hooks/usePreschoolPlanningDays.ts create mode 100644 frontend/src/hooks/usePreschoolPlanningDaysList.test.ts create mode 100644 frontend/src/hooks/usePreschoolPlanningDaysList.ts create mode 100644 frontend/src/i18n/en/invite.ts create mode 100644 frontend/src/i18n/sv/invite.ts create mode 100644 frontend/src/pages/Join.test.tsx create mode 100644 frontend/src/pages/Join.tsx create mode 100644 frontend/src/pages/join.css diff --git a/backend/app/db/connection.py b/backend/app/db/connection.py index c187503..2b12d63 100644 --- a/backend/app/db/connection.py +++ b/backend/app/db/connection.py @@ -150,6 +150,18 @@ CREATE TABLE IF NOT EXISTS vacation_days ( FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS preschool_planning_days ( + id TEXT PRIMARY KEY, + child_id TEXT NOT NULL, + year INTEGER NOT NULL, + planned_days INTEGER NOT NULL, + taken_days INTEGER, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE(child_id, year), + FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS planned_leave_day ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, @@ -245,6 +257,8 @@ CREATE INDEX IF NOT EXISTS idx_vacation_days_workspace ON vacation_days (workspace_id); CREATE INDEX IF NOT EXISTS idx_planned_day_exclusion_workspace ON planned_day_exclusion (workspace_id); +CREATE INDEX IF NOT EXISTS idx_preschool_planning_days_child + ON preschool_planning_days (child_id); """ diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index d4c20c1..ea4437b 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -29,6 +29,9 @@ from app.repositories.parental_leave_repository import ParentalLeaveRepository from app.repositories.personal_data_repository import PersonalDataRepository from app.repositories.planned_day_exclusion_repository import PlannedDayExclusionRepository from app.repositories.planned_leave_repository import PlannedLeaveRepository +from app.repositories.preschool_planning_days_repository import ( + PreschoolPlanningDaysRepository, +) from app.repositories.tip_dismissal_repository import TipDismissalRepository from app.repositories.unit_of_work import UnitOfWork from app.repositories.user_repository import UserRepository @@ -46,6 +49,9 @@ from app.repositories_impl.sqlite.planned_day_exclusion_repository import ( SQLitePlannedDayExclusionRepository, ) from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository +from app.repositories_impl.sqlite.preschool_planning_days_repository import ( + SQLitePreschoolPlanningDaysRepository, +) from app.repositories_impl.sqlite.tip_dismissal_repository import SQLiteTipDismissalRepository from app.repositories_impl.sqlite.unit_of_work import SQLiteUnitOfWork from app.repositories_impl.sqlite.user_repository import SQLiteUserRepository @@ -64,6 +70,7 @@ from app.services.leave_wish_service import LeaveWishService from app.services.personal_data_service import PersonalDataService from app.services.planned_day_exclusion_service import PlannedDayExclusionService from app.services.planning_service import PlanningService +from app.services.preschool_planning_days_service import PreschoolPlanningDaysService from app.services.tips_service import TipsService from app.services.vacation_days_service import VacationDaysService from app.services.workspace_service import WorkspaceService @@ -200,6 +207,19 @@ PlannedDayExclusionRepositoryDep = Annotated[ ] +def get_preschool_planning_days_repository( + conn: ConnectionDep, +) -> PreschoolPlanningDaysRepository: + if settings.database_type == "sqlite": + return SQLitePreschoolPlanningDaysRepository(conn) + raise ValueError(f"Unsupported database_type: {settings.database_type}") + + +PreschoolPlanningDaysRepositoryDep = Annotated[ + PreschoolPlanningDaysRepository, Depends(get_preschool_planning_days_repository) +] + + def get_day_override_repository(conn: ConnectionDep) -> DayOverrideRepository: if settings.database_type == "sqlite": return SQLiteDayOverrideRepository(conn) @@ -227,9 +247,10 @@ def get_auth_service( user_repo: UserRepositoryDep, pd_repo: PersonalDataRepositoryDep, ws_repo: WorkspaceRepositoryDep, + inv_repo: WorkspaceInvitationRepositoryDep, uow: UnitOfWorkDep, ) -> AuthService: - return AuthService(user_repo, pd_repo, ws_repo, uow=uow) + return AuthService(user_repo, pd_repo, ws_repo, uow=uow, inv_repo=inv_repo) AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)] @@ -242,6 +263,17 @@ def get_vacation_days_service(repo: VacationDaysRepositoryDep) -> VacationDaysSe VacationDaysServiceDep = Annotated[VacationDaysService, Depends(get_vacation_days_service)] +def get_preschool_planning_days_service( + repo: PreschoolPlanningDaysRepositoryDep, +) -> PreschoolPlanningDaysService: + return PreschoolPlanningDaysService(repo) + + +PreschoolPlanningDaysServiceDep = Annotated[ + PreschoolPlanningDaysService, Depends(get_preschool_planning_days_service) +] + + def get_leave_wish_service( repo: LeaveWishRepositoryDep, planned_leave_repo: PlannedLeaveRepositoryDep, @@ -374,6 +406,7 @@ def get_allocation_input_loader( leave_wish_repo: LeaveWishRepositoryDep, day_override_repo: DayOverrideRepositoryDep, exclusion_repo: PlannedDayExclusionRepositoryDep, + preschool_planning_days_repo: PreschoolPlanningDaysRepositoryDep, ) -> AllocationInputLoader: return AllocationInputLoader( workspace_repo=workspace_repo, @@ -385,6 +418,7 @@ def get_allocation_input_loader( leave_wish_repo=leave_wish_repo, day_override_repo=day_override_repo, exclusion_repo=exclusion_repo, + preschool_planning_days_repo=preschool_planning_days_repo, ) diff --git a/backend/app/entities/preschool_planning_days.py b/backend/app/entities/preschool_planning_days.py new file mode 100644 index 0000000..b93b9f9 --- /dev/null +++ b/backend/app/entities/preschool_planning_days.py @@ -0,0 +1,37 @@ +"""Domain entity: PreschoolPlanningDays. + +One row per (child, year): how many "förskolans planeringsdagar" (preschool +closure/planning days, e.g. "studiedagar") that child's preschool has for a +given year, and how many were actually taken. + +`taken_days` is nullable on purpose: `None` means "not recorded yet", which +must be distinguishable from "recorded, and it was zero" — that distinction +is the whole point of this slice. Before this table existed, +`app/services/allocation_service.py::_preschool_reserve_days` could only +multiply `Child.preschool_planning_days_per_year` (see +`app/entities/child.py`) by the number of years in the planning horizon and +subtract the lump sum from both parents' pools, with no way to know the true +per-year number or how many parental-leave days that assumption actually +cost. + +NOTE for whoever wires the allocation engine to this table (tracked as a +follow-up, not done in this change): a (child, year) pair with no row here +means "use `Child.preschool_planning_days_per_year` as the planned figure, +taken unknown" — callers must fall back to that default rather than treating +a missing row as zero days. +""" + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + + +@dataclass +class PreschoolPlanningDays: + id: UUID + child_id: UUID + year: int + planned_days: int + taken_days: int | None = None + created_at: datetime | None = None + updated_at: datetime | None = None diff --git a/backend/app/main.py b/backend/app/main.py index a6696c0..bf799f2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -30,6 +30,7 @@ from app.routers.leave_wishes import router as leave_wishes_router from app.routers.plan import router as plan_router from app.routers.planned_day_exclusions import router as planned_day_exclusions_router from app.routers.planned_days import router as planned_days_router +from app.routers.preschool_planning_days import router as preschool_planning_days_router from app.routers.settings import child_router, settings_child_router, tips_router from app.routers.workspace import _inv_router as invitations_router from app.routers.workspace import router as workspace_router @@ -129,6 +130,7 @@ def _register_routers(app: FastAPI) -> None: app.include_router(planned_days_router) app.include_router(planned_day_exclusions_router) app.include_router(vacation_days.router) + app.include_router(preschool_planning_days_router) app.include_router(day_overrides_router) app.include_router(calendar_router) diff --git a/backend/app/models/analytics.py b/backend/app/models/analytics.py index 4c787a2..3f543da 100644 --- a/backend/app/models/analytics.py +++ b/backend/app/models/analytics.py @@ -24,6 +24,8 @@ class VacationYearBalanceResponse(BaseModel): left: int carried_out: int expiring: int + expiring_age: int + expiring_over_cap: int @classmethod def from_entity(cls, v: VacationYearBalance) -> "VacationYearBalanceResponse": @@ -36,6 +38,8 @@ class VacationYearBalanceResponse(BaseModel): left=v.left, carried_out=v.carried_out, expiring=v.expiring, + expiring_age=v.expiring_age, + expiring_over_cap=v.expiring_over_cap, ) diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py index a8a9904..4540b09 100644 --- a/backend/app/models/auth.py +++ b/backend/app/models/auth.py @@ -7,6 +7,11 @@ class RegisterRequest(BaseModel): email: EmailStr password: str = Field(min_length=8) display_name: str = Field(min_length=1, max_length=100) + # Optional: lets a partner register straight from an invite link instead + # of first creating a throwaway solo workspace. When present, `register` + # joins the inviting workspace directly (claiming a placeholder member + # if one exists) instead of creating a new one. + invite_token: str | None = None class LoginRequest(BaseModel): diff --git a/backend/app/models/preschool_planning_days.py b/backend/app/models/preschool_planning_days.py new file mode 100644 index 0000000..5190383 --- /dev/null +++ b/backend/app/models/preschool_planning_days.py @@ -0,0 +1,66 @@ +"""API schemas (Pydantic) for PreschoolPlanningDays. + +Schemas are the API boundary — they validate request bodies and shape +responses, kept separate from the domain entity so the wire format can +evolve independently. + +`PreschoolPlanningDaysResponse.default_for` builds a response for a (child, +year) with no persisted row, using `Child.preschool_planning_days_per_year` +as the planned figure and `taken_days=None` ("not recorded"). `is_default` +tells the caller (frontend, or later the allocation engine) whether the +figures are a real record or just the child's fallback default. +""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.entities.child import Child +from app.entities.preschool_planning_days import PreschoolPlanningDays + + +class PreschoolPlanningDaysPlannedUpdate(BaseModel): + planned_days: int = Field(ge=0, le=30) + + +class PreschoolPlanningDaysTakenUpdate(BaseModel): + taken_days: int = Field(ge=0, le=30) + + +class PreschoolPlanningDaysResponse(BaseModel): + id: UUID | None + child_id: UUID + year: int + planned_days: int + taken_days: int | None + is_default: bool + created_at: datetime | None + updated_at: datetime | None + + @classmethod + def from_entity(cls, entity: PreschoolPlanningDays) -> "PreschoolPlanningDaysResponse": + return cls( + id=entity.id, + child_id=entity.child_id, + year=entity.year, + planned_days=entity.planned_days, + taken_days=entity.taken_days, + is_default=False, + created_at=entity.created_at, + updated_at=entity.updated_at, + ) + + @classmethod + def default_for(cls, child: Child, year: int) -> "PreschoolPlanningDaysResponse": + """A (child, year) with no persisted row: fall back to the child's default.""" + return cls( + id=None, + child_id=child.id, + year=year, + planned_days=child.preschool_planning_days_per_year, + taken_days=None, + is_default=True, + created_at=None, + updated_at=None, + ) diff --git a/backend/app/repositories/preschool_planning_days_repository.py b/backend/app/repositories/preschool_planning_days_repository.py new file mode 100644 index 0000000..34b5a80 --- /dev/null +++ b/backend/app/repositories/preschool_planning_days_repository.py @@ -0,0 +1,17 @@ +"""Abstract repository interface for PreschoolPlanningDays. + +Adds entity-specific queries on top of the generic CRUD contract. Concrete +implementations live in app/repositories_impl//. +""" + +from abc import abstractmethod +from uuid import UUID + +from app.entities.preschool_planning_days import PreschoolPlanningDays +from app.repositories.base import Repository + + +class PreschoolPlanningDaysRepository(Repository[PreschoolPlanningDays]): + @abstractmethod + async def get_by_child_year(self, child_id: UUID, year: int) -> PreschoolPlanningDays | None: + """Return the entry for a given (child, year) (UNIQUE), or None.""" diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py index 317a65e..7d7b659 100644 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -1,6 +1,7 @@ """Abstract repository for User (login accounts).""" from abc import abstractmethod +from uuid import UUID from app.entities.user import User from app.repositories.base import Repository @@ -10,3 +11,10 @@ class UserRepository(Repository[User]): @abstractmethod async def get_by_email(self, email: str) -> User | None: """Return the user with this email, or None if not found.""" + + @abstractmethod + async def get_by_personal_data_uuid(self, personal_data_uuid: UUID) -> User | None: + """Return the user linked to this personal_data row, or None if it has no login. + + `personal_data_uuid` is UNIQUE on `user`, so this is at most one row. + """ diff --git a/backend/app/repositories_impl/postgres/preschool_planning_days_repository.py b/backend/app/repositories_impl/postgres/preschool_planning_days_repository.py new file mode 100644 index 0000000..ed9f3af --- /dev/null +++ b/backend/app/repositories_impl/postgres/preschool_planning_days_repository.py @@ -0,0 +1,37 @@ +"""Postgres stub for PreschoolPlanningDaysRepository. + +Implement when switching to Postgres (install asyncpg via `uv sync --extra postgres`). +All methods raise NotImplementedError until then. +""" + +from uuid import UUID + +from app.entities.preschool_planning_days import PreschoolPlanningDays +from app.repositories.filter_validator import validate_filters +from app.repositories.preschool_planning_days_repository import ( + PreschoolPlanningDaysRepository, +) + + +class PostgresPreschoolPlanningDaysRepository(PreschoolPlanningDaysRepository): + def __init__(self, connection: object) -> None: + self._conn = connection + + async def create(self, item: PreschoolPlanningDays) -> PreschoolPlanningDays: + raise NotImplementedError("Postgres repo not yet implemented") + + async def get_by_id(self, id: UUID) -> PreschoolPlanningDays | None: + raise NotImplementedError("Postgres repo not yet implemented") + + async def update(self, id: UUID, item: PreschoolPlanningDays) -> PreschoolPlanningDays: + raise NotImplementedError("Postgres repo not yet implemented") + + async def delete(self, id: UUID) -> None: + raise NotImplementedError("Postgres repo not yet implemented") + + async def list(self, **filters: object) -> list[PreschoolPlanningDays]: + validate_filters(filters, {"child_id", "year"}) + raise NotImplementedError("Postgres repo not yet implemented") + + async def get_by_child_year(self, child_id: UUID, year: int) -> PreschoolPlanningDays | None: + raise NotImplementedError("Postgres repo not yet implemented") diff --git a/backend/app/repositories_impl/sqlite/preschool_planning_days_repository.py b/backend/app/repositories_impl/sqlite/preschool_planning_days_repository.py new file mode 100644 index 0000000..ece58e0 --- /dev/null +++ b/backend/app/repositories_impl/sqlite/preschool_planning_days_repository.py @@ -0,0 +1,115 @@ +"""SQLite implementation of PreschoolPlanningDaysRepository. + +Follows the SQLiteVacationDaysRepository reference shape: map rows <-> +entities explicitly in `_row_to_entity`, always parameterize queries, raise +NotFoundError from update/delete when the row is missing. +""" + +from datetime import datetime +from uuid import UUID + +import aiosqlite + +from app.db.connection import commit +from app.entities.preschool_planning_days import PreschoolPlanningDays +from app.repositories.filter_validator import validate_filters +from app.repositories.preschool_planning_days_repository import ( + PreschoolPlanningDaysRepository, +) +from app.utils.errors import NotFoundError + + +class SQLitePreschoolPlanningDaysRepository(PreschoolPlanningDaysRepository): + def __init__(self, connection: aiosqlite.Connection) -> None: + self._conn = connection + + @staticmethod + def _row_to_entity(row: aiosqlite.Row) -> PreschoolPlanningDays: + return PreschoolPlanningDays( + id=UUID(row["id"]), + child_id=UUID(row["child_id"]), + year=row["year"], + planned_days=row["planned_days"], + taken_days=row["taken_days"], + created_at=datetime.fromisoformat(row["created_at"]), + updated_at=datetime.fromisoformat(row["updated_at"]), + ) + + async def create(self, item: PreschoolPlanningDays) -> PreschoolPlanningDays: + now = datetime.now() + item.created_at = item.created_at or now + item.updated_at = now + await self._conn.execute( + """ + INSERT INTO preschool_planning_days + (id, child_id, year, planned_days, taken_days, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + str(item.id), + str(item.child_id), + item.year, + item.planned_days, + item.taken_days, + item.created_at.isoformat(), + item.updated_at.isoformat(), + ), + ) + await commit(self._conn) + return item + + async def get_by_id(self, id: UUID) -> PreschoolPlanningDays | None: + cursor = await self._conn.execute( + "SELECT * FROM preschool_planning_days WHERE id = ?", (str(id),) + ) + row = await cursor.fetchone() + return self._row_to_entity(row) if row else None + + async def get_by_child_year(self, child_id: UUID, year: int) -> PreschoolPlanningDays | None: + cursor = await self._conn.execute( + "SELECT * FROM preschool_planning_days WHERE child_id = ? AND year = ?", + (str(child_id), year), + ) + row = await cursor.fetchone() + return self._row_to_entity(row) if row else None + + async def update(self, id: UUID, item: PreschoolPlanningDays) -> PreschoolPlanningDays: + item.updated_at = datetime.now() + cursor = await self._conn.execute( + """ + UPDATE preschool_planning_days + SET planned_days = ?, taken_days = ?, updated_at = ? + WHERE id = ? + """, + (item.planned_days, item.taken_days, item.updated_at.isoformat(), str(id)), + ) + await commit(self._conn) + if cursor.rowcount == 0: + raise NotFoundError(f"PreschoolPlanningDays {id} not found") + refreshed = await self.get_by_id(id) + assert refreshed is not None # just updated + return refreshed + + async def delete(self, id: UUID) -> None: + cursor = await self._conn.execute( + "DELETE FROM preschool_planning_days WHERE id = ?", (str(id),) + ) + await commit(self._conn) + if cursor.rowcount == 0: + raise NotFoundError(f"PreschoolPlanningDays {id} not found") + + async def list(self, **filters: object) -> list[PreschoolPlanningDays]: + validate_filters(filters, {"child_id", "year"}) + clauses: list[str] = [] + params: list[object] = [] + for column in ("child_id", "year"): + if column in filters and filters[column] is not None: + value = filters[column] + clauses.append(f"{column} = ?") + params.append(str(value) if isinstance(value, UUID) else value) + where = f" WHERE {' AND '.join(clauses)}" if clauses else "" + cursor = await self._conn.execute( + f"SELECT * FROM preschool_planning_days{where} ORDER BY year DESC", params + ) + rows = await cursor.fetchall() + return [self._row_to_entity(row) for row in rows] diff --git a/backend/app/repositories_impl/sqlite/user_repository.py b/backend/app/repositories_impl/sqlite/user_repository.py index c4edd4f..65f6259 100644 --- a/backend/app/repositories_impl/sqlite/user_repository.py +++ b/backend/app/repositories_impl/sqlite/user_repository.py @@ -58,10 +58,33 @@ class SQLiteUserRepository(UserRepository): row = await cursor.fetchone() return self._row_to_entity(row) if row else None - async def update(self, id: UUID, item: User) -> User: + async def get_by_personal_data_uuid(self, personal_data_uuid: UUID) -> User | None: cursor = await self._conn.execute( - 'UPDATE "user" SET email = ?, hashed_password = ?, is_active = ? WHERE id = ?', - (item.email, item.hashed_password, int(item.is_active), str(id)), + 'SELECT * FROM "user" WHERE personal_data_uuid = ?', (str(personal_data_uuid),) + ) + row = await cursor.fetchone() + return self._row_to_entity(row) if row else None + + async def update(self, id: UUID, item: User) -> User: + # `personal_data_uuid` is included here (unlike a typical immutable + # FK) because `WorkspaceService.redeem_invitation` and + # `AuthService.register(..., invite_token=...)` re-point a user's + # login onto an existing placeholder `personal_data` row when + # claiming it. `user.personal_data_uuid` is UNIQUE, so re-pointing it + # onto a row already linked to another user fails at the DB. + cursor = await self._conn.execute( + """ + UPDATE "user" + SET personal_data_uuid = ?, email = ?, hashed_password = ?, is_active = ? + WHERE id = ? + """, + ( + str(item.personal_data_uuid), + item.email, + item.hashed_password, + int(item.is_active), + str(id), + ), ) await commit(self._conn) if cursor.rowcount == 0: diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 7b46eb2..0cca4da 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -13,7 +13,7 @@ from app.dependencies import ( ) from app.rate_limit import limiter from app.models.auth import LoginRequest, RegisterRequest, TokenResponse, UserMeResponse -from app.utils.errors import AuthError, ConflictError, UnauthorizedError +from app.utils.errors import AuthError, ConflictError, InvitationError, UnauthorizedError router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -31,9 +31,12 @@ async def register( email=str(payload.email), password=payload.password, display_name=payload.display_name, + invite_token=payload.invite_token, ) except ConflictError as exc: raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc + except InvitationError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc access = service.create_access_token(user.id) refresh = service.create_refresh_token(user.id) diff --git a/backend/app/routers/preschool_planning_days.py b/backend/app/routers/preschool_planning_days.py new file mode 100644 index 0000000..7bbd3f3 --- /dev/null +++ b/backend/app/routers/preschool_planning_days.py @@ -0,0 +1,77 @@ +"""HTTP routes for preschool planning ("studiedagar") days per year. + +Routers stay thin: validate via Pydantic, call the service, map domain +errors to HTTP. Scoped per child via `WorkspaceScope.check_child`, which +404s (never 403) on a missing or foreign-workspace child id. +""" + +from uuid import UUID + +from fastapi import APIRouter, HTTPException, status + +from app.dependencies import PreschoolPlanningDaysServiceDep, WorkspaceScopeDep +from app.models.preschool_planning_days import ( + PreschoolPlanningDaysPlannedUpdate, + PreschoolPlanningDaysResponse, + PreschoolPlanningDaysTakenUpdate, +) +from app.utils.errors import ValidationError + +router = APIRouter(prefix="/api/preschool-planning-days", tags=["preschool-planning-days"]) + + +@router.put("/{child_id}/{year}/planned", response_model=PreschoolPlanningDaysResponse) +async def upsert_planned_days( + child_id: UUID, + year: int, + payload: PreschoolPlanningDaysPlannedUpdate, + service: PreschoolPlanningDaysServiceDep, + scope: WorkspaceScopeDep, +) -> PreschoolPlanningDaysResponse: + child = await scope.check_child(child_id) + try: + entity = await service.upsert_planned(child, year, payload.planned_days) + except ValidationError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + return PreschoolPlanningDaysResponse.from_entity(entity) + + +@router.put("/{child_id}/{year}/taken", response_model=PreschoolPlanningDaysResponse) +async def record_taken_days( + child_id: UUID, + year: int, + payload: PreschoolPlanningDaysTakenUpdate, + service: PreschoolPlanningDaysServiceDep, + scope: WorkspaceScopeDep, +) -> PreschoolPlanningDaysResponse: + child = await scope.check_child(child_id) + try: + entity = await service.record_taken(child, year, payload.taken_days) + except ValidationError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + return PreschoolPlanningDaysResponse.from_entity(entity) + + +@router.get("/{child_id}/{year}", response_model=PreschoolPlanningDaysResponse) +async def get_year( + child_id: UUID, + year: int, + service: PreschoolPlanningDaysServiceDep, + scope: WorkspaceScopeDep, +) -> PreschoolPlanningDaysResponse: + child = await scope.check_child(child_id) + entity = await service.get_by_child_year(child_id, year) + if entity is None: + return PreschoolPlanningDaysResponse.default_for(child, year) + return PreschoolPlanningDaysResponse.from_entity(entity) + + +@router.get("/{child_id}", response_model=list[PreschoolPlanningDaysResponse]) +async def list_years( + child_id: UUID, + service: PreschoolPlanningDaysServiceDep, + scope: WorkspaceScopeDep, +) -> list[PreschoolPlanningDaysResponse]: + await scope.check_child(child_id) + entities = await service.list_for_child(child_id) + return [PreschoolPlanningDaysResponse.from_entity(e) for e in entities] diff --git a/backend/app/routers/workspace.py b/backend/app/routers/workspace.py index 0cf46d6..c09823d 100644 --- a/backend/app/routers/workspace.py +++ b/backend/app/routers/workspace.py @@ -175,6 +175,22 @@ async def create_invitation( return InviteResponse(token=raw_token) +@router.get( + "/{workspace_id}/invitations", + response_model=list[InvitationResponse], +) +async def list_invitations( + workspace_id: WorkspaceIdDep, + service: WorkspaceServiceDep, +) -> list[InvitationResponse]: + """List invitations for this workspace. Never includes `token_hash`. + + The only way `DELETE /api/invitations/{id}` can be given an id to act on. + """ + invitations = await service.list_invitations(workspace_id) + return [InvitationResponse.from_entity(inv) for inv in invitations] + + # Invitation redemption is workspace-agnostic (token carries workspace info). _inv_router = APIRouter(prefix="/api/invitations", tags=["invitations"]) diff --git a/backend/app/services/allocation_service.py b/backend/app/services/allocation_service.py index 21d5221..2c1022c 100644 --- a/backend/app/services/allocation_service.py +++ b/backend/app/services/allocation_service.py @@ -34,8 +34,18 @@ from app.repositories.parental_leave_repository import ParentalLeaveRepository from app.repositories.personal_data_repository import PersonalDataRepository from app.repositories.planned_day_exclusion_repository import PlannedDayExclusionRepository from app.repositories.planned_leave_repository import PlannedLeaveRepository +from app.repositories.preschool_planning_days_repository import PreschoolPlanningDaysRepository from app.repositories.vacation_days_repository import VacationDaysRepository from app.repositories.workspace_repository import WorkspaceRepository + +# §Task 2 (vacation-forfeiture bias) — `AllocationInputLoader` reuses +# `AnalyticsService._vacation_year_series` (the FIFO carry-forward bank +# simulation behind the Analytics tab's balance series) verbatim rather than +# re-deriving it, so the loader's forecast of what vacation would be lost +# this year can never quietly drift from what the dashboard reports for the +# same (parent, year). `AnalyticsService` does not import anything from this +# module, so this stays a one-way dependency. +from app.services.analytics_service import AnalyticsService from app.utils.errors import NotFoundError, ValidationError from app.utils.holidays import ( SUNDAY_HOLIDAY_NAME, @@ -46,7 +56,6 @@ from app.utils.holidays import ( from app.utils.tip_predicates import ( days_left_at_age_4_projection, default_tier_pool_share, - has_vacation_near_expiry, resolve_age4_savable_cap, ) @@ -281,6 +290,32 @@ class AllocationInput: # Tuesday is still a working Tuesday, for analytics and for everyone # else in the workspace). excluded_days: frozenset[tuple[UUID, UUID, date]] = frozenset() + # §Task 1 (per-year preschool reserve) — (child_id, year) -> the TRUE + # reserve for that specific calendar year, sourced from a + # `PreschoolPlanningDays` row: recorded `taken_days` wins over + # `planned_days` when present (the closure already happened, so what was + # actually taken is the true cost — see that entity's docstring), + # otherwise `planned_days`. A (child, year) pair with NO row here + # (including every year for a workspace whose loader wasn't given a + # `PreschoolPlanningDaysRepository`) falls back to `ChildFacts. + # preschool_planning_days_per_year` in `_preschool_reserve_days` — never + # to 0 — matching how a missing `vacation_days` row falls back to the + # statutory default rather than 0. Empty by default so every hand-built + # `AllocationInput` in the test suite keeps the old flat-lump behaviour. + preschool_reserve_by_year: dict[tuple[UUID, int], int] = field(default_factory=dict) + # §Task 2 (vacation-forfeiture bias) — (parent_uuid, year) -> the number + # of vacation days forecast to be LOST at that year's end (age-cap + + # over-cap combined — same `VacationYearBalance.expiring` the Analytics + # tab shows), for every year from `today.year` through `horizon_end.year` + # inclusive. Computed by `AllocationInputLoader` from already-committed + # usage, BEFORE this run's own placements are added — see + # `_Ledger._vacation_expiry_warnings`'s docstring for what that means for + # the warning it drives. Never contains a year before `today.year`: a + # past year's loss already happened and must never resurface here or bias + # `_assign_tier3`. Empty by default, so every hand-built `AllocationInput` + # in the test suite keeps producing byte-identical output absent an + # explicit forfeiture figure. + vacation_forfeiting: dict[tuple[UUID, int], int] = field(default_factory=dict) # --------------------------------------------------------------------------- @@ -496,16 +531,34 @@ def _red_day_cluster_dates( return result -def _preschool_reserve_days(child: ChildFacts, today: date, horizon_end: date) -> int: - """§9.3: days reserved off the top for preschool planning-day closures.""" +def _preschool_reserve_days( + child: ChildFacts, + today: date, + horizon_end: date, + overrides: dict[tuple[UUID, int], int] | None = None, +) -> int: + """§9.3 + §Task 1: days reserved off the top for preschool planning-day + closures, summed year by year. + + `overrides` (`AllocationInput.preschool_reserve_by_year`) supplies the + TRUE per-year figure where a `PreschoolPlanningDays` row exists for that + (child, year) — see that field's docstring for the taken/planned/missing + precedence. A year with no override falls back to + `child.preschool_planning_days_per_year`, exactly reproducing the old + flat `per_year * years` lump sum for every year this loader/test doesn't + supply a recorded figure for. + """ if child.preschool_start_date is None or child.preschool_start_date > horizon_end: return 0 start_year = max(child.preschool_start_date.year, today.year) end_year = horizon_end.year if end_year < start_year: return 0 - years = end_year - start_year + 1 - return child.preschool_planning_days_per_year * years + overrides = overrides or {} + return sum( + overrides.get((child.id, year), child.preschool_planning_days_per_year) + for year in range(start_year, end_year + 1) + ) # --------------------------------------------------------------------------- @@ -860,7 +913,12 @@ class _Ledger: def _reserve_preschool_days(self) -> None: for child in self._input.children: - reserve = _preschool_reserve_days(child, self._input.today, self._input.horizon_end) + reserve = _preschool_reserve_days( + child, + self._input.today, + self._input.horizon_end, + self._input.preschool_reserve_by_year, + ) if reserve <= 0: continue self._preschool_reserved[child.id] = reserve @@ -1125,6 +1183,22 @@ class _Ledger: preferred) fills the rest; an hourly parent would lose pay to a vacation day, so FP goes first and vacation only backstops what FP can't cover. + + §Task 2 (vacation-forfeiture bias) overrides that hourly ordering for + one specific case: when `d`'s (parent, year) is forecast to forfeit + vacation anyway (`AllocationInput.vacation_forfeiting`), spending a + vacation day here costs the household NOTHING it wasn't already + going to lose, while the FP day it displaces stays banked for a + later year — the whole point of the bias (see `_Ledger. + _assign_tier3`'s call site and `AllocationInput.vacation_forfeiting`'s + docstring). Never applies to a past year: every date reaching this + method already comes from a window clipped to `>= today`, and + `vacation_forfeiting` itself never carries a year before + `today.year`. Only ever spends MORE vacation than the hourly default + would, never less, so it can't push a parent below their + `min_vacation_days_per_year` floor — that floor bounds how little + vacation may be taken, not how much, and `_spend_vacation` still + refuses once a year's budget is exhausted regardless of this bias. """ if child is None or pool_key is None: # No resolvable child (genuinely ambiguous family-wide wish, or a @@ -1133,7 +1207,9 @@ class _Ledger: return "vacation", REASON_VACATION_WRAP_WEEKEND return None - if parent.salary_type == "monthly": + vacation_forfeiting = self._input.vacation_forfeiting.get((parent.uuid, d.year), 0) > 0 + + if parent.salary_type == "monthly" or vacation_forfeiting: if self._spend_vacation(parent.uuid, d): return "vacation", REASON_VACATION_WRAP_WEEKEND fp = self._spend_fp(pool_key, prefer="sjukpenning") @@ -2385,6 +2461,16 @@ class _Ledger: ): if remaining <= 0: break + if self._input.vacation_forfeiting.get((parent.uuid, year), 0) > 0: + # §Task 2 reconciliation — never advise reducing vacation use + # in a year where that same use is exactly what + # `_assign_tier3`'s forfeiture bias just spent to PREVENT a + # loss. Advising "use less" there would contradict what this + # same proposal already did (and correctly did) for that + # year: the two pieces of advice would otherwise both fire + # for the same (parent, year), pulling in opposite + # directions. + continue budget = self._vacation[(parent.uuid, year)] reducible = max(budget.already_used - parent.min_vacation_days_per_year, 0) take = min(reducible, remaining) @@ -2455,25 +2541,40 @@ class _Ledger: return warnings def _vacation_expiry_warnings(self) -> list[PlanWarning]: + """§Task 2 — the real forfeiture figure (`AllocationInput. + vacation_forfeiting`, sourced from the same FIFO carry-forward + simulation the Analytics tab uses) replaces the old + `has_vacation_near_expiry` heuristic, which only ever checked "large + total + large carry-in" and could both under- and over-warn relative + to what actually gets lost. + + This is a forecast, not a post-plan recomputation: `vacation_ + forfeiting` is computed by the loader from already-committed usage, + before this run's own placements (including `_assign_tier3`'s + forfeiture bias above) are added. A (parent, year) this run funds + with extra vacation days over a forecast forfeiture may end up + losing fewer days than warned here — re-simulating the bank with + this run's own placements would mean re-deriving `AnalyticsService`'s + full carry-forward algorithm a second time inside the pure `_Ledger`, + which is deliberately not duplicated here. + """ warnings: list[PlanWarning] = [] - for (parent_uuid, year), budget in sorted( - self._vacation.items(), key=lambda kv: (str(kv[0][0]), kv[0][1]) + for (parent_uuid, year), expiring in sorted( + self._input.vacation_forfeiting.items(), key=lambda kv: (str(kv[0][0]), kv[0][1]) ): - if ( - has_vacation_near_expiry(budget.total_days, budget.saved_days_in) - and budget.available > 0 - ): - warnings.append( - PlanWarning( - kind=WARNING_VACATION_EXPIRING, - message_key=f"planWarnings.{WARNING_VACATION_EXPIRING}", - data={ - "parent_personal_data_uuid": parent_uuid, - "year": year, - "saved_days_in": budget.saved_days_in, - }, - ) + if expiring <= 0: + continue + warnings.append( + PlanWarning( + kind=WARNING_VACATION_EXPIRING, + message_key=f"planWarnings.{WARNING_VACATION_EXPIRING}", + data={ + "parent_personal_data_uuid": parent_uuid, + "year": year, + "expiring_days": expiring, + }, ) + ) return warnings # -- final snapshot -------------------------------------------------- @@ -2611,6 +2712,7 @@ class AllocationInputLoader: leave_wish_repo: LeaveWishRepository, day_override_repo: DayOverrideRepository | None = None, exclusion_repo: PlannedDayExclusionRepository | None = None, + preschool_planning_days_repo: PreschoolPlanningDaysRepository | None = None, ) -> None: self._workspace_repo = workspace_repo self._children_repo = child_repo @@ -2621,6 +2723,14 @@ class AllocationInputLoader: self._leave_wish_repo = leave_wish_repo self._day_override_repo = day_override_repo self._exclusion_repo = exclusion_repo + # §Task 1 (per-year preschool reserve) — optional, like + # `day_override_repo`/`exclusion_repo` above: `None` (every caller + # that hasn't been wired up yet, including every existing DI + # container until it's updated to pass one) means "no per-year rows + # available", so `load()` leaves `AllocationInput. + # preschool_reserve_by_year` empty and `_preschool_reserve_days` + # falls back to the old flat `per_year * years` lump sum unchanged. + self._preschool_planning_days_repo = preschool_planning_days_repo async def load( self, @@ -2732,6 +2842,20 @@ class AllocationInputLoader: horizon_end = _compute_horizon_end(today, children, wishes) + # §Task 1 (per-year preschool reserve) — recorded `taken_days` wins + # over `planned_days` (see `PreschoolPlanningDays.taken_days`'s + # docstring); a (child, year) with no row is simply left out of the + # dict, so `_preschool_reserve_days` falls back to `ChildFacts. + # preschool_planning_days_per_year` for it rather than treating the + # gap as zero. + preschool_reserve_by_year: dict[tuple[UUID, int], int] = {} + if self._preschool_planning_days_repo is not None: + for child in child_entities: + rows = await self._preschool_planning_days_repo.list(child_id=child.id) + for row in rows: + reserve = row.taken_days if row.taken_days is not None else row.planned_days + preschool_reserve_by_year[(child.id, row.year)] = reserve + def _already_used_vacation(parent_uuid: UUID, year: int) -> int: # Pinned vacation days are excluded here for the same reason as # the FP pools above: `_Ledger._reserve_pinned_days` debits them @@ -2746,6 +2870,12 @@ class AllocationInputLoader: ) vacation: dict[tuple[UUID, int], VacationBudget] = {} + # §Task 2 (vacation-forfeiture bias) — see `AllocationInput. + # vacation_forfeiting`'s docstring. Computed inside this same loop + # (not a separate pass) so `vac_entries` is only fetched once per + # parent. + vacation_forfeiting: dict[tuple[UUID, int], int] = {} + forecast_years = list(range(today.year, horizon_end.year + 1)) for parent in parent_entities: vac_entries = await self._vacation_repo.list( personal_data_uuid=parent.uuid, workspace_id=workspace_id @@ -2777,6 +2907,38 @@ class AllocationInputLoader: is_assumed=True, ) + # §Task 2 — reuse `AnalyticsService._vacation_year_series` + # verbatim (the same FIFO carry-forward bank simulation behind + # the Analytics tab's balance series) rather than re-deriving it, + # so this forecast can never quietly drift from what the + # dashboard reports for the same (parent, year). `use_by_year` + # deliberately counts EVERY persisted 'vacation' row for this + # parent across ALL of their children, unfiltered by + # pinned/workspace — matching `AnalyticsService. + # _compute_year_balances` exactly, since a forfeiture forecast + # must reflect every already-committed day, pinned or not. + # Seeded from `today.year` (that year's `saved_days_in` as the + # sole carry-in, exactly like the `vacation` dict above) rather + # than the child's birth year: this loader never reconstructs + # full lifetime history, only "now onward" — this also means the + # simulation can never see, let alone report, a year before + # `today.year` (a past loss already happened and must not + # resurface here or bias `_assign_tier3`). + vac_by_year = {vd.year: vd for vd in vac_entries} + parent_planned_days = await self._planned_leave_repo.list( + parent_personal_data_uuid=parent.uuid + ) + use_by_year: dict[int, int] = {} + for pd in parent_planned_days: + if pd.day_type == "vacation": + use_by_year[pd.date.year] = use_by_year.get(pd.date.year, 0) + 1 + vacation_balances = AnalyticsService._vacation_year_series( + forecast_years, vac_by_year, use_by_year, parent.min_vacation_days_per_year + ) + for year, balance in zip(forecast_years, vacation_balances, strict=True): + if balance.expiring > 0: + vacation_forfeiting[(parent.uuid, year)] = balance.expiring + # `strict=True`: never silently fall back to the live `holidays` # library for a year outside `swedish_calendar.json`'s cached range — # that fallback is missing de facto days (Julafton/Nyårsafton/ @@ -2835,6 +2997,8 @@ class AllocationInputLoader: take_pl_on_red_days=ws.take_pl_on_red_days, all_existing_planned=all_existing_planned, excluded_days=excluded_days, + preschool_reserve_by_year=preschool_reserve_by_year, + vacation_forfeiting=vacation_forfeiting, ) diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py index baf762e..fa745b5 100644 --- a/backend/app/services/analytics_service.py +++ b/backend/app/services/analytics_service.py @@ -58,7 +58,16 @@ class VacationYearBalance: planned_use: int # committed 'vacation' planned_leave_day rows in this year (see PlanningService._vacation_days_left) left: int # max(earned + carried_in - planned_use, 0) carried_out: int # days saved forward into next year's carried_in - expiring: int # days that hit the 5-year (rules.vacation_carry_forward_years) cap this year — LOST if not used + # EVERY day lost at this year's end, from both causes combined — the sum + # of `expiring_age` and `expiring_over_cap` below. Never a subset: a UI + # showing only `expiring` must not silently under-report a forfeiture. + expiring: int + # subset of `expiring`: bank entries that hit the 5-year + # (rules.vacation_carry_forward_years) age cap this year. + expiring_age: int + # subset of `expiring`: days above (earned - floor) that couldn't be + # banked — includes an unused floor itself when usage is low. + expiring_over_cap: int @dataclass @@ -94,7 +103,17 @@ class ParentYearBalance: - A bank entry in its final valid year (earned exactly `vacation_carry_forward_years` years ago) that still has an unconsumed balance drops out instead of carrying forward, and is reported as - `expiring` for that year. + `expiring_age` for that year. + - Separately, whatever of `left` cannot be absorbed by `carried_out` + (days above `earned - floor` with nowhere to bank — including an + unused floor itself, when usage is low) is `expiring_over_cap` for + that year. + - `expiring` is `expiring_age + expiring_over_cap`: EVERY vacation day + lost at this year's end, however it was lost. Earlier versions of this + field counted only the age-cap portion, so a family could lose days + purely to the over-cap cause (e.g. earned 30, floor 20, used 15 → + 5 days over-cap lost) while the dashboard showed "Expiring 0" — that + silent gap is why the breakdown exists now. `saved_days_in` on a `vacation_days` row is normally only used to *seed* the bank for the first year of the series (year-earned = year − 1, since @@ -416,7 +435,7 @@ class AnalyticsService: # Entries still valid to carry into next year vs. ones in their # final valid year (expire now if any balance survived). old_carry_forward = sum(c for ye, c in bank if ye >= year - (carry_years - 1)) - expiring = sum(c for ye, c in bank if ye == year - carry_years) + expiring_age_raw = sum(c for ye, c in bank if ye == year - carry_years) bank = [e for e in bank if e[0] >= year - (carry_years - 1)] current_year_saveable = max(earned - floor, 0) @@ -425,6 +444,18 @@ class AnalyticsService: if new_entry_amount > 0: bank.append([year, new_entry_amount]) + # `left` minus what actually got banked is EVERY day lost this + # year — from both causes. `expiring_age_raw` (bank entries that + # just hit the age cap) is a subset of that total by construction + # (those days are inside `left` and excluded from `carried_out` + # via `old_carry_forward`), but clamp it defensively so the two + # portions can never sum to more than `total_lost` in an edge + # case (e.g. `left` itself clamped to 0 by the `max(..., 0)` + # above, when planned_use outstrips earned + carried_in). + total_lost = max(left - carried_out, 0) + expiring_age = min(expiring_age_raw, total_lost) + expiring_over_cap = total_lost - expiring_age + result.append( VacationYearBalance( year=year, @@ -434,7 +465,9 @@ class AnalyticsService: planned_use=planned_use, left=left, carried_out=carried_out, - expiring=expiring, + expiring=total_lost, + expiring_age=expiring_age, + expiring_over_cap=expiring_over_cap, ) ) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 15305a9..97233bf 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -12,6 +12,7 @@ TODO (post-MVP): password reset + email verification flows are not implemented. from __future__ import annotations import asyncio +import hashlib from datetime import datetime, timedelta, timezone from uuid import UUID, uuid4 @@ -26,8 +27,11 @@ from app.entities.workspace import Workspace from app.repositories.personal_data_repository import PersonalDataRepository from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork from app.repositories.user_repository import UserRepository -from app.repositories.workspace_repository import WorkspaceRepository -from app.utils.errors import AuthError, ConflictError, UnauthorizedError +from app.repositories.workspace_repository import ( + WorkspaceInvitationRepository, + WorkspaceRepository, +) +from app.utils.errors import AuthError, ConflictError, InvitationError, UnauthorizedError _ph = PasswordHasher() @@ -45,10 +49,16 @@ class AuthService: personal_data_repo: PersonalDataRepository, workspace_repo: WorkspaceRepository, uow: UnitOfWork | None = None, + inv_repo: WorkspaceInvitationRepository | None = None, ) -> None: self._users = user_repo self._personal_data = personal_data_repo self._workspaces = workspace_repo + # Optional: only needed for `register(..., invite_token=...)`. Kept + # optional (rather than required) so pre-existing direct-construction + # call sites (scripts/seed_demo.py, some test fixtures) that never + # exercise the invite-registration path keep working unchanged. + self._inv = inv_repo # Defaults to a no-op so pre-existing direct-construction call sites # (scripts/seed_demo.py, test fixtures) keep today's per-write-commit # behavior; `app.dependencies.get_auth_service` always wires a real @@ -56,9 +66,32 @@ class AuthService: self._uow = uow or NoOpUnitOfWork() async def register( - self, email: str, password: str, display_name: str + self, + email: str, + password: str, + display_name: str, + invite_token: str | None = None, ) -> tuple[User, PersonalData]: - """Create User + personal workspace + PersonalData. Raises ConflictError if email taken.""" + """Create a User, linked to a PersonalData profile. + + Without `invite_token`: creates a brand-new personal workspace + + PersonalData, as before. + + With `invite_token`: skips creating a new workspace altogether and + joins the inviting workspace directly, mirroring + `WorkspaceService.redeem_invitation`'s claim logic — + - a placeholder member (no linked User) in that workspace is claimed + (the new User links straight to it; its display name is updated to + `display_name`), preserving any plan data already recorded against + it; + - otherwise, if the workspace has a free slot, a fresh PersonalData + row is created there instead of in a new workspace; + - otherwise (both slots already have real logins) the invitation is + rejected. + + Raises ConflictError if the email is taken, InvitationError for an + invalid/expired/full/unconfigured invitation. + """ existing = await self._users.get_by_email(email) if existing is not None: raise ConflictError("Email already registered") @@ -68,26 +101,70 @@ class AuthService: # write lock for it. hashed_password = await asyncio.to_thread(_ph.hash, password) - # Workspace + PersonalData + User must land together: a crash between - # any two of these would otherwise leave an orphaned workspace, a - # personal_data row with no login, or (with the FK ON DELETE CASCADE) - # a user pointing at nothing. - async with self._uow(): - # Create a personal workspace for the registering user. - workspace = Workspace( - id=uuid4(), - name=f"{display_name}s planering", - created_at=datetime.now(), - ) - workspace = await self._workspaces.create(workspace) + inv = None + placeholder: PersonalData | None = None + if invite_token is not None: + if self._inv is None: + raise InvitationError("Invitations are not available") + token_hash = hashlib.sha256(invite_token.encode()).hexdigest() + inv = await self._inv.get_by_token_hash(token_hash) + if inv is None: + raise InvitationError("Invitation not found") + if inv.status != "pending": + raise InvitationError(f"Invitation is {inv.status}") + if datetime.now() > inv.expires_at: + inv.status = "expired" + await self._inv.update(inv.id, inv) + raise InvitationError("Invitation has expired") - pd = PersonalData( - uuid=uuid4(), - workspace_id=workspace.id, - name=display_name, - created_at=datetime.now(), - ) - pd = await self._personal_data.create(pd) + existing_members = await self._personal_data.list_by_workspace(inv.workspace_id) + for member in existing_members: + if await self._users.get_by_personal_data_uuid(member.uuid) is None: + placeholder = member + break + if placeholder is None and len(existing_members) >= 2: + raise InvitationError("This workspace already has its maximum number of members") + + # Workspace/PersonalData creation (or placeholder claim) + User + + # invitation acceptance must land together: a crash partway through + # would otherwise leave an orphaned workspace, a personal_data row + # with no login, a user pointing at nothing (FK ON DELETE CASCADE), + # or an invitation stuck out of step with membership. + async with self._uow(): + if inv is not None: + if placeholder is not None: + pd = placeholder + if pd.name != display_name: + pd.name = display_name + pd = await self._personal_data.update(pd.uuid, pd) + else: + pd = PersonalData( + uuid=uuid4(), + workspace_id=inv.workspace_id, + name=display_name, + created_at=datetime.now(), + ) + pd = await self._personal_data.create(pd) + + inv.status = "accepted" + inv.accepted_at = datetime.now() + assert self._inv is not None + await self._inv.update(inv.id, inv) + else: + workspace = Workspace( + id=uuid4(), + name=f"{display_name}s planering", + created_at=datetime.now(), + ) + workspace = await self._workspaces.create(workspace) + + pd = PersonalData( + uuid=uuid4(), + workspace_id=workspace.id, + name=display_name, + created_at=datetime.now(), + ) + pd = await self._personal_data.create(pd) user = User( id=uuid4(), diff --git a/backend/app/services/preschool_planning_days_service.py b/backend/app/services/preschool_planning_days_service.py new file mode 100644 index 0000000..394d079 --- /dev/null +++ b/backend/app/services/preschool_planning_days_service.py @@ -0,0 +1,93 @@ +"""Business logic for per-year preschool planning ("studiedagar") days. + +The service owns the "no row yet" fallback: `child.preschool_planning_days_per_year` +seeds `planned_days` the first time a year is touched (either a planned-days +override or a taken-days record), so a caller never has to know whether a row +already exists. + +NOTE for the allocation engine (`app/services/allocation_service.py`, +`_preschool_reserve_days`): this service/table is not wired into it yet. +That currently still multiplies `child.preschool_planning_days_per_year` by +the number of years in the horizon. A follow-up should make it consume +`PreschoolPlanningDaysRepository.list(child_id=...)` per year instead, +falling back to the child default for years with no row (see +`PreschoolPlanningDaysResponse.default_for`). +""" + +from datetime import datetime +from uuid import UUID, uuid4 + +from app.entities.child import Child +from app.entities.preschool_planning_days import PreschoolPlanningDays +from app.repositories.preschool_planning_days_repository import ( + PreschoolPlanningDaysRepository, +) +from app.utils.errors import NotFoundError, ValidationError + + +class PreschoolPlanningDaysService: + def __init__(self, repo: PreschoolPlanningDaysRepository) -> None: + self._repo = repo + + async def upsert_planned( + self, child: Child, year: int, planned_days: int + ) -> PreschoolPlanningDays: + """Create or update the planned-days figure for (child, year). + + Preserves any already-recorded `taken_days`. + """ + if planned_days < 0: + raise ValidationError("Planned days cannot be negative") + + existing = await self._repo.get_by_child_year(child.id, year) + if existing is not None: + existing.planned_days = planned_days + return await self._repo.update(existing.id, existing) + + entity = PreschoolPlanningDays( + id=uuid4(), + child_id=child.id, + year=year, + planned_days=planned_days, + taken_days=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + return await self._repo.create(entity) + + async def record_taken(self, child: Child, year: int, taken_days: int) -> PreschoolPlanningDays: + """Record how many planning days were actually taken for (child, year). + + Seeds `planned_days` from `child.preschool_planning_days_per_year` if + no row exists yet. + """ + if taken_days < 0: + raise ValidationError("Taken days cannot be negative") + + existing = await self._repo.get_by_child_year(child.id, year) + if existing is not None: + existing.taken_days = taken_days + return await self._repo.update(existing.id, existing) + + entity = PreschoolPlanningDays( + id=uuid4(), + child_id=child.id, + year=year, + planned_days=child.preschool_planning_days_per_year, + taken_days=taken_days, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + return await self._repo.create(entity) + + async def get_by_child_year(self, child_id: UUID, year: int) -> PreschoolPlanningDays | None: + return await self._repo.get_by_child_year(child_id, year) + + async def get(self, entry_id: UUID, child_id: UUID | None = None) -> PreschoolPlanningDays: + entity = await self._repo.get_by_id(entry_id) + if entity is None or (child_id is not None and entity.child_id != child_id): + raise NotFoundError(f"PreschoolPlanningDays {entry_id} not found") + return entity + + async def list_for_child(self, child_id: UUID) -> list[PreschoolPlanningDays]: + return await self._repo.list(child_id=child_id) diff --git a/backend/app/services/tips_service.py b/backend/app/services/tips_service.py index af40800..2005d4e 100644 --- a/backend/app/services/tips_service.py +++ b/backend/app/services/tips_service.py @@ -19,7 +19,7 @@ from __future__ import annotations import asyncio from collections import defaultdict -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import date, timedelta from typing import Callable, Coroutine, TypeAlias from uuid import UUID, uuid4 @@ -41,11 +41,12 @@ from app.repositories.personal_data_repository import PersonalDataRepository from app.repositories.planned_leave_repository import PlannedLeaveRepository from app.repositories.tip_dismissal_repository import TipDismissalRepository from app.repositories.vacation_days_repository import VacationDaysRepository +from app.services.analytics_service import AnalyticsService, VacationYearBalance from app.utils.errors import NotFoundError from app.utils.tip_predicates import ( age4_pooling_loss, days_left_at_age_4_projection, - has_vacation_near_expiry, + has_genuine_vacation_forfeiture, hash_tip_data, is_child_over_age, preschool_buffer_needed, @@ -90,6 +91,13 @@ class TipsContext: vacation_by_parent: dict[UUID, list[VacationDays]] leave_wishes: list[LeaveWish] today: date # injected for deterministic testing + # This (calendar) year's vacation balance per parent, via the same FIFO + # bank simulation AnalyticsService uses (`_vacation_year_series`) — so + # `_rule_use_vacation_before_expiry` can never disagree with the + # dashboard's own `expiring`/`expiring_age`/`expiring_over_cap` figures. + # Defaulted so existing direct-construction call sites/tests don't need + # to supply it. + vacation_year_balance_by_parent: dict[UUID, VacationYearBalance] = field(default_factory=dict) # Type alias for a rule function. @@ -102,25 +110,39 @@ RuleFn: TypeAlias = Callable[[TipsContext], Coroutine[None, None, Tip | None]] async def _rule_use_vacation_before_expiry(ctx: TipsContext) -> Tip | None: + """Warn when a parent's vacation balance genuinely forfeits days this year. + + Fires on the real per-year breakdown (`VacationYearBalance.expiring_age` + + `.expiring_over_cap`, see `AnalyticsService._vacation_year_series`) — + not the old `saved_in > 0 and total > 5` heuristic, which could both miss + a genuine over-cap loss and flag parents who were never at risk. + + Never fires for a year already in the past (`balance.year < ctx.today. + year`): those days are already gone, so a warning about them is pure + noise, not something actionable. The carry-forward simulation itself + still computes every year correctly, past ones included — this guard is + display-only, applied after the maths, using the injected `ctx.today` + rather than `date.today()` so it stays testable. + """ for parent in ctx.parents: - for vd in ctx.vacation_by_parent.get(parent.uuid, []): - if has_vacation_near_expiry(vd.total_days, vd.saved_days_in): - # `vd.year` is the only date-shaped fact this rule has; anchor - # to that vacation year's last day — the natural forcing point - # for "use it before it's gone" — rather than leaving the rail - # with nothing to place this tip on. - year_end = date(vd.year, 12, 31) - return Tip( - id="use_vacation_before_expiry", - severity="warning", - message_key="tips.use_vacation_before_expiry", - data={ - "parent_name": parent.name, - "saved_days": vd.saved_days_in, - "year": vd.year, - }, - anchor_date=year_end, - ) + balance = ctx.vacation_year_balance_by_parent.get(parent.uuid) + if balance is None or balance.year < ctx.today.year: + continue + if has_genuine_vacation_forfeiture(balance.expiring_age, balance.expiring_over_cap): + # The vacation year's last day is the natural forcing point for + # "use it before it's gone" — the only date-shaped fact here. + year_end = date(balance.year, 12, 31) + return Tip( + id="use_vacation_before_expiry", + severity="warning", + message_key="tips.use_vacation_before_expiry", + data={ + "parent_name": parent.name, + "lost_days": balance.expiring, + "year": balance.year, + }, + anchor_date=year_end, + ) return None @@ -746,6 +768,28 @@ class TipsService: personal_data_uuid=pd.uuid, workspace_id=workspace_id ) + # This year's real vacation forfeiture per parent — same FIFO bank + # simulation the dashboard uses (`AnalyticsService._vacation_year_series`), + # so `_rule_use_vacation_before_expiry` never derives a different + # answer than the numbers the user is already looking at. + carry_years = settings.rules.vacation_carry_forward_years + vacation_year_balance_by_parent: dict[UUID, VacationYearBalance] = {} + for pd in parents: + rows = vacation_by_parent[pd.uuid] + vacation_by_year = {v.year: v for v in rows} + parent_days = await self._planned.list(parent_personal_data_uuid=pd.uuid) + use_by_year: dict[int, int] = {} + for d in parent_days: + if d.day_type == "vacation": + use_by_year[d.date.year] = use_by_year.get(d.date.year, 0) + 1 + known_years = {v.year for v in rows} | set(use_by_year.keys()) | {today.year} + start_year = min(min(known_years), today.year - (carry_years - 1)) + years = list(range(start_year, today.year + 1)) + series = AnalyticsService._vacation_year_series( + years, vacation_by_year, use_by_year, pd.min_vacation_days_per_year + ) + vacation_year_balance_by_parent[pd.uuid] = series[-1] + workspace_ctx = TipsContext( workspace_id=workspace_id, children=[], @@ -755,6 +799,7 @@ class TipsService: vacation_by_parent=vacation_by_parent, leave_wishes=leave_wishes, today=today, + vacation_year_balance_by_parent=vacation_year_balance_by_parent, ) child_contexts: list[TipsContext] = [] diff --git a/backend/app/services/workspace_service.py b/backend/app/services/workspace_service.py index d506925..363ff7a 100644 --- a/backend/app/services/workspace_service.py +++ b/backend/app/services/workspace_service.py @@ -209,8 +209,7 @@ class WorkspaceService: one; a partner who registered and redeemed an invite into the same workspace does — removing them here would silently break their login. """ - users = await self._user.list() - return any(u.personal_data_uuid == personal_data_uuid for u in users) + return await self._user.get_by_personal_data_uuid(personal_data_uuid) is not None async def _dependent_data_blockers(self, personal_data_uuid: UUID) -> list[str]: """Return the kinds of plan data that would be orphaned by removing this member.""" @@ -264,7 +263,38 @@ class WorkspaceService: async def redeem_invitation( self, raw_token: str, personal_data_uuid: UUID ) -> WorkspaceInvitation: - """Link personal_data to the workspace and mark invitation accepted.""" + """Link the redeemer to the invitation's workspace and mark it accepted. + + The target workspace's second slot is in one of three states: + + - **Placeholder present** (a `personal_data` row with no linked + `User` — see `_has_login`; typically created by the onboarding + wizard's `add_placeholder_parent` before the real partner ever + registers). The redeemer's `User.personal_data_uuid` is re-pointed + at that placeholder instead of moving the redeemer's own profile + in, so any plan data already recorded against the placeholder + (leave wishes, planned leave, parental-leave snapshots, vacation + days — all denormalize `workspace_id` alongside their own + `personal_data_uuid`) is preserved untouched: the placeholder's + `workspace_id` already equals `inv.workspace_id`, so none of those + rows need updating. The redeemer's now-orphaned auto-created + `personal_data` row is soft-deleted. + - **Free slot, no placeholder**: today's behaviour — move the + redeemer's own `personal_data.workspace_id` into the target + workspace. + - **Both slots already have real logins**: refused. + + The claim path refuses (`InvitationError`) when the redeemer's own + `personal_data_uuid` already has dependent plan data of its own: that + data's rows also carry a denormalized `workspace_id`/ + `personal_data_uuid`, and no repository method exists to repoint + those columns onto the placeholder without either overwriting + whatever the placeholder already holds or leaving two conflicting + rows (e.g. two `vacation_days` rows for the same year). Refusing is + safer than silently losing or corrupting either side's data; this is + expected to be rare in practice since redemption normally follows + registration immediately, before the redeemer has entered anything. + """ token_hash = _sha256(raw_token) inv = await self._inv.get_by_token_hash(token_hash) @@ -277,7 +307,6 @@ class WorkspaceService: await self._inv.update(inv.id, inv) raise InvitationError("Invitation has expired") - # Link the personal_data to the workspace. pd = await self._pd.get_by_id(personal_data_uuid) if pd is None: raise NotFoundError(f"PersonalData {personal_data_uuid} not found") @@ -286,22 +315,69 @@ class WorkspaceService: raise InvitationError("You already belong to this workspace") existing_members = await self._pd.list_by_workspace(inv.workspace_id) - if len(existing_members) >= 2: + placeholder: PersonalData | None = None + for member in existing_members: + if not await self._has_login(member.uuid): + placeholder = member + break + + if placeholder is None and len(existing_members) >= 2: raise InvitationError("This workspace already has its maximum number of members") - # Both writes must land together: a crash between them would leave - # the redeemer either moved to the new workspace with a still-pending - # (re-usable) invitation, or stuck in their old workspace with an - # invitation marked accepted. + if placeholder is not None: + blockers = await self._dependent_data_blockers(personal_data_uuid) + if blockers: + raise InvitationError( + "Cannot redeem: your account already has " + f"{', '.join(blockers)} that cannot be merged automatically" + ) + + # Every write (including, on the claim path, re-pointing the login + # and discarding the redeemer's stub profile) must land together: a + # crash partway through would otherwise strand the user with no + # usable profile at all, or leave the invitation re-usable/consumed + # out of step with the membership change. async with self._uow(): - pd.workspace_id = inv.workspace_id - await self._pd.update(pd.uuid, pd) + if placeholder is not None: + user = await self._user.get_by_personal_data_uuid(personal_data_uuid) + if user is not None: + user.personal_data_uuid = placeholder.uuid + await self._user.update(user.id, user) + # Carry the redeemer's already-chosen display name over the + # placeholder's provisional one (e.g. "Partner"). + if placeholder.name != pd.name: + placeholder.name = pd.name + await self._pd.update(placeholder.uuid, placeholder) + # The placeholder already belongs to `inv.workspace_id`, and + # so do its dependent rows -- nothing else to repoint. The + # redeemer's stub `personal_data` row is now unreachable from + # any login; soft-delete it per the GDPR audit-trail + # convention (see `PersonalDataRepository`). Its now- + # memberless `workspace` row has no soft-delete column (no + # `deleted_at` in schema, no `hard_delete` on + # `WorkspaceRepository` — a schema gap, not fixed here per + # the "STOP and report" instruction for migrations) and is + # left in place as a harmless orphan (no PII beyond a name). + await self._pd.soft_delete(personal_data_uuid) + else: + pd.workspace_id = inv.workspace_id + await self._pd.update(pd.uuid, pd) inv.status = "accepted" inv.accepted_at = datetime.now() inv = await self._inv.update(inv.id, inv) return inv + async def list_invitations(self, workspace_id: UUID) -> list[WorkspaceInvitation]: + """Return every invitation ever issued for this workspace, newest first. + + Exists so `DELETE /api/invitations/{id}` has a discoverable id to + act on. Never exposes `token_hash` — callers must use + `InvitationResponse`, which omits it. + """ + await self.get_workspace(workspace_id) # 404 if the workspace doesn't exist + return await self._inv.list_by_workspace(workspace_id) + async def revoke_invitation(self, invitation_id: UUID, workspace_id: UUID) -> None: inv = await self._inv.get_by_id(invitation_id) if inv is None or inv.workspace_id != workspace_id: diff --git a/backend/app/utils/tip_predicates.py b/backend/app/utils/tip_predicates.py index 8640f63..883b822 100644 --- a/backend/app/utils/tip_predicates.py +++ b/backend/app/utils/tip_predicates.py @@ -211,10 +211,27 @@ def has_vacation_near_expiry( Swedish law: vacation days above 20 may be saved for up to 5 years. This predicate flags when saved days exist — the tips service adds richer expiry logic when it has the full vacation history per year. + + Kept as-is (still called by `allocation_service`'s per-budget check) — + `has_genuine_vacation_forfeiture` below is the real replacement used by + `tips_service._rule_use_vacation_before_expiry`, which has the actual + computed per-year breakdown instead of this coarse heuristic. """ return vacation_saved_in > 0 and vacation_total > threshold_remaining_days +def has_genuine_vacation_forfeiture(expiring_age: int, expiring_over_cap: int) -> bool: + """True if a parent's vacation balance for a given year actually lost days. + + Takes `VacationYearBalance.expiring_age`/`.expiring_over_cap` (see + `AnalyticsService._vacation_year_series`) — the real, already-computed + per-year forfeiture breakdown — rather than guessing from a raw total + like `has_vacation_near_expiry` does. Fires on genuine loss from either + cause (age-out or over-cap), never on days that are merely saved. + """ + return (expiring_age + expiring_over_cap) > 0 + + def hash_tip_data(data: dict[str, object]) -> str: """Stable fingerprint of a tip's `data` bag. diff --git a/backend/migrations/versions/007_preschool_planning_days.py b/backend/migrations/versions/007_preschool_planning_days.py new file mode 100644 index 0000000..e7da954 --- /dev/null +++ b/backend/migrations/versions/007_preschool_planning_days.py @@ -0,0 +1,67 @@ +"""Add preschool_planning_days: per-year record of "studiedagar" taken. + +`Child.preschool_planning_days_per_year` (see `app/entities/child.py`) is a +single default count of annual preschool closure/planning days used by +`app/services/allocation_service.py::_preschool_reserve_days`, which +multiplies it by the number of years in the planning horizon and subtracts +the lump sum from both parents' parental-leave pools. There was never a +per-year record of the true count, nor of how many were actually taken. + +This table adds that per-year record: `planned_days` (may differ from the +child's default for a specific year) and `taken_days` (nullable — `NULL` +means "not recorded yet", distinct from "recorded as zero", which is the +whole point of the feature). A missing row for a (child, year) still falls +back to `child.preschool_planning_days_per_year` at the service layer (see +`app/services/preschool_planning_days_service.py`); wiring the allocation +engine to consume these rows is a follow-up, not part of this migration. + +A plain `CREATE TABLE` suffices — nothing existing changes, and there is +nothing to backfill: before this migration no per-year figure could be +recorded, so the correct history is "none". + +Must stay byte-for-byte equivalent to the `preschool_planning_days` table in +`app/db/connection.py`'s `_FULL_SCHEMA` — +`tests/integration/test_schema_parity.py` asserts exactly that. + +Revision ID: 007 +Revises: 006 +Create Date: 2026-09-20 +""" + +from __future__ import annotations + +from alembic import op + +revision: str = "007" +down_revision: str | None = "006" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE IF NOT EXISTS preschool_planning_days ( + id TEXT PRIMARY KEY, + child_id TEXT NOT NULL, + year INTEGER NOT NULL, + planned_days INTEGER NOT NULL, + taken_days INTEGER, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE(child_id, year), + FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE + ) + """ + ) + op.execute( + """ + CREATE INDEX IF NOT EXISTS idx_preschool_planning_days_child + ON preschool_planning_days (child_id) + """ + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS idx_preschool_planning_days_child") + op.execute("DROP TABLE IF EXISTS preschool_planning_days") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 2996f2c..8f48e50 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -31,6 +31,9 @@ from app.repositories_impl.sqlite.planned_day_exclusion_repository import ( SQLitePlannedDayExclusionRepository, ) from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository +from app.repositories_impl.sqlite.preschool_planning_days_repository import ( + SQLitePreschoolPlanningDaysRepository, +) from app.repositories_impl.sqlite.tip_dismissal_repository import SQLiteTipDismissalRepository from app.repositories_impl.sqlite.unit_of_work import SQLiteUnitOfWork from app.repositories_impl.sqlite.user_repository import SQLiteUserRepository @@ -49,6 +52,7 @@ from app.services.day_override_service import DayOverrideService from app.services.leave_wish_service import LeaveWishService from app.services.planned_day_exclusion_service import PlannedDayExclusionService from app.services.planning_service import PlanningService +from app.services.preschool_planning_days_service import PreschoolPlanningDaysService from app.services.tips_service import TipsService from app.services.vacation_days_service import VacationDaysService from app.services.workspace_service import WorkspaceService @@ -114,8 +118,9 @@ async def auth_service( user_repo: SQLiteUserRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, + invitation_repo: SQLiteWorkspaceInvitationRepository, ) -> AuthService: - return AuthService(user_repo, personal_data_repo, workspace_repo) + return AuthService(user_repo, personal_data_repo, workspace_repo, inv_repo=invitation_repo) # --------------------------------------------------------------------------- @@ -448,6 +453,20 @@ async def tip_dismissal_repo( return SQLiteTipDismissalRepository(connection) +@pytest_asyncio.fixture +async def preschool_planning_days_repo( + connection: aiosqlite.Connection, +) -> SQLitePreschoolPlanningDaysRepository: + return SQLitePreschoolPlanningDaysRepository(connection) + + +@pytest_asyncio.fixture +async def preschool_planning_days_service( + preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository, +) -> PreschoolPlanningDaysService: + return PreschoolPlanningDaysService(preschool_planning_days_repo) + + @pytest_asyncio.fixture async def tips_service( parental_leave_repo: SQLiteParentalLeaveRepository, diff --git a/backend/tests/unit/test_analytics_router.py b/backend/tests/unit/test_analytics_router.py index b065ecb..f9be41c 100644 --- a/backend/tests/unit/test_analytics_router.py +++ b/backend/tests/unit/test_analytics_router.py @@ -74,6 +74,8 @@ class TestGetOverviewYearBalances: "left", "carried_out", "expiring", + "expiring_age", + "expiring_over_cap", } assert vacation["earned"] == 25 # statutory default — no vacation_days row entered assert vacation["is_assumed"] is True diff --git a/backend/tests/unit/test_authz.py b/backend/tests/unit/test_authz.py index 413f882..358c85f 100644 --- a/backend/tests/unit/test_authz.py +++ b/backend/tests/unit/test_authz.py @@ -641,3 +641,44 @@ class TestForeignWorkspaceIdIs404: ) resp = await client.delete(f"/api/invitations/{inv.id}") assert resp.status_code == 404 + + async def test_list_invitations_foreign_workspace( + self, client: AsyncClient, two_workspaces: _TwoWorkspaces + ) -> None: + """`GET /{workspace_id}/invitations` is the only way to discover an + id for `DELETE /api/invitations/{id}` -- it must not leak workspace + B's invitations (or their existence) to a caller in workspace A.""" + resp = await client.get(f"/api/workspace/{two_workspaces.ws_b.id}/invitations") + assert resp.status_code == 404 + + async def test_list_invitations_never_returns_token_hash( + self, client: AsyncClient, connection: aiosqlite.Connection, two_workspaces: _TwoWorkspaces + ) -> None: + user_repo = SQLiteUserRepository(connection) + inviter = await user_repo.create( + User( + id=uuid4(), + personal_data_uuid=_CALLER_PD_UUID, + email="inviter-a@example.com", + hashed_password="not-a-real-hash", + created_at=datetime.now(), + ) + ) + inv_repo = SQLiteWorkspaceInvitationRepository(connection) + await inv_repo.create( + WorkspaceInvitation( + id=uuid4(), + workspace_id=two_workspaces.ws_a.id, + token_hash="super-secret-hash", + invited_by_user_id=inviter.id, + status="pending", + expires_at=datetime.now() + timedelta(hours=1), + created_at=datetime.now(), + ) + ) + resp = await client.get(f"/api/workspace/{two_workspaces.ws_a.id}/invitations") + assert resp.status_code == 200 + body = resp.json() + assert len(body) == 1 + assert "token_hash" not in body[0] + assert "super-secret-hash" not in resp.text diff --git a/backend/tests/unit/test_planning_service_sync.py b/backend/tests/unit/test_planning_service_sync.py index 53ea9dd..74f3321 100644 --- a/backend/tests/unit/test_planning_service_sync.py +++ b/backend/tests/unit/test_planning_service_sync.py @@ -192,10 +192,27 @@ class TestSyncChangeKinds: ) -> None: ws, child = seeded_ids.workspace_id, seeded_ids.child_id # An hourly-salaried parent prefers FP (sjukpenning first) over - # vacation for tier-3 interior days, so this is deterministic - # regardless of any assumed vacation default. + # vacation for tier-3 interior days -- UNLESS §Task 2's + # vacation-forfeiture bias overrides that for a year where vacation + # would otherwise be lost. With no `vacation_days` row entered, the + # loader defaults this parent's 2027 entitlement to the statutory + # 25 days and, since nothing has been used yet, `AnalyticsService. + # _vacation_year_series` forecasts most of it as forfeited (the + # `min_vacation_days_per_year` floor caps how much can be banked) + # -- which would make this test's own assertions about sjukpenning + # flakily depend on the forfeiture bias instead of the FP-pool + # fallback it's meant to isolate. `min_vacation_days_per_year=0` + # means the ENTIRE entitlement is bankable, so the simulation + # forecasts zero forfeiture and this parent is deterministically + # FP-first, exactly like before §Task 2. parent = await personal_data_repo.create( - PersonalData(uuid=uuid4(), workspace_id=ws, name="Hourly Parent", salary_type="hourly") + PersonalData( + uuid=uuid4(), + workspace_id=ws, + name="Hourly Parent", + salary_type="hourly", + min_vacation_days_per_year=0, + ) ) # `parent_mode="specific"` -- with a second (seeded) parent now also # in the workspace, an "either" wish would default to whichever diff --git a/backend/tests/unit/test_preschool_planning_days_repository.py b/backend/tests/unit/test_preschool_planning_days_repository.py new file mode 100644 index 0000000..c36d6b1 --- /dev/null +++ b/backend/tests/unit/test_preschool_planning_days_repository.py @@ -0,0 +1,239 @@ +"""Tests for the PreschoolPlanningDays repository + service. + +Follows the pattern in `test_vacation_days_repository.py`: arrange an +entity, exercise the repo/service, assert on the result. +""" + +from datetime import date, datetime +from uuid import uuid4 + +import pytest + +from app.entities.child import Child +from app.entities.preschool_planning_days import PreschoolPlanningDays +from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository +from app.repositories_impl.sqlite.preschool_planning_days_repository import ( + SQLitePreschoolPlanningDaysRepository, +) +from app.services.preschool_planning_days_service import PreschoolPlanningDaysService +from app.utils.errors import NotFoundError, ValidationError +from tests.conftest import SeededIds + + +def _make(child_id=None, **overrides) -> PreschoolPlanningDays: + base = dict( + id=uuid4(), + child_id=child_id or uuid4(), + year=2026, + planned_days=6, + taken_days=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + base.update(overrides) + return PreschoolPlanningDays(**base) + + +class TestSQLitePreschoolPlanningDaysRepository: + async def test_create_and_get_by_id( + self, + preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository, + seeded_ids: SeededIds, + ) -> None: + entity = _make(child_id=seeded_ids.child_id) + await preschool_planning_days_repo.create(entity) + + fetched = await preschool_planning_days_repo.get_by_id(entity.id) + assert fetched is not None + assert fetched.id == entity.id + assert fetched.planned_days == 6 + assert fetched.taken_days is None + + async def test_get_by_child_year( + self, + preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository, + seeded_ids: SeededIds, + ) -> None: + entity = _make(child_id=seeded_ids.child_id) + await preschool_planning_days_repo.create(entity) + + found = await preschool_planning_days_repo.get_by_child_year(entity.child_id, 2026) + assert found is not None + assert found.id == entity.id + + missing = await preschool_planning_days_repo.get_by_child_year(uuid4(), 2026) + assert missing is None + + async def test_update_preserves_null_taken_days_distinction( + self, + preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository, + seeded_ids: SeededIds, + ) -> None: + """Not-recorded (None) must round-trip distinctly from recorded-as-zero.""" + entity = _make(child_id=seeded_ids.child_id, taken_days=None) + await preschool_planning_days_repo.create(entity) + + fetched = await preschool_planning_days_repo.get_by_id(entity.id) + assert fetched is not None + assert fetched.taken_days is None + + fetched.taken_days = 0 + updated = await preschool_planning_days_repo.update(fetched.id, fetched) + assert updated.taken_days == 0 + + async def test_update_missing_raises( + self, preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository + ) -> None: + with pytest.raises(NotFoundError): + await preschool_planning_days_repo.update(uuid4(), _make()) + + async def test_delete( + self, + preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository, + seeded_ids: SeededIds, + ) -> None: + entity = _make(child_id=seeded_ids.child_id) + await preschool_planning_days_repo.create(entity) + await preschool_planning_days_repo.delete(entity.id) + assert await preschool_planning_days_repo.get_by_id(entity.id) is None + + async def test_delete_missing_raises( + self, preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository + ) -> None: + with pytest.raises(NotFoundError): + await preschool_planning_days_repo.delete(uuid4()) + + async def test_list_filters_by_child( + self, + preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository, + seeded_ids: SeededIds, + child_repo: SQLiteChildRepository, + ) -> None: + other_child = await child_repo.create( + Child( + id=uuid4(), + workspace_id=seeded_ids.workspace_id, + name="Other Child", + birth_date=date(2022, 1, 1), + created_at=datetime.now(), + ) + ) + + await preschool_planning_days_repo.create(_make(child_id=seeded_ids.child_id, year=2025)) + await preschool_planning_days_repo.create(_make(child_id=seeded_ids.child_id, year=2026)) + await preschool_planning_days_repo.create(_make(child_id=other_child.id)) + + rows = await preschool_planning_days_repo.list(child_id=seeded_ids.child_id) + assert len(rows) == 2 + assert [r.year for r in rows] == [2026, 2025] # ordered year DESC + + async def test_list_rejects_unsupported_filter( + self, preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository + ) -> None: + with pytest.raises(ValueError, match="invalid_filter"): + await preschool_planning_days_repo.list(invalid_filter="value") + + +class TestPreschoolPlanningDaysService: + async def test_upsert_planned_creates_then_updates( + self, + preschool_planning_days_service: PreschoolPlanningDaysService, + seeded_ids: SeededIds, + child_repo: SQLiteChildRepository, + ) -> None: + child = await child_repo.get_by_id(seeded_ids.child_id) + assert child is not None + + created = await preschool_planning_days_service.upsert_planned(child, 2026, 8) + assert created.planned_days == 8 + assert created.taken_days is None + + updated = await preschool_planning_days_service.upsert_planned(child, 2026, 10) + assert updated.id == created.id + assert updated.planned_days == 10 + + async def test_upsert_planned_rejects_negative( + self, + preschool_planning_days_service: PreschoolPlanningDaysService, + child_repo: SQLiteChildRepository, + seeded_ids: SeededIds, + ) -> None: + child = await child_repo.get_by_id(seeded_ids.child_id) + assert child is not None + with pytest.raises(ValidationError): + await preschool_planning_days_service.upsert_planned(child, 2026, -1) + + async def test_record_taken_seeds_default_planned_days( + self, + preschool_planning_days_service: PreschoolPlanningDaysService, + child_repo: SQLiteChildRepository, + seeded_ids: SeededIds, + ) -> None: + """With no existing row, `planned_days` is seeded from the child's default.""" + child = await child_repo.get_by_id(seeded_ids.child_id) + assert child is not None + assert child.preschool_planning_days_per_year == 6 # entity default + + recorded = await preschool_planning_days_service.record_taken(child, 2026, 4) + assert recorded.planned_days == 6 + assert recorded.taken_days == 4 + + async def test_record_taken_preserves_planned_days_on_existing_row( + self, + preschool_planning_days_service: PreschoolPlanningDaysService, + child_repo: SQLiteChildRepository, + seeded_ids: SeededIds, + ) -> None: + child = await child_repo.get_by_id(seeded_ids.child_id) + assert child is not None + await preschool_planning_days_service.upsert_planned(child, 2026, 12) + + recorded = await preschool_planning_days_service.record_taken(child, 2026, 3) + assert recorded.planned_days == 12 + assert recorded.taken_days == 3 + + async def test_record_taken_rejects_negative( + self, + preschool_planning_days_service: PreschoolPlanningDaysService, + child_repo: SQLiteChildRepository, + seeded_ids: SeededIds, + ) -> None: + child = await child_repo.get_by_id(seeded_ids.child_id) + assert child is not None + with pytest.raises(ValidationError): + await preschool_planning_days_service.record_taken(child, 2026, -1) + + async def test_record_taken_zero_distinguishable_from_unrecorded( + self, + preschool_planning_days_service: PreschoolPlanningDaysService, + child_repo: SQLiteChildRepository, + seeded_ids: SeededIds, + ) -> None: + child = await child_repo.get_by_id(seeded_ids.child_id) + assert child is not None + + unrecorded = await preschool_planning_days_service.get_by_child_year(child.id, 2026) + assert unrecorded is None + + recorded_zero = await preschool_planning_days_service.record_taken(child, 2026, 0) + assert recorded_zero.taken_days == 0 + + async def test_get_missing_raises( + self, preschool_planning_days_service: PreschoolPlanningDaysService + ) -> None: + with pytest.raises(NotFoundError): + await preschool_planning_days_service.get(uuid4()) + + async def test_list_for_child( + self, + preschool_planning_days_service: PreschoolPlanningDaysService, + child_repo: SQLiteChildRepository, + seeded_ids: SeededIds, + ) -> None: + child = await child_repo.get_by_id(seeded_ids.child_id) + assert child is not None + await preschool_planning_days_service.upsert_planned(child, 2025, 6) + await preschool_planning_days_service.upsert_planned(child, 2026, 6) + + rows = await preschool_planning_days_service.list_for_child(child.id) + assert [r.year for r in rows] == [2026, 2025] diff --git a/backend/tests/unit/test_preschool_planning_days_router.py b/backend/tests/unit/test_preschool_planning_days_router.py new file mode 100644 index 0000000..e453df6 --- /dev/null +++ b/backend/tests/unit/test_preschool_planning_days_router.py @@ -0,0 +1,88 @@ +"""API-level tests for `/api/preschool-planning-days`. + +Uses the shared `authed_client`/`seeded_ids` fixtures (see `tests/conftest.py`) +since these routes are scoped per child within the caller's own workspace. +""" + +from uuid import uuid4 + +from httpx import AsyncClient + +from tests.conftest import SeededIds + + +class TestPreschoolPlanningDaysRouter: + async def test_get_year_with_no_row_returns_child_default( + self, authed_client: AsyncClient, seeded_ids: SeededIds + ) -> None: + resp = await authed_client.get(f"/api/preschool-planning-days/{seeded_ids.child_id}/2026") + assert resp.status_code == 200 + body = resp.json() + assert body["is_default"] is True + assert body["id"] is None + assert body["planned_days"] == 6 # Child entity default + assert body["taken_days"] is None + + async def test_upsert_planned_then_record_taken( + self, authed_client: AsyncClient, seeded_ids: SeededIds + ) -> None: + put_planned = await authed_client.put( + f"/api/preschool-planning-days/{seeded_ids.child_id}/2026/planned", + json={"planned_days": 8}, + ) + assert put_planned.status_code == 200 + body = put_planned.json() + assert body["is_default"] is False + assert body["planned_days"] == 8 + assert body["taken_days"] is None + + put_taken = await authed_client.put( + f"/api/preschool-planning-days/{seeded_ids.child_id}/2026/taken", + json={"taken_days": 5}, + ) + assert put_taken.status_code == 200 + taken_body = put_taken.json() + assert taken_body["id"] == body["id"] + assert taken_body["planned_days"] == 8 # preserved + assert taken_body["taken_days"] == 5 + + async def test_record_taken_zero_distinguishable_from_default_none( + self, authed_client: AsyncClient, seeded_ids: SeededIds + ) -> None: + resp = await authed_client.put( + f"/api/preschool-planning-days/{seeded_ids.child_id}/2026/taken", + json={"taken_days": 0}, + ) + assert resp.status_code == 200 + assert resp.json()["taken_days"] == 0 + assert resp.json()["is_default"] is False + + async def test_list_years_for_child( + self, authed_client: AsyncClient, seeded_ids: SeededIds + ) -> None: + await authed_client.put( + f"/api/preschool-planning-days/{seeded_ids.child_id}/2025/planned", + json={"planned_days": 6}, + ) + await authed_client.put( + f"/api/preschool-planning-days/{seeded_ids.child_id}/2026/planned", + json={"planned_days": 7}, + ) + + resp = await authed_client.get(f"/api/preschool-planning-days/{seeded_ids.child_id}") + assert resp.status_code == 200 + years = [row["year"] for row in resp.json()] + assert years == [2026, 2025] + + async def test_unknown_child_returns_404(self, authed_client: AsyncClient) -> None: + resp = await authed_client.get(f"/api/preschool-planning-days/{uuid4()}/2026") + assert resp.status_code == 404 + + async def test_rejects_negative_planned_days( + self, authed_client: AsyncClient, seeded_ids: SeededIds + ) -> None: + resp = await authed_client.put( + f"/api/preschool-planning-days/{seeded_ids.child_id}/2026/planned", + json={"planned_days": -1}, + ) + assert resp.status_code == 422 diff --git a/backend/tests/unit/test_services/test_allocation_input_loader.py b/backend/tests/unit/test_services/test_allocation_input_loader.py index aa69714..b6ca3dd 100644 --- a/backend/tests/unit/test_services/test_allocation_input_loader.py +++ b/backend/tests/unit/test_services/test_allocation_input_loader.py @@ -19,6 +19,7 @@ from app.entities.leave_wish import LeaveWish from app.entities.parental_leave import ParentalLeaveFetch, TierData from app.entities.personal_data import PersonalData from app.entities.planned_leave import PlannedLeaveDay +from app.entities.preschool_planning_days import PreschoolPlanningDays from app.entities.vacation_days import VacationDays from app.entities.workspace import Workspace from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository @@ -27,6 +28,9 @@ from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRe from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository +from app.repositories_impl.sqlite.preschool_planning_days_repository import ( + SQLitePreschoolPlanningDaysRepository, +) from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository from app.services.allocation_service import AllocationInputLoader, Allocator, PlanningEngineService @@ -41,6 +45,7 @@ def _make_loader( vacation_repo: SQLiteVacationDaysRepository, planned_leave_repo: SQLitePlannedLeaveRepository, leave_wish_repo: SQLiteLeaveWishRepository, + preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository | None = None, ) -> AllocationInputLoader: return AllocationInputLoader( workspace_repo=workspace_repo, @@ -50,6 +55,7 @@ def _make_loader( vacation_repo=vacation_repo, planned_leave_repo=planned_leave_repo, leave_wish_repo=leave_wish_repo, + preschool_planning_days_repo=preschool_planning_days_repo, ) @@ -626,3 +632,118 @@ class TestAllocationInputLoader: with pytest.raises(ValidationError): await loader.load(ws.id, today=date(2027, 1, 1)) + + +class TestPreschoolPlanningDaysReserve: + """§Task 1 — `AllocationInputLoader` sources the per-year preschool + + reserve from `PreschoolPlanningDaysRepository` rows instead of the flat + `preschool_planning_days_per_year * years` lump sum, using recorded + `taken_days` in preference to `planned_days`, and leaving a (child, + year) pair with no row out of the dict entirely — never defaulting it + to 0 — so `_preschool_reserve_days` falls back to `ChildFacts. + preschool_planning_days_per_year` for it (see that field's docstring). + """ + + async def test_recorded_taken_days_wins_over_planned_days( + self, + workspace_repo: SQLiteWorkspaceRepository, + child_repo: SQLiteChildRepository, + personal_data_repo: SQLitePersonalDataRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository, + ) -> None: + now = datetime.now() + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + await personal_data_repo.create( + PersonalData(uuid=uuid4(), workspace_id=ws.id, name="Parent", created_at=now) + ) + child = await child_repo.create( + Child( + id=uuid4(), + workspace_id=ws.id, + name="Barn", + birth_date=date(2025, 1, 1), + preschool_start_date=date(2027, 1, 1), + preschool_planning_days_per_year=6, + created_at=now, + ) + ) + # Recorded: planned 6, but only 3 actually taken -> 3 must win. + await preschool_planning_days_repo.create( + PreschoolPlanningDays( + id=uuid4(), child_id=child.id, year=2027, planned_days=6, taken_days=3 + ) + ) + + loader = _make_loader( + workspace_repo, + child_repo, + personal_data_repo, + parental_leave_repo, + vacation_repo, + planned_leave_repo, + leave_wish_repo, + preschool_planning_days_repo=preschool_planning_days_repo, + ) + alloc_input = await loader.load(ws.id, today=date(2027, 1, 1)) + + assert alloc_input.preschool_reserve_by_year[(child.id, 2027)] == 3 + + async def test_missing_row_is_absent_not_zero( + self, + workspace_repo: SQLiteWorkspaceRepository, + child_repo: SQLiteChildRepository, + personal_data_repo: SQLitePersonalDataRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository, + ) -> None: + now = datetime.now() + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + await personal_data_repo.create( + PersonalData(uuid=uuid4(), workspace_id=ws.id, name="Parent", created_at=now) + ) + child = await child_repo.create( + Child( + id=uuid4(), + workspace_id=ws.id, + name="Barn", + birth_date=date(2025, 1, 1), + preschool_start_date=date(2027, 1, 1), + preschool_planning_days_per_year=6, + created_at=now, + ) + ) + # No `preschool_planning_days` row created for (child, 2027) at all. + + loader = _make_loader( + workspace_repo, + child_repo, + personal_data_repo, + parental_leave_repo, + vacation_repo, + planned_leave_repo, + leave_wish_repo, + preschool_planning_days_repo=preschool_planning_days_repo, + ) + alloc_input = await loader.load(ws.id, today=date(2027, 1, 1)) + + # Absent, not zero -- `_preschool_reserve_days` is what supplies the + # child-default fallback (6/year), never this dict directly. + assert (child.id, 2027) not in alloc_input.preschool_reserve_by_year + + from app.services.allocation_service import _preschool_reserve_days + + reserve = _preschool_reserve_days( + next(c for c in alloc_input.children if c.id == child.id), + alloc_input.today, + date(2027, 12, 31), + alloc_input.preschool_reserve_by_year, + ) + assert reserve == 6 diff --git a/backend/tests/unit/test_services/test_allocation_service.py b/backend/tests/unit/test_services/test_allocation_service.py index 14ecee3..f1aa0b4 100644 --- a/backend/tests/unit/test_services/test_allocation_service.py +++ b/backend/tests/unit/test_services/test_allocation_service.py @@ -182,6 +182,8 @@ def _build_input( age4_savable_cap: int | None = None, take_pl_on_red_days: bool = False, excluded_days: frozenset[tuple[UUID, UUID, date]] | None = None, + preschool_reserve_by_year: dict[tuple[UUID, int], int] | None = None, + vacation_forfeiting: dict[tuple[UUID, int], int] | None = None, ) -> AllocationInput: years = {today.year, horizon_end.year, horizon_end.year + 1, *extra_holiday_years} if age4_savable_cap is None: @@ -206,6 +208,8 @@ def _build_input( take_pl_on_red_days=take_pl_on_red_days, all_existing_planned=all_existing_planned, excluded_days=excluded_days or frozenset(), + preschool_reserve_by_year=preschool_reserve_by_year or {}, + vacation_forfeiting=vacation_forfeiting or {}, ) diff --git a/backend/tests/unit/test_services/test_allocation_service_branches.py b/backend/tests/unit/test_services/test_allocation_service_branches.py index efad54e..7e5901c 100644 --- a/backend/tests/unit/test_services/test_allocation_service_branches.py +++ b/backend/tests/unit/test_services/test_allocation_service_branches.py @@ -322,6 +322,12 @@ class TestWarnings: assert sgi_warnings[0].data["days_planned"] == 3 def test_vacation_expiring_warning(self) -> None: + # §Task 2 — `_vacation_expiry_warnings` now surfaces the real, + # loader-computed `vacation_forfeiting` figure (same FIFO + # carry-forward simulation as the Analytics tab) instead of the old + # `has_vacation_near_expiry` heuristic, so a hand-built pure + # `AllocationInput` supplies that figure directly rather than a + # `VacationBudget` shape the heuristic used to infer it from. parent = _parent() alloc_input = _build_input( today=date(2027, 1, 1), @@ -329,15 +335,87 @@ class TestWarnings: children=[], parents=[parent], pools={}, - vacation={(parent.uuid, 2026): VacationBudget(25, 8, 0)}, + vacation={(parent.uuid, 2027): VacationBudget(25, 8, 0)}, wishes=[], + vacation_forfeiting={(parent.uuid, 2027): 5}, ) proposal = Allocator().propose(alloc_input) expiry_warnings = [w for w in proposal.warnings if w.kind == "vacation_expiring"] assert len(expiry_warnings) == 1 - assert expiry_warnings[0].data["saved_days_in"] == 8 + assert expiry_warnings[0].data["expiring_days"] == 5 + assert expiry_warnings[0].data["year"] == 2027 + + +class TestVacationForfeitureBias: + """§Task 2 — `_assign_tier3` spends vacation before FP in a year where + + `AllocationInput.vacation_forfeiting` says the vacation would otherwise + be lost anyway, even for an hourly-salaried parent (whose default + tier-3 order is FP-first, since a vacation day normally costs them + pay). February 2027 is used as the funded window: a clean month (no + real red days near its edges, per this file's module docstring), so + every non-free day goes through tier-3 with nothing else interfering. + """ + + def test_forfeiting_year_spends_vacation_before_fp(self) -> None: + child = _child(birth_date=date(2025, 1, 1)) + parent = _parent(salary_type="hourly") + wish = _wish(child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4) + alloc_input = _build_input( + today=date(2027, 1, 1), + horizon_end=date(2027, 12, 31), + children=[child], + parents=[parent], + pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, + vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, + wishes=[wish], + vacation_forfeiting={(parent.uuid, 2027): 5}, + ) + + proposal = Allocator().propose(alloc_input) + + interior_days = [ + d for d in proposal.days if d.day_type in ("vacation", "sjukpenning", "lagsta") + ] + assert interior_days, "expected some funded interior (non-free) days" + assert all(d.day_type == "vacation" for d in interior_days) + # The FP pool must be untouched: every interior day was paid from + # vacation instead, exactly the point of the bias (bank the FP days + # for a later year rather than let this year's vacation go to waste). + balances_after = proposal.balances_after.pools_after[(parent.uuid, child.id)] + assert balances_after.sjukpenning_left == 90 + assert balances_after.lagsta_left == 90 + + def test_non_forfeiting_year_is_unchanged_fp_first_for_hourly_parent(self) -> None: + """Regression guard: absent a forecast forfeiture, an hourly parent's + + tier-3 order must stay exactly what it was before §Task 2 — FP + first, vacation only backstopping what FP can't cover. + """ + child = _child(birth_date=date(2025, 1, 1)) + parent = _parent(salary_type="hourly") + wish = _wish(child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4) + alloc_input = _build_input( + today=date(2027, 1, 1), + horizon_end=date(2027, 12, 31), + children=[child], + parents=[parent], + pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, + vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, + wishes=[wish], + ) + + proposal = Allocator().propose(alloc_input) + + interior_days = [ + d for d in proposal.days if d.day_type in ("vacation", "sjukpenning", "lagsta") + ] + assert interior_days, "expected some funded interior (non-free) days" + assert all(d.day_type == "sjukpenning" for d in interior_days) + balances_after = proposal.balances_after.vacation_after[(parent.uuid, 2027)] + assert balances_after.already_used == 0 class TestRemedies: diff --git a/backend/tests/unit/test_services/test_analytics_service.py b/backend/tests/unit/test_services/test_analytics_service.py index 977649a..0a73024 100644 --- a/backend/tests/unit/test_services/test_analytics_service.py +++ b/backend/tests/unit/test_services/test_analytics_service.py @@ -732,7 +732,14 @@ class TestVacationYearBalanceSeries: assert first.carried_in == 0 assert first.left == 25 assert first.carried_out == 5 # max(25 - floor(20), 0) - assert first.expiring == 0 + # `expiring` now reports EVERY lost day, not just age-based losses + # (the bug this predicate/service pair fixes): of the 25 unused + # days, only 5 (earned - floor) can be banked — the other 20 (the + # unused floor) are forfeited outright, with no age-expiry involved + # yet in year one. + assert first.expiring == 20 + assert first.expiring_age == 0 + assert first.expiring_over_cap == 20 def test_carry_chain_accumulates_across_years(self) -> None: years = [2025, 2026, 2027] @@ -756,10 +763,14 @@ class TestVacationYearBalanceSeries: # 2020's 5 saved days must not still be sitting in `carried_in` # `carry_years` years later, and must have been reported as - # `expiring` exactly once, in the year they lapsed. - expiring_years = [y for y, r in by_year.items() if r.expiring > 0] - assert expiring_years == [2020 + carry_years] - assert by_year[2020 + carry_years].expiring == 5 + # `expiring_age` exactly once, in the year they lapsed. (`expiring` + # itself is nonzero every year here too, since the unused floor is + # an over-cap loss every year — isolate the age-based portion.) + age_expiring_years = [y for y, r in by_year.items() if r.expiring_age > 0] + assert age_expiring_years == [2020 + carry_years] + assert by_year[2020 + carry_years].expiring_age == 5 + assert by_year[2020 + carry_years].expiring_over_cap == 20 + assert by_year[2020 + carry_years].expiring == 25 def test_continuous_saving_expires_the_oldest_batch_every_year_thereafter(self) -> None: """Once the bank has been running >= carry_years, an unbroken @@ -775,7 +786,9 @@ class TestVacationYearBalanceSeries: by_year = {r.year: r for r in result} for year in years[carry_years:]: - assert by_year[year].expiring == 5 + assert by_year[year].expiring_age == 5 + assert by_year[year].expiring_over_cap == 20 # unused floor, every year + assert by_year[year].expiring == 25 # Carried-in plateaus at exactly carry_years batches of 5. assert by_year[years[-1]].carried_in == 5 * carry_years @@ -825,6 +838,53 @@ class TestVacationYearBalanceSeries: # 2026's row must not wipe that computed carry-in out. assert by_year[2026].carried_in == 5 + def test_expiring_breakdown_matches_dashboard_screenshot_case(self) -> None: + """Real dashboard bug report: earned 30, floor 20, planned_use 15 → + left 15, carried_out 10 — 5 days genuinely lost, but the OLD + age-only `expiring` showed 0. `expiring` must report every lost day, + with `expiring_over_cap` isolating the over-cap portion.""" + from app.entities.vacation_days import VacationDays + + years = [2025] + rows = { + 2025: VacationDays( + id=uuid4(), + personal_data_uuid=uuid4(), + workspace_id=uuid4(), + year=2025, + total_days=30, + saved_days_in=0, + ), + } + use_by_year = {2025: 15} + result = AnalyticsService._vacation_year_series(years, rows, use_by_year, floor=20) + balance = result[0] + + assert balance.left == 15 + assert balance.carried_out == 10 + assert balance.expiring == 5 + assert balance.expiring_age == 0 + assert balance.expiring_over_cap == 5 + + def test_age_expiry_and_over_cap_loss_coincide_without_double_counting(self) -> None: + """A bank entry aging out at the 5-year cap and a fresh over-cap + loss can both happen in the same year (earned 25, floor 20, never + used: every year loses the unused floor AND, from year + `carry_years` on, also ages out that year's oldest 5-day batch). + `expiring` must equal their sum exactly once, never double-counted. + """ + from app.config import settings + + carry_years = settings.rules.vacation_carry_forward_years + years = list(range(2020, 2020 + carry_years + 1)) + result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20) + last = result[-1] + + assert last.expiring_age == 5 + assert last.expiring_over_cap == 20 + assert last.expiring == 25 + assert last.expiring == last.expiring_age + last.expiring_over_cap + class TestParentalLeaveYearBalanceSeries: """Pure unit tests against `AnalyticsService._pl_year_series`.""" diff --git a/backend/tests/unit/test_services/test_auth_service.py b/backend/tests/unit/test_services/test_auth_service.py index dfd2bfa..b7de6a4 100644 --- a/backend/tests/unit/test_services/test_auth_service.py +++ b/backend/tests/unit/test_services/test_auth_service.py @@ -1,10 +1,17 @@ """Tests for AuthService: register, login, token round-trip.""" +from datetime import datetime +from uuid import uuid4 + import pytest +from app.entities.user import User +from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository from app.repositories_impl.sqlite.user_repository import SQLiteUserRepository +from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository from app.services.auth_service import AuthService -from app.utils.errors import AuthError, ConflictError, UnauthorizedError +from app.services.workspace_service import WorkspaceService +from app.utils.errors import AuthError, ConflictError, InvitationError, UnauthorizedError class TestAuthServiceRegisterLogin: @@ -104,3 +111,126 @@ class TestAuthServiceTokens: with pytest.raises(UnauthorizedError): await auth_service.get_user_from_token(token) + + +class TestAuthServiceRegisterWithInviteToken: + """`register(..., invite_token=...)` lets a partner join straight from an + invite link instead of first creating a throwaway solo workspace. Mirrors + `WorkspaceService.redeem_invitation`'s claim logic -- see + `test_workspace_service.py::TestInvitationScoping` for the service-level + coverage of that logic; these tests cover the registration entry point. + """ + + async def test_claims_existing_placeholder( + self, + auth_service: AuthService, + workspace_service: WorkspaceService, + user_repo: SQLiteUserRepository, + ) -> None: + ws = await workspace_service.create_workspace("Shared plan") + owner_pd = await workspace_service.add_placeholder_parent(ws.id, "Owner") + owner_user = await user_repo.create( + User( + id=uuid4(), + personal_data_uuid=owner_pd.uuid, + email="owner@example.com", + hashed_password="not-a-real-hash", + created_at=datetime.now(), + ) + ) + placeholder = await workspace_service.add_placeholder_parent(ws.id, "Partner") + token = await workspace_service.invite_partner(ws.id, owner_user.id) + + user, pd = await auth_service.register( + "partner@example.com", "password123", "Partner Real Name", invite_token=token + ) + + assert pd.uuid == placeholder.uuid + assert pd.name == "Partner Real Name" + assert user.personal_data_uuid == placeholder.uuid + + members = await workspace_service.list_members(ws.id) + assert {m.name for m in members} == {"Owner", "Partner Real Name"} + + async def test_joins_free_slot_when_no_placeholder_exists( + self, + auth_service: AuthService, + workspace_service: WorkspaceService, + user_repo: SQLiteUserRepository, + ) -> None: + ws = await workspace_service.create_workspace("Shared plan") + owner_pd = await workspace_service.add_placeholder_parent(ws.id, "Owner") + owner_user = await user_repo.create( + User( + id=uuid4(), + personal_data_uuid=owner_pd.uuid, + email="owner2@example.com", + hashed_password="not-a-real-hash", + created_at=datetime.now(), + ) + ) + token = await workspace_service.invite_partner(ws.id, owner_user.id) + + user, pd = await auth_service.register( + "joiner@example.com", "password123", "Joiner", invite_token=token + ) + + assert pd.workspace_id == ws.id + assert user.personal_data_uuid == pd.uuid + members = await workspace_service.list_members(ws.id) + assert {m.name for m in members} == {"Owner", "Joiner"} + + async def test_invalid_token_raises_invitation_error(self, auth_service: AuthService) -> None: + with pytest.raises(InvitationError, match="not found"): + await auth_service.register( + "nope@example.com", "password123", "Nope", invite_token="bogus-token" + ) + + async def test_refuses_when_both_slots_have_real_logins( + self, + auth_service: AuthService, + workspace_service: WorkspaceService, + user_repo: SQLiteUserRepository, + ) -> None: + ws = await workspace_service.create_workspace("Shared plan") + owner_pd = await workspace_service.add_placeholder_parent(ws.id, "Owner") + owner_user = await user_repo.create( + User( + id=uuid4(), + personal_data_uuid=owner_pd.uuid, + email="owner3@example.com", + hashed_password="not-a-real-hash", + created_at=datetime.now(), + ) + ) + second_pd = await workspace_service.add_placeholder_parent(ws.id, "Partner") + await user_repo.create( + User( + id=uuid4(), + personal_data_uuid=second_pd.uuid, + email="partner3@example.com", + hashed_password="not-a-real-hash", + created_at=datetime.now(), + ) + ) + token = await workspace_service.invite_partner(ws.id, owner_user.id) + + with pytest.raises(InvitationError, match="maximum number of members"): + await auth_service.register( + "late@example.com", "password123", "Late", invite_token=token + ) + + async def test_raises_when_invitations_not_configured( + self, + user_repo: SQLiteUserRepository, + personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, + ) -> None: + """A direct-construction call site (e.g. a script) that never wires + `inv_repo` gets a clear error rather than a crash if it's ever + handed an invite token.""" + bare_auth_service = AuthService(user_repo, personal_data_repo, workspace_repo) + with pytest.raises(InvitationError, match="not available"): + await bare_auth_service.register( + "x@example.com", "password123", "X", invite_token="whatever" + ) diff --git a/backend/tests/unit/test_services/test_tips_service.py b/backend/tests/unit/test_services/test_tips_service.py index 6e3581a..fec36e3 100644 --- a/backend/tests/unit/test_services/test_tips_service.py +++ b/backend/tests/unit/test_services/test_tips_service.py @@ -15,6 +15,7 @@ from app.entities.leave_wish import LeaveWish from app.entities.parental_leave import ParentalLeaveFetch, TierData from app.entities.personal_data import PersonalData from app.entities.planned_leave import PlannedLeaveDay +from app.services.analytics_service import VacationYearBalance from app.services.tips_service import ( TipsContext, _rule_age4_pooling_loss, @@ -25,6 +26,7 @@ from app.services.tips_service import ( _rule_reserve_for_preschool_planning_days, _rule_stretch_time_at_home, _rule_use_dubbeldagar_strategically, + _rule_use_vacation_before_expiry, _rule_wrap_red_days_with_vacation, _rule_wrap_weekend_with_vacation, ) @@ -159,6 +161,7 @@ def _make_ctx( vacation_by_parent: dict | None = None, leave_wishes: list | None = None, today: date | None = None, + vacation_year_balance_by_parent: dict | None = None, ) -> TipsContext: ws = workspace_id or uuid4() return TipsContext( @@ -170,6 +173,31 @@ def _make_ctx( vacation_by_parent=vacation_by_parent or {}, leave_wishes=leave_wishes or [], today=today or date.today(), + vacation_year_balance_by_parent=vacation_year_balance_by_parent or {}, + ) + + +def _make_vacation_balance( + year: int = 2026, + earned: int = 25, + carried_in: int = 0, + planned_use: int = 0, + left: int = 25, + carried_out: int = 5, + expiring_age: int = 0, + expiring_over_cap: int = 0, +) -> VacationYearBalance: + return VacationYearBalance( + year=year, + earned=earned, + is_assumed=False, + carried_in=carried_in, + planned_use=planned_use, + left=left, + carried_out=carried_out, + expiring=expiring_age + expiring_over_cap, + expiring_age=expiring_age, + expiring_over_cap=expiring_over_cap, ) @@ -181,6 +209,79 @@ def _next_weekday(target_weekday: int, offset_days: int = 1) -> date: return d +# --------------------------------------------------------------------------- +# _rule_use_vacation_before_expiry +# --------------------------------------------------------------------------- + + +class TestUseVacationBeforeExpiry: + async def test_fires_on_genuine_forfeiture(self): + ws = uuid4() + parent = _make_parent(workspace_id=ws, name="Alice") + balance = _make_vacation_balance( + year=2026, left=15, carried_out=10, expiring_age=0, expiring_over_cap=5 + ) + ctx = _make_ctx( + workspace_id=ws, + parents=[parent], + vacation_year_balance_by_parent={parent.uuid: balance}, + ) + tip = await _rule_use_vacation_before_expiry(ctx) + assert tip is not None + assert tip.id == "use_vacation_before_expiry" + assert tip.data["parent_name"] == "Alice" + assert tip.data["lost_days"] == 5 + assert tip.data["year"] == 2026 + assert tip.anchor_date == date(2026, 12, 31) + + async def test_fires_when_only_age_expiry_is_lost(self): + ws = uuid4() + parent = _make_parent(workspace_id=ws) + balance = _make_vacation_balance(expiring_age=5, expiring_over_cap=0) + ctx = _make_ctx( + workspace_id=ws, + parents=[parent], + vacation_year_balance_by_parent={parent.uuid: balance}, + ) + tip = await _rule_use_vacation_before_expiry(ctx) + assert tip is not None + assert tip.data["lost_days"] == 5 + + async def test_no_fire_when_nothing_is_lost(self): + ws = uuid4() + parent = _make_parent(workspace_id=ws) + balance = _make_vacation_balance(expiring_age=0, expiring_over_cap=0) + ctx = _make_ctx( + workspace_id=ws, + parents=[parent], + vacation_year_balance_by_parent={parent.uuid: balance}, + ) + tip = await _rule_use_vacation_before_expiry(ctx) + assert tip is None + + async def test_no_fire_when_parent_has_no_balance(self): + ws = uuid4() + parent = _make_parent(workspace_id=ws) + ctx = _make_ctx(workspace_id=ws, parents=[parent], vacation_year_balance_by_parent={}) + tip = await _rule_use_vacation_before_expiry(ctx) + assert tip is None + + async def test_no_fire_for_a_past_year_even_with_forfeiture(self): + """Days already lost in a past year are gone — warning about them + is noise the user shouldn't see, regardless of how much was lost.""" + ws = uuid4() + parent = _make_parent(workspace_id=ws) + balance = _make_vacation_balance(year=2024, expiring_age=0, expiring_over_cap=20) + ctx = _make_ctx( + workspace_id=ws, + parents=[parent], + vacation_year_balance_by_parent={parent.uuid: balance}, + today=date(2026, 1, 15), + ) + tip = await _rule_use_vacation_before_expiry(ctx) + assert tip is None + + # --------------------------------------------------------------------------- # _rule_balance_between_parents # --------------------------------------------------------------------------- diff --git a/backend/tests/unit/test_workspace_service.py b/backend/tests/unit/test_workspace_service.py index bb44638..89f40c3 100644 --- a/backend/tests/unit/test_workspace_service.py +++ b/backend/tests/unit/test_workspace_service.py @@ -410,14 +410,77 @@ class TestInvitationScoping: with pytest.raises(InvitationError, match="already belong"): await workspace_service.redeem_invitation(token, seeded_ids.personal_data_uuid) - async def test_redeem_refuses_when_workspace_already_has_two_members( + async def test_redeem_claims_placeholder_instead_of_refusing_when_full_of_a_placeholder( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, user_repo: SQLiteUserRepository, ) -> None: - # Fill the target workspace to its 2-member cap first. - await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Partner") + """Regression for "the second parent can't log in": the target + workspace's second slot is commonly a placeholder auto-created by the + onboarding wizard (`add_placeholder_parent`), not a genuinely full + workspace. Redemption must CLAIM that placeholder -- re-pointing the + redeemer's login onto it and preserving whatever plan data the + placeholder already holds -- rather than refusing with "maximum + number of members", which used to make the entire invite happy path + impossible (see `WorkspaceService.redeem_invitation` docstring).""" + placeholder = await workspace_service.add_placeholder_parent( + seeded_ids.workspace_id, "Partner" + ) + + other_ws = await workspace_service.create_workspace("Other WS") + redeemer_pd = await workspace_service.add_placeholder_parent(other_ws.id, "Redeemer") + redeemer_user = await user_repo.create( + User( + id=uuid4(), + personal_data_uuid=redeemer_pd.uuid, + email="redeemer@example.com", + hashed_password="not-a-real-hash", + created_at=datetime.now(), + ) + ) + + token = await workspace_service.invite_partner( + seeded_ids.workspace_id, await _make_inviter(user_repo, seeded_ids.personal_data_uuid) + ) + inv = await workspace_service.redeem_invitation(token, redeemer_pd.uuid) + assert inv.status == "accepted" + + # Login re-pointed onto the placeholder, not left on the (now + # discarded) auto-created profile. + refreshed_user = await user_repo.get_by_id(redeemer_user.id) + assert refreshed_user is not None + assert refreshed_user.personal_data_uuid == placeholder.uuid + + # The placeholder took over the redeemer's already-chosen name. + members = await workspace_service.list_members(seeded_ids.workspace_id) + assert {m.name for m in members} == {"Test Parent", "Redeemer"} + + # The redeemer's own auto-created profile is gone -- not left behind + # as a second, orphaned membership of the target workspace. + assert await workspace_service._pd.get_by_id(redeemer_pd.uuid) is None + + async def test_redeem_refuses_when_both_slots_have_real_logins( + self, + workspace_service: WorkspaceService, + seeded_ids: SeededIds, + user_repo: SQLiteUserRepository, + ) -> None: + """Unlike the placeholder case above, a workspace whose second slot + is a real, logged-in member has genuinely no room -- redemption must + still be refused.""" + second_member = await workspace_service.add_placeholder_parent( + seeded_ids.workspace_id, "Partner" + ) + await user_repo.create( + User( + id=uuid4(), + personal_data_uuid=second_member.uuid, + email="partner@example.com", + hashed_password="not-a-real-hash", + created_at=datetime.now(), + ) + ) other_ws = await workspace_service.create_workspace("Other WS") other_pd = await workspace_service.add_placeholder_parent(other_ws.id, "Redeemer") @@ -427,3 +490,45 @@ class TestInvitationScoping: ) with pytest.raises(InvitationError, match="maximum number of members"): await workspace_service.redeem_invitation(token, other_pd.uuid) + + async def test_redeem_refuses_claim_when_redeemer_has_dependent_plan_data( + self, + workspace_service: WorkspaceService, + seeded_ids: SeededIds, + user_repo: SQLiteUserRepository, + vacation_repo: SQLiteVacationDaysRepository, + ) -> None: + """A redeemer who already entered plan data under their own profile + cannot be safely claimed onto the placeholder: `vacation_days` (and + siblings) denormalize `workspace_id` alongside `personal_data_uuid`, + and no repository method exists to repoint that column -- silently + discarding or merging it would lose or corrupt data, so this is + refused instead.""" + await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Partner") + + other_ws = await workspace_service.create_workspace("Other WS") + redeemer_pd = await workspace_service.add_placeholder_parent(other_ws.id, "Redeemer") + await user_repo.create( + User( + id=uuid4(), + personal_data_uuid=redeemer_pd.uuid, + email="redeemer2@example.com", + hashed_password="not-a-real-hash", + created_at=datetime.now(), + ) + ) + await vacation_repo.create( + VacationDays( + id=uuid4(), + personal_data_uuid=redeemer_pd.uuid, + workspace_id=other_ws.id, + year=2026, + total_days=25, + ) + ) + + token = await workspace_service.invite_partner( + seeded_ids.workspace_id, await _make_inviter(user_repo, seeded_ids.personal_data_uuid) + ) + with pytest.raises(InvitationError, match="vacation_days"): + await workspace_service.redeem_invitation(token, redeemer_pd.uuid) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f73f75d..f7a78ee 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { Auth } from '@/pages/Auth' import { Calendar } from '@/pages/Calendar' import { Dashboard } from '@/pages/Dashboard' +import { Join } from '@/pages/Join' import { Settings } from '@/pages/Settings' import { DevFetch } from '@/pages/DevFetch' @@ -20,6 +21,7 @@ function AppRoutes() { return ( } /> + } /> { }) }) + describe('controlled year disclosures (survive a background refresh)', () => { + it('opens the current year by default and keeps other years closed', () => { + render() + expect(screen.getByTestId('red-days-year-2026')).toHaveAttribute('open') + expect(screen.getByTestId('red-days-year-2027')).not.toHaveAttribute('open') + }) + + it('opening a non-default year persists across a re-render with fresh props (a background refresh)', () => { + const { rerender } = render(); + fireEvent.click(screen.getByTestId('red-days-year-2027').querySelector('summary')!) + expect(screen.getByTestId('red-days-year-2027')).toHaveAttribute('open') + + // Simulate the props a background refresh (e.g. from toggling a day + // elsewhere) would produce — a new `days` array reference. + rerender() + + expect(screen.getByTestId('red-days-year-2027')).toHaveAttribute('open') + }) + + it('closing the default-open current year persists across a re-render', () => { + const { rerender } = render() + fireEvent.click(screen.getByTestId('red-days-year-2026').querySelector('summary')!) + expect(screen.getByTestId('red-days-year-2026')).not.toHaveAttribute('open') + + rerender() + + expect(screen.getByTestId('red-days-year-2026')).not.toHaveAttribute('open') + }) + }) + describe('toggling a day (§Task8)', () => { it('clicking a statutory red day upserts an override making that exact date a working day', async () => { const onUpsertOverride = vi.fn().mockResolvedValue(undefined) diff --git a/frontend/src/components/calendar/CalendarExceptions.tsx b/frontend/src/components/calendar/CalendarExceptions.tsx index b14f7b7..224c8aa 100644 --- a/frontend/src/components/calendar/CalendarExceptions.tsx +++ b/frontend/src/components/calendar/CalendarExceptions.tsx @@ -62,6 +62,19 @@ export function CalendarExceptions({ const [pendingDate, setPendingDate] = useState(null) const [toggleError, setToggleError] = useState(null) + // Per-year open/closed state, explicit and controlled — a background + // refresh (e.g. the toggle below, or a `planVersion` bump from an + // override made elsewhere) re-renders this component with fresh `days`, + // but must never reset which year(s) the user has open. Only a year the + // user hasn't touched yet falls back to the "current year starts open" + // default; any year they've explicitly opened or closed stays exactly as + // they left it for the lifetime of this component instance. + const [openYearOverrides, setOpenYearOverrides] = useState>({}) + const isYearOpen = (year: number) => openYearOverrides[year] ?? year === currentYear + const handleToggleYear = (year: number, open: boolean) => { + setOpenYearOverrides((prev) => (prev[year] === open ? prev : { ...prev, [year]: open })) + } + // Form state (ported from `DayOverridesInput`). const [date, setDate] = useState('') const [isWorkingDay, setIsWorkingDay] = useState(true) @@ -184,7 +197,8 @@ export function CalendarExceptions({ key={year} className="disclosure red-days-list__year" data-testid={`red-days-year-${year}`} - open={year === currentYear} + open={isYearOpen(year)} + onToggle={(e) => handleToggleYear(year, e.currentTarget.open)} > {year} diff --git a/frontend/src/components/dashboard/YearRows.test.tsx b/frontend/src/components/dashboard/YearRows.test.tsx index 59066de..259b87f 100644 --- a/frontend/src/components/dashboard/YearRows.test.tsx +++ b/frontend/src/components/dashboard/YearRows.test.tsx @@ -6,11 +6,14 @@ import { makeParentYearBalance, makeParentalLeaveYearBalance, makePlannedLeaveDay, + makePreschoolPlanningDays, makeShortfallRemedy, makeVacationYearBalance, makeWindowOutcome, } from '../../../tests/fixtures' +const CURRENT_YEAR = new Date().getFullYear() + const HORIZON = { startDate: '2025-06-01', endDate: '2029-06-01' } function balancesFor(uuid: string, years: number[], overrides: Partial> = {}) { @@ -110,6 +113,74 @@ describe('YearRows', () => { expect(screen.queryByTestId('year-row-2026-expiring-pd-a')).not.toBeInTheDocument() }) + it('suppresses the expiring warning (summary and table) for a year already in the past', () => { + renderRows() + // sam's fixture has `expiring: 3` for every year including 2025, which + // is before CURRENT_YEAR — already lost, no longer actionable, so it + // must not appear as a warning anywhere for that year. + expect(CURRENT_YEAR).toBeGreaterThan(2025) + expect(screen.queryByTestId('year-row-2025-expiring-pd-b')).not.toBeInTheDocument() + const table = screen.getByTestId('year-row-2025-table') + const expiring = within(table).getByRole('row', { name: /^Förfaller/ }) + const cells = within(expiring).getAllByRole('cell') + expect(cells[1]).not.toHaveClass('year-row__cell--danger') + // The current year (2026) is unaffected — still warns. + expect(screen.getByTestId('year-row-2026-expiring-pd-b')).toBeInTheDocument() + }) + + it('explains a comprehensible breakdown of why days expire (Jacob case: 15 left, 10 saved, 5 over the cap)', () => { + const jacob: YearRowsParent = { + personalDataUuid: 'pd-jacob', + name: 'Jacob', + yearBalances: [ + makeParentYearBalance({ + personalDataUuid: 'pd-jacob', + year: 2027, + vacation: makeVacationYearBalance({ + year: 2027, + earned: 30, + left: 15, + carriedOut: 10, + expiring: 5, + expiringAge: 0, + expiringOverCap: 5, + }), + }), + ], + } + renderRows({ parents: [jacob] }) + const table = screen.getByTestId('year-row-2027-table') + const expiring = within(table).getByRole('row', { name: /^Förfaller/ }) + expect(expiring).toHaveTextContent('5') + expect(expiring).toHaveTextContent('eftersom bara 10 av 15 kvarvarande dagar får sparas till nästa år') + // The 5-year age-out cause is not shown since expiringAge is 0. + expect(expiring).not.toHaveTextContent('5-årsgränsen') + }) + }) + + describe('preschool planning days reserve', () => { + it('shows the planned and taken figures for the year, with an assumed badge for a synthesized default', () => { + renderRows({ + parents: [alex], + preschoolPlanningDays: [makePreschoolPlanningDays({ year: 2026, plannedDays: 4, takenDays: 2, isDefault: true })], + }) + const planned = screen.getByTestId('year-row-2026-preschool-planned') + expect(planned).toHaveTextContent('4') + expect(planned).toHaveTextContent('antaget') + expect(screen.getByTestId('year-row-2026-preschool-taken')).toHaveTextContent('2') + }) + + it('reads "not recorded" (not zero) when takenDays is null, and "–" when the year has no row', () => { + renderRows({ + parents: [alex], + preschoolPlanningDays: [makePreschoolPlanningDays({ year: 2026, plannedDays: 4, takenDays: null, isDefault: false })], + }) + expect(screen.getByTestId('year-row-2026-preschool-taken')).toHaveTextContent('Ej registrerat') + expect(screen.getByTestId('year-row-2027-preschool-planned')).toHaveTextContent('–') + }) + }) + + describe('per-parent figures (couple), continued', () => { it('renders a dash for a year the parent has no balance entry for', () => { const partial: YearRowsParent = { ...alex, yearBalances: balancesFor('pd-a', [2025]) } renderRows({ parents: [partial, sam] }) diff --git a/frontend/src/components/dashboard/YearRows.tsx b/frontend/src/components/dashboard/YearRows.tsx index 7ed31d7..f840b89 100644 --- a/frontend/src/components/dashboard/YearRows.tsx +++ b/frontend/src/components/dashboard/YearRows.tsx @@ -1,5 +1,5 @@ import { MouseEvent, ReactNode } from 'react' -import type { ParentYearBalance, PlannedLeaveDay, ShortfallRemedy, WindowOutcome } from '@/types' +import type { ParentYearBalance, PlannedLeaveDay, PreschoolPlanningDays, ShortfallRemedy, WindowOutcome } from '@/types' import { useTranslation, toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n' import { getLabelForType, getTypeKey } from '@/utils/leaveTypes' import { formatDateForDisplay } from '@/utils/dates' @@ -27,6 +27,10 @@ export interface YearRowsProps { outcomes: WindowOutcome[] /** Called with a band's ISO-8601 start date when clicked. */ onBandClick: (date: string) => void + /** One row per (child, year) of preschool closure/planning days — a + * household-level reserve, not per-parent. Absent or missing a year + * renders that year's reserve as unknown ('–'), not zero. */ + preschoolPlanningDays?: PreschoolPlanningDays[] } interface Band { @@ -171,8 +175,20 @@ function renderRemedyLine(remedy: ShortfallRemedy, t: Translate): string { * Opening a row shows the full per-parent balance table for that year plus * the plan proposal's wish outcomes. */ -export function YearRows({ parents, plannedDays, startDate, endDate, outcomes, onBandClick }: YearRowsProps): ReactNode { +export function YearRows({ + parents, + plannedDays, + startDate, + endDate, + outcomes, + onBandClick, + preschoolPlanningDays, +}: YearRowsProps): ReactNode { const { t, locale } = useTranslation() + // Years before this one are already closed out — a red "expiring" warning + // there is noise the user can no longer act on, though the underlying + // figure stays correct (later years' carriedIn depends on it). + const currentYear = new Date().getFullYear() const startYear = toUtcDate(startDate).getUTCFullYear() const endYear = toUtcDate(endDate).getUTCFullYear() const years = startYear <= endYear ? Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i) : [] @@ -207,9 +223,11 @@ export function YearRows({ parents, plannedDays, startDate, endDate, outcomes, o {years.map((year) => { const yearLength = daysInYear(year) + const isPastYear = year < currentYear const outcomesForYear = outcomes .filter((o) => o.year === year) .sort((a, b) => a.startDate.localeCompare(b.startDate)) + const preschoolRow = (preschoolPlanningDays ?? []).find((p) => p.year === year) let bandIndex = 0 return (
@@ -283,7 +301,7 @@ export function YearRows({ parents, plannedDays, startDate, endDate, outcomes, o {t('yearRow.carriedOut', { days: balance.vacation.carriedOut })} )} - {balance && balance.vacation.expiring > 0 && ( + {balance && balance.vacation.expiring > 0 && year >= currentYear && ( b.vacation.expiring} - danger={(b) => b.vacation.expiring > 0} + danger={(b) => !isPastYear && b.vacation.expiring > 0} + detail={(b) => { + if (isPastYear || b.vacation.expiring === 0) return null + const lines: string[] = [] + if (b.vacation.expiringOverCap > 0) { + lines.push( + t('yearRow.expiringOverCapDetail', { + saved: b.vacation.carriedOut, + left: b.vacation.left, + }), + ) + } + if (b.vacation.expiringAge > 0) { + lines.push(t('yearRow.expiringAgeDetail', { days: b.vacation.expiringAge })) + } + if (lines.length === 0) return null + return {lines.join(' · ')} + }} /> + + + {t('yearRow.preschoolTitle')} + + + + + {t('yearRow.preschoolPlanned')} + + + {preschoolRow?.isDefault && ( + {t('yearRow.assumed')} + )} + {preschoolRow ? preschoolRow.plannedDays : '–'} + + + + + {t('yearRow.preschoolTaken')} + + + {preschoolRow + ? preschoolRow.takenDays === null + ? t('yearRow.preschoolNotRecorded') + : preschoolRow.takenDays + : '–'} + + )} @@ -436,9 +499,11 @@ interface BalanceRowProps { danger?: (balance: ParentYearBalance) => boolean /** A "what is left" row — heavier value weight. */ emphasis?: boolean + /** Optional muted explanatory line under the value (e.g. why days expire). */ + detail?: (balance: ParentYearBalance) => ReactNode | null } -function BalanceRow({ label, parents, year, pick, badge, danger, emphasis }: BalanceRowProps): ReactNode { +function BalanceRow({ label, parents, year, pick, badge, danger, emphasis, detail }: BalanceRowProps): ReactNode { return ( @@ -448,6 +513,7 @@ function BalanceRow({ label, parents, year, pick, badge, danger, emphasis }: Bal const balance = parent.yearBalances.find((b) => b.year === year) const badgeText = balance && badge ? badge(balance) : null const isDanger = balance && danger ? danger(balance) : false + const detailNode = balance && detail ? detail(balance) : null return ( {badgeText && {badgeText}} {balance ? pick(balance) : '–'} + {detailNode} ) })} diff --git a/frontend/src/components/settings/PartnerInvitePanel.test.tsx b/frontend/src/components/settings/PartnerInvitePanel.test.tsx new file mode 100644 index 0000000..f85ef05 --- /dev/null +++ b/frontend/src/components/settings/PartnerInvitePanel.test.tsx @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { PartnerInvitePanel } from './PartnerInvitePanel' +import * as api from '@/services/api' +import { makeInvitation } from '../../../tests/fixtures' + +vi.mock('@/services/api') + +const mockCreateInvitation = vi.mocked(api.createInvitation) +const mockListInvitations = vi.mocked(api.listInvitations) +const mockRevokeInvitation = vi.mocked(api.revokeInvitation) + +describe('PartnerInvitePanel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListInvitations.mockResolvedValue([]) + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }) + }) + + it('lists pending invitations with their expiry', async () => { + mockListInvitations.mockResolvedValue([ + makeInvitation({ id: 'inv-1', status: 'pending', expiresAt: '2025-01-08T00:00:00Z' }), + makeInvitation({ id: 'inv-2', status: 'accepted' }), + ]) + render() + + expect(await screen.findByTestId('invite-pending-list')).toBeInTheDocument() + expect(screen.getByTestId('invite-row-inv-1')).toBeInTheDocument() + // Accepted invitations aren't shown in the pending list. + expect(screen.queryByTestId('invite-row-inv-2')).not.toBeInTheDocument() + expect(mockListInvitations).toHaveBeenCalledWith('ws-1') + }) + + it('shows an empty state when there are no pending invitations', async () => { + render() + expect(await screen.findByText('Inga väntande inbjudningar just nu.')).toBeInTheDocument() + }) + + it('creates an invitation and shows the raw token as a copyable link, once', async () => { + mockCreateInvitation.mockResolvedValue({ token: 'raw-token-xyz' }) + render() + await waitFor(() => expect(mockListInvitations).toHaveBeenCalled()) + + fireEvent.click(screen.getByTestId('invite-generate-button')) + + const input = await screen.findByTestId('invite-link-input') + expect(input).toHaveValue(`${window.location.origin}/join/raw-token-xyz`) + expect(screen.getByText(/visas bara här och nu/)).toBeInTheDocument() + expect(mockCreateInvitation).toHaveBeenCalledWith('ws-1') + }) + + it('copies the invite link to the clipboard', async () => { + mockCreateInvitation.mockResolvedValue({ token: 'raw-token-xyz' }) + render() + fireEvent.click(screen.getByTestId('invite-generate-button')) + await screen.findByTestId('invite-link-input') + + fireEvent.click(screen.getByTestId('invite-copy-button')) + + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + `${window.location.origin}/join/raw-token-xyz`, + ), + ) + expect(await screen.findByText('Kopierad!')).toBeInTheDocument() + }) + + it('revokes a pending invitation and refreshes the list', async () => { + mockListInvitations + .mockResolvedValueOnce([makeInvitation({ id: 'inv-1', status: 'pending' })]) + .mockResolvedValueOnce([]) + render() + + fireEvent.click(await screen.findByTestId('invite-revoke-inv-1')) + + await waitFor(() => expect(mockRevokeInvitation).toHaveBeenCalledWith('inv-1')) + await waitFor(() => expect(screen.getByText('Inga väntande inbjudningar just nu.')).toBeInTheDocument()) + }) + + it('shows an error when creating the invitation fails', async () => { + mockCreateInvitation.mockRejectedValue(new Error('boom')) + render() + + fireEvent.click(screen.getByTestId('invite-generate-button')) + + expect(await screen.findByRole('alert')).toHaveTextContent('boom') + }) + + it('shows an error when listing invitations fails', async () => { + mockListInvitations.mockRejectedValue(new Error('list boom')) + render() + + expect(await screen.findByRole('alert')).toHaveTextContent('list boom') + }) +}) diff --git a/frontend/src/components/settings/PartnerInvitePanel.tsx b/frontend/src/components/settings/PartnerInvitePanel.tsx new file mode 100644 index 0000000..5df5003 --- /dev/null +++ b/frontend/src/components/settings/PartnerInvitePanel.tsx @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useState } from 'react' +import * as api from '@/services/api' +import { useTranslation } from '@/i18n' +import type { Invitation } from '@/types' +import './partner-invite-panel.css' + +export interface PartnerInvitePanelProps { + /** The workspace to invite a partner into. Invitation creation and listing + * are scoped to it; revoking an invitation is not (the backend derives the + * caller's own workspace from the session). */ + workspaceId: string +} + +/** + * Generates a one-time partner invite link, shows it once for copying, and + * lists/revokes pending invitations. Mounted by the Settings page inside its + * partner section — this component owns no layout beyond its own root, so it + * can drop into any surrounding section markup. + */ +export function PartnerInvitePanel({ workspaceId }: PartnerInvitePanelProps) { + const { t, locale } = useTranslation() + const [invitations, setInvitations] = useState([]) + const [isListLoading, setIsListLoading] = useState(true) + const [listError, setListError] = useState(null) + const [isCreating, setIsCreating] = useState(false) + const [createError, setCreateError] = useState(null) + const [newToken, setNewToken] = useState(null) + const [copied, setCopied] = useState(false) + const [revokingId, setRevokingId] = useState(null) + const [revokeError, setRevokeError] = useState(null) + + const loadInvitations = useCallback(async () => { + setIsListLoading(true) + setListError(null) + try { + const list = await api.listInvitations(workspaceId) + setInvitations(list) + } catch (err: unknown) { + setListError(err instanceof Error ? err.message : t('invite.listError')) + } finally { + setIsListLoading(false) + } + }, [workspaceId, t]) + + useEffect(() => { + void loadInvitations() + }, [loadInvitations]) + + const handleCreate = async () => { + setIsCreating(true) + setCreateError(null) + setCopied(false) + try { + const { token } = await api.createInvitation(workspaceId) + setNewToken(token) + await loadInvitations() + } catch (err: unknown) { + setCreateError(err instanceof Error ? err.message : t('invite.createError')) + } finally { + setIsCreating(false) + } + } + + const handleCopy = async () => { + if (!newToken) return + const link = `${window.location.origin}/join/${newToken}` + try { + await navigator.clipboard.writeText(link) + setCopied(true) + } catch { + // Clipboard API unavailable/denied — the link text is still visible + // and selectable, so the person can copy it manually. + setCopied(false) + } + } + + const handleRevoke = async (invitationId: string) => { + setRevokingId(invitationId) + setRevokeError(null) + try { + await api.revokeInvitation(invitationId) + if (newToken) { + // The just-created invitation may be the one being revoked; once + // revoked its raw token is dead, so stop displaying it as live. + setNewToken(null) + } + await loadInvitations() + } catch (err: unknown) { + setRevokeError(err instanceof Error ? err.message : t('invite.revokeError')) + } finally { + setRevokingId(null) + } + } + + const pending = invitations.filter((inv) => inv.status === 'pending') + const inviteLink = newToken ? `${window.location.origin}/join/${newToken}` : null + + return ( +
+

{t('invite.panelDescription')}

+ + + + {createError && ( +

+ {createError} +

+ )} + + {inviteLink && ( +
+ +
+ e.currentTarget.select()} + /> + +
+

{t('invite.onceWarning')}

+
+ )} + +

{t('invite.pendingTitle')}

+ + {isListLoading ? ( +

{t('common.loading')}

+ ) : listError ? ( +

+ {listError} +

+ ) : pending.length === 0 ? ( +

{t('invite.noPending')}

+ ) : ( +
    + {pending.map((inv) => ( +
  • + + {t('invite.expiresLabel', { date: formatExpiry(inv.expiresAt, locale) })} + + +
  • + ))} +
+ )} + + {revokeError && ( +

+ {revokeError} +

+ )} +
+ ) +} + +function formatExpiry(iso: string, locale: 'sv' | 'en'): string { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return iso + return date.toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-GB', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} diff --git a/frontend/src/components/settings/partner-invite-panel.css b/frontend/src/components/settings/partner-invite-panel.css new file mode 100644 index 0000000..f1c2e11 --- /dev/null +++ b/frontend/src/components/settings/partner-invite-panel.css @@ -0,0 +1,83 @@ +/* PartnerInvitePanel — invite-link generation and pending-invite list. + * Mounted inside Settings' partner section; owns no outer section chrome, + * just its own internal spacing. */ +.partner-invite-panel { + display: flex; + flex-direction: column; + gap: var(--sp-3); + align-items: flex-start; +} + +.partner-invite-panel__description { + margin: 0; + color: var(--c-text-muted); +} + +.partner-invite-panel__link-box { + width: 100%; + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding: var(--sp-3); + border: 1px solid var(--c-border); + border-radius: 8px; + background-color: var(--c-accent-weak); +} + +.partner-invite-panel__link-row { + display: flex; + flex-wrap: wrap; + gap: var(--sp-2); +} + +.partner-invite-panel__link-row .field__control { + flex: 1 1 240px; + min-width: 0; +} + +.partner-invite-panel__warning { + margin: 0; + font-size: var(--fs-sm); + color: var(--c-text-muted); +} + +.partner-invite-panel__pending-title { + margin: var(--sp-2) 0 0 0; +} + +.partner-invite-panel__empty { + margin: 0; + color: var(--c-text-muted); +} + +.partner-invite-panel__list { + list-style: none; + margin: 0; + padding: 0; + width: 100%; + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.partner-invite-panel__row { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--c-border); + border-radius: 8px; +} + +.partner-invite-panel__expiry { + font-size: var(--fs-sm); + color: var(--c-text-muted); +} + +.partner-invite-panel__error { + margin: 0; + font-size: var(--fs-sm); + color: var(--c-danger); +} diff --git a/frontend/src/components/vacation-days/VacationDaysTable.test.tsx b/frontend/src/components/vacation-days/VacationDaysTable.test.tsx index effbcd8..3604338 100644 --- a/frontend/src/components/vacation-days/VacationDaysTable.test.tsx +++ b/frontend/src/components/vacation-days/VacationDaysTable.test.tsx @@ -1,75 +1,191 @@ -import { describe, it, expect, vi } from 'vitest' -import { render, fireEvent, screen } from '@testing-library/react' -import { VacationDaysTable } from './VacationDaysTable' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, fireEvent, screen, act } from '@testing-library/react' +import { VacationDaysTable, type VacationDaysTableRow } from './VacationDaysTable' -const ROWS = [ +const ROWS: VacationDaysTableRow[] = [ { year: 2026, totalDays: 10, savedDaysIn: 2 }, { year: 2027, totalDays: 25, savedDaysIn: 0 }, { year: 2028, totalDays: 25, savedDaysIn: 0 }, ] +function renderTable( + rows: VacationDaysTableRow[], + onSave: (year: number, totalDays: number, savedDaysIn: number) => Promise, +) { + return render() +} + describe('VacationDaysTable', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + it('renders one row per year', () => { - render( {}} />) + renderTable(ROWS, vi.fn().mockResolvedValue(undefined)) expect(screen.getByTestId('vacation-year-row-2026')).toBeInTheDocument() expect(screen.getByTestId('vacation-year-row-2027')).toBeInTheDocument() expect(screen.getByTestId('vacation-year-row-2028')).toBeInTheDocument() }) it('shows the total days and saved days for each year', () => { - render( {}} />) + renderTable(ROWS, vi.fn().mockResolvedValue(undefined)) const totalInput = screen.getByTestId('vacation-total-days-2026') as HTMLInputElement const savedInput = screen.getByTestId('vacation-saved-days-2026') as HTMLInputElement expect(totalInput.value).toBe('10') expect(savedInput.value).toBe('2') }) - it('calls onSave with the edited year and total days when Spara is clicked', () => { - const onSave = vi.fn() - render() + it('autosaves a total-days edit after the debounce delay, without a Save button', async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + renderTable(ROWS, onSave) - const totalInput = screen.getByTestId('vacation-total-days-2027') - fireEvent.change(totalInput, { target: { value: '30' } }) - fireEvent.click(screen.getByTestId('vacation-save-2027')) + fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '30' } }) + expect(onSave).not.toHaveBeenCalled() + expect(screen.queryByTestId('vacation-save-2027')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(800) + }) expect(onSave).toHaveBeenCalledWith(2027, 30, 0) }) - it('calls onSave with edited saved-days-in value', () => { - const onSave = vi.fn() - render() + it('autosaves a saved-days-in edit', async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + renderTable(ROWS, onSave) - const savedInput = screen.getByTestId('vacation-saved-days-2026') - fireEvent.change(savedInput, { target: { value: '5' } }) - fireEvent.click(screen.getByTestId('vacation-save-2026')) + fireEvent.change(screen.getByTestId('vacation-saved-days-2026'), { target: { value: '5' } }) + await act(async () => { + await vi.advanceTimersByTimeAsync(800) + }) expect(onSave).toHaveBeenCalledWith(2026, 10, 5) }) - it('does not mix up edits between rows', () => { - const onSave = vi.fn() - render() + it('does not mix up edits between rows', async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + renderTable(ROWS, onSave) fireEvent.change(screen.getByTestId('vacation-total-days-2026'), { target: { value: '15' } }) - fireEvent.click(screen.getByTestId('vacation-save-2027')) + fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '20' } }) - expect(onSave).toHaveBeenCalledWith(2027, 25, 0) + await act(async () => { + await vi.advanceTimersByTimeAsync(800) + }) + + expect(onSave).toHaveBeenCalledWith(2026, 15, 2) + expect(onSave).toHaveBeenCalledWith(2027, 20, 0) + expect(onSave).toHaveBeenCalledTimes(2) }) - it('disables save buttons while saving', () => { - render( {}} isSaving />) - expect(screen.getByTestId('vacation-save-2026')).toBeDisabled() + it('collapses rapid successive edits to the same row into a single save', async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + renderTable(ROWS, onSave) + + const input = screen.getByTestId('vacation-total-days-2027') + fireEvent.change(input, { target: { value: '26' } }) + await act(async () => { + await vi.advanceTimersByTimeAsync(400) + }) + fireEvent.change(input, { target: { value: '27' } }) + await act(async () => { + await vi.advanceTimersByTimeAsync(800) + }) + + expect(onSave).toHaveBeenCalledTimes(1) + expect(onSave).toHaveBeenCalledWith(2027, 27, 0) + }) + + it('shows a saving then saved indicator around the autosave', async () => { + let resolveSave: () => void = () => {} + const onSave = vi.fn( + () => + new Promise((resolve) => { + resolveSave = resolve + }), + ) + renderTable(ROWS, onSave) + + fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '30' } }) + await act(async () => { + await vi.advanceTimersByTimeAsync(800) + }) + expect(screen.getByTestId('vacation-status-2027')).toHaveTextContent('Sparar') + + await act(async () => { + resolveSave() + await Promise.resolve() + }) + expect(screen.getByTestId('vacation-status-2027')).toHaveTextContent('Sparat') + }) + + it('shows an error indicator when the autosave fails, without losing the typed value', async () => { + const onSave = vi.fn().mockRejectedValue(new Error('network blew up')) + renderTable(ROWS, onSave) + + fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '99' } }) + await act(async () => { + await vi.advanceTimersByTimeAsync(800) + }) + + expect(screen.getByTestId('vacation-status-2027')).toHaveTextContent(/gick inte|kunde inte|error/i) + expect((screen.getByTestId('vacation-total-days-2027') as HTMLInputElement).value).toBe('99') + }) + + it('does not clobber an unsaved edit when the parent re-renders with fresh rows mid-debounce', async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + const { rerender } = render() + + fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '40' } }) + + // Simulate a background refetch (e.g. from another row's save bumping + // `data`) landing mid-debounce, still carrying the OLD server value for + // this row. + rerender() + + expect((screen.getByTestId('vacation-total-days-2027') as HTMLInputElement).value).toBe('40') + + await act(async () => { + await vi.advanceTimersByTimeAsync(800) + }) + expect(onSave).toHaveBeenCalledWith(2027, 40, 0) + }) + + it('accepts a fresh server value once the row is no longer dirty', async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + const { rerender } = render() + + fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '40' } }) + await act(async () => { + await vi.advanceTimersByTimeAsync(800) + }) + expect(onSave).toHaveBeenCalledWith(2027, 40, 0) + + // Once the save resolved the row is clean again — a later prop update + // (e.g. from a real refetch confirming the save) should be reflected. + const updatedRows = ROWS.map((r) => (r.year === 2027 ? { ...r, totalDays: 40 } : r)) + rerender() + expect((screen.getByTestId('vacation-total-days-2027') as HTMLInputElement).value).toBe('40') }) it('shows an empty message when there are no rows', () => { - render( {}} />) - expect(screen.queryByTestId('vacation-days-table')).not.toBeInTheDocument() + render() + expect(screen.queryByTestId('vacation-days-table-pd-1')).not.toBeInTheDocument() expect(screen.getByText(/Inga år att visa/)).toBeInTheDocument() }) + it('scopes its root testid to the given personalDataUuid, for multiple tables on one page', () => { + renderTable(ROWS, vi.fn().mockResolvedValue(undefined)) + expect(screen.getByTestId('vacation-days-table-pd-1')).toBeInTheDocument() + }) + it('shows an assumed badge for a year with no entered data (§3.2)', () => { const rows = [...ROWS, { year: 2029, totalDays: 25, savedDaysIn: 0, isAssumed: true }] - render( {}} />) + renderTable(rows, vi.fn().mockResolvedValue(undefined)) expect(screen.getByTestId('vacation-assumed-badge-2029')).toBeInTheDocument() expect(screen.queryByTestId('vacation-assumed-badge-2026')).not.toBeInTheDocument() diff --git a/frontend/src/components/vacation-days/VacationDaysTable.tsx b/frontend/src/components/vacation-days/VacationDaysTable.tsx index 3ba52a8..1328cfd 100644 --- a/frontend/src/components/vacation-days/VacationDaysTable.tsx +++ b/frontend/src/components/vacation-days/VacationDaysTable.tsx @@ -20,12 +20,40 @@ * not editable in this task) and would otherwise silently re-apply their * fixed 90px input width to this markup, since the class names would * collide even though the DOM shape changed. + * + * Autosave: each row debounces its own edits and saves in the background + * (no per-year Save button — see the user complaint this replaced: "needing + * to click save for each year is super annoying"). Each row is its own + * component (`VacationYearRow`) purely so it can own its own + * `useDebouncedCallback` instance — one shared debounce across every row + * would collapse edits to two different years into a single save of + * whichever was typed last. + * + * Draft-resync-vs-autosave race: `rows` (the server-confirmed values) can + * change under a row at any time — most commonly right after this row's own + * save triggers a background refetch (see `useVacationDays`'s + * `isRefreshing`), but also from another browser tab/window. A naive + * `useEffect` that always resynced `draft` from `rows` would clobber + * whatever the user is mid-typing the moment that refetch resolves — an + * edit made between "save fired" and "refetch landed" would silently + * vanish. Each row tracks a `dirtyRef`, set the instant the user edits a + * field and cleared only once ITS OWN save has resolved; the resync effect + * bails out entirely while `dirtyRef.current` is true, so an in-flight or + * about-to-fire edit is never overwritten by stale-relative-to-it server + * data. Once the save resolves, the next resync is safe to apply (it will + * either match what was just saved, or reflect a newer edit already + * folded into the pending debounce). */ -import { ReactNode, useEffect, useState } from 'react' +import { ReactNode, useEffect, useRef, useState } from 'react' import { useTranslation } from '@/i18n' +import { useDebouncedCallback } from '@/hooks' import './vacation-rows.css' +const AUTOSAVE_DELAY_MS = 800 +// How long the "Sparat" confirmation stays visible after a successful save. +const SAVED_INDICATOR_MS = 2000 + export interface VacationDaysTableRow { year: number totalDays: number @@ -38,8 +66,16 @@ export interface VacationDaysTableRow { export interface VacationDaysTableProps { rows: VacationDaysTableRow[] - onSave: (year: number, totalDays: number, savedDaysIn: number) => void - isSaving?: boolean + /** Persists one year's edited values. Awaited by the row that called it so + * it can show a saving/saved/error indicator — never throws away the + * rejection (a row surfaces it inline instead of a page-wide toast, since + * several rows can be saving independently at once). */ + onSave: (year: number, totalDays: number, savedDaysIn: number) => Promise + /** Distinguishes rows rendered for different workspace members when more + * than one `VacationDaysTable` is on the page at once (§3.3) — used only + * for this table's own root `data-testid` so a test can scope into the + * right one with `within()`; the per-row testids stay `year`-only. */ + personalDataUuid: string } interface Draft { @@ -47,88 +83,125 @@ interface Draft { savedDaysIn: number } -export function VacationDaysTable({ rows, onSave, isSaving = false }: VacationDaysTableProps): ReactNode { +type RowStatus = 'idle' | 'saving' | 'saved' | 'error' + +interface VacationYearRowProps { + row: VacationDaysTableRow + onSave: (year: number, totalDays: number, savedDaysIn: number) => Promise +} + +function VacationYearRow({ row, onSave }: VacationYearRowProps): ReactNode { const { t } = useTranslation() - const [drafts, setDrafts] = useState>({}) + const [draft, setDraft] = useState({ totalDays: row.totalDays, savedDaysIn: row.savedDaysIn }) + const [status, setStatus] = useState('idle') + const dirtyRef = useRef(false) + const savedTimeoutRef = useRef | null>(null) useEffect(() => { - const next: Record = {} - for (const row of rows) { - next[row.year] = { totalDays: row.totalDays, savedDaysIn: row.savedDaysIn } - } - setDrafts(next) - }, [rows]) + if (dirtyRef.current) return + setDraft({ totalDays: row.totalDays, savedDaysIn: row.savedDaysIn }) + }, [row.totalDays, row.savedDaysIn]) - const updateDraft = (year: number, field: keyof Draft, value: number) => { - setDrafts((prev) => { - const current = prev[year] ?? { totalDays: 0, savedDaysIn: 0 } - return { ...prev, [year]: { ...current, [field]: value } } + useEffect(() => { + return () => { + if (savedTimeoutRef.current) clearTimeout(savedTimeoutRef.current) + } + }, []) + + const debouncedSave = useDebouncedCallback((totalDays: number, savedDaysIn: number) => { + setStatus('saving') + onSave(row.year, totalDays, savedDaysIn) + .then(() => { + dirtyRef.current = false + setStatus('saved') + if (savedTimeoutRef.current) clearTimeout(savedTimeoutRef.current) + savedTimeoutRef.current = setTimeout(() => setStatus('idle'), SAVED_INDICATOR_MS) + }) + .catch(() => { + // Leave `dirtyRef` set — a failed save must not let a stale server + // value creep back in over what the user still sees on screen. + setStatus('error') + }) + }, AUTOSAVE_DELAY_MS) + + const updateDraft = (field: keyof Draft, value: number) => { + dirtyRef.current = true + setStatus('idle') + setDraft((prev) => { + const next = { ...prev, [field]: value } + debouncedSave(next.totalDays, next.savedDaysIn) + return next }) } + return ( +
+
+ {row.year} + {row.isAssumed && ( + + {t('vacationDaysTable.assumedBadge')} + + )} +
+ + + + + + + {status === 'saving' && t('vacationDaysTable.savingStatus')} + {status === 'saved' && t('vacationDaysTable.savedStatus')} + {status === 'error' && t('vacationDaysTable.saveErrorStatus')} + +
+ ) +} + +export function VacationDaysTable({ rows, onSave, personalDataUuid }: VacationDaysTableProps): ReactNode { + const { t } = useTranslation() + if (rows.length === 0) { return

{t('vacationDaysTable.empty')}

} return ( -
- {rows.map((row) => { - const draft = drafts[row.year] ?? { totalDays: row.totalDays, savedDaysIn: row.savedDaysIn } - return ( -
-
- {row.year} - {row.isAssumed && ( - - {t('vacationDaysTable.assumedBadge')} - - )} -
- - - - - - -
- ) - })} +
+ {rows.map((row) => ( + + ))}
) } diff --git a/frontend/src/components/vacation-days/vacation-rows.css b/frontend/src/components/vacation-days/vacation-rows.css index 1771b40..695e68a 100644 --- a/frontend/src/components/vacation-days/vacation-rows.css +++ b/frontend/src/components/vacation-days/vacation-rows.css @@ -46,8 +46,19 @@ width: 100%; } -.vacation-rows__save { +.vacation-rows__status { align-self: center; + min-width: 4.5rem; + font-size: var(--fs-sm); + color: var(--c-text-muted); +} + +.vacation-rows__status--saved { + color: var(--c-success, var(--c-text-muted)); +} + +.vacation-rows__status--error { + color: var(--c-danger); } .vacation-rows__assumed-badge { diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 63b7d97..e46ceef 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -14,3 +14,6 @@ export { useDayOverrides } from './useDayOverrides' export { usePlannedDayExclusions } from './usePlannedDayExclusions' export { useCalendarDays } from './useCalendarDays' export { useScraperFetch } from './useScraperFetch' +export { useDebouncedCallback } from './useDebouncedCallback' +export { usePreschoolPlanningDays } from './usePreschoolPlanningDays' +export { usePreschoolPlanningDaysList } from './usePreschoolPlanningDaysList' diff --git a/frontend/src/hooks/useCalendarDays.test.ts b/frontend/src/hooks/useCalendarDays.test.ts index a59ecbe..0940ea8 100644 --- a/frontend/src/hooks/useCalendarDays.test.ts +++ b/frontend/src/hooks/useCalendarDays.test.ts @@ -2,12 +2,16 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useCalendarDays } from './useCalendarDays' import * as api from '@/services/api' +import { useWorkspace } from '@/context' +import { makeWorkspaceContextValue } from '../../tests/fixtures' vi.mock('@/services/api') +vi.mock('@/context') describe('useCalendarDays', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ planVersion: 0 })) }) const mockWindow = { @@ -112,6 +116,69 @@ describe('useCalendarDays', () => { expect(result.current.error).toBe('Failed to load calendar days') }) + it('refetches when planVersion changes (a day-override mutation elsewhere)', async () => { + vi.mocked(api.getCalendarDays).mockResolvedValue(mockWindow) + vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ planVersion: 0 })) + + const { rerender } = renderHook(() => useCalendarDays('ws-1', 2026, 4)) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + vi.mocked(api.getCalendarDays).mockClear() + vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ planVersion: 1 })) + rerender() + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(api.getCalendarDays).toHaveBeenCalledWith('ws-1', 2026, 4) + }) + + it('a planVersion bump refreshes via isRefreshing, never isLoading — the list must not blank', async () => { + vi.mocked(api.getCalendarDays).mockResolvedValue(mockWindow) + vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ planVersion: 0 })) + + const { result, rerender } = renderHook(() => useCalendarDays('ws-1', 2026, 4)) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(result.current.isLoading).toBe(false) + expect(result.current.isRefreshing).toBe(false) + + let resolveBump: (value: typeof mockWindow) => void = () => {} + vi.mocked(api.getCalendarDays).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveBump = resolve + }), + ) + + vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ planVersion: 1 })) + act(() => { + rerender() + }) + + await act(async () => { + await Promise.resolve() + }) + expect(result.current.isRefreshing).toBe(true) + expect(result.current.isLoading).toBe(false) + // The whole point: the red-days list rendered from `data` stays mounted + // (not blanked by a page-level `isLoading` gate) while this resolves. + expect(result.current.data).toEqual(mockWindow) + + await act(async () => { + resolveBump(mockWindow) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(result.current.isRefreshing).toBe(false) + expect(result.current.isLoading).toBe(false) + }) + it('reports isLoading true while the request is in flight', async () => { let resolveFetch: (value: typeof mockWindow) => void const pending = new Promise((resolve) => { diff --git a/frontend/src/hooks/useCalendarDays.ts b/frontend/src/hooks/useCalendarDays.ts index e901b52..3ea5bb1 100644 --- a/frontend/src/hooks/useCalendarDays.ts +++ b/frontend/src/hooks/useCalendarDays.ts @@ -26,16 +26,38 @@ * from a ref at call time makes every `refetch()` — however stale the * closure holding it is — always act on whatever is actually displayed * right now. + * + * Also subscribes to `WorkspaceContext.planVersion` (bumped by + * `useDayOverrides` on every override mutation — see `useAnalytics.ts` for + * the same pattern) so a day-override change made elsewhere invalidates + * this window instead of leaving stale shading/blob text on screen. A + * `planVersion` bump is by definition a background refresh — the toggle + * that bumped it (e.g. Ledig/Arbetsdag in Settings' red-days list, which is + * itself rendered from this hook) must not see its own list blank out while + * the window reloads. It routes through `isRefreshing`, same as the initial + * load routes through `isLoading` — see `useLeaveWishes.ts`'s doc comment + * for the general pattern. The initial load and an explicit + * `workspaceId`/`startYear`/`years` change (a real view change, e.g. + * paging to a different year) still block via `isLoading`; an explicit + * `refetch()` call (e.g. Calendar.tsx's post-sync refresh) also blocks, + * matching its pre-existing behavior. */ import { useCallback, useEffect, useRef, useState } from 'react' import * as api from '@/services/api' import { useLatestRequest } from './useLatestRequest' +import { useWorkspace } from '@/context' import type { CalendarWindow } from '@/types' interface UseCalendarDaysResult { data: CalendarWindow | null + /** True only while the initial load or a `workspaceId`/`startYear`/`years` + * change is in flight — safe to gate a full-page blocking spinner on. */ isLoading: boolean + /** True while a `planVersion`-triggered (or otherwise explicitly + * background) refresh is in flight. Never true at the same time as + * `isLoading`. */ + isRefreshing: boolean error: string | null refetch: () => Promise } @@ -47,8 +69,10 @@ export function useCalendarDays( ): UseCalendarDaysResult { const [data, setData] = useState(null) const [isLoading, setIsLoading] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) const [error, setError] = useState(null) const { start, isCurrent } = useLatestRequest() + const { planVersion } = useWorkspace() // Always holds the LATEST params, independent of which render's closure // ends up calling `fetchWindow` — see the module doc comment above. @@ -60,23 +84,28 @@ export function useCalendarDays( // SAME function — which reads `paramsRef` fresh on every invocation — // rather than a snapshot frozen to whatever year was current when that // caller's own closure was created. - const fetchWindow = useCallback(async () => { - const requestId = start() - const { workspaceId, startYear, years } = paramsRef.current - if (!workspaceId) return - setIsLoading(true) - setError(null) - try { - const result = await api.getCalendarDays(workspaceId, startYear, years) - if (!isCurrent(requestId)) return - setData(result) - } catch (err: unknown) { - if (!isCurrent(requestId)) return - setError(err instanceof Error ? err.message : 'Failed to load calendar days') - } finally { - if (isCurrent(requestId)) setIsLoading(false) - } - }, [start, isCurrent]) + const fetchWindow = useCallback( + async (options?: { background?: boolean }) => { + const requestId = start() + const { workspaceId, startYear, years } = paramsRef.current + if (!workspaceId) return + const background = options?.background ?? false + const setBusy = background ? setIsRefreshing : setIsLoading + setBusy(true) + setError(null) + try { + const result = await api.getCalendarDays(workspaceId, startYear, years) + if (!isCurrent(requestId)) return + setData(result) + } catch (err: unknown) { + if (!isCurrent(requestId)) return + setError(err instanceof Error ? err.message : 'Failed to load calendar days') + } finally { + if (isCurrent(requestId)) setBusy(false) + } + }, + [start, isCurrent], + ) // `fetchWindow` is deliberately stable (see above) — re-run only when the // actual params change, via the primitives directly, not the callback. @@ -85,5 +114,19 @@ export function useCalendarDays( // eslint-disable-next-line react-hooks/exhaustive-deps }, [workspaceId, startYear, years]) - return { data, isLoading, error, refetch: fetchWindow } + // Separate effect for `planVersion`: a bump means some OTHER hook instance + // mutated overrides, not a real view change, so it must refresh in the + // background (`isRefreshing`), never `isLoading`. Guarded so the first run + // (mount, alongside the effect above) doesn't double-fetch. + const isFirstPlanVersionRun = useRef(true) + useEffect(() => { + if (isFirstPlanVersionRun.current) { + isFirstPlanVersionRun.current = false + return + } + void fetchWindow({ background: true }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [planVersion]) + + return { data, isLoading, isRefreshing, error, refetch: fetchWindow } } diff --git a/frontend/src/hooks/useChildSettings.test.ts b/frontend/src/hooks/useChildSettings.test.ts index 8c5f969..4adfd9a 100644 --- a/frontend/src/hooks/useChildSettings.test.ts +++ b/frontend/src/hooks/useChildSettings.test.ts @@ -112,4 +112,37 @@ describe('useChildSettings', () => { expect(api.updateChildSettings).not.toHaveBeenCalled() }) + + it('sets isRefreshing (not isLoading) while update() background-refreshes', async () => { + vi.mocked(api.getChild).mockResolvedValue(mockChild) + vi.mocked(api.updateChildSettings).mockResolvedValue(mockChild) + + const { result } = renderHook(() => useChildSettings('child1')) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.isRefreshing).toBe(false) + + let resolveRefresh: (child: Child) => void = () => {} + vi.mocked(api.getChild).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve + }), + ) + + let updatePromise: Promise = Promise.resolve() + act(() => { + updatePromise = result.current.update({ birthDate: '2024-06-01' }) + }) + + await waitFor(() => expect(result.current.isRefreshing).toBe(true)) + expect(result.current.isLoading).toBe(false) + + await act(async () => { + resolveRefresh(mockChild) + await updatePromise + }) + + expect(result.current.isRefreshing).toBe(false) + expect(result.current.isLoading).toBe(false) + }) }) diff --git a/frontend/src/hooks/useChildSettings.ts b/frontend/src/hooks/useChildSettings.ts index e02b8ce..40962b7 100644 --- a/frontend/src/hooks/useChildSettings.ts +++ b/frontend/src/hooks/useChildSettings.ts @@ -1,7 +1,8 @@ /** * Fetches and updates the full Child record (birth date, preschool settings) for - * the settings page. Mirrors the useVacationDays pattern: typed api.* calls, - * { data, isLoading, error }, and a mutation that refreshes afterwards. + * the settings page. Follows the useLeaveWishes background/isRefreshing + * pattern: the initial load blocks via `isLoading`, a post-mutation refetch + * runs through `isRefreshing` so it never unmounts a form built from `data`. */ import { useCallback, useEffect, useState } from 'react' @@ -16,7 +17,12 @@ export interface ChildSettingsUpdatePayload { interface UseChildSettingsResult { data: Child | null + /** True only while the initial (or child-switch) load is in flight — safe + * to gate a full-page blocking spinner on. */ isLoading: boolean + /** True while a post-mutation background refresh is in flight. Never true + * at the same time as `isLoading`. */ + isRefreshing: boolean error: string | null update: (payload: ChildSettingsUpdatePayload) => Promise } @@ -24,28 +30,34 @@ interface UseChildSettingsResult { export function useChildSettings(childId: string | null): UseChildSettingsResult { const [data, setData] = useState(null) const [isLoading, setIsLoading] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) const [error, setError] = useState(null) const { start, isCurrent } = useLatestRequest() - const fetch = useCallback(async () => { - const requestId = start() - if (!childId) { - setData(null) - return - } - setIsLoading(true) - setError(null) - try { - const result = await api.getChild(childId) - if (!isCurrent(requestId)) return - setData(result) - } catch (err: unknown) { - if (!isCurrent(requestId)) return - setError(err instanceof Error ? err.message : 'Failed to load child') - } finally { - if (isCurrent(requestId)) setIsLoading(false) - } - }, [childId, start, isCurrent]) + const fetch = useCallback( + async (options?: { background?: boolean }) => { + const requestId = start() + if (!childId) { + setData(null) + return + } + const background = options?.background ?? false + const setBusy = background ? setIsRefreshing : setIsLoading + setBusy(true) + setError(null) + try { + const result = await api.getChild(childId) + if (!isCurrent(requestId)) return + setData(result) + } catch (err: unknown) { + if (!isCurrent(requestId)) return + setError(err instanceof Error ? err.message : 'Failed to load child') + } finally { + if (isCurrent(requestId)) setBusy(false) + } + }, + [childId, start, isCurrent], + ) useEffect(() => { void fetch() @@ -60,10 +72,10 @@ export function useChildSettings(childId: string | null): UseChildSettingsResult ? { preschool_planning_days_per_year: payload.preschoolPlanningDaysPerYear } : {}), }) - await fetch() + await fetch({ background: true }) }, [childId, fetch], ) - return { data, isLoading, error, update } + return { data, isLoading, isRefreshing, error, update } } diff --git a/frontend/src/hooks/useDayOverrides.test.ts b/frontend/src/hooks/useDayOverrides.test.ts index c387dd0..fe456aa 100644 --- a/frontend/src/hooks/useDayOverrides.test.ts +++ b/frontend/src/hooks/useDayOverrides.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { renderHook, waitFor } from '@testing-library/react' +import { renderHook, waitFor, act } from '@testing-library/react' import { useDayOverrides } from './useDayOverrides' import * as api from '@/services/api' import { useWorkspace } from '@/context' @@ -58,11 +58,13 @@ describe('useDayOverrides', () => { await waitFor(() => expect(result.current.isLoading).toBe(false)) expect(result.current.data).toEqual(mockOverrides) - await result.current.upsert({ - workspaceId: 'ws-1', - date: '2026-12-24', - isWorkingDay: true, - note: 'Test', + await act(async () => { + await result.current.upsert({ + workspaceId: 'ws-1', + date: '2026-12-24', + isWorkingDay: true, + note: 'Test', + }) }) expect(api.upsertDayOverride).toHaveBeenCalledWith({ @@ -80,7 +82,9 @@ describe('useDayOverrides', () => { await waitFor(() => expect(result.current.isLoading).toBe(false)) - await result.current.remove('do-1') + await act(async () => { + await result.current.remove('do-1') + }) expect(api.deleteDayOverride).toHaveBeenCalledWith('do-1') expect(api.listDayOverridesByWorkspace).toHaveBeenCalledTimes(2) @@ -106,4 +110,39 @@ describe('useDayOverrides', () => { await waitFor(() => expect(result.current.isLoading).toBe(false)) expect(result.current.error).toBe('Failed to load day overrides') }) + + it('sets isRefreshing (not isLoading) while a mutation background-refreshes the list', async () => { + const { result } = renderHook(() => useDayOverrides('ws-1')) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.isRefreshing).toBe(false) + + let resolveRefresh: (overrides: typeof mockOverrides) => void = () => {} + vi.mocked(api.listDayOverridesByWorkspace).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve + }), + ) + + let upsertPromise: Promise = Promise.resolve() + act(() => { + upsertPromise = result.current.upsert({ + workspaceId: 'ws-1', + date: '2026-12-24', + isWorkingDay: true, + note: 'Test', + }) + }) + + await waitFor(() => expect(result.current.isRefreshing).toBe(true)) + expect(result.current.isLoading).toBe(false) + + await act(async () => { + resolveRefresh(mockOverrides) + await upsertPromise + }) + + expect(result.current.isRefreshing).toBe(false) + expect(result.current.isLoading).toBe(false) + }) }) diff --git a/frontend/src/hooks/useDayOverrides.ts b/frontend/src/hooks/useDayOverrides.ts index 0555f97..3e7afe1 100644 --- a/frontend/src/hooks/useDayOverrides.ts +++ b/frontend/src/hooks/useDayOverrides.ts @@ -6,7 +6,13 @@ import type { DayOverride, DayOverrideCreateRequest } from '@/types' interface UseDayOverridesResult { data: DayOverride[] + /** True only while the initial (or workspace-switch) load is in flight — + * safe to gate a full-page blocking spinner on. */ isLoading: boolean + /** True while a post-mutation background refresh is in flight. Never true + * at the same time as `isLoading` — see `useLeaveWishes.ts`'s doc comment + * for why a mutation's refetch must not flip the initial-load flag. */ + isRefreshing: boolean error: string | null upsert: (req: DayOverrideCreateRequest) => Promise remove: (id: string) => Promise @@ -15,26 +21,32 @@ interface UseDayOverridesResult { export function useDayOverrides(workspaceId: string): UseDayOverridesResult { const [data, setData] = useState([]) const [isLoading, setIsLoading] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) const [error, setError] = useState(null) const { start, isCurrent } = useLatestRequest() const { bumpPlanVersion } = useWorkspace() - const fetch = useCallback(async () => { - const requestId = start() - if (!workspaceId) return - setIsLoading(true) - setError(null) - try { - const result = await api.listDayOverridesByWorkspace(workspaceId) - if (!isCurrent(requestId)) return - setData(result) - } catch (err: unknown) { - if (!isCurrent(requestId)) return - setError(err instanceof Error ? err.message : 'Failed to load day overrides') - } finally { - if (isCurrent(requestId)) setIsLoading(false) - } - }, [workspaceId, start, isCurrent]) + const fetch = useCallback( + async (options?: { background?: boolean }) => { + const requestId = start() + if (!workspaceId) return + const background = options?.background ?? false + const setBusy = background ? setIsRefreshing : setIsLoading + setBusy(true) + setError(null) + try { + const result = await api.listDayOverridesByWorkspace(workspaceId) + if (!isCurrent(requestId)) return + setData(result) + } catch (err: unknown) { + if (!isCurrent(requestId)) return + setError(err instanceof Error ? err.message : 'Failed to load day overrides') + } finally { + if (isCurrent(requestId)) setBusy(false) + } + }, + [workspaceId, start, isCurrent], + ) useEffect(() => { void fetch() @@ -43,7 +55,7 @@ export function useDayOverrides(workspaceId: string): UseDayOverridesResult { const upsert = useCallback( async (req: DayOverrideCreateRequest) => { await api.upsertDayOverride(req) - await fetch() + await fetch({ background: true }) // An override changes which days count as free/working, which feeds // the at-risk-days analytics — bump the shared invalidation counter // so other hook instances (e.g. useAnalytics) refetch. See @@ -56,11 +68,11 @@ export function useDayOverrides(workspaceId: string): UseDayOverridesResult { const remove = useCallback( async (id: string) => { await api.deleteDayOverride(id) - await fetch() + await fetch({ background: true }) bumpPlanVersion() }, [fetch, bumpPlanVersion], ) - return { data, isLoading, error, upsert, remove } + return { data, isLoading, isRefreshing, error, upsert, remove } } diff --git a/frontend/src/hooks/useDebouncedCallback.test.ts b/frontend/src/hooks/useDebouncedCallback.test.ts new file mode 100644 index 0000000..1525adb --- /dev/null +++ b/frontend/src/hooks/useDebouncedCallback.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useDebouncedCallback } from './useDebouncedCallback' + +describe('useDebouncedCallback', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('delays invocation by the given delay', () => { + const callback = vi.fn() + const { result } = renderHook(() => useDebouncedCallback(callback, 500)) + + act(() => { + result.current('a') + }) + expect(callback).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(499) + }) + expect(callback).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith('a') + }) + + it('collapses rapid successive calls into a single invocation with the last args', () => { + const callback = vi.fn() + const { result } = renderHook(() => useDebouncedCallback(callback, 500)) + + act(() => { + result.current('a') + vi.advanceTimersByTime(100) + result.current('b') + vi.advanceTimersByTime(100) + result.current('c') + }) + + expect(callback).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith('c') + }) + + it('always invokes the latest callback passed in, even for an already-pending call', () => { + const first = vi.fn() + const second = vi.fn() + const { result, rerender } = renderHook( + ({ cb }: { cb: (value: string) => void }) => useDebouncedCallback(cb, 500), + { initialProps: { cb: first } }, + ) + + act(() => { + result.current('a') + }) + + rerender({ cb: second }) + + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledWith('a') + }) + + it('flushes a pending call synchronously on unmount instead of losing it', () => { + const callback = vi.fn() + const { result, unmount } = renderHook(() => useDebouncedCallback(callback, 500)) + + act(() => { + result.current('a') + }) + expect(callback).not.toHaveBeenCalled() + + unmount() + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith('a') + }) + + it('does nothing on unmount when there is no pending call', () => { + const callback = vi.fn() + const { unmount } = renderHook(() => useDebouncedCallback(callback, 500)) + + unmount() + + expect(callback).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/hooks/useDebouncedCallback.ts b/frontend/src/hooks/useDebouncedCallback.ts new file mode 100644 index 0000000..328701d --- /dev/null +++ b/frontend/src/hooks/useDebouncedCallback.ts @@ -0,0 +1,58 @@ +import { useCallback, useEffect, useRef } from 'react' + +/** + * Debounces a callback: rapid successive calls collapse into a single + * invocation using the arguments of the LAST call, `delayMs` after that + * last call. Intended for autosave-on-edit fields (e.g. vacation-day totals) + * where a per-keystroke or per-year API call would be wasteful and a + * manual Save button is one click too many. + * + * Flushes (runs immediately, synchronously) any pending call on unmount — + * without this, a user who edits a field and immediately navigates away + * (unmounting the component) would silently lose that edit: the debounce + * timer never fires because it was cleared, and nothing ever calls the + * underlying save. + * + * The returned function's identity is stable across renders; it always + * invokes the LATEST `callback` passed in (via a ref), so callers don't + * need to memoize `callback` themselves or worry about stale closures. + */ +export function useDebouncedCallback( + callback: (...args: Args) => void, + delayMs: number, +): (...args: Args) => void { + const callbackRef = useRef(callback) + callbackRef.current = callback + + const timeoutRef = useRef | null>(null) + const pendingArgsRef = useRef(null) + + const flush = useCallback(() => { + if (timeoutRef.current === null) return + clearTimeout(timeoutRef.current) + timeoutRef.current = null + const args = pendingArgsRef.current + pendingArgsRef.current = null + if (args !== null) callbackRef.current(...args) + }, []) + + useEffect(() => { + // Flush, not discard: an edit made just before navigating away must + // still be saved. + return flush + }, [flush]) + + return useCallback( + (...args: Args) => { + pendingArgsRef.current = args + if (timeoutRef.current !== null) clearTimeout(timeoutRef.current) + timeoutRef.current = setTimeout(() => { + timeoutRef.current = null + const pending = pendingArgsRef.current + pendingArgsRef.current = null + if (pending !== null) callbackRef.current(...pending) + }, delayMs) + }, + [delayMs], + ) +} diff --git a/frontend/src/hooks/usePreschoolPlanningDays.test.ts b/frontend/src/hooks/usePreschoolPlanningDays.test.ts new file mode 100644 index 0000000..3a3d519 --- /dev/null +++ b/frontend/src/hooks/usePreschoolPlanningDays.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { usePreschoolPlanningDays } from './usePreschoolPlanningDays' +import * as api from '@/services/api' +import { makePreschoolPlanningDays } from '../../tests/fixtures' +import type { PreschoolPlanningDays } from '@/types' + +vi.mock('@/services/api') + +const mockRow = makePreschoolPlanningDays({ + id: 'ppd-1', + childId: 'child1', + year: 2026, + plannedDays: 5, + takenDays: null, + isDefault: false, +}) + +describe('usePreschoolPlanningDays', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(api.getPreschoolPlanningDaysYear).mockResolvedValue(mockRow) + vi.mocked(api.upsertPreschoolPlanningDaysPlanned).mockResolvedValue(mockRow) + vi.mocked(api.recordPreschoolPlanningDaysTaken).mockResolvedValue(mockRow) + }) + + it('initializes with null data and not loading when childId is null', () => { + const { result } = renderHook(() => usePreschoolPlanningDays(null, 2026)) + expect(result.current.data).toBeNull() + expect(result.current.isLoading).toBe(false) + }) + + it('does not fetch when childId is null', () => { + renderHook(() => usePreschoolPlanningDays(null, 2026)) + expect(api.getPreschoolPlanningDaysYear).not.toHaveBeenCalled() + }) + + it('fetches the (child, year) row on mount', async () => { + const { result } = renderHook(() => usePreschoolPlanningDays('child1', 2026)) + + expect(result.current.isLoading).toBe(true) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(api.getPreschoolPlanningDaysYear).toHaveBeenCalledWith('child1', 2026) + expect(result.current.data).toEqual(mockRow) + }) + + it('refetches when the year changes', async () => { + const { rerender } = renderHook( + ({ year }: { year: number }) => usePreschoolPlanningDays('child1', year), + { initialProps: { year: 2026 } }, + ) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + rerender({ year: 2027 }) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(api.getPreschoolPlanningDaysYear).toHaveBeenCalledWith('child1', 2026) + expect(api.getPreschoolPlanningDaysYear).toHaveBeenCalledWith('child1', 2027) + }) + + it('sets error when fetch fails with an Error', async () => { + vi.mocked(api.getPreschoolPlanningDaysYear).mockRejectedValueOnce(new Error('Network down')) + + const { result } = renderHook(() => usePreschoolPlanningDays('child1', 2026)) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.error).toBe('Network down') + expect(result.current.data).toBeNull() + }) + + it('falls back to a generic message when a non-Error value is thrown', async () => { + vi.mocked(api.getPreschoolPlanningDaysYear).mockRejectedValueOnce('boom') + + const { result } = renderHook(() => usePreschoolPlanningDays('child1', 2026)) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.error).toBe('Failed to load preschool planning days') + }) + + it('setPlanned() upserts plannedDays and refetches', async () => { + const { result } = renderHook(() => usePreschoolPlanningDays('child1', 2026)) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + await act(async () => { + await result.current.setPlanned(10) + }) + + expect(api.upsertPreschoolPlanningDaysPlanned).toHaveBeenCalledWith('child1', 2026, { + plannedDays: 10, + }) + expect(api.getPreschoolPlanningDaysYear).toHaveBeenCalledTimes(2) + }) + + it('recordTaken() records takenDays (including 0) and refetches', async () => { + const { result } = renderHook(() => usePreschoolPlanningDays('child1', 2026)) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + await act(async () => { + await result.current.recordTaken(0) + }) + + expect(api.recordPreschoolPlanningDaysTaken).toHaveBeenCalledWith('child1', 2026, { + takenDays: 0, + }) + expect(api.getPreschoolPlanningDaysYear).toHaveBeenCalledTimes(2) + }) + + it('setPlanned() and recordTaken() are no-ops when childId is null', async () => { + const { result } = renderHook(() => usePreschoolPlanningDays(null, 2026)) + + await act(async () => { + await result.current.setPlanned(5) + await result.current.recordTaken(2) + }) + + expect(api.upsertPreschoolPlanningDaysPlanned).not.toHaveBeenCalled() + expect(api.recordPreschoolPlanningDaysTaken).not.toHaveBeenCalled() + }) + + it('sets isRefreshing (not isLoading) while a mutation background-refreshes', async () => { + const { result } = renderHook(() => usePreschoolPlanningDays('child1', 2026)) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.isRefreshing).toBe(false) + + let resolveRefresh: (row: PreschoolPlanningDays) => void = () => {} + vi.mocked(api.getPreschoolPlanningDaysYear).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve + }), + ) + + let setPlannedPromise: Promise = Promise.resolve() + act(() => { + setPlannedPromise = result.current.setPlanned(10) + }) + + await waitFor(() => expect(result.current.isRefreshing).toBe(true)) + expect(result.current.isLoading).toBe(false) + + await act(async () => { + resolveRefresh(mockRow) + await setPlannedPromise + }) + + expect(result.current.isRefreshing).toBe(false) + expect(result.current.isLoading).toBe(false) + }) +}) diff --git a/frontend/src/hooks/usePreschoolPlanningDays.ts b/frontend/src/hooks/usePreschoolPlanningDays.ts new file mode 100644 index 0000000..9e955b3 --- /dev/null +++ b/frontend/src/hooks/usePreschoolPlanningDays.ts @@ -0,0 +1,98 @@ +/** + * One (child, year) row of "förskolans planeringsdagar" (preschool + * closure/planning days) — GET/PUT `/api/preschool-planning-days/*`. + * + * Follows the useLeaveWishes background/isRefreshing pattern: the initial + * (or child/year-switch) load blocks via `isLoading`, while a post-mutation + * refetch runs through `isRefreshing` so saving `plannedDays`/`takenDays` + * never unmounts whatever form is currently showing `data`. + * + * `data.isDefault === true` means no row is persisted yet for this + * (child, year) and the figures shown are the child's fallback default + * (`Child.preschoolPlanningDaysPerYear`) — `data.id` is `null` in that case. + * `data.takenDays === null` means "not recorded yet", distinct from `0` + * ("recorded as zero") — callers must preserve that distinction rather than + * defaulting a `null` to `0`. + */ + +import { useCallback, useEffect, useState } from 'react' +import * as api from '@/services/api' +import { useLatestRequest } from './useLatestRequest' +import type { PreschoolPlanningDays } from '@/types' + +interface UsePreschoolPlanningDaysResult { + data: PreschoolPlanningDays | null + /** True only while the initial (or child/year-switch) load is in flight — + * safe to gate a full-page blocking spinner on. */ + isLoading: boolean + /** True while a post-mutation background refresh is in flight. Never true + * at the same time as `isLoading`. */ + isRefreshing: boolean + error: string | null + /** Sets how many planning days are budgeted for this year. */ + setPlanned: (plannedDays: number) => Promise + /** Records how many planning days were actually taken this year. Pass `0` + * to record "recorded, none taken" — distinct from never calling this at + * all, which leaves `takenDays` at `null`. */ + recordTaken: (takenDays: number) => Promise +} + +export function usePreschoolPlanningDays( + childId: string | null, + year: number, +): UsePreschoolPlanningDaysResult { + const [data, setData] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) + const [error, setError] = useState(null) + const { start, isCurrent } = useLatestRequest() + + const fetch = useCallback( + async (options?: { background?: boolean }) => { + const requestId = start() + if (!childId) { + setData(null) + return + } + const background = options?.background ?? false + const setBusy = background ? setIsRefreshing : setIsLoading + setBusy(true) + setError(null) + try { + const result = await api.getPreschoolPlanningDaysYear(childId, year) + if (!isCurrent(requestId)) return + setData(result) + } catch (err: unknown) { + if (!isCurrent(requestId)) return + setError(err instanceof Error ? err.message : 'Failed to load preschool planning days') + } finally { + if (isCurrent(requestId)) setBusy(false) + } + }, + [childId, year, start, isCurrent], + ) + + useEffect(() => { + void fetch() + }, [fetch]) + + const setPlanned = useCallback( + async (plannedDays: number) => { + if (!childId) return + await api.upsertPreschoolPlanningDaysPlanned(childId, year, { plannedDays }) + await fetch({ background: true }) + }, + [childId, year, fetch], + ) + + const recordTaken = useCallback( + async (takenDays: number) => { + if (!childId) return + await api.recordPreschoolPlanningDaysTaken(childId, year, { takenDays }) + await fetch({ background: true }) + }, + [childId, year, fetch], + ) + + return { data, isLoading, isRefreshing, error, setPlanned, recordTaken } +} diff --git a/frontend/src/hooks/usePreschoolPlanningDaysList.test.ts b/frontend/src/hooks/usePreschoolPlanningDaysList.test.ts new file mode 100644 index 0000000..f3eb741 --- /dev/null +++ b/frontend/src/hooks/usePreschoolPlanningDaysList.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { usePreschoolPlanningDaysList } from './usePreschoolPlanningDaysList' +import * as api from '@/services/api' +import { makePreschoolPlanningDays } from '../../tests/fixtures' +import type { PreschoolPlanningDays } from '@/types' + +vi.mock('@/services/api') + +const row2025 = makePreschoolPlanningDays({ id: 'ppd-1', childId: 'child1', year: 2025 }) +const row2026 = makePreschoolPlanningDays({ id: 'ppd-2', childId: 'child1', year: 2026 }) +const mockRows = [row2025, row2026] + +describe('usePreschoolPlanningDaysList', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(api.listPreschoolPlanningDays).mockResolvedValue(mockRows) + }) + + it('initializes with an empty list and not loading when childId is null', () => { + const { result } = renderHook(() => usePreschoolPlanningDaysList(null)) + expect(result.current.data).toEqual([]) + expect(result.current.isLoading).toBe(false) + }) + + it('does not fetch when childId is null', () => { + renderHook(() => usePreschoolPlanningDaysList(null)) + expect(api.listPreschoolPlanningDays).not.toHaveBeenCalled() + }) + + it('fetches every persisted row for the child on mount', async () => { + const { result } = renderHook(() => usePreschoolPlanningDaysList('child1')) + + expect(result.current.isLoading).toBe(true) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(api.listPreschoolPlanningDays).toHaveBeenCalledWith('child1') + expect(result.current.data).toEqual(mockRows) + }) + + it('only returns real (persisted) rows — a year with no row is simply absent', async () => { + vi.mocked(api.listPreschoolPlanningDays).mockResolvedValueOnce([row2025]) + + const { result } = renderHook(() => usePreschoolPlanningDaysList('child1')) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.data).toEqual([row2025]) + expect(result.current.data.some((row) => row.year === 2026)).toBe(false) + }) + + it('sets error when fetch fails with an Error', async () => { + vi.mocked(api.listPreschoolPlanningDays).mockRejectedValueOnce(new Error('Network down')) + + const { result } = renderHook(() => usePreschoolPlanningDaysList('child1')) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.error).toBe('Network down') + expect(result.current.data).toEqual([]) + }) + + it('falls back to a generic message when a non-Error value is thrown', async () => { + vi.mocked(api.listPreschoolPlanningDays).mockRejectedValueOnce('boom') + + const { result } = renderHook(() => usePreschoolPlanningDaysList('child1')) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.error).toBe('Failed to load preschool planning days') + }) + + it('refresh() re-fetches, defaulting to a blocking (isLoading) refresh', async () => { + const { result } = renderHook(() => usePreschoolPlanningDaysList('child1')) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + await act(async () => { + await result.current.refresh() + }) + + expect(api.listPreschoolPlanningDays).toHaveBeenCalledTimes(2) + }) + + it('refresh({ background: true }) sets isRefreshing (not isLoading)', async () => { + const { result } = renderHook(() => usePreschoolPlanningDaysList('child1')) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.isRefreshing).toBe(false) + + let resolveRefresh: (rows: PreschoolPlanningDays[]) => void = () => {} + vi.mocked(api.listPreschoolPlanningDays).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve + }), + ) + + let refreshPromise: Promise = Promise.resolve() + act(() => { + refreshPromise = result.current.refresh({ background: true }) + }) + + await waitFor(() => expect(result.current.isRefreshing).toBe(true)) + expect(result.current.isLoading).toBe(false) + + await act(async () => { + resolveRefresh(mockRows) + await refreshPromise + }) + + expect(result.current.isRefreshing).toBe(false) + expect(result.current.isLoading).toBe(false) + }) +}) diff --git a/frontend/src/hooks/usePreschoolPlanningDaysList.ts b/frontend/src/hooks/usePreschoolPlanningDaysList.ts new file mode 100644 index 0000000..bf84f6c --- /dev/null +++ b/frontend/src/hooks/usePreschoolPlanningDaysList.ts @@ -0,0 +1,78 @@ +/** + * All persisted "förskolans planeringsdagar" rows for a child, across every + * year — `GET /api/preschool-planning-days/{child_id}`. Backs a dashboard + * view that needs a per-year reserve across the whole year list; use + * `usePreschoolPlanningDays(childId, year)` instead when editing a single + * year (e.g. Settings). + * + * IMPORTANT: unlike the per-year hook, this list endpoint returns only REAL + * (persisted) rows — a year with no row is simply absent from `data`, not + * synthesized with `isDefault: true`. Consumers must treat an absent year as + * "fall back to the child's default / not yet recorded", never as `0` — + * conflating "no row" with "zero planned/taken" would misrepresent a year + * nobody has looked at yet as a year with nothing to plan. + * + * Follows the useLeaveWishes background/isRefreshing pattern: the initial + * (or child-switch) load blocks via `isLoading`; `refresh()` (for a caller + * that just mutated a year through the per-year hook and wants this list + * back in sync) runs through `isRefreshing` so it doesn't unmount whatever + * is currently rendered from `data`. + */ + +import { useCallback, useEffect, useState } from 'react' +import * as api from '@/services/api' +import { useLatestRequest } from './useLatestRequest' +import type { PreschoolPlanningDays } from '@/types' + +interface UsePreschoolPlanningDaysListResult { + data: PreschoolPlanningDays[] + /** True only while the initial (or child-switch) load is in flight — safe + * to gate a full-page blocking spinner on. */ + isLoading: boolean + /** True while a background refresh (via `refresh({ background: true })`) + * is in flight. Never true at the same time as `isLoading`. */ + isRefreshing: boolean + error: string | null + refresh: (options?: { background?: boolean }) => Promise +} + +export function usePreschoolPlanningDaysList( + childId: string | null, +): UsePreschoolPlanningDaysListResult { + const [data, setData] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) + const [error, setError] = useState(null) + const { start, isCurrent } = useLatestRequest() + + const fetch = useCallback( + async (options?: { background?: boolean }) => { + const requestId = start() + if (!childId) { + setData([]) + return + } + const background = options?.background ?? false + const setBusy = background ? setIsRefreshing : setIsLoading + setBusy(true) + setError(null) + try { + const result = await api.listPreschoolPlanningDays(childId) + if (!isCurrent(requestId)) return + setData(result) + } catch (err: unknown) { + if (!isCurrent(requestId)) return + setError(err instanceof Error ? err.message : 'Failed to load preschool planning days') + } finally { + if (isCurrent(requestId)) setBusy(false) + } + }, + [childId, start, isCurrent], + ) + + useEffect(() => { + void fetch() + }, [fetch]) + + return { data, isLoading, isRefreshing, error, refresh: fetch } +} diff --git a/frontend/src/hooks/useVacationDays.test.ts b/frontend/src/hooks/useVacationDays.test.ts index 7439071..6a68cbf 100644 --- a/frontend/src/hooks/useVacationDays.test.ts +++ b/frontend/src/hooks/useVacationDays.test.ts @@ -81,4 +81,40 @@ describe('useVacationDays', () => { }) expect(api.getVacationDaysByWorkspace).toHaveBeenCalledTimes(2) }) + + it('sets isRefreshing (not isLoading) while a mutation background-refreshes the list', async () => { + const { result } = renderHook(() => useVacationDays('ws-1')) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.isRefreshing).toBe(false) + + let resolveRefresh: (rows: typeof mockRow[]) => void = () => {} + vi.mocked(api.getVacationDaysByWorkspace).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve + }), + ) + + let upsertPromise: Promise = Promise.resolve() + act(() => { + upsertPromise = result.current.upsert({ + personalDataUuid: 'pd-1', + workspaceId: 'ws-1', + year: 2027, + totalDays: 25, + savedDaysIn: 0, + }) + }) + + await waitFor(() => expect(result.current.isRefreshing).toBe(true)) + expect(result.current.isLoading).toBe(false) + + await act(async () => { + resolveRefresh([mockRow]) + await upsertPromise + }) + + expect(result.current.isRefreshing).toBe(false) + expect(result.current.isLoading).toBe(false) + }) }) diff --git a/frontend/src/hooks/useVacationDays.ts b/frontend/src/hooks/useVacationDays.ts index 7aa1f4f..a2a8a7a 100644 --- a/frontend/src/hooks/useVacationDays.ts +++ b/frontend/src/hooks/useVacationDays.ts @@ -1,11 +1,15 @@ /** - * REFERENCE HOOK — mirrors the VacationDays backend slice on the frontend. + * NOTE: this hook previously carried a "REFERENCE HOOK — pattern to copy" + * banner, but its original single-`isLoading` shape caused a real bug: every + * post-mutation `fetch()` flipped the same `isLoading` flag used for the + * initial load, so `Settings.tsx` (which ORs loading flags together to gate + * a full-page ``) unmounted the whole page on every save. * - * Pattern to copy for other hooks (usePlannedDays, useParentalLeave, etc.): - * 1. Call the typed api.* function. - * 2. Track { data, isLoading, error }. - * 3. Expose a mutation (upsert/create/delete) that calls the API and refreshes. - * 4. Write a test in tests/unit/hooks/ using vi.mock('@/services/api'). + * `useLeaveWishes.ts` is the hook to copy instead — see its doc comment for + * the background/isRefreshing split this hook now also follows: the initial + * (or workspace-switch) load blocks via `isLoading`, while a post-mutation + * refetch runs through `isRefreshing` so it never unmounts a form built from + * `data`. */ import { useCallback, useEffect, useState } from 'react' @@ -15,7 +19,12 @@ import type { VacationDays, VacationDaysCreateRequest } from '@/types' interface UseVacationDaysResult { data: VacationDays[] + /** True only while the initial (or workspace-switch) load is in flight — + * safe to gate a full-page blocking spinner on. */ isLoading: boolean + /** True while a post-mutation background refresh is in flight. Never true + * at the same time as `isLoading`. */ + isRefreshing: boolean error: string | null upsert: (req: VacationDaysCreateRequest) => Promise } @@ -23,25 +32,31 @@ interface UseVacationDaysResult { export function useVacationDays(workspaceId: string): UseVacationDaysResult { const [data, setData] = useState([]) const [isLoading, setIsLoading] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) const [error, setError] = useState(null) const { start, isCurrent } = useLatestRequest() - const fetch = useCallback(async () => { - const requestId = start() - if (!workspaceId) return - setIsLoading(true) - setError(null) - try { - const result = await api.getVacationDaysByWorkspace(workspaceId) - if (!isCurrent(requestId)) return - setData(result) - } catch (err: unknown) { - if (!isCurrent(requestId)) return - setError(err instanceof Error ? err.message : 'Failed to load vacation days') - } finally { - if (isCurrent(requestId)) setIsLoading(false) - } - }, [workspaceId, start, isCurrent]) + const fetch = useCallback( + async (options?: { background?: boolean }) => { + const requestId = start() + if (!workspaceId) return + const background = options?.background ?? false + const setBusy = background ? setIsRefreshing : setIsLoading + setBusy(true) + setError(null) + try { + const result = await api.getVacationDaysByWorkspace(workspaceId) + if (!isCurrent(requestId)) return + setData(result) + } catch (err: unknown) { + if (!isCurrent(requestId)) return + setError(err instanceof Error ? err.message : 'Failed to load vacation days') + } finally { + if (isCurrent(requestId)) setBusy(false) + } + }, + [workspaceId, start, isCurrent], + ) useEffect(() => { void fetch() @@ -50,10 +65,10 @@ export function useVacationDays(workspaceId: string): UseVacationDaysResult { const upsert = useCallback( async (req: VacationDaysCreateRequest) => { await api.upsertVacationDays(req) - await fetch() + await fetch({ background: true }) }, [fetch], ) - return { data, isLoading, error, upsert } + return { data, isLoading, isRefreshing, error, upsert } } diff --git a/frontend/src/i18n/en/dashboard.ts b/frontend/src/i18n/en/dashboard.ts index 05670e3..de551a0 100644 --- a/frontend/src/i18n/en/dashboard.ts +++ b/frontend/src/i18n/en/dashboard.ts @@ -46,12 +46,18 @@ export const dashboard = { 'yearRow.carriedOutRow': 'Saved for next year', 'yearRow.earned': 'Earned', 'yearRow.expiring': 'expiring {days}', + 'yearRow.expiringAgeDetail': '{days} passed the 5-year limit for saving vacation', + 'yearRow.expiringOverCapDetail': 'because only {saved} of {left} remaining days may be saved for next year', 'yearRow.expiringRow': 'Expiring', 'yearRow.fpLeft': 'PL left', 'yearRow.left': 'Left', 'yearRow.leftAtYearEnd': 'Left at year end', 'yearRow.parentalLeaveTitle': 'Parental leave', 'yearRow.plannedUse': 'Planned', + 'yearRow.preschoolNotRecorded': 'Not recorded yet', + 'yearRow.preschoolPlanned': 'Planning days set aside', + 'yearRow.preschoolTaken': 'Planning days taken', + 'yearRow.preschoolTitle': 'Preschool planning days', 'yearRow.usedThisYear': 'Used this year', 'yearRow.vacationLeft': 'Vacation left', 'yearRow.vacationTitle': 'Vacation', diff --git a/frontend/src/i18n/en/index.ts b/frontend/src/i18n/en/index.ts index a1d0b0e..d4454aa 100644 --- a/frontend/src/i18n/en/index.ts +++ b/frontend/src/i18n/en/index.ts @@ -4,6 +4,7 @@ import { auth } from './auth' import { balance } from './balance' import { calendar } from './calendar' import { dashboard } from './dashboard' +import { invite } from './invite' import { onboarding } from './onboarding' import { settings } from './settings' import { plan } from './plan' @@ -29,6 +30,7 @@ export const en = { ...balance, ...calendar, ...dashboard, + ...invite, ...onboarding, ...settings, ...plan, diff --git a/frontend/src/i18n/en/invite.ts b/frontend/src/i18n/en/invite.ts new file mode 100644 index 0000000..6fe13e2 --- /dev/null +++ b/frontend/src/i18n/en/invite.ts @@ -0,0 +1,53 @@ +/** English mirror of `sv/invite.ts` — see that file for context. */ +export const invite = { + // PartnerInvitePanel (Settings) + 'invite.panelTitle': 'Invite your partner', + 'invite.panelDescription': + 'Create a one-time link that connects your partner to the same workspace, plan and child.', + 'invite.generateButton': 'Create invite link', + 'invite.generatingButton': 'Creating…', + 'invite.linkLabel': 'Invite link', + 'invite.copyButton': 'Copy', + 'invite.copiedButton': 'Copied!', + 'invite.onceWarning': + 'This link is shown once and cannot be retrieved again — send or save it now.', + 'invite.pendingTitle': 'Pending invitations', + 'invite.noPending': 'No pending invitations right now.', + 'invite.expiresLabel': 'Expires {date}', + 'invite.revokeButton': 'Revoke', + 'invite.revokingButton': 'Revoking…', + 'invite.createError': 'Could not create the invitation. Please try again.', + 'invite.listError': 'Could not load invitations.', + 'invite.revokeError': 'Could not revoke the invitation.', + 'invite.partnerPlaceholderIntro': + "This spot is set up for your partner, but no one has logged in yet. Send an invite link below to connect them to the same plan and child — without losing what's already entered.", + + // Join page (/join/:token) + 'invite.joinTitle': 'Join the workspace', + 'invite.joinIntro': "You've been invited to join the same plan and child.", + 'invite.joinChecking': 'Checking invitation…', + 'invite.joinRedeeming': 'Connecting you to the workspace…', + 'invite.joinSuccess': "Done! You're now connected to the workspace.", + 'invite.goToApp': 'Go to the app', + 'invite.joinLoginTab': 'Log in', + 'invite.joinRegisterTab': 'Create account', + 'invite.joinNameLabel': 'Name', + 'invite.joinNamePlaceholder': 'Your name', + 'invite.joinEmailLabel': 'Email', + 'invite.joinEmailPlaceholder': 'name@example.com', + 'invite.joinPasswordLabel': 'Password', + 'invite.joinPasswordPlaceholder': 'At least 8 characters', + 'invite.joinSubmitLogin': 'Log in and join', + 'invite.joinSubmitRegister': 'Create account and join', + 'invite.joinSubmittingLogin': 'Logging in…', + 'invite.joinSubmittingRegister': 'Creating account…', + 'invite.joinGenericError': 'Something went wrong. Please try again.', + 'invite.joinMissingToken': "This link is missing a valid invitation code.", + 'invite.errorExpired': 'This invitation has expired. Ask your partner for a new link.', + 'invite.errorAlreadyRedeemed': 'This invitation has already been used or revoked.', + 'invite.errorWorkspaceFull': 'This workspace already has two logged-in accounts.', + 'invite.errorNotFound': 'The invitation could not be found. Check the link.', + 'invite.errorAlreadyMember': "You're already a member of this workspace.", + 'invite.errorDataConflict': + 'Your account already has its own saved data that cannot be merged automatically. Contact your partner.', +} as const diff --git a/frontend/src/i18n/en/settings.ts b/frontend/src/i18n/en/settings.ts index f303792..63df7f9 100644 --- a/frontend/src/i18n/en/settings.ts +++ b/frontend/src/i18n/en/settings.ts @@ -115,5 +115,13 @@ export const settings = { 'vacationDaysTable.assumedPrompt': 'No value is recorded for this year — the statutory 25 days has been assumed. Confirm or edit below.', 'vacationDaysTable.empty': 'No years to show yet.', 'vacationDaysTable.savedDaysHeader': 'Saved days carried in', + 'vacationDaysTable.savedStatus': 'Saved', + 'vacationDaysTable.saveErrorStatus': 'Could not save', + 'vacationDaysTable.savingStatus': 'Saving…', 'vacationDaysTable.vacationDaysHeader': 'Vacation days', + 'settings.preschoolPerYearHelp': 'Enter how many planning days were actually taken in a given year — it can differ from the estimate above.', + 'settings.preschoolPerYearTitle': 'Planning days per year', + 'settings.preschoolPlannedLabel': 'Planned days', + 'settings.preschoolTakenLabel': 'Days taken', + 'settings.preschoolTakenPlaceholder': 'Not recorded', } as const diff --git a/frontend/src/i18n/en/tips.ts b/frontend/src/i18n/en/tips.ts index 459dbcf..ec8bf1d 100644 --- a/frontend/src/i18n/en/tips.ts +++ b/frontend/src/i18n/en/tips.ts @@ -23,7 +23,7 @@ export const tips = { 'tip.stretchTimeAtHome.title': 'Stretch out your time at home', 'tip.useDubbeldagarStrategically.body': 'You both have parental-benefit leave planned on {date} but it isn\'t marked as a double day (dubbeldag).', 'tip.useDubbeldagarStrategically.title': 'Use a double day', - 'tip.useVacationBeforeExpiry.body': '{parent_name} has {saved_days} saved vacation days from {year} that risk being lost if not used.', + 'tip.useVacationBeforeExpiry.body': '{parent_name} has {lost_days} vacation days from {year} that risk being lost if not used.', 'tip.useVacationBeforeExpiry.title': 'Vacation days at risk of expiring', 'tip.wrapRedDaysWithVacation.body': '{parent_name} has parental-benefit leave planned near a holiday cluster ({cluster_start}–{cluster_end}). A vacation day around the public holidays can extend the break.', 'tip.wrapRedDaysWithVacation.title': 'Make the most of public holidays', diff --git a/frontend/src/i18n/sv/dashboard.ts b/frontend/src/i18n/sv/dashboard.ts index f0aede3..da08f6f 100644 --- a/frontend/src/i18n/sv/dashboard.ts +++ b/frontend/src/i18n/sv/dashboard.ts @@ -46,12 +46,18 @@ export const dashboard = { 'yearRow.carriedOutRow': 'Sparas till nästa år', 'yearRow.earned': 'Intjänade', 'yearRow.expiring': 'förfaller {days}', + 'yearRow.expiringAgeDetail': '{days} har passerat 5-årsgränsen för att spara semester', + 'yearRow.expiringOverCapDetail': 'eftersom bara {saved} av {left} kvarvarande dagar får sparas till nästa år', 'yearRow.expiringRow': 'Förfaller', 'yearRow.fpLeft': 'FP kvar', 'yearRow.left': 'Kvar', 'yearRow.leftAtYearEnd': 'Kvar vid årets slut', 'yearRow.parentalLeaveTitle': 'Föräldrapenning', 'yearRow.plannedUse': 'Planerade', + 'yearRow.preschoolNotRecorded': 'Ej registrerat', + 'yearRow.preschoolPlanned': 'Planeringsdagar avsatta', + 'yearRow.preschoolTaken': 'Planeringsdagar uttagna', + 'yearRow.preschoolTitle': 'Förskolans planeringsdagar', 'yearRow.usedThisYear': 'Använda i år', 'yearRow.vacationLeft': 'Semester kvar', 'yearRow.vacationTitle': 'Semester', diff --git a/frontend/src/i18n/sv/index.ts b/frontend/src/i18n/sv/index.ts index da1e7e7..6dd599a 100644 --- a/frontend/src/i18n/sv/index.ts +++ b/frontend/src/i18n/sv/index.ts @@ -3,6 +3,7 @@ import { auth } from './auth' import { balance } from './balance' import { calendar } from './calendar' import { dashboard } from './dashboard' +import { invite } from './invite' import { onboarding } from './onboarding' import { settings } from './settings' import { plan } from './plan' @@ -26,6 +27,7 @@ export const sv = { ...balance, ...calendar, ...dashboard, + ...invite, ...onboarding, ...settings, ...plan, diff --git a/frontend/src/i18n/sv/invite.ts b/frontend/src/i18n/sv/invite.ts new file mode 100644 index 0000000..ced2a6e --- /dev/null +++ b/frontend/src/i18n/sv/invite.ts @@ -0,0 +1,59 @@ +/** + * Partner invite flow — generating/managing invite links (Settings) and + * redeeming one (`/join/:token`). Kept out of `settings.ts`: another agent + * owns that namespace and edits it concurrently. + */ +export const invite = { + // PartnerInvitePanel (Settings) + 'invite.panelTitle': 'Bjud in din partner', + 'invite.panelDescription': + 'Skapa en engångslänk som kopplar din partner till samma arbetsyta, plan och barn.', + 'invite.generateButton': 'Skapa inbjudningslänk', + 'invite.generatingButton': 'Skapar…', + 'invite.linkLabel': 'Inbjudningslänk', + 'invite.copyButton': 'Kopiera', + 'invite.copiedButton': 'Kopierad!', + 'invite.onceWarning': + 'Länken visas bara här och nu — den går inte att se igen. Skicka eller spara den direkt.', + 'invite.pendingTitle': 'Väntande inbjudningar', + 'invite.noPending': 'Inga väntande inbjudningar just nu.', + 'invite.expiresLabel': 'Går ut {date}', + 'invite.revokeButton': 'Återkalla', + 'invite.revokingButton': 'Återkallar…', + 'invite.createError': 'Det gick inte att skapa inbjudan. Försök igen.', + 'invite.listError': 'Det gick inte att hämta inbjudningar.', + 'invite.revokeError': 'Det gick inte att återkalla inbjudan.', + // Corrected replacement for the misleading `settings.partnerLinkedIntro`, + // which implies an account link even for an unclaimed placeholder member. + 'invite.partnerPlaceholderIntro': + 'Den här platsen är förberedd åt din partner, men ingen har loggat in än. Skicka en inbjudningslänk nedan så kopplas de till samma plan och barn — utan att förlora det som redan är ifyllt.', + + // Join page (/join/:token) + 'invite.joinTitle': 'Gå med i arbetsytan', + 'invite.joinIntro': 'Du har blivit inbjuden att gå med i samma plan och barn.', + 'invite.joinChecking': 'Kontrollerar inbjudan…', + 'invite.joinRedeeming': 'Kopplar dig till arbetsytan…', + 'invite.joinSuccess': 'Klart! Du är nu kopplad till arbetsytan.', + 'invite.goToApp': 'Gå till appen', + 'invite.joinLoginTab': 'Logga in', + 'invite.joinRegisterTab': 'Skapa konto', + 'invite.joinNameLabel': 'Namn', + 'invite.joinNamePlaceholder': 'Ditt namn', + 'invite.joinEmailLabel': 'E-post', + 'invite.joinEmailPlaceholder': 'namn@exempel.se', + 'invite.joinPasswordLabel': 'Lösenord', + 'invite.joinPasswordPlaceholder': 'Minst 8 tecken', + 'invite.joinSubmitLogin': 'Logga in och gå med', + 'invite.joinSubmitRegister': 'Skapa konto och gå med', + 'invite.joinSubmittingLogin': 'Loggar in…', + 'invite.joinSubmittingRegister': 'Skapar konto…', + 'invite.joinGenericError': 'Något gick fel. Försök igen.', + 'invite.joinMissingToken': 'Den här länken saknar en giltig inbjudningskod.', + 'invite.errorExpired': 'Den här inbjudan har gått ut. Be din partner skapa en ny länk.', + 'invite.errorAlreadyRedeemed': 'Den här inbjudan har redan använts eller återkallats.', + 'invite.errorWorkspaceFull': 'Den här arbetsytan har redan två inloggade konton.', + 'invite.errorNotFound': 'Inbjudan kunde inte hittas. Kontrollera länken.', + 'invite.errorAlreadyMember': 'Du är redan medlem i den här arbetsytan.', + 'invite.errorDataConflict': + 'Ditt konto har redan egna sparade uppgifter som inte kan slås ihop automatiskt. Kontakta din partner.', +} as const diff --git a/frontend/src/i18n/sv/settings.ts b/frontend/src/i18n/sv/settings.ts index 284363a..c76e7a7 100644 --- a/frontend/src/i18n/sv/settings.ts +++ b/frontend/src/i18n/sv/settings.ts @@ -115,5 +115,13 @@ export const settings = { 'vacationDaysTable.assumedPrompt': 'Inget värde är registrerat för detta år — 25 dagar (lagstadgat) har antagits. Bekräfta eller ändra nedan.', 'vacationDaysTable.empty': 'Inga år att visa ännu.', 'vacationDaysTable.savedDaysHeader': 'Sparade dagar in', + 'vacationDaysTable.savedStatus': 'Sparat', + 'vacationDaysTable.saveErrorStatus': 'Det gick inte att spara', + 'vacationDaysTable.savingStatus': 'Sparar…', 'vacationDaysTable.vacationDaysHeader': 'Semesterdagar', + 'settings.preschoolPerYearHelp': 'Ange hur många planeringsdagar som faktiskt togs ut ett visst år — det kan skilja sig från det uppskattade antalet ovan.', + 'settings.preschoolPerYearTitle': 'Planeringsdagar per år', + 'settings.preschoolPlannedLabel': 'Planerade dagar', + 'settings.preschoolTakenLabel': 'Uttagna dagar', + 'settings.preschoolTakenPlaceholder': 'Ej angivet', } as const \ No newline at end of file diff --git a/frontend/src/i18n/sv/tips.ts b/frontend/src/i18n/sv/tips.ts index 8530088..cc00fb3 100644 --- a/frontend/src/i18n/sv/tips.ts +++ b/frontend/src/i18n/sv/tips.ts @@ -23,7 +23,7 @@ export const tips = { 'tip.stretchTimeAtHome.title': 'Sträck ut tiden hemma', 'tip.useDubbeldagarStrategically.body': 'Ni har båda föräldrapenning planerad {date} men den är inte markerad som dubbeldag.', 'tip.useDubbeldagarStrategically.title': 'Använd en dubbeldag', - 'tip.useVacationBeforeExpiry.body': '{parent_name} har {saved_days} sparade semesterdagar från {year} som riskerar att gå förlorade om de inte används.', + 'tip.useVacationBeforeExpiry.body': '{parent_name} har {lost_days} semesterdagar från {year} som riskerar att gå förlorade om de inte används.', 'tip.useVacationBeforeExpiry.title': 'Semesterdagar riskerar att gå förlorade', 'tip.wrapRedDaysWithVacation.body': '{parent_name} har föräldrapenning planerad nära en klämperiod ({cluster_start}–{cluster_end}). En semesterdag runt de röda dagarna kan ge en längre sammanhängande ledighet.', 'tip.wrapRedDaysWithVacation.title': 'Utnyttja röda dagar', diff --git a/frontend/src/pages/Dashboard.test.tsx b/frontend/src/pages/Dashboard.test.tsx index ee9ad70..183c0ae 100644 --- a/frontend/src/pages/Dashboard.test.tsx +++ b/frontend/src/pages/Dashboard.test.tsx @@ -16,6 +16,8 @@ import { makeOptimizationTipsResult, makeLeaveWishesResult, makePlanProposalResult, + makePreschoolPlanningDaysListResult, + makePreschoolPlanningDays, makePlanProposal, makeSurplusAdvice, makeWindowOutcome, @@ -145,6 +147,7 @@ describe('Dashboard', () => { vi.mocked(hooks.useOptimizationTips).mockReturnValue(makeOptimizationTipsResult()) vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult()) vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult()) + vi.mocked(hooks.usePreschoolPlanningDaysList).mockReturnValue(makePreschoolPlanningDaysListResult({ data: [] })) // Default: both parents have a saved balance, so nothing is "estimated". vi.mocked(useParentalLeaves).mockReturnValue( makeParentalLeavesResult({ @@ -365,6 +368,21 @@ describe('Dashboard', () => { expect(screen.getByTestId('year-row-2024-vacation-pd-2')).toHaveTextContent('Semester kvar 20 · sparas 5') }) + it('feeds the preschool planning days list into the year rows — present year shows figures, absent year stays a dash', () => { + vi.mocked(hooks.useAnalytics).mockReturnValue(coupleAnalytics()) + vi.mocked(hooks.usePreschoolPlanningDaysList).mockReturnValue( + makePreschoolPlanningDaysListResult({ + data: [makePreschoolPlanningDays({ childId: 'c-1', year: 2024, plannedDays: 4, takenDays: 2, isDefault: false })], + }), + ) + renderWithRouter() + expect(screen.getByTestId('year-row-2024-preschool-planned')).toHaveTextContent('4') + expect(screen.getByTestId('year-row-2024-preschool-taken')).toHaveTextContent('2') + // 2025 has no row in `data` — must render as unknown ("–"), never 0. + expect(screen.getByTestId('year-row-2025-preschool-planned')).toHaveTextContent('–') + expect(screen.getByTestId('year-row-2025-preschool-taken')).toHaveTextContent('–') + }) + it('navigates to the calendar at the clicked band\'s date', async () => { const user = userEvent.setup() vi.mocked(hooks.usePlannedDays).mockReturnValue( diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 089bb51..a8dafcf 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -6,6 +6,7 @@ import { useLeaveWishes, usePlanProposal, usePlannedDays, + usePreschoolPlanningDaysList, useWorkspaceMembers, useOptimizationTips, } from '@/hooks' @@ -69,6 +70,10 @@ export function Dashboard(): ReactNode { selectedChildId ?? '' ) const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(selectedChildId ?? undefined) + // Supplementary to the page's main figures — never gates the loading + // skeleton; an empty array while it loads renders the same "–" as an + // absent year, which is the correct fallback either way. + const { data: preschoolPlanningDays } = usePreschoolPlanningDaysList(selectedChildId ?? null) const { data: members, isLoading: membersLoading, refresh: refreshMembers } = useWorkspaceMembers(workspace?.id ?? null) const { data: tips, dismiss: dismissTip } = useOptimizationTips(workspace?.id ?? null) const { data: wishes, isLoading: wishesLoading } = useLeaveWishes(workspace?.id ?? null) @@ -268,6 +273,7 @@ export function Dashboard(): ReactNode { endDate={fourthBirthday} outcomes={proposal?.outcomes ?? []} onBandClick={handleBandClick} + preschoolPlanningDays={preschoolPlanningDays} /> )} diff --git a/frontend/src/pages/Join.test.tsx b/frontend/src/pages/Join.test.tsx new file mode 100644 index 0000000..c7269d1 --- /dev/null +++ b/frontend/src/pages/Join.test.tsx @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { Join } from './Join' +import { useAuth } from '@/context/AuthContext' +import { useWorkspace } from '@/context/WorkspaceContext' +import { redeemInvitation } from '@/services/api' + +vi.mock('@/context/AuthContext') +vi.mock('@/context/WorkspaceContext') +vi.mock('@/services/api', () => ({ + redeemInvitation: vi.fn(), +})) + +const mockNavigate = vi.fn() +let mockParams: { token?: string } = { token: 'raw-token-1' } +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + useNavigate: () => mockNavigate, + useParams: () => mockParams, + } +}) + +const mockUseAuth = vi.mocked(useAuth) +const mockUseWorkspace = vi.mocked(useWorkspace) +const mockRedeemInvitation = vi.mocked(redeemInvitation) + +function authValue(overrides: Partial> = {}) { + return { + accessToken: null, + isAuthenticated: false, + isLoading: false, + login: vi.fn(), + register: vi.fn(), + logout: vi.fn(), + ...overrides, + } +} + +function workspaceValue(overrides: Partial> = {}) { + return { + workspace: null, + isLoading: false, + error: null, + workspaceId: null, + setWorkspaceId: vi.fn(), + refetch: vi.fn(), + children: [], + selectedChildId: null, + selectChild: vi.fn(), + personalDataUuid: null, + parentName: null, + refreshContext: vi.fn(), + planVersion: 0, + bumpPlanVersion: vi.fn(), + ...overrides, + } +} + +describe('Join', () => { + beforeEach(() => { + vi.clearAllMocks() + mockParams = { token: 'raw-token-1' } + mockUseAuth.mockReturnValue(authValue()) + mockUseWorkspace.mockReturnValue(workspaceValue()) + }) + + it('shows a missing-token error when the route has no token', () => { + mockParams = {} + render() + expect(screen.getByTestId('join-error')).toHaveTextContent('saknar en giltig inbjudningskod') + }) + + it('redeems immediately when already authenticated, then navigates home', async () => { + mockRedeemInvitation.mockResolvedValue({ + id: 'inv-1', + workspaceId: 'ws-1', + status: 'accepted', + expiresAt: '2025-01-08T00:00:00Z', + createdAt: '2025-01-01T00:00:00Z', + }) + const refreshContext = vi.fn().mockResolvedValue(undefined) + mockUseAuth.mockReturnValue(authValue({ isAuthenticated: true })) + mockUseWorkspace.mockReturnValue(workspaceValue({ refreshContext })) + + render() + + await waitFor(() => expect(mockRedeemInvitation).toHaveBeenCalledWith('raw-token-1')) + await waitFor(() => expect(refreshContext).toHaveBeenCalled()) + expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true }) + }) + + it('shows a distinct message for an expired invitation', async () => { + mockRedeemInvitation.mockRejectedValue(new Error('Invitation has expired')) + mockUseAuth.mockReturnValue(authValue({ isAuthenticated: true })) + + render() + + expect(await screen.findByTestId('join-error')).toHaveTextContent('gått ut') + }) + + it('shows a distinct message for an already-redeemed invitation', async () => { + mockRedeemInvitation.mockRejectedValue(new Error('Invitation is accepted')) + mockUseAuth.mockReturnValue(authValue({ isAuthenticated: true })) + + render() + + expect(await screen.findByTestId('join-error')).toHaveTextContent('redan använts eller återkallats') + }) + + it('shows a distinct message when the workspace already has two real logins', async () => { + mockRedeemInvitation.mockRejectedValue( + new Error('This workspace already has its maximum number of members'), + ) + mockUseAuth.mockReturnValue(authValue({ isAuthenticated: true })) + + render() + + expect(await screen.findByTestId('join-error')).toHaveTextContent('två inloggade konton') + }) + + it('renders the registration form by default when logged out', () => { + render() + expect(screen.getByTestId('join-tab-register')).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByPlaceholderText('Ditt namn')).toBeInTheDocument() + }) + + it('registers with the invite token and navigates home without a second redeem call', async () => { + const register = vi.fn().mockResolvedValue(undefined) + const refreshContext = vi.fn().mockResolvedValue(undefined) + mockUseAuth.mockReturnValue(authValue({ register })) + mockUseWorkspace.mockReturnValue(workspaceValue({ refreshContext })) + + render() + fireEvent.change(screen.getByPlaceholderText('Ditt namn'), { target: { value: 'Alex' } }) + fireEvent.change(screen.getByTestId('join-email'), { target: { value: 'a@b.com' } }) + fireEvent.change(screen.getByTestId('join-password'), { target: { value: 'secret123' } }) + fireEvent.click(screen.getByTestId('join-submit')) + + await waitFor(() => + expect(register).toHaveBeenCalledWith({ + email: 'a@b.com', + password: 'secret123', + displayName: 'Alex', + inviteToken: 'raw-token-1', + }), + ) + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true })) + expect(mockRedeemInvitation).not.toHaveBeenCalled() + }) + + it('logs in via the login tab, then redeems the invitation once authenticated', async () => { + const login = vi.fn().mockResolvedValue(undefined) + mockRedeemInvitation.mockResolvedValue({ + id: 'inv-1', + workspaceId: 'ws-1', + status: 'accepted', + expiresAt: '2025-01-08T00:00:00Z', + createdAt: '2025-01-01T00:00:00Z', + }) + mockUseAuth.mockReturnValue(authValue({ login })) + + const { rerender } = render() + + fireEvent.click(screen.getByTestId('join-tab-login')) + fireEvent.change(screen.getByTestId('join-email'), { target: { value: 'a@b.com' } }) + fireEvent.change(screen.getByTestId('join-password'), { target: { value: 'secret123' } }) + fireEvent.click(screen.getByTestId('join-submit')) + + await waitFor(() => expect(login).toHaveBeenCalledWith({ email: 'a@b.com', password: 'secret123' })) + + // Simulate the isAuthenticated flip AuthContext would produce after a + // real login succeeds — Join's effect reacts to it and redeems. + mockUseAuth.mockReturnValue(authValue({ isAuthenticated: true, login })) + rerender() + + await waitFor(() => expect(mockRedeemInvitation).toHaveBeenCalledWith('raw-token-1')) + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true })) + }) +}) diff --git a/frontend/src/pages/Join.tsx b/frontend/src/pages/Join.tsx new file mode 100644 index 0000000..3e8c976 --- /dev/null +++ b/frontend/src/pages/Join.tsx @@ -0,0 +1,242 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { useAuth } from '@/context/AuthContext' +import { useWorkspace } from '@/context/WorkspaceContext' +import { redeemInvitation } from '@/services/api' +import { useTranslation, type TranslationKey } from '@/i18n' +import { LanguageToggle } from '@/components/layout/LanguageToggle' +import './join.css' + +type Phase = 'checking' | 'form' | 'redeeming' | 'error' | 'done' +type Mode = 'login' | 'register' + +/** Maps a redeem/register failure's backend message to a translated, + * distinct reason. The backend (see `InvitationError` call sites in + * `workspace_service.py`/`auth_service.py`) only carries this as plain + * English `detail` text (no structured `message_key` yet), so matching on + * substrings is the only signal available from the frontend today. */ +function classifyInviteErrorKey(err: unknown): TranslationKey { + const message = err instanceof Error ? err.message.toLowerCase() : '' + if (message.includes('expired')) return 'invite.errorExpired' + if (message.includes('maximum number of members')) return 'invite.errorWorkspaceFull' + if (message.includes('revoked') || message.includes('accepted')) return 'invite.errorAlreadyRedeemed' + if (message.includes('already belong')) return 'invite.errorAlreadyMember' + if (message.includes('cannot be merged')) return 'invite.errorDataConflict' + if (message.includes('not found')) return 'invite.errorNotFound' + return 'invite.joinGenericError' +} + +/** + * `/join/:token` — the partner-invite landing page. Handles all three entry + * states: already logged in (redeem immediately), has an account but logged + * out (log in, then redeem), or no account (register with the token, which + * joins server-side in the same call — no throwaway workspace is created). + */ +export function Join() { + const { token } = useParams<{ token: string }>() + const { isAuthenticated, isLoading: authLoading, login, register } = useAuth() + const { refreshContext } = useWorkspace() + const { t } = useTranslation() + const navigate = useNavigate() + + const [phase, setPhase] = useState('checking') + const [errorKey, setErrorKey] = useState(null) + const [mode, setMode] = useState('register') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [displayName, setDisplayName] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + // Guards against redeeming twice: once from a submit handler and again + // from the effect below reacting to the resulting isAuthenticated flip. + const attemptedRef = useRef(false) + + const redeemAndGo = useCallback( + async (rawToken: string) => { + setPhase('redeeming') + setErrorKey(null) + try { + await redeemInvitation(rawToken) + await refreshContext() + navigate('/', { replace: true }) + } catch (err: unknown) { + setErrorKey(classifyInviteErrorKey(err)) + setPhase('error') + } + }, + [refreshContext, navigate], + ) + + useEffect(() => { + if (authLoading || attemptedRef.current) return + if (!token) { + setErrorKey('invite.joinMissingToken') + setPhase('error') + return + } + if (isAuthenticated) { + attemptedRef.current = true + void redeemAndGo(token) + } else { + setPhase('form') + } + }, [authLoading, isAuthenticated, token, redeemAndGo]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!token) return + setErrorKey(null) + setIsSubmitting(true) + try { + if (mode === 'register') { + // register() joins the inviting workspace directly server-side — + // marking attemptedRef here stops the effect above from also + // calling redeemInvitation once isAuthenticated flips true, which + // would otherwise hit "already accepted" against an invite this + // call just consumed. + attemptedRef.current = true + await register({ email, password, displayName, inviteToken: token }) + await refreshContext() + navigate('/', { replace: true }) + } else { + // login() only signs in; the effect above reacts to the resulting + // isAuthenticated flip and performs the actual redeem. + await login({ email, password }) + } + } catch (err: unknown) { + setErrorKey(classifyInviteErrorKey(err)) + setPhase('form') + } finally { + setIsSubmitting(false) + } + } + + const switchMode = (next: Mode) => { + setMode(next) + setErrorKey(null) + } + + return ( +
+
+ +
+
+ +

{t('invite.joinTitle')}

+

{t('invite.joinIntro')}

+ + {(phase === 'checking' || phase === 'redeeming') && ( +

+ {phase === 'checking' ? t('invite.joinChecking') : t('invite.joinRedeeming')} +

+ )} + + {phase === 'error' && ( + <> +

+ {errorKey ? t(errorKey) : t('invite.joinGenericError')} +

+ {isAuthenticated && ( + + )} + + )} + + {phase === 'form' && ( + <> +
+ + +
+
void handleSubmit(e)}> + {mode === 'register' && ( +
+ + setDisplayName(e.target.value)} + /> +
+ )} +
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+ {errorKey && ( +

+ {t(errorKey)} +

+ )} + +
+ + )} +
+
+ ) +} diff --git a/frontend/src/pages/Settings.test.tsx b/frontend/src/pages/Settings.test.tsx index f1b8dab..d8cd13c 100644 --- a/frontend/src/pages/Settings.test.tsx +++ b/frontend/src/pages/Settings.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react' import { Settings } from './Settings' import * as hooks from '@/hooks' import { useWorkspace } from '@/context' @@ -8,6 +8,7 @@ import { ApiRequestError } from '@/services/errors' import { LanguageProvider } from '@/i18n' import type { BalanceFlowProps } from '@/components/balance/BalanceFlow' import type { CalendarExceptionsProps } from '@/components/calendar/CalendarExceptions' +import type { PartnerInvitePanelProps } from '@/components/settings/PartnerInvitePanel' import { makeWorkspaceContextValue, makeWorkspaceMembersResult, @@ -16,6 +17,7 @@ import { makeChildSettingsResult, makeDayOverridesResult, makeCalendarDaysResult, + makePreschoolPlanningDaysResult, } from '../../tests/fixtures' vi.mock('@/hooks') @@ -49,6 +51,16 @@ vi.mock('@/components/calendar/CalendarExceptions', () => ({ }, })) +// `PartnerInvitePanel` owns its own test suite (PartnerInvitePanel.test.tsx) +// — Settings only needs to prove it's mounted with the right workspace id. +let lastPartnerInvitePanelProps: PartnerInvitePanelProps | null = null +vi.mock('@/components/settings/PartnerInvitePanel', () => ({ + PartnerInvitePanel: (props: PartnerInvitePanelProps) => { + lastPartnerInvitePanelProps = props + return
+ }, +})) + function renderSettings() { return render() } @@ -129,6 +141,7 @@ describe('Settings', () => { vi.clearAllMocks() lastBalanceFlowProps = null lastCalendarExceptionsProps = null + lastPartnerInvitePanelProps = null mockUpsertVacationDays = vi.fn().mockResolvedValue(undefined) mockUpdateChildDetails = vi.fn().mockResolvedValue(undefined) mockUpsertDayOverride = vi.fn().mockResolvedValue(undefined) @@ -182,6 +195,15 @@ describe('Settings', () => { data: mockCalendarWindow, }) ) + vi.mocked(hooks.usePreschoolPlanningDays).mockReturnValue(makePreschoolPlanningDaysResult()) + // `useDebouncedCallback` is a real hook exercised end-to-end by its own + // test suite and by `VacationDaysTable.test.tsx` — here it's mocked to + // fire immediately (no timers to juggle) since these tests only care + // that Settings wires the right save handler through, not about the + // debounce timing itself. + vi.mocked(hooks.useDebouncedCallback).mockImplementation( + ((callback: (...args: unknown[]) => void) => callback) as typeof hooks.useDebouncedCallback, + ) vi.mocked(api.updateParentSettings).mockResolvedValue({ uuid: 'pd-1', name: 'Test Parent', @@ -255,12 +277,12 @@ describe('Settings', () => { expect(totalInput.value).toBe('25') }) - it('saves the edited year with its total days when Spara is clicked', async () => { + it('autosaves an edited year with its total days, with no Save button', async () => { renderSettings() const year = CURRENT_YEAR + 1 + expect(screen.queryByTestId(`vacation-save-${year}`)).not.toBeInTheDocument() const totalInput = screen.getByTestId(`vacation-total-days-${year}`) fireEvent.change(totalInput, { target: { value: '30' } }) - fireEvent.click(screen.getByTestId(`vacation-save-${year}`)) await waitFor(() => expect(mockUpsertVacationDays).toHaveBeenCalledWith({ @@ -273,14 +295,77 @@ describe('Settings', () => { ) }) - it('does not disable the vacation table while the family panel is saving', async () => { + it('does not disable the vacation inputs while the family panel is saving', async () => { renderSettings() fireEvent.click(screen.getByTestId('save-family-button')) - // The vacation save button must stay enabled — it has its own - // `isSavingVacation` flag, independent of the family panel's. - expect(screen.getByTestId(`vacation-save-${CURRENT_YEAR}`)).not.toBeDisabled() + // The vacation table has no shared saving flag with the family panel + // any more — each row autosaves and reports its own status. + expect(screen.getByTestId(`vacation-total-days-${CURRENT_YEAR}`)).not.toBeDisabled() await waitFor(() => expect(screen.getByTestId('save-family-button')).not.toBeDisabled()) }) + + it('renders one table per workspace member, self first, keyed on (personalDataUuid, year)', async () => { + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( + makeWorkspaceMembersResult({ + data: [selfMember, partnerMember], + refresh: mockRefreshMembers, + }), + ) + vi.mocked(hooks.useVacationDays).mockReturnValue( + makeVacationDaysResult({ + data: [ + ...mockVacationDays, + { + id: 'vd-2', + personalDataUuid: 'pd-2', + workspaceId: 'ws-1', + year: CURRENT_YEAR, + totalDays: 20, + savedDaysIn: 3, + createdAt: '2026-01-01T00:00:00', + updatedAt: '2026-01-01T00:00:00', + }, + ], + upsert: mockUpsertVacationDays, + }), + ) + renderSettings() + + const selfTable = screen.getByTestId('vacation-days-table-pd-1') + const partnerTable = screen.getByTestId('vacation-days-table-pd-2') + expect( + (within(selfTable).getByTestId(`vacation-total-days-${CURRENT_YEAR}`) as HTMLInputElement).value, + ).toBe('10') + expect( + (within(partnerTable).getByTestId(`vacation-total-days-${CURRENT_YEAR}`) as HTMLInputElement).value, + ).toBe('20') + + // Self's table must come first in document order. + const tables = screen.getAllByTestId(/^vacation-days-table-/) + expect(tables[0]).toBe(selfTable) + expect(tables[1]).toBe(partnerTable) + }) + + it("saves a partner's edit under the partner's own personalDataUuid, not the logged-in user's", async () => { + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( + makeWorkspaceMembersResult({ + data: [selfMember, partnerMember], + refresh: mockRefreshMembers, + }), + ) + renderSettings() + + const partnerTable = screen.getByTestId('vacation-days-table-pd-2') + fireEvent.change(within(partnerTable).getByTestId(`vacation-total-days-${CURRENT_YEAR}`), { + target: { value: '18' }, + }) + + await waitFor(() => + expect(mockUpsertVacationDays).toHaveBeenCalledWith( + expect.objectContaining({ personalDataUuid: 'pd-2', year: CURRENT_YEAR, totalDays: 18 }), + ), + ) + }) }) describe('family panel — child + profile (one combined Save)', () => { @@ -322,6 +407,56 @@ describe('Settings', () => { }) }) + describe('preschool planning days — per-year editing', () => { + it('renders one row per vacation year, wired to usePreschoolPlanningDays', () => { + renderSettings() + for (let year = CURRENT_YEAR; year <= CURRENT_YEAR + 3; year++) { + expect(screen.getByTestId(`preschool-year-row-${year}`)).toBeInTheDocument() + } + expect(hooks.usePreschoolPlanningDays).toHaveBeenCalledWith('c-1', CURRENT_YEAR) + }) + + it('autosaves an edited planned-days value via setPlanned', async () => { + const mockSetPlanned = vi.fn().mockResolvedValue(undefined) + vi.mocked(hooks.usePreschoolPlanningDays).mockReturnValue( + makePreschoolPlanningDaysResult({ setPlanned: mockSetPlanned }), + ) + renderSettings() + + fireEvent.change(screen.getByTestId(`preschool-planned-${CURRENT_YEAR}`), { target: { value: '7' } }) + + await waitFor(() => expect(mockSetPlanned).toHaveBeenCalledWith(7)) + }) + + it('autosaves an edited taken-days value via recordTaken', async () => { + const mockRecordTaken = vi.fn().mockResolvedValue(undefined) + vi.mocked(hooks.usePreschoolPlanningDays).mockReturnValue( + makePreschoolPlanningDaysResult({ recordTaken: mockRecordTaken }), + ) + renderSettings() + + fireEvent.change(screen.getByTestId(`preschool-taken-${CURRENT_YEAR}`), { target: { value: '4' } }) + + await waitFor(() => expect(mockRecordTaken).toHaveBeenCalledWith(4)) + }) + + it('leaves takenDays blank (not 0) when it has never been recorded', () => { + vi.mocked(hooks.usePreschoolPlanningDays).mockReturnValue( + makePreschoolPlanningDaysResult({ + data: { id: 'ppd-1', childId: 'c-1', year: CURRENT_YEAR, plannedDays: 6, takenDays: null, isDefault: false, createdAt: null, updatedAt: null }, + }), + ) + renderSettings() + expect((screen.getByTestId(`preschool-taken-${CURRENT_YEAR}`) as HTMLInputElement).value).toBe('') + }) + }) + + it('mounts PartnerInvitePanel in the family section with the workspace id', () => { + renderSettings() + expect(screen.getByTestId('partner-invite-panel-stub')).toBeInTheDocument() + expect(lastPartnerInvitePanelProps).toEqual({ workspaceId: 'ws-1' }) + }) + it('adds a partner', async () => { renderSettings() fireEvent.change(screen.getByPlaceholderText('Partnerns namn'), { target: { value: 'Partner Name' } }) @@ -357,6 +492,16 @@ describe('Settings', () => { ) }) + it('describes a linked member as an unclaimed placeholder, not an already-linked account', () => { + renderSettings() + // The old copy ("Din partner är kopplad...") falsely implies an + // account link that doesn't exist for a placeholder member added via + // "Lägg till partner" — replaced by the `invite` namespace's copy, + // which correctly describes an invite as the way to connect one. + expect(screen.getByText(/Den här platsen är förberedd åt din partner/)).toBeInTheDocument() + expect(screen.queryByText('Din partner är kopplad till detta arbetsutrymme.')).not.toBeInTheDocument() + }) + it('shows the linked partner and hides the add-partner form', () => { renderSettings() expect(screen.getByTestId('member-name-pd-2')).toHaveTextContent('Sam') diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 66461fb..57cf693 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect, useMemo, useState } from 'react' +import { ReactNode, useEffect, useMemo, useRef, useState } from 'react' import { useWorkspace } from '@/context' import { useVacationDays, @@ -7,6 +7,8 @@ import { useDayOverrides, useCalendarDays, useWorkspaceMembers, + usePreschoolPlanningDays, + useDebouncedCallback, } from '@/hooks' import * as api from '@/services/api' import { isApiError } from '@/services/errors' @@ -19,6 +21,9 @@ import { VacationDaysInput } from '@/components/inputs/VacationDaysInput' import { VacationDaysTable, type VacationDaysTableRow } from '@/components/vacation-days/VacationDaysTable' import { CalendarExceptions } from '@/components/calendar/CalendarExceptions' import { BalanceFlow } from '@/components/balance/BalanceFlow' +import { PartnerInvitePanel } from '@/components/settings/PartnerInvitePanel' +import { orderSelfFirst } from '@/utils/members' +import type { VacationDays } from '@/types' import './settings.css' const RED_DAYS_WINDOW_YEARS = 4 @@ -100,6 +105,120 @@ function computeVacationYearRange(birthDate: string | undefined, currentYear: nu return years } +const PRESCHOOL_AUTOSAVE_DELAY_MS = 800 + +/** + * One (child, year) row letting the user record the true number of + * preschool planning days actually taken that year — the child-level + * `preschoolPlanningDaysPerYear` field above is only ever an estimate used + * as this row's default until a real number is entered (§ user complaint + * "how many did you take"). Its own component so each row can hold its own + * `useDebouncedCallback` instance, autosaving independently per field/year — + * same reasoning as `VacationYearRow` in `VacationDaysTable.tsx`. + */ +function PreschoolPlanningYearRow({ + childId, + year, + defaultPlannedDays, +}: { + childId: string + year: number + defaultPlannedDays: number +}): ReactNode { + const { t } = useTranslation() + const { data, setPlanned, recordTaken } = usePreschoolPlanningDays(childId, year) + + const [plannedDraft, setPlannedDraft] = useState(data?.plannedDays ?? defaultPlannedDays) + // `''` represents "not recorded" (`takenDays === null`) — deliberately + // distinct from `0` ("recorded, none taken"), so the input starts empty + // rather than defaulting to zero. + const [takenDraft, setTakenDraft] = useState(data?.takenDays === null || data?.takenDays === undefined ? '' : String(data.takenDays)) + const plannedDirtyRef = useRef(false) + const takenDirtyRef = useRef(false) + + useEffect(() => { + if (plannedDirtyRef.current) return + setPlannedDraft(data?.plannedDays ?? defaultPlannedDays) + }, [data?.plannedDays, defaultPlannedDays]) + + useEffect(() => { + if (takenDirtyRef.current) return + setTakenDraft(data?.takenDays === null || data?.takenDays === undefined ? '' : String(data.takenDays)) + }, [data?.takenDays]) + + const debouncedSetPlanned = useDebouncedCallback((value: number) => { + void setPlanned(value) + .then(() => { + plannedDirtyRef.current = false + }) + .catch(() => { + // Leave `plannedDirtyRef` set so a stale server value can't creep + // back in over the value still shown on screen. + }) + }, PRESCHOOL_AUTOSAVE_DELAY_MS) + + const debouncedRecordTaken = useDebouncedCallback((value: number) => { + void recordTaken(value) + .then(() => { + takenDirtyRef.current = false + }) + .catch(() => {}) + }, PRESCHOOL_AUTOSAVE_DELAY_MS) + + return ( +
+ + {year} + {data?.isDefault && ( + + {t('vacationDaysTable.assumedBadge')} + + )} + + + +
+ ) +} + export function Settings(): ReactNode { const { t, explicitLocale, setLocale } = useTranslation() const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext } = useWorkspace() @@ -137,7 +256,6 @@ export function Settings(): ReactNode { // (Saldon panel) also disabled the "Spara" button in the family panel, // and vice versa. const [isSavingFamily, setIsSavingFamily] = useState(false) - const [isSavingVacation, setIsSavingVacation] = useState(false) const [isSavingRedDayPref, setIsSavingRedDayPref] = useState(false) const [newPartnerName, setNewPartnerName] = useState('') const [renamingUuid, setRenamingUuid] = useState(null) @@ -170,27 +288,36 @@ export function Settings(): ReactNode { const selectedChild = children.find((c) => c.id === selectedChildId) const hasPartner = members.some((m) => m.uuid !== personalDataUuid) + // Self first, matching `BalanceFlow`'s ordering for the same list of + // members — used to render one `VacationDaysTable` per member (§3.3). + const orderedMembers = orderSelfFirst(members, personalDataUuid) const currentYear = new Date().getFullYear() const vacationYears = useMemo( () => computeVacationYearRange(selectedChild?.birth_date, currentYear), [selectedChild?.birth_date, currentYear], ) - const vacationRows: VacationDaysTableRow[] = useMemo( + // Keyed on (personalDataUuid, year), NOT year alone — with more than one + // workspace member, matching on year alone would silently pick up + // whichever member's row for that year happened to come first in + // `vacDays`, showing (and letting you overwrite) the wrong parent's data. + const vacationRowsByMember: VacationDaysTableRow[][] = useMemo( () => - vacationYears.map((year) => { - const existing = vacDays.find((v) => v.year === year) - return { - year, - totalDays: existing?.totalDays ?? DEFAULT_VACATION_DAYS, - savedDaysIn: existing?.savedDaysIn ?? 0, - // §3.2 — no vacation_days row for this year yet: the statutory - // default is shown, not an entered value. Mirrors the backend's - // `VacationBalanceResponse.is_assumed` for the propose-time table. - isAssumed: existing === undefined, - } - }), - [vacationYears, vacDays], + orderedMembers.map((member) => + vacationYears.map((year) => { + const existing = vacDays.find((v: VacationDays) => v.personalDataUuid === member.uuid && v.year === year) + return { + year, + totalDays: existing?.totalDays ?? DEFAULT_VACATION_DAYS, + savedDaysIn: existing?.savedDaysIn ?? 0, + // §3.2 — no vacation_days row for this year yet: the statutory + // default is shown, not an entered value. Mirrors the backend's + // `VacationBalanceResponse.is_assumed` for the propose-time table. + isAssumed: existing === undefined, + } + }), + ), + [orderedMembers, vacationYears, vacDays], ) if (isLoading) { @@ -256,24 +383,27 @@ export function Settings(): ReactNode { }) } - const handleSaveVacationYear = async (year: number, totalDays: number, savedDaysIn: number) => { - try { - if (workspace?.id && personalDataUuid) { - setIsSavingVacation(true) - await upsertVacationDays({ - personalDataUuid, - workspaceId: workspace.id, - year, - totalDays, - savedDaysIn, - }) - showMessage('success', t('settings.saveVacationSuccess', { year })) - } - } catch (err) { - showMessage('error', err instanceof Error ? err.message : t('settings.saveVacationError')) - } finally { - setIsSavingVacation(false) - } + // Takes the owning member's uuid explicitly (§3.3) rather than always + // saving under the logged-in user's own `personalDataUuid` — each + // `VacationDaysTable` instance below binds this to its own member. + // Deliberately doesn't catch: `VacationDaysTable`'s row calls this from + // its own autosave and shows a per-row saving/saved/error indicator from + // the settled promise, so a page-wide toast here would be redundant (and + // noisy — several rows across several members can be autosaving at once). + const handleSaveVacationYear = async ( + memberUuid: string, + year: number, + totalDays: number, + savedDaysIn: number, + ): Promise => { + if (!workspace?.id) return + await upsertVacationDays({ + personalDataUuid: memberUuid, + workspaceId: workspace.id, + year, + totalDays, + savedDaysIn, + }) } const handleRedDaysPrevWindow = () => { @@ -413,6 +543,23 @@ export function Settings(): ReactNode { max={30} />
+ + {selectedChildId && ( +
+

{t('settings.preschoolPerYearTitle')}

+

{t('settings.preschoolPerYearHelp')}

+
+ {vacationYears.map((year) => ( + + ))} +
+
+ )}
) : (

{t('settings.noChildSelected')}

@@ -435,7 +582,12 @@ export function Settings(): ReactNode {

{t('settings.partnerSectionTitle')}

-

{hasPartner ? t('settings.partnerLinkedIntro') : t('settings.partnerIntro')}

+ {/* A member added via "Lägg till partner" (or waiting on an invite) + * is a placeholder with no login of their own — `partnerLinkedIntro` + * used to claim an account link that doesn't exist for that case. + * `invite.partnerPlaceholderIntro` describes what's actually true: + * the slot is reserved, an invite connects it to a real account. */} +

{hasPartner ? t('invite.partnerPlaceholderIntro') : t('settings.partnerIntro')}

{membersLoading ? (

{t('settings.membersLoading')}

@@ -562,6 +714,8 @@ export function Settings(): ReactNode { )} + +
@@ -580,7 +734,26 @@ export function Settings(): ReactNode { )}

{t('settings.vacationHelp')}

- + {!membersLoading && + orderedMembers.map((member, index) => ( +
+ {orderedMembers.length > 1 && ( +

+ {member.name} + {member.uuid === personalDataUuid && ( + {t('settings.youBadge')} + )} +

+ )} + + handleSaveVacationYear(member.uuid, year, totalDays, savedDaysIn) + } + personalDataUuid={member.uuid} + /> +
+ ))}
diff --git a/frontend/src/pages/join.css b/frontend/src/pages/join.css new file mode 100644 index 0000000..3c7ac42 --- /dev/null +++ b/frontend/src/pages/join.css @@ -0,0 +1,71 @@ +/* /join/:token — mirrors auth-page's plain centred-column language (no + * header/session chrome yet). */ +.join-page { + position: relative; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--sp-5); +} + +.join-page__lang { + position: absolute; + top: var(--sp-5); + right: var(--sp-5); +} + +.join-page__column { + width: 100%; + max-width: 380px; + display: flex; + flex-direction: column; + align-items: center; +} + +.join-page__mark { + width: 32px; + height: 32px; + border-radius: 8px; + margin-bottom: var(--sp-4); +} + +.join-page__title { + font-size: var(--fs-xl); + text-align: center; + margin: 0 0 var(--sp-2) 0; +} + +.join-page__intro { + text-align: center; + color: var(--c-text-muted); + margin: 0 0 var(--sp-5) 0; +} + +.join-page__tabs { + display: flex; + width: 100%; + margin-bottom: var(--sp-5); +} + +.join-page__tab { + flex: 1; +} + +.join-page__form { + align-self: stretch; + display: flex; + flex-direction: column; + gap: var(--sp-3); +} + +.join-page__form button[type='submit'] { + margin-top: var(--sp-2); +} + +.join-page__error { + margin: 0; + font-size: var(--fs-sm); + color: var(--c-danger); + text-align: center; +} diff --git a/frontend/src/pages/settings.css b/frontend/src/pages/settings.css index 358873b..e66ca1a 100644 --- a/frontend/src/pages/settings.css +++ b/frontend/src/pages/settings.css @@ -107,3 +107,59 @@ color: var(--c-danger); font-size: var(--fs-sm); } + +/* One VacationDaysTable per workspace member (§3.3) — a small heading names + * whose table it is whenever there's more than one. */ +.settings-panel__vacation-member { + margin-top: var(--sp-4); +} + +.settings-panel__vacation-member-name { + font-weight: 600; + margin: 0 0 var(--sp-2) 0; +} + +/* Per-year preschool planning-days editor — a wrapping flex group, same + * reasoning as `vacation-rows.css`: it must reflow at 390px, not scroll + * sideways. */ +.preschool-year-rows { + display: flex; + flex-direction: column; +} + +.preschool-year-row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--sp-2) var(--sp-4); + padding: var(--sp-3) 0; + border-bottom: 1px solid var(--c-border); +} + +.preschool-year-row:last-child { + border-bottom: none; +} + +.preschool-year-row__year { + display: flex; + align-items: center; + gap: var(--sp-2); + min-width: 56px; + padding-bottom: var(--sp-2); + font-weight: 600; + align-self: center; +} + +.preschool-year-row__field { + flex: 1 1 110px; + min-width: 90px; + max-width: 160px; +} + +.preschool-year-row__input { + width: 100%; +} + +.preschool-year-row__assumed-badge { + cursor: help; +} diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index 3f27b67..0c0b436 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -47,6 +47,8 @@ describe('API response mappers (snake_case → camelCase)', () => { left: 23, carried_out: 5, expiring: 0, + expiring_age: 0, + expiring_over_cap: 0, }, parental_leave: { year: 2024, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0c05a63..6d0cb5b 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -23,6 +23,9 @@ import type { WorkspaceUpdateRequest, WorkspaceOverview, PlanSyncResponse, + PreschoolPlanningDaysPlannedRequest, + PreschoolPlanningDaysTakenRequest, + CreateInvitationResponse, } from '@/types' import type { Workspace, @@ -42,6 +45,8 @@ import type { ParentalLeaveYearBalance, ParentYearBalance, YearTotals, + PreschoolPlanningDays, + Invitation, } from '@/types' const BASE = '/api' @@ -121,6 +126,8 @@ export interface RawVacationYearBalance { left: number carried_out: number expiring: number + expiring_age: number + expiring_over_cap: number } function toVacationYearBalance(raw: RawVacationYearBalance): VacationYearBalance { @@ -133,6 +140,8 @@ function toVacationYearBalance(raw: RawVacationYearBalance): VacationYearBalance left: raw.left, carriedOut: raw.carried_out, expiring: raw.expiring, + expiringAge: raw.expiring_age, + expiringOverCap: raw.expiring_over_cap, } } @@ -762,6 +771,9 @@ export async function register(req: RegisterRequest): Promise { email: req.email, password: req.password, display_name: req.displayName, + // Omitted (rather than sent as `undefined`) so the backend's + // "no invite" default (`None`) applies untouched. + ...(req.inviteToken !== undefined ? { invite_token: req.inviteToken } : {}), }), }) } @@ -1403,3 +1415,120 @@ export async function cancelScraperSession(sessionId: string): Promise { body: JSON.stringify({ session_id: sessionId }), }) } + +// --------------------------------------------------------------------------- +// Preschool planning days ("studiedagar") — /api/preschool-planning-days +// --------------------------------------------------------------------------- + +interface RawPreschoolPlanningDays { + id: string | null + child_id: string + year: number + planned_days: number + taken_days: number | null + is_default: boolean + created_at: string | null + updated_at: string | null +} + +function toPreschoolPlanningDays(raw: RawPreschoolPlanningDays): PreschoolPlanningDays { + return { + id: raw.id, + childId: raw.child_id, + year: raw.year, + plannedDays: raw.planned_days, + takenDays: raw.taken_days, + isDefault: raw.is_default, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + } +} + +export async function upsertPreschoolPlanningDaysPlanned( + childId: string, + year: number, + req: PreschoolPlanningDaysPlannedRequest, +): Promise { + const raw = (await apiFetch(`/preschool-planning-days/${childId}/${year}/planned`, { + method: 'PUT', + body: JSON.stringify({ planned_days: req.plannedDays }), + })) as RawPreschoolPlanningDays + return toPreschoolPlanningDays(raw) +} + +export async function recordPreschoolPlanningDaysTaken( + childId: string, + year: number, + req: PreschoolPlanningDaysTakenRequest, +): Promise { + const raw = (await apiFetch(`/preschool-planning-days/${childId}/${year}/taken`, { + method: 'PUT', + body: JSON.stringify({ taken_days: req.takenDays }), + })) as RawPreschoolPlanningDays + return toPreschoolPlanningDays(raw) +} + +export async function getPreschoolPlanningDaysYear( + childId: string, + year: number, +): Promise { + const raw = (await apiFetch( + `/preschool-planning-days/${childId}/${year}`, + )) as RawPreschoolPlanningDays + return toPreschoolPlanningDays(raw) +} + +export async function listPreschoolPlanningDays(childId: string): Promise { + const raw = (await apiFetch(`/preschool-planning-days/${childId}`)) as RawPreschoolPlanningDays[] + return raw.map(toPreschoolPlanningDays) +} + +// --------------------------------------------------------------------------- +// Invitations — /api/workspace/{id}/invitations, /api/invitations/* +// --------------------------------------------------------------------------- + +interface RawInvitation { + id: string + workspace_id: string + status: 'pending' | 'accepted' | 'revoked' | 'expired' + expires_at: string + created_at: string +} + +function toInvitation(raw: RawInvitation): Invitation { + return { + id: raw.id, + workspaceId: raw.workspace_id, + status: raw.status, + expiresAt: raw.expires_at, + createdAt: raw.created_at, + } +} + +/** Creates a one-time invite for `workspaceId`; the raw `token` is returned + * once and never stored server-side — share it out-of-band (the backend + * only ever persists its hash). */ +export async function createInvitation(workspaceId: string): Promise { + return apiFetch(`/workspace/${workspaceId}/invitations`, { method: 'POST' }) +} + +/** Lists this workspace's invitations. Never includes `token_hash` — the + * backend omits it from the wire response entirely. */ +export async function listInvitations(workspaceId: string): Promise { + const raw = (await apiFetch(`/workspace/${workspaceId}/invitations`)) as RawInvitation[] + return raw.map(toInvitation) +} + +export async function redeemInvitation(token: string): Promise { + const raw = (await apiFetch('/invitations/redeem', { + method: 'POST', + body: JSON.stringify({ token }), + })) as RawInvitation + return toInvitation(raw) +} + +/** The caller's own workspace is inferred server-side from their session — + * no workspace id is sent. */ +export async function revokeInvitation(invitationId: string): Promise { + return apiFetch(`/invitations/${invitationId}`, { method: 'DELETE' }) +} diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index eadcec0..62503ac 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -385,6 +385,17 @@ margin-right: var(--sp-2); } +/* Explains a warning figure (e.g. why vacation days expire) directly under + * its value, so a bare red number is never left unaccounted for. */ +.year-row__cell-detail { + display: block; + margin-top: 2px; + font-weight: 400; + font-size: var(--fs-sm); + color: var(--c-text-muted); + white-space: normal; +} + .year-row__wishes-title { margin: 0 0 var(--sp-2); font-size: var(--fs-sm); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index e84124a..9f6d7f4 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -11,6 +11,10 @@ export interface RegisterRequest { email: string password: string displayName: string + // Lets a partner register straight from an invite link instead of first + // creating a throwaway solo workspace — see `RegisterRequest.invite_token` + // (backend/app/models/auth.py). Omitted/undefined means "no invite". + inviteToken?: string } export interface LoginRequest { @@ -209,3 +213,22 @@ export interface DayOverrideCreateRequest { isWorkingDay: boolean note: string | null } + +// Preschool planning days ("studiedagar") — PUT /api/preschool-planning-days/{child_id}/{year}/planned|taken +export interface PreschoolPlanningDaysPlannedRequest { + plannedDays: number +} + +export interface PreschoolPlanningDaysTakenRequest { + takenDays: number +} + +// Invitations — POST /api/workspace/{id}/invitations, POST /api/invitations/redeem +export interface CreateInvitationResponse { + // Raw one-time token — share out-of-band; never persisted server-side. + token: string +} + +export interface RedeemInvitationRequest { + token: string +} diff --git a/frontend/src/types/entities.ts b/frontend/src/types/entities.ts index cf72d8a..f47b7a5 100644 --- a/frontend/src/types/entities.ts +++ b/frontend/src/types/entities.ts @@ -247,8 +247,44 @@ export interface VacationYearBalance { left: number /** Days saved forward into next year's carriedIn. */ carriedOut: number - /** Days that hit the 5-year carry-forward cap this year — LOST if not used. */ + /** Every vacation day lost at this year's end, from BOTH causes below + * (expiringAge + expiringOverCap). Was previously age-out only — a + * consumer reading just `expiring` still gets the correct total loss. */ expiring: number + /** Subset of `expiring` that hit the 5-year carry-forward age-out. */ + expiringAge: number + /** Subset of `expiring` above the savable cap. */ + expiringOverCap: number +} + +// One (child, year) row of "förskolans planeringsdagar" (preschool +// closure/planning days) — GET/PUT /api/preschool-planning-days/*. See +// backend/app/entities/preschool_planning_days.py. +export interface PreschoolPlanningDays { + // Null for a synthesized (no persisted row) response — see `isDefault`. + id: string | null + childId: string + year: number + plannedDays: number + // Null means "not recorded yet", distinct from 0 ("recorded as zero"). + takenDays: number | null + // True when no row exists for this (child, year) and the figures are the + // child's fallback default (`Child.preschoolPlanningDaysPerYear`), not a + // real record. + isDefault: boolean + createdAt: string | null + updatedAt: string | null +} + +// A single-use partner invite — POST/GET /api/workspace/{id}/invitations, +// POST /api/invitations/redeem, DELETE /api/invitations/{id}. `token_hash` +// is never returned by the backend and must never appear here. +export interface Invitation { + id: string + workspaceId: string + status: 'pending' | 'accepted' | 'revoked' | 'expired' + expiresAt: string + createdAt: string } export interface ParentalLeaveYearBalance { diff --git a/frontend/tests/fixtures/analytics.ts b/frontend/tests/fixtures/analytics.ts index 25b0d1a..49b348d 100644 --- a/frontend/tests/fixtures/analytics.ts +++ b/frontend/tests/fixtures/analytics.ts @@ -21,6 +21,8 @@ export function makeVacationYearBalance( left: 25, carriedOut: 5, expiring: 0, + expiringAge: 0, + expiringOverCap: 0, ...overrides, } } diff --git a/frontend/tests/fixtures/hooks.ts b/frontend/tests/fixtures/hooks.ts index 2f41ded..d6d12c2 100644 --- a/frontend/tests/fixtures/hooks.ts +++ b/frontend/tests/fixtures/hooks.ts @@ -26,10 +26,13 @@ import type { usePlanSync } from '@/hooks/usePlanSync' import type { useVacationDays } from '@/hooks/useVacationDays' import type { useChildSettings } from '@/hooks/useChildSettings' import type { useCalendarDays } from '@/hooks/useCalendarDays' +import type { usePreschoolPlanningDays } from '@/hooks/usePreschoolPlanningDays' +import type { usePreschoolPlanningDaysList } from '@/hooks/usePreschoolPlanningDaysList' import type { useShowWeekNumbers } from '@/hooks/useCalendarPreferences' import { makeChild, makePersonalData, makeVacationDays, makeWorkspace, makeParentalLeaveFetch } from './mockData' import { makeWorkspaceOverview } from './analytics' import { makeCalendarWindow } from './calendar' +import { makePreschoolPlanningDays } from './workspace' // --------------------------------------------------------------------------- // Contexts @@ -166,6 +169,7 @@ export function makeDayOverridesResult( return { data: [], isLoading: false, + isRefreshing: false, error: null, upsert: vi.fn(), remove: vi.fn(), @@ -232,6 +236,7 @@ export function makeVacationDaysResult( return { data: [makeVacationDays()], isLoading: false, + isRefreshing: false, error: null, upsert: vi.fn(), ...overrides, @@ -244,6 +249,7 @@ export function makeChildSettingsResult( return { data: makeChild(), isLoading: false, + isRefreshing: false, error: null, update: vi.fn(), ...overrides, @@ -256,12 +262,40 @@ export function makeCalendarDaysResult( return { data: makeCalendarWindow(), isLoading: false, + isRefreshing: false, error: null, refetch: vi.fn(), ...overrides, } } +export function makePreschoolPlanningDaysResult( + overrides: Partial> = {}, +): ReturnType { + return { + data: makePreschoolPlanningDays(), + isLoading: false, + isRefreshing: false, + error: null, + setPlanned: vi.fn(), + recordTaken: vi.fn(), + ...overrides, + } +} + +export function makePreschoolPlanningDaysListResult( + overrides: Partial> = {}, +): ReturnType { + return { + data: [makePreschoolPlanningDays()], + isLoading: false, + isRefreshing: false, + error: null, + refresh: vi.fn(), + ...overrides, + } +} + // --------------------------------------------------------------------------- // Local-storage-backed preference hook (a tuple, not an object) // --------------------------------------------------------------------------- diff --git a/frontend/tests/fixtures/workspace.ts b/frontend/tests/fixtures/workspace.ts index 2cce658..a08d67a 100644 --- a/frontend/tests/fixtures/workspace.ts +++ b/frontend/tests/fixtures/workspace.ts @@ -7,6 +7,7 @@ */ import type { addPartner } from '@/services/api' +import type { Invitation, PreschoolPlanningDays } from '@/types' /** Shared by `addPartner` and `updateParentSettings` — both return the same * `{ uuid, name, salary_type, employment_status }` wire shape. */ @@ -23,3 +24,30 @@ export function makePartnerMemberResponse( ...overrides, } } + +export function makePreschoolPlanningDays( + overrides: Partial = {}, +): PreschoolPlanningDays { + return { + id: 'ppd-1', + childId: 'child-1', + year: 2025, + plannedDays: 5, + takenDays: null, + isDefault: false, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + ...overrides, + } +} + +export function makeInvitation(overrides: Partial = {}): Invitation { + return { + id: 'inv-1', + workspaceId: 'ws-1', + status: 'pending', + expiresAt: '2025-01-08T00:00:00Z', + createdAt: '2025-01-01T00:00:00Z', + ...overrides, + } +}