fix(insights): relocate range controls below the chart, right-align timestamps, tighten sleep cards

Move the time-range pills + period-over-period delta out of the slot between
the stat strip and the chart and render them as a clean row below the chart on
every metric sub-page (the HealthKit scaffold plus weight, BMI, pulse, blood
pressure, sleep). The control owns its own persisted range pref and delta read,
and the chart owns its window via its in-card range tabs, so the relocation is
a pure DOM reorder that leaves the chart range untouched.

Right-align the last-updated timestamp on the period narrative ("Zeitraum im
Überblick") and the daily-briefing footer so the "updated" line matches the
status cards, which already route through the right-aligned footer.

Tighten the sleep average and hypnogram cards — drop the default card gap and
the headline card's extra padding that left tall empty bands — and give the
hypnogram clock-aligned X-axis ticks (whole hours on a 1–3 h step picked from
the night's span) so the time axis reads 23:00 / 01:00 / 03:00 / 05:00 instead
of raw bedtime/wake stamps.

Add a compact density to the short-horizon projection card for the metric
sub-pages: it sheds the min-height floor, shortens the chart, and surfaces how
many more days of tracking are needed before a projection can appear, drawn
from the coverage envelope. The Insights overview keeps the roomier default
geometry for grid symmetry.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 21:01:50 +02:00
1 parent 1c4578d2f2
commit 045da70133
17 files changed
+148 -39

No files matched your search

+1 -1
View File
@@ -325,7 +325,7 @@ export function DailyBriefing({
{updatedAt && (
<p
data-slot="daily-briefing-updated"
className="text-muted-foreground border-border/60 border-t pt-3 text-xs"
className="text-muted-foreground border-border/60 border-t pt-3 text-right text-xs"
>
{t("insights.heroGenerated", {
time: formatRelativeTime(updatedAt, t),
@@ -47,6 +47,16 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types";
const TRAJECTORY_METRIC = "TRAJECTORY";
/**
* Mirror of `TRAJECTORY_MIN_HISTORY_DAYS` (`src/lib/insights/derived/trajectory.ts`).
* Re-declared as a client-side literal rather than value-imported because the
* engine module pulls the rollup/baseline server graph in at its top level —
* importing the constant would drag that graph into the client bundle (the same
* reason `TrajectoryValue` is a type-only import). Keep this in lockstep with
* the engine; the unit test pins both at 14.
*/
const TRAJECTORY_MIN_HISTORY_DAYS = 14;
/** A single value-scale used to render stored values in the display unit. */
function scaled(value: number, scale: number): number {
return value * scale;
@@ -63,6 +73,14 @@ interface TrajectoryForecastCardProps {
color?: string;
/** Gate the underlying derived read (e.g. on the auth/empty flag). */
enabled?: boolean;
/**
* v1.12.0 — density by render context. Sub-pages pass `compact` so the
* card sheds the `min-h-48` floor + drops to a shorter chart and a tighter
* no-trend state (a too-tall "1 of 1" empty band was the maintainer flag).
* The Insights OVERVIEW grid omits it so the tile KEEPS its current size
* for grid symmetry — the default is the unchanged, roomier geometry.
*/
compact?: boolean;
className?: string;
}
@@ -96,16 +114,25 @@ function TrajectoryProvenance({
/** Card shell — uppercase label + provenance affordance + body children. */
function CardShell({
provenance,
compact = false,
children,
}: {
provenance?: DerivedProvenance;
compact?: boolean;
children: React.ReactNode;
}) {
const { t } = useTranslations();
return (
<div
data-slot="trajectory-forecast-card"
className="bg-card border-border flex min-h-48 w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
data-density={compact ? "compact" : "default"}
className={cn(
"bg-card border-border flex w-full min-w-0 flex-col gap-2 rounded-xl border",
// Overview keeps the roomy `min-h-48` + `p-4 md:p-6` geometry for grid
// symmetry; sub-pages drop the height floor and tighten the padding so
// the no-trend state stops opening a tall empty band.
compact ? "p-4" : "min-h-48 p-4 md:p-6",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
@@ -119,18 +146,21 @@ function CardShell({
}
/** CLS-safe skeleton matching the resolved card geometry. */
function CardSkeleton() {
function CardSkeleton({ compact = false }: { compact?: boolean }) {
return (
<div
data-slot="trajectory-forecast-card-skeleton"
aria-hidden="true"
className="bg-card border-border flex min-h-48 w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
className={cn(
"bg-card border-border flex w-full min-w-0 flex-col gap-2 rounded-xl border",
compact ? "p-4" : "min-h-48 p-4 md:p-6",
)}
>
<div className="flex h-11 items-center justify-between gap-2">
<div className="bg-muted/40 h-3 w-32 rounded" />
<div className="bg-muted/40 h-5 w-5 rounded-full" />
</div>
<div className="bg-muted/40 h-24 w-full rounded" />
<div className={cn("bg-muted/40 w-full rounded", compact ? "h-12" : "h-24")} />
</div>
);
}
@@ -141,6 +171,7 @@ export function TrajectoryForecastCard({
valueScale = 1,
color = "var(--chart-1)",
enabled = true,
compact = false,
className,
}: TrajectoryForecastCardProps) {
const { t } = useTranslations();
@@ -177,7 +208,7 @@ export function TrajectoryForecastCard({
if (!data) {
return (
<div className={className}>
<CardSkeleton />
<CardSkeleton compact={compact} />
</div>
);
}
@@ -185,15 +216,38 @@ export function TrajectoryForecastCard({
// Below the fit / history / staleness floor — calm "no trend to project"
// state + coverage, never a weak line.
if (data.status === "insufficient" || !chart) {
// v1.12.0 — surface how much history is still missing before a projection
// can appear, drawn from the same coverage envelope the meter reads. The
// gating floor is distinct-in-window days, so the hint only shows when the
// shortfall is a history-depth one (the dominant insufficient cause); when
// the shortfall is fit quality (enough days, weak R²) the days are already
// there and the generic copy stands on its own.
const daysNeeded = Math.max(
0,
TRAJECTORY_MIN_HISTORY_DAYS - Math.floor(data.coverage.historyDays),
);
return (
<div className={className}>
<CardShell provenance={data.provenance}>
<CardShell provenance={data.provenance} compact={compact}>
<p
className="text-muted-foreground text-sm"
className={cn(
"text-muted-foreground",
compact ? "text-xs leading-snug" : "text-sm",
)}
data-slot="trajectory-insufficient"
>
{t("insights.derived.trajectory.insufficient")}
</p>
{daysNeeded > 0 ? (
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="trajectory-days-needed"
>
{t("insights.derived.trajectory.daysNeeded", {
count: daysNeeded,
})}
</p>
) : null}
<CoverageMeter coverage={data.coverage} />
</CardShell>
</div>
@@ -218,7 +272,7 @@ export function TrajectoryForecastCard({
return (
<div className={className}>
<CardShell provenance={data.provenance}>
<CardShell provenance={data.provenance} compact={compact}>
<div className="flex items-start gap-2">
<DirectionIcon
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
@@ -245,7 +299,10 @@ export function TrajectoryForecastCard({
{/* The fan: a stacked transparent base + a shaded range Area, then
the dashed projected line. The shaded band visibly widens toward
the horizon — the uncertainty signal. */}
<div className="h-28 w-full" data-slot="trajectory-chart">
<div
className={cn("w-full", compact ? "h-20" : "h-28")}
data-slot="trajectory-chart"
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chart.points}
@@ -222,12 +222,6 @@ export function HealthKitMetricPage({
coachLaunch
showAllValuesType={measurementType}
>
{/* v1.9.0 — time-range pills + period-over-period delta, between the
stat strip and the chart. The selected range persists across metrics
via `useInsightsRangePref`.
v1.10.2 — extracted into the shared `<MetricRangeControls>` so the
bespoke metric sub-pages render the identical control. */}
<MetricRangeControls measurementType={measurementType} enabled={!isEmpty} />
<HealthChartDynamic
chartKey={chartKey}
types={[measurementType]}
@@ -240,6 +234,13 @@ export function HealthKitMetricPage({
userTimezone={user?.timezone}
valueScale={valueScale}
/>
{/* v1.12.0 — time-range pills + period-over-period delta, relocated
BELOW the chart (iOS-parity). The pills drive the period-over-period
delta read and persist across metrics via `useInsightsRangePref`;
the chart owns its own window via its in-card range tabs, so this
block sits as a clean bottom-of-card row rather than between the
stat strip and the chart. */}
<MetricRangeControls measurementType={measurementType} enabled={!isEmpty} />
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}
@@ -257,6 +258,7 @@ export function HealthKitMetricPage({
valueScale={valueScale}
color={color}
enabled={!isEmpty}
compact
/>
) : null}
</SubPageShell>
@@ -204,7 +204,7 @@ export function PeriodNarrativeCard({
)}
{narrative ? (
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-right text-[11px]">
{data?.revalidating
? t("insights.narrativeUpdating")
: t("insights.narrativeUpdated", {
+41 -7
View File
@@ -125,10 +125,40 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
});
}, [session.segments]);
const domain: [number, number] = [
new Date(session.start).getTime(),
new Date(session.end).getTime(),
];
const domain = useMemo<[number, number]>(
() => [
new Date(session.start).getTime(),
new Date(session.end).getTime(),
],
[session.start, session.end],
);
// v1.12.0 — explicit clock-aligned X-axis ticks. With `data={[]}` Recharts
// has no points to derive ticks from and would otherwise label the domain
// endpoints with raw bedtime/wake stamps (e.g. 23:14 / 06:02). Snap to whole
// clock hours and pick a 1–3 h step from the night's span so the axis reads
// 23:00 / 01:00 / 03:00 / 05:00 and stays mobile-legible (≈4–6 ticks).
const xTicks = useMemo(() => {
const [from, to] = domain;
const spanHours = (to - from) / 3_600_000;
if (!Number.isFinite(spanHours) || spanHours <= 0) return [];
// Aim for ~5 ticks: 1 h up to 5 h span, 2 h up to 10 h, else 3 h.
const stepHours = spanHours <= 5 ? 1 : spanHours <= 10 ? 2 : 3;
const stepMs = stepHours * 3_600_000;
// First whole hour at or after `from`, aligned to the step grid.
const firstHour = new Date(from);
firstHour.setMinutes(0, 0, 0);
if (firstHour.getTime() < from) firstHour.setTime(firstHour.getTime() + 3_600_000);
// Align the first tick to a multiple of the step (relative to midnight).
while ((firstHour.getHours() % stepHours) !== 0) {
firstHour.setTime(firstHour.getTime() + 3_600_000);
}
const ticks: number[] = [];
for (let t = firstHour.getTime(); t <= to; t += stepMs) {
ticks.push(t);
}
return ticks;
}, [domain]);
const timeFmt = useMemo(
() =>
@@ -167,8 +197,11 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
const hasBreakdown = breakdown.length > 0;
return (
<Card data-slot="sleep-hypnogram">
<CardHeader className="pb-2">
// v1.12.0 — card tightened: drop the default Card `gap-4 md:gap-6` to
// `gap-3` so the header sits closer to the timeline, reclaiming the empty
// band the maintainer flagged. The chart keeps its 200 px footprint.
<Card data-slot="sleep-hypnogram" className="gap-3 md:gap-3">
<CardHeader className="pb-0">
<div className="flex flex-col gap-0.5">
<CardTitle className="text-sm font-medium">
{t("insights.sleep.hypnogram.title")}
@@ -180,7 +213,7 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
</span>
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-3">
{!hasTrack && !hasBreakdown ? (
<p
className="text-muted-foreground py-8 text-center text-xs"
@@ -207,6 +240,7 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
dataKey="t"
domain={domain}
scale="time"
ticks={xTicks}
stroke="var(--muted-foreground)"
fontSize={10}
tickFormatter={(v: number) => timeFmt.format(new Date(v))}
+7 -3
View File
@@ -130,8 +130,12 @@ export function SleepOverview() {
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-2">
{/* v1.12.0 — headline card tightened: the default Card
`gap-4 md:gap-6` + `py-4 md:py-6` left a tall empty band around a
two-line readout. `gap-2 py-4 md:py-4` pulls the caption up under the
number without crowding it. */}
<Card className="gap-2 py-4 md:gap-2 md:py-4">
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Moon className="text-dracula-cyan h-4 w-4" />
<CardTitle className="text-sm font-medium">
@@ -140,7 +144,7 @@ export function SleepOverview() {
</div>
</CardHeader>
<CardContent>
<div className="space-y-1">
<div className="space-y-0.5">
<p className="text-2xl font-semibold">{primary}</p>
<p className="text-muted-foreground text-xs">
{sleepSummary?.count