diff --git a/spa/src/components/dashboard/metrics-card.tsx b/spa/src/components/dashboard/metrics-card.tsx
index d4bc776..e72a68e 100644
--- a/spa/src/components/dashboard/metrics-card.tsx
+++ b/spa/src/components/dashboard/metrics-card.tsx
@@ -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 (
-
+
{shown.length ? (
+ )
+}
diff --git a/spa/src/components/metric/metric-row.tsx b/spa/src/components/metric/metric-row.tsx
index 7a8c6db..88b65ae 100644
--- a/spa/src/components/metric/metric-row.tsx
+++ b/spa/src/components/metric/metric-row.tsx
@@ -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 (
-
)
diff --git a/spa/src/components/metric/metric-value.ts b/spa/src/components/metric/metric-value.ts
new file mode 100644
index 0000000..9d6a10a
--- /dev/null
+++ b/spa/src/components/metric/metric-value.ts
@@ -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`
+}
diff --git a/spa/src/components/metric/metrics-editor.tsx b/spa/src/components/metric/metrics-editor.tsx
index 3cf6b2a..9a6052c 100644
--- a/spa/src/components/metric/metrics-editor.tsx
+++ b/spa/src/components/metric/metrics-editor.tsx
@@ -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 }))
}
diff --git a/spa/src/components/stats/metrics-link.tsx b/spa/src/components/stats/metrics-link.tsx
new file mode 100644
index 0000000..b6f9a38
--- /dev/null
+++ b/spa/src/components/stats/metrics-link.tsx
@@ -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 (
+
+
+
+
+
+
+ Daily metrics
+
+ Steps, sleep, heart rate and the rest
+
+
+
+
+
+
+
+ )
+}
diff --git a/spa/src/hooks/use-metrics.ts b/spa/src/hooks/use-metrics.ts
index eecc2db..17aa773 100644
--- a/spa/src/hooks/use-metrics.ts
+++ b/spa/src/hooks/use-metrics.ts
@@ -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 {
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
-}
diff --git a/spa/src/routes/_auth/stats.lazy.tsx b/spa/src/routes/_auth/stats.lazy.tsx
index cce17d4..22a0c91 100644
--- a/spa/src/routes/_auth/stats.lazy.tsx
+++ b/spa/src/routes/_auth/stats.lazy.tsx
@@ -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}
+
+
)
}
diff --git a/spa/tests/components/metric-value.test.ts b/spa/tests/components/metric-value.test.ts
new file mode 100644
index 0000000..dcac2f4
--- /dev/null
+++ b/spa/tests/components/metric-value.test.ts
@@ -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")
+ })
+})