Files
F-r-ldraledigplannerare/frontend/src/components/dashboard/PlanWarningCard.tsx
T
jacobh2 61e5487078 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
2026-09-20 13:07:36 +02:00

89 lines
3.5 KiB
TypeScript

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>
)
}