diff --git a/backend/app/models/plan.py b/backend/app/models/plan.py index 470076d..ff5e715 100644 --- a/backend/app/models/plan.py +++ b/backend/app/models/plan.py @@ -64,6 +64,10 @@ class SurplusAdviceResponse(BaseModel): class WindowOutcomeResponse(BaseModel): wish_id: UUID wish_name: str + # i18n provenance of `wish_name` — see `WindowOutcome.wish_name_message_key` + # (app/services/allocation_service.py). `None` for a free-typed/user-renamed + # wish, or any row predating this field. + wish_name_message_key: str | None year: int start_date: date end_date: date @@ -149,6 +153,7 @@ class PlanProposalResponse(BaseModel): WindowOutcomeResponse( wish_id=o.wish_id, wish_name=o.wish_name, + wish_name_message_key=o.wish_name_message_key, year=o.year, start_date=o.start_date, end_date=o.end_date, diff --git a/backend/app/services/allocation_service.py b/backend/app/services/allocation_service.py index 589fda9..bdebbba 100644 --- a/backend/app/services/allocation_service.py +++ b/backend/app/services/allocation_service.py @@ -262,6 +262,12 @@ class Age4Projection: class WindowOutcome: wish_id: UUID wish_name: str + # i18n provenance of `wish_name` (see `app.entities.leave_wish.LeaveWish + # .name_message_key`) — carried alongside the literal so the frontend can + # re-translate a preset-created wish's name into the UI's current locale, + # exactly like `resolveWishName` already does for the wish editor. `None` + # for a free-typed/user-renamed wish, or any row predating this field. + wish_name_message_key: str | None year: int start_date: date end_date: date @@ -1041,6 +1047,7 @@ class _Ledger: outcome = WindowOutcome( wish_id=wish.id, wish_name=wish.name, + wish_name_message_key=wish.name_message_key, year=w.anchor_year, start_date=w.start, end_date=w.end, @@ -1051,6 +1058,7 @@ class _Ledger: message_key=MESSAGE_KEY_WINDOW_EXPLANATION, data={ "wish_name": wish.name, + "wish_name_message_key": wish.name_message_key, "year": w.anchor_year, "weeks_wished": wish.target_weeks, "weeks_funded": weeks_funded, @@ -1143,6 +1151,7 @@ class _Ledger: message_key=f"planRemedies.{REMEDY_LOWER_PRIORITY_WISH}", data={ "wish_name": other.name, + "wish_name_message_key": other.name_message_key, "from_weeks": other.target_weeks, "to_weeks": half_weeks, "freed_days": freed_estimate, @@ -1171,7 +1180,11 @@ class _Ledger: ShortfallRemedy( kind=REMEDY_SHIFT_WINDOW_INTO_FREE_DAYS, message_key=f"planRemedies.{REMEDY_SHIFT_WINDOW_INTO_FREE_DAYS}", - data={"wish_name": wish.name, "shift_gain": shift_gain}, + data={ + "wish_name": wish.name, + "wish_name_message_key": wish.name_message_key, + "shift_gain": shift_gain, + }, days_freed=shift_gain, ) ) @@ -1384,6 +1397,7 @@ class _Ledger: return WindowOutcome( wish_id=wish.id, wish_name=wish.name, + wish_name_message_key=wish.name_message_key, year=anchor_year, start_date=bound_start, end_date=bound_end, @@ -1394,6 +1408,7 @@ class _Ledger: message_key=MESSAGE_KEY_WINDOW_EXPLANATION, data={ "wish_name": wish.name, + "wish_name_message_key": wish.name_message_key, "year": anchor_year, "weeks_wished": wish.target_weeks, "weeks_funded": 0.0, diff --git a/backend/tests/unit/test_plan_router.py b/backend/tests/unit/test_plan_router.py index 808bf5d..a470af2 100644 --- a/backend/tests/unit/test_plan_router.py +++ b/backend/tests/unit/test_plan_router.py @@ -336,6 +336,171 @@ class TestProposeMessageKeysOverTheWire: assert remedy["data"]["spare_total"] == remedy["days_freed"] == 5 +class TestProposeWishNameMessageKeyOverTheWire: + """A preset-created wish (`LeaveWish.name_message_key` set, see + + `app/entities/leave_wish.py` and `frontend/src/utils/wishName.ts`) must + carry that key through `POST /api/plan/propose`, not just `POST + /api/leave-wishes` -- `WindowOutcomeResponse.wish_name_message_key` is a + field the hand-written `PlanProposalResponse.from_domain` mapping in + `app/models/plan.py` could silently drop, the same class of bug + `TestProposeSurplusAdviceOverTheWire`/`TestProposeAge4ProjectionsOverTheWire` + above guard against for other fields. + """ + + async def test_wish_name_message_key_reaches_the_proposal_response( + self, + client: AsyncClient, + connection: aiosqlite.Connection, + ) -> None: + now = datetime.now() + workspace_repo = SQLiteWorkspaceRepository(connection) + personal_data_repo = SQLitePersonalDataRepository(connection) + child_repo = SQLiteChildRepository(connection) + parental_leave_repo = SQLiteParentalLeaveRepository(connection) + vacation_repo = SQLiteVacationDaysRepository(connection) + + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + parent = await personal_data_repo.create( + PersonalData( + uuid=uuid4(), + workspace_id=ws.id, + name="Alex", + salary_type="monthly", + created_at=now, + ) + ) + child = await child_repo.create( + Child( + id=uuid4(), + workspace_id=ws.id, + name="Barn", + birth_date=date.today() - relativedelta(months=6), + created_at=now, + ) + ) + await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id)) + next_year = date.today().year + 1 + await vacation_repo.create( + VacationDays( + id=uuid4(), + personal_data_uuid=parent.uuid, + workspace_id=ws.id, + year=next_year, + total_days=25, + saved_days_in=0, + ) + ) + + # Preset-created: name is the (server-agnostic) translated literal, + # name_message_key records which preset produced it -- exactly what + # `WishEditor.tsx`'s `addPreset` sends. + create_wish = await client.post( + "/api/leave-wishes", + json={ + "workspace_id": str(ws.id), + "child_id": str(child.id), + "name": "Höstlov", + "name_message_key": "wishEditor.presetHostlov", + "window_start_month": 10, + "window_start_day": 25, + "window_end_month": 11, + "window_end_day": 1, + "target_weeks": 1, + "recurrence": "once", + "first_year": next_year, + }, + ) + assert create_wish.status_code == 201 + wish_id = create_wish.json()["id"] + + response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)}) + assert response.status_code == 200 + body = response.json() + + outcome = next(o for o in body["outcomes"] if o["wish_id"] == wish_id) + assert outcome["wish_name"] == "Höstlov" + assert outcome["wish_name_message_key"] == "wishEditor.presetHostlov" + # Surface #1 (outcome.explanation) mirrors the key in `data` too, so + # `buildWindowExplanation` in PlanResults.tsx can re-translate it. + assert outcome["data"]["wish_name_message_key"] == "wishEditor.presetHostlov" + + async def test_wish_name_message_key_is_null_for_a_free_typed_wish( + self, + client: AsyncClient, + connection: aiosqlite.Connection, + ) -> None: + """A row predating this field, or a free-typed/user-renamed wish, has + + no key -- the response must expose `null`, never omit the field or + leak a stale/empty-string placeholder. + """ + now = datetime.now() + workspace_repo = SQLiteWorkspaceRepository(connection) + personal_data_repo = SQLitePersonalDataRepository(connection) + child_repo = SQLiteChildRepository(connection) + parental_leave_repo = SQLiteParentalLeaveRepository(connection) + vacation_repo = SQLiteVacationDaysRepository(connection) + + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + parent = await personal_data_repo.create( + PersonalData( + uuid=uuid4(), + workspace_id=ws.id, + name="Alex", + salary_type="monthly", + created_at=now, + ) + ) + child = await child_repo.create( + Child( + id=uuid4(), + workspace_id=ws.id, + name="Barn", + birth_date=date.today() - relativedelta(months=6), + created_at=now, + ) + ) + await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id)) + next_year = date.today().year + 1 + await vacation_repo.create( + VacationDays( + id=uuid4(), + personal_data_uuid=parent.uuid, + workspace_id=ws.id, + year=next_year, + total_days=25, + saved_days_in=0, + ) + ) + + create_wish = await client.post( + "/api/leave-wishes", + json={ + "workspace_id": str(ws.id), + "child_id": str(child.id), + "name": "Resa till mormor", + "window_start_month": 6, + "window_start_day": 1, + "window_end_month": 6, + "window_end_day": 7, + "target_weeks": 1, + "recurrence": "once", + "first_year": next_year, + }, + ) + assert create_wish.status_code == 201 + wish_id = create_wish.json()["id"] + + response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)}) + assert response.status_code == 200 + body = response.json() + + outcome = next(o for o in body["outcomes"] if o["wish_id"] == wish_id) + assert outcome["wish_name"] == "Resa till mormor" + assert outcome["wish_name_message_key"] is None + + class TestApplyRejectionsOverTheWireMessageKeys: """§3.5 — `PlanApplyResponse.rejections` must expose `message_key` + `data` diff --git a/backend/tests/unit/test_services/test_allocation_service.py b/backend/tests/unit/test_services/test_allocation_service.py index cfaf6b1..206617e 100644 --- a/backend/tests/unit/test_services/test_allocation_service.py +++ b/backend/tests/unit/test_services/test_allocation_service.py @@ -99,12 +99,14 @@ def _wish( workspace_id: UUID | None = None, window_mode: str = "fixed", wish_id: UUID | None = None, + name_message_key: str | None = None, ) -> LeaveWish: return LeaveWish( id=wish_id or uuid4(), workspace_id=workspace_id or uuid4(), child_id=child_id, name=name, + name_message_key=name_message_key, window_start_month=start[0], window_start_day=start[1], window_end_month=end[0], @@ -1452,6 +1454,7 @@ class TestCase20FlexibleWishUnplacedWhenEveryCandidateCollides: flexible = _wish( child_id=child.id, name="Flex", + name_message_key="wishEditor.seasonVar", start=VAR_2027[0], end=VAR_2027[1], weeks=1, @@ -1476,6 +1479,11 @@ class TestCase20FlexibleWishUnplacedWhenEveryCandidateCollides: flex_outcome = by_name["Flex"] assert flex_outcome.strategy == STRATEGY_UNPLACED + # build_unplaced_outcome (§10.4) is a separate WindowOutcome + # construction site from fund_window's -- assert its + # wish_name_message_key mapping independently so neither can drop it. + assert flex_outcome.wish_name_message_key == "wishEditor.seasonVar" + assert flex_outcome.data["wish_name_message_key"] == "wishEditor.seasonVar" assert flex_outcome.weeks_funded == 0.0 assert flex_outcome.shortfall_days == 7 # target_weeks * 7 assert flex_outcome.start_date == date(2027, 3, 1) diff --git a/backend/tests/unit/test_services/test_allocation_service_branches.py b/backend/tests/unit/test_services/test_allocation_service_branches.py index 320ba67..c5a738d 100644 --- a/backend/tests/unit/test_services/test_allocation_service_branches.py +++ b/backend/tests/unit/test_services/test_allocation_service_branches.py @@ -484,6 +484,63 @@ class TestRemedies: assert remedy.data["from_weeks"] != remedy.data["to_weeks"] assert remedy.days_freed > 0 + def test_wish_name_message_key_propagates_to_outcome_and_remedies(self) -> None: + """`WindowOutcome.wish_name_message_key` (and its `data` mirror) must + + carry the *outcome's own* wish's i18n key, `lower_priority_wish`'s + `data.wish_name_message_key` must carry the *sibling* wish's key (not + the outcome's own -- they can differ), and + `shift_window_into_free_days`'s `data.wish_name_message_key` must + again carry the outcome's own wish's key, so `PlanResults.tsx` can + re-translate a preset-created name in every surface, not just the + top-level field. + """ + child = _child(birth_date=date(2025, 1, 1)) + parent = _parent(salary_type="hourly", min_vacation_days_per_year=20) + wish_high = _wish( + child_id=child.id, + name="Vinter", + name_message_key="wishEditor.seasonJul", + start=FEB_2027[0], + end=FEB_2027[1], + weeks=4, + priority=1, + ) + wish_low = _wish( + child_id=child.id, + name="Sommar", + name_message_key="wishEditor.presetSommar", + start=AUG_2027[0], + end=AUG_2027[1], + weeks=2, + priority=2, + ) + alloc_input = _build_input( + today=date(2027, 1, 1), + horizon_end=date(2027, 12, 31), + planning_goal="max_money", + planning_mode="solo", + children=[child], + parents=[parent], + pools={(parent.uuid, child.id): DayPool(5, 0, 60)}, + vacation={(parent.uuid, 2026): VacationBudget(0, 0, 0)}, + wishes=[wish_high, wish_low], + ) + + proposal = Allocator().propose(alloc_input) + + outcome_high = next(o for o in proposal.outcomes if o.wish_name == "Vinter") + assert outcome_high.wish_name_message_key == "wishEditor.seasonJul" + assert outcome_high.data["wish_name_message_key"] == "wishEditor.seasonJul" + + lower_priority = next(r for r in outcome_high.remedies if r.kind == "lower_priority_wish") + assert lower_priority.data["wish_name_message_key"] == "wishEditor.presetSommar" + + shift_window = next( + r for r in outcome_high.remedies if r.kind == "shift_window_into_free_days" + ) + assert shift_window.data["wish_name_message_key"] == "wishEditor.seasonJul" + class TestDayOverrides: """`AllocationInput.day_overrides` — see `DayOverride` / `is_free_day`. diff --git a/frontend/src/components/planning/PlanResults.test.tsx b/frontend/src/components/planning/PlanResults.test.tsx index ec945bf..2d4cc2e 100644 --- a/frontend/src/components/planning/PlanResults.test.tsx +++ b/frontend/src/components/planning/PlanResults.test.tsx @@ -52,6 +52,7 @@ function buildProposal(overrides: Partial = {}): PlanProposal { { wishId: 'lw1', wishName: 'Sommar', + wishNameMessageKey: null, year: 2026, startDate: '2026-06-15', endDate: '2026-07-26', @@ -101,6 +102,7 @@ describe('PlanResults', () => { { wishId: 'lw1', wishName: 'Sommar', + wishNameMessageKey: null, year: 2026, startDate: '2026-06-15', endDate: '2026-07-26', @@ -116,6 +118,7 @@ describe('PlanResults', () => { { wishId: 'lw2', wishName: 'Jul', + wishNameMessageKey: null, year: 2027, startDate: '2027-12-20', endDate: '2028-01-16', @@ -167,6 +170,7 @@ describe('PlanResults', () => { { wishId: 'lw1', wishName: 'Sommar', + wishNameMessageKey: null, year: 2026, startDate: '2026-06-15', endDate: '2026-07-26', @@ -210,6 +214,7 @@ describe('PlanResults', () => { { wishId: 'lw1', wishName: 'Sommar', + wishNameMessageKey: null, year: 2026, startDate: '2026-06-15', endDate: '2026-07-26', @@ -417,6 +422,7 @@ describe('PlanResults', () => { return { wishId: 'lw3', wishName: 'Vår', + wishNameMessageKey: null, year: 2027, startDate: '2027-03-01', endDate: '2027-05-31', @@ -461,4 +467,121 @@ describe('PlanResults', () => { const shortfall = screen.getByTestId('plan-shortfall-lw3-2027') expect(shortfall).toHaveTextContent('42 days could not be funded') }) + + // The hole this closes: a preset-created wish's name used to reach the + // proposal only as a pre-translated literal (`wishName`/`data.wish_name`), + // so it stayed in whatever language the UI was in when the wish was + // created. `wishNameMessageKey` (mirroring `LeaveWish.nameMessageKey`, + // see src/utils/wishName.ts) lets every surface re-translate it, exactly + // like WishEditor already does for the wish list itself. + describe('wishNameMessageKey (preset wish names follow the UI language)', () => { + function buildPresetOutcome() { + return { + wishId: 'lw1', + wishName: 'STALE_SWEDISH_TEXT', + wishNameMessageKey: 'wishEditor.presetSommar', + year: 2026, + startDate: '2026-06-15', + endDate: '2026-07-26', + weeksWished: 6, + weeksFunded: 6, + shortfallDays: 0, + strategy: 'parent_swap', + messageKey: 'planOutcomes.windowExplanation', + data: { + wish_name: 'STALE_SWEDISH_TEXT', + wish_name_message_key: 'wishEditor.presetSommar', + year: 2026, + weeks_wished: 6, + weeks_funded: 6, + shortfall_days: 0, + }, + costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 }, + remedies: [], + } + } + + it('re-translates the name through the badge, card heading, and explanation in Swedish, ignoring the stale stored name', () => { + const proposal = buildProposal({ outcomes: [buildPresetOutcome()] }) + renderLocalized(, 'sv') + + expect(screen.getByText(/Sommar: 6\/6 veckor/)).toBeInTheDocument() + const card = screen.getByTestId('plan-outcome-lw1-2026') + expect(card).toHaveTextContent('Sommar 2026') + expect(card).toHaveTextContent('Sommar 2026 — 6 veckor önskade, 6 veckor finansierade') + expect(card).not.toHaveTextContent('STALE_SWEDISH_TEXT') + }) + + it('re-translates the same name to English when the locale is English', () => { + const proposal = buildProposal({ outcomes: [buildPresetOutcome()] }) + renderLocalized(, 'en') + + expect(screen.getByText(/Summer: 6\/6 weeks/)).toBeInTheDocument() + const card = screen.getByTestId('plan-outcome-lw1-2026') + expect(card).toHaveTextContent('Summer 2026') + expect(card).toHaveTextContent('Summer 2026 — 6 weeks wished, 6 weeks funded') + expect(card).not.toHaveTextContent('STALE_SWEDISH_TEXT') + }) + + it('shows the literal name verbatim when wishNameMessageKey is null (free-typed/renamed wish)', () => { + const outcome = { + ...buildPresetOutcome(), + wishName: 'Resa till mormor', + wishNameMessageKey: null, + data: { ...buildPresetOutcome().data, wish_name: 'Resa till mormor', wish_name_message_key: null }, + } + const proposal = buildProposal({ outcomes: [outcome] }) + renderLocalized(, 'sv') + + const card = screen.getByTestId('plan-outcome-lw1-2026') + expect(card).toHaveTextContent('Resa till mormor 2026') + }) + + it('falls back to the literal name for a key this build does not recognise', () => { + const outcome = { + ...buildPresetOutcome(), + wishName: 'Sommar', + wishNameMessageKey: 'wishEditor.thisKeyDoesNotExist', + data: { + ...buildPresetOutcome().data, + wish_name: 'Sommar', + wish_name_message_key: 'wishEditor.thisKeyDoesNotExist', + }, + } + const proposal = buildProposal({ outcomes: [outcome] }) + renderLocalized(, 'sv') + + const card = screen.getByTestId('plan-outcome-lw1-2026') + expect(card).toHaveTextContent('Sommar 2026') + expect(card).not.toHaveTextContent('wishEditor.thisKeyDoesNotExist') + }) + + it('re-translates a lower_priority_wish remedy’s referenced sibling name independently of the outcome’s own wish', () => { + const outcome = { + ...buildPresetOutcome(), + weeksFunded: 4, + shortfallDays: 14, + remedies: [ + { + kind: 'lower_priority_wish', + messageKey: 'planRemedies.lower_priority_wish', + data: { + wish_name: 'STALE_JUL_TEXT', + wish_name_message_key: 'wishEditor.seasonJul', + from_weeks: 4, + to_weeks: 2, + freed_days: 6, + }, + daysFreed: 6, + }, + ], + } + const proposal = buildProposal({ outcomes: [outcome] }) + renderLocalized(, 'sv') + + const remedies = screen.getByTestId('plan-remedies-lw1-2026') + expect(remedies).toHaveTextContent('Sänk Jul från 4 till 2 veckor frigör 6 dagar') + expect(remedies).not.toHaveTextContent('STALE_JUL_TEXT') + }) + }) }) diff --git a/frontend/src/components/planning/PlanResults.tsx b/frontend/src/components/planning/PlanResults.tsx index 4a870e7..f810aa5 100644 --- a/frontend/src/components/planning/PlanResults.tsx +++ b/frontend/src/components/planning/PlanResults.tsx @@ -19,6 +19,7 @@ import type { } from '@/types' import { useTranslation, toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n' import { nameResolver } from '@/utils/parentNames' +import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName' export interface PlanResultsProps { proposal: PlanProposal @@ -131,6 +132,9 @@ type Translate = (key: TranslationKey, params?: TranslationParams) => string */ function buildWindowExplanation(outcome: WindowOutcome, t: Translate): string { const params = toTranslationParams(outcome.data) + if (typeof outcome.data.wish_name === 'string') { + params.wish_name = resolveWishNameFromData(outcome.data, t) + } let text = t('planResults.explanationSummary', params) const shortfallDays = outcome.data.shortfall_days if (typeof shortfallDays === 'number' && shortfallDays > 0) { @@ -165,7 +169,12 @@ function buildWindowExplanation(outcome: WindowOutcome, t: Translate): string { /** Surface #2 — `remedy.description`. */ function renderRemedy(remedy: ShortfallRemedy, t: Translate): string { const key = REMEDY_BODY_KEYS[remedy.messageKey] - return key ? t(key, toTranslationParams(remedy.data)) : remedy.messageKey + if (!key) return remedy.messageKey + const params = toTranslationParams(remedy.data) + if (typeof remedy.data.wish_name === 'string') { + params.wish_name = resolveWishNameFromData(remedy.data, t) + } + return t(key, params) } /** Surface #3 — `warning.message`. */ @@ -483,7 +492,8 @@ export function PlanResults({ proposal, members, isApplying = false, onApply }: : 'plan-overview-badge--ok' }`} > - {outcome.wishName}: {outcome.weeksFunded}/{outcome.weeksWished} {t('planResults.weeksUnit')} + {resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)}:{' '} + {outcome.weeksFunded}/{outcome.weeksWished} {t('planResults.weeksUnit')} ))} @@ -497,14 +507,19 @@ export function PlanResults({ proposal, members, isApplying = false, onApply }:

