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 { Card, CardContent } from "@/components/ui/card"
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 type { DailyMetricResponse } from "@/api/schema"
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() {
const day = today()
const tracks = useTracksMetrics()
const { data } = useDailyMetrics(day, day)
const recorded = byKind(data)
if (!tracks) return null
const shown = METRIC_FIELDS.filter((field) => recorded.has(field.kind)).slice(
0,
MOST_SHOWN
@@ -23,35 +28,20 @@ export function MetricsCard() {
return (
<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">
{shown.length ? (
<div className="flex flex-1 items-center justify-around">
{shown.map((field) => {
const Icon = field.icon
return (
<div
{shown.map((field) => (
<Reading
key={field.kind}
className="flex flex-col items-center gap-1"
>
<Icon
className="h-4 w-4 text-muted-foreground"
aria-hidden="true"
field={field}
reading={recorded.get(field.kind)}
/>
<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>
) : (
<span className="text-sm text-muted-foreground">
No readings today
</span>
<Prompt />
)}
<ChevronRight
className="h-4 w-4 shrink-0 text-muted-foreground"
@@ -62,3 +52,34 @@ export function MetricsCard() {
</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
stored: DailyMetricResponse | undefined
draft: string
invalid?: boolean
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
// 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 (
<div className="flex flex-col gap-1.5">
<Label className="flex items-center gap-2 text-xs">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<Label htmlFor={inputId} className="flex items-center gap-2 text-xs">
<Icon className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
{field.label}
<span className="text-muted-foreground">({field.unit})</span>
</Label>
<Input
id={inputId}
type="number"
inputMode="numeric"
min={field.min}
max={field.max}
placeholder="Not recorded"
aria-invalid={invalid || undefined}
aria-describedby={stored ? noteId : undefined}
value={draft}
onChange={(event) => onChange(event.target.value)}
/>
{stored?.provider ? (
<p className="text-xs text-muted-foreground">
Reported by {stored.provider}. Saving replaces it with your own; empty
the field to clear it, and a later import may report it again.
{stored && (
<p id={noteId} className="text-xs text-muted-foreground">
{stored.provider
? `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>
) : (
stored && (
<p className="text-xs text-muted-foreground">
Entered by you. Empty the field to clear it.
</p>
)
)}
</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}
stored={recorded.get(field.kind)}
draft={drafts[field.kind] ?? ""}
invalid={invalid.includes(field)}
onChange={(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 { subDays } from "date-fns"
import { metrics } from "@/api/client"
import { formatDate } from "@/lib/day"
import type {
DailyMetricResponse,
MetricKind,
@@ -33,19 +31,3 @@ export function byKind(
): Map<MetricKind, DailyMetricResponse> {
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 { MoodSummary } from "@/components/stats/mood-summary"
import { HeatmapSection } from "@/components/stats/heatmap-section"
import { MetricsLink } from "@/components/stats/metrics-link"
import { useMoodStats } from "@/hooks/use-stats"
import { useTrend } from "@/hooks/use-trend"
import { PERIODS, PERIOD_LABELS } from "@/lib/period"
@@ -60,6 +61,8 @@ function StatsPage() {
) : null}
<HeatmapSection />
<MetricsLink />
</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")
})
})