Pluralise counts properly instead of writing dag(ar)
The translate function learns a {count|singular|plural} form, and the
24 strings that hedged with "dag(ar)", "day(s)" or "week(s)" now pick
the right word from the number they interpolate, in both languages.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
This commit is contained in:
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -105,9 +105,19 @@ export function toTranslationParams(data: Record<string, unknown>): 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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user