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: