Free days, vacation forfeiture, and global auto-replanning

Free days (Sat/Sun/red days, subject to DayOverride) can never be
vacation, and can only be parental leave when the workspace opts in via
take_pl_on_red_days. The allocator already honoured this; the hand-pin
path bypassed it entirely.

- PlanningService gains optional workspace_repo/day_override_repo deps
  and _reject_illegal_free_day, enforced in add_planned_day and update.
  Reuses allocation_service.is_free_day rather than re-deriving it, and
  no-ops when the deps are unset so existing callers are unaffected.
- update_planned_day had no ValidationError handler at all, so a
  validation failure there 500'd instead of 422'ing. Fixed.
- Calendar filters the pin write loop by the day's isFree; the modal
  explains free days stay off, and that pinning opts a day out of
  automatic re-planning.

Vacation days at risk of forfeiture were computed and sent over the
wire but never rendered: an at-risk column with the age-out/over-cap
split, and a dashboard key figure.

PlanProposal.warnings was likewise parsed but rendered nowhere, for all
three kinds. Adds PlanWarningCard in a Risks section above the tips,
with parent names resolved and dates formatted.

POST /api/plan/sync now fires from the workspace layer on every
plan-input change rather than only from Calendar, so a Settings edit or
a pinned-day save re-plans day types instead of merely refetching.
Overlapping bumps coalesce into one trailing run; a rejection cannot
wedge the in-flight guard. eviction_preview was silently dropped by the
frontend despite always being on the wire, and is now surfaced through
context.

Verified: backend 890 passed (91.49% coverage), frontend 1297 passed,
ruff/eslint/tsc clean. Browser-checked in Swedish and English.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVQx4NUvPbJhn6UcvRd2QY
This commit is contained in:
2026-09-20 13:07:36 +02:00
parent 16390fec50
commit 61e5487078
44 changed files with 1891 additions and 60 deletions
+54
View File
@@ -18,6 +18,10 @@ import {
makeDayOverridesResult,
makeCalendarDaysResult,
makePreschoolPlanningDaysResult,
makeAnalyticsResult,
makeAge4Projection,
makeParentYearBalance,
makeVacationYearBalance,
} from '../../tests/fixtures'
vi.mock('@/hooks')
@@ -128,6 +132,7 @@ describe('Settings', () => {
let mockUpsertDayOverride: ReturnType<typeof vi.fn>
let mockRemoveDayOverride: ReturnType<typeof vi.fn>
let mockRefreshMembers: ReturnType<typeof vi.fn>
let mockBumpPlanVersion: ReturnType<typeof vi.fn>
const mockCalendarWindow = {
startYear: CURRENT_YEAR,
@@ -147,6 +152,7 @@ describe('Settings', () => {
mockUpsertDayOverride = vi.fn().mockResolvedValue(undefined)
mockRemoveDayOverride = vi.fn().mockResolvedValue(undefined)
mockRefreshMembers = vi.fn().mockResolvedValue(undefined)
mockBumpPlanVersion = vi.fn()
vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({
@@ -160,6 +166,7 @@ describe('Settings', () => {
selectedChildId: 'c-1',
personalDataUuid: 'pd-1',
parentName: 'Test Parent',
bumpPlanVersion: mockBumpPlanVersion,
})
)
// Default: solo workspace (only the logged-in user) — the add-partner
@@ -176,6 +183,10 @@ describe('Settings', () => {
upsert: mockUpsertVacationDays,
})
)
// Default: no analytics data (matches a fresh/no-child workspace) — the
// vacation table's "at risk" column then shows 0, not blocking on it.
// Tests exercising the at-risk column override this.
vi.mocked(hooks.useAnalytics).mockReturnValue(makeAnalyticsResult({ data: null }))
vi.mocked(hooks.useShowWeekNumbers).mockReturnValue(makeShowWeekNumbersTuple(false, vi.fn()))
vi.mocked(hooks.useChildSettings).mockReturnValue(
makeChildSettingsResult({
@@ -277,6 +288,39 @@ describe('Settings', () => {
expect(totalInput.value).toBe('25')
})
it('surfaces the analytics-computed at-risk figure for a year, joined by member and year', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue(
makeAnalyticsResult({
data: {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [
makeAge4Projection({
personalDataUuid: 'pd-1',
childId: 'c-1',
yearBalances: [
makeParentYearBalance({
personalDataUuid: 'pd-1',
childId: 'c-1',
year: CURRENT_YEAR,
vacation: makeVacationYearBalance({ year: CURRENT_YEAR, expiring: 6, expiringAge: 6, expiringOverCap: 0 }),
}),
],
}),
],
combinedDaysUsed: 0,
combinedDaysLeft: 0,
combinedExpiringAtB4: 0,
yearTotals: [],
},
})
)
renderSettings()
expect(screen.getByTestId(`vacation-at-risk-${CURRENT_YEAR}`)).toHaveTextContent('6')
// A year with no matching yearBalance falls back to 0, not crashing.
expect(screen.getByTestId(`vacation-at-risk-${CURRENT_YEAR + 1}`)).toHaveTextContent('0')
})
it('autosaves an edited year with its total days, with no Save button', async () => {
renderSettings()
const year = CURRENT_YEAR + 1
@@ -397,6 +441,10 @@ describe('Settings', () => {
employment_status: 'employed',
}),
)
// Salary type feeds the allocator — the parent-settings save must
// re-plan, not just refetch. (Child-details bumps separately via
// useChildSettings.update, exercised by useChildSettings.test.ts.)
await waitFor(() => expect(mockBumpPlanVersion).toHaveBeenCalledTimes(1))
})
it('shows an error when the combined family save fails', async () => {
@@ -404,6 +452,7 @@ describe('Settings', () => {
renderSettings()
fireEvent.click(screen.getByTestId('save-family-button'))
expect(await screen.findByTestId('save-message')).toHaveTextContent('save failed')
expect(mockBumpPlanVersion).not.toHaveBeenCalled()
})
})
@@ -732,6 +781,7 @@ describe('Settings', () => {
personalDataUuid: 'pd-1',
parentName: 'Test Parent',
refreshContext: mockRefreshContext,
bumpPlanVersion: mockBumpPlanVersion,
})
)
renderSettings()
@@ -742,6 +792,9 @@ describe('Settings', () => {
expect(api.updateWorkspace).toHaveBeenCalledWith('ws-1', { takePlOnRedDays: true })
)
expect(mockRefreshContext).toHaveBeenCalled()
// Whether PL may be taken on red days changes what the allocator can
// place leave on — the toggle must re-plan, not just refresh context.
expect(mockBumpPlanVersion).toHaveBeenCalledTimes(1)
})
it('shows an error message when saving fails', async () => {
@@ -751,6 +804,7 @@ describe('Settings', () => {
fireEvent.click(screen.getByTestId('take-pl-on-red-days-checkbox'))
expect(await screen.findByTestId('save-message')).toHaveTextContent('network down')
expect(mockBumpPlanVersion).not.toHaveBeenCalled()
})
})