Tell parents how to save the days expiring at age 4

The dashboard stated the loss and stopped there. With Jacob at 48/48 savable
and 24 days over the cap, and Amelie at 3/48 with 45 days of headroom, those
24 days are not lost at all — transferring them before the 4th birthday saves
every one. The app never said so.

`_rule_age4_pooling_loss` already computed exactly that advice, bounded by the
FK-reported `transferable_days_left`, in finished Swedish and English. It never
ran: `age4_pooling_tip_min_child_age_years` held it back until the child turned
2. The calendar IS the plan, so gate on the plan instead — the noise case is a
newborn with nothing planned, not a young child whose parents have planned in
detail. `age4_pooling_tip_min_planned_days` (20) replaces the age gate.

Both variants drop to `info`. A projection four years out should not read as a
verdict on the user's planning; the hero carries the alarm, this card carries
the advice. The copy now leads with "plans change, this is only a projection"
and closes by pointing at keeping balances up to date, since parents will come
back and record what they actually took.

- Suppress `preserve_for_age_4` whenever `age4_pooling_loss` fires for the same
  child. The pooling tip states the same loss plus the remedy and the correct
  per-parent cap, so emitting both gave two contradictory "you're losing N
  days" figures.
- `preserve_for_age_4` now sends `parent_cap` and its copy interpolates it. It
  claimed "only 96 days can be saved" — false for a two-parent family, where
  the cap is 48 each, as the Age4Bars card two rows below already showed.
- The hero grows a "How to save them" line derived from the tip itself, so the
  two can never disagree on the number. Dismissing the tip retires both.
- "Försäkringskassan" in that line links to FK's BankID login carrying a `goto`
  to Min föräldrapenning — the e-service showing "Dagar ni kan föra över" —
  so the transfer is one click away. `translate()` returns a plain string and
  word order differs between locales, so the anchor's position travels inside
  the template as `{fk}` and the rendered string is split around it.

Verified in the browser: hero, link target, tip severity and the absence of
`preserve_for_age_4`, in both languages and at 390px. Note the demo seed cannot
reach this shape — it gives the surplus parent no FK snapshot at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
This commit is contained in:
2026-09-20 09:51:10 +02:00
parent 97a7200c1a
commit d5f33e8c7f
14 changed files with 495 additions and 27 deletions
+26
View File
@@ -26,6 +26,7 @@ import { SurplusAdviceTip } from '@/components/dashboard/SurplusAdviceTip'
import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
import { TipCard } from '@/components/inputs/TipCard'
import { Onboarding } from '@/pages/Onboarding'
import type { Tip } from '@/types'
/** Fallback horizon end when the analytics projection isn't available yet
* (e.g. mid-load) — plain date arithmetic, not a business rule, so deriving
@@ -36,6 +37,29 @@ function computeFourthBirthday(birthDateIso: string): string {
return d.toISOString().slice(0, 10)
}
interface HeroRemedy {
transferDays: number
fromParent: string
toParent: string
}
/** Surfaces the exact age-4 remedy in the hero, derived from the
* `tips.age4_pooling_loss_exact` tip's `data` — no separate API call, no
* new backend field. `Tip['data']` is `Record<string, unknown>`, so every
* field is narrowed by hand rather than cast. */
function deriveHeroRemedy(tips: Tip[] | null): HeroRemedy | null {
const tip = tips?.find((t) => t.messageKey === 'tips.age4_pooling_loss_exact')
if (!tip) return null
const { transfer_days, surplus_parent, lagging_parent } = tip.data
if (typeof transfer_days !== 'number' || !Number.isFinite(transfer_days) || transfer_days <= 0) return null
if (typeof surplus_parent !== 'string' || surplus_parent.length === 0) return null
if (typeof lagging_parent !== 'string' || lagging_parent.length === 0) return null
return { transferDays: transfer_days, fromParent: surplus_parent, toParent: lagging_parent }
}
export function Dashboard(): ReactNode {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -189,6 +213,7 @@ export function Dashboard(): ReactNode {
const surplusAdvice = proposal?.surplusAdvice ?? []
const hasTips = (tips?.length ?? 0) > 0 || surplusAdvice.length > 0
const heroRemedy = deriveHeroRemedy(tips)
return (
<Layout>
@@ -206,6 +231,7 @@ export function Dashboard(): ReactNode {
daysAtRisk={analytics.combinedExpiringAtB4}
childName={selectedChild.name}
fourthBirthday={fourthBirthday}
remedy={heroRemedy}
/>
)}