From de128cc4aebf1b8c748a4832afb433b55a0e6754 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 7 Oct 2026 22:57:51 +0200 Subject: [PATCH] wip(i18n): plural forms through Intl.PluralRules Unfinished, continued in the next session: t() resolves key.one/.few/... by the language's own plural rule, the parity check knows which forms a language needs, 16 singular/plural key pairs are merged in every locale and every count === 1 branch in the client uses the plural form. Still open: the forms each locale needs for the remaining count strings, the two new labels in every locale, and 35 tests that expect the old English wording. --- client/src/components/Collab/CollabPolls.tsx | 2 +- .../components/Files/FileManagerFilesView.tsx | 2 +- .../components/Journey/JourneyDayDawarich.tsx | 8 +- .../JourneyDetailPageGalleryView.test.tsx | 2 +- .../Planner/AirTrailImportModal.test.tsx | 2 +- .../AirTrailConnectionSection.test.tsx | 4 +- .../components/Settings/OfflineTab.test.tsx | 2 +- client/src/components/Tours/ToursSidebar.tsx | 2 +- .../src/components/Trips/TripMembersModal.tsx | 2 +- .../src/components/Trips/TripShareDialog.tsx | 2 +- client/src/i18n/TranslationContext.tsx | 9 +- client/src/i18n/resolveTemplate.test.ts | 48 ++++++++++ client/src/i18n/resolveTemplate.ts | 28 ++++++ .../mobile/screens/dashboard/MDashboard.tsx | 8 +- .../screens/trip/places/MPlacesBrowser.tsx | 2 +- .../trip/roadtrip/MRtCorridorSheet.tsx | 8 +- .../mobile/screens/trip/tabs/MCollabPolls.tsx | 2 +- client/src/pages/AtlasPage.tsx | 4 +- client/src/pages/AtlasPage.wiring.test.tsx | 6 +- client/src/pages/DashboardPage.tsx | 10 +-- client/src/pages/InAppNotificationsPage.tsx | 2 +- client/src/pages/OAuthAuthorizePage.tsx | 7 +- .../src/pages/admin/AdminUserModals.test.tsx | 2 +- client/src/pages/atlas/useAtlas.ts | 6 +- client/tests/unit/i18n/parity.test.ts | 18 +++- .../mobile/admin/MAdminPluginsPanel.test.tsx | 6 +- .../unit/mobile/admin/MAdminSheets.test.tsx | 2 +- .../unit/mobile/dashboard/MDashboard.test.tsx | 8 +- .../MAirTrailConnectionSection.test.tsx | 2 +- .../mobile/settings/MSettingsOffline.test.tsx | 2 +- .../unit/mobile/trip/MPlacesBrowser.test.tsx | 4 +- shared/scripts/i18n-parity.mjs | 88 ++++++++++++++++++- shared/src/i18n/ar/atlas.ts | 9 +- shared/src/i18n/ar/collab.ts | 2 +- shared/src/i18n/ar/dashboard.ts | 21 ++--- shared/src/i18n/ar/files.ts | 2 +- shared/src/i18n/ar/members.ts | 2 +- shared/src/i18n/ar/oauth.ts | 4 +- shared/src/i18n/ar/places.ts | 2 +- shared/src/i18n/ar/tours.ts | 2 +- shared/src/i18n/az/atlas.ts | 9 +- shared/src/i18n/az/collab.ts | 2 +- shared/src/i18n/az/dashboard.ts | 21 ++--- shared/src/i18n/az/files.ts | 2 +- shared/src/i18n/az/members.ts | 2 +- shared/src/i18n/az/oauth.ts | 4 +- shared/src/i18n/az/places.ts | 2 +- shared/src/i18n/az/tours.ts | 2 +- shared/src/i18n/br/atlas.ts | 9 +- shared/src/i18n/br/collab.ts | 2 +- shared/src/i18n/br/dashboard.ts | 21 ++--- shared/src/i18n/br/files.ts | 2 +- shared/src/i18n/br/members.ts | 2 +- shared/src/i18n/br/oauth.ts | 4 +- shared/src/i18n/br/places.ts | 2 +- shared/src/i18n/br/tours.ts | 2 +- shared/src/i18n/ca/atlas.ts | 9 +- shared/src/i18n/ca/collab.ts | 2 +- shared/src/i18n/ca/dashboard.ts | 21 ++--- shared/src/i18n/ca/files.ts | 2 +- shared/src/i18n/ca/members.ts | 2 +- shared/src/i18n/ca/oauth.ts | 4 +- shared/src/i18n/ca/places.ts | 2 +- shared/src/i18n/ca/tours.ts | 2 +- shared/src/i18n/cs/atlas.ts | 9 +- shared/src/i18n/cs/collab.ts | 2 +- shared/src/i18n/cs/dashboard.ts | 21 ++--- shared/src/i18n/cs/files.ts | 2 +- shared/src/i18n/cs/members.ts | 2 +- shared/src/i18n/cs/oauth.ts | 4 +- shared/src/i18n/cs/places.ts | 2 +- shared/src/i18n/cs/tours.ts | 2 +- shared/src/i18n/de/atlas.ts | 9 +- shared/src/i18n/de/collab.ts | 2 +- shared/src/i18n/de/dashboard.ts | 21 ++--- shared/src/i18n/de/files.ts | 2 +- shared/src/i18n/de/members.ts | 2 +- shared/src/i18n/de/oauth.ts | 4 +- shared/src/i18n/de/places.ts | 2 +- shared/src/i18n/de/tours.ts | 2 +- shared/src/i18n/en/admin.ts | 7 +- shared/src/i18n/en/atlas.ts | 11 ++- shared/src/i18n/en/budget.ts | 7 ++ shared/src/i18n/en/collab.ts | 2 +- shared/src/i18n/en/collection.ts | 21 +++++ shared/src/i18n/en/dashboard.ts | 28 +++--- shared/src/i18n/en/dawarich.ts | 9 ++ shared/src/i18n/en/files.ts | 4 +- shared/src/i18n/en/help.ts | 3 + shared/src/i18n/en/inspector.ts | 1 + shared/src/i18n/en/journey.ts | 6 ++ shared/src/i18n/en/map.ts | 6 +- shared/src/i18n/en/members.ts | 2 +- shared/src/i18n/en/memories.ts | 2 + shared/src/i18n/en/mobileJourney.ts | 4 + shared/src/i18n/en/mobileTrip.ts | 5 ++ shared/src/i18n/en/mobileVacay.ts | 2 + shared/src/i18n/en/notif.ts | 3 +- shared/src/i18n/en/notifications.ts | 2 + shared/src/i18n/en/oauth.ts | 4 +- shared/src/i18n/en/packing.ts | 4 + shared/src/i18n/en/photos.ts | 1 + shared/src/i18n/en/places.ts | 8 +- shared/src/i18n/en/planner.ts | 3 + shared/src/i18n/en/reservations.ts | 14 ++- shared/src/i18n/en/roadtrip.ts | 13 ++- shared/src/i18n/en/settings.ts | 6 +- shared/src/i18n/en/tours.ts | 2 +- shared/src/i18n/en/trip.ts | 4 + shared/src/i18n/es/atlas.ts | 9 +- shared/src/i18n/es/collab.ts | 2 +- shared/src/i18n/es/dashboard.ts | 21 ++--- shared/src/i18n/es/files.ts | 2 +- shared/src/i18n/es/members.ts | 2 +- shared/src/i18n/es/oauth.ts | 4 +- shared/src/i18n/es/places.ts | 2 +- shared/src/i18n/es/tours.ts | 2 +- shared/src/i18n/et/atlas.ts | 9 +- shared/src/i18n/et/collab.ts | 2 +- shared/src/i18n/et/dashboard.ts | 21 ++--- shared/src/i18n/et/files.ts | 2 +- shared/src/i18n/et/members.ts | 2 +- shared/src/i18n/et/oauth.ts | 4 +- shared/src/i18n/et/places.ts | 2 +- shared/src/i18n/et/tours.ts | 2 +- shared/src/i18n/fr/atlas.ts | 9 +- shared/src/i18n/fr/collab.ts | 2 +- shared/src/i18n/fr/dashboard.ts | 21 ++--- shared/src/i18n/fr/files.ts | 2 +- shared/src/i18n/fr/members.ts | 2 +- shared/src/i18n/fr/oauth.ts | 4 +- shared/src/i18n/fr/places.ts | 2 +- shared/src/i18n/fr/tours.ts | 2 +- shared/src/i18n/gr/atlas.ts | 9 +- shared/src/i18n/gr/collab.ts | 2 +- shared/src/i18n/gr/dashboard.ts | 21 ++--- shared/src/i18n/gr/files.ts | 2 +- shared/src/i18n/gr/members.ts | 2 +- shared/src/i18n/gr/oauth.ts | 4 +- shared/src/i18n/gr/places.ts | 2 +- shared/src/i18n/gr/tours.ts | 2 +- shared/src/i18n/hu/atlas.ts | 9 +- shared/src/i18n/hu/collab.ts | 2 +- shared/src/i18n/hu/dashboard.ts | 21 ++--- shared/src/i18n/hu/files.ts | 2 +- shared/src/i18n/hu/members.ts | 2 +- shared/src/i18n/hu/oauth.ts | 4 +- shared/src/i18n/hu/places.ts | 2 +- shared/src/i18n/hu/tours.ts | 2 +- shared/src/i18n/id/atlas.ts | 5 +- shared/src/i18n/id/collab.ts | 1 - shared/src/i18n/id/dashboard.ts | 15 ++-- shared/src/i18n/id/files.ts | 1 - shared/src/i18n/id/members.ts | 1 - shared/src/i18n/id/oauth.ts | 3 +- shared/src/i18n/id/places.ts | 1 - shared/src/i18n/id/tours.ts | 1 - shared/src/i18n/it/atlas.ts | 9 +- shared/src/i18n/it/collab.ts | 2 +- shared/src/i18n/it/dashboard.ts | 21 ++--- shared/src/i18n/it/files.ts | 2 +- shared/src/i18n/it/members.ts | 2 +- shared/src/i18n/it/oauth.ts | 4 +- shared/src/i18n/it/places.ts | 2 +- shared/src/i18n/it/tours.ts | 2 +- shared/src/i18n/ja/atlas.ts | 5 +- shared/src/i18n/ja/collab.ts | 1 - shared/src/i18n/ja/dashboard.ts | 15 ++-- shared/src/i18n/ja/files.ts | 1 - shared/src/i18n/ja/members.ts | 1 - shared/src/i18n/ja/oauth.ts | 3 +- shared/src/i18n/ja/places.ts | 1 - shared/src/i18n/ja/tours.ts | 1 - shared/src/i18n/ko/atlas.ts | 5 +- shared/src/i18n/ko/collab.ts | 1 - shared/src/i18n/ko/dashboard.ts | 15 ++-- shared/src/i18n/ko/files.ts | 1 - shared/src/i18n/ko/members.ts | 1 - shared/src/i18n/ko/oauth.ts | 3 +- shared/src/i18n/ko/places.ts | 1 - shared/src/i18n/ko/tours.ts | 1 - shared/src/i18n/nl/atlas.ts | 9 +- shared/src/i18n/nl/collab.ts | 2 +- shared/src/i18n/nl/dashboard.ts | 21 ++--- shared/src/i18n/nl/files.ts | 2 +- shared/src/i18n/nl/members.ts | 2 +- shared/src/i18n/nl/oauth.ts | 4 +- shared/src/i18n/nl/places.ts | 2 +- shared/src/i18n/nl/tours.ts | 2 +- shared/src/i18n/pl/atlas.ts | 9 +- shared/src/i18n/pl/collab.ts | 2 +- shared/src/i18n/pl/dashboard.ts | 21 ++--- shared/src/i18n/pl/files.ts | 2 +- shared/src/i18n/pl/members.ts | 2 +- shared/src/i18n/pl/oauth.ts | 4 +- shared/src/i18n/pl/places.ts | 2 +- shared/src/i18n/pl/tours.ts | 2 +- shared/src/i18n/plural.ts | 65 ++++++++++++++ shared/src/i18n/ru/atlas.ts | 9 +- shared/src/i18n/ru/collab.ts | 2 +- shared/src/i18n/ru/dashboard.ts | 21 ++--- shared/src/i18n/ru/files.ts | 2 +- shared/src/i18n/ru/members.ts | 2 +- shared/src/i18n/ru/oauth.ts | 4 +- shared/src/i18n/ru/places.ts | 2 +- shared/src/i18n/ru/tours.ts | 2 +- shared/src/i18n/sk/atlas.ts | 9 +- shared/src/i18n/sk/collab.ts | 2 +- shared/src/i18n/sk/dashboard.ts | 21 ++--- shared/src/i18n/sk/files.ts | 2 +- shared/src/i18n/sk/members.ts | 2 +- shared/src/i18n/sk/oauth.ts | 4 +- shared/src/i18n/sk/places.ts | 2 +- shared/src/i18n/sk/tours.ts | 2 +- shared/src/i18n/sv/atlas.ts | 9 +- shared/src/i18n/sv/collab.ts | 2 +- shared/src/i18n/sv/dashboard.ts | 21 ++--- shared/src/i18n/sv/files.ts | 2 +- shared/src/i18n/sv/members.ts | 2 +- shared/src/i18n/sv/oauth.ts | 4 +- shared/src/i18n/sv/places.ts | 2 +- shared/src/i18n/sv/tours.ts | 2 +- shared/src/i18n/th/atlas.ts | 5 +- shared/src/i18n/th/collab.ts | 1 - shared/src/i18n/th/dashboard.ts | 15 ++-- shared/src/i18n/th/files.ts | 1 - shared/src/i18n/th/members.ts | 1 - shared/src/i18n/th/oauth.ts | 3 +- shared/src/i18n/th/places.ts | 1 - shared/src/i18n/th/tours.ts | 1 - shared/src/i18n/tr/atlas.ts | 9 +- shared/src/i18n/tr/collab.ts | 2 +- shared/src/i18n/tr/dashboard.ts | 21 ++--- shared/src/i18n/tr/files.ts | 2 +- shared/src/i18n/tr/members.ts | 2 +- shared/src/i18n/tr/oauth.ts | 4 +- shared/src/i18n/tr/places.ts | 2 +- shared/src/i18n/tr/tours.ts | 2 +- shared/src/i18n/uk/atlas.ts | 9 +- shared/src/i18n/uk/collab.ts | 2 +- shared/src/i18n/uk/dashboard.ts | 21 ++--- shared/src/i18n/uk/files.ts | 2 +- shared/src/i18n/uk/members.ts | 2 +- shared/src/i18n/uk/oauth.ts | 4 +- shared/src/i18n/uk/places.ts | 2 +- shared/src/i18n/uk/tours.ts | 2 +- shared/src/i18n/vi/atlas.ts | 5 +- shared/src/i18n/vi/collab.ts | 1 - shared/src/i18n/vi/dashboard.ts | 15 ++-- shared/src/i18n/vi/files.ts | 1 - shared/src/i18n/vi/members.ts | 1 - shared/src/i18n/vi/oauth.ts | 3 +- shared/src/i18n/vi/places.ts | 1 - shared/src/i18n/vi/tours.ts | 1 - shared/src/i18n/zh-TW/atlas.ts | 5 +- shared/src/i18n/zh-TW/collab.ts | 1 - shared/src/i18n/zh-TW/dashboard.ts | 15 ++-- shared/src/i18n/zh-TW/files.ts | 1 - shared/src/i18n/zh-TW/members.ts | 1 - shared/src/i18n/zh-TW/oauth.ts | 3 +- shared/src/i18n/zh-TW/places.ts | 1 - shared/src/i18n/zh-TW/tours.ts | 1 - shared/src/i18n/zh/atlas.ts | 5 +- shared/src/i18n/zh/collab.ts | 1 - shared/src/i18n/zh/dashboard.ts | 15 ++-- shared/src/i18n/zh/files.ts | 1 - shared/src/i18n/zh/members.ts | 1 - shared/src/i18n/zh/oauth.ts | 3 +- shared/src/i18n/zh/places.ts | 1 - shared/src/i18n/zh/tours.ts | 1 - shared/src/index.ts | 1 + 271 files changed, 931 insertions(+), 651 deletions(-) create mode 100644 client/src/i18n/resolveTemplate.test.ts create mode 100644 client/src/i18n/resolveTemplate.ts create mode 100644 shared/src/i18n/plural.ts diff --git a/client/src/components/Collab/CollabPolls.tsx b/client/src/components/Collab/CollabPolls.tsx index 295aefbcf..0ee5d9afb 100644 --- a/client/src/components/Collab/CollabPolls.tsx +++ b/client/src/components/Collab/CollabPolls.tsx @@ -241,7 +241,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: {isClosed && }>{t('collab.polls.closed')}} {remaining && !isClosed && }>{remaining}} {poll.multiple_choice && {t('collab.polls.multiChoice')}} - {t(total === 1 ? 'collab.polls.vote' : 'collab.polls.votes', { n: total })} + {t('collab.polls.votes', { n: total })} {canEdit && ( diff --git a/client/src/components/Files/FileManagerFilesView.tsx b/client/src/components/Files/FileManagerFilesView.tsx index 3c976b589..3456a7037 100644 --- a/client/src/components/Files/FileManagerFilesView.tsx +++ b/client/src/components/Files/FileManagerFilesView.tsx @@ -56,7 +56,7 @@ export function FilesView(S: FileManagerState) { }}>{tab.icon ? : tab.label} ))} - {filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })} + {t('files.count', { count: filteredFiles.length })} diff --git a/client/src/components/Journey/JourneyDayDawarich.tsx b/client/src/components/Journey/JourneyDayDawarich.tsx index 821d20b49..b8b25dc9f 100644 --- a/client/src/components/Journey/JourneyDayDawarich.tsx +++ b/client/src/components/Journey/JourneyDayDawarich.tsx @@ -60,13 +60,7 @@ export default function JourneyDayDawarich({ - {/* Two keys picked by the count, the shape this codebase uses for a number - that changes its noun: there is no plural engine in the i18n layer, and one - key with {count} in it reads as "1 stays". */} - {t( - suggestions.length === 1 ? 'dawarich.journey.dayStays.one' : 'dawarich.journey.dayStays.other', - { count: suggestions.length }, - )} + {t('dawarich.journey.dayStays', { count: suggestions.length })} {span && {span}} diff --git a/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx b/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx index a16e004de..3d997b182 100644 --- a/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx @@ -297,7 +297,7 @@ describe('GalleryView', () => { await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(1)) expect(bodies[0]).toEqual({ provider: 'immich', asset_ids: ['asset-1'], media_types: ['image'] }) - expect(toastSpy).toHaveBeenCalledWith('1 photos added', 'success', undefined) + expect(toastSpy).toHaveBeenCalledWith('1 photo added', 'success', undefined) expect(screen.queryByRole('heading', { name: 'Immich' })).not.toBeInTheDocument() }) diff --git a/client/src/components/Planner/AirTrailImportModal.test.tsx b/client/src/components/Planner/AirTrailImportModal.test.tsx index e994898bc..5bc103ffc 100644 --- a/client/src/components/Planner/AirTrailImportModal.test.tsx +++ b/client/src/components/Planner/AirTrailImportModal.test.tsx @@ -288,7 +288,7 @@ describe('AirTrailImportModal', () => { await user.click(await screen.findByRole('button', { name: /Import 1/i })); await waitFor(() => expect(pushUndo).toHaveBeenCalled()); - expect(toasts).toContainEqual(['success', '1 flight(s) imported']); + expect(toasts).toContainEqual(['success', '1 flight imported']); expect(toasts).toContainEqual(['warning', '1 already in this trip, skipped']); expect(pushUndo.mock.calls[0][0]).toBe('Import from AirTrail'); diff --git a/client/src/components/Settings/AirTrailConnectionSection.test.tsx b/client/src/components/Settings/AirTrailConnectionSection.test.tsx index 2669ba1f8..cefc47a5c 100644 --- a/client/src/components/Settings/AirTrailConnectionSection.test.tsx +++ b/client/src/components/Settings/AirTrailConnectionSection.test.tsx @@ -247,7 +247,7 @@ describe('AirTrailConnectionSection', () => { await screen.findByDisplayValue('https://air.example.com'); await user.click(screen.getByRole('button', { name: /Test connection/ })); - await screen.findByText('Connected — 12 flight(s) found'); + await screen.findByText('Connected. 12 flights found'); expect(screen.getByText('Connected')).toBeInTheDocument(); expect(body).toEqual({ url: 'https://air.example.com', allowInsecureTls: false }); }); @@ -263,7 +263,7 @@ describe('AirTrailConnectionSection', () => { await screen.findByDisplayValue('https://air.example.com'); await user.click(screen.getByRole('button', { name: /Test connection/ })); - expect(await screen.findByText('Connected — 0 flight(s) found')).toBeInTheDocument(); + expect(await screen.findByText('Connected. 0 flights found')).toBeInTheDocument(); }); it('FE-COMP-AIRTRAIL-015: a refused test shows the returned error and keeps the badge off', async () => { diff --git a/client/src/components/Settings/OfflineTab.test.tsx b/client/src/components/Settings/OfflineTab.test.tsx index 2f4467f16..74c8613db 100644 --- a/client/src/components/Settings/OfflineTab.test.tsx +++ b/client/src/components/Settings/OfflineTab.test.tsx @@ -319,7 +319,7 @@ describe('OfflineTab', () => { expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled(); await act(async () => { release(); }); - expect(await screen.findByText('Stored 4 trip(s) on this device')).toBeInTheDocument(); + expect(await screen.findByText('Stored 4 trips on this device')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled(); }); diff --git a/client/src/components/Tours/ToursSidebar.tsx b/client/src/components/Tours/ToursSidebar.tsx index b7c741aca..1042c9078 100644 --- a/client/src/components/Tours/ToursSidebar.tsx +++ b/client/src/components/Tours/ToursSidebar.tsx @@ -70,7 +70,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel // enter the shared store used by the day map. await onToursChanged?.(result.tours.map(tour => tour.place_id)) await loadTrip(tripId) - toast.success(t(result.tours.length === 1 ? 'tours.import.successOne' : 'tours.import.success', { count: result.tours.length })) + toast.success(t('tours.import.success', { count: result.tours.length })) if (result.caution) toast.info(t('tours.import.caution')) } else { toast.warning(t('places.importAllSkipped')) diff --git a/client/src/components/Trips/TripMembersModal.tsx b/client/src/components/Trips/TripMembersModal.tsx index 87112e4db..b11b7c33c 100644 --- a/client/src/components/Trips/TripMembersModal.tsx +++ b/client/src/components/Trips/TripMembersModal.tsx @@ -240,7 +240,7 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
- {t('members.access')} ({realMembers.length} {realMembers.length === 1 ? t('members.person') : t('members.persons')}) + {t('members.access')} ({realMembers.length} {t('members.persons', { count: realMembers.length })})
diff --git a/client/src/components/Trips/TripShareDialog.tsx b/client/src/components/Trips/TripShareDialog.tsx index 61dd432da..adb421ec6 100644 --- a/client/src/components/Trips/TripShareDialog.tsx +++ b/client/src/components/Trips/TripShareDialog.tsx @@ -41,7 +41,7 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD const labelId = useId() const m = useTripMembers({ isOpen: true, tripId, onClose, onMembersChanged }) const [ask, setAsk] = useState(null) - const persons = `${m.realMembers.length} ${m.realMembers.length === 1 ? t('members.person') : t('members.persons')}` + const persons = `${m.realMembers.length} ${t('members.persons', { count: m.realMembers.length })}` return ( <> diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx index 4ee437cdf..940b82312 100644 --- a/client/src/i18n/TranslationContext.tsx +++ b/client/src/i18n/TranslationContext.tsx @@ -12,6 +12,7 @@ import { } from '@trek/shared' import type { TranslationStrings } from '@trek/shared/i18n' import { importChunk } from '../utils/chunkReload' +import { resolveTemplate } from './resolveTemplate' export { SUPPORTED_LANGUAGES } @@ -134,8 +135,12 @@ export function TranslationProvider({ children }: TranslationProviderProps) { }, [language]) const value = useMemo((): TranslationContextValue => { + const intlLanguage = getIntlLanguage(language) + const template = (key: string, params?: Record): string => + resolveTemplate(strings, intlLanguage, key, params) + function t(key: string, params?: Record): string { - let val: string = (strings[key] ?? en[key] ?? key) as string + let val = template(key, params) if (params) { Object.entries(params).forEach(([k, v]) => { // Function replacement: a value carrying `$&` or `$1` (a trip named @@ -147,7 +152,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) { } function tHtml(key: string, params?: Record): string { - let val: string = (strings[key] ?? en[key] ?? key) as string + let val = template(key, params) if (params) { Object.entries(params).forEach(([k, v]) => { // Escape BEFORE substitution so a user-controlled value with `<` or diff --git a/client/src/i18n/resolveTemplate.test.ts b/client/src/i18n/resolveTemplate.test.ts new file mode 100644 index 000000000..7328251cb --- /dev/null +++ b/client/src/i18n/resolveTemplate.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import type { TranslationStrings } from '@trek/shared/i18n' +import { resolveTemplate } from './resolveTemplate' + +const ru = { + 'x.places': '{count} места', + 'x.places.one': '{count} место', + 'x.places.few': '{count} места', + 'x.places.many': '{count} мест', +} as unknown as TranslationStrings + +describe('resolveTemplate', () => { + it('picks the form the language selects, not count === 1', () => { + expect(resolveTemplate(ru, 'ru', 'x.places', { count: 1 })).toBe('{count} место') + expect(resolveTemplate(ru, 'ru', 'x.places', { count: 21 })).toBe('{count} место') + expect(resolveTemplate(ru, 'ru', 'x.places', { count: 3 })).toBe('{count} места') + expect(resolveTemplate(ru, 'ru', 'x.places', { count: 5 })).toBe('{count} мест') + expect(resolveTemplate(ru, 'ru', 'x.places', { count: 11 })).toBe('{count} мест') + }) + + it('a form the locale does not spell out falls back to its general form, never to English', () => { + const ja = { 'x.places': '{count}件の場所' } as unknown as TranslationStrings + expect(resolveTemplate(ja, 'ja', 'x.places', { count: 1 })).toBe('{count}件の場所') + const ruWithoutMany = { 'x.places': '{count} места', 'x.places.one': '{count} место' } as unknown as TranslationStrings + expect(resolveTemplate(ruWithoutMany, 'ru', 'x.places', { count: 5 })).toBe('{count} места') + }) + + it('a key the locale lacks resolves in English with English rules', () => { + // 'places.count' and its .one form ship in en. + const empty = {} as TranslationStrings + expect(resolveTemplate(empty, 'ru', 'places.count', { count: 1 })).toBe('{count} place') + expect(resolveTemplate(empty, 'ru', 'places.count', { count: 2 })).toBe('{count} places') + }) + + it('reads n when there is no count, and ignores a count that is not a number', () => { + expect(resolveTemplate(ru, 'ru', 'x.places', { n: 5 })).toBe('{count} мест') + expect(resolveTemplate(ru, 'ru', 'x.places', { count: 'five' })).toBe('{count} места') + expect(resolveTemplate(ru, 'ru', 'x.places', { count: Number.NaN })).toBe('{count} места') + }) + + it('a group with only category keys still resolves, and an unknown key is the key', () => { + const groupOnly = { 'x.stays.one': 'one stay', 'x.stays.other': '{count} stays' } as unknown as TranslationStrings + expect(resolveTemplate(groupOnly, 'en', 'x.stays', { count: 1 })).toBe('one stay') + expect(resolveTemplate(groupOnly, 'en', 'x.stays', { count: 4 })).toBe('{count} stays') + expect(resolveTemplate(groupOnly, 'en', 'x.nothing', { count: 4 })).toBe('x.nothing') + expect(resolveTemplate(groupOnly, 'en', 'x.nothing')).toBe('x.nothing') + }) +}) diff --git a/client/src/i18n/resolveTemplate.ts b/client/src/i18n/resolveTemplate.ts new file mode 100644 index 000000000..f6f31d145 --- /dev/null +++ b/client/src/i18n/resolveTemplate.ts @@ -0,0 +1,28 @@ +import en from '@trek/shared/i18n/en' +import { resolvePluralKey } from '@trek/shared' +import type { TranslationStrings } from '@trek/shared/i18n' + +/** + * The template a key reads as. A numeric `count` (or `n`) picks the plural + * form the language's own rule selects (see `@trek/shared` plural helpers): + * `places.count.one`, `.few`, ... and the key itself as the general form. + * The active locale is asked with its rule first; English, with English's, + * only when the locale has none of the key's forms. + */ +export function resolveTemplate( + strings: TranslationStrings, + intlLanguage: string, + key: string, + params?: Record, +): string { + const raw = params?.count ?? params?.n + const count = typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined + if (count !== undefined) { + const own = resolvePluralKey(strings, key, count, intlLanguage) + if (own) return strings[own] as string + const fallback = resolvePluralKey(en, key, count, 'en') + if (fallback) return en[fallback] as string + return key + } + return (strings[key] ?? en[key] ?? key) as string +} diff --git a/client/src/mobile/screens/dashboard/MDashboard.tsx b/client/src/mobile/screens/dashboard/MDashboard.tsx index ccfbc3a79..f5e357c41 100644 --- a/client/src/mobile/screens/dashboard/MDashboard.tsx +++ b/client/src/mobile/screens/dashboard/MDashboard.tsx @@ -403,9 +403,9 @@ function MSpotlightCard({ trip, t, onOpen, actions }: { )}
- } label={days === 1 ? t('dashboard.mobile.spotlightDayOne', { count: days }) : t('dashboard.mobile.spotlightDaysMany', { count: days })} /> - } label={places === 1 ? t('dashboard.hero.destinationOne', { count: places }) : t('dashboard.hero.destinationMany', { count: places })} /> - } label={people === 1 ? t('dashboard.hero.travelerOne', { count: people }) : t('dashboard.hero.travelerMany', { count: people })} /> + } label={t('dashboard.mobile.spotlightDays', { count: days })} /> + } label={t('dashboard.hero.destinations', { count: places })} /> + } label={t('dashboard.hero.travelers', { count: people })} />
@@ -564,7 +564,7 @@ function MTripListCard({ trip, locale, t, badge, pluginBadges, actions, onOpen }
- +
diff --git a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx index 986736d94..3856794a4 100644 --- a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx +++ b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx @@ -331,7 +331,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
- {filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })} + {t('places.count', { count: filtered.length })}
diff --git a/client/src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx index 393dac72d..d2e510293 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx @@ -187,14 +187,8 @@ function StatusBand({ corridor, t }: { corridor: MRtCorridorController; t: Trans // The phone's own wording, because the desk's ends in "narrow the corridor" and the // corridor width is a control this screen deliberately does not have. What it does // have is the category chips, and fewer of them really is what makes a box fit. - // Two keys picked by the count: the i18n layer has no plural engine, so a single key - // with {count} in a sentence that names the noun once reads as "1 stretches". if (corridor.truncatedAreas > 0) { - notes.push( - t(corridor.truncatedAreas === 1 ? 'mobileTrip.rtTruncated.one' : 'mobileTrip.rtTruncated.other', { - count: corridor.truncatedAreas, - }), - ) + notes.push(t('mobileTrip.rtTruncated', { count: corridor.truncatedAreas })) } if (corridor.offline) return }>{t('mobileTrip.rtSearchOffline')} diff --git a/client/src/mobile/screens/trip/tabs/MCollabPolls.tsx b/client/src/mobile/screens/trip/tabs/MCollabPolls.tsx index c5031ff5d..50164c676 100644 --- a/client/src/mobile/screens/trip/tabs/MCollabPolls.tsx +++ b/client/src/mobile/screens/trip/tabs/MCollabPolls.tsx @@ -281,7 +281,7 @@ function PollCardRow({ poll, canEdit, currentUserId, t, onVote, onClosePoll, onD )} - {t(total === 1 ? 'collab.polls.vote' : 'collab.polls.votes', { n: total })} + {t('collab.polls.votes', { n: total })} diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 88d7a9367..3354bd3fc 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -798,7 +798,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
{streak} - {streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')} + {t('atlas.yearsInRow', { count: streak })}
)} @@ -807,7 +807,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
{tripsThisYear} - {tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear} + {t('atlas.tripsIn', { count: tripsThisYear })} {thisYear}
)} diff --git a/client/src/pages/AtlasPage.wiring.test.tsx b/client/src/pages/AtlasPage.wiring.test.tsx index ad6fc1c02..74e86aaeb 100644 --- a/client/src/pages/AtlasPage.wiring.test.tsx +++ b/client/src/pages/AtlasPage.wiring.test.tsx @@ -428,9 +428,9 @@ describe('AtlasPage wiring', () => { expect(screen.getAllByText('9')).toHaveLength(2); expect(screen.getAllByText('40')).toHaveLength(2); expect(screen.getByText('atlas.europe')).toBeInTheDocument(); - // Singular labels are picked when streak / tripsThisYear are 1. - expect(screen.getByText('atlas.yearInRow')).toBeInTheDocument(); - expect(screen.getByText(/atlas\.tripIn/)).toBeInTheDocument(); + // The labels get the counts, so the language's own rule picks the form. + expect(screen.getByText('atlas.yearsInRow:1')).toBeInTheDocument(); + expect(screen.getByText(/atlas\.tripsIn:1/)).toBeInTheDocument(); // The last-trip tile used to sit in this row; the row is stats only now. expect(screen.queryByText('Paris Trip')).not.toBeInTheDocument(); diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index f15a82136..ae1594951 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -367,11 +367,11 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch const daysLeft = Math.max(0, Math.round((endMid.getTime() - todayMid.getTime()) / MS_PER_DAY)) countdownTop = t('dashboard.status.ongoing') countdownNumber = String(daysLeft) - countdownLabel = daysLeft === 0 ? t('dashboard.hero.lastDay') : daysLeft === 1 ? t('dashboard.hero.dayLeft') : t('dashboard.hero.daysLeft') + countdownLabel = daysLeft === 0 ? t('dashboard.hero.lastDay') : t('dashboard.hero.daysLeft', { count: daysLeft }) } else if (until !== null && until >= 0) { countdownTop = t('dashboard.hero.startsIn') countdownNumber = String(until) - countdownLabel = until === 1 ? t('dashboard.hero.dayUnitOne') : t('dashboard.hero.dayUnitMany') + countdownLabel = t('dashboard.hero.dayUnit', { count: until }) } const members = bundle?.members || [] @@ -398,7 +398,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch {members.length > 4 &&
+{members.length - 4}
} {members.length === 0 &&
{initials(trip.owner_username)}
} -
{buddyCount === 1 ? t('dashboard.hero.travelerOne', { count: buddyCount }) : t('dashboard.hero.travelerMany', { count: buddyCount })}
+
{t('dashboard.hero.travelers', { count: buddyCount })}
@@ -433,7 +433,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch {places.length === 0 &&
} {places.length > 3 &&
+{places.length - 3}
}
-
{placeCount === 1 ? t('dashboard.hero.destinationOne', { count: placeCount }) : t('dashboard.hero.destinationMany', { count: placeCount })}
+
{t('dashboard.hero.destinations', { count: placeCount })}
) @@ -666,7 +666,7 @@ function TripCard({ trip, locale, badges, matchedPlaces, onOpen, onEdit, onCopy,
{trip.day_count ?? 0}{t('dashboard.days')}
{trip.place_count ?? 0}{t('dashboard.places')}
-
{trip.shared_count ?? 0}{trip.shared_count === 1 ? t('dashboard.card.buddyOne') : t('dashboard.members')}
+
{trip.shared_count ?? 0}{t('dashboard.card.buddies', { count: trip.shared_count ?? 0 })}
diff --git a/client/src/pages/InAppNotificationsPage.tsx b/client/src/pages/InAppNotificationsPage.tsx index a34c3a706..294badbc4 100644 --- a/client/src/pages/InAppNotificationsPage.tsx +++ b/client/src/pages/InAppNotificationsPage.tsx @@ -37,7 +37,7 @@ function InAppNotificationsPageDesktop(): React.ReactElement { )}

- {total} {total === 1 ? 'notification' : 'notifications'} + {total} {t('notifications.countLabel', { count: total })}

diff --git a/client/src/pages/OAuthAuthorizePage.tsx b/client/src/pages/OAuthAuthorizePage.tsx index cbf6af6cd..4b751e299 100644 --- a/client/src/pages/OAuthAuthorizePage.tsx +++ b/client/src/pages/OAuthAuthorizePage.tsx @@ -112,12 +112,7 @@ export default function OAuthAuthorizePage(): React.ReactElement { : validation?.scopeSelectable && selectedScopes.length === 0 ? t('oauth.authorize.selectScope') : validation?.scopeSelectable - ? t( - selectedScopes.length !== 1 - ? 'oauth.authorize.approveManyScopes' - : 'oauth.authorize.approveOneScope', - { count: selectedScopes.length }, - ) + ? t('oauth.authorize.approveScopes', { count: selectedScopes.length }) : t('oauth.authorize.approveAccess')}