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
@@ -3,9 +3,9 @@ import { render, fireEvent, screen, act } from '@testing-library/react'
import { VacationDaysTable, type VacationDaysTableRow } from './VacationDaysTable'
const ROWS: VacationDaysTableRow[] = [
{ year: 2026, totalDays: 10, savedDaysIn: 2 },
{ year: 2027, totalDays: 25, savedDaysIn: 0 },
{ year: 2028, totalDays: 25, savedDaysIn: 0 },
{ year: 2026, totalDays: 10, savedDaysIn: 2, expiring: 0, expiringAge: 0, expiringOverCap: 0 },
{ year: 2027, totalDays: 25, savedDaysIn: 0, expiring: 0, expiringAge: 0, expiringOverCap: 0 },
{ year: 2028, totalDays: 25, savedDaysIn: 0, expiring: 0, expiringAge: 0, expiringOverCap: 0 },
]
function renderTable(
@@ -184,10 +184,43 @@ describe('VacationDaysTable', () => {
})
it('shows an assumed badge for a year with no entered data (§3.2)', () => {
const rows = [...ROWS, { year: 2029, totalDays: 25, savedDaysIn: 0, isAssumed: true }]
const rows = [
...ROWS,
{ year: 2029, totalDays: 25, savedDaysIn: 0, isAssumed: true, expiring: 0, expiringAge: 0, expiringOverCap: 0 },
]
renderTable(rows, vi.fn().mockResolvedValue(undefined))
expect(screen.getByTestId('vacation-assumed-badge-2029')).toBeInTheDocument()
expect(screen.queryByTestId('vacation-assumed-badge-2026')).not.toBeInTheDocument()
})
it('shows the at-risk figure in neutral ink when nothing is expiring', () => {
renderTable(ROWS, vi.fn().mockResolvedValue(undefined))
const atRisk = screen.getByTestId('vacation-at-risk-2026')
expect(atRisk).toHaveTextContent('0')
expect(atRisk).not.toHaveClass('vacation-rows__at-risk-value--danger')
})
it('shows the at-risk figure in danger ink when days are expiring, with a tooltip splitting the cause', () => {
const rows = [
{ year: 2026, totalDays: 30, savedDaysIn: 8, expiring: 5, expiringAge: 2, expiringOverCap: 3 },
...ROWS.slice(1),
]
renderTable(rows, vi.fn().mockResolvedValue(undefined))
const atRisk = screen.getByTestId('vacation-at-risk-2026')
expect(atRisk).toHaveTextContent('5')
expect(atRisk).toHaveClass('vacation-rows__at-risk-value--danger')
expect(atRisk.title).toContain('2')
expect(atRisk.title).toContain('3')
})
it('shows only the relevant cause in the at-risk tooltip when the other is zero', () => {
const rows = [
{ year: 2026, totalDays: 30, savedDaysIn: 8, expiring: 4, expiringAge: 4, expiringOverCap: 0 },
...ROWS.slice(1),
]
renderTable(rows, vi.fn().mockResolvedValue(undefined))
const atRisk = screen.getByTestId('vacation-at-risk-2026')
expect(atRisk.title).toBe('4 dagar har passerat 5-årsgränsen för att spara semester')
})
})