Skip to content

Commit 049b8bc

Browse files
feat(ui): add per-user locale preference for date/time formatting
Hardcoded en-GB / en-US locales rendered dates the same way for every viewer, ignoring the browser preference. Users whose locale uses ISO 8601 (e.g. sv-SE) had no way to see `2026-06-01` instead of `01/06/2026`. Adds a LocaleProvider (mirrors ThemeProvider) backed by localStorage and exposed under Settings → Profile → "Date & time format". The "Auto" default resolves to `navigator.language`, so users with a Swedish browser get ISO output without any configuration. The picker lists ~60 BCP-47 tags labelled via Intl.DisplayNames in the active locale. The "Auto · <tag>" label always shows the real browser default rather than the currently selected locale, so its meaning stays consistent regardless of selection. 11 display formatters (MessageModal, TransactionModal, OTLP*, QosPanel, ClockPanel, ResultsPanel, TimeRangePicker display, flow-data) now read the locale from context and pass it to Intl.DateTimeFormat instead of hardcoding `en-GB`. The cosmetic `.replace(',', '')` and the `hour12: false` / `2-digit` overrides are dropped so the locale's natural conventions apply; `fractionalSecondDigits: 3` is preserved where it was (SIP packet timing). Left untouched: `resolveTimeRange.ts` and `TimeRangePicker`'s input formatter path. Those use `formatToParts` to read deterministic ISO components for `<input type="datetime-local">` and timezone math — not for display — so they keep the existing locale literal.
1 parent 2efd323 commit 049b8bc

16 files changed

Lines changed: 320 additions & 103 deletions

