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 { 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
|
||||
key={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>
|
||||
)
|
||||
})}
|
||||
{shown.map((field) => (
|
||||
<Reading
|
||||
key={field.kind}
|
||||
field={field}
|
||||
reading={recorded.get(field.kind)}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
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}
|
||||
stored={recorded.get(field.kind)}
|
||||
draft={drafts[field.kind] ?? ""}
|
||||
invalid={invalid.includes(field)}
|
||||
onChange={(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 { 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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user