diff --git a/CHANGELOG.md b/CHANGELOG.md index cfd8596..b4346b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ System screen. authenticated `GET /api/config`, so the published image needs no rebuild. With neither set, behaviour is unchanged (and, until you add a key, still watermarked). +- **Metric / imperial display units in the dashboard.** Distance, pace and + weight can be shown in miles, min/mi and lb. Metric is the default; imperial + is opt-in under **Settings → Units**. The choice is stored per browser. + Display only: the API and the iOS app are untouched and still speak metric. + Splits stay 1 km splits (only their pace converts), and server-written + warning text on the plan screen is shown as written. ## [0.1.15] — 2026-09-20 diff --git a/frontend/src/components/NutritionCards.tsx b/frontend/src/components/NutritionCards.tsx index 9516e0d..2746e0b 100644 --- a/frontend/src/components/NutritionCards.tsx +++ b/frontend/src/components/NutritionCards.tsx @@ -18,7 +18,8 @@ import { ForkKnife } from '@phosphor-icons/react' import { ChartTooltip, Crosshair, GridLines, TipRow, useChartHover } from './charts' import { EmptyState, SectionLabel } from './ui' -import { fmtDay, todayKey } from '../lib/format' +import { fmtDay, fmtDistance, fmtWeight, todayKey, weightValue } from '../lib/format' +import { useUnits } from '../lib/units' import type { HealthMetricsDay, NutritionDay, NutritionPeriod } from '../lib/types' const MACROS = [ @@ -335,6 +336,7 @@ function EnergyBalanceChart({ } function ProteinPerKgChart({ periods }: { periods: NutritionPeriod[] }) { + const units = useUnits() // Every week in range, not a fixed 12: `periods` is already bounded by the // range toggle, and 52 dots on a line read fine where 52 bars would not. const withProtein = periods.filter((p) => p.protein_g_per_kg != null).slice(0, 52).reverse() @@ -397,7 +399,7 @@ function ProteinPerKgChart({ periods }: { periods: NutritionPeriod[] }) { )} {hovered.body.weight_avg != null && ( - + )} {hovered.days_logged} of {hovered.days_in_period} days logged @@ -418,6 +420,7 @@ function ProteinPerKgChart({ periods }: { periods: NutritionPeriod[] }) { /** The numbers behind the charts — and the table view the charts need. */ function WeeklyTable({ periods }: { periods: NutritionPeriod[] }) { + const units = useUnits() const withData = periods.filter((p) => p.days_logged > 0) // Capped for length, but the cap is stated rather than silent — a table that // quietly stops at 12 rows reads as "that is all the data there is". @@ -485,18 +488,18 @@ function WeeklyTable({ periods }: { periods: NutritionPeriod[] }) { {macros} {p.protein_g_per_kg != null ? p.protein_g_per_kg.toFixed(2) : '—'} - {p.body.weight_avg != null ? `${p.body.weight_avg.toFixed(1)} kg` : '—'} + {fmtWeight(p.body.weight_avg, units)} {change != null && ( {' '} {change > 0 ? '+' : ''} - {change.toFixed(1)} + {weightValue(change, units).toFixed(1)} )} {p.training.workouts > 0 - ? `${p.training.workouts}× · ${p.training.distance_km != null ? `${p.training.distance_km.toFixed(1)} km` : '—'}` + ? `${p.training.workouts}× · ${p.training.distance_km != null ? fmtDistance(p.training.distance_km * 1000, units, 1) : '—'}` : '—'} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 4b8b704..897468a 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -1,4 +1,13 @@ -/** Formatting helpers. Backend units: duration seconds, distance meters, energy kcal. */ +import type { Units } from './units' + +/** + * Formatting helpers. Backend units: duration seconds, distance meters, pace + * seconds-per-km, weight kg, energy kcal. The API stays metric; the helpers + * below that take `units` convert for display only. + */ + +const M_PER_MI = 1609.344 +const LB_PER_KG = 2.2046226218 const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'] const DOWS = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'] @@ -29,19 +38,88 @@ export function fmtHoursMinutes(seconds: number | null | undefined): string { return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}` } -export function fmtKm(meters: number | null | undefined, digits = 2): string { +export const distanceUnit = (units: Units) => (units === 'imperial' ? 'mi' : 'km') +export const paceUnit = (units: Units) => (units === 'imperial' ? '/mi' : '/km') +export const weightUnit = (units: Units) => (units === 'imperial' ? 'lb' : 'kg') + +/** Meters → km or mi, as a number (no unit, no rounding). */ +export function distanceValue(meters: number, units: Units): number { + return units === 'imperial' ? meters / M_PER_MI : meters / 1000 +} + +/** Kilometers (plan targets, weekly totals) → km or mi, rounded to one decimal. */ +export function kmDisplay(km: number, units: Units): number { + return Number(distanceValue(km * 1000, units).toFixed(1)) +} + +/** Short distance label for tight spots: "mi" or "k" (after the number). */ +export const distanceShortUnit = (units: Units) => (units === 'imperial' ? ' mi' : 'k') + +export function fmtDistance(meters: number | null | undefined, units: Units, digits = 2): string { if (meters == null || !Number.isFinite(meters)) return '—' - return `${(meters / 1000).toFixed(digits)} km` + return `${distanceValue(meters, units).toFixed(digits)} ${distanceUnit(units)}` +} + +/** km or mi → meters; null for anything else (meters included, which callers keep as is). */ +export function toMeters(value: number, unit: string | undefined): number | null { + switch (unit?.trim().toLowerCase()) { + case 'kilometers': + case 'kilometres': + case 'km': + return value * 1000 + case 'miles': + case 'mile': + case 'mi': + return value * M_PER_MI + default: + return null + } } -/** Pace from seconds-per-km → "6:29". */ -export function fmtPace(secPerKm: number | null | undefined): string { +/** A distance goal's value in meters; goals are authored in meters, km or miles. */ +export function goalMeters(value: number, unit: string | undefined): number { + return toMeters(value, unit) ?? value +} + +/** + * Converts a free-form `{target, unit}` from a plan goal when the unit is a + * known distance unit; anything else comes back unchanged. + */ +export function convertGoalTarget( + target: number, + unit: string, + units: Units, +): { target: number; unit: string } { + const meters = toMeters(target, unit) + if (meters == null) return { target, unit } + return { target: Number(distanceValue(meters, units).toFixed(1)), unit: distanceUnit(units) } +} + +/** Kilograms → kg or lb, as a number. */ +export function weightValue(kg: number, units: Units): number { + return units === 'imperial' ? kg * LB_PER_KG : kg +} + +export function fmtWeight(kg: number | null | undefined, units: Units, digits = 1): string { + if (kg == null || !Number.isFinite(kg)) return '—' + return `${weightValue(kg, units).toFixed(digits)} ${weightUnit(units)}` +} + +/** Pace from seconds-per-km → "6:29" per km, or per mile when imperial. */ +export function fmtPace(secPerKm: number | null | undefined, units: Units): string { if (secPerKm == null || !Number.isFinite(secPerKm) || secPerKm <= 0) return '—' - const m = Math.floor(secPerKm / 60) - const s = Math.round(secPerKm % 60) + const perUnit = units === 'imperial' ? secPerKm * (M_PER_MI / 1000) : secPerKm + const m = Math.floor(perUnit / 60) + const s = Math.round(perUnit % 60) return s === 60 ? `${m + 1}:00` : `${m}:${String(s).padStart(2, '0')}` } +/** Pace with its unit: "6:29 /km" or "10:26 /mi". */ +export function fmtPaceWithUnit(secPerKm: number | null | undefined, units: Units): string { + return `${fmtPace(secPerKm, units)} ${paceUnit(units)}` +} + +/** Seconds per km — always metric, convert with `fmtPace`. */ export function paceOf(durationSec: number | null | undefined, meters: number | null | undefined): number | null { if (!durationSec || !meters || meters < 100) return null return durationSec / (meters / 1000) diff --git a/frontend/src/lib/units.ts b/frontend/src/lib/units.ts new file mode 100644 index 0000000..40501cf --- /dev/null +++ b/frontend/src/lib/units.ts @@ -0,0 +1,66 @@ +import { useSyncExternalStore } from 'react' + +/** + * Display-unit preference (metric / imperial). Display only: the API always + * speaks metric and nothing is converted on the wire. + * + * Stored in localStorage under a `loopback.*` key like the auth bits in + * api.ts. Metric is the default: imperial is opt-in, never guessed from the + * browser locale. + */ +export type Units = 'metric' | 'imperial' + +const UNITS_KEY = 'loopback.units' + +// Only set when localStorage is unavailable, so the toggle still works for the session. +let fallback: Units | null = null + +function read(): Units { + if (fallback) return fallback + try { + const stored = localStorage.getItem(UNITS_KEY) + if (stored === 'metric' || stored === 'imperial') return stored + } catch { + // storage blocked (private mode, etc.) — fall through to the default + } + return 'metric' +} + +let current: Units = read() +const listeners = new Set<() => void>() + +function notify() { + listeners.forEach((fn) => fn()) +} + +// Another tab changing the preference fires `storage` here. +if (typeof window !== 'undefined') { + window.addEventListener('storage', (e) => { + if (e.key !== UNITS_KEY && e.key !== null) return + current = read() + notify() + }) +} + +function subscribe(fn: () => void): () => void { + listeners.add(fn) + return () => { + listeners.delete(fn) + } +} + +export function setUnits(units: Units) { + try { + localStorage.setItem(UNITS_KEY, units) + fallback = null + } catch { + fallback = units + } + current = read() + notify() +} + +/** Current preference; components re-render when it changes. */ +export function useUnits(): Units { + return useSyncExternalStore(subscribe, () => current) +} diff --git a/frontend/src/screens/Health.tsx b/frontend/src/screens/Health.tsx index 765c98b..654a5ef 100644 --- a/frontend/src/screens/Health.tsx +++ b/frontend/src/screens/Health.tsx @@ -13,8 +13,9 @@ import { } from '../components/charts' import { NutritionSection } from '../components/NutritionCards' import { EmptyState, ErrorNote, Loading, SectionLabel } from '../components/ui' -import { addDays, fmtDay, fmtHoursMinutes, toDateKey } from '../lib/format' +import { addDays, fmtDay, fmtHoursMinutes, fmtWeight, toDateKey } from '../lib/format' import { useHealthMetrics, useNutrition, useNutritionSummary } from '../lib/queries' +import { useUnits } from '../lib/units' import type { HealthMetricsDay } from '../lib/types' import '../styles/workouts.css' import '../styles/health.css' @@ -255,6 +256,9 @@ function SleepChart({ days, range }: { days: HealthMetricsDay[]; range: Range }) } function WeightChart({ days }: { days: HealthMetricsDay[] }) { + const units = useUnits() + // The line is auto-scaled with no axis labels, so it plots kg as sent; only + // the readouts convert. const values = days.map((d) => d.weight) const present = values.filter((v): v is number => v != null) // Hover resolves in the FULL day series — that is the axis the line is drawn @@ -275,7 +279,7 @@ function WeightChart({ days }: { days: HealthMetricsDay[] }) {
Weight - {last.toFixed(1)} kg + {fmtWeight(last, units)}
@@ -294,12 +298,12 @@ function WeightChart({ days }: { days: HealthMetricsDay[] }) { {hovered && ( <> {fmtDay(hovered.date)} - + {hovered.body_fat_percentage != null && ( )} {hovered.lean_body_mass != null && ( - + )} )} diff --git a/frontend/src/screens/Overview.tsx b/frontend/src/screens/Overview.tsx index 6b6d837..6774d63 100644 --- a/frontend/src/screens/Overview.tsx +++ b/frontend/src/screens/Overview.tsx @@ -21,13 +21,16 @@ import { fmtDowDayTime, fmtDuration, fmtHoursMinutes, - fmtKm, + fmtDistance, paceOf, - fmtPace, + fmtPaceWithUnit, + weightUnit, + weightValue, toDateKey, addDays, todayKey, } from '../lib/format' +import { useUnits } from '../lib/units' import { useFeedback, useHealthMetrics, @@ -50,6 +53,7 @@ function latest(days: HealthMetricsDay[] | undefined, pick: (d: HealthMetrics } function HealthTiles() { + const units = useUnits() const start = toDateKey(addDays(new Date(), -14)) const { data, isSuccess } = useHealthMetrics(start) @@ -94,8 +98,8 @@ function HealthTiles() { icon: Scales, color: 'var(--text)', label: 'Weight', - value: weight ? weight.value.toFixed(1) : '—', - unit: 'kg', + value: weight ? weightValue(weight.value, units).toFixed(1) : '—', + unit: weightUnit(units), sub: dayLabel(weight?.date), }, ] @@ -165,6 +169,7 @@ function NextUp() { } function RecentWorkouts() { + const units = useUnits() const navigate = useNavigate() const { data, isSuccess } = useWorkouts({ limit: 5 }) @@ -186,8 +191,8 @@ function RecentWorkouts() { const pace = w.activity_type === 'running' ? paceOf(w.duration, w.total_distance) : null const parts = [ fmtDay(w.start_date), - w.total_distance ? fmtKm(w.total_distance) : fmtDuration(w.duration), - pace ? `${fmtPace(pace)}/km` : null, + w.total_distance ? fmtDistance(w.total_distance, units) : fmtDuration(w.duration), + pace ? fmtPaceWithUnit(pace, units) : null, ].filter(Boolean) return (
))}
@@ -188,7 +202,7 @@ export function PlanDetail() { {guardrails.map((g, i) => (
- {fmtGoal(g)} + {fmtGoal(g, units)}
))} @@ -240,10 +254,10 @@ export function PlanDetail() { wk {fmtDay(wk.week_start)}
- {wk.planned_km} km planned - {wk.actual_km > 0 ? ` (+${wk.actual_km} done)` : ''} + {kmDisplay(wk.planned_km, units)} {distanceUnit(units)} planned + {wk.actual_km > 0 ? ` (+${kmDisplay(wk.actual_km, units)} done)` : ''} {wk.hard_days > 0 ? ` · ${wk.hard_days} hard` : ''} - {wk.longest_km > 0 ? ` · long ${wk.longest_km} km` : ''} + {wk.longest_km > 0 ? ` · long ${kmDisplay(wk.longest_km, units)} ${distanceUnit(units)}` : ''} {wk.estimated ? ' ≈' : ''} diff --git a/frontend/src/screens/Queue.tsx b/frontend/src/screens/Queue.tsx index 5af8bb6..dca32c3 100644 --- a/frontend/src/screens/Queue.tsx +++ b/frontend/src/screens/Queue.tsx @@ -11,7 +11,7 @@ import { StatusPill, } from '../components/ui' import { activityMeta } from '../lib/activity' -import { fmtDay, fmtDowDayTime, fmtDuration } from '../lib/format' +import { distanceShortUnit, distanceValue, fmtDay, fmtDowDayTime, fmtDuration, goalMeters } from '../lib/format' import { useAcknowledgeFeedback, useDeleteQueueItem, @@ -19,6 +19,7 @@ import { usePlans, useQueue, } from '../lib/queries' +import { type Units, useUnits } from '../lib/units' import type { CompositionStep, QueueItem, WorkoutComposition } from '../lib/types' import '../styles/queue.css' @@ -31,7 +32,7 @@ const REASON_LABELS: Record = { other: 'Other', } -function stepsOf(comp: WorkoutComposition): { label: string; flex: number }[] { +function stepsOf(comp: WorkoutComposition, units: Units): { label: string; flex: number }[] { const out: { label: string; flex: number }[] = [] const secondsOf = (s: CompositionStep | undefined) => { const g = s?.goal @@ -51,8 +52,8 @@ function stepsOf(comp: WorkoutComposition): { label: string; flex: number }[] { return `${name} ${fmtDuration(g.unit === 'minutes' ? g.value * 60 : g.value)}` } if (g?.type === 'distance' && g.value) { - const km = g.unit === 'kilometers' ? g.value : g.value / 1000 - return `${name} ${km.toFixed(km < 10 ? 1 : 0)}k` + const dist = distanceValue(goalMeters(g.value, g.unit), units) + return `${name} ${dist.toFixed(dist < 10 ? 1 : 0)}${distanceShortUnit(units)}` } return name } @@ -71,6 +72,7 @@ function stepsOf(comp: WorkoutComposition): { label: string; flex: number }[] { } export function Queue() { + const units = useUnits() const { data: queue, isLoading, error } = useQueue(undefined, 200) const { data: feedback } = useFeedback() const { data: plans } = usePlans() @@ -113,7 +115,7 @@ export function Queue() {
{(queue ?? []).map((q) => { const meta = activityMeta(q.activity_type) - const steps = q.workout_data ? stepsOf(q.workout_data) : [] + const steps = q.workout_data ? stepsOf(q.workout_data, units) : [] const metaParts = [ q.scheduled_date ? fmtDowDayTime(q.scheduled_date) : 'unscheduled', q.plan_id ? planName.get(q.plan_id) : null, diff --git a/frontend/src/screens/Settings.tsx b/frontend/src/screens/Settings.tsx index ee53620..e295d64 100644 --- a/frontend/src/screens/Settings.tsx +++ b/frontend/src/screens/Settings.tsx @@ -7,6 +7,7 @@ import { useAuth } from '../lib/auth' import { clientLabel, fmtDayYear, relTime } from '../lib/format' import { useChangePassword, useMe, useMintToken, useRevokeToken } from '../lib/queries' import type { ApiTokenInfo } from '../lib/types' +import { setUnits, useUnits } from '../lib/units' import '../styles/settings.css' const MIN_PASSWORD = 8 @@ -232,6 +233,7 @@ export function Settings() { const { user, tokenId, logout } = useAuth() const { data, isLoading, error } = useMe() const revoke = useRevokeToken() + const units = useUnits() const [revoking, setRevoking] = useState(null) const [confirmLogout, setConfirmLogout] = useState(false) const [changingPassword, setChangingPassword] = useState(false) @@ -262,6 +264,28 @@ export function Settings() {
+
+ Units +
+
+
+
+ Distance, pace & weight +
+
+ {units === 'imperial' ? 'miles, min/mi, lb' : 'kilometers, min/km, kg'} · this browser only +
+
+
+ + +
+
+
Devices & tokens