mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
1c4578d2f2
commit
045da70133
17 files changed
+148
-39
No files matched your search
+2
-1
@@ -2751,7 +2751,8 @@
|
||||
"headline": "Wenn dieses Muster anhält — die nächsten {days} Tage",
|
||||
"range": "Projiziert auf rund {low}–{high} {unit} in {days} Tagen.",
|
||||
"caveat": "Eine Fortschreibung deines eigenen jüngsten Trends, keine Vorhersage. Das schattierte Band wird breiter und zeigt, wie schnell die Unsicherheit wächst.",
|
||||
"insufficient": "Noch kein klar genuger Trend für eine Projektion — erfasse weiter, dann erscheint eine Projektion, sobald das jüngste Muster deutlich genug ist."
|
||||
"insufficient": "Noch kein klar genuger Trend für eine Projektion — erfasse weiter, dann erscheint eine Projektion, sobald das jüngste Muster deutlich genug ist.",
|
||||
"daysNeeded": "Noch etwa {count} Tage Erfassung nötig, bis eine Projektion erscheinen kann."
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
+2
-1
@@ -2751,7 +2751,8 @@
|
||||
"headline": "If this pattern continues — the next {days} days",
|
||||
"range": "Projected around {low}–{high} {unit} in {days} days.",
|
||||
"caveat": "A projection of your own recent trend, not a prediction. The shaded band widens to show how quickly the uncertainty grows.",
|
||||
"insufficient": "Not enough of a clear trend to project yet — keep tracking and a projection will appear once the recent pattern is strong enough."
|
||||
"insufficient": "Not enough of a clear trend to project yet — keep tracking and a projection will appear once the recent pattern is strong enough.",
|
||||
"daysNeeded": "About {count} more days of tracking needed before a projection can appear."
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
+2
-1
@@ -2751,7 +2751,8 @@
|
||||
"headline": "Si este patrón continúa: los próximos {days} días",
|
||||
"range": "Proyectado en torno a {low}–{high} {unit} en {days} días.",
|
||||
"caveat": "Una proyección de tu propia tendencia reciente, no una predicción. La banda sombreada se ensancha para mostrar la rapidez con que crece la incertidumbre.",
|
||||
"insufficient": "Aún no hay una tendencia lo bastante clara para proyectar: sigue registrando y aparecerá una proyección cuando el patrón reciente sea lo bastante marcado."
|
||||
"insufficient": "Aún no hay una tendencia lo bastante clara para proyectar: sigue registrando y aparecerá una proyección cuando el patrón reciente sea lo bastante marcado.",
|
||||
"daysNeeded": "Faltan unos {count} días más de registro para que pueda aparecer una proyección."
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
+2
-1
@@ -2751,7 +2751,8 @@
|
||||
"headline": "Si ce schéma se poursuit — les {days} prochains jours",
|
||||
"range": "Projeté autour de {low}–{high} {unit} dans {days} jours.",
|
||||
"caveat": "Une projection de votre propre tendance récente, pas une prédiction. La bande ombrée s’élargit pour montrer la vitesse à laquelle l’incertitude augmente.",
|
||||
"insufficient": "Pas encore de tendance assez nette pour projeter — continuez à enregistrer et une projection apparaîtra dès que le schéma récent sera suffisamment marqué."
|
||||
"insufficient": "Pas encore de tendance assez nette pour projeter — continuez à enregistrer et une projection apparaîtra dès que le schéma récent sera suffisamment marqué.",
|
||||
"daysNeeded": "Encore environ {count} jours d’enregistrement nécessaires avant qu’une projection puisse apparaître."
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
+2
-1
@@ -2751,7 +2751,8 @@
|
||||
"headline": "Se questo schema continua — i prossimi {days} giorni",
|
||||
"range": "Proiettato intorno a {low}–{high} {unit} tra {days} giorni.",
|
||||
"caveat": "Una proiezione della tua tendenza recente, non una previsione. La banda ombreggiata si allarga per mostrare quanto rapidamente cresce l’incertezza.",
|
||||
"insufficient": "Non c’è ancora una tendenza abbastanza chiara da proiettare — continua a registrare e comparirà una proiezione quando lo schema recente sarà abbastanza marcato."
|
||||
"insufficient": "Non c’è ancora una tendenza abbastanza chiara da proiettare — continua a registrare e comparirà una proiezione quando lo schema recente sarà abbastanza marcato.",
|
||||
"daysNeeded": "Servono ancora circa {count} giorni di registrazione prima che possa comparire una proiezione."
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
+2
-1
@@ -2751,7 +2751,8 @@
|
||||
"headline": "Jeśli ten wzorzec się utrzyma — następne {days} dni",
|
||||
"range": "Rzutowane na około {low}–{high} {unit} za {days} dni.",
|
||||
"caveat": "Rzut Twojego własnego ostatniego trendu, nie prognoza. Zacieniony pas poszerza się, pokazując, jak szybko rośnie niepewność.",
|
||||
"insufficient": "Jeszcze brak wystarczająco wyraźnego trendu do rzutowania — rejestruj dalej, a rzut pojawi się, gdy ostatni wzorzec będzie dość wyraźny."
|
||||
"insufficient": "Jeszcze brak wystarczająco wyraźnego trendu do rzutowania — rejestruj dalej, a rzut pojawi się, gdy ostatni wzorzec będzie dość wyraźny.",
|
||||
"daysNeeded": "Potrzeba jeszcze około {count} dni rejestrowania, zanim pojawi się rzut."
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
@@ -118,7 +118,6 @@ export default function InsightsBlutdruckPage() {
|
||||
>
|
||||
{/* The period-over-period delta keys on the systolic series — the
|
||||
canonical BP figure the targets + status surfaces lead with. */}
|
||||
<MetricRangeControls measurementType="BLOOD_PRESSURE_SYS" enabled={!isEmpty} />
|
||||
<HealthChartDynamic
|
||||
chartKey="bp"
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
@@ -130,6 +129,8 @@ export default function InsightsBlutdruckPage() {
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
|
||||
<MetricRangeControls measurementType="BLOOD_PRESSURE_SYS" enabled={!isEmpty} />
|
||||
|
||||
<MetricTargetSummary slug="blood-pressure" />
|
||||
|
||||
|
||||
@@ -121,7 +121,6 @@ export default function InsightsBmiPage() {
|
||||
page's controls.
|
||||
BMI is derived from WEIGHT, so the period-over-period delta keys on
|
||||
the underlying weight series the chart reuses. */}
|
||||
<MetricRangeControls measurementType="WEIGHT" enabled={!isEmpty} />
|
||||
<HealthChartDynamic
|
||||
chartKey="bmi"
|
||||
types={["WEIGHT"]}
|
||||
@@ -133,6 +132,8 @@ export default function InsightsBmiPage() {
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
|
||||
<MetricRangeControls measurementType="WEIGHT" enabled={!isEmpty} />
|
||||
|
||||
<MetricTargetSummary slug="bmi" />
|
||||
|
||||
|
||||
@@ -132,7 +132,6 @@ export default function InsightsPulsPage() {
|
||||
coachLaunch
|
||||
showAllValuesType="PULSE"
|
||||
>
|
||||
<MetricRangeControls measurementType="PULSE" enabled={!isEmpty} />
|
||||
<HealthChartDynamic
|
||||
chartKey="pulse"
|
||||
types={["PULSE"]}
|
||||
@@ -143,6 +142,8 @@ export default function InsightsPulsPage() {
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
|
||||
<MetricRangeControls measurementType="PULSE" enabled={!isEmpty} />
|
||||
|
||||
<MetricTargetSummary slug="pulse" />
|
||||
|
||||
|
||||
@@ -80,10 +80,11 @@ export default function InsightsSchlafPage() {
|
||||
min / max / median / mean strip would duplicate it and read in an
|
||||
awkward unit. */
|
||||
>
|
||||
<MetricRangeControls measurementType="SLEEP_DURATION" enabled={!isEmpty} />
|
||||
|
||||
<SleepOverview />
|
||||
|
||||
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
|
||||
<MetricRangeControls measurementType="SLEEP_DURATION" enabled={!isEmpty} />
|
||||
|
||||
<MetricTargetSummary slug="sleep" />
|
||||
|
||||
{/*
|
||||
|
||||
@@ -90,7 +90,6 @@ export default function InsightsGewichtPage() {
|
||||
coachLaunch
|
||||
showAllValuesType="WEIGHT"
|
||||
>
|
||||
<MetricRangeControls measurementType="WEIGHT" enabled={!isEmpty} />
|
||||
<HealthChartDynamic
|
||||
chartKey="weight"
|
||||
types={["WEIGHT"]}
|
||||
@@ -101,6 +100,8 @@ export default function InsightsGewichtPage() {
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
|
||||
<MetricRangeControls measurementType="WEIGHT" enabled={!isEmpty} />
|
||||
|
||||
<MetricTargetSummary slug="weight" />
|
||||
|
||||
@@ -109,6 +110,7 @@ export default function InsightsGewichtPage() {
|
||||
unit="kg"
|
||||
color="#bd93f9"
|
||||
enabled={!isEmpty}
|
||||
compact
|
||||
/>
|
||||
|
||||
<InsightStatusCard
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user