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">
|
||||
|
||||
@@ -1116,6 +1116,7 @@ describe('endpoint functions (representative coverage)', () => {
|
||||
{
|
||||
wish_id: 'lw1',
|
||||
wish_name: 'Sommar',
|
||||
wish_name_message_key: 'wishEditor.presetSommar',
|
||||
year: 2026,
|
||||
start_date: '2026-06-15',
|
||||
end_date: '2026-07-26',
|
||||
@@ -1124,7 +1125,14 @@ describe('endpoint functions (representative coverage)', () => {
|
||||
shortfall_days: 0,
|
||||
strategy: 'parent_swap',
|
||||
message_key: 'planOutcomes.windowExplanation',
|
||||
data: { wish_name: 'Sommar', year: 2026, weeks_wished: 6, weeks_funded: 6, shortfall_days: 0 },
|
||||
data: {
|
||||
wish_name: 'Sommar',
|
||||
wish_name_message_key: 'wishEditor.presetSommar',
|
||||
year: 2026,
|
||||
weeks_wished: 6,
|
||||
weeks_funded: 6,
|
||||
shortfall_days: 0,
|
||||
},
|
||||
cost_breakdown: { free: 12, vacation: 19, sjukpenning: 11, lagsta: 0 },
|
||||
remedies: [],
|
||||
},
|
||||
@@ -1181,6 +1189,7 @@ describe('endpoint functions (representative coverage)', () => {
|
||||
expect(lastRequest()).toEqual({ url: '/api/plan/propose', method: 'POST' })
|
||||
expect(lastRequestBody()).toEqual({ workspace_id: 'ws1' })
|
||||
expect(result.outcomes[0]!.wishName).toBe('Sommar')
|
||||
expect(result.outcomes[0]!.wishNameMessageKey).toBe('wishEditor.presetSommar')
|
||||
expect(result.outcomes[0]!.costBreakdown).toEqual({
|
||||
free: 12,
|
||||
vacation: 19,
|
||||
@@ -1190,6 +1199,7 @@ describe('endpoint functions (representative coverage)', () => {
|
||||
expect(result.outcomes[0]!.messageKey).toBe('planOutcomes.windowExplanation')
|
||||
expect(result.outcomes[0]!.data).toEqual({
|
||||
wish_name: 'Sommar',
|
||||
wish_name_message_key: 'wishEditor.presetSommar',
|
||||
year: 2026,
|
||||
weeks_wished: 6,
|
||||
weeks_funded: 6,
|
||||
|
||||
@@ -269,6 +269,7 @@ interface RawShortfallRemedy {
|
||||
interface RawWindowOutcome {
|
||||
wish_id: string
|
||||
wish_name: string
|
||||
wish_name_message_key: string | null
|
||||
year: number
|
||||
start_date: string
|
||||
end_date: string
|
||||
@@ -354,6 +355,7 @@ function toPlanProposal(raw: RawPlanProposal): PlanProposal {
|
||||
outcomes: raw.outcomes.map((o) => ({
|
||||
wishId: o.wish_id,
|
||||
wishName: o.wish_name,
|
||||
wishNameMessageKey: o.wish_name_message_key,
|
||||
year: o.year,
|
||||
startDate: o.start_date,
|
||||
endDate: o.end_date,
|
||||
|
||||
@@ -204,6 +204,11 @@ export interface CostBreakdown {
|
||||
export interface WindowOutcome {
|
||||
wishId: string
|
||||
wishName: string
|
||||
// i18n provenance of `wishName` — see `LeaveWish.nameMessageKey` and
|
||||
// `resolveWishName` (src/utils/wishName.ts). `null` for a free-typed/
|
||||
// user-renamed wish, or any row predating this field. Resolve display
|
||||
// text through `resolveWishName`, never render `wishName` directly.
|
||||
wishNameMessageKey: string | null
|
||||
year: number
|
||||
startDate: string // ISO-8601 date
|
||||
endDate: string // ISO-8601 date
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveWishName } from './wishName'
|
||||
import { resolveWishName, resolveWishNameFromData } from './wishName'
|
||||
import { translate } from '@/i18n'
|
||||
|
||||
const t = (key: Parameters<typeof translate>[1]) => translate('sv', key)
|
||||
@@ -30,3 +30,32 @@ describe('resolveWishName', () => {
|
||||
expect(resolveWishName(wish, t)).toBe('Custom trip')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWishNameFromData', () => {
|
||||
it('translates through wish_name_message_key when present, ignoring wish_name', () => {
|
||||
const data = { wish_name: 'STALE_ENGLISH_NAME', wish_name_message_key: 'wishEditor.seasonJul' }
|
||||
expect(resolveWishNameFromData(data, t)).toBe('Jul')
|
||||
})
|
||||
|
||||
it('returns wish_name verbatim when wish_name_message_key is null', () => {
|
||||
const data = { wish_name: 'Resa till mormor', wish_name_message_key: null }
|
||||
expect(resolveWishNameFromData(data, t)).toBe('Resa till mormor')
|
||||
})
|
||||
|
||||
it('returns wish_name verbatim when wish_name_message_key is absent', () => {
|
||||
// A `data` bag from before this field existed, or one that never
|
||||
// carried a wish name key at all.
|
||||
const data = { wish_name: 'Sommar' }
|
||||
expect(resolveWishNameFromData(data, t)).toBe('Sommar')
|
||||
})
|
||||
|
||||
it('falls back to wish_name for a key this build does not recognise', () => {
|
||||
const data = { wish_name: 'Sommar', wish_name_message_key: 'wishEditor.thisKeyDoesNotExist' }
|
||||
expect(resolveWishNameFromData(data, t)).toBe('Sommar')
|
||||
})
|
||||
|
||||
it('resolves to an empty string when wish_name is missing or not a string', () => {
|
||||
expect(resolveWishNameFromData({}, t)).toBe('')
|
||||
expect(resolveWishNameFromData({ wish_name: 42 }, t)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,3 +32,26 @@ export function resolveWishName(wish: NamedWish, t: (key: TranslationKey) => str
|
||||
}
|
||||
return wish.name
|
||||
}
|
||||
|
||||
/**
|
||||
* `resolveWishName`, adapted for a backend `data`/params bag that embeds a
|
||||
* wish name under the wire's snake_case keys (`wish_name` /
|
||||
* `wish_name_message_key`) rather than as a typed `NamedWish` — the shape
|
||||
* `PlanProposalResponse`'s outcome/remedy `data` dicts use (see
|
||||
* `WindowOutcome`/`ShortfallRemedy` in `backend/app/services/
|
||||
* allocation_service.py`) so `PlanResults.tsx` can interpolate `{wish_name}`
|
||||
* in a translated template with the same locale-aware rule everywhere else
|
||||
* a wish name is shown, instead of a second, divergent resolution.
|
||||
*
|
||||
* `data` arrives off the wire as `Record<string, unknown>` — both keys are
|
||||
* read defensively (a missing/non-string `wish_name` resolves to `''`,
|
||||
* matching there being nothing sensible to show).
|
||||
*/
|
||||
export function resolveWishNameFromData(
|
||||
data: Record<string, unknown>,
|
||||
t: (key: TranslationKey) => string,
|
||||
): string {
|
||||
const name = typeof data.wish_name === 'string' ? data.wish_name : ''
|
||||
const nameMessageKey = typeof data.wish_name_message_key === 'string' ? data.wish_name_message_key : null
|
||||
return resolveWishName({ name, nameMessageKey }, t)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user