diff --git a/frontend/src/components/dashboard/YearRows.test.tsx b/frontend/src/components/dashboard/YearRows.test.tsx index 2720b66..59066de 100644 --- a/frontend/src/components/dashboard/YearRows.test.tsx +++ b/frontend/src/components/dashboard/YearRows.test.tsx @@ -351,12 +351,12 @@ describe('YearRows', () => { }) const wish = screen.getByTestId('year-row-wish-lw-1-2027') expect(wish).toHaveTextContent('1 av 2 veckor') - expect(screen.getByTestId('year-row-shortfall-lw-1-2027')).toHaveTextContent('2 dag(ar) kunde inte finansieras') + expect(screen.getByTestId('year-row-shortfall-lw-1-2027')).toHaveTextContent('2 dagar kunde inte finansieras') expect(screen.getByTestId('year-row-remedy-lw-1-2027-0')).toHaveTextContent( '5 semesterdagar från tidigare år kan sparas framåt', ) expect(screen.getByTestId('year-row-remedy-lw-1-2027-1')).toHaveTextContent( - 'Sänk Höstlov från 2 till 1 veckor frigör 4 dagar', + 'Sänk Höstlov från 2 till 1 vecka frigör 4 dagar', ) expect(screen.getByTestId('year-row-remedy-lw-1-2027-2')).toHaveTextContent('planRemedies.novel') }) diff --git a/frontend/src/i18n/LanguageContext.test.tsx b/frontend/src/i18n/LanguageContext.test.tsx index 1ec4bff..80a2386 100644 --- a/frontend/src/i18n/LanguageContext.test.tsx +++ b/frontend/src/i18n/LanguageContext.test.tsx @@ -118,6 +118,89 @@ describe('LanguageContext', () => { '© {year} Föräldraledigplannerare. Alla rättigheter förbehållna.', ) }) + + it('handles plural form with count = 1 (singular)', () => { + expect(translate('sv', 'calendar.toastChangedDays', { name: 'Test', count: 1 })).toBe( + 'Test: 1 dag ändrades', + ) + }) + + it('handles plural form with count = 0 (plural)', () => { + expect(translate('sv', 'calendar.toastChangedDays', { name: 'Test', count: 0 })).toBe( + 'Test: 0 dagar ändrades', + ) + }) + + it('handles plural form with count = 2 (plural)', () => { + expect(translate('sv', 'calendar.toastChangedDays', { name: 'Test', count: 2 })).toBe( + 'Test: 2 dagar ändrades', + ) + }) + + it('handles plural form in English with 1 week', () => { + expect( + translate('en', 'calendar.toastShortfallNone', { + name: 'Test', + start: '2026-01-01', + end: '2026-12-31', + weeksWished: 1, + shortfallDays: 5, + }), + ).toBe( + 'Test (2026-01-01–2026-12-31): none of the 1 week requested could be funded — 5 days short', + ) + }) + + it('handles plural form in English with 3 weeks', () => { + expect( + translate('en', 'calendar.toastShortfallNone', { + name: 'Test', + start: '2026-01-01', + end: '2026-12-31', + weeksWished: 3, + shortfallDays: 5, + }), + ).toBe( + 'Test (2026-01-01–2026-12-31): none of the 3 weeks requested could be funded — 5 days short', + ) + }) + + it('handles plural form with 1 day (singular)', () => { + expect( + translate('en', 'calendar.toastShortfallNone', { + name: 'Test', + start: '2026-01-01', + end: '2026-12-31', + weeksWished: 2, + shortfallDays: 1, + }), + ).toBe( + 'Test (2026-01-01–2026-12-31): none of the 2 weeks requested could be funded — 1 day short', + ) + }) + + it('uses plural form when param is missing', () => { + expect( + translate('sv', 'calendar.toastChangedDays', { name: 'Test' }), + ).toBe( + 'Test: {count} dagar ändrades', + ) + }) + + it('preserves plain {param} placeholders alongside plural forms', () => { + expect( + translate('sv', 'calendar.toastShortfallPartial', { + name: 'Test', + start: '2026-01-01', + end: '2026-12-31', + weeksFunded: 2, + weeksWished: 2, + shortfallDays: 0, + }), + ).toBe( + 'Test (2026-01-01–2026-12-31): endast 2 av 2 veckor finansierades — 0 dagar saknas', + ) + }) }) describe('isTranslationKey', () => { diff --git a/frontend/src/i18n/LanguageContext.tsx b/frontend/src/i18n/LanguageContext.tsx index 86b0c8b..c2b6ec9 100644 --- a/frontend/src/i18n/LanguageContext.tsx +++ b/frontend/src/i18n/LanguageContext.tsx @@ -105,9 +105,19 @@ export function toTranslationParams(data: Record): TranslationP function interpolate(template: string, params?: TranslationParams): string { if (!params) return template - return template.replace(/\{(\w+)\}/g, (match, key: string) => { + // Handle both plain {param} and plural {param|singular|plural} forms. + // When the param is the number 1, render singular; otherwise render plural. + // If the param is missing or not a number, default to plural. + // The regex captures: (1) the key, (2) optional singular form, (3) optional plural form. + return template.replace(/\{(\w+)(?:\|([^|}]*)\|([^|}]*))?\}/g, (match, key: string, singular?: string, plural?: string): string => { const value = params[key] - return value === undefined ? match : String(value) + // If no singular/plural forms given, just interpolate the value + if (singular === undefined) { + return value === undefined ? match : String(value) + } + // Plural form: render singular when value is exactly 1, plural otherwise + const numValue = Number(value) + return !Number.isNaN(numValue) && numValue === 1 ? singular : (plural ?? match) }) } diff --git a/frontend/src/i18n/en/calendar.ts b/frontend/src/i18n/en/calendar.ts index 269909b..bcbc2f4 100644 --- a/frontend/src/i18n/en/calendar.ts +++ b/frontend/src/i18n/en/calendar.ts @@ -70,16 +70,16 @@ export const calendar = { 'calendar.periodCreateError': 'Failed to create the period', 'calendar.syncError': 'Failed to sync the plan', 'calendar.syncingLabel': 'Syncing…', - 'calendar.toastChangedDays': '{name}: {count} day(s) changed', + 'calendar.toastChangedDays': '{name}: {count} {count|day|days} changed', 'calendar.toastGenericPeriod': 'The period', 'calendar.toastShortfallNone': - '{name} ({start}–{end}): none of the {weeksWished} week(s) requested could be funded — {shortfallDays} day(s) short', + '{name} ({start}–{end}): none of the {weeksWished} {weeksWished|week|weeks} requested could be funded — {shortfallDays} {shortfallDays|day|days} short', 'calendar.toastShortfallPartial': - '{name} ({start}–{end}): only {weeksFunded} of {weeksWished} week(s) funded — {shortfallDays} day(s) short', + '{name} ({start}–{end}): only {weeksFunded} of {weeksWished} weeks funded — {shortfallDays} {shortfallDays|day|days} short', 'calendar.toastShortfallNoneDays': - '{name} ({start}–{end}): none of the {daysWished} day(s) requested could be funded — {shortfallDays} day(s) short', + '{name} ({start}–{end}): none of the {daysWished} {daysWished|day|days} requested could be funded — {shortfallDays} {shortfallDays|day|days} short', 'calendar.toastShortfallPartialDays': - '{name} ({start}–{end}): only {daysFunded} of {daysWished} day(s) funded — {shortfallDays} day(s) short', + '{name} ({start}–{end}): only {daysFunded} of {daysWished} days funded — {shortfallDays} {shortfallDays|day|days} short', 'calendar.undoButton': 'Undo', 'calendar.undoError': 'Failed to undo the change', 'calendar.dismissToastAria': 'Dismiss the message', diff --git a/frontend/src/i18n/en/plan.ts b/frontend/src/i18n/en/plan.ts index 7e07fca..ecd38c2 100644 --- a/frontend/src/i18n/en/plan.ts +++ b/frontend/src/i18n/en/plan.ts @@ -6,21 +6,21 @@ // (`SurplusAdviceTip.tsx`) and `frontend/src/utils/rejectionMessages.ts` // still render. export const plan = { - 'planResults.remedyBodyCarryVacationForward': '{spare_total} vacation day(s) from earlier years can be carried forward', - 'planResults.remedyBodyLowerPriorityWish': 'Lowering {wish_name} from {from_weeks} to {to_weeks} week(s) frees {days_freed} day(s)', - 'planResults.remedyBodyReducePreschoolReserve': 'Reducing the preschool planning-day reserve ({reserved} reserved) frees {days_freed} day(s)', - 'planResults.remedyBodyShiftWindowIntoFreeDays': 'Moving the period to {new_start_date} – {new_end_date} frees {days_freed} day(s)', - 'planResults.rejectionCollisionsNeedReplace': '{count} day(s) already planned differently; pass replace=true to overwrite', + 'planResults.remedyBodyCarryVacationForward': '{spare_total} {spare_total|vacation day|vacation days} from earlier years can be carried forward', + 'planResults.remedyBodyLowerPriorityWish': 'Lowering {wish_name} from {from_weeks} to {to_weeks} {to_weeks|week|weeks} frees {days_freed} {days_freed|day|days}', + 'planResults.remedyBodyReducePreschoolReserve': 'Reducing the preschool planning-day reserve ({reserved} reserved) frees {days_freed} {days_freed|day|days}', + 'planResults.remedyBodyShiftWindowIntoFreeDays': 'Moving the period to {new_start_date} – {new_end_date} frees {days_freed} {days_freed|day|days}', + 'planResults.rejectionCollisionsNeedReplace': '{count} {count|day|days} already planned differently; pass replace=true to overwrite', 'planResults.rejectionConflictingDuplicate': 'Conflicting duplicate entries for {date} (child {child_id}, parent {parent_name}) in the same batch', 'planResults.rejectionDubbeldagBatchQuotaExceeded': 'This batch would result in {projected_total} double days, the max is {max_dubbeldagar}', 'planResults.rejectionDubbeldagInvalid': 'The double day for {date} could not be approved', 'planResults.rejectionDubbeldagQuotaReached': 'The double-days quota ({max_dubbeldagar} days) has already been reached', 'planResults.rejectionDubbeldagWindowExceeded': 'Double days are only allowed within the child\'s first {months_limit} months (deadline {deadline})', 'planResults.rejectionInsufficientBalance': 'This batch needs {needed} more "{tier}" day(s) but only {remaining} remain', - 'planResults.rejectionInsufficientVacationBalance': '{parent_name} would need {needed} more vacation day(s) in {year}, but only {remaining} remain', + 'planResults.rejectionInsufficientVacationBalance': '{parent_name} would need {needed} more {needed|vacation day|vacation days} in {year}, but only {remaining} remain', 'planResults.rejectionInvalidDayType': 'Day {index} ({date}): invalid day type "{day_type}"', 'planResults.rejectionOutsideEligibleWindow': '{date}: outside the child\'s eligible window ({birth_date} – {hard_expiry})', - 'planResults.shortfallLine': '{shortfall_days} day(s) could not be funded', + 'planResults.shortfallLine': '{shortfall_days} {shortfall_days|day|days} could not be funded', 'planResults.surplusAdviceBodyFrontLoadEarlyYears': '{parent_name} has a large surplus ({days_involved} parental-benefit days) — take more days now while the child is still small and you are home anyway, rather than spreading them thinly until the 4th birthday.', 'planResults.surplusAdviceBodyShiftMixToFp': '{parent_name} could swap {days_involved} vacation day(s) for parental-benefit days in this plan — the parental-benefit days would otherwise be forfeited at the 4th birthday, while the vacation days would not.', 'planResults.surplusAdviceBodyUseLessVacation': '{parent_name} could take {days_involved} fewer vacation day(s) ({years_text}) without going below the statutory minimum ({min_vacation_days_per_year} days/year) — save them instead to cover parental-benefit days that would otherwise be forfeited.', diff --git a/frontend/src/i18n/sv/calendar.ts b/frontend/src/i18n/sv/calendar.ts index 5fa3169..f4b4f54 100644 --- a/frontend/src/i18n/sv/calendar.ts +++ b/frontend/src/i18n/sv/calendar.ts @@ -70,16 +70,16 @@ export const calendar = { 'calendar.periodCreateError': 'Det gick inte att skapa perioden', 'calendar.syncError': 'Det gick inte att synka planen', 'calendar.syncingLabel': 'Synkroniserar…', - 'calendar.toastChangedDays': '{name}: {count} dag(ar) ändrades', + 'calendar.toastChangedDays': '{name}: {count} {count|dag|dagar} ändrades', 'calendar.toastGenericPeriod': 'Perioden', 'calendar.toastShortfallNone': - '{name} ({start}–{end}): inget av de {weeksWished} önskade veckorna kunde finansieras — {shortfallDays} dag(ar) saknas', + '{name} ({start}–{end}): inget av de {weeksWished} {weeksWished|önskad vecka|önskade veckor} kunde finansieras — {shortfallDays} {shortfallDays|dag|dagar} saknas', 'calendar.toastShortfallPartial': - '{name} ({start}–{end}): endast {weeksFunded} av {weeksWished} veckor finansierades — {shortfallDays} dag(ar) saknas', + '{name} ({start}–{end}): endast {weeksFunded} av {weeksWished} veckor finansierades — {shortfallDays} {shortfallDays|dag|dagar} saknas', 'calendar.toastShortfallNoneDays': - '{name} ({start}–{end}): inget av de {daysWished} önskade dagarna kunde finansieras — {shortfallDays} dag(ar) saknas', + '{name} ({start}–{end}): inget av de {daysWished} {daysWished|önskad dag|önskade dagar} kunde finansieras — {shortfallDays} {shortfallDays|dag|dagar} saknas', 'calendar.toastShortfallPartialDays': - '{name} ({start}–{end}): endast {daysFunded} av {daysWished} dagar finansierades — {shortfallDays} dag(ar) saknas', + '{name} ({start}–{end}): endast {daysFunded} av {daysWished} dagar finansierades — {shortfallDays} {shortfallDays|dag|dagar} saknas', 'calendar.undoButton': 'Ångra', 'calendar.undoError': 'Det gick inte att ångra ändringen', 'calendar.dismissToastAria': 'Stäng meddelandet', diff --git a/frontend/src/i18n/sv/plan.ts b/frontend/src/i18n/sv/plan.ts index 1bcb4dc..fe46ff3 100644 --- a/frontend/src/i18n/sv/plan.ts +++ b/frontend/src/i18n/sv/plan.ts @@ -6,21 +6,21 @@ // (`SurplusAdviceTip.tsx`) and `frontend/src/utils/rejectionMessages.ts` // still render. export const plan = { - 'planResults.remedyBodyCarryVacationForward': '{spare_total} semesterdagar från tidigare år kan sparas framåt', - 'planResults.remedyBodyLowerPriorityWish': 'Sänk {wish_name} från {from_weeks} till {to_weeks} veckor frigör {days_freed} dagar', - 'planResults.remedyBodyReducePreschoolReserve': 'Minska förskolans planeringsdagar ({reserved} reserverade) frigör {days_freed} dagar', - 'planResults.remedyBodyShiftWindowIntoFreeDays': 'Flytta perioden till {new_start_date} – {new_end_date} frigör {days_freed} dagar', - 'planResults.rejectionCollisionsNeedReplace': '{count} dag(ar) är redan planerade på annat sätt; skicka replace=true för att skriva över', + 'planResults.remedyBodyCarryVacationForward': '{spare_total} {spare_total|semesterdag|semesterdagar} från tidigare år kan sparas framåt', + 'planResults.remedyBodyLowerPriorityWish': 'Sänk {wish_name} från {from_weeks} till {to_weeks} {to_weeks|vecka|veckor} frigör {days_freed} {days_freed|dag|dagar}', + 'planResults.remedyBodyReducePreschoolReserve': 'Minska förskolans planeringsdagar ({reserved} reserverade) frigör {days_freed} {days_freed|dag|dagar}', + 'planResults.remedyBodyShiftWindowIntoFreeDays': 'Flytta perioden till {new_start_date} – {new_end_date} frigör {days_freed} {days_freed|dag|dagar}', + 'planResults.rejectionCollisionsNeedReplace': '{count} {count|dag|dagar} är redan planerade på annat sätt; skicka replace=true för att skriva över', 'planResults.rejectionConflictingDuplicate': 'Motstridiga dubbletter för {date} (barn {child_id}, förälder {parent_name}) i samma omgång', 'planResults.rejectionDubbeldagBatchQuotaExceeded': 'Omgången skulle resultera i {projected_total} dubbeldagar, max är {max_dubbeldagar}', 'planResults.rejectionDubbeldagInvalid': 'Dubbeldag för {date} kunde inte godkännas', 'planResults.rejectionDubbeldagQuotaReached': 'Kvoten för dubbeldagar ({max_dubbeldagar} dagar) är redan nådd', 'planResults.rejectionDubbeldagWindowExceeded': 'Dubbeldagar är endast tillåtna inom barnets första {months_limit} månader (senast {deadline})', 'planResults.rejectionInsufficientBalance': 'Omgången behöver {needed} fler "{tier}"-dagar men endast {remaining} finns kvar', - 'planResults.rejectionInsufficientVacationBalance': '{parent_name} skulle behöva {needed} fler semesterdag(ar) {year}, men endast {remaining} finns kvar', + 'planResults.rejectionInsufficientVacationBalance': '{parent_name} skulle behöva {needed} fler {needed|semesterdag|semesterdagar} {year}, men endast {remaining} finns kvar', 'planResults.rejectionInvalidDayType': 'Dag {index} ({date}): ogiltig dagtyp "{day_type}"', 'planResults.rejectionOutsideEligibleWindow': '{date}: utanför barnets tillåtna period ({birth_date} – {hard_expiry})', - 'planResults.shortfallLine': '{shortfall_days} dag(ar) kunde inte finansieras', + 'planResults.shortfallLine': '{shortfall_days} {shortfall_days|dag|dagar} kunde inte finansieras', 'planResults.surplusAdviceBodyFrontLoadEarlyYears': '{parent_name} har ett stort överskott ({days_involved} FP-dagar) — ta ut fler dagar nu medan barnet är litet och ni ändå är hemma, istället för att sprida ut dem tunt fram till 4-årsdagen.', 'planResults.surplusAdviceBodyShiftMixToFp': '{parent_name} kan byta {days_involved} semesterdagar mot föräldrapenningdagar under planen — FP-dagarna går annars förlorade vid 4-årsdagen, medan semestern inte gör det.', 'planResults.surplusAdviceBodyUseLessVacation': '{parent_name} kan ta ut {days_involved} färre semesterdagar ({years_text}) utan att gå under lägsta uttaget ({min_vacation_days_per_year} dagar/år) — spara dem istället för att täcka FP-dagar som annars går förlorade.', diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx index 6cb7723..33d0e41 100644 --- a/frontend/src/pages/Calendar.test.tsx +++ b/frontend/src/pages/Calendar.test.tsx @@ -1166,7 +1166,7 @@ describe('Calendar', () => { const toast = await screen.findByTestId('calendar-toast') expect(toast).toHaveTextContent('7') - expect(toast).toHaveTextContent('dagarna') + expect(toast).toHaveTextContent('dagar') expect(toast).not.toHaveTextContent('3 vecko') expect(toast).not.toHaveTextContent('veckorna') }) @@ -1218,7 +1218,7 @@ describe('Calendar', () => { fireEvent.click(screen.getByTestId('preset-click')) const toast = await screen.findByTestId('calendar-toast') - // What changed (existing "N day(s) changed" summary) ... + // What changed (the "N days changed" summary with pluralization) ... expect(toast).toHaveTextContent('ändrades') // ... and what fell short. expect(toast).toHaveTextContent('5') diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index 22a9e65..2c716e2 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -181,7 +181,7 @@ function summarizeChanges(changes: PlanChange[], wishes: LeaveWish[], t: Transla * week-rounded view. A calendar-drawn period sets `targetDays` (an exact * day count); `targetWeeks` on that same wish is just `Math.ceil(days / 7)`, * a leftover the API still requires, never what the user actually asked - * for. Reporting `weeksWished` for those wishes says "3 week(s)" for a + * for. Reporting `weeksWished` for those wishes says "3 weeks" for a * 7-day drag. When `targetDays` is set, report days instead — the figure * the user actually asked for — and only fall back to weeks for wishes that * never had a day-granular target (e.g. the preset rail, which only ever