Compare commits

...

13 Commits

Author SHA1 Message Date
Maurice 74864f2543 fix(places): keep the places repository at its size baseline
The insert's note now says that a longitude is stored on the real world, in
one line fewer.
2026-11-04 12:09:29 +01:00
Maurice 178df0fbe3 fix(map): keep the world-copy fix inside the CI ratchets
Moves the Leaflet map's event bridges (click, context menu, viewport, POI drop
and the longitude-frame follower) out of MapView into leafletMapBridges, and
shares one POI drop target between the Leaflet and GL maps, so both map files
shrink instead of growing. The repair migration does its arithmetic in one
UPDATE per table, and the new tests drop their non-null assertions and loops.
2026-11-04 11:37:52 +01:00
Maurice 51af575c9a fix(map): keep places and routes on one world, and start new installs with Journey and Collections
A right-click, click or drag on a repeated world copy reported the longitude it
has on that copy (274.9 for -85.1). The place was saved there, the geocoder
found nothing, and a route to a place on the real world ran across the whole
map (#2976). Longitudes are now brought back onto [-180, 180] where the map
hands them out (Leaflet and GL), where they are stored (places, booking
endpoints, collection places, so REST, MCP, plugin RPC and importers alike)
and before reverse geocoding. A migration moves the ones already stored.

Leaflet draws a layer at its literal longitude only, so a trip from Los
Angeles to Auckland had its pins at opposite ends of the world while the
flight ran off the edge to the next copy (#2975). A trip that crosses the
antimeridian is now drawn in one longitude frame, seamed in the widest
stretch it leaves empty, so pins, routes, flights and every overlay lie on
one copy and the map opens across the Pacific. Every other trip has no frame
and draws exactly as before. The GL day fit also takes the short way.

A new install now starts with Journey and Collections switched on, next to
the trip addons (all but Road trip), Atlas, Vacay and the Naver import that
already were. Existing installs keep their addons as they are.

Closes #2975
Closes #2976
2026-11-04 10:56:36 +01:00
Maurice 3e90ece087 fix(settings): list the phone plugin version date in the feature pairs 2026-11-04 00:28:49 +00:00
Maurice 65e5d4ab72 fix(settings): format the date format files prettier flagged 2026-11-03 23:39:06 +00:00
Maurice 154620b99a fix(settings): format two settings tests and keep the plugin panel at its size 2026-11-03 23:38:52 +00:00
Maurice adbe3a40f6 fix(settings): lint, size and test follow-ups for the date format
The admin reset link is a component of its own instead of one made during render, the version line of the plugin panels moved into small modules, the new settings rows hand their save on as void, the date picker takes an accessible name for fields without a visible label (the Vacay hire date), the Vacay and poll tests type through the shared picker, and the theme, size and lint baselines are lowered to what the files hold now.
2026-11-03 23:38:52 +00:00
Maurice 44c6fbf372 fix(settings): let every date follow a date format of the user's choice
Every date in TREK was written in the interface language's format, and
English is en-US, so an English interface always showed and read
mm/dd/yyyy with no way to change it. A few native date inputs followed
the browser instead, so one screen could mix two formats.

date_format is now a user setting with an admin default, next to the
time format: automatic (the language's format, as before), YYYY-MM-DD,
YYYY/MM/DD, DD.MM.YYYY, DD/MM/YYYY or MM/DD/YYYY. The date picker shows,
pre-fills, hints and reads typed dates in it, and every date written in
numbers across the app (lists, cards, admin panels, CSV and PDF exports)
goes through one formatter. Dates written with a month name keep the
language's wording. The remaining native date inputs are the shared
picker now. The display settings MCP tool reads and writes the key.

Closes #2971
2026-11-03 23:38:37 +00:00
Maurice 45690ecd14 chore(costs): keep the money helpers used only by settlementFx module-private 2026-11-03 23:21:12 +00:00
Maurice c6149cd64d fix(costs): move the settlement currency setup into the money helpers and hold the size baselines 2026-11-03 22:16:41 +00:00
Maurice 4587cf2099 Drop the unused within import and lower the test assertion baseline 2026-11-03 22:16:41 +00:00
Maurice 4585988dfd fix(costs): keep convertBooked at five parameters and update the tests to as-typed display rows
The display-currency check is its own helper (inDisplayCurrency), used by the Costs model and the shared trip page. The tests that pinned #2525's reading of a display-currency bill through the trip currency now pin the as-typed reading, which the balances use too; read in the trip currency, such a bill and its repayment may stay a euro cent apart.
2026-11-03 22:16:41 +00:00
Maurice a37a9c0fa8 fix(costs): count an expense in the display currency as typed
On a yen trip read in euros, an expense entered in euros was booked through the yen at the rate frozen on entry and shown back at today's rate, so 1,081.09 EUR read 1,078.86 EUR a day later, and repaying exactly what was typed left 2.23 EUR owed.

Rows in the display currency now net apart in whole display cents, as entered, and the lists show them as typed. A payment in the display currency first pays what is owed between the two in such rows; only the rest goes through the trip currency at its frozen rate, so a trip-currency debt settled in the display currency stays settled when rates move (#1445). Every other row still nets in trip cents and converts once (#1382), so the balances add up to zero in both halves.

Closes #2965
2026-11-03 22:16:41 +00:00
182 changed files with 3141 additions and 757 deletions
+3 -3
View File
@@ -15,7 +15,7 @@
"@typescript-eslint/no-unused-vars": 124,
"react-hooks/immutability": 28,
"max-lines-per-function": 130,
"react-hooks/static-components": 78,
"react-hooks/static-components": 50,
"react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7,
"no-empty": 19,
@@ -25,7 +25,7 @@
"max-depth": 21,
"react-hooks/void-use-memo": 1,
"@typescript-eslint/no-unused-expressions": 2,
"react-hooks/purity": 2,
"react-hooks/purity": 1,
"preserve-caught-error": 53,
"no-useless-escape": 1
},
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1915,
"@typescript-eslint/no-non-null-assertion": 1914,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+1
View File
@@ -983,6 +983,7 @@
"src/mobile/screens/admin/MAdminOidcCard.tsx": "Markup only; the OIDC card of MAdminSettingsSection, with the form state from useAdmin and the save from its useAdminSettingsActions handed in through props.",
"src/mobile/screens/admin/MAdminResetMfaSheet.tsx": "Markup only; the 2FA reset confirm that MAdminSheets opens, with the reset handed in through props from its useAdminUserActions.",
"src/mobile/screens/admin/MAdminUi.tsx": "Phone only admin UI primitives (button, card, field, input, secret input, sheet frame).",
"src/mobile/screens/admin/MPluginVersionDate.tsx": "Markup only; the published date of a plugin version in MAdminPluginsPanel, formatted with the user's date format through useFormatDate.",
"src/mobile/screens/admin/MStorageReclaimCard.tsx": "Markup only; renders the reclaim view and handlers that MAdminStoragePanel takes from useStoragePanel, the twin of StorageReclaimSection.",
"src/mobile/screens/atlas/MAtlasSearch.tsx": "Phone full screen country search overlay; options, suggestions and selection come through props from MAtlas.",
"src/mobile/screens/atlas/MAtlasStatsCard.tsx": "Markup only; renders the AtlasStats that MAtlas passes in.",
+4 -4
View File
@@ -2,12 +2,12 @@
"e2e/help/fixtures.ts": 2291,
"src/api/client.ts": 1873,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1277,
"src/components/Budget/CostsPanel.test.tsx": 2736,
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
"src/components/Budget/CostsPanel.test.tsx": 2730,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/MapView.tsx": 1337,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2218,
"src/components/Map/MapViewGL.tsx": 2198,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2929,
+1 -1
View File
@@ -284,7 +284,7 @@
"mobile/screens/vacay/MVacay.tsx": 23,
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
"mobile/screens/vacay/MVacayMonth.tsx": 3,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 36,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
"mobile/screens/vacay/vacayDayModel.ts": 1,
"pages/AtlasPage.tsx": 32,
@@ -1,6 +1,7 @@
import { type ReactNode } from 'react'
import { Key, Trash2, User, Loader2, Shield, KeyRound, Bot } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
@@ -64,9 +65,10 @@ export default function AdminMcpTokensPanel() {
sessions, sessionsLoading, tokens, tokensLoading, expandedScopes, toggleScopes,
revokeConfirmId, setRevokeConfirmId, deleteConfirmId, setDeleteConfirmId, handleRevoke, handleDelete,
} = useMcpTokensAdmin()
const { t, locale } = useTranslation()
const { t } = useTranslation()
const { formatDate } = useFormatDate()
const date = (iso: string) => new Date(iso).toLocaleDateString(locale)
const date = (iso: string) => formatDate(new Date(iso))
return (
<div>
@@ -11,6 +11,7 @@ import { type RangeWarning } from './useRangeBypass'
import { deriveCaps } from './pluginCaps'
import PluginPoiCategoryList from './PluginPoiCategoryList'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import ConfirmDialog from '../shared/ConfirmDialog'
import ToggleSwitch from '../Settings/ToggleSwitch'
import CustomSelect from '../shared/CustomSelect'
@@ -33,6 +34,7 @@ import { usePluginsAdmin } from './plugins/usePluginsAdmin'
import PluginFilterMenu from './plugins/PluginFilterMenu'
import PluginInstalledRow from './plugins/PluginInstalledRow'
import PluginSecurityDialog from './plugins/PluginSecurityDialog'
import VersionLine from './plugins/VersionLine'
/**
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
@@ -303,7 +305,7 @@ export default function AdminPluginsPanel() {
{versionPicker && (
<VersionPickerDialog plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
busy={busy} t={t} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
)}
<ConfirmDialog
@@ -745,6 +747,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
}) {
const { detail, failed } = usePluginDetail(item.id)
const { formatDate } = useFormatDate()
const manifest = detail?.manifest ?? null
const permissions = manifest?.permissions ?? []
@@ -910,7 +913,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
{(item.trek || item.minTrekVersion) && (
<Meta k={t('admin.plugins.metaRequires')} v={item.trek ? `TREK ${item.trek}` : `TREK ${item.minTrekVersion}+`} />
)}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={new Date(item.reviewedAt).toLocaleDateString(locale)} />}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={formatDate(new Date(item.reviewedAt))} />}
{typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
<Meta k={t('admin.plugins.downloads')} v={item.downloadCount.toLocaleString(locale)} />
)}
@@ -934,7 +937,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
) : (
<span className="text-content-faint" style={fs(11.5)}>{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
)}>
<VersionLine v={v} locale={locale} />
<VersionLine v={v} />
</DialogListItem>
))}
</DialogList>
@@ -944,17 +947,6 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
)
}
/** A version number with its date and, when the author signed it, a quiet tick. */
function VersionLine({ v, locale }: { v: VersionInfo; locale: string }) {
return (
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
<span className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`} style={fs(12.5)}>v{v.version}</span>
{v.publishedAt && <span className="text-content-faint" style={fs(11.5)}>{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
</span>
)
}
function Meta({ k, v }: { k: string; v: string }) {
return (
<div className="min-w-0 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
@@ -970,9 +962,9 @@ function Meta({ k, v }: { k: string; v: string }) {
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
* explained and never offered.
*/
function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
function VersionPickerDialog({ plugin, versions, failed, busy, t, onPick, onClose }: {
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
t: T; onPick: (version: string) => void; onClose: () => void
}) {
return (
<DialogShell
@@ -994,7 +986,7 @@ function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick
const current = v.version === plugin.version
return (
<div key={v.version} data-testid={`version-row-${v.version}`} className="flex items-center gap-3 px-3.5 py-2.5">
<div className="min-w-0 flex-1"><VersionLine v={v} locale={locale} /></div>
<div className="min-w-0 flex-1"><VersionLine v={v} /></div>
{current ? (
<StatusPill tone="success" icon={<Check size={11} />}>{t('admin.plugins.installed')}</StatusPill>
) : v.compatible ? (
@@ -1,4 +1,4 @@
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-029
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-031
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -70,7 +70,7 @@ function cartoInput(): HTMLInputElement {
}
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string | RegExp) {
await user.click(screen.getByRole('button', { name: trigger }));
const choices = await screen.findAllByRole('button', { name: option });
await user.click(choices[choices.length - 1]);
@@ -481,3 +481,34 @@ describe('DefaultUserSettingsTab week start (#2029)', () => {
await waitFor(() => expect(puts).toEqual([{ week_start: 'sunday' }, { week_start: null }]));
});
});
describe('DefaultUserSettingsTab date format (#2971)', () => {
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
});
it('FE-ADMIN-DUS-030: the date-format default saves its own key', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ date_format: 'YYYY-MM-DD' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
// The choices are labelled by how they write 31 December.
await pickFromSelect(user, '2026-12-31', '31.12.2026');
await waitFor(() => expect(puts).toEqual([{ date_format: 'DD.MM.YYYY' }]));
await waitFor(() => expect(screen.getByRole('button', { name: '31.12.2026' })).toBeInTheDocument());
});
it('FE-ADMIN-DUS-031: auto is offered with the example of the interface language', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ date_format: 'DD/MM/YYYY' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await pickFromSelect(user, '31/12/2026', /\(12\/31\/2026\)/);
await waitFor(() => expect(puts).toEqual([{ date_format: 'auto' }]));
});
});
@@ -10,6 +10,7 @@ import { MapView } from '../Map/MapView'
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
import type { DistanceUnit, WeekStart } from '../../types'
import { weekStartOptions } from '../../utils/calendarWeek'
import { dateFormatOptions } from '../../utils/dateFormat'
import { withTileApiKey } from '../../utils/tileUrl'
import { defaultStyleForProvider } from '../Map/glProviders'
import type { MapProvider } from '../Settings/mapSettingsModel'
@@ -66,6 +67,27 @@ function errorText(err: unknown, fallback: string): string {
return err instanceof Error ? err.message : fallback
}
interface ResetProps {
shown: boolean
onReset: () => void
label: string
}
// Inside the row's own <label>, after the name: the link only shows once the
// default is set, and the name stays the label's text.
function ResetButton({ shown, onReset, label }: ResetProps): React.ReactElement | null {
if (!shown) return null
return (
<button type="button"
onClick={onReset}
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
style={fs(11)}
>
{label}
</button>
)
}
export default function DefaultUserSettingsTab(): React.ReactElement {
const { t, locale } = useTranslation()
const {
@@ -74,18 +96,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
} = useDefaultUserSettings({ errorMessage: errorText, previewCreatedAt: () => String(new Date()) })
// Inside the row's own <label>, after the name: the link only shows once the
// default is set, and the name stays the label's text.
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button type="button"
onClick={() => reset(field)}
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
style={fs(11)}
>
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
) : null
const resetOf = (field: keyof Defaults): ResetProps => ({
shown: isSet(field),
onReset: () => reset(field),
label: t('admin.defaultSettings.resetToBuiltIn'),
})
if (!loaded) {
return (
@@ -118,7 +133,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
<SettingRows>
{/* Color Mode */}
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton {...resetOf('dark_mode')} /></>}>
<Segmented<string>
label={t('settings.colorMode')}
value={colorModeValue(darkMode)}
@@ -132,7 +147,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Temperature */}
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton {...resetOf('temperature_unit')} /></>}>
<Segmented<string>
label={t('settings.temperature')}
value={defaults.temperature_unit ?? ''}
@@ -145,7 +160,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Distance */}
<ChoiceRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
<ChoiceRow label={<>{t('settings.distance')} <ResetButton {...resetOf('distance_unit')} /></>}>
<Segmented<DistanceUnit | ''>
label={t('settings.distance')}
value={defaults.distance_unit ?? ''}
@@ -158,7 +173,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Time Format */}
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton {...resetOf('time_format')} /></>}>
<Segmented<string>
label={t('settings.timeFormat')}
value={defaults.time_format ?? ''}
@@ -171,7 +186,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Week start: the first column of every date picker (#2029) */}
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton {...resetOf('week_start')} /></>}>
<Segmented<WeekStart | ''>
label={t('settings.weekStart')}
value={defaults.week_start ?? ''}
@@ -180,8 +195,19 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
</ChoiceRow>
{/* Date format: every date field and numeric date (#2971) */}
<ChoiceRow label={<>{t('settings.dateFormat')} <ResetButton {...resetOf('date_format')} /></>}>
<div className="w-[220px] max-w-full">
<CustomSelect
value={defaults.date_format ?? ''}
onChange={value => { if (value) void save({ date_format: String(value) as Defaults['date_format'] }) }}
options={dateFormatOptions(locale, t('settings.dateFormatAuto'))}
/>
</div>
</ChoiceRow>
{/* Default Currency */}
<ChoiceRow label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}>
<ChoiceRow label={<>{t('settings.currency')} <ResetButton {...resetOf('default_currency')} /></>}>
<div className="w-[220px] max-w-full">
<CustomSelect
value={defaults.default_currency || ''}
@@ -194,7 +220,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Blur Booking Codes */}
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton {...resetOf('blur_booking_codes')} /></>}>
<Segmented<string>
label={t('settings.blurBookingCodes')}
value={onOffValue(defaults.blur_booking_codes)}
@@ -211,7 +237,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<Section title={t('settings.map')} icon={MapIcon}>
{/* Map Tile URL */}
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton field="map_tile_url" /></>} hint={t('settings.mapDefaultHint')}>
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton {...resetOf('map_tile_url')} /></>} hint={t('settings.mapDefaultHint')}>
<div className="flex flex-col gap-2">
<CustomSelect
value={mapTileUrl}
@@ -232,7 +258,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{/* The key comes with the instance on a managed install, injected when the
settings are read. A field here would only let somebody save a worse one. */}
{!managed && (
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton field="carto_api_key" /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton {...resetOf('carto_api_key')} /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
<input
type="text"
value={cartoKey}
@@ -273,7 +299,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<SettingRows>
<ChoiceRow
stacked
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton {...resetOf('map_provider')} /></>}
hint={t('admin.defaultSettings.mapProviderHint')}
>
<Segmented<MapProvider>
@@ -293,7 +319,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
settings are read. A field here would only let somebody save a worse one. */}
{mapProvider === 'mapbox-gl' && !managed && (
<div className={FIELD_ROW}>
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton field="mapbox_access_token" /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton {...resetOf('mapbox_access_token')} /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
<input
type="text"
value={mapboxToken}
@@ -310,7 +336,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{mapProvider !== 'leaflet' && (
<div className={FIELD_ROW}>
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton field={styleKey} /></>}>
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton {...resetOf(styleKey)} /></>}>
<div className="flex flex-col gap-2">
<CustomSelect
value={mapboxStyle}
@@ -333,7 +359,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{mapProvider === 'mapbox-gl' && (
<>
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton {...resetOf('mapbox_3d_enabled')} /></>}>
<Segmented<string>
label={t('admin.defaultSettings.mapbox3d')}
value={onOffValue(defaults.mapbox_3d_enabled ?? true)}
@@ -345,7 +371,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
</ChoiceRow>
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton {...resetOf('mapbox_quality_mode')} /></>}>
<Segmented<string>
label={t('admin.defaultSettings.mapboxQuality')}
value={onOffValue(defaults.mapbox_quality_mode ?? false)}
@@ -0,0 +1,25 @@
import { ShieldCheck } from 'lucide-react';
import { useFormatDate } from '../../../hooks/useDateFormat';
import { fs } from '../../shared/DialogShell';
import type { VersionInfo } from './pluginModel';
/** A version number with its date and, when the author signed it, a quiet tick. */
export default function VersionLine({ v }: { v: VersionInfo }) {
const { formatDate } = useFormatDate();
return (
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
<span
className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}
style={fs(12.5)}
>
v{v.version}
</span>
{v.publishedAt && (
<span className="text-content-faint" style={fs(11.5)}>
{formatDate(new Date(v.publishedAt))}
</span>
)}
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
</span>
);
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useFormatDate } from '../../hooks/useDateFormat';
export interface AuditEntry {
id: number;
@@ -41,7 +41,7 @@ export function auditUserLabel(e: AuditEntry): string {
* cards render their own markup over it.
*/
export function useAuditLog(serverTimezone?: string) {
const { locale } = useTranslation();
const { formatDateTime } = useFormatDate();
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
@@ -92,7 +92,7 @@ export function useAuditLog(serverTimezone?: string) {
const fmtTime = (iso: string) => {
try {
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
return formatDateTime(new Date(iso.endsWith('Z') ? iso : iso + 'Z'), {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: serverTimezone || undefined,
@@ -1,6 +1,7 @@
import { type ChangeEvent, useEffect, useEffectEvent, useRef, useState } from 'react';
import { backupApi } from '../../api/client';
import { useFormatDate } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { getApiErrorMessage } from '../../types';
@@ -104,7 +105,8 @@ export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const { t, locale } = useTranslation();
const { t } = useTranslation();
const { formatDateTime } = useFormatDate();
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
const loadBackups = async () => {
@@ -241,7 +243,7 @@ export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}
minute: '2-digit',
};
if (serverTimezone) opts.timeZone = serverTimezone;
return new Date(dateStr).toLocaleString(locale, opts);
return formatDateTime(new Date(dateStr), opts);
} catch {
return dateStr;
}
@@ -4,7 +4,7 @@ import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { DistanceUnit, Place, WeekStart } from '../../types';
import type { DateFormat, DistanceUnit, Place, WeekStart } from '../../types';
import { normalizeTileUrl } from '../../utils/tileUrl';
import {
defaultStyleForProvider,
@@ -28,6 +28,7 @@ export type Defaults = RoutingDefaults & {
dark_mode?: string | boolean;
time_format?: string;
week_start?: WeekStart;
date_format?: DateFormat;
default_currency?: string;
blur_booking_codes?: boolean;
map_tile_url?: string;
@@ -2301,15 +2301,12 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
mount([hotel()])
await screen.findByText('Aparthotel Silver')
// The entered amount was nowhere on the row, so $791.57 read as a wrong copy of
// the 801.76 in the dialog. The pill beside the line is the last step.
expect(screen.getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
// Row total and payer chip stay the booked euros at today's rate, and so does what
// I lent: half of 685.26 EUR, the figure settle-up offers. Printing $400.88 here
// had Bob pay back more than he owed and reopened the trip.
expect(screen.getAllByText('$791.55')).toHaveLength(2)
expect(screen.getByText('you lent $395.77')).toBeInTheDocument()
expect(screen.queryByText('you lent $400.88')).toBeNull()
// A bill in the display currency counts as typed (#2965), as the balances net it:
// row total and payer chip read the dollars entered, nothing to explain under it,
// and what I lent is half of them, which settle-up offers too.
expect(screen.queryByText(/^\$801\.76 → /)).toBeNull()
expect(screen.getAllByText('$801.76')).toHaveLength(2)
expect(screen.getByText('you lent $400.88')).toBeInTheDocument()
})
it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => {
@@ -2344,7 +2341,8 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
await waitFor(() => expect(exported).toBeTruthy())
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note')
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/)
// The trip-currency figure it was booked at, then the dollars as typed (#2965).
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;801\.76;$/)
} finally {
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
}
@@ -2357,13 +2355,13 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
])
await screen.findByText('Aparthotel Silver')
// 120 USD at 1.17 is 102.56 EUR, $118.47 today; with the hotel's $791.55 that is
// $910.02, on the day header and on the total spend card.
expect(screen.getByText(/^\$120\.00 → 102,56\s€$/)).toBeInTheDocument()
expect(screen.getByText('$910.02 spent')).toBeInTheDocument()
// Both in the display currency, both as typed (#2965): $801.76 and $120.00 make
// $921.76, on the day header and on the total spend card.
expect(screen.queryByText(/^\$120\.00 → /)).toBeNull()
expect(screen.getByText('$921.76 spent')).toBeInTheDocument()
// Label, its text block, the header row, then the card itself.
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
expect(card.textContent).toContain('$910.02')
expect(card.textContent).toContain('$921.76')
})
it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => {
@@ -2399,7 +2397,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
expect(screen.queryByText(/^\$50\.00 →/)).toBeNull()
})
it('FE-W5COSTS-082: a payment recorded in dollars is booked like an expense, a legacy one stays as entered', async () => {
it('FE-W5COSTS-082: a payment recorded in dollars reads as entered, a legacy one too (#2965)', async () => {
mount([hotel()], {
settlements: [
{ id: 21, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, created_at: '2026-08-07 09:00:00' },
@@ -2410,29 +2408,26 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
})
await screen.findByText('Aparthotel Silver')
expect(await screen.findByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
// The hotel's split chips can carry the same figure, so look past them to the payment.
const outsideChips = (text: string) => screen.queryAllByText(text).filter(el => !el.closest('[data-testid="split-chip"]'))
expect(outsideChips('$395.77')).toHaveLength(1)
expect(await screen.findByText('$30.00')).toBeInTheDocument()
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
expect(outsideChips('$400.88')).toHaveLength(1)
expect(outsideChips('$30.00')).toHaveLength(1)
expect(screen.queryByText('$34.65')).toBeNull()
})
it('FE-W5COSTS-085: the edit dialog previews the rate the save keeps, the one the row was booked at', async () => {
it('FE-W5COSTS-085: the edit dialog has nothing to convert for a bill in the display currency (#2965)', async () => {
const user = userEvent.setup()
mount([hotel()])
await screen.findByText('Aparthotel Silver')
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument()
// Same currency as the list, so the dialog said nothing, and 801.76 there beside
// $791.57 in the list looked like two different expenses.
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
// Each share next to what it counts as, the figure the row's "you lent" is made of.
expect(screen.getByText(/\$400\.88 → \$395\.77 per person/)).toBeInTheDocument()
// The list reads the dollars typed, so there is no conversion to preview.
expect(screen.queryByText(/live rate/)).toBeNull()
expect(screen.getByText(/\$400\.88 per person/)).toBeInTheDocument()
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
})
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
@@ -89,7 +89,8 @@ describe('costsModel: recorded payments', () => {
it('FE-BUDGET-COSTSMODEL-005: a legacy payment without a currency was entered in the display currency', () => {
const inUsd: CostsCtx = { ...ctx, displayCurrency: 'USD' };
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(5);
// Entered in the display currency, it reads as entered (#2965).
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(10);
expect(paymentAmount(payment({ amount: 10, currency: null }), ctx)).toBe(10);
});
+26 -10
View File
@@ -1,6 +1,8 @@
import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared';
import { convertBooked, convertedLine, tripAmountOf } from '../../hooks/useExchangeRates';
import { readDateFormat } from '../../hooks/useDateFormat';
import { convertBooked, convertedLine, inDisplayCurrency, tripAmountOf } from '../../hooks/useExchangeRates';
import type { BudgetItem } from '../../types';
import { formatDisplayDate } from '../../utils/dateFormat';
import { currencyDecimals } from '../../utils/formatters';
import { currenciesWith, SYMBOLS } from './BudgetPanel.constants';
import { paidByUser, readUserNote, settlementDate, splitEqualShares } from './CostsPanel.helpers';
@@ -30,8 +32,12 @@ export function currencyOf(e: BudgetItem, ctx: CostsCtx): string {
return (e.currency || ctx.tripCurrency).toUpperCase();
}
/** An amount of this expense in the display currency, at the rate the expense was booked at. */
/**
* An amount of this expense in the display currency, at the rate the expense was booked at;
* as typed when it was entered in the display currency (#2965).
*/
export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number {
if (inDisplayCurrency(e.currency, ctx.tripCurrency, ctx.displayCurrency)) return amount;
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert);
}
@@ -130,9 +136,14 @@ function paymentCurrency(s: CostsSettlement, ctx: CostsCtx): string {
return (s.currency || ctx.displayCurrency).toUpperCase();
}
/** A recorded payment in the display currency, at the rate frozen when it was settled (#1445). */
/**
* A recorded payment in the display currency, at the rate frozen when it was settled (#1445);
* as entered when it was made in the display currency (#2965).
*/
export function paymentAmount(s: CostsSettlement, ctx: CostsCtx): number {
return convertBooked(s.amount, paymentCurrency(s, ctx), s.exchange_rate, ctx.tripCurrency, ctx.convert);
const currency = paymentCurrency(s, ctx);
if (inDisplayCurrency(currency, ctx.tripCurrency, ctx.displayCurrency)) return s.amount;
return convertBooked(s.amount, currency, s.exchange_rate, ctx.tripCurrency, ctx.convert);
}
/** The line under a payment shown converted, explained the way an expense's is (#2525). */
@@ -369,12 +380,17 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
const fmtDate = (iso: string) => {
if (!iso) return '';
try {
return new Date(iso + 'T00:00:00Z').toLocaleDateString(opts.locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
return formatDisplayDate(
new Date(iso + 'T00:00:00Z'),
opts.locale,
{
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
},
readDateFormat()
);
} catch {
return iso;
}
@@ -23,9 +23,14 @@ describe('editingCurrencyOf', () => {
describe('expenseFxPreview', () => {
it('previews an edit that keeps the currency at the rate frozen on entry', () => {
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })!
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'GBP' })!
expect(fx.inTrip).toBeCloseTo(685.26, 2)
expect(fx.shown).toBeCloseTo(791.55, 2)
expect(fx.shown).toBeCloseTo(convert(801.76 / 1.17, 'EUR'), 2)
})
it('has nothing to preview for a bill in the display currency, which counts as typed (#2965)', () => {
// Frozen at 1.17 while a euro buys 1.1551 today: still the dollars typed.
expect(expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'USD' })).toBeNull()
})
it('has nothing to preview for a bill in the display currency that reads back as typed', () => {
@@ -47,8 +52,8 @@ describe('expenseFxPreview', () => {
describe('splitShareLabel', () => {
it('prints the entered share, then the share the row lends or borrows', () => {
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })
expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', 'en-US')).toBe('$400.88 → $395.77')
const fx = expenseFxPreview({ ...base, total: 1700, currency: 'JPY', editing: null })
expect(splitShareLabel(850, 'JPY', fx, 2, 'USD', 'en-US')).toBe(`${formatMoney(850, 'JPY', 'en-US')} → ${formatMoney(convert(1700, 'JPY') / 2, 'USD', 'en-US')}`)
// Read in the trip currency, the share is the booked euros.
const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })
expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`)
+4 -7
View File
@@ -1,6 +1,6 @@
import { bookedInTrip, useExchangeRates } from '../../hooks/useExchangeRates'
import { useTripStore } from '../../store/tripStore'
import { currencyDecimals, formatMoney } from '../../utils/formatters'
import { formatMoney } from '../../utils/formatters'
/**
* The conversion preview of the expense editors, shared by the desktop ExpenseModal and the
@@ -33,8 +33,8 @@ export interface ExpenseFx {
* What the amount being edited will count as. An edit that keeps the currency keeps the rate
* frozen when the expense was entered (the server re-freezes only when the currency changes),
* so it is previewed at that rate, the way the list will show it after the save. Anything
* else is previewed at today's rate. Null when there is nothing to convert, including a bill
* in the display currency that still reads back as typed.
* else is previewed at today's rate. Null when there is nothing to convert, and for a bill in
* the display currency, which counts as typed (#2965).
*/
export function expenseFxPreview(args: {
total: number
@@ -46,12 +46,9 @@ export function expenseFxPreview(args: {
convert: (amount: number, from: string | null | undefined) => number
}): ExpenseFx | null {
const { total, currency, editing, editingCurrency, tripCurrency, base, convert } = args
if (total === 0) return null
if (total === 0 || currency === base) return null
const inTrip = editing && currency === editingCurrency ? bookedInTrip(total, currency, editing.exchange_rate, tripCurrency) : null
if (currency === base && inTrip == null) return null
const shown = inTrip == null ? convert(total, currency) : convert(inTrip, tripCurrency)
const unit = 10 ** currencyDecimals(base)
if (currency === base && Math.round(shown * unit) === Math.round(total * unit)) return null
return { inTrip, shown: inTrip != null && tripCurrency === base ? null : shown }
}
@@ -3,6 +3,7 @@ import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell'
import MSheet from '../../mobile/components/MSheet'
import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
@@ -64,6 +65,7 @@ export default function DawarichAcceptDialog({
onConfirm,
}: DawarichAcceptDialogProps): React.ReactElement | null {
const { t, locale } = useTranslation()
const { formatDateTime } = useFormatDate()
const phone = useIsPhone()
const labelId = useId()
@@ -140,7 +142,7 @@ export default function DawarichAcceptDialog({
</span>
<p className="min-w-0 flex-1 text-caption text-content">
{t('dawarich.accept.recorded', {
from: new Date(suggestion.startedAt).toLocaleString(locale),
from: formatDateTime(new Date(suggestion.startedAt)),
to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
})}
</p>
@@ -3,6 +3,8 @@ import { useMemo, useState } from 'react';
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
import { useTranslation } from '../../i18n';
import { frameLine, frameLng } from '../../utils/lngFrame';
import { useLngFrame } from '../Map/lngFrameContext';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_SIZE, ferrataIconSvg, placeFerrataIcons } from './ferrataIcon';
import { ferrataPositions } from './ferrataModel';
@@ -81,6 +83,8 @@ export default function FerrataLayerLeaflet({
selectedId?: string | null;
}) {
const paint = useFerrataPaint();
// Drawn in the map's longitude frame, with the trip (#2975).
const frame = useLngFrame();
const iconGroups = useIconGroups(groups);
const [hovered, setHovered] = useState<string | null>(null);
// Rebuilt per set of ferratas and per theme: pathOptions only reach Leaflet when their reference changes.
@@ -110,7 +114,7 @@ export default function FerrataLayerLeaflet({
group.segments.map((segment) => ({
group,
segment,
positions: ferrataPositions(segment),
positions: frameLine(frame, ferrataPositions(segment)),
casing,
litCasing,
line,
@@ -118,7 +122,7 @@ export default function FerrataLayerLeaflet({
hit,
}))
);
}, [groups, paint]);
}, [groups, paint, frame]);
if (lines.length === 0) return null;
const lit = (group: FerrataGroup) => group.id === hovered || group.id === selectedId;
@@ -163,7 +167,7 @@ export default function FerrataLayerLeaflet({
{iconGroups.map((group) => (
<Marker
key={`${group.id}-icon`}
position={group.midpoint}
position={frame ? [group.midpoint[0], frameLng(frame, group.midpoint[1])] : group.midpoint}
icon={ICON}
keyboard={false}
eventHandlers={{
@@ -1,5 +1,7 @@
import { FileText, FileImage, File, FileVideo, Plane, Train, Car, Ship, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route } from 'lucide-react'
import { downloadFile } from '../../utils/fileDownload'
import { readDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
export function isImage(mimeType?: string | null) {
if (!mimeType) return false
@@ -57,10 +59,10 @@ export function triggerDownload(url: string, filename: string) {
downloadFile(url, filename).catch(() => {})
}
export function formatDateWithLocale(dateStr?: string | null, locale?: string) {
export function formatDateWithLocale(dateStr?: string | null, locale?: string, dateFormat: string = readDateFormat()) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
return formatDisplayDate(new Date(dateStr), locale, { day: '2-digit', month: '2-digit', year: 'numeric' }, dateFormat)
} catch { return '' }
}
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import { useTranslation } from '../../../i18n/TranslationContext'
import { useFormatDate } from '../../../hooks/useDateFormat'
import { relativeTime } from '../../../utils/relativeTime'
import { Tooltip } from '../../shared/Tooltip'
@@ -83,11 +84,12 @@ export function StateBadge({ state, compact }: { state: string; compact?: boolea
/** "2h ago", or "never" when a binding has not run yet. */
export function LastRun({ at }: { at: string | null }) {
const { t, language, locale } = useTranslation()
const { t, language } = useTranslation()
const { formatDateTime } = useFormatDate()
if (!at) return <span className="text-content-faint">{t('docsync.binding.neverRun')}</span>
const ms = Date.parse(at)
if (Number.isNaN(ms)) return null
return <Tooltip label={new Date(ms).toLocaleString(locale)}><span>{relativeTime(ms, language)}</span></Tooltip>
return <Tooltip label={formatDateTime(new Date(ms))}><span>{relativeTime(ms, language)}</span></Tooltip>
}
/**
@@ -1,8 +1,10 @@
import L from 'leaflet';
import { useCallback, useEffect } from 'react';
import { useMap } from 'react-leaflet';
import { frameLng } from '../../utils/lngFrame';
import { MAX_ZOOM_RASTER, type ViewportPadding } from '../../utils/mapViewport';
import { ASSISTANT_CAMERA_MS, centreOf, coverPadding, useCameraRequest, useUserMoveClock } from './assistantCamera';
import { useLngFrame } from './lngFrameContext';
import type { AssistantCamera } from './mapViewContract';
interface AssistantCameraControllerProps {
@@ -18,12 +20,17 @@ interface AssistantCameraControllerProps {
*/
export function AssistantCameraController({ camera, padding }: AssistantCameraControllerProps) {
const map = useMap();
// The points are framed the way the map draws them (#2975).
const frame = useLngFrame();
const getContainer = useCallback(() => map.getContainer(), [map]);
const userMovedAt = useUserMoveClock(getContainer, true);
const request = useCameraRequest(camera, userMovedAt);
useEffect(() => {
request((next) => {
request((camera) => {
const next = frame
? { ...camera, points: camera.points.map(([lat, lng]) => [lat, frameLng(frame, lng)] as [number, number]) }
: camera;
const duration = ASSISTANT_CAMERA_MS / 1000;
if (next.mode === 'pan') {
const centre = centreOf(next.points);
@@ -39,7 +46,7 @@ export function AssistantCameraController({ camera, padding }: AssistantCameraCo
duration,
});
});
}, [map, padding, request]);
}, [map, padding, request, frame]);
return null;
}
@@ -1,7 +1,9 @@
import L from 'leaflet';
import { useEffect, useLayoutEffect, useRef } from 'react';
import { useMap } from 'react-leaflet';
import { frameLng } from '../../utils/lngFrame';
import { GHOST_SIZE, mountGhosts, setGhostHot, type GhostHandlers } from './assistantGhostMarker';
import { useLngFrame } from './lngFrameContext';
import type { AssistantGhost } from './mapViewContract';
interface AssistantGhostLayerProps {
@@ -22,6 +24,8 @@ const GHOST_Z_OFFSET = 900;
*/
export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: AssistantGhostLayerProps) {
const map = useMap();
// Pinned where the map draws the trip, in its longitude frame (#2975).
const frame = useLngFrame();
const handlers = useRef<GhostHandlers>({ onHover, onClick });
useLayoutEffect(() => {
handlers.current = { onHover, onClick };
@@ -36,7 +40,11 @@ export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: Ass
const icon = L.divIcon({ html: el, className: 'trek-ghost-pin', iconSize: [GHOST_SIZE, GHOST_SIZE] });
const label = document.createElement('span');
label.textContent = ghost.name;
const marker = L.marker([ghost.lat, ghost.lng], { icon, keyboard: false, zIndexOffset: GHOST_Z_OFFSET })
const marker = L.marker([ghost.lat, frameLng(frame, ghost.lng)], {
icon,
keyboard: false,
zIndexOffset: GHOST_Z_OFFSET,
})
.bindTooltip(label, { direction: 'top', offset: [0, -GHOST_SIZE / 2], className: 'map-tooltip' })
.addTo(map);
return { remove: () => marker.remove() };
@@ -48,7 +56,7 @@ export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: Ass
mounted.dispose();
elements.current = new Map();
};
}, [ghosts, map]);
}, [ghosts, map, frame]);
useEffect(() => {
setGhostHot(elements.current, hoveredId);
@@ -1,8 +1,11 @@
import type { FeatureGroup as LeafletFeatureGroup, LeafletMouseEvent } from 'leaflet';
import { useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { CircleMarker, FeatureGroup, Polyline, Tooltip } from 'react-leaflet';
import type { AssistantRouteLine } from '../../types/assistantMap';
import { frameLine, frameLng, type LngFrame } from '../../utils/lngFrame';
import { onRealWorld } from '../../utils/worldCopy';
import { useLngFrame } from './lngFrameContext';
/** A rim under the line, the line, and the invisible fat line that takes the pointer. */
const CASING_WEIGHT = 7;
@@ -38,7 +41,10 @@ function GhostLine({ line, onHover, onPoint }: { line: AssistantRouteLine } & Li
if (line.hot) group.current?.bringToFront();
}, [line.hot]);
const opacity = line.dim ? DIM_OPACITY : 1;
const point = (event: LeafletMouseEvent) => onPoint?.(line.ref, event.latlng.lat, event.latlng.lng);
const point = (event: LeafletMouseEvent) => {
const at = onRealWorld(event.latlng);
onPoint?.(line.ref, at.lat, at.lng);
};
return (
<FeatureGroup ref={group}>
<Polyline
@@ -122,11 +128,25 @@ interface AssistantGhostLineLayerProps extends LineHandlers {
* under its pins. The twin of
* useAssistantGhostLinesGL.
*/
/** A line drawn in the map's longitude frame, with the trip it proposes changes to (#2975). */
function inFrame(frame: LngFrame, line: AssistantRouteLine): AssistantRouteLine {
const at = ([lat, lng]: [number, number]): [number, number] => [lat, frameLng(frame, lng)];
return {
...line,
lines: line.lines.map((part) => frameLine(frame, part)),
ends: line.ends && { start: at(line.ends.start), end: line.ends.end && at(line.ends.end) },
stops: line.stops?.map((stop) => ({ ...stop, lng: frameLng(frame, stop.lng) })),
tints: line.tints?.map((tint) => ({ ...tint, line: frameLine(frame, tint.line) })),
};
}
export function AssistantGhostLineLayer({ lines, onHover, onPoint }: AssistantGhostLineLayerProps) {
if (!lines?.length) return null;
const frame = useLngFrame();
const drawn = useMemo(() => (frame && lines ? lines.map((line) => inFrame(frame, line)) : lines), [frame, lines]);
if (!drawn?.length) return null;
return (
<>
{lines.map((line) => (
{drawn.map((line) => (
<GhostLine key={line.id} line={line} onHover={onHover} onPoint={onPoint} />
))}
</>
+8 -4
View File
@@ -4,11 +4,14 @@ import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import type { Poi } from './poiCategories'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
pois: Poi[]; enabled: boolean; onPoiClick?: (poi: Poi) => void; children: ReactNode[]
}) {
const map = useMap()
const frame = useLngFrame()
const { t } = useTranslation()
const [zoom, setZoom] = useState(() => map.getZoom())
const popup = useRef<L.Popup | null>(null)
@@ -18,20 +21,21 @@ export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
map.on('zoomend', update)
return () => { map.off('zoomend', update) }
}, [map])
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, poi.lng]), zoom) : [], [pois, enabled, map, zoom])
// Measured and placed where the map draws them, in its longitude frame (#2975).
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, frameLng(frame, poi.lng)]), zoom) : [], [pois, enabled, map, zoom, frame])
const markers = new Map(pois.map((poi, index) => [poi.osm_id, children[index]]))
if (!enabled) return <>{children}</>
return <>{groups.map(group => {
if (group.pois.length === 1) return markers.get(group.pois[0].osm_id)
const label = t('roadtrip.poi.found', { count: group.pois.length })
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, group.lng]} alt={label}
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, frameLng(frame, group.lng)]} alt={label}
icon={L.divIcon({ className: '', html: poiClusterMarkup(group.pois.length), iconSize: [38, 38], iconAnchor: [19, 19] })}
zIndexOffset={600} eventHandlers={{ click: () => {
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
popup.current?.remove()
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, group.lng])
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, frameLng(frame, group.lng)])
.setContent(poiClusterList(group.pois, poi => { popup.current?.remove(); onPoiClick?.(poi) })).openOn(map)
} else map.fitBounds(group.pois.map(poi => [poi.lat, poi.lng] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
} else map.fitBounds(group.pois.map(poi => [poi.lat, frameLng(frame, poi.lng)] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
} }} />
})}</>
}
@@ -1,5 +1,7 @@
import { useMemo } from 'react'
import { Polyline } from 'react-leaflet'
import { useLngFrame } from './lngFrameContext'
import { frameLine } from '../../utils/lngFrame'
import type { DawarichTrack } from '@trek/shared'
import {
DAWARICH_TRAIL_CASING,
@@ -37,7 +39,10 @@ export default function DawarichTrailLayer({
/** The `trek-track-casing` pane, when the renderer supports panes. */
casingPane?: string
}) {
const segments = useMemo(() => trailSegments(track, selectedDate, hiddenDates), [track, selectedDate, hiddenDates])
const frame = useLngFrame()
// Drawn in the map's longitude frame, with the trip it was recorded on (#2975).
const segments = useMemo(() => trailSegments(track, selectedDate, hiddenDates)
.map(segment => (frame ? { ...segment, points: frameLine(frame, segment.points) } : segment)), [track, selectedDate, hiddenDates, frame])
if (segments.length === 0) return null
return (
+5 -1
View File
@@ -4,10 +4,14 @@ import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { hazardPopup, hazardFeature } from './hazardPopup'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
export default function HazardLayers({ hazards }: { hazards: RoadtripHazard[] }) {
const { t } = useTranslation()
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}`} data={hazardFeature(hazard)}
const frame = useLngFrame()
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}-${frame?.seam ?? ''}`} data={hazardFeature(hazard)}
coordsToLatLng={coords => L.latLng(coords[1], frameLng(frame, coords[0]))}
style={{ color: 'var(--warning)', weight: 2, fillOpacity: 0.16 }}
pointToLayer={(_, point) => L.circleMarker(point, { radius: 7, color: 'var(--warning)', fillOpacity: 0.65 })}
onEachFeature={(_, layer) => layer.bindPopup(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point')), { className: 'map-tooltip trek-hazard-popup', maxWidth: 320 })}
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react'
import { Circle, Polygon, Polyline, Tooltip, useMap } from 'react-leaflet'
import { pluginsApi, type PluginMapLayer, type PluginMapLayerFeature } from '../../api/client'
import { useLngFrame } from './lngFrameContext'
import { frameLine, frameLng } from '../../utils/lngFrame'
/**
* Host-rendered overlay for the `mapLayerProvider` plugin hook. A plugin returns
@@ -47,6 +49,7 @@ function pathOptions(f: PluginMapLayerFeature, pane: string | undefined) {
export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
const map = useMap()
const frame = useLngFrame()
const [layers, setLayers] = useState<PluginMapLayer[]>([])
// Whether our dedicated below-overlay pane exists. Pane support is a Leaflet
// optimization — if the map has no pane API (e.g. a minimal renderer), the
@@ -73,6 +76,7 @@ export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
if (layers.length === 0) return null
const pane = hasPane ? PANE : undefined
// Drawn in the map's longitude frame, with the trip (#2975).
return (
<>
@@ -80,13 +84,13 @@ export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
const key = `${layer.pluginId}:${layer.id}:${i}`
const tooltip = f.label ? <Tooltip sticky>{f.label}</Tooltip> : null
if (f.type === 'polyline' && f.points) {
return <Polyline key={key} positions={f.points} pathOptions={pathOptions(f, pane)}>{tooltip}</Polyline>
return <Polyline key={key} positions={frameLine(frame, f.points)} pathOptions={pathOptions(f, pane)}>{tooltip}</Polyline>
}
if (f.type === 'polygon' && f.points) {
return <Polygon key={key} positions={f.points} pathOptions={pathOptions(f, pane)}>{tooltip}</Polygon>
return <Polygon key={key} positions={frameLine(frame, f.points)} pathOptions={pathOptions(f, pane)}>{tooltip}</Polygon>
}
if (f.type === 'circle' && f.center && f.radiusM) {
return <Circle key={key} center={f.center} radius={f.radiusM} pathOptions={pathOptions(f, pane)}>{tooltip}</Circle>
return <Circle key={key} center={frame ? [f.center[0], frameLng(frame, f.center[1])] : f.center} radius={f.radiusM} pathOptions={pathOptions(f, pane)}>{tooltip}</Circle>
}
return null
}))}
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react'
import { Marker, Popup } from 'react-leaflet'
import L from 'leaflet'
import { pluginsApi, type PluginMapMarker } from '../../api/client'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
/**
* Host-rendered overlay for the `mapMarkerProvider` plugin hook (#587). A plugin
@@ -33,6 +35,8 @@ function markerIcon(tone: PluginMapMarker['tone']): L.DivIcon {
export function PluginMapMarkers({ tripId }: { tripId?: number | string }) {
const [markers, setMarkers] = useState<PluginMapMarker[]>([])
// Drawn in the map's longitude frame, with the trip (#2975).
const frame = useLngFrame()
useEffect(() => {
if (tripId == null) { setMarkers([]); return }
@@ -54,7 +58,7 @@ export function PluginMapMarkers({ tripId }: { tripId?: number | string }) {
return (
<>
{markers.map(mk => (
<Marker key={`${mk.pluginId}:${mk.id}`} position={[mk.lat, mk.lng]} icon={icons.get(mk.tone)!}>
<Marker key={`${mk.pluginId}:${mk.id}`} position={[mk.lat, frameLng(frame, mk.lng)]} icon={icons.get(mk.tone)!}>
{(mk.label || mk.popupText || mk.url) && (
<Popup>
<div style={{ minWidth: 120, fontSize: 13 }}>
+13 -4
View File
@@ -1185,9 +1185,14 @@ describe('MapView map event wiring', () => {
it('FE-COMP-MAPVIEW-046: a map-click handler is attached and detached again', () => {
const onMapClick = vi.fn()
const { unmount } = render(<MapView onMapClick={onMapClick} />)
expect(mapMock.on).toHaveBeenCalledWith('click', onMapClick)
const bound = mapMock.on.mock.calls.find(c => c[0] === 'click')?.[1]
expect(bound).toEqual(expect.any(Function))
// A click on the real world reaches the caller as the very event the map fired.
const event = { latlng: { lat: 48, lng: 2 } }
bound(event)
expect(onMapClick).toHaveBeenCalledWith(event)
unmount()
expect(mapMock.off).toHaveBeenCalledWith('click', onMapClick)
expect(mapMock.off).toHaveBeenCalledWith('click', bound)
})
it('FE-COMP-MAPVIEW-047: without a click handler nothing is bound', () => {
@@ -1198,9 +1203,13 @@ describe('MapView map event wiring', () => {
it('FE-COMP-MAPVIEW-048: a context-menu handler is attached and detached again', () => {
const onMapContextMenu = vi.fn()
const { unmount } = render(<MapView onMapContextMenu={onMapContextMenu} />)
expect(mapMock.on).toHaveBeenCalledWith('contextmenu', onMapContextMenu)
const bound = mapMock.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1]
expect(bound).toEqual(expect.any(Function))
const event = { latlng: { lat: 48, lng: 2 } }
bound(event)
expect(onMapContextMenu).toHaveBeenCalledWith(event)
unmount()
expect(mapMock.off).toHaveBeenCalledWith('contextmenu', onMapContextMenu)
expect(mapMock.off).toHaveBeenCalledWith('contextmenu', bound)
})
it('FE-COMP-MAPVIEW-049: changing the center prop moves the camera there', () => {
+46 -99
View File
@@ -3,7 +3,7 @@ import DOM from 'react-dom'
import { categoryIconSvg } from './categoryIconSvg'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable } from './markerDrag'
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
import RoadtripViaMarkers from './RoadtripViaMarkers'
import PlannerWaypointMarker from './PlannerWaypointMarker'
@@ -121,7 +121,8 @@ function RouteViaMarker({ via, controls, eventHandlers, children }: {
via: RouteVia; controls?: DayBoundaryControls; eventHandlers?: L.LeafletEventHandlerFnMap; children?: React.ReactNode
}) {
const [marker, setMarker] = useState<L.Marker | null>(null)
return <Marker ref={setMarker} position={[via.lat, via.lng]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
const frame = useLngFrame()
return <Marker ref={setMarker} position={[via.lat, frameLng(frame, via.lng)]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
alt={via.nightPause ? via.label : undefined} zIndexOffset={800} eventHandlers={eventHandlers}>
{children}
{via.nightPause && <NightPauseDrag marker={marker} via={via} controls={controls} />}
@@ -298,59 +299,6 @@ function CameraHoverGuard({ movingRef, onMoveStart, onZoom }: { movingRef: { cur
return null
}
/**
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
*
* The container rather than the drawn route: a polyline is a real element here but not in
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
* once the caller projects it onto the routed geometry — one behaviour for all three.
*/
function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void }) {
const map = useMap()
useEffect(() => {
if (!onPoiDropOnRoute) return
const container = map.getContainer()
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e)
if (!osmId) return
e.preventDefault()
const rect = container.getBoundingClientRect()
const at = map.containerPointToLatLng([e.clientX - rect.left, e.clientY - rect.top])
onPoiDropOnRoute(osmId, at.lat, at.lng)
}
container.addEventListener('dragover', onDragOver)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', onDragOver)
container.removeEventListener('drop', onDrop)
}
}, [map, onPoiDropOnRoute])
return null
}
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
// OSM places for the visible area, with the zoom an overlay's minimum is read against.
function ViewportController({ onViewportChange }: { onViewportChange?: MapViewProps['onViewportChange'] }) {
const map = useMap()
useEffect(() => {
if (!onViewportChange) return
const emit = () => {
const b = map.getBounds()
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }, map.getZoom())
}
map.whenReady(emit) // ensure the first bbox is captured once the map is laid out
map.on('moveend', emit)
map.on('zoomend', emit)
return () => { map.off('moveend', emit); map.off('zoomend', emit) }
}, [map, onViewportChange])
return null
}
function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
const map = useMap()
useEffect(() => {
@@ -377,6 +325,7 @@ interface SelectionControllerProps {
function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
const map = useMap()
const frame = useLngFrame()
const prev = useRef(null)
useEffect(() => {
@@ -387,7 +336,7 @@ function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selec
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
const selected = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
if (selected?.lat != null && selected?.lng != null) {
const latlng: [number, number] = [selected.lat, selected.lng]
const latlng: [number, number] = [selected.lat, frameLng(frame, selected.lng)]
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
const br = paddingOpts.paddingBottomRight as [number, number] | undefined
if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') {
@@ -399,7 +348,7 @@ function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selec
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow])
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow, frame])
return null
}
@@ -411,14 +360,15 @@ interface MapControllerProps {
function MapController({ center, zoom }: MapControllerProps) {
const map = useMap()
const frame = useLngFrame()
const prevCenter = useRef(center)
useEffect(() => {
if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1]) {
map.setView(center, zoom)
map.setView([center[0], frameLng(frame, center[1])], zoom)
prevCenter.current = center
}
}, [center, zoom, map])
}, [center, zoom, map, frame])
return null
}
@@ -456,10 +406,12 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
const prevFocusKey = useRef(focusKey)
const built = useRef(false)
const frame = useLngFrame()
const fitTo = useCallback((coords: [number, number][], padding: L.FitBoundsOptions = paddingOpts) => {
if (coords.length === 0) return
try {
const bounds = L.latLngBounds(coords)
// In the map's longitude frame, so a trip across the Pacific is framed across the Pacific (#2975).
const bounds = L.latLngBounds(frame ? coords.map(([lat, lng]) => [lat, frameLng(frame, lng)] as [number, number]) : coords)
if (bounds.isValid()) {
/*
* The padding already reserves the day panel's height at the bottom
@@ -478,7 +430,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
map.fitBounds(bounds, { maxZoom: 16, animate: true, ...padding })
}
} catch {}
}, [map, paddingOpts])
}, [map, paddingOpts, frame])
// New fitKey (initial trip fit or a day selection): fit to the destinations now
// and arm a one-shot re-fit for when the route arrives.
@@ -535,10 +487,6 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
return null
}
interface MapClickHandlerProps {
onClick: ((e: L.LeafletMouseEvent) => void) | null
}
const TRACK_CASING_PANE = 'trek-track-casing'
/**
@@ -561,26 +509,6 @@ function TrackCasingPane({ onReady }: { onReady: (ready: boolean) => void }) {
return null
}
function MapClickHandler({ onClick }: MapClickHandlerProps) {
const map = useMap()
useEffect(() => {
if (!onClick) return
map.on('click', onClick)
return () => { map.off('click', onClick) }
}, [map, onClick])
return null
}
function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
const map = useMap()
useEffect(() => {
if (!onContextMenu) return
map.on('contextmenu', onContextMenu)
return () => { map.off('contextmenu', onContextMenu) }
}, [map, onContextMenu])
return null
}
// Travel times are shown in the day sidebar (per-segment connectors), not on the map.
import { useMarkerThumbs } from './useMarkerThumbs'
@@ -594,9 +522,15 @@ import { useIsPhone } from '../../mobile/useIsPhone'
// identically. Heading is shown as a rotated conic SVG when available.
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import { LngFrameProvider, useLngFrame, useTripLngFrame } from './lngFrameContext'
import { frameLine, frameLng } from '../../utils/lngFrame'
import { LngFrameFollower, MapClickHandler, MapContextMenuHandler, PoiDropTarget, ViewportController } from './leafletMapBridges'
function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
function LeafletLocationLayer({ position: fix, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
const map = useMap()
const frame = useLngFrame()
// Drawn on the trip's world copy (#2975); every field but the longitude is the fix itself.
const position = useMemo(() => (fix && frame ? { ...fix, lng: frameLng(frame, fix.lng) } : fix), [fix, frame])
// When the user is in follow mode, keep the map centred on the dot.
// setView (no animation) is what Google Maps does during navigation —
@@ -711,7 +645,8 @@ const MemoMarker = memo(function MemoMarker({
// differs, and the cluster group answers a moved child by taking it out and putting
// it back — which collapses an open fan. A fresh array literal here did that on
// every render the marker took part in.
const position = useMemo<[number, number]>(() => [place.lat, place.lng], [place.lat, place.lng])
const frame = useLngFrame()
const position = useMemo<[number, number]>(() => [place.lat, frameLng(frame, place.lng)], [place.lat, place.lng, frame])
const register = useCallback((marker: unknown) => { onRegister(place.id, marker) }, [onRegister, place.id])
return (
<Marker
@@ -835,11 +770,15 @@ export const MapView = memo(function MapView({
*/
const isGcjBasemap = !tourRaster && basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
// Leaflet draws a layer on one world copy only, so a trip across the antimeridian is
// drawn in a longitude frame that keeps it on one copy (#2975). Null for every other
// trip, and then each layer below draws exactly what it always drew.
const lngFrame = useTripLngFrame(places, dayPlaces, reservations)
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
key={`poi-${poi.osm_id}`}
alt={poi.name}
position={[poi.lat, poi.lng]}
position={[poi.lat, frameLng(lngFrame, poi.lng)]}
icon={createPoiIcon(poi, poi.brand_wikidata)}
zIndexOffset={500}
eventHandlers={{
@@ -858,7 +797,7 @@ export const MapView = memo(function MapView({
>
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip"><PoiTooltipBody poi={poi} /></Tooltip>
</Marker>
)), [pois, onPoiClick, onPoiDropOnRoute])
)), [pois, onPoiClick, onPoiDropOnRoute, lngFrame])
const visibleReservations = useMemo(() => (
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
@@ -892,8 +831,11 @@ export const MapView = memo(function MapView({
const framed = computeMapViewport(dayPlaces.length > 0 ? dayPlaces : places, {
tileSize: TILE_SIZE_RASTER,
padding: paddingBox,
// With a longitude frame the trip lies on one copy, so it can be framed the short way.
wrapsWorld: lngFrame !== null,
})
return { center: framed?.center ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
const opening: [number, number] | undefined = framed ? [framed.center[0], frameLng(lngFrame, framed.center[1])] : undefined
return { center: opening ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
})
const cameraSnapshotRef = useRef<{ center: [number, number]; zoom: number } | null>(null)
const saveCameraSnapshot = useCallback((cameraCenter: [number, number], cameraZoom: number) => {
@@ -947,7 +889,8 @@ export const MapView = memo(function MapView({
// Flattened [lat,lng] points of the selected day's route, so the bounds fit can
// include the full polyline once it has been computed.
const routeCoords = useMemo<[number, number][]>(() => (route || []).flat() as [number, number][], [route])
const framedRoute = useMemo(() => (lngFrame && route ? route.map(seg => frameLine(lngFrame, seg)) : route), [route, lngFrame])
const routeCoords = useMemo<[number, number][]>(() => (framedRoute || []).flat() as [number, number][], [framedRoute])
const photoUrls = useMarkerThumbs(places)
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0
@@ -1015,9 +958,9 @@ export const MapView = memo(function MapView({
const gpxTracks = useMemo(() => places.flatMap(place => {
const geometry = parseRenderableRouteGeometry(place.route_geometry)
if (!geometry) return []
const coords: [number, number][] = geometry.map(([lat, lng]) => [lat, lng])
const coords: [number, number][] = frameLine(lngFrame, geometry.map(([lat, lng]) => [lat, lng] as [number, number]))
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }]
}), [places])
}), [places, lngFrame])
// Keeps the click handler out of the track lines' memo (TrackLines): `handleMarkerClick`
// changes on every selection, and depending on it would redraw all tracks.
@@ -1053,6 +996,7 @@ export const MapView = memo(function MapView({
return (
<>
<div className="w-full h-full relative">
<LngFrameProvider value={lngFrame}>
<MapContainer
// The datum is in the element's identity, because react-leaflet builds the
// map once and Leaflet cannot change a CRS afterwards. Settings can arrive
@@ -1125,6 +1069,8 @@ export const MapView = memo(function MapView({
/>
)}
{/* First, so a fit or a camera request in the same commit starts from the trip's world copy. */}
<LngFrameFollower />
<MapController center={center} zoom={zoom} />
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} />
@@ -1142,7 +1088,7 @@ export const MapView = memo(function MapView({
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
When the route can be reshaped an invisible band wider than both takes the click. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
{framedRoute && framedRoute.length > 0 && framedRoute.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
<Polyline
key={`${i}-walk`}
@@ -1174,7 +1120,7 @@ export const MapView = memo(function MapView({
{(accessLines ?? []).map((spur, i) => (
<Polyline
key={`access-${i}`}
positions={spur.line}
positions={frameLine(lngFrame, spur.line)}
pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.85, dashArray: '2 7', lineCap: 'round' }}
interactive={false}
/>
@@ -1190,13 +1136,13 @@ export const MapView = memo(function MapView({
return [
<Polyline
key={`alt-${alt.index}-casing`}
positions={alt.coordinates}
positions={frameLine(lngFrame, alt.coordinates)}
pathOptions={{ color: ALT_CASING, weight: active ? 9 : 8, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
<Polyline
key={`alt-${alt.index}`}
positions={alt.coordinates}
positions={frameLine(lngFrame, alt.coordinates)}
pathOptions={{
color: alt.color,
weight: active ? 6 : 4,
@@ -1213,7 +1159,7 @@ export const MapView = memo(function MapView({
// The drive time on the road itself, the way Apple labels them.
<Marker
key={`alt-${alt.index}-label`}
position={[alt.at.lat, alt.at.lng]}
position={[alt.at.lat, frameLng(lngFrame, alt.at.lng)]}
zIndexOffset={600}
icon={alternativeLabelIcon(alt.label, alt.note, alt.labelBg, active)}
eventHandlers={onChooseAlternative ? {
@@ -1249,7 +1195,7 @@ export const MapView = memo(function MapView({
{routeProfileFocus && (
<CircleMarker
center={[routeProfileFocus.lat, routeProfileFocus.lng]}
center={[routeProfileFocus.lat, frameLng(lngFrame, routeProfileFocus.lng)]}
radius={8}
pathOptions={{ className: 'tour-profile-focus-marker', color: 'var(--bg-card)', weight: 3, fillColor: 'var(--text-muted)', fillOpacity: 1 }}
interactive={false}
@@ -1301,6 +1247,7 @@ export const MapView = memo(function MapView({
<AssistantMapLeaflet assistant={assistant} padding={paddingBox} markerEl={assistantMarkerEl} />
<PluginMapLayers tripId={tripId} />
</MapContainer>
</LngFrameProvider>
{isMobile && <LocationButton
mode={trackingMode}
error={trackingError}
+11 -31
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useMemo, useState, useCallback } from 'react'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable } from './markerDrag'
import { useStableVias } from './viaMarkerState'
import { usePlannerWaypointMarkersGL } from './usePlannerWaypointMarkersGL'
import { carryAssistantMarks, useAssistantMapGL } from './useAssistantMapGL'
@@ -49,6 +49,8 @@ import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { onRealWorld } from '../../utils/worldCopy'
import { bindPoiDropTarget } from './poiDropTarget'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import {
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
@@ -725,7 +727,7 @@ export function MapViewGL({
return
}
const rect = map.getContainer().getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
const at = onRealWorld(map.unproject([e.clientX - rect.left, e.clientY - rect.top]))
viaHandlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
}
// The browser fires this when the captured element is torn out of the DOM,
@@ -925,7 +927,8 @@ export function MapViewGL({
// trip reloads for a gesture the user made once. The picker is a modal
// choice about that leg; dropping a via mid-choice is not offered anywhere.
if (e.point && map.queryRenderedFeatures?.(e.point, { layers: ['route-alt-hit'] })?.length) return
onRouteClick(e.lngLat.lat, e.lngLat.lng)
const at = onRealWorld(e.lngLat)
onRouteClick(at.lat, at.lng)
}
const enter = () => { map.getCanvas().style.cursor = 'copy' }
const leave = () => { map.getCanvas().style.cursor = '' }
@@ -944,35 +947,12 @@ export function MapViewGL({
}
}, [mapReady, onRouteClick])
/**
* The map takes the drop, not the drawn route: in a GL renderer the line lives in the
* canvas and has no element to aim at. The coordinate under the pointer says just as
* much once it is projected onto the routed geometry, and it works the same in all
* three renderers.
*/
// A POI dropped on the map lands on the drive (see poiDropTarget.ts).
useEffect(() => {
const map = mapRef.current
const container = map?.getContainer?.()
if (!container || !mapReady || !onPoiDropOnRoute) return
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e)
if (!osmId) return
e.preventDefault()
const rect = container.getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
onPoiDropOnRoute(osmId, at.lat, at.lng)
}
container.addEventListener('dragover', onDragOver)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', onDragOver)
container.removeEventListener('drop', onDrop)
}
return bindPoiDropTarget(container, (x, y) => map.unproject([x, y]), onPoiDropOnRoute)
}, [mapReady, onPoiDropOnRoute])
const onViewportChangeRef = useRef(onViewportChange)
onViewportChangeRef.current = onViewportChange
@@ -1343,7 +1323,7 @@ export function MapViewGL({
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
) return
if (isTourLayerHit(map, e.point)) return // a picked via ferrata or paddle obstacle opens its card instead
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
onClickRefs.current.map?.({ latlng: onRealWorld({ lat: e.lngLat.lat, lng: e.lngLat.lng }) })
})
// Emit the viewport (pan/zoom + once on first idle) for the POI-explore pill.
const emitViewport = () => reportGlViewport(map, onViewportChangeRef.current)
@@ -1376,7 +1356,7 @@ export function MapViewGL({
// timer — dedupe so the form doesn't open twice.
if (Date.now() - lastContextFire < 700) return false
lastContextFire = Date.now()
onClickRefs.current.context?.({ latlng: { lat: lngLat.lat, lng: lngLat.lng }, originalEvent })
onClickRefs.current.context?.({ latlng: onRealWorld({ lat: lngLat.lat, lng: lngLat.lng }), originalEvent })
return true
}
// MapLibre swallows the map contextmenu at the end of a right-button
@@ -1982,7 +1962,7 @@ export function MapViewGL({
const fitPoints = routeCoords.length > 0 ? [...routeCoords, ...markerPoints] : markerPoints
if (fitPoints.length === 0) return
const bounds = new gl.LngLatBounds()
fitPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
alongShortestArc(fitPoints).forEach(([lat, lng]) => bounds.extend([lng, lat]))
let fitted = false
const run = () => {
try {
+7 -3
View File
@@ -3,25 +3,29 @@ import { useMap } from 'react-leaflet'
import type L from 'leaflet'
import type { RouteVia } from '../../types'
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
export function NightPauseDrag({ marker, via, controls }: { marker: L.Marker | null; via: RouteVia; controls?: DayBoundaryControls }) {
const map = useMap()
// The route is drawn in the map's longitude frame (#2975), so its points are measured there too.
const frame = useLngFrame()
useEffect(() => {
const el = marker?.getElement()
if (!marker || !el || !controls) return
return bindDayBoundaryDrag(el, via, controls, {
project: (lat, lng) => {
const point = map.latLngToContainerPoint([lat, lng])
const point = map.latLngToContainerPoint([lat, frameLng(frame, lng)])
const rect = map.getContainer().getBoundingClientRect()
return { x: point.x + rect.left, y: point.y + rect.top }
},
setPosition: (lat, lng) => { marker.setLatLng([lat, lng]) },
setPosition: (lat, lng) => { marker.setLatLng([lat, frameLng(frame, lng)]) },
lock: () => {
const enabled = map.dragging.enabled()
map.dragging.disable()
return () => { if (enabled) map.dragging.enable() }
},
})
}, [map, marker, via, controls])
}, [map, marker, via, controls, frame])
return null
}
@@ -1,6 +1,9 @@
import L from 'leaflet';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Marker, Tooltip, useMap } from 'react-leaflet';
import { frameLng } from '../../utils/lngFrame';
import { onRealWorld } from '../../utils/worldCopy';
import { useLngFrame } from './lngFrameContext';
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
import { plannerPinBadge, plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
@@ -45,7 +48,11 @@ export default function PlannerWaypointMarker({
useLayoutEffect(() => {
handlersRef.current = { onClick, onMove };
}, [onClick, onMove]);
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
const frame = useLngFrame();
const position = useMemo(
() => [point.lat, frameLng(frame, point.lng)] as [number, number],
[point.lat, point.lng, frame]
);
const { label, shaping, stageEnd } = point;
const icon = useMemo(
() => plannerWaypointIcon(pinCss, { label, shaping, stageEnd }, index, selected, !!onMove),
@@ -67,7 +74,7 @@ export default function PlannerWaypointMarker({
if (!element) return;
return wirePlannerWaypointDrag({
element,
initial: { lat: point.lat, lng: point.lng },
initial: { lat: position[0], lng: position[1] },
coordinateAt: (clientX, clientY) => {
const rect = map.getContainer().getBoundingClientRect();
const at = map.containerPointToLatLng([clientX - rect.left, clientY - rect.top]);
@@ -76,9 +83,13 @@ export default function PlannerWaypointMarker({
setPosition: ({ lat, lng }) => {
marker.setLatLng([lat, lng]);
},
onCommit: ({ lat, lng }) => handlersRef.current.onMove?.(point.id, lat, lng) ?? false,
// Saved on the real world, whichever copy of it the pin was dragged on (#2976).
onCommit: (at) => {
const { lat, lng } = onRealWorld(at);
return handlersRef.current.onMove?.(point.id, lat, lng) ?? false;
},
});
}, [map, marker, onMove, point.id, point.lat, point.lng]);
}, [map, marker, onMove, point.id, position]);
return (
<Marker
@@ -9,6 +9,8 @@ import { useSettingsStore } from '../../store/settingsStore'
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
import { TRANSPORT_COLOR, TRANSPORT_META, transportHop, type TransportHop, type TransportType } from './transportHops'
import type { Reservation } from '../../types'
import { frameLine } from '../../utils/lngFrame'
import { useLngFrame } from './lngFrameContext'
const ENDPOINT_PANE = 'reservation-endpoints'
@@ -48,11 +50,13 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
function linesFor(item: TransportItem, roadRoutes: Map<number, [number, number][]> | undefined): [number, number][][] {
const road = roadRoutes?.get(item.res.id)
return road && road.length >= 2 ? [road] : item.arcs
return road && road.length >= 2 ? [frameLine(item.frame, road)] : item.arcs
}
interface TransportItem extends TransportHop {
transitSegs: TransitMapSegment[]
/** The map's longitude frame the hop was drawn in (#2975), for the road route that arrives later. */
frame: ReturnType<typeof useLngFrame>
}
interface Props {
@@ -75,16 +79,19 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
zoomend: () => setZoom(map.getZoom()),
})
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
const frame = useLngFrame()
const items = useMemo<TransportItem[]>(() => {
const out: TransportItem[] = []
for (const r of reservations) {
const hop = transportHop(r, true)
const hop = transportHop(r, true, frame)
if (!hop) continue
out.push({ ...hop, transitSegs: hop.type === 'transit' ? getTransitMapSegments(r) : [] })
const segs = hop.type === 'transit' ? getTransitMapSegments(r) : []
const transitSegs = frame ? segs.map(seg => ({ ...seg, coords: frameLine(frame, seg.coords) })) : segs
out.push({ ...hop, transitSegs, frame })
}
return out
}, [reservations])
}, [reservations, frame])
const visibleItems = useMemo(() => {
const project = (p: readonly [number, number]) => map.latLngToContainerPoint([p[0], p[1]])
@@ -3,6 +3,9 @@ import { Marker, Tooltip, useMap } from 'react-leaflet'
import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { useStableVias } from './viaMarkerState'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
import { onRealWorld } from '../../utils/worldCopy'
import type { RoadtripVia } from '@trek/shared'
/**
@@ -64,12 +67,14 @@ export default function RoadtripViaMarkers({
return () => { map.off('zoomend', update) }
}, [map])
const frame = useLngFrame()
const markers = useMemo(() => vias.map(via => ({
via,
position: [via.lat, via.lng] as [number, number],
position: [via.lat, frameLng(frame, via.lng)] as [number, number],
handlers: {
dragend: (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
const at = e.target.getLatLng()
// Saved on the real world, whichever copy of it the handle was dragged on (#2976).
const at = onRealWorld(e.target.getLatLng())
handlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
},
// Right-click rather than a delete handle: a 12px dot has no room for one,
@@ -79,7 +84,7 @@ export default function RoadtripViaMarkers({
handlersRef.current.onRemoveVia?.(via.day_id, via.id)
},
},
})), [vias])
})), [vias, frame])
if (!markers.length || !zoomedIn) return null
+4 -1
View File
@@ -1,4 +1,5 @@
import { Polyline } from 'react-leaflet';
import { onRealWorld } from '../../utils/worldCopy';
/**
* P2A-03: an invisible band along a route segment on the Leaflet map, wider
@@ -31,7 +32,9 @@ export function RouteHitBand({
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
// Stops the map's own click, which would otherwise open the add-place menu underneath the new via.
e.originalEvent.stopPropagation();
onRouteClick(e.latlng.lat, e.latlng.lng);
// The via is saved on the real world, whichever copy of it the route was clicked on (#2976).
const at = onRealWorld(e.latlng);
onRouteClick(at.lat, at.lng);
},
}}
/>
+11 -2
View File
@@ -4,6 +4,7 @@ import { useMap } from 'react-leaflet';
import { useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import { bboxOnRealWorld } from '../../utils/worldCopy';
/**
* The part of the planner's map on screen, told to the assistant after every
@@ -54,7 +55,11 @@ export function useReportViewLeaflet(): void {
if (tripId === null) return;
const read = () => {
const b = map.getBounds();
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
// On the real world, whichever world copy (or longitude frame, #2975) the map shows.
report(
tripId,
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
);
};
read();
map.on('moveend', read);
@@ -87,7 +92,11 @@ export function useReportViewGL(mapRef: { readonly current: unknown }, mapReady:
const read = () => {
const b = getBounds();
if (typeof b?.getSouth !== 'function') return;
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
// On the real world, whichever world copy (or longitude frame, #2975) the map shows.
report(
tripId,
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
);
};
read();
on('moveend', read);
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { greatCircle, unwrapLngs, geodesicArcs } from './flightGeodesy'
import { tripLngFrame } from '../../utils/lngFrame'
const YVR: [number, number] = [49.19, -123.18]
const ICN: [number, number] = [37.46, 126.44]
@@ -61,3 +62,29 @@ describe('flightGeodesy (#1411)', () => {
expect(unwrapLngs(pts)).toEqual(pts)
})
})
describe('flightGeodesy in a longitude frame (#2975)', () => {
const LAX: [number, number] = [33.94, -118.41]
const AKL: [number, number] = [-37.01, 174.79]
const frame = tripLngFrame([LAX[1], AKL[1]])
it('draws LAX to Auckland as one arc on one world copy, between the two pins as the frame places them', () => {
expect(frame).not.toBeNull()
const arcs = geodesicArcs([LAX[0], LAX[1] + 360], AKL, true, frame)
expect(arcs).toHaveLength(1)
const [arc] = arcs
expect(maxConsecutiveDeltaLng(arc)).toBeLessThan(180)
expect(arc[0][1]).toBeCloseTo(LAX[1] + 360, 5)
expect(arc[arc.length - 1][1]).toBeCloseTo(AKL[1], 5)
})
it('still copies an arc that leaves the frame, so both of its ends keep a line', () => {
const arcs = geodesicArcs(FRA, JFK, true, { seam: -30 })
expect(arcs).toHaveLength(2)
expect(arcs[1][0][1]).toBeCloseTo(arcs[0][0][1] + 360, 10)
})
it('without a frame draws what it drew before', () => {
expect(geodesicArcs(YVR, ICN, true, null)).toEqual(geodesicArcs(YVR, ICN, true))
})
})
+12 -4
View File
@@ -1,6 +1,8 @@
// Great-circle geometry for transport routes (flights, cruises, ferries),
// shared by the Leaflet and Mapbox/MapLibre renderers (#1411).
import { frameLine, type LngFrame } from '../../utils/lngFrame'
const toRad = (d: number) => d * Math.PI / 180
const toDeg = (r: number) => r * 180 / Math.PI
@@ -51,11 +53,17 @@ export function unwrapLngs(points: [number, number][]): [number, number][] {
* copies), a ±360-shifted duplicate keeps both halves visible in the
* standard [-180, 180] view; GL maps repeat features themselves
* (renderWorldCopies), so the duplicate would just double the line opacity.
*
* Given a longitude frame (Leaflet, a trip across the antimeridian, #2975), the
* arc starts at `a` as the frame draws it, and the duplicate is only needed
* when the arc leaves the frame rather than the plain world.
*/
export function geodesicArcs(a: [number, number], b: [number, number], wrapCopies: boolean): [number, number][][] {
const arc = unwrapLngs(greatCircle(a, b))
const crosses = arc.some(([, lng]) => lng < -180 || lng > 180)
export function geodesicArcs(a: [number, number], b: [number, number], wrapCopies: boolean, frame: LngFrame | null = null): [number, number][][] {
const arc = frame ? frameLine(frame, greatCircle(a, b)) : unwrapLngs(greatCircle(a, b))
const west = frame ? frame.seam : -180
const east = west + 360
const crosses = arc.some(([, lng]) => lng < west || lng > east)
if (!crosses || !wrapCopies) return [arc]
const shift = arc.some(([, lng]) => lng < -180) ? 360 : -360
const shift = arc.some(([, lng]) => lng < west) ? 360 : -360
return [arc, arc.map(([lat, lng]) => [lat, lng + shift] as [number, number])]
}
+7 -1
View File
@@ -1,3 +1,4 @@
import { bboxOnRealWorld } from '../../utils/worldCopy';
import { rasterTileZoom } from './glRasterLayers';
import type { MapViewProps } from './mapViewContract';
@@ -16,6 +17,11 @@ interface ViewportSource {
export function reportGlViewport(map: ViewportSource, report: MapViewProps['onViewportChange']): void {
const bounds = map.getBounds();
if (!report || !bounds) return;
const bbox = { south: bounds.getSouth(), west: bounds.getWest(), north: bounds.getNorth(), east: bounds.getEast() };
const bbox = bboxOnRealWorld({
south: bounds.getSouth(),
west: bounds.getWest(),
north: bounds.getNorth(),
east: bounds.getEast(),
});
report(bbox, rasterTileZoom(map.getZoom()));
}
@@ -0,0 +1,120 @@
// The Leaflet map's bridges to the page: the events it hands out, with their
// points on the real world (#2976), and the view kept on the world copy the
// trip is drawn on (#2975).
import { wrapLongitude } from '@trek/shared';
import L from 'leaflet';
import { useEffect, useRef } from 'react';
import { useMap } from 'react-leaflet';
import { frameLng, type LngFrame } from '../../utils/lngFrame';
import { bboxOnRealWorld } from '../../utils/worldCopy';
import { useLngFrame } from './lngFrameContext';
import type { MapViewProps } from './mapViewContract';
import { bindPoiDropTarget } from './poiDropTarget';
/**
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
*
* The container rather than the drawn route: a polyline is a real element here but not in
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
* once the caller projects it onto the routed geometry — one behaviour for all three.
*/
export function PoiDropTarget({
onPoiDropOnRoute,
}: {
onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void;
}) {
const map = useMap();
useEffect(() => {
if (!onPoiDropOnRoute) return;
return bindPoiDropTarget(map.getContainer(), (x, y) => map.containerPointToLatLng([x, y]), onPoiDropOnRoute);
}, [map, onPoiDropOnRoute]);
return null;
}
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
// OSM places for the visible area, with the zoom an overlay's minimum is read against.
export function ViewportController({ onViewportChange }: { onViewportChange?: MapViewProps['onViewportChange'] }) {
const map = useMap();
useEffect(() => {
if (!onViewportChange) return;
const emit = () => {
const b = map.getBounds();
// A view of a world copy (or of a trip drawn past the antimeridian) asks for the real places there.
onViewportChange(
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }),
map.getZoom()
);
};
map.whenReady(emit); // ensure the first bbox is captured once the map is laid out
map.on('moveend', emit);
map.on('zoomend', emit);
return () => {
map.off('moveend', emit);
map.off('zoomend', emit);
};
}, [map, onViewportChange]);
return null;
}
/**
* Moves the view along when the longitude frame changes under it (#2975): the trip
* is then drawn one world further east or west, and a view left where it was would
* look at an empty copy. One jump of a whole world, so nothing on screen changes.
*/
export function LngFrameFollower() {
const map = useMap();
const frame = useLngFrame();
const prev = useRef<LngFrame | null>(frame);
useEffect(() => {
const before = prev.current;
prev.current = frame;
if (before === frame || typeof map.getCenter !== 'function') return;
const center = map.getCenter();
// Without a frame the trip is back on the real world, and so is the view.
const target = frame ? frameLng(frame, center.lng) : wrapLongitude(center.lng);
const shift = target - center.lng;
if (shift !== 0) map.setView([center.lat, target], map.getZoom(), { animate: false });
}, [frame, map]);
return null;
}
interface MapClickHandlerProps {
onClick: ((e: L.LeafletMouseEvent) => void) | null;
}
/**
* The event as the map reports it, with its point moved from a repeated world copy
* back onto the real world: a right-click on the copy east of it said 274.9 for
* -85.1, and the place was saved there (#2976).
*/
function onRealWorldEvent(e: L.LeafletMouseEvent): L.LeafletMouseEvent {
const lng = wrapLongitude(e.latlng.lng);
return lng === e.latlng.lng ? e : { ...e, latlng: L.latLng(e.latlng.lat, lng) };
}
export function MapClickHandler({ onClick }: MapClickHandlerProps) {
const map = useMap();
useEffect(() => {
if (!onClick) return;
const handle = (e: L.LeafletMouseEvent) => onClick(onRealWorldEvent(e));
map.on('click', handle);
return () => {
map.off('click', handle);
};
}, [map, onClick]);
return null;
}
export function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
const map = useMap();
useEffect(() => {
if (!onContextMenu) return;
const handle = (e: L.LeafletMouseEvent) => onContextMenu(onRealWorldEvent(e));
map.on('contextmenu', handle);
return () => {
map.off('contextmenu', handle);
};
}, [map, onContextMenu]);
return null;
}
@@ -0,0 +1,51 @@
// The longitude frame of the Leaflet map, for every layer that draws in it (#2975).
// See utils/lngFrame.ts for what the frame is and why only Leaflet needs it.
import { createContext, useContext, useMemo, useState } from 'react';
import type { Place, Reservation } from '../../types';
import { tripLngFrame, type LngFrame } from '../../utils/lngFrame';
const LngFrameContext = createContext<LngFrame | null>(null);
export const LngFrameProvider = LngFrameContext.Provider;
/** The frame the map draws in, or null when it draws the plain world. */
export function useLngFrame(): LngFrame | null {
return useContext(LngFrameContext);
}
type Located = Pick<Place, 'lng'>;
function tripLngs(
places: readonly Located[],
dayPlaces: readonly Located[],
reservations: readonly Reservation[]
): number[] {
const lngs: number[] = [];
for (const p of places) if (typeof p.lng === 'number') lngs.push(p.lng);
for (const p of dayPlaces) if (typeof p.lng === 'number') lngs.push(p.lng);
for (const r of reservations) for (const e of r.endpoints ?? []) if (typeof e.lng === 'number') lngs.push(e.lng);
return lngs;
}
/**
* The frame for a trip: its places, the day's places and every endpoint of every
* booking, shown or not, so switching a day or a booking on and off does not move
* the seam. The frame in use is handed back in, which keeps it while it still fits.
*/
export function useTripLngFrame(
places: readonly Located[],
dayPlaces: readonly Located[],
reservations: readonly Reservation[]
): LngFrame | null {
const [current, setCurrent] = useState<LngFrame | null>(() =>
tripLngFrame(tripLngs(places, dayPlaces, reservations))
);
const next = useMemo(
() => tripLngFrame(tripLngs(places, dayPlaces, reservations), current),
[places, dayPlaces, reservations, current]
);
// Derived state, adjusted during render: the next render already draws in the new frame.
if (next !== current) setCurrent(next);
return next;
}
@@ -0,0 +1,39 @@
// A corridor hit dropped anywhere on the map, for every renderer.
//
// The map takes the drop, not the drawn route: in a GL renderer the line lives in the
// canvas and has no element to aim at, and the coordinate under the pointer says just
// as much once the caller projects it onto the routed geometry. The point goes out on
// the real world, whichever world copy it was dropped on (#2976).
import { onRealWorld } from '../../utils/worldCopy';
import { draggedPoiId } from './markerDrag';
/** Where a point of the container lies, in the renderer's own coordinates. */
type PointAt = (x: number, y: number) => { lat: number; lng: number };
/** Listens for POI drops on `container` and reports each one; returns the cleanup. */
export function bindPoiDropTarget(
container: HTMLElement,
pointAt: PointAt,
onPoiDropOnRoute: (osmId: string, lat: number, lng: number) => void
): () => void {
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
};
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e);
if (!osmId) return;
e.preventDefault();
const rect = container.getBoundingClientRect();
const at = onRealWorld(pointAt(e.clientX - rect.left, e.clientY - rect.top));
onPoiDropOnRoute(osmId, at.lat, at.lng);
};
container.addEventListener('dragover', onDragOver);
container.addEventListener('drop', onDrop);
return () => {
container.removeEventListener('dragover', onDragOver);
container.removeEventListener('drop', onDrop);
};
}
@@ -88,6 +88,20 @@ describe('transportHop', () => {
expect(transportHop(r, false)!.arcs[0].length).toBeGreaterThan(2);
});
it("draws the waypoints and arcs in the map's longitude frame, so the pins sit on the line (#2975)", () => {
const r = booking('flight', [
endpoint({ role: 'from', lat: 33.94, lng: -118.41 }),
endpoint({ role: 'to', sequence: 1, lat: -37.01, lng: 174.79 }),
]);
const hop = transportHop(r, true, { seam: 28 });
if (!hop) throw new Error('a flight with two located ends draws a hop');
expect(hop.from.lng).toBeCloseTo(241.59, 5);
expect(hop.to.lng).toBeCloseTo(174.79, 5);
expect(hop.arcs).toHaveLength(1);
expect(hop.arcs[0][0][1]).toBeCloseTo(241.59, 5);
expect(hop.arcs[0][hop.arcs[0].length - 1][1]).toBeCloseTo(174.79, 5);
});
it('draws ferries and cruises as arcs but a cable car as a straight line', () => {
expect(TRANSPORT_META.ferry.geodesic).toBe(true);
expect(TRANSPORT_META.cruise.geodesic).toBe(true);
+9 -5
View File
@@ -7,6 +7,7 @@
import { Bike, Bus, CableCar, Car, CarTaxiFront, Plane, Route, Sailboat, Ship, Train, TramFront } from 'lucide-react';
import type { Reservation, ReservationEndpoint } from '../../types';
import { haversineKm } from '../../utils/geo';
import { frameLine, frameLng, type LngFrame } from '../../utils/lngFrame';
import { geodesicArcs } from './flightGeodesy';
export type TransportType =
@@ -144,14 +145,17 @@ export interface TransportHop {
* antimeridian also gets a copy shifted by 360 degrees: Leaflet needs that, while a
* GL map repeats features across world copies itself, so there one continuous arc
* is enough (a shifted duplicate would sit on the wrapped copy and double the opacity).
* `frame` is Leaflet's longitude frame (#2975): the arcs and the waypoints come back
* drawn in it, so a pin and the line that ends at it are on the same world copy.
*/
export function transportHop(r: Reservation, wrapArcs: boolean): TransportHop | null {
export function transportHop(r: Reservation, wrapArcs: boolean, frame: LngFrame | null = null): TransportHop | null {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) return null;
// Ordered waypoints (from · stops · to). A single-leg booking has exactly two.
const waypoints = (r.endpoints || [])
const sorted = (r.endpoints || [])
.filter((e) => e.role === 'from' || e.role === 'to' || e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
const waypoints = frame ? sorted.map((e) => ({ ...e, lng: frameLng(frame, e.lng) })) : sorted;
if (waypoints.length < 2) return null;
const from = waypoints[0];
const to = waypoints[waypoints.length - 1];
@@ -164,12 +168,12 @@ export function transportHop(r: Reservation, wrapArcs: boolean): TransportHop |
const a = waypoints[i];
const b = waypoints[i + 1];
const segArcs = isGeo
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], wrapArcs)
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], wrapArcs, frame)
: [
[
frameLine(frame, [
[a.lat, a.lng],
[b.lat, b.lng],
] as [number, number][],
] as [number, number][]),
];
arcs.push(...segArcs);
distanceKm += haversineKm(a, b);
@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import type { AssistantRouteLine } from '../../types/assistantMap';
import { onRealWorld } from '../../utils/worldCopy';
import { mapHoverLabel } from './mapHoverLabel';
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers';
@@ -245,12 +246,15 @@ export function useAssistantGhostLinesGL({ mapRef, mapReady, lines, onHover, onP
return;
}
handlers.current.onHover?.(ref);
handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
const at = onRealWorld(event.lngLat);
handlers.current.onPoint?.(ref, at.lat, at.lng);
};
const leave = () => handlers.current.onHover?.(null);
const click = (event: LayerEvent) => {
const ref = refOf(event);
if (ref !== null && !onPlanPin(map, event)) handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
if (ref === null || onPlanPin(map, event)) return;
const at = onRealWorld(event.lngLat);
handlers.current.onPoint?.(ref, at.lat, at.lng);
};
const stop = (event: LayerEvent) => {
const name = event.features?.[0]?.properties?.label;
@@ -1,5 +1,6 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import { onRealWorld } from '../../utils/worldCopy';
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
import type { MapViewProps } from './mapViewContract';
import { plannerPinBadge, plannerPinShape } from './plannerPinShape';
@@ -102,7 +103,7 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
coordinateAt: (clientX, clientY) => {
const rect = map.getContainer().getBoundingClientRect();
const at = map.unproject([clientX - rect.left, clientY - rect.top]);
return { lat: at.lat, lng: at.lng };
return onRealWorld({ lat: at.lat, lng: at.lng });
},
setPosition: ({ lat, lng }) => pin.setLngLat([lng, lat]),
onCommit: ({ lat, lng }) =>
@@ -9,6 +9,8 @@ import { airtrailApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { isEffectivelyOffline } from '../../sync/networkMode'
import { parseReservationMetadata } from '../../utils/flightLegs'
import { useDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
interface AirTrailImportModalProps {
isOpen: boolean
@@ -52,15 +54,15 @@ export function detectConnections(flights: AirtrailFlight[]): AirtrailFlight[][]
}
/** Locale-aware date (e.g. de → 13.06.2026, en-US → 06/13/2026). */
function fmtDate(d: string | null, locale: string): string {
function fmtDate(d: string | null, locale: string, dateFormat: string): string {
if (!d) return ''
try {
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, {
return formatDisplayDate(new Date(d + 'T00:00:00Z'), locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: 'UTC',
})
}, dateFormat)
} catch {
return d
}
@@ -68,6 +70,7 @@ function fmtDate(d: string | null, locale: string): string {
export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo }: AirTrailImportModalProps) {
const { t, locale } = useTranslation()
const dateFormat = useDateFormat()
const toast = useToast()
const trip = useTripStore(s => s.trip)
const reservations = useTripStore(s => s.reservations)
@@ -270,7 +273,7 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<span style={{ display: 'block', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)' }}>
{f.fromCode ?? f.fromName ?? '?'} → {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale)}` : ''}
{f.fromCode ?? f.fromName ?? '?'} → {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale, dateFormat)}` : ''}
</span>
</span>
{already && (
@@ -1,12 +1,15 @@
import { createPortal } from 'react-dom'
import { CalendarDays, Pencil, Trash2, ChevronDown, Check, Eye } from 'lucide-react'
import type { SidebarState } from './usePlacesSidebar'
import { useDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
export function MobileDayPickerSheet(S: SidebarState) {
const {
dayPickerPlace, setDayPickerPlace, setMobileShowDays, onPlaceClick, canEditPlaces, onEditPlace,
t, days, mobileShowDays, onAssignToDay, assignments, onDeletePlace,
} = S
const dateFormat = useDateFormat()
return createPortal(
<div
role="presentation"
@@ -64,7 +67,7 @@ export function MobileDayPickerSheet(S: SidebarState) {
<div className="bg-surface-tertiary text-content" style={{ width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{i + 1}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{day.title || t('dayplan.dayN', { n: i + 1 })}</div>
{day.date && <div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { timeZone: 'UTC' })}</div>}
{day.date && <div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{formatDisplayDate(new Date(day.date + 'T00:00:00Z'), undefined, { timeZone: 'UTC' }, dateFormat)}</div>}
</div>
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <Check size={14} color="var(--text-faint)" />}
</button>
@@ -1,6 +1,7 @@
import { cheapestEnergy, type ChargingEnergyPrice, type ChargingInfo as Info } from '@trek/shared';
import { ChevronDown, Coins, PlugZap } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useFormatDate } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n/TranslationContext';
import { chargingRepo } from '../../repo/chargingRepo';
import { useTripStore } from '../../store/tripStore';
@@ -76,6 +77,7 @@ export default function ChargingInfo({
}: ChargingTarget & { compact?: boolean }) {
const tripId = useTripStore((s) => s.trip?.id);
const { t } = useTranslation();
const { formatDateTime } = useFormatDate();
const [info, setInfo] = useState<Info | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -133,7 +135,7 @@ export default function ChargingInfo({
const cheapest = info ? cheapestEnergy(info.tariffs) : null;
const price = (value: number, currency: string) =>
new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value);
const label = `${status}${info?.updatedAt ? ` (${new Date(info.updatedAt).toLocaleString()})` : ''}`;
const label = `${status}${info?.updatedAt ? ` (${formatDateTime(new Date(info.updatedAt))})` : ''}`;
if (compact)
return (
<ChargingBadges
@@ -211,7 +213,7 @@ export default function ChargingInfo({
</a>
<br />
{info.license}
{info.updatedAt && ` · ${new Date(info.updatedAt).toLocaleString()}`}
{info.updatedAt && ` · ${formatDateTime(new Date(info.updatedAt))}`}
</p>
)}
</div>
@@ -11,6 +11,8 @@ import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, Foot
import { EditorField, GRID_2, INPUT, LABEL } from '../shared/dialogParts'
import { SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingsHint, StatusPill } from './settingsKit'
import { useTranslation } from '../../i18n'
import { useDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
import { useToast } from '../shared/Toast'
import { authApi } from '../../api/client'
@@ -55,10 +57,10 @@ const SCOPE_ICONS: Record<PublicApiScope, LucideIcon> = {
}
/** SQLite hands out "YYYY-MM-DD HH:MM:SS" in UTC, which Safari will not parse as it stands. */
function formatStamp(ts: string, locale: string): string {
function formatStamp(ts: string, locale: string, dateFormat: string): string {
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T')
const d = new Date(/(Z|[+-]\d{2}:?\d{2})$/.test(iso) ? iso : `${iso}Z`)
return Number.isNaN(d.getTime()) ? ts : d.toLocaleDateString(locale)
return Number.isNaN(d.getTime()) ? ts : formatDisplayDate(d, locale, {}, dateFormat)
}
/**
@@ -380,6 +382,7 @@ export default function ApiKeysSection(): React.ReactElement {
function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: string; onDelete: () => void }): React.ReactElement {
const { t } = useTranslation()
const dateFormat = useDateFormat()
const scopes = apiKey.scope_mode === 'limited' ? apiKey.scopes : undefined
return (
<li className="flex items-start gap-3 px-3.5 py-3">
@@ -394,10 +397,10 @@ function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: strin
</code>
</div>
<p className="m-0 mt-0.5 tabular-nums text-content-faint first-letter:uppercase" style={fs(11.5)}>
{t('settings.apiKeys.createdAt')} {formatStamp(apiKey.created_at, locale)}
{t('settings.apiKeys.createdAt')} {formatStamp(apiKey.created_at, locale, dateFormat)}
{', '}
{apiKey.last_used_at
? `${t('settings.apiKeys.usedAt')} ${formatStamp(apiKey.last_used_at, locale)}`
? `${t('settings.apiKeys.usedAt')} ${formatStamp(apiKey.last_used_at, locale, dateFormat)}`
: t('settings.apiKeys.neverUsed')}
</p>
{/* What the key may read. Shown on the row rather than behind a detail
@@ -1,6 +1,7 @@
import React from 'react'
import { Loader2, Save, Plug, RefreshCw, Unplug } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import { useDawarichConnection } from '../../hooks/useDawarichConnection'
import DawarichIcon from '../shared/DawarichIcon'
import Section from './Section'
@@ -23,7 +24,7 @@ import { fs } from '../shared/DialogShell'
* no reason is the version of this card that generates support questions.
*/
export default function DawarichConnectionSection(): React.ReactElement {
const { t, locale } = useTranslation()
const { t } = useTranslation()
const S = useDawarichConnection()
return (
@@ -123,7 +124,7 @@ export default function DawarichConnectionSection(): React.ReactElement {
)}
</div>
<DawarichConnectionStatus state={S} locale={locale} />
<DawarichConnectionStatus state={S} />
</Section>
)
}
@@ -138,12 +139,11 @@ export default function DawarichConnectionSection(): React.ReactElement {
*/
function DawarichConnectionStatus({
state,
locale,
}: {
state: ReturnType<typeof useDawarichConnection>
locale: string
}): React.ReactElement | null {
const { t } = useTranslation()
const { formatDateTime } = useFormatDate()
if (!state.connected && !state.probeMessage) return null
const missing: string[] = []
@@ -161,7 +161,7 @@ function DawarichConnectionStatus({
{state.connected && (
<p className="m-0 text-content-secondary">
{state.lastSyncAt
? t('dawarich.lastSync', { when: new Date(state.lastSyncAt).toLocaleString(locale) })
? t('dawarich.lastSync', { when: formatDateTime(new Date(state.lastSyncAt)) })
: t('dawarich.neverSynced')}
{state.lastSyncState === 'partial' && `, ${t('dawarich.syncPartial')}`}
</p>
@@ -1,4 +1,4 @@
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-055
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-057
import { render, screen, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -484,6 +484,35 @@ describe('DisplaySettingsTab week start (#2029)', () => {
});
});
// ── Date format (056, 057) ────────────────────────────────────────────────────
describe('DisplaySettingsTab date format (#2971)', () => {
it('FE-COMP-DISPLAY-056: shows the language format when unset and saves the format picked', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ date_format: undefined }), updateSetting });
render(<DisplaySettingsTab />);
// Each choice is labelled by how it writes 31 December; auto carries the language's own way.
await user.click(screen.getByRole('button', { name: /\(12\/31\/2026\)/ }));
await user.click(await screen.findByText('31.12.2026'));
expect(updateSetting).toHaveBeenCalledWith('date_format', 'DD.MM.YYYY');
});
it('FE-COMP-DISPLAY-057: a rejected change surfaces the error', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockRejectedValue(new Error('Save failed'));
seedStore(useSettingsStore, { settings: buildSettings({ date_format: 'YYYY-MM-DD' }), updateSetting });
render(<><ToastContainer /><DisplaySettingsTab /></>);
await user.click(screen.getByRole('button', { name: /2026-12-31/ }));
await user.click(await screen.findByText('31/12/2026'));
expect(await screen.findByText('Save failed')).toBeInTheDocument();
});
});
describe('DisplaySettingsTab – the demo account', () => {
it('FE-COMP-DISPLAY-DEMO-001: the shared demo account sees English under the demo lock', () => {
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
@@ -16,8 +16,9 @@ import { useSettingSaver } from './useSettingSaver'
import { TRIP_TAB_IDS, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDestination'
import type { DistanceUnit } from '../../types'
import { DEFAULT_WEEK_START } from '@trek/shared'
import { DEFAULT_DATE_FORMAT, DEFAULT_WEEK_START } from '@trek/shared'
import { weekStartOptions } from '../../utils/calendarWeek'
import { dateFormatOptions } from '../../utils/dateFormat'
import DemoLock from '../Demo/DemoLock'
import { useDemoAccountLock } from '../Demo/useDemoLock'
@@ -265,6 +266,23 @@ export default function DisplaySettingsTab(): React.ReactElement {
</div>
}
/>
{/* Date format: every date field and every date written in numbers (#2971) */}
<SettingRow
label={t('settings.dateFormat')}
hint={t('settings.dateFormatHint')}
control={
<div className={SELECT_BOX}>
<CustomSelect
value={settings.date_format || DEFAULT_DATE_FORMAT}
onChange={v => {
void save('date_format', String(v))
}}
options={dateFormatOptions(locale, t('settings.dateFormatAuto'))}
/>
</div>
}
/>
</SettingRows>
</Section>
@@ -1,6 +1,7 @@
import Section from './Section'
import React, { useId } from 'react'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import { useToast } from '../shared/Toast'
import { Trash2, Copy, Terminal, Plus, Check, Key, KeyRound, ChevronRight, RefreshCw, AlertTriangle, Link2 } from 'lucide-react'
import PhotoProvidersSection from './PhotoProvidersSection'
@@ -62,12 +63,13 @@ export default function IntegrationsTab(): React.ReactElement {
function useIntegrations() {
const { t, locale } = useTranslation()
const { formatDate } = useFormatDate()
const toast = useToast()
const { mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled, wandererEnabled, managed } = useIntegrationGates({ reloadAddons: true })
const mcp = useMcpIntegration({ enabled: mcpEnabled, reloadClientsAfterWrite: false })
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled,
return { t, locale, formatDate, toast, mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled,
wandererEnabled,
managed, ...mcp,
}
@@ -171,7 +173,7 @@ function ConfigBlock({ open, onToggle, json, hint, copied, onCopy, t }: {
/** One registered OAuth client: what it is, the scopes it may use, and its two actions. */
function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsState }) {
const { t, locale, oauthScopesExpanded, setOauthScopesExpanded, setOauthRotateId, setOauthDeleteId } = S
const { t, formatDate, oauthScopesExpanded, setOauthScopesExpanded, setOauthRotateId, setOauthDeleteId } = S
const expanded = !!oauthScopesExpanded[client.id]
return (
<div className={`${ROW} items-start`}>
@@ -185,7 +187,7 @@ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsSta
</div>
<div className="mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
<span className="min-w-0 truncate font-geist">{t('settings.oauth.clientId')}: {client.client_id}</span>
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(client.created_at))}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1" style={fs(10.5)}>
{(expanded ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => (
@@ -216,7 +218,7 @@ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsSta
/** The OAuth tab: the snippet, the registered clients and the sessions they hold. */
function OAuthTab({ S }: { S: IntegrationsState }) {
const {
t, locale, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, configOpenOAuth, setConfigOpenOAuth, copiedKey, mcpJsonConfigOAuth, handleCopy,
t, formatDate, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, configOpenOAuth, setConfigOpenOAuth, copiedKey, mcpJsonConfigOAuth, handleCopy,
} = S
return (
<>
@@ -260,7 +262,7 @@ function OAuthTab({ S }: { S: IntegrationsState }) {
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{session.client_name}</p>
<p className="m-0 mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
<span className="min-w-0 truncate">{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}</span>
<span className="tabular-nums">{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}</span>
<span className="tabular-nums">{t('settings.oauth.sessionExpires')} {formatDate(new Date(session.access_token_expires_at))}</span>
</p>
</div>
<button type="button" onClick={() => setOauthRevokeId(session.id)}
@@ -278,7 +280,7 @@ function OAuthTab({ S }: { S: IntegrationsState }) {
/** The deprecated API tokens tab: the warning, the snippet and the tokens. */
function ApiTokensTab({ S }: { S: IntegrationsState }) {
const { t, locale, configOpenToken, setConfigOpenToken, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S
const { t, formatDate, configOpenToken, setConfigOpenToken, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S
return (
<>
<WarningNote>{t('settings.mcp.apiTokensDeprecated')}</WarningNote>
@@ -311,9 +313,9 @@ function ApiTokensTab({ S }: { S: IntegrationsState }) {
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{token.name}</p>
<p className="m-0 mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
<span className="font-geist">{token.token_prefix}...</span>
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(token.created_at))}</span>
{token.last_used_at && (
<span className="tabular-nums">{t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
<span className="tabular-nums">{t('settings.mcp.tokenUsedAt')} {formatDate(new Date(token.last_used_at))}</span>
)}
</p>
</div>
@@ -2,9 +2,11 @@ import { escapeHtml } from '@trek/shared';
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { adminApi, authApi } from '../../api/client';
import { useDateFormat } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import { getApiErrorMessage, type UserWithOidc } from '../../types';
import { formatDisplayDateTime } from '../../utils/dateFormat';
import { passwordErrorKey } from '../../utils/passwordError';
import { useToast } from '../shared/Toast';
@@ -35,6 +37,7 @@ export function useAccountSettings({ avatarRemoveErrorKey, ignoreEmptyBackupCode
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { t } = useTranslation();
const dateFormat = useDateFormat();
const toast = useToast();
const avatarInputRef = useRef<HTMLInputElement>(null);
@@ -126,7 +129,7 @@ export function useAccountSettings({ avatarRemoveErrorKey, ignoreEmptyBackupCode
if (skipBackupCodes) return;
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
</head><body><h1>TREK MFA Backup Codes</h1><p>${escapeHtml(new Date().toLocaleString())}</p><pre>${escapeHtml(backupCodesText)}</pre></body></html>`;
</head><body><h1>TREK MFA Backup Codes</h1><p>${escapeHtml(formatDisplayDateTime(new Date(), undefined, {}, dateFormat))}</p><pre>${escapeHtml(backupCodesText)}</pre></body></html>`;
const w = window.open('', '_blank', 'width=900,height=700');
if (!w) return;
w.document.open();
@@ -1,8 +1,10 @@
import { startRegistration } from '@simplewebauthn/browser';
import { useEffect, useState } from 'react';
import { authApi, type PasskeyCredential } from '../../api/client';
import { readDateFormat } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { getApiErrorMessage } from '../../types';
import { formatDisplayDate } from '../../utils/dateFormat';
import { useToast } from '../shared/Toast';
/** Parse a SQLite UTC timestamp ("YYYY-MM-DD HH:MM:SS") into a local date string. */
@@ -10,7 +12,7 @@ export function fmtDate(ts: string | null): string | null {
if (!ts) return null;
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T');
const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
return Number.isNaN(d.getTime()) ? null : d.toLocaleDateString();
return Number.isNaN(d.getTime()) ? null : formatDisplayDate(d, undefined, {}, readDateFormat());
}
/** True when the browser cancellation / no-matching-credential DOMExceptions fire. */
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import { pluginsApi } from '../../api/client';
import { useDateFormat } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { formatDisplayDateTime } from '../../utils/dateFormat';
export interface ActivityRow {
ts: string;
@@ -18,6 +20,7 @@ export interface ActivityRow {
*/
export function usePluginActivity() {
const { locale } = useTranslation();
const dateFormat = useDateFormat();
const [rows, setRows] = useState<ActivityRow[]>([]);
const [loading, setLoading] = useState(true);
@@ -36,7 +39,7 @@ export function usePluginActivity() {
const fmtWhen = (ts: string): string => {
const d = new Date(ts);
return Number.isNaN(d.getTime()) ? ts : d.toLocaleString(locale);
return Number.isNaN(d.getTime()) ? ts : formatDisplayDateTime(d, locale, {}, dateFormat);
};
return { rows, loading, load, fmtWhen };
@@ -2,6 +2,8 @@ import L from 'leaflet';
import { useMemo, useState } from 'react';
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
import { useTranslation } from '../../../i18n';
import { frameLine, frameLng } from '../../../utils/lngFrame';
import { useLngFrame } from '../../Map/lngFrameContext';
import {
isPaddleItemSelected,
markerSize,
@@ -74,6 +76,8 @@ function useLabel() {
export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
const label = useLabel();
const map = useMap();
// Drawn in the map's longitude frame, with the trip (#2975).
const frame = useLngFrame();
const linePane = useLinePane();
const [zoom, setZoom] = useState(() => map.getZoom());
useMapEvents({ zoomend: () => setZoom(map.getZoom()) });
@@ -105,7 +109,7 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
item.line ? (
<Polyline
key={`${item.feature.ref}-casing`}
positions={item.line}
positions={frameLine(frame, item.line)}
pane={linePane}
pathOptions={{
color: item.disc,
@@ -120,7 +124,7 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
item.line ? (
<Polyline
key={`${item.feature.ref}-line`}
positions={item.line}
positions={frameLine(frame, item.line)}
pane={linePane}
bubblingMouseEvents={false}
pathOptions={{
@@ -137,7 +141,7 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
{items.map((item) => (
<Marker
key={`${item.feature.ref}-pin`}
position={item.marker}
position={frame ? [item.marker[0], frameLng(frame, item.marker[1])] : item.marker}
icon={icons.get(item.feature.ref) as L.DivIcon}
zIndexOffset={
isPaddleItemSelected(item, selectedRef, selectedKey) ? 900 : item.relation === 'on_route' ? 600 : 300
@@ -18,8 +18,10 @@ import {
} from 'lucide-react';
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { useDateFormat } from '../../../hooks/useDateFormat';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDisplayDate } from '../../../utils/dateFormat';
import { formatDistance, formatElevation } from '../../../utils/units';
import { BarButton, WhiteBadge } from '../../Planner/planParts';
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell';
@@ -428,12 +430,13 @@ function SnapshotNotice({
onOpen: (placeId: number) => void;
}) {
const { t } = useTranslation();
const dateFormat = useDateFormat();
const snapshot = preview.snapshot;
if (preview.state === 'new' || !snapshot) {
return <TourNotice>{t('wanderer.preview.newHint')}</TourNotice>;
}
const when = snapshot.importedAt
? new Date(`${snapshot.importedAt.replace(' ', 'T')}Z`).toLocaleDateString(locale)
? formatDisplayDate(new Date(`${snapshot.importedAt.replace(' ', 'T')}Z`), locale, {}, dateFormat)
: '';
const open = (
<button
@@ -78,6 +78,19 @@ afterEach(() => {
delete window.__addToast
})
// The hire date is the shared date picker (#2971): typed through its manual
// field, cleared from its calendar.
function typeDate(trigger: HTMLElement, value: string) {
fireEvent.click(within(trigger.parentElement as HTMLElement).getByRole('button', { name: /enterManually|Enter date manually/ }))
const input = screen.getByRole('textbox', { name: /enterManually|Enter date manually/ })
fireEvent.change(input, { target: { value } })
fireEvent.keyDown(input, { key: 'Enter' })
}
function clearDate(trigger: HTMLElement) {
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: /clearDate|Clear date/ }))
}
describe('VacaySettings calendars', () => {
it('FE-COMP-VCYSET-001: the week-start buttons save the other day', () => {
const updatePlan = vi.fn(async (_u: Partial<VacayPlan>) => {})
@@ -312,14 +325,15 @@ describe('VacaySettings calendars', () => {
})
render(<VacaySettings onClose={vi.fn()} />)
expect(screen.getByLabelText('Hire date')).toHaveValue('2020-09-14')
const hire = () => screen.getByRole('button', { name: /^Hire date: / })
expect(hire()).toHaveAccessibleName(/2020/)
fireEvent.change(screen.getByLabelText('Hire date'), { target: { value: '2021-03-02' } })
typeDate(hire(), '2021-03-02')
expect(updateYearSettings).toHaveBeenLastCalledWith({
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: '2021-03-02',
})
fireEvent.change(screen.getByLabelText('Hire date'), { target: { value: '' } })
clearDate(hire())
expect(updateYearSettings).toHaveBeenLastCalledWith({
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: null,
})
@@ -3,6 +3,7 @@ import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRigh
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useSchoolHolidayCountries } from './useSchoolHolidayCountries'
import type { VacayHolidayCalendar } from '../../types'
@@ -355,12 +356,12 @@ function YearTypePicker() {
{type === 'anniversary' && (
<div style={{ paddingInlineStart: 36, marginTop: 8 }}>
<input
type="date"
<CustomDatePicker
value={yearSettings.hire_date || ''}
onChange={e => save({ hire_date: e.target.value || null })}
aria-label={t('vacay.hireDate')}
style={{ width: '100%', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, padding: '8px 14px', borderRadius: 10, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
onChange={v => save({ hire_date: v || null })}
placeholder={t('vacay.hireDate')}
ariaLabel={t('vacay.hireDate')}
style={{ width: '100%' }}
/>
{!yearSettings.hire_date && (
<p className="text-[11px] mt-1 text-content-faint">{t('vacay.hireDateHint')}</p>
@@ -819,3 +819,82 @@ describe('CustomDatePicker week start (#2029)', () => {
expect(await openMarch2026('saturday')).toEqual({ header: 'SSMTWTF', blanks: 1 });
});
});
// ─── Date format (#2971) ──────────────────────────────────────────────────────
describe('CustomDatePicker date format (#2971)', () => {
const onChange = vi.fn();
const original = useSettingsStore.getState().settings;
beforeEach(() => vi.clearAllMocks());
afterEach(() => useSettingsStore.setState({ settings: original }));
const seed = (dateFormat: 'auto' | 'YYYY-MM-DD' | 'DD.MM.YYYY') =>
useSettingsStore.setState({ settings: { ...original, date_format: dateFormat } });
function typeDate(value: string) {
fireEvent.click(screen.getByRole('button', { name: /enter date manually/i }));
const input = screen.getByRole('textbox', { name: 'Enter date manually' });
fireEvent.change(input, { target: { value } });
fireEvent.keyDown(input, { key: 'Enter' });
}
it('FE-COMP-DATEPICKER-036: the trigger writes the date in the chosen format', () => {
seed('YYYY-MM-DD');
render(<CustomDatePicker value="2026-11-03" onChange={onChange} />);
expect(screen.getAllByRole('button')[0].textContent).toContain('2026-11-03');
});
it('FE-COMP-DATEPICKER-037: compact mode writes the chosen format with the whole year', () => {
seed('DD.MM.YYYY');
render(<CustomDatePicker value="2026-11-03" onChange={onChange} compact />);
expect(screen.getByText('03.11.2026')).toBeInTheDocument();
});
it('FE-COMP-DATEPICKER-038: the manual entry is hinted and pre-filled in the chosen format', () => {
seed('YYYY-MM-DD');
render(<CustomDatePicker value="2026-11-03" onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: /enter date manually/i }));
const input = screen.getByRole('textbox', { name: 'Enter date manually' });
expect(input).toHaveProperty('placeholder', 'YYYY-MM-DD');
expect(input).toHaveProperty('value', '2026-11-03');
});
it('FE-COMP-DATEPICKER-039: a typed date is read in the order of the chosen format', () => {
seed('YYYY-MM-DD');
render(<CustomDatePicker value="" onChange={onChange} />);
typeDate('2026/11/04');
expect(onChange).toHaveBeenCalledWith('2026-11-04');
});
it('FE-COMP-DATEPICKER-040: day first under DD.MM.YYYY, even with an English interface', () => {
seed('DD.MM.YYYY');
render(<CustomDatePicker value="" onChange={onChange} />);
typeDate('04.11.2026');
expect(onChange).toHaveBeenCalledWith('2026-11-04');
});
it('FE-COMP-DATEPICKER-043: a four-digit year written first is read as year, month, day in any format', () => {
seed('DD.MM.YYYY');
render(<CustomDatePicker value="" onChange={onChange} />);
typeDate('2026/11/04');
expect(onChange).toHaveBeenCalledWith('2026-11-04');
});
it('FE-COMP-DATEPICKER-041: auto keeps the short month of the language on the trigger', () => {
seed('auto');
render(<CustomDatePicker value="2026-11-03" onChange={onChange} />);
expect(screen.getAllByRole('button')[0].textContent).toMatch(/Nov/);
});
it('FE-COMP-DATEPICKER-042: auto keeps the language for typing: month first in English', () => {
seed('auto');
render(<CustomDatePicker value="" onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: /enter date manually/i }));
const input = screen.getByRole('textbox', { name: 'Enter date manually' });
expect(input).toHaveProperty('placeholder', 'MM/DD/YYYY');
fireEvent.change(input, { target: { value: '04.11.2026' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith('2026-04-11');
});
});
@@ -4,6 +4,8 @@ import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from '../../i18n';
import { dateInputPattern } from '../../utils/dateInputPattern';
import { type DateField, dateFieldOrder, dateLayout, formatDisplayDate } from '../../utils/dateFormat';
import { useDateFormat } from '../../hooks/useDateFormat';
import { useRemeasureSignal } from '../../hooks/useAnchoredPosition';
import { useWeekStartDay } from '../../hooks/useWeekStartDay';
import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek';
@@ -27,6 +29,8 @@ interface CustomDatePickerProps {
// disabled in the calendar and rejected on manual entry.
min?: string;
max?: string;
/** What the field is, for a picker without a visible label around it; the trigger reads it with its date. */
ariaLabel?: string;
}
export function CustomDatePicker({
@@ -39,9 +43,11 @@ export function CustomDatePicker({
disabled = false,
min,
max,
ariaLabel,
}: CustomDatePickerProps) {
const { locale, t } = useTranslation();
const weekStart = useWeekStartDay();
const dateFormat = useDateFormat();
const [open, setOpen] = useState(false);
const [view, setView] = useState<CalendarView>('days');
const [yearPageStart, setYearPageStart] = useState(0);
@@ -149,22 +155,17 @@ export function CustomDatePicker({
);
const years = Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i);
const displayValue = parsed
? parsed.toLocaleDateString(
locale,
compact
? { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' }
: { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }
)
: null;
// A chosen date format (#2971) writes the field in numbers, the whole year
// included; 'auto' keeps the language's short month as before.
const fieldOptions: Intl.DateTimeFormatOptions = dateLayout(dateFormat)
? { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }
: compact
? { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' }
: { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' };
const displayValue = parsed ? formatDisplayDate(parsed, locale, fieldOptions, dateFormat) : null;
const inputValue = parsed
? parsed.toLocaleDateString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
})
? formatDisplayDate(parsed, locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }, dateFormat)
: '';
// Inclusive range check on ISO (YYYY-MM-DD) strings — lexicographic order
@@ -212,13 +213,11 @@ export function CustomDatePicker({
return;
}
// Determine field order for active locale via Intl
const parts = new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'numeric', year: 'numeric' }).formatToParts(
new Date(2001, 5, 15)
); // known date: June 15, 2001
const order = parts
.filter((p) => ['day', 'month', 'year'].includes(p.type))
.map((p) => p.type as 'day' | 'month' | 'year');
// The order of the chosen date format (#2971), or the active locale's. A
// four-digit first number is a year in every format, so 2026/11/04 or
// 2026.11.04 reads as year, month, day whichever format is set.
const yearFirst = /^\D*\d{4}\D/.test(input);
const order: readonly DateField[] = yearFirst ? ['year', 'month', 'day'] : dateFieldOrder(locale, dateFormat);
// Strip non-numeric chars (handles RTL marks, dots, slashes, spaces)
const nums = input
@@ -277,7 +276,7 @@ export function CustomDatePicker({
if (e.key === 'Escape') setIsTyping(false);
}}
onBlur={handleTextSubmit}
placeholder={dateInputPattern(locale)}
placeholder={dateInputPattern(locale, dateFormat)}
aria-label={t('common.datepicker.enterManually')}
style={{
width: '100%',
@@ -302,7 +301,11 @@ export function CustomDatePicker({
type="button"
onClick={() => setOpen((o) => !o)}
disabled={disabled}
aria-label={displayValue || placeholder || t('common.date')}
aria-label={
ariaLabel
? [ariaLabel, displayValue].filter(Boolean).join(': ')
: displayValue || placeholder || t('common.date')
}
aria-expanded={open}
aria-haspopup="dialog"
style={{
@@ -700,9 +703,12 @@ interface CustomDateTimePickerProps {
onChange: (value: string) => void;
placeholder?: string;
style?: React.CSSProperties;
/** Earliest and latest day (YYYY-MM-DD), as CustomDatePicker takes them. */
min?: string;
max?: string;
}
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }: CustomDateTimePickerProps) {
export function CustomDateTimePicker({ value, onChange, placeholder, style = {}, min, max }: CustomDateTimePickerProps) {
const { locale } = useTranslation();
const [datePart, timePart] = (value || '').split('T');
@@ -716,7 +722,13 @@ export function CustomDateTimePicker({ value, onChange, placeholder, style = {}
return (
<div style={{ display: 'flex', gap: 8, ...style }}>
<CustomDatePicker value={datePart || ''} onChange={handleDateChange} style={{ flex: 1, minWidth: 0 }} />
<CustomDatePicker
value={datePart || ''}
onChange={handleDateChange}
style={{ flex: 1, minWidth: 0 }}
min={min}
max={max}
/>
<div style={{ width: 110, flexShrink: 0 }}>
<CustomTimePicker value={timePart || ''} onChange={handleTimeChange} />
</div>
+33
View File
@@ -0,0 +1,33 @@
import { DEFAULT_DATE_FORMAT, type DateFormat } from '@trek/shared';
import { useCallback } from 'react';
import { useTranslation } from '../i18n';
import { useSettingsStore } from '../store/settingsStore';
import { formatDisplayDate, formatDisplayDateTime } from '../utils/dateFormat';
/** The current user's date format (#2971); 'auto' follows the interface language. */
export function useDateFormat(): DateFormat {
return useSettingsStore((s) => s.settings.date_format) ?? DEFAULT_DATE_FORMAT;
}
/** The same value outside React (stores, exports, plain helpers), read when called. */
export function readDateFormat(): DateFormat {
return useSettingsStore.getState().settings.date_format ?? DEFAULT_DATE_FORMAT;
}
/**
* `toLocaleDateString` and `toLocaleString` bound to the interface language and
* the user's date format, for components that write dates.
*/
export function useFormatDate() {
const { locale } = useTranslation();
const format = useDateFormat();
const date = useCallback(
(value: Date, opts?: Intl.DateTimeFormatOptions) => formatDisplayDate(value, locale, opts, format),
[locale, format]
);
const dateTime = useCallback(
(value: Date, opts?: Intl.DateTimeFormatOptions) => formatDisplayDateTime(value, locale, opts, format),
[locale, format]
);
return { formatDate: date, formatDateTime: dateTime, locale, dateFormat: format };
}
+12 -5
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import { bookedInTrip, convertBooked, convertedLine, crossRate, fetchExchangeRates, fetchFreshRates, freshRates, clearExchangeRateCache, tripAmountOf, useExchangeRates, withFallbackFx } from './useExchangeRates'
import { bookedInTrip, convertBooked, convertedLine, inDisplayCurrency, crossRate, fetchExchangeRates, fetchFreshRates, freshRates, clearExchangeRateCache, tripAmountOf, useExchangeRates, withFallbackFx } from './useExchangeRates'
const FX_URL = 'https://api.frankfurter.dev/v2/rates'
@@ -182,10 +182,9 @@ describe('convertBooked', () => {
expect(convertBooked(100, 'eur', 1, 'EUR', live)).toBe(100)
})
// #2525: a euro trip read in dollars, the bill entered in dollars. It was booked at 685.26
// EUR, and that is what the balances and settle-up net and then show at today's rate. The
// row has to show the same figure, or paying back the "you lent" it printed reopens the trip.
it('reads an amount in the display currency through the trip currency too', () => {
// #2525: a euro trip read in dollars, the bill entered in dollars. Without the display
// currency the row goes through the trip currency at its frozen rate, as before.
it('reads an amount through the trip currency when no display currency is given', () => {
const liveUsd = (amount: number, from: string | null | undefined): number => {
const rates: Record<string, number> = { USD: 1, EUR: 1 / 1.1398 }
const r = rates[(from || 'USD').toUpperCase()]
@@ -197,6 +196,14 @@ describe('convertBooked', () => {
// Without a booked rate there is nothing to go through, and dollars stay dollars.
expect(convertBooked(801.76, 'USD', 1, 'EUR', liveUsd)).toBe(801.76)
})
// #2965: read in dollars, a bill entered in dollars counts as typed, as the balances net it.
it('knows a row entered in the display currency, a NULL currency being the trip one', () => {
expect(inDisplayCurrency('usd', 'EUR', 'USD')).toBe(true)
expect(inDisplayCurrency(null, 'EUR', 'eur')).toBe(true)
expect(inDisplayCurrency(null, 'EUR', 'USD')).toBe(false)
expect(inDisplayCurrency('USD', 'EUR', 'GBP')).toBe(false)
})
})
describe('bookedInTrip', () => {
+13
View File
@@ -141,6 +141,19 @@ export function convertBooked(
return convertLive(amount, (rowCurrency || trip).toUpperCase())
}
/**
* Whether a row was entered in the display currency (#2965). Such a row counts as typed:
* through the trip currency at its frozen rate and back at today's it drifted off the
* amount entered, and the balances, which net such rows as typed, would disagree.
*/
export function inDisplayCurrency(
rowCurrency: string | null | undefined,
tripCurrency: string,
displayCurrency: string,
): boolean {
return ((rowCurrency || tripCurrency) || '').toUpperCase() === (displayCurrency || '').toUpperCase()
}
/**
* The amount a row was booked at in the trip currency, when it was booked through one: a
* foreign currency with a frozen rate. Null for a row in the trip currency, and for one
@@ -5,6 +5,7 @@ import { MapView } from '../../../components/Map/MapView'
import { SYMBOLS, currenciesWith } from '../../../components/Budget/BudgetPanel.constants'
import { getApiErrorMessage, type DistanceUnit, type WeekStart } from '../../../types'
import { weekStartOptions } from '../../../utils/calendarWeek'
import { dateFormatOptions } from '../../../utils/dateFormat'
import { withTileApiKey } from '../../../utils/tileUrl'
import { defaultStyleForProvider } from '../../../components/Map/glProviders'
import type { MapProvider } from '../../../components/Settings/mapSettingsModel'
@@ -19,6 +20,25 @@ import RoutingInstanceFields from '../../../components/Admin/RoutingInstanceFiel
// Mobile-native rebuild of the desktop DefaultUserSettingsTab. Identical data
// layer (adminApi defaults, per-change auto-save, reset-to-built-in) — only the
// presentation is relaid on the admin mobile design system.
interface ResetProps {
shown: boolean
onReset: () => void
label: string
}
function ResetButton({ shown, onReset, label }: ResetProps): React.ReactElement | null {
if (!shown) return null
return (
<button
type="button"
onClick={onReset}
className="ms-2 font-geist text-[0.625rem] font-medium text-m-faint underline"
>
{label}
</button>
)
}
export default function MAdminDefaultUserSettings(): React.ReactElement {
const { t, locale } = useTranslation()
const {
@@ -27,19 +47,15 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
} = useDefaultUserSettings({ errorMessage: getApiErrorMessage, previewCreatedAt: () => new Date().toISOString() })
const [currencyOpen, setCurrencyOpen] = useState(false)
const [dateFormatOpen, setDateFormatOpen] = useState(false)
const [presetOpen, setPresetOpen] = useState(false)
const [styleOpen, setStyleOpen] = useState(false)
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button
type="button"
onClick={() => reset(field)}
className="ms-2 font-geist text-[0.625rem] font-medium text-m-faint underline"
>
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
) : null
const resetOf = (field: keyof Defaults): ResetProps => ({
shown: isSet(field),
onReset: () => reset(field),
label: t('admin.defaultSettings.resetToBuiltIn'),
})
if (!loaded) {
return (
@@ -90,6 +106,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
const tilePresetLabel = presets.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')
const stylePresetLabel = glStylePresets.find((p) => p.url === mapboxStyle)?.name || t('admin.defaultSettings.mapboxStylePlaceholder')
const chevron = <ChevronDown size={13} strokeWidth={2} className="flex-none text-m-faint" />
const dateFormats = dateFormatOptions(locale, t('settings.dateFormatAuto'))
return (
<div className="space-y-3">
@@ -99,33 +116,42 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
<div className="mt-2 space-y-[14px]">
{/* Color Mode */}
<MAdminField label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
<MAdminField label={<>{t('settings.colorMode')} <ResetButton {...resetOf('dark_mode')} /></>}>
<MSegmented value={colorModeValue} onChange={(v) => save({ dark_mode: v })} options={modeOptions} />
</MAdminField>
{/* Temperature */}
<MAdminField label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
<MAdminField label={<>{t('settings.temperature')} <ResetButton {...resetOf('temperature_unit')} /></>}>
<MSegmented value={defaults.temperature_unit || ''} onChange={(v) => save({ temperature_unit: v })} options={tempOptions} />
</MAdminField>
{/* Distance */}
<MAdminField label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
<MAdminField label={<>{t('settings.distance')} <ResetButton {...resetOf('distance_unit')} /></>}>
<MSegmented value={defaults.distance_unit || ''} onChange={(v) => save({ distance_unit: v as DistanceUnit })} options={distanceOptions} />
</MAdminField>
{/* Time Format */}
<MAdminField label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
<MAdminField label={<>{t('settings.timeFormat')} <ResetButton {...resetOf('time_format')} /></>}>
<MSegmented value={defaults.time_format || ''} onChange={(v) => save({ time_format: v })} options={timeOptions} />
</MAdminField>
{/* Week start (#2029) */}
<MAdminField label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
<MAdminField label={<>{t('settings.weekStart')} <ResetButton {...resetOf('week_start')} /></>}>
<MSegmented value={defaults.week_start || ''} onChange={(v) => save({ week_start: v as WeekStart })} options={weekStartOptions(locale)} />
</MAdminField>
{/* Date format (#2971) */}
<MAdminField label={<>{t('settings.dateFormat')} <ResetButton {...resetOf('date_format')} /></>}>
<MSetSelectRow
label={dateFormats.find((o) => o.value === defaults.date_format)?.label ?? t('settings.dateFormat')}
trailing={chevron}
onClick={() => setDateFormatOpen(true)}
/>
</MAdminField>
{/* Default Currency */}
<MAdminField
label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}
label={<>{t('settings.currency')} <ResetButton {...resetOf('default_currency')} /></>}
hint={t('settings.currencyHint')}
>
<MSetSelectRow label={currencyLabel} trailing={chevron} onClick={() => setCurrencyOpen(true)} />
@@ -134,7 +160,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
{/* Blur Booking Codes */}
<MAdminRow
first
title={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}
title={<>{t('settings.blurBookingCodes')} <ResetButton {...resetOf('blur_booking_codes')} /></>}
trailing={
<MToggle
checked={defaults.blur_booking_codes === true}
@@ -156,7 +182,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
<div className="mt-2 space-y-[14px]">
{/* Map Tile URL */}
<MAdminField
label={<>{t('settings.mapTemplate')} <ResetButton field="map_tile_url" /></>}
label={<>{t('settings.mapTemplate')} <ResetButton {...resetOf('map_tile_url')} /></>}
hint={t('settings.mapDefaultHint')}
>
<MSetSelectRow label={tilePresetLabel} trailing={chevron} onClick={() => setPresetOpen(true)} />
@@ -174,7 +200,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
settings are read. A field here would only let somebody save a worse one. */}
{!managed && (
<MAdminField
label={<>{t('admin.defaultSettings.cartoKey')} <ResetButton field="carto_api_key" /></>}
label={<>{t('admin.defaultSettings.cartoKey')} <ResetButton {...resetOf('carto_api_key')} /></>}
hint={t('admin.defaultSettings.cartoKeyHint')}
>
<MAdminInput
@@ -218,7 +244,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
{/* Map provider */}
<div className="border-t border-[color:var(--m-rowbr)] pt-[14px]">
<MAdminField
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton {...resetOf('map_provider')} /></>}
hint={t('admin.defaultSettings.mapProviderHint')}
>
<MSegmented value={mapProvider} onChange={(v) => saveMapProvider(v as MapProvider)} options={providerOptions} />
@@ -229,7 +255,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
<div className="space-y-[14px]">
{mapProvider === 'mapbox-gl' && !managed && (
<MAdminField
label={<>{t('admin.defaultSettings.mapboxToken')} <ResetButton field="mapbox_access_token" /></>}
label={<>{t('admin.defaultSettings.mapboxToken')} <ResetButton {...resetOf('mapbox_access_token')} /></>}
hint={t('admin.defaultSettings.mapboxTokenHint')}
>
<MAdminInput
@@ -244,7 +270,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
</MAdminField>
)}
<MAdminField label={<>{t('admin.defaultSettings.mapboxStyle')} <ResetButton field={styleKey} /></>}>
<MAdminField label={<>{t('admin.defaultSettings.mapboxStyle')} <ResetButton {...resetOf(styleKey)} /></>}>
<MSetSelectRow label={stylePresetLabel} trailing={chevron} onClick={() => setStyleOpen(true)} />
<MAdminInput
type="text"
@@ -260,7 +286,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
<div>
<MAdminRow
first
title={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}
title={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton {...resetOf('mapbox_3d_enabled')} /></>}
trailing={
<MToggle
checked={defaults.mapbox_3d_enabled ?? true}
@@ -270,7 +296,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
}
/>
<MAdminRow
title={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}
title={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton {...resetOf('mapbox_quality_mode')} /></>}
trailing={
<MToggle
checked={defaults.mapbox_quality_mode ?? false}
@@ -287,6 +313,15 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
</MAdminCard>
{/* Pickers */}
<MSetPickerSheet
open={dateFormatOpen}
onClose={() => setDateFormatOpen(false)}
title={t('settings.dateFormat')}
value={defaults.date_format || ''}
onSelect={(value) => { if (value) void save({ date_format: value as Defaults['date_format'] }) }}
options={dateFormats}
/>
<MSetPickerSheet
open={currencyOpen}
onClose={() => setCurrencyOpen(false)}
@@ -1,6 +1,7 @@
import { Key, Trash2, User, Loader2, Shield } from 'lucide-react'
import { SCOPES_PREVIEW, useMcpTokensAdmin } from '../../../components/Admin/useMcpTokensAdmin'
import { useTranslation } from '../../../i18n'
import { useFormatDate } from '../../../hooks/useDateFormat'
import { MAdminCard } from './MAdminUi'
import MConfirmSheet from '../settings/MConfirmSheet'
@@ -13,7 +14,8 @@ export default function MAdminMcpTokensPanel() {
sessions, sessionsLoading, tokens, tokensLoading, expandedScopes, toggleScopes,
revokeConfirmId, setRevokeConfirmId, deleteConfirmId, setDeleteConfirmId, handleRevoke, handleDelete,
} = useMcpTokensAdmin()
const { t, locale } = useTranslation()
const { t } = useTranslation()
const { formatDate } = useFormatDate()
return (
<div className="space-y-3">
@@ -58,7 +60,7 @@ export default function MAdminMcpTokensPanel() {
{session.username}
</span>
<span className="text-m-faint">·</span>
<span>{new Date(session.created_at).toLocaleDateString(locale)}</span>
<span>{formatDate(new Date(session.created_at))}</span>
</div>
<div className="mt-[6px] flex flex-wrap gap-1">
{visible.map(scope => (
@@ -135,11 +137,11 @@ export default function MAdminMcpTokensPanel() {
{token.username}
</span>
<span className="text-m-faint">·</span>
<span>{new Date(token.created_at).toLocaleDateString(locale)}</span>
<span>{formatDate(new Date(token.created_at))}</span>
<span className="text-m-faint">·</span>
<span>
{token.last_used_at
? new Date(token.last_used_at).toLocaleDateString(locale)
? formatDate(new Date(token.last_used_at))
: t('admin.mcpTokens.never')}
</span>
</div>
@@ -1,4 +1,6 @@
import { useState } from 'react'
import { useFormatDate } from '../../../hooks/useDateFormat'
import MPluginVersionDate from './MPluginVersionDate'
import {
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
@@ -311,7 +313,7 @@ export default function MAdminPluginsPanel() {
{/* Version picker sheet */}
{versionPicker && (
<VersionPickerSheet plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
busy={busy} t={t} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
)}
{/* Downgrade confirm */}
@@ -806,9 +808,9 @@ function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onSetti
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
* explained and never offered.
*/
function VersionPickerSheet({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
function VersionPickerSheet({ plugin, versions, failed, busy, t, onPick, onClose }: {
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
t: T; onPick: (version: string) => void; onClose: () => void
}) {
return (
<MSheet open onClose={onClose} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.versionPickerTitle', { name: plugin.name })}>
@@ -823,7 +825,7 @@ function VersionPickerSheet({ plugin, versions, failed, busy, t, locale, onPick,
<div key={v.version} data-testid={`version-row-${v.version}`}
className="flex items-center gap-2.5 rounded-xl px-2.5 py-2.5">
<span className={`text-[13px] font-semibold tabular-nums ${v.compatible ? 'text-m-ink' : 'text-m-faint'}`}>v{v.version}</span>
{v.publishedAt && <span className="text-[11.5px] text-m-faint">{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
<MPluginVersionDate at={v.publishedAt} className="text-[11.5px] text-m-faint" />
{v.signed && <ShieldCheck size={13} className="shrink-0 text-[color:var(--m-st-confirmed)]" />}
<span className="ms-auto" />
{current ? (
@@ -956,6 +958,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local
item: RegistryItem; installed: boolean; busy: string | null; ignoreTrekRange: boolean
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
}) {
const { formatDate: dateOf } = useFormatDate()
const { detail, failed } = usePluginDetail<MRegistryDetail>(item.id)
const manifest = detail?.manifest ?? null
@@ -1109,7 +1112,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local
{(item.trek || item.minTrekVersion) && (
<Meta k={t('admin.plugins.metaRequires')} v={item.trek ? `TREK ${item.trek}` : `TREK ${item.minTrekVersion}+`} />
)}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={new Date(item.reviewedAt).toLocaleDateString(locale)} />}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={dateOf(new Date(item.reviewedAt))} />}
{typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
<Meta k={t('admin.plugins.downloads')} v={item.downloadCount.toLocaleString(locale)} />
)}
@@ -1125,7 +1128,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local
{detail!.versions!.map(v => (
<div key={v.version} className="flex items-center gap-2.5 py-1">
<span className={`text-[12.5px] font-medium tabular-nums ${v.compatible ? 'text-m-ink' : 'text-m-faint'}`}>v{v.version}</span>
{v.publishedAt && <span className="text-[11.5px] text-m-faint">{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
<MPluginVersionDate at={v.publishedAt} className="text-[11.5px] text-m-faint" />
{v.signed && <ShieldCheck size={13} className="shrink-0 text-[color:var(--m-st-confirmed)]" />}
<span className="ms-auto" />
{v.compatible ? (
@@ -0,0 +1,8 @@
import { useFormatDate } from '../../../hooks/useDateFormat';
/** The day a plugin version was published, in the user's date format (#2971). */
export default function MPluginVersionDate({ at, className }: { at?: string | null; className?: string }) {
const { formatDate } = useFormatDate();
if (!at) return null;
return <span className={className}>{formatDate(new Date(at))}</span>;
}
@@ -1,6 +1,7 @@
import React from 'react'
import { Plug, Save, RefreshCw, Unplug } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import { useFormatDate } from '../../../hooks/useDateFormat'
import { useDawarichConnection } from '../../../hooks/useDawarichConnection'
import { MSetCard, MSetRow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
import MToggle from '../../components/MToggle'
@@ -14,7 +15,8 @@ import MToggle from '../../components/MToggle'
* sync logic would spend the whole budget on its own.
*/
export default function MDawarichConnectionSection(): React.ReactElement {
const { t, locale } = useTranslation()
const { t } = useTranslation()
const { formatDateTime } = useFormatDate()
const S = useDawarichConnection()
return (
@@ -92,7 +94,7 @@ export default function MDawarichConnectionSection(): React.ReactElement {
{S.connected && (
<p className="font-geist text-[0.625rem] leading-relaxed text-m-muted">
{S.lastSyncAt
? t('dawarich.lastSync', { when: new Date(S.lastSyncAt).toLocaleString(locale) })
? t('dawarich.lastSync', { when: formatDateTime(new Date(S.lastSyncAt)) })
: t('dawarich.neverSynced')}
</p>
)}
@@ -6,8 +6,9 @@ import { SYMBOLS, currenciesWith } from '../../../components/Budget/BudgetPanel.
import { TRIP_TAB_IDS, TRIP_TAB_LABEL_KEYS, isTripTabId } from '../../../constants/tripTabs'
import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, type StartPage } from '../../../utils/startDestination'
import type { Settings, DistanceUnit, WeekStart } from '../../../types'
import { DEFAULT_WEEK_START } from '@trek/shared'
import { DEFAULT_DATE_FORMAT, DEFAULT_WEEK_START } from '@trek/shared'
import { weekStartOptions } from '../../../utils/calendarWeek'
import { dateFormatOptions } from '../../../utils/dateFormat'
import { MSetCard, MSetEyebrow, MSetHint, MSetSelectRow, MSetSegments, MSetRow } from './MSettingsUi'
import MToggle from '../../components/MToggle'
import MSetPickerSheet from './MSetPickerSheet'
@@ -27,6 +28,7 @@ export default function MSettingsGeneral() {
const [langOpen, setLangOpen] = useState(false)
const [startTabOpen, setStartTabOpen] = useState(false)
const [navAppOpen, setNavAppOpen] = useState(false)
const [dateFormatOpen, setDateFormatOpen] = useState(false)
const navAppOptions = preferredNavAppOptions(t)
const navAppValue = settings.preferred_nav_app || ''
@@ -37,6 +39,7 @@ export default function MSettingsGeneral() {
const language = SUPPORTED_LANGUAGES.find((l) => l.value === settings.language) || SUPPORTED_LANGUAGES[0]
const chevron = <ChevronDown size={13} strokeWidth={2} className="flex-none text-m-faint" />
const dateFormats = dateFormatOptions(locale, t('settings.dateFormatAuto'))
const travelRows: {
key: keyof Settings
@@ -166,6 +169,15 @@ export default function MSettingsGeneral() {
onChange={(v) => save('week_start', v)}
options={weekStartOptions(locale)}
/>
{/* Date format (#2971) */}
<MSetEyebrow className="mb-[6px] mt-[14px]">{t('settings.dateFormat')}</MSetEyebrow>
<MSetSelectRow
label={dateFormats.find((o) => o.value === (settings.date_format || DEFAULT_DATE_FORMAT))?.label ?? ''}
trailing={chevron}
onClick={() => setDateFormatOpen(true)}
/>
<MSetHint>{t('settings.dateFormatHint')}</MSetHint>
</MSetCard>
<MSetCard title={t('settings.general.travelMap')} icon={Map} className="mt-3">
@@ -206,6 +218,17 @@ export default function MSettingsGeneral() {
]}
/>
<MSetPickerSheet
open={dateFormatOpen}
onClose={() => setDateFormatOpen(false)}
title={t('settings.dateFormat')}
value={settings.date_format || DEFAULT_DATE_FORMAT}
onSelect={(v) => {
void save('date_format', v)
}}
options={dateFormats}
/>
<MSetPickerSheet
open={langOpen}
onClose={() => setLangOpen(false)}
@@ -1,5 +1,6 @@
import { Check, ChevronDown, ChevronRight, Copy, KeyRound, Plus, RefreshCw, Terminal, Trash2 } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import { useFormatDate } from '../../../hooks/useDateFormat'
import { OAUTH_PRESETS, useMcpIntegration } from '../../../components/Settings/useMcpIntegration'
import MScopeGroupPicker from './MScopeGroupPicker'
import MSheet from '../../components/MSheet'
@@ -38,7 +39,8 @@ function CodeBlock({ children }: { children: string }) {
* tokens, with the desktop modals as floating sheets.
*/
export default function MSettingsMcp() {
const { t, locale } = useTranslation()
const { t } = useTranslation()
const { formatDate } = useFormatDate()
const demoLocked = useDemoAccountLock()
const {
activeMcpTab: activeTab,
@@ -190,7 +192,7 @@ export default function MSettingsMcp() {
{t('settings.oauth.clientId')}: {client.client_id}
</p>
<p className="mt-[2px] font-geist text-[0.625rem] text-m-faint">
{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}
{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(client.created_at))}
</p>
<div className="mt-[6px] flex flex-wrap gap-1">
{(scopesExpanded[client.id] ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map((s) => (
@@ -226,7 +228,7 @@ export default function MSettingsMcp() {
{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}
</p>
<p className="font-geist text-[0.625rem] text-m-faint">
{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}
{t('settings.oauth.sessionExpires')} {formatDate(new Date(session.access_token_expires_at))}
</p>
</div>
<MSetButton variant="danger" onClick={() => setRevokeId(session.id)}>
@@ -272,11 +274,11 @@ export default function MSettingsMcp() {
<p className="mt-[2px] font-mono text-[0.625rem] text-m-faint">
{token.token_prefix}...
<span className="ms-2 font-geist">
{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}
{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(token.created_at))}
</span>
{token.last_used_at && (
<span className="font-geist">
, {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}
, {t('settings.mcp.tokenUsedAt')} {formatDate(new Date(token.last_used_at))}
</span>
)}
</p>
@@ -8,8 +8,10 @@ import MDancingTrek from '../../../components/MDancingTrek'
import { useCollabPolls } from '../../../../components/Collab/useCollabPolls'
import { useAuthStore } from '../../../../store/authStore'
import ToggleSwitch from '../../../../components/Settings/ToggleSwitch'
import { CustomDateTimePicker } from '../../../../components/shared/CustomDateTimePicker'
import { localIsoDate } from '../../../../utils/localDate'
import MSheet from '../../../components/MSheet'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import { Eyebrow, FIELD_AREA_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import MConfirmSheet from '../../settings/MConfirmSheet'
import type { TripPlanner } from '../MTripShell'
import { SectionHeader, TabScroller } from './tabChrome'
@@ -338,8 +340,6 @@ function PollFormSheet({ open, onClose, onSubmit, t }: {
}
}
const minDeadline = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16)
return (
<MSheet open={open} onClose={onClose} ariaLabel={t('collab.polls.new')}>
<FormSheetHeader icon={BarChart3} title={t('collab.polls.new')} onClose={onClose} closeLabel={t('common.close')} />
@@ -396,12 +396,11 @@ function PollFormSheet({ open, onClose, onSubmit, t }: {
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('collab.polls.deadline')}</Eyebrow>
<div className="flex items-center gap-[6px]">
<input
type="datetime-local"
<CustomDateTimePicker
value={deadline}
min={minDeadline}
onChange={e => setDeadline(e.target.value)}
className={`${FIELD_CLS} flex-1`}
onChange={setDeadline}
min={localIsoDate()}
style={{ flex: 1, minWidth: 0 }}
/>
{deadline && (
<button
@@ -3,6 +3,7 @@ import { ArrowRightLeft, Building2, Calendar, CalendarRange, CalendarX, ChevronD
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle'
import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
import { useVacayStore } from '../../../store/vacayStore'
import { useTranslation } from '../../../i18n'
@@ -338,13 +339,14 @@ function YearTypeRows() {
{type === 'anniversary' && (
<div className="ms-7">
<input
type="date"
value={yearSettings.hire_date || ''}
onChange={e => save({ hire_date: e.target.value || null })}
aria-label={t('vacay.hireDate')}
className="mt-[7px] w-full rounded-[10px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] px-[11px] py-[9px] font-[inherit] text-[0.78125rem] font-semibold text-m-ink outline-none"
/>
<div className="mt-[7px]">
<CustomDatePicker
value={yearSettings.hire_date || ''}
onChange={v => save({ hire_date: v || null })}
placeholder={t('vacay.hireDate')}
ariaLabel={t('vacay.hireDate')}
/>
</div>
</div>
)}
+4 -2
View File
@@ -20,6 +20,7 @@ import HelpAnchor from '../components/Help/HelpAnchor'
import AtlasCountryPlaces from '../components/Atlas/AtlasCountryPlaces'
import { AtlasTourActivityPane } from '../components/Atlas/tourActivity/AtlasTourActivityPane'
import { useAddonStore } from '../store/addonStore'
import { useFormatDate } from '../hooks/useDateFormat'
import { getIntlLanguage } from '@trek/shared'
// Fixed ids rather than useId: the page body stays free of hooks, and there is only one atlas.
@@ -448,6 +449,7 @@ interface SidebarContentProps {
function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPlaces, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onClearBucketVisit, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
const { language } = useTranslation()
const { formatDate } = useFormatDate()
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
const statsContentRef = useRef<HTMLDivElement>(null)
const bucketSearchRowRef = useRef<HTMLDivElement>(null)
@@ -548,7 +550,7 @@ ${t('dawarich.bucket.clearVisit')}`
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', background: 'none', border: 'none', padding: 0, cursor: 'pointer', fontFamily: 'inherit' }}
>
<Check size={9} />
{new Date(item.visited_at).toLocaleDateString(getIntlLanguage(language))}
{formatDate(new Date(item.visited_at))}
</button>
) : (
<span
@@ -556,7 +558,7 @@ ${t('dawarich.bucket.clearVisit')}`
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))' }}
>
<Check size={9} />
{new Date(item.visited_at).toLocaleDateString(getIntlLanguage(language))}
{formatDate(new Date(item.visited_at))}
</span>
)}
</Tooltip>
+7 -7
View File
@@ -664,10 +664,10 @@ describe('SharedTripPage', () => {
await waitFor(() => expect(screen.getAllByText('12,345.67 USD')).toHaveLength(4));
});
it('reads a bill in the display currency through the trip currency too, beside what was entered', async () => {
// The owner reads in USD on a CHF trip. The USD bill was booked at 685.26 CHF,
// which today is 780.86 USD, the figure Costs and its balances use; the CHF one
// converts at today's rate (100 CHF = 113.95 USD).
it('reads a bill in the display currency as typed, as Costs does (#2965)', async () => {
// The owner reads in USD on a CHF trip. The USD bill reads as the 801.76 USD
// typed, as Costs and its balances count it; the CHF one converts at today's
// rate (100 CHF = 113.95 USD).
server.use(
http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([{ quote: 'CHF', rate: 1 / 1.1395 }])),
http.get('/api/shared/:token', ({ params }) => {
@@ -692,9 +692,9 @@ describe('SharedTripPage', () => {
fireEvent.click(screen.getByRole('button', { name: /costs/i }));
await waitFor(() => expect(screen.getAllByText('113.95 USD')).toHaveLength(3));
expect(screen.getAllByText('780.86 USD')).toHaveLength(3);
expect(screen.getByText('894.81 USD')).toBeInTheDocument();
expect(screen.getByText('801.76 USD')).toBeInTheDocument();
expect(screen.getAllByText('801.76 USD').length).toBeGreaterThan(0);
expect(screen.queryByText('780.86 USD')).toBeNull();
expect(screen.getByText('915.71 USD')).toBeInTheDocument();
expect(screen.getByText('100.00 CHF')).toBeInTheDocument();
});
});
+4 -1
View File
@@ -10,6 +10,8 @@ import { SettingsCard, SettingRows, SettingsHint, StatusPill, SETTINGS_BUTTON, S
import type { TranslationFn } from '../../types'
import type { useAdmin } from './useAdmin'
import { formatLastActive, inviteMeta, inviteStatus } from './adminModel'
import { useDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
interface AdminUsersTabProps {
admin: ReturnType<typeof useAdmin>
@@ -36,6 +38,7 @@ function RowIconButton({ label, onClick, disabled, danger, children }: { label:
// "Users" admin tab: user table, invite links, permissions panel + the
// create-invite modal. Pure layout around the useAdmin hook — no logic of its own.
export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): React.ReactElement {
const dateFormat = useDateFormat()
const {
hour12, currentUser,
users, isLoading,
@@ -112,7 +115,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
: <StatusPill>{t('settings.roleUser')}</StatusPill>}
</td>
<td className={`${TD} whitespace-nowrap font-geist tabular-nums text-content-muted`} style={fs(12)}>
{new Date(u.created_at).toLocaleDateString(locale)}
{formatDisplayDate(new Date(u.created_at), locale, {}, dateFormat)}
</td>
<td className={`${TD} whitespace-nowrap font-geist tabular-nums text-content-muted`} style={fs(12)}>
{/* Activity, not only sign-ins: a renewing cookie keeps people signed in for weeks. */}
+3 -1
View File
@@ -1,4 +1,6 @@
/** Shared types and pure helpers for the admin page + its data hook. No React, no side effects. */
import { readDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
export interface AdminUser {
id: number
@@ -87,7 +89,7 @@ export function inviteMeta(
locale: string,
): string {
const parts = [`${invite.used_count}/${invite.max_uses === 0 ? '∞' : invite.max_uses} ${t('admin.invite.uses')}`]
if (invite.expires_at) parts.push(`${t('admin.invite.expiresAt')} ${new Date(invite.expires_at).toLocaleDateString(locale)}`)
if (invite.expires_at) parts.push(`${t('admin.invite.expiresAt')} ${formatDisplayDate(new Date(invite.expires_at), locale, {}, readDateFormat())}`)
if (invite.trip_title) parts.push(t('admin.invite.boundTo', { trip: invite.trip_title }))
parts.push(`${t('admin.invite.createdBy')} ${invite.created_by_name ?? ''}`)
return parts.join(', ')
@@ -3,7 +3,7 @@ import { catMeta } from '../../components/Budget/costsCategories'
import { fs, NEUTRAL_TINT } from '../../components/shared/DialogShell'
import { CountPill } from '../../components/Planner/bookings/bookingParts'
import { TripMemberAvatar } from '../../components/Trips/TripMemberAvatar'
import { convertBooked, convertedLine } from '../../hooks/useExchangeRates'
import { convertBooked, convertedLine, inDisplayCurrency } from '../../hooks/useExchangeRates'
import { useTranslation } from '../../i18n'
import { avatarSrc } from '../../utils/avatarSrc'
import { currencyDecimals } from '../../utils/formatters'
@@ -92,7 +92,11 @@ export function SharedCostsView({ items, base, tripCurrency, convert }: CostsPro
const { t, locale } = useTranslation()
if (items.length === 0) return <EmptySection icon={<Wallet size={22} />} text={t('shared.emptyCosts')} />
const amountOf = (i: Expense) => Number.parseFloat(String(i.total_price ?? '')) || 0
const valueOf = (i: Expense) => convertBooked(amountOf(i), i.currency, i.exchange_rate, tripCurrency, convert)
// A bill in the owner's display currency reads as typed, as in Costs (#2965).
const valueOf = (i: Expense) =>
inDisplayCurrency(i.currency, tripCurrency, base)
? amountOf(i)
: convertBooked(amountOf(i), i.currency, i.exchange_rate, tripCurrency, convert)
// In the reader's own number format with the currency's code: a public page has
// no idea where its reader is, and "$" alone does not say which dollar.
// Whole cents: a converted amount otherwise printed a third decimal.
+2 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'
import { settingsApi } from '../api/client'
import type { Settings } from '../types'
import { DEFAULT_APPEARANCE, DEFAULT_WEEK_START } from '@trek/shared'
import { DEFAULT_APPEARANCE, DEFAULT_DATE_FORMAT, DEFAULT_WEEK_START } from '@trek/shared'
import { getApiErrorMessage } from '../types'
import { SUPPORTED_LANGUAGE_CODES } from '../i18n/supportedLanguages'
import { detectBrowserLanguage } from '../i18n/detectBrowserLanguage'
@@ -88,6 +88,7 @@ export const DEFAULT_SETTINGS: Settings = {
distance_unit: 'metric',
time_format: '24h',
week_start: DEFAULT_WEEK_START,
date_format: DEFAULT_DATE_FORMAT,
show_place_description: false,
optimize_from_accommodation: true,
map_provider: 'leaflet',
+4
View File
@@ -32,6 +32,7 @@ import type {
AppearanceConfig,
ValhallaProvider,
WeekStart,
DateFormat,
} from '@trek/shared'
export type {
@@ -57,6 +58,7 @@ export type {
Category,
AppearanceConfig,
WeekStart,
DateFormat,
}
export interface User {
@@ -143,6 +145,8 @@ export interface Settings {
time_format: string
/** First column of every date picker (#2029); missing means Monday. */
week_start?: WeekStart
/** How dates are written in numbers (#2971); missing means the language's own format. */
date_format?: DateFormat
show_place_description: boolean
blur_booking_codes?: boolean
/** Day headings lead with the calendar date and name the trip day second (#1953). */
+228
View File
@@ -0,0 +1,228 @@
// FE-UTIL-DATEFMT-001 to FE-UTIL-DATEFMT-020
import { describe, expect, it } from 'vitest';
import {
dateFieldOrder,
dateFormatOptions,
dateLayout,
displayDateFormatter,
formatDisplayDate,
formatDisplayDateTime,
isNumericDate,
} from './dateFormat';
// 31 December 2026, 15:04 UTC: no day of it reads as a month, so every layout is unambiguous.
const NYE = new Date(Date.UTC(2026, 11, 31, 15, 4));
const UTC = { timeZone: 'UTC' } as const;
const NUMERIC: Intl.DateTimeFormatOptions = { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' };
describe('dateFormat (#2971)', () => {
describe('formatDisplayDate', () => {
it('FE-UTIL-DATEFMT-001: auto, an unknown value or none is exactly toLocaleDateString', () => {
const cases: [string, Intl.DateTimeFormatOptions | undefined][] = [
['en-US', undefined],
['en-US', NUMERIC],
['de-DE', NUMERIC],
['ko-KR', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }],
['en-GB', { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' }],
];
for (const [locale, opts] of cases) {
const expected = NYE.toLocaleDateString(locale, opts);
expect(formatDisplayDate(NYE, locale, opts, 'auto')).toBe(expected);
expect(formatDisplayDate(NYE, locale, opts, 'DD-MM-YY')).toBe(expected);
expect(formatDisplayDate(NYE, locale, opts, null)).toBe(expected);
expect(formatDisplayDate(NYE, locale, opts)).toBe(expected);
}
});
it('FE-UTIL-DATEFMT-002: each format writes 31 December 2026 in its own layout', () => {
expect(formatDisplayDate(NYE, 'en-US', UTC, 'YYYY-MM-DD')).toBe('2026-12-31');
expect(formatDisplayDate(NYE, 'en-US', UTC, 'YYYY/MM/DD')).toBe('2026/12/31');
expect(formatDisplayDate(NYE, 'en-US', UTC, 'DD.MM.YYYY')).toBe('31.12.2026');
expect(formatDisplayDate(NYE, 'en-US', UTC, 'DD/MM/YYYY')).toBe('31/12/2026');
expect(formatDisplayDate(NYE, 'de-DE', UTC, 'MM/DD/YYYY')).toBe('12/31/2026');
});
it('FE-UTIL-DATEFMT-003: the language does not change a chosen format', () => {
for (const locale of ['en-US', 'de-DE', 'ko-KR', 'ar', 'fr-FR']) {
expect(formatDisplayDate(NYE, locale, NUMERIC, 'YYYY-MM-DD')).toBe('2026-12-31');
}
});
it('FE-UTIL-DATEFMT-004: single-digit days and months are padded', () => {
const march = new Date(Date.UTC(2026, 2, 5));
expect(
formatDisplayDate(
march,
'en-US',
{ day: 'numeric', month: 'numeric', year: 'numeric', timeZone: 'UTC' },
'DD.MM.YYYY'
)
).toBe('05.03.2026');
});
it('FE-UTIL-DATEFMT-005: year 2-digit keeps two digits of the year', () => {
const opts: Intl.DateTimeFormatOptions = { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' };
expect(formatDisplayDate(NYE, 'en-US', opts, 'DD.MM.YYYY')).toBe('31.12.26');
expect(formatDisplayDate(NYE, 'en-US', opts, 'YYYY-MM-DD')).toBe('26-12-31');
});
it('FE-UTIL-DATEFMT-006: day and month without a year write only those two, in layout order', () => {
const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'numeric', timeZone: 'UTC' };
expect(formatDisplayDate(NYE, 'en-US', opts, 'DD.MM.YYYY')).toBe('31.12');
expect(formatDisplayDate(NYE, 'en-US', opts, 'YYYY-MM-DD')).toBe('12-31');
expect(formatDisplayDate(NYE, 'en-US', opts, 'MM/DD/YYYY')).toBe('12/31');
});
it('FE-UTIL-DATEFMT-007: a month name or a long dateStyle is left to Intl', () => {
const named: Intl.DateTimeFormatOptions[] = [
{ weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' },
{ day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' },
{ month: 'long', year: 'numeric', timeZone: 'UTC' },
{ dateStyle: 'medium', timeZone: 'UTC' },
];
for (const opts of named) {
expect(formatDisplayDate(NYE, 'de-DE', opts, 'YYYY-MM-DD')).toBe(NYE.toLocaleDateString('de-DE', opts));
}
});
it('FE-UTIL-DATEFMT-021: a weekday beside a numeric date keeps the language, the date takes the format', () => {
const opts: Intl.DateTimeFormatOptions = {
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
};
expect(formatDisplayDate(NYE, 'de-DE', opts, 'YYYY-MM-DD')).toBe(
`${NYE.toLocaleDateString('de-DE', { weekday: 'short', timeZone: 'UTC' })}, 2026-12-31`
);
expect(formatDisplayDate(NYE, 'de-DE', opts, 'auto')).toBe(NYE.toLocaleDateString('de-DE', opts));
});
it('FE-UTIL-DATEFMT-008: dateStyle short counts as numeric', () => {
expect(formatDisplayDate(NYE, 'en-US', { dateStyle: 'short', timeZone: 'UTC' }, 'DD/MM/YYYY')).toBe('31/12/2026');
});
it('FE-UTIL-DATEFMT-009: an invalid date falls back to toLocaleDateString', () => {
const invalid = new Date('nope');
expect(formatDisplayDate(invalid, 'en-US', NUMERIC, 'YYYY-MM-DD')).toBe(
invalid.toLocaleDateString('en-US', NUMERIC)
);
});
});
describe('formatDisplayDateTime', () => {
it('FE-UTIL-DATEFMT-010: auto, an unknown value or none is exactly toLocaleString', () => {
const opts: Intl.DateTimeFormatOptions = { ...NUMERIC, hour: '2-digit', minute: '2-digit' };
for (const locale of ['en-US', 'de-DE']) {
expect(formatDisplayDateTime(NYE, locale, opts, 'auto')).toBe(NYE.toLocaleString(locale, opts));
expect(formatDisplayDateTime(NYE, locale, opts, 'nope')).toBe(NYE.toLocaleString(locale, opts));
expect(formatDisplayDateTime(NYE, locale, UTC)).toBe(NYE.toLocaleString(locale, UTC));
}
});
it('FE-UTIL-DATEFMT-011: the date follows the format, the time of day stays Intl', () => {
const opts: Intl.DateTimeFormatOptions = { ...NUMERIC, hour: '2-digit', minute: '2-digit' };
const time = NYE.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' });
expect(formatDisplayDateTime(NYE, 'en-US', opts, 'YYYY-MM-DD')).toBe(`2026-12-31 ${time}`);
const hour12 = { ...opts, hour12: false };
const time24 = NYE.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'UTC',
});
expect(formatDisplayDateTime(NYE, 'en-US', hour12, 'DD.MM.YYYY')).toBe(`31.12.2026 ${time24}`);
});
it('FE-UTIL-DATEFMT-012: without options it writes the date and the default time', () => {
expect(formatDisplayDateTime(NYE, 'en-US', UTC, 'DD/MM/YYYY')).toBe(
`31/12/2026 ${NYE.toLocaleTimeString('en-US', UTC)}`
);
});
it('FE-UTIL-DATEFMT-013: time-only options are passed through untouched', () => {
const opts: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' };
expect(formatDisplayDateTime(NYE, 'de-DE', opts, 'YYYY-MM-DD')).toBe(NYE.toLocaleString('de-DE', opts));
});
it('FE-UTIL-DATEFMT-014: date-only options give just the date, named dates stay Intl', () => {
expect(formatDisplayDateTime(NYE, 'en-US', NUMERIC, 'YYYY/MM/DD')).toBe('2026/12/31');
const named: Intl.DateTimeFormatOptions = {
weekday: 'short',
day: 'numeric',
month: 'short',
hour: '2-digit',
timeZone: 'UTC',
};
expect(formatDisplayDateTime(NYE, 'en-US', named, 'YYYY-MM-DD')).toBe(NYE.toLocaleString('en-US', named));
});
});
describe('displayDateFormatter', () => {
it('FE-UTIL-DATEFMT-015: auto behaves like Intl.DateTimeFormat', () => {
const intl = new Intl.DateTimeFormat('en-US', NUMERIC);
expect(displayDateFormatter('en-US', NUMERIC, 'auto').format(NYE)).toBe(intl.format(NYE));
expect(displayDateFormatter('en-US', NUMERIC).format(NYE.getTime())).toBe(intl.format(NYE));
});
it('FE-UTIL-DATEFMT-016: numeric dates follow the format, for Date and timestamp alike', () => {
const f = displayDateFormatter('en-US', NUMERIC, 'DD.MM.YYYY');
expect(f.format(NYE)).toBe('31.12.2026');
expect(f.format(NYE.getTime())).toBe('31.12.2026');
const withTime = displayDateFormatter('en-US', { ...NUMERIC, hour: '2-digit', minute: '2-digit' }, 'YYYY-MM-DD');
expect(withTime.format(NYE)).toBe(
`2026-12-31 ${NYE.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })}`
);
});
it('FE-UTIL-DATEFMT-017: a named month is left to Intl', () => {
const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', timeZone: 'UTC' };
expect(displayDateFormatter('de-DE', opts, 'YYYY-MM-DD').format(NYE)).toBe(
new Intl.DateTimeFormat('de-DE', opts).format(NYE)
);
});
});
describe('dateFieldOrder, dateLayout and isNumericDate', () => {
it('FE-UTIL-DATEFMT-018: a chosen format sets the order, auto asks the language', () => {
expect(dateFieldOrder('en-US', 'DD.MM.YYYY')).toEqual(['day', 'month', 'year']);
expect(dateFieldOrder('de-DE', 'MM/DD/YYYY')).toEqual(['month', 'day', 'year']);
expect(dateFieldOrder('en-US', 'YYYY/MM/DD')).toEqual(['year', 'month', 'day']);
expect(dateFieldOrder('de-DE', 'auto')).toEqual(['day', 'month', 'year']);
expect(dateFieldOrder('en-US')).toEqual(['month', 'day', 'year']);
expect(dateFieldOrder('ko-KR', null)).toEqual(['year', 'month', 'day']);
});
it('FE-UTIL-DATEFMT-019: dateLayout knows the five formats only, isNumericDate spots names', () => {
expect(dateLayout('DD/MM/YYYY')).toEqual({ order: ['day', 'month', 'year'], separator: '/' });
expect(dateLayout('auto')).toBeNull();
expect(dateLayout('nope')).toBeNull();
expect(dateLayout(undefined)).toBeNull();
expect(isNumericDate()).toBe(true);
expect(isNumericDate({ month: '2-digit' })).toBe(true);
expect(isNumericDate({ month: 'short' })).toBe(false);
expect(isNumericDate({ weekday: 'long' })).toBe(false);
expect(isNumericDate({ era: 'short' })).toBe(false);
expect(isNumericDate({ dateStyle: 'short' })).toBe(true);
expect(isNumericDate({ dateStyle: 'long' })).toBe(false);
});
});
describe('dateFormatOptions', () => {
it('FE-UTIL-DATEFMT-020: every choice is labelled by how it writes 31 December, auto with its example', () => {
expect(dateFormatOptions('en-US', 'Automatic')).toEqual([
{ value: 'auto', label: 'Automatic (12/31/2026)' },
{ value: 'YYYY-MM-DD', label: '2026-12-31' },
{ value: 'YYYY/MM/DD', label: '2026/12/31' },
{ value: 'DD.MM.YYYY', label: '31.12.2026' },
{ value: 'DD/MM/YYYY', label: '31/12/2026' },
{ value: 'MM/DD/YYYY', label: '12/31/2026' },
]);
expect(dateFormatOptions('de-DE', 'Automatisch')[0]).toEqual({
value: 'auto',
label: 'Automatisch (31.12.2026)',
});
});
});
});
+182
View File
@@ -0,0 +1,182 @@
import { DATE_FORMAT_VALUES, type DateFormat } from '@trek/shared';
/**
* The user's date format (#2971), applied wherever a date is written in numbers.
*
* Every function here takes the format as an argument and stays pure; the
* current user's value comes from `useDateFormat()` (or `readDateFormat()`
* outside React) in hooks/useDateFormat.ts. 'auto', an unknown value or none
* at all leaves everything to `Intl` and the interface language, exactly as
* TREK wrote dates before the setting existed.
*
* Only the numeric part follows the setting. A date written with a weekday or
* a month name ("Tue, 3 Nov") keeps the language's own wording and order: the
* setting answers "how do I read 03/11", not "what is November called".
*/
export type DateField = 'day' | 'month' | 'year';
interface DateLayout {
order: DateField[];
separator: string;
}
const LAYOUTS: Record<Exclude<DateFormat, 'auto'>, DateLayout> = {
'YYYY-MM-DD': { order: ['year', 'month', 'day'], separator: '-' },
'YYYY/MM/DD': { order: ['year', 'month', 'day'], separator: '/' },
'DD.MM.YYYY': { order: ['day', 'month', 'year'], separator: '.' },
'DD/MM/YYYY': { order: ['day', 'month', 'year'], separator: '/' },
'MM/DD/YYYY': { order: ['month', 'day', 'year'], separator: '/' },
};
/** The order and separator of a chosen format; null for 'auto' or anything unknown. */
export function dateLayout(format: string | null | undefined): DateLayout | null {
if (!format || format === 'auto') return null;
return LAYOUTS[format as keyof typeof LAYOUTS] ?? null;
}
const DATE_KEYS = ['day', 'month', 'year'] as const;
const TIME_KEYS = ['hour', 'minute', 'second', 'dayPeriod', 'fractionalSecondDigits'] as const;
/**
* Whether these options write the date in numbers only: no weekday, no month
* name, no era. `dateStyle: 'short'` is numeric in every language; the longer
* styles name the month.
*/
export function isNumericDate(opts: Intl.DateTimeFormatOptions = {}): boolean {
if (opts.weekday || opts.era) return false;
if (opts.dateStyle) return opts.dateStyle === 'short';
return !opts.month || opts.month === 'numeric' || opts.month === '2-digit';
}
const hasAny = (opts: Intl.DateTimeFormatOptions, keys: readonly string[]) =>
keys.some((key) => (opts as Record<string, unknown>)[key] !== undefined);
/** The date in the layout's order, each field the options ask for (all three when they name none). */
function numericDate(date: Date, opts: Intl.DateTimeFormatOptions, layout: DateLayout): string {
const named = opts.dateStyle ? DATE_KEYS : DATE_KEYS.filter((key) => opts[key] !== undefined);
const fields = named.length > 0 ? named : DATE_KEYS;
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: opts.timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
numberingSystem: 'latn',
}).formatToParts(date);
const value = (type: DateField) => parts.find((part) => part.type === type)?.value ?? '';
const shortYear = opts.year === '2-digit';
return layout.order
.filter((field) => (fields as readonly string[]).includes(field))
.map((field) => (field === 'year' && shortYear ? value('year').slice(-2) : value(field)))
.join(layout.separator);
}
/** Only the time-of-day options, for the clock part beside a numeric date. */
function timeOptions(opts: Intl.DateTimeFormatOptions): Intl.DateTimeFormatOptions {
const time: Intl.DateTimeFormatOptions = {};
for (const key of [...TIME_KEYS, 'hour12', 'hourCycle', 'timeZone', 'timeZoneName'] as const) {
const value = (opts as Record<string, unknown>)[key];
if (value !== undefined) (time as Record<string, unknown>)[key] = value;
}
if (opts.timeStyle) time.timeStyle = opts.timeStyle;
return time;
}
/**
* `date.toLocaleDateString(locale, opts)`, with a numeric date written in the
* chosen format. Anything with a weekday or a month name is left to `Intl`.
*/
export function formatDisplayDate(
date: Date,
locale: string | undefined,
opts: Intl.DateTimeFormatOptions = {},
format?: string | null
): string {
const layout = dateLayout(format);
if (!layout || Number.isNaN(date.getTime())) return date.toLocaleDateString(locale, opts);
if (opts.weekday && isNumericWithWeekday(opts)) {
const weekday = date.toLocaleDateString(locale, { weekday: opts.weekday, timeZone: opts.timeZone });
return `${weekday}, ${numericDate(date, opts, layout)}`;
}
if (!isNumericDate(opts)) return date.toLocaleDateString(locale, opts);
return numericDate(date, opts, layout);
}
/** A weekday beside a date that is otherwise all numbers ("Tue, 03.11.2026"). */
function isNumericWithWeekday(opts: Intl.DateTimeFormatOptions): boolean {
if (opts.era || opts.dateStyle) return false;
return (opts.month === 'numeric' || opts.month === '2-digit') && opts.day !== undefined;
}
/**
* `date.toLocaleString(locale, opts)`, with a numeric date part written in the
* chosen format and the time of day left to `Intl` (12h/24h stays the
* caller's option). Options naming only a time are passed through untouched.
*/
export function formatDisplayDateTime(
date: Date,
locale: string | undefined,
opts: Intl.DateTimeFormatOptions = {},
format?: string | null
): string {
const layout = dateLayout(format);
const named = hasAny(opts, DATE_KEYS) || !!opts.dateStyle;
const timed = hasAny(opts, TIME_KEYS) || !!opts.timeStyle;
if (!layout || !isNumericDate(opts) || Number.isNaN(date.getTime())) return date.toLocaleString(locale, opts);
if (!named && timed) return date.toLocaleString(locale, opts);
const day = numericDate(date, opts, layout);
if (named && !timed) return day;
const time = date.toLocaleTimeString(locale, timed ? timeOptions(opts) : { timeZone: opts.timeZone });
return `${day} ${time}`;
}
/**
* A drop-in for `new Intl.DateTimeFormat(locale, opts)` where only `.format()`
* is used: numeric dates in the chosen format, the rest by `Intl`.
*/
export function displayDateFormatter(
locale: string | undefined,
opts: Intl.DateTimeFormatOptions = {},
format?: string | null
): { format: (date: Date | number) => string } {
const intl = new Intl.DateTimeFormat(locale, opts);
const layout = dateLayout(format);
if (!layout || !isNumericDate(opts)) return { format: (date) => intl.format(date) };
return {
format: (date) => {
const value = typeof date === 'number' ? new Date(date) : date;
return hasAny(opts, TIME_KEYS) || opts.timeStyle
? formatDisplayDateTime(value, locale, opts, format)
: formatDisplayDate(value, locale, opts, format);
},
};
}
/** The order the fields of a typed date are read in: the chosen format's, or the language's. */
export function dateFieldOrder(locale: string | undefined, format?: string | null): DateField[] {
const layout = dateLayout(format);
if (layout) return layout.order;
return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'numeric', year: 'numeric' })
.formatToParts(new Date(2001, 5, 15))
.filter((part) => part.type === 'day' || part.type === 'month' || part.type === 'year')
.map((part) => part.type as DateField);
}
/** 31 December: no day of it reads as a month, so every option's example is unambiguous. */
const SAMPLE = new Date(Date.UTC(2026, 11, 31));
/**
* The choices of the setting, each labelled by how it writes 31 December, and
* 'auto' by `autoLabel` with the language's own way in brackets.
*/
export function dateFormatOptions(
locale: string | undefined,
autoLabel: string
): { value: DateFormat; label: string }[] {
const example = (format: DateFormat) =>
formatDisplayDate(SAMPLE, locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }, format);
return DATE_FORMAT_VALUES.map((value) => ({
value,
label: value === 'auto' ? `${autoLabel} (${example(value)})` : example(value),
}));
}
+14
View File
@@ -15,4 +15,18 @@ describe('dateInputPattern', () => {
expect(dateInputPattern('ar')).not.toMatch(/[٠-٩\d]/);
expect(dateInputPattern('ar')).toContain('YYYY');
});
it('writes a chosen date format (#2971) as its own pattern, whatever the language', () => {
expect(dateInputPattern('en-US', 'YYYY-MM-DD')).toBe('YYYY-MM-DD');
expect(dateInputPattern('en-US', 'DD.MM.YYYY')).toBe('DD.MM.YYYY');
expect(dateInputPattern('de-DE', 'MM/DD/YYYY')).toBe('MM/DD/YYYY');
expect(dateInputPattern('ko-KR', 'YYYY/MM/DD')).toBe('YYYY/MM/DD');
});
it('keeps the locale pattern for auto, an unknown value or none', () => {
expect(dateInputPattern('de-DE', 'auto')).toBe('DD.MM.YYYY');
expect(dateInputPattern('en-US', 'auto')).toBe('MM/DD/YYYY');
expect(dateInputPattern('en-GB', 'DD-MM-YY')).toBe('DD/MM/YYYY');
expect(dateInputPattern('en-GB', null)).toBe('DD/MM/YYYY');
});
});
+6 -2
View File
@@ -1,3 +1,5 @@
import { dateLayout } from './dateFormat';
const LETTERS: Record<string, string> = { day: 'DD', month: 'MM', year: 'YYYY' };
/**
@@ -5,9 +7,11 @@ const LETTERS: Record<string, string> = { day: 'DD', month: 'MM', year: 'YYYY' }
* locale's own separators. It is the hint of the manual date field, which is
* pre-filled and read back in this same order, so a Korean user sees
* "YYYY. MM. DD." rather than a German "DD.MM.YYYY". Direction marks are dropped:
* the hint shows the shape, the field itself carries the script.
* the hint shows the shape, the field itself carries the script. A chosen date
* format (#2971) is its own pattern.
*/
export function dateInputPattern(locale: string): string {
export function dateInputPattern(locale: string, format?: string | null): string {
if (dateLayout(format)) return String(format);
return new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' })
.formatToParts(new Date(Date.UTC(2001, 5, 15)))
.map((part) => LETTERS[part.type] ?? part.value.replace(/[؜‎‏]/g, ''))
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { frameLine, frameLng, tripLngFrame } from './lngFrame';
const LA = -118.4;
const AUCKLAND = 174.8;
describe('tripLngFrame (#2975)', () => {
it('gives a trip that stays off the antimeridian no frame, so every layer draws as before', () => {
expect(tripLngFrame([2.35, 13.4, -3.7, 12.5])).toBeNull();
expect(tripLngFrame([LA, -73.9, -0.1, 139.7])).toBeNull();
expect(tripLngFrame([])).toBeNull();
expect(tripLngFrame([LA])).toBeNull();
});
it('seams a trip across the Pacific in the middle of the stretch it leaves empty', () => {
const frame = tripLngFrame([LA, -118.3, AUCKLAND, 174.76]);
expect(frame).not.toBeNull();
expect(frame?.seam).toBeCloseTo((-118.3 + 174.76) / 2, 5);
expect(frameLng(frame, LA)).toBeCloseTo(LA + 360, 9);
expect(frameLng(frame, AUCKLAND)).toBeCloseTo(AUCKLAND, 9);
});
it('seams Fiji to Samoa, a few degrees apart across the date line', () => {
const frame = tripLngFrame([178.4, -171.8]);
expect(frame).not.toBeNull();
expect(frameLng(frame, -171.8) - frameLng(frame, 178.4)).toBeCloseTo(9.8, 9);
});
it('leaves a trip around the world alone when re-seaming would barely narrow it', () => {
expect(tripLngFrame([-150, -90, -30, 30, 90, 150])).toBeNull();
});
it('keeps the frame in use while it still fits, so a new place does not slide the map', () => {
const first = tripLngFrame([LA, AUCKLAND]);
expect(tripLngFrame([LA, AUCKLAND, 151.2], first)).toBe(first);
});
it('reads a longitude from a world copy where it really is', () => {
const fromCopies = tripLngFrame([LA + 360, AUCKLAND - 720]);
expect(fromCopies?.seam).toBeCloseTo(tripLngFrame([LA, AUCKLAND])?.seam ?? Number.NaN, 9);
});
});
describe('frameLng', () => {
it('draws a longitude where it is without a frame', () => {
expect(frameLng(null, LA)).toBe(LA);
expect(frameLng(null, 400)).toBe(400);
});
it('moves any longitude into the frame, and one already in it nowhere', () => {
const frame = { seam: 28 };
expect(frameLng(frame, LA)).toBeCloseTo(241.6, 9);
expect(frameLng(frame, 241.6)).toBeCloseTo(241.6, 9);
expect(frameLng(frame, 28)).toBe(28);
expect(frameLng(frame, 27.9)).toBeCloseTo(387.9, 9);
});
});
describe('frameLine', () => {
it('hands the line back untouched without a frame', () => {
const line: [number, number][] = [
[0, 170],
[0, -170],
];
expect(frameLine(null, line)).toBe(line);
});
it('starts the line in the frame and keeps it one line across the antimeridian', () => {
const drawn = frameLine({ seam: 28 }, [
[34, LA],
[0, -160],
[-20, 179],
[-37, AUCKLAND],
]);
expect(drawn.map(([, lng]) => Math.round(lng * 10) / 10)).toEqual([241.6, 200, 179, 174.8]);
});
it('runs past the edge of the frame rather than jumping back across the world', () => {
const drawn = frameLine({ seam: 28 }, [
[0, 30],
[0, 20],
]);
expect(drawn.map(([, lng]) => lng)).toEqual([30, 20]);
});
});
+108
View File
@@ -0,0 +1,108 @@
// One stretch of longitudes to draw a trip in, for a map that does not repeat its
// layers across world copies (Leaflet) (#2975).
//
// Leaflet draws a marker at its literal longitude only, on one copy of the world.
// A trip from Los Angeles (-118) to Auckland (174.8) therefore had its pins at
// opposite ends of that copy, while the flight between them, drawn the short way
// across the Pacific, ran off the edge to the Los Angeles of the next copy. The
// frame moves the seam of the drawn world into the widest stretch of the planet
// the trip leaves empty, so the whole trip, flight included, lies on one copy:
// Los Angeles at 242, Auckland at 174.8.
//
// A trip that does not cross the antimeridian gets no frame at all, and every
// layer draws exactly what it drew before.
import { wrapLongitude } from '@trek/shared';
/** The drawn world runs from `seam` to `seam + 360`. */
export interface LngFrame {
readonly seam: number;
}
/** Re-seam only when that narrows the trip by at least this much: a world tour stays as it is. */
const MIN_SAVING_DEG = 45;
/** A previous seam is kept while the trip drawn with it is at most this much wider than the best. */
const KEEP_SEAM_SLACK_DEG = 15;
/** Width of the trip, in degrees, when the world is drawn from `seam` eastwards. */
function spanFrom(seam: number, sorted: number[]): number {
let min = Infinity;
let max = -Infinity;
for (const lng of sorted) {
const framed = lng < seam ? lng + 360 : lng;
if (framed < min) min = framed;
if (framed > max) max = framed;
}
return max - min;
}
/**
* The frame for these longitudes, or null when the plain world already holds the
* trip narrowly enough. `previous` is the frame in use: it is kept while it still
* works, so adding or filtering a place does not slide every layer sideways.
*/
export function tripLngFrame(lngs: readonly number[], previous: LngFrame | null = null): LngFrame | null {
const sorted = lngs
.filter(Number.isFinite)
.map(wrapLongitude)
.sort((a, b) => a - b);
if (sorted.length < 2) return null;
const last = sorted.length - 1;
// The widest stretch with no point in it. The one across the antimeridian comes first,
// so a trip that leaves the Pacific empty keeps the plain world.
let gapSize = sorted[0] + 360 - sorted[last];
let gapWest = sorted[last];
let crossesAntimeridian = false;
for (let i = 0; i < last; i++) {
const size = sorted[i + 1] - sorted[i];
if (size > gapSize) {
gapSize = size;
gapWest = sorted[i];
crossesAntimeridian = true;
}
}
if (!crossesAntimeridian) return null;
const plainSpan = sorted[last] - sorted[0];
const bestSpan = 360 - gapSize;
if (plainSpan - bestSpan < MIN_SAVING_DEG) return null;
if (previous && spanFrom(previous.seam, sorted) <= bestSpan + KEEP_SEAM_SLACK_DEG) return previous;
// The middle of the empty stretch: lines that bulge a little past a stop stay clear of it.
return { seam: gapWest + gapSize / 2 };
}
/** Where a longitude is drawn in the frame. Without a frame it is drawn where it is. */
export function frameLng(frame: LngFrame | null, lng: number): number {
if (!frame) return lng;
return frame.seam + ((((lng - frame.seam) % 360) + 360) % 360);
}
/**
* A line in the frame: its first point moved into it, every next point kept within
* half a world of the one before, so the line stays one line where it crosses the
* antimeridian or the seam. Without a frame the line is drawn as it is.
*/
export function frameLine<T extends readonly [number, number]>(
frame: LngFrame | null,
line: readonly T[]
): [number, number][] {
if (!frame) return line as unknown as [number, number][];
const out: [number, number][] = [];
let prevRaw = 0;
let prevDrawn = 0;
for (let i = 0; i < line.length; i++) {
const [lat, lng] = line[i];
if (i === 0) {
prevDrawn = frameLng(frame, lng);
} else {
let delta = lng - prevRaw;
delta -= 360 * Math.round(delta / 360);
prevDrawn += delta;
}
prevRaw = lng;
out.push([lat, prevDrawn]);
}
return out;
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { bboxOnRealWorld, onRealWorld } from './worldCopy';
describe('onRealWorld', () => {
it('brings a right-click on the copy east of the real world back to the real point (#2976)', () => {
const at = onRealWorld({ lat: 10.14, lng: 274.921875 });
expect(at.lat).toBe(10.14);
expect(at.lng).toBeCloseTo(-85.078125, 9);
});
it('brings a point on the copy west of it back too', () => {
expect(onRealWorld({ lat: 35.68, lng: -220.3 }).lng).toBeCloseTo(139.7, 9);
});
it('hands a point already on the real world back as it is', () => {
const point = { lat: 34, lng: -118.4, extra: 1 };
expect(onRealWorld(point)).toBe(point);
});
it('keeps any other field of the point', () => {
expect(onRealWorld({ lat: 1, lng: 365, name: 'x' })).toEqual({ lat: 1, lng: 5, name: 'x' });
});
});
describe('bboxOnRealWorld', () => {
it('moves a box looking at a copy of Japan onto Japan', () => {
const box = bboxOnRealWorld({ south: 34, west: -225, north: 37, east: -215 });
expect(box.west).toBeCloseTo(135, 9);
expect(box.east).toBeCloseTo(145, 9);
expect([box.south, box.north]).toEqual([34, 37]);
});
it('leaves a box on the real world alone', () => {
const box = { south: 40, west: 0, north: 50, east: 20 };
expect(bboxOnRealWorld(box)).toBe(box);
});
it('keeps a box across the antimeridian across it', () => {
expect(bboxOnRealWorld({ south: -20, west: 170, north: -10, east: 190 })).toEqual({
south: -20,
west: 170,
north: -10,
east: 190,
});
});
});
+32
View File
@@ -0,0 +1,32 @@
// Coordinates the map hands out, brought back from a repeated world copy (#2976).
//
// Leaflet and MapLibre/Mapbox repeat the world sideways, and a pointer on a copy
// reports the longitude it has on that copy: 274.9 for -85.1. Anything that
// stores a point or asks a server about it wants the point on the real world.
import { wrapLongitude } from '@trek/shared';
/** The same point with its longitude on the real world, in [-180, 180]. */
export function onRealWorld<T extends { lat: number; lng: number }>(point: T): T {
const lng = wrapLongitude(point.lng);
return lng === point.lng ? point : { ...point, lng };
}
export interface Bbox {
south: number;
west: number;
north: number;
east: number;
}
/**
* A viewport box moved whole by a multiple of 360 degrees so its centre lies on
* the real world. A box looking at a copy of Japan then asks for Japan. A box
* that spans the antimeridian keeps one edge past it, as it always had.
*/
export function bboxOnRealWorld<T extends Bbox>(bbox: T): T {
const centre = (bbox.west + bbox.east) / 2;
const shift = wrapLongitude(centre) - centre;
if (shift === 0) return bbox;
return { ...bbox, west: bbox.west + shift, east: bbox.east + shift };
}
@@ -1,4 +1,4 @@
// FE-MOB-MDUS-001 to FE-MOB-MDUS-030
// FE-MOB-MDUS-001 to FE-MOB-MDUS-032
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor, within, fireEvent } from '../../../helpers/render';
import userEvent from '@testing-library/user-event';
@@ -500,3 +500,36 @@ describe('MAdminDefaultUserSettings, the routing service', () => {
expect(screen.queryByRole('combobox', { name: 'Routing service' })).not.toBeInTheDocument();
});
});
describe('MAdminDefaultUserSettings date format (#2971)', () => {
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
});
it('FE-MOB-MDUS-031: the date-format sheet saves its own key', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ date_format: 'YYYY-MM-DD' });
render(<MAdminDefaultUserSettings />);
await screen.findByText('Default User Settings');
// The choices are labelled by how they write 31 December.
await user.click(screen.getByRole('button', { name: '2026-12-31' }));
const sheet = await screen.findByRole('dialog');
await user.click(within(sheet).getByRole('button', { name: '31.12.2026' }));
await waitFor(() => expect(puts).toEqual([{ date_format: 'DD.MM.YYYY' }]));
});
it('FE-MOB-MDUS-032: a stored date format can be reset', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ date_format: 'DD/MM/YYYY' });
render(<MAdminDefaultUserSettings />);
await screen.findByText('Default User Settings');
// The only default set, so the only reset link on the page.
await user.click(screen.getByRole('button', { name: 'reset' }));
await waitFor(() => expect(puts).toEqual([{ date_format: null }]));
});
});
@@ -231,3 +231,24 @@ describe('MSettingsGeneral week start (#2029)', () => {
expect(updateSetting).toHaveBeenCalledWith('week_start', 'saturday');
});
});
describe('MSettingsGeneral date format (#2971)', () => {
it('FE-MOB-SET-018: the date-format row shows the language format when unset and the sheet saves a pick', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en', date_format: undefined }), updateSetting });
render(<MSettingsGeneral />);
// The choices are labelled by how they write 31 December; auto adds the language's own way.
await user.click(screen.getByRole('button', { name: /\(12\/31\/2026\)/ }));
await user.click(await screen.findByRole('button', { name: '31.12.2026' }));
expect(updateSetting).toHaveBeenCalledWith('date_format', 'DD.MM.YYYY');
});
it('FE-MOB-SET-019: a stored format is shown on its row', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en', date_format: 'YYYY/MM/DD' }) });
render(<MSettingsGeneral />);
expect(screen.getByRole('button', { name: '2026/12/31' })).toBeInTheDocument();
});
});
@@ -53,8 +53,13 @@ function wsHandler() {
return calls[calls.length - 1][0]
}
function deadlineInput(): HTMLInputElement {
return document.querySelector('input[type="datetime-local"]') as HTMLInputElement
// The deadline is the shared date and time picker (#2971): the day typed in its
// manual field, the time left at the noon it starts from.
function setDeadline(day: string) {
fireEvent.click(screen.getByRole('button', { name: /enterManually|Enter date manually/ }))
const input = screen.getByRole('textbox', { name: /enterManually|Enter date manually/ })
fireEvent.change(input, { target: { value: day } })
fireEvent.keyDown(input, { key: 'Enter' })
}
describe('MCollabPolls', () => {
@@ -335,7 +340,7 @@ describe('MCollabPolls', () => {
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:1'), { target: { value: 'Ryokan' } })
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:2'), { target: { value: ' Capsule ' } })
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.multiChoice' }))
fireEvent.change(deadlineInput(), { target: { value: '2026-08-01T12:00' } })
setDeadline('2030-08-01')
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.create' }))
await waitFor(() => expect(createPoll).toHaveBeenCalledWith(1, {
@@ -343,7 +348,7 @@ describe('MCollabPolls', () => {
options: ['Ryokan', 'Capsule'],
multiple: true,
multiple_choice: true,
deadline: new Date('2026-08-01T12:00').toISOString(),
deadline: new Date('2030-08-01T12:00').toISOString(),
}))
expect(await screen.findByText('Which hotel?')).toBeInTheDocument()
})
@@ -381,10 +386,9 @@ describe('MCollabPolls', () => {
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[0])
expect(screen.queryByPlaceholderText('collab.polls.optionPlaceholder:3')).not.toBeInTheDocument()
fireEvent.change(deadlineInput(), { target: { value: '2026-08-01T12:00' } })
expect(deadlineInput()).toHaveValue('2026-08-01T12:00')
setDeadline('2030-08-01')
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.clearDeadline' }))
expect(deadlineInput()).toHaveValue('')
expect(screen.queryByRole('button', { name: 'collab.polls.clearDeadline' })).not.toBeInTheDocument()
})
it('FE-MOB-POLLS-025: keeps the form open and toasts when creating fails', async () => {
@@ -8,7 +8,7 @@ import type { BudgetItem, BudgetItemMember } from '../../../../src/types'
import { buildBudgetItem, buildTrip } from '../../../helpers/factories'
import { localToday } from '../../../../src/components/Planner/today'
import { resetAllStores } from '../../../helpers/store'
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-COSTSH-001 to FE-MOB-COSTSH-038
// The sheet reads its copy from useTranslation(), so assertions are English.
@@ -681,7 +681,7 @@ describe('MCostSheet', () => {
})))
})
it('FE-MOB-COSTSH-036: editing a booked bill previews the rate the save keeps (#2525)', () => {
it('FE-MOB-COSTSH-036: editing a bill in the display currency has nothing to preview (#2965)', () => {
// A euro trip read in dollars, the bill entered in dollars at 1.17 to the euro.
// Same currency as the list, so the sheet said nothing, while the list showed the
// booked euros at today's rate beside the 801.76 typed here.
@@ -695,12 +695,9 @@ describe('MCostSheet', () => {
})
renderSheet({ base: 'USD', editing })
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
// Each share next to what it counts as, the figure the row's "you lent" is made of.
expect(screen.getByText(/Split 2 ways · \$400\.88 → \$395\.77 each/)).toBeInTheDocument()
// The list reads the dollars typed, so the sheet has no conversion to show.
expect(screen.queryByText(/live rate/)).toBeNull()
expect(screen.getByText(/Split 2 ways · \$400\.88 each/)).toBeInTheDocument()
})
it('FE-MOB-COSTSH-037: a bill booked at today\'s rate has nothing to preview (#2525)', () => {
@@ -764,7 +764,7 @@ describe('MCostsTab', () => {
expect(update).toHaveBeenCalledTimes(1)
})
it('FE-MOB-COSTT-045: a bill whose rate moved shows what was entered and what it was booked at (#2525)', async () => {
it('FE-MOB-COSTT-045: a bill whose rate moved still reads as the dollars typed (#2965)', async () => {
// A euro trip read in dollars, the bill entered in dollars at a frozen 1.17 to the
// euro while today the euro buys 1.1551. It is 685.26 EUR of trip money, $791.55
// today, and that is what settle-up offers too; the row now says where the
@@ -793,17 +793,16 @@ describe('MCostsTab', () => {
await renderTab(planner({ trip: { ...TRIP, currency: 'EUR' } as Trip, budgetItems: [hotel] }))
const card = cardOf('Aparthotel Silver')
expect(within(card).getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
expect(within(card).getByText('$791.55')).toBeInTheDocument()
// Each share is half of the booked euros, the figure the balances net.
expect(within(card).getAllByText('$395.77')).toHaveLength(2)
expect(within(card).queryByText('$400.88')).toBeNull()
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$791.55')
expect(screen.getByText('costs.spent:$791.55')).toBeInTheDocument()
expect(within(card).queryByText(/^\$801\.76 → /)).toBeNull()
expect(within(card).getByText('$801.76')).toBeInTheDocument()
// Each share is half of the dollars typed, the figure the balances net (#2965).
expect(within(card).getAllByText('$400.88')).toHaveLength(2)
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$801.76')
expect(screen.getByText('costs.spent:$801.76')).toBeInTheDocument()
expect(screen.getAllByText('costs.payment')).toHaveLength(2)
const paid = up(screen.getByText('Ada → costs.you'), 2)
expect(within(paid).getByText(/^\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
expect(within(paid).getByText('$400.88')).toBeInTheDocument()
expect(within(up(screen.getByText('Bob → costs.you'), 2)).getByText('$30.00')).toBeInTheDocument()
expect(screen.queryByText('$34.65')).toBeNull()
})
@@ -325,17 +325,14 @@ describe('costsModel: a bill entered in the display currency (#2525)', () => {
payers: [payer(1, 801.76)], members: [member(1), member(2)],
});
it('FE-MOB-CMOD-038: a bill booked at a rate that has moved since counts as its euros at today\'s rate', () => {
// 685.26 EUR, which the balances net and settle-up offers; the row, the payer's
// figure and the share read the same, or a payback of the row's half reopens the trip.
expect(baseTotal(hotel(), usdCtx)).toBeCloseTo(791.55, 2);
expect(myPaidOf(hotel(), usdCtx)).toBeCloseTo(791.55, 2);
expect(myShareOf(hotel(), usdCtx)).toBeCloseTo(395.77, 2);
// Under it, what was entered and the euros it was booked at, no third step.
const line = lineOf(801.76, hotel(), usdCtx, baseTotal(hotel(), usdCtx))!;
expect(line.entered).toEqual({ amount: 801.76, currency: 'USD' });
expect(line.into.currency).toBe('EUR');
expect(line.into.amount).toBeCloseTo(685.26, 2);
it('FE-MOB-CMOD-038: a bill booked at a rate that has moved since still counts as the dollars typed (#2965)', () => {
// The balances net it as typed too, so the row, the payer's figure and the share
// read the dollars entered, and paying back half of them squares the trip.
expect(baseTotal(hotel(), usdCtx)).toBe(801.76);
expect(myPaidOf(hotel(), usdCtx)).toBe(801.76);
expect(myShareOf(hotel(), usdCtx)).toBe(400.88);
// Nothing to explain under it.
expect(lineOf(801.76, hotel(), usdCtx, baseTotal(hotel(), usdCtx))).toBeNull();
});
it('FE-MOB-CMOD-039: booked at today\'s rate, the bill reads exactly as typed and needs no line', () => {
@@ -380,7 +377,7 @@ describe('costsModel: a bill entered in the display currency (#2525)', () => {
].map((e, i) => ({ ...e, name: e.name || 'Hotel', expense_date: e.expense_date || '2026-07-19', id: i + 1 }));
const inUsd = buildCostsCsv(items, { base: 'USD', ctx: usdCtx, locale: 'en-US', tripTitle: 'Trip', t }).content.split('\r\n');
expect(inUsd[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note');
expect(inUsd[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/);
expect(inUsd[1]).toMatch(/;801\.76;USD;685\.26;801\.76;$/);
// Never booked, so counted at today's rate, as the server counts it.
expect(inUsd[2]).toMatch(/;115\.51;USD;100\.00;115\.51;$/);
const inEur = buildCostsCsv(items, { base: 'EUR', ctx: { ...usdCtx, displayCurrency: 'EUR' }, locale: 'en-US', tripTitle: 'Trip', t }).content.split('\r\n');
@@ -69,6 +69,19 @@ async function waitForCountries() {
await waitFor(() => expect(screen.getAllByRole('option', { name: 'Germany' }).length).toBeGreaterThan(0));
}
// The hire date is the shared date picker (#2971): typed through its manual
// field, cleared from its calendar.
function typeDate(trigger: HTMLElement, value: string) {
fireEvent.click(within(trigger.parentElement as HTMLElement).getByRole('button', { name: /enterManually|Enter date manually/ }))
const input = screen.getByRole('textbox', { name: /enterManually|Enter date manually/ })
fireEvent.change(input, { target: { value } })
fireEvent.keyDown(input, { key: 'Enter' })
}
function clearDate(trigger: HTMLElement) {
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: /clearDate|Clear date/ }))
}
describe('MVacaySettingsSheet', () => {
it('FE-MOB-MVACSET-001: renders nothing without a plan', () => {
useVacayStore.setState({ plan: null });
@@ -217,15 +230,15 @@ describe('MVacaySettingsSheet', () => {
});
renderSheet();
const hire = screen.getByLabelText('Hire date');
expect(hire).toHaveValue('2020-09-14');
const hire = () => screen.getByRole('button', { name: /^Hire date: / });
expect(hire()).toHaveAccessibleName(/2020/);
fireEvent.change(hire, { target: { value: '2021-03-02' } });
typeDate(hire(), '2021-03-02');
expect(updateYearSettings).toHaveBeenLastCalledWith({
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: '2021-03-02',
});
fireEvent.change(hire, { target: { value: '' } });
clearDate(hire());
expect(updateYearSettings).toHaveBeenLastCalledWith({
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: null,
});
+2 -2
View File
@@ -1,13 +1,13 @@
{
"scripts/db-call-graph.mjs": 1036,
"scripts/generate-entities.ts": 2259,
"src/db/repositories/Places.repository.ts": 1438,
"src/db/repositories/Places.repository.ts": 1437,
"src/db/repositories/Reservations.repository.ts": 1604,
"src/db/repositories/Trips.repository.ts": 1121,
"src/db/repositories/Users.repository.ts": 1445,
"src/nest/atlas/atlas.service.ts": 1177,
"src/nest/auth/auth.service.ts": 1143,
"src/nest/budget/budget.service.ts": 1617,
"src/nest/budget/budget.service.ts": 1613,
"src/nest/collections/collections.service.ts": 1426,
"src/nest/doc-sync/doc-sync.service.ts": 1060,
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160,

Some files were not shown because too many files have changed in this diff Show More