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