Spend vacation on the days worth having, and name the ones about to expire

The planner knew exactly how many semesterdagar a parent was about to
forfeit and never placed a single day to prevent it. It also had no
weekend-wrap logic at all: REASON_VACATION_WRAP_WEEKEND was stamped on
every tier-3 interior vacation day, including a lone Tuesday in February.

Two changes.

Spend better. A workday adjacent to a free stretch, or in the middle of a
gap of three or fewer fenced by free days, now takes vacation --
_vacation_bridge_reason, consulted for every parent type. This departs
from spec 6.1's "wrapping suppressed for hourly": the FP-preservation
argument is salary-type independent, only the pay argument was hourly-moot.
In a both-parents window, both now take vacation on such a date rather
than one paying with FP. After funding, reallocate_vacation_by_value swaps
vacation onto higher-ranked dates 1-for-1 with FP days, so which dates hold
vacation improves while how many never drops -- greedy first, re-timed
after, never withheld. An invariant test asserts the count can only rise.

Name the losses. A new pass proposes concrete dates for vacation that would
otherwise expire, current year only, ranked red-day bridge then weekend
bridge then interior, later dates first. They ride on
PlanProposal.rescue_days, kept off `days` so nothing downstream commits
them, and surface on the Dashboard with accept-one, accept-all (one
batched /api/plan/apply, not N) and dismiss. Dismissal reuses the
tip_dismissal table, keyed on (parent, year) identity rather than the
group's dates -- those are recomputed from the whole plan's residual on
every propose, so date-hashing made dismissal self-defeating. Dismiss does
not bump the plan version; it changes no data and must not trigger a write.

Measured on the seeded demo: 12 forfeiting days for one parent now yield 12
proposed dates, all Fridays or Mondays. Total vacation placed is unchanged
at 203 days, confirming the reallocation only re-times.

Three defects here were found by driving a browser, while 2273 unit tests
passed -- all three lived in the seam between layers that unit tests mock
away. Adds API-level tests over the real request sequence. Rate limiting is
now off in tests (one file consumed the whole registration budget) with the
suite's first real 429 assertion.

PROPOSE_VS_SYNC_BUG.md documents a pre-existing, unfixed issue found along
the way: Dashboard figures swing on the first sync with no user action.

