Translate wish names in plan proposal output
Preset wish names follow the UI language in the wish editor, but the plan proposal still rendered outcome.wishName straight from the stored entity.name, so a proposal could show "Höstlov" with the UI in English. The proposal now carries wish_name_message_key alongside each wish name, and the frontend resolves it with the same helper the editor uses -- falling back to the stored literal when the key is absent or unknown. Nothing is translated on the backend; text crosses the wire as a key. Four surfaces carried a name and all four are covered: WindowOutcome itself, the explanation summary's interpolated data, and two remedies -- including REMEDY_LOWER_PRIORITY_WISH, which names a *different* sibling wish whose key can differ from the outcome's own. The proposal response mapping is hand-written and has dropped new fields twice before, so this is covered by an API-level test asserting the field reaches the response body, not only a service test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -52,6 +52,7 @@ function buildProposal(overrides: Partial<PlanProposal> = {}): 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(<PlanResults proposal={proposal} members={mockMembers} />, '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(<PlanResults proposal={proposal} members={mockMembers} />, '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(<PlanResults proposal={proposal} members={mockMembers} />, '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(<PlanResults proposal={proposal} members={mockMembers} />, '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(<PlanResults proposal={proposal} members={mockMembers} />, '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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -497,14 +507,19 @@ export function PlanResults({ proposal, members, isApplying = false, onApply }:
|
||||
|
||||
<section className="plan-results__section" data-testid="plan-outcome-cards">
|
||||
<h2>{t('planResults.detailTitle')}</h2>
|
||||
{sortedOutcomes.map((outcome) => (
|
||||
{sortedOutcomes.map((outcome) => {
|
||||
const resolvedWishName = resolveWishName(
|
||||
{ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey },
|
||||
t,
|
||||
)
|
||||
return (
|
||||
<article
|
||||
key={`${outcome.wishId}-${outcome.year}`}
|
||||
className="plan-outcome-card"
|
||||
data-testid={`plan-outcome-${outcome.wishId}-${outcome.year}`}
|
||||
>
|
||||
<h3>
|
||||
{outcome.wishName} {outcome.year}
|
||||
{resolvedWishName} {outcome.year}
|
||||
<span className="plan-outcome-card__dates">
|
||||
{' '}
|
||||
({outcome.startDate} → {outcome.endDate})
|
||||
@@ -568,7 +583,8 @@ export function PlanResults({ proposal, members, isApplying = false, onApply }:
|
||||
<p className="plan-outcome-card__ok">{t('planResults.fullyFunded')}</p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="plan-results__section" data-testid="plan-balances">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof translate>[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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>` — 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<string, unknown>,
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user