{t('planResults.detailTitle')}

- {sortedOutcomes.map((outcome) => ( + {sortedOutcomes.map((outcome) => { + const resolvedWishName = resolveWishName( + { name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, + t, + ) + return (

- {outcome.wishName} {outcome.year} + {resolvedWishName} {outcome.year} {' '} ({outcome.startDate} → {outcome.endDate}) @@ -568,7 +583,8 @@ export function PlanResults({ proposal, members, isApplying = false, onApply }:

{t('planResults.fullyFunded')}

)}

- ))} + ) + })}
diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index de92024..fd58914 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -1116,6 +1116,7 @@ describe('endpoint functions (representative coverage)', () => { { wish_id: 'lw1', wish_name: 'Sommar', + wish_name_message_key: 'wishEditor.presetSommar', year: 2026, start_date: '2026-06-15', end_date: '2026-07-26', @@ -1124,7 +1125,14 @@ describe('endpoint functions (representative coverage)', () => { shortfall_days: 0, strategy: 'parent_swap', message_key: 'planOutcomes.windowExplanation', - data: { wish_name: 'Sommar', year: 2026, weeks_wished: 6, weeks_funded: 6, shortfall_days: 0 }, + data: { + wish_name: 'Sommar', + wish_name_message_key: 'wishEditor.presetSommar', + year: 2026, + weeks_wished: 6, + weeks_funded: 6, + shortfall_days: 0, + }, cost_breakdown: { free: 12, vacation: 19, sjukpenning: 11, lagsta: 0 }, remedies: [], }, @@ -1181,6 +1189,7 @@ describe('endpoint functions (representative coverage)', () => { expect(lastRequest()).toEqual({ url: '/api/plan/propose', method: 'POST' }) expect(lastRequestBody()).toEqual({ workspace_id: 'ws1' }) expect(result.outcomes[0]!.wishName).toBe('Sommar') + expect(result.outcomes[0]!.wishNameMessageKey).toBe('wishEditor.presetSommar') expect(result.outcomes[0]!.costBreakdown).toEqual({ free: 12, vacation: 19, @@ -1190,6 +1199,7 @@ describe('endpoint functions (representative coverage)', () => { expect(result.outcomes[0]!.messageKey).toBe('planOutcomes.windowExplanation') expect(result.outcomes[0]!.data).toEqual({ wish_name: 'Sommar', + wish_name_message_key: 'wishEditor.presetSommar', year: 2026, weeks_wished: 6, weeks_funded: 6, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e29e145..fbd2d2e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -269,6 +269,7 @@ interface RawShortfallRemedy { interface RawWindowOutcome { wish_id: string wish_name: string + wish_name_message_key: string | null year: number start_date: string end_date: string @@ -354,6 +355,7 @@ function toPlanProposal(raw: RawPlanProposal): PlanProposal { outcomes: raw.outcomes.map((o) => ({ wishId: o.wish_id, wishName: o.wish_name, + wishNameMessageKey: o.wish_name_message_key, year: o.year, startDate: o.start_date, endDate: o.end_date, diff --git a/frontend/src/types/entities.ts b/frontend/src/types/entities.ts index c8a8694..ee93c76 100644 --- a/frontend/src/types/entities.ts +++ b/frontend/src/types/entities.ts @@ -204,6 +204,11 @@ export interface CostBreakdown { export interface WindowOutcome { wishId: string wishName: string + // i18n provenance of `wishName` — see `LeaveWish.nameMessageKey` and + // `resolveWishName` (src/utils/wishName.ts). `null` for a free-typed/ + // user-renamed wish, or any row predating this field. Resolve display + // text through `resolveWishName`, never render `wishName` directly. + wishNameMessageKey: string | null year: number startDate: string // ISO-8601 date endDate: string // ISO-8601 date diff --git a/frontend/src/utils/wishName.test.ts b/frontend/src/utils/wishName.test.ts index 41673a8..4b47b7e 100644 --- a/frontend/src/utils/wishName.test.ts +++ b/frontend/src/utils/wishName.test.ts @@ -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[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('') + }) +}) diff --git a/frontend/src/utils/wishName.ts b/frontend/src/utils/wishName.ts index b0df70f..bead204 100644 --- a/frontend/src/utils/wishName.ts +++ b/frontend/src/utils/wishName.ts @@ -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` — 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, + 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) +}