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
+1
View File
@@ -81,6 +81,7 @@ CREATE TABLE IF NOT EXISTS leave_wish (
workspace_id TEXT NOT NULL,
child_id TEXT,
name TEXT NOT NULL,
name_message_key TEXT,
window_start_month INTEGER NOT NULL DEFAULT 1
CHECK (window_start_month >= 1 AND window_start_month <= 12),
window_start_day INTEGER NOT NULL DEFAULT 1
+11 -1
View File
@@ -24,7 +24,7 @@ class LeaveWish:
id: UUID
workspace_id: UUID
child_id: UUID | None # None = family-wide; set = tied to that child's pools
name: str # "Sommar", "Jullov"
name: str # "Sommar", "Jullov" — persisted, possibly-translated display text
# Recurring anchor stored as month/day ints (SQLite-friendly, no custom type).
window_start_month: int # 1-12
@@ -55,6 +55,16 @@ class LeaveWish:
# completely unmodified.
window_mode: str = "fixed" # 'fixed' | 'flexible'
# Provenance of `name`: when a wish is created from a preset (Sommar,
# Jullov, Höstlov, ...) this holds the i18n message key that produced the
# translated string stored in `name` (e.g. "wishEditor.presetSommar").
# Nullable — free-typed names, and every row created before this field
# existed, have no key and just show `name` verbatim. Cleared by
# `LeaveWishService.update` the moment the user edits `name`, since a
# user-typed name is no longer a preset label and must never silently
# revert to one.
name_message_key: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
+12
View File
@@ -18,6 +18,11 @@ class LeaveWishCreate(BaseModel):
workspace_id: UUID
child_id: UUID | None = None
name: str = Field(min_length=1, max_length=255)
# i18n message key when `name` came from a preset (e.g.
# "wishEditor.presetSommar") — see app/entities/leave_wish.py. Stored
# alongside the translated `name` so the frontend can re-translate it;
# not validated against a key catalogue here (that's a frontend concern).
name_message_key: str | None = Field(default=None, max_length=255)
window_start_month: int = Field(ge=1, le=12)
window_start_day: int = Field(ge=1, le=31)
@@ -46,6 +51,11 @@ class LeaveWishUpdate(BaseModel):
child_id: UUID | None = None
name: str | None = Field(None, min_length=1, max_length=255)
# Present + null in the payload means "clear it"; absent means "leave
# unchanged" (see `model_fields_set` handling in the router). The service
# also clears this automatically whenever `name` actually changes, so a
# caller can never forget to and leave a stale key pointing at old text.
name_message_key: str | None = Field(default=None, max_length=255)
window_start_month: int | None = Field(None, ge=1, le=12)
window_start_day: int | None = Field(None, ge=1, le=31)
@@ -71,6 +81,7 @@ class LeaveWishResponse(BaseModel):
workspace_id: UUID
child_id: UUID | None
name: str
name_message_key: str | None
window_start_month: int
window_start_day: int
@@ -100,6 +111,7 @@ class LeaveWishResponse(BaseModel):
workspace_id=entity.workspace_id,
child_id=entity.child_id,
name=entity.name,
name_message_key=entity.name_message_key,
window_start_month=entity.window_start_month,
window_start_day=entity.window_start_day,
window_end_month=entity.window_end_month,
@@ -26,6 +26,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
workspace_id=UUID(row["workspace_id"]),
child_id=UUID(row["child_id"]) if row["child_id"] is not None else None,
name=row["name"],
name_message_key=row["name_message_key"],
window_start_month=row["window_start_month"],
window_start_day=row["window_start_day"],
window_end_month=row["window_end_month"],
@@ -54,18 +55,19 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
await self._conn.execute(
"""
INSERT INTO leave_wish
(id, workspace_id, child_id, name,
(id, workspace_id, child_id, name, name_message_key,
window_start_month, window_start_day, window_end_month, window_end_day,
target_weeks, recurrence, first_year, last_year,
priority, parent_mode, parent_personal_data_uuid, goal_override,
window_mode, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(item.id),
str(item.workspace_id),
str(item.child_id) if item.child_id is not None else None,
item.name,
item.name_message_key,
item.window_start_month,
item.window_start_day,
item.window_end_month,
@@ -109,7 +111,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
cursor = await self._conn.execute(
"""
UPDATE leave_wish
SET child_id = ?, name = ?,
SET child_id = ?, name = ?, name_message_key = ?,
window_start_month = ?, window_start_day = ?,
window_end_month = ?, window_end_day = ?,
target_weeks = ?, recurrence = ?, first_year = ?, last_year = ?,
@@ -120,6 +122,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
(
str(item.child_id) if item.child_id is not None else None,
item.name,
item.name_message_key,
item.window_start_month,
item.window_start_day,
item.window_end_month,
+5
View File
@@ -29,6 +29,7 @@ async def create_leave_wish(
workspace_id=payload.workspace_id,
child_id=payload.child_id,
name=payload.name,
name_message_key=payload.name_message_key,
window_start_month=payload.window_start_month,
window_start_day=payload.window_start_day,
window_end_month=payload.window_end_month,
@@ -70,6 +71,7 @@ async def update_leave_wish(
leave_wish_id=leave_wish_id,
child_id=payload.child_id,
name=payload.name,
name_message_key=payload.name_message_key,
window_start_month=payload.window_start_month,
window_start_day=payload.window_start_day,
window_end_month=payload.window_end_month,
@@ -90,6 +92,9 @@ async def update_leave_wish(
and payload.parent_personal_data_uuid is None
),
_clear_goal_override=("goal_override" in fields_set and payload.goal_override is None),
_clear_name_message_key=(
"name_message_key" in fields_set and payload.name_message_key is None
),
)
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
@@ -37,6 +37,7 @@ class LeaveWishService:
target_weeks: int,
first_year: int,
child_id: UUID | None = None,
name_message_key: str | None = None,
recurrence: str = "once",
last_year: int | None = None,
priority: int = 1,
@@ -65,6 +66,7 @@ class LeaveWishService:
workspace_id=workspace_id,
child_id=child_id,
name=name,
name_message_key=name_message_key,
window_start_month=window_start_month,
window_start_day=window_start_day,
window_end_month=window_end_month,
@@ -97,6 +99,7 @@ class LeaveWishService:
leave_wish_id: UUID,
child_id: UUID | None = None,
name: str | None = None,
name_message_key: str | None = None,
window_start_month: int | None = None,
window_start_day: int | None = None,
window_end_month: int | None = None,
@@ -114,21 +117,35 @@ class LeaveWishService:
_clear_last_year: bool = False,
_clear_parent_personal_data_uuid: bool = False,
_clear_goal_override: bool = False,
_clear_name_message_key: bool = False,
) -> LeaveWish:
"""Partial update — only non-None fields are applied.
`_clear_*` flags exist because a nullable field's "no change" and
"set to None" both look like `None` on the wire; the router passes
them based on whether the field was present in the request payload.
`name_message_key` is additionally enforced here rather than left to
callers: whenever `name` actually changes, the key is force-cleared
regardless of what was passed for it, since a caller (frontend bug,
future API client, ...) forgetting to clear it would silently make a
user's edited name revert to a stale preset label.
"""
entity = await self.get(leave_wish_id)
original_name = entity.name
if _clear_child_id:
entity.child_id = None
elif child_id is not None:
entity.child_id = child_id
if _clear_name_message_key:
entity.name_message_key = None
elif name_message_key is not None:
entity.name_message_key = name_message_key
if name is not None:
entity.name = name
if name != original_name:
entity.name_message_key = None
if window_start_month is not None:
entity.window_start_month = window_start_month
if window_start_day is not None:
@@ -0,0 +1,51 @@
"""Add leave_wish.name_message_key (nullable i18n key for preset names).
Bug: `WishEditor.tsx` created preset wishes (Sommar/Jullov/Höstlov/...) with
`name` set to the already-translated string (e.g. "Jul"), persisted as-is.
Switching the UI language afterwards could never fix a wish's name, because
the Swedish (or English) text was the data, not a rendering choice.
Fix: `name_message_key` stores the i18n key that produced `name` at creation
time (e.g. "wishEditor.seasonJul"), alongside the translated `name` so
anything reading `name` directly (backend, exports, ...) still gets a sane
value. The frontend re-translates via the key when present and falls back to
the literal `name` otherwise. Nullable and no default: every existing row
(and any row created by a free-typed custom name) has no key and must keep
showing its stored `name` unchanged — see `LeaveWishService.update`, which
also force-clears the key the moment a user edits `name`.
Revision ID: 007
Revises: 006
Create Date: 2026-09-14
"""
from __future__ import annotations
from alembic import op
revision: str = "007"
down_revision: str | None = "006"
branch_labels: str | None = None
depends_on: str | None = None
_UPGRADE_SQL = """
ALTER TABLE leave_wish ADD COLUMN name_message_key TEXT;
"""
_DOWNGRADE_SQL = """
ALTER TABLE leave_wish DROP COLUMN name_message_key;
"""
def upgrade() -> None:
from sqlalchemy import text
for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]:
op.execute(text(stmt))
def downgrade() -> None:
from sqlalchemy import text
for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]:
op.execute(text(stmt))
@@ -823,3 +823,81 @@ class TestWorkspaceParentsRenameAndRemove:
# The blocked placeholder and the caller are both still members.
assert placeholder_uuid in member_uuids
assert str(pd_uuid) in member_uuids
class TestLeaveWishNameMessageKeyRoundTrip:
"""`name_message_key` (added to fix the "Jul"/"Höstlov" always-Swedish
bug — see `backend/app/entities/leave_wish.py`) must actually appear on
the wire. The hand-written entity<->response mapping in this codebase
has silently dropped new fields twice before, so this asserts the
response *body* directly rather than only the service/repository layer.
All four scenarios share a single registered user/workspace (one
`_register` call) rather than one each: `/api/auth/register` is rate
limited to 10/minute (see `app/routers/auth.py`), and this file already
registers a user per test class — four more registrations pushed the
whole module over that limit when run together.
"""
async def test_name_message_key_round_trip(
self, client: AsyncClient, connection: aiosqlite.Connection
) -> None:
headers, ws_id, _ = await _register(client, "wish-key-roundtrip@example.com", "Alex")
# --- create returns the key it was given -----------------------
wish = await _create_wish(
client,
headers,
workspace_id=str(ws_id),
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=date.today().year,
)
assert wish["name_message_key"] == "wishEditor.seasonJul"
assert wish["name"] == "Jul"
# --- a wish created without a key round-trips null, not a crash -
# (pre-existing rows, or a free-typed custom name, have no key.)
keyless_wish = await _create_wish(
client,
headers,
workspace_id=str(ws_id),
name="Custom trip",
window_start_month=6,
window_start_day=1,
window_end_month=6,
window_end_day=7,
target_weeks=1,
recurrence="once",
first_year=date.today().year,
)
assert keyless_wish["name_message_key"] is None
# --- updating an unrelated field preserves the key --------------
resp = await client.put(
f"/api/leave-wishes/{wish['id']}", headers=headers, json={"target_weeks": 5}
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["target_weeks"] == 5
assert body["name"] == "Jul"
assert body["name_message_key"] == "wishEditor.seasonJul"
# --- renaming clears the key even if the caller never mentions --
# it: the backend enforces this, not just the frontend. Getting
# this backwards would mean a user's custom name silently reverts
# to the old preset label the next time the UI translates it.
resp = await client.put(
f"/api/leave-wishes/{wish['id']}", headers=headers, json={"name": "Vinterresa"}
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["name"] == "Vinterresa"
assert body["name_message_key"] is None
@@ -133,6 +133,57 @@ class TestSQLiteLeaveWishRepository:
result = await leave_wish_repo.get_by_id(uuid4())
assert result is None
async def test_create_and_get_round_trip_name_message_key(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
)
created = await leave_wish_repo.create(entity)
assert created.name_message_key == "wishEditor.seasonJul"
fetched = await leave_wish_repo.get_by_id(created.id)
assert fetched is not None
assert fetched.name_message_key == "wishEditor.seasonJul"
async def test_create_without_name_message_key_defaults_to_none(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Existing-row simulation: a wish created before this field existed
(or a free-typed custom name) has no key and must read back `None`,
not a synthesized or empty-string value.
"""
entity = _make(workspace_id=seeded_ids.workspace_id, name="Custom trip")
created = await leave_wish_repo.create(entity)
assert created.name_message_key is None
fetched = await leave_wish_repo.get_by_id(created.id)
assert fetched is not None
assert fetched.name_message_key is None
async def test_update_round_trips_name_message_key(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id, name="Jul", name_message_key=None)
created = await leave_wish_repo.create(entity)
created.name_message_key = "wishEditor.seasonJul"
updated = await leave_wish_repo.update(created.id, created)
assert updated.name_message_key == "wishEditor.seasonJul"
fetched = await leave_wish_repo.get_by_id(created.id)
assert fetched is not None
assert fetched.name_message_key == "wishEditor.seasonJul"
async def test_create_preserves_all_fields(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
@@ -902,6 +953,105 @@ class TestLeaveWishService:
assert result.parent_mode == "either"
assert result.parent_personal_data_uuid is None
# -- name_message_key (fixes the "Jul"/"Höstlov" always-Swedish bug) ---
async def test_create_passes_through_name_message_key(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
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,
first_year=2027,
)
assert result.name_message_key == "wishEditor.seasonJul"
async def test_update_without_changing_name_preserves_name_message_key(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(leave_wish_id=created.id, target_weeks=5)
assert result.name_message_key == "wishEditor.seasonJul"
assert result.name == "Jul"
async def test_update_that_resends_the_same_name_preserves_name_message_key(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""The auto-save flow resends the unchanged `name` on every edit —
only an *actual* change to the text may clear the key.
"""
entity = _make(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(leave_wish_id=created.id, name="Jul")
assert result.name_message_key == "wishEditor.seasonJul"
async def test_update_renaming_clears_name_message_key_even_without_being_told_to(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Backend enforcement, not caller-trust: renaming clears the key
even if the caller never mentions `name_message_key` at all — this
is the critical guard against a user's custom name silently
reverting to a stale preset label.
"""
entity = _make(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(leave_wish_id=created.id, name="Vinterresa")
assert result.name == "Vinterresa"
assert result.name_message_key is None
async def test_update_can_explicitly_clear_name_message_key(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(
leave_wish_id=created.id, _clear_name_message_key=True
)
assert result.name_message_key is None
assert result.name == "Jul" # unrelated to the clear
async def test_update_missing_raises(
self,
leave_wish_service: LeaveWishService,
@@ -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
}