Skip to content

Commit 8e92862

Browse files
atulmguptaCopilot
andcommitted
feat!: honor user display preferences across all UI surfaces
Phase A–H sweep: plumb user display preferences (units, currency, decimal precision, gas unit, range type, locale, time/date format) end-to-end through the entire frontend so user-facing values respect Settings consistently. Telemetry ingestion remains unchanged — this only affects how stored SI values are rendered. Highlights: - New/extended hooks: useFormatting (locale-aware currency + energy cost), useUnits (distance/speed/temperature/pressure/energy/power/ duration), useDateFormat, usePreferredRange, usePressureFormat. - New reusable Range format component and preferredRange helper. - FormatterPrefsBridge syncs settings.decimal_precision into fmtNumber/fmtInt/fmtPercent/fmtCompact/fmtWithUnit globals. - chargingAggregation accepts currencySymbol; messages use fmtNumber. - Removed Google Maps API Key field from Settings UI. - Migrated 100+ user-visible surfaces to honor prefs: charging cost/cards/charts/maps, dashboard widgets, year-review slides, weekly digest, fleet/lifetime/timeline/true-cost analytics, battery cells/projected range/sleep/energy, climate, maintenance, drive-detail/trip-planner/shared-drive, system status (uptime, SLO, Tesla API usage, scheduled maintenance, telemetry pipeline), alert/notification surfaces, signal viewer, vehicle hero/detail. - toFixed sweep on production numeric displays → fmtNumber/fmtPercent. useFormatting.formatCurrency(amount) and formatEnergyCost(kwh) now default to settings.decimal_precision instead of hardcoded 2. Callers passing an explicit decimals value are unaffected. Several i18n keys also changed to drop the hardcoded "$" prefix (currency symbol now embedded in the substituted value via formatCurrency) — affected keys live in SystemStatusPage templates and require translation re-keying for non-English locales. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9479ae4 commit 8e92862

129 files changed

Lines changed: 990 additions & 536 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

web/src/components/charts/SmallMultiplesChart.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { ChartTooltip } from '@/components/charts/ChartTooltip';
4444
import { CHART_COLORS } from '@/lib/colors';
4545
import { cn } from '@/lib/cn';
4646
import { useInView } from '@/hooks/useInView';
47+
import { useDateFormat } from '@/hooks/useDateFormat';
4748

