mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
7ce31756a3
Settings > General > Language & region gets "Week starts on" (Monday, Sunday or Saturday), and Admin > User Defaults a default for everyone who has not picked one. Monday stays the built-in value, so nothing changes for anyone who leaves it alone. Both calendar implementations follow it: CustomDatePicker, used by trip dates, bookings, costs, to-dos and the admin school holidays on desktop and phone, and the journey date picker, whose weekday headers were hardcoded English and now speak the app language. The grid math and the header row live in one helper (utils/calendarWeek.ts). The day names on the options come from Intl, so only the label and a hint needed translating. Vacay keeps its own per-plan week start. The value is validated like the other regional settings: VALID_VALUES for admin defaults, the display-settings MCP tool, and the managed key list. Addresses discussion #2029.
218 lines
9.6 KiB
TypeScript
218 lines
9.6 KiB
TypeScript
import { create } from 'zustand'
|
|
import { settingsApi } from '../api/client'
|
|
import type { Settings } from '../types'
|
|
import { DEFAULT_APPEARANCE, DEFAULT_WEEK_START } from '@trek/shared'
|
|
import { getApiErrorMessage } from '../types'
|
|
import { SUPPORTED_LANGUAGE_CODES } from '../i18n/supportedLanguages'
|
|
import { normalizeTileUrl, stripTileApiKey } from '../utils/tileUrl'
|
|
import { clearTileCache } from '../sync/tilePrefetcher'
|
|
import { rememberStartDestination, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../utils/startDestination'
|
|
|
|
interface SettingsState {
|
|
settings: Settings
|
|
isLoaded: boolean
|
|
|
|
loadSettings: () => Promise<void>
|
|
updateSetting: (key: keyof Settings, value: Settings[keyof Settings]) => Promise<void>
|
|
setLanguageLocal: (lang: string) => void
|
|
setLanguageTransient: (lang: string) => void
|
|
updateSettings: (settingsObj: Partial<Settings>) => Promise<void>
|
|
}
|
|
|
|
// Returns true when the user has explicitly chosen a language (persisted in localStorage).
|
|
// Use this instead of reading localStorage directly so the key stays encapsulated here.
|
|
export const hasStoredLanguage = (): boolean =>
|
|
typeof localStorage !== 'undefined' && !!localStorage.getItem('app_language')
|
|
|
|
const SERVER_LANGUAGE_KEY = 'app_language_server'
|
|
|
|
// An account that never picked a language sends no language key at all
|
|
// (getUserSettings returns only what was set), so a mirror left over from
|
|
// whoever used this browser before has to go rather than survive the load.
|
|
function rememberServerLanguage(settings: Partial<Settings>): void {
|
|
try {
|
|
const language = settings.language
|
|
if (language && SUPPORTED_LANGUAGE_CODES.includes(language)) {
|
|
localStorage.setItem(SERVER_LANGUAGE_KEY, language)
|
|
} else {
|
|
localStorage.removeItem(SERVER_LANGUAGE_KEY)
|
|
}
|
|
} catch {
|
|
// Private mode: the session still has the value in the store.
|
|
}
|
|
}
|
|
|
|
/** On logout, so the next account on this browser starts in its own language. */
|
|
export function forgetServerLanguage(): void {
|
|
try {
|
|
localStorage.removeItem(SERVER_LANGUAGE_KEY)
|
|
} catch {
|
|
// Nothing to clean up if storage is unavailable.
|
|
}
|
|
}
|
|
|
|
// The effective client-side defaults for a fresh instance. The server sends no value for
|
|
// a setting an admin hasn't defaulted (see settingsService.getAdminUserDefaults), so these
|
|
// are what a brand-new user actually sees. Keep them internally consistent — one
|
|
// measurement system, not °F alongside kilometres — and note that DisplaySettingsTab
|
|
// imports these same values for its fallbacks, so the store default and the UI fallback
|
|
// can't drift apart again.
|
|
export const DEFAULT_SETTINGS: Settings = {
|
|
map_tile_url: '',
|
|
// Empty = the public routing hosts TREK ships with.
|
|
routing_base_url: '',
|
|
// Empty = the public Valhalla, unless routing_base_url names an own router, in which
|
|
// case no second engine is asked at all. See valhallaBase().
|
|
valhalla_base_url: '',
|
|
// Empty = not said, and then a stop of either kind fills up. See refuelsRange().
|
|
roadtrip_vehicle: '',
|
|
// Off = the rail routes a day at a time, the way the day plan stores it, and the road
|
|
// between one day's last stop and the next day's first is simply not drawn. On = the
|
|
// trip is one continuous drive and every one of those gaps is routed too.
|
|
roadtrip_connect_days: false,
|
|
// Off = one blue line for the whole trip. On = a colour per day, so a route drawn end
|
|
// to end can still be read as days.
|
|
roadtrip_day_colors: false,
|
|
dark_mode: false,
|
|
// Empty = no personal display currency, so Costs falls back to the trip's own.
|
|
default_currency: '',
|
|
// Explicit in-app choice first, then the mirror of the account's server-side
|
|
// language (written by loadSettings below), then English. Without the mirror
|
|
// an offline cold start boots in English: the account's language lives on the
|
|
// server, and the fetch that would apply it cannot happen.
|
|
language: localStorage.getItem('app_language') || localStorage.getItem(SERVER_LANGUAGE_KEY) || 'en',
|
|
temperature_unit: 'celsius',
|
|
distance_unit: 'metric',
|
|
time_format: '24h',
|
|
week_start: DEFAULT_WEEK_START,
|
|
show_place_description: false,
|
|
optimize_from_accommodation: true,
|
|
map_provider: 'leaflet',
|
|
map_base_layer: 'default',
|
|
map_poi_pill_enabled: true,
|
|
carto_api_key: '',
|
|
mapbox_access_token: '',
|
|
mapbox_style: 'mapbox://styles/mapbox/standard',
|
|
maplibre_style: '',
|
|
mapbox_3d_enabled: true,
|
|
mapbox_quality_mode: false,
|
|
dashboard_fx_from: 'EUR',
|
|
dashboard_fx_to: 'USD',
|
|
start_page: DEFAULT_START_PAGE,
|
|
start_trip_tab: DEFAULT_START_TRIP_TAB,
|
|
appearance: DEFAULT_APPEARANCE,
|
|
// dashboard_timezones is intentionally left unset so the widget can tell "never
|
|
// chosen" (fall back to home + defaults) from an explicitly emptied list.
|
|
}
|
|
|
|
// De-dupe concurrent loads: the reconnection triggers (online / visibility /
|
|
// periodic) can all fire a retry at once — collapse them into one in-flight GET.
|
|
let _loadInFlight: Promise<void> | null = null
|
|
|
|
// Every tile consumer (planner map, journey map, tile prefetcher, the settings
|
|
// preview) reads the template from this store, so the retired
|
|
// {s}.tile.openstreetmap.org host is rewritten here — on the way in from the
|
|
// server and on the way out to it. Rewriting only on read would let a template
|
|
// typed by hand survive in the database until the next load put it back (#1733).
|
|
function withNormalizedTileUrl<T extends Partial<Settings>>(patch: T): T {
|
|
if (typeof patch.map_tile_url !== 'string') return patch
|
|
return { ...patch, map_tile_url: cleanTileTemplate(patch.map_tile_url) }
|
|
}
|
|
|
|
// A CARTO key pasted along with a full tile URL is dropped here: the key lives
|
|
// in its own setting and is appended at render time, so leaving it in the
|
|
// template would freeze it into the saved value and break on the next rotation.
|
|
function cleanTileTemplate(url: string): string {
|
|
return stripTileApiKey(normalizeTileUrl(url))
|
|
}
|
|
|
|
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|
settings: { ...DEFAULT_SETTINGS },
|
|
isLoaded: false,
|
|
|
|
loadSettings: async () => {
|
|
if (_loadInFlight !== null) return _loadInFlight
|
|
_loadInFlight = (async () => {
|
|
try {
|
|
const data = await settingsApi.get()
|
|
// Rewritten here so the Map settings input already shows the host that
|
|
// still resolves, and persists it on the next save.
|
|
const incoming = withNormalizedTileUrl({ ...data.settings } as Partial<Settings>)
|
|
set((state) => ({
|
|
settings: { ...state.settings, ...incoming },
|
|
isLoaded: true,
|
|
}))
|
|
// The startup redirect runs before this ever resolves, so keep a mirror
|
|
// it can read synchronously on the next launch.
|
|
rememberStartDestination(incoming)
|
|
// Same trick for the language: mirror the account's value so the next
|
|
// launch — including an offline one, where this fetch fails — boots in
|
|
// it instead of English (#1618 fixed the same stranding for currency
|
|
// and units, but the language never made it into that fix). Its own
|
|
// key rather than 'app_language': that one means an explicit in-app
|
|
// choice, and the login page's detection chain must keep running for
|
|
// users who never made one.
|
|
rememberServerLanguage(incoming)
|
|
} catch (err: unknown) {
|
|
// Leave isLoaded false so a transient failure — offline at launch, or a
|
|
// (docker) server cold-start racing the first request — is retried on
|
|
// reconnect instead of stranding the user on built-in defaults (wrong
|
|
// currency/units) for the whole session (#1618).
|
|
console.error('Failed to load settings:', err)
|
|
} finally {
|
|
_loadInFlight = null
|
|
}
|
|
})()
|
|
return _loadInFlight
|
|
},
|
|
|
|
updateSetting: async (key: keyof Settings, value: Settings[keyof Settings]) => {
|
|
const next =
|
|
key === 'map_tile_url' && typeof value === 'string' ? cleanTileTemplate(value) : value
|
|
if (key === 'carto_api_key' && next !== get().settings.carto_api_key) void clearTileCache()
|
|
set((state) => ({
|
|
settings: { ...state.settings, [key]: next },
|
|
}))
|
|
if (key === 'language') localStorage.setItem('app_language', next as string)
|
|
rememberStartDestination({ [key]: next } as Partial<Settings>)
|
|
try {
|
|
await settingsApi.set(key, next)
|
|
} catch (err: unknown) {
|
|
console.error('Failed to save setting:', err)
|
|
throw new Error(getApiErrorMessage(err, 'Error saving setting'))
|
|
}
|
|
},
|
|
|
|
setLanguageLocal: (lang: string) => {
|
|
localStorage.setItem('app_language', lang)
|
|
set((state) => ({ settings: { ...state.settings, language: lang } }))
|
|
},
|
|
|
|
// Applies a language for the current session without persisting to localStorage.
|
|
// Used for automatic detection (browser/server default) — only explicit user
|
|
// choices via the UI should be persisted.
|
|
setLanguageTransient: (lang: string) => {
|
|
if (!SUPPORTED_LANGUAGE_CODES.includes(lang)) return
|
|
set((state) => ({ settings: { ...state.settings, language: lang } }))
|
|
},
|
|
|
|
updateSettings: async (settingsObj: Partial<Settings>) => {
|
|
const patch = withNormalizedTileUrl(settingsObj)
|
|
// Cached tiles are keyed by their full URL, so a new key leaves the whole
|
|
// offline cache stranded behind the old one.
|
|
if ('carto_api_key' in patch && patch.carto_api_key !== get().settings.carto_api_key) {
|
|
void clearTileCache()
|
|
}
|
|
set((state) => ({
|
|
settings: { ...state.settings, ...patch },
|
|
}))
|
|
rememberStartDestination(patch)
|
|
try {
|
|
await settingsApi.setBulk(patch)
|
|
} catch (err: unknown) {
|
|
console.error('Failed to save settings:', err)
|
|
throw new Error(getApiErrorMessage(err, 'Error saving settings'))
|
|
}
|
|
},
|
|
}))
|