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.
This commit is contained in:
Maurice
2026-10-08 00:01:59 +00:00
parent 55697b75c1
commit bee500880e
851 changed files with 11378 additions and 6077 deletions
@@ -1178,7 +1178,7 @@ describe('AdminPluginsPanel — compatible updates only', () => {
render(<AdminPluginsPanel />)
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']))
@@ -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)}>
<Globe size={12} />
{hostCount > 0
? t('admin.plugins.allowedHosts.count').replace('{n}', String(hostCount))
? t('admin.plugins.allowedHosts.count', { n: hostCount })
: t('admin.plugins.allowedHosts.add')}
</button>
</Tooltip>
@@ -243,7 +243,7 @@ export default function PackingTemplateManager() {
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="min-w-0 flex-1 truncate text-start font-semibold text-content" style={fs(13, 'body')}>{tmpl.name}</button>
)}
<span className="max-sm:hidden">
<StatusPill>{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}</StatusPill>
<StatusPill>{tmpl.category_count} {t('admin.packingTemplates.categories', { count: tmpl.category_count })} · {tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })}</StatusPill>
</span>
<Tooltip label={t('common.rename')}>
<button type="button" onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
@@ -434,7 +434,7 @@ describe('AdminStoragePanel', () => {
);
fireEvent.click(screen.getByRole('button', { name: 'Compute now' }));
await screen.findByText(/Usage computed/);
expect(within(categoryRow('files')).getByText(/1 objects · 2\.0 KB/)).toBeInTheDocument();
expect(within(categoryRow('files')).getByText(/1 object · 2\.0 KB/)).toBeInTheDocument();
});
it('FE-ADMIN-STOR-020: Sync now runs the backfill — running line with counts, then the done line (50ms test poll)', async () => {
@@ -506,7 +506,7 @@ export default function AdminStoragePanel(): React.ReactElement {
<div className={DETAILS}>
{rowUsage && (
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
{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')})`
: ''}
@@ -763,7 +763,7 @@ export default function AdminStoragePanel(): React.ReactElement {
{state.usage?.categories[category] && (
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
{t('storage.usage.line', {
objects: String(state.usage.categories[category]!.objects),
count: state.usage.categories[category]!.objects,
size: formatBytes(state.usage.categories[category]!.bytes),
})}
</p>
@@ -1150,7 +1150,7 @@ describe('CostsPanel — filtering the ledger', () => {
fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Sun, Jun 15' }))
expect(screen.getByText('Sunday, June 15')).toBeInTheDocument()
expect(screen.getByText('1 expenses')).toBeInTheDocument()
expect(screen.getByText('1 expense')).toBeInTheDocument()
expect(screen.getByText('Dinner')).toBeInTheDocument()
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
// The 16th carries the payment, so it is filtered out with the day too.
+1 -1
View File
@@ -871,7 +871,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
}}
>
<Receipt size={12} className="text-content-muted" />
<span>{t('costs.receipts') || 'Beleg'}{e.receipts!.length > 1 ? ` (${e.receipts!.length})` : ''}</span>
<span>{t('costs.receipts', { count: e.receipts!.length })}{e.receipts!.length > 1 ? ` (${e.receipts!.length})` : ''}</span>
</button>
</Tooltip>
)}
@@ -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 () => {
@@ -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();
});
@@ -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);
});
@@ -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')
})
@@ -295,9 +295,7 @@ export default function DawarichAtlasDialog({
{country.cities.length > 0 && (
<>
<DawarichBadge icon={Building2} size="sm">
{country.cities.length === 1
? t('dawarich.atlas.citiesOne')
: t('dawarich.atlas.cities', { count: country.cities.length })}
{t('dawarich.atlas.cities', { count: country.cities.length })}
</DawarichBadge>
<span className="min-w-0 truncate text-caption text-content-muted">
{cityLine(country.cities)}
@@ -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('')
})
@@ -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 */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-[0.07em]" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}>
<Camera size={11} /> {allPhotos.length} {t('journey.detail.photos')}
<Camera size={11} /> {allPhotos.length} {t('journey.detail.photos', { count: allPhotos.length })}
</span>
</div>
@@ -382,7 +382,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
<span className="h-8 w-8 flex-none rounded-[9px]" style={{ background: pickGradient(trip.trip_id) }} />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{trip.title}</div>
<div className="text-content-faint" style={fs(11.5)}>{trip.place_count || 0} {t('journey.synced.places')}</div>
<div className="text-content-faint" style={fs(11.5)}>{trip.place_count || 0} {t('journey.synced.places', { count: trip.place_count || 0 })}</div>
</div>
<RowAction label={t('journey.trips.unlinkTrip')} onClick={() => setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}>
<Trash2 size={14} />
+2 -2
View File
@@ -915,11 +915,11 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
<div class="cover-stats">
<div>
<div class="cover-stat-num">${sorted.length}</div>
<div class="cover-stat-lbl">${escHtml(tr('dashboard.days'))}</div>
<div class="cover-stat-lbl">${escHtml(tr('dashboard.days', { count: sorted.length }))}</div>
</div>
<div>
<div class="cover-stat-num">${places?.length || 0}</div>
<div class="cover-stat-lbl">${escHtml(tr('dashboard.places'))}</div>
<div class="cover-stat-lbl">${escHtml(tr('dashboard.places', { count: places?.length || 0 }))}</div>
</div>
<div>
<div class="cover-stat-num">${totalAssigned}</div>
@@ -92,7 +92,7 @@ export default function ApplyTemplateButton({ tripId, visibility, style, classNa
<div style={{ flex: 1, textAlign: 'start' }}>
<div style={{ fontWeight: 600 }}>{tmpl.name}</div>
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>
{tmpl.item_count} {t('admin.packingTemplates.items')}
{tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })}
</div>
</div>
</button>
@@ -125,7 +125,7 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 5 }}>
<span style={{ fontSize: sz.count, color: 'var(--text-faint)' }}>{bagItems.length} {t('admin.packingTemplates.items')}</span>
<span style={{ fontSize: sz.count, color: 'var(--text-faint)' }}>{bagItems.length} {t('admin.packingTemplates.items', { count: bagItems.length })}</span>
<span style={{ fontSize: sz.weight, color: 'var(--text-muted)', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 3, fontVariantNumeric: 'tabular-nums' }}>
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
{editingLimit && canEdit ? (
@@ -59,7 +59,7 @@ export function BagModal(S: PackingState) {
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
</span>
</div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items', { count: unassigned.length })}</div>
</div>
)
})()}
@@ -85,7 +85,7 @@ export function BagSidebar(S: PackingState) {
<div style={{ ...hairline(bags.length === 0), display: 'flex', alignItems: 'center', gap: 6, padding: '11px 0' }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '1.5px dashed var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)' }}>{t('packing.noBag')}</span>
<span title={`${unassigned.length} ${t('admin.packingTemplates.items')}`} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, padding: '1px 6px', borderRadius: 99, minWidth: 18, textAlign: 'center', background: 'var(--bg-tertiary)', color: 'var(--text-faint)', fontVariantNumeric: 'tabular-nums' }}>{unassigned.length}</span>
<span title={`${unassigned.length} ${t('admin.packingTemplates.items', { count: unassigned.length })}`} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, padding: '1px 6px', borderRadius: 99, minWidth: 18, textAlign: 'center', background: 'var(--bg-tertiary)', color: 'var(--text-faint)', fontVariantNumeric: 'tabular-nums' }}>{unassigned.length}</span>
<span style={{ flex: 1 }} />
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}>{kgOrG(unassignedWeight)}</span>
</div>
@@ -76,7 +76,7 @@ export function PackingHeader(S: PackingState) {
<Package size={13} className="text-content-faint" />
<div style={{ flex: 1, textAlign: 'start' }}>
<div style={{ fontWeight: 600 }}>{tmpl.name}</div>
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{tmpl.item_count} {t('admin.packingTemplates.items')}</div>
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })}</div>
</div>
</button>
))}
@@ -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)
})
@@ -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()
@@ -1119,7 +1119,7 @@ describe('PlaceInspector', () => {
const { openFile } = await import('../../utils/fileDownload');
const spy = vi.spyOn({ openFile }, 'openFile');
render(<PlaceInspector {...defaultProps} files={[placeFile()] as any} />);
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.
@@ -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 (
<FilterDropdown name={t('categories.title')} label={label} active={categoryFilters.size > 0} badge={categoryFilters.size}
@@ -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 () => {
@@ -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));
});
@@ -326,7 +326,7 @@ describe('RoadtripCorridorPanel', () => {
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
// 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()
})
@@ -128,7 +128,7 @@ export function StudioExport({
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{t('journey.studio.exportNote', {
sheets: sheets.length,
count: sheets.length,
width: round1(box.width),
height: round1(box.height),
})}
@@ -193,7 +193,7 @@ describe('TripMembersModal', () => {
)
);
render(<TripMembersModal {...defaultProps} />);
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(<TripMembersModal {...defaultProps} />);
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 () => {
+12 -5
View File
@@ -112,7 +112,10 @@ interface TranslationProviderProps {
export function TranslationProvider({ children }: TranslationProviderProps) {
const language = useSettingsStore((s) => s.settings.language) || 'en'
const [strings, setStrings] = useState<TranslationStrings>(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, string | number>): string =>
resolveTemplate(strings, intlLanguage, key, params)
resolveTemplate(catalogue.strings, intlLanguage, key, params)
function t(key: string, params?: Record<string, string | number>): 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 <TranslationContext value={value}>{children}</TranslationContext>
}
+17
View File
@@ -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')
+10 -3
View File
@@ -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, string | number>,
): 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
}
+4 -4
View File
@@ -199,10 +199,10 @@ export default function MAdmin() {
{/* Stats grid (§6.3) */}
{stats && (
<div className="mb-3 grid grid-cols-2 gap-[10px]">
<MAdminStat label={t('admin.stats.users')} value={stats.totalUsers} icon={Users} />
<MAdminStat label={t('admin.stats.trips')} value={stats.totalTrips} icon={Briefcase} />
<MAdminStat label={t('admin.stats.places')} value={stats.totalPlaces} icon={Map} />
<MAdminStat label={t('admin.stats.files')} value={stats.totalFiles || 0} icon={FileText} />
<MAdminStat label={t('admin.stats.users', { count: stats.totalUsers })} value={stats.totalUsers} icon={Users} />
<MAdminStat label={t('admin.stats.trips', { count: stats.totalTrips })} value={stats.totalTrips} icon={Briefcase} />
<MAdminStat label={t('admin.stats.places', { count: stats.totalPlaces })} value={stats.totalPlaces} icon={Map} />
<MAdminStat label={t('admin.stats.files', { count: stats.totalFiles || 0 })} value={stats.totalFiles || 0} icon={FileText} />
</div>
)}
@@ -261,7 +261,7 @@ export default function MAdminPackingTemplateManager() {
<button type="button" className="min-w-0 flex-1 text-start" onClick={() => toggleExpand(tmpl.id)}>
<div className="truncate text-[0.8125rem] font-bold text-m-ink">{tmpl.name}</div>
<div className="mt-[1px] font-geist text-[0.59375rem] text-m-faint">
{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 })}
</div>
</button>
)}
@@ -1409,7 +1409,7 @@ function InstalledRow({ p, t, busy, hasUpdate, latestVer, newerIncompatible, blo
>
<Globe size={12} className={p.egressHostCount > 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')}
</button>
)}
@@ -510,7 +510,7 @@ export default function MAdminStoragePanel(): React.ReactElement {
</p>
{rowUsage && (
<p className="mt-1 font-geist text-[0.625rem] text-m-muted">
{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] && (
<p className="mb-1 font-geist text-[0.625rem] text-m-muted">
{t('storage.usage.line', {
objects: String(state.usage.categories[category]!.objects),
count: state.usage.categories[category]!.objects,
size: formatBytes(state.usage.categories[category]!.bytes),
})}
</p>
@@ -37,7 +37,7 @@ export default function MAdminUsersSection({ admin, t, locale }: MAdminUsersSect
<div className="mb-1 flex items-center gap-2">
<span className="text-[0.875rem] font-extrabold text-m-ink">{t('admin.tabs.users')}</span>
<span className="font-geist text-[0.625rem] font-bold text-m-faint">
{users.length} {t('admin.stats.users').toLowerCase()}
{users.length} {t('admin.stats.users', { count: users.length })}
</span>
<MAdminButton className="ms-auto" onClick={() => setShowCreateUser(true)}>
<UserPlus size={12} strokeWidth={2.2} />
+1 -1
View File
@@ -193,7 +193,7 @@ export default function MAtlas() {
<div className="min-w-0 flex-1">
<div className="truncate text-[1.0625rem] font-extrabold text-m-ink">{resolveName(selectedCountry)}</div>
<div className="mt-[4px] flex flex-wrap gap-1">
{[[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]) => (
<span key={label} className="rounded-full bg-[color:var(--m-ic)] px-2 py-[1px] font-geist text-[0.625rem] font-bold tabular-nums text-m-muted">
{n} {label}
</span>
@@ -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.
@@ -562,8 +562,8 @@ function MTripListCard({ trip, locale, t, badge, pluginBadges, actions, onOpen }
</div>
<div className="my-[13px] h-px bg-[color:var(--m-rowbr)]" />
<div className="flex text-center">
<ListStat value={trip.day_count ?? 0} label={t('dashboard.days')} />
<ListStat value={trip.place_count ?? 0} label={t('dashboard.places')} />
<ListStat value={trip.day_count ?? 0} label={t('dashboard.days', { count: trip.day_count ?? 0 })} />
<ListStat value={trip.place_count ?? 0} label={t('dashboard.places', { count: trip.place_count ?? 0 })} />
<ListStat value={trip.shared_count ?? 0} label={t('dashboard.card.buddies', { count: trip.shared_count ?? 0 })} />
</div>
<MTripBadges items={pluginBadges} />
@@ -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) {
@@ -95,7 +95,7 @@ export default function MBagsSheet({
<span className="font-geist text-[0.71875rem] text-m-faint">{formatWeight(unassignedWeight)}</span>
</div>
<div className="font-geist text-[0.65625rem] text-m-faint">
{unassigned.length} {t('admin.packingTemplates.items')}
{unassigned.length} {t('admin.packingTemplates.items', { count: unassigned.length })}
</div>
</div>
)}
@@ -307,7 +307,7 @@ function BagRow({ planner, bag, itemCount, weight, pct, tripMembers, canEdit, on
<div className="h-[7px] overflow-hidden rounded-full bg-[color:var(--m-ic)]">
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: bag.color }} />
</div>
<div className="mt-[3px] font-geist text-[0.65625rem] text-m-faint">{itemCount} {t('admin.packingTemplates.items')}</div>
<div className="mt-[3px] font-geist text-[0.65625rem] text-m-faint">{itemCount} {t('admin.packingTemplates.items', { count: itemCount })}</div>
</div>
)
}
@@ -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"
>
<Receipt size={11} className="text-m-faint" />
<span>{t('costs.receipts') || 'Beleg'}{item.receipts!.length > 1 ? ` (${item.receipts!.length})` : ''}</span>
<span>{t('costs.receipts', { count: item.receipts!.length })}{item.receipts!.length > 1 ? ` (${item.receipts!.length})` : ''}</span>
</button>
)}
</div>
@@ -374,7 +374,7 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) {
<Package size={14} strokeWidth={2} className="flex-none text-m-muted" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.78125rem] font-semibold text-m-ink">{tmpl.name}</span>
<span className="block font-geist text-[0.625rem] text-m-faint">{tmpl.item_count} {t('admin.packingTemplates.items')}</span>
<span className="block font-geist text-[0.625rem] text-m-faint">{tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })}</span>
</span>
</button>
))}
+4 -4
View File
@@ -97,10 +97,10 @@ function AdminPageDesktop(): React.ReactElement {
actions={stats ? (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
{ 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 }) => (
<AdminStatCard key={label} label={label} value={value} icon={Icon} />
))}
+21 -21
View File
@@ -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(<AtlasPage />);
// 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(<AtlasPage />);
// 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(<AtlasPage />);
// 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(<AtlasPage />);
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(<AtlasPage />);
// 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(<AtlasPage />);
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(<AtlasPage />);
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);
});
});
+8 -8
View File
@@ -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 */}
<div className="text-center px-3 py-1.5 rounded-xl" style={{ background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }}>
<p className="text-3xl font-black tabular-nums leading-none text-content">{stats.totalCountries}</p>
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1 text-content-faint">{t('atlas.countries')}</p>
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1 text-content-faint">{t('atlas.countries', { count: stats.totalCountries })}</p>
</div>
{[[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) => (
<div key={i} className="text-center px-1">
<p className="text-xl font-black tabular-nums leading-none text-content">{v}</p>
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1 text-content-faint">{l}</p>
@@ -752,7 +752,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
<div className="flex flex-col justify-center px-5 py-4 mx-2 my-2 rounded-xl" style={{ background: bg(0.08) }}>
<div className="flex items-baseline gap-1.5">
<span className="text-5xl font-black tabular-nums leading-none" style={{ color: tp }}>{stats.totalCountries}</span>
<span className="text-sm font-medium" style={{ color: tm }}>{t('atlas.countries')}</span>
<span className="text-sm font-medium" style={{ color: tm }}>{t('atlas.countries', { count: stats.totalCountries })}</span>
</div>
{(stats.totalCountriesPlanned || 0) > 0 && (
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide whitespace-nowrap" style={{ color: tf }}>
@@ -761,7 +761,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
)}
</div>
{/* 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) => (
<div key={i} className="flex flex-col items-center justify-center px-3 py-5 shrink-0">
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{v}</span>
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide whitespace-nowrap" style={{ color: tf }}>{l}</span>
@@ -835,13 +835,13 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
<button type="button" onClick={onOpenPlaces}
className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-1.5 py-px text-[10px] font-semibold tabular-nums transition-opacity hover:opacity-75"
style={{ background: bg(0.1), color: tp }}>
<MapPin size={9} />{countryDetail.places.length} {t('atlas.places')}
<MapPin size={9} />{countryDetail.places.length} {t('atlas.places', { count: countryDetail.places.length })}
</button>
</Tooltip>
)}
{[
[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]) => (
<span key={label} className="whitespace-nowrap rounded-full px-1.5 py-px text-[10px] font-semibold tabular-nums" style={{ background: bg(0.06), color: tf }}>
{n} {label}
+2 -2
View File
@@ -460,8 +460,8 @@ describe('AtlasPage wiring', () => {
render(<AtlasPage />);
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();
+3 -3
View File
@@ -423,7 +423,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
</div>
<div className="pass-cell places">
<div className="pass-label">{t('dashboard.places')}</div>
<div className="pass-label">{t('dashboard.pass.places')}</div>
<div className="places-preview">
{places.slice(0, 3).map(p => (
<div key={p.id} className="place-av">
@@ -664,8 +664,8 @@ function TripCard({ trip, locale, badges, matchedPlaces, onOpen, onEdit, onCopy,
</div>
)}
<div className="trip-meta" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
<div><span className="n mono">{trip.day_count ?? 0}</span><span className="k">{t('dashboard.days')}</span></div>
<div><span className="n mono">{trip.place_count ?? 0}</span><span className="k">{t('dashboard.places')}</span></div>
<div><span className="n mono">{trip.day_count ?? 0}</span><span className="k">{t('dashboard.days', { count: trip.day_count ?? 0 })}</span></div>
<div><span className="n mono">{trip.place_count ?? 0}</span><span className="k">{t('dashboard.places', { count: trip.place_count ?? 0 })}</span></div>
<div><span className="n mono">{trip.shared_count ?? 0}</span><span className="k">{t('dashboard.card.buddies', { count: trip.shared_count ?? 0 })}</span></div>
</div>
<TripCardBadges items={badges ?? []} />
+10 -10
View File
@@ -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 => {
+1 -1
View File
@@ -468,7 +468,7 @@ function JourneyDetailPageDesktop() {
<h3 className="text-[14px] font-semibold capitalize" style={{ color: 'var(--vg-ink)' }}>{new Date(date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}</h3>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-[0.07em]" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}><MapPin size={12} /> {entries.length} {t('journey.synced.places')}</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-[0.07em]" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}><MapPin size={12} /> {entries.length} {t('journey.synced.places', { count: entries.length })}</span>
{/* 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). */}
+5 -4
View File
@@ -208,7 +208,7 @@ function JourneyPageDesktop() {
<span className="text-[13px] text-zinc-500">
{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 })}`}
</span>
</div>
)}
@@ -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 (
<div
key={trip.id}
@@ -396,8 +397,8 @@ function CreateJourneyDialog({ title, onTitleChange, subtitle, onSubtitleChange,
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13.5, 'body')}>{trip.title}</div>
<div className="mt-0.5 flex items-center gap-2.5 text-content-faint" style={fs(11.5)}>
<span className="flex items-center gap-1"><Calendar size={11} /> {days} {t('journey.stats.days').toLowerCase()}</span>
<span className="flex items-center gap-1"><MapPin size={11} /> {trip.place_count || 0} {t('journey.frontpage.places')}</span>
{days !== null && <span className="flex items-center gap-1"><Calendar size={11} /> {t('mobileJourney.daysCount', { count: days })}</span>}
<span className="flex items-center gap-1"><MapPin size={11} /> {trip.place_count || 0} {t('journey.frontpage.places', { count: trip.place_count || 0 })}</span>
</div>
</div>
<span className={`flex-none rounded-full px-2 py-[2px] font-semibold ${TRIP_STATUS_LOOK[status]}`} style={fs(10.5)}>
+1 -1
View File
@@ -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();
});
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -51,7 +51,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
<SettingsCard
icon={Users}
title={t('admin.tabs.users')}
hint={<>{users.length} {t('admin.stats.users')}</>}
hint={<>{users.length} {t('admin.stats.users', { count: users.length })}</>}
action={
<button type="button" onClick={() => setShowCreateUser(true)} className={SETTINGS_BUTTON_PRIMARY} style={fs(12.5, 'body')}>
<UserPlus size={14} strokeWidth={2.1} />
@@ -300,7 +300,7 @@ export function useJourneyDetail() {
try {
const { restored } = await journeyApi.restoreSuggestions(current.id)
await loadJourney(current.id)
toast.success(t('journey.suggestions.restored', { count: String(restored) }))
toast.success(t('journey.suggestions.restored', { count: restored }))
} catch {
toast.error(t('common.errorTitle'))
}
@@ -70,7 +70,7 @@ export function SharedDayCard(p: SharedDayCardProps) {
</span>
<span className="mt-1.5 flex min-w-0 flex-wrap items-center gap-1">
{p.stays.map(stay => <StayPill key={stay.id} stay={stay} dayId={day.id} />)}
{!p.travelOnly && <SoftPill>{p.placeCount} {t('shared.places')}</SoftPill>}
{!p.travelOnly && <SoftPill>{p.placeCount} {t('shared.places', { count: p.placeCount })}</SoftPill>}
</span>
</span>
</button>
@@ -26,7 +26,7 @@ export function SharedUnplannedCard({ places, categories }: SharedUnplannedCardP
<MapPin size={14} strokeWidth={2} />
</span>
<span className="min-w-0 flex-1 truncate font-bold text-content" style={fs(13.5, 'body')}>{t('shared.unplanned')}</span>
<SoftPill>{places.length} {t('shared.places')}</SoftPill>
<SoftPill>{places.length} {t('shared.places', { count: places.length })}</SoftPill>
<button
type="button"
onClick={() => setCollapsed(c => !c)}