Backend 946 tests, frontend 1341, coverage above all gates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZaX6xbuMEMXuyKA1fq2Mm
This commit is contained in:
2026-09-21 07:00:18 +02:00
parent fc90cf9a5f
commit 014503e89b
35 changed files with 5023 additions and 99 deletions
+90 -6
View File
@@ -1,4 +1,4 @@
import { ReactNode, useEffect, useRef } from 'react'
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useWorkspace } from '@/context'
import {
@@ -25,10 +25,41 @@ import { Age4Bars } from '@/components/dashboard/Age4Bars'
import { YearRows } from '@/components/dashboard/YearRows'
import { SurplusAdviceTip } from '@/components/dashboard/SurplusAdviceTip'
import { PlanWarningCard } from '@/components/dashboard/PlanWarningCard'
import { VacationRescueTip } from '@/components/dashboard/VacationRescueTip'
import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
import { TipCard } from '@/components/inputs/TipCard'
import { Onboarding } from '@/pages/Onboarding'
import type { Tip } from '@/types'
import type { RescueDay, Tip } from '@/types'
/** Key matching `rescueDayGroups`' own `Map` key below — shared so a
* locally-dismissed group (Defect 2, §vacation-rescue) can be filtered back
* out by the same identity it was grouped by. */
function rescueGroupKey(parentPersonalDataUuid: string, year: number): string {
return `${parentPersonalDataUuid}:${year}`
}
/** Whether a `use_vacation_before_expiry` tip (`TipsService`, backend) is
* superseded by an existing vacation-rescue group for the same parent and
* year (Defect 3, §vacation-rescue) — otherwise the dashboard shows three
* different numbers for what a user reads as one problem. The tip's `data`
* only carries a resolved display name (`parent_name`), never a parent
* uuid (see `_rule_use_vacation_before_expiry` in `tips_service.py`), so
* matching goes through the same `nameFor` resolver both cards already use
* to render a name, rather than inventing a backend field to compare
* uuids directly. */
function isSupersededByRescueGroup(
tip: Tip,
rescueDayGroups: RescueDay[][],
nameFor: (uuid: string) => string,
): boolean {
if (tip.messageKey !== 'tips.use_vacation_before_expiry') return false
const { parent_name, year } = tip.data
if (typeof parent_name !== 'string' || typeof year !== 'number') return false
return rescueDayGroups.some((group) => {
const first = group[0]
return first !== undefined && first.year === year && nameFor(first.parentPersonalDataUuid) === parent_name
})
}
/** Fallback horizon end when the analytics projection isn't available yet
* (e.g. mid-load) — plain date arithmetic, not a business rule, so deriving
@@ -96,6 +127,43 @@ export function Dashboard(): ReactNode {
// dependency array.
const { data: proposal, isLoading: isProposing, error: proposalError, propose } = usePlanProposal(workspace?.id ?? null)
// §vacation-rescue — group the proposal's suggest-only rescue days by
// (parent, year); each group renders as one `VacationRescueTip` card
// below. A `Map` keeps groups in first-seen order, which is already date
// order since the backend returns `rescue_days` sorted by (date, parent)
// — see `RescueDay`'s docstring (allocation_service.py). Computed above
// every early return (Rules of Hooks — this page returns early for both
// the loading skeleton and onboarding).
// §vacation-rescue dismissal (Defect 2) — a successful dismiss writes no
// plan data (just a `tip_dismissal` row server-side), so it must never
// trigger a plan re-sync (`bumpPlanVersion`) the way accept does. Instead
// the dismissed group is removed from view here, optimistically, in local
// state — keyed the same way `rescueDayGroups` itself groups, so it
// survives that memo re-running on any unrelated proposal refresh.
const [dismissedRescueGroupKeys, setDismissedRescueGroupKeys] = useState<Set<string>>(new Set())
const handleRescueDismissed = useCallback((parentPersonalDataUuid: string, year: number) => {
setDismissedRescueGroupKeys((prev) => {
const next = new Set(prev)
next.add(rescueGroupKey(parentPersonalDataUuid, year))
return next
})
}, [])
const rescueDayGroups = useMemo(() => {
const groups = new Map<string, RescueDay[]>()
for (const day of proposal?.rescueDays ?? []) {
const key = rescueGroupKey(day.parentPersonalDataUuid, day.year)
if (dismissedRescueGroupKeys.has(key)) continue
const group = groups.get(key)
if (group) {
group.push(day)
} else {
groups.set(key, [day])
}
}
return Array.from(groups.values())
}, [proposal, dismissedRescueGroupKeys])
const orderedMembers = orderSelfFirst(members, personalDataUuid)
const { data: leaves, isLoading: leavesLoading } = useParentalLeaves(
orderedMembers.map((m) => m.uuid),
@@ -257,7 +325,7 @@ export function Dashboard(): ReactNode {
// which lands here without this component doing anything sync-specific.
const surplusAdvice = proposal?.surplusAdvice ?? []
const warnings = proposal?.warnings ?? []
const hasTips = (tips?.length ?? 0) > 0 || surplusAdvice.length > 0
const hasTips = (tips?.length ?? 0) > 0 || surplusAdvice.length > 0 || rescueDayGroups.length > 0
const heroRemedy = deriveHeroRemedy(tips)
return (
@@ -334,12 +402,28 @@ export function Dashboard(): ReactNode {
<section className="dashboard__section" data-testid="dashboard-tips">
<h2>{t('tips.title')}</h2>
<div className="dashboard__tips-list">
{(tips ?? []).map((tip) => (
<TipCard key={tip.id} tip={tip} onDismiss={() => dismissTip(tip.id)} />
))}
{(tips ?? [])
// Defect 3 — a `use_vacation_before_expiry` tip for a
// parent/year that already has a rescue card is redundant
// (three different numbers for one problem); the rescue
// card supersedes it, being both actionable and current.
.filter((tip) => !isSupersededByRescueGroup(tip, rescueDayGroups, nameForAdvice))
.map((tip) => (
<TipCard key={tip.id} tip={tip} onDismiss={() => dismissTip(tip.id)} />
))}
{surplusAdvice.map((advice, index) => (
<SurplusAdviceTip key={`${advice.kind}-${index}`} advice={advice} nameFor={nameForAdvice} index={index} />
))}
{rescueDayGroups.map((group, index) => (
<VacationRescueTip
key={`${group[0]!.parentPersonalDataUuid}-${group[0]!.year}`}
workspaceId={workspace?.id ?? null}
days={group}
nameFor={nameForAdvice}
index={index}
onDismissed={handleRescueDismissed}
/>
))}
</div>
</section>
)}