mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(insights): add a Recovery sub-page for the device-native strain scores
WHOOP and Polar record day strain, workout strain, ANS charge, cardio load, and the day's whole-cycle average / max heart rate and kilojoule energy expenditure — all stored end-to-end with no render host. The composite recovery score had only its anatomy detail at /insights/scores/recovery; the raw signals around it appeared nowhere. Add /insights/recovery as the home for them, grouped into recharge, strain & load, and the day's heart & energy. Each block is data-gated, so an account without a strap or ring lands on a calm empty note rather than a broken surface. When a recovery score is present the page cross-links to its anatomy view rather than duplicating the ring. Server-authoritative throughout. Give the page a single nav home alongside Workouts in the unified destination model so it is discoverable on both the sidebar and the mobile More hub.
This commit is contained in:
1 parent
e063f79353
commit
866f3dc0e2
3 files changed
+272
No files matched your search
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { RecoverySection } from "@/components/insights/recovery/recovery-section";
|
||||
|
||||
/**
|
||||
* v1.17.1 — `/insights/recovery`.
|
||||
*
|
||||
* The render host for the WHOOP / Polar device-native recovery + strain scores
|
||||
* that were stored end-to-end but had no page: day strain, workout strain, ANS
|
||||
* charge, cardio load, and the day's whole-cycle average / max heart rate and
|
||||
* kilojoule energy expenditure. The composite RECOVERY_SCORE keeps its anatomy
|
||||
* detail at `/insights/scores/recovery`; this page cross-links to it and
|
||||
* surrounds it with the raw signals it never surfaced.
|
||||
*
|
||||
* Every block is data-gated inside `<RecoverySection>`, so an account without a
|
||||
* strap / ring sees only the calm empty note. Server-authoritative: the tiles
|
||||
* render stored values, never a recompute.
|
||||
*/
|
||||
export default function InsightsRecoveryPage() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.recovery.title")}
|
||||
description={t("insights.recovery.description")}
|
||||
explainerMetric="recoveryPage"
|
||||
coachLaunch
|
||||
>
|
||||
<RecoverySection />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Activity,
|
||||
Battery,
|
||||
Flame,
|
||||
Gauge,
|
||||
Heart,
|
||||
HeartPulse,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
|
||||
|
||||
interface TileSpec {
|
||||
type: string;
|
||||
title: string;
|
||||
icon: typeof Activity;
|
||||
color: string;
|
||||
unit?: string;
|
||||
fractionDigits?: number;
|
||||
}
|
||||
|
||||
interface TileGroup {
|
||||
key: string;
|
||||
headingIcon: typeof Activity;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
tiles: TileSpec[];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.1 — body of the `/insights/recovery` sub-page.
|
||||
*
|
||||
* The home for the WHOOP / Polar device-native recovery + strain scores that
|
||||
* were ingested but had no render host: day strain, workout strain, ANS charge,
|
||||
* cardio load, plus the day's whole-cycle average / max heart rate and the
|
||||
* kilojoule energy-expenditure figure. The composite RECOVERY_SCORE keeps its
|
||||
* own anatomy view at `/insights/scores/recovery`; this page cross-links to it
|
||||
* rather than duplicating the ring, and surrounds it with the raw device
|
||||
* signals it never showed.
|
||||
*
|
||||
* Each tile is data-gated (`<DeviceScoreTile>` returns null at count 0) and
|
||||
* each group hides when none of its tiles has data, so a non-wearable account
|
||||
* sees only the calm empty state below. Reads the shared analytics summaries
|
||||
* slice (no extra round-trip); server-authoritative throughout.
|
||||
*/
|
||||
export function RecoverySection() {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useInsightsAnalytics("SLEEP_DURATION");
|
||||
|
||||
const summaries = data?.summaries;
|
||||
|
||||
const groups: TileGroup[] = [
|
||||
{
|
||||
key: "recharge",
|
||||
headingIcon: Battery,
|
||||
title: t("insights.recovery.recharge.title"),
|
||||
subtitle: t("insights.recovery.recharge.subtitle"),
|
||||
tiles: [
|
||||
{
|
||||
type: "ANS_CHARGE",
|
||||
title: t("measurements.typeAnsCharge"),
|
||||
icon: Battery,
|
||||
color: "#50fa7b",
|
||||
unit: t("insights.deviceScore.unitScore"),
|
||||
fractionDigits: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "strain",
|
||||
headingIcon: Gauge,
|
||||
title: t("insights.recovery.strain.title"),
|
||||
subtitle: t("insights.recovery.strain.subtitle"),
|
||||
tiles: [
|
||||
{
|
||||
type: "DAY_STRAIN",
|
||||
title: t("measurements.typeDayStrain"),
|
||||
icon: Gauge,
|
||||
color: "#ffb86c",
|
||||
unit: t("insights.deviceScore.unitScore"),
|
||||
fractionDigits: 1,
|
||||
},
|
||||
{
|
||||
type: "WORKOUT_STRAIN",
|
||||
title: t("measurements.typeWorkoutStrain"),
|
||||
icon: Activity,
|
||||
color: "#ff79c6",
|
||||
unit: t("insights.deviceScore.unitScore"),
|
||||
fractionDigits: 1,
|
||||
},
|
||||
{
|
||||
type: "CARDIO_LOAD",
|
||||
title: t("measurements.typeCardioLoad"),
|
||||
icon: TrendingUp,
|
||||
color: "#bd93f9",
|
||||
unit: t("insights.deviceScore.unitScore"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "cardio",
|
||||
headingIcon: HeartPulse,
|
||||
title: t("insights.recovery.cardio.title"),
|
||||
subtitle: t("insights.recovery.cardio.subtitle"),
|
||||
tiles: [
|
||||
{
|
||||
type: "AVERAGE_HEART_RATE",
|
||||
title: t("measurements.typeAverageHeartRate"),
|
||||
icon: Heart,
|
||||
color: "#8be9fd",
|
||||
unit: t("insights.deviceScore.unitBpm"),
|
||||
},
|
||||
{
|
||||
type: "MAX_HEART_RATE",
|
||||
title: t("measurements.typeMaxHeartRate"),
|
||||
icon: HeartPulse,
|
||||
color: "#ff5555",
|
||||
unit: t("insights.deviceScore.unitBpm"),
|
||||
},
|
||||
{
|
||||
type: "ENERGY_EXPENDITURE_KJ",
|
||||
title: t("measurements.typeEnergyExpenditureKj"),
|
||||
icon: Flame,
|
||||
color: "#f1fa8c",
|
||||
unit: t("insights.deviceScore.unitKj"),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const hasAny =
|
||||
summaries != null &&
|
||||
groups.some((group) =>
|
||||
group.tiles.some((tile) => (summaries[tile.type]?.count ?? 0) > 0),
|
||||
);
|
||||
|
||||
const hasRecoveryScore = (summaries?.RECOVERY_SCORE?.count ?? 0) > 0;
|
||||
|
||||
if (!isLoading && summaries != null && !hasAny && !hasRecoveryScore) {
|
||||
return (
|
||||
<div
|
||||
data-slot="recovery-empty"
|
||||
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
|
||||
>
|
||||
{t("insights.recovery.empty")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8" data-slot="recovery-section">
|
||||
{hasRecoveryScore ? (
|
||||
<Link
|
||||
href="/insights/scores/recovery"
|
||||
data-slot="recovery-score-link"
|
||||
className="bg-card border-border hover:bg-accent/40 flex items-center justify-between gap-3 rounded-xl border p-4 transition-colors"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Heart
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">
|
||||
{t("insights.recovery.scoreLink.title")}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t("insights.recovery.scoreLink.subtitle")}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{t("insights.recovery.scoreLink.cta")}
|
||||
</span>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{groups.map((group) => {
|
||||
const present =
|
||||
summaries == null
|
||||
? []
|
||||
: group.tiles.filter(
|
||||
(tile) => (summaries[tile.type]?.count ?? 0) > 0,
|
||||
);
|
||||
if (present.length === 0) return null;
|
||||
return (
|
||||
<section
|
||||
key={group.key}
|
||||
data-slot={`recovery-group-${group.key}`}
|
||||
className="space-y-3"
|
||||
>
|
||||
<SectionHeading
|
||||
icon={group.headingIcon}
|
||||
title={group.title}
|
||||
action={
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{group.subtitle}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{present.map((tile) => (
|
||||
<DeviceScoreTile
|
||||
key={tile.type}
|
||||
type={tile.type}
|
||||
summary={summaries?.[tile.type]}
|
||||
title={tile.title}
|
||||
icon={tile.icon}
|
||||
color={tile.color}
|
||||
unit={tile.unit}
|
||||
fractionDigits={tile.fractionDigits}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Droplets,
|
||||
Dumbbell,
|
||||
FlaskConical,
|
||||
HeartPulse,
|
||||
Home,
|
||||
Lightbulb,
|
||||
MessagesSquare,
|
||||
@@ -82,6 +83,17 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
icon: Dumbbell,
|
||||
tourId: "nav-workouts",
|
||||
},
|
||||
// v1.17.1 — the Recovery surface gets a nav home alongside Workouts. It
|
||||
// hosts the WHOOP / Polar device-native recovery + strain scores that were
|
||||
// stored end-to-end but had no page. Like Workouts it is shown to every
|
||||
// account (the page itself data-gates each block, so a non-wearable user
|
||||
// lands on a calm empty note rather than a broken surface).
|
||||
{
|
||||
href: "/insights/recovery",
|
||||
tKey: "nav.recovery",
|
||||
icon: HeartPulse,
|
||||
tourId: "nav-recovery",
|
||||
},
|
||||
{
|
||||
href: "/insights",
|
||||
tKey: "nav.insights",
|
||||
|
||||
Reference in new issue
Block a user