spa: restore metrics on dashboard; it was the only route to the metrics page
Some checks failed
CI / ci (push) Failing after 1m42s

hiding the card when nothing was recorded made /metrics unreachable, so an
account with no readings could never enter its first one. always shown now,
with today's readings or a prompt. second entry point on stats.

also: metric inputs had no label association, so all eight announced as
'Not recorded' (the placeholder). minutes read as 7h 12m, not 432.
This commit is contained in:
2026-08-28 15:11:03 +02:00
parent bf148902ab
commit 128e66abda
8 changed files with 160 additions and 60 deletions

View File

@@ -2,20 +2,25 @@ import { Link } from "@tanstack/react-router"
import { ChevronRight } from "lucide-react" import { ChevronRight } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { METRIC_FIELDS } from "@/components/metric/metric-fields" import { METRIC_FIELDS } from "@/components/metric/metric-fields"
import { byKind, useDailyMetrics, useTracksMetrics } from "@/hooks/use-metrics" import { formatMetric } from "@/components/metric/metric-value"
import type { MetricField } from "@/components/metric/metric-fields"
import { byKind, useDailyMetrics } from "@/hooks/use-metrics"
import { today } from "@/lib/day" import { today } from "@/lib/day"
import type { DailyMetricResponse } from "@/api/schema"
const MOST_SHOWN = 3 const MOST_SHOWN = 3
/** Today's readings, for accounts that record them. */ /**
* Today's readings, and the way in to record them. Always present: this is the
* only route to the metrics screen, so hiding it when nothing is recorded
* leaves an account that has never entered a reading unable to enter its
* first one.
*/
export function MetricsCard() { export function MetricsCard() {
const day = today() const day = today()
const tracks = useTracksMetrics()
const { data } = useDailyMetrics(day, day) const { data } = useDailyMetrics(day, day)
const recorded = byKind(data) const recorded = byKind(data)
if (!tracks) return null
const shown = METRIC_FIELDS.filter((field) => recorded.has(field.kind)).slice( const shown = METRIC_FIELDS.filter((field) => recorded.has(field.kind)).slice(
0, 0,
MOST_SHOWN MOST_SHOWN
@@ -23,35 +28,20 @@ export function MetricsCard() {
return ( return (
<Link to="/metrics" aria-label="Daily metrics"> <Link to="/metrics" aria-label="Daily metrics">
<Card> <Card className="transition-colors hover:bg-muted/50">
<CardContent className="flex items-center justify-between gap-3 py-4"> <CardContent className="flex items-center justify-between gap-3 py-4">
{shown.length ? ( {shown.length ? (
<div className="flex flex-1 items-center justify-around"> <div className="flex flex-1 items-center justify-around">
{shown.map((field) => { {shown.map((field) => (
const Icon = field.icon <Reading
return ( key={field.kind}
<div field={field}
key={field.kind} reading={recorded.get(field.kind)}
className="flex flex-col items-center gap-1" />
> ))}
<Icon
className="h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
<span className="text-sm font-semibold tabular-nums">
{recorded.get(field.kind)?.value.toLocaleString()}
</span>
<span className="text-[10px] text-muted-foreground">
{field.label}
</span>
</div>
)
})}
</div> </div>
) : ( ) : (
<span className="text-sm text-muted-foreground"> <Prompt />
No readings today
</span>
)} )}
<ChevronRight <ChevronRight
className="h-4 w-4 shrink-0 text-muted-foreground" className="h-4 w-4 shrink-0 text-muted-foreground"
@@ -62,3 +52,34 @@ export function MetricsCard() {
</Link> </Link>
) )
} }
function Reading({
field,
reading,
}: {
field: MetricField
reading: DailyMetricResponse | undefined
}) {
const Icon = field.icon
return (
<div className="flex flex-col items-center gap-1">
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<span className="text-sm font-semibold tabular-nums">
{reading ? formatMetric(field, reading.value) : "—"}
</span>
<span className="text-[10px] text-muted-foreground">{field.label}</span>
</div>
)
}
function Prompt() {
const Icon = METRIC_FIELDS[0].icon
return (
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<span className="text-sm">Add today's metrics</span>
</div>
)
}

View File

@@ -7,39 +7,49 @@ type MetricRowProps = {
field: MetricField field: MetricField
stored: DailyMetricResponse | undefined stored: DailyMetricResponse | undefined
draft: string draft: string
invalid?: boolean
onChange: (draft: string) => void onChange: (draft: string) => void
} }
export function MetricRow({ field, stored, draft, onChange }: MetricRowProps) { export function MetricRow({
field,
stored,
draft,
invalid = false,
onChange,
}: MetricRowProps) {
const Icon = field.icon const Icon = field.icon
// Ties the label and the note to the field. Without the first of these the
// only name the input had was its placeholder, so every one of them
// announced itself as "Not recorded".
const inputId = `metric-${field.kind}`
const noteId = `${inputId}-note`
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="flex items-center gap-2 text-xs"> <Label htmlFor={inputId} className="flex items-center gap-2 text-xs">
<Icon className="h-3.5 w-3.5 text-muted-foreground" /> <Icon className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
{field.label} {field.label}
<span className="text-muted-foreground">({field.unit})</span> <span className="text-muted-foreground">({field.unit})</span>
</Label> </Label>
<Input <Input
id={inputId}
type="number" type="number"
inputMode="numeric" inputMode="numeric"
min={field.min} min={field.min}
max={field.max} max={field.max}
placeholder="Not recorded" placeholder="Not recorded"
aria-invalid={invalid || undefined}
aria-describedby={stored ? noteId : undefined}
value={draft} value={draft}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
/> />
{stored?.provider ? ( {stored && (
<p className="text-xs text-muted-foreground"> <p id={noteId} className="text-xs text-muted-foreground">
Reported by {stored.provider}. Saving replaces it with your own; empty {stored.provider
the field to clear it, and a later import may report it again. ? `Reported by ${stored.provider}. Saving replaces it with your own; empty the field to clear it, and a later import may report it again.`
: "Entered by you. Empty the field to clear it."}
</p> </p>
) : (
stored && (
<p className="text-xs text-muted-foreground">
Entered by you. Empty the field to clear it.
</p>
)
)} )}
</div> </div>
) )

View File

@@ -0,0 +1,19 @@
import type { MetricField } from "./metric-fields"
const MINUTES_PER_HOUR = 60
/**
* A reading as it should be read back. "432" under a heading of "Sleep" says
* very little; "7h 12m" says the thing itself.
*/
export function formatMetric(field: MetricField, value: number): string {
if (field.unit !== "minutes") return value.toLocaleString()
const hours = Math.floor(value / MINUTES_PER_HOUR)
const minutes = value % MINUTES_PER_HOUR
if (hours === 0) return `${minutes}m`
if (minutes === 0) return `${hours}h`
return `${hours}h ${minutes}m`
}

View File

@@ -81,6 +81,7 @@ export function MetricsEditor({ date, stored }: MetricsEditorProps) {
field={field} field={field}
stored={recorded.get(field.kind)} stored={recorded.get(field.kind)}
draft={drafts[field.kind] ?? ""} draft={drafts[field.kind] ?? ""}
invalid={invalid.includes(field)}
onChange={(draft) => onChange={(draft) =>
setDrafts((current) => ({ ...current, [field.kind]: draft })) setDrafts((current) => ({ ...current, [field.kind]: draft }))
} }

View File

@@ -0,0 +1,35 @@
import { Link } from "@tanstack/react-router"
import { ChevronRight, LineChart } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
/**
* The second way in to daily metrics. They belong with the statistics — and a
* single entry point on the dashboard once meant that an account with nothing
* recorded could not reach the screen at all.
*/
export function MetricsLink() {
return (
<Link to="/metrics">
<Card className="transition-colors hover:bg-muted/50">
<CardContent className="flex items-center justify-between gap-3 py-4">
<div className="flex items-center gap-3">
<LineChart
className="h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
<div className="flex flex-col">
<span className="text-sm">Daily metrics</span>
<span className="text-xs text-muted-foreground">
Steps, sleep, heart rate and the rest
</span>
</div>
</div>
<ChevronRight
className="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</CardContent>
</Card>
</Link>
)
}

View File

@@ -1,7 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { subDays } from "date-fns"
import { metrics } from "@/api/client" import { metrics } from "@/api/client"
import { formatDate } from "@/lib/day"
import type { import type {
DailyMetricResponse, DailyMetricResponse,
MetricKind, MetricKind,
@@ -33,19 +31,3 @@ export function byKind(
): Map<MetricKind, DailyMetricResponse> { ): Map<MetricKind, DailyMetricResponse> {
return new Map(stored?.map((metric) => [metric.kind, metric]) ?? []) return new Map(stored?.map((metric) => [metric.kind, metric]) ?? [])
} }
const DAYS_THAT_MAKE_A_HABIT = 30
/**
* Whether this account records daily metrics at all. Someone who has never
* entered one should not be shown a card asking about their steps.
*/
export function useTracksMetrics(): boolean {
const today = new Date()
const { data } = useDailyMetrics(
formatDate(subDays(today, DAYS_THAT_MAKE_A_HABIT)),
formatDate(today)
)
return (data?.length ?? 0) > 0
}

View File

@@ -7,6 +7,7 @@ import { MoodDistribution } from "@/components/stats/mood-distribution"
import { MoodCorrelations } from "@/components/stats/mood-correlations" import { MoodCorrelations } from "@/components/stats/mood-correlations"
import { MoodSummary } from "@/components/stats/mood-summary" import { MoodSummary } from "@/components/stats/mood-summary"
import { HeatmapSection } from "@/components/stats/heatmap-section" import { HeatmapSection } from "@/components/stats/heatmap-section"
import { MetricsLink } from "@/components/stats/metrics-link"
import { useMoodStats } from "@/hooks/use-stats" import { useMoodStats } from "@/hooks/use-stats"
import { useTrend } from "@/hooks/use-trend" import { useTrend } from "@/hooks/use-trend"
import { PERIODS, PERIOD_LABELS } from "@/lib/period" import { PERIODS, PERIOD_LABELS } from "@/lib/period"
@@ -60,6 +61,8 @@ function StatsPage() {
) : null} ) : null}
<HeatmapSection /> <HeatmapSection />
<MetricsLink />
</div> </div>
) )
} }

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest"
import { METRIC_FIELDS } from "@/components/metric/metric-fields"
import { formatMetric } from "@/components/metric/metric-value"
const field = (kind: string) =>
METRIC_FIELDS.find((each) => each.kind === kind)!
describe("reading a metric back", () => {
it("reads minutes as hours and minutes", () => {
expect(formatMetric(field("sleepMinutes"), 432)).toBe("7h 12m")
})
it("drops the hours when there are none", () => {
expect(formatMetric(field("exerciseMinutes"), 45)).toBe("45m")
})
it("drops the minutes when the hour is whole", () => {
expect(formatMetric(field("sleepMinutes"), 480)).toBe("8h")
})
it("shows zero minutes as zero, not blank", () => {
expect(formatMetric(field("awakeMinutes"), 0)).toBe("0m")
})
it("leaves counts alone but groups them", () => {
expect(formatMetric(field("steps"), 8432)).toBe((8432).toLocaleString())
expect(formatMetric(field("restingHeartRate"), 58)).toBe("58")
})
})