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

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

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