Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 8 additions & 5 deletions frontend/src/components/NutritionCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -397,7 +399,7 @@ function ProteinPerKgChart({ periods }: { periods: NutritionPeriod[] }) {
<TipRow label="· per day" value={`${round(hovered.nutrition.protein_g)} g`} />
)}
{hovered.body.weight_avg != null && (
<TipRow label="· weight" value={`${hovered.body.weight_avg.toFixed(1)} kg`} />
<TipRow label="· weight" value={fmtWeight(hovered.body.weight_avg, units)} />
)}
<span className="tip-note">
{hovered.days_logged} of {hovered.days_in_period} days logged
Expand All @@ -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".
Expand Down Expand Up @@ -485,18 +488,18 @@ function WeeklyTable({ periods }: { periods: NutritionPeriod[] }) {
<span role="cell">{macros}</span>
<span role="cell">{p.protein_g_per_kg != null ? p.protein_g_per_kg.toFixed(2) : '—'}</span>
<span role="cell">
{p.body.weight_avg != null ? `${p.body.weight_avg.toFixed(1)} kg` : '—'}
{fmtWeight(p.body.weight_avg, units)}
{change != null && (
<span style={{ color: change < 0 ? 'var(--green)' : 'var(--text-3)' }}>
{' '}
{change > 0 ? '+' : ''}
{change.toFixed(1)}
{weightValue(change, units).toFixed(1)}
</span>
)}
</span>
<span role="cell" className="mono-meta">
{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) : '—'}`
: '—'}
</span>
</div>
Expand Down
92 changes: 85 additions & 7 deletions frontend/src/lib/format.ts
Original file line number Diff line number Diff line change
@@ -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']
Expand Down Expand Up @@ -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)
Expand Down
66 changes: 66 additions & 0 deletions frontend/src/lib/units.ts
Original file line number Diff line number Diff line change
@@ -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)
}
12 changes: 8 additions & 4 deletions frontend/src/screens/Health.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -275,7 +279,7 @@ function WeightChart({ days }: { days: HealthMetricsDay[] }) {
<div className="chart-card">
<div className="chart-head">
<SectionLabel>Weight</SectionLabel>
<span className="mono-meta">{last.toFixed(1)} kg</span>
<span className="mono-meta">{fmtWeight(last, units)}</span>
</div>
<div className="chart-hover" {...hover.bind}>
<svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none">
Expand All @@ -294,12 +298,12 @@ function WeightChart({ days }: { days: HealthMetricsDay[] }) {
{hovered && (
<>
<span className="tip-date">{fmtDay(hovered.date)}</span>
<TipRow color="var(--accent)" label="Weight" value={`${hovered.weight!.toFixed(1)} kg`} />
<TipRow color="var(--accent)" label="Weight" value={fmtWeight(hovered.weight, units)} />
{hovered.body_fat_percentage != null && (
<TipRow label="Body fat" value={`${hovered.body_fat_percentage.toFixed(1)} %`} />
)}
{hovered.lean_body_mass != null && (
<TipRow label="Lean mass" value={`${hovered.lean_body_mass.toFixed(1)} kg`} />
<TipRow label="Lean mass" value={fmtWeight(hovered.lean_body_mass, units)} />
)}
</>
)}
Expand Down
17 changes: 11 additions & 6 deletions frontend/src/screens/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -50,6 +53,7 @@ function latest<T>(days: HealthMetricsDay[] | undefined, pick: (d: HealthMetrics
}

function HealthTiles() {
const units = useUnits()
const start = toDateKey(addDays(new Date(), -14))
const { data, isSuccess } = useHealthMetrics(start)

Expand Down Expand Up @@ -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),
},
]
Expand Down Expand Up @@ -165,6 +169,7 @@ function NextUp() {
}

function RecentWorkouts() {
const units = useUnits()
const navigate = useNavigate()
const { data, isSuccess } = useWorkouts({ limit: 5 })

Expand All @@ -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 (
<button className="row-item" key={w.id} onClick={() => navigate(`/workouts/${w.id}`)}>
Expand Down
Loading
Loading