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 })}