mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path. Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container. Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app. The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
150 lines
4.8 KiB
TypeScript
150 lines
4.8 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { Moon } from "lucide-react";
|
|
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
|
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { EmptyState } from "@/components/ui/empty-state";
|
|
import {
|
|
SleepStageStackedBar,
|
|
type SleepStageBreakdown,
|
|
} from "./sleep-stage-stacked-bar";
|
|
import { SleepDurationChart } from "./sleep-duration-chart";
|
|
|
|
/**
|
|
* v1.4.25 W4c — Sleep insights composition.
|
|
*
|
|
* Pulls the analytics `sleepStages` aggregate (added in v1.4.23 W2)
|
|
* plus the SLEEP_DURATION trend series via `<SleepDurationChart>` and
|
|
* renders three blocks:
|
|
*
|
|
* 1. Headline card with the average nightly total (h / min) and the
|
|
* number of nights covered.
|
|
* 2. Stage composition stacked bar (REM / Deep / Core / Awake / In
|
|
* bed / Asleep).
|
|
* 3. Duration trend chart with chart-cog parity (`chartKey="sleep"`).
|
|
*
|
|
* Empty state: no SLEEP_DURATION rows yet → render the "Apple Health
|
|
* sync is coming in v1.5" message with a deep-link to Settings →
|
|
* Devices so the user can prepare. We do NOT render any of the three
|
|
* blocks in that case — half-rendered empty cards would be worse than
|
|
* a single clean CTA.
|
|
*/
|
|
|
|
interface SleepAnalyticsSummary {
|
|
latest: number | null;
|
|
avg7: number | null;
|
|
avg30: number | null;
|
|
count: number;
|
|
}
|
|
|
|
interface SleepAnalyticsResponse {
|
|
summaries: {
|
|
SLEEP_DURATION?: SleepAnalyticsSummary | null;
|
|
};
|
|
sleepStages: SleepStageBreakdown | null;
|
|
}
|
|
|
|
function formatHoursMinutes(
|
|
totalMinutes: number,
|
|
locale: string,
|
|
): { primary: string } {
|
|
const hours = Math.floor(totalMinutes / 60);
|
|
const mins = Math.round(totalMinutes - hours * 60);
|
|
if (locale === "de") {
|
|
return { primary: `${hours} Std. ${mins} Min.` };
|
|
}
|
|
return { primary: `${hours}h ${mins}m` };
|
|
}
|
|
|
|
export function SleepOverview() {
|
|
const { isAuthenticated, user } = useAuth();
|
|
const { t, locale } = useTranslations();
|
|
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
|
|
|
|
// v1.4.33 IW2 — sleep overview reads `sleepStages` (a thick-only
|
|
// field) alongside `summaries.SLEEP_DURATION`, so it stays on the
|
|
// default thick slice. The shared hook still centralises the cache
|
|
// settings (60s staleTime, no refetch on mount / window focus) so
|
|
// the consumer behaves identically to every other analytics mount.
|
|
const analyticsQuery = useAnalyticsQuery();
|
|
const data = analyticsQuery.data as SleepAnalyticsResponse | undefined;
|
|
const isLoading = analyticsQuery.isLoading;
|
|
|
|
const sleepSummary = data?.summaries?.SLEEP_DURATION ?? null;
|
|
const sleepStages = data?.sleepStages ?? null;
|
|
const totalCount = sleepSummary?.count ?? 0;
|
|
|
|
if (!isLoading && totalCount === 0) {
|
|
return (
|
|
<EmptyState
|
|
icon={<Moon className="size-6" />}
|
|
title={t("insights.sleep.empty.title")}
|
|
description={t("insights.sleep.empty.description")}
|
|
action={
|
|
<Button size="sm" variant="outline" asChild>
|
|
<Link href="/settings/devices">
|
|
{t("insights.sleep.empty.action")}
|
|
</Link>
|
|
</Button>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const averageMinutes = sleepSummary?.avg30 ?? sleepSummary?.avg7 ?? null;
|
|
const suffix = t("insights.sleep.headlineCaptionSuffix");
|
|
const { primary } =
|
|
averageMinutes != null
|
|
? formatHoursMinutes(averageMinutes, locale)
|
|
: { primary: "—" };
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center gap-2">
|
|
<Moon className="text-dracula-cyan h-4 w-4" />
|
|
<CardTitle className="text-sm font-medium">
|
|
{t("insights.sleep.headlineTitle")}
|
|
</CardTitle>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-1">
|
|
<p className="text-2xl font-semibold">{primary}</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
{sleepSummary?.count
|
|
? t("insights.sleep.headlineCaption", {
|
|
count: sleepSummary.count,
|
|
suffix,
|
|
})
|
|
: suffix}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{sleepStages ? (
|
|
<SleepStageStackedBar breakdown={sleepStages} />
|
|
) : (
|
|
<Card>
|
|
<CardContent className="text-muted-foreground py-6 text-sm">
|
|
{t("insights.sleep.stages.unavailable")}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<SleepDurationChart
|
|
compareBaseline={compareBaseline}
|
|
userTimezone={user?.timezone}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|