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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user