4849
export interface SmallMultiplesChartProps<T extends Record<string, unknown> = Record<string, unknown>> {
4950
/** Time-ordered rows. Each row holds `timestamp` + arbitrary series keys. */
@@ -220,6 +221,7 @@ function SmallMultiplesCell({
220221
noData,
221222
onCellClick,
222223
}: SmallMultiplesCellProps) {
224+
const { formatTime } = useDateFormat();
223225
const { ref, inView } = useInView<HTMLDivElement>({ rootMargin: '300px' });
224226
const cellInteractive = Boolean(onCellClick);
225227
return (
@@ -288,12 +290,7 @@ function SmallMultiplesCell({
288290
<XAxis
289291
dataKey={xKey}
290292
tick={{ fill: 'var(--text-muted)', fontSize: 9 }}
291-
tickFormatter={(v: string) => {
292-
const d = new Date(v);
293-
return Number.isNaN(d.getTime())
294-
? String(v)
295-
: d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
296-
}}
293+
tickFormatter={(v: string) => formatTime(v)}
297294
minTickGap={24}
298295
tickLine={false}
299296
/>

web/src/components/data-display/DataFreshness.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { RefreshCw, Wifi, WifiOff } from 'lucide-react';
44
import type { UseQueryResult } from '@tanstack/react-query';
55
import { cn } from '@/lib/cn';
66
import { useMotionPreference } from '@/hooks/useMotionPreference';
7+
import { useDateFormat } from '@/hooks/useDateFormat';
78

89
/**
910
* `<DataFreshness>` — query-result-driven freshness chip.
@@ -119,6 +120,7 @@ export function DataFreshness({
119120
}: DataFreshnessProps) {
120121
const { t } = useTranslation();
121122
const { reduce } = useMotionPreference();
123+
const { formatTime } = useDateFormat();
122124
const [, setTick] = useState(0);
123125

124126
// Re-render every second to keep relative time accurate
@@ -167,7 +169,7 @@ export function DataFreshness({
167169
? t('freshness.updatingTooltip', 'Updating…')
168170
: updatedAt
169171
? t('freshness.lastUpdated', 'Last updated: {{time}}', {
170-
time: new Date(updatedAt).toLocaleTimeString(),
172+
time: formatTime(new Date(updatedAt)),
171173
})
172174
: t('freshness.neverUpdated', 'Never updated');
173175

web/src/components/data-display/InsightsEngine.test.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
1-
import { describe, it, expect } from 'vitest'
1+
import { describe, it, expect, vi } from 'vitest'
22
import { render, screen } from '@testing-library/react'
33
import { InsightsEngine } from './InsightsEngine'
44

5+
vi.mock('@/hooks/useFormatting', () => ({
6+
useFormatting: () => ({
7+
formatCurrency: (amount: number, decimals = 2) =>
8+
`$${Number(amount).toFixed(decimals)}`,
9+
formatEnergyCost: (kwh: number) => `$${(kwh * 0.12).toFixed(2)}`,
10+
currencySymbol: '$',
11+
costPerKwh: 0.12,
12+
costPerDistanceUnit: () => null,
13+
estimateGasCost: () => null,
14+
}),
15+
}))
16+
517
describe('InsightsEngine', () => {
618
it('renders nothing with empty data', () => {
719
const { container } = render(<InsightsEngine data={{}} />)

web/src/components/data-display/InsightsEngine.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { GlassPanel } from '@/components/ui'
99
import { FadeIn } from '@/components/motion'
1010
import { fmtNumber } from '@/lib/numberFormat'
11+
import { useFormatting } from '@/hooks/useFormatting'
1112
import { trendColor } from '@/lib/colors'
1213
import type {
1314
Drive, ChargingSession, EnergyStats, BatteryReport,
@@ -55,7 +56,7 @@ const TREND_ICON: Record<Trend, { Icon: React.ElementType; color: string }> = {
5556

5657
// ─── Analysis helpers ─────────────────────────────────────────
5758

58-
function analyzeChargingCost(sessions: ChargingSession[]): Insight | null {
59+
function analyzeChargingCost(sessions: ChargingSession[], formatCurrency: (amount: number, decimals?: number) => string): Insight | null {
5960
const withCost = sessions.filter(s => s.cost != null && s.charge_energy_added > 0)
6061
if (withCost.length < 2) return null
6162

@@ -72,7 +73,7 @@ function analyzeChargingCost(sessions: ChargingSession[]): Insight | null {
7273
const homeCost = home.length > 0 ? avgCost(home) : null
7374
const scCost = supercharger.length > 0 ? avgCost(supercharger) : null
7475

75-
let description = `Your average charging cost is $${fmtNumber(overall, 2)}/kWh.`
76+
let description = `Your average charging cost is ${formatCurrency(overall, 2)}/kWh.`
7677
let trend: Trend = 'neutral'
7778
let trendGood = true
7879

@@ -268,7 +269,7 @@ function analyzeDrivingPatterns(drives: Drive[]): Insight | null {
268269
}
269270
}
270271

271-
function analyzeCostSavings(energy: EnergyStats): Insight | null {
272+
function analyzeCostSavings(energy: EnergyStats, formatCurrency: (amount: number, decimals?: number) => string): Insight | null {
272273
if (energy.total_energy_used_kwh <= 0) return null
273274

274275
// Average gas car: 8.5 L/100km, avg gas price ~$1.50/L
@@ -282,7 +283,7 @@ function analyzeCostSavings(energy: EnergyStats): Insight | null {
282283
id: 'cost-savings',
283284
icon: Leaf,
284285
title: 'EV Cost Savings',
285-
description: `You've saved approximately $${fmtNumber(savings, 0)} vs. gasoline based on ${fmtNumber(energy.total_energy_used_kwh, 0)} kWh consumed over ${fmtNumber(energy.total_distance_km, 0)} km. That's also ${fmtNumber(energy.co2_saved_kg, 0)} kg of CO₂ saved!`,
286+
description: `You've saved approximately ${formatCurrency(savings, 0)} vs. gasoline based on ${fmtNumber(energy.total_energy_used_kwh, 0)} kWh consumed over ${fmtNumber(energy.total_distance_km, 0)} km. That's also ${fmtNumber(energy.co2_saved_kg, 0)} kg of CO₂ saved!`,
286287
trend: 'up',
287288
trendGood: true,
288289
severity: 'success',
@@ -319,11 +320,12 @@ function analyzeRangeOptimization(energy: EnergyStats, battery?: BatteryReport):
319320
// ─── Main component ───────────────────────────────────────────
320321

321322
export function InsightsEngine({ data }: { data: InsightData }) {
323+
const { formatCurrency } = useFormatting()
322324
const insights = useMemo(() => {
323325
const results: Insight[] = []
324326

325327
if (data.chargingSessions?.length) {
326-
const c = analyzeChargingCost(data.chargingSessions)
328+
const c = analyzeChargingCost(data.chargingSessions, formatCurrency)
327329
if (c) results.push(c)
328330
}
329331
if (data.drives?.length) {
@@ -347,7 +349,7 @@ export function InsightsEngine({ data }: { data: InsightData }) {
347349
if (p) results.push(p)
348350
}
349351
if (data.energyStats) {
350-
const s = analyzeCostSavings(data.energyStats)
352+
const s = analyzeCostSavings(data.energyStats, formatCurrency)
351353
if (s) results.push(s)
352354
}
353355
if (data.energyStats) {
@@ -356,7 +358,7 @@ export function InsightsEngine({ data }: { data: InsightData }) {
356358
}
357359

358360
return results
359-
}, [data])
361+
}, [data, formatCurrency])
360362

361363
if (insights.length === 0) return null
362364

web/src/components/data-display/TimeStamp.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Tooltip } from '@/components/ui';
2-
import { formatDateTime, formatRelative } from '@/lib/dateFormat';
2+
import { useDateFormat } from '@/hooks/useDateFormat';
33
import { useTimeFormatPreference } from '@/hooks/useTimeFormatPreference';
4+
import type { TzMode } from '@/lib/timezone';
45

56
export type TimeStampFormat = 'relative' | 'absolute' | 'auto';
67

@@ -13,6 +14,14 @@ export interface TimeStampProps {
1314
* 'absolute' overrides it for a specific surface.
1415
*/
1516
format?: TimeStampFormat;
17+
/**
18+
* Optional timezone-display mode override. When unset the component
19+
* defaults to `settings.tz_display_default` ('vehicle' out of the
20+
* box). Passing `'utc'` is useful for forensic/audit surfaces that
21+
* must always render in UTC regardless of the user's preference.
22+
* Mirrors the `in` prop on `<DateTime>`.
23+
*/
24+
in?: TzMode;
1625
className?: string;
1726
}
1827

@@ -23,11 +32,16 @@ export interface TimeStampProps {
2332
* global Settings preference. The tooltip always shows the OTHER format
2433
* so power users can flip between perspectives without leaving the page.
2534
*
35+
* Honors `settings.locale` and the resolved IANA timezone (defaults to
36+
* `settings.tz_display_default`, overridable via the `in` prop) when
37+
* formatting both the visible body and the tooltip alternate.
38+
*
2639
* Renders the universal "—" placeholder (no tooltip) when `value` is
2740
* null, undefined, or an unparseable timestamp. (Phase-45 / Prompt 22.)
2841
*/
29-
export function TimeStamp({ value, format = 'auto', className }: TimeStampProps) {
42+
export function TimeStamp({ value, format = 'auto', in: mode, className }: TimeStampProps) {
3043
const pref = useTimeFormatPreference();
44+
const { formatDateTime, formatRelative } = useDateFormat(mode);
3145

3246
if (value == null) {
3347
return <span className={className}></span>;

web/src/components/data-display/__tests__/DataFreshness.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ import {
66
FRESHNESS_COLORS,
77
type FreshnessQuery,
88
} from '../DataFreshness'
9+
import {
10+
formatDate,
11+
formatDateTime,
12+
formatTime,
13+
formatDateShort,
14+
formatDateWithDay,
15+
formatRelative,
16+
formatRelativeTime,
17+
formatRelativeDays,
18+
} from '@/lib/dateFormat'
919

1020
vi.mock('react-i18next', () => ({
1121
useTranslation: () => ({
@@ -28,6 +38,25 @@ vi.mock('framer-motion', () => ({
2838
useReducedMotion: () => reducedMotionMock(),
2939
}))
3040

41+
// `<DataFreshness>` reads locale + tz via `useDateFormat()` for its
42+
// `title` attribute. Mock the hook so tests don't need a TanStack Query
43+
// provider or a Router (both required by `useSettings` + `useTimezone`).
44+
vi.mock('@/hooks/useDateFormat', () => ({
45+
useDateFormat: () => ({
46+
opts: {},
47+
tz: 'UTC',
48+
locale: 'en-US',
49+
formatDate,
50+
formatDateTime,
51+
formatTime,
52+
formatDateShort,
53+
formatDateWithDay,
54+
formatRelative,
55+
formatRelativeTime,
56+
formatRelativeDays,
57+
}),
58+
}))
59+
3160
describe('FRESHNESS_COLORS', () => {
3261
it('exposes a dot + text color tier for every status', () => {
3362
expect(FRESHNESS_COLORS.fresh.dot).toBe('bg-emerald-400')

web/src/components/data-display/__tests__/TimeStamp.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,42 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
33
import { render } from '@testing-library/react';
44
import { TimeStamp } from '../TimeStamp';
55
import { useTimeFormatPreference } from '@/hooks/useTimeFormatPreference';
6+
import {
7+
formatDate,
8+
formatDateTime,
9+
formatTime,
10+
formatDateShort,
11+
formatDateWithDay,
12+
formatRelative,
13+
formatRelativeTime,
14+
formatRelativeDays,
15+
} from '@/lib/dateFormat';
616

717
vi.mock('@/hooks/useTimeFormatPreference', () => ({
818
useTimeFormatPreference: vi.fn(),
919
}));
1020

21+
// `<TimeStamp>` now reads locale + tz via `useDateFormat()`, which in turn
22+
// subscribes to `useSettings()` (TanStack Query) and `useTimezone()` (router).
23+
// The component's behavior under test is the relative/absolute branching
24+
// and the tooltip swap, NOT the locale/tz plumbing — so we mock the hook
25+
// to return the pure lib helpers and keep the test render-tree minimal.
26+
vi.mock('@/hooks/useDateFormat', () => ({
27+
useDateFormat: () => ({
28+
opts: {},
29+
tz: 'UTC',
30+
locale: 'en-US',
31+
formatDate,
32+
formatDateTime,
33+
formatTime,
34+
formatDateShort,
35+
formatDateWithDay,
36+
formatRelative,
37+
formatRelativeTime,
38+
formatRelativeDays,
39+
}),
40+
}));
41+
1142
const mockedPref = vi.mocked(useTimeFormatPreference);
1243

1344
beforeEach(() => {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { useTranslation } from 'react-i18next'
2+
import { useUnits } from '@/hooks/useUnits'
3+
import { usePreferredRange, type PreferredRangeFields } from '@/hooks/usePreferredRange'
4+
5+
interface RangeProps {
6+
/** Vehicle/charge state snapshot with `rated_range` + `ideal_range` in SI metres. */
7+
state: PreferredRangeFields | null | undefined
8+
/** Optional decimal precision override for the value. */
9+
precision?: number
10+
className?: string
11+
}
12+
13+
/**
14+
* Reusable "primary range" renderer that respects both the user's
15+
* distance-unit preference (km vs mi via `useUnits`) and the user's
16+
* `preferred_range` preference (rated vs ideal via `usePreferredRange`).
17+
*
18+
* Use on surfaces that show "the range" generically — Glance, vehicle
19+
* list cards, fleet summary, charge status, the dashboard hero. Do NOT
20+
* use on explicit comparison surfaces (RangeBarWidget,
21+
* RangeEstimateWidget, BatteryRangePanel) which render BOTH ranges
22+
* side-by-side regardless of preference.
23+
*/
24+
export function Range({ state, precision = 0, className }: RangeProps) {
25+
const { formatDistance } = useUnits()
26+
const { meters } = usePreferredRange(state)
27+
28+
if (meters == null) return <span className={className}></span>
29+
30+
return <span className={className}>{formatDistance(meters, { precision })}</span>
31+
}
32+
33+
/**
34+
* Companion hook returning the localized "Rated Range" / "Ideal Range"
35+
* label honoring the user's `preferred_range` preference. Use when you
36+
* need the label separate from the value — e.g. inside a stat tile that
37+
* renders the label and value in different elements.
38+
*/
39+
export function useRangeLabel(state: PreferredRangeFields | null | undefined): string {
40+
const { t } = useTranslation()
41+
const { labelKey, defaultLabel } = usePreferredRange(state)
42+
return t(`common.${labelKey}`, defaultLabel)
43+
}
44+

web/src/components/data-display/format/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export { Percentage } from './Percentage';
1313
export { FormattedNumber } from './Number';
1414
export { Duration } from './Duration';
1515
export type { DurationVariant } from './Duration';
16+
export { Range, useRangeLabel } from './Range';

web/src/components/data-display/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,5 +108,7 @@ export {
108108
Percentage,
109109
FormattedNumber,
110110
Duration,
111+
Range,
112+
useRangeLabel,
111113
} from './format';
112114
export type { DateTimeVariant, DurationVariant } from './format';

0 commit comments

Comments
 (0)