Show preset wish names in the current UI language

Preset wishes were created with `name: t(preset.labelKey)`, so the
translated string was persisted as the wish's name. A wish created while
the UI was Swedish stayed "Jul" in English forever -- the Swedish text
was the data, so no display-layer change could fix it.

Wish names are user-editable free text, so `name` cannot simply be
translated at render time. Migration 007 adds a nullable
`leave_wish.name_message_key` recording where a name came from: preset
wishes store the i18n key alongside the translated literal, and the UI
renders the key when present.

The key is cleared server-side the moment `name` actually changes,
compared against the stored value rather than trusting the caller, so no
client can forget to clear it and silently revert a user's own name to a
preset label.

An unknown key (a stale row, a renamed key) falls back to the stored
literal via an isTranslationKey guard, so a bad key can never crash the
page or leak a raw key to the user. Rows predating this column keep
displaying their stored name.

_FULL_SCHEMA in db/connection.py is updated in step with the migration.

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:32:54 +02:00
parent b03462066e
commit dc4c0a5edd
20 changed files with 668 additions and 17 deletions
@@ -8,6 +8,7 @@ const mockWish: LeaveWish = {
workspaceId: 'ws1',
childId: null,
name: 'Sommar',
nameMessageKey: null,
windowStartMonth: 6,
windowStartDay: 15,
windowEndMonth: 7,
@@ -103,6 +104,11 @@ describe('WishEditor', () => {
expect.objectContaining({
workspaceId: 'ws1',
name: 'Sommar',
// The fix for the "Jul"/"Höstlov" always-Swedish bug: a preset wish
// carries the i18n key that produced its name, so switching locale
// later can re-translate it instead of the persisted text being
// stuck in whatever language the UI happened to be in.
nameMessageKey: 'wishEditor.presetSommar',
windowStartMonth: 6,
windowStartDay: 15,
windowEndMonth: 7,
@@ -122,6 +128,7 @@ describe('WishEditor', () => {
expect(onAdd).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Jullov',
nameMessageKey: 'wishEditor.presetJullov',
windowStartMonth: 12,
windowStartDay: 20,
windowEndMonth: 1,
@@ -136,7 +143,12 @@ describe('WishEditor', () => {
fireEvent.click(screen.getByTestId('wish-preset-custom'))
expect(onAdd).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'ws1' }))
expect(onAdd).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: 'ws1',
nameMessageKey: 'wishEditor.customLeaveName',
}),
)
})
it('renders an existing wish as a card', () => {
@@ -280,6 +292,7 @@ describe('WishEditor', () => {
expect(onAdd).toHaveBeenCalledWith(
expect.objectContaining({
windowMode: 'flexible',
nameMessageKey: 'wishEditor.seasonVar',
windowStartMonth: 3,
windowStartDay: 1,
windowEndMonth: 5,
@@ -342,6 +355,7 @@ describe('WishEditor', () => {
expect.objectContaining({
workspaceId: 'ws1',
windowMode: 'flexible',
nameMessageKey: 'wishEditor.customFlexName',
}),
)
})
@@ -572,4 +586,77 @@ describe('WishEditor', () => {
expect(onUpdate).not.toHaveBeenCalled()
})
})
describe('nameMessageKey (preset name follows the UI language)', () => {
it('shows the translated name when nameMessageKey is set, even if the stored name is stale', () => {
// Simulates the bug scenario: `name` was persisted in whatever
// language the wish was created in ("STALE_ENGLISH_NAME" here stands
// in for e.g. "Christmas" saved while the UI was English), but the
// test environment is pinned to Swedish (tests/setup.ts) — the input
// must show the current-locale translation of the key, not the
// stored, possibly-stale `name`.
const presetWish: LeaveWish = {
...mockWish,
name: 'STALE_ENGLISH_NAME',
nameMessageKey: 'wishEditor.seasonJul',
}
setup({ wishes: [presetWish] })
expect(screen.getByTestId('wish-name-lw1')).toHaveValue('Jul')
})
it('shows the literal name verbatim when nameMessageKey is null (free-typed or pre-existing wish)', () => {
const customWish: LeaveWish = { ...mockWish, name: 'Resa till mormor', nameMessageKey: null }
setup({ wishes: [customWish] })
expect(screen.getByTestId('wish-name-lw1')).toHaveValue('Resa till mormor')
})
it('falls back to the literal name for an nameMessageKey this build does not recognise', () => {
// A stale/future key (older or newer server, a renamed catalogue
// entry, ...) must never crash the row or render a raw key.
const staleKeyWish: LeaveWish = {
...mockWish,
name: 'Sommar',
nameMessageKey: 'wishEditor.noLongerExists',
}
setup({ wishes: [staleKeyWish] })
expect(screen.getByTestId('wish-name-lw1')).toHaveValue('Sommar')
})
it('clears nameMessageKey the moment the user edits the name, and auto-saves the literal text', () => {
const presetWish: LeaveWish = {
...mockWish,
name: 'Jul',
nameMessageKey: 'wishEditor.seasonJul',
}
const { onUpdate } = setup({ wishes: [presetWish] })
fireEvent.change(screen.getByTestId('wish-name-lw1'), { target: { value: 'Vinterresa' } })
advanceAutosave()
expect(onUpdate).toHaveBeenCalledWith(
'lw1',
expect.objectContaining({ name: 'Vinterresa', nameMessageKey: null }),
)
})
it('does not re-save a preset wish on mount just because its resolved name differs from the stored name', () => {
// Guards against a regression where seeding the draft with the
// *resolved* display name (instead of the raw `name`) would make the
// dirty-check think an untouched row had been edited, firing a bogus
// rename save on every load.
const presetWish: LeaveWish = {
...mockWish,
name: 'Jul',
nameMessageKey: 'wishEditor.seasonJul',
}
const { onUpdate } = setup({ wishes: [presetWish] })
advanceAutosave(5000)
expect(onUpdate).not.toHaveBeenCalled()
})
})
})
+42 -12
View File
@@ -35,6 +35,7 @@ import type {
PersonalData,
} from '@/types'
import { useTranslation, type TranslationKey } from '@/i18n'
import { resolveWishName } from '@/utils/wishName'
export interface WishEditorProps {
workspaceId: string
@@ -133,6 +134,13 @@ const MONTH_KEYS: TranslationKey[] = [
interface Draft {
name: string
// Mirrors `LeaveWish.nameMessageKey`: the i18n key `name` was translated
// from when created via preset, or `null` for a free-typed/edited name.
// Cleared the moment the name input is edited (see the `onChange` below) —
// the backend also enforces this independently (see
// `LeaveWishService.update`), but clearing it here too keeps the draft's
// dirty-check (`draftEqualsWish`) and the save it triggers consistent.
nameMessageKey: string | null
targetWeeks: number
priority: number
parentMode: 'either' | 'both' | 'specific'
@@ -147,9 +155,15 @@ interface Draft {
windowMode: WindowMode
}
function draftFromWish(wish: LeaveWish): Draft {
function draftFromWish(wish: LeaveWish, t: (key: TranslationKey) => string): Draft {
return {
name: wish.name,
// Resolved through the wish's i18n provenance (§ nameMessageKey above),
// not `wish.name` directly — this is the actual fix for the bug where a
// preset wish's stored, already-translated name never changed with the
// UI language. A free-typed/edited name (no key) is unaffected: it's
// literal text either way.
name: resolveWishName(wish, t),
nameMessageKey: wish.nameMessageKey,
targetWeeks: wish.targetWeeks,
priority: wish.priority,
parentMode: wish.parentMode,
@@ -186,9 +200,14 @@ function isBoundTooShort(draft: Draft): boolean {
* would send — the dirty check that stops a reseeded-but-untouched draft
* (mount, or a parent refetch after another row's save) from ever
* scheduling a save. */
function draftEqualsWish(draft: Draft, wish: LeaveWish): boolean {
function draftEqualsWish(
draft: Draft,
wish: LeaveWish,
t: (key: TranslationKey) => string,
): boolean {
return (
draft.name === wish.name &&
draft.name === resolveWishName(wish, t) &&
draft.nameMessageKey === wish.nameMessageKey &&
draft.targetWeeks === wish.targetWeeks &&
draft.priority === wish.priority &&
draft.parentMode === wish.parentMode &&
@@ -209,6 +228,7 @@ function draftEqualsWish(draft: Draft, wish: LeaveWish): boolean {
function buildPatch(draft: Draft): LeaveWishUpdateRequest {
return {
name: draft.name,
nameMessageKey: draft.nameMessageKey,
targetWeeks: draft.targetWeeks,
priority: draft.priority,
parentMode: draft.parentMode,
@@ -263,11 +283,11 @@ export function WishEditor({
setDrafts((prev) => {
const next: Record<string, Draft> = {}
for (const wish of wishes) {
next[wish.id] = prev[wish.id] ?? draftFromWish(wish)
next[wish.id] = prev[wish.id] ?? draftFromWish(wish, t)
}
return next
})
}, [wishes])
}, [wishes, t])
// Resolves in-flight auto-saves: a row whose sent draft now matches the
// freshly-arrived `wishes` succeeded; a row still unresolved when the
@@ -284,7 +304,8 @@ export function WishEditor({
for (const id of Object.keys(sentDraftRef.current)) {
const sentDraft = sentDraftRef.current[id]
const wish = wishes.find((w) => w.id === id)
const succeeded = wish !== undefined && sentDraft !== undefined && draftEqualsWish(sentDraft, wish)
const succeeded =
wish !== undefined && sentDraft !== undefined && draftEqualsWish(sentDraft, wish, t)
if (succeeded) {
delete sentDraftRef.current[id]
@@ -305,7 +326,7 @@ export function WishEditor({
setRowStatus((prev) => ({ ...prev, [id]: 'error' }))
}
}
}, [wishes, isSaving])
}, [wishes, isSaving, t])
// Clean up every outstanding timer on unmount so a debounced save (or a
// "Sparat" auto-clear) never fires against an unmounted component. Both
@@ -327,6 +348,7 @@ export function WishEditor({
onAdd({
workspaceId,
name: t(preset.labelKey),
nameMessageKey: preset.labelKey,
windowStartMonth: preset.startMonth,
windowStartDay: preset.startDay,
windowEndMonth: preset.endMonth,
@@ -347,6 +369,7 @@ export function WishEditor({
onAdd({
workspaceId,
name: t(preset.labelKey),
nameMessageKey: preset.labelKey,
windowStartMonth: preset.startMonth,
windowStartDay: preset.startDay,
windowEndMonth: preset.endMonth,
@@ -364,6 +387,7 @@ export function WishEditor({
onAdd({
workspaceId,
name: t('wishEditor.customLeaveName'),
nameMessageKey: 'wishEditor.customLeaveName',
windowStartMonth: 6,
windowStartDay: 1,
windowEndMonth: 6,
@@ -384,6 +408,7 @@ export function WishEditor({
onAdd({
workspaceId,
name: t('wishEditor.customFlexName'),
nameMessageKey: 'wishEditor.customFlexName',
windowStartMonth: 6,
windowStartDay: 1,
windowEndMonth: 6,
@@ -404,7 +429,7 @@ export function WishEditor({
const wish = wishes.find((w) => w.id === id)
const draft = draftsRef.current[id]
if (!wish || !draft) return
if (isBoundTooShort(draft) || draftEqualsWish(draft, wish)) return
if (isBoundTooShort(draft) || draftEqualsWish(draft, wish, t)) return
sentDraftRef.current[id] = draft
setRowStatus((prev) => ({ ...prev, [id]: 'saving' }))
@@ -508,7 +533,7 @@ export function WishEditor({
) : (
<ul className="wish-editor__list">
{wishes.map((wish) => {
const draft = drafts[wish.id] ?? draftFromWish(wish)
const draft = drafts[wish.id] ?? draftFromWish(wish, t)
const isExpanded = expanded[wish.id] ?? false
const parentSelectValue =
draft.parentMode === 'specific' ? draft.parentPersonalDataUuid ?? '' : draft.parentMode
@@ -527,10 +552,15 @@ export function WishEditor({
<input
type="text"
value={draft.name}
onChange={(e) => updateDraft(wish.id, { name: e.target.value })}
onChange={(e) =>
// Editing the name detaches it from any preset: it is
// now the user's own text and must never be silently
// re-translated back to a stale preset label.
updateDraft(wish.id, { name: e.target.value, nameMessageKey: null })
}
className="wish-editor__name-input"
data-testid={`wish-name-${wish.id}`}
aria-label={t('wishEditor.nameAria', { name: wish.name })}
aria-label={t('wishEditor.nameAria', { name: resolveWishName(wish, t) })}
/>
<label className="wish-editor__field">
@@ -7,6 +7,7 @@ import {
readStoredLocale,
resolveLocale,
translate,
isTranslationKey,
} from './LanguageContext'
const STORAGE_KEY = 'language_preference'
@@ -119,6 +120,22 @@ describe('LanguageContext', () => {
})
})
describe('isTranslationKey', () => {
// Used to validate untyped strings from the backend (e.g.
// `LeaveWish.nameMessageKey`, see src/utils/wishName.ts) before they are
// ever passed to t()/translate() — an unknown or stale key must never
// crash a render.
it('accepts a real catalogue key', () => {
expect(isTranslationKey('common.save')).toBe(true)
expect(isTranslationKey('wishEditor.seasonJul')).toBe(true)
})
it('rejects a string that is not in the catalogue', () => {
expect(isTranslationKey('not.a.real.key')).toBe(false)
expect(isTranslationKey('')).toBe(false)
})
})
describe('readStoredLocale', () => {
it('returns null when nothing is stored', () => {
expect(readStoredLocale()).toBeNull()
+16
View File
@@ -115,6 +115,22 @@ export function translate(locale: Locale, key: TranslationKey, params?: Translat
return interpolate(CATALOGUES[locale][key], params)
}
// Every locale is typed against `TranslationKey` (`en/` mirrors `sv/` field
// for field), so `sv`'s own keys are the complete, authoritative keyset —
// checking against it alone is enough to validate an arbitrary string.
const KNOWN_TRANSLATION_KEYS = new Set<string>(Object.keys(sv))
/** Type guard for validating an untyped string (e.g. a value stored on a
* backend record, like `LeaveWish.nameMessageKey`) before it is ever passed
* to `t()`/`translate()`. A stale key from an old build, or one this build
* simply doesn't recognise, must never crash a render or surface a raw key
* to the user — callers should fall back to a literal display value instead
* of asserting the type away.
*/
export function isTranslationKey(key: string): key is TranslationKey {
return KNOWN_TRANSLATION_KEYS.has(key)
}
export interface LanguageContextValue {
/** The active, resolved locale — always concrete, never "auto". */
locale: Locale
+1
View File
@@ -6,6 +6,7 @@ export {
resolveLocale,
translate,
toTranslationParams,
isTranslationKey,
} from './LanguageContext'
export type { Locale, TranslationParams, LanguageContextValue } from './LanguageContext'
export type { TranslationKey } from './sv'
+93
View File
@@ -176,6 +176,7 @@ describe('API response mappers (snake_case → camelCase)', () => {
workspace_id: 'ws1',
child_id: 'child1',
name: 'Sommar',
name_message_key: 'wishEditor.presetSommar',
window_start_month: 6,
window_start_day: 15,
window_end_month: 7,
@@ -205,6 +206,7 @@ describe('API response mappers (snake_case → camelCase)', () => {
expect(result[0]!.workspaceId).toBe('ws1')
expect(result[0]!.childId).toBe('child1')
expect(result[0]!.name).toBe('Sommar')
expect(result[0]!.nameMessageKey).toBe('wishEditor.presetSommar')
expect(result[0]!.windowStartMonth).toBe(6)
expect(result[0]!.windowEndDay).toBe(26)
expect(result[0]!.targetWeeks).toBe(6)
@@ -301,6 +303,57 @@ describe('API response mappers (snake_case → camelCase)', () => {
expect(result.windowStartMonth).toBe(6)
expect(result.targetWeeks).toBe(6)
})
it('sends name_message_key for a preset-created wish and maps it back', async () => {
const rawResponse = {
id: 'lw1',
workspace_id: 'ws1',
child_id: null,
name: 'Jul',
name_message_key: 'wishEditor.seasonJul',
window_start_month: 12,
window_start_day: 1,
window_end_month: 1,
window_end_day: 31,
target_weeks: 4,
recurrence: 'yearly',
first_year: 2026,
last_year: null,
priority: 1,
parent_mode: 'either',
parent_personal_data_uuid: null,
goal_override: null,
created_at: '2026-06-14T17:09:47',
updated_at: '2026-06-14T17:09:47',
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => rawResponse,
})
const result = await api.createLeaveWish({
workspaceId: 'ws1',
name: 'Jul',
nameMessageKey: 'wishEditor.seasonJul',
windowStartMonth: 12,
windowStartDay: 1,
windowEndMonth: 1,
windowEndDay: 31,
targetWeeks: 4,
recurrence: 'yearly',
firstYear: 2026,
})
const calls = (fetchMock.mock?.calls ?? []) as any[][]
if (calls.length === 0) throw new Error('No fetch calls made')
const [, options] = calls[0]!
const body = JSON.parse(options.body)
expect(body.name_message_key).toBe('wishEditor.seasonJul')
expect(result.nameMessageKey).toBe('wishEditor.seasonJul')
})
})
describe('getWorkspace', () => {
@@ -829,6 +882,46 @@ describe('endpoint functions (representative coverage)', () => {
expect(result.targetWeeks).toBe(8)
})
it('updateLeaveWish sends name_message_key: null to explicitly clear it on rename', async () => {
// Mirrors what WishEditor sends the moment a user edits a preset wish's
// name — the request must carry an explicit `null`, not omit the field
// (omitting it would mean "leave unchanged" server-side).
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'lw1',
workspace_id: 'ws1',
child_id: null,
name: 'Vinterresa',
name_message_key: null,
window_start_month: 6,
window_start_day: 15,
window_end_month: 7,
window_end_day: 26,
target_weeks: 6,
recurrence: 'yearly',
first_year: 2026,
last_year: null,
priority: 1,
parent_mode: 'either',
parent_personal_data_uuid: null,
goal_override: null,
created_at: '2026-01-01',
updated_at: '2026-01-01',
}),
})
const result = await api.updateLeaveWish('lw1', {
name: 'Vinterresa',
nameMessageKey: null,
})
expect(lastRequestBody()).toEqual({ name: 'Vinterresa', name_message_key: null })
expect(result.name).toBe('Vinterresa')
expect(result.nameMessageKey).toBeNull()
})
it('updateLeaveWish sends every provided field, snake_cased', async () => {
fetchMock.mockResolvedValue({
ok: true,
+4
View File
@@ -206,6 +206,7 @@ interface RawLeaveWish {
workspace_id: string
child_id: string | null
name: string
name_message_key: string | null
window_start_month: number
window_start_day: number
window_end_month: number
@@ -229,6 +230,7 @@ function toLeaveWish(raw: RawLeaveWish): LeaveWish {
workspaceId: raw.workspace_id,
childId: raw.child_id,
name: raw.name,
nameMessageKey: raw.name_message_key,
windowStartMonth: raw.window_start_month,
windowStartDay: raw.window_start_day,
windowEndMonth: raw.window_end_month,
@@ -757,6 +759,7 @@ export async function createLeaveWish(payload: LeaveWishCreateRequest): Promise<
workspace_id: payload.workspaceId,
child_id: payload.childId ?? null,
name: payload.name,
name_message_key: payload.nameMessageKey ?? null,
window_start_month: payload.windowStartMonth,
window_start_day: payload.windowStartDay,
window_end_month: payload.windowEndMonth,
@@ -782,6 +785,7 @@ export async function updateLeaveWish(
const body: Record<string, unknown> = {}
if (payload.childId !== undefined) body.child_id = payload.childId
if (payload.name !== undefined) body.name = payload.name
if (payload.nameMessageKey !== undefined) body.name_message_key = payload.nameMessageKey
if (payload.windowStartMonth !== undefined) body.window_start_month = payload.windowStartMonth
if (payload.windowStartDay !== undefined) body.window_start_day = payload.windowStartDay
if (payload.windowEndMonth !== undefined) body.window_end_month = payload.windowEndMonth
+4
View File
@@ -71,6 +71,10 @@ export interface LeaveWishCreateRequest {
workspaceId: string
childId?: string | null
name: string
// The i18n key `name` was translated from, when created from a preset —
// see `LeaveWish.nameMessageKey`. `undefined`/omitted means "no key"
// (a free-typed name); passing `null` on an update explicitly clears it.
nameMessageKey?: string | null
windowStartMonth: number
windowStartDay: number
windowEndMonth: number
+6
View File
@@ -33,6 +33,12 @@ export interface LeaveWish {
workspaceId: string
childId: string | null
name: string
// i18n provenance of `name` (see backend/app/entities/leave_wish.py):
// the message key a preset (Sommar/Jullov/...) was created from, or
// `null` for a free-typed/edited name. An untyped string as it arrives —
// resolve display text through `resolveWishName` (src/utils/wishName.ts),
// never `t()` directly, since a stale/unknown key must not crash.
nameMessageKey: string | null
windowStartMonth: number
windowStartDay: number
windowEndMonth: number
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { resolveWishName } from './wishName'
import { translate } from '@/i18n'
const t = (key: Parameters<typeof translate>[1]) => translate('sv', key)
describe('resolveWishName', () => {
it('translates through nameMessageKey when present, ignoring the stored name', () => {
// The core bug fix: the persisted `name` can be stale (saved while the
// UI was a different language) — the key, when present, is the source
// of truth for display.
const wish = { name: 'STALE_ENGLISH_NAME', nameMessageKey: 'wishEditor.seasonJul' }
expect(resolveWishName(wish, t)).toBe('Jul')
})
it('returns the literal name verbatim when nameMessageKey is null', () => {
const wish = { name: 'Resa till mormor', nameMessageKey: null }
expect(resolveWishName(wish, t)).toBe('Resa till mormor')
})
it('falls back to the literal name for a key this build does not recognise', () => {
// A stale key from an older/newer server build must never crash or
// render a raw, untranslated key string to the user.
const wish = { name: 'Sommar', nameMessageKey: 'wishEditor.thisKeyDoesNotExist' }
expect(resolveWishName(wish, t)).toBe('Sommar')
})
it('falls back to the literal name for an empty-string key', () => {
const wish = { name: 'Custom trip', nameMessageKey: '' }
expect(resolveWishName(wish, t)).toBe('Custom trip')
})
})
+34
View File
@@ -0,0 +1,34 @@
import { isTranslationKey, type TranslationKey } from '@/i18n'
/** The subset of `LeaveWish` `resolveWishName` needs — kept minimal so any
* call site (the full entity, a plan-outcome projection, a test fixture,
* ...) can pass itself in without an adapter.
*/
export interface NamedWish {
name: string
nameMessageKey: string | null
}
/**
* Resolves a leave wish's display name, following its i18n provenance when
* present.
*
* Fixes the bug where preset wishes (Sommar/Jullov/Höstlov/...) were created
* with `name` set to the already-translated string, so the persisted text
* stayed in whatever language the UI was in at creation time forever —
* switching locale later could never change it. `nameMessageKey` (see
* `backend/app/entities/leave_wish.py`) records which preset produced the
* name, if any, so it can be re-translated on every render.
*
* `nameMessageKey` arrives off the wire as a plain string, not a
* `TranslationKey` — it may be `null` (a free-typed or user-edited name, or
* any row created before this field existed), or a key this build doesn't
* recognise (an older/newer server, a stale row). Both fall back to the
* literal `name` rather than crashing or ever rendering a raw key.
*/
export function resolveWishName(wish: NamedWish, t: (key: TranslationKey) => string): string {
if (wish.nameMessageKey !== null && isTranslationKey(wish.nameMessageKey)) {
return t(wish.nameMessageKey)
}
return wish.name
}