diff --git a/src/app/insights/recovery/page.tsx b/src/app/insights/recovery/page.tsx new file mode 100644 index 000000000..31125cec1 --- /dev/null +++ b/src/app/insights/recovery/page.tsx @@ -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 ``, 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 ( + + + + ); +} diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx new file mode 100644 index 000000000..4f423cc45 --- /dev/null +++ b/src/components/insights/recovery/recovery-section.tsx @@ -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 (`` 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 ( +
+ {t("insights.recovery.empty")} +
+ ); + } + + return ( +
+ {hasRecoveryScore ? ( + + + + + {t("insights.recovery.scoreLink.cta")} + + + ) : 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 ( +
+ + {group.subtitle} +

+ } + /> +
+ {present.map((tile) => ( + + ))} +
+
+ ); + })} +
+ ); +} diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 75ff2b4d4..2a3c94fe9 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -3,6 +3,7 @@ import { Droplets, Dumbbell, FlaskConical, + HeartPulse, Home, Lightbulb, MessagesSquare, @@ -82,6 +83,17 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ 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",