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
+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