spa: restore metrics on dashboard; it was the only route to the metrics page
Some checks failed
CI / ci (push) Failing after 1m42s
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:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
19
spa/src/components/metric/metric-value.ts
Normal file
19
spa/src/components/metric/metric-value.ts
Normal 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`
|
||||||
|
}
|
||||||
@@ -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 }))
|
||||||
}
|
}
|
||||||
|
|||||||
35
spa/src/components/stats/metrics-link.tsx
Normal file
35
spa/src/components/stats/metrics-link.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
29
spa/tests/components/metric-value.test.ts
Normal file
29
spa/tests/components/metric-value.test.ts
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user