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