diff --git a/backend/app/services/allocation_service.py b/backend/app/services/allocation_service.py index 1d3f04f..589fda9 100644 --- a/backend/app/services/allocation_service.py +++ b/backend/app/services/allocation_service.py @@ -1116,29 +1116,40 @@ class _Ledger: ] if siblings: other = min(siblings, key=lambda o: o.priority) - other_start, other_end = other.window_for_year(other.first_year) - other_dates = _dates_in(other_start, other_end) - spendable = sum( - 1 - for d in other_dates - if not is_free_day(d, self._input.holidays, self._input.day_overrides) - ) - half_weeks = max(other.target_weeks // 2, 1) - freed_estimate = round(spendable / 2) - if freed_estimate > 0: - remedies.append( - ShortfallRemedy( - kind=REMEDY_LOWER_PRIORITY_WISH, - message_key=f"planRemedies.{REMEDY_LOWER_PRIORITY_WISH}", - data={ - "wish_name": other.name, - "from_weeks": other.target_weeks, - "to_weeks": half_weeks, - "freed_days": freed_estimate, - }, - days_freed=freed_estimate, - ) + # A whole-week target can only be halved to a genuinely + # smaller whole week count when it's at least 2 weeks to + # begin with (`target_weeks` has a domain floor of 1 — see + # `LeaveWishCreate.target_weeks`). At exactly 1 week, + # `target_weeks // 2 == 0`, which used to get clamped back + # up to 1, producing a nonsensical "from 1 to 1 weeks" + # no-op remedy. Suppress the suggestion entirely rather + # than claim a change that isn't one — the planner works in + # whole days/weeks, so there's no fractional week to offer + # instead. + half_weeks = other.target_weeks // 2 + if half_weeks >= 1: + other_start, other_end = other.window_for_year(other.first_year) + other_dates = _dates_in(other_start, other_end) + spendable = sum( + 1 + for d in other_dates + if not is_free_day(d, self._input.holidays, self._input.day_overrides) ) + freed_estimate = round(spendable / 2) + if freed_estimate > 0: + remedies.append( + ShortfallRemedy( + kind=REMEDY_LOWER_PRIORITY_WISH, + message_key=f"planRemedies.{REMEDY_LOWER_PRIORITY_WISH}", + data={ + "wish_name": other.name, + "from_weeks": other.target_weeks, + "to_weeks": half_weeks, + "freed_days": freed_estimate, + }, + days_freed=freed_estimate, + ) + ) shift_gain = 0 probe = w.start - timedelta(days=1) @@ -1890,8 +1901,16 @@ class AllocationInputLoader: lagsta_left = tier.lagsta_niva_left dubbel_left = latest.dubbeldagar_left else: - sjuk_left = rules.sjukpenning_days - lagsta_left = rules.lagsta_niva_days + # No FK snapshot yet: `rules.sjukpenning_days`/`lagsta_niva_days` + # are the WHOLE CHILD's combined tier totals (390 + 90, split + # across both parents) — assuming that full amount for a single + # parent double-counts the pool. Halve it to get this parent's + # default share, matching AnalyticsService's no-snapshot + # fallback (`rules.days_per_parent` == 195 + 45 == 240) and the + # manual-entry convention (see scripts/seed_demo.py, and + # `fk_scraper.py`'s "= rules.lagsta_niva_days/2" comment). + sjuk_left = rules.sjukpenning_days // 2 + lagsta_left = rules.lagsta_niva_days // 2 dubbel_left = rules.max_dubbeldagar # Net out days already committed locally (planned_leave_day) diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py index b325ef9..227f500 100644 --- a/backend/app/services/analytics_service.py +++ b/backend/app/services/analytics_service.py @@ -25,6 +25,7 @@ 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.utils.errors import NotFoundError +from app.utils.tip_predicates import days_left_at_age_4_projection @dataclass @@ -107,10 +108,15 @@ class AnalyticsService: ) planned_fp_before_b4 = sjukpenning_before_b4 + lagsta_before_b4 - projected_used = days_used + planned_fp_before_b4 - projected_left = total_days - projected_used + # 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`. + projected_left, expiring = days_left_at_age_4_projection( + days_used=days_used, + planned_fp_before_b4=planned_fp_before_b4, + total_days=total_days, + ) savable = min(projected_left, rules.max_savable_days_at_age_4) - expiring = max(projected_left - rules.max_savable_days_at_age_4, 0) return Age4Projection( personal_data_uuid=personal_data_uuid, diff --git a/backend/app/services/planning_service.py b/backend/app/services/planning_service.py index 455276e..550bb47 100644 --- a/backend/app/services/planning_service.py +++ b/backend/app/services/planning_service.py @@ -469,8 +469,13 @@ class PlanningService: sjuk_total_left = latest.tier_data.sjukpenning_left lagsta_total_left = latest.tier_data.lagsta_niva_left else: - sjuk_total_left = rules.sjukpenning_days - lagsta_total_left = rules.lagsta_niva_days + # `rules.sjukpenning_days`/`lagsta_niva_days` are the WHOLE + # CHILD's combined tier totals across both parents — halve them + # for this parent's default share, matching + # `AllocationInputLoader.load`'s no-snapshot fallback (Bug A + # fix: the two used to disagree, 390/90 vs. 195/45). + sjuk_total_left = rules.sjukpenning_days // 2 + lagsta_total_left = rules.lagsta_niva_days // 2 committed_sjuk = await self._repo.count_by_parent_type(parent_uuid, child_id, "sjukpenning") committed_lagsta = await self._repo.count_by_parent_type(parent_uuid, child_id, "lagsta") diff --git a/backend/tests/unit/test_planning_service_apply.py b/backend/tests/unit/test_planning_service_apply.py index 28523ff..bff0e48 100644 --- a/backend/tests/unit/test_planning_service_apply.py +++ b/backend/tests/unit/test_planning_service_apply.py @@ -9,7 +9,7 @@ The "nothing written on refusal" assertions check actual repository state property this test file cares about most. """ -from datetime import date, datetime +from datetime import date, datetime, timedelta from uuid import uuid4 from app.entities.parental_leave import ParentalLeaveFetch, TierData @@ -253,6 +253,48 @@ class TestApplyProposedDaysValidation: ) assert rows == [] + async def test_no_snapshot_default_balance_matches_allocation_engine( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + """Bug A regression, third copy: with no FK snapshot at all, this + parent's default tier balance must be their per-parent SHARE (195 + sjukpenning / 45 lagsta) of `rules.sjukpenning_days`/`lagsta_niva_days` + (390/90, the WHOLE CHILD's combined total across both parents) — not + the full 390/90, which `AllocationInputLoader.load` also used to + wrongly assume for a single parent before the fix. A batch of exactly + 195 sjukpenning days must be accepted; one more must be rejected. + """ + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + # No ParentalLeaveFetch created -> exercises the default fallback. + one_too_many = [ + ProposedDayInput(date(2026, 1, 1) + timedelta(days=i), parent, child, "sjukpenning") + for i in range(196) # 195 default share + 1 over + ] + + result = await planning_service.apply_proposed_days(ws, one_too_many) + + assert result.accepted is False + assert any( + r.message_key == "planApply.rejection.insufficientBalance" + and r.data["tier"] == "sjukpenning" + for r in result.rejections + ) + rows = await planned_leave_repo.list_by_workspace_child( + workspace_id=ws, child_id=child, from_date=date(2026, 1, 1), to_date=date(2027, 1, 1) + ) + assert rows == [] + + exactly_the_share = one_too_many[:195] + result_ok = await planning_service.apply_proposed_days(ws, exactly_the_share) + assert result_ok.accepted is True + async def test_batch_exceeding_remaining_balance_rejected_whole( self, planning_service: PlanningService, 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 4848f64..04f3e3d 100644 --- a/backend/tests/unit/test_services/test_allocation_input_loader.py +++ b/backend/tests/unit/test_services/test_allocation_input_loader.py @@ -210,9 +210,14 @@ class TestAllocationInputLoader: assert pool_with_fetch.lagsta_left == 90 # No FK snapshot at all for parent_without_fetch -> default entitlement. + # `rules.sjukpenning_days`/`lagsta_niva_days` (390/90) are the WHOLE + # CHILD's combined tier totals across both parents, so a single + # parent's default share is half of each (195/45, summing to + # `rules.days_per_parent` == 240) — matching AnalyticsService's own + # no-snapshot fallback (Bug A regression: these two must agree). pool_defaulted = alloc_input.pools[(parent_without_fetch.uuid, child.id)] - assert pool_defaulted.sjukpenning_left == 390 - assert pool_defaulted.lagsta_left == 90 + assert pool_defaulted.sjukpenning_left == 195 + assert pool_defaulted.lagsta_left == 45 vac = alloc_input.vacation[(parent_with_fetch.uuid, 2027)] assert vac.total_days == 25 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 4c44161..320ba67 100644 --- a/backend/tests/unit/test_services/test_allocation_service_branches.py +++ b/backend/tests/unit/test_services/test_allocation_service_branches.py @@ -420,6 +420,70 @@ class TestRemedies: carry = next(r for r in outcome.remedies if r.kind == "carry_vacation_forward") assert carry.days_freed == 13 + def test_lower_priority_wish_suppressed_when_target_is_already_one_week(self) -> None: + """Bug B regression: a 1-week sibling wish has no smaller whole-week + target to suggest (`target_weeks` floors at 1 — see + `LeaveWishCreate.target_weeks`). Halving used to clamp back up to 1, + producing a nonsensical "lower Vår from 1 to 1 weeks" remedy. It must + be suppressed instead of claiming a change that isn't one.""" + child = _child(birth_date=date(2025, 1, 1)) + parent = _parent(salary_type="hourly", min_vacation_days_per_year=20) + wish_high = _wish( + child_id=child.id, name="Feb", start=FEB_2027[0], end=FEB_2027[1], weeks=4, priority=1 + ) + wish_low = _wish( + child_id=child.id, name="Vår", start=AUG_2027[0], end=AUG_2027[1], weeks=1, priority=2 + ) + 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], + pools={(parent.uuid, child.id): DayPool(5, 0, 60)}, + vacation={(parent.uuid, 2026): VacationBudget(0, 0, 0)}, # no carry-forward remedy + wishes=[wish_high, wish_low], + ) + + proposal = Allocator().propose(alloc_input) + + outcome_high = next(o for o in proposal.outcomes if o.wish_name == "Feb") + assert outcome_high.shortfall_days > 0 + assert "lower_priority_wish" not in {r.kind for r in outcome_high.remedies} + + def test_lower_priority_wish_reports_a_genuine_reduction(self) -> None: + """A sibling wish of >= 2 weeks halves to a strictly smaller whole + week count, so the remedy fires with a real (not no-op) suggestion.""" + child = _child(birth_date=date(2025, 1, 1)) + parent = _parent(salary_type="hourly", min_vacation_days_per_year=20) + wish_high = _wish( + child_id=child.id, name="Feb", start=FEB_2027[0], end=FEB_2027[1], weeks=4, priority=1 + ) + wish_low = _wish( + child_id=child.id, name="Aug", start=AUG_2027[0], end=AUG_2027[1], weeks=2, priority=2 + ) + 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], + pools={(parent.uuid, child.id): DayPool(5, 0, 60)}, + vacation={(parent.uuid, 2026): VacationBudget(0, 0, 0)}, + wishes=[wish_high, wish_low], + ) + + proposal = Allocator().propose(alloc_input) + + outcome_high = next(o for o in proposal.outcomes if o.wish_name == "Feb") + remedy = next(r for r in outcome_high.remedies if r.kind == "lower_priority_wish") + assert remedy.data["from_weeks"] == 2 + assert remedy.data["to_weeks"] == 1 + assert remedy.data["from_weeks"] != remedy.data["to_weeks"] + assert remedy.days_freed > 0 + class TestDayOverrides: """`AllocationInput.day_overrides` — see `DayOverride` / `is_free_day`. diff --git a/backend/tests/unit/test_services/test_analytics_service.py b/backend/tests/unit/test_services/test_analytics_service.py index d9e3e6a..45e1cb3 100644 --- a/backend/tests/unit/test_services/test_analytics_service.py +++ b/backend/tests/unit/test_services/test_analytics_service.py @@ -15,7 +15,10 @@ from app.entities.parental_leave import ParentalLeaveFetch, TierData from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository +from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository +from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository +from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository from app.services.analytics_service import AnalyticsService from tests.conftest import SeededIds @@ -317,3 +320,87 @@ class TestGetOverview: ) with pytest.raises(NotFoundError): await service.get_overview(uuid4(), uuid4()) + + +class TestAgreesWithPlanProposalForTheSameCommittedState: + """Bug A regression: the Dashboard (`AnalyticsService`) and the Plan tab's + propose-time projection (`allocation_service._Ledger.build_age4_projections`, + via `Allocator`/`AllocationInputLoader`) must compute the same + `expiring_at_b4` for a parent with no FK snapshot yet and no leave wishes + (so the proposal adds nothing new) — i.e. the *committed* state. + + Before the fix, `AllocationInputLoader`'s no-snapshot fallback assumed a + single parent was entitled to the WHOLE child's combined tier totals + (`rules.sjukpenning_days` + `rules.lagsta_niva_days` == 390 + 90 == 480) + instead of that parent's fair half-share (195 + 45 == 240, matching + `AnalyticsService`'s own fallback, `rules.days_per_parent`). That made the + Plan tab report roughly double what the Dashboard showed for the exact + same (empty) committed plan — this test pins the two together. + """ + + async def test_no_snapshot_no_wishes_matches_dashboard( + self, + workspace_repo: SQLiteWorkspaceRepository, + child_repo: SQLiteChildRepository, + personal_data_repo: SQLitePersonalDataRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + ) -> None: + from app.entities.personal_data import PersonalData + from app.entities.workspace import Workspace + from app.services.allocation_service import AllocationInputLoader, Allocator + + 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="Sam", + salary_type="monthly", + employment_status="employed", + created_at=now, + ) + ) + birth = date.today() + relativedelta(years=2) + child = _make_child(ws.id, birth) + await child_repo.create(child) + # Deliberately no ParentalLeaveFetch and no leave wishes for `parent` + # -> both surfaces must fall back to default entitlement. + + # -- Dashboard side -- + analytics = await _make_service( + child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo + ) + dashboard_proj = await analytics.get_days_left_at_age_4(parent.uuid, child.id) + + # -- Plan-tab side -- + loader = AllocationInputLoader( + workspace_repo=workspace_repo, + child_repo=child_repo, + personal_data_repo=personal_data_repo, + parental_leave_repo=parental_leave_repo, + vacation_repo=vacation_repo, + planned_leave_repo=planned_leave_repo, + leave_wish_repo=leave_wish_repo, + ) + alloc_input = await loader.load(ws.id, today=date.today()) + proposal = Allocator().propose(alloc_input) + assert proposal.days == [] # no wishes -> nothing new proposed + plan_proj = next( + p + for p in proposal.age4_projections + if p.parent_personal_data_uuid == parent.uuid and p.child_id == child.id + ) + + assert dashboard_proj.projected_left_at_b4 == plan_proj.projected_left_at_b4 + assert dashboard_proj.savable_after_b4 == plan_proj.savable_after_b4 + assert dashboard_proj.expiring_at_b4 == plan_proj.expiring_at_b4 + # Pin the actual figures too, so a future regression of either + # fallback (240 vs. 480) fails loudly rather than merely disagreeing. + assert dashboard_proj.projected_left_at_b4 == 240 + assert dashboard_proj.expiring_at_b4 == 144 diff --git a/frontend/src/context/WorkspaceContext.test.tsx b/frontend/src/context/WorkspaceContext.test.tsx index fb1aafc..e6fa2e3 100644 --- a/frontend/src/context/WorkspaceContext.test.tsx +++ b/frontend/src/context/WorkspaceContext.test.tsx @@ -263,6 +263,66 @@ describe('WorkspaceContext', () => { expect(api.getWorkspace).not.toHaveBeenCalled() }) + it('clears identity/workspace state on logout so it cannot leak into the next session', async () => { + vi.mocked(useAuth).mockReturnValue(authenticated) + vi.mocked(api.getUserContext).mockResolvedValue(userContext) + vi.mocked(api.getWorkspace).mockResolvedValue(workspace) + + const { result, rerender } = renderHook(() => useWorkspace(), { wrapper }) + await waitFor(() => expect(result.current.workspace).not.toBeNull()) + expect(result.current.parentName).toBe('Anna') + + vi.mocked(useAuth).mockReturnValue(unauthenticated) + rerender() + + await waitFor(() => expect(result.current.workspace).toBeNull()) + expect(result.current.workspaceId).toBeNull() + expect(result.current.parentName).toBeNull() + expect(result.current.personalDataUuid).toBeNull() + expect(result.current.children).toEqual([]) + expect(result.current.selectedChildId).toBeNull() + expect(localStorage.getItem('workspaceId')).toBeNull() + }) + + it('refetches on a fresh login even when stale data from a previous session is already present', async () => { + // Regression for a login-swap bug: a previous guard only refetched when + // workspaceId/children looked empty, so logging in as a *different* + // user after a previous session's data was already cached here never + // refetched at all, and the new session silently rendered the old + // account's workspace/name/children. + vi.mocked(useAuth).mockReturnValue(authenticated) + vi.mocked(api.getUserContext).mockResolvedValue(userContext) + vi.mocked(api.getWorkspace).mockResolvedValue(workspace) + + const { result, rerender } = renderHook(() => useWorkspace(), { wrapper }) + await waitFor(() => expect(result.current.parentName).toBe('Anna')) + + vi.mocked(useAuth).mockReturnValue(unauthenticated) + rerender() + await waitFor(() => expect(result.current.workspace).toBeNull()) + + const otherWorkspace: Workspace = { ...workspace, id: 'ws2', name: 'Other workspace' } + const otherUserContext: api.UserContext = { + personal_data_uuid: 'pd2', + workspace_id: 'ws2', + parent_name: 'Alex', + children: [{ id: 'c3', name: 'Child Three', birth_date: '2026-01-01' }], + } + vi.mocked(api.getUserContext).mockResolvedValue(otherUserContext) + vi.mocked(api.getWorkspace).mockResolvedValue(otherWorkspace) + vi.mocked(api.getUserContext).mockClear() + + vi.mocked(useAuth).mockReturnValue(authenticated) + rerender() + + await waitFor(() => expect(result.current.parentName).toBe('Alex')) + expect(api.getUserContext).toHaveBeenCalled() + expect(result.current.workspaceId).toBe('ws2') + expect(result.current.personalDataUuid).toBe('pd2') + expect(result.current.selectedChildId).toBe('c3') + expect(result.current.workspace).toEqual(otherWorkspace) + }) + it('throws when useWorkspace is used outside a WorkspaceProvider', () => { vi.mocked(useAuth).mockReturnValue(unauthenticated) diff --git a/frontend/src/context/WorkspaceContext.tsx b/frontend/src/context/WorkspaceContext.tsx index 18eb764..23eca74 100644 --- a/frontend/src/context/WorkspaceContext.tsx +++ b/frontend/src/context/WorkspaceContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useCallback, useContext, useEffect, useState } from 'react' +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' import * as api from '@/services/api' import { useAuth } from '@/context/AuthContext' import type { Workspace } from '@/types' @@ -38,6 +38,11 @@ export function WorkspaceProvider({ children: childrenProp }: { children: React. const [selectedChildId, setSelectedChildId] = useState(null) const [personalDataUuid, setPersonalDataUuid] = useState(null) const [parentName, setParentName] = useState(null) + // Tracks isAuthenticated across renders so the effect below can react to + // login/logout *transitions* rather than to the mere presence (or + // absence) of cached data — see the effect for why that distinction is + // what makes a login swap actually refetch. + const wasAuthenticatedRef = useRef(false) const fetchWorkspace = useCallback(async (id: string) => { setIsLoading(true) @@ -80,11 +85,43 @@ export function WorkspaceProvider({ children: childrenProp }: { children: React. } }, []) + // Clears every piece of identity/workspace state this provider holds, + // including the persisted workspaceId. Called on logout so stale data is + // actively wiped rather than left to be overwritten "eventually" by the + // next login's fetch — the gap between those two points is exactly where + // one account's data could otherwise flash under another account's + // session. + const clearWorkspaceState = useCallback(() => { + localStorage.removeItem('workspaceId') + setWorkspace(null) + setWorkspaceId(null) + setChildren([]) + setSelectedChildId(null) + setPersonalDataUuid(null) + setParentName(null) + setError(null) + }, []) + + // React to isAuthenticated *transitions*, not to whether data already + // looks present. This provider sits above the router and outlives page + // navigation, so on a logout-then-login-as-a-different-user swap, + // workspaceId/children/personalDataUuid/parentName from the previous + // session are all still sitting in state here. A guard like + // `!workspaceId || children.length === 0` would then never re-fire for + // the new session (that data isn't empty, just stale), so the new + // account would silently keep rendering the old account's context. Using + // an explicit false->true edge means a fresh login always refetches, and + // a true->false edge always clears. useEffect(() => { - if (isAuthenticated && (!workspaceId || children.length === 0)) { + const wasAuthenticated = wasAuthenticatedRef.current + wasAuthenticatedRef.current = isAuthenticated + + if (isAuthenticated && !wasAuthenticated) { void refreshContext() + } else if (!isAuthenticated && wasAuthenticated) { + clearWorkspaceState() } - }, [isAuthenticated, workspaceId, children.length, refreshContext]) + }, [isAuthenticated, refreshContext, clearWorkspaceState]) useEffect(() => { if (isAuthenticated && workspaceId && !workspace) { diff --git a/frontend/src/hooks/useAnalytics.test.ts b/frontend/src/hooks/useAnalytics.test.ts index 8fc8fcc..5048fc4 100644 --- a/frontend/src/hooks/useAnalytics.test.ts +++ b/frontend/src/hooks/useAnalytics.test.ts @@ -76,4 +76,37 @@ describe('useAnalytics', () => { expect(api.getWorkspaceOverview).toHaveBeenCalled() }) + + it('clears stale data instead of leaving it rendered when the workspace/child identity is lost', async () => { + const mockData = { + workspaceId: 'ws1', + childId: 'child1', + parentProjections: [], + combinedDaysUsed: 76, + combinedDaysLeft: 164, + combinedExpiringAtB4: 50, + } + vi.mocked(api.getWorkspaceOverview).mockResolvedValue(mockData as any) + + const { result, rerender } = renderHook( + ({ workspaceId, childId }: { workspaceId: string | null; childId: string | undefined }) => + useAnalytics(workspaceId, childId), + { initialProps: { workspaceId: 'ws1' as string | null, childId: 'child1' as string | undefined } }, + ) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(result.current.data).toEqual(mockData) + + // Simulate a logout clearing the workspace/child selection — this must + // not leave the previous account's figures on screen. + rerender({ workspaceId: null, childId: undefined }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(result.current.data).toBeNull() + }) }) diff --git a/frontend/src/hooks/useAnalytics.ts b/frontend/src/hooks/useAnalytics.ts index 70ab2d7..4ab3f75 100644 --- a/frontend/src/hooks/useAnalytics.ts +++ b/frontend/src/hooks/useAnalytics.ts @@ -15,7 +15,14 @@ export function useAnalytics(workspaceId: string | null, childId?: string): UseA const [error, setError] = useState(null) const fetch = useCallback(async () => { - if (!workspaceId || !childId) return + if (!workspaceId || !childId) { + // The identity this data belongs to is gone (e.g. a logout cleared + // the workspace/child selection) — clear rather than leave the + // previous account's figures rendered until a new id arrives. + setData(null) + setError(null) + return + } setIsLoading(true) setError(null) try { diff --git a/frontend/src/hooks/useCalendarDays.test.ts b/frontend/src/hooks/useCalendarDays.test.ts index 262b827..a59ecbe 100644 --- a/frontend/src/hooks/useCalendarDays.test.ts +++ b/frontend/src/hooks/useCalendarDays.test.ts @@ -47,6 +47,25 @@ describe('useCalendarDays', () => { expect(result.current.error).toBeNull() }) + it('refetch re-runs the fetch with the current parameters', async () => { + vi.mocked(api.getCalendarDays).mockResolvedValue(mockWindow) + + const { result } = renderHook(() => useCalendarDays('ws-1', 2026, 4)) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + vi.mocked(api.getCalendarDays).mockClear() + + await act(async () => { + await result.current.refetch() + }) + + expect(api.getCalendarDays).toHaveBeenCalledWith('ws-1', 2026, 4) + expect(api.getCalendarDays).toHaveBeenCalledTimes(1) + }) + it('refetches when startYear changes', async () => { vi.mocked(api.getCalendarDays).mockResolvedValue(mockWindow) diff --git a/frontend/src/hooks/useCalendarDays.ts b/frontend/src/hooks/useCalendarDays.ts index cecee63..390e1bc 100644 --- a/frontend/src/hooks/useCalendarDays.ts +++ b/frontend/src/hooks/useCalendarDays.ts @@ -5,6 +5,12 @@ * Backs both the Kalender page's shading + click-to-override (§3.4/§3.6) and * the Settings red-days list (§3.8) from the same `GET /api/calendar/days` * endpoint — see `app/routers/calendar.py` / ARCHITECTURE.md §13.3. + * + * There is no shared cache/invalidation layer (no TanStack Query) here, so a + * day-override mutation elsewhere does not automatically make this window + * stale — callers that mutate overrides (see `useDayOverrides`) must + * explicitly call `refetch` afterwards or the calendar grid will keep + * showing the pre-mutation shading until a full reload. */ import { useCallback, useEffect, useState } from 'react' @@ -15,6 +21,7 @@ interface UseCalendarDaysResult { data: CalendarWindow | null isLoading: boolean error: string | null + refetch: () => Promise } export function useCalendarDays( @@ -44,5 +51,5 @@ export function useCalendarDays( void fetchWindow() }, [fetchWindow]) - return { data, isLoading, error } + return { data, isLoading, error, refetch: fetchWindow } } diff --git a/frontend/src/hooks/useParentalLeave.test.ts b/frontend/src/hooks/useParentalLeave.test.ts index ebf091a..2875043 100644 --- a/frontend/src/hooks/useParentalLeave.test.ts +++ b/frontend/src/hooks/useParentalLeave.test.ts @@ -78,4 +78,47 @@ describe('useParentalLeave', () => { expect(api.getParentalLeave).toHaveBeenCalled() }) + + it('clears stale data instead of leaving it rendered when the parent/child identity is lost', async () => { + const mockData = { + id: 'pf1', + personalDataUuid: 'pd1', + workspaceId: 'ws1', + childId: 'child1', + source: 'manual' as const, + fetchedAt: new Date().toISOString(), + totalDays: 300, + daysUsed: 20, + daysLeft: 280, + reservedDaysTotal: 90, + reservedDaysLeft: 80, + transferableDaysLeft: 50, + dubbeldagarLeft: 60, + tierData: { sjukpenningTotal: 390, sjukpenningUsed: 10, sjukpenningLeft: 380, lagstaNivaTotal: 90, lagstaNivaUsed: 0, lagstaNivaLeft: 90 }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + vi.mocked(api.getParentalLeave).mockResolvedValue(mockData as any) + + const { result, rerender } = renderHook( + ({ personalDataUuid, childId }: { personalDataUuid: string | null; childId: string | null }) => + useParentalLeave(personalDataUuid, childId), + { initialProps: { personalDataUuid: 'pd1' as string | null, childId: 'child1' as string | null } }, + ) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(result.current.data).toEqual(mockData) + + // Simulate a logout clearing the parent/child selection — this must not + // leave the previous account's figures on screen. + rerender({ personalDataUuid: null, childId: null }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(result.current.data).toBeNull() + }) }) diff --git a/frontend/src/hooks/useParentalLeave.ts b/frontend/src/hooks/useParentalLeave.ts index d8a3e38..79591e3 100644 --- a/frontend/src/hooks/useParentalLeave.ts +++ b/frontend/src/hooks/useParentalLeave.ts @@ -18,7 +18,14 @@ export function useParentalLeave( const [error, setError] = useState(null) const fetch = useCallback(async () => { - if (!personalDataUuid || !childId) return + if (!personalDataUuid || !childId) { + // The identity this data belongs to is gone (e.g. a logout cleared + // the parent/child selection) — clear rather than leave the previous + // account's figures rendered until a new id arrives. + setData(null) + setError(null) + return + } setIsLoading(true) setError(null) try { diff --git a/frontend/src/hooks/usePlannedDays.ts b/frontend/src/hooks/usePlannedDays.ts index 8a7689b..430759d 100644 --- a/frontend/src/hooks/usePlannedDays.ts +++ b/frontend/src/hooks/usePlannedDays.ts @@ -18,7 +18,14 @@ export function usePlannedDays(childId?: string): UsePlannedDaysResult { const [error, setError] = useState(null) const fetch = useCallback(async () => { - if (!childId) return + if (!childId) { + // The child this data belongs to is gone (e.g. a logout cleared the + // selection) — clear rather than leave the previous account's + // planned days rendered until a new childId arrives. + setData(null) + setError(null) + return + } setIsLoading(true) setError(null) try { diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 5a4b497..1ef769a 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -78,7 +78,7 @@ export const en: Record = { 'dashboard.overviewTitle': 'Overview', 'dashboard.usedDays': 'Used days', 'dashboard.daysLeft': 'Days left', - 'dashboard.expiringAtB4': 'Expires at age 4', + 'dashboard.expiringAtB4': 'Total expiring at age 4 (all parents)', 'dashboard.plannedDaysTitle': 'Planned days', 'dashboard.remainingUntilB4Title': 'Remaining until age 4', 'dashboard.saveManualFetchError': 'Could not save the details', diff --git a/frontend/src/i18n/sv.ts b/frontend/src/i18n/sv.ts index 3aeb479..cbbd96c 100644 --- a/frontend/src/i18n/sv.ts +++ b/frontend/src/i18n/sv.ts @@ -72,7 +72,7 @@ export const sv = { 'dashboard.overviewTitle': 'Översikt', 'dashboard.usedDays': 'Använda dagar', 'dashboard.daysLeft': 'Kvarvarande dagar', - 'dashboard.expiringAtB4': 'Förfaller vid 4 års ålder', + 'dashboard.expiringAtB4': 'Totalt förfaller vid 4 års ålder (alla föräldrar)', 'dashboard.plannedDaysTitle': 'Planerade dagar', 'dashboard.remainingUntilB4Title': 'Återstående till 4 års ålder', 'dashboard.saveManualFetchError': 'Kunde inte spara uppgifterna', diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx index 1b4abce..1f3228f 100644 --- a/frontend/src/pages/Calendar.test.tsx +++ b/frontend/src/pages/Calendar.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { Calendar } from './Calendar' import { useWorkspace } from '@/context' import { usePlannedDays, useCalendarDays, useDayOverrides } from '@/hooks' @@ -53,6 +53,7 @@ describe('Calendar', () => { data: { startYear: 2026, endYear: 2026, minYear: 2026, maxYear: 2035, days: [] }, isLoading: false, error: null, + refetch: vi.fn(), } const mockDayOverrides = { @@ -311,6 +312,7 @@ describe('Calendar', () => { }, isLoading: false, error: null, + refetch: vi.fn(), }) render() @@ -345,6 +347,7 @@ describe('Calendar', () => { }, isLoading: false, error: null, + refetch: vi.fn(), }) render() @@ -401,6 +404,7 @@ describe('Calendar', () => { it('clicking a currently-free day in override mode marks it as a working day', async () => { const today = todayDateString() const upsert = vi.fn().mockResolvedValue(undefined) + const refetchCalendarWindow = vi.fn().mockResolvedValue(undefined) ;(useWorkspace as any).mockReturnValue(mockWorkspace) ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) ;(useCalendarDays as any).mockReturnValue({ @@ -422,6 +426,7 @@ describe('Calendar', () => { }, isLoading: false, error: null, + refetch: refetchCalendarWindow, }) ;(useDayOverrides as any).mockReturnValue({ data: [], isLoading: false, error: null, upsert, remove: vi.fn() }) @@ -440,11 +445,17 @@ describe('Calendar', () => { isWorkingDay: true, note: null, }) + // Regression: the day cell's shading comes from a separate fetch + // (GET /api/calendar/days) than the override list, so a successful + // mutation must explicitly refetch it — otherwise the cell keeps + // showing its pre-mutation state until a full page reload. + await waitFor(() => expect(refetchCalendarWindow).toHaveBeenCalledTimes(1)) }) it('clicking an already-overridden day in override mode removes the override', async () => { const today = todayDateString() const removeOverride = vi.fn().mockResolvedValue(undefined) + const refetchCalendarWindow = vi.fn().mockResolvedValue(undefined) ;(useWorkspace as any).mockReturnValue(mockWorkspace) ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) ;(useCalendarDays as any).mockReturnValue({ @@ -466,6 +477,7 @@ describe('Calendar', () => { }, isLoading: false, error: null, + refetch: refetchCalendarWindow, }) ;(useDayOverrides as any).mockReturnValue({ data: [{ id: 'ov-1', workspaceId: 'ws-1', date: today, isWorkingDay: false, note: null }], @@ -485,6 +497,7 @@ describe('Calendar', () => { await screen.findByTestId('calendar-override-toggle') expect(removeOverride).toHaveBeenCalledWith('ov-1') + await waitFor(() => expect(refetchCalendarWindow).toHaveBeenCalledTimes(1)) }) }) }) diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index a2652f7..9111073 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -56,7 +56,11 @@ export function Calendar(): ReactNode { const calendarMaxYear = viewMonth === 11 ? viewYear + 1 : viewYear const calendarYearsSpan = calendarMaxYear - calendarMinYear + 1 - const { data: calendarWindow } = useCalendarDays(workspace?.id ?? '', calendarMinYear, calendarYearsSpan) + const { data: calendarWindow, refetch: refetchCalendarWindow } = useCalendarDays( + workspace?.id ?? '', + calendarMinYear, + calendarYearsSpan, + ) const { data: dayOverrides, upsert: upsertDayOverride, remove: removeDayOverride } = useDayOverrides( workspace?.id ?? '', ) @@ -183,6 +187,12 @@ export function Calendar(): ReactNode { note: null, }) } + // The grid's shading/badges come from GET /api/calendar/days, a + // separate fetch from the override list above — without this + // explicit refetch the day-overrides list would be up to date but the + // calendar cell would keep showing the pre-mutation state until a + // full page reload. + await refetchCalendarWindow() } catch (err) { setOverrideError(err instanceof Error ? err.message : t('calendar.overrideError')) }