From bee500880e61517049fe4279e93bd577286b37ef Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 00:01:59 +0000 Subject: [PATCH] fix(i18n): every language says one file and five files its own way Finishes the plural work the previous wip commit started. t() picks a count string's form by the language's own Intl.PluralRules, and every locale now spells out the forms its language reaches with whole numbers, about 160 count strings in all: one, few and many in Russian, Ukrainian and Polish, one and few in Czech and Slovak, five forms in Arabic, one in the others, none in Japanese, Korean, Chinese, Thai, Vietnamese and Indonesian. Each language was translated and then reviewed on its own. English no longer reads "1 files", "1 updates available" or "photo(s)". Count labels shown next to a number now take that number too: the atlas and admin statistics, the trip cards, journey and shared trip pills, packing templates, receipts, storage usage, the Studio export note. A heading that shared its key with a counted label keeps a key of its own (the boarding pass heading, the Bookings tab), so languages that put a noun after a number in the singular keep a plural heading. The plugin hosts chip passed its count through .replace() after t() and never got a form; three toasts sent the count as text. Mail and push texts for shared photos and suppressed storage failures pick their form in code through pluralForm. A whole number sent as text counts too, which is how in-app notification params arrive, and the plural rule follows the language of the strings on screen while another one still loads. The parity check knows which forms each language needs and refuses missing and impossible ones. It also reads keys that contain a colon now: it skipped them, which hid 58 missing plugin permission texts in Catalan and 35 in Vietnamese, both translated. The client parity test covers every language of the registry instead of a hand-kept list of 19, and the placeholder spec lets a form spell its number out only where its category is that one number. Brazilian Portuguese error screens were in Breton, and an Arabic dashboard label held Hebrew text. --- CLAUDE.md | 1 + .../Admin/AdminPluginsPanel.test.tsx | 4 +- .../components/Admin/AdminPluginsPanel.tsx | 2 +- .../Admin/PackingTemplateManager.tsx | 2 +- .../Admin/storage/AdminStoragePanel.test.tsx | 2 +- .../Admin/storage/AdminStoragePanel.tsx | 4 +- .../src/components/Budget/CostsPanel.test.tsx | 2 +- client/src/components/Budget/CostsPanel.tsx | 2 +- .../Collections/CopyToTripModal.test.tsx | 4 +- .../Collections/ImportFromTripModal.test.tsx | 2 +- .../SaveTripPlacesToListModal.test.tsx | 2 +- .../Dawarich/DawarichAtlasDialog.test.tsx | 18 +- .../Dawarich/DawarichAtlasDialog.tsx | 4 +- .../JourneyDetailPageGalleryView.test.tsx | 2 +- .../Journey/JourneyDetailPageGalleryView.tsx | 4 +- .../JourneyDetailPageSettingsDialog.tsx | 2 +- client/src/components/PDF/TripPDF.tsx | 4 +- .../Packing/ApplyTemplateButton.tsx | 2 +- .../Packing/PackingListPanelBagCard.tsx | 2 +- .../Packing/PackingListPanelBagModal.tsx | 2 +- .../Packing/PackingListPanelBagSidebar.tsx | 2 +- .../Packing/PackingListPanelHeader.tsx | 2 +- .../Packing/usePackingListPanel.test.tsx | 4 +- .../Planner/DayPlanSidebar.test.tsx | 2 +- .../Planner/PlaceInspector.test.tsx | 2 +- .../Planner/PlacesSidebarHeader.tsx | 2 +- .../Planner/TransitSearchPanel.test.tsx | 2 +- .../Planner/usePlacesSidebar.test.tsx | 2 +- .../Roadtrip/RoadtripCorridorPanel.test.tsx | 2 +- client/src/components/Studio/StudioExport.tsx | 2 +- .../Trips/TripMembersModal.test.tsx | 6 +- client/src/i18n/TranslationContext.tsx | 17 +- client/src/i18n/resolveTemplate.test.ts | 17 + client/src/i18n/resolveTemplate.ts | 13 +- client/src/mobile/screens/admin/MAdmin.tsx | 8 +- .../admin/MAdminPackingTemplateManager.tsx | 2 +- .../screens/admin/MAdminPluginsPanel.tsx | 2 +- .../screens/admin/MAdminStoragePanel.tsx | 4 +- .../screens/admin/MAdminUsersSection.tsx | 2 +- client/src/mobile/screens/atlas/MAtlas.tsx | 2 +- .../mobile/screens/atlas/MAtlasStatsCard.tsx | 10 +- .../mobile/screens/dashboard/MDashboard.tsx | 4 +- .../mobile/screens/journey/MJourneyDetail.tsx | 2 +- .../mobile/screens/trip/tabs/MBagsSheet.tsx | 4 +- .../mobile/screens/trip/tabs/MCostsTab.tsx | 2 +- .../screens/trip/tabs/MPackingListTab.tsx | 2 +- client/src/pages/AdminPage.tsx | 8 +- client/src/pages/AtlasPage.test.tsx | 42 +- client/src/pages/AtlasPage.tsx | 16 +- client/src/pages/AtlasPage.wiring.test.tsx | 4 +- client/src/pages/DashboardPage.tsx | 6 +- client/src/pages/JourneyDetailPage.test.tsx | 20 +- client/src/pages/JourneyDetailPage.tsx | 2 +- client/src/pages/JourneyPage.tsx | 9 +- client/src/pages/JourneyPage.wiring.test.tsx | 2 +- client/src/pages/SharedTripPage.tsx | 4 +- client/src/pages/admin/AdminUsersTab.tsx | 2 +- .../pages/journeyDetail/useJourneyDetail.ts | 2 +- client/src/pages/sharedTrip/SharedDayCard.tsx | 2 +- .../pages/sharedTrip/SharedUnplannedCard.tsx | 2 +- .../unit/i18n/TranslationContext.test.tsx | 12 +- client/tests/unit/i18n/parity.test.ts | 78 +- .../MAdminPackingTemplateManager.test.tsx | 2 +- .../mobile/admin/MAdminPluginsPanel.test.tsx | 6 +- .../mobile/admin/MAdminUsersSection.test.tsx | 2 +- .../tests/unit/mobile/atlas/MAtlas.test.tsx | 4 +- .../collections/MCollTripPickerSheet.test.tsx | 4 +- .../unit/mobile/trip/MBagsSheet.test.tsx | 4 +- .../unit/mobile/trip/MCollabPolls.test.tsx | 6 +- .../unit/mobile/trip/MCostSheet.test.tsx | 2 +- .../unit/mobile/trip/MPackingListTab.test.tsx | 2 +- .../MPlacesSaveToCollectionSheet.test.tsx | 2 +- .../mobile/trip/MRtCorridorSheet.test.tsx | 10 +- .../unit/mobile/trip/MTripSheets.test.tsx | 2 +- shared/CLAUDE.md | 3 +- shared/scripts/i18n-parity.mjs | 37 +- shared/src/i18n/ar/admin.ts | 61 +- shared/src/i18n/ar/atlas.ts | 73 +- shared/src/i18n/ar/budget.ts | 59 +- shared/src/i18n/ar/collab.ts | 8 +- shared/src/i18n/ar/collection.ts | 143 +- shared/src/i18n/ar/dashboard.ts | 100 +- shared/src/i18n/ar/dawarich.ts | 58 +- shared/src/i18n/ar/docsync.ts | 2 +- shared/src/i18n/ar/externalNotifications.ts | 30 +- shared/src/i18n/ar/files.ts | 18 +- shared/src/i18n/ar/help.ts | 21 +- shared/src/i18n/ar/inspector.ts | 7 +- shared/src/i18n/ar/journey.ts | 71 +- shared/src/i18n/ar/map.ts | 10 + shared/src/i18n/ar/members.ts | 6 +- shared/src/i18n/ar/memories.ts | 14 +- shared/src/i18n/ar/mobileJourney.ts | 28 +- shared/src/i18n/ar/mobileTrip.ts | 45 +- shared/src/i18n/ar/mobileVacay.ts | 14 +- shared/src/i18n/ar/notif.ts | 5 + shared/src/i18n/ar/notifications.ts | 6 + shared/src/i18n/ar/oauth.ts | 8 +- shared/src/i18n/ar/packing.ts | 22 +- shared/src/i18n/ar/photos.ts | 5 + shared/src/i18n/ar/places.ts | 47 +- shared/src/i18n/ar/planner.ts | 21 +- shared/src/i18n/ar/reservations.ts | 42 +- shared/src/i18n/ar/roadtrip.ts | 59 +- shared/src/i18n/ar/settings.ts | 12 +- shared/src/i18n/ar/shared.ts | 7 +- shared/src/i18n/ar/storage.ts | 7 +- shared/src/i18n/ar/tours.ts | 6 +- shared/src/i18n/ar/trip.ts | 28 +- shared/src/i18n/az/admin.ts | 17 +- shared/src/i18n/az/atlas.ts | 23 +- shared/src/i18n/az/budget.ts | 11 + shared/src/i18n/az/collection.ts | 22 + shared/src/i18n/az/dashboard.ts | 18 +- shared/src/i18n/az/dawarich.ts | 10 +- shared/src/i18n/az/externalNotifications.ts | 16 +- shared/src/i18n/az/files.ts | 2 + shared/src/i18n/az/help.ts | 3 + shared/src/i18n/az/inspector.ts | 1 + shared/src/i18n/az/journey.ts | 14 +- shared/src/i18n/az/login.ts | 10 +- shared/src/i18n/az/map.ts | 2 + shared/src/i18n/az/memories.ts | 2 + shared/src/i18n/az/mobileJourney.ts | 4 + shared/src/i18n/az/mobileTrip.ts | 5 + shared/src/i18n/az/mobileVacay.ts | 2 + shared/src/i18n/az/notif.ts | 1 + shared/src/i18n/az/notifications.ts | 2 + shared/src/i18n/az/packing.ts | 5 + shared/src/i18n/az/photos.ts | 1 + shared/src/i18n/az/places.ts | 7 + shared/src/i18n/az/planner.ts | 3 + shared/src/i18n/az/reservations.ts | 6 + shared/src/i18n/az/roadtrip.ts | 15 +- shared/src/i18n/az/settings.ts | 5 +- shared/src/i18n/az/shared.ts | 1 + shared/src/i18n/az/storage.ts | 3 +- shared/src/i18n/az/trip.ts | 4 + shared/src/i18n/br/admin.ts | 13 +- shared/src/i18n/br/atlas.ts | 9 +- shared/src/i18n/br/budget.ts | 17 +- shared/src/i18n/br/collection.ts | 24 +- shared/src/i18n/br/common.ts | 19 +- shared/src/i18n/br/dashboard.ts | 14 +- shared/src/i18n/br/dawarich.ts | 14 +- shared/src/i18n/br/docsync.ts | 2 +- shared/src/i18n/br/externalNotifications.ts | 18 +- shared/src/i18n/br/files.ts | 4 +- shared/src/i18n/br/help.ts | 5 +- shared/src/i18n/br/inspector.ts | 1 + shared/src/i18n/br/journey.ts | 17 +- shared/src/i18n/br/map.ts | 8 +- shared/src/i18n/br/memories.ts | 2 + shared/src/i18n/br/mobileJourney.ts | 4 + shared/src/i18n/br/mobileTrip.ts | 7 +- shared/src/i18n/br/mobileVacay.ts | 2 + shared/src/i18n/br/notif.ts | 3 +- shared/src/i18n/br/notifications.ts | 2 + shared/src/i18n/br/oauth.ts | 4 +- shared/src/i18n/br/packing.ts | 6 +- shared/src/i18n/br/photos.ts | 1 + shared/src/i18n/br/places.ts | 9 +- shared/src/i18n/br/planner.ts | 3 + shared/src/i18n/br/reservations.ts | 16 +- shared/src/i18n/br/roadtrip.ts | 17 +- shared/src/i18n/br/settings.ts | 6 +- shared/src/i18n/br/shared.ts | 1 + shared/src/i18n/br/storage.ts | 3 +- shared/src/i18n/br/trip.ts | 4 + shared/src/i18n/ca/admin.ts | 135 +- shared/src/i18n/ca/atlas.ts | 13 +- shared/src/i18n/ca/budget.ts | 14 +- shared/src/i18n/ca/collab.ts | 16 +- shared/src/i18n/ca/collection.ts | 29 +- shared/src/i18n/ca/common.ts | 9 +- shared/src/i18n/ca/dashboard.ts | 14 +- shared/src/i18n/ca/dawarich.ts | 14 +- shared/src/i18n/ca/docsync.ts | 2 +- shared/src/i18n/ca/externalNotifications.ts | 15 +- shared/src/i18n/ca/files.ts | 2 + shared/src/i18n/ca/help.ts | 5 +- shared/src/i18n/ca/inspector.ts | 1 + shared/src/i18n/ca/journey.ts | 29 +- shared/src/i18n/ca/map.ts | 6 +- shared/src/i18n/ca/memories.ts | 2 + shared/src/i18n/ca/mobileJourney.ts | 4 + shared/src/i18n/ca/mobileTrip.ts | 8 +- shared/src/i18n/ca/mobileVacay.ts | 2 + shared/src/i18n/ca/notif.ts | 3 +- shared/src/i18n/ca/notifications.ts | 2 + shared/src/i18n/ca/oauth.ts | 3 +- shared/src/i18n/ca/packing.ts | 4 + shared/src/i18n/ca/photos.ts | 1 + shared/src/i18n/ca/places.ts | 9 +- shared/src/i18n/ca/planner.ts | 3 + shared/src/i18n/ca/reservations.ts | 16 +- shared/src/i18n/ca/roadtrip.ts | 12 + shared/src/i18n/ca/settings.ts | 25 +- shared/src/i18n/ca/shared.ts | 1 + shared/src/i18n/ca/storage.ts | 3 +- shared/src/i18n/ca/trip.ts | 6 +- shared/src/i18n/cs/admin.ts | 26 +- shared/src/i18n/cs/atlas.ts | 34 +- shared/src/i18n/cs/budget.ts | 24 +- shared/src/i18n/cs/collab.ts | 1 + shared/src/i18n/cs/collection.ts | 62 +- shared/src/i18n/cs/dashboard.ts | 35 +- shared/src/i18n/cs/dawarich.ts | 32 +- shared/src/i18n/cs/docsync.ts | 2 +- shared/src/i18n/cs/externalNotifications.ts | 21 +- shared/src/i18n/cs/files.ts | 7 +- shared/src/i18n/cs/help.ts | 12 +- shared/src/i18n/cs/inspector.ts | 2 + shared/src/i18n/cs/journey.ts | 34 +- shared/src/i18n/cs/map.ts | 8 +- shared/src/i18n/cs/members.ts | 1 + shared/src/i18n/cs/memories.ts | 4 + shared/src/i18n/cs/mobileJourney.ts | 8 + shared/src/i18n/cs/mobileTrip.ts | 18 +- shared/src/i18n/cs/mobileVacay.ts | 8 +- shared/src/i18n/cs/notif.ts | 4 +- shared/src/i18n/cs/notifications.ts | 3 + shared/src/i18n/cs/oauth.ts | 5 +- shared/src/i18n/cs/packing.ts | 8 + shared/src/i18n/cs/photos.ts | 2 + shared/src/i18n/cs/places.ts | 23 +- shared/src/i18n/cs/planner.ts | 6 + shared/src/i18n/cs/reservations.ts | 22 +- shared/src/i18n/cs/roadtrip.ts | 28 +- shared/src/i18n/cs/settings.ts | 8 +- shared/src/i18n/cs/shared.ts | 2 + shared/src/i18n/cs/storage.ts | 4 +- shared/src/i18n/cs/tours.ts | 3 +- shared/src/i18n/cs/trip.ts | 8 + shared/src/i18n/de/admin.ts | 11 +- shared/src/i18n/de/atlas.ts | 13 +- shared/src/i18n/de/budget.ts | 12 +- shared/src/i18n/de/collection.ts | 24 +- shared/src/i18n/de/dashboard.ts | 12 +- shared/src/i18n/de/dawarich.ts | 14 +- shared/src/i18n/de/docsync.ts | 2 +- shared/src/i18n/de/externalNotifications.ts | 13 +- shared/src/i18n/de/files.ts | 2 + shared/src/i18n/de/help.ts | 5 +- shared/src/i18n/de/inspector.ts | 1 + shared/src/i18n/de/journey.ts | 15 +- shared/src/i18n/de/map.ts | 6 +- shared/src/i18n/de/memories.ts | 2 + shared/src/i18n/de/mobileJourney.ts | 4 + shared/src/i18n/de/mobileTrip.ts | 11 +- shared/src/i18n/de/mobileVacay.ts | 2 + shared/src/i18n/de/notif.ts | 3 +- shared/src/i18n/de/notifications.ts | 2 + shared/src/i18n/de/oauth.ts | 4 +- shared/src/i18n/de/packing.ts | 4 + shared/src/i18n/de/photos.ts | 1 + shared/src/i18n/de/places.ts | 7 + shared/src/i18n/de/planner.ts | 3 + shared/src/i18n/de/reservations.ts | 14 +- shared/src/i18n/de/roadtrip.ts | 15 +- shared/src/i18n/de/settings.ts | 6 +- shared/src/i18n/de/shared.ts | 1 + shared/src/i18n/de/storage.ts | 3 +- shared/src/i18n/de/trip.ts | 4 + shared/src/i18n/en/admin.ts | 19 +- shared/src/i18n/en/atlas.ts | 7 +- shared/src/i18n/en/budget.ts | 5 +- shared/src/i18n/en/dashboard.ts | 3 + shared/src/i18n/en/dawarich.ts | 5 +- shared/src/i18n/en/docsync.ts | 2 +- shared/src/i18n/en/externalNotifications.ts | 15 +- shared/src/i18n/en/journey.ts | 11 +- shared/src/i18n/en/members.ts | 2 +- shared/src/i18n/en/mobileTrip.ts | 2 +- shared/src/i18n/en/places.ts | 1 + shared/src/i18n/en/roadtrip.ts | 2 +- shared/src/i18n/en/settings.ts | 3 +- shared/src/i18n/en/shared.ts | 1 + shared/src/i18n/en/storage.ts | 3 +- shared/src/i18n/es/admin.ts | 13 +- shared/src/i18n/es/atlas.ts | 9 +- shared/src/i18n/es/budget.ts | 13 +- shared/src/i18n/es/collection.ts | 30 +- shared/src/i18n/es/dashboard.ts | 14 +- shared/src/i18n/es/dawarich.ts | 14 +- shared/src/i18n/es/docsync.ts | 2 +- shared/src/i18n/es/externalNotifications.ts | 15 +- shared/src/i18n/es/files.ts | 2 + shared/src/i18n/es/help.ts | 5 +- shared/src/i18n/es/inspector.ts | 1 + shared/src/i18n/es/journey.ts | 17 +- shared/src/i18n/es/map.ts | 6 +- shared/src/i18n/es/memories.ts | 2 + shared/src/i18n/es/mobileJourney.ts | 4 + shared/src/i18n/es/mobileTrip.ts | 9 +- shared/src/i18n/es/mobileVacay.ts | 2 + shared/src/i18n/es/notif.ts | 3 +- shared/src/i18n/es/notifications.ts | 2 + shared/src/i18n/es/oauth.ts | 4 +- shared/src/i18n/es/packing.ts | 6 +- shared/src/i18n/es/photos.ts | 1 + shared/src/i18n/es/places.ts | 9 +- shared/src/i18n/es/planner.ts | 3 + shared/src/i18n/es/reservations.ts | 16 +- shared/src/i18n/es/roadtrip.ts | 16 +- shared/src/i18n/es/settings.ts | 6 +- shared/src/i18n/es/shared.ts | 1 + shared/src/i18n/es/storage.ts | 3 +- shared/src/i18n/es/trip.ts | 4 + shared/src/i18n/et/admin.ts | 9 + shared/src/i18n/et/atlas.ts | 13 +- shared/src/i18n/et/budget.ts | 12 +- shared/src/i18n/et/collection.ts | 21 + shared/src/i18n/et/dashboard.ts | 10 + shared/src/i18n/et/dawarich.ts | 14 +- shared/src/i18n/et/externalNotifications.ts | 15 +- shared/src/i18n/et/files.ts | 2 + shared/src/i18n/et/help.ts | 3 + shared/src/i18n/et/inspector.ts | 1 + shared/src/i18n/et/journey.ts | 13 +- shared/src/i18n/et/map.ts | 2 + shared/src/i18n/et/memories.ts | 2 + shared/src/i18n/et/mobileJourney.ts | 4 + shared/src/i18n/et/mobileTrip.ts | 9 +- shared/src/i18n/et/mobileVacay.ts | 2 + shared/src/i18n/et/notif.ts | 1 + shared/src/i18n/et/notifications.ts | 2 + shared/src/i18n/et/packing.ts | 4 + shared/src/i18n/et/photos.ts | 1 + shared/src/i18n/et/places.ts | 7 + shared/src/i18n/et/planner.ts | 3 + shared/src/i18n/et/reservations.ts | 6 + shared/src/i18n/et/roadtrip.ts | 14 +- shared/src/i18n/et/settings.ts | 4 +- shared/src/i18n/et/shared.ts | 1 + shared/src/i18n/et/storage.ts | 3 +- shared/src/i18n/et/trip.ts | 4 + .../externalNotifications/locales.spec.ts | 12 + shared/src/i18n/fr/admin.ts | 40 +- shared/src/i18n/fr/atlas.ts | 9 +- shared/src/i18n/fr/budget.ts | 14 +- shared/src/i18n/fr/collection.ts | 26 +- shared/src/i18n/fr/common.ts | 8 +- shared/src/i18n/fr/dashboard.ts | 14 +- shared/src/i18n/fr/dawarich.ts | 14 +- shared/src/i18n/fr/docsync.ts | 2 +- shared/src/i18n/fr/externalNotifications.ts | 13 +- shared/src/i18n/fr/files.ts | 4 +- shared/src/i18n/fr/help.ts | 5 +- shared/src/i18n/fr/inspector.ts | 1 + shared/src/i18n/fr/journey.ts | 28 +- shared/src/i18n/fr/map.ts | 6 +- shared/src/i18n/fr/memories.ts | 2 + shared/src/i18n/fr/mobileJourney.ts | 4 + shared/src/i18n/fr/mobileTrip.ts | 13 +- shared/src/i18n/fr/mobileVacay.ts | 2 + shared/src/i18n/fr/notif.ts | 3 +- shared/src/i18n/fr/notifications.ts | 2 + shared/src/i18n/fr/oauth.ts | 6 +- shared/src/i18n/fr/packing.ts | 4 + shared/src/i18n/fr/pdf.ts | 2 +- shared/src/i18n/fr/photos.ts | 1 + shared/src/i18n/fr/places.ts | 13 +- shared/src/i18n/fr/planner.ts | 3 + shared/src/i18n/fr/reservations.ts | 16 +- shared/src/i18n/fr/roadtrip.ts | 13 +- shared/src/i18n/fr/settings.ts | 15 +- shared/src/i18n/fr/shared.ts | 1 + shared/src/i18n/fr/storage.ts | 3 +- shared/src/i18n/fr/trip.ts | 8 +- shared/src/i18n/gr/admin.ts | 11 +- shared/src/i18n/gr/atlas.ts | 13 +- shared/src/i18n/gr/budget.ts | 11 + shared/src/i18n/gr/collection.ts | 26 +- shared/src/i18n/gr/dashboard.ts | 34 +- shared/src/i18n/gr/dawarich.ts | 16 +- shared/src/i18n/gr/docsync.ts | 2 +- shared/src/i18n/gr/externalNotifications.ts | 18 +- shared/src/i18n/gr/files.ts | 2 + shared/src/i18n/gr/help.ts | 5 +- shared/src/i18n/gr/inspector.ts | 1 + shared/src/i18n/gr/journey.ts | 20 +- shared/src/i18n/gr/map.ts | 6 +- shared/src/i18n/gr/memories.ts | 2 + shared/src/i18n/gr/mobileJourney.ts | 4 + shared/src/i18n/gr/mobileTrip.ts | 11 +- shared/src/i18n/gr/mobileVacay.ts | 4 +- shared/src/i18n/gr/notif.ts | 3 +- shared/src/i18n/gr/notifications.ts | 2 + shared/src/i18n/gr/oauth.ts | 4 +- shared/src/i18n/gr/packing.ts | 4 + shared/src/i18n/gr/photos.ts | 1 + shared/src/i18n/gr/places.ts | 7 + shared/src/i18n/gr/planner.ts | 3 + shared/src/i18n/gr/reservations.ts | 14 +- shared/src/i18n/gr/roadtrip.ts | 19 +- shared/src/i18n/gr/settings.ts | 4 +- shared/src/i18n/gr/shared.ts | 1 + shared/src/i18n/gr/storage.ts | 3 +- shared/src/i18n/gr/tours.ts | 2 +- shared/src/i18n/gr/trip.ts | 4 + shared/src/i18n/hu/admin.ts | 17 +- shared/src/i18n/hu/atlas.ts | 25 +- shared/src/i18n/hu/budget.ts | 17 +- shared/src/i18n/hu/collection.ts | 28 +- shared/src/i18n/hu/dashboard.ts | 14 +- shared/src/i18n/hu/dawarich.ts | 18 +- shared/src/i18n/hu/docsync.ts | 2 +- shared/src/i18n/hu/externalNotifications.ts | 15 +- shared/src/i18n/hu/files.ts | 2 + shared/src/i18n/hu/help.ts | 5 +- shared/src/i18n/hu/inspector.ts | 1 + shared/src/i18n/hu/journey.ts | 17 +- shared/src/i18n/hu/map.ts | 4 +- shared/src/i18n/hu/memories.ts | 2 + shared/src/i18n/hu/mobileJourney.ts | 4 + shared/src/i18n/hu/mobileTrip.ts | 11 +- shared/src/i18n/hu/mobileVacay.ts | 2 + shared/src/i18n/hu/notif.ts | 1 + shared/src/i18n/hu/notifications.ts | 2 + shared/src/i18n/hu/oauth.ts | 4 +- shared/src/i18n/hu/packing.ts | 9 +- shared/src/i18n/hu/photos.ts | 1 + shared/src/i18n/hu/places.ts | 11 +- shared/src/i18n/hu/planner.ts | 3 + shared/src/i18n/hu/reservations.ts | 8 +- shared/src/i18n/hu/roadtrip.ts | 20 +- shared/src/i18n/hu/settings.ts | 4 +- shared/src/i18n/hu/shared.ts | 1 + shared/src/i18n/hu/storage.ts | 3 +- shared/src/i18n/hu/tours.ts | 2 +- shared/src/i18n/hu/trip.ts | 6 +- shared/src/i18n/i18n-placeholders.spec.ts | 72 +- shared/src/i18n/id/admin.ts | 2 +- shared/src/i18n/id/atlas.ts | 4 +- shared/src/i18n/id/budget.ts | 4 +- shared/src/i18n/id/collection.ts | 4 +- shared/src/i18n/id/dashboard.ts | 1 + shared/src/i18n/id/dawarich.ts | 4 +- shared/src/i18n/id/docsync.ts | 2 +- shared/src/i18n/id/externalNotifications.ts | 2 +- shared/src/i18n/id/help.ts | 2 +- shared/src/i18n/id/journey.ts | 7 +- shared/src/i18n/id/map.ts | 4 +- shared/src/i18n/id/mobileTrip.ts | 4 +- shared/src/i18n/id/notifications.ts | 1 + shared/src/i18n/id/oauth.ts | 2 +- shared/src/i18n/id/planner.ts | 2 +- shared/src/i18n/id/roadtrip.ts | 10 +- shared/src/i18n/id/settings.ts | 2 +- shared/src/i18n/id/storage.ts | 2 +- shared/src/i18n/id/trip.ts | 2 +- shared/src/i18n/it/admin.ts | 25 +- shared/src/i18n/it/atlas.ts | 13 +- shared/src/i18n/it/budget.ts | 10 + shared/src/i18n/it/collection.ts | 24 +- shared/src/i18n/it/common.ts | 2 +- shared/src/i18n/it/dashboard.ts | 14 +- shared/src/i18n/it/dawarich.ts | 16 +- shared/src/i18n/it/docsync.ts | 2 +- shared/src/i18n/it/externalNotifications.ts | 10 +- shared/src/i18n/it/files.ts | 4 +- shared/src/i18n/it/help.ts | 5 +- shared/src/i18n/it/inspector.ts | 1 + shared/src/i18n/it/journey.ts | 19 +- shared/src/i18n/it/map.ts | 8 +- shared/src/i18n/it/memories.ts | 2 + shared/src/i18n/it/mobileJourney.ts | 4 + shared/src/i18n/it/mobileTrip.ts | 13 +- shared/src/i18n/it/mobileVacay.ts | 2 + shared/src/i18n/it/notif.ts | 1 + shared/src/i18n/it/notifications.ts | 2 + shared/src/i18n/it/oauth.ts | 4 +- shared/src/i18n/it/packing.ts | 4 + shared/src/i18n/it/photos.ts | 1 + shared/src/i18n/it/places.ts | 9 +- shared/src/i18n/it/planner.ts | 3 + shared/src/i18n/it/reservations.ts | 16 +- shared/src/i18n/it/roadtrip.ts | 17 +- shared/src/i18n/it/settings.ts | 6 +- shared/src/i18n/it/shared.ts | 1 + shared/src/i18n/it/storage.ts | 3 +- shared/src/i18n/it/trip.ts | 4 + shared/src/i18n/ja/atlas.ts | 2 +- shared/src/i18n/ja/budget.ts | 4 +- shared/src/i18n/ja/dashboard.ts | 3 +- shared/src/i18n/ja/dawarich.ts | 4 +- shared/src/i18n/ja/docsync.ts | 2 +- shared/src/i18n/ja/files.ts | 2 +- shared/src/i18n/ja/help.ts | 2 +- shared/src/i18n/ja/journey.ts | 16 +- shared/src/i18n/ja/mobileTrip.ts | 3 +- shared/src/i18n/ja/notifications.ts | 1 + shared/src/i18n/ja/oauth.ts | 2 +- shared/src/i18n/ja/settings.ts | 2 +- shared/src/i18n/ja/shared.ts | 2 +- shared/src/i18n/ja/storage.ts | 2 +- shared/src/i18n/ja/trip.ts | 2 +- shared/src/i18n/ko/atlas.ts | 4 +- shared/src/i18n/ko/budget.ts | 2 +- shared/src/i18n/ko/dashboard.ts | 1 + shared/src/i18n/ko/dawarich.ts | 8 +- shared/src/i18n/ko/docsync.ts | 2 +- shared/src/i18n/ko/externalNotifications.ts | 6 +- shared/src/i18n/ko/help.ts | 2 +- shared/src/i18n/ko/journey.ts | 6 +- shared/src/i18n/ko/mobileTrip.ts | 3 +- shared/src/i18n/ko/notifications.ts | 1 + shared/src/i18n/ko/oauth.ts | 2 +- shared/src/i18n/ko/roadtrip.ts | 4 +- shared/src/i18n/ko/settings.ts | 2 +- shared/src/i18n/ko/storage.ts | 2 +- shared/src/i18n/nl/admin.ts | 23 +- shared/src/i18n/nl/atlas.ts | 9 +- shared/src/i18n/nl/budget.ts | 13 +- shared/src/i18n/nl/collection.ts | 22 + shared/src/i18n/nl/dashboard.ts | 12 +- shared/src/i18n/nl/dawarich.ts | 14 +- shared/src/i18n/nl/docsync.ts | 2 +- shared/src/i18n/nl/externalNotifications.ts | 15 +- shared/src/i18n/nl/files.ts | 2 + shared/src/i18n/nl/help.ts | 5 +- shared/src/i18n/nl/inspector.ts | 1 + shared/src/i18n/nl/journey.ts | 18 +- shared/src/i18n/nl/map.ts | 6 +- shared/src/i18n/nl/memories.ts | 2 + shared/src/i18n/nl/mobileJourney.ts | 6 +- shared/src/i18n/nl/mobileTrip.ts | 8 +- shared/src/i18n/nl/mobileVacay.ts | 2 + shared/src/i18n/nl/notif.ts | 3 +- shared/src/i18n/nl/notifications.ts | 2 + shared/src/i18n/nl/oauth.ts | 4 +- shared/src/i18n/nl/packing.ts | 4 + shared/src/i18n/nl/photos.ts | 1 + shared/src/i18n/nl/places.ts | 11 +- shared/src/i18n/nl/planner.ts | 5 +- shared/src/i18n/nl/reservations.ts | 14 +- shared/src/i18n/nl/roadtrip.ts | 15 +- shared/src/i18n/nl/settings.ts | 6 +- shared/src/i18n/nl/shared.ts | 1 + shared/src/i18n/nl/storage.ts | 3 +- shared/src/i18n/nl/trip.ts | 6 +- shared/src/i18n/pl/admin.ts | 41 +- shared/src/i18n/pl/atlas.ts | 45 +- shared/src/i18n/pl/budget.ts | 43 +- shared/src/i18n/pl/collab.ts | 4 +- shared/src/i18n/pl/collection.ts | 102 +- shared/src/i18n/pl/dashboard.ts | 68 +- shared/src/i18n/pl/dawarich.ts | 46 +- shared/src/i18n/pl/docsync.ts | 2 +- shared/src/i18n/pl/externalNotifications.ts | 24 +- shared/src/i18n/pl/files.ts | 14 +- shared/src/i18n/pl/help.ts | 15 +- shared/src/i18n/pl/inspector.ts | 5 +- shared/src/i18n/pl/journey.ts | 51 +- shared/src/i18n/pl/map.ts | 10 +- shared/src/i18n/pl/members.ts | 2 + shared/src/i18n/pl/memories.ts | 10 +- shared/src/i18n/pl/mobileJourney.ts | 20 +- shared/src/i18n/pl/mobileTrip.ts | 35 +- shared/src/i18n/pl/mobileVacay.ts | 6 + shared/src/i18n/pl/notif.ts | 5 +- shared/src/i18n/pl/notifications.ts | 4 + shared/src/i18n/pl/oauth.ts | 6 +- shared/src/i18n/pl/packing.ts | 20 +- shared/src/i18n/pl/photos.ts | 5 +- shared/src/i18n/pl/places.ts | 37 +- shared/src/i18n/pl/planner.ts | 15 +- shared/src/i18n/pl/reservations.ts | 30 +- shared/src/i18n/pl/roadtrip.ts | 48 +- shared/src/i18n/pl/settings.ts | 8 +- shared/src/i18n/pl/shared.ts | 3 + shared/src/i18n/pl/storage.ts | 5 +- shared/src/i18n/pl/tours.ts | 4 +- shared/src/i18n/pl/trip.ts | 20 +- shared/src/i18n/plural.spec.ts | 99 ++ shared/src/i18n/plural.ts | 65 + shared/src/i18n/ru/admin.ts | 39 +- shared/src/i18n/ru/atlas.ts | 47 +- shared/src/i18n/ru/budget.ts | 46 +- shared/src/i18n/ru/collab.ts | 4 +- shared/src/i18n/ru/collection.ts | 102 +- shared/src/i18n/ru/dashboard.ts | 64 +- shared/src/i18n/ru/dawarich.ts | 48 +- shared/src/i18n/ru/docsync.ts | 2 +- shared/src/i18n/ru/externalNotifications.ts | 17 +- shared/src/i18n/ru/files.ts | 14 +- shared/src/i18n/ru/help.ts | 15 +- shared/src/i18n/ru/inspector.ts | 5 +- shared/src/i18n/ru/journey.ts | 50 +- shared/src/i18n/ru/map.ts | 10 +- shared/src/i18n/ru/members.ts | 4 +- shared/src/i18n/ru/memories.ts | 6 + shared/src/i18n/ru/mobileJourney.ts | 18 +- shared/src/i18n/ru/mobileTrip.ts | 33 +- shared/src/i18n/ru/mobileVacay.ts | 6 + shared/src/i18n/ru/notif.ts | 3 + shared/src/i18n/ru/notifications.ts | 4 + shared/src/i18n/ru/oauth.ts | 6 +- shared/src/i18n/ru/packing.ts | 20 +- shared/src/i18n/ru/photos.ts | 3 + shared/src/i18n/ru/places.ts | 39 +- shared/src/i18n/ru/planner.ts | 15 +- shared/src/i18n/ru/reservations.ts | 30 +- shared/src/i18n/ru/roadtrip.ts | 48 +- shared/src/i18n/ru/settings.ts | 10 +- shared/src/i18n/ru/shared.ts | 5 +- shared/src/i18n/ru/storage.ts | 5 +- shared/src/i18n/ru/tours.ts | 4 +- shared/src/i18n/ru/trip.ts | 20 +- shared/src/i18n/sk/admin.ts | 26 +- shared/src/i18n/sk/atlas.ts | 36 +- shared/src/i18n/sk/budget.ts | 24 +- shared/src/i18n/sk/collab.ts | 1 + shared/src/i18n/sk/collection.ts | 60 +- shared/src/i18n/sk/dashboard.ts | 33 +- shared/src/i18n/sk/dawarich.ts | 30 +- shared/src/i18n/sk/externalNotifications.ts | 21 +- shared/src/i18n/sk/files.ts | 7 +- shared/src/i18n/sk/help.ts | 12 +- shared/src/i18n/sk/inspector.ts | 2 + shared/src/i18n/sk/journey.ts | 37 +- shared/src/i18n/sk/map.ts | 8 +- shared/src/i18n/sk/members.ts | 1 + shared/src/i18n/sk/memories.ts | 4 + shared/src/i18n/sk/mobileJourney.ts | 8 + shared/src/i18n/sk/mobileTrip.ts | 16 +- shared/src/i18n/sk/mobileVacay.ts | 4 + shared/src/i18n/sk/notif.ts | 4 +- shared/src/i18n/sk/notifications.ts | 3 + shared/src/i18n/sk/oauth.ts | 1 + shared/src/i18n/sk/packing.ts | 8 + shared/src/i18n/sk/photos.ts | 2 + shared/src/i18n/sk/places.ts | 15 + shared/src/i18n/sk/planner.ts | 6 + shared/src/i18n/sk/reservations.ts | 18 +- shared/src/i18n/sk/roadtrip.ts | 26 +- shared/src/i18n/sk/settings.ts | 6 +- shared/src/i18n/sk/shared.ts | 2 + shared/src/i18n/sk/storage.ts | 4 +- shared/src/i18n/sk/tours.ts | 3 +- shared/src/i18n/sk/trip.ts | 8 + shared/src/i18n/sv/admin.ts | 9 + shared/src/i18n/sv/atlas.ts | 17 +- shared/src/i18n/sv/budget.ts | 12 +- shared/src/i18n/sv/collab.ts | 2 +- shared/src/i18n/sv/collection.ts | 23 +- shared/src/i18n/sv/dashboard.ts | 22 +- shared/src/i18n/sv/dawarich.ts | 14 +- shared/src/i18n/sv/docsync.ts | 2 +- shared/src/i18n/sv/externalNotifications.ts | 15 +- shared/src/i18n/sv/files.ts | 2 + shared/src/i18n/sv/help.ts | 5 +- shared/src/i18n/sv/inspector.ts | 1 + shared/src/i18n/sv/journey.ts | 17 +- shared/src/i18n/sv/map.ts | 6 +- shared/src/i18n/sv/memories.ts | 2 + shared/src/i18n/sv/mobileJourney.ts | 4 + shared/src/i18n/sv/mobileTrip.ts | 9 +- shared/src/i18n/sv/mobileVacay.ts | 2 + shared/src/i18n/sv/notif.ts | 3 +- shared/src/i18n/sv/notifications.ts | 2 + shared/src/i18n/sv/packing.ts | 10 +- shared/src/i18n/sv/photos.ts | 1 + shared/src/i18n/sv/places.ts | 15 +- shared/src/i18n/sv/planner.ts | 5 +- shared/src/i18n/sv/reservations.ts | 16 +- shared/src/i18n/sv/roadtrip.ts | 14 +- shared/src/i18n/sv/settings.ts | 6 +- shared/src/i18n/sv/shared.ts | 1 + shared/src/i18n/sv/storage.ts | 3 +- shared/src/i18n/sv/trip.ts | 4 + shared/src/i18n/th/admin.ts | 1403 +++++++++-------- shared/src/i18n/th/airport.ts | 2 +- shared/src/i18n/th/atlas.ts | 114 +- shared/src/i18n/th/backup.ts | 130 +- shared/src/i18n/th/budget.ts | 10 +- shared/src/i18n/th/categories.ts | 40 +- shared/src/i18n/th/collab.ts | 182 +-- shared/src/i18n/th/collection.ts | 378 ++--- shared/src/i18n/th/common.ts | 144 +- shared/src/i18n/th/dashboard.ts | 355 ++--- shared/src/i18n/th/dawarich.ts | 297 ++-- shared/src/i18n/th/day.ts | 42 +- shared/src/i18n/th/dayplan.ts | 174 +- shared/src/i18n/th/externalNotifications.ts | 4 +- shared/src/i18n/th/files.ts | 120 +- shared/src/i18n/th/help.ts | 4 +- shared/src/i18n/th/inspector.ts | 50 +- shared/src/i18n/th/journey.ts | 1277 +++++++-------- shared/src/i18n/th/login.ts | 136 +- shared/src/i18n/th/map.ts | 53 +- shared/src/i18n/th/members.ts | 63 +- shared/src/i18n/th/memories.ts | 135 +- shared/src/i18n/th/mobileAdmin.ts | 6 +- shared/src/i18n/th/mobileAtlas.ts | 4 +- shared/src/i18n/th/mobileCollections.ts | 2 +- shared/src/i18n/th/mobileJourney.ts | 22 +- shared/src/i18n/th/mobileNav.ts | 6 +- shared/src/i18n/th/mobileSettings.ts | 2 +- shared/src/i18n/th/mobileTrip.ts | 129 +- shared/src/i18n/th/mobileVacay.ts | 26 +- shared/src/i18n/th/nav.ts | 32 +- shared/src/i18n/th/notif.ts | 86 +- shared/src/i18n/th/notifications.ts | 61 +- shared/src/i18n/th/oauth.ts | 214 +-- shared/src/i18n/th/packing.ts | 160 +- shared/src/i18n/th/pdf.ts | 18 +- shared/src/i18n/th/perm.ts | 92 +- shared/src/i18n/th/photos.ts | 40 +- shared/src/i18n/th/places.ts | 8 +- shared/src/i18n/th/planner.ts | 124 +- shared/src/i18n/th/register.ts | 40 +- shared/src/i18n/th/reservations.ts | 8 +- shared/src/i18n/th/roadtrip.ts | 492 +++--- shared/src/i18n/th/settings.ts | 1077 ++++++------- shared/src/i18n/th/share.ts | 20 +- shared/src/i18n/th/shared.ts | 32 +- shared/src/i18n/th/stats.ts | 16 +- shared/src/i18n/th/storage.ts | 231 +-- shared/src/i18n/th/todo.ts | 72 +- shared/src/i18n/th/trip.ts | 170 +- shared/src/i18n/th/trips.ts | 23 +- shared/src/i18n/th/undo.ts | 38 +- shared/src/i18n/th/vacay.ts | 288 ++-- shared/src/i18n/tours-locales.spec.ts | 25 +- shared/src/i18n/tr/admin.ts | 41 +- shared/src/i18n/tr/atlas.ts | 31 +- shared/src/i18n/tr/budget.ts | 13 +- shared/src/i18n/tr/collection.ts | 26 +- shared/src/i18n/tr/dashboard.ts | 20 +- shared/src/i18n/tr/dawarich.ts | 14 +- shared/src/i18n/tr/docsync.ts | 2 +- shared/src/i18n/tr/externalNotifications.ts | 18 +- shared/src/i18n/tr/files.ts | 4 +- shared/src/i18n/tr/help.ts | 5 +- shared/src/i18n/tr/inspector.ts | 1 + shared/src/i18n/tr/journey.ts | 19 +- shared/src/i18n/tr/map.ts | 2 + shared/src/i18n/tr/memories.ts | 6 +- shared/src/i18n/tr/mobileJourney.ts | 6 +- shared/src/i18n/tr/mobileTrip.ts | 9 +- shared/src/i18n/tr/mobileVacay.ts | 2 + shared/src/i18n/tr/notif.ts | 1 + shared/src/i18n/tr/notifications.ts | 2 + shared/src/i18n/tr/oauth.ts | 4 +- shared/src/i18n/tr/packing.ts | 8 +- shared/src/i18n/tr/photos.ts | 1 + shared/src/i18n/tr/places.ts | 17 +- shared/src/i18n/tr/planner.ts | 3 + shared/src/i18n/tr/reservations.ts | 8 +- shared/src/i18n/tr/roadtrip.ts | 17 +- shared/src/i18n/tr/settings.ts | 4 +- shared/src/i18n/tr/shared.ts | 1 + shared/src/i18n/tr/storage.ts | 3 +- shared/src/i18n/tr/tours.ts | 2 +- shared/src/i18n/tr/trip.ts | 6 +- shared/src/i18n/uk/admin.ts | 37 +- shared/src/i18n/uk/atlas.ts | 43 +- shared/src/i18n/uk/budget.ts | 46 +- shared/src/i18n/uk/collab.ts | 4 +- shared/src/i18n/uk/collection.ts | 99 +- shared/src/i18n/uk/dashboard.ts | 68 +- shared/src/i18n/uk/dawarich.ts | 48 +- shared/src/i18n/uk/externalNotifications.ts | 17 +- shared/src/i18n/uk/files.ts | 14 +- shared/src/i18n/uk/help.ts | 15 +- shared/src/i18n/uk/inspector.ts | 5 +- shared/src/i18n/uk/journey.ts | 56 +- shared/src/i18n/uk/map.ts | 10 +- shared/src/i18n/uk/members.ts | 4 +- shared/src/i18n/uk/memories.ts | 6 + shared/src/i18n/uk/mobileJourney.ts | 18 +- shared/src/i18n/uk/mobileTrip.ts | 33 +- shared/src/i18n/uk/mobileVacay.ts | 6 + shared/src/i18n/uk/notif.ts | 3 + shared/src/i18n/uk/notifications.ts | 4 + shared/src/i18n/uk/oauth.ts | 6 +- shared/src/i18n/uk/packing.ts | 20 +- shared/src/i18n/uk/photos.ts | 3 + shared/src/i18n/uk/places.ts | 37 +- shared/src/i18n/uk/planner.ts | 15 +- shared/src/i18n/uk/reservations.ts | 28 +- shared/src/i18n/uk/roadtrip.ts | 47 +- shared/src/i18n/uk/settings.ts | 10 +- shared/src/i18n/uk/shared.ts | 5 +- shared/src/i18n/uk/storage.ts | 5 +- shared/src/i18n/uk/tours.ts | 4 +- shared/src/i18n/uk/trip.ts | 20 +- shared/src/i18n/vi/admin.ts | 63 +- shared/src/i18n/vi/atlas.ts | 6 +- shared/src/i18n/vi/budget.ts | 8 +- shared/src/i18n/vi/collection.ts | 8 +- shared/src/i18n/vi/dashboard.ts | 7 +- shared/src/i18n/vi/dawarich.ts | 6 +- shared/src/i18n/vi/docsync.ts | 2 +- shared/src/i18n/vi/externalNotifications.ts | 8 +- shared/src/i18n/vi/files.ts | 2 +- shared/src/i18n/vi/help.ts | 2 +- shared/src/i18n/vi/journey.ts | 8 +- shared/src/i18n/vi/map.ts | 4 +- shared/src/i18n/vi/memories.ts | 2 +- shared/src/i18n/vi/mobileTrip.ts | 8 +- shared/src/i18n/vi/notifications.ts | 1 + shared/src/i18n/vi/places.ts | 4 +- shared/src/i18n/vi/planner.ts | 2 +- shared/src/i18n/vi/reservations.ts | 6 +- shared/src/i18n/vi/roadtrip.ts | 4 +- shared/src/i18n/vi/settings.ts | 2 +- shared/src/i18n/vi/storage.ts | 2 +- shared/src/i18n/zh-TW/admin.ts | 4 +- shared/src/i18n/zh-TW/atlas.ts | 8 +- shared/src/i18n/zh-TW/budget.ts | 4 +- shared/src/i18n/zh-TW/collection.ts | 2 +- shared/src/i18n/zh-TW/dashboard.ts | 5 +- shared/src/i18n/zh-TW/dawarich.ts | 4 +- shared/src/i18n/zh-TW/docsync.ts | 2 +- .../src/i18n/zh-TW/externalNotifications.ts | 9 +- shared/src/i18n/zh-TW/files.ts | 2 +- shared/src/i18n/zh-TW/help.ts | 2 +- shared/src/i18n/zh-TW/journey.ts | 4 +- shared/src/i18n/zh-TW/mobileJourney.ts | 2 +- shared/src/i18n/zh-TW/mobileTrip.ts | 1 - shared/src/i18n/zh-TW/notifications.ts | 1 + shared/src/i18n/zh-TW/oauth.ts | 2 +- shared/src/i18n/zh-TW/packing.ts | 2 +- shared/src/i18n/zh-TW/places.ts | 4 +- shared/src/i18n/zh-TW/roadtrip.ts | 6 +- shared/src/i18n/zh-TW/settings.ts | 2 +- shared/src/i18n/zh-TW/storage.ts | 2 +- shared/src/i18n/zh-TW/trip.ts | 2 +- shared/src/i18n/zh/admin.ts | 4 +- shared/src/i18n/zh/atlas.ts | 6 +- shared/src/i18n/zh/budget.ts | 4 +- shared/src/i18n/zh/dashboard.ts | 5 +- shared/src/i18n/zh/dawarich.ts | 4 +- shared/src/i18n/zh/docsync.ts | 2 +- shared/src/i18n/zh/externalNotifications.ts | 9 +- shared/src/i18n/zh/files.ts | 2 +- shared/src/i18n/zh/help.ts | 2 +- shared/src/i18n/zh/journey.ts | 4 +- shared/src/i18n/zh/mobileTrip.ts | 3 +- shared/src/i18n/zh/notifications.ts | 1 + shared/src/i18n/zh/packing.ts | 2 +- shared/src/i18n/zh/places.ts | 4 +- shared/src/i18n/zh/reservations.ts | 2 +- shared/src/i18n/zh/roadtrip.ts | 6 +- shared/src/i18n/zh/settings.ts | 2 +- shared/src/i18n/zh/storage.ts | 2 +- shared/src/i18n/zh/tours.ts | 2 +- wiki/Languages.md | 6 +- 851 files changed, 11378 insertions(+), 6077 deletions(-) create mode 100644 shared/src/i18n/plural.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index dd6e7f15b..12d937b81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,7 @@ Offline-first, with a layered data flow. A Page never owns state directly: - A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema, rebuild shared, then server (validation + DTO types) and client (typed requests) pick it up. - Locale files live in `shared/src/i18n//`, one file per domain; `en/` is canonical. When you add or change a key, **add a real translation to every locale** — parity fails CI on missing keys, and an English placeholder is not acceptable in a non-`en` locale. +- Count-bearing strings are plural groups: the key holds the general form and `key.one`, `.few` … the forms the language's `Intl.PluralRules` selects. Pass `{ count }` as a number and never choose between two keys with `count === 1`; details in `shared/CLAUDE.md`. ## Project direction diff --git a/client/src/components/Admin/AdminPluginsPanel.test.tsx b/client/src/components/Admin/AdminPluginsPanel.test.tsx index 472f6a7da..7eab30992 100644 --- a/client/src/components/Admin/AdminPluginsPanel.test.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.test.tsx @@ -1178,7 +1178,7 @@ describe('AdminPluginsPanel — compatible updates only', () => { render() expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument() - expect(screen.getByText('1 updates available for your plugins.')).toBeInTheDocument() + expect(screen.getByText('1 update available for your plugins.')).toBeInTheDocument() expect(screen.queryByRole('button', { name: /update → v3\.0\.0/i })).not.toBeInTheDocument() }) @@ -1254,7 +1254,7 @@ describe('AdminPluginsPanel — update hold', () => { })) withToast() - expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument() + expect(await screen.findByText('1 update available for your plugins.')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /update all/i })) await waitFor(() => expect(updated).toEqual(['trek-ntfy'])) diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index eab547c67..b802e7378 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -1285,7 +1285,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn className={`${CHIP} ${hostCount > 0 ? CHIP_NET : CHIP_WARN} hover:opacity-80`} style={fs(11)}> {hostCount > 0 - ? t('admin.plugins.allowedHosts.count').replace('{n}', String(hostCount)) + ? t('admin.plugins.allowedHosts.count', { n: hostCount }) : t('admin.plugins.allowedHosts.add')} diff --git a/client/src/components/Admin/PackingTemplateManager.tsx b/client/src/components/Admin/PackingTemplateManager.tsx index f1e439fde..5bf4d720a 100644 --- a/client/src/components/Admin/PackingTemplateManager.tsx +++ b/client/src/components/Admin/PackingTemplateManager.tsx @@ -243,7 +243,7 @@ export default function PackingTemplateManager() { )} - {tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')} + {tmpl.category_count} {t('admin.packingTemplates.categories', { count: tmpl.category_count })} · {tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })} )} diff --git a/client/src/components/Collections/CopyToTripModal.test.tsx b/client/src/components/Collections/CopyToTripModal.test.tsx index 3cb737b33..6fc5fe5b8 100644 --- a/client/src/components/Collections/CopyToTripModal.test.tsx +++ b/client/src/components/Collections/CopyToTripModal.test.tsx @@ -171,8 +171,8 @@ describe('CopyToTripModal', () => { renderModal({ onCopy }); fireEvent.click(await screen.findByText('Rome 2026')); - await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicates', 'info', undefined)); - expect(addToast).toHaveBeenCalledWith('Copied 1 places', 'success', undefined); + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicate', 'info', undefined)); + expect(addToast).toHaveBeenCalledWith('Copied 1 place', 'success', undefined); }); it('FE-COMP-COPYTRIP-010: a no-op copy says so instead of staying silent', async () => { diff --git a/client/src/components/Collections/ImportFromTripModal.test.tsx b/client/src/components/Collections/ImportFromTripModal.test.tsx index 77f85c721..b580f3c28 100644 --- a/client/src/components/Collections/ImportFromTripModal.test.tsx +++ b/client/src/components/Collections/ImportFromTripModal.test.tsx @@ -126,7 +126,7 @@ describe('ImportFromTripModal', () => { expect([...body.source_place_ids].sort()).toEqual([11, 12]); await waitFor(() => expect(screen.getByText('2 places added')).toBeInTheDocument()); - expect(screen.getByText(/1 were already on the list/)).toBeInTheDocument(); + expect(screen.getByText(/1 was already on the list and stayed as it was/)).toBeInTheDocument(); expect(onImported).toHaveBeenCalled(); }); diff --git a/client/src/components/Collections/SaveTripPlacesToListModal.test.tsx b/client/src/components/Collections/SaveTripPlacesToListModal.test.tsx index f588d579c..43e2647f3 100644 --- a/client/src/components/Collections/SaveTripPlacesToListModal.test.tsx +++ b/client/src/components/Collections/SaveTripPlacesToListModal.test.tsx @@ -130,7 +130,7 @@ describe('SaveTripPlacesToListModal', () => { renderModal(); fireEvent.click(await screen.findByText('Favorites')); - await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicates', 'info', undefined)); + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicate', 'info', undefined)); expect(addToast).toHaveBeenCalledWith('Saved 1 to Favorites', 'success', undefined); }); diff --git a/client/src/components/Dawarich/DawarichAtlasDialog.test.tsx b/client/src/components/Dawarich/DawarichAtlasDialog.test.tsx index 033640387..8624debb8 100644 --- a/client/src/components/Dawarich/DawarichAtlasDialog.test.tsx +++ b/client/src/components/Dawarich/DawarichAtlasDialog.test.tsx @@ -256,7 +256,7 @@ describe('DawarichAtlasDialog', () => { fireEvent.click(screen.getByRole('button', { name: 'Look for countries' })) await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument()) - fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ })) + fireEvent.click(screen.getByRole('button', { name: /Add 1 country/ })) await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['NL'])) }) @@ -351,7 +351,7 @@ describe('DawarichAtlasDialog', () => { await waitFor(() => expect(screen.getByText('Your recordings show no countries TREK does not already have.')).toBeInTheDocument(), ) - expect(screen.queryByRole('button', { name: /Add \d+ countries/ })).toBeNull() + expect(screen.queryByRole('button', { name: /Add \d+ countr/ })).toBeNull() }) it('FE-DAWARICH-ATLASDLG-015: wishes reached on one day travel in one request, another day in its own', async () => { @@ -430,7 +430,7 @@ describe('DawarichAtlasDialog', () => { await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument()) setForcedOffline(true) - fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ })) + fireEvent.click(screen.getByRole('button', { name: /Add 1 country/ })) expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now')) expect(api.acceptAtlasCountries).not.toHaveBeenCalled() @@ -463,7 +463,7 @@ describe('DawarichAtlasDialog', () => { fireEvent.click(screen.getByRole('button', { name: 'Look for countries' })) await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument()) - fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ })) + fireEvent.click(screen.getByRole('button', { name: /Add 1 country/ })) await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.')) expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toHaveAttribute('aria-checked', 'true') @@ -572,9 +572,9 @@ describe('DawarichAtlasDialog', () => { fireEvent.click(screen.getByRole('button', { name: 'Look for countries' })) await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument()) - fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ })) + fireEvent.click(screen.getByRole('button', { name: /Add 1 country/ })) - await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 countries added')) + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 country added')) // Marked in place rather than re-read: the reading costs an upstream request // and the only thing that changed is what the reader just pressed. The row // leaving the list is what says the write landed. @@ -582,7 +582,7 @@ describe('DawarichAtlasDialog', () => { expect(screen.getByText('Your recordings show no countries TREK does not already have.')).toBeInTheDocument(), ) expect(screen.queryByRole('checkbox', { name: 'Netherlands' })).toBeNull() - expect(screen.queryByRole('button', { name: /Add \d+ countries/ })).toBeNull() + expect(screen.queryByRole('button', { name: /Add \d+ countr/ })).toBeNull() expect(repo.atlasSuggestions).toHaveBeenCalledTimes(1) // The Atlas keeps its own map and its own counts; without this the country // it was just told about only appears there after a reload. @@ -626,11 +626,11 @@ describe('DawarichAtlasDialog', () => { fireEvent.click(screen.getByRole('checkbox', { name: 'France' })) expect(screen.getByRole('checkbox', { name: 'France' })).toHaveAttribute('aria-checked', 'true') - fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ })) + fireEvent.click(screen.getByRole('button', { name: /Add 1 country/ })) // Belgium was never sent, so nothing has to be undone in the Atlas after. await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['FR'])) - await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 countries added')) + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 country added')) // And it is still on offer, still unticked, for the trip it does belong to. expect(screen.getByRole('checkbox', { name: 'Belgium' })).toHaveAttribute('aria-checked', 'false') }) diff --git a/client/src/components/Dawarich/DawarichAtlasDialog.tsx b/client/src/components/Dawarich/DawarichAtlasDialog.tsx index 6f9159959..afb5f0e88 100644 --- a/client/src/components/Dawarich/DawarichAtlasDialog.tsx +++ b/client/src/components/Dawarich/DawarichAtlasDialog.tsx @@ -295,9 +295,7 @@ export default function DawarichAtlasDialog({ {country.cities.length > 0 && ( <> - {country.cities.length === 1 - ? t('dawarich.atlas.citiesOne') - : t('dawarich.atlas.cities', { count: country.cities.length })} + {t('dawarich.atlas.cities', { count: country.cities.length })} {cityLine(country.cities)} diff --git a/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx b/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx index 3d997b182..61b8f24ee 100644 --- a/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx @@ -345,7 +345,7 @@ describe('GalleryView', () => { await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(1)) expect(uploadGalleryPhotos).toHaveBeenCalledTimes(1) - expect(toastSpy).toHaveBeenCalledWith('1 photos uploaded', 'success', undefined) + expect(toastSpy).toHaveBeenCalledWith('1 photo uploaded', 'success', undefined) expect(input.value).toBe('') }) diff --git a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx index 9c88ae6c7..998ab45ac 100644 --- a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx +++ b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx @@ -78,7 +78,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot if (failed.length > 0) { toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) })) } else { - toast.success(t('journey.photosUploaded', { count: String(files.length) })) + toast.success(t('journey.photosUploaded', { count: files.length })) } onRefresh() } catch (err) { @@ -120,7 +120,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot {/* Header — the provider buttons live in the page header next to Upload */}
- {allPhotos.length} {t('journey.detail.photos')} + {allPhotos.length} {t('journey.detail.photos', { count: allPhotos.length })}
diff --git a/client/src/components/Journey/JourneyDetailPageSettingsDialog.tsx b/client/src/components/Journey/JourneyDetailPageSettingsDialog.tsx index ed6b72fc7..82bfdd783 100644 --- a/client/src/components/Journey/JourneyDetailPageSettingsDialog.tsx +++ b/client/src/components/Journey/JourneyDetailPageSettingsDialog.tsx @@ -382,7 +382,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
{trip.title}
-
{trip.place_count || 0} {t('journey.synced.places')}
+
{trip.place_count || 0} {t('journey.synced.places', { count: trip.place_count || 0 })}
setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}> diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index bfee18723..c882456ad 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -915,11 +915,11 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
${sorted.length}
-
${escHtml(tr('dashboard.days'))}
+
${escHtml(tr('dashboard.days', { count: sorted.length }))}
${places?.length || 0}
-
${escHtml(tr('dashboard.places'))}
+
${escHtml(tr('dashboard.places', { count: places?.length || 0 }))}
${totalAssigned}
diff --git a/client/src/components/Packing/ApplyTemplateButton.tsx b/client/src/components/Packing/ApplyTemplateButton.tsx index 1efc173ba..70bd13187 100644 --- a/client/src/components/Packing/ApplyTemplateButton.tsx +++ b/client/src/components/Packing/ApplyTemplateButton.tsx @@ -92,7 +92,7 @@ export default function ApplyTemplateButton({ tripId, visibility, style, classNa
{tmpl.name}
- {tmpl.item_count} {t('admin.packingTemplates.items')} + {tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })}
diff --git a/client/src/components/Packing/PackingListPanelBagCard.tsx b/client/src/components/Packing/PackingListPanelBagCard.tsx index c56806c50..1d8adab01 100644 --- a/client/src/components/Packing/PackingListPanelBagCard.tsx +++ b/client/src/components/Packing/PackingListPanelBagCard.tsx @@ -125,7 +125,7 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
- {bagItems.length} {t('admin.packingTemplates.items')} + {bagItems.length} {t('admin.packingTemplates.items', { count: bagItems.length })} {totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`} {editingLimit && canEdit ? ( diff --git a/client/src/components/Packing/PackingListPanelBagModal.tsx b/client/src/components/Packing/PackingListPanelBagModal.tsx index 355a680d8..938eb0c20 100644 --- a/client/src/components/Packing/PackingListPanelBagModal.tsx +++ b/client/src/components/Packing/PackingListPanelBagModal.tsx @@ -59,7 +59,7 @@ export function BagModal(S: PackingState) { {unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
-
{unassigned.length} {t('admin.packingTemplates.items')}
+
{unassigned.length} {t('admin.packingTemplates.items', { count: unassigned.length })}
) })()} diff --git a/client/src/components/Packing/PackingListPanelBagSidebar.tsx b/client/src/components/Packing/PackingListPanelBagSidebar.tsx index 81c0edb3e..5c39b6aa5 100644 --- a/client/src/components/Packing/PackingListPanelBagSidebar.tsx +++ b/client/src/components/Packing/PackingListPanelBagSidebar.tsx @@ -85,7 +85,7 @@ export function BagSidebar(S: PackingState) {
{t('packing.noBag')} - {unassigned.length} + {unassigned.length} {kgOrG(unassignedWeight)}
diff --git a/client/src/components/Packing/PackingListPanelHeader.tsx b/client/src/components/Packing/PackingListPanelHeader.tsx index 68c803b40..aa11c5b86 100644 --- a/client/src/components/Packing/PackingListPanelHeader.tsx +++ b/client/src/components/Packing/PackingListPanelHeader.tsx @@ -76,7 +76,7 @@ export function PackingHeader(S: PackingState) {
{tmpl.name}
-
{tmpl.item_count} {t('admin.packingTemplates.items')}
+
{tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })}
))} diff --git a/client/src/components/Packing/usePackingListPanel.test.tsx b/client/src/components/Packing/usePackingListPanel.test.tsx index 0e31a59a1..643c82ad9 100644 --- a/client/src/components/Packing/usePackingListPanel.test.tsx +++ b/client/src/components/Packing/usePackingListPanel.test.tsx @@ -621,7 +621,7 @@ describe('usePackingList — templates, import and signals', () => { await act(async () => { await result.current.handleApplyTemplate(2) }) expect(useTripStore.getState().packingItems).toContainEqual(fresh) - expect(toastSpy).toHaveBeenCalledWith('1 items added from template', 'success', undefined) + expect(toastSpy).toHaveBeenCalledWith('1 item added from template', 'success', undefined) expect(result.current.showTemplateDropdown).toBe(false) expect(result.current.applyingTemplate).toBe(false) }) @@ -700,7 +700,7 @@ describe('usePackingList — templates, import and signals', () => { await act(async () => { await result.current.handleBulkImport() }) expect(useTripStore.getState().packingItems).toContainEqual(imported) - expect(toastSpy).toHaveBeenCalledWith('1 items imported', 'success', undefined) + expect(toastSpy).toHaveBeenCalledWith('1 item imported', 'success', undefined) expect(result.current.importText).toBe('') expect(result.current.showImportModal).toBe(false) }) diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index 5dc9f7d54..b0c2ecde6 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -527,7 +527,7 @@ describe('DayPlanSidebar', () => { // title uses an arrow icon, so its parts are separate text nodes. expect(screen.getByText('U2')).toBeInTheDocument() // Transfer counts stay out of the compact row — the chips say it all. - expect(screen.queryByText(/1 transfers/)).not.toBeInTheDocument() + expect(screen.queryByText(/1 transfer/)).not.toBeInTheDocument() // Clicking the row opens the journey view — not the edit form. await user.click(screen.getByText('Fernsehturm')) expect(onEditTransport).not.toHaveBeenCalled() diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx index 658917f19..d5266e865 100644 --- a/client/src/components/Planner/PlaceInspector.test.tsx +++ b/client/src/components/Planner/PlaceInspector.test.tsx @@ -1119,7 +1119,7 @@ describe('PlaceInspector', () => { const { openFile } = await import('../../utils/fileDownload'); const spy = vi.spyOn({ openFile }, 'openFile'); render(); - fireEvent.click(screen.getByText('1 files')); + fireEvent.click(screen.getByText('1 file')); const link = await screen.findByText('map.pdf'); fireEvent.click(link); // The click is handled without throwing; the row stays in the list. diff --git a/client/src/components/Planner/PlacesSidebarHeader.tsx b/client/src/components/Planner/PlacesSidebarHeader.tsx index f3d03fb6a..7f3a66542 100644 --- a/client/src/components/Planner/PlacesSidebarHeader.tsx +++ b/client/src/components/Planner/PlacesSidebarHeader.tsx @@ -262,7 +262,7 @@ function CategoryFilter(S: SidebarState) { if (categoryFilters.has(UNCATEGORIZED)) label = t('places.noCategory') else if (only) label = only.name } else if (categoryFilters.size > 1) { - label = `${categoryFilters.size} ${t('places.categoriesSelected')}` + label = `${categoryFilters.size} ${t('places.categoriesSelected', { count: categoryFilters.size })}` } return ( 0} badge={categoryFilters.size} diff --git a/client/src/components/Planner/TransitSearchPanel.test.tsx b/client/src/components/Planner/TransitSearchPanel.test.tsx index fd8dc074a..9e6a47762 100644 --- a/client/src/components/Planner/TransitSearchPanel.test.tsx +++ b/client/src/components/Planner/TransitSearchPanel.test.tsx @@ -107,7 +107,7 @@ describe('TransitSearchPanel', () => { // Local Berlin times, U2 badge, 1 transfer. expect(await screen.findByText(/08:30 – 09:00/)).toBeInTheDocument() expect(screen.getByText('U2')).toBeInTheDocument() - expect(screen.getByText('1 transfers')).toBeInTheDocument() + expect(screen.getByText('1 transfer')).toBeInTheDocument() }) it('FE-PLANNER-TRANSIT-003: adding a route builds a transport payload with local times + endpoints', async () => { diff --git a/client/src/components/Planner/usePlacesSidebar.test.tsx b/client/src/components/Planner/usePlacesSidebar.test.tsx index 68078f0a5..8ee9a79a2 100644 --- a/client/src/components/Planner/usePlacesSidebar.test.tsx +++ b/client/src/components/Planner/usePlacesSidebar.test.tsx @@ -611,7 +611,7 @@ describe('usePlacesSidebar list import', () => { await act(async () => { await S.handleListImport(); }); expect(sentEnrich).toBe(true); - expect(addToast).toHaveBeenCalledWith('1 places imported from "Seoul"', 'success', undefined); + expect(addToast).toHaveBeenCalledWith('1 place imported from "Seoul"', 'success', undefined); expect(pushUndo).toHaveBeenCalledWith('Naver Maps import', expect.any(Function)); }); diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx index 3755ff8b1..f02a6c5d6 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx @@ -326,7 +326,7 @@ describe('RoadtripCorridorPanel', () => { wrap() // The difference between "no fuel here" and "nobody looked here". - expect(screen.getByText('2 stretches could not be searched — the place search did not answer.')).toBeInTheDocument() + expect(screen.getByText('2 stretches could not be searched. The place search did not answer.')).toBeInTheDocument() expect(screen.getByText('Aral')).toBeInTheDocument() }) diff --git a/client/src/components/Studio/StudioExport.tsx b/client/src/components/Studio/StudioExport.tsx index 671d15911..d5025f2ac 100644 --- a/client/src/components/Studio/StudioExport.tsx +++ b/client/src/components/Studio/StudioExport.tsx @@ -128,7 +128,7 @@ export function StudioExport({

{t('journey.studio.exportNote', { - sheets: sheets.length, + count: sheets.length, width: round1(box.width), height: round1(box.height), })} diff --git a/client/src/components/Trips/TripMembersModal.test.tsx b/client/src/components/Trips/TripMembersModal.test.tsx index 800441439..086ff21a5 100644 --- a/client/src/components/Trips/TripMembersModal.test.tsx +++ b/client/src/components/Trips/TripMembersModal.test.tsx @@ -193,7 +193,7 @@ describe('TripMembersModal', () => { ) ); render(); - expect(await screen.findByText(/2 persons/i)).toBeInTheDocument(); + expect(await screen.findByText(/2 people/i)).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-012: shows "you" label next to current user', async () => { @@ -526,7 +526,7 @@ describe('TripMembersModal', () => { // The guest carries a "Guest" badge. expect(screen.getAllByText('Guest').length).toBeGreaterThan(0); // The head band counts the owner and the real member (2), not the guest. - expect(screen.getByText('2 persons')).toBeInTheDocument(); + expect(screen.getByText('2 people')).toBeInTheDocument(); }); // ── Avatars and load failures (028-031) ─────────────────────────────────── @@ -553,7 +553,7 @@ describe('TripMembersModal', () => { render(); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to load members', 'error', undefined)); - expect(screen.getByText('0 persons')).toBeInTheDocument(); + expect(screen.getByText('0 people')).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-031: a failing share-link load still offers link creation', async () => { diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx index 940b82312..3791dc0de 100644 --- a/client/src/i18n/TranslationContext.tsx +++ b/client/src/i18n/TranslationContext.tsx @@ -112,7 +112,10 @@ interface TranslationProviderProps { export function TranslationProvider({ children }: TranslationProviderProps) { const language = useSettingsStore((s) => s.settings.language) || 'en' - const [strings, setStrings] = useState(en) + // The catalogue together with the language it belongs to: while another + // language loads, or when its chunk fails, the strings on screen are still + // the previous ones, and their plural forms follow that language's rule. + const [catalogue, setCatalogue] = useState<{ language: string; strings: TranslationStrings }>({ language: 'en', strings: en }) useEffect(() => { document.documentElement.lang = getIntlLanguage(language) @@ -125,7 +128,11 @@ export function TranslationProvider({ children }: TranslationProviderProps) { let cancelled = false importChunk(loader).then(mod => { - if (!cancelled) setStrings(mod.default) + // The same table for the same language keeps the state, and with it t(): + // English arrives as the very table the provider started with. + if (!cancelled) { + setCatalogue(prev => (prev.language === language && prev.strings === mod.default ? prev : { language, strings: mod.default })) + } }).catch(err => { // The locale chunk can be gone after a deploy. Keep the strings we have — // an untranslated UI beats an unhandled rejection and a blank screen. @@ -135,9 +142,9 @@ export function TranslationProvider({ children }: TranslationProviderProps) { }, [language]) const value = useMemo((): TranslationContextValue => { - const intlLanguage = getIntlLanguage(language) + const intlLanguage = getIntlLanguage(catalogue.language) const template = (key: string, params?: Record): string => - resolveTemplate(strings, intlLanguage, key, params) + resolveTemplate(catalogue.strings, intlLanguage, key, params) function t(key: string, params?: Record): string { let val = template(key, params) @@ -167,7 +174,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) { } return { t, tHtml, language, locale: getLocaleForLanguage(language) } - }, [strings, language]) + }, [catalogue, language]) return {children} } diff --git a/client/src/i18n/resolveTemplate.test.ts b/client/src/i18n/resolveTemplate.test.ts index 7328251cb..b19959eab 100644 --- a/client/src/i18n/resolveTemplate.test.ts +++ b/client/src/i18n/resolveTemplate.test.ts @@ -23,6 +23,12 @@ describe('resolveTemplate', () => { 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} места') + // A key English has forms for: count 1 is "one" in English too, and still + // the locale's own general form wins over English's .one. + const jaPlaces = { 'places.count': '{count}件' } as unknown as TranslationStrings + expect(resolveTemplate(jaPlaces, 'ja', 'places.count', { count: 1 })).toBe('{count}件') + const ruPlaces = { 'places.count': '{count} мест' } as unknown as TranslationStrings + expect(resolveTemplate(ruPlaces, 'ru', 'places.count', { count: 1 })).toBe('{count} мест') }) it('a key the locale lacks resolves in English with English rules', () => { @@ -30,14 +36,25 @@ describe('resolveTemplate', () => { 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') + // Where the locale's rule and English's differ: Russian puts 21 and French + // puts 0 in "one", English puts both in "other". + expect(resolveTemplate(empty, 'ru', 'places.count', { count: 21 })).toBe('{count} places') + expect(resolveTemplate(empty, 'fr', 'places.count', { count: 0 })).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: '1,5' })).toBe('{count} места') + expect(resolveTemplate(ru, 'ru', 'x.places', { count: '' })).toBe('{count} места') expect(resolveTemplate(ru, 'ru', 'x.places', { count: Number.NaN })).toBe('{count} места') }) + it('a whole number sent as text still picks its form, as in-app notification params arrive', () => { + expect(resolveTemplate(ru, 'ru', 'x.places', { count: '21' })).toBe('{count} место') + expect(resolveTemplate(ru, 'ru', 'x.places', { count: '5' })).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') diff --git a/client/src/i18n/resolveTemplate.ts b/client/src/i18n/resolveTemplate.ts index f6f31d145..0f93e4c12 100644 --- a/client/src/i18n/resolveTemplate.ts +++ b/client/src/i18n/resolveTemplate.ts @@ -7,7 +7,9 @@ import type { TranslationStrings } from '@trek/shared/i18n' * 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. + * only when the locale has none of the key's forms. A count that arrives as + * text (in-app notification params are strings) counts when it is a plain + * whole number. */ export function resolveTemplate( strings: TranslationStrings, @@ -15,8 +17,7 @@ export function resolveTemplate( key: string, params?: Record, ): string { - const raw = params?.count ?? params?.n - const count = typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined + const count = asCount(params?.count ?? params?.n) if (count !== undefined) { const own = resolvePluralKey(strings, key, count, intlLanguage) if (own) return strings[own] as string @@ -26,3 +27,9 @@ export function resolveTemplate( } return (strings[key] ?? en[key] ?? key) as string } + +function asCount(raw: string | number | undefined): number | undefined { + if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined + if (typeof raw === 'string' && /^\d+$/.test(raw)) return Number(raw) + return undefined +} diff --git a/client/src/mobile/screens/admin/MAdmin.tsx b/client/src/mobile/screens/admin/MAdmin.tsx index 3b802c677..9d3aa5ff1 100644 --- a/client/src/mobile/screens/admin/MAdmin.tsx +++ b/client/src/mobile/screens/admin/MAdmin.tsx @@ -199,10 +199,10 @@ export default function MAdmin() { {/* Stats grid (§6.3) */} {stats && (

- - - - + + + +
)} diff --git a/client/src/mobile/screens/admin/MAdminPackingTemplateManager.tsx b/client/src/mobile/screens/admin/MAdminPackingTemplateManager.tsx index 841cafab9..6e2af87dd 100644 --- a/client/src/mobile/screens/admin/MAdminPackingTemplateManager.tsx +++ b/client/src/mobile/screens/admin/MAdminPackingTemplateManager.tsx @@ -261,7 +261,7 @@ export default function MAdminPackingTemplateManager() { )} diff --git a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx index cf8088d80..027918ddb 100644 --- a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx @@ -1409,7 +1409,7 @@ function InstalledRow({ p, t, busy, hasUpdate, latestVer, newerIncompatible, blo > 0 ? 'text-[color:var(--m-st-info)]' : 'text-[color:var(--m-st-pending)]'} /> {p.egressHostCount > 0 - ? t('admin.plugins.allowedHosts.count').replace('{n}', String(p.egressHostCount)) + ? t('admin.plugins.allowedHosts.count', { n: p.egressHostCount }) : t('admin.plugins.allowedHosts.add')} )} diff --git a/client/src/mobile/screens/admin/MAdminStoragePanel.tsx b/client/src/mobile/screens/admin/MAdminStoragePanel.tsx index 36007caa0..d662ba49f 100644 --- a/client/src/mobile/screens/admin/MAdminStoragePanel.tsx +++ b/client/src/mobile/screens/admin/MAdminStoragePanel.tsx @@ -510,7 +510,7 @@ export default function MAdminStoragePanel(): React.ReactElement {

{rowUsage && (

- {t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })} + {t('storage.usage.line', { count: rowUsage.objects, size: formatBytes(rowUsage.bytes) })} {row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0 ? ` (${t('storage.usage.legacyNote')})` : ''} @@ -772,7 +772,7 @@ export default function MAdminStoragePanel(): React.ReactElement { {state.usage?.categories[category] && (

{t('storage.usage.line', { - objects: String(state.usage.categories[category]!.objects), + count: state.usage.categories[category]!.objects, size: formatBytes(state.usage.categories[category]!.bytes), })}

diff --git a/client/src/mobile/screens/admin/MAdminUsersSection.tsx b/client/src/mobile/screens/admin/MAdminUsersSection.tsx index a16c52777..a6e133db0 100644 --- a/client/src/mobile/screens/admin/MAdminUsersSection.tsx +++ b/client/src/mobile/screens/admin/MAdminUsersSection.tsx @@ -37,7 +37,7 @@ export default function MAdminUsersSection({ admin, t, locale }: MAdminUsersSect
{t('admin.tabs.users')} - {users.length} {t('admin.stats.users').toLowerCase()} + {users.length} {t('admin.stats.users', { count: users.length })} setShowCreateUser(true)}> diff --git a/client/src/mobile/screens/atlas/MAtlas.tsx b/client/src/mobile/screens/atlas/MAtlas.tsx index 598835e4e..b505ea67f 100644 --- a/client/src/mobile/screens/atlas/MAtlas.tsx +++ b/client/src/mobile/screens/atlas/MAtlas.tsx @@ -193,7 +193,7 @@ export default function MAtlas() {
{resolveName(selectedCountry)}
- {[[countryDetail.places.length, t('atlas.places')], [countryDetail.trips.length, t('atlas.trips')]].map(([n, label]) => ( + {[[countryDetail.places.length, t('atlas.places', { count: countryDetail.places.length })], [countryDetail.trips.length, t('atlas.trips', { count: countryDetail.trips.length })]].map(([n, label]) => ( {n} {label} diff --git a/client/src/mobile/screens/atlas/MAtlasStatsCard.tsx b/client/src/mobile/screens/atlas/MAtlasStatsCard.tsx index 55b059913..b29ec034d 100644 --- a/client/src/mobile/screens/atlas/MAtlasStatsCard.tsx +++ b/client/src/mobile/screens/atlas/MAtlasStatsCard.tsx @@ -13,11 +13,11 @@ interface MAtlasStatsCardProps { export default function MAtlasStatsCard({ stats }: MAtlasStatsCardProps) { const { t } = useTranslation() const cols: [number, string][] = [ - [stats.totalCountries, t('atlas.countries')], - [stats.totalTrips, t('atlas.trips')], - [stats.totalPlaces, t('atlas.places')], - [stats.totalCities || 0, t('atlas.cities')], - [stats.totalDays, t('atlas.days')], + [stats.totalCountries, t('atlas.countries', { count: stats.totalCountries })], + [stats.totalTrips, t('atlas.trips', { count: stats.totalTrips })], + [stats.totalPlaces, t('atlas.places', { count: stats.totalPlaces })], + [stats.totalCities || 0, t('atlas.cities', { count: stats.totalCities || 0 })], + [stats.totalDays, t('atlas.days', { count: stats.totalDays })], ] // All five columns are spoken for, so the planned count rides along with the country // number as a superscript rather than claiming a sixth column. diff --git a/client/src/mobile/screens/dashboard/MDashboard.tsx b/client/src/mobile/screens/dashboard/MDashboard.tsx index bdf3d7e21..6a98ec14c 100644 --- a/client/src/mobile/screens/dashboard/MDashboard.tsx +++ b/client/src/mobile/screens/dashboard/MDashboard.tsx @@ -562,8 +562,8 @@ function MTripListCard({ trip, locale, t, badge, pluginBadges, actions, onOpen }
- - + +
diff --git a/client/src/mobile/screens/journey/MJourneyDetail.tsx b/client/src/mobile/screens/journey/MJourneyDetail.tsx index 43b8cc827..2f05ec114 100644 --- a/client/src/mobile/screens/journey/MJourneyDetail.tsx +++ b/client/src/mobile/screens/journey/MJourneyDetail.tsx @@ -299,7 +299,7 @@ export default function MJourneyDetail() { if (failed.length > 0) { toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) })) } else { - toast.success(t('journey.photosUploaded', { count: String(files.length) })) + toast.success(t('journey.photosUploaded', { count: files.length })) } loadJourney(Number(id)) } catch (err) { diff --git a/client/src/mobile/screens/trip/tabs/MBagsSheet.tsx b/client/src/mobile/screens/trip/tabs/MBagsSheet.tsx index 5bacbfb0f..05cd8343a 100644 --- a/client/src/mobile/screens/trip/tabs/MBagsSheet.tsx +++ b/client/src/mobile/screens/trip/tabs/MBagsSheet.tsx @@ -95,7 +95,7 @@ export default function MBagsSheet({ {formatWeight(unassignedWeight)}
- {unassigned.length} {t('admin.packingTemplates.items')} + {unassigned.length} {t('admin.packingTemplates.items', { count: unassigned.length })}
)} @@ -307,7 +307,7 @@ function BagRow({ planner, bag, itemCount, weight, pct, tripMembers, canEdit, on
-
{itemCount} {t('admin.packingTemplates.items')}
+
{itemCount} {t('admin.packingTemplates.items', { count: itemCount })}
) } diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx index 9a351f011..17cb49f01 100644 --- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx @@ -670,7 +670,7 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT className="inline-flex items-center gap-1 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-2 py-0.5 text-[0.625rem] font-semibold text-m-muted active:scale-95 transition-all" > - {t('costs.receipts') || 'Beleg'}{item.receipts!.length > 1 ? ` (${item.receipts!.length})` : ''} + {t('costs.receipts', { count: item.receipts!.length })}{item.receipts!.length > 1 ? ` (${item.receipts!.length})` : ''} )}
diff --git a/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx b/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx index 92849264d..eebef79dc 100644 --- a/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx @@ -374,7 +374,7 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) { {tmpl.name} - {tmpl.item_count} {t('admin.packingTemplates.items')} + {tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })} ))} diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 170763ed5..dd47f7691 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -97,10 +97,10 @@ function AdminPageDesktop(): React.ReactElement { actions={stats ? (
{[ - { label: t('admin.stats.users'), value: stats.totalUsers, icon: Users }, - { label: t('admin.stats.trips'), value: stats.totalTrips, icon: Briefcase }, - { label: t('admin.stats.places'), value: stats.totalPlaces, icon: Map }, - { label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText }, + { label: t('admin.stats.users', { count: stats.totalUsers }), value: stats.totalUsers, icon: Users }, + { label: t('admin.stats.trips', { count: stats.totalTrips }), value: stats.totalTrips, icon: Briefcase }, + { label: t('admin.stats.places', { count: stats.totalPlaces }), value: stats.totalPlaces, icon: Map }, + { label: t('admin.stats.files', { count: stats.totalFiles || 0 }), value: stats.totalFiles || 0, icon: FileText }, ].map(({ label, value, icon: Icon }) => ( ))} diff --git a/client/src/pages/AtlasPage.test.tsx b/client/src/pages/AtlasPage.test.tsx index a51f17d90..a7bbc4960 100644 --- a/client/src/pages/AtlasPage.test.tsx +++ b/client/src/pages/AtlasPage.test.tsx @@ -256,7 +256,7 @@ describe('AtlasPage', () => { // totalCountries = 1 — appears in both mobile bar and desktop panel expect(screen.getAllByText('1').length).toBeGreaterThan(0); }); - expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0); }); }); @@ -278,7 +278,7 @@ describe('AtlasPage', () => { it('keeps the trip out of the stats bar even when the API still returns one', async () => { render(); // The row is stats only now; the trip lives in the country detail below. - await waitFor(() => expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0)); expect(screen.queryByText('Paris Trip')).not.toBeInTheDocument(); }); }); @@ -289,7 +289,7 @@ describe('AtlasPage', () => { await waitFor(() => { // Both "Countries" labels (mobile + desktop) should be present - expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/trips/i).length).toBeGreaterThan(0); }); }); @@ -335,7 +335,7 @@ describe('AtlasPage', () => { await user.click(screen.getByText('Stats')); await waitFor(() => { - expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0); }); }); }); @@ -362,7 +362,7 @@ describe('AtlasPage', () => { await waitFor(() => { // Mobile bar always renders; check for the stats labels - const countryLabels = screen.getAllByText(/countries/i); + const countryLabels = screen.getAllByText(/countr(y|ies)/i); expect(countryLabels.length).toBeGreaterThan(0); }); }); @@ -505,7 +505,7 @@ describe('AtlasPage', () => { render(); // Wait for data to load so geoData is set and search input is rendered - await waitFor(() => expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0)); const searchInput = screen.getByPlaceholderText(/search a country/i); await user.type(searchInput, 'fr'); @@ -552,7 +552,7 @@ describe('AtlasPage', () => { render(); // Wait for both atlas data and geoData to load (search input renders after load) - await waitFor(() => expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0)); const searchInput = screen.getByPlaceholderText(/search a country/i); @@ -589,7 +589,7 @@ describe('AtlasPage', () => { // Eventually loads await waitFor(() => { - expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0); }); }); }); @@ -598,7 +598,7 @@ describe('AtlasPage', () => { it('mouseMove and mouseLeave events on the desktop panel work without errors', async () => { render(); - await waitFor(() => expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0)); // Find the desktop panel container and fire events const panel = document.querySelector('.hidden.md\\:flex') as HTMLElement | null; @@ -608,7 +608,7 @@ describe('AtlasPage', () => { } // No error thrown; DOM is still intact - expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0); }); }); @@ -622,7 +622,7 @@ describe('AtlasPage', () => { render(); // Wait for data and search input to be ready - await waitFor(() => expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0)); const searchInput = screen.getByPlaceholderText(/search a country/i); await user.type(searchInput, 'fr'); @@ -922,7 +922,7 @@ describe('AtlasPage', () => { render(); await waitFor(() => { - expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/trips/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/places/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/days/i).length).toBeGreaterThan(0); @@ -1005,11 +1005,11 @@ describe('AtlasPage', () => { // FR is in atlasStatsResponse.countries → visited branch // DE is not → unvisited else branch in onEachFeature await waitFor(() => { - expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0); }); // Both branches covered via Leaflet mock calling onEachFeature for each feature - expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0); }); }); @@ -1406,11 +1406,11 @@ describe('AtlasPage', () => { render(); await waitFor(() => { - expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0); }); // XK is not in A2_TO_A3_BASE, so the geoJSON loop covers the `A2_TO_A3[a2] = a3` line - expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0); }); }); @@ -1595,7 +1595,7 @@ describe('AtlasPage', () => { // Wait for initial data to load and geoJSON layer to be built await waitFor(() => { - expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0); }); // Change dark mode setting — this re-triggers the map init useEffect [dark] @@ -1605,7 +1605,7 @@ describe('AtlasPage', () => { // After dark mode change, the page re-renders and map re-initializes await waitFor(() => { - expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0); }); }); }); @@ -1726,7 +1726,7 @@ describe('AtlasPage', () => { }, { timeout: 3000 }).catch(() => {}); // Page is still rendered - expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0); }); }); @@ -1760,7 +1760,7 @@ describe('AtlasPage', () => { // mode re-initializes the map, re-registers zoomend, and by then FR's country layer // already exists, so loadRegionsForViewport actually fetches /regions/geo this time. await waitFor(() => { - expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0); }); seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: true }) }); @@ -1781,7 +1781,7 @@ describe('AtlasPage', () => { expect(screen.queryAllByRole('button').some((b) => b.textContent?.trim() === 'Remove')).toBe(false); }, { timeout: 3000 }).catch(() => {}); - expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0); }); }); diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 48275c7a4..87ba6b69d 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -146,7 +146,7 @@ function AtlasPageDesktop(): React.ReactElement { labelId="atlas-places-title" onClose={() => setPlacesOpen(false)} title={resolveName(selectedCountry)} - sub={`${countryDetail.places.length} ${t('atlas.places')}`} + sub={`${countryDetail.places.length} ${t('atlas.places', { count: countryDetail.places.length })}`} /> )} > @@ -161,9 +161,9 @@ function AtlasPageDesktop(): React.ReactElement { {/* Countries highlighted */}

{stats.totalCountries}

-

{t('atlas.countries')}

+

{t('atlas.countries', { count: stats.totalCountries })}

- {[[regionsVisited, t('atlas.regions')], [stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => ( + {[[regionsVisited, t('atlas.regions', { count: regionsVisited })], [stats.totalTrips, t('atlas.trips', { count: stats.totalTrips })], [stats.totalPlaces, t('atlas.places', { count: stats.totalPlaces })], [stats.totalCities || 0, t('atlas.cities', { count: stats.totalCities || 0 })], [stats.totalDays, t('atlas.days', { count: stats.totalDays })]].map(([v, l], i) => (

{v}

{l}

@@ -752,7 +752,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
{stats.totalCountries} - {t('atlas.countries')} + {t('atlas.countries', { count: stats.totalCountries })}
{(stats.totalCountriesPlanned || 0) > 0 && ( @@ -761,7 +761,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla )}
{/* Other stats */} - {[[regionsVisited, t('atlas.regions')], [stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => ( + {[[regionsVisited, t('atlas.regions', { count: regionsVisited })], [stats.totalTrips, t('atlas.trips', { count: stats.totalTrips })], [stats.totalPlaces, t('atlas.places', { count: stats.totalPlaces })], [stats.totalCities || 0, t('atlas.cities', { count: stats.totalCities || 0 })], [stats.totalDays, t('atlas.days', { count: stats.totalDays })]].map(([v, l], i) => (
{v} {l} @@ -835,13 +835,13 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla )} {[ - [countryDetail.trips.length, t('atlas.tripPlural')], - ...(countryRegions > 0 ? [[countryRegions, t('atlas.regions')] as const] : []), + [countryDetail.trips.length, t('atlas.tripsLabel', { count: countryDetail.trips.length })], + ...(countryRegions > 0 ? [[countryRegions, t('atlas.regions', { count: countryRegions })] as const] : []), ].map(([n, label]) => ( {n} {label} diff --git a/client/src/pages/AtlasPage.wiring.test.tsx b/client/src/pages/AtlasPage.wiring.test.tsx index 74e86aaeb..899af7193 100644 --- a/client/src/pages/AtlasPage.wiring.test.tsx +++ b/client/src/pages/AtlasPage.wiring.test.tsx @@ -460,8 +460,8 @@ describe('AtlasPage wiring', () => { render(); expect(screen.getByText('France')).toBeInTheDocument(); - expect(screen.getAllByText((_, el) => el?.textContent?.trim() === '3 atlas.places').length).toBeGreaterThan(0); - expect(screen.getAllByText((_, el) => el?.textContent?.trim() === '4 atlas.tripPlural').length).toBeGreaterThan(0); + expect(screen.getAllByText((_, el) => el?.textContent?.trim() === '3 atlas.places:3').length).toBeGreaterThan(0); + expect(screen.getAllByText((_, el) => el?.textContent?.trim() === '4 atlas.tripsLabel:4').length).toBeGreaterThan(0); // Only the first three trips are shown. expect(screen.queryByText('Brest')).not.toBeInTheDocument(); diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index ae1594951..a1ab4c927 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -423,7 +423,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
-
{t('dashboard.places')}
+
{t('dashboard.pass.places')}
{places.slice(0, 3).map(p => (
@@ -664,8 +664,8 @@ function TripCard({ trip, locale, badges, matchedPlaces, onOpen, onEdit, onCopy,
)}
-
{trip.day_count ?? 0}{t('dashboard.days')}
-
{trip.place_count ?? 0}{t('dashboard.places')}
+
{trip.day_count ?? 0}{t('dashboard.days', { count: trip.day_count ?? 0 })}
+
{trip.place_count ?? 0}{t('dashboard.places', { count: trip.place_count ?? 0 })}
{trip.shared_count ?? 0}{t('dashboard.card.buddies', { count: trip.shared_count ?? 0 })}
diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx index dfb8b0802..f4314aed4 100644 --- a/client/src/pages/JourneyDetailPage.test.tsx +++ b/client/src/pages/JourneyDetailPage.test.tsx @@ -362,7 +362,7 @@ describe('JourneyDetailPage', () => { // Gallery view renders photo count text await waitFor(() => { - expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); + expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument(); }); }); }); @@ -1100,7 +1100,7 @@ describe('JourneyDetailPage', () => { await user.click(galleryBtn); await waitFor(() => { - expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); + expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument(); }); // Gallery renders photos as images @@ -1120,7 +1120,7 @@ describe('JourneyDetailPage', () => { await user.click(galleryBtn); await waitFor(() => { - expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); + expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument(); }); // Gallery has an Upload button @@ -1787,7 +1787,7 @@ describe('JourneyDetailPage', () => { await user.click(galleryBtn); await waitFor(() => { - expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); + expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument(); }); // Click the photo in the gallery grid @@ -2005,7 +2005,7 @@ describe('JourneyDetailPage', () => { await user.click(galleryBtn); await waitFor(() => { - expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); + expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument(); }); // Gallery photos render in a grid; each photo has a group container @@ -2024,7 +2024,7 @@ describe('JourneyDetailPage', () => { await user.click(galleryBtn); await waitFor(() => { - expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); + expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument(); }); // The photo has caption 'Colosseum' @@ -2064,7 +2064,7 @@ describe('JourneyDetailPage', () => { await user.click(galleryBtn); await waitFor(() => { - expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); + expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument(); }); expect(screen.getByText('Immich')).toBeInTheDocument(); @@ -2105,7 +2105,7 @@ describe('JourneyDetailPage', () => { await user.click(galleryBtn); await waitFor(() => { - expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); + expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument(); }); expect(screen.getByText('Synology Photos')).toBeInTheDocument(); @@ -3357,12 +3357,12 @@ describe('JourneyDetailPage', () => { await user.click(galleryBtn); await waitFor(() => { - expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); + expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument(); }); // The gallery photo has a delete (X) button that appears on hover // In the gallery grid, each photo container has an X button - const galleryGrid = screen.getByText(/1 photos/i).closest('div')!.parentElement!; + const galleryGrid = screen.getByText(/1 photo\b/i).closest('div')!.parentElement!; const xButtons = galleryGrid.querySelectorAll('button'); // Find the X delete button on the photo const deleteBtn = Array.from(xButtons).find(btn => { diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index af1fbc9a2..56ff67cd0 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -468,7 +468,7 @@ function JourneyDetailPageDesktop() {

{new Date(date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}

- {entries.length} {t('journey.synced.places')} + {entries.length} {t('journey.synced.places', { count: entries.length })} {/* The only Add button used to be at the very top and always started on today, so putting something into an earlier day meant correcting the date by hand (discussion #2299). */} diff --git a/client/src/pages/JourneyPage.tsx b/client/src/pages/JourneyPage.tsx index 2325f28cd..02981c01b 100644 --- a/client/src/pages/JourneyPage.tsx +++ b/client/src/pages/JourneyPage.tsx @@ -208,7 +208,7 @@ function JourneyPageDesktop() { {filteredJourneys.length === 0 ? t('journey.search.noResults', { query: searchQuery.trim() }) - : `${filteredJourneys.length} ${t('journey.frontpage.journeys')}`} + : `${filteredJourneys.length} ${t('journey.frontpage.journeys', { count: filteredJourneys.length })}`}
)} @@ -372,9 +372,10 @@ function CreateJourneyDialog({ title, onTitleChange, subtitle, onSubtitleChange, let status: TripStatus = 'upcoming' if (trip.end_date && trip.end_date < today) status = 'completed' else if (trip.start_date && trip.start_date <= today) status = 'active' + // A trip without dates has no length to show, so it gets no day chip. const days = trip.start_date ? Math.ceil((new Date(trip.end_date || trip.start_date).getTime() - new Date(trip.start_date).getTime()) / 86400000) + 1 - : '?' + : null return (
{trip.title}
- {days} {t('journey.stats.days').toLowerCase()} - {trip.place_count || 0} {t('journey.frontpage.places')} + {days !== null && {t('mobileJourney.daysCount', { count: days })}} + {trip.place_count || 0} {t('journey.frontpage.places', { count: trip.place_count || 0 })}
diff --git a/client/src/pages/JourneyPage.wiring.test.tsx b/client/src/pages/JourneyPage.wiring.test.tsx index b16d9e090..3302670a4 100644 --- a/client/src/pages/JourneyPage.wiring.test.tsx +++ b/client/src/pages/JourneyPage.wiring.test.tsx @@ -102,7 +102,7 @@ describe('JourneyPage wiring', () => { it('FE-JRN-LISTWIRE-005: an active search reports how many journeys matched', () => { const journeys = [buildJourney({ id: 1, title: 'Kyoto trip' })]; setup({ journeys, filteredJourneys: journeys, searchQuery: 'kyo' }); - expect(screen.getByText('1 journeys')).toBeInTheDocument(); + expect(screen.getByText('1 journey')).toBeInTheDocument(); expect(screen.queryByText('All Journeys')).not.toBeInTheDocument(); }); diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 665b8e08d..3766ae72b 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -52,8 +52,8 @@ export default function SharedTripPage() { ]; const stats: HeroStat[] = [ - { key: 'days', value: data.days?.length || 0, label: t('dashboard.days') }, - { key: 'places', value: data.places?.length || 0, label: t('dashboard.places') }, + { key: 'days', value: data.days?.length || 0, label: t('dashboard.days', { count: data.days?.length || 0 }) }, + { key: 'places', value: data.places?.length || 0, label: t('dashboard.places', { count: data.places?.length || 0 }) }, { key: 'bookings', value: permissions?.share_bookings ? reservations.length : 0, label: t('trip.tabs.reservations') }, ].filter(s => s.value > 0); diff --git a/client/src/pages/admin/AdminUsersTab.tsx b/client/src/pages/admin/AdminUsersTab.tsx index ee6068a30..057a34131 100644 --- a/client/src/pages/admin/AdminUsersTab.tsx +++ b/client/src/pages/admin/AdminUsersTab.tsx @@ -51,7 +51,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): {users.length} {t('admin.stats.users')}} + hint={<>{users.length} {t('admin.stats.users', { count: users.length })}} action={ diff --git a/client/src/pages/sharedTrip/SharedUnplannedCard.tsx b/client/src/pages/sharedTrip/SharedUnplannedCard.tsx index 45179cd70..3d6c9ed34 100644 --- a/client/src/pages/sharedTrip/SharedUnplannedCard.tsx +++ b/client/src/pages/sharedTrip/SharedUnplannedCard.tsx @@ -26,7 +26,7 @@ export function SharedUnplannedCard({ places, categories }: SharedUnplannedCardP {t('shared.unplanned')} - {places.length} {t('shared.places')} + {places.length} {t('shared.places', { count: places.length })}