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,22 +3,24 @@ import { render, screen } from '@testing-library/react'
import { KeyFigures } from './KeyFigures'
describe('KeyFigures', () => {
it('renders the three combined figures with their labels', () => {
render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={100} />)
it('renders the four combined figures with their labels', () => {
render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={100} vacationAtRisk={12} />)
expect(screen.getByTestId('dashboard-figure-left')).toHaveTextContent('270')
expect(screen.getByTestId('dashboard-figure-left')).toHaveTextContent('Dagar kvar')
expect(screen.getByTestId('dashboard-figure-used')).toHaveTextContent('30')
expect(screen.getByTestId('dashboard-figure-used')).toHaveTextContent('Använda')
expect(screen.getByTestId('dashboard-figure-expiring')).toHaveTextContent('100')
expect(screen.getByTestId('dashboard-figure-expiring')).toHaveTextContent('Förfaller vid 4 år')
expect(screen.getByTestId('dashboard-figure-vacationAtRisk')).toHaveTextContent('12')
expect(screen.getByTestId('dashboard-figure-vacationAtRisk')).toHaveTextContent('Semester som riskerar att gå förlorad')
})
it('wears danger ink on the expiring figure only when it is non-zero', () => {
const { rerender } = render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={100} />)
const { rerender } = render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={100} vacationAtRisk={0} />)
expect(screen.getByTestId('dashboard-figure-expiring').querySelector('dd')).toHaveClass(
'key-figures__value--danger',
)
rerender(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={0} />)
rerender(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={0} vacationAtRisk={0} />)
expect(screen.getByTestId('dashboard-figure-expiring').querySelector('dd')).not.toHaveClass(
'key-figures__value--danger',
)
@@ -26,4 +28,15 @@ describe('KeyFigures', () => {
'key-figures__value--danger',
)
})
it('wears danger ink on the vacation-at-risk figure only when it is non-zero', () => {
const { rerender } = render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={0} vacationAtRisk={12} />)
expect(screen.getByTestId('dashboard-figure-vacationAtRisk').querySelector('dd')).toHaveClass(
'key-figures__value--danger',
)
rerender(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={0} vacationAtRisk={0} />)
expect(screen.getByTestId('dashboard-figure-vacationAtRisk').querySelector('dd')).not.toHaveClass(
'key-figures__value--danger',
)
})
})
@@ -8,19 +8,31 @@ export interface KeyFiguresProps {
daysUsed: number
/** `combinedExpiringAtB4` — projected to be lost at the 4th birthday. */
expiringAtB4: number
/** Sum of every parent/year's `VacationYearBalance.expiring` from the
* current year onward — vacation days forecast to be forfeited (age-out
* or over the carry-forward cap), the reason the planner may shift
* placements toward spending vacation sooner. */
vacationAtRisk: number
}
/**
* The three headline figures under the hero — large tabular numerals with
* a 14px label each, no tiles. The expiring figure wears danger ink only
* when it is non-zero: a zero is good news and should read as quiet.
* The four headline figures under the hero — large tabular numerals with
* a 14px label each, no tiles. The expiring and vacation-at-risk figures
* wear danger ink only when non-zero: a zero is good news and should read
* as quiet.
*/
export function KeyFigures({ daysLeft, daysUsed, expiringAtB4 }: KeyFiguresProps): ReactNode {
export function KeyFigures({ daysLeft, daysUsed, expiringAtB4, vacationAtRisk }: KeyFiguresProps): ReactNode {
const { t } = useTranslation()
const figures = [
{ key: 'left', label: t('dashboard.figureLeft'), value: daysLeft, danger: false },
{ key: 'used', label: t('dashboard.figureUsed'), value: daysUsed, danger: false },
{ key: 'expiring', label: t('dashboard.figureExpiring'), value: expiringAtB4, danger: expiringAtB4 > 0 },
{
key: 'vacationAtRisk',
label: t('dashboard.figureVacationAtRisk'),
value: vacationAtRisk,
danger: vacationAtRisk > 0,
},
]
return (
<dl className="key-figures" data-testid="key-figures">
@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { PlanWarningCard } from './PlanWarningCard'
import { makePlanWarning } from '../../../tests/fixtures'
const nameFor = (uuid: string) => (uuid === 'pd-b' ? 'Sam' : 'Okänd förälder')
describe('PlanWarningCard', () => {
it('renders a sgi_risk warning with the parent name and a formatted (non-ISO) week date', () => {
render(
<PlanWarningCard
index={0}
nameFor={nameFor}
warning={makePlanWarning({
kind: 'sgi_risk',
messageKey: 'planWarnings.sgi_risk',
data: {
parent_personal_data_uuid: 'pd-b',
week_start: '2027-03-08',
days_planned: 1,
min_days_per_week: 3,
},
})}
/>,
)
const warning = screen.getByTestId('dashboard-plan-warning-0')
expect(warning).toHaveClass('tip-card', 'tip-card--danger')
expect(warning).toHaveTextContent('Risk för SGI-skyddet')
expect(warning).toHaveTextContent('Sam är planerad för endast 1 av minst 3 ledighetsdagar veckan som börjar 8 mars 2027')
expect(warning).not.toHaveTextContent('pd-b')
expect(warning).not.toHaveTextContent('2027-03-08')
})
it('renders a days_lost_at_age_4 warning with the parent name and a formatted (non-ISO) birthday', () => {
render(
<PlanWarningCard
index={1}
nameFor={nameFor}
warning={makePlanWarning({
kind: 'days_lost_at_age_4',
messageKey: 'planWarnings.days_lost_at_age_4',
data: {
parent_personal_data_uuid: 'pd-b',
child_id: 'child-1',
expiring_days: 15,
projected_left_at_b4: 40,
fourth_birthday: '2029-06-01',
},
})}
/>,
)
const warning = screen.getByTestId('dashboard-plan-warning-1')
expect(warning).toHaveTextContent('Dagar riskerar att gå förlorade vid 4 år')
expect(warning).toHaveTextContent('Sam riskerar att förlora 15 föräldrapenningdagar vid barnets 4-årsdag (1 juni 2029)')
expect(warning).toHaveTextContent('40 dagar beräknas då finnas kvar totalt')
expect(warning).not.toHaveTextContent('2029-06-01')
})
it('renders a vacation_expiring warning with the parent name and its figures', () => {
render(
<PlanWarningCard
index={2}
nameFor={nameFor}
warning={makePlanWarning({
kind: 'vacation_expiring',
messageKey: 'planWarnings.vacation_expiring',
data: { parent_personal_data_uuid: 'pd-b', year: 2027, expiring_days: 4 },
})}
/>,
)
const warning = screen.getByTestId('dashboard-plan-warning-2')
expect(warning).toHaveTextContent('Semesterdagar riskerar att gå förlorade')
expect(warning).toHaveTextContent('4 semesterdagar riskerar att gå förlorade 2027')
})
it('falls back to the raw kind and messageKey for an unrecognised warning', () => {
render(
<PlanWarningCard
index={3}
nameFor={nameFor}
warning={makePlanWarning({ kind: 'brand_new_kind', messageKey: 'planWarnings.brand_new_kind', data: {} })}
/>,
)
const warning = screen.getByTestId('dashboard-plan-warning-3')
expect(warning).toHaveTextContent('brand_new_kind')
expect(warning).toHaveTextContent('planWarnings.brand_new_kind')
})
it('has no dismiss control', () => {
render(<PlanWarningCard index={0} nameFor={nameFor} warning={makePlanWarning()} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,88 @@
import { ReactNode } from 'react'
import type { PlanWarning } from '@/types'
import { useTranslation, toTranslationParams, type TranslationKey } from '@/i18n'
import { formatDateForDisplay } from '@/utils/dates'
// The three `PlanWarning` kinds the backend can emit
// (`allocation_service.py`'s `_sgi_risk_warnings`, `_age4_warnings`,
// `_vacation_expiry_warnings`). `kind` -> title, `messageKey` -> the body
// template. The backend's own `message_key` is already `f"planWarnings.
// {kind}"`, i.e. a real translation key — routed through a lookup anyway
// (like `SurplusAdviceTip`'s `SURPLUS_ADVICE_BODY_KEYS`) so an unrecognised
// kind (a newer backend rule this catalogue hasn't caught up with) falls
// back to the raw value instead of rendering a missing-key placeholder.
const PLAN_WARNING_TITLE_KEYS: Record<string, TranslationKey> = {
sgi_risk: 'planWarnings.sgiRiskTitle',
days_lost_at_age_4: 'planWarnings.daysLostAtAge4Title',
vacation_expiring: 'planWarnings.vacationExpiringTitle',
}
const PLAN_WARNING_BODY_KEYS: Record<string, TranslationKey> = {
'planWarnings.sgi_risk': 'planWarnings.sgi_risk',
'planWarnings.days_lost_at_age_4': 'planWarnings.days_lost_at_age_4',
'planWarnings.vacation_expiring': 'planWarnings.vacation_expiring',
}
// `data` fields that carry an ISO-8601 date and must go through
// `formatDateForDisplay` before interpolation — a raw ISO string is never
// shown to the user.
const DATE_FIELDS_BY_KIND: Record<string, readonly string[]> = {
sgi_risk: ['week_start'],
days_lost_at_age_4: ['fourth_birthday'],
vacation_expiring: [],
}
export interface PlanWarningCardProps {
warning: PlanWarning
/** Resolves a parent uuid to a display name (never a raw uuid) —
* `Dashboard.tsx`'s `nameForAdvice`, which already falls back to
* `planResults.unknownParent`. */
nameFor: (uuid: string) => string
/** Position in the warnings list — only used for the test id. */
index: number
}
/**
* One plan-proposal warning rendered in the same visual as a backend
* `TipCard` (same `tip-card` classes) so the dashboard reads as one system,
* but with the danger-ink dot (`var(--c-danger)`, the same colour
* `KeyFigures`/`Age4Bars`/`YearRows` already use for at-risk figures) —
* warnings are risks, not suggestions. No dismiss: it is derived from the
* plan and goes away when the plan changes.
*/
export function PlanWarningCard({ warning, nameFor, index }: PlanWarningCardProps): ReactNode {
const { t, locale } = useTranslation()
const titleKey = PLAN_WARNING_TITLE_KEYS[warning.kind]
const bodyKey = PLAN_WARNING_BODY_KEYS[warning.messageKey]
const title = titleKey ? t(titleKey) : warning.kind
let body = warning.messageKey
if (bodyKey) {
const params = toTranslationParams(warning.data)
const uuid = warning.data.parent_personal_data_uuid
if (typeof uuid === 'string') {
params.parent_name = nameFor(uuid)
}
for (const field of DATE_FIELDS_BY_KIND[warning.kind] ?? []) {
const raw = warning.data[field]
if (typeof raw === 'string') {
params[field] = formatDateForDisplay(raw, locale)
}
}
body = t(bodyKey, params)
}
return (
<div
className="tip-card tip-card--danger"
role="article"
aria-label={t('tip.aria', { title })}
data-testid={`dashboard-plan-warning-${index}`}
>
<div className="tip-card__header">
<h4 className="tip-card__title">{title}</h4>
</div>
<p className="tip-card__body">{body}</p>
</div>
)
}
@@ -701,6 +701,80 @@ describe('PlannedDayModal', () => {
})
})
describe('free-day hand-pin guard notices (§ free days never consume vacation)', () => {
it('warns that free days stay off when pinning vacation over a selection that includes one', async () => {
render(
<PlannedDayModal
{...defaultProps}
isOpen={true}
isRangeMode
dayCount={2}
freeDatesInSelection={['2026-06-13']}
/>,
)
await pickType('vacation')
expect(screen.getByTestId('free-days-stay-off-notice')).toHaveTextContent(
'Lediga dagar (helg/röd dag) i urvalet lämnas lediga',
)
})
it('warns for a PL type on a free day when takePlOnRedDays is off', async () => {
render(
<PlannedDayModal
{...defaultProps}
isOpen={true}
isRangeMode
dayCount={2}
freeDatesInSelection={['2026-06-13']}
takePlOnRedDays={false}
/>,
)
await pickType('sjukpenning')
expect(screen.getByTestId('free-days-stay-off-notice')).toBeInTheDocument()
})
it('does not warn for a PL type on a free day once takePlOnRedDays is on', async () => {
render(
<PlannedDayModal
{...defaultProps}
isOpen={true}
isRangeMode
dayCount={2}
freeDatesInSelection={['2026-06-13']}
takePlOnRedDays={true}
/>,
)
await pickType('sjukpenning')
expect(screen.queryByTestId('free-days-stay-off-notice')).not.toBeInTheDocument()
})
it('does not warn when the selection has no free days', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} isRangeMode dayCount={2} freeDatesInSelection={[]} />)
await pickType('vacation')
expect(screen.queryByTestId('free-days-stay-off-notice')).not.toBeInTheDocument()
})
it('notes that pinning excludes the day from automatic re-planning', async () => {
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
await pickType('vacation')
expect(screen.getByTestId('pin-exclusion-notice')).toBeInTheDocument()
})
it('does not duplicate the pin-exclusion notice when the day is already pinned (the pinned notice already says so)', () => {
const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1', isPinned: true })
render(<PlannedDayModal {...defaultProps} isOpen={true} existingDay={existingDay} />)
expect(screen.getByTestId('pinned-notice')).toBeInTheDocument()
expect(screen.queryByTestId('pin-exclusion-notice')).not.toBeInTheDocument()
})
it('shows neither notice while the type select is still on "let the planner choose"', () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} freeDatesInSelection={['2026-06-13']} />)
expect(screen.queryByTestId('free-days-stay-off-notice')).not.toBeInTheDocument()
expect(screen.queryByTestId('pin-exclusion-notice')).not.toBeInTheDocument()
})
})
describe('removed days (PlannedDayExclusion) — the way back', () => {
it('names the parent whose day was removed and offers to restore it', async () => {
const onRestoreExcluded = vi.fn().mockResolvedValue(undefined)
@@ -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">
@@ -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')
})
})
@@ -62,6 +62,14 @@ export interface VacationDaysTableRow {
// shown is the statutory default (25 days) rather than something the user
// entered — §3.2. Prompts the user to confirm/override.
isAssumed?: boolean
// Every vacation day forecast lost at this year's end — the sum of
// `expiringAge` + `expiringOverCap` (see `VacationYearBalance` in
// `types/entities.ts`). 0 when nothing is at risk.
expiring: number
// Subset of `expiring` that hit the 5-year carry-forward age-out.
expiringAge: number
// Subset of `expiring` above the savable carry-forward cap.
expiringOverCap: number
}
export interface VacationDaysTableProps {
@@ -134,6 +142,18 @@ function VacationYearRow({ row, onSave }: VacationYearRowProps): ReactNode {
})
}
// Splits the single `expiring` figure back into its two causes for the
// tooltip — the column itself stays a bare number so it never fights the
// narrow flex field for width in either language.
const atRiskDetailParts: string[] = []
if (row.expiringAge > 0) {
atRiskDetailParts.push(t('vacationDaysTable.atRiskAgeDetail', { days: row.expiringAge }))
}
if (row.expiringOverCap > 0) {
atRiskDetailParts.push(t('vacationDaysTable.atRiskOverCapDetail', { days: row.expiringOverCap }))
}
const atRiskDetail = atRiskDetailParts.join(' · ')
return (
<div className="vacation-rows__row" data-testid={`vacation-year-row-${row.year}`}>
<div className="vacation-rows__year">
@@ -177,6 +197,21 @@ function VacationYearRow({ row, onSave }: VacationYearRowProps): ReactNode {
/>
</label>
<div className="field vacation-rows__field vacation-rows__at-risk">
<span className="field__label">{t('vacationDaysTable.atRiskHeader')}</span>
<span
className={
row.expiring > 0
? 'vacation-rows__at-risk-value vacation-rows__at-risk-value--danger tnum'
: 'vacation-rows__at-risk-value tnum'
}
data-testid={`vacation-at-risk-${row.year}`}
title={atRiskDetail || undefined}
>
{row.expiring}
</span>
</div>
<span
className={`vacation-rows__status vacation-rows__status--${status}`}
data-testid={`vacation-status-${row.year}`}
@@ -65,6 +65,29 @@
cursor: help;
}
/* At-risk column — a bare tabular number, danger ink only when non-zero
* (see `.key-figures__value--danger` / `.year-row__cell--danger`, the same
* pattern used elsewhere for this figure). Sized like the other fields so
* neither "At risk"/"Riskerar" nor the short two-digit value ever wraps. */
.vacation-rows__at-risk {
flex: 0 1 90px;
min-width: 70px;
max-width: 130px;
}
.vacation-rows__at-risk-value {
display: flex;
align-items: center;
min-height: var(--control-h);
font-weight: 500;
color: var(--c-text);
}
.vacation-rows__at-risk-value--danger {
color: var(--c-danger);
cursor: help;
}
.vacation-rows__empty {
color: var(--c-text-muted);
font-size: var(--fs-sm);
@@ -0,0 +1,205 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, waitFor, act } from '@testing-library/react'
import type { ReactNode } from 'react'
import { PlanSyncProvider, usePlanSyncContext } from './PlanSyncProvider'
import { useWorkspace } from './WorkspaceContext'
import * as api from '@/services/api'
import { ApiRequestError } from '@/services/errors'
import {
makeWorkspaceContextValue,
makePlanSyncResponse,
makeEvictionPreview,
makeEvictionCandidate,
makeResourceShortage,
} from '../../tests/fixtures'
vi.mock('@/services/api')
vi.mock('./WorkspaceContext', () => ({
useWorkspace: vi.fn(),
}))
function wrapper({ children }: { children: ReactNode }) {
return <PlanSyncProvider>{children}</PlanSyncProvider>
}
function mockWorkspace(overrides: Partial<ReturnType<typeof useWorkspace>> = {}) {
vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue(overrides))
}
describe('PlanSyncProvider', () => {
let bumpPlanVersion: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
bumpPlanVersion = vi.fn()
mockWorkspace({ workspaceId: 'ws-1', planVersion: 0, bumpPlanVersion })
vi.mocked(api.syncPlan).mockResolvedValue(makePlanSyncResponse())
})
it('does not sync when there is no workspace yet', () => {
mockWorkspace({ workspaceId: null, planVersion: 0, bumpPlanVersion })
renderHook(() => usePlanSyncContext(), { wrapper })
expect(api.syncPlan).not.toHaveBeenCalled()
})
it('syncs silently once the workspace is known', async () => {
renderHook(() => usePlanSyncContext(), { wrapper })
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(1))
expect(api.syncPlan).toHaveBeenCalledWith('ws-1')
})
it('a bump from a non-Calendar hook triggers exactly one more sync', async () => {
const { rerender } = renderHook(() => usePlanSyncContext(), { wrapper })
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(1))
// Simulates e.g. useVacationDays.upsert calling bumpPlanVersion().
mockWorkspace({ workspaceId: 'ws-1', planVersion: 1, bumpPlanVersion })
rerender()
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(2))
expect(api.syncPlan).toHaveBeenLastCalledWith('ws-1')
})
it('coalesces two rapid bumps into a single trailing call, not a stampede', async () => {
let resolveFirst: (value: ReturnType<typeof makePlanSyncResponse>) => void = () => {}
vi.mocked(api.syncPlan).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve
}),
)
const { rerender } = renderHook(() => usePlanSyncContext(), { wrapper })
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(1))
// Two rapid bumps while the first sync is still in flight.
mockWorkspace({ workspaceId: 'ws-1', planVersion: 1, bumpPlanVersion })
rerender()
mockWorkspace({ workspaceId: 'ws-1', planVersion: 2, bumpPlanVersion })
rerender()
// Neither bump starts an overlapping call while the first is pending.
expect(api.syncPlan).toHaveBeenCalledTimes(1)
await act(async () => {
resolveFirst(makePlanSyncResponse())
})
// Exactly one trailing call coalesces both bumps — not two.
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(2))
// Settling further ticks raises no additional calls of its own.
await act(async () => {
await Promise.resolve()
})
expect(api.syncPlan).toHaveBeenCalledTimes(2)
})
it('never calls bumpPlanVersion itself — cycle safety', async () => {
const { rerender } = renderHook(() => usePlanSyncContext(), { wrapper })
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(1))
mockWorkspace({ workspaceId: 'ws-1', planVersion: 1, bumpPlanVersion })
rerender()
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(2))
expect(bumpPlanVersion).not.toHaveBeenCalled()
})
it('a failing sync resets the in-flight ref so a later bump still syncs', async () => {
vi.mocked(api.syncPlan).mockRejectedValueOnce(new Error('sync exploded'))
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { rerender } = renderHook(() => usePlanSyncContext(), { wrapper })
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(1))
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('sync exploded'))
vi.mocked(api.syncPlan).mockResolvedValueOnce(makePlanSyncResponse())
mockWorkspace({ workspaceId: 'ws-1', planVersion: 1, bumpPlanVersion })
rerender()
// Not wedged — the next bump's effect still finds `inFlightRef` clear.
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(2))
consoleErrorSpy.mockRestore()
})
it('logs a rejected ApiRequestError through the shared error model, never swallowed', async () => {
vi.mocked(api.syncPlan).mockRejectedValueOnce(
new ApiRequestError(500, { code: 'server_error', message: 'boom', details: {} }),
)
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
renderHook(() => usePlanSyncContext(), { wrapper })
await waitFor(() => expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('500 boom')))
consoleErrorSpy.mockRestore()
})
it('logs a non-Error rejection without swallowing it', async () => {
vi.mocked(api.syncPlan).mockRejectedValueOnce('network exploded')
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
renderHook(() => usePlanSyncContext(), { wrapper })
await waitFor(() =>
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('network exploded')),
)
consoleErrorSpy.mockRestore()
})
it('surfaces a would-evict response through context instead of discarding it', async () => {
const preview = makeEvictionPreview({
wouldEvict: true,
candidates: [makeEvictionCandidate()],
shortages: [makeResourceShortage()],
})
vi.mocked(api.syncPlan).mockResolvedValueOnce(makePlanSyncResponse({ evictionPreview: preview }))
const { result } = renderHook(() => usePlanSyncContext(), { wrapper })
await waitFor(() => expect(result.current.pendingEvictionPreview).toEqual(preview))
})
it('leaves pendingEvictionPreview null when nothing would be evicted', async () => {
const { result } = renderHook(() => usePlanSyncContext(), { wrapper })
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(1))
expect(result.current.pendingEvictionPreview).toBeNull()
})
it('clears a previously-surfaced eviction preview once a later sync evicts nothing', async () => {
const preview = makeEvictionPreview({ wouldEvict: true })
vi.mocked(api.syncPlan).mockResolvedValueOnce(makePlanSyncResponse({ evictionPreview: preview }))
const { result, rerender } = renderHook(() => usePlanSyncContext(), { wrapper })
await waitFor(() => expect(result.current.pendingEvictionPreview).toEqual(preview))
vi.mocked(api.syncPlan).mockResolvedValueOnce(makePlanSyncResponse())
mockWorkspace({ workspaceId: 'ws-1', planVersion: 1, bumpPlanVersion })
rerender()
await waitFor(() => expect(result.current.pendingEvictionPreview).toBeNull())
})
it('clearPendingEvictionPreview resets it to null on demand', async () => {
const preview = makeEvictionPreview({ wouldEvict: true })
vi.mocked(api.syncPlan).mockResolvedValueOnce(makePlanSyncResponse({ evictionPreview: preview }))
const { result } = renderHook(() => usePlanSyncContext(), { wrapper })
await waitFor(() => expect(result.current.pendingEvictionPreview).toEqual(preview))
act(() => {
result.current.clearPendingEvictionPreview()
})
expect(result.current.pendingEvictionPreview).toBeNull()
})
it('throws when used outside a PlanSyncProvider', () => {
expect(() => renderHook(() => usePlanSyncContext())).toThrow(
'usePlanSyncContext must be used inside <PlanSyncProvider>',
)
})
})
+125
View File
@@ -0,0 +1,125 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import * as api from '@/services/api'
import { useWorkspace } from './WorkspaceContext'
import { isApiError } from '@/services/errors'
import type { EvictionPreview } from '@/types'
interface PlanSyncContextValue {
/** Set when the workspace's most recent background sync came back with
* `evictionPreview.wouldEvict === true` — that sync wrote NOTHING at all
* (see `EvictionPreview`'s doc comment in `src/types/entities.ts`). A
* consumer such as Calendar can read this — e.g. on its next mount/visit
* — to prompt the user for an eviction choice instead of the change
* silently vanishing. Reset to `null` by a later sync that resolves
* without an eviction, or by `clearPendingEvictionPreview`. */
pendingEvictionPreview: EvictionPreview | null
/** Call once a consumer has shown/handled `pendingEvictionPreview`. */
clearPendingEvictionPreview: () => void
}
const PlanSyncContext = createContext<PlanSyncContextValue | null>(null)
/**
* Fires `POST /api/plan/sync` silently (no toast, no undo UI) whenever the
* workspace's plan inputs change — i.e. on every `planVersion` bump (see
* `WorkspaceContext.planVersion`'s doc comment). Today only Calendar.tsx's
* own `runSync` re-plans the day *types*; a pinned-day save, a vacation
* balance edit, or a Settings change only ever bumped `planVersion` to make
* other hooks refetch what's already persisted — none of them re-ran the
* allocator. This provider is what makes those writes actually re-type the
* rest of the plan's unpinned days, from anywhere in the app, not just from
* the calendar page.
*
* Mounted inside `WorkspaceProvider` (see `WorkspaceContext.tsx`'s return),
* so it reads `workspaceId`/`planVersion` from the very same context
* instance every plan-input hook already bumps, and runs for the lifetime
* of the app, not just while Calendar happens to be mounted.
*
* **Cycle safety.** This effect must NEVER call `bumpPlanVersion`.
* `planVersion` is the shared invalidation signal every plan-input hook's
* own fetch effect watches (see `usePlannedDays.ts:84-93`); a sync path
* that both reacts to AND writes that counter would retrigger itself
* forever. `syncPlan` mutates the plan server-side but is never treated as
* a "plan input" write itself.
*/
export function PlanSyncProvider({ children }: { children: React.ReactNode }) {
const { workspaceId, planVersion } = useWorkspace()
const [pendingEvictionPreview, setPendingEvictionPreview] = useState<EvictionPreview | null>(null)
// In-flight/pending refs, not state — flipping them must never itself
// cause a re-render (that's exactly the kind of feedback loop the
// cycle-safety note above warns about). Read/written synchronously around
// the single `await` in `runSyncOnce` below, with no other code able to
// interleave between a `finally` and the pending-check right after it.
const inFlightRef = useRef(false)
const pendingRef = useRef(false)
const pendingWorkspaceIdRef = useRef<string | null>(null)
/** Runs one `syncPlan` call for `id`, then — if one or more further bumps
* landed while it was in flight — exactly one trailing run for the
* latest workspace id, never one call per bump. This is what coalesces a
* burst of rapid bumps (e.g. several quick edits) into a single trailing
* sync instead of a stampede of overlapping requests.
*
* `inFlightRef`/`pendingRef` are always reset in `finally`, so a
* rejected call can never wedge this closed — the next bump's effect
* always finds `inFlightRef.current === false` and starts a fresh call. */
const runSyncOnce = useCallback(async (id: string) => {
inFlightRef.current = true
try {
const result = await api.syncPlan(id)
// `evictionPreview.wouldEvict === true` means this call wrote NOTHING
// — never discard that into an empty catch/no-op. Surface it so a
// consumer (Calendar) can ask the user on its next visit. A sync that
// did write (or evicted nothing) clears any earlier pending preview.
setPendingEvictionPreview(result.evictionPreview.wouldEvict ? result.evictionPreview : null)
} catch (err) {
// No toast exists for a background sync — this is the minimum bar
// from the module doc comment: never an empty catch, always logged
// through the shared API-error model instead of silently dropped.
const message = isApiError(err)
? `${err.status} ${err.error.message}`
: err instanceof Error
? err.message
: String(err)
console.error(`Background plan sync failed for workspace ${id}: ${message}`)
} finally {
inFlightRef.current = false
}
if (pendingRef.current) {
pendingRef.current = false
const nextId = pendingWorkspaceIdRef.current ?? id
pendingWorkspaceIdRef.current = null
await runSyncOnce(nextId)
}
}, [])
useEffect(() => {
if (!workspaceId) return
if (inFlightRef.current) {
// A sync is already running — record that another bump landed and
// let that call's own trailing check (in `runSyncOnce`) pick it up,
// rather than starting a second overlapping request here.
pendingRef.current = true
pendingWorkspaceIdRef.current = workspaceId
return
}
void runSyncOnce(workspaceId)
}, [workspaceId, planVersion, runSyncOnce])
const clearPendingEvictionPreview = useCallback(() => {
setPendingEvictionPreview(null)
}, [])
return (
<PlanSyncContext.Provider value={{ pendingEvictionPreview, clearPendingEvictionPreview }}>
{children}
</PlanSyncContext.Provider>
)
}
export function usePlanSyncContext(): PlanSyncContextValue {
const ctx = useContext(PlanSyncContext)
if (!ctx) throw new Error('usePlanSyncContext must be used inside <PlanSyncProvider>')
return ctx
}
+6 -1
View File
@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import * as api from '@/services/api'
import { useAuth } from '@/context/AuthContext'
import { PlanSyncProvider } from './PlanSyncProvider'
import type { Workspace } from '@/types'
interface Child {
@@ -199,7 +200,11 @@ export function WorkspaceProvider({ children: childrenProp }: { children: React.
bumpPlanVersion,
}}
>
{childrenProp}
{/* Mounted inside this provider (not beside it) so it reads
* workspaceId/planVersion off this same context instance — see
* PlanSyncProvider.tsx's doc comment for what it does and why it
* must never call bumpPlanVersion. */}
<PlanSyncProvider>{childrenProp}</PlanSyncProvider>
</WorkspaceContext.Provider>
)
}
+1
View File
@@ -1,2 +1,3 @@
export { useAuth } from './AuthContext'
export { useWorkspace } from './WorkspaceContext'
export { usePlanSyncContext } from './PlanSyncProvider'
@@ -2,9 +2,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useChildSettings } from './useChildSettings'
import * as api from '@/services/api'
import { useWorkspace } from '@/context'
import { makeWorkspaceContextValue } from '../../tests/fixtures'
import type { Child } from '@/types'
vi.mock('@/services/api')
vi.mock('@/context')
const mockChild: Child = {
id: 'child1',
@@ -17,8 +20,14 @@ const mockChild: Child = {
}
describe('useChildSettings', () => {
let bumpPlanVersion: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
bumpPlanVersion = vi.fn()
vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({ planVersion: 0, bumpPlanVersion }),
)
})
it('initializes with null data and not loading', () => {
@@ -85,6 +94,7 @@ describe('useChildSettings', () => {
})
// refetches after a successful update
expect(api.getChild).toHaveBeenCalledWith('child1')
expect(bumpPlanVersion).toHaveBeenCalledTimes(1)
})
it('update() includes preschoolPlanningDaysPerYear when provided', async () => {
@@ -111,6 +121,7 @@ describe('useChildSettings', () => {
})
expect(api.updateChildSettings).not.toHaveBeenCalled()
expect(bumpPlanVersion).not.toHaveBeenCalled()
})
it('sets isRefreshing (not isLoading) while update() background-refreshes', async () => {
+7 -1
View File
@@ -8,6 +8,7 @@
import { useCallback, useEffect, useState } from 'react'
import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import { useWorkspace } from '@/context'
import type { Child } from '@/types'
export interface ChildSettingsUpdatePayload {
@@ -33,6 +34,7 @@ export function useChildSettings(childId: string | null): UseChildSettingsResult
const [isRefreshing, setIsRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const { start, isCurrent } = useLatestRequest()
const { bumpPlanVersion } = useWorkspace()
const fetch = useCallback(
async (options?: { background?: boolean }) => {
@@ -73,8 +75,12 @@ export function useChildSettings(childId: string | null): UseChildSettingsResult
: {}),
})
await fetch({ background: true })
// Birth date / preschool days feed the allocator (age-4 cutoff,
// preschool-closure demand) — re-type the rest of the plan's unpinned
// days. See `WorkspaceContext.planVersion`.
bumpPlanVersion()
},
[childId, fetch],
[childId, fetch, bumpPlanVersion],
)
return { data, isLoading, isRefreshing, error, update }
@@ -2,10 +2,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { usePreschoolPlanningDays } from './usePreschoolPlanningDays'
import * as api from '@/services/api'
import { makePreschoolPlanningDays } from '../../tests/fixtures'
import { useWorkspace } from '@/context'
import { makePreschoolPlanningDays, makeWorkspaceContextValue } from '../../tests/fixtures'
import type { PreschoolPlanningDays } from '@/types'
vi.mock('@/services/api')
vi.mock('@/context')
const mockRow = makePreschoolPlanningDays({
id: 'ppd-1',
@@ -17,8 +19,14 @@ const mockRow = makePreschoolPlanningDays({
})
describe('usePreschoolPlanningDays', () => {
let bumpPlanVersion: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
bumpPlanVersion = vi.fn()
vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({ planVersion: 0, bumpPlanVersion }),
)
vi.mocked(api.getPreschoolPlanningDaysYear).mockResolvedValue(mockRow)
vi.mocked(api.upsertPreschoolPlanningDaysPlanned).mockResolvedValue(mockRow)
vi.mocked(api.recordPreschoolPlanningDaysTaken).mockResolvedValue(mockRow)
@@ -94,6 +102,10 @@ describe('usePreschoolPlanningDays', () => {
plannedDays: 10,
})
expect(api.getPreschoolPlanningDaysYear).toHaveBeenCalledTimes(2)
// Called once per settled `setPlanned` (the hook has no debounce of its
// own — Settings.tsx's PreschoolPlanningYearRow debounces before ever
// calling this), never per keystroke.
expect(bumpPlanVersion).toHaveBeenCalledTimes(1)
})
it('recordTaken() records takenDays (including 0) and refetches', async () => {
@@ -108,6 +120,7 @@ describe('usePreschoolPlanningDays', () => {
takenDays: 0,
})
expect(api.getPreschoolPlanningDaysYear).toHaveBeenCalledTimes(2)
expect(bumpPlanVersion).toHaveBeenCalledTimes(1)
})
it('setPlanned() and recordTaken() are no-ops when childId is null', async () => {
@@ -120,6 +133,7 @@ describe('usePreschoolPlanningDays', () => {
expect(api.upsertPreschoolPlanningDaysPlanned).not.toHaveBeenCalled()
expect(api.recordPreschoolPlanningDaysTaken).not.toHaveBeenCalled()
expect(bumpPlanVersion).not.toHaveBeenCalled()
})
it('sets isRefreshing (not isLoading) while a mutation background-refreshes', async () => {
+11 -2
View File
@@ -18,6 +18,7 @@
import { useCallback, useEffect, useState } from 'react'
import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import { useWorkspace } from '@/context'
import type { PreschoolPlanningDays } from '@/types'
interface UsePreschoolPlanningDaysResult {
@@ -46,6 +47,7 @@ export function usePreschoolPlanningDays(
const [isRefreshing, setIsRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const { start, isCurrent } = useLatestRequest()
const { bumpPlanVersion } = useWorkspace()
const fetch = useCallback(
async (options?: { background?: boolean }) => {
@@ -81,8 +83,13 @@ export function usePreschoolPlanningDays(
if (!childId) return
await api.upsertPreschoolPlanningDaysPlanned(childId, year, { plannedDays })
await fetch({ background: true })
// Called only once per `PRESCHOOL_AUTOSAVE_DELAY_MS` debounce settling
// (see `PreschoolPlanningYearRow` in Settings.tsx), never per
// keystroke. Planning-day demand feeds the allocator — re-type the
// rest of the plan's unpinned days. See `WorkspaceContext.planVersion`.
bumpPlanVersion()
},
[childId, year, fetch],
[childId, year, fetch, bumpPlanVersion],
)
const recordTaken = useCallback(
@@ -90,8 +97,10 @@ export function usePreschoolPlanningDays(
if (!childId) return
await api.recordPreschoolPlanningDaysTaken(childId, year, { takenDays })
await fetch({ background: true })
// Same debounce-settled cadence as `setPlanned` above.
bumpPlanVersion()
},
[childId, year, fetch],
[childId, year, fetch, bumpPlanVersion],
)
return { data, isLoading, isRefreshing, error, setPlanned, recordTaken }
@@ -2,8 +2,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, waitFor, act } from '@testing-library/react'
import { useVacationDays } from './useVacationDays'
import * as api from '@/services/api'
import { useWorkspace } from '@/context'
import { makeWorkspaceContextValue } from '../../tests/fixtures'
vi.mock('@/services/api')
vi.mock('@/context')
const mockRow = {
id: 'vd-1',
@@ -17,8 +20,14 @@ const mockRow = {
}
describe('useVacationDays', () => {
let bumpPlanVersion: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
bumpPlanVersion = vi.fn()
vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({ planVersion: 0, bumpPlanVersion }),
)
vi.mocked(api.getVacationDaysByWorkspace).mockResolvedValue([mockRow])
vi.mocked(api.upsertVacationDays).mockResolvedValue(mockRow)
})
@@ -80,6 +89,7 @@ describe('useVacationDays', () => {
savedDaysIn: 0,
})
expect(api.getVacationDaysByWorkspace).toHaveBeenCalledTimes(2)
expect(bumpPlanVersion).toHaveBeenCalledTimes(1)
})
it('sets isRefreshing (not isLoading) while a mutation background-refreshes the list', async () => {
+7 -1
View File
@@ -15,6 +15,7 @@
import { useCallback, useEffect, useState } from 'react'
import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import { useWorkspace } from '@/context'
import type { VacationDays, VacationDaysCreateRequest } from '@/types'
interface UseVacationDaysResult {
@@ -35,6 +36,7 @@ export function useVacationDays(workspaceId: string): UseVacationDaysResult {
const [isRefreshing, setIsRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const { start, isCurrent } = useLatestRequest()
const { bumpPlanVersion } = useWorkspace()
const fetch = useCallback(
async (options?: { background?: boolean }) => {
@@ -66,8 +68,12 @@ export function useVacationDays(workspaceId: string): UseVacationDaysResult {
async (req: VacationDaysCreateRequest) => {
await api.upsertVacationDays(req)
await fetch({ background: true })
// A vacation balance is a plan input — it changes how much vacation
// the allocator has to spend, so re-typing the rest of the plan's
// unpinned days needs to run. See `WorkspaceContext.planVersion`.
bumpPlanVersion()
},
[fetch],
[fetch, bumpPlanVersion],
)
return { data, isLoading, isRefreshing, error, upsert }
+6
View File
@@ -4,6 +4,8 @@ export const calendar = {
'calendar.deleteError': 'Failed to delete the planned day',
'calendar.nextMonthAria': 'Next month',
'calendar.partner': 'Partner',
'calendar.pinnedDayAllFreeError':
'None of the selected days can be saved as that type — free days (weekends/red days) can never be vacation, and parental leave on them needs the Settings toggle turned on.',
'calendar.prevMonthAria': 'Previous month',
'calendar.saveError': 'Failed to save the planned days',
'calendar.today': 'Today',
@@ -37,6 +39,10 @@ export const calendar = {
'plannedDayModal.newTitle': 'Plan a Day',
'plannedDayModal.parentLabel': 'Parent',
'plannedDayModal.pinnedNotice': 'This day is pinned by you — the planner will not change it automatically.',
'plannedDayModal.pinExcludesFromSyncNotice':
'Pinning excludes this day from the planner’s automatic re-planning.',
'plannedDayModal.freeDaysStayOffNotice':
'Free days (weekends/red days) in your selection are left as days off — vacation can never go there, and parental leave needs "Take parental leave on red days" turned on in Settings.',
'plannedDayModal.excludedNotice': 'You removed this day for {parent}.',
'plannedDayModal.restoreExcludedButton': 'Put the day back',
'plannedDayModal.rangeTitle': 'Plan {count} days',
+3
View File
@@ -15,6 +15,9 @@ export const dashboard = {
'dashboard.figureExpiring': 'Expiring at age 4',
'dashboard.figureLeft': 'Days left',
'dashboard.figureUsed': 'Used',
// Vacation days forecast lost to age-out or the carry-forward cap if the
// current plan is followed — see `VacationYearBalance.expiring`.
'dashboard.figureVacationAtRisk': 'Vacation at risk of being lost',
'dashboard.hero.fkLinkText': 'Försäkringskassan',
'dashboard.hero.hasPlanMessage': '{atRisk} days are at risk of disappearing when {childName} turns 4 ({date}).',
'dashboard.hero.noneAtRiskMessage': 'No days will be lost — your plan uses them all before {childName} turns 4 ({date}).',
+12
View File
@@ -30,4 +30,16 @@ export const plan = {
'planResults.surplusAdviceUseLessVacation': 'Save fewer vacation days',
'planResults.surplusAdviceWishMoreLeave': 'Wish for more leave',
'planResults.unknownParent': 'Unknown parent',
// `PlanWarning.messageKey`/`kind` for the backend's three warning kinds
// (`allocation_service.py`'s `_sgi_risk_warnings`, `_age4_warnings`,
// `_vacation_expiry_warnings`), rendered by `PlanWarningCard.tsx` on the
// dashboard. `message_key` is `f"planWarnings.{kind}"` — matches these
// body keys exactly.
'planWarnings.sectionTitle': 'Risks',
'planWarnings.sgiRiskTitle': 'SGI-protection risk',
'planWarnings.sgi_risk': '{parent_name} is planned for only {days_planned} of the required {min_days_per_week} leave days in the week starting {week_start} — this can lower their sickness-benefit-qualifying income (SGI).',
'planWarnings.daysLostAtAge4Title': 'Days at risk at the 4th birthday',
'planWarnings.days_lost_at_age_4': '{parent_name} risks losing {expiring_days} parental-benefit {expiring_days|day|days} at the child\'s 4th birthday ({fourth_birthday}) — {projected_left_at_b4} {projected_left_at_b4|day|days} are projected to remain in total then.',
'planWarnings.vacationExpiringTitle': 'Vacation days at risk',
'planWarnings.vacation_expiring': '{expiring_days} vacation {expiring_days|day|days} at risk of being lost in {year} unless something is done.',
} as const
+3
View File
@@ -113,6 +113,9 @@ export const settings = {
'settings.youBadge': '(You)',
'vacationDaysTable.assumedBadge': 'Assumed',
'vacationDaysTable.assumedPrompt': 'No value is recorded for this year — the statutory 25 days has been assumed. Confirm or edit below.',
'vacationDaysTable.atRiskAgeDetail': '{days} days have passed the 5-year limit for saving vacation',
'vacationDaysTable.atRiskHeader': 'At risk',
'vacationDaysTable.atRiskOverCapDetail': '{days} days exceed how much may be saved for next year',
'vacationDaysTable.empty': 'No years to show yet.',
'vacationDaysTable.savedDaysHeader': 'Saved days carried in',
'vacationDaysTable.savedStatus': 'Saved',
+6
View File
@@ -4,6 +4,8 @@ export const calendar = {
'calendar.deleteError': 'Det gick inte att ta bort den planerade dagen',
'calendar.nextMonthAria': 'Nästa månad',
'calendar.partner': 'Partner',
'calendar.pinnedDayAllFreeError':
'Ingen av de valda dagarna kan sparas som den typen — lediga dagar (helg/röd dag) kan aldrig bli semester, och föräldrapenning på dem kräver att inställningen i Inställningar är påslagen.',
'calendar.prevMonthAria': 'Föregående månad',
'calendar.saveError': 'Det gick inte att spara de planerade dagarna',
'calendar.today': 'Idag',
@@ -37,6 +39,10 @@ export const calendar = {
'plannedDayModal.newTitle': 'Planera en dag',
'plannedDayModal.parentLabel': 'Förälder',
'plannedDayModal.pinnedNotice': 'Den här dagen är låst av dig — planeraren ändrar den inte automatiskt.',
'plannedDayModal.pinExcludesFromSyncNotice':
'Att låsa en dag utesluter den från planerarens automatiska omplanering.',
'plannedDayModal.freeDaysStayOffNotice':
'Lediga dagar (helg/röd dag) i urvalet lämnas lediga — semester går aldrig att lägga dit, och föräldrapenning kräver att "Ta ut föräldrapenning på röda dagar" är påslaget i Inställningar.',
'plannedDayModal.excludedNotice': 'Du har tagit bort den här dagen för {parent}.',
'plannedDayModal.restoreExcludedButton': 'Lägg tillbaka dagen',
'plannedDayModal.rangeTitle': 'Planera {count} dagar',
+3
View File
@@ -15,6 +15,9 @@ export const dashboard = {
'dashboard.figureExpiring': 'Förfaller vid 4 år',
'dashboard.figureLeft': 'Dagar kvar',
'dashboard.figureUsed': 'Använda',
// Vacation days forecast lost to age-out or the carry-forward cap if the
// current plan is followed — see `VacationYearBalance.expiring`.
'dashboard.figureVacationAtRisk': 'Semester som riskerar att gå förlorad',
'dashboard.hero.fkLinkText': 'Försäkringskassan',
'dashboard.hero.hasPlanMessage': '{atRisk} dagar riskerar att försvinna när {childName} fyller 4 ({date}).',
'dashboard.hero.noneAtRiskMessage': 'Inga dagar går förlorade — er plan hinner använda alla innan {childName} fyller 4 ({date}).',
+12
View File
@@ -30,4 +30,16 @@ export const plan = {
'planResults.surplusAdviceUseLessVacation': 'Spara färre semesterdagar',
'planResults.surplusAdviceWishMoreLeave': 'Önska mer ledighet',
'planResults.unknownParent': 'Okänd förälder',
// `PlanWarning.messageKey`/`kind` for the backend's three warning kinds
// (`allocation_service.py`'s `_sgi_risk_warnings`, `_age4_warnings`,
// `_vacation_expiry_warnings`), rendered by `PlanWarningCard.tsx` on the
// dashboard. `message_key` is `f"planWarnings.{kind}"` — matches these
// body keys exactly.
'planWarnings.sectionTitle': 'Risker',
'planWarnings.sgiRiskTitle': 'Risk för SGI-skyddet',
'planWarnings.sgi_risk': '{parent_name} är planerad för endast {days_planned} av minst {min_days_per_week} ledighetsdagar veckan som börjar {week_start} — det kan sänka den sjukpenninggrundande inkomsten (SGI).',
'planWarnings.daysLostAtAge4Title': 'Dagar riskerar att gå förlorade vid 4 år',
'planWarnings.days_lost_at_age_4': '{parent_name} riskerar att förlora {expiring_days} {expiring_days|föräldrapenningdag|föräldrapenningdagar} vid barnets 4-årsdag ({fourth_birthday}) — {projected_left_at_b4} {projected_left_at_b4|dag|dagar} beräknas då finnas kvar totalt.',
'planWarnings.vacationExpiringTitle': 'Semesterdagar riskerar att gå förlorade',
'planWarnings.vacation_expiring': '{expiring_days} {expiring_days|semesterdag|semesterdagar} riskerar att gå förlorade {year} om inget görs.',
} as const
+3
View File
@@ -113,6 +113,9 @@ export const settings = {
'settings.youBadge': '(Du)',
'vacationDaysTable.assumedBadge': 'Antaget',
'vacationDaysTable.assumedPrompt': 'Inget värde är registrerat för detta år — 25 dagar (lagstadgat) har antagits. Bekräfta eller ändra nedan.',
'vacationDaysTable.atRiskAgeDetail': '{days} dagar har passerat 5-årsgränsen för att spara semester',
'vacationDaysTable.atRiskHeader': 'Riskerar',
'vacationDaysTable.atRiskOverCapDetail': '{days} dagar överstiger hur mycket som får sparas till nästa år',
'vacationDaysTable.empty': 'Inga år att visa ännu.',
'vacationDaysTable.savedDaysHeader': 'Sparade dagar in',
'vacationDaysTable.savedStatus': 'Sparat',
+113 -19
View File
@@ -38,6 +38,7 @@ import {
makePlanApplyResponse,
makePlannedDayExclusionsResult,
makePlannedDayExclusion,
makeWorkspace,
} from '../../tests/fixtures'
import { makeCalendarDayEntry, makeCalendarWindow } from '../../tests/fixtures/calendar'
@@ -992,9 +993,11 @@ describe('Calendar', () => {
fireEvent.click(screen.getByTestId('period-delete-year'))
await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-1'))
// Sync was still called once, from the initial auto-sync on mount —
// but not a second time for this ad-hoc delete (it isn't wish-driven).
expect(sync).toHaveBeenCalledTimes(1)
// An ad-hoc delete removes the planned day directly and never syncs
// (it isn't wish-driven) — Calendar itself performs no auto-sync of
// its own any more (see the "§Task7 auto-sync moved to
// PlanSyncProvider" describe block below).
expect(sync).not.toHaveBeenCalled()
})
it('closes the popover on close/cancel', () => {
@@ -1358,10 +1361,8 @@ describe('Calendar', () => {
const today = todayDateString()
const wish = makeLeaveWish({ id: 'lw-1', name: 'Sommar' })
const update = vi.fn().mockResolvedValue(wish)
// Every call (including the initial auto-sync on mount, which is not
// under test here) reports the same non-empty diff — the point of
// this test is the UNDO's re-sync, not what any particular sync call
// returns.
// Every call reports the same non-empty diff — the point of this test
// is the UNDO's re-sync, not what any particular sync call returns.
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] }))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
@@ -1372,8 +1373,6 @@ describe('Calendar', () => {
)
render(<Calendar />)
// Let the initial auto-sync (unrelated to this test) settle first.
await waitFor(() => expect(sync).toHaveBeenCalledTimes(1))
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
fireEvent.click(screen.getByTestId('period-save'))
@@ -1478,17 +1477,14 @@ describe('Calendar', () => {
})
})
describe('§Task7 initial auto-sync', () => {
it('syncs once per workspace on mount without user interaction', async () => {
describe('§Task7 auto-sync moved to PlanSyncProvider', () => {
it('performs no sync of its own on mount — that is now PlanSyncProvider, mounted globally inside WorkspaceProvider', () => {
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
const { rerender } = render(<Calendar />)
await waitFor(() => expect(sync).toHaveBeenCalledWith('ws-1'))
render(<Calendar />)
rerender(<Calendar />)
// No extra auto-sync from a plain re-render of the same workspace.
expect(sync).toHaveBeenCalledTimes(1)
expect(sync).not.toHaveBeenCalled()
})
it('does not crash when the workspace id is not yet available', () => {
@@ -1613,10 +1609,14 @@ describe('Calendar', () => {
})
it('shows a generic error toast for a non-Error rejection while syncing', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockRejectedValue('boom')
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
fireEvent.click(screen.getByTestId('preset-click'))
await waitFor(() => {
const toastEl = screen.getByTestId('calendar-toast')
@@ -1704,11 +1704,15 @@ describe('Calendar', () => {
await waitFor(() => expect(screen.getByTestId('period-popover')).toHaveAttribute('data-error', 'cannot delete'))
})
it('shows the Error message directly when the initial sync rejects with an Error', async () => {
it('shows the Error message directly when a sync rejects with an Error', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockRejectedValue(new Error('workspace not found'))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
fireEvent.click(screen.getByTestId('preset-click'))
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('workspace not found'))
})
@@ -1716,12 +1720,11 @@ describe('Calendar', () => {
it('does not navigate or flash a band when the preset is created but the follow-up sync fails', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValueOnce(makePlanSyncResponse()).mockRejectedValueOnce(new Error('sync failed'))
const sync = vi.fn().mockRejectedValue(new Error('sync failed'))
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
await waitFor(() => expect(sync).toHaveBeenCalledTimes(1))
const yearBefore = (screen.getByLabelText('Välj år') as HTMLSelectElement).value
@@ -1835,6 +1838,97 @@ describe('Calendar', () => {
})
})
describe('hand-pin path never lets a free day become vacation (§ free days never consume vacation)', () => {
// June 2026: the 1st is a Monday, so 06-01..06-07 is a clean Mon–Sun
// week with 06-06/06-07 as the only free (weekend) days in it.
const MON = '2026-06-01'
const SUN = '2026-06-07'
const SAT_DATE = '2026-06-06'
const SUN_DATE = '2026-06-07'
function mockFreeWeekend() {
vi.mocked(useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: makeCalendarWindow({
days: [
makeCalendarDayEntry({ date: SAT_DATE, dayType: 'weekend', isFree: true }),
makeCalendarDayEntry({ date: SUN_DATE, dayType: 'weekend', isFree: true }),
],
}),
}),
)
}
function selectMonToSun() {
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(`date=${MON}`), mockSetSearchParams])
render(<Calendar />)
const days = screen.getAllByTestId('calendar-day')
const startDay = days.find((el) => el.getAttribute('aria-label')?.startsWith(MON))!
const endDay = days.find((el) => el.getAttribute('aria-label')?.startsWith(SUN))!
fireEvent.pointerDown(startDay)
fireEvent.pointerEnter(endDay)
fireEvent.pointerUp(window)
}
it('pinning a Mon–Sun range as vacation creates 5 rows, skipping the weekend', async () => {
mockFreeWeekend()
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add }))
selectMonToSun()
const modal = await screen.findByTestId('modal')
expect(modal).toHaveAttribute('data-day-count', '7')
fireEvent.click(screen.getByTestId('modal-save-pinned'))
await waitFor(() => expect(add).toHaveBeenCalledTimes(5))
const pinnedDates = add.mock.calls.map((call) => (call[0] as { date: string }).date)
expect(pinnedDates).not.toContain(SAT_DATE)
expect(pinnedDates).not.toContain(SUN_DATE)
})
it('pinning the same range as sjukpenning with takePlOnRedDays on creates all 7 rows', async () => {
mockFreeWeekend()
vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({
selectedChildId: 'child-1',
personalDataUuid: 'person-1',
workspace: makeWorkspace({ takePlOnRedDays: true }),
}),
)
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add }))
selectMonToSun()
const modal = await screen.findByTestId('modal')
expect(modal).toHaveAttribute('data-day-count', '7')
fireEvent.click(screen.getByTestId('modal-save-pinned-sjukpenning'))
await waitFor(() => expect(add).toHaveBeenCalledTimes(7))
})
it('shows an error and saves nothing when every selected date is free and ineligible', async () => {
mockFreeWeekend()
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add }))
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(`date=${SAT_DATE}`), mockSetSearchParams])
render(<Calendar />)
const days = screen.getAllByTestId('calendar-day')
const startDay = days.find((el) => el.getAttribute('aria-label')?.startsWith(SAT_DATE))!
const endDay = days.find((el) => el.getAttribute('aria-label')?.startsWith(SUN_DATE))!
fireEvent.pointerDown(startDay)
fireEvent.pointerEnter(endDay)
fireEvent.pointerUp(window)
const modal = await screen.findByTestId('modal')
expect(modal).toHaveAttribute('data-day-count', '2')
fireEvent.click(screen.getByTestId('modal-save-pinned'))
await waitFor(() => expect(screen.getByTestId('modal')).toBeInTheDocument())
expect(add).not.toHaveBeenCalled()
})
})
describe('grid layout: uniform cell size and row height regardless of content', () => {
it('keeps every week row on the same shared grid definition even when one week contains a long holiday name', () => {
// Regression guard for the "31 October" bug: a long holiday name
+35 -11
View File
@@ -1,4 +1,4 @@
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useWorkspace } from '@/context'
import {
@@ -421,15 +421,13 @@ export function Calendar(): ReactNode {
[syncHook, plannedDaysHook, calendarHook, wishesHook.data, membersHook.data, t, showToast],
)
// Sync once per workspace as soon as it's known, so wishes seeded/created
// elsewhere show up as bands without requiring a manual edit first.
const didAutoSyncRef = useRef<string | null>(null)
useEffect(() => {
if (!workspace?.id) return
if (didAutoSyncRef.current === workspace.id) return
didAutoSyncRef.current = workspace.id
void runSync(workspace.id, { silent: true })
}, [workspace?.id, runSync])
// The one-time-per-workspace auto-sync that used to live here (so wishes
// seeded/created elsewhere showed up as bands without a manual edit
// first) is now `PlanSyncProvider`'s job — it runs globally, for every
// plan-input bump, from the moment the workspace is known, not only
// while Calendar happens to be mounted. See `WorkspaceContext.tsx`/
// `PlanSyncProvider.tsx`. Calendar keeps only the `runSync` calls below
// that need their own toast/undo UI.
useEffect(() => {
const handlePointerUp = () => {
@@ -596,13 +594,31 @@ export function Calendar(): ReactNode {
}) => {
if (selectedDates.size === 0 || !selectedChildId || !workspace?.id) return
// A free day (Saturday, Sunday, or Swedish red day, subject to a
// workspace DayOverride) can never be `vacation`; it may be
// `sjukpenning`/`lagsta` only when the workspace opted in via
// `takePlOnRedDays`. Filtering here keeps the request from ever
// reaching the backend's authoritative guard
// (`PlanningService._reject_illegal_free_day`) in the common case.
const eligibleDates = Array.from(selectedDates).filter((date) => {
const isFree = calendarDaysByDate.get(date)?.isFree ?? false
if (!isFree) return true
if (payload.dayType === 'vacation') return false
return workspace.takePlOnRedDays === true
})
if (eligibleDates.length === 0) {
setModalError(t('calendar.pinnedDayAllFreeError'))
return
}
try {
setIsSaving(true)
setModalError(null)
setLastLeaveType(payload.dayType)
for (const date of selectedDates) {
for (const date of eligibleDates) {
const existingDay = getExistingDay(date, payload.parentId)
if (existingDay) {
@@ -1087,6 +1103,12 @@ export function Calendar(): ReactNode {
const defaultLeaveType = getLastLeaveType() ?? 'vacation'
const firstSelectedDate = Array.from(selectedDates).sort()[0]
// Free dates (weekend/red day, per workspace DayOverride) within the
// current selection — drives PlannedDayModal's "will stay off" notice.
const freeDatesInSelection = Array.from(selectedDates).filter(
(date) => calendarDaysByDate.get(date)?.isFree ?? false,
)
// Dates to highlight in the grid. While dragging, show a live preview of the
// range under the cursor; otherwise show the committed selection.
const highlightedDates =
@@ -1278,6 +1300,8 @@ export function Calendar(): ReactNode {
defaultDayType={defaultLeaveType}
isRangeMode={selectedDates.size > 1}
dayCount={selectedDates.size}
freeDatesInSelection={freeDatesInSelection}
takePlOnRedDays={workspace?.takePlOnRedDays === true}
/>
)}
+102 -1
View File
@@ -20,6 +20,7 @@ import {
makePreschoolPlanningDays,
makePlanProposal,
makeSurplusAdvice,
makePlanWarning,
makeWindowOutcome,
makeParentalLeaveFetch,
makeParentYearBalance,
@@ -79,6 +80,8 @@ const workspaceValue = (overrides: Partial<ReturnType<typeof useWorkspace>> = {}
...overrides,
})
const CURRENT_YEAR = new Date().getFullYear()
const alex = makePersonalData({ uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex' })
const sam = makePersonalData({ uuid: 'pd-2', workspaceId: 'ws-1', name: 'Sam' })
@@ -318,11 +321,52 @@ describe('Dashboard', () => {
})
})
it('renders the three key figures from the combined analytics', () => {
it('renders the four key figures from the combined analytics', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('dashboard-figure-left')).toHaveTextContent('270')
expect(screen.getByTestId('dashboard-figure-used')).toHaveTextContent('30')
expect(screen.getByTestId('dashboard-figure-expiring')).toHaveTextContent('100')
// Default mock has no parent projections, so nothing is at risk yet.
expect(screen.getByTestId('dashboard-figure-vacationAtRisk')).toHaveTextContent('0')
})
it('sums vacation days at risk across every parent and current/future year, ignoring past years', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue(
coupleAnalytics({
parentProjections: [
projection('pd-1', {
yearBalances: [
makeParentYearBalance({
personalDataUuid: 'pd-1',
childId: 'c-1',
year: 2020,
vacation: makeVacationYearBalance({ year: 2020, expiring: 99 }),
}),
makeParentYearBalance({
personalDataUuid: 'pd-1',
childId: 'c-1',
year: CURRENT_YEAR,
vacation: makeVacationYearBalance({ year: CURRENT_YEAR, expiring: 3 }),
}),
],
}),
projection('pd-2', {
yearBalances: [
makeParentYearBalance({
personalDataUuid: 'pd-2',
childId: 'c-1',
year: CURRENT_YEAR,
vacation: makeVacationYearBalance({ year: CURRENT_YEAR, expiring: 4 }),
}),
],
}),
],
}),
)
renderWithRouter(<Dashboard />)
const tile = screen.getByTestId('dashboard-figure-vacationAtRisk')
expect(tile).toHaveTextContent('7')
expect(tile.querySelector('dd')).toHaveClass('key-figures__value--danger')
})
describe('"Fram till 4 år" section', () => {
@@ -460,6 +504,63 @@ describe('Dashboard', () => {
})
})
describe('Warnings', () => {
it('is hidden entirely when the proposal has no warnings', () => {
renderWithRouter(<Dashboard />)
expect(screen.queryByTestId('dashboard-warnings')).not.toBeInTheDocument()
})
it('renders each warning above the tips section, with the parent name resolved', () => {
vi.mocked(hooks.useOptimizationTips).mockReturnValue(
makeOptimizationTipsResult({ data: [makeTip({ id: 'tip-1', messageKey: 'tips.use_vacation_before_expiry' })] }),
)
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
warnings: [
makePlanWarning({
kind: 'days_lost_at_age_4',
messageKey: 'planWarnings.days_lost_at_age_4',
data: {
parent_personal_data_uuid: 'pd-2',
child_id: 'child-1',
expiring_days: 15,
projected_left_at_b4: 40,
fourth_birthday: '2029-06-01',
},
}),
],
}),
}),
)
renderWithRouter(<Dashboard />)
const warningsSection = screen.getByTestId('dashboard-warnings')
const tipsSection = screen.getByTestId('dashboard-tips')
expect(warningsSection).toBeInTheDocument()
expect(screen.getByTestId('dashboard-plan-warning-0')).toHaveTextContent('Sam')
expect(screen.getByTestId('dashboard-plan-warning-0')).toHaveTextContent('15 föräldrapenningdagar vid barnets 4-årsdag (1 juni 2029)')
// Warnings render above the tips section — DOCUMENT_POSITION_FOLLOWING
// set on `tipsSection` means it comes after `warningsSection`.
const position = warningsSection.compareDocumentPosition(tipsSection)
expect(Boolean(position & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true)
})
it('degrades gracefully for an unrecognised warning kind', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
warnings: [makePlanWarning({ kind: 'brand_new_warning', messageKey: 'planWarnings.brand_new_warning', data: {} })],
}),
}),
)
renderWithRouter(<Dashboard />)
const warning = screen.getByTestId('dashboard-plan-warning-0')
expect(warning).toHaveTextContent('brand_new_warning')
expect(warning).toHaveTextContent('planWarnings.brand_new_warning')
})
})
describe('Tips', () => {
it('is hidden entirely when there are neither tips nor surplus advice', () => {
renderWithRouter(<Dashboard />)
+28
View File
@@ -24,6 +24,7 @@ import { KeyFigures } from '@/components/dashboard/KeyFigures'
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 { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
import { TipCard } from '@/components/inputs/TipCard'
import { Onboarding } from '@/pages/Onboarding'
@@ -133,6 +134,20 @@ export function Dashboard(): ReactNode {
// the combined total, so skip the breakdown in that case.
const showPerParentExpiring = parentProjections.length > 1
// Vacation days forecast lost (age-out or over the carry-forward cap),
// across every parent, from this year onward — a past year's loss already
// happened and is no longer actionable (same guard `YearRows` uses for
// its own per-year expiring figure).
const currentYear = new Date().getFullYear()
const vacationAtRisk = parentProjections.reduce(
(sum, projection) =>
sum +
projection.yearBalances
.filter((balance) => balance.year >= currentYear)
.reduce((yearSum, balance) => yearSum + balance.vacation.expiring, 0),
0,
)
const parentDisplayName = (uuid: string, index: number): string => {
const resolved = membersLoading ? uuid : nameFor(uuid)
return resolved === uuid
@@ -217,6 +232,7 @@ export function Dashboard(): ReactNode {
}))
const surplusAdvice = proposal?.surplusAdvice ?? []
const warnings = proposal?.warnings ?? []
const hasTips = (tips?.length ?? 0) > 0 || surplusAdvice.length > 0
const heroRemedy = deriveHeroRemedy(tips)
@@ -254,6 +270,7 @@ export function Dashboard(): ReactNode {
daysLeft={analytics.combinedDaysLeft}
daysUsed={analytics.combinedDaysUsed}
expiringAtB4={analytics.combinedExpiringAtB4}
vacationAtRisk={vacationAtRisk}
/>
)}
@@ -278,6 +295,17 @@ export function Dashboard(): ReactNode {
</section>
)}
{warnings.length > 0 && (
<section className="dashboard__section" data-testid="dashboard-warnings">
<h2>{t('planWarnings.sectionTitle')}</h2>
<div className="dashboard__tips-list">
{warnings.map((warning, index) => (
<PlanWarningCard key={`${warning.kind}-${index}`} warning={warning} nameFor={nameForAdvice} index={index} />
))}
</div>
</section>
)}
{hasTips && (
<section className="dashboard__section" data-testid="dashboard-tips">
<h2>{t('tips.title')}</h2>
+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()
})
})
+32 -6
View File
@@ -9,6 +9,7 @@ import {
useWorkspaceMembers,
usePreschoolPlanningDays,
useDebouncedCallback,
useAnalytics,
} from '@/hooks'
import * as api from '@/services/api'
import { isApiError } from '@/services/errors'
@@ -221,7 +222,7 @@ function PreschoolPlanningYearRow({
export function Settings(): ReactNode {
const { t, explicitLocale, setLocale } = useTranslation()
const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext } = useWorkspace()
const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext, bumpPlanVersion } = useWorkspace()
const { data: vacDays, isLoading: vacLoading, upsert: upsertVacationDays } = useVacationDays(workspace?.id ?? '')
const {
data: members,
@@ -268,6 +269,14 @@ export function Settings(): ReactNode {
// a child is chosen/created elsewhere in the app.
const balanceChildId = selectedChildId ?? children[0]?.id ?? null
// Supplies the vacation-days table's "at risk" column (already computed by
// the same analytics endpoint Dashboard.tsx uses — see
// `AnalyticsService._vacation_year_series`). Deliberately not folded into
// `isLoading` below: it's an annotation on the editable table, not data
// the table needs to render at all, and a new/unselected-child workspace
// (no `balanceChildId` yet) must not block the rest of the page on it.
const { data: analytics } = useAnalytics(workspace?.id ?? null, balanceChildId ?? undefined)
const isLoading = wsLoading || vacLoading || childDetailsLoading || dayOverridesLoading
useEffect(() => {
@@ -303,9 +312,14 @@ export function Settings(): ReactNode {
// `vacDays`, showing (and letting you overwrite) the wrong parent's data.
const vacationRowsByMember: VacationDaysTableRow[][] = useMemo(
() =>
orderedMembers.map((member) =>
vacationYears.map((year) => {
orderedMembers.map((member) => {
const projection = analytics?.parentProjections.find((p) => p.personalDataUuid === member.uuid)
return vacationYears.map((year) => {
const existing = vacDays.find((v: VacationDays) => v.personalDataUuid === member.uuid && v.year === year)
// Analytics may not have loaded yet (or there's no child selected)
// — the at-risk figures then default to 0 rather than block the
// rest of this editable row on a supplementary fetch.
const vacationBalance = projection?.yearBalances.find((b) => b.year === year)?.vacation
return {
year,
totalDays: existing?.totalDays ?? DEFAULT_VACATION_DAYS,
@@ -314,10 +328,13 @@ export function Settings(): ReactNode {
// default is shown, not an entered value. Mirrors the backend's
// `VacationBalanceResponse.is_assumed` for the propose-time table.
isAssumed: existing === undefined,
expiring: vacationBalance?.expiring ?? 0,
expiringAge: vacationBalance?.expiringAge ?? 0,
expiringOverCap: vacationBalance?.expiringOverCap ?? 0,
}
}),
),
[orderedMembers, vacationYears, vacDays],
})
}),
[orderedMembers, vacationYears, vacDays, analytics],
)
if (isLoading) {
@@ -354,6 +371,11 @@ export function Settings(): ReactNode {
salary_type: parentData.salaryType,
employment_status: parentData.employmentStatus,
})
// Salary type feeds the allocator's cost calculations (sjukpenning
// vs lägstanivå tradeoffs) — re-type the rest of the plan's
// unpinned days. See `WorkspaceContext.planVersion`. (Child-details
// saves above already bump via `useChildSettings.update`.)
bumpPlanVersion()
}
showMessage('success', t('settings.saveFamilySuccess'))
} catch (err) {
@@ -499,6 +521,10 @@ export function Settings(): ReactNode {
setIsSavingRedDayPref(true)
await api.updateWorkspace(workspace.id, { takePlOnRedDays: checked })
await refreshContext()
// Whether PL may be taken on red days changes which dates the
// allocator can place leave on — re-type the rest of the plan's
// unpinned days. See `WorkspaceContext.planVersion`.
bumpPlanVersion()
} catch (err) {
showMessage('error', err instanceof Error ? err.message : t('settings.takePlOnRedDaysError'))
} finally {
+91
View File
@@ -1749,6 +1749,11 @@ describe('endpoint functions (representative coverage)', () => {
new_wish_id: 'lw1',
},
],
eviction_preview: {
would_evict: false,
candidates: [],
shortages: [],
},
}),
})
@@ -1798,6 +1803,92 @@ describe('endpoint functions (representative coverage)', () => {
oldWishId: null,
newWishId: 'lw1',
})
expect(result.evictionPreview).toEqual({ wouldEvict: false, candidates: [], shortages: [] })
})
it('syncPlan maps a would-evict eviction_preview with candidates and shortages', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
proposal: {
days: [],
outcomes: [],
warnings: [],
pool_balances: [],
vacation_balances: [],
surplus_advice: [],
age4_projections: [],
},
apply_result: {
accepted: false,
created_count: 0,
skipped_identical_count: 0,
skipped_free_count: 0,
replaced_count: 0,
collisions: [],
rejections: [
{
message_key: 'planSync.rejection.evictionChoiceRequired',
data: { count: 1 },
},
],
created_days: [],
},
changes: [],
eviction_preview: {
would_evict: true,
candidates: [
{
date: '2028-06-20',
parent_personal_data_uuid: 'pd1',
child_id: 'child1',
day_type: 'vacation',
is_dubbeldag: false,
wish_id: 'lw1',
wish_name: 'Sommar',
},
],
shortages: [
{
resource_kind: 'vacation',
parent_personal_data_uuid: 'pd1',
tier: null,
child_id: null,
year: 2028,
days_evicted: 1,
},
],
},
}),
})
const result = await api.syncPlan('ws1')
expect(result.evictionPreview).toEqual({
wouldEvict: true,
candidates: [
{
date: '2028-06-20',
parentPersonalDataUuid: 'pd1',
childId: 'child1',
dayType: 'vacation',
isDubbeldag: false,
wishId: 'lw1',
wishName: 'Sommar',
},
],
shortages: [
{
resourceKind: 'vacation',
parentPersonalDataUuid: 'pd1',
tier: null,
childId: null,
year: 2028,
daysEvicted: 1,
},
],
})
})
it('listWorkspaceMembers maps snake_case members to camelCase', async () => {
+50
View File
@@ -1121,10 +1121,59 @@ function toPlanChange(raw: RawPlanChange): PlanChange {
}
}
interface RawEvictionCandidate {
date: string
parent_personal_data_uuid: string
child_id: string
day_type: string
is_dubbeldag: boolean
wish_id: string
wish_name: string | null
}
interface RawResourceShortage {
resource_kind: string
parent_personal_data_uuid: string
tier: string | null
child_id: string | null
year: number | null
days_evicted: number
}
interface RawEvictionPreview {
would_evict: boolean
candidates: RawEvictionCandidate[]
shortages: RawResourceShortage[]
}
function toEvictionPreview(raw: RawEvictionPreview): import('@/types').EvictionPreview {
return {
wouldEvict: raw.would_evict,
candidates: raw.candidates.map((c) => ({
date: c.date,
parentPersonalDataUuid: c.parent_personal_data_uuid,
childId: c.child_id,
dayType: c.day_type as import('@/types').EvictionCandidate['dayType'],
isDubbeldag: c.is_dubbeldag,
wishId: c.wish_id,
wishName: c.wish_name,
})),
shortages: raw.shortages.map((s) => ({
resourceKind: s.resource_kind,
parentPersonalDataUuid: s.parent_personal_data_uuid,
tier: s.tier,
childId: s.child_id,
year: s.year,
daysEvicted: s.days_evicted,
})),
}
}
interface RawPlanSyncResponse {
proposal: RawPlanProposal
apply_result: RawPlanApplyResponse
changes: RawPlanChange[]
eviction_preview: RawEvictionPreview
}
/** POST /api/plan/sync — the direct-manipulation calendar's one call:
@@ -1143,6 +1192,7 @@ export async function syncPlan(workspaceId: string): Promise<PlanSyncResponse> {
proposal: toPlanProposal(raw.proposal),
applyResult: toPlanApplyResponse(raw.apply_result),
changes: raw.changes.map(toPlanChange),
evictionPreview: toEvictionPreview(raw.eviction_preview),
}
}
+4
View File
@@ -474,6 +474,10 @@
background-color: var(--c-accent);
}
.tip-card--danger::before {
background-color: var(--c-danger);
}
.tip-card__header {
display: flex;
align-items: flex-start;
+4
View File
@@ -197,6 +197,10 @@ export interface PlanSyncResponse {
proposal: import('./entities').PlanProposal
applyResult: PlanApplyResponse
changes: import('./entities').PlanChange[]
// Always present — `evictionPreview.wouldEvict === true` means this call
// wrote NOTHING (see `EvictionPreview`'s doc comment in entities.ts). A
// caller must check this, not just `applyResult.accepted`/`changes`.
evictionPreview: import('./entities').EvictionPreview
}
// Day overrides (calendar exceptions)
+39
View File
@@ -113,6 +113,45 @@ export interface PlanChange {
newWishId: string | null
}
/** One day a `POST /api/plan/sync` eviction preview would remove — see
* `EvictionPreview`'s docstring. Structured data only, same house style as
* `PlanChange`: the frontend composes the warning copy itself. */
export interface EvictionCandidate {
date: string // ISO-8601 date
parentPersonalDataUuid: string
childId: string
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
isDubbeldag: boolean
wishId: string
wishName: string | null
}
/** One exhausted resource behind an eviction preview.
* `resourceKind === 'parental_leave'` carries `tier`/`childId` (`year` is
* `null`); `resourceKind === 'vacation'` carries `year` instead (`tier`/
* `childId` are `null`). */
export interface ResourceShortage {
resourceKind: string
parentPersonalDataUuid: string
tier: string | null
childId: string | null
year: number | null
daysEvicted: number
}
/** Always present on `PlanSyncResponse` — `wouldEvict: false` with empty
* lists in the common (fits-within-budget) case (see
* `backend/app/services/planning_service.py`'s `EvictionPreview`). When
* `wouldEvict` is `true`, `sync_plan` wrote NOTHING at all (unless the
* caller passed `eviction_choice`, which this app's frontend never does
* yet) — a caller receiving this must surface it for a user decision, never
* silently discard it and assume the plan was written. */
export interface EvictionPreview {
wouldEvict: boolean
candidates: EvictionCandidate[]
shortages: ResourceShortage[]
}
export interface TierData {
sjukpenningTotal: number
sjukpenningUsed: number