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