src/ui/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import DashboardHeader from './dashboard/DashboardHeader'
2828
import { fetchMeAvatarObjectUrl, handleUnauthorized } from './api'
2929
import { ThemeProvider } from "@/components/theme/theme-provider"
30+
import { LocaleProvider } from "@/components/locale/locale-provider"
3031
import { WindowDock } from "@/components/ui/window-dock"
3132
import { useConfirm } from "@/components/ui/confirm-dialog"
3233
import { LoginPage } from './LoginPage'
@@ -548,6 +549,7 @@ function App() {
548549

549550
return (
550551
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
552+
<LocaleProvider defaultLocale="auto" storageKey="vite-ui-locale">
551553
<div className="app">
552554
<main className="content">
553555
{!token ? (
@@ -611,6 +613,7 @@ function App() {
611613
</main>
612614
<WindowDock />
613615
</div>
616+
</LocaleProvider>
614617
</ThemeProvider>
615618
)
616619
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { createContext, useContext, useEffect, useMemo, useState } from "react"
2+
3+
type LocalePref = "auto" | string
4+
5+
type LocaleProviderProps = {
6+
children: React.ReactNode
7+
defaultLocale?: LocalePref
8+
storageKey?: string
9+
}
10+
11+
type LocaleProviderState = {
12+
locale: LocalePref
13+
setLocale: (locale: LocalePref) => void
14+
/** Resolved BCP-47 tag to pass to Intl APIs. For 'auto', equals `auto`. */
15+
resolved: string
16+
/** What 'auto' would resolve to right now, independent of the current selection. */
17+
auto: string
18+
}
19+
20+
function browserLocale(): string {
21+
if (typeof navigator === "undefined") return "en-US"
22+
return navigator.languages?.[0] || navigator.language || "en-US"
23+
}
24+
25+
const initialState: LocaleProviderState = {
26+
locale: "auto",
27+
setLocale: () => null,
28+
resolved: "en-US",
29+
auto: "en-US",
30+
}
31+
32+
const LocaleProviderContext = createContext<LocaleProviderState>(initialState)
33+
34+
export function LocaleProvider({
35+
children,
36+
defaultLocale = "auto",
37+
storageKey = "vite-ui-locale",
38+
...props
39+
}: LocaleProviderProps) {
40+
const [locale, setLocaleState] = useState<LocalePref>(
41+
() => (localStorage.getItem(storageKey) as LocalePref) || defaultLocale,
42+
)
43+
44+
useEffect(() => {
45+
localStorage.setItem(storageKey, locale)
46+
}, [locale, storageKey])
47+
48+
const value = useMemo<LocaleProviderState>(
49+
() => {
50+
const auto = browserLocale()
51+
return {
52+
locale,
53+
setLocale: setLocaleState,
54+
resolved: locale === "auto" ? auto : locale,
55+
auto,
56+
}
57+
},
58+
[locale],
59+
)
60+
61+
return (
62+
<LocaleProviderContext.Provider {...props} value={value}>
63+
{children}
64+
</LocaleProviderContext.Provider>
65+
)
66+
}
67+
68+
// eslint-disable-next-line react-refresh/only-export-components
69+
export const useLocale = () => {
70+
const context = useContext(LocaleProviderContext)
71+
if (context === undefined)
72+
throw new Error("useLocale must be used within a LocaleProvider")
73+
return context
74+
}

src/ui/src/dashboard/CallFlow.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { FlowItem } from './flow/FlowItem'
77
import type { FlowItemData, RawMessage } from './flow/flow-data'
88
import { buildFlow, buildCallIdLegend } from './flow/flow-data'
99
import { useFlowFilters } from './flow/useFlowFilters'
10+
import { useLocale } from '@/components/locale/locale-provider'
1011

1112
interface CallFlowProps {
1213
items: RawMessage[] | null | undefined
@@ -15,6 +16,7 @@ interface CallFlowProps {
1516
}
1617

1718
export default function CallFlow({ items, timeZone, onClickMessage }: CallFlowProps) {
19+
const { resolved: locale } = useLocale()
1820
const {
1921
filters,
2022
setFilters,
@@ -30,8 +32,9 @@ export default function CallFlow({ items, timeZone, onClickMessage }: CallFlowPr
3032
buildFlow(filteredItems, {
3133
timeZone,
3234
grouping: filters.hostGrouping,
35+
locale,
3336
}),
34-
[filteredItems, timeZone, filters.hostGrouping],
37+
[filteredItems, timeZone, filters.hostGrouping, locale],
3538
)
3639

3740
const callIds = useMemo(() => buildCallIdLegend(items), [items])

src/ui/src/dashboard/MessageModal.tsx

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { FloatingWindow } from '@/components/ui/floating-window'
77
import { ScrollArea } from '@/components/ui/scroll-area'
88
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
99
import { displayDstIp, displaySrcIp } from '@/lib/ipAliasDisplay'
10+
import { useLocale } from '@/components/locale/locale-provider'
1011

1112
function escapeHtml(str) {
1213
if (typeof str !== 'string') return str
@@ -158,30 +159,27 @@ function parseTimestampValue(value) {
158159
return null
159160
}
160161

161-
function formatDateTime(value, timeZone, dateOnly = false) {
162+
function formatDateTime(value, locale, timeZone, dateOnly = false) {
162163
const date = parseTimestampValue(value)
163164
if (!date) return value
164165
const options = {
165166
year: 'numeric',
166-
month: '2-digit',
167-
day: '2-digit',
167+
month: 'numeric',
168+
day: 'numeric',
168169
...(dateOnly
169170
? {}
170171
: {
171-
hour: '2-digit',
172-
minute: '2-digit',
173-
second: '2-digit',
172+
hour: 'numeric',
173+
minute: 'numeric',
174+
second: 'numeric',
174175
fractionalSecondDigits: 3,
175-
hour12: false,
176176
}),
177177
}
178-
const formatter = timeZone && timeZone !== 'local'
179-
? new Intl.DateTimeFormat('en-GB', { ...options, timeZone })
180-
: new Intl.DateTimeFormat('en-GB', options)
181-
return formatter.format(date).replace(',', '')
178+
if (timeZone && timeZone !== 'local') options.timeZone = timeZone
179+
return new Intl.DateTimeFormat(locale, options).format(date)
182180
}
183181

184-
function MetaGrid({ data, timeZone }) {
182+
function MetaGrid({ data, timeZone, locale }) {
185183
if (!data) return null
186184
return (
187185
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-[11px] sm:grid-cols-3">
@@ -190,9 +188,9 @@ function MetaGrid({ data, timeZone }) {
190188
const display = raw === undefined
191189
? '—'
192190
: field === 'timestamp'
193-
? formatDateTime(raw, timeZone)
191+
? formatDateTime(raw, locale, timeZone)
194192
: field === 'date'
195-
? formatDateTime(raw, timeZone, true)
193+
? formatDateTime(raw, locale, timeZone, true)
196194
: field === 'src_ip'
197195
? displaySrcIp(data)
198196
: field === 'dst_ip'
@@ -218,6 +216,7 @@ function MetaGrid({ data, timeZone }) {
218216
}
219217

220218
export default function MessageModal({ modal, onClose, timeZone }) {
219+
const { resolved: locale } = useLocale()
221220
const [decoded, setDecoded] = React.useState(null)
222221
const [decoding, setDecoding] = React.useState(false)
223222
const [decodeError, setDecodeError] = React.useState('')
@@ -286,7 +285,7 @@ export default function MessageModal({ modal, onClose, timeZone }) {
286285

287286
{!loading && !error && (
288287
<>
289-
<MetaGrid data={data} timeZone={timeZone} />
288+
<MetaGrid data={data} timeZone={timeZone} locale={locale} />
290289

291290
<div className="flex items-center justify-between">
292291
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">

src/ui/src/dashboard/OTLPLogRowModal.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { FloatingWindow } from '@/components/ui/floating-window'
44
import { ScrollArea } from '@/components/ui/scroll-area'
55
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
66
import { cn } from '@/lib/utils'
7+
import { useLocale } from '@/components/locale/locale-provider'
78

89
function escapeHtml(str) {
910
if (typeof str !== 'string') return str
@@ -79,23 +80,22 @@ function severityBadgeClass(label) {
7980
return 'border-border bg-card text-foreground'
8081
}
8182

82-
function formatTs(val, timeZone) {
83+
function formatTs(val, locale, timeZone) {
8384
if (!val) return '—'
8485
try {
8586
const d = val instanceof Date ? val : new Date(val)
8687
if (Number.isNaN(d.getTime())) return String(val)
8788
const opts = {
8889
year: 'numeric',
89-
month: '2-digit',
90-
day: '2-digit',
91-
hour: '2-digit',
92-
minute: '2-digit',
93-
second: '2-digit',
90+
month: 'numeric',
91+
day: 'numeric',
92+
hour: 'numeric',
93+
minute: 'numeric',
94+
second: 'numeric',
9495
fractionalSecondDigits: 3,
95-
hour12: false,
9696
}
9797
if (timeZone && timeZone !== 'local') opts.timeZone = timeZone
98-
return new Intl.DateTimeFormat('en-GB', opts).format(d).replace(',', '')
98+
return new Intl.DateTimeFormat(locale, opts).format(d)
9999
} catch {
100100
return String(val)
101101
}
@@ -120,6 +120,7 @@ const DETAIL_PRIORITY = [
120120
]
121121

122122
export default function OTLPLogRowModal({ modal, timeZone, onClose }) {
123+
const { resolved: locale } = useLocale()
123124
const { modalKey, row } = modal
124125
const [jsonTab, setJsonTab] = useState('details')
125126

@@ -134,7 +135,7 @@ export default function OTLPLogRowModal({ modal, timeZone, onClose }) {
134135
seen.add(k)
135136
const v = row[k]
136137
let display = v
137-
if (k === 'timestamp' || k === 'TIMESTAMP') display = formatTs(v, timeZone)
138+
if (k === 'timestamp' || k === 'TIMESTAMP') display = formatTs(v, locale, timeZone)
138139
else if (v != null && typeof v === 'object') display = JSON.stringify(v)
139140
else if (v != null) display = String(v)
140141
else display = '—'
@@ -159,7 +160,7 @@ export default function OTLPLogRowModal({ modal, timeZone, onClose }) {
159160
}
160161
const body = row?.body ?? row?.BODY ?? ''
161162
return { detailEntries: entries, jsonText: text, bodyText: String(body ?? '') }
162-
}, [row, timeZone])
163+
}, [row, timeZone, locale])
163164

164165
const traceId = row?.trace_id ?? row?.TRACE_ID ?? ''
165166
const shortTrace =
@@ -200,7 +201,7 @@ export default function OTLPLogRowModal({ modal, timeZone, onClose }) {
200201
{sev}
201202
</span>
202203
<span className="font-mono text-muted-foreground">
203-
{formatTs(row?.timestamp ?? row?.TIMESTAMP, timeZone)}
204+
{formatTs(row?.timestamp ?? row?.TIMESTAMP, locale, timeZone)}
204205
</span>
205206
</div>
206207
{bodyText ? (

src/ui/src/dashboard/OTLPLogsTraceModal.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
55
import { Alert, AlertDescription } from '@/components/ui/alert'
66
import { cn } from '@/lib/utils'
77
import OTLPLogRowModal from './OTLPLogRowModal'
8+
import { useLocale } from '@/components/locale/locale-provider'
89

910
/** OpenTelemetry SeverityNumber ranges (stable mapping when severity_text is empty). */
1011
function severityFromNumber(n) {
@@ -53,6 +54,7 @@ function rowTimestampMs(row) {
5354
}
5455

5556
export default function OTLPLogsTraceModal({ modal, timeZone, onClose }) {
57+
const { resolved: locale } = useLocale()
5658
const { modalKey, traceId, loading, items, error } = modal
5759
const [detailModal, setDetailModal] = useState(null)
5860

@@ -69,16 +71,15 @@ export default function OTLPLogsTraceModal({ modal, timeZone, onClose }) {
6971
if (Number.isNaN(d.getTime())) return String(val)
7072
const opts = {
7173
year: 'numeric',
72-
month: '2-digit',
73-
day: '2-digit',
74-
hour: '2-digit',
75-
minute: '2-digit',
76-
second: '2-digit',
74+
month: 'numeric',
75+
day: 'numeric',
76+
hour: 'numeric',
77+
minute: 'numeric',
78+
second: 'numeric',
7779
fractionalSecondDigits: 3,
78-
hour12: false,
7980
}
8081
if (timeZone && timeZone !== 'local') opts.timeZone = timeZone
81-
return new Intl.DateTimeFormat('en-GB', opts).format(d).replace(',', '')
82+
return new Intl.DateTimeFormat(locale, opts).format(d)
8283
} catch {
8384
return String(val)
8485
}

src/ui/src/dashboard/OTLPMetricsSeriesModal.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
normalizeOtlpMetricChartType,
2121
rowTimestampMs,
2222
} from './otlpMetricsSeriesChart'
23+
import { useLocale } from '@/components/locale/locale-provider'
2324

2425
function formatValue(row) {
2526
const vd = row?.value_double ?? row?.VALUE_DOUBLE
@@ -30,6 +31,7 @@ function formatValue(row) {
3031
}
3132

3233
export default function OTLPMetricsSeriesModal({ modal, timeZone, onClose }) {
34+
const { resolved: locale } = useLocale()
3335
const { modalKey, metricName, loading, items, error } = modal
3436
const chartElRef = useRef(null)
3537
const disposeRef = useRef(null)
@@ -75,16 +77,15 @@ export default function OTLPMetricsSeriesModal({ modal, timeZone, onClose }) {
7577
if (Number.isNaN(d.getTime())) return String(val)
7678
const opts = {
7779
year: 'numeric',
78-
month: '2-digit',
79-
day: '2-digit',
80-
hour: '2-digit',
81-
minute: '2-digit',
82-
second: '2-digit',
80+
month: 'numeric',
81+
day: 'numeric',
82+
hour: 'numeric',
83+
minute: 'numeric',
84+
second: 'numeric',
8385
fractionalSecondDigits: 3,
84-
hour12: false,
8586
}
8687
if (timeZone && timeZone !== 'local') opts.timeZone = timeZone
87-
return new Intl.DateTimeFormat('en-GB', opts).format(d).replace(',', '')
88+
return new Intl.DateTimeFormat(locale, opts).format(d)
8889
} catch {
8990
return String(val)
9091
}

src/ui/src/dashboard/OTLPTraceModal.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
77
import { Alert, AlertDescription } from '@/components/ui/alert'
88
import { Input } from '@/components/ui/input'
99
import { cn } from '@/lib/utils'
10+
import { useLocale } from '@/components/locale/locale-provider'
1011

1112
function isEmptyParentSpanId(v) {
1213
if (v == null) return true
@@ -351,6 +352,7 @@ function TraceMinimap({ items, traceMinMs, rangeMs, dark }) {
351352
}
352353

353354
export default function OTLPTraceModal({ modal, timeZone, onClose }) {
355+
const { resolved: locale } = useLocale()
354356
const { modalKey, traceId, loading, items, error } = modal
355357
const byParent = useMemo(() => buildSpanForest(items), [items])
356358
const [selected, setSelected] = useState(null)
@@ -432,16 +434,15 @@ export default function OTLPTraceModal({ modal, timeZone, onClose }) {
432434
const d = new Date(ms)
433435
const opts = {
434436
year: 'numeric',
435-
month: '2-digit',
436-
day: '2-digit',
437-
hour: '2-digit',
438-
minute: '2-digit',
439-
second: '2-digit',
437+
month: 'numeric',
438+
day: 'numeric',
439+
hour: 'numeric',
440+
minute: 'numeric',
441+
second: 'numeric',
440442
fractionalSecondDigits: 3,
441-
hour12: false,
442443
}
443444
if (timeZone && timeZone !== 'local') opts.timeZone = timeZone
444-
return new Intl.DateTimeFormat('en-GB', opts).format(d).replace(',', '')
445+
return new Intl.DateTimeFormat(locale, opts).format(d)
445446
} catch {
446447
return String(ms)
447448
}

0 commit comments

Comments
 (0)