Compare commits

..

10 Commits

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

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

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

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

Closes #2965
2026-11-03 22:16:41 +00:00
134 changed files with 1819 additions and 552 deletions
+3 -3
View File
@@ -15,7 +15,7 @@
"@typescript-eslint/no-unused-vars": 124,
"react-hooks/immutability": 28,
"max-lines-per-function": 130,
"react-hooks/static-components": 78,
"react-hooks/static-components": 50,
"react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7,
"no-empty": 19,
@@ -25,7 +25,7 @@
"max-depth": 21,
"react-hooks/void-use-memo": 1,
"@typescript-eslint/no-unused-expressions": 2,
"react-hooks/purity": 2,
"react-hooks/purity": 1,
"preserve-caught-error": 53,
"no-useless-escape": 1
},
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1915,
"@typescript-eslint/no-non-null-assertion": 1914,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+1
View File
@@ -983,6 +983,7 @@
"src/mobile/screens/admin/MAdminOidcCard.tsx": "Markup only; the OIDC card of MAdminSettingsSection, with the form state from useAdmin and the save from its useAdminSettingsActions handed in through props.",
"src/mobile/screens/admin/MAdminResetMfaSheet.tsx": "Markup only; the 2FA reset confirm that MAdminSheets opens, with the reset handed in through props from its useAdminUserActions.",
"src/mobile/screens/admin/MAdminUi.tsx": "Phone only admin UI primitives (button, card, field, input, secret input, sheet frame).",
"src/mobile/screens/admin/MPluginVersionDate.tsx": "Markup only; the published date of a plugin version in MAdminPluginsPanel, formatted with the user's date format through useFormatDate.",
"src/mobile/screens/admin/MStorageReclaimCard.tsx": "Markup only; renders the reclaim view and handlers that MAdminStoragePanel takes from useStoragePanel, the twin of StorageReclaimSection.",
"src/mobile/screens/atlas/MAtlasSearch.tsx": "Phone full screen country search overlay; options, suggestions and selection come through props from MAtlas.",
"src/mobile/screens/atlas/MAtlasStatsCard.tsx": "Markup only; renders the AtlasStats that MAtlas passes in.",
+2 -2
View File
@@ -2,8 +2,8 @@
"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/MapViewGL.test.tsx": 2919,
+1 -1
View File
@@ -284,7 +284,7 @@
"mobile/screens/vacay/MVacay.tsx": 23,
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
"mobile/screens/vacay/MVacayMonth.tsx": 3,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 36,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
"mobile/screens/vacay/vacayDayModel.ts": 1,
"pages/AtlasPage.tsx": 32,
@@ -1,6 +1,7 @@
import { type ReactNode } from 'react'
import { Key, Trash2, User, Loader2, Shield, KeyRound, Bot } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
@@ -64,9 +65,10 @@ export default function AdminMcpTokensPanel() {
sessions, sessionsLoading, tokens, tokensLoading, expandedScopes, toggleScopes,
revokeConfirmId, setRevokeConfirmId, deleteConfirmId, setDeleteConfirmId, handleRevoke, handleDelete,
} = useMcpTokensAdmin()
const { t, locale } = useTranslation()
const { t } = useTranslation()
const { formatDate } = useFormatDate()
const date = (iso: string) => new Date(iso).toLocaleDateString(locale)
const date = (iso: string) => formatDate(new Date(iso))
return (
<div>
@@ -11,6 +11,7 @@ import { type RangeWarning } from './useRangeBypass'
import { deriveCaps } from './pluginCaps'
import PluginPoiCategoryList from './PluginPoiCategoryList'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import ConfirmDialog from '../shared/ConfirmDialog'
import ToggleSwitch from '../Settings/ToggleSwitch'
import CustomSelect from '../shared/CustomSelect'
@@ -33,6 +34,7 @@ import { usePluginsAdmin } from './plugins/usePluginsAdmin'
import PluginFilterMenu from './plugins/PluginFilterMenu'
import PluginInstalledRow from './plugins/PluginInstalledRow'
import PluginSecurityDialog from './plugins/PluginSecurityDialog'
import VersionLine from './plugins/VersionLine'
/**
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
@@ -303,7 +305,7 @@ export default function AdminPluginsPanel() {
{versionPicker && (
<VersionPickerDialog plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
busy={busy} t={t} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
)}
<ConfirmDialog
@@ -745,6 +747,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
}) {
const { detail, failed } = usePluginDetail(item.id)
const { formatDate } = useFormatDate()
const manifest = detail?.manifest ?? null
const permissions = manifest?.permissions ?? []
@@ -910,7 +913,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
{(item.trek || item.minTrekVersion) && (
<Meta k={t('admin.plugins.metaRequires')} v={item.trek ? `TREK ${item.trek}` : `TREK ${item.minTrekVersion}+`} />
)}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={new Date(item.reviewedAt).toLocaleDateString(locale)} />}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={formatDate(new Date(item.reviewedAt))} />}
{typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
<Meta k={t('admin.plugins.downloads')} v={item.downloadCount.toLocaleString(locale)} />
)}
@@ -934,7 +937,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
) : (
<span className="text-content-faint" style={fs(11.5)}>{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
)}>
<VersionLine v={v} locale={locale} />
<VersionLine v={v} />
</DialogListItem>
))}
</DialogList>
@@ -944,17 +947,6 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
)
}
/** A version number with its date and, when the author signed it, a quiet tick. */
function VersionLine({ v, locale }: { v: VersionInfo; locale: string }) {
return (
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
<span className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`} style={fs(12.5)}>v{v.version}</span>
{v.publishedAt && <span className="text-content-faint" style={fs(11.5)}>{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
</span>
)
}
function Meta({ k, v }: { k: string; v: string }) {
return (
<div className="min-w-0 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
@@ -970,9 +962,9 @@ function Meta({ k, v }: { k: string; v: string }) {
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
* explained and never offered.
*/
function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
function VersionPickerDialog({ plugin, versions, failed, busy, t, onPick, onClose }: {
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
t: T; onPick: (version: string) => void; onClose: () => void
}) {
return (
<DialogShell
@@ -994,7 +986,7 @@ function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick
const current = v.version === plugin.version
return (
<div key={v.version} data-testid={`version-row-${v.version}`} className="flex items-center gap-3 px-3.5 py-2.5">
<div className="min-w-0 flex-1"><VersionLine v={v} locale={locale} /></div>
<div className="min-w-0 flex-1"><VersionLine v={v} /></div>
{current ? (
<StatusPill tone="success" icon={<Check size={11} />}>{t('admin.plugins.installed')}</StatusPill>
) : v.compatible ? (
@@ -1,4 +1,4 @@
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-029
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-031
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -70,7 +70,7 @@ function cartoInput(): HTMLInputElement {
}
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string | RegExp) {
await user.click(screen.getByRole('button', { name: trigger }));
const choices = await screen.findAllByRole('button', { name: option });
await user.click(choices[choices.length - 1]);
@@ -481,3 +481,34 @@ describe('DefaultUserSettingsTab week start (#2029)', () => {
await waitFor(() => expect(puts).toEqual([{ week_start: 'sunday' }, { week_start: null }]));
});
});
describe('DefaultUserSettingsTab date format (#2971)', () => {
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
});
it('FE-ADMIN-DUS-030: the date-format default saves its own key', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ date_format: 'YYYY-MM-DD' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
// The choices are labelled by how they write 31 December.
await pickFromSelect(user, '2026-12-31', '31.12.2026');
await waitFor(() => expect(puts).toEqual([{ date_format: 'DD.MM.YYYY' }]));
await waitFor(() => expect(screen.getByRole('button', { name: '31.12.2026' })).toBeInTheDocument());
});
it('FE-ADMIN-DUS-031: auto is offered with the example of the interface language', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ date_format: 'DD/MM/YYYY' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await pickFromSelect(user, '31/12/2026', /\(12\/31\/2026\)/);
await waitFor(() => expect(puts).toEqual([{ date_format: 'auto' }]));
});
});
@@ -10,6 +10,7 @@ import { MapView } from '../Map/MapView'
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
import type { DistanceUnit, WeekStart } from '../../types'
import { weekStartOptions } from '../../utils/calendarWeek'
import { dateFormatOptions } from '../../utils/dateFormat'
import { withTileApiKey } from '../../utils/tileUrl'
import { defaultStyleForProvider } from '../Map/glProviders'
import type { MapProvider } from '../Settings/mapSettingsModel'
@@ -66,6 +67,27 @@ function errorText(err: unknown, fallback: string): string {
return err instanceof Error ? err.message : fallback
}
interface ResetProps {
shown: boolean
onReset: () => void
label: string
}
// Inside the row's own <label>, after the name: the link only shows once the
// default is set, and the name stays the label's text.
function ResetButton({ shown, onReset, label }: ResetProps): React.ReactElement | null {
if (!shown) return null
return (
<button type="button"
onClick={onReset}
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
style={fs(11)}
>
{label}
</button>
)
}
export default function DefaultUserSettingsTab(): React.ReactElement {
const { t, locale } = useTranslation()
const {
@@ -74,18 +96,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
} = useDefaultUserSettings({ errorMessage: errorText, previewCreatedAt: () => String(new Date()) })
// Inside the row's own <label>, after the name: the link only shows once the
// default is set, and the name stays the label's text.
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button type="button"
onClick={() => reset(field)}
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
style={fs(11)}
>
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
) : null
const resetOf = (field: keyof Defaults): ResetProps => ({
shown: isSet(field),
onReset: () => reset(field),
label: t('admin.defaultSettings.resetToBuiltIn'),
})
if (!loaded) {
return (
@@ -118,7 +133,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
<SettingRows>
{/* Color Mode */}
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton {...resetOf('dark_mode')} /></>}>
<Segmented<string>
label={t('settings.colorMode')}
value={colorModeValue(darkMode)}
@@ -132,7 +147,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Temperature */}
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton {...resetOf('temperature_unit')} /></>}>
<Segmented<string>
label={t('settings.temperature')}
value={defaults.temperature_unit ?? ''}
@@ -145,7 +160,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Distance */}
<ChoiceRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
<ChoiceRow label={<>{t('settings.distance')} <ResetButton {...resetOf('distance_unit')} /></>}>
<Segmented<DistanceUnit | ''>
label={t('settings.distance')}
value={defaults.distance_unit ?? ''}
@@ -158,7 +173,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Time Format */}
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton {...resetOf('time_format')} /></>}>
<Segmented<string>
label={t('settings.timeFormat')}
value={defaults.time_format ?? ''}
@@ -171,7 +186,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Week start: the first column of every date picker (#2029) */}
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton {...resetOf('week_start')} /></>}>
<Segmented<WeekStart | ''>
label={t('settings.weekStart')}
value={defaults.week_start ?? ''}
@@ -180,8 +195,19 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
</ChoiceRow>
{/* Date format: every date field and numeric date (#2971) */}
<ChoiceRow label={<>{t('settings.dateFormat')} <ResetButton {...resetOf('date_format')} /></>}>
<div className="w-[220px] max-w-full">
<CustomSelect
value={defaults.date_format ?? ''}
onChange={value => { if (value) void save({ date_format: String(value) as Defaults['date_format'] }) }}
options={dateFormatOptions(locale, t('settings.dateFormatAuto'))}
/>
</div>
</ChoiceRow>
{/* Default Currency */}
<ChoiceRow label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}>
<ChoiceRow label={<>{t('settings.currency')} <ResetButton {...resetOf('default_currency')} /></>}>
<div className="w-[220px] max-w-full">
<CustomSelect
value={defaults.default_currency || ''}
@@ -194,7 +220,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Blur Booking Codes */}
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton {...resetOf('blur_booking_codes')} /></>}>
<Segmented<string>
label={t('settings.blurBookingCodes')}
value={onOffValue(defaults.blur_booking_codes)}
@@ -211,7 +237,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<Section title={t('settings.map')} icon={MapIcon}>
{/* Map Tile URL */}
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton field="map_tile_url" /></>} hint={t('settings.mapDefaultHint')}>
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton {...resetOf('map_tile_url')} /></>} hint={t('settings.mapDefaultHint')}>
<div className="flex flex-col gap-2">
<CustomSelect
value={mapTileUrl}
@@ -232,7 +258,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{/* The key comes with the instance on a managed install, injected when the
settings are read. A field here would only let somebody save a worse one. */}
{!managed && (
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton field="carto_api_key" /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton {...resetOf('carto_api_key')} /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
<input
type="text"
value={cartoKey}
@@ -273,7 +299,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<SettingRows>
<ChoiceRow
stacked
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton {...resetOf('map_provider')} /></>}
hint={t('admin.defaultSettings.mapProviderHint')}
>
<Segmented<MapProvider>
@@ -293,7 +319,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
settings are read. A field here would only let somebody save a worse one. */}
{mapProvider === 'mapbox-gl' && !managed && (
<div className={FIELD_ROW}>
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton field="mapbox_access_token" /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton {...resetOf('mapbox_access_token')} /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
<input
type="text"
value={mapboxToken}
@@ -310,7 +336,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{mapProvider !== 'leaflet' && (
<div className={FIELD_ROW}>
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton field={styleKey} /></>}>
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton {...resetOf(styleKey)} /></>}>
<div className="flex flex-col gap-2">
<CustomSelect
value={mapboxStyle}
@@ -333,7 +359,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{mapProvider === 'mapbox-gl' && (
<>
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton {...resetOf('mapbox_3d_enabled')} /></>}>
<Segmented<string>
label={t('admin.defaultSettings.mapbox3d')}
value={onOffValue(defaults.mapbox_3d_enabled ?? true)}
@@ -345,7 +371,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
</ChoiceRow>
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton {...resetOf('mapbox_quality_mode')} /></>}>
<Segmented<string>
label={t('admin.defaultSettings.mapboxQuality')}
value={onOffValue(defaults.mapbox_quality_mode ?? false)}
@@ -0,0 +1,25 @@
import { ShieldCheck } from 'lucide-react';
import { useFormatDate } from '../../../hooks/useDateFormat';
import { fs } from '../../shared/DialogShell';
import type { VersionInfo } from './pluginModel';
/** A version number with its date and, when the author signed it, a quiet tick. */
export default function VersionLine({ v }: { v: VersionInfo }) {
const { formatDate } = useFormatDate();
return (
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
<span
className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}
style={fs(12.5)}
>
v{v.version}
</span>
{v.publishedAt && (
<span className="text-content-faint" style={fs(11.5)}>
{formatDate(new Date(v.publishedAt))}
</span>
)}
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
</span>
);
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useFormatDate } from '../../hooks/useDateFormat';
export interface AuditEntry {
id: number;
@@ -41,7 +41,7 @@ export function auditUserLabel(e: AuditEntry): string {
* cards render their own markup over it.
*/
export function useAuditLog(serverTimezone?: string) {
const { locale } = useTranslation();
const { formatDateTime } = useFormatDate();
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
@@ -92,7 +92,7 @@ export function useAuditLog(serverTimezone?: string) {
const fmtTime = (iso: string) => {
try {
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
return formatDateTime(new Date(iso.endsWith('Z') ? iso : iso + 'Z'), {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: serverTimezone || undefined,
@@ -1,6 +1,7 @@
import { type ChangeEvent, useEffect, useEffectEvent, useRef, useState } from 'react';
import { backupApi } from '../../api/client';
import { useFormatDate } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { getApiErrorMessage } from '../../types';
@@ -104,7 +105,8 @@ export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const { t, locale } = useTranslation();
const { t } = useTranslation();
const { formatDateTime } = useFormatDate();
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
const loadBackups = async () => {
@@ -241,7 +243,7 @@ export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}
minute: '2-digit',
};
if (serverTimezone) opts.timeZone = serverTimezone;
return new Date(dateStr).toLocaleString(locale, opts);
return formatDateTime(new Date(dateStr), opts);
} catch {
return dateStr;
}
@@ -4,7 +4,7 @@ import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { DistanceUnit, Place, WeekStart } from '../../types';
import type { DateFormat, DistanceUnit, Place, WeekStart } from '../../types';
import { normalizeTileUrl } from '../../utils/tileUrl';
import {
defaultStyleForProvider,
@@ -28,6 +28,7 @@ export type Defaults = RoutingDefaults & {
dark_mode?: string | boolean;
time_format?: string;
week_start?: WeekStart;
date_format?: DateFormat;
default_currency?: string;
blur_booking_codes?: boolean;
map_tile_url?: string;
@@ -2301,15 +2301,12 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
mount([hotel()])
await screen.findByText('Aparthotel Silver')
// The entered amount was nowhere on the row, so $791.57 read as a wrong copy of
// the 801.76 in the dialog. The pill beside the line is the last step.
expect(screen.getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
// Row total and payer chip stay the booked euros at today's rate, and so does what
// I lent: half of 685.26 EUR, the figure settle-up offers. Printing $400.88 here
// had Bob pay back more than he owed and reopened the trip.
expect(screen.getAllByText('$791.55')).toHaveLength(2)
expect(screen.getByText('you lent $395.77')).toBeInTheDocument()
expect(screen.queryByText('you lent $400.88')).toBeNull()
// A bill in the display currency counts as typed (#2965), as the balances net it:
// row total and payer chip read the dollars entered, nothing to explain under it,
// and what I lent is half of them, which settle-up offers too.
expect(screen.queryByText(/^\$801\.76 → /)).toBeNull()
expect(screen.getAllByText('$801.76')).toHaveLength(2)
expect(screen.getByText('you lent $400.88')).toBeInTheDocument()
})
it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => {
@@ -2344,7 +2341,8 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
await waitFor(() => expect(exported).toBeTruthy())
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note')
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/)
// The trip-currency figure it was booked at, then the dollars as typed (#2965).
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;801\.76;$/)
} finally {
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
}
@@ -2357,13 +2355,13 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
])
await screen.findByText('Aparthotel Silver')
// 120 USD at 1.17 is 102.56 EUR, $118.47 today; with the hotel's $791.55 that is
// $910.02, on the day header and on the total spend card.
expect(screen.getByText(/^\$120\.00 → 102,56\s€$/)).toBeInTheDocument()
expect(screen.getByText('$910.02 spent')).toBeInTheDocument()
// Both in the display currency, both as typed (#2965): $801.76 and $120.00 make
// $921.76, on the day header and on the total spend card.
expect(screen.queryByText(/^\$120\.00 → /)).toBeNull()
expect(screen.getByText('$921.76 spent')).toBeInTheDocument()
// Label, its text block, the header row, then the card itself.
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
expect(card.textContent).toContain('$910.02')
expect(card.textContent).toContain('$921.76')
})
it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => {
@@ -2399,7 +2397,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
expect(screen.queryByText(/^\$50\.00 →/)).toBeNull()
})
it('FE-W5COSTS-082: a payment recorded in dollars is booked like an expense, a legacy one stays as entered', async () => {
it('FE-W5COSTS-082: a payment recorded in dollars reads as entered, a legacy one too (#2965)', async () => {
mount([hotel()], {
settlements: [
{ id: 21, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, created_at: '2026-08-07 09:00:00' },
@@ -2410,29 +2408,26 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
})
await screen.findByText('Aparthotel Silver')
expect(await screen.findByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
// The hotel's split chips can carry the same figure, so look past them to the payment.
const outsideChips = (text: string) => screen.queryAllByText(text).filter(el => !el.closest('[data-testid="split-chip"]'))
expect(outsideChips('$395.77')).toHaveLength(1)
expect(await screen.findByText('$30.00')).toBeInTheDocument()
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
expect(outsideChips('$400.88')).toHaveLength(1)
expect(outsideChips('$30.00')).toHaveLength(1)
expect(screen.queryByText('$34.65')).toBeNull()
})
it('FE-W5COSTS-085: the edit dialog previews the rate the save keeps, the one the row was booked at', async () => {
it('FE-W5COSTS-085: the edit dialog has nothing to convert for a bill in the display currency (#2965)', async () => {
const user = userEvent.setup()
mount([hotel()])
await screen.findByText('Aparthotel Silver')
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument()
// Same currency as the list, so the dialog said nothing, and 801.76 there beside
// $791.57 in the list looked like two different expenses.
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
// Each share next to what it counts as, the figure the row's "you lent" is made of.
expect(screen.getByText(/\$400\.88 → \$395\.77 per person/)).toBeInTheDocument()
// The list reads the dollars typed, so there is no conversion to preview.
expect(screen.queryByText(/live rate/)).toBeNull()
expect(screen.getByText(/\$400\.88 per person/)).toBeInTheDocument()
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
})
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
@@ -89,7 +89,8 @@ describe('costsModel: recorded payments', () => {
it('FE-BUDGET-COSTSMODEL-005: a legacy payment without a currency was entered in the display currency', () => {
const inUsd: CostsCtx = { ...ctx, displayCurrency: 'USD' };
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(5);
// Entered in the display currency, it reads as entered (#2965).
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(10);
expect(paymentAmount(payment({ amount: 10, currency: null }), ctx)).toBe(10);
});
+26 -10
View File
@@ -1,6 +1,8 @@
import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared';
import { convertBooked, convertedLine, tripAmountOf } from '../../hooks/useExchangeRates';
import { readDateFormat } from '../../hooks/useDateFormat';
import { convertBooked, convertedLine, inDisplayCurrency, tripAmountOf } from '../../hooks/useExchangeRates';
import type { BudgetItem } from '../../types';
import { formatDisplayDate } from '../../utils/dateFormat';
import { currencyDecimals } from '../../utils/formatters';
import { currenciesWith, SYMBOLS } from './BudgetPanel.constants';
import { paidByUser, readUserNote, settlementDate, splitEqualShares } from './CostsPanel.helpers';
@@ -30,8 +32,12 @@ export function currencyOf(e: BudgetItem, ctx: CostsCtx): string {
return (e.currency || ctx.tripCurrency).toUpperCase();
}
/** An amount of this expense in the display currency, at the rate the expense was booked at. */
/**
* An amount of this expense in the display currency, at the rate the expense was booked at;
* as typed when it was entered in the display currency (#2965).
*/
export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number {
if (inDisplayCurrency(e.currency, ctx.tripCurrency, ctx.displayCurrency)) return amount;
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert);
}
@@ -130,9 +136,14 @@ function paymentCurrency(s: CostsSettlement, ctx: CostsCtx): string {
return (s.currency || ctx.displayCurrency).toUpperCase();
}
/** A recorded payment in the display currency, at the rate frozen when it was settled (#1445). */
/**
* A recorded payment in the display currency, at the rate frozen when it was settled (#1445);
* as entered when it was made in the display currency (#2965).
*/
export function paymentAmount(s: CostsSettlement, ctx: CostsCtx): number {
return convertBooked(s.amount, paymentCurrency(s, ctx), s.exchange_rate, ctx.tripCurrency, ctx.convert);
const currency = paymentCurrency(s, ctx);
if (inDisplayCurrency(currency, ctx.tripCurrency, ctx.displayCurrency)) return s.amount;
return convertBooked(s.amount, currency, s.exchange_rate, ctx.tripCurrency, ctx.convert);
}
/** The line under a payment shown converted, explained the way an expense's is (#2525). */
@@ -369,12 +380,17 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
const fmtDate = (iso: string) => {
if (!iso) return '';
try {
return new Date(iso + 'T00:00:00Z').toLocaleDateString(opts.locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
return formatDisplayDate(
new Date(iso + 'T00:00:00Z'),
opts.locale,
{
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
},
readDateFormat()
);
} catch {
return iso;
}
@@ -23,9 +23,14 @@ describe('editingCurrencyOf', () => {
describe('expenseFxPreview', () => {
it('previews an edit that keeps the currency at the rate frozen on entry', () => {
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })!
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'GBP' })!
expect(fx.inTrip).toBeCloseTo(685.26, 2)
expect(fx.shown).toBeCloseTo(791.55, 2)
expect(fx.shown).toBeCloseTo(convert(801.76 / 1.17, 'EUR'), 2)
})
it('has nothing to preview for a bill in the display currency, which counts as typed (#2965)', () => {
// Frozen at 1.17 while a euro buys 1.1551 today: still the dollars typed.
expect(expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'USD' })).toBeNull()
})
it('has nothing to preview for a bill in the display currency that reads back as typed', () => {
@@ -47,8 +52,8 @@ describe('expenseFxPreview', () => {
describe('splitShareLabel', () => {
it('prints the entered share, then the share the row lends or borrows', () => {
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })
expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', 'en-US')).toBe('$400.88 → $395.77')
const fx = expenseFxPreview({ ...base, total: 1700, currency: 'JPY', editing: null })
expect(splitShareLabel(850, 'JPY', fx, 2, 'USD', 'en-US')).toBe(`${formatMoney(850, 'JPY', 'en-US')} → ${formatMoney(convert(1700, 'JPY') / 2, 'USD', 'en-US')}`)
// Read in the trip currency, the share is the booked euros.
const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })
expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`)
+4 -7
View File
@@ -1,6 +1,6 @@
import { bookedInTrip, useExchangeRates } from '../../hooks/useExchangeRates'
import { useTripStore } from '../../store/tripStore'
import { currencyDecimals, formatMoney } from '../../utils/formatters'
import { formatMoney } from '../../utils/formatters'
/**
* The conversion preview of the expense editors, shared by the desktop ExpenseModal and the
@@ -33,8 +33,8 @@ export interface ExpenseFx {
* What the amount being edited will count as. An edit that keeps the currency keeps the rate
* frozen when the expense was entered (the server re-freezes only when the currency changes),
* so it is previewed at that rate, the way the list will show it after the save. Anything
* else is previewed at today's rate. Null when there is nothing to convert, including a bill
* in the display currency that still reads back as typed.
* else is previewed at today's rate. Null when there is nothing to convert, and for a bill in
* the display currency, which counts as typed (#2965).
*/
export function expenseFxPreview(args: {
total: number
@@ -46,12 +46,9 @@ export function expenseFxPreview(args: {
convert: (amount: number, from: string | null | undefined) => number
}): ExpenseFx | null {
const { total, currency, editing, editingCurrency, tripCurrency, base, convert } = args
if (total === 0) return null
if (total === 0 || currency === base) return null
const inTrip = editing && currency === editingCurrency ? bookedInTrip(total, currency, editing.exchange_rate, tripCurrency) : null
if (currency === base && inTrip == null) return null
const shown = inTrip == null ? convert(total, currency) : convert(inTrip, tripCurrency)
const unit = 10 ** currencyDecimals(base)
if (currency === base && Math.round(shown * unit) === Math.round(total * unit)) return null
return { inTrip, shown: inTrip != null && tripCurrency === base ? null : shown }
}
@@ -3,6 +3,7 @@ import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell'
import MSheet from '../../mobile/components/MSheet'
import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
@@ -64,6 +65,7 @@ export default function DawarichAcceptDialog({
onConfirm,
}: DawarichAcceptDialogProps): React.ReactElement | null {
const { t, locale } = useTranslation()
const { formatDateTime } = useFormatDate()
const phone = useIsPhone()
const labelId = useId()
@@ -140,7 +142,7 @@ export default function DawarichAcceptDialog({
</span>
<p className="min-w-0 flex-1 text-caption text-content">
{t('dawarich.accept.recorded', {
from: new Date(suggestion.startedAt).toLocaleString(locale),
from: formatDateTime(new Date(suggestion.startedAt)),
to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
})}
</p>
@@ -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>
}
/**
@@ -9,6 +9,8 @@ import { airtrailApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { isEffectivelyOffline } from '../../sync/networkMode'
import { parseReservationMetadata } from '../../utils/flightLegs'
import { useDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
interface AirTrailImportModalProps {
isOpen: boolean
@@ -52,15 +54,15 @@ export function detectConnections(flights: AirtrailFlight[]): AirtrailFlight[][]
}
/** Locale-aware date (e.g. de → 13.06.2026, en-US → 06/13/2026). */
function fmtDate(d: string | null, locale: string): string {
function fmtDate(d: string | null, locale: string, dateFormat: string): string {
if (!d) return ''
try {
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, {
return formatDisplayDate(new Date(d + 'T00:00:00Z'), locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: 'UTC',
})
}, dateFormat)
} catch {
return d
}
@@ -68,6 +70,7 @@ function fmtDate(d: string | null, locale: string): string {
export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo }: AirTrailImportModalProps) {
const { t, locale } = useTranslation()
const dateFormat = useDateFormat()
const toast = useToast()
const trip = useTripStore(s => s.trip)
const reservations = useTripStore(s => s.reservations)
@@ -270,7 +273,7 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<span style={{ display: 'block', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)' }}>
{f.fromCode ?? f.fromName ?? '?'} → {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale)}` : ''}
{f.fromCode ?? f.fromName ?? '?'} → {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale, dateFormat)}` : ''}
</span>
</span>
{already && (
@@ -1,12 +1,15 @@
import { createPortal } from 'react-dom'
import { CalendarDays, Pencil, Trash2, ChevronDown, Check, Eye } from 'lucide-react'
import type { SidebarState } from './usePlacesSidebar'
import { useDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
export function MobileDayPickerSheet(S: SidebarState) {
const {
dayPickerPlace, setDayPickerPlace, setMobileShowDays, onPlaceClick, canEditPlaces, onEditPlace,
t, days, mobileShowDays, onAssignToDay, assignments, onDeletePlace,
} = S
const dateFormat = useDateFormat()
return createPortal(
<div
role="presentation"
@@ -64,7 +67,7 @@ export function MobileDayPickerSheet(S: SidebarState) {
<div className="bg-surface-tertiary text-content" style={{ width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{i + 1}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{day.title || t('dayplan.dayN', { n: i + 1 })}</div>
{day.date && <div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { timeZone: 'UTC' })}</div>}
{day.date && <div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{formatDisplayDate(new Date(day.date + 'T00:00:00Z'), undefined, { timeZone: 'UTC' }, dateFormat)}</div>}
</div>
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <Check size={14} color="var(--text-faint)" />}
</button>
@@ -1,6 +1,7 @@
import { cheapestEnergy, type ChargingEnergyPrice, type ChargingInfo as Info } from '@trek/shared';
import { ChevronDown, Coins, PlugZap } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useFormatDate } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n/TranslationContext';
import { chargingRepo } from '../../repo/chargingRepo';
import { useTripStore } from '../../store/tripStore';
@@ -76,6 +77,7 @@ export default function ChargingInfo({
}: ChargingTarget & { compact?: boolean }) {
const tripId = useTripStore((s) => s.trip?.id);
const { t } = useTranslation();
const { formatDateTime } = useFormatDate();
const [info, setInfo] = useState<Info | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -133,7 +135,7 @@ export default function ChargingInfo({
const cheapest = info ? cheapestEnergy(info.tariffs) : null;
const price = (value: number, currency: string) =>
new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value);
const label = `${status}${info?.updatedAt ? ` (${new Date(info.updatedAt).toLocaleString()})` : ''}`;
const label = `${status}${info?.updatedAt ? ` (${formatDateTime(new Date(info.updatedAt))})` : ''}`;
if (compact)
return (
<ChargingBadges
@@ -211,7 +213,7 @@ export default function ChargingInfo({
</a>
<br />
{info.license}
{info.updatedAt && ` · ${new Date(info.updatedAt).toLocaleString()}`}
{info.updatedAt && ` · ${formatDateTime(new Date(info.updatedAt))}`}
</p>
)}
</div>
@@ -11,6 +11,8 @@ import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, Foot
import { EditorField, GRID_2, INPUT, LABEL } from '../shared/dialogParts'
import { SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingsHint, StatusPill } from './settingsKit'
import { useTranslation } from '../../i18n'
import { useDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
import { useToast } from '../shared/Toast'
import { authApi } from '../../api/client'
@@ -55,10 +57,10 @@ const SCOPE_ICONS: Record<PublicApiScope, LucideIcon> = {
}
/** SQLite hands out "YYYY-MM-DD HH:MM:SS" in UTC, which Safari will not parse as it stands. */
function formatStamp(ts: string, locale: string): string {
function formatStamp(ts: string, locale: string, dateFormat: string): string {
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T')
const d = new Date(/(Z|[+-]\d{2}:?\d{2})$/.test(iso) ? iso : `${iso}Z`)
return Number.isNaN(d.getTime()) ? ts : d.toLocaleDateString(locale)
return Number.isNaN(d.getTime()) ? ts : formatDisplayDate(d, locale, {}, dateFormat)
}
/**
@@ -380,6 +382,7 @@ export default function ApiKeysSection(): React.ReactElement {
function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: string; onDelete: () => void }): React.ReactElement {
const { t } = useTranslation()
const dateFormat = useDateFormat()
const scopes = apiKey.scope_mode === 'limited' ? apiKey.scopes : undefined
return (
<li className="flex items-start gap-3 px-3.5 py-3">
@@ -394,10 +397,10 @@ function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: strin
</code>
</div>
<p className="m-0 mt-0.5 tabular-nums text-content-faint first-letter:uppercase" style={fs(11.5)}>
{t('settings.apiKeys.createdAt')} {formatStamp(apiKey.created_at, locale)}
{t('settings.apiKeys.createdAt')} {formatStamp(apiKey.created_at, locale, dateFormat)}
{', '}
{apiKey.last_used_at
? `${t('settings.apiKeys.usedAt')} ${formatStamp(apiKey.last_used_at, locale)}`
? `${t('settings.apiKeys.usedAt')} ${formatStamp(apiKey.last_used_at, locale, dateFormat)}`
: t('settings.apiKeys.neverUsed')}
</p>
{/* What the key may read. Shown on the row rather than behind a detail
@@ -1,6 +1,7 @@
import React from 'react'
import { Loader2, Save, Plug, RefreshCw, Unplug } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import { useDawarichConnection } from '../../hooks/useDawarichConnection'
import DawarichIcon from '../shared/DawarichIcon'
import Section from './Section'
@@ -23,7 +24,7 @@ import { fs } from '../shared/DialogShell'
* no reason is the version of this card that generates support questions.
*/
export default function DawarichConnectionSection(): React.ReactElement {
const { t, locale } = useTranslation()
const { t } = useTranslation()
const S = useDawarichConnection()
return (
@@ -123,7 +124,7 @@ export default function DawarichConnectionSection(): React.ReactElement {
)}
</div>
<DawarichConnectionStatus state={S} locale={locale} />
<DawarichConnectionStatus state={S} />
</Section>
)
}
@@ -138,12 +139,11 @@ export default function DawarichConnectionSection(): React.ReactElement {
*/
function DawarichConnectionStatus({
state,
locale,
}: {
state: ReturnType<typeof useDawarichConnection>
locale: string
}): React.ReactElement | null {
const { t } = useTranslation()
const { formatDateTime } = useFormatDate()
if (!state.connected && !state.probeMessage) return null
const missing: string[] = []
@@ -161,7 +161,7 @@ function DawarichConnectionStatus({
{state.connected && (
<p className="m-0 text-content-secondary">
{state.lastSyncAt
? t('dawarich.lastSync', { when: new Date(state.lastSyncAt).toLocaleString(locale) })
? t('dawarich.lastSync', { when: formatDateTime(new Date(state.lastSyncAt)) })
: t('dawarich.neverSynced')}
{state.lastSyncState === 'partial' && `, ${t('dawarich.syncPartial')}`}
</p>
@@ -1,4 +1,4 @@
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-055
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-057
import { render, screen, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -484,6 +484,35 @@ describe('DisplaySettingsTab week start (#2029)', () => {
});
});
// ── Date format (056, 057) ────────────────────────────────────────────────────
describe('DisplaySettingsTab date format (#2971)', () => {
it('FE-COMP-DISPLAY-056: shows the language format when unset and saves the format picked', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ date_format: undefined }), updateSetting });
render(<DisplaySettingsTab />);
// Each choice is labelled by how it writes 31 December; auto carries the language's own way.
await user.click(screen.getByRole('button', { name: /\(12\/31\/2026\)/ }));
await user.click(await screen.findByText('31.12.2026'));
expect(updateSetting).toHaveBeenCalledWith('date_format', 'DD.MM.YYYY');
});
it('FE-COMP-DISPLAY-057: a rejected change surfaces the error', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockRejectedValue(new Error('Save failed'));
seedStore(useSettingsStore, { settings: buildSettings({ date_format: 'YYYY-MM-DD' }), updateSetting });
render(<><ToastContainer /><DisplaySettingsTab /></>);
await user.click(screen.getByRole('button', { name: /2026-12-31/ }));
await user.click(await screen.findByText('31/12/2026'));
expect(await screen.findByText('Save failed')).toBeInTheDocument();
});
});
describe('DisplaySettingsTab – the demo account', () => {
it('FE-COMP-DISPLAY-DEMO-001: the shared demo account sees English under the demo lock', () => {
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
@@ -16,8 +16,9 @@ import { useSettingSaver } from './useSettingSaver'
import { TRIP_TAB_IDS, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDestination'
import type { DistanceUnit } from '../../types'
import { DEFAULT_WEEK_START } from '@trek/shared'
import { DEFAULT_DATE_FORMAT, DEFAULT_WEEK_START } from '@trek/shared'
import { weekStartOptions } from '../../utils/calendarWeek'
import { dateFormatOptions } from '../../utils/dateFormat'
import DemoLock from '../Demo/DemoLock'
import { useDemoAccountLock } from '../Demo/useDemoLock'
@@ -265,6 +266,23 @@ export default function DisplaySettingsTab(): React.ReactElement {
</div>
}
/>
{/* Date format: every date field and every date written in numbers (#2971) */}
<SettingRow
label={t('settings.dateFormat')}
hint={t('settings.dateFormatHint')}
control={
<div className={SELECT_BOX}>
<CustomSelect
value={settings.date_format || DEFAULT_DATE_FORMAT}
onChange={v => {
void save('date_format', String(v))
}}
options={dateFormatOptions(locale, t('settings.dateFormatAuto'))}
/>
</div>
}
/>
</SettingRows>
</Section>
@@ -1,6 +1,7 @@
import Section from './Section'
import React, { useId } from 'react'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import { useToast } from '../shared/Toast'
import { Trash2, Copy, Terminal, Plus, Check, Key, KeyRound, ChevronRight, RefreshCw, AlertTriangle, Link2 } from 'lucide-react'
import PhotoProvidersSection from './PhotoProvidersSection'
@@ -62,12 +63,13 @@ export default function IntegrationsTab(): React.ReactElement {
function useIntegrations() {
const { t, locale } = useTranslation()
const { formatDate } = useFormatDate()
const toast = useToast()
const { mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled, wandererEnabled, managed } = useIntegrationGates({ reloadAddons: true })
const mcp = useMcpIntegration({ enabled: mcpEnabled, reloadClientsAfterWrite: false })
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled,
return { t, locale, formatDate, toast, mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled,
wandererEnabled,
managed, ...mcp,
}
@@ -171,7 +173,7 @@ function ConfigBlock({ open, onToggle, json, hint, copied, onCopy, t }: {
/** One registered OAuth client: what it is, the scopes it may use, and its two actions. */
function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsState }) {
const { t, locale, oauthScopesExpanded, setOauthScopesExpanded, setOauthRotateId, setOauthDeleteId } = S
const { t, formatDate, oauthScopesExpanded, setOauthScopesExpanded, setOauthRotateId, setOauthDeleteId } = S
const expanded = !!oauthScopesExpanded[client.id]
return (
<div className={`${ROW} items-start`}>
@@ -185,7 +187,7 @@ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsSta
</div>
<div className="mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
<span className="min-w-0 truncate font-geist">{t('settings.oauth.clientId')}: {client.client_id}</span>
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(client.created_at))}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1" style={fs(10.5)}>
{(expanded ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => (
@@ -216,7 +218,7 @@ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsSta
/** The OAuth tab: the snippet, the registered clients and the sessions they hold. */
function OAuthTab({ S }: { S: IntegrationsState }) {
const {
t, locale, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, configOpenOAuth, setConfigOpenOAuth, copiedKey, mcpJsonConfigOAuth, handleCopy,
t, formatDate, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, configOpenOAuth, setConfigOpenOAuth, copiedKey, mcpJsonConfigOAuth, handleCopy,
} = S
return (
<>
@@ -260,7 +262,7 @@ function OAuthTab({ S }: { S: IntegrationsState }) {
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{session.client_name}</p>
<p className="m-0 mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
<span className="min-w-0 truncate">{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}</span>
<span className="tabular-nums">{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}</span>
<span className="tabular-nums">{t('settings.oauth.sessionExpires')} {formatDate(new Date(session.access_token_expires_at))}</span>
</p>
</div>
<button type="button" onClick={() => setOauthRevokeId(session.id)}
@@ -278,7 +280,7 @@ function OAuthTab({ S }: { S: IntegrationsState }) {
/** The deprecated API tokens tab: the warning, the snippet and the tokens. */
function ApiTokensTab({ S }: { S: IntegrationsState }) {
const { t, locale, configOpenToken, setConfigOpenToken, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S
const { t, formatDate, configOpenToken, setConfigOpenToken, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S
return (
<>
<WarningNote>{t('settings.mcp.apiTokensDeprecated')}</WarningNote>
@@ -311,9 +313,9 @@ function ApiTokensTab({ S }: { S: IntegrationsState }) {
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{token.name}</p>
<p className="m-0 mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
<span className="font-geist">{token.token_prefix}...</span>
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(token.created_at))}</span>
{token.last_used_at && (
<span className="tabular-nums">{t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
<span className="tabular-nums">{t('settings.mcp.tokenUsedAt')} {formatDate(new Date(token.last_used_at))}</span>
)}
</p>
</div>
@@ -2,9 +2,11 @@ import { escapeHtml } from '@trek/shared';
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { adminApi, authApi } from '../../api/client';
import { useDateFormat } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import { getApiErrorMessage, type UserWithOidc } from '../../types';
import { formatDisplayDateTime } from '../../utils/dateFormat';
import { passwordErrorKey } from '../../utils/passwordError';
import { useToast } from '../shared/Toast';
@@ -35,6 +37,7 @@ export function useAccountSettings({ avatarRemoveErrorKey, ignoreEmptyBackupCode
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { t } = useTranslation();
const dateFormat = useDateFormat();
const toast = useToast();
const avatarInputRef = useRef<HTMLInputElement>(null);
@@ -126,7 +129,7 @@ export function useAccountSettings({ avatarRemoveErrorKey, ignoreEmptyBackupCode
if (skipBackupCodes) return;
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
</head><body><h1>TREK MFA Backup Codes</h1><p>${escapeHtml(new Date().toLocaleString())}</p><pre>${escapeHtml(backupCodesText)}</pre></body></html>`;
</head><body><h1>TREK MFA Backup Codes</h1><p>${escapeHtml(formatDisplayDateTime(new Date(), undefined, {}, dateFormat))}</p><pre>${escapeHtml(backupCodesText)}</pre></body></html>`;
const w = window.open('', '_blank', 'width=900,height=700');
if (!w) return;
w.document.open();
@@ -1,8 +1,10 @@
import { startRegistration } from '@simplewebauthn/browser';
import { useEffect, useState } from 'react';
import { authApi, type PasskeyCredential } from '../../api/client';
import { readDateFormat } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { getApiErrorMessage } from '../../types';
import { formatDisplayDate } from '../../utils/dateFormat';
import { useToast } from '../shared/Toast';
/** Parse a SQLite UTC timestamp ("YYYY-MM-DD HH:MM:SS") into a local date string. */
@@ -10,7 +12,7 @@ export function fmtDate(ts: string | null): string | null {
if (!ts) return null;
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T');
const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
return Number.isNaN(d.getTime()) ? null : d.toLocaleDateString();
return Number.isNaN(d.getTime()) ? null : formatDisplayDate(d, undefined, {}, readDateFormat());
}
/** True when the browser cancellation / no-matching-credential DOMExceptions fire. */
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import { pluginsApi } from '../../api/client';
import { useDateFormat } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { formatDisplayDateTime } from '../../utils/dateFormat';
export interface ActivityRow {
ts: string;
@@ -18,6 +20,7 @@ export interface ActivityRow {
*/
export function usePluginActivity() {
const { locale } = useTranslation();
const dateFormat = useDateFormat();
const [rows, setRows] = useState<ActivityRow[]>([]);
const [loading, setLoading] = useState(true);
@@ -36,7 +39,7 @@ export function usePluginActivity() {
const fmtWhen = (ts: string): string => {
const d = new Date(ts);
return Number.isNaN(d.getTime()) ? ts : d.toLocaleString(locale);
return Number.isNaN(d.getTime()) ? ts : formatDisplayDateTime(d, locale, {}, dateFormat);
};
return { rows, loading, load, fmtWhen };
@@ -18,8 +18,10 @@ import {
} from 'lucide-react';
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { useDateFormat } from '../../../hooks/useDateFormat';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDisplayDate } from '../../../utils/dateFormat';
import { formatDistance, formatElevation } from '../../../utils/units';
import { BarButton, WhiteBadge } from '../../Planner/planParts';
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell';
@@ -428,12 +430,13 @@ function SnapshotNotice({
onOpen: (placeId: number) => void;
}) {
const { t } = useTranslation();
const dateFormat = useDateFormat();
const snapshot = preview.snapshot;
if (preview.state === 'new' || !snapshot) {
return <TourNotice>{t('wanderer.preview.newHint')}</TourNotice>;
}
const when = snapshot.importedAt
? new Date(`${snapshot.importedAt.replace(' ', 'T')}Z`).toLocaleDateString(locale)
? formatDisplayDate(new Date(`${snapshot.importedAt.replace(' ', 'T')}Z`), locale, {}, dateFormat)
: '';
const open = (
<button
@@ -78,6 +78,19 @@ afterEach(() => {
delete window.__addToast
})
// The hire date is the shared date picker (#2971): typed through its manual
// field, cleared from its calendar.
function typeDate(trigger: HTMLElement, value: string) {
fireEvent.click(within(trigger.parentElement as HTMLElement).getByRole('button', { name: /enterManually|Enter date manually/ }))
const input = screen.getByRole('textbox', { name: /enterManually|Enter date manually/ })
fireEvent.change(input, { target: { value } })
fireEvent.keyDown(input, { key: 'Enter' })
}
function clearDate(trigger: HTMLElement) {
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: /clearDate|Clear date/ }))
}
describe('VacaySettings calendars', () => {
it('FE-COMP-VCYSET-001: the week-start buttons save the other day', () => {
const updatePlan = vi.fn(async (_u: Partial<VacayPlan>) => {})
@@ -312,14 +325,15 @@ describe('VacaySettings calendars', () => {
})
render(<VacaySettings onClose={vi.fn()} />)
expect(screen.getByLabelText('Hire date')).toHaveValue('2020-09-14')
const hire = () => screen.getByRole('button', { name: /^Hire date: / })
expect(hire()).toHaveAccessibleName(/2020/)
fireEvent.change(screen.getByLabelText('Hire date'), { target: { value: '2021-03-02' } })
typeDate(hire(), '2021-03-02')
expect(updateYearSettings).toHaveBeenLastCalledWith({
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: '2021-03-02',
})
fireEvent.change(screen.getByLabelText('Hire date'), { target: { value: '' } })
clearDate(hire())
expect(updateYearSettings).toHaveBeenLastCalledWith({
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: null,
})
@@ -3,6 +3,7 @@ import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRigh
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useSchoolHolidayCountries } from './useSchoolHolidayCountries'
import type { VacayHolidayCalendar } from '../../types'
@@ -355,12 +356,12 @@ function YearTypePicker() {
{type === 'anniversary' && (
<div style={{ paddingInlineStart: 36, marginTop: 8 }}>
<input
type="date"
<CustomDatePicker
value={yearSettings.hire_date || ''}
onChange={e => save({ hire_date: e.target.value || null })}
aria-label={t('vacay.hireDate')}
style={{ width: '100%', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, padding: '8px 14px', borderRadius: 10, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
onChange={v => save({ hire_date: v || null })}
placeholder={t('vacay.hireDate')}
ariaLabel={t('vacay.hireDate')}
style={{ width: '100%' }}
/>
{!yearSettings.hire_date && (
<p className="text-[11px] mt-1 text-content-faint">{t('vacay.hireDateHint')}</p>
@@ -819,3 +819,82 @@ describe('CustomDatePicker week start (#2029)', () => {
expect(await openMarch2026('saturday')).toEqual({ header: 'SSMTWTF', blanks: 1 });
});
});
// ─── Date format (#2971) ──────────────────────────────────────────────────────
describe('CustomDatePicker date format (#2971)', () => {
const onChange = vi.fn();
const original = useSettingsStore.getState().settings;
beforeEach(() => vi.clearAllMocks());
afterEach(() => useSettingsStore.setState({ settings: original }));
const seed = (dateFormat: 'auto' | 'YYYY-MM-DD' | 'DD.MM.YYYY') =>
useSettingsStore.setState({ settings: { ...original, date_format: dateFormat } });
function typeDate(value: string) {
fireEvent.click(screen.getByRole('button', { name: /enter date manually/i }));
const input = screen.getByRole('textbox', { name: 'Enter date manually' });
fireEvent.change(input, { target: { value } });
fireEvent.keyDown(input, { key: 'Enter' });
}
it('FE-COMP-DATEPICKER-036: the trigger writes the date in the chosen format', () => {
seed('YYYY-MM-DD');
render(<CustomDatePicker value="2026-11-03" onChange={onChange} />);
expect(screen.getAllByRole('button')[0].textContent).toContain('2026-11-03');
});
it('FE-COMP-DATEPICKER-037: compact mode writes the chosen format with the whole year', () => {
seed('DD.MM.YYYY');
render(<CustomDatePicker value="2026-11-03" onChange={onChange} compact />);
expect(screen.getByText('03.11.2026')).toBeInTheDocument();
});
it('FE-COMP-DATEPICKER-038: the manual entry is hinted and pre-filled in the chosen format', () => {
seed('YYYY-MM-DD');
render(<CustomDatePicker value="2026-11-03" onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: /enter date manually/i }));
const input = screen.getByRole('textbox', { name: 'Enter date manually' });
expect(input).toHaveProperty('placeholder', 'YYYY-MM-DD');
expect(input).toHaveProperty('value', '2026-11-03');
});
it('FE-COMP-DATEPICKER-039: a typed date is read in the order of the chosen format', () => {
seed('YYYY-MM-DD');
render(<CustomDatePicker value="" onChange={onChange} />);
typeDate('2026/11/04');
expect(onChange).toHaveBeenCalledWith('2026-11-04');
});
it('FE-COMP-DATEPICKER-040: day first under DD.MM.YYYY, even with an English interface', () => {
seed('DD.MM.YYYY');
render(<CustomDatePicker value="" onChange={onChange} />);
typeDate('04.11.2026');
expect(onChange).toHaveBeenCalledWith('2026-11-04');
});
it('FE-COMP-DATEPICKER-043: a four-digit year written first is read as year, month, day in any format', () => {
seed('DD.MM.YYYY');
render(<CustomDatePicker value="" onChange={onChange} />);
typeDate('2026/11/04');
expect(onChange).toHaveBeenCalledWith('2026-11-04');
});
it('FE-COMP-DATEPICKER-041: auto keeps the short month of the language on the trigger', () => {
seed('auto');
render(<CustomDatePicker value="2026-11-03" onChange={onChange} />);
expect(screen.getAllByRole('button')[0].textContent).toMatch(/Nov/);
});
it('FE-COMP-DATEPICKER-042: auto keeps the language for typing: month first in English', () => {
seed('auto');
render(<CustomDatePicker value="" onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: /enter date manually/i }));
const input = screen.getByRole('textbox', { name: 'Enter date manually' });
expect(input).toHaveProperty('placeholder', 'MM/DD/YYYY');
fireEvent.change(input, { target: { value: '04.11.2026' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith('2026-04-11');
});
});
@@ -4,6 +4,8 @@ import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from '../../i18n';
import { dateInputPattern } from '../../utils/dateInputPattern';
import { type DateField, dateFieldOrder, dateLayout, formatDisplayDate } from '../../utils/dateFormat';
import { useDateFormat } from '../../hooks/useDateFormat';
import { useRemeasureSignal } from '../../hooks/useAnchoredPosition';
import { useWeekStartDay } from '../../hooks/useWeekStartDay';
import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek';
@@ -27,6 +29,8 @@ interface CustomDatePickerProps {
// disabled in the calendar and rejected on manual entry.
min?: string;
max?: string;
/** What the field is, for a picker without a visible label around it; the trigger reads it with its date. */
ariaLabel?: string;
}
export function CustomDatePicker({
@@ -39,9 +43,11 @@ export function CustomDatePicker({
disabled = false,
min,
max,
ariaLabel,
}: CustomDatePickerProps) {
const { locale, t } = useTranslation();
const weekStart = useWeekStartDay();
const dateFormat = useDateFormat();
const [open, setOpen] = useState(false);
const [view, setView] = useState<CalendarView>('days');
const [yearPageStart, setYearPageStart] = useState(0);
@@ -149,22 +155,17 @@ export function CustomDatePicker({
);
const years = Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i);
const displayValue = parsed
? parsed.toLocaleDateString(
locale,
compact
? { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' }
: { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }
)
: null;
// A chosen date format (#2971) writes the field in numbers, the whole year
// included; 'auto' keeps the language's short month as before.
const fieldOptions: Intl.DateTimeFormatOptions = dateLayout(dateFormat)
? { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }
: compact
? { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' }
: { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' };
const displayValue = parsed ? formatDisplayDate(parsed, locale, fieldOptions, dateFormat) : null;
const inputValue = parsed
? parsed.toLocaleDateString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
})
? formatDisplayDate(parsed, locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }, dateFormat)
: '';
// Inclusive range check on ISO (YYYY-MM-DD) strings — lexicographic order
@@ -212,13 +213,11 @@ export function CustomDatePicker({
return;
}
// Determine field order for active locale via Intl
const parts = new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'numeric', year: 'numeric' }).formatToParts(
new Date(2001, 5, 15)
); // known date: June 15, 2001
const order = parts
.filter((p) => ['day', 'month', 'year'].includes(p.type))
.map((p) => p.type as 'day' | 'month' | 'year');
// The order of the chosen date format (#2971), or the active locale's. A
// four-digit first number is a year in every format, so 2026/11/04 or
// 2026.11.04 reads as year, month, day whichever format is set.
const yearFirst = /^\D*\d{4}\D/.test(input);
const order: readonly DateField[] = yearFirst ? ['year', 'month', 'day'] : dateFieldOrder(locale, dateFormat);
// Strip non-numeric chars (handles RTL marks, dots, slashes, spaces)
const nums = input
@@ -277,7 +276,7 @@ export function CustomDatePicker({
if (e.key === 'Escape') setIsTyping(false);
}}
onBlur={handleTextSubmit}
placeholder={dateInputPattern(locale)}
placeholder={dateInputPattern(locale, dateFormat)}
aria-label={t('common.datepicker.enterManually')}
style={{
width: '100%',
@@ -302,7 +301,11 @@ export function CustomDatePicker({
type="button"
onClick={() => setOpen((o) => !o)}
disabled={disabled}
aria-label={displayValue || placeholder || t('common.date')}
aria-label={
ariaLabel
? [ariaLabel, displayValue].filter(Boolean).join(': ')
: displayValue || placeholder || t('common.date')
}
aria-expanded={open}
aria-haspopup="dialog"
style={{
@@ -700,9 +703,12 @@ interface CustomDateTimePickerProps {
onChange: (value: string) => void;
placeholder?: string;
style?: React.CSSProperties;
/** Earliest and latest day (YYYY-MM-DD), as CustomDatePicker takes them. */
min?: string;
max?: string;
}
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }: CustomDateTimePickerProps) {
export function CustomDateTimePicker({ value, onChange, placeholder, style = {}, min, max }: CustomDateTimePickerProps) {
const { locale } = useTranslation();
const [datePart, timePart] = (value || '').split('T');
@@ -716,7 +722,13 @@ export function CustomDateTimePicker({ value, onChange, placeholder, style = {}
return (
<div style={{ display: 'flex', gap: 8, ...style }}>
<CustomDatePicker value={datePart || ''} onChange={handleDateChange} style={{ flex: 1, minWidth: 0 }} />
<CustomDatePicker
value={datePart || ''}
onChange={handleDateChange}
style={{ flex: 1, minWidth: 0 }}
min={min}
max={max}
/>
<div style={{ width: 110, flexShrink: 0 }}>
<CustomTimePicker value={timePart || ''} onChange={handleTimeChange} />
</div>
+33
View File
@@ -0,0 +1,33 @@
import { DEFAULT_DATE_FORMAT, type DateFormat } from '@trek/shared';
import { useCallback } from 'react';
import { useTranslation } from '../i18n';
import { useSettingsStore } from '../store/settingsStore';
import { formatDisplayDate, formatDisplayDateTime } from '../utils/dateFormat';
/** The current user's date format (#2971); 'auto' follows the interface language. */
export function useDateFormat(): DateFormat {
return useSettingsStore((s) => s.settings.date_format) ?? DEFAULT_DATE_FORMAT;
}
/** The same value outside React (stores, exports, plain helpers), read when called. */
export function readDateFormat(): DateFormat {
return useSettingsStore.getState().settings.date_format ?? DEFAULT_DATE_FORMAT;
}
/**
* `toLocaleDateString` and `toLocaleString` bound to the interface language and
* the user's date format, for components that write dates.
*/
export function useFormatDate() {
const { locale } = useTranslation();
const format = useDateFormat();
const date = useCallback(
(value: Date, opts?: Intl.DateTimeFormatOptions) => formatDisplayDate(value, locale, opts, format),
[locale, format]
);
const dateTime = useCallback(
(value: Date, opts?: Intl.DateTimeFormatOptions) => formatDisplayDateTime(value, locale, opts, format),
[locale, format]
);
return { formatDate: date, formatDateTime: dateTime, locale, dateFormat: format };
}
+12 -5
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import { bookedInTrip, convertBooked, convertedLine, crossRate, fetchExchangeRates, fetchFreshRates, freshRates, clearExchangeRateCache, tripAmountOf, useExchangeRates, withFallbackFx } from './useExchangeRates'
import { bookedInTrip, convertBooked, convertedLine, inDisplayCurrency, crossRate, fetchExchangeRates, fetchFreshRates, freshRates, clearExchangeRateCache, tripAmountOf, useExchangeRates, withFallbackFx } from './useExchangeRates'
const FX_URL = 'https://api.frankfurter.dev/v2/rates'
@@ -182,10 +182,9 @@ describe('convertBooked', () => {
expect(convertBooked(100, 'eur', 1, 'EUR', live)).toBe(100)
})
// #2525: a euro trip read in dollars, the bill entered in dollars. It was booked at 685.26
// EUR, and that is what the balances and settle-up net and then show at today's rate. The
// row has to show the same figure, or paying back the "you lent" it printed reopens the trip.
it('reads an amount in the display currency through the trip currency too', () => {
// #2525: a euro trip read in dollars, the bill entered in dollars. Without the display
// currency the row goes through the trip currency at its frozen rate, as before.
it('reads an amount through the trip currency when no display currency is given', () => {
const liveUsd = (amount: number, from: string | null | undefined): number => {
const rates: Record<string, number> = { USD: 1, EUR: 1 / 1.1398 }
const r = rates[(from || 'USD').toUpperCase()]
@@ -197,6 +196,14 @@ describe('convertBooked', () => {
// Without a booked rate there is nothing to go through, and dollars stay dollars.
expect(convertBooked(801.76, 'USD', 1, 'EUR', liveUsd)).toBe(801.76)
})
// #2965: read in dollars, a bill entered in dollars counts as typed, as the balances net it.
it('knows a row entered in the display currency, a NULL currency being the trip one', () => {
expect(inDisplayCurrency('usd', 'EUR', 'USD')).toBe(true)
expect(inDisplayCurrency(null, 'EUR', 'eur')).toBe(true)
expect(inDisplayCurrency(null, 'EUR', 'USD')).toBe(false)
expect(inDisplayCurrency('USD', 'EUR', 'GBP')).toBe(false)
})
})
describe('bookedInTrip', () => {
+13
View File
@@ -141,6 +141,19 @@ export function convertBooked(
return convertLive(amount, (rowCurrency || trip).toUpperCase())
}
/**
* Whether a row was entered in the display currency (#2965). Such a row counts as typed:
* through the trip currency at its frozen rate and back at today's it drifted off the
* amount entered, and the balances, which net such rows as typed, would disagree.
*/
export function inDisplayCurrency(
rowCurrency: string | null | undefined,
tripCurrency: string,
displayCurrency: string,
): boolean {
return ((rowCurrency || tripCurrency) || '').toUpperCase() === (displayCurrency || '').toUpperCase()
}
/**
* The amount a row was booked at in the trip currency, when it was booked through one: a
* foreign currency with a frozen rate. Null for a row in the trip currency, and for one
@@ -5,6 +5,7 @@ import { MapView } from '../../../components/Map/MapView'
import { SYMBOLS, currenciesWith } from '../../../components/Budget/BudgetPanel.constants'
import { getApiErrorMessage, type DistanceUnit, type WeekStart } from '../../../types'
import { weekStartOptions } from '../../../utils/calendarWeek'
import { dateFormatOptions } from '../../../utils/dateFormat'
import { withTileApiKey } from '../../../utils/tileUrl'
import { defaultStyleForProvider } from '../../../components/Map/glProviders'
import type { MapProvider } from '../../../components/Settings/mapSettingsModel'
@@ -19,6 +20,25 @@ import RoutingInstanceFields from '../../../components/Admin/RoutingInstanceFiel
// Mobile-native rebuild of the desktop DefaultUserSettingsTab. Identical data
// layer (adminApi defaults, per-change auto-save, reset-to-built-in) — only the
// presentation is relaid on the admin mobile design system.
interface ResetProps {
shown: boolean
onReset: () => void
label: string
}
function ResetButton({ shown, onReset, label }: ResetProps): React.ReactElement | null {
if (!shown) return null
return (
<button
type="button"
onClick={onReset}
className="ms-2 font-geist text-[0.625rem] font-medium text-m-faint underline"
>
{label}
</button>
)
}
export default function MAdminDefaultUserSettings(): React.ReactElement {
const { t, locale } = useTranslation()
const {
@@ -27,19 +47,15 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
} = useDefaultUserSettings({ errorMessage: getApiErrorMessage, previewCreatedAt: () => new Date().toISOString() })
const [currencyOpen, setCurrencyOpen] = useState(false)
const [dateFormatOpen, setDateFormatOpen] = useState(false)
const [presetOpen, setPresetOpen] = useState(false)
const [styleOpen, setStyleOpen] = useState(false)
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button
type="button"
onClick={() => reset(field)}
className="ms-2 font-geist text-[0.625rem] font-medium text-m-faint underline"
>
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
) : null
const resetOf = (field: keyof Defaults): ResetProps => ({
shown: isSet(field),
onReset: () => reset(field),
label: t('admin.defaultSettings.resetToBuiltIn'),
})
if (!loaded) {
return (
@@ -90,6 +106,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
const tilePresetLabel = presets.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')
const stylePresetLabel = glStylePresets.find((p) => p.url === mapboxStyle)?.name || t('admin.defaultSettings.mapboxStylePlaceholder')
const chevron = <ChevronDown size={13} strokeWidth={2} className="flex-none text-m-faint" />
const dateFormats = dateFormatOptions(locale, t('settings.dateFormatAuto'))
return (
<div className="space-y-3">
@@ -99,33 +116,42 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
<div className="mt-2 space-y-[14px]">
{/* Color Mode */}
<MAdminField label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
<MAdminField label={<>{t('settings.colorMode')} <ResetButton {...resetOf('dark_mode')} /></>}>
<MSegmented value={colorModeValue} onChange={(v) => save({ dark_mode: v })} options={modeOptions} />
</MAdminField>
{/* Temperature */}
<MAdminField label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
<MAdminField label={<>{t('settings.temperature')} <ResetButton {...resetOf('temperature_unit')} /></>}>
<MSegmented value={defaults.temperature_unit || ''} onChange={(v) => save({ temperature_unit: v })} options={tempOptions} />
</MAdminField>
{/* Distance */}
<MAdminField label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
<MAdminField label={<>{t('settings.distance')} <ResetButton {...resetOf('distance_unit')} /></>}>
<MSegmented value={defaults.distance_unit || ''} onChange={(v) => save({ distance_unit: v as DistanceUnit })} options={distanceOptions} />
</MAdminField>
{/* Time Format */}
<MAdminField label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
<MAdminField label={<>{t('settings.timeFormat')} <ResetButton {...resetOf('time_format')} /></>}>
<MSegmented value={defaults.time_format || ''} onChange={(v) => save({ time_format: v })} options={timeOptions} />
</MAdminField>
{/* Week start (#2029) */}
<MAdminField label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
<MAdminField label={<>{t('settings.weekStart')} <ResetButton {...resetOf('week_start')} /></>}>
<MSegmented value={defaults.week_start || ''} onChange={(v) => save({ week_start: v as WeekStart })} options={weekStartOptions(locale)} />
</MAdminField>
{/* Date format (#2971) */}
<MAdminField label={<>{t('settings.dateFormat')} <ResetButton {...resetOf('date_format')} /></>}>
<MSetSelectRow
label={dateFormats.find((o) => o.value === defaults.date_format)?.label ?? t('settings.dateFormat')}
trailing={chevron}
onClick={() => setDateFormatOpen(true)}
/>
</MAdminField>
{/* Default Currency */}
<MAdminField
label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}
label={<>{t('settings.currency')} <ResetButton {...resetOf('default_currency')} /></>}
hint={t('settings.currencyHint')}
>
<MSetSelectRow label={currencyLabel} trailing={chevron} onClick={() => setCurrencyOpen(true)} />
@@ -134,7 +160,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
{/* Blur Booking Codes */}
<MAdminRow
first
title={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}
title={<>{t('settings.blurBookingCodes')} <ResetButton {...resetOf('blur_booking_codes')} /></>}
trailing={
<MToggle
checked={defaults.blur_booking_codes === true}
@@ -156,7 +182,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
<div className="mt-2 space-y-[14px]">
{/* Map Tile URL */}
<MAdminField
label={<>{t('settings.mapTemplate')} <ResetButton field="map_tile_url" /></>}
label={<>{t('settings.mapTemplate')} <ResetButton {...resetOf('map_tile_url')} /></>}
hint={t('settings.mapDefaultHint')}
>
<MSetSelectRow label={tilePresetLabel} trailing={chevron} onClick={() => setPresetOpen(true)} />
@@ -174,7 +200,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
settings are read. A field here would only let somebody save a worse one. */}
{!managed && (
<MAdminField
label={<>{t('admin.defaultSettings.cartoKey')} <ResetButton field="carto_api_key" /></>}
label={<>{t('admin.defaultSettings.cartoKey')} <ResetButton {...resetOf('carto_api_key')} /></>}
hint={t('admin.defaultSettings.cartoKeyHint')}
>
<MAdminInput
@@ -218,7 +244,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
{/* Map provider */}
<div className="border-t border-[color:var(--m-rowbr)] pt-[14px]">
<MAdminField
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton {...resetOf('map_provider')} /></>}
hint={t('admin.defaultSettings.mapProviderHint')}
>
<MSegmented value={mapProvider} onChange={(v) => saveMapProvider(v as MapProvider)} options={providerOptions} />
@@ -229,7 +255,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
<div className="space-y-[14px]">
{mapProvider === 'mapbox-gl' && !managed && (
<MAdminField
label={<>{t('admin.defaultSettings.mapboxToken')} <ResetButton field="mapbox_access_token" /></>}
label={<>{t('admin.defaultSettings.mapboxToken')} <ResetButton {...resetOf('mapbox_access_token')} /></>}
hint={t('admin.defaultSettings.mapboxTokenHint')}
>
<MAdminInput
@@ -244,7 +270,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
</MAdminField>
)}
<MAdminField label={<>{t('admin.defaultSettings.mapboxStyle')} <ResetButton field={styleKey} /></>}>
<MAdminField label={<>{t('admin.defaultSettings.mapboxStyle')} <ResetButton {...resetOf(styleKey)} /></>}>
<MSetSelectRow label={stylePresetLabel} trailing={chevron} onClick={() => setStyleOpen(true)} />
<MAdminInput
type="text"
@@ -260,7 +286,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
<div>
<MAdminRow
first
title={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}
title={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton {...resetOf('mapbox_3d_enabled')} /></>}
trailing={
<MToggle
checked={defaults.mapbox_3d_enabled ?? true}
@@ -270,7 +296,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
}
/>
<MAdminRow
title={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}
title={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton {...resetOf('mapbox_quality_mode')} /></>}
trailing={
<MToggle
checked={defaults.mapbox_quality_mode ?? false}
@@ -287,6 +313,15 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
</MAdminCard>
{/* Pickers */}
<MSetPickerSheet
open={dateFormatOpen}
onClose={() => setDateFormatOpen(false)}
title={t('settings.dateFormat')}
value={defaults.date_format || ''}
onSelect={(value) => { if (value) void save({ date_format: value as Defaults['date_format'] }) }}
options={dateFormats}
/>
<MSetPickerSheet
open={currencyOpen}
onClose={() => setCurrencyOpen(false)}
@@ -1,6 +1,7 @@
import { Key, Trash2, User, Loader2, Shield } from 'lucide-react'
import { SCOPES_PREVIEW, useMcpTokensAdmin } from '../../../components/Admin/useMcpTokensAdmin'
import { useTranslation } from '../../../i18n'
import { useFormatDate } from '../../../hooks/useDateFormat'
import { MAdminCard } from './MAdminUi'
import MConfirmSheet from '../settings/MConfirmSheet'
@@ -13,7 +14,8 @@ export default function MAdminMcpTokensPanel() {
sessions, sessionsLoading, tokens, tokensLoading, expandedScopes, toggleScopes,
revokeConfirmId, setRevokeConfirmId, deleteConfirmId, setDeleteConfirmId, handleRevoke, handleDelete,
} = useMcpTokensAdmin()
const { t, locale } = useTranslation()
const { t } = useTranslation()
const { formatDate } = useFormatDate()
return (
<div className="space-y-3">
@@ -58,7 +60,7 @@ export default function MAdminMcpTokensPanel() {
{session.username}
</span>
<span className="text-m-faint">·</span>
<span>{new Date(session.created_at).toLocaleDateString(locale)}</span>
<span>{formatDate(new Date(session.created_at))}</span>
</div>
<div className="mt-[6px] flex flex-wrap gap-1">
{visible.map(scope => (
@@ -135,11 +137,11 @@ export default function MAdminMcpTokensPanel() {
{token.username}
</span>
<span className="text-m-faint">·</span>
<span>{new Date(token.created_at).toLocaleDateString(locale)}</span>
<span>{formatDate(new Date(token.created_at))}</span>
<span className="text-m-faint">·</span>
<span>
{token.last_used_at
? new Date(token.last_used_at).toLocaleDateString(locale)
? formatDate(new Date(token.last_used_at))
: t('admin.mcpTokens.never')}
</span>
</div>
@@ -1,4 +1,6 @@
import { useState } from 'react'
import { useFormatDate } from '../../../hooks/useDateFormat'
import MPluginVersionDate from './MPluginVersionDate'
import {
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
@@ -311,7 +313,7 @@ export default function MAdminPluginsPanel() {
{/* Version picker sheet */}
{versionPicker && (
<VersionPickerSheet plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
busy={busy} t={t} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
)}
{/* Downgrade confirm */}
@@ -806,9 +808,9 @@ function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onSetti
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
* explained and never offered.
*/
function VersionPickerSheet({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
function VersionPickerSheet({ plugin, versions, failed, busy, t, onPick, onClose }: {
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
t: T; onPick: (version: string) => void; onClose: () => void
}) {
return (
<MSheet open onClose={onClose} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.versionPickerTitle', { name: plugin.name })}>
@@ -823,7 +825,7 @@ function VersionPickerSheet({ plugin, versions, failed, busy, t, locale, onPick,
<div key={v.version} data-testid={`version-row-${v.version}`}
className="flex items-center gap-2.5 rounded-xl px-2.5 py-2.5">
<span className={`text-[13px] font-semibold tabular-nums ${v.compatible ? 'text-m-ink' : 'text-m-faint'}`}>v{v.version}</span>
{v.publishedAt && <span className="text-[11.5px] text-m-faint">{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
<MPluginVersionDate at={v.publishedAt} className="text-[11.5px] text-m-faint" />
{v.signed && <ShieldCheck size={13} className="shrink-0 text-[color:var(--m-st-confirmed)]" />}
<span className="ms-auto" />
{current ? (
@@ -956,6 +958,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local
item: RegistryItem; installed: boolean; busy: string | null; ignoreTrekRange: boolean
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
}) {
const { formatDate: dateOf } = useFormatDate()
const { detail, failed } = usePluginDetail<MRegistryDetail>(item.id)
const manifest = detail?.manifest ?? null
@@ -1109,7 +1112,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local
{(item.trek || item.minTrekVersion) && (
<Meta k={t('admin.plugins.metaRequires')} v={item.trek ? `TREK ${item.trek}` : `TREK ${item.minTrekVersion}+`} />
)}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={new Date(item.reviewedAt).toLocaleDateString(locale)} />}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={dateOf(new Date(item.reviewedAt))} />}
{typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
<Meta k={t('admin.plugins.downloads')} v={item.downloadCount.toLocaleString(locale)} />
)}
@@ -1125,7 +1128,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local
{detail!.versions!.map(v => (
<div key={v.version} className="flex items-center gap-2.5 py-1">
<span className={`text-[12.5px] font-medium tabular-nums ${v.compatible ? 'text-m-ink' : 'text-m-faint'}`}>v{v.version}</span>
{v.publishedAt && <span className="text-[11.5px] text-m-faint">{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
<MPluginVersionDate at={v.publishedAt} className="text-[11.5px] text-m-faint" />
{v.signed && <ShieldCheck size={13} className="shrink-0 text-[color:var(--m-st-confirmed)]" />}
<span className="ms-auto" />
{v.compatible ? (
@@ -0,0 +1,8 @@
import { useFormatDate } from '../../../hooks/useDateFormat';
/** The day a plugin version was published, in the user's date format (#2971). */
export default function MPluginVersionDate({ at, className }: { at?: string | null; className?: string }) {
const { formatDate } = useFormatDate();
if (!at) return null;
return <span className={className}>{formatDate(new Date(at))}</span>;
}
@@ -1,6 +1,7 @@
import React from 'react'
import { Plug, Save, RefreshCw, Unplug } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import { useFormatDate } from '../../../hooks/useDateFormat'
import { useDawarichConnection } from '../../../hooks/useDawarichConnection'
import { MSetCard, MSetRow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
import MToggle from '../../components/MToggle'
@@ -14,7 +15,8 @@ import MToggle from '../../components/MToggle'
* sync logic would spend the whole budget on its own.
*/
export default function MDawarichConnectionSection(): React.ReactElement {
const { t, locale } = useTranslation()
const { t } = useTranslation()
const { formatDateTime } = useFormatDate()
const S = useDawarichConnection()
return (
@@ -92,7 +94,7 @@ export default function MDawarichConnectionSection(): React.ReactElement {
{S.connected && (
<p className="font-geist text-[0.625rem] leading-relaxed text-m-muted">
{S.lastSyncAt
? t('dawarich.lastSync', { when: new Date(S.lastSyncAt).toLocaleString(locale) })
? t('dawarich.lastSync', { when: formatDateTime(new Date(S.lastSyncAt)) })
: t('dawarich.neverSynced')}
</p>
)}
@@ -6,8 +6,9 @@ import { SYMBOLS, currenciesWith } from '../../../components/Budget/BudgetPanel.
import { TRIP_TAB_IDS, TRIP_TAB_LABEL_KEYS, isTripTabId } from '../../../constants/tripTabs'
import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, type StartPage } from '../../../utils/startDestination'
import type { Settings, DistanceUnit, WeekStart } from '../../../types'
import { DEFAULT_WEEK_START } from '@trek/shared'
import { DEFAULT_DATE_FORMAT, DEFAULT_WEEK_START } from '@trek/shared'
import { weekStartOptions } from '../../../utils/calendarWeek'
import { dateFormatOptions } from '../../../utils/dateFormat'
import { MSetCard, MSetEyebrow, MSetHint, MSetSelectRow, MSetSegments, MSetRow } from './MSettingsUi'
import MToggle from '../../components/MToggle'
import MSetPickerSheet from './MSetPickerSheet'
@@ -27,6 +28,7 @@ export default function MSettingsGeneral() {
const [langOpen, setLangOpen] = useState(false)
const [startTabOpen, setStartTabOpen] = useState(false)
const [navAppOpen, setNavAppOpen] = useState(false)
const [dateFormatOpen, setDateFormatOpen] = useState(false)
const navAppOptions = preferredNavAppOptions(t)
const navAppValue = settings.preferred_nav_app || ''
@@ -37,6 +39,7 @@ export default function MSettingsGeneral() {
const language = SUPPORTED_LANGUAGES.find((l) => l.value === settings.language) || SUPPORTED_LANGUAGES[0]
const chevron = <ChevronDown size={13} strokeWidth={2} className="flex-none text-m-faint" />
const dateFormats = dateFormatOptions(locale, t('settings.dateFormatAuto'))
const travelRows: {
key: keyof Settings
@@ -166,6 +169,15 @@ export default function MSettingsGeneral() {
onChange={(v) => save('week_start', v)}
options={weekStartOptions(locale)}
/>
{/* Date format (#2971) */}
<MSetEyebrow className="mb-[6px] mt-[14px]">{t('settings.dateFormat')}</MSetEyebrow>
<MSetSelectRow
label={dateFormats.find((o) => o.value === (settings.date_format || DEFAULT_DATE_FORMAT))?.label ?? ''}
trailing={chevron}
onClick={() => setDateFormatOpen(true)}
/>
<MSetHint>{t('settings.dateFormatHint')}</MSetHint>
</MSetCard>
<MSetCard title={t('settings.general.travelMap')} icon={Map} className="mt-3">
@@ -206,6 +218,17 @@ export default function MSettingsGeneral() {
]}
/>
<MSetPickerSheet
open={dateFormatOpen}
onClose={() => setDateFormatOpen(false)}
title={t('settings.dateFormat')}
value={settings.date_format || DEFAULT_DATE_FORMAT}
onSelect={(v) => {
void save('date_format', v)
}}
options={dateFormats}
/>
<MSetPickerSheet
open={langOpen}
onClose={() => setLangOpen(false)}
@@ -1,5 +1,6 @@
import { Check, ChevronDown, ChevronRight, Copy, KeyRound, Plus, RefreshCw, Terminal, Trash2 } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import { useFormatDate } from '../../../hooks/useDateFormat'
import { OAUTH_PRESETS, useMcpIntegration } from '../../../components/Settings/useMcpIntegration'
import MScopeGroupPicker from './MScopeGroupPicker'
import MSheet from '../../components/MSheet'
@@ -38,7 +39,8 @@ function CodeBlock({ children }: { children: string }) {
* tokens, with the desktop modals as floating sheets.
*/
export default function MSettingsMcp() {
const { t, locale } = useTranslation()
const { t } = useTranslation()
const { formatDate } = useFormatDate()
const demoLocked = useDemoAccountLock()
const {
activeMcpTab: activeTab,
@@ -190,7 +192,7 @@ export default function MSettingsMcp() {
{t('settings.oauth.clientId')}: {client.client_id}
</p>
<p className="mt-[2px] font-geist text-[0.625rem] text-m-faint">
{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}
{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(client.created_at))}
</p>
<div className="mt-[6px] flex flex-wrap gap-1">
{(scopesExpanded[client.id] ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map((s) => (
@@ -226,7 +228,7 @@ export default function MSettingsMcp() {
{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}
</p>
<p className="font-geist text-[0.625rem] text-m-faint">
{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}
{t('settings.oauth.sessionExpires')} {formatDate(new Date(session.access_token_expires_at))}
</p>
</div>
<MSetButton variant="danger" onClick={() => setRevokeId(session.id)}>
@@ -272,11 +274,11 @@ export default function MSettingsMcp() {
<p className="mt-[2px] font-mono text-[0.625rem] text-m-faint">
{token.token_prefix}...
<span className="ms-2 font-geist">
{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}
{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(token.created_at))}
</span>
{token.last_used_at && (
<span className="font-geist">
, {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}
, {t('settings.mcp.tokenUsedAt')} {formatDate(new Date(token.last_used_at))}
</span>
)}
</p>
@@ -8,8 +8,10 @@ import MDancingTrek from '../../../components/MDancingTrek'
import { useCollabPolls } from '../../../../components/Collab/useCollabPolls'
import { useAuthStore } from '../../../../store/authStore'
import ToggleSwitch from '../../../../components/Settings/ToggleSwitch'
import { CustomDateTimePicker } from '../../../../components/shared/CustomDateTimePicker'
import { localIsoDate } from '../../../../utils/localDate'
import MSheet from '../../../components/MSheet'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import { Eyebrow, FIELD_AREA_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import MConfirmSheet from '../../settings/MConfirmSheet'
import type { TripPlanner } from '../MTripShell'
import { SectionHeader, TabScroller } from './tabChrome'
@@ -338,8 +340,6 @@ function PollFormSheet({ open, onClose, onSubmit, t }: {
}
}
const minDeadline = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16)
return (
<MSheet open={open} onClose={onClose} ariaLabel={t('collab.polls.new')}>
<FormSheetHeader icon={BarChart3} title={t('collab.polls.new')} onClose={onClose} closeLabel={t('common.close')} />
@@ -396,12 +396,11 @@ function PollFormSheet({ open, onClose, onSubmit, t }: {
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('collab.polls.deadline')}</Eyebrow>
<div className="flex items-center gap-[6px]">
<input
type="datetime-local"
<CustomDateTimePicker
value={deadline}
min={minDeadline}
onChange={e => setDeadline(e.target.value)}
className={`${FIELD_CLS} flex-1`}
onChange={setDeadline}
min={localIsoDate()}
style={{ flex: 1, minWidth: 0 }}
/>
{deadline && (
<button
@@ -3,6 +3,7 @@ import { ArrowRightLeft, Building2, Calendar, CalendarRange, CalendarX, ChevronD
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle'
import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
import { useVacayStore } from '../../../store/vacayStore'
import { useTranslation } from '../../../i18n'
@@ -338,13 +339,14 @@ function YearTypeRows() {
{type === 'anniversary' && (
<div className="ms-7">
<input
type="date"
value={yearSettings.hire_date || ''}
onChange={e => save({ hire_date: e.target.value || null })}
aria-label={t('vacay.hireDate')}
className="mt-[7px] w-full rounded-[10px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] px-[11px] py-[9px] font-[inherit] text-[0.78125rem] font-semibold text-m-ink outline-none"
/>
<div className="mt-[7px]">
<CustomDatePicker
value={yearSettings.hire_date || ''}
onChange={v => save({ hire_date: v || null })}
placeholder={t('vacay.hireDate')}
ariaLabel={t('vacay.hireDate')}
/>
</div>
</div>
)}
+4 -2
View File
@@ -20,6 +20,7 @@ import HelpAnchor from '../components/Help/HelpAnchor'
import AtlasCountryPlaces from '../components/Atlas/AtlasCountryPlaces'
import { AtlasTourActivityPane } from '../components/Atlas/tourActivity/AtlasTourActivityPane'
import { useAddonStore } from '../store/addonStore'
import { useFormatDate } from '../hooks/useDateFormat'
import { getIntlLanguage } from '@trek/shared'
// Fixed ids rather than useId: the page body stays free of hooks, and there is only one atlas.
@@ -448,6 +449,7 @@ interface SidebarContentProps {
function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPlaces, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onClearBucketVisit, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
const { language } = useTranslation()
const { formatDate } = useFormatDate()
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
const statsContentRef = useRef<HTMLDivElement>(null)
const bucketSearchRowRef = useRef<HTMLDivElement>(null)
@@ -548,7 +550,7 @@ ${t('dawarich.bucket.clearVisit')}`
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', background: 'none', border: 'none', padding: 0, cursor: 'pointer', fontFamily: 'inherit' }}
>
<Check size={9} />
{new Date(item.visited_at).toLocaleDateString(getIntlLanguage(language))}
{formatDate(new Date(item.visited_at))}
</button>
) : (
<span
@@ -556,7 +558,7 @@ ${t('dawarich.bucket.clearVisit')}`
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))' }}
>
<Check size={9} />
{new Date(item.visited_at).toLocaleDateString(getIntlLanguage(language))}
{formatDate(new Date(item.visited_at))}
</span>
)}
</Tooltip>
+7 -7
View File
@@ -664,10 +664,10 @@ describe('SharedTripPage', () => {
await waitFor(() => expect(screen.getAllByText('12,345.67 USD')).toHaveLength(4));
});
it('reads a bill in the display currency through the trip currency too, beside what was entered', async () => {
// The owner reads in USD on a CHF trip. The USD bill was booked at 685.26 CHF,
// which today is 780.86 USD, the figure Costs and its balances use; the CHF one
// converts at today's rate (100 CHF = 113.95 USD).
it('reads a bill in the display currency as typed, as Costs does (#2965)', async () => {
// The owner reads in USD on a CHF trip. The USD bill reads as the 801.76 USD
// typed, as Costs and its balances count it; the CHF one converts at today's
// rate (100 CHF = 113.95 USD).
server.use(
http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([{ quote: 'CHF', rate: 1 / 1.1395 }])),
http.get('/api/shared/:token', ({ params }) => {
@@ -692,9 +692,9 @@ describe('SharedTripPage', () => {
fireEvent.click(screen.getByRole('button', { name: /costs/i }));
await waitFor(() => expect(screen.getAllByText('113.95 USD')).toHaveLength(3));
expect(screen.getAllByText('780.86 USD')).toHaveLength(3);
expect(screen.getByText('894.81 USD')).toBeInTheDocument();
expect(screen.getByText('801.76 USD')).toBeInTheDocument();
expect(screen.getAllByText('801.76 USD').length).toBeGreaterThan(0);
expect(screen.queryByText('780.86 USD')).toBeNull();
expect(screen.getByText('915.71 USD')).toBeInTheDocument();
expect(screen.getByText('100.00 CHF')).toBeInTheDocument();
});
});
+4 -1
View File
@@ -10,6 +10,8 @@ import { SettingsCard, SettingRows, SettingsHint, StatusPill, SETTINGS_BUTTON, S
import type { TranslationFn } from '../../types'
import type { useAdmin } from './useAdmin'
import { formatLastActive, inviteMeta, inviteStatus } from './adminModel'
import { useDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
interface AdminUsersTabProps {
admin: ReturnType<typeof useAdmin>
@@ -36,6 +38,7 @@ function RowIconButton({ label, onClick, disabled, danger, children }: { label:
// "Users" admin tab: user table, invite links, permissions panel + the
// create-invite modal. Pure layout around the useAdmin hook — no logic of its own.
export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): React.ReactElement {
const dateFormat = useDateFormat()
const {
hour12, currentUser,
users, isLoading,
@@ -112,7 +115,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
: <StatusPill>{t('settings.roleUser')}</StatusPill>}
</td>
<td className={`${TD} whitespace-nowrap font-geist tabular-nums text-content-muted`} style={fs(12)}>
{new Date(u.created_at).toLocaleDateString(locale)}
{formatDisplayDate(new Date(u.created_at), locale, {}, dateFormat)}
</td>
<td className={`${TD} whitespace-nowrap font-geist tabular-nums text-content-muted`} style={fs(12)}>
{/* Activity, not only sign-ins: a renewing cookie keeps people signed in for weeks. */}
+3 -1
View File
@@ -1,4 +1,6 @@
/** Shared types and pure helpers for the admin page + its data hook. No React, no side effects. */
import { readDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
export interface AdminUser {
id: number
@@ -87,7 +89,7 @@ export function inviteMeta(
locale: string,
): string {
const parts = [`${invite.used_count}/${invite.max_uses === 0 ? '∞' : invite.max_uses} ${t('admin.invite.uses')}`]
if (invite.expires_at) parts.push(`${t('admin.invite.expiresAt')} ${new Date(invite.expires_at).toLocaleDateString(locale)}`)
if (invite.expires_at) parts.push(`${t('admin.invite.expiresAt')} ${formatDisplayDate(new Date(invite.expires_at), locale, {}, readDateFormat())}`)
if (invite.trip_title) parts.push(t('admin.invite.boundTo', { trip: invite.trip_title }))
parts.push(`${t('admin.invite.createdBy')} ${invite.created_by_name ?? ''}`)
return parts.join(', ')
@@ -3,7 +3,7 @@ import { catMeta } from '../../components/Budget/costsCategories'
import { fs, NEUTRAL_TINT } from '../../components/shared/DialogShell'
import { CountPill } from '../../components/Planner/bookings/bookingParts'
import { TripMemberAvatar } from '../../components/Trips/TripMemberAvatar'
import { convertBooked, convertedLine } from '../../hooks/useExchangeRates'
import { convertBooked, convertedLine, inDisplayCurrency } from '../../hooks/useExchangeRates'
import { useTranslation } from '../../i18n'
import { avatarSrc } from '../../utils/avatarSrc'
import { currencyDecimals } from '../../utils/formatters'
@@ -92,7 +92,11 @@ export function SharedCostsView({ items, base, tripCurrency, convert }: CostsPro
const { t, locale } = useTranslation()
if (items.length === 0) return <EmptySection icon={<Wallet size={22} />} text={t('shared.emptyCosts')} />
const amountOf = (i: Expense) => Number.parseFloat(String(i.total_price ?? '')) || 0
const valueOf = (i: Expense) => convertBooked(amountOf(i), i.currency, i.exchange_rate, tripCurrency, convert)
// A bill in the owner's display currency reads as typed, as in Costs (#2965).
const valueOf = (i: Expense) =>
inDisplayCurrency(i.currency, tripCurrency, base)
? amountOf(i)
: convertBooked(amountOf(i), i.currency, i.exchange_rate, tripCurrency, convert)
// In the reader's own number format with the currency's code: a public page has
// no idea where its reader is, and "$" alone does not say which dollar.
// Whole cents: a converted amount otherwise printed a third decimal.
+2 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'
import { settingsApi } from '../api/client'
import type { Settings } from '../types'
import { DEFAULT_APPEARANCE, DEFAULT_WEEK_START } from '@trek/shared'
import { DEFAULT_APPEARANCE, DEFAULT_DATE_FORMAT, DEFAULT_WEEK_START } from '@trek/shared'
import { getApiErrorMessage } from '../types'
import { SUPPORTED_LANGUAGE_CODES } from '../i18n/supportedLanguages'
import { detectBrowserLanguage } from '../i18n/detectBrowserLanguage'
@@ -88,6 +88,7 @@ export const DEFAULT_SETTINGS: Settings = {
distance_unit: 'metric',
time_format: '24h',
week_start: DEFAULT_WEEK_START,
date_format: DEFAULT_DATE_FORMAT,
show_place_description: false,
optimize_from_accommodation: true,
map_provider: 'leaflet',
+4
View File
@@ -32,6 +32,7 @@ import type {
AppearanceConfig,
ValhallaProvider,
WeekStart,
DateFormat,
} from '@trek/shared'
export type {
@@ -57,6 +58,7 @@ export type {
Category,
AppearanceConfig,
WeekStart,
DateFormat,
}
export interface User {
@@ -143,6 +145,8 @@ export interface Settings {
time_format: string
/** First column of every date picker (#2029); missing means Monday. */
week_start?: WeekStart
/** How dates are written in numbers (#2971); missing means the language's own format. */
date_format?: DateFormat
show_place_description: boolean
blur_booking_codes?: boolean
/** Day headings lead with the calendar date and name the trip day second (#1953). */
+228
View File
@@ -0,0 +1,228 @@
// FE-UTIL-DATEFMT-001 to FE-UTIL-DATEFMT-020
import { describe, expect, it } from 'vitest';
import {
dateFieldOrder,
dateFormatOptions,
dateLayout,
displayDateFormatter,
formatDisplayDate,
formatDisplayDateTime,
isNumericDate,
} from './dateFormat';
// 31 December 2026, 15:04 UTC: no day of it reads as a month, so every layout is unambiguous.
const NYE = new Date(Date.UTC(2026, 11, 31, 15, 4));
const UTC = { timeZone: 'UTC' } as const;
const NUMERIC: Intl.DateTimeFormatOptions = { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' };
describe('dateFormat (#2971)', () => {
describe('formatDisplayDate', () => {
it('FE-UTIL-DATEFMT-001: auto, an unknown value or none is exactly toLocaleDateString', () => {
const cases: [string, Intl.DateTimeFormatOptions | undefined][] = [
['en-US', undefined],
['en-US', NUMERIC],
['de-DE', NUMERIC],
['ko-KR', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }],
['en-GB', { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' }],
];
for (const [locale, opts] of cases) {
const expected = NYE.toLocaleDateString(locale, opts);
expect(formatDisplayDate(NYE, locale, opts, 'auto')).toBe(expected);
expect(formatDisplayDate(NYE, locale, opts, 'DD-MM-YY')).toBe(expected);
expect(formatDisplayDate(NYE, locale, opts, null)).toBe(expected);
expect(formatDisplayDate(NYE, locale, opts)).toBe(expected);
}
});
it('FE-UTIL-DATEFMT-002: each format writes 31 December 2026 in its own layout', () => {
expect(formatDisplayDate(NYE, 'en-US', UTC, 'YYYY-MM-DD')).toBe('2026-12-31');
expect(formatDisplayDate(NYE, 'en-US', UTC, 'YYYY/MM/DD')).toBe('2026/12/31');
expect(formatDisplayDate(NYE, 'en-US', UTC, 'DD.MM.YYYY')).toBe('31.12.2026');
expect(formatDisplayDate(NYE, 'en-US', UTC, 'DD/MM/YYYY')).toBe('31/12/2026');
expect(formatDisplayDate(NYE, 'de-DE', UTC, 'MM/DD/YYYY')).toBe('12/31/2026');
});
it('FE-UTIL-DATEFMT-003: the language does not change a chosen format', () => {
for (const locale of ['en-US', 'de-DE', 'ko-KR', 'ar', 'fr-FR']) {
expect(formatDisplayDate(NYE, locale, NUMERIC, 'YYYY-MM-DD')).toBe('2026-12-31');
}
});
it('FE-UTIL-DATEFMT-004: single-digit days and months are padded', () => {
const march = new Date(Date.UTC(2026, 2, 5));
expect(
formatDisplayDate(
march,
'en-US',
{ day: 'numeric', month: 'numeric', year: 'numeric', timeZone: 'UTC' },
'DD.MM.YYYY'
)
).toBe('05.03.2026');
});
it('FE-UTIL-DATEFMT-005: year 2-digit keeps two digits of the year', () => {
const opts: Intl.DateTimeFormatOptions = { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' };
expect(formatDisplayDate(NYE, 'en-US', opts, 'DD.MM.YYYY')).toBe('31.12.26');
expect(formatDisplayDate(NYE, 'en-US', opts, 'YYYY-MM-DD')).toBe('26-12-31');
});
it('FE-UTIL-DATEFMT-006: day and month without a year write only those two, in layout order', () => {
const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'numeric', timeZone: 'UTC' };
expect(formatDisplayDate(NYE, 'en-US', opts, 'DD.MM.YYYY')).toBe('31.12');
expect(formatDisplayDate(NYE, 'en-US', opts, 'YYYY-MM-DD')).toBe('12-31');
expect(formatDisplayDate(NYE, 'en-US', opts, 'MM/DD/YYYY')).toBe('12/31');
});
it('FE-UTIL-DATEFMT-007: a month name or a long dateStyle is left to Intl', () => {
const named: Intl.DateTimeFormatOptions[] = [
{ weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' },
{ day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' },
{ month: 'long', year: 'numeric', timeZone: 'UTC' },
{ dateStyle: 'medium', timeZone: 'UTC' },
];
for (const opts of named) {
expect(formatDisplayDate(NYE, 'de-DE', opts, 'YYYY-MM-DD')).toBe(NYE.toLocaleDateString('de-DE', opts));
}
});
it('FE-UTIL-DATEFMT-021: a weekday beside a numeric date keeps the language, the date takes the format', () => {
const opts: Intl.DateTimeFormatOptions = {
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
};
expect(formatDisplayDate(NYE, 'de-DE', opts, 'YYYY-MM-DD')).toBe(
`${NYE.toLocaleDateString('de-DE', { weekday: 'short', timeZone: 'UTC' })}, 2026-12-31`
);
expect(formatDisplayDate(NYE, 'de-DE', opts, 'auto')).toBe(NYE.toLocaleDateString('de-DE', opts));
});
it('FE-UTIL-DATEFMT-008: dateStyle short counts as numeric', () => {
expect(formatDisplayDate(NYE, 'en-US', { dateStyle: 'short', timeZone: 'UTC' }, 'DD/MM/YYYY')).toBe('31/12/2026');
});
it('FE-UTIL-DATEFMT-009: an invalid date falls back to toLocaleDateString', () => {
const invalid = new Date('nope');
expect(formatDisplayDate(invalid, 'en-US', NUMERIC, 'YYYY-MM-DD')).toBe(
invalid.toLocaleDateString('en-US', NUMERIC)
);
});
});
describe('formatDisplayDateTime', () => {
it('FE-UTIL-DATEFMT-010: auto, an unknown value or none is exactly toLocaleString', () => {
const opts: Intl.DateTimeFormatOptions = { ...NUMERIC, hour: '2-digit', minute: '2-digit' };
for (const locale of ['en-US', 'de-DE']) {
expect(formatDisplayDateTime(NYE, locale, opts, 'auto')).toBe(NYE.toLocaleString(locale, opts));
expect(formatDisplayDateTime(NYE, locale, opts, 'nope')).toBe(NYE.toLocaleString(locale, opts));
expect(formatDisplayDateTime(NYE, locale, UTC)).toBe(NYE.toLocaleString(locale, UTC));
}
});
it('FE-UTIL-DATEFMT-011: the date follows the format, the time of day stays Intl', () => {
const opts: Intl.DateTimeFormatOptions = { ...NUMERIC, hour: '2-digit', minute: '2-digit' };
const time = NYE.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' });
expect(formatDisplayDateTime(NYE, 'en-US', opts, 'YYYY-MM-DD')).toBe(`2026-12-31 ${time}`);
const hour12 = { ...opts, hour12: false };
const time24 = NYE.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'UTC',
});
expect(formatDisplayDateTime(NYE, 'en-US', hour12, 'DD.MM.YYYY')).toBe(`31.12.2026 ${time24}`);
});
it('FE-UTIL-DATEFMT-012: without options it writes the date and the default time', () => {
expect(formatDisplayDateTime(NYE, 'en-US', UTC, 'DD/MM/YYYY')).toBe(
`31/12/2026 ${NYE.toLocaleTimeString('en-US', UTC)}`
);
});
it('FE-UTIL-DATEFMT-013: time-only options are passed through untouched', () => {
const opts: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' };
expect(formatDisplayDateTime(NYE, 'de-DE', opts, 'YYYY-MM-DD')).toBe(NYE.toLocaleString('de-DE', opts));
});
it('FE-UTIL-DATEFMT-014: date-only options give just the date, named dates stay Intl', () => {
expect(formatDisplayDateTime(NYE, 'en-US', NUMERIC, 'YYYY/MM/DD')).toBe('2026/12/31');
const named: Intl.DateTimeFormatOptions = {
weekday: 'short',
day: 'numeric',
month: 'short',
hour: '2-digit',
timeZone: 'UTC',
};
expect(formatDisplayDateTime(NYE, 'en-US', named, 'YYYY-MM-DD')).toBe(NYE.toLocaleString('en-US', named));
});
});
describe('displayDateFormatter', () => {
it('FE-UTIL-DATEFMT-015: auto behaves like Intl.DateTimeFormat', () => {
const intl = new Intl.DateTimeFormat('en-US', NUMERIC);
expect(displayDateFormatter('en-US', NUMERIC, 'auto').format(NYE)).toBe(intl.format(NYE));
expect(displayDateFormatter('en-US', NUMERIC).format(NYE.getTime())).toBe(intl.format(NYE));
});
it('FE-UTIL-DATEFMT-016: numeric dates follow the format, for Date and timestamp alike', () => {
const f = displayDateFormatter('en-US', NUMERIC, 'DD.MM.YYYY');
expect(f.format(NYE)).toBe('31.12.2026');
expect(f.format(NYE.getTime())).toBe('31.12.2026');
const withTime = displayDateFormatter('en-US', { ...NUMERIC, hour: '2-digit', minute: '2-digit' }, 'YYYY-MM-DD');
expect(withTime.format(NYE)).toBe(
`2026-12-31 ${NYE.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })}`
);
});
it('FE-UTIL-DATEFMT-017: a named month is left to Intl', () => {
const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', timeZone: 'UTC' };
expect(displayDateFormatter('de-DE', opts, 'YYYY-MM-DD').format(NYE)).toBe(
new Intl.DateTimeFormat('de-DE', opts).format(NYE)
);
});
});
describe('dateFieldOrder, dateLayout and isNumericDate', () => {
it('FE-UTIL-DATEFMT-018: a chosen format sets the order, auto asks the language', () => {
expect(dateFieldOrder('en-US', 'DD.MM.YYYY')).toEqual(['day', 'month', 'year']);
expect(dateFieldOrder('de-DE', 'MM/DD/YYYY')).toEqual(['month', 'day', 'year']);
expect(dateFieldOrder('en-US', 'YYYY/MM/DD')).toEqual(['year', 'month', 'day']);
expect(dateFieldOrder('de-DE', 'auto')).toEqual(['day', 'month', 'year']);
expect(dateFieldOrder('en-US')).toEqual(['month', 'day', 'year']);
expect(dateFieldOrder('ko-KR', null)).toEqual(['year', 'month', 'day']);
});
it('FE-UTIL-DATEFMT-019: dateLayout knows the five formats only, isNumericDate spots names', () => {
expect(dateLayout('DD/MM/YYYY')).toEqual({ order: ['day', 'month', 'year'], separator: '/' });
expect(dateLayout('auto')).toBeNull();
expect(dateLayout('nope')).toBeNull();
expect(dateLayout(undefined)).toBeNull();
expect(isNumericDate()).toBe(true);
expect(isNumericDate({ month: '2-digit' })).toBe(true);
expect(isNumericDate({ month: 'short' })).toBe(false);
expect(isNumericDate({ weekday: 'long' })).toBe(false);
expect(isNumericDate({ era: 'short' })).toBe(false);
expect(isNumericDate({ dateStyle: 'short' })).toBe(true);
expect(isNumericDate({ dateStyle: 'long' })).toBe(false);
});
});
describe('dateFormatOptions', () => {
it('FE-UTIL-DATEFMT-020: every choice is labelled by how it writes 31 December, auto with its example', () => {
expect(dateFormatOptions('en-US', 'Automatic')).toEqual([
{ value: 'auto', label: 'Automatic (12/31/2026)' },
{ value: 'YYYY-MM-DD', label: '2026-12-31' },
{ value: 'YYYY/MM/DD', label: '2026/12/31' },
{ value: 'DD.MM.YYYY', label: '31.12.2026' },
{ value: 'DD/MM/YYYY', label: '31/12/2026' },
{ value: 'MM/DD/YYYY', label: '12/31/2026' },
]);
expect(dateFormatOptions('de-DE', 'Automatisch')[0]).toEqual({
value: 'auto',
label: 'Automatisch (31.12.2026)',
});
});
});
});
+182
View File
@@ -0,0 +1,182 @@
import { DATE_FORMAT_VALUES, type DateFormat } from '@trek/shared';
/**
* The user's date format (#2971), applied wherever a date is written in numbers.
*
* Every function here takes the format as an argument and stays pure; the
* current user's value comes from `useDateFormat()` (or `readDateFormat()`
* outside React) in hooks/useDateFormat.ts. 'auto', an unknown value or none
* at all leaves everything to `Intl` and the interface language, exactly as
* TREK wrote dates before the setting existed.
*
* Only the numeric part follows the setting. A date written with a weekday or
* a month name ("Tue, 3 Nov") keeps the language's own wording and order: the
* setting answers "how do I read 03/11", not "what is November called".
*/
export type DateField = 'day' | 'month' | 'year';
interface DateLayout {
order: DateField[];
separator: string;
}
const LAYOUTS: Record<Exclude<DateFormat, 'auto'>, DateLayout> = {
'YYYY-MM-DD': { order: ['year', 'month', 'day'], separator: '-' },
'YYYY/MM/DD': { order: ['year', 'month', 'day'], separator: '/' },
'DD.MM.YYYY': { order: ['day', 'month', 'year'], separator: '.' },
'DD/MM/YYYY': { order: ['day', 'month', 'year'], separator: '/' },
'MM/DD/YYYY': { order: ['month', 'day', 'year'], separator: '/' },
};
/** The order and separator of a chosen format; null for 'auto' or anything unknown. */
export function dateLayout(format: string | null | undefined): DateLayout | null {
if (!format || format === 'auto') return null;
return LAYOUTS[format as keyof typeof LAYOUTS] ?? null;
}
const DATE_KEYS = ['day', 'month', 'year'] as const;
const TIME_KEYS = ['hour', 'minute', 'second', 'dayPeriod', 'fractionalSecondDigits'] as const;
/**
* Whether these options write the date in numbers only: no weekday, no month
* name, no era. `dateStyle: 'short'` is numeric in every language; the longer
* styles name the month.
*/
export function isNumericDate(opts: Intl.DateTimeFormatOptions = {}): boolean {
if (opts.weekday || opts.era) return false;
if (opts.dateStyle) return opts.dateStyle === 'short';
return !opts.month || opts.month === 'numeric' || opts.month === '2-digit';
}
const hasAny = (opts: Intl.DateTimeFormatOptions, keys: readonly string[]) =>
keys.some((key) => (opts as Record<string, unknown>)[key] !== undefined);
/** The date in the layout's order, each field the options ask for (all three when they name none). */
function numericDate(date: Date, opts: Intl.DateTimeFormatOptions, layout: DateLayout): string {
const named = opts.dateStyle ? DATE_KEYS : DATE_KEYS.filter((key) => opts[key] !== undefined);
const fields = named.length > 0 ? named : DATE_KEYS;
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: opts.timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
numberingSystem: 'latn',
}).formatToParts(date);
const value = (type: DateField) => parts.find((part) => part.type === type)?.value ?? '';
const shortYear = opts.year === '2-digit';
return layout.order
.filter((field) => (fields as readonly string[]).includes(field))
.map((field) => (field === 'year' && shortYear ? value('year').slice(-2) : value(field)))
.join(layout.separator);
}
/** Only the time-of-day options, for the clock part beside a numeric date. */
function timeOptions(opts: Intl.DateTimeFormatOptions): Intl.DateTimeFormatOptions {
const time: Intl.DateTimeFormatOptions = {};
for (const key of [...TIME_KEYS, 'hour12', 'hourCycle', 'timeZone', 'timeZoneName'] as const) {
const value = (opts as Record<string, unknown>)[key];
if (value !== undefined) (time as Record<string, unknown>)[key] = value;
}
if (opts.timeStyle) time.timeStyle = opts.timeStyle;
return time;
}
/**
* `date.toLocaleDateString(locale, opts)`, with a numeric date written in the
* chosen format. Anything with a weekday or a month name is left to `Intl`.
*/
export function formatDisplayDate(
date: Date,
locale: string | undefined,
opts: Intl.DateTimeFormatOptions = {},
format?: string | null
): string {
const layout = dateLayout(format);
if (!layout || Number.isNaN(date.getTime())) return date.toLocaleDateString(locale, opts);
if (opts.weekday && isNumericWithWeekday(opts)) {
const weekday = date.toLocaleDateString(locale, { weekday: opts.weekday, timeZone: opts.timeZone });
return `${weekday}, ${numericDate(date, opts, layout)}`;
}
if (!isNumericDate(opts)) return date.toLocaleDateString(locale, opts);
return numericDate(date, opts, layout);
}
/** A weekday beside a date that is otherwise all numbers ("Tue, 03.11.2026"). */
function isNumericWithWeekday(opts: Intl.DateTimeFormatOptions): boolean {
if (opts.era || opts.dateStyle) return false;
return (opts.month === 'numeric' || opts.month === '2-digit') && opts.day !== undefined;
}
/**
* `date.toLocaleString(locale, opts)`, with a numeric date part written in the
* chosen format and the time of day left to `Intl` (12h/24h stays the
* caller's option). Options naming only a time are passed through untouched.
*/
export function formatDisplayDateTime(
date: Date,
locale: string | undefined,
opts: Intl.DateTimeFormatOptions = {},
format?: string | null
): string {
const layout = dateLayout(format);
const named = hasAny(opts, DATE_KEYS) || !!opts.dateStyle;
const timed = hasAny(opts, TIME_KEYS) || !!opts.timeStyle;
if (!layout || !isNumericDate(opts) || Number.isNaN(date.getTime())) return date.toLocaleString(locale, opts);
if (!named && timed) return date.toLocaleString(locale, opts);
const day = numericDate(date, opts, layout);
if (named && !timed) return day;
const time = date.toLocaleTimeString(locale, timed ? timeOptions(opts) : { timeZone: opts.timeZone });
return `${day} ${time}`;
}
/**
* A drop-in for `new Intl.DateTimeFormat(locale, opts)` where only `.format()`
* is used: numeric dates in the chosen format, the rest by `Intl`.
*/
export function displayDateFormatter(
locale: string | undefined,
opts: Intl.DateTimeFormatOptions = {},
format?: string | null
): { format: (date: Date | number) => string } {
const intl = new Intl.DateTimeFormat(locale, opts);
const layout = dateLayout(format);
if (!layout || !isNumericDate(opts)) return { format: (date) => intl.format(date) };
return {
format: (date) => {
const value = typeof date === 'number' ? new Date(date) : date;
return hasAny(opts, TIME_KEYS) || opts.timeStyle
? formatDisplayDateTime(value, locale, opts, format)
: formatDisplayDate(value, locale, opts, format);
},
};
}
/** The order the fields of a typed date are read in: the chosen format's, or the language's. */
export function dateFieldOrder(locale: string | undefined, format?: string | null): DateField[] {
const layout = dateLayout(format);
if (layout) return layout.order;
return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'numeric', year: 'numeric' })
.formatToParts(new Date(2001, 5, 15))
.filter((part) => part.type === 'day' || part.type === 'month' || part.type === 'year')
.map((part) => part.type as DateField);
}
/** 31 December: no day of it reads as a month, so every option's example is unambiguous. */
const SAMPLE = new Date(Date.UTC(2026, 11, 31));
/**
* The choices of the setting, each labelled by how it writes 31 December, and
* 'auto' by `autoLabel` with the language's own way in brackets.
*/
export function dateFormatOptions(
locale: string | undefined,
autoLabel: string
): { value: DateFormat; label: string }[] {
const example = (format: DateFormat) =>
formatDisplayDate(SAMPLE, locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }, format);
return DATE_FORMAT_VALUES.map((value) => ({
value,
label: value === 'auto' ? `${autoLabel} (${example(value)})` : example(value),
}));
}
+14
View File
@@ -15,4 +15,18 @@ describe('dateInputPattern', () => {
expect(dateInputPattern('ar')).not.toMatch(/[٠-٩\d]/);
expect(dateInputPattern('ar')).toContain('YYYY');
});
it('writes a chosen date format (#2971) as its own pattern, whatever the language', () => {
expect(dateInputPattern('en-US', 'YYYY-MM-DD')).toBe('YYYY-MM-DD');
expect(dateInputPattern('en-US', 'DD.MM.YYYY')).toBe('DD.MM.YYYY');
expect(dateInputPattern('de-DE', 'MM/DD/YYYY')).toBe('MM/DD/YYYY');
expect(dateInputPattern('ko-KR', 'YYYY/MM/DD')).toBe('YYYY/MM/DD');
});
it('keeps the locale pattern for auto, an unknown value or none', () => {
expect(dateInputPattern('de-DE', 'auto')).toBe('DD.MM.YYYY');
expect(dateInputPattern('en-US', 'auto')).toBe('MM/DD/YYYY');
expect(dateInputPattern('en-GB', 'DD-MM-YY')).toBe('DD/MM/YYYY');
expect(dateInputPattern('en-GB', null)).toBe('DD/MM/YYYY');
});
});
+6 -2
View File
@@ -1,3 +1,5 @@
import { dateLayout } from './dateFormat';
const LETTERS: Record<string, string> = { day: 'DD', month: 'MM', year: 'YYYY' };
/**
@@ -5,9 +7,11 @@ const LETTERS: Record<string, string> = { day: 'DD', month: 'MM', year: 'YYYY' }
* locale's own separators. It is the hint of the manual date field, which is
* pre-filled and read back in this same order, so a Korean user sees
* "YYYY. MM. DD." rather than a German "DD.MM.YYYY". Direction marks are dropped:
* the hint shows the shape, the field itself carries the script.
* the hint shows the shape, the field itself carries the script. A chosen date
* format (#2971) is its own pattern.
*/
export function dateInputPattern(locale: string): string {
export function dateInputPattern(locale: string, format?: string | null): string {
if (dateLayout(format)) return String(format);
return new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' })
.formatToParts(new Date(Date.UTC(2001, 5, 15)))
.map((part) => LETTERS[part.type] ?? part.value.replace(/[؜‎‏]/g, ''))
@@ -1,4 +1,4 @@
// FE-MOB-MDUS-001 to FE-MOB-MDUS-030
// FE-MOB-MDUS-001 to FE-MOB-MDUS-032
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor, within, fireEvent } from '../../../helpers/render';
import userEvent from '@testing-library/user-event';
@@ -500,3 +500,36 @@ describe('MAdminDefaultUserSettings, the routing service', () => {
expect(screen.queryByRole('combobox', { name: 'Routing service' })).not.toBeInTheDocument();
});
});
describe('MAdminDefaultUserSettings date format (#2971)', () => {
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
});
it('FE-MOB-MDUS-031: the date-format sheet saves its own key', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ date_format: 'YYYY-MM-DD' });
render(<MAdminDefaultUserSettings />);
await screen.findByText('Default User Settings');
// The choices are labelled by how they write 31 December.
await user.click(screen.getByRole('button', { name: '2026-12-31' }));
const sheet = await screen.findByRole('dialog');
await user.click(within(sheet).getByRole('button', { name: '31.12.2026' }));
await waitFor(() => expect(puts).toEqual([{ date_format: 'DD.MM.YYYY' }]));
});
it('FE-MOB-MDUS-032: a stored date format can be reset', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ date_format: 'DD/MM/YYYY' });
render(<MAdminDefaultUserSettings />);
await screen.findByText('Default User Settings');
// The only default set, so the only reset link on the page.
await user.click(screen.getByRole('button', { name: 'reset' }));
await waitFor(() => expect(puts).toEqual([{ date_format: null }]));
});
});
@@ -231,3 +231,24 @@ describe('MSettingsGeneral week start (#2029)', () => {
expect(updateSetting).toHaveBeenCalledWith('week_start', 'saturday');
});
});
describe('MSettingsGeneral date format (#2971)', () => {
it('FE-MOB-SET-018: the date-format row shows the language format when unset and the sheet saves a pick', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en', date_format: undefined }), updateSetting });
render(<MSettingsGeneral />);
// The choices are labelled by how they write 31 December; auto adds the language's own way.
await user.click(screen.getByRole('button', { name: /\(12\/31\/2026\)/ }));
await user.click(await screen.findByRole('button', { name: '31.12.2026' }));
expect(updateSetting).toHaveBeenCalledWith('date_format', 'DD.MM.YYYY');
});
it('FE-MOB-SET-019: a stored format is shown on its row', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en', date_format: 'YYYY/MM/DD' }) });
render(<MSettingsGeneral />);
expect(screen.getByRole('button', { name: '2026/12/31' })).toBeInTheDocument();
});
});
@@ -53,8 +53,13 @@ function wsHandler() {
return calls[calls.length - 1][0]
}
function deadlineInput(): HTMLInputElement {
return document.querySelector('input[type="datetime-local"]') as HTMLInputElement
// The deadline is the shared date and time picker (#2971): the day typed in its
// manual field, the time left at the noon it starts from.
function setDeadline(day: string) {
fireEvent.click(screen.getByRole('button', { name: /enterManually|Enter date manually/ }))
const input = screen.getByRole('textbox', { name: /enterManually|Enter date manually/ })
fireEvent.change(input, { target: { value: day } })
fireEvent.keyDown(input, { key: 'Enter' })
}
describe('MCollabPolls', () => {
@@ -335,7 +340,7 @@ describe('MCollabPolls', () => {
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:1'), { target: { value: 'Ryokan' } })
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:2'), { target: { value: ' Capsule ' } })
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.multiChoice' }))
fireEvent.change(deadlineInput(), { target: { value: '2026-08-01T12:00' } })
setDeadline('2030-08-01')
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.create' }))
await waitFor(() => expect(createPoll).toHaveBeenCalledWith(1, {
@@ -343,7 +348,7 @@ describe('MCollabPolls', () => {
options: ['Ryokan', 'Capsule'],
multiple: true,
multiple_choice: true,
deadline: new Date('2026-08-01T12:00').toISOString(),
deadline: new Date('2030-08-01T12:00').toISOString(),
}))
expect(await screen.findByText('Which hotel?')).toBeInTheDocument()
})
@@ -381,10 +386,9 @@ describe('MCollabPolls', () => {
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[0])
expect(screen.queryByPlaceholderText('collab.polls.optionPlaceholder:3')).not.toBeInTheDocument()
fireEvent.change(deadlineInput(), { target: { value: '2026-08-01T12:00' } })
expect(deadlineInput()).toHaveValue('2026-08-01T12:00')
setDeadline('2030-08-01')
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.clearDeadline' }))
expect(deadlineInput()).toHaveValue('')
expect(screen.queryByRole('button', { name: 'collab.polls.clearDeadline' })).not.toBeInTheDocument()
})
it('FE-MOB-POLLS-025: keeps the form open and toasts when creating fails', async () => {
@@ -8,7 +8,7 @@ import type { BudgetItem, BudgetItemMember } from '../../../../src/types'
import { buildBudgetItem, buildTrip } from '../../../helpers/factories'
import { localToday } from '../../../../src/components/Planner/today'
import { resetAllStores } from '../../../helpers/store'
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-COSTSH-001 to FE-MOB-COSTSH-038
// The sheet reads its copy from useTranslation(), so assertions are English.
@@ -681,7 +681,7 @@ describe('MCostSheet', () => {
})))
})
it('FE-MOB-COSTSH-036: editing a booked bill previews the rate the save keeps (#2525)', () => {
it('FE-MOB-COSTSH-036: editing a bill in the display currency has nothing to preview (#2965)', () => {
// A euro trip read in dollars, the bill entered in dollars at 1.17 to the euro.
// Same currency as the list, so the sheet said nothing, while the list showed the
// booked euros at today's rate beside the 801.76 typed here.
@@ -695,12 +695,9 @@ describe('MCostSheet', () => {
})
renderSheet({ base: 'USD', editing })
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
// Each share next to what it counts as, the figure the row's "you lent" is made of.
expect(screen.getByText(/Split 2 ways · \$400\.88 → \$395\.77 each/)).toBeInTheDocument()
// The list reads the dollars typed, so the sheet has no conversion to show.
expect(screen.queryByText(/live rate/)).toBeNull()
expect(screen.getByText(/Split 2 ways · \$400\.88 each/)).toBeInTheDocument()
})
it('FE-MOB-COSTSH-037: a bill booked at today\'s rate has nothing to preview (#2525)', () => {
@@ -764,7 +764,7 @@ describe('MCostsTab', () => {
expect(update).toHaveBeenCalledTimes(1)
})
it('FE-MOB-COSTT-045: a bill whose rate moved shows what was entered and what it was booked at (#2525)', async () => {
it('FE-MOB-COSTT-045: a bill whose rate moved still reads as the dollars typed (#2965)', async () => {
// A euro trip read in dollars, the bill entered in dollars at a frozen 1.17 to the
// euro while today the euro buys 1.1551. It is 685.26 EUR of trip money, $791.55
// today, and that is what settle-up offers too; the row now says where the
@@ -793,17 +793,16 @@ describe('MCostsTab', () => {
await renderTab(planner({ trip: { ...TRIP, currency: 'EUR' } as Trip, budgetItems: [hotel] }))
const card = cardOf('Aparthotel Silver')
expect(within(card).getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
expect(within(card).getByText('$791.55')).toBeInTheDocument()
// Each share is half of the booked euros, the figure the balances net.
expect(within(card).getAllByText('$395.77')).toHaveLength(2)
expect(within(card).queryByText('$400.88')).toBeNull()
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$791.55')
expect(screen.getByText('costs.spent:$791.55')).toBeInTheDocument()
expect(within(card).queryByText(/^\$801\.76 → /)).toBeNull()
expect(within(card).getByText('$801.76')).toBeInTheDocument()
// Each share is half of the dollars typed, the figure the balances net (#2965).
expect(within(card).getAllByText('$400.88')).toHaveLength(2)
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$801.76')
expect(screen.getByText('costs.spent:$801.76')).toBeInTheDocument()
expect(screen.getAllByText('costs.payment')).toHaveLength(2)
const paid = up(screen.getByText('Ada → costs.you'), 2)
expect(within(paid).getByText(/^\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
expect(within(paid).getByText('$400.88')).toBeInTheDocument()
expect(within(up(screen.getByText('Bob → costs.you'), 2)).getByText('$30.00')).toBeInTheDocument()
expect(screen.queryByText('$34.65')).toBeNull()
})
@@ -325,17 +325,14 @@ describe('costsModel: a bill entered in the display currency (#2525)', () => {
payers: [payer(1, 801.76)], members: [member(1), member(2)],
});
it('FE-MOB-CMOD-038: a bill booked at a rate that has moved since counts as its euros at today\'s rate', () => {
// 685.26 EUR, which the balances net and settle-up offers; the row, the payer's
// figure and the share read the same, or a payback of the row's half reopens the trip.
expect(baseTotal(hotel(), usdCtx)).toBeCloseTo(791.55, 2);
expect(myPaidOf(hotel(), usdCtx)).toBeCloseTo(791.55, 2);
expect(myShareOf(hotel(), usdCtx)).toBeCloseTo(395.77, 2);
// Under it, what was entered and the euros it was booked at, no third step.
const line = lineOf(801.76, hotel(), usdCtx, baseTotal(hotel(), usdCtx))!;
expect(line.entered).toEqual({ amount: 801.76, currency: 'USD' });
expect(line.into.currency).toBe('EUR');
expect(line.into.amount).toBeCloseTo(685.26, 2);
it('FE-MOB-CMOD-038: a bill booked at a rate that has moved since still counts as the dollars typed (#2965)', () => {
// The balances net it as typed too, so the row, the payer's figure and the share
// read the dollars entered, and paying back half of them squares the trip.
expect(baseTotal(hotel(), usdCtx)).toBe(801.76);
expect(myPaidOf(hotel(), usdCtx)).toBe(801.76);
expect(myShareOf(hotel(), usdCtx)).toBe(400.88);
// Nothing to explain under it.
expect(lineOf(801.76, hotel(), usdCtx, baseTotal(hotel(), usdCtx))).toBeNull();
});
it('FE-MOB-CMOD-039: booked at today\'s rate, the bill reads exactly as typed and needs no line', () => {
@@ -380,7 +377,7 @@ describe('costsModel: a bill entered in the display currency (#2525)', () => {
].map((e, i) => ({ ...e, name: e.name || 'Hotel', expense_date: e.expense_date || '2026-07-19', id: i + 1 }));
const inUsd = buildCostsCsv(items, { base: 'USD', ctx: usdCtx, locale: 'en-US', tripTitle: 'Trip', t }).content.split('\r\n');
expect(inUsd[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note');
expect(inUsd[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/);
expect(inUsd[1]).toMatch(/;801\.76;USD;685\.26;801\.76;$/);
// Never booked, so counted at today's rate, as the server counts it.
expect(inUsd[2]).toMatch(/;115\.51;USD;100\.00;115\.51;$/);
const inEur = buildCostsCsv(items, { base: 'EUR', ctx: { ...usdCtx, displayCurrency: 'EUR' }, locale: 'en-US', tripTitle: 'Trip', t }).content.split('\r\n');
@@ -69,6 +69,19 @@ async function waitForCountries() {
await waitFor(() => expect(screen.getAllByRole('option', { name: 'Germany' }).length).toBeGreaterThan(0));
}
// The hire date is the shared date picker (#2971): typed through its manual
// field, cleared from its calendar.
function typeDate(trigger: HTMLElement, value: string) {
fireEvent.click(within(trigger.parentElement as HTMLElement).getByRole('button', { name: /enterManually|Enter date manually/ }))
const input = screen.getByRole('textbox', { name: /enterManually|Enter date manually/ })
fireEvent.change(input, { target: { value } })
fireEvent.keyDown(input, { key: 'Enter' })
}
function clearDate(trigger: HTMLElement) {
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: /clearDate|Clear date/ }))
}
describe('MVacaySettingsSheet', () => {
it('FE-MOB-MVACSET-001: renders nothing without a plan', () => {
useVacayStore.setState({ plan: null });
@@ -217,15 +230,15 @@ describe('MVacaySettingsSheet', () => {
});
renderSheet();
const hire = screen.getByLabelText('Hire date');
expect(hire).toHaveValue('2020-09-14');
const hire = () => screen.getByRole('button', { name: /^Hire date: / });
expect(hire()).toHaveAccessibleName(/2020/);
fireEvent.change(hire, { target: { value: '2021-03-02' } });
typeDate(hire(), '2021-03-02');
expect(updateYearSettings).toHaveBeenLastCalledWith({
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: '2021-03-02',
});
fireEvent.change(hire, { target: { value: '' } });
clearDate(hire());
expect(updateYearSettings).toHaveBeenLastCalledWith({
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: null,
});
+1 -1
View File
@@ -7,7 +7,7 @@
"src/db/repositories/Users.repository.ts": 1445,
"src/nest/atlas/atlas.service.ts": 1177,
"src/nest/auth/auth.service.ts": 1143,
"src/nest/budget/budget.service.ts": 1617,
"src/nest/budget/budget.service.ts": 1613,
"src/nest/collections/collections.service.ts": 1426,
"src/nest/doc-sync/doc-sync.service.ts": 1060,
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160,
+77 -3
View File
@@ -1,4 +1,4 @@
import { splitEqualShares, sumMinor, toMinor } from '@trek/shared';
import { currencyDecimals, splitEqualShares, sumMinor, toMinor } from '@trek/shared';
/**
* Re-denominate whole trip-currency cents into whole display-currency cents
@@ -41,7 +41,7 @@ export function sumMoney(amounts: number[]): number {
* a won and rounded one by one on screen stops adding up (#2604). In the trip's own
* currency the remainders are integers, so booking what is offered clears it.
*/
export function allocateDisplayCents(cents: number[], factor: number, total?: number, unit = 1): number[] {
function allocateDisplayCents(cents: number[], factor: number, total?: number, unit = 1): number[] {
if (factor === 1 && unit === 1) return [...cents];
const exact = cents.map((c) => (c * factor) / unit);
const out = exact.map((v) => Math.floor(v));
@@ -159,8 +159,82 @@ export function tripConverter(
* Units of `to` per 1 `from`, from a set of rates quoted against any base (each rate is
* units of that currency per 1 base). Null when either quote is missing.
*/
export function quoteRatio(rates: Record<string, number> | null, to: string, from: string): number | null {
function quoteRatio(rates: Record<string, number> | null, to: string, from: string): number | null {
const rTo = rates?.[to];
const rFrom = rates?.[from];
return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : null;
}
/**
* How a settlement reads its currencies: the display currency it answers in, the factor
* from trip cents into it, and the converters for expenses and recorded transfers.
* `calculateSettlement` nets with these; they are pure, so they live here.
*/
export function settlementFx(opts: {
base?: string;
rates?: Record<string, number> | null;
tripCurrency?: string;
baseRate?: number;
}) {
const requested = (opts.base || opts.tripCurrency || 'EUR').toUpperCase();
const tripCurrency = (opts.tripCurrency || requested).toUpperCase();
const rates = opts.rates ?? null;
// Net the whole settlement in the trip's canonical currency and convert the final
// totals to the display currency once, instead of netting in the (moving) display
// currency. Otherwise per-expense rounding shifts as live FX drifts and the greedy
// debt-simplifier reshuffles it into phantom third-party micro-flows (#1382). When
// the display currency IS the trip currency (the common case) every conversion below
// is the identity, so behaviour is unchanged.
// rates[X] = units of X per 1 base; the frozen exchange_rate is units of item-currency
// per 1 trip-currency. Pre-rework rows store currency = NULL = "the trip's own currency".
// Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate
// it was booked at, so a later live-rate drift doesn't re-open it with a residual.
// Legacy rows without a frozen rate convert via base with live rates.
const converterFor = (itemCurrency: string | null | undefined, itemRate?: number | null) =>
tripConverter(itemCurrency, itemRate, tripCurrency, rates);
// trip-currency → display currency, applied once to the final netted totals.
// Held as a plain factor so it is exactly linear: the balances are converted as
// one set (allocateDisplayCents) rather than one at a time, which is what keeps
// them adding up to zero in whatever currency the viewer picked (#1382).
//
// `rates` may be quoted against any base; the ratio of the two quotes is the
// factor either way. Callers pass the trip currency's own quote (see settlement()),
// the one freezeForeignRate froze every entry rate from, so a bill entered today in
// the display currency converts back to exactly what was typed (#2525). Taken the
// other way round, from the display currency's quote, the two quotes are not exact
// inverses and 12,345.67 USD came back as 12,346.06.
//
// Without a quote, the caller's own figure for the pair (`baseRate`) stands in for
// the missing display quote, and for nothing else: a row with a currency of its own
// is never converted with it. Like a live quote, it also reads a legacy transfer
// without a currency, which is taken to be in the display currency. With neither,
// the answer stays in the trip currency and says so, rather than printing trip cents
// as the display currency.
const baseRate = opts.baseRate != null && Number.isFinite(opts.baseRate) && opts.baseRate > 0 ? opts.baseRate : null;
const quoted = requested === tripCurrency ? 1 : (quoteRatio(rates, requested, tripCurrency) ?? baseRate);
const currency = quoted === null ? tripCurrency : requested;
const displayFactor = quoted ?? 1;
// Every figure goes out in whole steps of that currency: whole won or yen (#2604).
const displayUnit = 10 ** Math.max(0, 2 - currencyDecimals(currency));
const toDisplay = (cents: number[], total?: number) => allocateDisplayCents(cents, displayFactor, total, displayUnit);
// An expense entered in the display currency itself is owed in that currency
// (#2965). Booked through the trip currency at its frozen rate and back at
// today's, 1,081.09 EUR on a yen trip came back a day later as 1,078.86 EUR,
// and a repayment of exactly what was typed left 2.23 EUR owed. Such rows are
// netted apart, in whole display cents as entered; every other row still nets
// in trip cents and converts once, so Σ balances stays 0 in both halves.
const direct = currency !== tripCurrency ? currency : null;
const isDirect = (rowCurrency: string | null | undefined) =>
direct !== null && (rowCurrency || tripCurrency).toUpperCase() === direct;
// A recorded settle-up amount is entered in whatever display currency the payer
// was viewing. New rows capture that currency and the rate frozen at settle time
// (#1445), so a settled position stays balanced when live rates drift — mirroring
// the expenses; a frozen currency without a usable rate falls back to live rates,
// and without those the transfer is left out like an expense. Legacy rows
// (currency = NULL) have no frozen rate, so fall back to the old behaviour: assume
// they were entered in the current display base and convert with live rates, by the
// inverse of the display factor, so such a transfer reads back as entered.
const settleConverterFor = (sCurrency?: string | null, sRate?: number | null) =>
sCurrency ? tripConverter(sCurrency, sRate, tripCurrency, rates) : (amount: number) => amount / displayFactor;
return { currency, displayFactor, toDisplay, converterFor, settleConverterFor, isDirect };
}
+76 -76
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { BudgetFallbackFx, BudgetParticipantFinal, BudgetUnconverted, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { currencyDecimals, splitEqualShares, sumMinor, toMinor } from '@trek/shared';
import { splitEqualShares, sumMinor, toMinor } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { avatarUrl } from '../common/avatarUrl';
@@ -31,7 +31,7 @@ import { BUDGET_ITEM_TICKET, RESERVATION_METADATA } from '../../db/json-columns'
import { decodeJson, decodeJsonResult, encodeJson } from '../../utils/json-column';
import { DomainError } from '../common/domain-error';
import { GUEST_HAS_PAYMENTS, shareAfterLeaving } from './budget-departure.helpers';
import { allocateDisplayCents, customSplitCents, isFrozenRate, NOT_FROZEN_RATE, quoteRatio, summaryShareCents, sumMoney, tripConverter } from './budget-money.helpers';
import { customSplitCents, isFrozenRate, NOT_FROZEN_RATE, settlementFx, summaryShareCents, sumMoney, tripConverter } from './budget-money.helpers';
type Trip = TripAccess;
@@ -1083,59 +1083,7 @@ export class BudgetService {
tripId: string | number,
opts: { base?: string; rates?: Record<string, number> | null; tripCurrency?: string; baseRate?: number } = {},
) {
const requested = (opts.base || opts.tripCurrency || 'EUR').toUpperCase();
const tripCurrency = (opts.tripCurrency || requested).toUpperCase();
const rates = opts.rates ?? null;
// Net the whole settlement in the trip's canonical currency and convert the final
// totals to the display currency once, instead of netting in the (moving) display
// currency. Otherwise per-expense rounding shifts as live FX drifts and the greedy
// debt-simplifier reshuffles it into phantom third-party micro-flows (#1382). When
// the display currency IS the trip currency (the common case) every conversion below
// is the identity, so behaviour is unchanged.
// rates[X] = units of X per 1 base; the frozen exchange_rate is units of item-currency
// per 1 trip-currency. Pre-rework rows store currency = NULL = "the trip's own currency".
// Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate
// it was booked at, so a later live-rate drift doesn't re-open it with a residual.
// Legacy rows without a frozen rate convert via base with live rates.
const converterFor = (itemCurrency: string | null | undefined, itemRate?: number | null) =>
tripConverter(itemCurrency, itemRate, tripCurrency, rates);
// trip-currency → display currency, applied once to the final netted totals.
// Held as a plain factor so it is exactly linear: the balances are converted as
// one set (allocateDisplayCents) rather than one at a time, which is what keeps
// them adding up to zero in whatever currency the viewer picked (#1382).
//
// `rates` may be quoted against any base; the ratio of the two quotes is the
// factor either way. Callers pass the trip currency's own quote (see settlement()),
// the one freezeForeignRate froze every entry rate from, so a bill entered today in
// the display currency converts back to exactly what was typed (#2525). Taken the
// other way round, from the display currency's quote, the two quotes are not exact
// inverses and 12,345.67 USD came back as 12,346.06.
//
// Without a quote, the caller's own figure for the pair (`baseRate`) stands in for
// the missing display quote, and for nothing else: a row with a currency of its own
// is never converted with it. Like a live quote, it also reads a legacy transfer
// without a currency, which is taken to be in the display currency. With neither,
// the answer stays in the trip currency and says so, rather than printing trip cents
// as the display currency.
const baseRate = opts.baseRate != null && Number.isFinite(opts.baseRate) && opts.baseRate > 0 ? opts.baseRate : null;
const quoted = requested === tripCurrency ? 1 : (quoteRatio(rates, requested, tripCurrency) ?? baseRate);
const currency = quoted === null ? tripCurrency : requested;
const displayFactor = quoted ?? 1;
// Every figure goes out in whole steps of that currency: whole won or yen (#2604).
const displayUnit = 10 ** Math.max(0, 2 - currencyDecimals(currency));
const toDisplay = (cents: number[], total?: number) => allocateDisplayCents(cents, displayFactor, total, displayUnit);
// A recorded settle-up amount is entered in whatever display currency the payer
// was viewing. New rows capture that currency and the rate frozen at settle time
// (#1445), so a settled position stays balanced when live rates drift — mirroring
// the expenses; a frozen currency without a usable rate falls back to live rates,
// and without those the transfer is left out like an expense. Legacy rows
// (currency = NULL) have no frozen rate, so fall back to the old behaviour: assume
// they were entered in the current display base and convert with live rates.
const settleConverterFor = (sCurrency?: string | null, sRate?: number | null) =>
sCurrency
? tripConverter(sCurrency, sRate, tripCurrency, rates)
// The inverse of the display factor, so such a transfer reads back as entered.
: (amount: number) => amount / displayFactor;
const { currency, displayFactor, toDisplay, converterFor, settleConverterFor, isDirect } = settlementFx(opts);
const unconvertedItems: number[] = [];
const unconvertedSettlements: number[] = [];
const unconvertedCurrencies = new Set<string>();
@@ -1149,9 +1097,15 @@ export class BudgetService {
// and rounded to a cent once, at the boundary — from there on the ledger is
// integer arithmetic, so Σ(balances) is exactly 0 and no sub-cent residual can
// build up behind the two-decimal figures the user sees (#1382).
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; cents: number }> = {};
// `direct` is the half in display cents (#2965), see isDirect in settlementFx.
const balances: Record<
number,
{ user_id: number; username: string; avatar_url: string | null; cents: number; direct: number }
> = {};
const ensure = (id: number, src: { username?: string; avatar?: string | null }) => {
if (!balances[id]) balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), cents: 0 };
if (!balances[id]) {
balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), cents: 0, direct: 0 };
}
return balances[id];
};
// The two halves of the balance, kept apart so the per-person final budget can
@@ -1161,10 +1115,16 @@ export class BudgetService {
// are derived from.
const frontedCents: Record<number, number> = {};
const reimbursedCents: Record<number, number> = {};
const frontedDirect: Record<number, number> = {};
const reimbursedDirect: Record<number, number> = {};
// ...and the rows they are made of, so the breakdown lists them in these same
// cents rather than converting the expense list a second time on the client.
const frontedRows: Record<number, { item_id: number; cents: number }[]> = {};
const movedRows: Record<number, { settlement_id: number; from_user_id: number; to_user_id: number; cents: number }[]> = {};
// `cents` is the trip-cent part each row is converted from, `direct` the part already in display cents.
const frontedRows: Record<number, { item_id: number; cents: number; direct: number }[]> = {};
const movedRows: Record<
number,
{ settlement_id: number; from_user_id: number; to_user_id: number; cents: number; direct: number }[]
> = {};
// Read in the trip currency, those figures are the ledger's own trip cents. Read in
// another one, they are built from each row's unrounded trip amount instead: a trip
// cent is worth more than a dollar cent on a euro trip, so a bill of 12,345.67 USD
@@ -1175,7 +1135,8 @@ export class BudgetService {
for (const item of items) {
// Before anything else, planning-only and unpaid rows included: a row no rate can
// convert is reported whole and moves nothing, credits and shares alike.
const convert = converterFor(item.currency, item.exchange_rate);
const inDisplay = isDirect(item.currency);
const convert = inDisplay ? (amount: number) => amount : converterFor(item.currency, item.exchange_rate);
if (!convert) {
unconvertedItems.push(item.id);
unconvertedCurrencies.add((item.currency || '').toUpperCase());
@@ -1210,9 +1171,16 @@ export class BudgetService {
for (const p of payers) {
const exact = convert(p.amount) * 100;
const paid = Math.round(exact);
ensure(p.user_id, p).cents += paid;
frontedCents[p.user_id] = (frontedCents[p.user_id] || 0) + finalUnit(exact);
if (paid !== 0) (frontedRows[p.user_id] ??= []).push({ item_id: item.id, cents: finalUnit(exact) });
const payer = ensure(p.user_id, p);
if (inDisplay) {
payer.direct += paid;
frontedDirect[p.user_id] = (frontedDirect[p.user_id] || 0) + paid;
if (paid !== 0) (frontedRows[p.user_id] ??= []).push({ item_id: item.id, cents: 0, direct: paid });
} else {
payer.cents += paid;
frontedCents[p.user_id] = (frontedCents[p.user_id] || 0) + finalUnit(exact);
if (paid !== 0) (frontedRows[p.user_id] ??= []).push({ item_id: item.id, cents: finalUnit(exact), direct: 0 });
}
creditCents += paid;
}
// …and each split participant owes their share — a custom per-member amount
@@ -1231,7 +1199,9 @@ export class BudgetService {
? customSplitCents(members, convert, creditCents, sumMinor(payers.map(p => p.amount)))
: splitEqualShares(creditCents, members, item.id);
for (const m of members) {
ensure(m.user_id, m).cents -= shares[m.user_id] || 0;
const member = ensure(m.user_id, m);
if (inDisplay) member.direct -= shares[m.user_id] || 0;
else member.cents -= shares[m.user_id] || 0;
}
}
@@ -1242,7 +1212,9 @@ export class BudgetService {
// surfaces as an amount still to square up instead of silently vanishing.
const settlements = await this.listSettlements(tripId);
const ensureSettled = (id: number, username: string | undefined, avatar_url: string | null | undefined) => {
if (!balances[id]) balances[id] = { user_id: id, username: username || '', avatar_url: avatar_url ?? null, cents: 0 };
if (!balances[id]) {
balances[id] = { user_id: id, username: username || '', avatar_url: avatar_url ?? null, cents: 0, direct: 0 };
}
return balances[id];
};
for (const s of settlements) {
@@ -1252,12 +1224,26 @@ export class BudgetService {
unconvertedCurrencies.add((s.currency || '').toUpperCase());
continue;
}
const from = ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url);
const to = ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url);
// A transfer in the display currency first pays what the payer owes the
// receiver in display-currency expenses, as typed (#2965); only the rest
// goes through the trip currency at its frozen rate, which keeps a debt
// in the trip currency settled when rates move (#1445).
const total = Math.round(s.amount * 100);
const room = isDirect(s.currency) ? Math.max(0, Math.min(-from.direct, to.direct)) : 0;
const paidDirect = total > 0 ? Math.min(total, room) : 0;
from.direct += paidDirect;
to.direct -= paidDirect;
reimbursedDirect[s.to_user_id] = (reimbursedDirect[s.to_user_id] || 0) + paidDirect;
reimbursedDirect[s.from_user_id] = (reimbursedDirect[s.from_user_id] || 0) - paidDirect;
// Rounded to a trip cent per transfer, so recording one in a display currency
// can't leave a sliver of a cent behind to accumulate over a trip's lifetime.
const exact = convert(s.amount) * 100;
const rest = total === 0 ? 0 : (total - paidDirect) / total;
const exact = convert(s.amount) * 100 * rest;
const inTrip = Math.round(exact);
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).cents += inTrip;
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).cents -= inTrip;
from.cents += inTrip;
to.cents -= inTrip;
// Net of the transfers in both directions: sending one back is a reimbursement
// received in reverse, and netting them is what keeps the final budget's
// subtraction equal to the balance it is taken from.
@@ -1265,23 +1251,32 @@ export class BudgetService {
reimbursedCents[s.to_user_id] = (reimbursedCents[s.to_user_id] || 0) + shown;
reimbursedCents[s.from_user_id] = (reimbursedCents[s.from_user_id] || 0) - shown;
const moved = { settlement_id: s.id, from_user_id: s.from_user_id, to_user_id: s.to_user_id };
(movedRows[s.to_user_id] ??= []).push({ ...moved, cents: shown });
(movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -shown });
(movedRows[s.to_user_id] ??= []).push({ ...moved, cents: shown, direct: paidDirect });
(movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -shown, direct: -paidDirect });
}
// Into the display currency as one set, then simplify — balances and flows are
// derived from the same integers, so what the balances say is owed is exactly
// what "Settle up" offers to move, down to the last cent (#1382).
const ledger = Object.values(balances);
const displayCents = toDisplay(ledger.map(b => b.cents));
// The trip-cent half converts as one set; the direct half is display cents already.
const displayCents = toDisplay(ledger.map(b => b.cents)).map((cents, i) => cents + ledger[i].direct);
// Each component of the final budget is re-denominated as its own set, for the
// same reason the balances are: rounding one person at a time lets the column
// drift away from the figure it converted from. The final itself is then
// subtracted in display cents rather than converted separately, so the three
// lines the breakdown shows always add up to the total beside them, whatever
// currency the viewer picked.
const frontedDisplayCents = toDisplay(ledger.map(b => frontedCents[b.user_id] || 0));
const reimbursedDisplayCents = toDisplay(ledger.map(b => reimbursedCents[b.user_id] || 0));
const frontedViaTrip = toDisplay(ledger.map(b => frontedCents[b.user_id] || 0));
const reimbursedViaTrip = toDisplay(ledger.map(b => reimbursedCents[b.user_id] || 0));
const frontedDisplayCents = frontedViaTrip.map((cents, i) => cents + (frontedDirect[ledger[i].user_id] || 0));
const reimbursedDisplayCents = reimbursedViaTrip.map((cents, i) => cents + (reimbursedDirect[ledger[i].user_id] || 0));
// A row list against its figure: the trip-cent parts split the converted half
// the same largest-remainder way, the direct parts are added as they are.
const rowsToDisplay = <R extends { cents: number; direct: number }>(rows: R[], viaTrip: number) => {
const converted = toDisplay(rows.map(r => r.cents), viaTrip);
return rows.map((r, k) => converted[k] + r.direct);
};
// Calculate optimized payment flows (greedy algorithm)
const people = ledger
@@ -1323,8 +1318,8 @@ export class BudgetService {
// figure as the target, so each list adds up to the line it sits under.
const fronted = frontedRows[b.user_id] || [];
const moved = movedRows[b.user_id] || [];
const frontedDisplay = toDisplay(fronted.map(r => r.cents), frontedDisplayCents[i]);
const movedDisplay = toDisplay(moved.map(r => r.cents), reimbursedDisplayCents[i]);
const frontedDisplay = rowsToDisplay(fronted, frontedViaTrip[i]);
const movedDisplay = rowsToDisplay(moved, reimbursedViaTrip[i]);
return {
user_id: b.user_id, username: b.username, avatar_url: b.avatar_url,
expenses: frontedDisplayCents[i] / 100,
@@ -1333,7 +1328,12 @@ export class BudgetService {
final: (frontedDisplayCents[i] - reimbursedDisplayCents[i] - displayCents[i]) / 100,
sources: {
fronted: fronted.map((r, k) => ({ item_id: r.item_id, cents: frontedDisplay[k] })),
moved: moved.map((r, k) => ({ ...r, cents: movedDisplay[k] })),
moved: moved.map((r, k) => ({
settlement_id: r.settlement_id,
from_user_id: r.from_user_id,
to_user_id: r.to_user_id,
cents: movedDisplay[k],
})),
// The flows are display cents already, and the greedy pass drains every
// balance completely, so the flows on a person's side sum to their balance.
outstanding: flows
+1
View File
@@ -156,6 +156,7 @@ export const MANAGED_CUSTOMER_KEYS = [
'temperature_unit',
'time_format',
'week_start',
'date_format',
] as const;
const LOCKED = new Set<string>(MANAGED_LOCKED_SETTING_KEYS);
+4 -2
View File
@@ -14,6 +14,7 @@ import {
SUPPORTED_LANGUAGE_CODES,
settingsBulkRequestSchema,
weekStartSchema,
dateFormatSchema,
} from '@trek/shared';
import { z } from 'zod';
@@ -67,6 +68,7 @@ const DISPLAY_PREFERENCES = {
distance_unit: z.enum(['metric', 'imperial']),
time_format: z.enum(['12h', '24h']),
week_start: weekStartSchema,
date_format: dateFormatSchema,
// Booleans are the pre-'auto' form and still sit in older rows, so they stay
// writable: this is exactly the VALID_VALUES.dark_mode set the admin-defaults
// path accepts, and refusing them here would make a value TREK itself wrote
@@ -132,7 +134,7 @@ export class SettingsMcp {
@Tool({
name: 'get_display_settings',
description: `Read the current user's display preferences: ${KEY_LIST}. Call this before rendering a temperature, a distance, a time of day or an amount, so the answer matches what the person sees inside TREK instead of being guessed, and call it before update_display_settings whenever the request is relative ("switch me back", "use the other unit"). Only keys that are actually set are returned; TREK's own fallbacks for the rest are celsius, metric, 24h, week_start monday, language en, start_page dashboard, start_trip_tab plan, and no personal default currency, which means amounts fall back to the currency of the trip they belong to. Stored credentials (map tokens, LLM API keys, webhook URLs, SMTP) are deliberately not part of this surface and never appear in the result.`,
description: `Read the current user's display preferences: ${KEY_LIST}. Call this before rendering a temperature, a distance, a time of day or an amount, so the answer matches what the person sees inside TREK instead of being guessed, and call it before update_display_settings whenever the request is relative ("switch me back", "use the other unit"). Only keys that are actually set are returned; TREK's own fallbacks for the rest are celsius, metric, 24h, week_start monday, date_format auto (the interface language's own format), language en, start_page dashboard, start_trip_tab plan, and no personal default currency, which means amounts fall back to the currency of the trip they belong to. Stored credentials (map tokens, LLM API keys, webhook URLs, SMTP) are deliberately not part of this surface and never appear in the result.`,
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'settings', mode: 'read' },
@@ -143,7 +145,7 @@ export class SettingsMcp {
@Tool({
name: 'update_display_settings',
description: `Change one or more of the current user's display preferences. Pass a settings object holding only the keys to change; anything left out keeps its current value. Writable keys: ${KEY_LIST}. start_page is dashboard or active_trip, start_trip_tab is one of plan, transports, buchungen, listen, finanzplan, dateien, collab (the ids are the planner's internal German ones) or plugin:<id> for a plugin tab, default_currency is a three-letter ISO code or "" to fall back to each trip's own currency, dark_mode is light, dark or auto, week_start is monday, sunday or saturday and sets the first column of every date picker in TREK. Every other key is refused, including API keys, map tokens, LLM endpoint settings and SMTP: those belong to the account owner or to whoever operates the instance and are set in TREK itself, not here. Prefer get_display_settings when you only need to read a value.`,
description: `Change one or more of the current user's display preferences. Pass a settings object holding only the keys to change; anything left out keeps its current value. Writable keys: ${KEY_LIST}. start_page is dashboard or active_trip, start_trip_tab is one of plan, transports, buchungen, listen, finanzplan, dateien, collab (the ids are the planner's internal German ones) or plugin:<id> for a plugin tab, default_currency is a three-letter ISO code or "" to fall back to each trip's own currency, dark_mode is light, dark or auto, week_start is monday, sunday or saturday and sets the first column of every date picker in TREK, date_format is auto (the interface language's format), YYYY-MM-DD, YYYY/MM/DD, DD.MM.YYYY, DD/MM/YYYY or MM/DD/YYYY and sets how every date field and numeric date in TREK is written. Every other key is refused, including API keys, map tokens, LLM endpoint settings and SMTP: those belong to the account owner or to whoever operates the instance and are set in TREK itself, not here. Prefer get_display_settings when you only need to read a value.`,
inputSchema: {
settings: settingsBulkRequestSchema.shape.settings.describe(
`Object of preference key to new value, e.g. {"temperature_unit": "fahrenheit", "distance_unit": "imperial"}. At least one key, and every key must be one of: ${KEY_LIST}.`,
@@ -13,6 +13,7 @@ import {
MASKED_SETTING_VALUE,
PLANNER_TRIP_VIEWS_KEY,
WEEK_START_VALUES,
DATE_FORMAT_VALUES,
compactPlannerTripViews,
normalizeAppearance,
plannerTripViewsSchema,
@@ -43,6 +44,8 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
'time_format',
// First day of the week in every date picker (#2029).
'week_start',
// How dates are written in numbers, in every date field and list (#2971).
'date_format',
// Instance-wide default currency for Costs (new users inherit it until they
// pick their own). Free-form ISO code, validated on the client.
'default_currency',
@@ -110,6 +113,7 @@ const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
distance_unit: ['metric', 'imperial'],
time_format: ['12h', '24h'],
week_start: [...WEEK_START_VALUES],
date_format: [...DATE_FORMAT_VALUES],
dark_mode: [true, false, 'light', 'dark', 'auto'],
map_provider: ['leaflet', 'mapbox-gl', 'maplibre-gl'],
llm_provider: ['local', 'openai', 'anthropic'],
+22
View File
@@ -338,6 +338,28 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => {
});
});
describe('date_format (#2971)', () => {
it('an admin default reaches a user who never picked a date format', async () => {
const put = await request(server)
.put('/api/admin/default-user-settings')
.set('Cookie', sessionCookie(2))
.send({ date_format: 'YYYY-MM-DD' });
expect(put.status).toBe(200);
const res = await request(server).get('/api/settings').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body.settings.date_format).toBe('YYYY-MM-DD');
});
it('PUT /api/admin/default-user-settings 400 for a format the app does not offer', async () => {
const res = await request(server)
.put('/api/admin/default-user-settings')
.set('Cookie', sessionCookie(2))
.send({ date_format: 'YY.MM.DD' });
expect(res.status).toBe(400);
expect(await findRow(orm, AppSettings, { key: 'default_user_setting_date_format' })).toBeNull();
});
});
describe('planner_trip_views (#2639)', () => {
beforeEach(async () => {
await deleteRows(orm, TripMembers);
@@ -264,6 +264,19 @@ describe('Tool: update_display_settings', () => {
});
});
it('writes date_format and refuses a format the app does not offer (#2971)', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const ok = parseToolResult(await update(h, { date_format: 'DD.MM.YYYY' })) as {
settings: Record<string, unknown>;
};
expect(ok.settings.date_format).toBe('DD.MM.YYYY');
const refused = await update(h, { date_format: 'dd-mm' });
expect(refused.isError).toBe(true);
expect(await readSetting(user.id, 'date_format')).toBe('DD.MM.YYYY');
});
});
it('writes week_start and refuses a day outside the three the app offers (#2029)', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
@@ -1268,7 +1268,7 @@ describe('calculateSettlement: unconverted rows', () => {
it('baseRate never converts a row', async () => {
await setupDb(
[{ ...makeItem(1, 100), currency: 'EUR', exchange_rate: 1 } as BudgetItem],
[{ ...makeItem(1, 100), currency: 'USD', exchange_rate: 1 } as BudgetItem],
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
[makePayer(1, 1, 100, 'alice')],
);
@@ -1280,9 +1280,25 @@ describe('calculateSettlement: unconverted rows', () => {
baseRate: 0.61,
});
expect(result.currency).toBe('EUR');
expect(result.unconverted).toEqual({ item_ids: [1], settlement_ids: [], currencies: ['EUR'] });
expect(result.unconverted).toEqual({ item_ids: [1], settlement_ids: [], currencies: ['USD'] });
expect(result.balances).toEqual([]);
});
it('a bill in the display currency needs no rate at all: it counts as typed (#2965)', async () => {
await setupDb(
[{ ...makeItem(1, 100), currency: 'EUR', exchange_rate: 1 } as BudgetItem],
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
[makePayer(1, 1, 100, 'alice')],
);
const result = await budget.calculateSettlement(1, {
base: 'EUR',
tripCurrency: 'AUD',
rates: null,
baseRate: 0.61,
});
expect(result.unconverted).toEqual(none);
expect(result.balances.map((b) => b.balance)).toEqual([50, -50]);
});
});
// ── Currencies shown without decimals (#2604) ────────────────────────────────
@@ -1546,3 +1562,79 @@ describe('applySettlementUpdate', () => {
expect(res).toMatchObject({ id: 7, from_user_id: 2, to_user_id: 1, amount: 10.13 });
});
});
// ── A bill in the display currency counts as typed (#2965) ──────────────────
// A yen trip read in euros, the flight entered in euros: booked through the yen
// at its frozen rate and back at today's, 1,081.09 EUR came back as 1,078.86 EUR
// a day later, and repaying exactly what was typed left 2.23 EUR owed.
describe('calculateSettlement: bills in the display currency (#2965)', () => {
// The flight was frozen at 176.37 yen per euro; today a euro buys 176.73.
const flight = () => [{ ...makeItem(1, 1081.09), currency: 'EUR', exchange_rate: 1 / 176.37 } as BudgetItem];
const pair = () => [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')];
const paid = () => [makePayer(1, 1, 1081.09, 'alice')];
const today = { base: 'EUR', tripCurrency: 'JPY', rates: { JPY: 1, EUR: 1 / 176.73 } };
it('owes half of what was typed, whatever the rate did since', async () => {
await setupDb(flight(), pair(), paid());
const result = await budget.calculateSettlement(1, today);
expect(result.currency).toBe('EUR');
expect(centSum(result.balances.map((b) => b.balance))).toBe(0);
const [alice, bob] = result.balances.map((b) => b.balance);
expect([540.54, 540.55]).toContain(alice);
expect(bob).toBe(-alice);
const fronted = result.finalBudgets.find((f) => f.user_id === 1);
expect(fronted?.expenses).toBe(1081.09);
expect(fronted?.sources.fronted).toEqual([{ item_id: 1, cents: 108109 }]);
});
it('a repayment of exactly what is owed in euros squares the trip, frozen at any rate', async () => {
await setupDb(flight(), pair(), paid());
const owed = (await budget.calculateSettlement(1, today)).flows[0].amount;
// Bob pays the euros the flow names, frozen at today's yen rate.
await setupDb(flight(), pair(), paid(), [makeSettlementRow(5, 2, 1, owed, 'EUR', 1 / 176.73)]);
const results = await Promise.all(
[1 / 176.73, 1 / 170, 1 / 185].map((eurPerYen) =>
budget.calculateSettlement(1, { ...today, rates: { JPY: 1, EUR: eurPerYen } }),
),
);
for (const result of results) {
expect(result.balances.map((b) => b.balance)).toEqual([0, 0]);
expect(result.flows).toEqual([]);
for (const f of result.finalBudgets) expect(f.final).toBeCloseTo(f.user_id === 1 ? 1081.09 - owed : owed, 2);
}
});
it('a euro payment beyond the euro debt pays the yen debt at its frozen rate (#1445 stays)', async () => {
// A yen dinner on top: Bob owes 540.55 EUR for the flight and 5,000 yen for dinner.
await setupDb(
[...flight(), { ...makeItem(2, 10000) } as BudgetItem],
[...pair(), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')],
[...paid(), makePayer(2, 1, 10000, 'alice')],
);
const before = await budget.calculateSettlement(1, today);
const owed = before.flows[0].amount;
await setupDb(
[...flight(), { ...makeItem(2, 10000) } as BudgetItem],
[...pair(), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')],
[...paid(), makePayer(2, 1, 10000, 'alice')],
[makeSettlementRow(5, 2, 1, owed, 'EUR', 1 / 176.73)],
);
// Settled at today's rate it squares the trip; the yen part keeps its frozen rate,
// so it stays within a cent when the yen moves afterwards.
const now = await budget.calculateSettlement(1, today);
expect(now.balances.map((b) => b.balance)).toEqual([0, 0]);
const later = await budget.calculateSettlement(1, { ...today, rates: { JPY: 1, EUR: 1 / 160 } });
for (const b of later.balances) expect(Math.abs(b.balance)).toBeLessThanOrEqual(0.01);
expect(centSum(later.balances.map((b) => b.balance))).toBe(0);
});
it('read in the trip currency nothing changes: the bill goes through its frozen rate', async () => {
await setupDb(flight(), pair(), paid());
const result = await budget.calculateSettlement(1, { base: 'JPY', tripCurrency: 'JPY', rates: null });
expect(result.currency).toBe('JPY');
// 1,081.09 EUR at 176.37 is 190,672 yen (whole yen), half each.
expect(centSum(result.balances.map((b) => b.balance))).toBe(0);
expect(Math.abs(result.balances[0].balance)).toBeCloseTo(95336, -1);
});
});
@@ -1807,14 +1807,14 @@ describe('the settlement converts with the quote the entry rate was frozen from
const mine = before.finalBudgets.find(f => f.user_id === me.id)!;
expect(mine.expenses).toBe(12345.67);
expect(mine.sources.fronted).toEqual([{ item_id: bill.id, cents: 1234567 }]);
expect(before.flows.map(f => f.amount)).toEqual([6172.84]);
// Bob pays exactly what settle-up offers, in dollars, as the Costs screen records it.
await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 6172.84, currency: 'USD' }, me.id);
expect(before.flows.map(f => [6172.83, 6172.84].includes(f.amount))).toEqual([true]);
// Bob pays exactly what settle-up offers, in dollars; read in euros one cent may stay apart (#2965).
await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: before.flows[0].amount, currency: 'USD' }, me.id);
const after = await budget.settlement(trip.id, 'USD', 'EUR');
expect(after.balances.map(b => b.balance)).toEqual([0, 0]);
expect(after.flows).toEqual([]);
expect((await budget.settlement(trip.id, 'EUR', 'EUR')).balances.map(b => b.balance)).toEqual([0, 0]);
const inEur = (await budget.settlement(trip.id, 'EUR', 'EUR')).balances.map(b => Math.round(b.balance * 100));
expect([[0, 0], [1, -1], [-1, 1]]).toContainEqual(inEur);
});
it('BUDGET-SVC-DB-083: the final budget lists a bill at what was typed even where its trip cent rounds away', async () => {
@@ -387,6 +387,22 @@ describe('legacy quirk fixes', () => {
expect((await svc.getUserSettings(own.id)).week_start).toBe('saturday');
});
it('SET-SVC-037 — a date_format default reaches users without their own, and theirs wins (#2971)', async () => {
const { user: plain } = createUser(testDb);
const { user: own } = createUser(testDb);
await svc.setAdminUserDefaults({ date_format: 'YYYY-MM-DD' });
await svc.upsertSetting(own.id, 'date_format', 'DD/MM/YYYY');
expect((await svc.getUserSettings(plain.id)).date_format).toBe('YYYY-MM-DD');
expect((await svc.getUserSettings(own.id)).date_format).toBe('DD/MM/YYYY');
});
it('SET-SVC-038 — a date_format default the app does not offer is refused and not stored', async () => {
await expect(svc.setAdminUserDefaults({ date_format: 'YY-MM-DD' })).rejects.toThrow(
/Invalid value for date_format/,
);
expect((await svc.getAdminUserDefaults()).date_format).toBeUndefined();
});
it('SET-SVC-036 — a week_start default outside monday/sunday/saturday is refused and not stored', async () => {
await expect(svc.setAdminUserDefaults({ week_start: 'friday' })).rejects.toThrow(/Invalid value for week_start/);
// Vacay's 0/1 numbers are not this setting's values either.
+6 -6
View File
@@ -1063,7 +1063,7 @@ const help: TranslationStrings = {
'help.ctx.settings.bullet.1':
'الشريط الجانبي على اليسار: «العرض» و«المظهر» و«الخريطة» و«الإشعارات» و«التكاملات» و«غير متصل» و«الحساب». ويظهر «الإضافات الخارجية» عند وجود إضافة نشطة. ويظهر «حول» ما لم يخفه المشغّل. ويظهر الإصدار في الأسفل.',
'help.ctx.settings.bullet.2':
'يفتح «العرض» أولًا. ويضم اللغة، ولغة أسماء الأماكن، والوحدات، وتنسيق الوقت، وبداية الأسبوع، وعملة العرض، إضافة إلى سلوك خريطة الرحلة. ويضم «المظهر» السمة والألوان وسهولة القراءة وحجم النص وأدوات لوحة التحكم.',
'يفتح «العرض» أولًا. ويضم اللغة، ولغة أسماء الأماكن، والوحدات، وتنسيق الوقت، وتنسيق التاريخ، وبداية الأسبوع، وعملة العرض، إضافة إلى سلوك خريطة الرحلة. ويضم «المظهر» السمة والألوان وسهولة القراءة وحجم النص وأدوات لوحة التحكم.',
'help.ctx.settings.bullet.3':
'«الخريطة» تحدد محرك الخريطة ونمطها؛ و«الإشعارات» تحدد أي الأحداث تصل عبر أي قناة؛ و«التكاملات» تربط مكتبات الصور وAirTrail وDawarich وwanderer، وفيها مفاتيح API وMCP؛ و«غير متصل» يحدد ما يخزّنه هذا الجهاز.',
'help.ctx.settings.bullet.4':
@@ -1074,7 +1074,7 @@ const help: TranslationStrings = {
'help.ctx.settings-display.bullet.1':
'«البدء»: «صفحة البدء» تفتح على «لوحة التحكم» أو «الرحلة النشطة»؛ ومع «الرحلة النشطة»، تختار «علامة تبويب البدء» التبويب الذي تفتح عليه تلك الرحلة.',
'help.ctx.settings-display.bullet.2':
'اللغة والمنطقة: «عملة العرض» (أو «عملة الرحلة» دون تحويل)، و«اللغة»، و«أسماء الأماكن»، و«وحدة الحرارة»، و«وحدة المسافة»، و«تنسيق الوقت»، و«يبدأ الأسبوع في».',
'اللغة والمنطقة: «عملة العرض» (أو «عملة الرحلة» دون تحويل)، و«اللغة»، و«أسماء الأماكن»، و«وحدة الحرارة»، و«وحدة المسافة»، و«تنسيق الوقت»، و«يبدأ الأسبوع في»، و«تنسيق التاريخ».',
'help.ctx.settings-display.bullet.3':
'«السفر والخريطة»: «فتح الأماكن في»، و«التاريخ أولًا في عناوين الأيام»، و«تسميات مسارات الحجوزات»، و«إظهار مسارات الحجوزات دائمًا»، و«علامات مصغّرة للأماكن غير المخطط لها»، و«استكشاف الأماكن على الخريطة»، و«إخفاء رموز الحجز»، و«تحسين المسار انطلاقًا من مكان الإقامة».',
'help.ctx.settings-appearance.title': 'المظهر',
@@ -1375,9 +1375,9 @@ const help: TranslationStrings = {
'«إعدادات الصلاحيات» في الأسفل: لكل إجراء، من يحق له تنفيذه («الجميع» أو «أعضاء الرحلة» أو «مالك الرحلة» أو «المسؤول فقط»). انقر على «حفظ» أعلى البطاقة لحفظها.',
'help.ctx.admin-defaults.title': 'الإعدادات الافتراضية',
'help.ctx.admin-defaults.summary':
'ما يبدأ به الحساب الجديد، حتى لا يضطر أحد إلى ضبطه أولًا: نمط الألوان والوحدات وتنسيق الوقت وبداية الأسبوع وعملة العرض وإخفاء رموز الحجز، إضافة إلى الخريطة ومفاتيحها وخوادم حساب المسارات.',
'ما يبدأ به الحساب الجديد، حتى لا يضطر أحد إلى ضبطه أولًا: نمط الألوان والوحدات وتنسيق الوقت وتنسيق التاريخ وبداية الأسبوع وعملة العرض وإخفاء رموز الحجز، إضافة إلى الخريطة ومفاتيحها وخوادم حساب المسارات.',
'help.ctx.admin-defaults.bullet.1':
'«إعدادات المستخدم الافتراضية» على اليسار: «نمط الألوان» و«وحدة الحرارة» و«وحدة المسافة» و«تنسيق الوقت» و«يبدأ الأسبوع في» و«عملة العرض» و«إخفاء رموز الحجز».',
'«إعدادات المستخدم الافتراضية» على اليسار: «نمط الألوان» و«وحدة الحرارة» و«وحدة المسافة» و«تنسيق الوقت» و«يبدأ الأسبوع في» و«تنسيق التاريخ» و«عملة العرض» و«إخفاء رموز الحجز».',
'help.ctx.admin-defaults.bullet.2':
'«خريطة» على اليمين: «قالب الخريطة» و«مفتاح CARTO المشترك» و«محرك مسارات خاص» و«خادم Valhalla خاص بك»، ومعاينة، ثم «محرك الخرائط» مع «نمط الخريطة» و«رمز Mapbox المشترك» و«المباني والتضاريس ثلاثية الأبعاد» و«وضع الجودة العالية».',
'help.ctx.admin-config.title': 'التخصيص',
@@ -4032,7 +4032,7 @@ const help: TranslationStrings = {
'إعداداتك الخاصة للإضافات التي ثبّتها المسؤول: المفاتيح والتفضيلات التي تطلبها منك كل إضافة، وسجلّ بما فعلته الإضافات نيابةً عنك. لا يظهر هذا التبويب إلا عند وجود إضافة نشطة.',
'help.ctx.settings-about.bullet.3': 'عبر Ko-fi وBuy Me a Coffee وDiscord يمكنك دعم TREK أو التحدث مع صانعيه.',
'help.guide.language-region.step.3':
'اضبط «عملة العرض»، أو أبقِ «عملة الرحلة» لترى كل رحلة بعملتها. ثم اختر «وحدة الحرارة» و«وحدة المسافة» و«تنسيق الوقت»، واليوم الذي يبدأ به الأسبوع في «يبدأ الأسبوع في».',
'اضبط «عملة العرض»، أو أبقِ «عملة الرحلة» لترى كل رحلة بعملتها. ثم اختر «وحدة الحرارة» و«وحدة المسافة» و«تنسيق الوقت»، واليوم الذي يبدأ به الأسبوع في «يبدأ الأسبوع في»، و«تنسيق التاريخ».',
'help.guide.travel-map-prefs.step.3':
'«استكشاف الأماكن على الخريطة» يُظهر شريط الفئات للبحث عن أماكن قريبة. و«إخفاء رموز الحجز» يخفي أرقام التأكيد حتى تمرّر المؤشر فوقها أو تلمسها. و«تحسين المسار انطلاقًا من مكان الإقامة» يبدأ اليوم وينهيه عند فندقك.',
'help.guide.web-push-device.step.2':
@@ -5906,7 +5906,7 @@ const help: TranslationStrings = {
'رابط «إعادة تعيين» بجانب حقل ضبطته يعيد قيمة TREK الافتراضية. وإعداد المستخدم نفسه يتقدم دائمًا على هذه القيم.',
'help.ctx.admin-settings.bullet.5': '«منطقة الخطر»: «تدوير سر JWT» يسجّل خروج كل المستخدمين، وأنت منهم.',
'help.guide.default-user-settings.step.1':
'تحت «إعدادات المستخدم الافتراضية»، اختر «نمط الألوان» («فاتح» أو «داكن» أو «تلقائي»)، و«وحدة الحرارة»، و«وحدة المسافة»، و«تنسيق الوقت»، و«يبدأ الأسبوع في»، و«عملة العرض»، وهل «إخفاء رموز الحجز» مفعّل أم «إيقاف». يُحفظ كل اختيار فورًا.',
'تحت «إعدادات المستخدم الافتراضية»، اختر «نمط الألوان» («فاتح» أو «داكن» أو «تلقائي»)، و«وحدة الحرارة»، و«وحدة المسافة»، و«تنسيق الوقت»، و«يبدأ الأسبوع في»، و«تنسيق التاريخ»، و«عملة العرض»، وهل «إخفاء رموز الحجز» مفعّل أم «إيقاف». يُحفظ كل اختيار فورًا.',
'help.guide.admin-passkeys.step.2':
'أدخل «Relying Party ID (النطاق)»، مثل trek.example.org، و«الأصول المسموح بها» التي يُفتح منها TREK لديك، ثم انقر على «حفظ». واتركهما فارغين لاستخدام APP_URL.',
'help.guide.rotate-jwt-secret.title': 'تسجيل خروج الجميع دفعة واحدة',
+4
View File
@@ -84,6 +84,10 @@ const settings: TranslationStrings = {
'settings.timeFormat': 'تنسيق الوقت',
'settings.weekStart': 'يبدأ الأسبوع في',
'settings.weekStartHint': 'أول أيام الأسبوع في جميع منتقيات التاريخ. لدى Vacay إعداد خاص به.',
'settings.dateFormat': 'تنسيق التاريخ',
'settings.dateFormatHint':
'طريقة كتابة التواريخ في جميع حقول التاريخ والقوائم. التواريخ التي تتضمن اسم الشهر تحتفظ بصياغة لغتك.',
'settings.dateFormatAuto': 'تلقائي',
'settings.preferredNavApp': 'فتح الأماكن في',
'settings.preferredNavAppAsk': 'اسأل في كل مرة',
'settings.preferredNavAppHint': 'عند اختيار تطبيق، يفتحه زر التنقل مباشرةً بدلاً من عرض جميع تطبيقات الخرائط.',
+6 -6
View File
@@ -1183,7 +1183,7 @@ const help: TranslationStrings = {
'help.ctx.settings.bullet.1':
'Soldakı yan panel: «Ümumi», «Görünüş», «Xəritə», «Bildirişlər», «İnteqrasiyalar», «Oflayn» və «Hesab». «Plaginlər» aktiv plagin olduqda görünür. «Haqqında» operator onu gizlətməyibsə görünür. Versiya aşağıda göstərilir.',
'help.ctx.settings.bullet.2':
'Birinci «Ümumi» açılır. Burada dil, məkan adlarının dili, vahidlər, vaxt formatı, həftənin başlanğıcı və göstərilən valyuta, həmçinin səyahət xəritəsinin necə işlədiyi var. «Görünüş»də tema, rənglər, oxunaqlılıq, mətn ölçüsü və idarə paneli vidcetləri var.',
'Birinci «Ümumi» açılır. Burada dil, məkan adlarının dili, vahidlər, vaxt formatı, tarix formatı, həftənin başlanğıcı və göstərilən valyuta, həmçinin səyahət xəritəsinin necə işlədiyi var. «Görünüş»də tema, rənglər, oxunaqlılıq, mətn ölçüsü və idarə paneli vidcetləri var.',
'help.ctx.settings.bullet.3':
'«Xəritə» xəritə mühərrikini və üslubunu seçir; «Bildirişlər» hansı hadisənin hansı kanala göndəriləcəyini təyin edir; «İnteqrasiyalar» foto kitabxanalarını, AirTrail, Dawarich və wanderer xidmətlərini qoşur, burada həmçinin API açarları və MCP var; «Oflayn» bu cihazda nələrin saxlanacağını təyin edir.',
'help.ctx.settings.bullet.4':
@@ -1194,7 +1194,7 @@ const help: TranslationStrings = {
'help.ctx.settings-display.bullet.1':
'«Başlanğıc»: «Başlanğıc səhifəsi» «İdarə paneli» və ya «Aktiv səyahət» ilə açılır; «Aktiv səyahət» seçildikdə «Başlanğıc tabı» həmin səyahətin hansı vərəqdə açılacağını təyin edir.',
'help.ctx.settings-display.bullet.2':
'«Dil və region»: «Göstərilən valyuta» (çevirmə istəmirsinizsə, «Səyahət valyutası»), «Dil», «Məkan adları», «Temperatur vahidi», «Məsafə vahidi», «Vaxt formatı» və «Həftənin başlanğıcı».',
'«Dil və region»: «Göstərilən valyuta» (çevirmə istəmirsinizsə, «Səyahət valyutası»), «Dil», «Məkan adları», «Temperatur vahidi», «Məsafə vahidi», «Vaxt formatı», «Həftənin başlanğıcı» və «Tarix formatı».',
'help.ctx.settings-display.bullet.3':
'«Səyahət və xəritə»: «Yerləri açmaq üçün», «Gün başlıqlarında əvvəlcə tarix», «Rezervasiya marşrutu etiketləri», «Rezervasiya marşrutlarını həmişə göstər», «Planlaşdırılmamış məkanlar üçün kiçik nişanlar», «Xəritədə məkanları kəşf et», «Rezervasiya kodlarını bulanıqlaşdır» və «Marşrutu yaşayış yerindən başlayaraq optimallaşdır».',
'help.ctx.settings-appearance.title': 'Görünüş',
@@ -1516,9 +1516,9 @@ const help: TranslationStrings = {
'Aşağıdakı «İcazə tənzimləmələri»: hər əməliyyatı kimin edə biləcəyi («Hər kəs», «Səyahət üzvləri», «Səyahətin sahibi» və ya «Yalnız administrator»). Saxlamaq üçün kartın yuxarısındakı «Yadda saxla» düyməsinə klikləyin.',
'help.ctx.admin-defaults.title': 'İstifadəçi standartları',
'help.ctx.admin-defaults.summary':
'Yeni hesabın başlanğıc ayarları, beləliklə, heç kim əvvəlcə onları qurmalı olmur: rəng rejimi, vahidlər, vaxt formatı, həftənin başlanğıcı, göstərilən valyuta və bulanıqlaşdırılmış rezervasiya kodları, həmçinin xəritə, onun açarları və marşrut serverləri.',
'Yeni hesabın başlanğıc ayarları, beləliklə, heç kim əvvəlcə onları qurmalı olmur: rəng rejimi, vahidlər, vaxt formatı, tarix formatı, həftənin başlanğıcı, göstərilən valyuta və bulanıqlaşdırılmış rezervasiya kodları, həmçinin xəritə, onun açarları və marşrut serverləri.',
'help.ctx.admin-defaults.bullet.1':
'Solda «Standart istifadəçi tənzimləmələri»: «Rəng rejimi», «Temperatur vahidi», «Məsafə vahidi», «Vaxt formatı», «Həftənin başlanğıcı», «Göstərilən valyuta» və «Rezervasiya kodlarını bulanıqlaşdır».',
'Solda «Standart istifadəçi tənzimləmələri»: «Rəng rejimi», «Temperatur vahidi», «Məsafə vahidi», «Vaxt formatı», «Həftənin başlanğıcı», «Tarix formatı», «Göstərilən valyuta» və «Rezervasiya kodlarını bulanıqlaşdır».',
'help.ctx.admin-defaults.bullet.2':
'Sağda «Xəritə»: «Xəritə şablonu», «Paylaşılan CARTO açarı», «Şəxsi marşrut mühərriki» və «Şəxsi Valhalla instansiyası», önizləmə, sonra «Xəritə üslubu» ilə «Xəritə mühərriki», «Paylaşılan Mapbox tokeni», «3D binalar və relyef» və «Yüksək keyfiyyət rejimi».',
'help.ctx.admin-config.title': 'Fərdiləşdirmə',
@@ -4329,7 +4329,7 @@ const help: TranslationStrings = {
'help.ctx.settings-about.bullet.3':
'Ko-fi, «Mənə qəhvə al» və Discord vasitəsilə TREK-i dəstəkləyə və ya onu hazırlayan insanlarla danışa bilərsiniz.',
'help.guide.language-region.step.3':
'«Göstərilən valyuta»nı seçin və ya hər səyahəti öz valyutasında görmək üçün «Səyahət valyutası»nı saxlayın. Sonra «Temperatur vahidi», «Məsafə vahidi», «Vaxt formatı» və həftənin hansı gündən başladığını seçin.',
'«Göstərilən valyuta»nı seçin və ya hər səyahəti öz valyutasında görmək üçün «Səyahət valyutası»nı saxlayın. Sonra «Temperatur vahidi», «Məsafə vahidi», «Vaxt formatı», həftənin hansı gündən başladığını və «Tarix formatı»nı seçin.',
'help.guide.travel-map-prefs.step.3':
'«Xəritədə məkanları kəşf et» yaxınlıqdakı məkanları tapmaq üçün kateqoriya düymələrini göstərir. «Rezervasiya kodlarını bulanıqlaşdır» təsdiq nömrələrini kursoru üzərinə gətirənə və ya toxunana qədər gizlədir. «Marşrutu yaşayış yerindən başlayaraq optimallaşdır» günü otelinizdə başladır və bitirir.',
'help.guide.web-push-device.step.2':
@@ -6374,7 +6374,7 @@ const help: TranslationStrings = {
'help.ctx.admin-settings.bullet.5':
'«Təhlükəli zona»: «JWT sirrini yenilə» siz də daxil olmaqla bütün istifadəçiləri hesabdan çıxarır.',
'help.guide.default-user-settings.step.1':
'«Standart istifadəçi tənzimləmələri» altında «Rəng rejimi» («İşıqlı», «Tünd» və ya «Avtomatik»), «Temperatur vahidi», «Məsafə vahidi», «Vaxt formatı», «Həftənin başlanğıcı», «Göstərilən valyuta» və «Rezervasiya kodlarını bulanıqlaşdır» seçiminin aktiv və ya deaktiv olmasını seçin. Hər seçim dərhal yadda saxlanır.',
'«Standart istifadəçi tənzimləmələri» altında «Rəng rejimi» («İşıqlı», «Tünd» və ya «Avtomatik»), «Temperatur vahidi», «Məsafə vahidi», «Vaxt formatı», «Həftənin başlanğıcı», «Tarix formatı», «Göstərilən valyuta» və «Rezervasiya kodlarını bulanıqlaşdır» seçiminin aktiv və ya deaktiv olmasını seçin. Hər seçim dərhal yadda saxlanır.',
'help.guide.admin-passkeys.step.2':
'«Relying Party ID (domen)» (məsələn, trek.example.org) və TREK-inizin açıldığı «İcazə verilən mənbələr»i daxil edin, sonra «Yadda saxla» düyməsinə klikləyin. APP_URL istifadə etmək üçün hər ikisini boş buraxın.',
'help.guide.rotate-jwt-secret.title': 'Hamını birdəfəlik hesabdan çıxarmaq',
+4
View File
@@ -108,6 +108,10 @@ const settings: TranslationStrings = {
'settings.timeFormat': 'Vaxt formatı',
'settings.weekStart': 'Həftənin başlanğıcı',
'settings.weekStartHint': 'Bütün tarix seçicilərində həftənin ilk günü. Vacay-ın öz ayarı var.',
'settings.dateFormat': 'Tarix formatı',
'settings.dateFormatHint':
'Tarixlərin bütün tarix sahələrində və siyahılarda necə yazıldığı. Ay adı olan tarixlər dilinizin ifadə tərzini saxlayır.',
'settings.dateFormatAuto': 'Avtomatik',
'settings.preferredNavApp': 'Yerləri açmaq üçün',
'settings.preferredNavAppAsk': 'Hər dəfə soruş',
'settings.preferredNavAppHint':
+6 -6
View File
@@ -1157,7 +1157,7 @@ const help: TranslationStrings = {
'help.ctx.settings.bullet.1':
'Barra lateral à esquerda: Exibição, Aparência, Mapa, Notificações, Integrações, Offline e Conta. Plugins aparece quando há um plugin ativo. Sobre aparece, a menos que o operador o tenha ocultado. A versão aparece no fim.',
'help.ctx.settings.bullet.2':
'Exibição abre primeiro. Ela reúne o idioma, o idioma dos nomes de lugares, as unidades, o formato de hora, o início da semana e a moeda de exibição, além do comportamento do mapa da viagem. Aparência reúne tema, cores, legibilidade, tamanho do texto e os widgets do painel.',
'Exibição abre primeiro. Ela reúne o idioma, o idioma dos nomes de lugares, as unidades, o formato de hora, o formato de data, o início da semana e a moeda de exibição, além do comportamento do mapa da viagem. Aparência reúne tema, cores, legibilidade, tamanho do texto e os widgets do painel.',
'help.ctx.settings.bullet.3':
'Mapa define o motor de mapa e o estilo; Notificações define quais eventos vão para qual canal; Integrações conecta bibliotecas de fotos, AirTrail, Dawarich e wanderer, e reúne as chaves de API e o MCP; Offline define o que este dispositivo armazena.',
'help.ctx.settings.bullet.4':
@@ -1168,7 +1168,7 @@ const help: TranslationStrings = {
'help.ctx.settings-display.bullet.1':
'Inicialização: “Página inicial” abre no Painel ou na “Viagem ativa”; com “Viagem ativa”, “Aba inicial” escolhe em que aba essa viagem abre.',
'help.ctx.settings-display.bullet.2':
'Idioma e região: “Moeda de exibição” (ou “Moeda da viagem” para não converter), Idioma, “Nomes de lugares”, “Unidade de temperatura”, “Unidade de distância”, “Formato de hora” e “Semana começa em”.',
'Idioma e região: “Moeda de exibição” (ou “Moeda da viagem” para não converter), Idioma, “Nomes de lugares”, “Unidade de temperatura”, “Unidade de distância”, “Formato de hora”, “Semana começa em” e “Formato de data”.',
'help.ctx.settings-display.bullet.3':
'Viagem e mapa: “Abrir lugares em”, “Data primeiro nos títulos dos dias”, “Rótulos das rotas de reservas”, “Sempre mostrar rotas de reserva”, “Marcadores compactos para lugares não planejados”, “Explorar lugares no mapa”, “Ocultar códigos de reserva” e “Otimizar rota a partir da hospedagem”.',
'help.ctx.settings-appearance.title': 'Aparência',
@@ -1484,9 +1484,9 @@ const help: TranslationStrings = {
'“Configurações de Permissões”, no fim: para cada ação, quem pode realizá-la (Todos, “Membros da viagem”, “Dono da viagem” ou “Apenas administrador”). Clique em Salvar no topo do cartão para salvar.',
'help.ctx.admin-defaults.title': 'Padrões do usuário',
'help.ctx.admin-defaults.summary':
'Com o que uma conta nova já começa, para ninguém precisar configurar antes: tema de cores, unidades, formato de hora, início da semana, moeda de exibição e códigos de reserva ocultos, além do mapa, das chaves dele e dos servidores de rotas.',
'Com o que uma conta nova já começa, para ninguém precisar configurar antes: tema de cores, unidades, formato de hora, formato de data, início da semana, moeda de exibição e códigos de reserva ocultos, além do mapa, das chaves dele e dos servidores de rotas.',
'help.ctx.admin-defaults.bullet.1':
'“Configurações padrão do usuário”, à esquerda: “Tema de cores”, “Unidade de temperatura”, “Unidade de distância”, “Formato de hora”, “Semana começa em”, “Moeda de exibição” e “Ocultar códigos de reserva”.',
'“Configurações padrão do usuário”, à esquerda: “Tema de cores”, “Unidade de temperatura”, “Unidade de distância”, “Formato de hora”, “Semana começa em”, “Formato de data”, “Moeda de exibição” e “Ocultar códigos de reserva”.',
'help.ctx.admin-defaults.bullet.2':
'Mapa, à direita: “Modelo de mapa”, “Chave CARTO compartilhada”, “Servidor de rotas próprio” e “Instância Valhalla própria”, uma pré-visualização e depois “Motor de mapas”, com “Estilo do mapa”, “Token compartilhado do Mapbox”, “Edifícios & relevo em 3D” e “Modo de alta qualidade”.',
'help.ctx.admin-config.title': 'Personalização',
@@ -4304,7 +4304,7 @@ const help: TranslationStrings = {
'help.ctx.settings-about.bullet.3':
'No Ko-fi, no Buy Me a Coffee e no Discord você pode apoiar o TREK ou conversar com quem o desenvolve.',
'help.guide.language-region.step.3':
'Defina a “Moeda de exibição” ou mantenha “Moeda da viagem” para ver cada viagem na própria moeda. Depois escolha “Unidade de temperatura”, “Unidade de distância”, “Formato de hora” e o dia em “Semana começa em”.',
'Defina a “Moeda de exibição” ou mantenha “Moeda da viagem” para ver cada viagem na própria moeda. Depois escolha “Unidade de temperatura”, “Unidade de distância”, “Formato de hora”, o dia em “Semana começa em” e o “Formato de data”.',
'help.guide.travel-map-prefs.step.3':
'“Explorar lugares no mapa” mostra a pílula de categorias para encontrar lugares próximos. “Ocultar códigos de reserva” esconde os números de confirmação até você passar o mouse ou tocar neles. “Otimizar rota a partir da hospedagem” faz o dia começar e terminar no seu hotel.',
'help.guide.web-push-device.step.2':
@@ -6377,7 +6377,7 @@ const help: TranslationStrings = {
'help.ctx.admin-settings.bullet.5':
'Zona de perigo: “Rotacionar segredo JWT” desconecta todos os usuários, inclusive você.',
'help.guide.default-user-settings.step.1':
'Em “Configurações padrão do usuário”, escolha “Tema de cores” (Claro, Escuro ou Automático), “Unidade de temperatura”, “Unidade de distância”, “Formato de hora”, “Semana começa em”, a “Moeda de exibição” e se “Ocultar códigos de reserva” fica ativado ou desativado. Cada escolha é salva na hora.',
'Em “Configurações padrão do usuário”, escolha “Tema de cores” (Claro, Escuro ou Automático), “Unidade de temperatura”, “Unidade de distância”, “Formato de hora”, “Semana começa em”, o “Formato de data”, a “Moeda de exibição” e se “Ocultar códigos de reserva” fica ativado ou desativado. Cada escolha é salva na hora.',
'help.guide.admin-passkeys.step.2':
'Informe o “Relying Party ID (domínio)”, como trek.example.org, e as “Origens permitidas” pelas quais o seu TREK é acessado, e clique em Salvar. Deixe os dois em branco para usar APP_URL.',
'help.guide.rotate-jwt-secret.title': 'Desconectar todos de uma vez',
+4
View File
@@ -89,6 +89,10 @@ const settings: TranslationStrings = {
'settings.weekStart': 'Semana começa em',
'settings.weekStartHint':
'Primeiro dia da semana em todos os seletores de data. O Vacay tem sua própria configuração.',
'settings.dateFormat': 'Formato de data',
'settings.dateFormatHint':
'Como as datas são escritas em todos os campos de data e listas. Datas com o nome do mês mantêm a escrita do seu idioma.',
'settings.dateFormatAuto': 'Automático',
'settings.preferredNavApp': 'Abrir lugares em',
'settings.preferredNavAppAsk': 'Perguntar sempre',
'settings.preferredNavAppHint':
+6 -6
View File
@@ -1170,7 +1170,7 @@ const help: TranslationStrings = {
'help.ctx.settings.bullet.1':
"Barra lateral de l'esquerra: «Pantalla», «Aparença», «Mapa», «Notificacions», «Integracions», «Fora de línia» i «Compte». «Connectors» apareix quan hi ha algun connector actiu. «Quant a» apareix tret que l'operador l'hagi amagat. La versió es mostra a baix de tot.",
'help.ctx.settings.bullet.2':
"«Pantalla» s'obre primer. Inclou l'idioma, l'idioma dels noms de llocs, les unitats, el format d'hora, el primer dia de la setmana i la divisa de visualització, i també com es comporta el mapa del viatge. «Aparença» inclou el tema, els colors, la llegibilitat, la mida del text i els ginys del tauler.",
"«Pantalla» s'obre primer. Inclou l'idioma, l'idioma dels noms de llocs, les unitats, el format d'hora, el format de data, el primer dia de la setmana i la divisa de visualització, i també com es comporta el mapa del viatge. «Aparença» inclou el tema, els colors, la llegibilitat, la mida del text i els ginys del tauler.",
'help.ctx.settings.bullet.3':
'«Mapa» defineix el motor del mapa i el seu estil; «Notificacions», quins esdeveniments van a cada canal; «Integracions» connecta biblioteques de fotos, AirTrail, Dawarich i wanderer, i inclou les claus API i MCP; «Fora de línia» defineix què desa aquest dispositiu.',
'help.ctx.settings.bullet.4':
@@ -1181,7 +1181,7 @@ const help: TranslationStrings = {
'help.ctx.settings-display.bullet.1':
"«Inici»: «Pàgina d'inici» obre el «Tauler» o el «Viatge actiu»; amb «Viatge actiu», «Pestanya d'inici» tria la pestanya amb què s'obre aquell viatge.",
'help.ctx.settings-display.bullet.2':
"«Idioma i regió»: «Divisa de visualització» (o «Divisa del viatge» per no convertir), «Idioma», «Noms de llocs», «Unitat de temperatura», «Unitat de distància», «Format d'hora» i «La setmana comença el».",
"«Idioma i regió»: «Divisa de visualització» (o «Divisa del viatge» per no convertir), «Idioma», «Noms de llocs», «Unitat de temperatura», «Unitat de distància», «Format d'hora», «La setmana comença el» i «Format de data».",
'help.ctx.settings-display.bullet.3':
"«Viatge i mapa»: «Obre els llocs a», «Data primer als títols dels dies», «Etiquetes de rutes de reserves», «Mostra sempre les rutes de reserva», «Marcadors compactes per als llocs sense planificar», «Explora llocs al mapa», «Difumina els codis de reserva» i «Optimitza la ruta des de l'allotjament».",
'help.ctx.settings-appearance.title': 'Aparença',
@@ -1505,9 +1505,9 @@ const help: TranslationStrings = {
"«Configuració de permisos», a baix de tot: per a cada acció, qui la pot fer («Tothom», «Membres del viatge», «Propietari del viatge» o «Només l'administrador»). Fes clic a «Desa», a dalt de la targeta, per desar-ho.",
'help.ctx.admin-defaults.title': 'Valors per defecte',
'help.ctx.admin-defaults.summary':
"Amb què comença un compte nou, perquè ningú no l'hagi de configurar abans: mode de color, unitats, format d'hora, primer dia de la setmana, divisa de visualització i codis de reserva difuminats, i també el mapa, les seves claus i els servidors de rutes.",
"Amb què comença un compte nou, perquè ningú no l'hagi de configurar abans: mode de color, unitats, format d'hora, format de data, primer dia de la setmana, divisa de visualització i codis de reserva difuminats, i també el mapa, les seves claus i els servidors de rutes.",
'help.ctx.admin-defaults.bullet.1':
"«Configuració per defecte dels usuaris», a l'esquerra: «Mode de color», «Unitat de temperatura», «Unitat de distància», «Format d'hora», «La setmana comença el», «Divisa de visualització» i «Difumina els codis de reserva».",
"«Configuració per defecte dels usuaris», a l'esquerra: «Mode de color», «Unitat de temperatura», «Unitat de distància», «Format d'hora», «La setmana comença el», «Format de data», «Divisa de visualització» i «Difumina els codis de reserva».",
'help.ctx.admin-defaults.bullet.2':
"«Mapa», a la dreta: «Plantilla del mapa», «Clau CARTO compartida», «Motor de rutes propi» i «Instància Valhalla pròpia», una vista prèvia, i després «Motor de mapes» amb «Estil de mapa», «Token de Mapbox compartit», «Edificis i terreny en 3D» i «Mode d'alta qualitat».",
'help.ctx.admin-config.title': 'Personalització',
@@ -4355,7 +4355,7 @@ const help: TranslationStrings = {
'help.ctx.settings-about.bullet.3':
'A Ko-fi, Buy Me a Coffee i Discord pots donar suport a TREK o parlar amb la gent que el fa.',
'help.guide.language-region.step.3':
"Tria la «Divisa de visualització», o deixa «Divisa del viatge» per veure cada viatge en la seva divisa. Després tria «Unitat de temperatura», «Unitat de distància», «Format d'hora» i, a «La setmana comença el», el dia en què comença la setmana.",
"Tria la «Divisa de visualització», o deixa «Divisa del viatge» per veure cada viatge en la seva divisa. Després tria «Unitat de temperatura», «Unitat de distància», «Format d'hora», a «La setmana comença el» el dia en què comença la setmana i el «Format de data».",
'help.guide.travel-map-prefs.step.3':
"«Explora llocs al mapa» mostra la pastilla de categories per trobar llocs propers. «Difumina els codis de reserva» amaga els números de confirmació fins que hi passes el ratolí per sobre o els toques. «Optimitza la ruta des de l'allotjament» fa que el dia comenci i acabi al teu hotel.",
'help.guide.web-push-device.step.2':
@@ -6437,7 +6437,7 @@ const help: TranslationStrings = {
'help.ctx.admin-settings.bullet.5':
'«Zona de perill»: «Rota el secret JWT» tanca la sessió de tots els usuaris, tu inclòs.',
'help.guide.default-user-settings.step.1':
"A «Configuració per defecte dels usuaris», tria «Mode de color» («Clar», «Fosc» o «Automàtic»), «Unitat de temperatura», «Unitat de distància», «Format d'hora», «La setmana comença el», la «Divisa de visualització» i si «Difumina els codis de reserva» està activat o desactivat. Cada opció es desa a l'instant.",
"A «Configuració per defecte dels usuaris», tria «Mode de color» («Clar», «Fosc» o «Automàtic»), «Unitat de temperatura», «Unitat de distància», «Format d'hora», «La setmana comença el», el «Format de data», la «Divisa de visualització» i si «Difumina els codis de reserva» està activat o desactivat. Cada opció es desa a l'instant.",
'help.guide.admin-passkeys.step.2':
"Introdueix l'«ID de la part confiable (domini)», com ara trek.example.org, i els «Orígens permesos» des d'on s'accedeix al teu TREK, i fes clic a «Desa». Deixa'ls tots dos buits per fer servir APP_URL.",
'help.guide.rotate-jwt-secret.title': 'Tancar la sessió de tothom alhora',
+4
View File
@@ -73,6 +73,10 @@ const settings: TranslationStrings = {
'settings.timeFormat': "Format d'hora",
'settings.weekStart': 'La setmana comença el',
'settings.weekStartHint': 'Primer dia de la setmana a tots els selectors de data. Vacay té la seva pròpia opció.',
'settings.dateFormat': 'Format de data',
'settings.dateFormatHint':
"Com s'escriuen les dates a tots els camps de data i a les llistes. Les dates amb el nom del mes mantenen la redacció del teu idioma.",
'settings.dateFormatAuto': 'Automàtic',
'settings.preferredNavApp': 'Obre els llocs a',
'settings.preferredNavAppAsk': 'Pregunta cada vegada',
'settings.preferredNavAppHint':
+6 -6
View File
@@ -1120,7 +1120,7 @@ const help: TranslationStrings = {
'help.ctx.settings.bullet.1':
'Postranní panel vlevo: „Zobrazení“, „Vzhled“, „Mapa“, „Oznámení“, „Integrace“, „Offline“ a „Účet“. „Pluginy“ se zobrazí, jakmile je aktivní nějaký plugin. „O aplikaci“ se zobrazí, pokud ho provozovatel neskryl. Verze je uvedená dole.',
'help.ctx.settings.bullet.2':
'Jako první se otevře „Zobrazení“. Obsahuje jazyk, jazyk názvů míst, jednotky, formát času, začátek týdne a zobrazovanou měnu a také chování mapy cesty. „Vzhled“ obsahuje motiv, barvy, čitelnost, velikost písma a widgety přehledu.',
'Jako první se otevře „Zobrazení“. Obsahuje jazyk, jazyk názvů míst, jednotky, formát času, formát data, začátek týdne a zobrazovanou měnu a také chování mapy cesty. „Vzhled“ obsahuje motiv, barvy, čitelnost, velikost písma a widgety přehledu.',
'help.ctx.settings.bullet.3':
'„Mapa“ určuje, čím se mapa vykresluje, a její styl; „Oznámení“ určuje, které události chodí kterým kanálem; „Integrace“ propojuje knihovny fotek, AirTrail, Dawarich a wanderer a obsahuje API klíče a MCP; „Offline“ určuje, co si toto zařízení ukládá.',
'help.ctx.settings.bullet.4':
@@ -1131,7 +1131,7 @@ const help: TranslationStrings = {
'help.ctx.settings-display.bullet.1':
'„Spuštění“: „Úvodní stránka“ otevře „Přehled“ nebo „Aktivní cesta“; u „Aktivní cesta“ vyberete v „Úvodní karta“, na které kartě se cesta otevře.',
'help.ctx.settings-display.bullet.2':
'Jazyk a region: „Zobrazovaná měna“ (nebo „Měna cesty“ bez přepočtu), „Jazyk“, „Názvy míst“, „Jednotky teploty“, „Jednotky vzdálenosti“, „Formát času“ a „Týden začíná“.',
'Jazyk a region: „Zobrazovaná měna“ (nebo „Měna cesty“ bez přepočtu), „Jazyk“, „Názvy míst“, „Jednotky teploty“, „Jednotky vzdálenosti“, „Formát času“, „Týden začíná“ a „Formát data“.',
'help.ctx.settings-display.bullet.3':
'Cestování a mapa: „Otevírat místa v“, „Datum na začátku nadpisů dnů“, „Popisky tras rezervací“, „Vždy zobrazovat trasy rezervací“, „Kompaktní značky pro nenaplánovaná místa“, „Objevovat místa na mapě“, „Skrýt rezervační kódy“ a „Optimalizovat trasu od ubytování“.',
'help.ctx.settings-appearance.title': 'Vzhled',
@@ -1445,9 +1445,9 @@ const help: TranslationStrings = {
'„Nastavení oprávnění“ dole: u každé akce, kdo ji smí provádět („Všichni“, „Členové výletu“, „Vlastník výletu“ nebo „Pouze administrátor“). Uložíte tlačítkem „Uložit“ nahoře na kartě.',
'help.ctx.admin-defaults.title': 'Výchozí nastavení uživatele',
'help.ctx.admin-defaults.summary':
'S čím nový účet začíná, aby nikdo nemusel nic nastavovat hned na začátku: barevné schéma, jednotky, formát času, začátek týdne, zobrazovaná měna a skryté rezervační kódy, a k tomu mapa, její klíče a servery pro plánování tras.',
'S čím nový účet začíná, aby nikdo nemusel nic nastavovat hned na začátku: barevné schéma, jednotky, formát času, formát data, začátek týdne, zobrazovaná měna a skryté rezervační kódy, a k tomu mapa, její klíče a servery pro plánování tras.',
'help.ctx.admin-defaults.bullet.1':
'„Výchozí nastavení uživatele“ vlevo: „Barevné schéma“, „Jednotky teploty“, „Jednotky vzdálenosti“, „Formát času“, „Týden začíná“, „Zobrazovaná měna“ a „Skrýt rezervační kódy“.',
'„Výchozí nastavení uživatele“ vlevo: „Barevné schéma“, „Jednotky teploty“, „Jednotky vzdálenosti“, „Formát času“, „Týden začíná“, „Formát data“, „Zobrazovaná měna“ a „Skrýt rezervační kódy“.',
'help.ctx.admin-defaults.bullet.2':
'Mapa vpravo: „Šablona mapy“, „Sdílený klíč CARTO“, „Vlastní směrovací server“ a „Vlastní instance Valhalla“, náhled, pak „Mapový engine“ se „Styl mapy“, „Sdílený token Mapbox“, „3D budovy & terén“ a „Režim vysoké kvality“.',
'help.ctx.admin-config.title': 'Personalizace',
@@ -4224,7 +4224,7 @@ const help: TranslationStrings = {
'help.ctx.settings-about.bullet.3':
'Přes Ko-fi, Buy Me a Coffee a Discord můžete TREK podpořit nebo si promluvit s lidmi, kteří ho vyvíjejí.',
'help.guide.language-region.step.3':
'Nastavte „Zobrazovaná měna“, nebo ponechte „Měna cesty“, aby se každá cesta zobrazovala ve své vlastní měně. Pak zvolte „Jednotky teploty“, „Jednotky vzdálenosti“, „Formát času“ a den, kterým začíná týden („Týden začíná“).',
'Nastavte „Zobrazovaná měna“, nebo ponechte „Měna cesty“, aby se každá cesta zobrazovala ve své vlastní měně. Pak zvolte „Jednotky teploty“, „Jednotky vzdálenosti“, „Formát času“, den, kterým začíná týden („Týden začíná“), a „Formát data“.',
'help.guide.travel-map-prefs.step.3':
'„Objevovat místa na mapě“ zobrazí pilulku s kategoriemi pro hledání míst v okolí. „Skrýt rezervační kódy“ skryje čísla potvrzení, dokud na ně nenajedete myší nebo neklepnete. „Optimalizovat trasu od ubytování“ začíná a končí den u vašeho hotelu.',
'help.guide.web-push-device.step.2':
@@ -6256,7 +6256,7 @@ const help: TranslationStrings = {
'help.ctx.admin-settings.bullet.5':
'„Nebezpečná zóna“: „Obnovit tajný klíč JWT“ odhlásí všechny uživatele včetně vás.',
'help.guide.default-user-settings.step.1':
'V části „Výchozí nastavení uživatele“ vyberte „Barevné schéma“ („Světlé“, „Tmavá“ nebo „Automatické“), „Jednotky teploty“, „Jednotky vzdálenosti“, „Formát času“, „Týden začíná“, „Zobrazovaná měna“ a zda je „Skrýt rezervační kódy“ zapnuté, nebo vypnuté. Každá volba se uloží okamžitě.',
'V části „Výchozí nastavení uživatele“ vyberte „Barevné schéma“ („Světlé“, „Tmavá“ nebo „Automatické“), „Jednotky teploty“, „Jednotky vzdálenosti“, „Formát času“, „Týden začíná“, „Formát data“, „Zobrazovaná měna“ a zda je „Skrýt rezervační kódy“ zapnuté, nebo vypnuté. Každá volba se uloží okamžitě.',
'help.guide.admin-passkeys.step.2':
'Zadejte „Relying Party ID (doména)“, například trek.example.org, a „Povolené origins“, na kterých je váš TREK dostupný, a klikněte na „Uložit“. Pokud obě pole necháte prázdná, použije se APP_URL.',
'help.guide.rotate-jwt-secret.title': 'Odhlášení všech najednou',
+4
View File
@@ -85,6 +85,10 @@ const settings: TranslationStrings = {
'settings.timeFormat': 'Formát času',
'settings.weekStart': 'Týden začíná',
'settings.weekStartHint': 'První den týdne ve všech výběrech data. Vacay má vlastní nastavení.',
'settings.dateFormat': 'Formát data',
'settings.dateFormatHint':
'Jak se zapisují data ve všech datových polích a seznamech. Data s názvem měsíce zachovávají znění vašeho jazyka.',
'settings.dateFormatAuto': 'Automaticky',
'settings.preferredNavApp': 'Otevírat místa v',
'settings.preferredNavAppAsk': 'Pokaždé se zeptat',
'settings.preferredNavAppHint':
+6 -6
View File
@@ -1180,7 +1180,7 @@ const help: TranslationStrings = {
'help.ctx.settings.bullet.1':
'Seitenleiste links: „Allgemein“, „Darstellung“, „Karte“, „Benachrichtigungen“, „Integrationen“, „Offline“ und „Konto“. „Plugins“ erscheint, sobald ein Plugin aktiv ist, „Über“ überall dort, wo der Betreiber es nicht ausgeblendet hat, und ganz unten steht die Version.',
'help.ctx.settings.bullet.2':
'Unter Allgemein legst du fest, womit TREK startet: Sprache, Sprache der Ortsnamen, Einheiten, Zeitformat, Wochenbeginn und Anzeigewährung, dazu das Verhalten der Reisekarte. Unter Darstellung findest du Theme, Farben, Lesbarkeit, Schriftgröße und die Dashboard-Widgets.',
'Unter Allgemein legst du fest, womit TREK startet: Sprache, Sprache der Ortsnamen, Einheiten, Zeitformat, Datumsformat, Wochenbeginn und Anzeigewährung, dazu das Verhalten der Reisekarte. Unter Darstellung findest du Theme, Farben, Lesbarkeit, Schriftgröße und die Dashboard-Widgets.',
'help.ctx.settings.bullet.3':
'„Karte“ wählt die Engine und ihren Stil. Unter „Benachrichtigungen“ legst du fest, welche Ereignisse über welchen Kanal kommen. „Integrationen“ verbindet Fotobibliotheken, AirTrail, Dawarich und wanderer und enthält API-Schlüssel und MCP. Unter „Offline“ legst du fest, was dieses Gerät speichert.',
'help.ctx.settings.bullet.4':
@@ -1191,7 +1191,7 @@ const help: TranslationStrings = {
'help.ctx.settings-display.bullet.1':
'Start: Unter „Startseite“ legst du fest, ob TREK mit dem Dashboard oder der aktiven Reise startet; bei „Aktive Reise“ wählst du unter „Start-Tab“, mit welchem Tab sich diese Reise öffnet.',
'help.ctx.settings-display.bullet.2':
'Sprache & Region: „Anzeigewährung“ (oder „Reisewährung“, wenn nicht umgerechnet werden soll), „Sprache“, „Ortsnamen“, „Temperatureinheit“, „Entfernungseinheit“, „Zeitformat“ und „Woche beginnt am“.',
'Sprache & Region: „Anzeigewährung“ (oder „Reisewährung“, wenn nicht umgerechnet werden soll), „Sprache“, „Ortsnamen“, „Temperatureinheit“, „Entfernungseinheit“, „Zeitformat“, „Woche beginnt am“ und „Datumsformat“.',
'help.ctx.settings-display.bullet.3':
'Reise & Karte: „Orte öffnen in“, „Datum zuerst in Tagesüberschriften“, „Orts-Labels auf Buchungsrouten“, „Buchungsrouten immer anzeigen“, „Kompakte Markierungen für ungeplante Orte“, „Orte auf der Karte entdecken“, „Buchungscodes verbergen“ und „Route ab der Unterkunft optimieren“.',
'help.ctx.settings-appearance.title': 'Darstellung',
@@ -1517,9 +1517,9 @@ const help: TranslationStrings = {
'„Berechtigungseinstellungen“ unten: Für jede Aktion legst du fest, wer sie ausführen darf („Alle“, „Reise-Mitglieder“, „Reise-Eigentümer“ oder „Nur Administrator“), und speicherst mit „Speichern“ oben in der Karte.',
'help.ctx.admin-defaults.title': 'Benutzer-Standards',
'help.ctx.admin-defaults.summary':
'Womit ein neues Konto startet, damit niemand erst alles einrichten muss: Farbmodus, Einheiten, Zeitformat, Wochenbeginn, Anzeigewährung und verborgene Buchungscodes, dazu die Karte, ihre Schlüssel und die Routing-Server.',
'Womit ein neues Konto startet, damit niemand erst alles einrichten muss: Farbmodus, Einheiten, Zeitformat, Datumsformat, Wochenbeginn, Anzeigewährung und verborgene Buchungscodes, dazu die Karte, ihre Schlüssel und die Routing-Server.',
'help.ctx.admin-defaults.bullet.1':
'Links die Standard-Benutzereinstellungen: „Farbmodus“, „Temperatureinheit“, „Entfernungseinheit“, „Zeitformat“, „Woche beginnt am“, „Anzeigewährung“ und „Buchungscodes verbergen“.',
'Links die Standard-Benutzereinstellungen: „Farbmodus“, „Temperatureinheit“, „Entfernungseinheit“, „Zeitformat“, „Woche beginnt am“, „Datumsformat“, „Anzeigewährung“ und „Buchungscodes verbergen“.',
'help.ctx.admin-defaults.bullet.2':
'Karte rechts: „Karten-Vorlage“, „Gemeinsamer CARTO-Key“, „Eigene Routing-Instanz“ und „Eigene Valhalla-Instanz“, eine Vorschau, danach „Kartendienst“ mit „Kartenstil“, „Gemeinsames Mapbox-Token“, „3D-Gebäude & Gelände“ und „Hochqualitätsmodus“.',
'help.ctx.admin-config.title': 'Personalisierung',
@@ -4386,7 +4386,7 @@ const help: TranslationStrings = {
'help.ctx.settings-about.bullet.3':
'Über Ko-fi, Buy Me a Coffee und Discord kannst du TREK unterstützen oder die Leute dahinter kennenlernen.',
'help.guide.language-region.step.3':
'Leg die „Anzeigewährung“ fest oder behalte „Reisewährung“, damit jede Reise in ihrer eigenen Währung angezeigt wird. Wähl dann „Temperatureinheit“, „Entfernungseinheit“, „Zeitformat“ und unter „Woche beginnt am“ den ersten Wochentag.',
'Leg die „Anzeigewährung“ fest oder behalte „Reisewährung“, damit jede Reise in ihrer eigenen Währung angezeigt wird. Wähl dann „Temperatureinheit“, „Entfernungseinheit“, „Zeitformat“, unter „Woche beginnt am“ den ersten Wochentag und das „Datumsformat“.',
'help.guide.travel-map-prefs.step.3':
'„Orte auf der Karte entdecken“ blendet die Kategorie-Pille ein, mit der du Orte in der Nähe findest. „Buchungscodes verbergen“ macht Bestätigungsnummern unkenntlich, bis du mit der Maus darüberfährst oder sie antippst. „Route ab der Unterkunft optimieren“ lässt jeden Tag an deinem Hotel beginnen und enden.',
'help.guide.web-push-device.step.2':
@@ -6495,7 +6495,7 @@ const help: TranslationStrings = {
'Über den Link zum Zurücksetzen neben einem geänderten Feld stellst du den TREK-Standard wieder her. Die eigenen Einstellungen der Benutzer haben immer Vorrang.',
'help.ctx.admin-settings.bullet.5': 'Gefahrenzone: „JWT-Secret erneuern“ meldet alle Benutzer ab, auch dich.',
'help.guide.default-user-settings.step.1':
'Wähl unter „Standard-Benutzereinstellungen“ den „Farbmodus“ (Hell, Dunkel oder Automatisch), „Temperatureinheit“, „Entfernungseinheit“, „Zeitformat“, „Woche beginnt am“, die „Anzeigewährung“ und ob „Buchungscodes verbergen“ auf „Ein“ oder „Aus“ steht. Jede Auswahl wird sofort gespeichert.',
'Wähl unter „Standard-Benutzereinstellungen“ den „Farbmodus“ (Hell, Dunkel oder Automatisch), „Temperatureinheit“, „Entfernungseinheit“, „Zeitformat“, „Woche beginnt am“, das „Datumsformat“, die „Anzeigewährung“ und ob „Buchungscodes verbergen“ auf „Ein“ oder „Aus“ steht. Jede Auswahl wird sofort gespeichert.',
'help.guide.admin-passkeys.step.2':
'Gib die „Relying Party ID (Domain)“ ein, etwa trek.example.org, und unter „Erlaubte Origins“ die Adressen, unter denen dein TREK erreichbar ist. Klick dann auf „Speichern“. Lässt du beide leer, wird APP_URL verwendet.',
'help.guide.rotate-jwt-secret.title': 'Alle auf einmal abmelden',
+4
View File
@@ -87,6 +87,10 @@ const settings: TranslationStrings = {
'settings.timeFormat': 'Zeitformat',
'settings.weekStart': 'Woche beginnt am',
'settings.weekStartHint': 'Erster Tag der Woche in jedem Datumswähler. Vacay hat eine eigene Einstellung.',
'settings.dateFormat': 'Datumsformat',
'settings.dateFormatHint':
'Wie Daten in jedem Datumsfeld und jeder Liste geschrieben werden. Daten mit Monatsnamen behalten die Schreibweise deiner Sprache.',
'settings.dateFormatAuto': 'Automatisch',
'settings.preferredNavApp': 'Orte öffnen in',
'settings.preferredNavAppAsk': 'Jedes Mal fragen',
'settings.preferredNavAppHint':
+6 -6
View File
@@ -1336,7 +1336,7 @@ const help: TranslationStrings = {
'help.ctx.settings.bullet.1':
'Sidebar on the left: General, Appearance, Map, Notifications, Integrations, Offline and Account. Plugins appears once a plugin is active. About appears unless the operator has hidden it. The version is shown at the bottom.',
'help.ctx.settings.bullet.2':
'General opens first. It holds the language, the language of place names, units, time format, week start and display currency, plus how the trip map behaves. Appearance holds theme, colours, readability, text size and the dashboard widgets.',
'General opens first. It holds the language, the language of place names, units, time format, date format, week start and display currency, plus how the trip map behaves. Appearance holds theme, colours, readability, text size and the dashboard widgets.',
'help.ctx.settings.bullet.3':
'Map sets the map engine and its style; Notifications sets which events go to which channel; Integrations connects photo libraries, AirTrail, Dawarich and wanderer, and contains API keys and MCP; Offline sets what this device stores.',
'help.ctx.settings.bullet.4':
@@ -1347,7 +1347,7 @@ const help: TranslationStrings = {
'help.ctx.settings-display.bullet.1':
'Startup: Start page opens on the Dashboard or on the Active trip; with Active trip, Start tab picks the tab that trip opens on.',
'help.ctx.settings-display.bullet.2':
'Language & region: Display currency (or Trip currency for none), Language, Place names, Temperature Unit, Distance Unit, Time Format and Week starts on.',
'Language & region: Display currency (or Trip currency for none), Language, Place names, Temperature Unit, Distance Unit, Time Format, Week starts on and Date format.',
'help.ctx.settings-display.bullet.3':
'Travel & map: Open places in, Date first in day headings, Booking route labels, Always show booking routes, Compact markers for unplanned places, Explore places on the map, Blur Booking Codes and Optimize route from accommodation.',
'help.ctx.settings-appearance.title': 'Appearance',
@@ -1445,7 +1445,7 @@ const help: TranslationStrings = {
'help.guide.language-region.step.2':
'Place names sets the language that place search, suggestions and addresses use; Same as the app follows the interface language.',
'help.guide.language-region.step.3':
'Set the Display currency, or keep Trip currency to see each trip in its own. Then choose Temperature Unit, Distance Unit, Time Format and the day the Week starts on.',
'Set the Display currency, or keep Trip currency to see each trip in its own. Then choose Temperature Unit, Distance Unit, Time Format, the day the Week starts on and the Date format.',
'help.guide.language-region.result':
'Dates, distances and money are shown in the format you expect. In Costs, amounts are shown in your display currency for display only; the amounts that were entered do not change.',
'help.guide.language-region.tip.1': 'Trip currency shows every trip in its own currency, without converting.',
@@ -1751,9 +1751,9 @@ const help: TranslationStrings = {
'Permission Settings at the bottom: per action, who may do it (Everyone, Trip members, Trip owner or Admin only). Click Save at the top of the card to save it.',
'help.ctx.admin-defaults.title': 'User Defaults',
'help.ctx.admin-defaults.summary':
'What a new account starts with, so nobody has to set it up first: colour mode, units, time format, week start, display currency and blurred booking codes, plus the map, its keys and the routing servers.',
'What a new account starts with, so nobody has to set it up first: colour mode, units, time format, date format, week start, display currency and blurred booking codes, plus the map, its keys and the routing servers.',
'help.ctx.admin-defaults.bullet.1':
'Default User Settings on the left: Color Mode, Temperature Unit, Distance Unit, Time Format, Week starts on, Display currency and Blur Booking Codes.',
'Default User Settings on the left: Color Mode, Temperature Unit, Distance Unit, Time Format, Week starts on, Date format, Display currency and Blur Booking Codes.',
'help.ctx.admin-defaults.bullet.2':
'Map on the right: Map Template, Shared CARTO key, Own routing engine and Own Valhalla instance, a preview, then Map engine with Map style, Shared Mapbox token, 3D buildings & terrain and High-quality mode.',
'help.ctx.admin-defaults.bullet.3':
@@ -1903,7 +1903,7 @@ const help: TranslationStrings = {
'help.guide.default-user-settings.goal':
'Give every account sensible units, colour mode and currency without anyone opening Settings.',
'help.guide.default-user-settings.step.1':
'Under Default User Settings, pick Color Mode (Light, Dark or Auto), Temperature Unit, Distance Unit, Time Format, Week starts on, the Display currency and whether Blur Booking Codes is On or Off. Each choice saves at once.',
'Under Default User Settings, pick Color Mode (Light, Dark or Auto), Temperature Unit, Distance Unit, Time Format, Week starts on, Date format, the Display currency and whether Blur Booking Codes is On or Off. Each choice saves at once.',
'help.guide.default-user-settings.step.2':
"A field you set gets a reset link beside its name; it restores TREK's built-in default.",
'help.guide.default-user-settings.result':
+4
View File
@@ -94,6 +94,10 @@ const settings: TranslationStrings = {
'settings.timeFormat': 'Time Format',
'settings.weekStart': 'Week starts on',
'settings.weekStartHint': 'First day of the week in every date picker. Vacay has its own setting.',
'settings.dateFormat': 'Date format',
'settings.dateFormatHint':
'How dates are written in every date field and list. Dates with a month name keep the wording of your language.',
'settings.dateFormatAuto': 'Automatic',
'settings.preferredNavApp': 'Open places in',
'settings.preferredNavAppAsk': 'Ask every time',
'settings.preferredNavAppHint':
+6 -6
View File
@@ -1172,7 +1172,7 @@ const help: TranslationStrings = {
'help.ctx.settings.bullet.1':
'Barra lateral izquierda: «Pantalla», «Apariencia», «Mapa», «Notificaciones», «Integraciones», «Sin conexión» y «Cuenta». «Plugins» aparece cuando hay algún plugin activo. «Acerca de» aparece salvo que el administrador del servidor la haya ocultado. La versión se muestra abajo del todo.',
'help.ctx.settings.bullet.2':
'«Pantalla» se abre primero. Incluye el idioma, el idioma de los nombres de lugares, las unidades, el formato de hora, el primer día de la semana y la moneda de visualización, además del comportamiento del mapa del viaje. «Apariencia» incluye el tema, los colores, la legibilidad, el tamaño del texto y los widgets del panel.',
'«Pantalla» se abre primero. Incluye el idioma, el idioma de los nombres de lugares, las unidades, el formato de hora, el formato de fecha, el primer día de la semana y la moneda de visualización, además del comportamiento del mapa del viaje. «Apariencia» incluye el tema, los colores, la legibilidad, el tamaño del texto y los widgets del panel.',
'help.ctx.settings.bullet.3':
'«Mapa» define el motor de mapas y su estilo; «Notificaciones», qué eventos van a cada canal; «Integraciones» conecta bibliotecas de fotos, AirTrail, Dawarich y wanderer, e incluye las claves de API y MCP; «Sin conexión» define qué guarda este dispositivo.',
'help.ctx.settings.bullet.4':
@@ -1183,7 +1183,7 @@ const help: TranslationStrings = {
'help.ctx.settings-display.bullet.1':
'«Inicio»: «Página de inicio» abre el «Panel» o el «Viaje activo»; con «Viaje activo», «Pestaña de inicio» elige la pestaña con la que se abre ese viaje.',
'help.ctx.settings-display.bullet.2':
'Idioma y región: «Moneda de visualización» (o «Moneda del viaje» para no convertir), «Idioma», «Nombres de lugares», «Unidad de temperatura», «Unidad de distancia», «Formato de hora» y «La semana comienza el».',
'Idioma y región: «Moneda de visualización» (o «Moneda del viaje» para no convertir), «Idioma», «Nombres de lugares», «Unidad de temperatura», «Unidad de distancia», «Formato de hora», «La semana comienza el» y «Formato de fecha».',
'help.ctx.settings-display.bullet.3':
'«Viaje y mapa»: «Abrir lugares en», «Fecha primero en los títulos de día», «Etiquetas de rutas de reservas», «Mostrar siempre las rutas de reserva», «Marcadores compactos para lugares sin planificar», «Explorar lugares en el mapa», «Difuminar códigos de reserva» y «Optimizar la ruta desde el alojamiento».',
'help.ctx.settings-appearance.title': 'Apariencia',
@@ -1507,9 +1507,9 @@ const help: TranslationStrings = {
'«Configuración de permisos», abajo: para cada acción, quién puede hacerla («Todos», «Miembros del viaje», «Propietario del viaje» o «Solo administrador»). Haz clic en Guardar, arriba de la tarjeta, para guardarlo.',
'help.ctx.admin-defaults.title': 'Valores predeterminados',
'help.ctx.admin-defaults.summary':
'Con qué empieza una cuenta nueva, para que nadie tenga que configurarlo antes: modo de color, unidades, formato de hora, primer día de la semana, moneda de visualización y códigos de reserva difuminados, además del mapa, sus claves y los servidores de rutas.',
'Con qué empieza una cuenta nueva, para que nadie tenga que configurarlo antes: modo de color, unidades, formato de hora, formato de fecha, primer día de la semana, moneda de visualización y códigos de reserva difuminados, además del mapa, sus claves y los servidores de rutas.',
'help.ctx.admin-defaults.bullet.1':
'«Configuración predeterminada de usuarios», a la izquierda: «Modo de color», «Unidad de temperatura», «Unidad de distancia», «Formato de hora», «La semana comienza el», «Moneda de visualización» y «Difuminar códigos de reserva».',
'«Configuración predeterminada de usuarios», a la izquierda: «Modo de color», «Unidad de temperatura», «Unidad de distancia», «Formato de hora», «La semana comienza el», «Formato de fecha», «Moneda de visualización» y «Difuminar códigos de reserva».',
'help.ctx.admin-defaults.bullet.2':
'«Mapa», a la derecha: «Plantilla del mapa», «Clave de CARTO compartida», «Motor de rutas propio» e «Instancia de Valhalla propia», una vista previa y, después, «Motor de mapas» con «Estilo de mapa», «Token de Mapbox compartido», «Edificios y terreno en 3D» y «Modo de alta calidad».',
'help.ctx.admin-config.title': 'Personalización',
@@ -4362,7 +4362,7 @@ const help: TranslationStrings = {
'help.ctx.settings-about.bullet.3':
'En Ko-fi, Buy Me a Coffee y Discord puedes apoyar a TREK o hablar con las personas que lo desarrollan.',
'help.guide.language-region.step.3':
'Elige la «Moneda de visualización», o deja «Moneda del viaje» para ver cada viaje en su propia moneda. Después elige «Unidad de temperatura», «Unidad de distancia», «Formato de hora» y el día en que empieza la semana en «La semana comienza el».',
'Elige la «Moneda de visualización», o deja «Moneda del viaje» para ver cada viaje en su propia moneda. Después elige «Unidad de temperatura», «Unidad de distancia», «Formato de hora», el día en que empieza la semana en «La semana comienza el» y el «Formato de fecha».',
'help.guide.travel-map-prefs.step.3':
'«Explorar lugares en el mapa» muestra la píldora de categorías para buscar lugares cercanos. «Difuminar códigos de reserva» oculta los números de confirmación hasta que pasas el cursor por encima o los tocas. «Optimizar la ruta desde el alojamiento» hace que el día empiece y termine en tu hotel.',
'help.guide.web-push-device.step.2':
@@ -6457,7 +6457,7 @@ const help: TranslationStrings = {
'help.ctx.admin-settings.bullet.5':
'«Zona de peligro»: «Rotar secreto JWT» cierra la sesión de todos los usuarios, también la tuya.',
'help.guide.default-user-settings.step.1':
'En «Configuración predeterminada de usuarios», elige «Modo de color» («Claro», «Oscuro» o «Automático»), «Unidad de temperatura», «Unidad de distancia», «Formato de hora», «La semana comienza el», la «Moneda de visualización» y si «Difuminar códigos de reserva» está activado o desactivado. Cada opción se guarda al momento.',
'En «Configuración predeterminada de usuarios», elige «Modo de color» («Claro», «Oscuro» o «Automático»), «Unidad de temperatura», «Unidad de distancia», «Formato de hora», «La semana comienza el», el «Formato de fecha», la «Moneda de visualización» y si «Difuminar códigos de reserva» está activado o desactivado. Cada opción se guarda al momento.',
'help.guide.admin-passkeys.step.2':
'Introduce el «Relying Party ID (dominio)», por ejemplo trek.example.org, y los «Orígenes permitidos» desde los que se accede a tu TREK, y haz clic en Guardar. Déjalos vacíos para usar APP_URL.',
'help.guide.rotate-jwt-secret.title': 'Cerrar la sesión de todos a la vez',
+4
View File
@@ -88,6 +88,10 @@ const settings: TranslationStrings = {
'settings.timeFormat': 'Formato de hora',
'settings.weekStart': 'La semana comienza el',
'settings.weekStartHint': 'Primer día de la semana en todos los selectores de fecha. Vacay tiene su propio ajuste.',
'settings.dateFormat': 'Formato de fecha',
'settings.dateFormatHint':
'Cómo se escriben las fechas en todos los campos de fecha y listas. Las fechas con el nombre del mes mantienen la redacción de tu idioma.',
'settings.dateFormatAuto': 'Automático',
'settings.preferredNavApp': 'Abrir lugares en',
'settings.preferredNavAppAsk': 'Preguntar siempre',
'settings.preferredNavAppHint':
+6 -6
View File
@@ -1138,7 +1138,7 @@ const help: TranslationStrings = {
'help.ctx.settings.bullet.1':
'Vasakul olev külgriba: „Üldine“, „Välimus“, „Kaart“, „Teavitused“, „Integratsioonid“, „Võrguühenduseta“ ja „Konto“. „Pluginad“ ilmub, kui mõni plugin on aktiivne. „Teave“ on näha, kui haldaja pole seda peitnud. Versioon on näidatud allosas.',
'help.ctx.settings.bullet.2':
'Esimesena avaneb „Üldine“. Seal on keel, kohanimede keel, ühikud, ajavorming, nädala alguspäev ja kuvamisvaluuta ning reisikaardi käitumine. „Välimus“ sisaldab teemat, värve, loetavust, teksti suurust ja avalehe vidinaid.',
'Esimesena avaneb „Üldine“. Seal on keel, kohanimede keel, ühikud, ajavorming, kuupäevavorming, nädala alguspäev ja kuvamisvaluuta ning reisikaardi käitumine. „Välimus“ sisaldab teemat, värve, loetavust, teksti suurust ja avalehe vidinaid.',
'help.ctx.settings.bullet.3':
'„Kaart“ määrab kaardimootori ja selle stiili; „Teavitused“ määrab, millised sündmused millisesse kanalisse lähevad; „Integratsioonid“ ühendab fototeegid, AirTraili, Dawarichi ja wandereri ning sisaldab API-võtmeid ja MCP-d; „Võrguühenduseta“ määrab, mida see seade salvestab.',
'help.ctx.settings.bullet.4':
@@ -1149,7 +1149,7 @@ const help: TranslationStrings = {
'help.ctx.settings-display.bullet.1':
'„Käivitamine“: „Avaleht“ avab „Ülevaate“ või „Aktiivse reisi“; valikuga „Aktiivne reis“ määrab „Avatav vahekaart“, milline vaheleht reisil avaneb.',
'help.ctx.settings-display.bullet.2':
'„Keel ja piirkond“: „Kuvamisvaluuta“ (või „Reisi valuuta“, kui teisendust ei soovi), „Keel“, „Kohtade nimed“, „Temperatuuriühik“, „Vahemaaühik“, „Ajavorming“ ja „Nädala alguspäev“.',
'„Keel ja piirkond“: „Kuvamisvaluuta“ (või „Reisi valuuta“, kui teisendust ei soovi), „Keel“, „Kohtade nimed“, „Temperatuuriühik“, „Vahemaaühik“, „Ajavorming“, „Nädala alguspäev“ ja „Kuupäevavorming“.',
'help.ctx.settings-display.bullet.3':
'„Reisimine ja kaart“: „Ava kohad rakenduses“, „Kuupäev päevapealkirjades eespool“, „Broneeringute marsruutide sildid“, „Näita alati broneeringute marsruute“, „Kompaktsed tähised planeerimata kohtadele“, „Avasta kaardil kohti“, „Hägusta broneerimiskoodid“ ja „Optimeeri marsruut majutuskohast“.',
'help.ctx.settings-appearance.title': 'Välimus',
@@ -1463,9 +1463,9 @@ const help: TranslationStrings = {
'All olev „Õiguste seaded“: iga toimingu puhul, kes tohib seda teha („Kõik“, „Reisi liikmed“, „Reisi omanik“ või „Ainult administraator“). Salvestamiseks klõpsa kaardi ülaosas „Salvesta“.',
'help.ctx.admin-defaults.title': 'Kasutaja vaikevalikud',
'help.ctx.admin-defaults.summary':
'Millega uus konto alustab, et keegi ei peaks seda kõigepealt seadistama: värvirežiim, ühikud, ajavorming, nädala alguspäev, kuvamisvaluuta ja hägustatud broneerimiskoodid ning lisaks kaart, selle võtmed ja marsruutimisserverid.',
'Millega uus konto alustab, et keegi ei peaks seda kõigepealt seadistama: värvirežiim, ühikud, ajavorming, kuupäevavorming, nädala alguspäev, kuvamisvaluuta ja hägustatud broneerimiskoodid ning lisaks kaart, selle võtmed ja marsruutimisserverid.',
'help.ctx.admin-defaults.bullet.1':
'Vasakul olev „Kasutajate vaikeseaded“: „Värvirežiim“, „Temperatuuriühik“, „Vahemaaühik“, „Ajavorming“, „Nädala alguspäev“, „Kuvamisvaluuta“ ja „Hägusta broneerimiskoodid“.',
'Vasakul olev „Kasutajate vaikeseaded“: „Värvirežiim“, „Temperatuuriühik“, „Vahemaaühik“, „Ajavorming“, „Nädala alguspäev“, „Kuupäevavorming“, „Kuvamisvaluuta“ ja „Hägusta broneerimiskoodid“.',
'help.ctx.admin-defaults.bullet.2':
'Paremal olev „Kaart“: „Kaardimall“, „Ühine CARTO võti“, „Oma marsruutimootor“ ja „Oma Valhalla server“, eelvaade, seejärel „Kaardimootor“ koos väljadega „Kaardi stiil“, „Ühine Mapboxi pääsutõend“, „3D-hooned ja maastik“ ning „Kõrge kvaliteediga režiim“.',
'help.ctx.admin-config.title': 'Isikupärastamine',
@@ -4236,7 +4236,7 @@ const help: TranslationStrings = {
'help.ctx.settings-about.bullet.3':
'Ko-fi, „Osta mulle kohv“ ja Discordi kaudu saad TREKi toetada või selle tegijatega suhelda.',
'help.guide.language-region.step.3':
'Määra „Kuvamisvaluuta“ või jäta „Reisi valuuta“, et näha iga reisi tema enda valuutas. Seejärel vali „Temperatuuriühik“, „Vahemaaühik“, „Ajavorming“ ja „Nädala alguspäev“.',
'Määra „Kuvamisvaluuta“ või jäta „Reisi valuuta“, et näha iga reisi tema enda valuutas. Seejärel vali „Temperatuuriühik“, „Vahemaaühik“, „Ajavorming“, „Nädala alguspäev“ ja „Kuupäevavorming“.',
'help.guide.travel-map-prefs.step.3':
'„Avasta kaardil kohti“ näitab kategooriasilti lähedal asuvate kohtade leidmiseks. „Hägusta broneerimiskoodid“ peidab kinnitusnumbrid, kuni viid kursori nende kohale või puudutad neid. „Optimeeri marsruut majutuskohast“ alustab ja lõpetab päeva sinu hotellis.',
'help.guide.web-push-device.step.2':
@@ -6243,7 +6243,7 @@ const help: TranslationStrings = {
'Määratud välja kõrval olev lähtestuslink taastab TREKi vaikeväärtuse. Kasutaja enda seade on alati nende ees ülimuslik.',
'help.ctx.admin-settings.bullet.5': '„Ohutsoon“: „Roteeri JWT saladus“ logib välja kõik kasutajad, ka sinu.',
'help.guide.default-user-settings.step.1':
'Vali jaotises „Kasutajate vaikeseaded“ „Värvirežiim“ („Hele“, „Tume“ või „Automaatne“), „Temperatuuriühik“, „Vahemaaühik“, „Ajavorming“, „Nädala alguspäev“, „Kuvamisvaluuta“ ja see, kas „Hägusta broneerimiskoodid“ on sees või „Väljas“. Iga valik salvestub kohe.',
'Vali jaotises „Kasutajate vaikeseaded“ „Värvirežiim“ („Hele“, „Tume“ või „Automaatne“), „Temperatuuriühik“, „Vahemaaühik“, „Ajavorming“, „Nädala alguspäev“, „Kuupäevavorming“, „Kuvamisvaluuta“ ja see, kas „Hägusta broneerimiskoodid“ on sees või „Väljas“. Iga valik salvestub kohe.',
'help.guide.admin-passkeys.step.2':
'Sisesta „Teenuseosutaja ID (domeen)“, näiteks trek.example.org, ja „Lubatud päritolud“, mille kaudu sinu TREK on kättesaadav, seejärel klõpsa „Salvesta“. Jäta mõlemad tühjaks, et kasutada APP_URL-i.',
'help.guide.rotate-jwt-secret.title': 'Kõigi korraga väljalogimine',
+4
View File
@@ -97,6 +97,10 @@ const settings: TranslationStrings = {
'settings.timeFormat': 'Ajavorming',
'settings.weekStart': 'Nädala alguspäev',
'settings.weekStartHint': 'Nädala esimene päev kõigis kuupäevavalijates. Vacayl on oma seade.',
'settings.dateFormat': 'Kuupäevavorming',
'settings.dateFormatHint':
'Kuidas kuupäevi kõigis kuupäevaväljades ja loendites kirjutatakse. Kuu nimega kuupäevad järgivad sinu keele sõnastust.',
'settings.dateFormatAuto': 'Automaatne',
'settings.preferredNavApp': 'Ava kohad rakenduses',
'settings.preferredNavAppAsk': 'Küsi iga kord',
'settings.preferredNavAppHint':
+6 -6
View File
@@ -1190,7 +1190,7 @@ const help: TranslationStrings = {
'help.ctx.settings.bullet.1':
'La barre latérale à gauche : « Affichage », « Apparence », « Carte », « Notifications », « Intégrations », « Hors ligne » et « Compte ». « Plugins » apparaît dès qu’un plugin est actif. « À propos » apparaît sauf si l’exploitant l’a masqué. La version est indiquée en bas.',
'help.ctx.settings.bullet.2':
'« Affichage » s’ouvre en premier. Il regroupe la langue, la langue des noms de lieux, les unités, le format de l’heure, le premier jour de la semaine et la devise d’affichage, ainsi que le comportement de la carte du voyage. « Apparence » regroupe le thème, les couleurs, la lisibilité, la taille du texte et les widgets du tableau de bord.',
'« Affichage » s’ouvre en premier. Il regroupe la langue, la langue des noms de lieux, les unités, le format de l’heure, le format de date, le premier jour de la semaine et la devise d’affichage, ainsi que le comportement de la carte du voyage. « Apparence » regroupe le thème, les couleurs, la lisibilité, la taille du texte et les widgets du tableau de bord.',
'help.ctx.settings.bullet.3':
'« Carte » règle le moteur de carte et son style ; « Notifications » détermine quels événements passent par quel canal ; « Intégrations » connecte les photothèques, AirTrail, Dawarich et wanderer, et regroupe les clés API et MCP ; « Hors ligne » détermine ce que cet appareil garde en mémoire.',
'help.ctx.settings.bullet.4':
@@ -1201,7 +1201,7 @@ const help: TranslationStrings = {
'help.ctx.settings-display.bullet.1':
'« Démarrage » : « Page de démarrage » ouvre le « Tableau de bord » ou le « Voyage en cours » ; avec « Voyage en cours », « Onglet de démarrage » détermine l’onglet sur lequel s’ouvre ce voyage.',
'help.ctx.settings-display.bullet.2':
"« Langue et région » : « Devise d'affichage » (ou « Devise du voyage » pour ne rien convertir), « Langue », « Noms de lieux », « Unité de température », « Unité de distance », « Format de l'heure » et « La semaine commence le ».",
"« Langue et région » : « Devise d'affichage » (ou « Devise du voyage » pour ne rien convertir), « Langue », « Noms de lieux », « Unité de température », « Unité de distance », « Format de l'heure », « La semaine commence le » et « Format de date ».",
'help.ctx.settings-display.bullet.3':
"« Voyage et carte » : « Ouvrir les lieux dans », « Date en premier dans les titres de jour », « Étiquettes des itinéraires », « Toujours afficher les itinéraires de réservation », « Repères compacts pour les lieux non planifiés », « Explorer les lieux sur la carte », « Masquer les codes de réservation » et « Optimiser l'itinéraire depuis l'hébergement ».",
'help.ctx.settings-appearance.title': 'Apparence',
@@ -1535,9 +1535,9 @@ const help: TranslationStrings = {
'« Paramètres des permissions », en bas : pour chaque action, qui peut l’effectuer (« Tout le monde », « Membres du voyage », « Propriétaire du voyage » ou « Administrateur uniquement »). Cliquez sur « Enregistrer » en haut de la carte pour valider.',
'help.ctx.admin-defaults.title': 'Valeurs par défaut',
'help.ctx.admin-defaults.summary':
'Les réglages de départ d’un nouveau compte, pour que personne n’ait à tout configurer d’abord : mode de couleur, unités, format de l’heure, premier jour de la semaine, devise d’affichage et codes de réservation masqués, ainsi que la carte, ses clés et les serveurs de calcul d’itinéraire.',
'Les réglages de départ d’un nouveau compte, pour que personne n’ait à tout configurer d’abord : mode de couleur, unités, format de l’heure, format de date, premier jour de la semaine, devise d’affichage et codes de réservation masqués, ainsi que la carte, ses clés et les serveurs de calcul d’itinéraire.',
'help.ctx.admin-defaults.bullet.1':
"« Paramètres utilisateur par défaut », à gauche : « Mode de couleur », « Unité de température », « Unité de distance », « Format de l'heure », « La semaine commence le », « Devise d'affichage » et « Masquer les codes de réservation ».",
"« Paramètres utilisateur par défaut », à gauche : « Mode de couleur », « Unité de température », « Unité de distance », « Format de l'heure », « La semaine commence le », « Format de date », « Devise d'affichage » et « Masquer les codes de réservation ».",
'help.ctx.admin-defaults.bullet.2':
'« Carte », à droite : « Modèle de carte », « Clé CARTO partagée », « Moteur de calcul d’itinéraire propre » et « Instance Valhalla propre », un aperçu, puis « Moteur cartographique » avec « Style de carte », « Jeton Mapbox partagé », « Bâtiments & terrain en 3D » et « Mode haute qualité ».',
'help.ctx.admin-config.title': 'Personnalisation',
@@ -4415,7 +4415,7 @@ const help: TranslationStrings = {
'help.ctx.settings-about.bullet.3':
'Ko-fi, Buy Me a Coffee et Discord : c’est là que vous pouvez soutenir TREK ou échanger avec l’équipe qui le développe.',
'help.guide.language-region.step.3':
"Réglez la « Devise d'affichage », ou gardez « Devise du voyage » pour voir chaque voyage dans sa propre devise. Choisissez ensuite « Unité de température », « Unité de distance », « Format de l'heure » et le jour où commence la semaine.",
"Réglez la « Devise d'affichage », ou gardez « Devise du voyage » pour voir chaque voyage dans sa propre devise. Choisissez ensuite « Unité de température », « Unité de distance », « Format de l'heure », le jour où commence la semaine et le « Format de date ».",
'help.guide.travel-map-prefs.step.3':
"« Explorer les lieux sur la carte » affiche la pastille de catégories pour trouver des lieux à proximité. « Masquer les codes de réservation » cache les numéros de confirmation jusqu’à ce que vous les survoliez ou les touchiez. « Optimiser l'itinéraire depuis l'hébergement » fait commencer et finir chaque journée à votre hôtel.",
'help.guide.web-push-device.step.2':
@@ -6537,7 +6537,7 @@ const help: TranslationStrings = {
'help.ctx.admin-settings.bullet.5':
'« Zone de danger » : « Renouveler le secret JWT » déconnecte tous les utilisateurs, vous compris.',
'help.guide.default-user-settings.step.1':
"Sous « Paramètres utilisateur par défaut », choisissez « Mode de couleur » (« Clair », « Sombre » ou « Auto »), « Unité de température », « Unité de distance », « Format de l'heure », « La semaine commence le », la « Devise d'affichage » et si « Masquer les codes de réservation » est activé ou désactivé. Chaque choix est enregistré immédiatement.",
"Sous « Paramètres utilisateur par défaut », choisissez « Mode de couleur » (« Clair », « Sombre » ou « Auto »), « Unité de température », « Unité de distance », « Format de l'heure », « La semaine commence le », le « Format de date », la « Devise d'affichage » et si « Masquer les codes de réservation » est activé ou désactivé. Chaque choix est enregistré immédiatement.",
'help.guide.admin-passkeys.step.2':
'Saisissez le « Relying Party ID (domaine) », par exemple trek.example.org, et les « Origines autorisées » par lesquelles on accède à votre TREK, puis cliquez sur « Enregistrer ». Laissez les deux champs vides pour utiliser APP_URL.',
'help.guide.rotate-jwt-secret.title': 'Déconnecter tout le monde d’un coup',
+4
View File
@@ -90,6 +90,10 @@ const settings: TranslationStrings = {
'settings.timeFormat': "Format de l'heure",
'settings.weekStart': 'La semaine commence le',
'settings.weekStartHint': 'Premier jour de la semaine dans tous les sélecteurs de date. Vacay a son propre réglage.',
'settings.dateFormat': 'Format de date',
'settings.dateFormatHint':
"Façon dont les dates s'écrivent dans tous les champs de date et toutes les listes. Les dates avec un nom de mois gardent la formulation de votre langue.",
'settings.dateFormatAuto': 'Automatique',
'settings.preferredNavApp': 'Ouvrir les lieux dans',
'settings.preferredNavAppAsk': 'Demander à chaque fois',
'settings.preferredNavAppHint':
+6 -6
View File
@@ -1187,7 +1187,7 @@ const help: TranslationStrings = {
'help.ctx.settings.bullet.1':
'Πλαϊνή στήλη αριστερά: «Γενικά», «Εμφάνιση», «Χάρτης», «Ειδοποιήσεις», «Ενσωματώσεις», «Εκτός σύνδεσης» και «Λογαριασμός». Τα Plugins εμφανίζονται μόλις ενεργοποιηθεί ένα plugin. Το «Σχετικά» εμφανίζεται, εκτός αν ο διαχειριστής της εγκατάστασης το έχει κρύψει. Η έκδοση φαίνεται στο κάτω μέρος.',
'help.ctx.settings.bullet.2':
'Πρώτη ανοίγει η καρτέλα «Γενικά». Εκεί ορίζετε τη γλώσσα, τη γλώσσα των ονομάτων μερών, τις μονάδες, τη μορφή ώρας, την πρώτη ημέρα της εβδομάδας και το νόμισμα εμφάνισης, καθώς και τη συμπεριφορά του χάρτη του ταξιδιού. Στην «Εμφάνιση» θα βρείτε θέμα, χρώματα, αναγνωσιμότητα, μέγεθος κειμένου και τα widgets του πίνακα ελέγχου.',
'Πρώτη ανοίγει η καρτέλα «Γενικά». Εκεί ορίζετε τη γλώσσα, τη γλώσσα των ονομάτων μερών, τις μονάδες, τη μορφή ώρας, τη μορφή ημερομηνίας, την πρώτη ημέρα της εβδομάδας και το νόμισμα εμφάνισης, καθώς και τη συμπεριφορά του χάρτη του ταξιδιού. Στην «Εμφάνιση» θα βρείτε θέμα, χρώματα, αναγνωσιμότητα, μέγεθος κειμένου και τα widgets του πίνακα ελέγχου.',
'help.ctx.settings.bullet.3':
'Στον «Χάρτη» ορίζετε τη μηχανή χάρτη και το στυλ της. Στις «Ειδοποιήσεις» ορίζετε ποια συμβάντα πηγαίνουν σε ποιο κανάλι. Οι «Ενσωματώσεις» συνδέουν βιβλιοθήκες φωτογραφιών, AirTrail, Dawarich και wanderer και περιέχουν τα κλειδιά API και το MCP. Στο «Εκτός σύνδεσης» ορίζετε τι αποθηκεύει αυτή η συσκευή.',
'help.ctx.settings.bullet.4':
@@ -1198,7 +1198,7 @@ const help: TranslationStrings = {
'help.ctx.settings-display.bullet.1':
'«Εκκίνηση»: η «Αρχική σελίδα» ανοίγει στον «Πίνακα ελέγχου» ή στο «Ενεργό ταξίδι». Με «Ενεργό ταξίδι», η «Αρχική καρτέλα» ορίζει σε ποια καρτέλα ανοίγει το ταξίδι.',
'help.ctx.settings-display.bullet.2':
'Γλώσσα και περιοχή: «Νόμισμα εμφάνισης» (ή «Νόμισμα ταξιδιού» χωρίς μετατροπή), «Γλώσσα», «Ονόματα μερών», «Μονάδα Θερμοκρασίας», «Μονάδα Απόστασης», «Μορφή Ώρας» και «Η εβδομάδα ξεκινά την».',
'Γλώσσα και περιοχή: «Νόμισμα εμφάνισης» (ή «Νόμισμα ταξιδιού» χωρίς μετατροπή), «Γλώσσα», «Ονόματα μερών», «Μονάδα Θερμοκρασίας», «Μονάδα Απόστασης», «Μορφή Ώρας», «Η εβδομάδα ξεκινά την» και «Μορφή Ημερομηνίας».',
'help.ctx.settings-display.bullet.3':
'Ταξίδι και χάρτης: «Άνοιγμα τοποθεσιών σε», «Πρώτα η ημερομηνία στους τίτλους ημερών», «Ετικέτες διαδρομής κρατήσεων», «Πάντα εμφάνιση διαδρομών κράτησης», «Συμπαγείς δείκτες για μη προγραμματισμένα μέρη», «Εξερεύνηση μερών στον χάρτη», «Θόλωμα Κωδικών Κρατήσεων» και «Βελτιστοποίηση διαδρομής από το κατάλυμα».',
'help.ctx.settings-appearance.title': 'Εμφάνιση',
@@ -1530,9 +1530,9 @@ const help: TranslationStrings = {
'«Ρυθμίσεις Δικαιωμάτων» στο κάτω μέρος: για κάθε ενέργεια, ποιος μπορεί να την εκτελεί («Όλοι», «Μέλη ταξιδιού», «Ιδιοκτήτης ταξιδιού» ή «Μόνο διαχειριστής»). Κάντε κλικ στο «Αποθήκευση» στην κορυφή της κάρτας για να αποθηκευτούν.',
'help.ctx.admin-defaults.title': 'Προεπιλογές Χρήστη',
'help.ctx.admin-defaults.summary':
'Με τι ξεκινά ένας νέος λογαριασμός, ώστε να μη χρειάζεται να το ρυθμίσει κανείς πρώτα: λειτουργία χρώματος, μονάδες, μορφή ώρας, πρώτη ημέρα της εβδομάδας, νόμισμα εμφάνισης και θόλωμα κωδικών κρατήσεων, καθώς και ο χάρτης, τα κλειδιά του και οι διακομιστές δρομολόγησης.',
'Με τι ξεκινά ένας νέος λογαριασμός, ώστε να μη χρειάζεται να το ρυθμίσει κανείς πρώτα: λειτουργία χρώματος, μονάδες, μορφή ώρας, μορφή ημερομηνίας, πρώτη ημέρα της εβδομάδας, νόμισμα εμφάνισης και θόλωμα κωδικών κρατήσεων, καθώς και ο χάρτης, τα κλειδιά του και οι διακομιστές δρομολόγησης.',
'help.ctx.admin-defaults.bullet.1':
'«Προεπιλεγμένες Ρυθμίσεις Χρήστη» αριστερά: «Λειτουργία Χρώματος», «Μονάδα Θερμοκρασίας», «Μονάδα Απόστασης», «Μορφή Ώρας», «Η εβδομάδα ξεκινά την», «Νόμισμα εμφάνισης» και «Θόλωμα Κωδικών Κρατήσεων».',
'«Προεπιλεγμένες Ρυθμίσεις Χρήστη» αριστερά: «Λειτουργία Χρώματος», «Μονάδα Θερμοκρασίας», «Μονάδα Απόστασης», «Μορφή Ώρας», «Η εβδομάδα ξεκινά την», «Μορφή Ημερομηνίας», «Νόμισμα εμφάνισης» και «Θόλωμα Κωδικών Κρατήσεων».',
'help.ctx.admin-defaults.bullet.2':
'Χάρτης δεξιά: «Πρότυπο Χάρτη», «Κοινόχρηστο κλειδί CARTO», «Δική σας μηχανή δρομολόγησης» και «Δική σας υπηρεσία Valhalla», μια προεπισκόπηση και μετά «Μηχανή χάρτη» με «Στυλ χάρτη», «Κοινόχρηστο διακριτικό Mapbox», «Κτίρια & ανάγλυφο 3D» και «Λειτουργία υψηλής ποιότητας».',
'help.ctx.admin-config.title': 'Εξατομίκευση',
@@ -4409,7 +4409,7 @@ const help: TranslationStrings = {
'help.ctx.settings-about.bullet.3':
'Στο Ko-fi, στο Buy Me a Coffee και στο Discord μπορείτε να στηρίξετε το TREK ή να μιλήσετε με τους ανθρώπους που το φτιάχνουν.',
'help.guide.language-region.step.3':
'Ορίστε το «Νόμισμα εμφάνισης» ή κρατήστε το «Νόμισμα ταξιδιού» για να βλέπετε κάθε ταξίδι στο δικό του νόμισμα. Μετά επιλέξτε «Μονάδα Θερμοκρασίας», «Μονάδα Απόστασης», «Μορφή Ώρας» και την ημέρα στο «Η εβδομάδα ξεκινά την».',
'Ορίστε το «Νόμισμα εμφάνισης» ή κρατήστε το «Νόμισμα ταξιδιού» για να βλέπετε κάθε ταξίδι στο δικό του νόμισμα. Μετά επιλέξτε «Μονάδα Θερμοκρασίας», «Μονάδα Απόστασης», «Μορφή Ώρας», την ημέρα στο «Η εβδομάδα ξεκινά την» και τη «Μορφή Ημερομηνίας».',
'help.guide.travel-map-prefs.step.3':
'Το «Εξερεύνηση μερών στον χάρτη» εμφανίζει το κουμπί κατηγοριών για να βρίσκετε μέρη κοντά σας. Το «Θόλωμα Κωδικών Κρατήσεων» κρύβει τους αριθμούς επιβεβαίωσης μέχρι να περάσετε τον δείκτη από πάνω ή να τους πατήσετε. Με το «Βελτιστοποίηση διαδρομής από το κατάλυμα» η ημέρα ξεκινά και τελειώνει στο ξενοδοχείο σας.',
'help.guide.web-push-device.step.2':
@@ -6523,7 +6523,7 @@ const help: TranslationStrings = {
'help.ctx.admin-settings.bullet.5':
'«Επικίνδυνη ζώνη»: το «Εναλλαγή μυστικού JWT» αποσυνδέει όλους τους χρήστες, μαζί κι εσάς.',
'help.guide.default-user-settings.step.1':
'Στις «Προεπιλεγμένες Ρυθμίσεις Χρήστη», επιλέξτε «Λειτουργία Χρώματος» («Φωτεινό», «Σκούρο» ή «Αυτόματο»), «Μονάδα Θερμοκρασίας», «Μονάδα Απόστασης», «Μορφή Ώρας», «Η εβδομάδα ξεκινά την», «Νόμισμα εμφάνισης» και αν το «Θόλωμα Κωδικών Κρατήσεων» είναι ενεργό ή «Ανενεργό». Κάθε επιλογή αποθηκεύεται αμέσως.',
'Στις «Προεπιλεγμένες Ρυθμίσεις Χρήστη», επιλέξτε «Λειτουργία Χρώματος» («Φωτεινό», «Σκούρο» ή «Αυτόματο»), «Μονάδα Θερμοκρασίας», «Μονάδα Απόστασης», «Μορφή Ώρας», «Η εβδομάδα ξεκινά την», «Μορφή Ημερομηνίας», «Νόμισμα εμφάνισης» και αν το «Θόλωμα Κωδικών Κρατήσεων» είναι ενεργό ή «Ανενεργό». Κάθε επιλογή αποθηκεύεται αμέσως.',
'help.guide.admin-passkeys.step.2':
'Συμπληρώστε το «Relying Party ID (τομέας)», π.χ. trek.example.org, και τις «Επιτρεπόμενες προελεύσεις» από τις οποίες ανοίγει το TREK σας και κάντε κλικ στο «Αποθήκευση». Αφήστε τα κενά για να χρησιμοποιηθεί το APP_URL.',
'help.guide.rotate-jwt-secret.title': 'Αποσύνδεση όλων μαζί',
+4
View File
@@ -92,6 +92,10 @@ const settings: TranslationStrings = {
'settings.timeFormat': 'Μορφή Ώρας',
'settings.weekStart': 'Η εβδομάδα ξεκινά την',
'settings.weekStartHint': 'Πρώτη ημέρα της εβδομάδας σε κάθε επιλογέα ημερομηνίας. Το Vacay έχει δική του ρύθμιση.',
'settings.dateFormat': 'Μορφή Ημερομηνίας',
'settings.dateFormatHint':
'Πώς γράφονται οι ημερομηνίες σε κάθε πεδίο ημερομηνίας και λίστα. Οι ημερομηνίες με όνομα μήνα διατηρούν τη διατύπωση της γλώσσας σας.',
'settings.dateFormatAuto': 'Αυτόματο',
'settings.preferredNavApp': 'Άνοιγμα τοποθεσιών σε',
'settings.preferredNavAppAsk': 'Ερώτηση κάθε φορά',
'settings.preferredNavAppHint':
+6 -6
View File
@@ -1238,7 +1238,7 @@ const help: TranslationStrings = {
'help.ctx.settings.bullet.1':
'סרגל הצד משמאל: כללי, מראה, מפה, התראות, שילובים, אופליין וחשבון. תוספים מופיע כשיש תוסף פעיל. אודות מופיע אלא אם מפעילי המערכת הסתירו אותו. הגרסה מוצגת בתחתית.',
'help.ctx.settings.bullet.2':
'כללי נפתח ראשון. הוא כולל את השפה, שפת שמות המקומות, יחידות המידה, פורמט השעה, תחילת השבוע ומטבע התצוגה, וגם את אופן הפעולה של מפת הטיול. מראה כולל ערכת נושא, צבעים, קריאות, גודל טקסט ואת הווידג׳טים בלוח הבקרה.',
'כללי נפתח ראשון. הוא כולל את השפה, שפת שמות המקומות, יחידות המידה, פורמט השעה, פורמט התאריך, תחילת השבוע ומטבע התצוגה, וגם את אופן הפעולה של מפת הטיול. מראה כולל ערכת נושא, צבעים, קריאות, גודל טקסט ואת הווידג׳טים בלוח הבקרה.',
'help.ctx.settings.bullet.3':
'מפה מגדיר את מנוע המפה ואת הסגנון שלו; התראות מגדיר אילו אירועים נשלחים לכל ערוץ; שילובים מחבר ספריות תמונות, AirTrail, Dawarich ו-wanderer, וכולל מפתחות API ו-MCP; אופליין מגדיר מה נשמר במכשיר הזה.',
'help.ctx.settings.bullet.4':
@@ -1249,7 +1249,7 @@ const help: TranslationStrings = {
'help.ctx.settings-display.bullet.1':
'הפעלה: עמוד פתיחה פותח את לוח הבקרה או את הטיול הפעיל; כשבוחרים בטיול הפעיל, לשונית פתיחה קובעת באיזו לשונית הטיול ייפתח.',
'help.ctx.settings-display.bullet.2':
'שפה ואזור: מטבע תצוגה (או מטבע הטיול אם לא נבחר מטבע), שפה, שמות מקומות, יחידת טמפרטורה, יחידת מרחק, פורמט שעה והשבוע מתחיל ביום.',
'שפה ואזור: מטבע תצוגה (או מטבע הטיול אם לא נבחר מטבע), שפה, שמות מקומות, יחידת טמפרטורה, יחידת מרחק, פורמט שעה, השבוע מתחיל ביום ופורמט תאריך.',
'help.ctx.settings-display.bullet.3':
'טיול ומפה: פתיחת מקומות ב-, התאריך קודם בכותרות הימים, תוויות במסלולי הזמנות, הצגת מסלולי הזמנות תמיד, סמנים קטנים למקומות שלא תוכננו, גילוי מקומות על המפה, טשטוש קודי הזמנה ואופטימיזציית מסלול מהלינה.',
'help.ctx.settings-appearance.title': 'מראה',
@@ -1347,7 +1347,7 @@ const help: TranslationStrings = {
'help.guide.language-region.step.2':
'שמות מקומות קובע את השפה של חיפוש המקומות, ההצעות והכתובות; כמו באפליקציה משתמש בשפת הממשק.',
'help.guide.language-region.step.3':
'הגדירו את מטבע התצוגה, או השאירו את מטבע הטיול כדי לראות כל טיול במטבע שלו. לאחר מכן בחרו יחידת טמפרטורה, יחידת מרחק, פורמט שעה ואת היום שבו השבוע מתחיל.',
'הגדירו את מטבע התצוגה, או השאירו את מטבע הטיול כדי לראות כל טיול במטבע שלו. לאחר מכן בחרו יחידת טמפרטורה, יחידת מרחק, פורמט שעה, את היום שבו השבוע מתחיל ואת פורמט התאריך.',
'help.guide.language-region.result':
'תאריכים, מרחקים וסכומי כסף מוצגים בפורמט שאתם רגילים אליו. בהוצאות, הסכומים מוצגים במטבע התצוגה שלכם לצורכי תצוגה בלבד; הסכומים שהוזנו לא משתנים.',
'help.guide.language-region.tip.1': 'מטבע הטיול מציג כל טיול במטבע שלו, ללא המרה.',
@@ -1630,9 +1630,9 @@ const help: TranslationStrings = {
'הגדרות הרשאות בתחתית: מי רשאי לבצע כל פעולה (כולם, חברי הטיול, בעלי הטיול או מנהלים בלבד). לחצו על שמירה בראש הכרטיסייה כדי לשמור.',
'help.ctx.admin-defaults.title': 'ברירות מחדל למשתמשים',
'help.ctx.admin-defaults.summary':
'ההגדרות שחשבון חדש מתחיל איתן, כדי שלא יהיה צורך להגדיר אותן מראש: מצב צבע, יחידות מידה, פורמט שעה, תחילת השבוע, מטבע תצוגה וטשטוש קודי הזמנה, וכן המפה, המפתחות שלה ושרתי הניווט.',
'ההגדרות שחשבון חדש מתחיל איתן, כדי שלא יהיה צורך להגדיר אותן מראש: מצב צבע, יחידות מידה, פורמט שעה, פורמט תאריך, תחילת השבוע, מטבע תצוגה וטשטוש קודי הזמנה, וכן המפה, המפתחות שלה ושרתי הניווט.',
'help.ctx.admin-defaults.bullet.1':
'הגדרות ברירת מחדל למשתמשים משמאל: מצב צבע, יחידת טמפרטורה, יחידת מרחק, פורמט שעה, השבוע מתחיל ביום, מטבע תצוגה וטשטוש קודי הזמנה.',
'הגדרות ברירת מחדל למשתמשים משמאל: מצב צבע, יחידת טמפרטורה, יחידת מרחק, פורמט שעה, השבוע מתחיל ביום, פורמט תאריך, מטבע תצוגה וטשטוש קודי הזמנה.',
'help.ctx.admin-defaults.bullet.2':
'מפה מימין: תבנית מפה, מפתח CARTO משותף, מנוע ניווט משלכם ומופע Valhalla משלכם, תצוגה מקדימה, ואז מנוע מפה עם סגנון מפה, אסימון Mapbox משותף, בניינים ותבליט בתלת-ממד ומצב איכות גבוהה.',
'help.ctx.admin-defaults.bullet.3':
@@ -1774,7 +1774,7 @@ const help: TranslationStrings = {
'help.guide.default-user-settings.goal':
'הגדירו לכל חשבון יחידות מידה, מצב צבע ומטבע מתאימים, בלי שהמשתמשים יצטרכו לפתוח את ההגדרות.',
'help.guide.default-user-settings.step.1':
'תחת הגדרות ברירת מחדל למשתמשים, בחרו מצב צבע (בהיר, כהה או אוטומטי), יחידת טמפרטורה, יחידת מרחק, תבנית שעה, יום תחילת השבוע ומטבע תצוגה, והגדירו אם טשטוש קודי הזמנה פועל או כבוי. כל בחירה נשמרת מיד.',
'תחת הגדרות ברירת מחדל למשתמשים, בחרו מצב צבע (בהיר, כהה או אוטומטי), יחידת טמפרטורה, יחידת מרחק, תבנית שעה, יום תחילת השבוע, פורמט תאריך ומטבע תצוגה, והגדירו אם טשטוש קודי הזמנה פועל או כבוי. כל בחירה נשמרת מיד.',
'help.guide.default-user-settings.step.2':
'לצד שמו של שדה שהגדרתם מופיע קישור איפוס, שמחזיר את ברירת המחדל המובנית של TREK.',
'help.guide.default-user-settings.result':

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