From cc3ced905e0c5340d08cc9995b229e288e35a6eb Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Tue, 15 Sep 2026 20:28:06 +0200 Subject: [PATCH] Link planned days to their wish, fix the age-4 cap, drop the planning goal Three changes that together unblock treating a calendar period as one editable object. Migration 008 adds `planned_leave_day.wish_id` (nullable, ON DELETE SET NULL) so a persisted day knows which `leave_wish` produced it, `is_pinned` to mark a day the user hand-edited, and `leave_wish.target_days` so a period drawn on a calendar need not be a whole number of weeks. Apply-time identity still keys on day_type/is_dubbeldag only, so a wish_id-only difference is a metadata update rather than a collision. The age-4 savable ceiling was applying the per-child figure of 96 to each parent's pool, modelling a two-parent family as able to save 192 days past the child's fourth birthday. `resolve_age4_savable_cap` is now the single place that decides 48 (couple with two parents) versus 96 (everyone else), and both AnalyticsService and the allocator read it. Migration 009 drops `workspace.planning_goal` and `leave_wish.goal_override`. The allocator's tier-3 policy collapses to one rule; `salary_type` still branches, since that reflects what a vacation day actually costs a parent rather than a user preference. --- backend/app/config.py | 8 +- backend/app/db/connection.py | 10 +- backend/app/dependencies.py | 3 +- backend/app/entities/leave_wish.py | 14 +- backend/app/entities/planned_leave.py | 8 + backend/app/entities/workspace.py | 1 - backend/app/models/analytics.py | 2 + backend/app/models/leave_wish.py | 16 +- backend/app/models/plan.py | 15 +- backend/app/models/planned_leave.py | 9 + backend/app/models/workspace.py | 3 - .../sqlite/leave_wish_repository.py | 14 +- .../sqlite/planned_leave_repository.py | 19 +- .../sqlite/workspace_repository.py | 10 +- backend/app/routers/analytics.py | 13 +- backend/app/routers/leave_wishes.py | 10 +- backend/app/routers/workspace.py | 4 +- backend/app/services/allocation_service.py | 117 ++++---- backend/app/services/analytics_service.py | 40 ++- backend/app/services/leave_wish_service.py | 49 ++-- backend/app/services/planning_service.py | 60 +++- backend/app/services/tips_service.py | 45 +-- backend/app/services/workspace_service.py | 6 - backend/app/utils/tip_predicates.py | 31 +- .../008_add_wish_link_and_target_days.py | 68 +++++ .../versions/009_drop_planning_goal.py | 50 ++++ backend/scripts/demo_plan.py | 1 - backend/scripts/seed_demo.py | 6 +- backend/tests/conftest.py | 5 +- .../tests/unit/test_leave_wish_repository.py | 232 +++++++++++---- backend/tests/unit/test_leave_wish_router.py | 251 ++++++++++++++++ backend/tests/unit/test_plan_router.py | 4 + backend/tests/unit/test_planning_service.py | 99 +++++++ .../tests/unit/test_planning_service_apply.py | 166 +++++++++++ .../test_allocation_input_loader.py | 13 +- .../test_services/test_allocation_service.py | 268 ++++++++---------- .../test_allocation_service_branches.py | 48 ++-- .../test_services/test_analytics_service.py | 134 ++++++++- .../unit/test_services/test_tips_service.py | 65 ++--- .../inputs/PlanningGoalSelector.test.tsx | 94 ------ .../inputs/PlanningGoalSelector.tsx | 62 ---- frontend/src/components/inputs/TipCard.tsx | 4 - .../components/planning/WishEditor.test.tsx | 1 - .../src/context/WorkspaceContext.test.tsx | 1 - frontend/src/hooks/useLeaveWishes.test.ts | 1 - frontend/src/i18n/en/plan.ts | 7 - frontend/src/i18n/en/tips.ts | 2 - frontend/src/i18n/sv/plan.ts | 7 - frontend/src/i18n/sv/tips.ts | 2 - frontend/src/pages/Dashboard.test.tsx | 16 +- frontend/src/pages/Plan.test.tsx | 2 - frontend/src/pages/Settings.test.tsx | 6 +- frontend/src/pages/Settings.tsx | 10 +- frontend/src/pages/Tips.test.tsx | 2 +- frontend/src/services/api.test.ts | 20 +- frontend/src/services/api.ts | 7 - frontend/src/types/api.ts | 2 - frontend/src/types/entities.ts | 2 - frontend/tests/fixtures/mockData.ts | 2 - 59 files changed, 1427 insertions(+), 740 deletions(-) create mode 100644 backend/migrations/versions/008_add_wish_link_and_target_days.py create mode 100644 backend/migrations/versions/009_drop_planning_goal.py create mode 100644 backend/tests/unit/test_leave_wish_router.py delete mode 100644 frontend/src/components/inputs/PlanningGoalSelector.test.tsx delete mode 100644 frontend/src/components/inputs/PlanningGoalSelector.tsx diff --git a/backend/app/config.py b/backend/app/config.py index 09e0929..84a160b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -32,7 +32,13 @@ class LeaveRules(BaseModel): max_dubbeldagar: int = 60 # both parents, same child-day dubbeldagar_months_limit: int = 15 # only within the child's first 15 months - max_savable_days_at_age_4: int = 96 # age-4 ceiling + max_savable_days_at_age_4: int = 96 # age-4 ceiling — per CHILD (pooled across parents) + # Age-4 ceiling — per PARENT. The 96-day figure above is a per-child pool; + # when a child has two parents, each parent's own savable balance is + # capped at half of that (48), not 96 each (which would let a two-parent + # family save 192 combined). `app.utils.tip_predicates.resolve_age4_savable_cap` + # is the single place that decides which of the two applies. + max_savable_days_per_parent_at_age_4: int = 48 hard_expiry_age_years: int = 12 sgi_protected_first_months: int = 12 diff --git a/backend/app/db/connection.py b/backend/app/db/connection.py index 9195604..0a76ecf 100644 --- a/backend/app/db/connection.py +++ b/backend/app/db/connection.py @@ -19,8 +19,6 @@ _FULL_SCHEMA = """ CREATE TABLE IF NOT EXISTS workspace ( id TEXT PRIMARY KEY, name TEXT NOT NULL, - planning_goal TEXT NOT NULL DEFAULT 'balanced' - CHECK (planning_goal IN ('max_time','max_money','balanced')), planning_mode TEXT NOT NULL DEFAULT 'solo' CHECK (planning_mode IN ('solo','couple')), created_at TIMESTAMP NOT NULL @@ -91,6 +89,7 @@ CREATE TABLE IF NOT EXISTS leave_wish ( window_end_day INTEGER NOT NULL DEFAULT 1 CHECK (window_end_day >= 1 AND window_end_day <= 31), target_weeks INTEGER NOT NULL, + target_days INTEGER, recurrence TEXT NOT NULL DEFAULT 'once' CHECK (recurrence IN ('yearly','once')), first_year INTEGER NOT NULL DEFAULT 2026, @@ -99,8 +98,6 @@ CREATE TABLE IF NOT EXISTS leave_wish ( parent_mode TEXT NOT NULL DEFAULT 'either' CHECK (parent_mode IN ('either','both','specific')), parent_personal_data_uuid TEXT, - goal_override TEXT - CHECK (goal_override IS NULL OR goal_override IN ('max_time','max_money','balanced')), window_mode TEXT NOT NULL DEFAULT 'fixed' CHECK (window_mode IN ('fixed','flexible')), created_at TIMESTAMP NOT NULL, @@ -154,12 +151,15 @@ CREATE TABLE IF NOT EXISTS planned_leave_day ( date DATE NOT NULL, day_type TEXT NOT NULL CHECK (day_type IN ('sjukpenning','lagsta','vacation')), is_dubbeldag INTEGER NOT NULL DEFAULT 0, + wish_id TEXT, + is_pinned INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, UNIQUE(workspace_id, child_id, date, parent_personal_data_uuid), FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE, - FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE + FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE, + FOREIGN KEY (wish_id) REFERENCES leave_wish(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS day_override ( diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 40ec61e..e0a7f56 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -263,8 +263,9 @@ def get_analytics_service( planned_repo: PlannedLeaveRepositoryDep, child_repo: ChildRepositoryDep, pd_repo: PersonalDataRepositoryDep, + workspace_repo: WorkspaceRepositoryDep, ) -> AnalyticsService: - return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo) + return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo) AnalyticsServiceDep = Annotated[AnalyticsService, Depends(get_analytics_service)] diff --git a/backend/app/entities/leave_wish.py b/backend/app/entities/leave_wish.py index 1e72732..43cc11b 100644 --- a/backend/app/entities/leave_wish.py +++ b/backend/app/entities/leave_wish.py @@ -41,10 +41,6 @@ class LeaveWish: parent_mode: str # 'either' | 'both' | 'specific' parent_personal_data_uuid: UUID | None # required when parent_mode == 'specific' - # Per-window override of workspace planning_goal. Nullable, no UI in v1 — - # the field exists now so enabling it later needs no migration. - goal_override: str | None = None # 'max_time' | 'max_money' | 'balanced' | None - # PLANNING_ENGINE_SPEC.md §10.1 — 'fixed' (default): window_start_*/window_end_* # are the exact placement, as above. 'flexible': the SAME four fields instead # describe the BOUNDING RANGE the allocator searches inside for the @@ -65,9 +61,19 @@ class LeaveWish: # revert to one. name_message_key: str | None = None + # A calendar-drawn period (e.g. dragging a band on the calendar UI) is not + # a whole number of weeks. NULL (every row created before this field + # existed, and any wish still specified in weeks) falls back to + # `target_weeks * 7` — see `target_span_days()`. + target_days: int | None = None + created_at: datetime | None = None updated_at: datetime | None = None + def target_span_days(self) -> int: + """Wanted duration in days. `target_days` wins when set; otherwise weeks * 7.""" + return self.target_days if self.target_days is not None else self.target_weeks * 7 + def window_wraps(self) -> bool: """Whether (end_month, end_day) sorts before (start_month, start_day). diff --git a/backend/app/entities/planned_leave.py b/backend/app/entities/planned_leave.py index 5a880fe..236d735 100644 --- a/backend/app/entities/planned_leave.py +++ b/backend/app/entities/planned_leave.py @@ -14,5 +14,13 @@ class PlannedLeaveDay: date: date day_type: str # 'sjukpenning' | 'lagsta' | 'vacation' is_dubbeldag: bool = False # True → counts against the 60-day dubbeldagar quota + # Which `LeaveWish` produced this day (nullable: ad-hoc/legacy/unattributable + # days have none). Lets the UI click a multi-day band on a calendar and edit + # it as one period instead of one day at a time. + wish_id: UUID | None = None + # True once the user hand-edits this day. A later task makes the allocator + # refuse to retype a pinned day on replan; this field only carries that + # state through the stack so far. + is_pinned: bool = False created_at: datetime | None = None updated_at: datetime | None = None diff --git a/backend/app/entities/workspace.py b/backend/app/entities/workspace.py index cafd0aa..170e5a2 100644 --- a/backend/app/entities/workspace.py +++ b/backend/app/entities/workspace.py @@ -9,6 +9,5 @@ from uuid import UUID class Workspace: id: UUID name: str - planning_goal: str = "balanced" # 'max_time' | 'max_money' | 'balanced' planning_mode: str = "solo" # 'solo' | 'couple' — explicit, not inferred from member count created_at: datetime | None = None diff --git a/backend/app/models/analytics.py b/backend/app/models/analytics.py index e592131..aa0a517 100644 --- a/backend/app/models/analytics.py +++ b/backend/app/models/analytics.py @@ -19,6 +19,7 @@ class Age4ProjectionResponse(BaseModel): projected_left_at_b4: int savable_after_b4: int expiring_at_b4: int + savable_cap_at_b4: int @classmethod def from_entity(cls, p: Age4Projection) -> "Age4ProjectionResponse": @@ -33,6 +34,7 @@ class Age4ProjectionResponse(BaseModel): projected_left_at_b4=p.projected_left_at_b4, savable_after_b4=p.savable_after_b4, expiring_at_b4=p.expiring_at_b4, + savable_cap_at_b4=p.savable_cap_at_b4, ) diff --git a/backend/app/models/leave_wish.py b/backend/app/models/leave_wish.py index ab8bcd8..78cafa4 100644 --- a/backend/app/models/leave_wish.py +++ b/backend/app/models/leave_wish.py @@ -30,6 +30,9 @@ class LeaveWishCreate(BaseModel): window_end_day: int = Field(ge=1, le=31) target_weeks: int = Field(ge=1, le=52) + # A calendar-drawn period is not a whole number of weeks. None (default) + # falls back to `target_weeks * 7` — see `LeaveWish.target_span_days()`. + target_days: int | None = Field(default=None, ge=1) recurrence: str = "once" # 'yearly' | 'once' first_year: int = Field(ge=2000, le=2200) last_year: int | None = Field(default=None, ge=2000, le=2200) @@ -38,8 +41,6 @@ class LeaveWishCreate(BaseModel): parent_mode: str = "either" # 'either' | 'both' | 'specific' parent_personal_data_uuid: UUID | None = None - goal_override: str | None = None # 'max_time' | 'max_money' | 'balanced' | None - # PLANNING_ENGINE_SPEC.md §10.1 — 'fixed': window_start_*/window_end_* are the # exact placement (default). 'flexible': the same fields describe the # bounding range the allocator searches within for the best-costing window. @@ -63,6 +64,10 @@ class LeaveWishUpdate(BaseModel): window_end_day: int | None = Field(None, ge=1, le=31) target_weeks: int | None = Field(None, ge=1, le=52) + # Present + null means "clear it" (fall back to target_weeks); absent + # means "leave unchanged" — same convention as the other nullable fields + # on this model (see `_clear_*` handling in `LeaveWishService.update`). + target_days: int | None = Field(default=None, ge=1) recurrence: str | None = None first_year: int | None = Field(None, ge=2000, le=2200) last_year: int | None = Field(None, ge=2000, le=2200) @@ -71,8 +76,6 @@ class LeaveWishUpdate(BaseModel): parent_mode: str | None = None parent_personal_data_uuid: UUID | None = None - goal_override: str | None = None - window_mode: str | None = None # 'fixed' | 'flexible' @@ -89,6 +92,7 @@ class LeaveWishResponse(BaseModel): window_end_day: int target_weeks: int + target_days: int | None recurrence: str first_year: int last_year: int | None @@ -97,8 +101,6 @@ class LeaveWishResponse(BaseModel): parent_mode: str parent_personal_data_uuid: UUID | None - goal_override: str | None - window_mode: str created_at: datetime | None @@ -117,13 +119,13 @@ class LeaveWishResponse(BaseModel): window_end_month=entity.window_end_month, window_end_day=entity.window_end_day, target_weeks=entity.target_weeks, + target_days=entity.target_days, recurrence=entity.recurrence, first_year=entity.first_year, last_year=entity.last_year, priority=entity.priority, parent_mode=entity.parent_mode, parent_personal_data_uuid=entity.parent_personal_data_uuid, - goal_override=entity.goal_override, window_mode=entity.window_mode, created_at=entity.created_at, updated_at=entity.updated_at, diff --git a/backend/app/models/plan.py b/backend/app/models/plan.py index ff5e715..b5c933c 100644 --- a/backend/app/models/plan.py +++ b/backend/app/models/plan.py @@ -123,6 +123,7 @@ class Age4ProjectionResponse(BaseModel): projected_left_at_b4: int savable_after_b4: int expiring_at_b4: int + savable_cap_at_b4: int class PlanProposalResponse(BaseModel): @@ -218,6 +219,7 @@ class PlanProposalResponse(BaseModel): projected_left_at_b4=p.projected_left_at_b4, savable_after_b4=p.savable_after_b4, expiring_at_b4=p.expiring_at_b4, + savable_cap_at_b4=p.savable_cap_at_b4, ) for p in proposal.age4_projections ], @@ -231,9 +233,12 @@ class PlanProposalResponse(BaseModel): class ProposedDayApplyItem(BaseModel): """One day to persist. Same shape as `ProposedDayResponse` minus the - explain-only `wish_id`/`reason` (harmless if a caller sends them anyway — - a frontend can hand back `ProposedDayResponse` entries from `/propose` - verbatim; extra fields are ignored). + explain-only `reason` (harmless if a caller sends it anyway — a frontend + can hand back `ProposedDayResponse` entries from `/propose` verbatim; + extra fields are ignored). `wish_id` DOES carry persistence meaning here + (unlike `reason`): it links the persisted row back to the wish that + produced it, so a client handing back a `/propose` day verbatim gets that + attribution persisted for free. `day_type` additionally accepts 'free' (an automatic weekend/red-day entry from a proposal) purely so a client doesn't have to filter those @@ -246,6 +251,8 @@ class ProposedDayApplyItem(BaseModel): child_id: UUID day_type: str = Field(pattern="^(sjukpenning|lagsta|vacation|free)$") is_dubbeldag: bool = False + wish_id: UUID | None = None + is_pinned: bool = False def to_domain(self) -> ProposedDayInput: return ProposedDayInput( @@ -254,6 +261,8 @@ class ProposedDayApplyItem(BaseModel): child_id=self.child_id, day_type=self.day_type, is_dubbeldag=self.is_dubbeldag, + wish_id=self.wish_id, + is_pinned=self.is_pinned, ) diff --git a/backend/app/models/planned_leave.py b/backend/app/models/planned_leave.py index 0f65fcb..7a8fa6c 100644 --- a/backend/app/models/planned_leave.py +++ b/backend/app/models/planned_leave.py @@ -19,6 +19,9 @@ class PlannedLeaveDayCreate(BaseModel): date: date day_type: str = Field(pattern="^(sjukpenning|lagsta|vacation)$") is_dubbeldag: bool = False + # Which `LeaveWish` produced this day; None = ad-hoc. + wish_id: UUID | None = None + is_pinned: bool = False class PlannedLeaveDayUpdate(BaseModel): @@ -26,6 +29,8 @@ class PlannedLeaveDayUpdate(BaseModel): day_type: str | None = Field(None, pattern="^(sjukpenning|lagsta|vacation)$") is_dubbeldag: bool | None = None + wish_id: UUID | None = None + is_pinned: bool | None = None class PlannedLeaveDayResponse(BaseModel): @@ -36,6 +41,8 @@ class PlannedLeaveDayResponse(BaseModel): date: date day_type: str is_dubbeldag: bool + wish_id: UUID | None + is_pinned: bool created_at: datetime | None updated_at: datetime | None @@ -49,6 +56,8 @@ class PlannedLeaveDayResponse(BaseModel): date=entity.date, day_type=entity.day_type, is_dubbeldag=entity.is_dubbeldag, + wish_id=entity.wish_id, + is_pinned=entity.is_pinned, created_at=entity.created_at, updated_at=entity.updated_at, ) diff --git a/backend/app/models/workspace.py b/backend/app/models/workspace.py index e329319..e25314a 100644 --- a/backend/app/models/workspace.py +++ b/backend/app/models/workspace.py @@ -15,14 +15,12 @@ class WorkspaceCreate(BaseModel): class WorkspaceUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=100) - planning_goal: str | None = None planning_mode: str | None = None class WorkspaceResponse(BaseModel): id: UUID name: str - planning_goal: str planning_mode: str created_at: datetime | None @@ -31,7 +29,6 @@ class WorkspaceResponse(BaseModel): return cls( id=ws.id, name=ws.name, - planning_goal=ws.planning_goal, planning_mode=ws.planning_mode, created_at=ws.created_at, ) diff --git a/backend/app/repositories_impl/sqlite/leave_wish_repository.py b/backend/app/repositories_impl/sqlite/leave_wish_repository.py index f91ec7c..c0af868 100644 --- a/backend/app/repositories_impl/sqlite/leave_wish_repository.py +++ b/backend/app/repositories_impl/sqlite/leave_wish_repository.py @@ -32,6 +32,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): window_end_month=row["window_end_month"], window_end_day=row["window_end_day"], target_weeks=row["target_weeks"], + target_days=row["target_days"], recurrence=row["recurrence"], first_year=row["first_year"], last_year=row["last_year"], @@ -42,7 +43,6 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): if row["parent_personal_data_uuid"] is not None else None ), - goal_override=row["goal_override"], window_mode=row["window_mode"], created_at=datetime.fromisoformat(row["created_at"]), updated_at=datetime.fromisoformat(row["updated_at"]), @@ -57,8 +57,8 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): INSERT INTO leave_wish (id, workspace_id, child_id, name, name_message_key, window_start_month, window_start_day, window_end_month, window_end_day, - target_weeks, recurrence, first_year, last_year, - priority, parent_mode, parent_personal_data_uuid, goal_override, + target_weeks, target_days, recurrence, first_year, last_year, + priority, parent_mode, parent_personal_data_uuid, window_mode, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, @@ -73,6 +73,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): item.window_end_month, item.window_end_day, item.target_weeks, + item.target_days, item.recurrence, item.first_year, item.last_year, @@ -83,7 +84,6 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): if item.parent_personal_data_uuid is not None else None ), - item.goal_override, item.window_mode, item.created_at.isoformat(), item.updated_at.isoformat(), @@ -114,9 +114,9 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): SET child_id = ?, name = ?, name_message_key = ?, window_start_month = ?, window_start_day = ?, window_end_month = ?, window_end_day = ?, - target_weeks = ?, recurrence = ?, first_year = ?, last_year = ?, + target_weeks = ?, target_days = ?, recurrence = ?, first_year = ?, last_year = ?, priority = ?, parent_mode = ?, parent_personal_data_uuid = ?, - goal_override = ?, window_mode = ?, updated_at = ? + window_mode = ?, updated_at = ? WHERE id = ? """, ( @@ -128,6 +128,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): item.window_end_month, item.window_end_day, item.target_weeks, + item.target_days, item.recurrence, item.first_year, item.last_year, @@ -138,7 +139,6 @@ class SQLiteLeaveWishRepository(LeaveWishRepository): if item.parent_personal_data_uuid is not None else None ), - item.goal_override, item.window_mode, item.updated_at.isoformat(), str(id), diff --git a/backend/app/repositories_impl/sqlite/planned_leave_repository.py b/backend/app/repositories_impl/sqlite/planned_leave_repository.py index e8a2a93..c902f59 100644 --- a/backend/app/repositories_impl/sqlite/planned_leave_repository.py +++ b/backend/app/repositories_impl/sqlite/planned_leave_repository.py @@ -25,6 +25,8 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository): date=date.fromisoformat(row["date"]), day_type=row["day_type"], is_dubbeldag=bool(row["is_dubbeldag"]), + wish_id=UUID(row["wish_id"]) if row["wish_id"] is not None else None, + is_pinned=bool(row["is_pinned"]), created_at=datetime.fromisoformat(row["created_at"]), updated_at=datetime.fromisoformat(row["updated_at"]), ) @@ -37,8 +39,8 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository): """ INSERT INTO planned_leave_day (id, workspace_id, child_id, parent_personal_data_uuid, date, - day_type, is_dubbeldag, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + day_type, is_dubbeldag, wish_id, is_pinned, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( str(item.id), @@ -48,6 +50,8 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository): item.date.isoformat(), item.day_type, int(item.is_dubbeldag), + str(item.wish_id) if item.wish_id is not None else None, + int(item.is_pinned), item.created_at.isoformat(), item.updated_at.isoformat(), ), @@ -67,10 +71,17 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository): cursor = await self._conn.execute( """ UPDATE planned_leave_day - SET day_type = ?, is_dubbeldag = ?, updated_at = ? + SET day_type = ?, is_dubbeldag = ?, wish_id = ?, is_pinned = ?, updated_at = ? WHERE id = ? """, - (item.day_type, int(item.is_dubbeldag), item.updated_at.isoformat(), str(id)), + ( + item.day_type, + int(item.is_dubbeldag), + str(item.wish_id) if item.wish_id is not None else None, + int(item.is_pinned), + item.updated_at.isoformat(), + str(id), + ), ) await self._conn.commit() if cursor.rowcount == 0: diff --git a/backend/app/repositories_impl/sqlite/workspace_repository.py b/backend/app/repositories_impl/sqlite/workspace_repository.py index d8aab90..625d518 100644 --- a/backend/app/repositories_impl/sqlite/workspace_repository.py +++ b/backend/app/repositories_impl/sqlite/workspace_repository.py @@ -24,7 +24,6 @@ class SQLiteWorkspaceRepository(WorkspaceRepository): return Workspace( id=UUID(row["id"]), name=row["name"], - planning_goal=row["planning_goal"], planning_mode=row["planning_mode"], created_at=datetime.fromisoformat(row["created_at"]), ) @@ -33,13 +32,12 @@ class SQLiteWorkspaceRepository(WorkspaceRepository): item.created_at = item.created_at or datetime.now() await self._conn.execute( """ - INSERT INTO workspace (id, name, planning_goal, planning_mode, created_at) - VALUES (?, ?, ?, ?, ?) + INSERT INTO workspace (id, name, planning_mode, created_at) + VALUES (?, ?, ?, ?) """, ( str(item.id), item.name, - item.planning_goal, item.planning_mode, item.created_at.isoformat(), ), @@ -54,8 +52,8 @@ class SQLiteWorkspaceRepository(WorkspaceRepository): async def update(self, id: UUID, item: Workspace) -> Workspace: cursor = await self._conn.execute( - "UPDATE workspace SET name = ?, planning_goal = ?, planning_mode = ? WHERE id = ?", - (item.name, item.planning_goal, item.planning_mode, str(id)), + "UPDATE workspace SET name = ?, planning_mode = ? WHERE id = ?", + (item.name, item.planning_mode, str(id)), ) await self._conn.commit() if cursor.rowcount == 0: diff --git a/backend/app/routers/analytics.py b/backend/app/routers/analytics.py index 13dc1a7..3a52d58 100644 --- a/backend/app/routers/analytics.py +++ b/backend/app/routers/analytics.py @@ -10,6 +10,7 @@ from app.dependencies import ( ParentalLeaveRepositoryDep, PersonalDataRepositoryDep, PlannedLeaveRepositoryDep, + WorkspaceRepositoryDep, ) from app.models.analytics import Age4ProjectionResponse, WorkspaceOverviewResponse from app.services.analytics_service import AnalyticsService @@ -23,8 +24,9 @@ def _make_service( planned_repo: PlannedLeaveRepositoryDep, child_repo: ChildRepositoryDep, pd_repo: PersonalDataRepositoryDep, + workspace_repo: WorkspaceRepositoryDep, ) -> AnalyticsService: - return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo) + return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo) @router.get("/{workspace_id}/overview", response_model=WorkspaceOverviewResponse) @@ -35,9 +37,10 @@ async def get_overview( planned_repo: PlannedLeaveRepositoryDep, child_repo: ChildRepositoryDep, pd_repo: PersonalDataRepositoryDep, + workspace_repo: WorkspaceRepositoryDep, _current_user: CurrentUserDep, ) -> WorkspaceOverviewResponse: - service = _make_service(leave_repo, planned_repo, child_repo, pd_repo) + service = _make_service(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo) try: overview = await service.get_overview(workspace_id, child_id) except NotFoundError as exc: @@ -53,9 +56,10 @@ async def get_projection( planned_repo: PlannedLeaveRepositoryDep, child_repo: ChildRepositoryDep, pd_repo: PersonalDataRepositoryDep, + workspace_repo: WorkspaceRepositoryDep, _current_user: CurrentUserDep, ) -> WorkspaceOverviewResponse: - service = _make_service(leave_repo, planned_repo, child_repo, pd_repo) + service = _make_service(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo) try: overview = await service.get_overview(workspace_id, child_id) except NotFoundError as exc: @@ -73,9 +77,10 @@ async def get_age4_projection( planned_repo: PlannedLeaveRepositoryDep, child_repo: ChildRepositoryDep, pd_repo: PersonalDataRepositoryDep, + workspace_repo: WorkspaceRepositoryDep, _current_user: CurrentUserDep, ) -> Age4ProjectionResponse: - service = _make_service(leave_repo, planned_repo, child_repo, pd_repo) + service = _make_service(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo) try: proj = await service.get_days_left_at_age_4(personal_data_uuid, child_id) except NotFoundError as exc: diff --git a/backend/app/routers/leave_wishes.py b/backend/app/routers/leave_wishes.py index 83642d8..7ee9162 100644 --- a/backend/app/routers/leave_wishes.py +++ b/backend/app/routers/leave_wishes.py @@ -1,8 +1,8 @@ """HTTP routes for leave wishes. Routers stay thin: validate via Pydantic, call the service, map domain errors -to HTTP. Business logic (recurrence/parent_mode/goal_override validation, -month-day range checks) lives in LeaveWishService. See PLANNING_ENGINE_SPEC.md §4. +to HTTP. Business logic (recurrence/parent_mode validation, month-day range +checks) lives in LeaveWishService. See PLANNING_ENGINE_SPEC.md §4. """ from uuid import UUID @@ -35,13 +35,13 @@ async def create_leave_wish( window_end_month=payload.window_end_month, window_end_day=payload.window_end_day, target_weeks=payload.target_weeks, + target_days=payload.target_days, recurrence=payload.recurrence, first_year=payload.first_year, last_year=payload.last_year, priority=payload.priority, parent_mode=payload.parent_mode, parent_personal_data_uuid=payload.parent_personal_data_uuid, - goal_override=payload.goal_override, window_mode=payload.window_mode, ) except ValidationError as exc: @@ -77,13 +77,13 @@ async def update_leave_wish( window_end_month=payload.window_end_month, window_end_day=payload.window_end_day, target_weeks=payload.target_weeks, + target_days=payload.target_days, recurrence=payload.recurrence, first_year=payload.first_year, last_year=payload.last_year, priority=payload.priority, parent_mode=payload.parent_mode, parent_personal_data_uuid=payload.parent_personal_data_uuid, - goal_override=payload.goal_override, window_mode=payload.window_mode, _clear_child_id="child_id" in fields_set and payload.child_id is None, _clear_last_year="last_year" in fields_set and payload.last_year is None, @@ -91,10 +91,10 @@ async def update_leave_wish( "parent_personal_data_uuid" in fields_set and payload.parent_personal_data_uuid is None ), - _clear_goal_override=("goal_override" in fields_set and payload.goal_override is None), _clear_name_message_key=( "name_message_key" in fields_set and payload.name_message_key is None ), + _clear_target_days="target_days" in fields_set and payload.target_days is None, ) except ValidationError as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc diff --git a/backend/app/routers/workspace.py b/backend/app/routers/workspace.py index bb84d1f..6717f46 100644 --- a/backend/app/routers/workspace.py +++ b/backend/app/routers/workspace.py @@ -81,9 +81,7 @@ async def update_workspace( _current_user: CurrentUserDep, ) -> WorkspaceResponse: try: - ws = await service.update_workspace( - workspace_id, payload.name, payload.planning_goal, payload.planning_mode - ) + ws = await service.update_workspace(workspace_id, payload.name, payload.planning_mode) except (NotFoundError, ValidationError) as exc: code = ( status.HTTP_404_NOT_FOUND diff --git a/backend/app/services/allocation_service.py b/backend/app/services/allocation_service.py index bdebbba..5e53f8f 100644 --- a/backend/app/services/allocation_service.py +++ b/backend/app/services/allocation_service.py @@ -42,7 +42,11 @@ from app.utils.holidays import ( cached_year_bounds, get_swedish_holidays, ) -from app.utils.tip_predicates import days_left_at_age_4_projection, has_vacation_near_expiry +from app.utils.tip_predicates import ( + days_left_at_age_4_projection, + has_vacation_near_expiry, + resolve_age4_savable_cap, +) # --------------------------------------------------------------------------- # Reason codes (§3.5) — machine-readable so the UI can explain itself. @@ -106,7 +110,12 @@ MESSAGE_KEY_WINDOW_EXPLANATION = "planOutcomes.windowExplanation" _SUNDAY_HOLIDAY_NAME = SUNDAY_HOLIDAY_NAME _DUBBELDAGAR_MONTHS_LIMIT = settings.rules.dubbeldagar_months_limit _MAX_DUBBELDAGAR = settings.rules.max_dubbeldagar -_MAX_SAVABLE_AT_B4 = settings.rules.max_savable_days_at_age_4 +# NOTE: no module-level "_MAX_SAVABLE_AT_B4" here on purpose — which age-4 cap +# applies (96 per child vs. 48 per parent) depends on the workspace's +# `planning_mode` and parent count, so it is resolved once per `propose()` +# run by `AllocationInputLoader` onto `AllocationInput.age4_savable_cap` and +# read from there (`self._input.age4_savable_cap`) everywhere `_Ledger` needs +# it — never as a constant computed at import time. _SGI_MIN_DAYS_PER_WEEK = settings.rules.sgi_min_days_per_week_after_age_1 # A "week off" costs ~5 spendable weekdays (Sat/Sun are already free) — see # §3.4: "a 6-week summer window is ~42 calendar days but only ~30 spendable @@ -178,8 +187,12 @@ class VacationBudget: class AllocationInput: today: date horizon_end: date - planning_goal: str # 'max_time' | 'max_money' | 'balanced' planning_mode: str # 'solo' | 'couple' + # The age-4 savable-days ceiling that applies to THIS workspace, resolved + # once by the loader via `app.utils.tip_predicates.resolve_age4_savable_cap` + # (96 per child, or 48 per parent for a couple workspace with 2 parents) — + # never re-derived inside the (pure) `_Ledger`. + age4_savable_cap: int children: list[ChildFacts] parents: list[ParentFacts] pools: dict[tuple[UUID, UUID], DayPool] # (parent, child) -> pool @@ -254,8 +267,10 @@ class Age4Projection: child_id: UUID fourth_birthday: date projected_left_at_b4: int - savable_after_b4: int # min(projected_left_at_b4, max_savable_days_at_age_4) - expiring_at_b4: int # max(projected_left_at_b4 - max_savable, 0) — LOST if not used + savable_after_b4: int # min(projected_left_at_b4, savable_cap_at_b4) + expiring_at_b4: int # max(projected_left_at_b4 - savable_cap_at_b4, 0) — LOST if not used + # The cap actually applied — see `app.utils.tip_predicates.resolve_age4_savable_cap`. + savable_cap_at_b4: int @dataclass(frozen=True) @@ -733,7 +748,8 @@ class _Ledger: def _maybe_clamp_age4(self, pool_key: tuple[UUID, UUID] | None, window_start: date) -> None: """§9.1: from the 4th birthday onward, the FP pool is hard-capped at - min(remaining, 96). Applied once per pool, the first time (in + min(remaining, `self._input.age4_savable_cap`). Applied once per + pool, the first time (in priority-then-chronological processing order) a window starting on or after the child's 4th birthday is funded from it. @@ -754,7 +770,7 @@ class _Ledger: if pool is None or pool.clamped_at_b4: return remaining = pool.sjukpenning_left + pool.lagsta_left - capped = min(remaining, _MAX_SAVABLE_AT_B4) + capped = min(remaining, self._input.age4_savable_cap) excess = remaining - capped if excess > 0: cut_lagsta = min(excess, pool.lagsta_left) @@ -772,9 +788,15 @@ class _Ledger: parent: ParentFacts, child: ChildFacts | None, pool_key: tuple[UUID, UUID] | None, - resolved_goal: str, - early: bool, ) -> tuple[str, str] | None: + """§3.3 tier-3 interior resolution — the single policy: maximise the + value extracted from FP + vacation days combined. `salary_type` still + branches (not a goal): a monthly-salaried parent loses nothing by + spending a vacation day, so vacation goes first and FP (sjukpenning + 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. + """ if child is None or pool_key is None: # No resolvable child (genuinely ambiguous family-wide wish, or a # stale child_id) -> no FP pool to draw from. Vacation or nothing. @@ -782,48 +804,14 @@ class _Ledger: return "vacation", REASON_VACATION_WRAP_WEEKEND return None - if resolved_goal == "max_money": - if parent.salary_type == "monthly": - if self._spend_vacation(parent.uuid, d): - return "vacation", REASON_VACATION_WRAP_WEEKEND - fp = self._spend_fp(pool_key, prefer="sjukpenning") - if fp: - return fp, ( - REASON_FP_INTERIOR if fp == "sjukpenning" else REASON_FP_LAGSTA_STRETCH - ) - return None - fp = self._spend_fp(pool_key, prefer="sjukpenning") - if fp: - return fp, (REASON_FP_INTERIOR if fp == "sjukpenning" else REASON_FP_LAGSTA_STRETCH) - if self._spend_vacation(parent.uuid, d): - return "vacation", REASON_VACATION_WRAP_WEEKEND - return None - - if resolved_goal == "max_time": - if d < child.birth_date + relativedelta(months=12): - # skip_fp_first_12_months: SGI is protected regardless, so - # don't spend the scarce FP pool now — save it to stretch - # later. Vacation only, or leave unfunded. - if self._spend_vacation(parent.uuid, d): - return "vacation", REASON_VACATION_WRAP_WEEKEND - return None - # stretch_time_at_home: draw the flat lagsta tier first to - # preserve the higher-value sjukpenning tier for later. - fp = self._spend_fp(pool_key, prefer="lagsta") - if fp: - return fp, (REASON_FP_LAGSTA_STRETCH if fp == "lagsta" else REASON_FP_INTERIOR) - if self._spend_vacation(parent.uuid, d): - return "vacation", REASON_VACATION_WRAP_WEEKEND - return None - - # 'balanced': semester early in the window, FP for the remainder. - if early: + if parent.salary_type == "monthly": if self._spend_vacation(parent.uuid, d): return "vacation", REASON_VACATION_WRAP_WEEKEND fp = self._spend_fp(pool_key, prefer="sjukpenning") if fp: return fp, (REASON_FP_INTERIOR if fp == "sjukpenning" else REASON_FP_LAGSTA_STRETCH) return None + fp = self._spend_fp(pool_key, prefer="sjukpenning") if fp: return fp, (REASON_FP_INTERIOR if fp == "sjukpenning" else REASON_FP_LAGSTA_STRETCH) @@ -838,7 +826,6 @@ class _Ledger: wish: LeaveWish, dates: list[date], red_cluster: set[date], - resolved_goal: str, parent: ParentFacts, free_reason_override: str | None, ) -> tuple[list[ProposedDay], dict[str, int]]: @@ -847,9 +834,8 @@ class _Ledger: child = self._resolve_wish_child(wish) child_id = child.id if child is not None else None pool_key = (parent.uuid, child_id) if child_id is not None else None - half_index = (len(dates) + 1) // 2 - for i, d in enumerate(dates): + for d in dates: existing_type = self._existing_lookup.get((parent.uuid, child_id, d)) if existing_type is not None: cost[existing_type] = cost.get(existing_type, 0) + 1 @@ -877,9 +863,7 @@ class _Ledger: resolved = ("vacation", REASON_VACATION_WRAP_RED_DAY) if resolved is None: - resolved = self._assign_tier3( - wish, d, parent, child, pool_key, resolved_goal, early=(i < half_index) - ) + resolved = self._assign_tier3(wish, d, parent, child, pool_key) if resolved is None: continue # unfunded — reflected in the window's shortfall_days @@ -896,7 +880,6 @@ class _Ledger: w: _WishWindow, dates: list[date], red_cluster: set[date], - resolved_goal: str, responsible: _Responsible, ) -> tuple[list[ProposedDay], dict[str, int]]: if not dates: @@ -912,10 +895,10 @@ class _Ledger: self._maybe_clamp_age4((parent_b.uuid, resolved_child.id), w.start) days_a, cost_a = self._fund_solo( - wish, first_half, red_cluster, resolved_goal, parent_a, REASON_SWAP_FIRST_HALF + wish, first_half, red_cluster, parent_a, REASON_SWAP_FIRST_HALF ) days_b, cost_b = self._fund_solo( - wish, second_half, red_cluster, resolved_goal, parent_b, REASON_SWAP_SECOND_HALF + wish, second_half, red_cluster, parent_b, REASON_SWAP_SECOND_HALF ) merged_cost = {k: cost_a[k] + cost_b[k] for k in cost_a} return days_a + days_b, merged_cost @@ -926,14 +909,13 @@ class _Ledger: w: _WishWindow, dates: list[date], red_cluster: set[date], - resolved_goal: str, responsible: _Responsible, ) -> tuple[list[ProposedDay], dict[str, int]]: parent_a, parent_b = responsible.parents[0], responsible.parents[1] child = self._resolve_wish_child(wish) if child is None: - return self._fund_swap(wish, w, dates, red_cluster, resolved_goal, responsible) + return self._fund_swap(wish, w, dates, red_cluster, responsible) self._maybe_clamp_age4((parent_a.uuid, child.id), w.start) self._maybe_clamp_age4((parent_b.uuid, child.id), w.start) @@ -998,7 +980,7 @@ class _Ledger: if leftover_for_swap: swap_days, swap_cost = self._fund_swap( - wish, w, sorted(leftover_for_swap), red_cluster, resolved_goal, responsible + wish, w, sorted(leftover_for_swap), red_cluster, responsible ) days.extend(swap_days) for k in cost: @@ -1010,7 +992,6 @@ class _Ledger: def fund_window(self, w: _WishWindow) -> tuple[list[ProposedDay], WindowOutcome]: wish = w.wish - resolved_goal = wish.goal_override or self._input.planning_goal responsible = self._resolve_parents(wish) all_dates = _dates_in(w.start, w.end) @@ -1019,21 +1000,17 @@ class _Ledger: ) if responsible.mode == "dubbeldag": - days, cost = self._fund_both( - wish, w, all_dates, red_cluster, resolved_goal, responsible - ) + days, cost = self._fund_both(wish, w, all_dates, red_cluster, responsible) strategy = "dubbeldag" elif responsible.mode == "swap": - days, cost = self._fund_swap( - wish, w, all_dates, red_cluster, resolved_goal, responsible - ) + days, cost = self._fund_swap(wish, w, all_dates, red_cluster, responsible) strategy = "parent_swap" else: parent = responsible.parents[0] resolved_child = self._resolve_wish_child(wish) if resolved_child is not None: self._maybe_clamp_age4((parent.uuid, resolved_child.id), w.start) - days, cost = self._fund_solo(wish, all_dates, red_cluster, resolved_goal, parent, None) + days, cost = self._fund_solo(wish, all_dates, red_cluster, parent, None) strategy = "solo" funded_home_days = cost["free"] + cost["vacation"] + cost["sjukpenning"] + cost["lagsta"] @@ -1329,7 +1306,7 @@ class _Ledger: already-claimed date — the 'unplaced' case (§10.4), for the caller to report via `build_unplaced_outcome`. """ - length_days = wish.target_weeks * 7 + length_days = wish.target_span_days() bound_length = (bound_end - bound_start).days + 1 if bound_length < length_days: @@ -1382,7 +1359,7 @@ class _Ledger: (`WindowOutcome`) rather than inventing a new failure mode; the only new value is `strategy = 'unplaced'`. """ - length_days = wish.target_weeks * 7 + length_days = wish.target_span_days() bound_window = _WishWindow( wish=wish, anchor_year=anchor_year, start=bound_start, end=bound_end ) @@ -1466,6 +1443,7 @@ class _Ledger: days_used=0, planned_fp_before_b4=existing_before + new_before, total_days=total_initial, + max_savable=self._input.age4_savable_cap, ) return result @@ -1507,7 +1485,7 @@ class _Ledger: child = self._children_by_id.get(child_id) if child is None: continue - savable = min(projected_left, _MAX_SAVABLE_AT_B4) + savable = min(projected_left, self._input.age4_savable_cap) projections.append( Age4Projection( parent_personal_data_uuid=parent_uuid, @@ -1516,6 +1494,7 @@ class _Ledger: projected_left_at_b4=projected_left, savable_after_b4=savable, expiring_at_b4=expiring, + savable_cap_at_b4=self._input.age4_savable_cap, ) ) return projections @@ -2023,8 +2002,8 @@ class AllocationInputLoader: return AllocationInput( today=today, horizon_end=horizon_end, - planning_goal=ws.planning_goal, planning_mode=ws.planning_mode, + age4_savable_cap=resolve_age4_savable_cap(ws.planning_mode, len(parents)), children=children, parents=parents, pools=pools, diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py index 227f500..2a80e3b 100644 --- a/backend/app/services/analytics_service.py +++ b/backend/app/services/analytics_service.py @@ -4,8 +4,12 @@ The headline calculation (§6.5 ARCHITECTURE.md): projected_used_by_B4 = days_used + planned_days(date < B4) projected_left_at_B4 = total_days - projected_used_by_B4 - savable_after_B4 = min(projected_left_at_B4, 96) # age-4 ceiling - expiring_at_B4 = max(projected_left_at_B4 - 96, 0) # LOST days + savable_after_B4 = min(projected_left_at_B4, CAP) # age-4 ceiling + expiring_at_B4 = max(projected_left_at_B4 - CAP, 0) # LOST days + +Where `CAP` is 96 (per child) normally, or 48 (per parent) for a `couple` +workspace with two parents — see `app.utils.tip_predicates. +resolve_age4_savable_cap`, the single function that decides which applies. All rule constants come from config.LeaveRules — no magic numbers. dateutil.relativedelta handles leap-year safe 4th-birthday arithmetic. @@ -24,8 +28,9 @@ from app.repositories.child_repository import ChildRepository from app.repositories.parental_leave_repository import ParentalLeaveRepository from app.repositories.personal_data_repository import PersonalDataRepository from app.repositories.planned_leave_repository import PlannedLeaveRepository +from app.repositories.workspace_repository import WorkspaceRepository from app.utils.errors import NotFoundError -from app.utils.tip_predicates import days_left_at_age_4_projection +from app.utils.tip_predicates import days_left_at_age_4_projection, resolve_age4_savable_cap @dataclass @@ -40,8 +45,13 @@ class Age4Projection: days_used: int # from latest FK snapshot planned_days_before_b4: int # from calendar projected_left_at_b4: int - savable_after_b4: int # min(projected_left, 96) - expiring_at_b4: int # max(projected_left - 96, 0) — LOST if not used + savable_after_b4: int # min(projected_left, savable_cap_at_b4) + expiring_at_b4: int # max(projected_left - savable_cap_at_b4, 0) — LOST if not used + # The cap actually applied — 96 (per child) or 48 (per parent, couple + # workspace with 2 parents). See `resolve_age4_savable_cap`. Surfaced so + # the frontend can render "X kvar / Y sparbara" without re-deriving the + # rule itself. + savable_cap_at_b4: int @dataclass @@ -61,11 +71,25 @@ class AnalyticsService: planned_leave_repo: PlannedLeaveRepository, child_repo: ChildRepository, personal_data_repo: PersonalDataRepository, + workspace_repo: WorkspaceRepository, ) -> None: self._leave = parental_leave_repo self._planned = planned_leave_repo self._children = child_repo self._parents = personal_data_repo + self._workspace_repo = workspace_repo + + async def _resolve_savable_cap(self, workspace_id: UUID) -> int: + """The single call site AnalyticsService uses for the age-4 cap — + + wraps `resolve_age4_savable_cap` with the DB reads it needs + (workspace planning_mode + member count) so callers never resolve + the cap any other way. + """ + workspace = await self._workspace_repo.get_by_id(workspace_id) + planning_mode = workspace.planning_mode if workspace is not None else "solo" + members = await self._parents.list_by_workspace(workspace_id) + return resolve_age4_savable_cap(planning_mode, len(members)) async def get_days_left_at_age_4( self, personal_data_uuid: UUID, child_id: UUID @@ -108,6 +132,8 @@ class AnalyticsService: ) planned_fp_before_b4 = sjukpenning_before_b4 + lagsta_before_b4 + savable_cap = await self._resolve_savable_cap(child.workspace_id) + # Shared with the plan-proposal side (`allocation_service._Ledger. # build_age4_projections`) so the two surfaces can never re-derive # this arithmetic differently — see `days_left_at_age_4_projection`. @@ -115,8 +141,9 @@ class AnalyticsService: days_used=days_used, planned_fp_before_b4=planned_fp_before_b4, total_days=total_days, + max_savable=savable_cap, ) - savable = min(projected_left, rules.max_savable_days_at_age_4) + savable = min(projected_left, savable_cap) return Age4Projection( personal_data_uuid=personal_data_uuid, @@ -129,6 +156,7 @@ class AnalyticsService: projected_left_at_b4=projected_left, savable_after_b4=savable, expiring_at_b4=expiring, + savable_cap_at_b4=savable_cap, ) async def get_overview(self, workspace_id: UUID, child_id: UUID) -> WorkspaceOverview: diff --git a/backend/app/services/leave_wish_service.py b/backend/app/services/leave_wish_service.py index 4d9ac06..eac86bf 100644 --- a/backend/app/services/leave_wish_service.py +++ b/backend/app/services/leave_wish_service.py @@ -1,9 +1,8 @@ """Business logic for leave wishes (family recurring home-together intent). See PLANNING_ENGINE_SPEC.md §2.1. This service owns the validation the -allocator (Phase B) depends on: valid recurrence/parent_mode/goal_override, -sane month/day ranges, and the parent_mode <-> parent_personal_data_uuid -pairing rule. +allocator (Phase B) depends on: valid recurrence/parent_mode, sane month/day +ranges, and the parent_mode <-> parent_personal_data_uuid pairing rule. """ from datetime import date, datetime @@ -15,7 +14,6 @@ from app.utils.errors import NotFoundError, ValidationError _VALID_RECURRENCE = {"yearly", "once"} _VALID_PARENT_MODE = {"either", "both", "specific"} -_VALID_GOAL_OVERRIDE = {"max_time", "max_money", "balanced"} _VALID_WINDOW_MODE = {"fixed", "flexible"} # A leap year, so day=29 for month=2 (Feb 29) is accepted when validating ranges. @@ -38,12 +36,12 @@ class LeaveWishService: first_year: int, child_id: UUID | None = None, name_message_key: str | None = None, + target_days: int | None = None, recurrence: str = "once", last_year: int | None = None, priority: int = 1, parent_mode: str = "either", parent_personal_data_uuid: UUID | None = None, - goal_override: str | None = None, window_mode: str = "fixed", ) -> LeaveWish: _validate( @@ -52,12 +50,12 @@ class LeaveWishService: window_end_month=window_end_month, window_end_day=window_end_day, target_weeks=target_weeks, + target_days=target_days, recurrence=recurrence, first_year=first_year, last_year=last_year, parent_mode=parent_mode, parent_personal_data_uuid=parent_personal_data_uuid, - goal_override=goal_override, window_mode=window_mode, ) now = datetime.now() @@ -72,13 +70,13 @@ class LeaveWishService: window_end_month=window_end_month, window_end_day=window_end_day, target_weeks=target_weeks, + target_days=target_days, recurrence=recurrence, first_year=first_year, last_year=last_year, priority=priority, parent_mode=parent_mode, parent_personal_data_uuid=parent_personal_data_uuid, - goal_override=goal_override, window_mode=window_mode, created_at=now, updated_at=now, @@ -105,19 +103,19 @@ class LeaveWishService: window_end_month: int | None = None, window_end_day: int | None = None, target_weeks: int | None = None, + target_days: int | None = None, recurrence: str | None = None, first_year: int | None = None, last_year: int | None = None, priority: int | None = None, parent_mode: str | None = None, parent_personal_data_uuid: UUID | None = None, - goal_override: str | None = None, window_mode: str | None = None, _clear_child_id: bool = False, _clear_last_year: bool = False, _clear_parent_personal_data_uuid: bool = False, - _clear_goal_override: bool = False, _clear_name_message_key: bool = False, + _clear_target_days: bool = False, ) -> LeaveWish: """Partial update — only non-None fields are applied. @@ -130,6 +128,9 @@ class LeaveWishService: regardless of what was passed for it, since a caller (frontend bug, future API client, ...) forgetting to clear it would silently make a user's edited name revert to a stale preset label. + + `_clear_target_days` resets a day-granular wish back to being sized + by `target_weeks` alone. """ entity = await self.get(leave_wish_id) original_name = entity.name @@ -156,6 +157,10 @@ class LeaveWishService: entity.window_end_day = window_end_day if target_weeks is not None: entity.target_weeks = target_weeks + if _clear_target_days: + entity.target_days = None + elif target_days is not None: + entity.target_days = target_days if recurrence is not None: entity.recurrence = recurrence if first_year is not None: @@ -172,10 +177,6 @@ class LeaveWishService: entity.parent_personal_data_uuid = None elif parent_personal_data_uuid is not None: entity.parent_personal_data_uuid = parent_personal_data_uuid - if _clear_goal_override: - entity.goal_override = None - elif goal_override is not None: - entity.goal_override = goal_override if window_mode is not None: entity.window_mode = window_mode @@ -185,12 +186,12 @@ class LeaveWishService: window_end_month=entity.window_end_month, window_end_day=entity.window_end_day, target_weeks=entity.target_weeks, + target_days=entity.target_days, recurrence=entity.recurrence, first_year=entity.first_year, last_year=entity.last_year, parent_mode=entity.parent_mode, parent_personal_data_uuid=entity.parent_personal_data_uuid, - goal_override=entity.goal_override, window_mode=entity.window_mode, ) return await self._repo.update(leave_wish_id, entity) @@ -207,20 +208,18 @@ def _validate( window_end_month: int, window_end_day: int, target_weeks: int, + target_days: int | None, recurrence: str, first_year: int, last_year: int | None, parent_mode: str, parent_personal_data_uuid: UUID | None, - goal_override: str | None, window_mode: str, ) -> None: if recurrence not in _VALID_RECURRENCE: raise ValidationError(f"recurrence must be one of {_VALID_RECURRENCE}") if parent_mode not in _VALID_PARENT_MODE: raise ValidationError(f"parent_mode must be one of {_VALID_PARENT_MODE}") - if goal_override is not None and goal_override not in _VALID_GOAL_OVERRIDE: - raise ValidationError(f"goal_override must be one of {_VALID_GOAL_OVERRIDE} or None") if window_mode not in _VALID_WINDOW_MODE: raise ValidationError(f"window_mode must be one of {_VALID_WINDOW_MODE}") @@ -229,6 +228,8 @@ def _validate( if target_weeks <= 0: raise ValidationError("target_weeks must be > 0") + if target_days is not None and target_days < 1: + raise ValidationError("target_days must be >= 1") if last_year is not None and last_year < first_year: raise ValidationError("last_year must be >= first_year") @@ -244,18 +245,20 @@ def _validate( if window_mode == "flexible": # PLANNING_ENGINE_SPEC.md §10.1 — the bounding range's raw length - # (before any horizon clipping) must be able to fit target_weeks; - # "find a 6-week window inside this 2-week range" is a user-input - # error, not a legitimate future shortfall. + # (before any horizon clipping) must be able to fit the wanted + # duration; "find a 6-week window inside this 2-week range" is a + # user-input error, not a legitimate future shortfall. Mirrors + # `LeaveWish.target_span_days()`: `target_days` wins when set, + # otherwise `target_weeks * 7` (no entity exists yet in `create()`). bound_days = _bound_length_days( window_start_month, window_start_day, window_end_month, window_end_day, first_year ) - needed_days = target_weeks * 7 + needed_days = target_days if target_days is not None else target_weeks * 7 if bound_days < needed_days: raise ValidationError( "flexible wish bounding range " - f"({bound_days} days) is shorter than target_weeks " - f"({target_weeks} weeks = {needed_days} days)" + f"({bound_days} days) is shorter than the wanted duration " + f"({needed_days} days)" ) diff --git a/backend/app/services/planning_service.py b/backend/app/services/planning_service.py index 550bb47..d840f26 100644 --- a/backend/app/services/planning_service.py +++ b/backend/app/services/planning_service.py @@ -32,8 +32,12 @@ class ProposedDayInput: Deliberately a plain domain dataclass (not a Pydantic model) so the service stays independent of the wire schema in `app.models.plan` — the router is responsible for that translation. Shape mirrors - `allocation_service.ProposedDay` minus the explain-only `wish_id`/`reason` - fields, which carry no persistence meaning. + `allocation_service.ProposedDay` minus the explain-only `reason` field, + which carries no persistence meaning. `wish_id` DOES carry persistence + meaning (unlike `reason`): it links the persisted `planned_leave_day` row + back to the `LeaveWish` that produced it (nullable — ad-hoc/legacy days + have none), which is what lets the UI later treat a multi-day band as one + editable period instead of one day at a time. """ date: date @@ -41,6 +45,9 @@ class ProposedDayInput: child_id: UUID day_type: str is_dubbeldag: bool = False + wish_id: UUID | None = None + # True once the user hand-edits this day; see `PlannedLeaveDay.is_pinned`. + is_pinned: bool = False @dataclass(frozen=True) @@ -99,6 +106,8 @@ class PlanningService: date: date, day_type: str, is_dubbeldag: bool = False, + wish_id: UUID | None = None, + is_pinned: bool = False, ) -> PlannedLeaveDay: """Add a planned leave day. @@ -131,6 +140,8 @@ class PlanningService: date=date, day_type=day_type, is_dubbeldag=is_dubbeldag, + wish_id=wish_id, + is_pinned=is_pinned, created_at=now, updated_at=now, ) @@ -178,6 +189,8 @@ class PlanningService: planned_leave_day_id: UUID, day_type: str | None = None, is_dubbeldag: bool | None = None, + wish_id: UUID | None = None, + is_pinned: bool | None = None, ) -> PlannedLeaveDay: """Update a planned day (partial update).""" entity = await self._repo.get_by_id(planned_leave_day_id) @@ -188,6 +201,10 @@ class PlanningService: entity.day_type = day_type if is_dubbeldag is not None: entity.is_dubbeldag = is_dubbeldag + if wish_id is not None: + entity.wish_id = wish_id + if is_pinned is not None: + entity.is_pinned = is_pinned return await self._repo.update(planned_leave_day_id, entity) @@ -217,10 +234,15 @@ class PlanningService: Idempotent per (workspace, child, parent, date): re-applying an identical batch creates nothing and is reported as all - `skipped_identical_count`. A date already planned *differently* is a - collision — by default the whole batch is refused and nothing is - written; pass `replace=True` to overwrite those specific collisions - (every other collision-free day in the batch is written too). + `skipped_identical_count`. Identity here is `day_type`/`is_dubbeldag` + only — a day whose only difference from what's persisted is + `wish_id`/`is_pinned` still counts as `skipped_identical_count`, but + is quietly written as a metadata-only update rather than left stale; + it is never treated as a collision. A date already planned + *differently* (in `day_type`/`is_dubbeldag`) is a collision — by + default the whole batch is refused and nothing is written; pass + `replace=True` to overwrite those specific collisions (every other + collision-free day in the batch is written too). Validates the entire batch up front (day types, per-day dubbeldagar window, batch-wide dubbeldagar quota, child eligibility window, @@ -247,7 +269,13 @@ class PlanningService: for d in persistable: key = (d.child_id, d.parent_personal_data_uuid, d.date) prior = deduped.get(key) - if prior is not None and prior != d: + # Conflict is keyed on day_type/is_dubbeldag only — the same + # (child, parent, date) appearing twice with different `wish_id`/ + # `is_pinned` is not a genuine conflict (attribution metadata, + # not the persisted day's identity); the later entry simply wins. + if prior is not None and ( + prior.day_type != d.day_type or prior.is_dubbeldag != d.is_dubbeldag + ): structural_errors.append( Rejection( message_key="planApply.rejection.conflictingDuplicate", @@ -285,6 +313,7 @@ class PlanningService: to_create: list[ProposedDayInput] = [] to_replace: list[tuple[PlannedLeaveDay, ProposedDayInput]] = [] + to_update_metadata: list[tuple[PlannedLeaveDay, ProposedDayInput]] = [] collisions: list[DayCollision] = [] skipped_identical_count = 0 @@ -296,6 +325,14 @@ class PlanningService: continue if existing.day_type == d.day_type and existing.is_dubbeldag == d.is_dubbeldag: skipped_identical_count += 1 + # Identical-vs-collision is (and stays) keyed on + # day_type/is_dubbeldag only. A row whose only difference is + # `wish_id`/`is_pinned` is never a collision requiring + # `replace=True` — it's a plain metadata update, written + # below without touching `skipped_identical_count` or + # `collisions`. + if existing.wish_id != d.wish_id or existing.is_pinned != d.is_pinned: + to_update_metadata.append((existing, d)) continue collisions.append( DayCollision( @@ -322,7 +359,7 @@ class PlanningService: ) pending = to_create + [new for _, new in to_replace] - if not pending: + if not pending and not to_update_metadata: # Nothing left to write — either an empty batch or a fully # idempotent re-apply. return PlanApplyResult( @@ -355,11 +392,18 @@ class PlanningService: date=d.date, day_type=d.day_type, is_dubbeldag=d.is_dubbeldag, + wish_id=d.wish_id, + is_pinned=d.is_pinned, created_at=now, updated_at=now, ) created.append(await self._repo.create(entity)) + for existing, d in to_update_metadata: + existing.wish_id = d.wish_id + existing.is_pinned = d.is_pinned + await self._repo.update(existing.id, existing) + return PlanApplyResult( accepted=True, created=created, diff --git a/backend/app/services/tips_service.py b/backend/app/services/tips_service.py index 8b9c538..fc9ade8 100644 --- a/backend/app/services/tips_service.py +++ b/backend/app/services/tips_service.py @@ -44,9 +44,9 @@ from app.utils.tip_predicates import ( days_left_at_age_4_projection, has_vacation_near_expiry, is_child_over_age, - is_child_under_age, preschool_buffer_needed, red_day_clusters_in_range, + resolve_age4_savable_cap, sgi_at_risk, weekly_intensity, ) @@ -63,7 +63,6 @@ class Tip: @dataclass class TipsContext: workspace_id: UUID - workspace_planning_goal: str # 'max_time' | 'max_money' | 'balanced' children: list[Child] parents: list[PersonalData] leave_by_parent: dict[UUID, ParentalLeaveFetch | None] # personal_data_uuid → latest @@ -71,6 +70,10 @@ class TipsContext: vacation_by_parent: dict[UUID, list[VacationDays]] leave_wishes: list[LeaveWish] today: date # injected for deterministic testing + # 'solo' | 'couple' — see `app.utils.tip_predicates.resolve_age4_savable_cap`. + # Defaulted for backward compatibility with call sites/tests that don't + # care about the age-4 cap split. + workspace_planning_mode: str = "solo" # Type alias for a rule function. @@ -131,6 +134,7 @@ async def _rule_preserve_for_age_4(ctx: TipsContext) -> Tip | None: return None child = ctx.children[0] fourth_bday = child.birth_date + _relativedelta(years=4) + savable_cap = resolve_age4_savable_cap(ctx.workspace_planning_mode, len(ctx.parents)) for parent in ctx.parents: leave = ctx.leave_by_parent.get(parent.uuid) if leave is None: @@ -143,7 +147,7 @@ async def _rule_preserve_for_age_4(ctx: TipsContext) -> Tip | None: and d.date < fourth_bday ) _, expiring_at_b4 = days_left_at_age_4_projection( - leave.days_used, planned_fp_before_b4, leave.total_days + leave.days_used, planned_fp_before_b4, leave.total_days, savable_cap ) if expiring_at_b4 > 0: return Tip( @@ -216,6 +220,7 @@ async def _rule_stretch_time_at_home(ctx: TipsContext) -> Tip | None: fourth_bday = child.birth_date + _relativedelta(years=4) today = ctx.today window_end = today + timedelta(weeks=4) + savable_cap = resolve_age4_savable_cap(ctx.workspace_planning_mode, len(ctx.parents)) for parent in ctx.parents: parent_window_days = [ @@ -237,7 +242,7 @@ async def _rule_stretch_time_at_home(ctx: TipsContext) -> Tip | None: and d.date < fourth_bday ) projected_left, _ = days_left_at_age_4_projection( - leave.days_used, planned_fp_before_b4, leave.total_days + leave.days_used, planned_fp_before_b4, leave.total_days, savable_cap ) if projected_left < 60: return Tip( @@ -253,29 +258,6 @@ async def _rule_stretch_time_at_home(ctx: TipsContext) -> Tip | None: return None -async def _rule_skip_fp_first_12_months(ctx: TipsContext) -> Tip | None: - if ctx.workspace_planning_goal != "max_time": - return None - for child in ctx.children: - if is_child_under_age(child.birth_date, months=12): - for parent in ctx.parents: - fp_planned = [ - d - for d in ctx.planned_days - if d.parent_personal_data_uuid == parent.uuid - and d.day_type in ("sjukpenning", "lagsta") - and d.date < child.birth_date + _relativedelta(months=12) - ] - if fp_planned: - return Tip( - id="skip_fp_first_12_months", - severity="info", - message_key="tips.skip_fp_first_12_months", - data={"parent_name": parent.name}, - ) - return None - - async def _rule_sgi_minimum_after_age_1(ctx: TipsContext) -> Tip | None: today = ctx.today for child in ctx.children: @@ -297,8 +279,6 @@ async def _rule_sgi_minimum_after_age_1(ctx: TipsContext) -> Tip | None: async def _rule_wrap_weekend_with_vacation(ctx: TipsContext) -> Tip | None: - if ctx.workspace_planning_goal != "max_money": - return None today = ctx.today look_ahead = today + timedelta(weeks=8) for parent in ctx.parents: @@ -329,8 +309,6 @@ async def _rule_wrap_weekend_with_vacation(ctx: TipsContext) -> Tip | None: async def _rule_wrap_red_days_with_vacation(ctx: TipsContext) -> Tip | None: - if ctx.workspace_planning_goal != "max_money": - return None today = ctx.today look_ahead = today + timedelta(weeks=12) clusters = red_day_clusters_in_range(today, look_ahead) @@ -455,7 +433,6 @@ RULE_REGISTRY: list[RuleFn] = [ _rule_use_dubbeldagar_strategically, _rule_lagsta_niva_coverage, _rule_stretch_time_at_home, - _rule_skip_fp_first_12_months, _rule_sgi_minimum_after_age_1, _rule_wrap_weekend_with_vacation, _rule_wrap_red_days_with_vacation, @@ -513,11 +490,10 @@ class TipsService: ) ws = await self._workspace_repo.get_by_id(workspace_id) - planning_goal = ws.planning_goal if ws else "balanced" + planning_mode = ws.planning_mode if ws else "solo" return TipsContext( workspace_id=workspace_id, - workspace_planning_goal=planning_goal, children=children, parents=parents, leave_by_parent=leave_by_parent, @@ -525,4 +501,5 @@ class TipsService: vacation_by_parent=vacation_by_parent, leave_wishes=leave_wishes, today=today, + workspace_planning_mode=planning_mode, ) diff --git a/backend/app/services/workspace_service.py b/backend/app/services/workspace_service.py index bc1fbe4..2d820cf 100644 --- a/backend/app/services/workspace_service.py +++ b/backend/app/services/workspace_service.py @@ -75,17 +75,11 @@ class WorkspaceService: self, workspace_id: UUID, name: str | None = None, - planning_goal: str | None = None, planning_mode: str | None = None, ) -> Workspace: ws = await self.get_workspace(workspace_id) if name is not None: ws.name = name - if planning_goal is not None: - valid = {"max_time", "max_money", "balanced"} - if planning_goal not in valid: - raise ValidationError(f"planning_goal must be one of {valid}") - ws.planning_goal = planning_goal if planning_mode is not None: valid_modes = {"solo", "couple"} if planning_mode not in valid_modes: diff --git a/backend/app/utils/tip_predicates.py b/backend/app/utils/tip_predicates.py index 83d8688..dd7d4a2 100644 --- a/backend/app/utils/tip_predicates.py +++ b/backend/app/utils/tip_predicates.py @@ -30,19 +30,42 @@ def weekly_intensity(planned_days: list[PlannedLeaveDay], period_weeks: float) - return len(fp_days) / period_weeks +def resolve_age4_savable_cap(planning_mode: str, parent_count: int) -> int: + """Single source of truth for which age-4 savable-days ceiling applies. + + `rules.max_savable_days_at_age_4` (96) is the per-CHILD ceiling. When a + child actually has two parents in a workspace explicitly flagged + `planning_mode == 'couple'`, each parent's own balance is capped at + `rules.max_savable_days_per_parent_at_age_4` (48) instead — 96 each would + let the family save 192 combined, double the legal figure. + + Requiring both conditions (couple mode AND >=2 parents) is a safety belt: + a workspace flagged 'couple' that only has one member so far still gets + the per-child (96) figure, since there is no second parent to divide it + with. `AnalyticsService` and `allocation_service`'s `_Ledger` both call + this so they can never disagree on which cap applies. + """ + rules = settings.rules + if planning_mode == "couple" and parent_count >= 2: + return rules.max_savable_days_per_parent_at_age_4 + return rules.max_savable_days_at_age_4 + + def days_left_at_age_4_projection( days_used: int, planned_fp_before_b4: int, total_days: int, + max_savable: int, ) -> tuple[int, int]: """Return (projected_left_at_b4, expiring_at_b4) without hitting the DB. - Delegates the full DB-backed version to AnalyticsService. Use this in - tests or when the numbers are already loaded. + `max_savable` is the resolved cap (see `resolve_age4_savable_cap`) — the + caller decides which figure applies; this function only does the shared + arithmetic so `AnalyticsService` and `allocation_service._Ledger` can + never derive it differently. """ - rules = settings.rules projected_left = total_days - (days_used + planned_fp_before_b4) - expiring = max(projected_left - rules.max_savable_days_at_age_4, 0) + expiring = max(projected_left - max_savable, 0) return projected_left, expiring diff --git a/backend/migrations/versions/008_add_wish_link_and_target_days.py b/backend/migrations/versions/008_add_wish_link_and_target_days.py new file mode 100644 index 0000000..ce6585f --- /dev/null +++ b/backend/migrations/versions/008_add_wish_link_and_target_days.py @@ -0,0 +1,68 @@ +"""Link planned_leave_day back to the wish that produced it; add pin + day-granular target. + +Persisted leave days currently have no link back to the `leave_wish` that +produced them: the allocator's `ProposedDay` carries `wish_id`, but it was +deliberately stripped at persist time. That makes it impossible for the UI to +click a multi-day band on a calendar and edit it as one period — the entire +point of the UX rework this migration is the keystone for. + +Three additive, independently-nullable/defaulted changes (no backfill +possible or needed — every existing row predates this link): + +1. `planned_leave_day.wish_id` — which wish produced this day. NULL = ad-hoc + / legacy / unattributable (e.g. a day added by hand, or persisted before + this column existed). `ON DELETE SET NULL` so deleting a wish later never + cascades into deleting the days it already funded — it only orphans them + back to "ad-hoc", matching the nullable semantics. +2. `planned_leave_day.is_pinned` — marks a day the user hand-edited so a + later allocator pass knows not to retype it. This migration only adds the + column and carries it through the read/write path; the allocator itself + does not yet honor it (a follow-up task wires that up). +3. `leave_wish.target_days` — a calendar-drawn period is not a whole number + of weeks. NULL (the default for every existing row) falls back to + `target_weeks * 7` via `LeaveWish.target_span_days()`. + +Revision ID: 008 +Revises: 007 +Create Date: 2026-09-15 +""" + +from __future__ import annotations + +from alembic import op + +revision: str = "008" +down_revision: str | None = "007" +branch_labels: str | None = None +depends_on: str | None = None + +_UPGRADE_SQL = """ +ALTER TABLE planned_leave_day ADD COLUMN wish_id TEXT + REFERENCES leave_wish(id) ON DELETE SET NULL; + +ALTER TABLE planned_leave_day ADD COLUMN is_pinned INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE leave_wish ADD COLUMN target_days INTEGER; +""" + +_DOWNGRADE_SQL = """ +ALTER TABLE leave_wish DROP COLUMN target_days; + +ALTER TABLE planned_leave_day DROP COLUMN is_pinned; + +ALTER TABLE planned_leave_day DROP COLUMN wish_id; +""" + + +def upgrade() -> None: + from sqlalchemy import text + + for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]: + op.execute(text(stmt)) + + +def downgrade() -> None: + from sqlalchemy import text + + for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]: + op.execute(text(stmt)) diff --git a/backend/migrations/versions/009_drop_planning_goal.py b/backend/migrations/versions/009_drop_planning_goal.py new file mode 100644 index 0000000..83e09c0 --- /dev/null +++ b/backend/migrations/versions/009_drop_planning_goal.py @@ -0,0 +1,50 @@ +"""Drop workspace.planning_goal and leave_wish.goal_override. + +Product decision: there is only one sensible allocation objective — maximise +the value extracted from parental-leave days plus vacation days. Exposing +`max_time` / `max_money` / `balanced` as a user-facing setting made the +planner's behaviour unpredictable without making it more useful. The engine +now always applies what used to be the `max_money` policy; the `goal` +concept (and the ability to override it per-wish) is removed entirely. + +Revision ID: 009 +Revises: 008 +Create Date: 2026-09-15 +""" + +from __future__ import annotations + +from alembic import op + +revision: str = "009" +down_revision: str | None = "008" +branch_labels: str | None = None +depends_on: str | None = None + +_UPGRADE_SQL = """ +ALTER TABLE workspace DROP COLUMN planning_goal; + +ALTER TABLE leave_wish DROP COLUMN goal_override; +""" + +_DOWNGRADE_SQL = """ +ALTER TABLE workspace ADD COLUMN planning_goal TEXT NOT NULL DEFAULT 'balanced' + CHECK (planning_goal IN ('max_time','max_money','balanced')); + +ALTER TABLE leave_wish ADD COLUMN goal_override TEXT + CHECK (goal_override IS NULL OR goal_override IN ('max_time','max_money','balanced')); +""" + + +def upgrade() -> None: + from sqlalchemy import text + + for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]: + op.execute(text(stmt)) + + +def downgrade() -> None: + from sqlalchemy import text + + for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]: + op.execute(text(stmt)) diff --git a/backend/scripts/demo_plan.py b/backend/scripts/demo_plan.py index 6c6cd4e..c318aac 100644 --- a/backend/scripts/demo_plan.py +++ b/backend/scripts/demo_plan.py @@ -123,7 +123,6 @@ def _build_input() -> tuple[AllocationInput, dict]: alloc_input = AllocationInput( today=TODAY, horizon_end=horizon_end, - planning_goal="balanced", planning_mode="couple", children=[child], parents=[parent_1, parent_2], diff --git a/backend/scripts/seed_demo.py b/backend/scripts/seed_demo.py index cbd6a3c..150c878 100644 --- a/backend/scripts/seed_demo.py +++ b/backend/scripts/seed_demo.py @@ -118,7 +118,7 @@ def _build_services(conn: aiosqlite.Connection) -> DemoServices: parental_leave = SQLiteParentalLeaveRepository(conn) planned_leave = SQLitePlannedLeaveRepository(conn) - analytics = AnalyticsService(parental_leave, planned_leave, children, personal_data) + analytics = AnalyticsService(parental_leave, planned_leave, children, personal_data, workspaces) return DemoServices( auth=AuthService(users, personal_data, workspaces), @@ -214,9 +214,7 @@ async def _seed(conn: aiosqlite.Connection, *, reset: bool) -> None: email=DEMO_EMAIL, password=DEMO_PASSWORD, display_name=DEMO_NAME ) workspace_id = alex.workspace_id - await services.workspace.update_workspace( - workspace_id, planning_mode="couple", planning_goal="balanced" - ) + await services.workspace.update_workspace(workspace_id, planning_mode="couple") # -- 2. Partner (no login — shared/partner planning needs a second parent # on the workspace, but nothing requires them to have registered) ------- diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0c1cd3b..523fbd0 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -255,8 +255,11 @@ async def analytics_service( planned_leave_repo: SQLitePlannedLeaveRepository, child_repo: SQLiteChildRepository, personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, ) -> AnalyticsService: - return AnalyticsService(parental_leave_repo, planned_leave_repo, child_repo, personal_data_repo) + return AnalyticsService( + parental_leave_repo, planned_leave_repo, child_repo, personal_data_repo, workspace_repo + ) @pytest_asyncio.fixture diff --git a/backend/tests/unit/test_leave_wish_repository.py b/backend/tests/unit/test_leave_wish_repository.py index 8c1311d..7c5c3ea 100644 --- a/backend/tests/unit/test_leave_wish_repository.py +++ b/backend/tests/unit/test_leave_wish_repository.py @@ -30,13 +30,13 @@ def _make(workspace_id=None, **overrides) -> LeaveWish: window_end_month=8, window_end_day=15, target_weeks=6, + target_days=None, recurrence="yearly", first_year=2027, last_year=None, priority=1, parent_mode="either", parent_personal_data_uuid=None, - goal_override=None, created_at=datetime.now(), updated_at=datetime.now(), ) @@ -103,6 +103,18 @@ class TestLeaveWishWindowLogic: assert wish.applies_to_year(2029) is True assert wish.applies_to_year(2030) is False + # -- target_span_days() (calendar-drawn periods, day-granular) --------- + + def test_target_span_days_falls_back_to_weeks_times_seven_when_unset(self) -> None: + wish = _make(target_weeks=6, target_days=None) + assert wish.target_span_days() == 42 + + def test_target_span_days_prefers_target_days_when_set(self) -> None: + # A calendar-drawn 10-day band, even though target_weeks still holds + # a stale/unrelated whole-week value. + wish = _make(target_weeks=6, target_days=10) + assert wish.target_span_days() == 10 + class TestSQLiteLeaveWishRepository: """Test LeaveWishRepository SQLite implementation.""" @@ -124,7 +136,6 @@ class TestSQLiteLeaveWishRepository: assert fetched.parent_mode == "either" assert fetched.child_id is None assert fetched.parent_personal_data_uuid is None - assert fetched.goal_override is None async def test_get_by_id_missing( self, @@ -204,7 +215,6 @@ class TestSQLiteLeaveWishRepository: priority=2, parent_mode="specific", parent_personal_data_uuid=seeded_ids.personal_data_uuid, - goal_override="max_time", ) created = await leave_wish_repo.create(entity) @@ -221,7 +231,6 @@ class TestSQLiteLeaveWishRepository: assert created.priority == 2 assert created.parent_mode == "specific" assert created.parent_personal_data_uuid == seeded_ids.personal_data_uuid - assert created.goal_override == "max_time" # Round-trip through the DB, not just the in-memory object. fetched = await leave_wish_repo.get_by_id(created.id) @@ -229,7 +238,40 @@ class TestSQLiteLeaveWishRepository: assert fetched.child_id == seeded_ids.child_id assert fetched.last_year == 2031 assert fetched.parent_personal_data_uuid == seeded_ids.personal_data_uuid - assert fetched.goal_override == "max_time" + + async def test_create_and_update_round_trip_target_days( + self, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + entity = _make(workspace_id=seeded_ids.workspace_id, target_weeks=6, target_days=10) + created = await leave_wish_repo.create(entity) + assert created.target_days == 10 + + fetched = await leave_wish_repo.get_by_id(created.id) + assert fetched is not None + assert fetched.target_days == 10 + + fetched.target_days = None + updated = await leave_wish_repo.update(fetched.id, fetched) + assert updated.target_days is None + + refetched = await leave_wish_repo.get_by_id(created.id) + assert refetched is not None + assert refetched.target_days is None + + async def test_create_without_target_days_defaults_to_none( + self, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + entity = _make(workspace_id=seeded_ids.workspace_id, target_days=None) + created = await leave_wish_repo.create(entity) + assert created.target_days is None + + fetched = await leave_wish_repo.get_by_id(created.id) + assert fetched is not None + assert fetched.target_days is None async def test_get_by_workspace( self, @@ -517,44 +559,6 @@ class TestLeaveWishService: parent_personal_data_uuid=seeded_ids.personal_data_uuid, ) - @pytest.mark.parametrize("goal_override", ["max_time", "max_money", "balanced", None]) - async def test_create_accepts_valid_goal_override( - self, - leave_wish_service: LeaveWishService, - seeded_ids: SeededIds, - goal_override: str | None, - ) -> None: - result = await leave_wish_service.create( - workspace_id=seeded_ids.workspace_id, - name="Wish", - window_start_month=1, - window_start_day=1, - window_end_month=1, - window_end_day=10, - target_weeks=1, - first_year=2027, - goal_override=goal_override, - ) - assert result.goal_override == goal_override - - async def test_create_rejects_invalid_goal_override( - self, - leave_wish_service: LeaveWishService, - seeded_ids: SeededIds, - ) -> None: - with pytest.raises(ValidationError, match="goal_override"): - await leave_wish_service.create( - workspace_id=seeded_ids.workspace_id, - name="Wish", - window_start_month=1, - window_start_day=1, - window_end_month=1, - window_end_day=10, - target_weeks=1, - first_year=2027, - goal_override="minimize_stress", - ) - # -- window_mode (§10.1) ------------------------------------------------ async def test_create_defaults_to_fixed_window_mode( @@ -709,6 +713,130 @@ class TestLeaveWishService: ) assert result.window_start_day == 29 + # -- target_days (calendar-drawn periods, day-granular) ---------------- + + async def test_create_with_target_days_persists_it( + self, + leave_wish_service: LeaveWishService, + seeded_ids: SeededIds, + ) -> None: + result = await leave_wish_service.create( + workspace_id=seeded_ids.workspace_id, + name="Wish", + window_start_month=1, + window_start_day=1, + window_end_month=1, + window_end_day=31, + target_weeks=1, + target_days=10, + first_year=2027, + ) + assert result.target_days == 10 + assert result.target_span_days() == 10 + + async def test_create_without_target_days_defaults_to_none( + self, + leave_wish_service: LeaveWishService, + seeded_ids: SeededIds, + ) -> None: + result = await leave_wish_service.create( + workspace_id=seeded_ids.workspace_id, + name="Wish", + window_start_month=1, + window_start_day=1, + window_end_month=1, + window_end_day=10, + target_weeks=2, + first_year=2027, + ) + assert result.target_days is None + assert result.target_span_days() == 14 + + async def test_create_rejects_target_days_below_one( + self, + leave_wish_service: LeaveWishService, + seeded_ids: SeededIds, + ) -> None: + with pytest.raises(ValidationError, match="target_days"): + await leave_wish_service.create( + workspace_id=seeded_ids.workspace_id, + name="Wish", + window_start_month=1, + window_start_day=1, + window_end_month=1, + window_end_day=10, + target_weeks=1, + target_days=0, + first_year=2027, + ) + + async def test_create_flexible_wish_sized_by_target_days_rejects_short_bound( + self, + leave_wish_service: LeaveWishService, + seeded_ids: SeededIds, + ) -> None: + # Bound = 2027-03-01 -> 2027-03-10 (10 days); target_days=11 doesn't + # fit even though target_weeks=1 (7 days) would. target_days must be + # the value actually checked, not silently ignored in favor of weeks. + with pytest.raises(ValidationError, match="flexible"): + await leave_wish_service.create( + workspace_id=seeded_ids.workspace_id, + name="Vårlov", + window_start_month=3, + window_start_day=1, + window_end_month=3, + window_end_day=10, + target_weeks=1, + target_days=11, + first_year=2027, + window_mode="flexible", + ) + + async def test_create_flexible_wish_sized_by_target_days_accepts_matching_bound( + self, + leave_wish_service: LeaveWishService, + seeded_ids: SeededIds, + ) -> None: + # Same 10-day bound, target_days=10 fits exactly. + result = await leave_wish_service.create( + workspace_id=seeded_ids.workspace_id, + name="Vårlov", + window_start_month=3, + window_start_day=1, + window_end_month=3, + window_end_day=10, + target_weeks=1, + target_days=10, + first_year=2027, + window_mode="flexible", + ) + assert result.target_days == 10 + + async def test_update_can_set_target_days( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + entity = _make(workspace_id=seeded_ids.workspace_id, target_days=None) + created = await leave_wish_repo.create(entity) + + result = await leave_wish_service.update(leave_wish_id=created.id, target_days=10) + assert result.target_days == 10 + + async def test_update_can_clear_target_days( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + entity = _make(workspace_id=seeded_ids.workspace_id, target_days=10) + created = await leave_wish_repo.create(entity) + assert created.target_days == 10 + + result = await leave_wish_service.update(leave_wish_id=created.id, _clear_target_days=True) + assert result.target_days is None + async def test_create_rejects_zero_target_weeks( self, leave_wish_service: LeaveWishService, @@ -857,7 +985,6 @@ class TestLeaveWishService: priority=5, parent_mode="specific", parent_personal_data_uuid=seeded_ids.personal_data_uuid, - goal_override="balanced", ) assert result.child_id == seeded_ids.child_id @@ -872,7 +999,6 @@ class TestLeaveWishService: assert result.priority == 5 assert result.parent_mode == "specific" assert result.parent_personal_data_uuid == seeded_ids.personal_data_uuid - assert result.goal_override == "balanced" async def test_update_rejects_invalid_values( self, @@ -886,22 +1012,6 @@ class TestLeaveWishService: with pytest.raises(ValidationError): await leave_wish_service.update(leave_wish_id=created.id, target_weeks=0) - async def test_update_can_clear_goal_override( - self, - leave_wish_service: LeaveWishService, - leave_wish_repo: SQLiteLeaveWishRepository, - seeded_ids: SeededIds, - ) -> None: - entity = _make(workspace_id=seeded_ids.workspace_id, goal_override="max_time") - created = await leave_wish_repo.create(entity) - assert created.goal_override == "max_time" - - result = await leave_wish_service.update( - leave_wish_id=created.id, - _clear_goal_override=True, - ) - assert result.goal_override is None - async def test_update_can_clear_last_year( self, leave_wish_service: LeaveWishService, diff --git a/backend/tests/unit/test_leave_wish_router.py b/backend/tests/unit/test_leave_wish_router.py new file mode 100644 index 0000000..ab75e6a --- /dev/null +++ b/backend/tests/unit/test_leave_wish_router.py @@ -0,0 +1,251 @@ +"""API-level test for `/api/leave-wishes` POST and PUT routes. + +Tests that `target_days` and other fields round-trip correctly through the +router (i.e. are passed from the request payload to the service). This guards +against the pattern where validation accepts a field but the router drops it +before calling the service. +""" + +from datetime import date, datetime +from uuid import uuid4 + +import aiosqlite +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from app.dependencies import get_connection, get_current_user +from app.entities.user import User +from app.entities.workspace import Workspace +from app.main import app +from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository + + +@pytest_asyncio.fixture +async def client(connection: aiosqlite.Connection) -> AsyncClient: + """An httpx client against the real app: `get_connection` and + `get_current_user` are overridden to use the test's in-memory connection + and a fixed fake user. + """ + app.dependency_overrides[get_connection] = lambda: connection + app.dependency_overrides[get_current_user] = lambda: User( + id=uuid4(), + personal_data_uuid=uuid4(), + email="test@example.com", + hashed_password="not-a-real-hash", + created_at=datetime.now(), + ) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + yield ac + app.dependency_overrides.pop(get_connection, None) + app.dependency_overrides.pop(get_current_user, None) + + +class TestLeaveWishTargetDaysRoundTrip: + """Test that `target_days` is correctly passed from router to service.""" + + async def test_create_leave_wish_with_target_days( + self, + client: AsyncClient, + connection: aiosqlite.Connection, + ) -> None: + """POST /api/leave-wishes with target_days should store it and return + it in the response.""" + now = datetime.now() + workspace_repo = SQLiteWorkspaceRepository(connection) + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + + target_days_value = 15 + response = await client.post( + "/api/leave-wishes", + json={ + "workspace_id": str(ws.id), + "child_id": None, + "name": "Vårlov", + "window_start_month": 3, + "window_start_day": 1, + "window_end_month": 5, + "window_end_day": 31, + "target_weeks": 2, + "target_days": target_days_value, + "recurrence": "once", + "first_year": date.today().year + 1, + }, + ) + + assert response.status_code == 201 + body = response.json() + assert body["target_days"] == target_days_value + + # Verify the value persists: list and check + listed = await client.get(f"/api/leave-wishes/{ws.id}") + assert listed.status_code == 200 + assert listed.json()[0]["target_days"] == target_days_value + + async def test_create_leave_wish_without_target_days_defaults_to_null( + self, + client: AsyncClient, + connection: aiosqlite.Connection, + ) -> None: + """POST without target_days should store NULL (defaults to None in + Pydantic).""" + now = datetime.now() + workspace_repo = SQLiteWorkspaceRepository(connection) + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + + response = await client.post( + "/api/leave-wishes", + json={ + "workspace_id": str(ws.id), + "child_id": None, + "name": "Vårlov", + "window_start_month": 3, + "window_start_day": 1, + "window_end_month": 5, + "window_end_day": 31, + "target_weeks": 2, + "recurrence": "once", + "first_year": date.today().year + 1, + }, + ) + + assert response.status_code == 201 + body = response.json() + assert body["target_days"] is None + + async def test_update_leave_wish_target_days( + self, + client: AsyncClient, + connection: aiosqlite.Connection, + ) -> None: + """PUT /api/leave-wishes/{id} with target_days should update it.""" + now = datetime.now() + workspace_repo = SQLiteWorkspaceRepository(connection) + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + + # Create a wish without target_days + create_response = await client.post( + "/api/leave-wishes", + json={ + "workspace_id": str(ws.id), + "child_id": None, + "name": "Vårlov", + "window_start_month": 3, + "window_start_day": 1, + "window_end_month": 5, + "window_end_day": 31, + "target_weeks": 2, + "recurrence": "once", + "first_year": date.today().year + 1, + }, + ) + assert create_response.status_code == 201 + wish_id = create_response.json()["id"] + assert create_response.json()["target_days"] is None + + # Update with target_days + target_days_value = 20 + update_response = await client.put( + f"/api/leave-wishes/{wish_id}", + json={"target_days": target_days_value}, + ) + + assert update_response.status_code == 200 + body = update_response.json() + assert body["target_days"] == target_days_value + + # Verify persistence + listed = await client.get(f"/api/leave-wishes/{ws.id}") + assert listed.status_code == 200 + assert listed.json()[0]["target_days"] == target_days_value + + async def test_update_leave_wish_omitting_target_days_leaves_unchanged( + self, + client: AsyncClient, + connection: aiosqlite.Connection, + ) -> None: + """PUT without target_days field should leave the stored value + unchanged.""" + now = datetime.now() + workspace_repo = SQLiteWorkspaceRepository(connection) + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + + # Create a wish with target_days + target_days_value = 25 + create_response = await client.post( + "/api/leave-wishes", + json={ + "workspace_id": str(ws.id), + "child_id": None, + "name": "Vårlov", + "window_start_month": 3, + "window_start_day": 1, + "window_end_month": 5, + "window_end_day": 31, + "target_weeks": 2, + "target_days": target_days_value, + "recurrence": "once", + "first_year": date.today().year + 1, + }, + ) + assert create_response.status_code == 201 + wish_id = create_response.json()["id"] + + # Update other fields but omit target_days + update_response = await client.put( + f"/api/leave-wishes/{wish_id}", + json={"name": "Sommarvikarie"}, # Change name only + ) + + assert update_response.status_code == 200 + body = update_response.json() + # target_days should be unchanged + assert body["target_days"] == target_days_value + # but name should be updated + assert body["name"] == "Sommarvikarie" + + async def test_update_leave_wish_clear_target_days_to_null( + self, + client: AsyncClient, + connection: aiosqlite.Connection, + ) -> None: + """PUT with target_days explicitly set to null should clear it.""" + now = datetime.now() + workspace_repo = SQLiteWorkspaceRepository(connection) + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + + # Create a wish with target_days + target_days_value = 30 + create_response = await client.post( + "/api/leave-wishes", + json={ + "workspace_id": str(ws.id), + "child_id": None, + "name": "Vårlov", + "window_start_month": 3, + "window_start_day": 1, + "window_end_month": 5, + "window_end_day": 31, + "target_weeks": 2, + "target_days": target_days_value, + "recurrence": "once", + "first_year": date.today().year + 1, + }, + ) + assert create_response.status_code == 201 + wish_id = create_response.json()["id"] + + # Update with target_days explicitly set to null + update_response = await client.put( + f"/api/leave-wishes/{wish_id}", + json={"target_days": None}, + ) + + assert update_response.status_code == 200 + body = update_response.json() + # target_days should now be None + assert body["target_days"] is None + + # Verify persistence + listed = await client.get(f"/api/leave-wishes/{ws.id}") + assert listed.status_code == 200 + assert listed.json()[0]["target_days"] is None diff --git a/backend/tests/unit/test_plan_router.py b/backend/tests/unit/test_plan_router.py index a470af2..1403921 100644 --- a/backend/tests/unit/test_plan_router.py +++ b/backend/tests/unit/test_plan_router.py @@ -216,6 +216,10 @@ class TestProposeAge4ProjectionsOverTheWire: assert isinstance(projection["projected_left_at_b4"], int) assert isinstance(projection["savable_after_b4"], int) assert isinstance(projection["expiring_at_b4"], int) + # savable_cap_at_b4 (the resolved 96-per-child vs. 48-per-parent + # ceiling) is exactly the kind of field this test class exists to + # catch going missing at the API boundary. + assert projection["savable_cap_at_b4"] == 96 # solo workspace, one parent # No leave planned at all before B4 in this scenario -> everything # above the savable ceiling is forecast to expire, matching the # surplus-advice test above. diff --git a/backend/tests/unit/test_planning_service.py b/backend/tests/unit/test_planning_service.py index 67e70ca..6b6868f 100644 --- a/backend/tests/unit/test_planning_service.py +++ b/backend/tests/unit/test_planning_service.py @@ -10,14 +10,40 @@ from uuid import uuid4 import pytest from app.entities.child import Child +from app.entities.leave_wish import LeaveWish from app.entities.personal_data import PersonalData from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository +from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository from app.services.planning_service import PlanningService from app.utils.errors import NotFoundError, ValidationError from tests.conftest import SeededIds +def _make_wish(workspace_id, child_id=None, **overrides) -> LeaveWish: + base = dict( + id=uuid4(), + workspace_id=workspace_id, + child_id=child_id, + name="Test wish", + window_start_month=6, + window_start_day=1, + window_end_month=6, + window_end_day=30, + target_weeks=1, + recurrence="once", + first_year=2026, + last_year=None, + priority=1, + parent_mode="either", + parent_personal_data_uuid=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + base.update(overrides) + return LeaveWish(**base) + + class TestPlanningService: """Test PlanningService business logic.""" @@ -39,6 +65,49 @@ class TestPlanningService: assert result.day_type == "vacation" assert result.is_dubbeldag is False + async def test_add_planned_day_with_wish_id_and_is_pinned( + self, + planning_service: PlanningService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """Round-trips `wish_id`/`is_pinned` through the repository (create + + re-fetch), not just the in-memory object the create call returns.""" + wish = await leave_wish_repo.create( + _make_wish(seeded_ids.workspace_id, seeded_ids.child_id) + ) + + result = await planning_service.add_planned_day( + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=date(2026, 6, 15), + day_type="vacation", + wish_id=wish.id, + is_pinned=True, + ) + assert result.wish_id == wish.id + assert result.is_pinned is True + + fetched = await planning_service.get(result.id) + assert fetched.wish_id == wish.id + assert fetched.is_pinned is True + + async def test_add_planned_day_without_wish_id_defaults_to_none( + self, + planning_service: PlanningService, + seeded_ids: SeededIds, + ) -> None: + result = await planning_service.add_planned_day( + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=date(2026, 6, 15), + day_type="vacation", + ) + assert result.wish_id is None + assert result.is_pinned is False + async def test_add_double_booking_raises( self, planning_service: PlanningService, @@ -339,6 +408,36 @@ class TestPlanningService: assert updated.day_type == "sjukpenning" assert updated.is_dubbeldag is True + async def test_update_planned_day_wish_id_and_is_pinned( + self, + planning_service: PlanningService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + wish = await leave_wish_repo.create( + _make_wish(seeded_ids.workspace_id, seeded_ids.child_id) + ) + created = await planning_service.add_planned_day( + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=date(2026, 6, 15), + day_type="vacation", + ) + assert created.wish_id is None + + updated = await planning_service.update( + created.id, + wish_id=wish.id, + is_pinned=True, + ) + assert updated.wish_id == wish.id + assert updated.is_pinned is True + + refetched = await planning_service.get(created.id) + assert refetched.wish_id == wish.id + assert refetched.is_pinned is True + async def test_update_missing_raises( self, planning_service: PlanningService, diff --git a/backend/tests/unit/test_planning_service_apply.py b/backend/tests/unit/test_planning_service_apply.py index bff0e48..47203d9 100644 --- a/backend/tests/unit/test_planning_service_apply.py +++ b/backend/tests/unit/test_planning_service_apply.py @@ -12,13 +12,40 @@ property this test file cares about most. from datetime import date, datetime, timedelta from uuid import uuid4 +from app.entities.leave_wish import LeaveWish from app.entities.parental_leave import ParentalLeaveFetch, TierData +from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository from app.services.planning_service import PlanningService, ProposedDayInput from tests.conftest import SeededIds +def _make_wish(workspace_id, child_id=None, **overrides) -> LeaveWish: + """A `leave_wish` row for FK-constrained `planned_leave_day.wish_id` tests.""" + base = dict( + id=uuid4(), + workspace_id=workspace_id, + child_id=child_id, + name="Test wish", + window_start_month=6, + window_start_day=1, + window_end_month=6, + window_end_day=30, + target_weeks=1, + recurrence="once", + first_year=2026, + last_year=None, + priority=1, + parent_mode="either", + parent_personal_data_uuid=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + base.update(overrides) + return LeaveWish(**base) + + def _make_fetch( personal_data_uuid, workspace_id, @@ -226,6 +253,145 @@ class TestApplyProposedDaysCollisions: assert by_date[date(2026, 6, 16)].day_type == "vacation" +class TestApplyProposedDaysWishAttribution: + """`wish_id`/`is_pinned` are new fields on `ProposedDayInput` — the + keystone that lets the UI later click a multi-day band on the calendar + and edit it as one period. This covers them surviving the write path and + the "metadata-only update" carve-out from the collision rules. + """ + + async def test_wish_id_and_is_pinned_survive_create( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + wish = await leave_wish_repo.create(_make_wish(ws, child)) + wish_id = wish.id + days = [ + ProposedDayInput( + date(2026, 6, 15), + parent, + child, + "vacation", + wish_id=wish_id, + is_pinned=True, + ), + ] + + result = await planning_service.apply_proposed_days(ws, days) + + assert result.accepted is True + assert result.created_count == 1 + assert result.created[0].wish_id == wish_id + assert result.created[0].is_pinned is True + + rows = await planned_leave_repo.list_by_workspace_child( + workspace_id=ws, child_id=child, from_date=date(2026, 6, 1), to_date=date(2026, 6, 30) + ) + assert len(rows) == 1 + assert rows[0].wish_id == wish_id + assert rows[0].is_pinned is True + + async def test_ad_hoc_day_has_no_wish_id( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + days = [ProposedDayInput(date(2026, 6, 15), parent, child, "vacation")] + + result = await planning_service.apply_proposed_days(ws, days) + + assert result.created[0].wish_id is None + assert result.created[0].is_pinned is False + + async def test_reapply_with_different_wish_id_updates_attribution_not_collision( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """Same day_type/is_dubbeldag but a different `wish_id` must be a + plain metadata update — not a collision requiring `replace=True`, + and it must not be silently dropped as a no-op skip either. + """ + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + wish_a = (await leave_wish_repo.create(_make_wish(ws, child, name="A"))).id + wish_b = (await leave_wish_repo.create(_make_wish(ws, child, name="B"))).id + first = await planning_service.apply_proposed_days( + ws, [ProposedDayInput(date(2026, 6, 15), parent, child, "vacation", wish_id=wish_a)] + ) + assert first.created[0].wish_id == wish_a + original_id = first.created[0].id + + second = await planning_service.apply_proposed_days( + ws, + [ProposedDayInput(date(2026, 6, 15), parent, child, "vacation", wish_id=wish_b)], + replace=False, + ) + + assert second.accepted is True + assert second.collisions == [] + assert second.skipped_identical_count == 1 + + rows = await planned_leave_repo.list_by_workspace_child( + workspace_id=ws, child_id=child, from_date=date(2026, 6, 1), to_date=date(2026, 6, 30) + ) + assert len(rows) == 1 + assert rows[0].id == original_id # same row, not replaced + assert rows[0].wish_id == wish_b # attribution updated + + async def test_batch_internal_duplicate_with_different_wish_id_last_one_wins( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """Two entries for the same (child, parent, date) that agree on + day_type/is_dubbeldag but differ only in `wish_id` must not trip the + conflicting-duplicate structural rejection. + """ + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + wish_a = (await leave_wish_repo.create(_make_wish(ws, child, name="A"))).id + wish_b = (await leave_wish_repo.create(_make_wish(ws, child, name="B"))).id + days = [ + ProposedDayInput(date(2026, 6, 15), parent, child, "vacation", wish_id=wish_a), + ProposedDayInput(date(2026, 6, 15), parent, child, "vacation", wish_id=wish_b), + ] + + result = await planning_service.apply_proposed_days(ws, days) + + assert result.accepted is True + assert result.rejections == [] + rows = await planned_leave_repo.list_by_workspace_child( + workspace_id=ws, child_id=child, from_date=date(2026, 6, 1), to_date=date(2026, 6, 30) + ) + assert len(rows) == 1 + assert rows[0].wish_id == wish_b # later entry wins + + class TestApplyProposedDaysValidation: async def test_invalid_day_type_rejected_before_any_write( self, 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 04f3e3d..225b4bc 100644 --- a/backend/tests/unit/test_services/test_allocation_input_loader.py +++ b/backend/tests/unit/test_services/test_allocation_input_loader.py @@ -4,8 +4,8 @@ planning engine (see app.services.allocation_service module docstring). Uses the same sqlite-backed fixtures as the rest of the service test suite (tests/conftest.py) to seed a realistic workspace and verify the loader nets out already-committed `planned_leave_day` rows, falls back to default -entitlement when no FK snapshot exists, and reads `planning_mode` / -`planning_goal` off the workspace. +entitlement when no FK snapshot exists, and reads `planning_mode` off the +workspace. """ from datetime import date, datetime @@ -91,7 +91,6 @@ class TestAllocationInputLoader: Workspace( id=uuid4(), name="Familj", - planning_goal="max_money", planning_mode="couple", created_at=now, ) @@ -202,8 +201,10 @@ class TestAllocationInputLoader: alloc_input = await loader.load(ws.id, today=date(2027, 1, 1)) assert alloc_input.planning_mode == "couple" - assert alloc_input.planning_goal == "max_money" assert alloc_input.today == date(2027, 1, 1) + # Two parents in a 'couple' workspace -> per-parent age-4 cap (48), + # not the per-child figure (96). + assert alloc_input.age4_savable_cap == 48 pool_with_fetch = alloc_input.pools[(parent_with_fetch.uuid, child.id)] assert pool_with_fetch.sjukpenning_left == 288 # 290 - 2 committed @@ -284,9 +285,7 @@ class TestAllocationInputLoader: leave_wish_repo: SQLiteLeaveWishRepository, ) -> None: now = datetime.now() - ws = await workspace_repo.create( - Workspace(id=uuid4(), name="Solo", planning_goal="balanced", created_at=now) - ) + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Solo", created_at=now)) await personal_data_repo.create( PersonalData(uuid=uuid4(), workspace_id=ws.id, name="Solo Parent", created_at=now) ) diff --git a/backend/tests/unit/test_services/test_allocation_service.py b/backend/tests/unit/test_services/test_allocation_service.py index 206617e..614546e 100644 --- a/backend/tests/unit/test_services/test_allocation_service.py +++ b/backend/tests/unit/test_services/test_allocation_service.py @@ -24,7 +24,6 @@ from app.entities.leave_wish import LeaveWish from app.entities.planned_leave import PlannedLeaveDay from app.services.allocation_service import ( REASON_DUBBELDAG, - REASON_FP_LAGSTA_STRETCH, REASON_VACATION_WRAP_RED_DAY, REASON_VACATION_WRAP_WEEKEND, STRATEGY_UNPLACED, @@ -36,6 +35,7 @@ from app.services.allocation_service import ( VacationBudget, ) from app.utils.holidays import get_swedish_holidays +from app.utils.tip_predicates import resolve_age4_savable_cap # --------------------------------------------------------------------------- # Builders @@ -89,13 +89,13 @@ def _wish( start: tuple[int, int] = (2, 1), end: tuple[int, int] = (2, 28), weeks: int = 4, + target_days: int | None = None, recurrence: str = "once", first_year: int = 2027, last_year: int | None = None, priority: int = 1, parent_mode: str = "either", parent_uuid: UUID | None = None, - goal_override: str | None = None, workspace_id: UUID | None = None, window_mode: str = "fixed", wish_id: UUID | None = None, @@ -112,13 +112,13 @@ def _wish( window_end_month=end[0], window_end_day=end[1], target_weeks=weeks, + target_days=target_days, recurrence=recurrence, first_year=first_year, last_year=last_year, priority=priority, parent_mode=parent_mode, parent_personal_data_uuid=parent_uuid, - goal_override=goal_override, window_mode=window_mode, ) @@ -127,7 +127,6 @@ def _build_input( *, today: date, horizon_end: date, - planning_goal: str = "balanced", planning_mode: str = "solo", children: list[ChildFacts], parents: list[ParentFacts], @@ -137,13 +136,21 @@ def _build_input( existing_planned: list | None = None, extra_holiday_years: tuple[int, ...] = (), day_overrides: dict[date, bool] | None = None, + age4_savable_cap: int | None = None, ) -> AllocationInput: years = {today.year, horizon_end.year, horizon_end.year + 1, *extra_holiday_years} + if age4_savable_cap is None: + # Same resolution `AllocationInputLoader` uses in production — see + # `app.utils.tip_predicates.resolve_age4_savable_cap`. Computed here + # by default so existing callers that don't care about the age-4 cap + # split keep getting the historically-correct figure for their + # `planning_mode`/`parents` without having to pass it explicitly. + age4_savable_cap = resolve_age4_savable_cap(planning_mode, len(parents)) return AllocationInput( today=today, horizon_end=horizon_end, - planning_goal=planning_goal, planning_mode=planning_mode, + age4_savable_cap=age4_savable_cap, children=children, parents=parents, pools=pools, @@ -180,12 +187,15 @@ AUG_2027 = ((8, 1), (8, 31)) # 31 days, 22 spendable, no real holidays nearby # --------------------------------------------------------------------------- -# #1 — solo, max_time, 6-week summer, child < 12 months -> zero FP days +# #1 — solo, child < 12 months, monthly salary -> the single policy still +# falls back to FP once vacation is exhausted (the old age-gated "skip FP for +# the first 12 months" planning-goal special case was removed entirely; +# nothing about the tier-3 policy special-cases a child's age any more). # --------------------------------------------------------------------------- -class TestCase1SoloMaxTimeUnder12Months: - def test_zero_fp_days_assigned(self) -> None: +class TestCase1SoloUnder12MonthsFallsBackToFP: + def test_fp_used_before_12_months_once_vacation_exhausted(self) -> None: child = _child(birth_date=date(2026, 9, 1)) parent = _parent(salary_type="monthly") wish = _wish( @@ -198,31 +208,29 @@ class TestCase1SoloMaxTimeUnder12Months: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_time", planning_mode="solo", children=[child], parents=[parent], pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, - vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, + vacation={(parent.uuid, 2027): VacationBudget(0, 0, 0)}, wishes=[wish], ) proposal = Allocator().propose(alloc_input) - assert all(d.day_type not in ("sjukpenning", "lagsta") for d in proposal.days) outcome = proposal.outcomes[0] assert outcome.shortfall_days == 0 - assert outcome.cost_breakdown["vacation"] == 20 - assert outcome.cost_breakdown["sjukpenning"] == 0 + assert outcome.cost_breakdown["vacation"] == 0 + assert outcome.cost_breakdown["sjukpenning"] == 20 assert outcome.cost_breakdown["lagsta"] == 0 # --------------------------------------------------------------------------- -# #2 — couple, balanced, 6-week summer -> parent_swap, 3+3 +# #2 — couple, 6-week summer -> parent_swap, 3+3 # --------------------------------------------------------------------------- -class TestCase2CoupleBalancedSwap: +class TestCase2CoupleSwap: def test_swap_splits_6_weeks_into_3_plus_3(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent_a = _parent(name="A") @@ -238,7 +246,6 @@ class TestCase2CoupleBalancedSwap: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="couple", children=[child], parents=[parent_a, parent_b], @@ -271,11 +278,11 @@ class TestCase2CoupleBalancedSwap: # --------------------------------------------------------------------------- -# #3 — max_money + monthly salary -> vacation on every spendable day +# #3 — monthly salary -> vacation on every spendable day # --------------------------------------------------------------------------- -class TestCase3MaxMoneyMonthly: +class TestCase3MonthlySalaryVacationFirst: def test_vacation_lands_on_fridays_and_mondays_around_weekends(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") @@ -283,7 +290,6 @@ class TestCase3MaxMoneyMonthly: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -311,11 +317,11 @@ class TestCase3MaxMoneyMonthly: # --------------------------------------------------------------------------- -# #4 — max_money + hourly salary -> wrapping suppressed +# #4 — hourly salary -> wrapping suppressed # --------------------------------------------------------------------------- -class TestCase4MaxMoneyHourly: +class TestCase4HourlySalaryFPFirst: def test_wrapping_suppressed_for_hourly(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="hourly") @@ -323,7 +329,6 @@ class TestCase4MaxMoneyHourly: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -359,7 +364,6 @@ class TestCase5ShortfallByPriority: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -396,7 +400,6 @@ class TestCase6YearWrapping: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2028, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -435,7 +438,6 @@ class TestCase7SeparatePoolsPerChild: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child_a, child_b], parents=[parent], @@ -471,7 +473,6 @@ class TestCase8DaysLostAtAge4Warning: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2029, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -502,7 +503,6 @@ class TestCase9MovableEaster: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -542,7 +542,6 @@ class TestCase10DubbeldagOutsideWindow: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="couple", children=[child], parents=[parent_a, parent_b], @@ -581,7 +580,6 @@ class TestCase11Determinism: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="couple", children=[child], parents=[parent_a, parent_b], @@ -616,7 +614,6 @@ class TestCase12Age4HardCap: alloc_input = _build_input( today=date(2027, 1, 15), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -635,6 +632,60 @@ class TestCase12Age4HardCap: # Clamped to 96 BEFORE spending, then 20 consumed -> 76, not 200-20=180. assert remaining == 76 + def test_pool_clamped_to_48_each_for_two_parent_couple(self) -> None: + """The per-parent mirror of the above: a `couple` workspace with two + + parents must clamp EACH parent's pool to 48 (not 96) at the 4th + birthday — this is the headline bug the age-4-cap task exists to + fix (a two-parent family was previously able to save 96 each, 192 + combined, double the legal figure). + """ + child = _child(birth_date=date(2020, 1, 1)) # B4 = 2024-01-01, already past + parent_a = _parent(name="A", salary_type="hourly") + parent_b = _parent(name="B", salary_type="hourly") + wish = _wish( + child_id=child.id, + start=FEB_2027[0], + end=FEB_2027[1], + weeks=4, + parent_mode="specific", + parent_uuid=parent_a.uuid, + ) + alloc_input = _build_input( + today=date(2027, 1, 15), + horizon_end=date(2027, 12, 31), + planning_mode="couple", + children=[child], + parents=[parent_a, parent_b], + pools={ + (parent_a.uuid, child.id): DayPool(200, 0, 60), + (parent_b.uuid, child.id): DayPool(200, 0, 60), + }, + vacation={}, + wishes=[wish], + ) + + proposal = Allocator().propose(alloc_input) + + outcome = proposal.outcomes[0] + assert outcome.cost_breakdown["sjukpenning"] == 20 # all 20 spendable Feb days funded + + pool_a_after = proposal.balances_after.pools_after[(parent_a.uuid, child.id)] + remaining_a = pool_a_after.sjukpenning_left + pool_a_after.lagsta_left + # Clamped to 48 BEFORE spending, then 20 consumed -> 28, not 200-20=180. + assert remaining_a == 28 + + # Parent B never has a window funded from their pool in this test, + # but their untouched pool is still clamped the first time ANY + # window starting on/after B4 is funded for this (parent, child) — + # here that never happens for B, so B's pool stays unclamped at 200. + # The important invariant is A's clamp target, asserted above. + + age4_a = next( + p for p in proposal.age4_projections if p.parent_personal_data_uuid == parent_a.uuid + ) + assert age4_a.savable_cap_at_b4 == 48 + # --------------------------------------------------------------------------- # #13 — shortfall caused by the preschool reserve -> matching remedy @@ -653,7 +704,6 @@ class TestCase13PreschoolReserveRemedy: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), # single year -> reserve = 6*1 = 6 - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -670,90 +720,6 @@ class TestCase13PreschoolReserveRemedy: assert remedy.days_freed == 6 -# --------------------------------------------------------------------------- -# #14 — wish.goal_override applies per-window; other windows use workspace goal -# --------------------------------------------------------------------------- - - -class TestCase14GoalOverride: - def test_override_applies_only_to_its_own_window(self) -> None: - # Born recently enough that the 4th birthday falls well after the - # horizon, so the §9.1 age-4 hard cap (tested separately in #12) - # cannot interfere with this test's FP pool numbers. - child = _child(birth_date=date(2025, 1, 1)) - parent = _parent(salary_type="monthly") - wish_override = _wish( - child_id=child.id, - name="Feb", - start=FEB_2027[0], - end=FEB_2027[1], - weeks=4, - goal_override="max_money", - ) - wish_default = _wish( - child_id=child.id, name="Aug", start=AUG_2027[0], end=AUG_2027[1], weeks=4 - ) - alloc_input = _build_input( - today=date(2027, 1, 1), - horizon_end=date(2027, 12, 31), - planning_goal="max_time", # workspace default - planning_mode="solo", - children=[child], - parents=[parent], - pools={(parent.uuid, child.id): DayPool(90, 30, 60)}, - vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, - wishes=[wish_override, wish_default], - ) - - proposal = Allocator().propose(alloc_input) - - feb_days = [d for d in proposal.days if d.wish_id == wish_override.id] - aug_days = [d for d in proposal.days if d.wish_id == wish_default.id] - - # Feb window used its own override (max_money + monthly -> vacation). - assert all(d.day_type == "vacation" for d in feb_days if d.day_type != "free") - # Aug window used the workspace default (max_time, child >= 12mo -> - # stretch via the lagsta tier). - assert all(d.day_type == "lagsta" for d in aug_days if d.day_type != "free") - assert any(d.reason == REASON_FP_LAGSTA_STRETCH for d in aug_days) - - def test_convergence_corollary_red_day_dense_window(self) -> None: - """§3.3 corollary: on a window where every spendable day is a tier-2 - klämdag, goal_override='max_money' produces the same allocation as - 'max_time' would, since tiers 1-2 are goal-independent.""" - child = _child(birth_date=date(2020, 1, 1)) - parent = _parent(salary_type="monthly") - - def _run(goal: str, override: str | None) -> list: - wish = _wish( - child_id=child.id, - name="Klämdagar", - start=(12, 24), - end=(12, 27), - weeks=1, - goal_override=override, - ) - alloc_input = _build_input( - today=date(2027, 1, 1), - horizon_end=date(2027, 12, 31), - planning_goal=goal, - planning_mode="solo", - children=[child], - parents=[parent], - pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, - vacation={(parent.uuid, 2027): VacationBudget(10, 0, 0)}, - wishes=[wish], - ) - proposal = Allocator().propose(alloc_input) - return [(d.date, d.day_type, d.reason) for d in proposal.days] - - max_time_result = _run("max_time", None) - max_money_result = _run("balanced", "max_money") - - assert max_time_result == max_money_result - assert any(day_type == "vacation" for _, day_type, _ in max_time_result) - - # --------------------------------------------------------------------------- # REGRESSION: solo parent with exhausted vacation but available FP # --------------------------------------------------------------------------- @@ -822,7 +788,6 @@ class TestRegressionSoloVacationExhaustedFPAvailable: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2030, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -881,7 +846,6 @@ class TestRegressionSoloVacationExhaustedFPAvailable: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2028, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -990,7 +954,6 @@ class TestRegressionNoShortfallWhileResourcesRemain: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2028, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -1038,7 +1001,6 @@ class TestRegressionNoShortfallWhileResourcesRemain: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -1085,7 +1047,6 @@ class TestRegressionGenuineShortfallStillReported: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -1125,7 +1086,6 @@ class TestRegressionGenuineShortfallStillReported: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -1153,10 +1113,9 @@ class TestRegressionTwoParentSwapUnaffected: two-parent scenario still splits the wish as expected. """ - def test_couple_balanced_swap_still_works(self) -> None: - """Two parents, balanced goal, 6-week wish with parent_mode='either'. - Should split 3+3 as before. This path already worked; ensure it hasn't - regressed. + def test_couple_swap_still_works(self) -> None: + """Two parents, 6-week wish with parent_mode='either'. Should split + 3+3 as before. This path already worked; ensure it hasn't regressed. """ child = _child(birth_date=date(2020, 1, 1)) parent_a = _parent(name="A") @@ -1172,7 +1131,6 @@ class TestRegressionTwoParentSwapUnaffected: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="couple", children=[child], parents=[parent_a, parent_b], @@ -1225,7 +1183,6 @@ class TestCase15FlexibleWishFindsEasterCluster: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -1268,7 +1225,6 @@ class TestCase16FlexibleTieBreakByEarliestStart: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -1283,9 +1239,9 @@ class TestCase16FlexibleTieBreakByEarliestStart: assert outcome.start_date == date(2027, 2, 1) assert outcome.end_date == date(2027, 2, 7) # 5 spendable days (2 free: Sat+Sun) -- split across vacation/FP per - # the 'balanced' goal's early/late rule, but always 5 total, which is - # what the tie-break must be indifferent to (every start in this - # bound costs exactly 5). + # the single tier-3 policy, but always 5 total, which is what the + # tie-break must be indifferent to (every start in this bound costs + # exactly 5). spendable = ( outcome.cost_breakdown["vacation"] + outcome.cost_breakdown["sjukpenning"] @@ -1295,6 +1251,44 @@ class TestCase16FlexibleTieBreakByEarliestStart: assert outcome.cost_breakdown["free"] == 2 +class TestFlexibleWishSizedByTargetDays: + """`target_days` (day-granular, e.g. a calendar-drawn band) must drive + + the flexible-window search's candidate length via `target_span_days()`, + not the stale/unrelated `target_weeks` value alongside it. + """ + + def test_resolved_window_length_matches_target_days_not_target_weeks(self) -> None: + child = _child(birth_date=date(2020, 1, 1)) + parent = _parent(salary_type="monthly") + wish = _wish( + child_id=child.id, + name="Bandlov", + start=FEB_2027[0], + end=FEB_2027[1], # 28-day bound + weeks=4, # would mean 28 days if target_days were ignored + target_days=10, # the value that must actually win + first_year=2027, + window_mode="flexible", + ) + alloc_input = _build_input( + today=date(2027, 1, 1), + horizon_end=date(2027, 12, 31), + planning_mode="solo", + 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) + + outcome = proposal.outcomes[0] + assert (outcome.end_date - outcome.start_date).days + 1 == 10 + assert outcome.shortfall_days == 0 + + class TestCase17TwoFlexibleWishesPriorityOrdering: def test_priority_one_gets_cheapest_candidate_priority_two_scored_on_rest(self) -> None: child = _child(birth_date=date(2020, 1, 1)) @@ -1322,7 +1316,6 @@ class TestCase17TwoFlexibleWishesPriorityOrdering: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -1376,7 +1369,6 @@ class TestCase18FixedWishAlwaysWinsOverFlexible: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -1418,7 +1410,6 @@ class TestCase19FlexibleBoundShrunkByHorizonIsPartialNotUnplaced: # 7 days target_weeks needs. today=date(2027, 5, 20), horizon_end=date(2027, 5, 25), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -1464,7 +1455,6 @@ class TestCase20FlexibleWishUnplacedWhenEveryCandidateCollides: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -1518,7 +1508,6 @@ class TestCase21FlexibleYearlyWishTracksMovingEaster: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2029, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -1564,7 +1553,6 @@ class TestCase22FlexibleDubbeldagOutsideWindowFallsBack: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="couple", children=[child], parents=[parent_a, parent_b], @@ -1616,7 +1604,6 @@ class TestCase23FlexibleWishDeterminism: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="couple", children=[child], parents=[parent_a, parent_b], @@ -1665,7 +1652,6 @@ class TestCase24WindowModeDefaultsToFixed: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -1717,7 +1703,6 @@ class TestCase25FlexibleAlreadyFundedYearProposesNothingNew: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -1761,7 +1746,6 @@ class TestCase26FlexiblePartialFundingTopsUpRemainder: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -1806,7 +1790,6 @@ class TestCase27FlexibleDeterminismWithExistingPlannedDays: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -1863,7 +1846,6 @@ class TestCase28FlexibleWishStillMovesWhenItsYearIsNotAlreadySatisfied: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], 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 c5a738d..3a998c5 100644 --- a/backend/tests/unit/test_services/test_allocation_service_branches.py +++ b/backend/tests/unit/test_services/test_allocation_service_branches.py @@ -49,7 +49,6 @@ class TestParentModeSpecific: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="couple", children=[child], parents=[parent_a, parent_b], @@ -76,7 +75,6 @@ class TestFamilyWideWish: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[], parents=[parent], @@ -96,7 +94,6 @@ class TestFamilyWideWish: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[], parents=[parent], @@ -113,14 +110,13 @@ class TestFamilyWideWish: class TestTier3Fallbacks: - def test_max_money_monthly_falls_back_to_fp_once_vacation_exhausted(self) -> None: + def test_monthly_falls_back_to_fp_once_vacation_exhausted(self) -> None: child = _child(birth_date=date(2025, 1, 1)) parent = _parent(salary_type="monthly") 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), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -136,14 +132,13 @@ class TestTier3Fallbacks: assert outcome.cost_breakdown["sjukpenning"] == 15 assert outcome.shortfall_days == 0 - def test_max_money_hourly_falls_back_to_vacation_once_fp_exhausted(self) -> None: + def test_hourly_falls_back_to_vacation_once_fp_exhausted(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), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -159,14 +154,19 @@ class TestTier3Fallbacks: assert outcome.cost_breakdown["vacation"] == 18 assert outcome.shortfall_days == 0 - def test_max_time_under_12_months_unfunded_without_vacation(self) -> None: + def test_monthly_under_12_months_falls_back_to_fp_without_vacation(self) -> None: + """The removed goal-dependent "stretch time at home" policy used to + skip FP entirely for a child's first 12 months (skip_fp_first_12_months) + to stretch the pool for later. The single policy has no such age gate: + with no vacation available, a monthly parent still funds the window + from FP even though the child is under 12 months old. + """ child = _child(birth_date=date(2026, 9, 1)) parent = _parent(salary_type="monthly") 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), - planning_goal="max_time", planning_mode="solo", children=[child], parents=[parent], @@ -178,12 +178,12 @@ class TestTier3Fallbacks: proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] - assert outcome.cost_breakdown["sjukpenning"] == 0 + assert outcome.cost_breakdown["sjukpenning"] == 20 assert outcome.cost_breakdown["lagsta"] == 0 assert outcome.cost_breakdown["vacation"] == 0 - assert outcome.shortfall_days == 20 + assert outcome.shortfall_days == 0 - def test_max_time_over_12_months_falls_back_to_vacation_then_unfunded(self) -> None: + def test_monthly_fp_exhausted_falls_back_to_vacation_then_unfunded(self) -> None: child = _child(birth_date=date(2020, 1, 1)) parent = _parent(salary_type="monthly") wish = _wish(child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4) @@ -192,7 +192,6 @@ class TestTier3Fallbacks: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_time", planning_mode="solo", children=[child], parents=[parent], @@ -209,7 +208,6 @@ class TestTier3Fallbacks: alloc_input_empty = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_time", planning_mode="solo", children=[child], parents=[parent], @@ -221,14 +219,13 @@ class TestTier3Fallbacks: outcome_empty = proposal_empty.outcomes[0] assert outcome_empty.shortfall_days == 20 - def test_balanced_early_falls_back_to_fp_when_vacation_scarce(self) -> None: + def test_monthly_vacation_scarce_falls_back_to_fp(self) -> None: child = _child(birth_date=date(2025, 1, 1)) parent = _parent(salary_type="monthly") 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), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -244,14 +241,13 @@ class TestTier3Fallbacks: assert outcome.cost_breakdown["sjukpenning"] == 19 assert outcome.shortfall_days == 0 - def test_balanced_late_falls_back_to_vacation_when_fp_scarce(self) -> None: + def test_monthly_fp_exhausted_uses_vacation_for_all_days(self) -> None: child = _child(birth_date=date(2025, 1, 1)) parent = _parent(salary_type="monthly") 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), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -284,7 +280,6 @@ class TestDubbeldagSuccess: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="couple", children=[child], parents=[parent_a, parent_b], @@ -320,7 +315,6 @@ class TestWarnings: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -340,7 +334,6 @@ class TestWarnings: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[], parents=[parent], @@ -373,7 +366,6 @@ class TestRemedies: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -404,7 +396,6 @@ class TestRemedies: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -437,7 +428,6 @@ class TestRemedies: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -466,7 +456,6 @@ class TestRemedies: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -518,7 +507,6 @@ class TestRemedies: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -571,7 +559,6 @@ class TestDayOverrides: base_kwargs = dict( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], @@ -605,7 +592,6 @@ class TestDayOverrides: base_kwargs = dict( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -635,8 +621,8 @@ class TestDayOverrides: def _big_surplus_input(*, min_vacation_days_per_year: int = 10): """A parent who funds a whole 20-day interior window entirely from - vacation (max_money + monthly, no red days nearby -> tier-3 picks - vacation first) while sitting on a huge, untouched FP pool. By the child's + vacation (monthly salary, no red days nearby -> tier-3 picks vacation + first) while sitting on a huge, untouched FP pool. By the child's 4th birthday the untouched pool massively exceeds the 96-day ceiling, so every surplus-advice kind should have something to say: @@ -660,7 +646,6 @@ def _big_surplus_input(*, min_vacation_days_per_year: int = 10): alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2030, 12, 31), - planning_goal="max_money", planning_mode="solo", children=[child], parents=[parent], @@ -728,7 +713,6 @@ class TestSurplusAdvice: alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), - planning_goal="balanced", planning_mode="solo", children=[child], parents=[parent], diff --git a/backend/tests/unit/test_services/test_analytics_service.py b/backend/tests/unit/test_services/test_analytics_service.py index 45e1cb3..0a812f7 100644 --- a/backend/tests/unit/test_services/test_analytics_service.py +++ b/backend/tests/unit/test_services/test_analytics_service.py @@ -70,8 +70,9 @@ async def _make_service( leave_repo: SQLiteParentalLeaveRepository, planned_repo: SQLitePlannedLeaveRepository, pd_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, ) -> AnalyticsService: - return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo) + return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo) class TestAge4Projection: @@ -81,6 +82,7 @@ class TestAge4Projection: parental_leave_repo: SQLiteParentalLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, seeded_ids: SeededIds, ) -> None: ws = seeded_ids.workspace_id @@ -95,7 +97,7 @@ class TestAge4Projection: await parental_leave_repo.create(fetch) service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo + child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo ) proj = await service.get_days_left_at_age_4(pd_uuid, child.id) @@ -104,6 +106,7 @@ class TestAge4Projection: assert proj.projected_left_at_b4 == 140 # 240 - 100 assert proj.savable_after_b4 == 96 assert proj.expiring_at_b4 == 44 # 140 - 96 + assert proj.savable_cap_at_b4 == 96 # solo workspace -> per-child ceiling async def test_no_fetch_data_assumes_full_entitlement( self, @@ -111,6 +114,7 @@ class TestAge4Projection: parental_leave_repo: SQLiteParentalLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, seeded_ids: SeededIds, ) -> None: ws = seeded_ids.workspace_id @@ -121,7 +125,7 @@ class TestAge4Projection: # No FK data entered service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo + child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo ) proj = await service.get_days_left_at_age_4(pd_uuid, child.id) @@ -137,6 +141,7 @@ class TestAge4Projection: parental_leave_repo: SQLiteParentalLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, seeded_ids: SeededIds, ) -> None: ws = seeded_ids.workspace_id @@ -147,7 +152,7 @@ class TestAge4Projection: await child_repo.create(child) service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo + child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo ) proj = await service.get_days_left_at_age_4(pd_uuid, child.id) @@ -160,6 +165,7 @@ class TestAge4Projection: parental_leave_repo: SQLiteParentalLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, seeded_ids: SeededIds, ) -> None: ws = seeded_ids.workspace_id @@ -172,7 +178,7 @@ class TestAge4Projection: await parental_leave_repo.create(fetch) service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo + child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo ) proj = await service.get_days_left_at_age_4(pd_uuid, child.id) @@ -186,11 +192,12 @@ class TestAge4Projection: parental_leave_repo: SQLiteParentalLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, ) -> None: from app.utils.errors import NotFoundError service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo + child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo ) with pytest.raises(NotFoundError): await service.get_days_left_at_age_4(uuid4(), uuid4()) @@ -203,11 +210,12 @@ class TestValidateDubbeldagar: parental_leave_repo: SQLiteParentalLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, ) -> None: from app.utils.errors import NotFoundError service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo + child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo ) with pytest.raises(NotFoundError): await service.validate_dubbeldagar(uuid4(), date.today()) @@ -218,6 +226,7 @@ class TestValidateDubbeldagar: parental_leave_repo: SQLiteParentalLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, seeded_ids: SeededIds, ) -> None: from app.utils.errors import DubbeldagError @@ -228,7 +237,7 @@ class TestValidateDubbeldagar: await child_repo.create(child) service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo + child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo ) # Date beyond the 15-month window beyond_window = birth + relativedelta(months=16) @@ -241,6 +250,7 @@ class TestValidateDubbeldagar: parental_leave_repo: SQLiteParentalLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, seeded_ids: SeededIds, ) -> None: from app.config import settings @@ -269,7 +279,7 @@ class TestValidateDubbeldagar: ) service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo + child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo ) within_window = birth + relativedelta(months=1) with pytest.raises(DubbeldagError): @@ -283,6 +293,7 @@ class TestGetOverview: parental_leave_repo: SQLiteParentalLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, seeded_ids: SeededIds, ) -> None: ws = seeded_ids.workspace_id @@ -296,7 +307,7 @@ class TestGetOverview: await parental_leave_repo.create(fetch) service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo + child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo ) overview = await service.get_overview(ws, child.id) @@ -312,16 +323,115 @@ class TestGetOverview: parental_leave_repo: SQLiteParentalLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository, personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, ) -> None: from app.utils.errors import NotFoundError service = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo + child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo ) with pytest.raises(NotFoundError): await service.get_overview(uuid4(), uuid4()) +class TestCoupleWorkspacePerParentCap: + """Headline regression: a two-parent `couple` workspace must cap each + + parent's own savable balance at 48 (not 96), so the combined savable + figure across both parents tops out at 96 total — never 192. This is + the bug this task exists to fix: the age-4 ceiling is per CHILD, not + per PARENT pool. + """ + + async def test_two_parent_couple_caps_48_each_96_combined( + self, + child_repo: SQLiteChildRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, + ) -> None: + from app.entities.personal_data import PersonalData + from app.entities.workspace import Workspace + + now = datetime.now() + ws = await workspace_repo.create( + Workspace(id=uuid4(), name="Familj", planning_mode="couple", created_at=now) + ) + parent_a = await personal_data_repo.create( + PersonalData(uuid=uuid4(), workspace_id=ws.id, name="A", created_at=now) + ) + parent_b = await personal_data_repo.create( + PersonalData(uuid=uuid4(), workspace_id=ws.id, name="B", created_at=now) + ) + + birth = date.today() + relativedelta(years=2) + child = _make_child(ws.id, birth) + await child_repo.create(child) + + # Both parents sit on a huge untouched pool -> both should be + # clamped to the per-parent cap, never the per-child one. + fetch_a = _make_fetch(parent_a.uuid, ws.id, child.id, total=240, used=0) + fetch_b = _make_fetch(parent_b.uuid, ws.id, child.id, total=240, used=0) + await parental_leave_repo.create(fetch_a) + await parental_leave_repo.create(fetch_b) + + service = await _make_service( + child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + ) + + proj_a = await service.get_days_left_at_age_4(parent_a.uuid, child.id) + proj_b = await service.get_days_left_at_age_4(parent_b.uuid, child.id) + assert proj_a.savable_cap_at_b4 == 48 + assert proj_b.savable_cap_at_b4 == 48 + assert proj_a.savable_after_b4 == 48 + assert proj_b.savable_after_b4 == 48 + assert proj_a.expiring_at_b4 == 192 # 240 - 48 + assert proj_b.expiring_at_b4 == 192 + + overview = await service.get_overview(ws.id, child.id) + combined_savable = sum(p.savable_after_b4 for p in overview.parent_projections) + assert combined_savable == 96 # never 192 — this is the headline regression + assert overview.combined_expiring_at_b4 == 384 # (240-48) * 2 + + async def test_couple_mode_with_single_parent_falls_back_to_96( + self, + child_repo: SQLiteChildRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, + ) -> None: + """Safety belt: `planning_mode == 'couple'` with only one member so + + far (partner not added yet) must still use the per-child (96) + figure — there is no second parent to divide it with. + """ + from app.entities.personal_data import PersonalData + from app.entities.workspace import Workspace + + now = datetime.now() + ws = await workspace_repo.create( + Workspace(id=uuid4(), name="Familj", planning_mode="couple", created_at=now) + ) + parent = await personal_data_repo.create( + PersonalData(uuid=uuid4(), workspace_id=ws.id, name="A", created_at=now) + ) + birth = date.today() + relativedelta(years=2) + child = _make_child(ws.id, birth) + await child_repo.create(child) + fetch = _make_fetch(parent.uuid, ws.id, child.id, total=240, used=0) + await parental_leave_repo.create(fetch) + + service = await _make_service( + child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo + ) + proj = await service.get_days_left_at_age_4(parent.uuid, child.id) + assert proj.savable_cap_at_b4 == 96 + assert proj.savable_after_b4 == 96 + assert proj.expiring_at_b4 == 144 # 240 - 96 + + class TestAgreesWithPlanProposalForTheSameCommittedState: """Bug A regression: the Dashboard (`AnalyticsService`) and the Plan tab's propose-time projection (`allocation_service._Ledger.build_age4_projections`, @@ -374,7 +484,7 @@ class TestAgreesWithPlanProposalForTheSameCommittedState: # -- Dashboard side -- analytics = await _make_service( - child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo + child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo ) dashboard_proj = await analytics.get_days_left_at_age_4(parent.uuid, child.id) diff --git a/backend/tests/unit/test_services/test_tips_service.py b/backend/tests/unit/test_services/test_tips_service.py index c8afed7..d585739 100644 --- a/backend/tests/unit/test_services/test_tips_service.py +++ b/backend/tests/unit/test_services/test_tips_service.py @@ -151,7 +151,6 @@ def _make_break( def _make_ctx( workspace_id: UUID | None = None, - planning_goal: str = "balanced", children: list | None = None, parents: list | None = None, leave_by_parent: dict | None = None, @@ -159,11 +158,11 @@ def _make_ctx( vacation_by_parent: dict | None = None, leave_wishes: list | None = None, today: date | None = None, + planning_mode: str = "solo", ) -> TipsContext: ws = workspace_id or uuid4() return TipsContext( workspace_id=ws, - workspace_planning_goal=planning_goal, children=children or [], parents=parents or [], leave_by_parent=leave_by_parent or {}, @@ -171,6 +170,7 @@ def _make_ctx( vacation_by_parent=vacation_by_parent or {}, leave_wishes=leave_wishes or [], today=today or date.today(), + workspace_planning_mode=planning_mode, ) @@ -320,6 +320,30 @@ class TestPreserveForAge4: assert tip is not None assert tip.data["expiring_days"] == 74 + async def test_couple_workspace_with_two_parents_uses_per_parent_cap(self): + """A two-parent `couple` workspace caps each parent at 48, not 96 — + + the headline bug this task fixes. Same inputs as + `test_fires_when_days_will_expire` (total=240, used=20 -> projected + 220) but with a second parent and `planning_mode='couple'`: + expiring should be 220-48=172, not 220-96=124. + """ + ws = uuid4() + child = _make_child(date.today() - relativedelta(years=1)) + p1 = _make_parent(workspace_id=ws, name="A") + p2 = _make_parent(workspace_id=ws, name="B") + leave1 = _make_leave(p1, child.id, total=240, used=20) + ctx = _make_ctx( + workspace_id=ws, + children=[child], + parents=[p1, p2], + leave_by_parent={p1.uuid: leave1}, + planning_mode="couple", + ) + tip = await _rule_preserve_for_age_4(ctx) + assert tip is not None + assert tip.data["expiring_days"] == 172 # 220 - 48 + # --------------------------------------------------------------------------- # _rule_use_dubbeldagar_strategically @@ -534,7 +558,6 @@ class TestWrapWeekendWithVacation: d_mon = _make_fp_day(p, child.id, ws, monday) ctx = _make_ctx( workspace_id=ws, - planning_goal="max_money", children=[child], parents=[p], planned_days=[d_fri, d_mon], @@ -553,26 +576,6 @@ class TestWrapWeekendWithVacation: monday = friday + timedelta(days=3) ctx = _make_ctx( workspace_id=ws, - planning_goal="max_money", - children=[child], - parents=[p], - planned_days=[ - _make_fp_day(p, child.id, ws, friday), - _make_fp_day(p, child.id, ws, monday), - ], - ) - tip = await _rule_wrap_weekend_with_vacation(ctx) - assert tip is None - - async def test_no_fire_when_goal_is_not_max_money(self): - ws = uuid4() - child = _make_child(date.today() - relativedelta(years=1)) - p = _make_parent(workspace_id=ws, salary_type="monthly") - friday = _next_weekday(4, offset_days=1) - monday = friday + timedelta(days=3) - ctx = _make_ctx( - workspace_id=ws, - planning_goal="max_time", # not max_money children=[child], parents=[p], planned_days=[ @@ -590,7 +593,6 @@ class TestWrapWeekendWithVacation: friday = _next_weekday(4, offset_days=1) ctx = _make_ctx( workspace_id=ws, - planning_goal="max_money", children=[child], parents=[p], planned_days=[_make_fp_day(p, child.id, ws, friday)], @@ -618,7 +620,6 @@ class TestWrapRedDaysWithVacation: fp_date = cluster_start - timedelta(days=1) ctx = _make_ctx( workspace_id=ws, - planning_goal="max_money", children=[child], parents=[p], planned_days=[_make_fp_day(p, child.id, ws, fp_date)], @@ -629,19 +630,6 @@ class TestWrapRedDaysWithVacation: assert tip.id == "wrap_red_days_with_vacation" assert tip.data["cluster_start"] == cluster_start.isoformat() - async def test_no_fire_when_goal_is_not_max_money(self): - ws = uuid4() - child = _make_child(date.today() - relativedelta(years=1)) - p = _make_parent(workspace_id=ws, salary_type="monthly") - ctx = _make_ctx( - workspace_id=ws, - planning_goal="balanced", - children=[child], - parents=[p], - ) - tip = await _rule_wrap_red_days_with_vacation(ctx) - assert tip is None - async def test_no_fire_for_hourly_parent(self): ws = uuid4() # Use a fixed date (Dec 5, 2024) to ensure Christmas cluster is within lookahead @@ -655,7 +643,6 @@ class TestWrapRedDaysWithVacation: fp_date = cluster_start - timedelta(days=1) ctx = _make_ctx( workspace_id=ws, - planning_goal="max_money", children=[child], parents=[p], planned_days=[_make_fp_day(p, child.id, ws, fp_date)], diff --git a/frontend/src/components/inputs/PlanningGoalSelector.test.tsx b/frontend/src/components/inputs/PlanningGoalSelector.test.tsx deleted file mode 100644 index e581caa..0000000 --- a/frontend/src/components/inputs/PlanningGoalSelector.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { render, fireEvent } from '@testing-library/react' -import { PlanningGoalSelector } from './PlanningGoalSelector' - -describe('PlanningGoalSelector', () => { - it('renders three planning goal options', () => { - const { getByTestId } = render( - {}} /> - ) - expect(getByTestId('goal-max-time')).toBeInTheDocument() - expect(getByTestId('goal-max-money')).toBeInTheDocument() - expect(getByTestId('goal-balanced')).toBeInTheDocument() - }) - - it('renders as radio buttons', () => { - const { getByTestId } = render( - {}} /> - ) - const radio1 = getByTestId('goal-max-time') as HTMLInputElement - const radio2 = getByTestId('goal-max-money') as HTMLInputElement - expect(radio1.type).toBe('radio') - expect(radio2.type).toBe('radio') - }) - - it('checks selected value', () => { - const { getByTestId } = render( - {}} /> - ) - const radio = getByTestId('goal-max-money') as HTMLInputElement - expect(radio.checked).toBe(true) - }) - - it('calls onChange with max_time when selecting max_time option', () => { - const onChange = vi.fn() - const { getByTestId } = render( - - ) - const radio = getByTestId('goal-max-time') - fireEvent.click(radio) - expect(onChange).toHaveBeenCalledWith('max_time') - }) - - it('calls onChange with max_money when selecting max_money option', () => { - const onChange = vi.fn() - const { getByTestId } = render( - - ) - const radio = getByTestId('goal-max-money') - fireEvent.click(radio) - expect(onChange).toHaveBeenCalledWith('max_money') - }) - - it('calls onChange with balanced when selecting balanced option', () => { - const onChange = vi.fn() - const { getByTestId } = render( - - ) - const radio = getByTestId('goal-balanced') - fireEvent.click(radio) - expect(onChange).toHaveBeenCalledWith('balanced') - }) - - it('updates selection when value changes', () => { - const { getByTestId, rerender } = render( - {}} /> - ) - let radio = getByTestId('goal-max-time') as HTMLInputElement - expect(radio.checked).toBe(true) - - rerender( {}} />) - radio = getByTestId('goal-balanced') as HTMLInputElement - expect(radio.checked).toBe(true) - }) - - it('only one option can be selected at a time', () => { - const { getByTestId } = render( - {}} /> - ) - const radio1 = getByTestId('goal-max-time') as HTMLInputElement - const radio2 = getByTestId('goal-max-money') as HTMLInputElement - expect(radio1.checked).toBe(true) - expect(radio2.checked).toBe(false) - }) - - it('has no selection when value is undefined', () => { - const { getByTestId } = render( - {}} /> - ) - const radio1 = getByTestId('goal-max-time') as HTMLInputElement - const radio2 = getByTestId('goal-max-money') as HTMLInputElement - expect(radio1.checked).toBe(false) - expect(radio2.checked).toBe(false) - }) -}) diff --git a/frontend/src/components/inputs/PlanningGoalSelector.tsx b/frontend/src/components/inputs/PlanningGoalSelector.tsx deleted file mode 100644 index 935ea2c..0000000 --- a/frontend/src/components/inputs/PlanningGoalSelector.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { ReactNode } from 'react' -import { useTranslation } from '@/i18n' - -export type PlanningGoal = 'max_time' | 'max_money' | 'balanced' - -export interface PlanningGoalSelectorProps { - value?: PlanningGoal - onChange: (goal: PlanningGoal) => void -} - -export function PlanningGoalSelector({ value, onChange }: PlanningGoalSelectorProps): ReactNode { - const { t } = useTranslation() - return ( -
-
- {t('planningGoalSelector.legend')} - -
- onChange(e.target.value as PlanningGoal)} - data-testid="goal-max-time" - /> - - {t('planningGoalSelector.maxTimeHelp')} -
- -
- onChange(e.target.value as PlanningGoal)} - data-testid="goal-max-money" - /> - - {t('planningGoalSelector.maxMoneyHelp')} -
- -
- onChange(e.target.value as PlanningGoal)} - data-testid="goal-balanced" - /> - - {t('planningGoalSelector.balancedHelp')} -
-
-
- ) -} diff --git a/frontend/src/components/inputs/TipCard.tsx b/frontend/src/components/inputs/TipCard.tsx index 426d572..c3126a8 100644 --- a/frontend/src/components/inputs/TipCard.tsx +++ b/frontend/src/components/inputs/TipCard.tsx @@ -42,10 +42,6 @@ const TIP_TRANSLATION_KEYS: Record { vi.mocked(useAuth).mockReturnValue(makeAuthContextValue()) vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, workspaceId: 'ws-1', children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], selectedChildId: 'c-1', @@ -107,7 +107,7 @@ describe('Dashboard', () => { it('renders loading spinner initially', () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, workspaceId: 'ws-1', children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], selectedChildId: 'c-1', @@ -123,7 +123,7 @@ describe('Dashboard', () => { it('shows add child form when no children', () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, workspaceId: 'ws-1', children: [], selectedChildId: null, @@ -160,7 +160,7 @@ describe('Dashboard', () => { it('disables FK fetch button when no child selected', () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, workspaceId: 'ws-1', children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], selectedChildId: null, @@ -281,7 +281,7 @@ describe('Dashboard', () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, workspaceId: 'ws-1', children: [], selectedChildId: null, @@ -318,7 +318,7 @@ describe('Dashboard', () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, workspaceId: 'ws-1', children: [], selectedChildId: null, @@ -351,7 +351,7 @@ describe('Dashboard', () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, workspaceId: 'ws-1', children: [], selectedChildId: null, @@ -412,7 +412,7 @@ describe('Dashboard', () => { it('disables manual fetch button when no child selected', () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ - workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + workspace: { id: 'ws-1', name: 'Test Workspace', planningMode: 'solo', createdAt: '2026-01-01' }, workspaceId: 'ws-1', children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], selectedChildId: null, diff --git a/frontend/src/pages/Plan.test.tsx b/frontend/src/pages/Plan.test.tsx index c29321e..e929c4c 100644 --- a/frontend/src/pages/Plan.test.tsx +++ b/frontend/src/pages/Plan.test.tsx @@ -125,7 +125,6 @@ describe('Plan', () => { workspace: { id: 'ws-1', name: 'Test Workspace', - planningGoal: 'balanced', planningMode: 'couple', createdAt: '2026-01-01', }, @@ -167,7 +166,6 @@ describe('Plan', () => { workspace: { id: 'ws-1', name: 'Test Workspace', - planningGoal: 'balanced', planningMode: 'couple', createdAt: '2026-01-01', }, diff --git a/frontend/src/pages/Settings.test.tsx b/frontend/src/pages/Settings.test.tsx index b395320..8bfd23d 100644 --- a/frontend/src/pages/Settings.test.tsx +++ b/frontend/src/pages/Settings.test.tsx @@ -117,7 +117,6 @@ describe('Settings', () => { workspace: { id: 'ws-1', name: 'Test Workspace', - planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01', }, @@ -164,7 +163,6 @@ describe('Settings', () => { vi.mocked(api.updateWorkspace).mockResolvedValue({ id: 'ws-1', name: 'Test Workspace', - planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01', }) @@ -190,7 +188,6 @@ describe('Settings', () => { workspace: { id: 'ws-1', name: 'Test Workspace', - planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01', }, @@ -283,14 +280,13 @@ describe('Settings', () => { expect(screen.getByTestId('planning-mode-solo')).toBeChecked() }) - it('saves the selected planning mode together with the planning goal', async () => { + it('saves the selected planning mode', async () => { renderSettings() fireEvent.click(screen.getByTestId('planning-mode-couple')) fireEvent.click(screen.getByTestId('save-planning-goal-button')) await waitFor(() => expect(api.updateWorkspace).toHaveBeenCalledWith('ws-1', { - planningGoal: 'balanced', planningMode: 'couple', }), ) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index ed4137e..96e11ed 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -15,7 +15,6 @@ import { Layout } from '@/components/layout/Layout' import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput' import { ParentProfileInput, type SalaryType, type EmploymentStatus } from '@/components/inputs/ParentProfileInput' -import { PlanningGoalSelector, type PlanningGoal } from '@/components/inputs/PlanningGoalSelector' import { PlanningModeSelector, type PlanningMode } from '@/components/inputs/PlanningModeSelector' import { VacationDaysInput } from '@/components/inputs/VacationDaysInput' import { VacationDaysTable, type VacationDaysTableRow } from '@/components/vacation-days/VacationDaysTable' @@ -132,7 +131,6 @@ export function Settings(): ReactNode { const [childBirthDate, setChildBirthDate] = useState('') const [preschoolPlanningDays, setPreschoolPlanningDays] = useState(6) const [parentData, setParentData] = useState<{ salaryType?: SalaryType; employmentStatus?: EmploymentStatus }>({}) - const [planningGoal, setPlanningGoal] = useState(workspace?.planningGoal ?? 'balanced') const [planningMode, setPlanningMode] = useState(workspace?.planningMode ?? 'solo') const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) const [isSaving, setIsSaving] = useState(false) @@ -152,13 +150,10 @@ export function Settings(): ReactNode { }, [childDetails]) useEffect(() => { - if (workspace?.planningGoal) { - setPlanningGoal(workspace.planningGoal) - } if (workspace?.planningMode) { setPlanningMode(workspace.planningMode) } - }, [workspace?.planningGoal, workspace?.planningMode]) + }, [workspace?.planningMode]) const selectedChild = children.find((c) => c.id === selectedChildId) const hasPartner = members.some((m) => m.uuid !== personalDataUuid) @@ -256,7 +251,7 @@ export function Settings(): ReactNode { try { if (workspace?.id) { setIsSaving(true) - await api.updateWorkspace(workspace.id, { planningGoal, planningMode }) + await api.updateWorkspace(workspace.id, { planningMode }) await refreshContext() showMessage('success', t('settings.savePlanningSuccess')) } @@ -442,7 +437,6 @@ export function Settings(): ReactNode {

{t('settings.planningSectionTitle')}

{t('settings.planningHelp')}

-