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
@@ -68,6 +68,14 @@ export interface PlannedDayModalProps {
isRangeMode?: boolean | undefined
dayCount?: number | undefined
defaultDayType?: 'sjukpenning' | 'lagsta' | 'vacation' | undefined
/** Dates in the current selection that are free days (weekend/red day,
* per workspace `DayOverride`) — a `vacation` pin always leaves these off,
* and a `sjukpenning`/`lagsta` pin does too unless `takePlOnRedDays`.
* Drives the "will stay off" notice below the type select. */
freeDatesInSelection?: string[] | undefined
/** Mirrors `Workspace.takePlOnRedDays` (see Settings) — whether this
* workspace opted in to funding free days with parental leave. */
takePlOnRedDays?: boolean | undefined
}
export function PlannedDayModal({
@@ -88,6 +96,8 @@ export function PlannedDayModal({
isRangeMode = false,
dayCount = 1,
defaultDayType,
freeDatesInSelection = [],
takePlOnRedDays = false,
}: PlannedDayModalProps): ReactNode {
const { t, locale } = useTranslation()
const hasMultipleParents = parents.length > 1
@@ -118,6 +128,23 @@ export function PlannedDayModal({
const pinnedPath = forcedAdvanced || (soloDirect ? dayType !== 'auto' : isAdvancedOpen)
const showPinnedControls = forcedAdvanced || soloDirect || isAdvancedOpen
// A concrete type is about to be pinned (not the 'auto' planner-decides
// choice) — the two notices below only make sense once that's true.
const isPinningConcreteType = pinnedPath && dayType !== 'auto'
// The chosen type would leave every free day in the selection (weekend/red
// day) as a day off: always true for `vacation`, and true for a PL type
// unless the workspace opted in via `takePlOnRedDays` — mirrors
// `Calendar.tsx`'s `handleSavePinnedDay` filter exactly.
const freeDaysWillStayOff =
isPinningConcreteType &&
freeDatesInSelection.length > 0 &&
(dayType === 'vacation' || !takePlOnRedDays)
// Pinning excludes the day from `sync_plan`'s automatic re-planning —
// worth saying prospectively (about to pin) even when the day isn't
// already pinned; `existingDay?.isPinned` below already covers the
// already-pinned case, so this doesn't repeat there.
const showPinExclusionNotice = isPinningConcreteType && !existingDay?.isPinned
const [localError, setLocalError] = useState<string | null>(null)
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
@@ -359,6 +386,18 @@ export function PlannedDayModal({
</select>
</div>
{freeDaysWillStayOff && (
<p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice">
{t('plannedDayModal.freeDaysStayOffNotice')}
</p>
)}
{showPinExclusionNotice && (
<p className="planned-day-modal__hint" data-testid="pin-exclusion-notice">
{t('plannedDayModal.pinExcludesFromSyncNotice')}
</p>
)}
{hasMultipleParents && (
<div className="field">
<label htmlFor="parent-select" className="field__label">