mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82295a1d3f | |||
| 3c78452c14 | |||
| 74864f2543 | |||
| 178df0fbe3 | |||
| 51af575c9a | |||
| 3e90ece087 | |||
| 65e5d4ab72 | |||
| 154620b99a | |||
| adbe3a40f6 | |||
| 44c6fbf372 | |||
| 45690ecd14 | |||
| c6149cd64d | |||
| 4587cf2099 | |||
| 4585988dfd | |||
| a37a9c0fa8 |
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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')}`)
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -169,6 +169,45 @@ describe('buildDayRouteRuns', () => {
|
||||
expect(hotelBookendOf(run[0], run[1])).toBe('morning')
|
||||
})
|
||||
|
||||
it("FE-MAP-DRP-017: the morning after a night away starts at the side trip's hotel, not the room kept at home", () => {
|
||||
// The first hotel stays booked over the whole trip and comes first in the list; the
|
||||
// side trip's hotel holds the night before. The day used to start with a 716 km drive
|
||||
// from the kept room to the side trip's first stop.
|
||||
const days = [1, 2, 3, 4, 5].map(n => buildDay({ id: n, day_number: n }))
|
||||
const kept = { id: 1, trip_id: 1, place_lat: 13.7466, place_lng: 100.5393, start_day_id: 1, end_day_id: 5 } as unknown as Accommodation
|
||||
const away = { id: 2, trip_id: 1, place_lat: 18.7883, place_lng: 98.9853, start_day_id: 2, end_day_id: 3 } as unknown as Accommodation
|
||||
const runs = buildDayRouteRuns(3, inputs({
|
||||
days,
|
||||
assignments: { '3': [buildAssignment({ day_id: 3, order_index: 0, place: buildPlace({ lat: 18.8048, lng: 98.9216, place_time: '10:00' }) })] },
|
||||
accommodations: [kept, away],
|
||||
optimizeFromAccommodation: true,
|
||||
}))
|
||||
expect(runs[0][0]).toMatchObject({ lat: 18.7883, lng: 98.9853, hotel: 'morning' })
|
||||
expect(runs.flat().some(p => p.hotel === 'morning' && p.lat === 13.7466)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-018: a taxi that leaves from the hotel adds no road from the hotel to itself', () => {
|
||||
const days = [1, 2, 3].map(n => buildDay({ id: n, day_number: n }))
|
||||
const hotel = { id: 1, trip_id: 1, place_lat: 13.7466, place_lng: 100.5393, start_day_id: 1, end_day_id: 3 } as unknown as Accommodation
|
||||
const taxi = {
|
||||
id: 9, trip_id: 1, type: 'taxi', title: 'Taxi', status: 'confirmed', day_id: 2, end_day_id: 2,
|
||||
reservation_time: '2026-12-02T09:00', reservation_end_time: '2026-12-02T09:40',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Hotel', lat: 13.7466, lng: 100.5393 },
|
||||
{ role: 'to', sequence: 1, name: 'Museum', lat: 13.7516, lng: 100.4927 },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
const runs = buildDayRouteRuns(2, inputs({
|
||||
days,
|
||||
assignments: { '2': [buildAssignment({ day_id: 2, order_index: 0, place: buildPlace({ lat: 13.7563, lng: 100.5018, place_time: '12:00' }) })] },
|
||||
reservations: [taxi],
|
||||
accommodations: [hotel],
|
||||
optimizeFromAccommodation: true,
|
||||
}))
|
||||
const zeroLength = runs.filter(run => run.length === 2 && run[0].lat === run[1].lat && run[0].lng === run[1].lng)
|
||||
expect(zeroLength).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
|
||||
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
|
||||
})
|
||||
|
||||
@@ -196,7 +196,10 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
const role: CarrierEdge = side === 'first'
|
||||
? (e.from ? 'departure' : 'arrival')
|
||||
: (e.to ? 'arrival' : 'departure')
|
||||
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
|
||||
// The booking's own point at this edge of the day, as the sidebar passes it: a taxi
|
||||
// that starts or ends at the hotel then adds no zero-length leg to it.
|
||||
const point = side === 'first' ? (e.from ?? e.to) : (e.to ?? e.from)
|
||||
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null, lat: point?.lat, lng: point?.lng }
|
||||
}
|
||||
// A carrier with a located endpoint today means the day changes geography by
|
||||
// air/rail/sea — the check-in hotel is then the day's destination and the
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByRole('button', { name: /^Accommodation$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Restaurant$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Event$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Tour$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Guided tour$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Parking$/i })).toBeInTheDocument();
|
||||
// The pill reads "Booking Type: Other", so only its option is named plain "Other".
|
||||
expect(screen.getAllByRole('button', { name: /^Other$/i })).toHaveLength(1);
|
||||
@@ -708,7 +708,7 @@ describe('ReservationModal', () => {
|
||||
|
||||
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await pickType(/^Tour$/i);
|
||||
await pickType(/^Guided tour$/i);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('BookingsTimeline: trip view', () => {
|
||||
expect(screen.getByText('No date')).toBeInTheDocument();
|
||||
const metro = screen.getByRole('button', { name: /Metro ride/ });
|
||||
expect(metro).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: /Bonus tour/ })).toHaveTextContent('Tour, Pending');
|
||||
expect(screen.getByRole('button', { name: /Bonus tour/ })).toHaveTextContent('Guided tour, Pending');
|
||||
expect(screen.getByRole('button', { name: /Farewell dinner/ })).toHaveTextContent('Restaurant, Confirmed');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Farewell dinner/ }));
|
||||
expect(onSelect).toHaveBeenCalledWith(early);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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)',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, ''))
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user