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 ? (
- {shown.map((field) => { - const Icon = field.icon - return ( -
-
- ) - })} + {shown.map((field) => ( + + ))}
) : ( - - No readings today - + )} ) } + +function Reading({ + field, + reading, +}: { + field: MetricField + reading: DailyMetricResponse | undefined +}) { + const Icon = field.icon + + return ( +
+
+ ) +} + +function Prompt() { + const Icon = METRIC_FIELDS[0].icon + + return ( +
+
+ ) +} 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 ( + + + +
+
+
+
+ + ) +} 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") + }) +})