Translate wish names in plan proposal output

Preset wish names follow the UI language in the wish editor, but the plan
proposal still rendered outcome.wishName straight from the stored
entity.name, so a proposal could show "Höstlov" with the UI in English.

The proposal now carries wish_name_message_key alongside each wish name,
and the frontend resolves it with the same helper the editor uses --
falling back to the stored literal when the key is absent or unknown.
Nothing is translated on the backend; text crosses the wire as a key.

Four surfaces carried a name and all four are covered: WindowOutcome
itself, the explanation summary's interpolated data, and two remedies --
including REMEDY_LOWER_PRIORITY_WISH, which names a *different* sibling
wish whose key can differ from the outcome's own.

The proposal response mapping is hand-written and has dropped new fields
twice before, so this is covered by an API-level test asserting the field
reaches the response body, not only a service test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
2026-09-14 22:51:15 +02:00
parent e22007dc50
commit 20f45d6c7b
12 changed files with 466 additions and 8 deletions
@@ -52,6 +52,7 @@ function buildProposal(overrides: Partial<PlanProposal> = {}): PlanProposal {
{
wishId: 'lw1',
wishName: 'Sommar',
wishNameMessageKey: null,
year: 2026,
startDate: '2026-06-15',
endDate: '2026-07-26',
@@ -101,6 +102,7 @@ describe('PlanResults', () => {
{
wishId: 'lw1',
wishName: 'Sommar',
wishNameMessageKey: null,
year: 2026,
startDate: '2026-06-15',
endDate: '2026-07-26',
@@ -116,6 +118,7 @@ describe('PlanResults', () => {
{
wishId: 'lw2',
wishName: 'Jul',
wishNameMessageKey: null,
year: 2027,
startDate: '2027-12-20',
endDate: '2028-01-16',
@@ -167,6 +170,7 @@ describe('PlanResults', () => {
{
wishId: 'lw1',
wishName: 'Sommar',
wishNameMessageKey: null,
year: 2026,
startDate: '2026-06-15',
endDate: '2026-07-26',
@@ -210,6 +214,7 @@ describe('PlanResults', () => {
{
wishId: 'lw1',
wishName: 'Sommar',
wishNameMessageKey: null,
year: 2026,
startDate: '2026-06-15',
endDate: '2026-07-26',
@@ -417,6 +422,7 @@ describe('PlanResults', () => {
return {
wishId: 'lw3',
wishName: 'Vår',
wishNameMessageKey: null,
year: 2027,
startDate: '2027-03-01',
endDate: '2027-05-31',
@@ -461,4 +467,121 @@ describe('PlanResults', () => {
const shortfall = screen.getByTestId('plan-shortfall-lw3-2027')
expect(shortfall).toHaveTextContent('42 days could not be funded')
})
// The hole this closes: a preset-created wish's name used to reach the
// proposal only as a pre-translated literal (`wishName`/`data.wish_name`),
// so it stayed in whatever language the UI was in when the wish was
// created. `wishNameMessageKey` (mirroring `LeaveWish.nameMessageKey`,
// see src/utils/wishName.ts) lets every surface re-translate it, exactly
// like WishEditor already does for the wish list itself.
describe('wishNameMessageKey (preset wish names follow the UI language)', () => {
function buildPresetOutcome() {
return {
wishId: 'lw1',
wishName: 'STALE_SWEDISH_TEXT',
wishNameMessageKey: 'wishEditor.presetSommar',
year: 2026,
startDate: '2026-06-15',
endDate: '2026-07-26',
weeksWished: 6,
weeksFunded: 6,
shortfallDays: 0,
strategy: 'parent_swap',
messageKey: 'planOutcomes.windowExplanation',
data: {
wish_name: 'STALE_SWEDISH_TEXT',
wish_name_message_key: 'wishEditor.presetSommar',
year: 2026,
weeks_wished: 6,
weeks_funded: 6,
shortfall_days: 0,
},
costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 },
remedies: [],
}
}
it('re-translates the name through the badge, card heading, and explanation in Swedish, ignoring the stale stored name', () => {
const proposal = buildProposal({ outcomes: [buildPresetOutcome()] })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
expect(screen.getByText(/Sommar: 6\/6 veckor/)).toBeInTheDocument()
const card = screen.getByTestId('plan-outcome-lw1-2026')
expect(card).toHaveTextContent('Sommar 2026')
expect(card).toHaveTextContent('Sommar 2026 — 6 veckor önskade, 6 veckor finansierade')
expect(card).not.toHaveTextContent('STALE_SWEDISH_TEXT')
})
it('re-translates the same name to English when the locale is English', () => {
const proposal = buildProposal({ outcomes: [buildPresetOutcome()] })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'en')
expect(screen.getByText(/Summer: 6\/6 weeks/)).toBeInTheDocument()
const card = screen.getByTestId('plan-outcome-lw1-2026')
expect(card).toHaveTextContent('Summer 2026')
expect(card).toHaveTextContent('Summer 2026 — 6 weeks wished, 6 weeks funded')
expect(card).not.toHaveTextContent('STALE_SWEDISH_TEXT')
})
it('shows the literal name verbatim when wishNameMessageKey is null (free-typed/renamed wish)', () => {
const outcome = {
...buildPresetOutcome(),
wishName: 'Resa till mormor',
wishNameMessageKey: null,
data: { ...buildPresetOutcome().data, wish_name: 'Resa till mormor', wish_name_message_key: null },
}
const proposal = buildProposal({ outcomes: [outcome] })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
const card = screen.getByTestId('plan-outcome-lw1-2026')
expect(card).toHaveTextContent('Resa till mormor 2026')
})
it('falls back to the literal name for a key this build does not recognise', () => {
const outcome = {
...buildPresetOutcome(),
wishName: 'Sommar',
wishNameMessageKey: 'wishEditor.thisKeyDoesNotExist',
data: {
...buildPresetOutcome().data,
wish_name: 'Sommar',
wish_name_message_key: 'wishEditor.thisKeyDoesNotExist',
},
}
const proposal = buildProposal({ outcomes: [outcome] })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
const card = screen.getByTestId('plan-outcome-lw1-2026')
expect(card).toHaveTextContent('Sommar 2026')
expect(card).not.toHaveTextContent('wishEditor.thisKeyDoesNotExist')
})
it('re-translates a lower_priority_wish remedy’s referenced sibling name independently of the outcome’s own wish', () => {
const outcome = {
...buildPresetOutcome(),
weeksFunded: 4,
shortfallDays: 14,
remedies: [
{
kind: 'lower_priority_wish',
messageKey: 'planRemedies.lower_priority_wish',
data: {
wish_name: 'STALE_JUL_TEXT',
wish_name_message_key: 'wishEditor.seasonJul',
from_weeks: 4,
to_weeks: 2,
freed_days: 6,
},
daysFreed: 6,
},
],
}
const proposal = buildProposal({ outcomes: [outcome] })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
const remedies = screen.getByTestId('plan-remedies-lw1-2026')
expect(remedies).toHaveTextContent('Sänk Jul från 4 till 2 veckor frigör 6 dagar')
expect(remedies).not.toHaveTextContent('STALE_JUL_TEXT')
})
})
})
@@ -19,6 +19,7 @@ import type {
} from '@/types'
import { useTranslation, toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n'
import { nameResolver } from '@/utils/parentNames'
import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName'
export interface PlanResultsProps {
proposal: PlanProposal
@@ -131,6 +132,9 @@ type Translate = (key: TranslationKey, params?: TranslationParams) => string
*/
function buildWindowExplanation(outcome: WindowOutcome, t: Translate): string {
const params = toTranslationParams(outcome.data)
if (typeof outcome.data.wish_name === 'string') {
params.wish_name = resolveWishNameFromData(outcome.data, t)
}
let text = t('planResults.explanationSummary', params)
const shortfallDays = outcome.data.shortfall_days
if (typeof shortfallDays === 'number' && shortfallDays > 0) {
@@ -165,7 +169,12 @@ function buildWindowExplanation(outcome: WindowOutcome, t: Translate): string {
/** Surface #2 — `remedy.description`. */
function renderRemedy(remedy: ShortfallRemedy, t: Translate): string {
const key = REMEDY_BODY_KEYS[remedy.messageKey]
return key ? t(key, toTranslationParams(remedy.data)) : remedy.messageKey
if (!key) return remedy.messageKey
const params = toTranslationParams(remedy.data)
if (typeof remedy.data.wish_name === 'string') {
params.wish_name = resolveWishNameFromData(remedy.data, t)
}
return t(key, params)
}
/** Surface #3 — `warning.message`. */
@@ -483,7 +492,8 @@ export function PlanResults({ proposal, members, isApplying = false, onApply }:
: 'plan-overview-badge--ok'
}`}
>
{outcome.wishName}: {outcome.weeksFunded}/{outcome.weeksWished} {t('planResults.weeksUnit')}
{resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)}:{' '}
{outcome.weeksFunded}/{outcome.weeksWished} {t('planResults.weeksUnit')}
</span>
))}
</div>
@@ -497,14 +507,19 @@ export function PlanResults({ proposal, members, isApplying = false, onApply }:
<section className="plan-results__section" data-testid="plan-outcome-cards">
<h2>{t('planResults.detailTitle')}</h2>
{sortedOutcomes.map((outcome) => (
{sortedOutcomes.map((outcome) => {
const resolvedWishName = resolveWishName(
{ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey },
t,
)
return (
<article
key={`${outcome.wishId}-${outcome.year}`}
className="plan-outcome-card"
data-testid={`plan-outcome-${outcome.wishId}-${outcome.year}`}
>
<h3>
{outcome.wishName} {outcome.year}
{resolvedWishName} {outcome.year}
<span className="plan-outcome-card__dates">
{' '}
({outcome.startDate} → {outcome.endDate})
@@ -568,7 +583,8 @@ export function PlanResults({ proposal, members, isApplying = false, onApply }:
<p className="plan-outcome-card__ok">{t('planResults.fullyFunded')}</p>
)}
</article>
))}
)
})}
</section>
<section className="plan-results__section" data-testid="plan-balances">