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
+30 -1
View File
@@ -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('')
})
})
+23
View File
@@ -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)
}