mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Render the day around a workout instead of linking away from it
The "That day" card took the workout, dropped it, and offered three chips to Pulse, Sleep and Mood. None of those pages accepts a date, so every chip landed on an all-time trend surface with the day nowhere in sight. Replace it with the day itself: the intraday pulse chart pinned to the session's day, the night the person woke from into it, and that day's mood entries. All three reads are addressed by the server-resolved dayKey, so nothing here decides which day a workout belongs to. The two outbound links that remain are undated and say what they lead to, Sleep trends and Mood history. Sleep and the intraday chart mount only when their module is on, so a switched-off module cannot paint as a failed read, and a query that has not answered yet shows a skeleton rather than claiming the night was empty. The detail page also drops the description it borrowed from the list.
This commit is contained in:
1 parent
0094445f07
commit
76b407b6de
15 files changed
+492
-111
No files matched your search
+8
-3
@@ -3102,9 +3102,14 @@
|
||||
"splitsTime": "Zeit",
|
||||
"splitsPace": "Tempo",
|
||||
"thatDayTitle": "An diesem Tag",
|
||||
"dayPulse": "Puls",
|
||||
"daySleep": "Schlaf",
|
||||
"dayMood": "Stimmung",
|
||||
"daySleepTitle": "Die Nacht davor",
|
||||
"daySleepNone": "Für diese Nacht ist kein Schlaf erfasst",
|
||||
"daySleepAwakeningsOne": "{count}× aufgewacht",
|
||||
"daySleepAwakeningsMany": "{count}× aufgewacht",
|
||||
"dayMoodTitle": "Stimmung an diesem Tag",
|
||||
"dayMoodNone": "An diesem Tag ist keine Stimmung erfasst",
|
||||
"sleepTrendsLink": "Schlaf-Trends",
|
||||
"moodHistoryLink": "Stimmungsverlauf",
|
||||
"insightTitle": "Aktivitäts-Einblick"
|
||||
},
|
||||
"sport": {
|
||||
|
||||
+8
-3
@@ -3102,9 +3102,14 @@
|
||||
"splitsTime": "Time",
|
||||
"splitsPace": "Pace",
|
||||
"thatDayTitle": "That day",
|
||||
"dayPulse": "Pulse",
|
||||
"daySleep": "Sleep",
|
||||
"dayMood": "Mood",
|
||||
"daySleepTitle": "The night before",
|
||||
"daySleepNone": "No sleep recorded for that night",
|
||||
"daySleepAwakeningsOne": "{count} awakening",
|
||||
"daySleepAwakeningsMany": "{count} awakenings",
|
||||
"dayMoodTitle": "Mood that day",
|
||||
"dayMoodNone": "No mood logged that day",
|
||||
"sleepTrendsLink": "Sleep trends",
|
||||
"moodHistoryLink": "Mood history",
|
||||
"insightTitle": "Activity insight"
|
||||
},
|
||||
"sport": {
|
||||
|
||||
+8
-3
@@ -3102,9 +3102,14 @@
|
||||
"splitsTime": "Tiempo",
|
||||
"splitsPace": "Ritmo",
|
||||
"thatDayTitle": "Ese día",
|
||||
"dayPulse": "Pulso",
|
||||
"daySleep": "Sueño",
|
||||
"dayMood": "Ánimo",
|
||||
"daySleepTitle": "La noche anterior",
|
||||
"daySleepNone": "No hay sueño registrado esa noche",
|
||||
"daySleepAwakeningsOne": "{count} despertar",
|
||||
"daySleepAwakeningsMany": "{count} despertares",
|
||||
"dayMoodTitle": "Ánimo ese día",
|
||||
"dayMoodNone": "No hay ánimo registrado ese día",
|
||||
"sleepTrendsLink": "Tendencias de sueño",
|
||||
"moodHistoryLink": "Historial de ánimo",
|
||||
"insightTitle": "Análisis de la actividad"
|
||||
},
|
||||
"sport": {
|
||||
|
||||
+8
-3
@@ -3102,9 +3102,14 @@
|
||||
"splitsTime": "Temps",
|
||||
"splitsPace": "Allure",
|
||||
"thatDayTitle": "Ce jour-là",
|
||||
"dayPulse": "Pouls",
|
||||
"daySleep": "Sommeil",
|
||||
"dayMood": "Humeur",
|
||||
"daySleepTitle": "La nuit précédente",
|
||||
"daySleepNone": "Aucun sommeil enregistré cette nuit-là",
|
||||
"daySleepAwakeningsOne": "{count} réveil",
|
||||
"daySleepAwakeningsMany": "{count} réveils",
|
||||
"dayMoodTitle": "Humeur ce jour-là",
|
||||
"dayMoodNone": "Aucune humeur enregistrée ce jour-là",
|
||||
"sleepTrendsLink": "Tendances du sommeil",
|
||||
"moodHistoryLink": "Historique d'humeur",
|
||||
"insightTitle": "Analyse de l'activité"
|
||||
},
|
||||
"sport": {
|
||||
|
||||
+8
-3
@@ -3102,9 +3102,14 @@
|
||||
"splitsTime": "Tempo",
|
||||
"splitsPace": "Ritmo",
|
||||
"thatDayTitle": "Quel giorno",
|
||||
"dayPulse": "Battito",
|
||||
"daySleep": "Sonno",
|
||||
"dayMood": "Umore",
|
||||
"daySleepTitle": "La notte prima",
|
||||
"daySleepNone": "Nessun sonno registrato quella notte",
|
||||
"daySleepAwakeningsOne": "{count} risveglio",
|
||||
"daySleepAwakeningsMany": "{count} risvegli",
|
||||
"dayMoodTitle": "Umore di quel giorno",
|
||||
"dayMoodNone": "Nessun umore registrato quel giorno",
|
||||
"sleepTrendsLink": "Andamento del sonno",
|
||||
"moodHistoryLink": "Cronologia dell'umore",
|
||||
"insightTitle": "Analisi dell'attività"
|
||||
},
|
||||
"sport": {
|
||||
|
||||
+8
-3
@@ -3102,9 +3102,14 @@
|
||||
"splitsTime": "Czas",
|
||||
"splitsPace": "Tempo",
|
||||
"thatDayTitle": "Tego dnia",
|
||||
"dayPulse": "Tętno",
|
||||
"daySleep": "Sen",
|
||||
"dayMood": "Nastrój",
|
||||
"daySleepTitle": "Noc wcześniej",
|
||||
"daySleepNone": "Brak zapisanego snu tej nocy",
|
||||
"daySleepAwakeningsOne": "{count} przebudzenie",
|
||||
"daySleepAwakeningsMany": "{count} przebudzeń",
|
||||
"dayMoodTitle": "Nastrój tego dnia",
|
||||
"dayMoodNone": "Brak zapisanego nastroju tego dnia",
|
||||
"sleepTrendsLink": "Trendy snu",
|
||||
"moodHistoryLink": "Historia nastroju",
|
||||
"insightTitle": "Analiza aktywności"
|
||||
},
|
||||
"sport": {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
WorkoutDetailZones,
|
||||
WorkoutDetailRoute,
|
||||
WorkoutDetailSplits,
|
||||
WorkoutDetailDayLinks,
|
||||
WorkoutDetailDayContext,
|
||||
WorkoutInsightCard,
|
||||
} from "@/components/insights/workout-detail";
|
||||
|
||||
@@ -31,7 +31,12 @@ import {
|
||||
* Layout, top to bottom (mobile-first single column):
|
||||
* hero header → reserved Activity-Insight seam (renders nothing today)
|
||||
* → stats grid + sport-average line → HR curve → effort zones → GPS
|
||||
* route → per-km splits → "that day" cross-links → coach launch.
|
||||
* route → per-km splits → "that day" → coach launch.
|
||||
*
|
||||
* "That day" answers the last question the page is for: what the rest of
|
||||
* the day looked like around the session. It renders the day's own pulse
|
||||
* shape, that night's sleep and the day's mood inline, all addressed by
|
||||
* the server-resolved `dayKey`.
|
||||
*
|
||||
* Every data-less section returns `null` (hide, don't render empty), so
|
||||
* an aggregates-only workout (a Strava ride with no wearable, a manual
|
||||
@@ -70,7 +75,9 @@ export default function InsightsWorkoutDetailPage({
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.workouts.title")}
|
||||
description={t("insights.workouts.description")}
|
||||
// No description. The one this page used to carry belongs to the LIST
|
||||
// ("Recent runs, rides, walks…, deduped…") and describes a surface
|
||||
// this is not — a detail page must not claim to be the list.
|
||||
backLink={
|
||||
<BackLink
|
||||
href="/insights/workouts"
|
||||
@@ -112,7 +119,7 @@ export default function InsightsWorkoutDetailPage({
|
||||
<WorkoutDetailZones workout={data} />
|
||||
<WorkoutDetailRoute workout={data} />
|
||||
<WorkoutDetailSplits workout={data} />
|
||||
<WorkoutDetailDayLinks workout={data} />
|
||||
<WorkoutDetailDayContext workout={data} />
|
||||
{/* 2026-07-17 UX-flows audit F6-1 — `workouts` narrows the
|
||||
snapshot the first coach turn reads. v1.31.0 — `workoutId` pins
|
||||
THIS session's own numbers as one additional snapshot section, so
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
WorkoutDetailHrSection,
|
||||
WorkoutDetailZones,
|
||||
WorkoutDetailSplits,
|
||||
WorkoutDetailDayLinks,
|
||||
WorkoutDetailDayContext,
|
||||
} from "../workout-detail";
|
||||
import { WorkoutInsightCard } from "../workout-detail/insight-slot";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
@@ -28,6 +29,23 @@ function render(node: React.ReactNode) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* For the one section that reads: the day context queries sleep and mood,
|
||||
* so it needs a client in the tree. Retries off and no network in this
|
||||
* environment, so every query stays pending and the section renders its
|
||||
* loading state — which is exactly what the structural assertions want.
|
||||
*/
|
||||
function renderWithQueryClient(node: React.ReactNode) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function expectSingleSectionHeading(html: string, title: string) {
|
||||
const headings = Array.from(
|
||||
html.matchAll(/<h2(?:\s[^>]*)?>(.*?)<\/h2>/g),
|
||||
@@ -41,6 +59,7 @@ const FIXTURE: WorkoutDetailPayload = {
|
||||
sportType: "running",
|
||||
startedAt: "2026-05-15T07:00:00Z",
|
||||
endedAt: "2026-05-15T07:30:00Z",
|
||||
dayKey: "2026-05-15",
|
||||
durationSec: 1800,
|
||||
distanceM: 5200,
|
||||
activeEnergyKcal: 320,
|
||||
@@ -267,14 +286,42 @@ describe("<WorkoutDetailSplits>", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("<WorkoutDetailDayLinks>", () => {
|
||||
it("renders the that-day navigation links", () => {
|
||||
const html = render(<WorkoutDetailDayLinks workout={FIXTURE} />);
|
||||
describe("<WorkoutDetailDayContext>", () => {
|
||||
it("renders the day's own signals under the that-day heading", () => {
|
||||
const html = renderWithQueryClient(
|
||||
<WorkoutDetailDayContext workout={FIXTURE} />,
|
||||
);
|
||||
expectSingleSectionHeading(html, "That day");
|
||||
expect(html).toContain('data-slot="workout-detail-day-links"');
|
||||
expect(html).toContain('href="/insights/pulse"');
|
||||
expect(html).toContain('data-slot="workout-detail-day-context"');
|
||||
expect(html).toContain('data-slot="workout-detail-day-sleep"');
|
||||
expect(html).toContain('data-slot="workout-detail-day-mood"');
|
||||
// The section is dated by the heading's meta, not by the links.
|
||||
expect(html).toContain("Fri, 05/15");
|
||||
});
|
||||
|
||||
it("never reads a pending query as an absent night", () => {
|
||||
// The queries are disabled here (no session), so nothing has answered.
|
||||
// "No sleep recorded" would be a claim the section cannot make.
|
||||
const html = renderWithQueryClient(
|
||||
<WorkoutDetailDayContext workout={FIXTURE} />,
|
||||
);
|
||||
expect(html).not.toContain("No sleep recorded for that night");
|
||||
expect(html).not.toContain("No mood logged that day");
|
||||
expect(html).toContain('data-slot="skeleton"');
|
||||
});
|
||||
|
||||
it("labels its outbound links as the undated surfaces they lead to", () => {
|
||||
const html = renderWithQueryClient(
|
||||
<WorkoutDetailDayContext workout={FIXTURE} />,
|
||||
);
|
||||
expect(html).toContain('href="/insights/sleep"');
|
||||
expect(html).toContain('href="/insights/mood"');
|
||||
expect(html).toContain("Sleep trends");
|
||||
expect(html).toContain('href="/mood"');
|
||||
expect(html).toContain("Mood history");
|
||||
// The dead "that day" chips are gone: the pulse page cannot take a
|
||||
// date, so nothing here pretends it can.
|
||||
expect(html).not.toContain('href="/insights/pulse"');
|
||||
expect(html).not.toContain('href="/insights/mood"');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -77,11 +77,19 @@ const AXIS_TICKS = [0, 360, 720, 1080, 1440];
|
||||
* hourly day (it needs per-sample resolution to stay honest). A failed fetch
|
||||
* renders `<QueryErrorCard>` with a retry action instead of falling through
|
||||
* to the empty-day copy.
|
||||
*
|
||||
* `initialDateKey` pins the first day shown, for callers that already know
|
||||
* which day they are talking about — the workout detail page seeds it with
|
||||
* the session's `dayKey` so the chart opens on the day of the workout. The
|
||||
* navigator still works from there, so "the day before" stays one tap away.
|
||||
* Absent, the view opens on today and keeps tracking it.
|
||||
*/
|
||||
export function IntradayPulseChart({
|
||||
userTimezone,
|
||||
initialDateKey,
|
||||
}: {
|
||||
userTimezone?: string;
|
||||
initialDateKey?: string;
|
||||
}) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
@@ -90,8 +98,11 @@ export function IntradayPulseChart({
|
||||
const todayKey = userDayKey(new Date(), tz);
|
||||
// `null` tracks "today" live (so a session left open across midnight keeps
|
||||
// following the current day); a non-null value pins the viewed day once
|
||||
// the user has navigated away from today.
|
||||
const [selectedDateKey, setSelectedDateKey] = useState<string | null>(null);
|
||||
// the user has navigated away from today — or from the start, when the
|
||||
// caller seeded a day of its own.
|
||||
const [selectedDateKey, setSelectedDateKey] = useState<string | null>(
|
||||
initialDateKey ?? null,
|
||||
);
|
||||
const dateKey = selectedDateKey ?? todayKey;
|
||||
const isToday = dateKey === todayKey;
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, CalendarDays, Moon, Smile } from "lucide-react";
|
||||
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import { useMoodDay } from "@/hooks/use-mood-day";
|
||||
import { useSleepNight } from "@/hooks/use-sleep-night";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { MOOD_LABEL_KEYS } from "@/lib/mood/labels";
|
||||
|
||||
// Through the shared chart-runtime boundary, never a direct import — a
|
||||
// second `from "recharts"` entry point mints its own copy of the library's
|
||||
// chunk. Same pattern the pulse page uses for this very chart.
|
||||
const IntradayPulseChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.IntradayPulseChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
|
||||
const LINK_CLASS =
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex shrink-0 items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none";
|
||||
|
||||
const ROW_CLASS = "flex items-start gap-3 py-2";
|
||||
|
||||
const GLYPH_CLASS = "text-muted-foreground size-4 shrink-0";
|
||||
|
||||
export interface WorkoutDetailDayContextProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* "That day" — the day around the session, rendered here rather than
|
||||
* linked to.
|
||||
*
|
||||
* After reading one workout the question is comparative and simultaneous:
|
||||
* how did the pulse settle afterwards, how had the person slept going into
|
||||
* it, how did the day feel. A comparison needs both halves on one screen,
|
||||
* so this section shows the day's own signals instead of handing off to
|
||||
* three aggregate-trend pages that carry no date.
|
||||
*
|
||||
* Every read is addressed by `workout.dayKey`, the local calendar day the
|
||||
* server resolved from `startedAt` in the user's timezone. Nothing here
|
||||
* derives a day from a timestamp.
|
||||
*
|
||||
* Absence and failure stay distinct: a night with nothing recorded and a
|
||||
* day with no mood entry each read as one muted sentence, while a failed
|
||||
* read gets `QueryErrorRow` with a retry. The two outbound links are
|
||||
* undated and named for what their targets actually deliver — trends and
|
||||
* history, not "that day".
|
||||
*
|
||||
* Module gating: the intraday tile mounts only when `insights` is on and
|
||||
* the sleep row only when `sleep` is on, so a switched-off module never
|
||||
* paints as a failed request. Mood has no module gate, so the section
|
||||
* always carries at least one part and the heading is never an orphan.
|
||||
*/
|
||||
export function WorkoutDetailDayContext({
|
||||
workout,
|
||||
}: WorkoutDetailDayContextProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const insightsEnabled = useModuleEnabled("insights");
|
||||
const sleepEnabled = useModuleEnabled("sleep");
|
||||
|
||||
const sleep = useSleepNight(workout.dayKey, isAuthenticated && sleepEnabled);
|
||||
const mood = useMoodDay(workout.dayKey, isAuthenticated);
|
||||
|
||||
const night = sleep.data?.main ?? null;
|
||||
const moodEntries = mood.entries ?? [];
|
||||
|
||||
return (
|
||||
<section data-slot="workout-detail-day-context" className="space-y-3">
|
||||
<SectionHeading
|
||||
icon={CalendarDays}
|
||||
title={t("insights.workouts.detail.thatDayTitle")}
|
||||
action={
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{fmt.dateWithWeekdaySmart(new Date(workout.startedAt))}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{insightsEnabled ? (
|
||||
<IntradayPulseChart
|
||||
userTimezone={user?.timezone}
|
||||
initialDateKey={workout.dayKey}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card className="gap-2 py-3 md:py-4" data-slot="workout-detail-day-card">
|
||||
<CardContent>
|
||||
<div className="divide-border divide-y">
|
||||
{sleepEnabled ? (
|
||||
<div className={ROW_CLASS} data-slot="workout-detail-day-sleep">
|
||||
<Moon className={GLYPH_CLASS} aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.workouts.detail.daySleepTitle")}
|
||||
</p>
|
||||
{sleep.isError ? (
|
||||
// A failed read is not an absent night.
|
||||
<QueryErrorRow
|
||||
onRetry={sleep.refetch}
|
||||
slot="workout-detail-day-sleep-error"
|
||||
/>
|
||||
) : sleep.data === undefined ? (
|
||||
// Nothing has answered yet. The absence line below is
|
||||
// only ever shown for an answer that HAD no night.
|
||||
<Skeleton className="h-4 w-32" />
|
||||
) : night ? (
|
||||
<p className="text-sm">
|
||||
{formatDurationMinutes(night.asleepMinutes, t)}
|
||||
{night.awakenings > 0
|
||||
? ` · ${
|
||||
night.awakenings === 1
|
||||
? t(
|
||||
"insights.workouts.detail.daySleepAwakeningsOne",
|
||||
{ count: night.awakenings },
|
||||
)
|
||||
: t(
|
||||
"insights.workouts.detail.daySleepAwakeningsMany",
|
||||
{ count: night.awakenings },
|
||||
)
|
||||
}`
|
||||
: null}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.workouts.detail.daySleepNone")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href="/insights/sleep"
|
||||
data-slot="workout-detail-day-sleep-link"
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{t("insights.workouts.detail.sleepTrendsLink")}
|
||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={ROW_CLASS} data-slot="workout-detail-day-mood">
|
||||
<Smile className={GLYPH_CLASS} aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.workouts.detail.dayMoodTitle")}
|
||||
</p>
|
||||
{mood.isError ? (
|
||||
<QueryErrorRow
|
||||
onRetry={mood.refetch}
|
||||
slot="workout-detail-day-mood-error"
|
||||
/>
|
||||
) : mood.entries === undefined ? (
|
||||
<Skeleton className="h-4 w-32" />
|
||||
) : moodEntries.length > 0 ? (
|
||||
// User data, so foreground — never muted.
|
||||
<ul className="flex flex-wrap gap-x-3 gap-y-1 text-sm">
|
||||
{moodEntries.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
{MOOD_LABEL_KEYS[entry.mood]
|
||||
? t(MOOD_LABEL_KEYS[entry.mood])
|
||||
: entry.mood}
|
||||
<span className="text-muted-foreground ml-1.5 text-xs tabular-nums">
|
||||
{fmt.time(new Date(entry.moodLoggedAt))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.workouts.detail.dayMoodNone")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href="/mood"
|
||||
data-slot="workout-detail-day-mood-link"
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{t("insights.workouts.detail.moodHistoryLink")}
|
||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Activity, Moon, Smile } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
interface DayLink {
|
||||
href: string;
|
||||
labelKey: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const LINKS: DayLink[] = [
|
||||
{
|
||||
href: "/insights/pulse",
|
||||
labelKey: "insights.workouts.detail.dayPulse",
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
href: "/insights/sleep",
|
||||
labelKey: "insights.workouts.detail.daySleep",
|
||||
icon: Moon,
|
||||
},
|
||||
{
|
||||
href: "/insights/mood",
|
||||
labelKey: "insights.workouts.detail.dayMood",
|
||||
icon: Smile,
|
||||
},
|
||||
];
|
||||
|
||||
export interface WorkoutDetailDayLinksProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* "That day" — a plain inline link row to the day's other signals
|
||||
* (intraday pulse, that night's sleep, mood). No reads; just navigation
|
||||
* so the workout sits in the context of the rest of the day.
|
||||
*/
|
||||
export function WorkoutDetailDayLinks({ workout }: WorkoutDetailDayLinksProps) {
|
||||
const { t } = useTranslations();
|
||||
void workout;
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-day-links">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={CalendarDays}
|
||||
title={t("insights.workouts.detail.thatDayTitle")}
|
||||
titleAs="h2"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{LINKS.map(({ href, labelKey, icon: Icon }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="border-border hover:bg-muted inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
|
||||
>
|
||||
<Icon
|
||||
className="text-muted-foreground size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(labelKey)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Barrel for the workout-detail surface (#67). The former single
|
||||
* `workout-detail.tsx` grew past a comfortable size once the HR curve,
|
||||
* zones, route SVG, splits and day-links landed, so it was split into
|
||||
* zones, route SVG, splits and day context landed, so it was split into
|
||||
* this directory. Import path stays `@/components/insights/workout-detail`.
|
||||
*/
|
||||
export { WorkoutDetailHeader } from "./header";
|
||||
@@ -10,7 +10,7 @@ export { WorkoutDetailHrSection } from "./hr-section";
|
||||
export { WorkoutDetailZones } from "./zones";
|
||||
export { WorkoutDetailRoute } from "./route-map";
|
||||
export { WorkoutDetailSplits } from "./splits";
|
||||
export { WorkoutDetailDayLinks } from "./day-links";
|
||||
export { WorkoutDetailDayContext } from "./day-context";
|
||||
export {
|
||||
WorkoutInsightCard,
|
||||
type WorkoutActivityInsightData,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* Every mood entry a person logged on ONE local day, from
|
||||
* `GET /api/mood-entries?from=<day>&to=<day>`.
|
||||
*
|
||||
* The route filters on the mood `date` column, which already IS the local
|
||||
* day key, so no timezone maths happens here. Entries come back newest
|
||||
* first (`sortBy=moodLoggedAt`, `sortDir=desc`, the route's defaults).
|
||||
*/
|
||||
export interface MoodDayEntry {
|
||||
id: string;
|
||||
date: string;
|
||||
/** The mood enum, e.g. `GUT`. Rendered through `MOOD_LABEL_KEYS`. */
|
||||
mood: string;
|
||||
score: number;
|
||||
note: string | null;
|
||||
source: string;
|
||||
moodLoggedAt: string;
|
||||
}
|
||||
|
||||
interface MoodDayResponse {
|
||||
entries: MoodDayEntry[];
|
||||
meta: { total: number };
|
||||
}
|
||||
|
||||
export interface UseMoodDayResult {
|
||||
/** `undefined` until the route has answered; `[]` means the day really
|
||||
* carried no entry. */
|
||||
entries: MoodDayEntry[] | undefined;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one day's mood entries.
|
||||
*
|
||||
* `enabled` is the caller's gate — pass `false` when the user is signed
|
||||
* out, and nothing is fetched. Mood has no module gate, so there is
|
||||
* nothing else to check.
|
||||
*/
|
||||
export function useMoodDay(
|
||||
date: string | undefined,
|
||||
enabled: boolean,
|
||||
): UseMoodDayResult {
|
||||
const { data, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.moodEntriesDay(date ?? ""),
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({
|
||||
from: date as string,
|
||||
to: date as string,
|
||||
limit: "50",
|
||||
});
|
||||
return apiGet<MoodDayResponse>(`/api/mood-entries?${params}`);
|
||||
},
|
||||
enabled: enabled && Boolean(date),
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return {
|
||||
entries: data?.entries,
|
||||
isError,
|
||||
refetch: () => void refetch(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* One reconstructed night from `GET /api/sleep/night?date=YYYY-MM-DD`.
|
||||
*
|
||||
* The date is the WAKE day: the night a person woke from into that day.
|
||||
* The server owns the reconstruction (stage folding, cross-source pick,
|
||||
* WHOOP timeline synthesis) and the day keying, so this hook only carries
|
||||
* the resolved figures across.
|
||||
*
|
||||
* The route is gated on the `sleep` module and answers 403 when it is off.
|
||||
* Callers gate the mount on `useModuleEnabled("sleep")` so a switched-off
|
||||
* module never paints as a failed read.
|
||||
*/
|
||||
export interface SleepNightSegment {
|
||||
stage: string;
|
||||
start: string;
|
||||
end: string;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
export interface SleepNightSession {
|
||||
/** `YYYY-MM-DD` wake-day key. */
|
||||
night: string;
|
||||
source: string | null;
|
||||
start: string;
|
||||
end: string;
|
||||
asleepMinutes: number;
|
||||
inBedMinutes: number | null;
|
||||
awakeMinutes: number | null;
|
||||
/** Mid-sleep awakenings. `0` is a real answer, not an absence. */
|
||||
awakenings: number;
|
||||
reconstructed: boolean;
|
||||
stages: Record<string, number>;
|
||||
segments: SleepNightSegment[];
|
||||
}
|
||||
|
||||
export interface SleepNightPayload {
|
||||
night: string | null;
|
||||
/** `null` when nothing scorable was recorded for that night. */
|
||||
main: SleepNightSession | null;
|
||||
naps: SleepNightSession[];
|
||||
}
|
||||
|
||||
export interface UseSleepNightResult {
|
||||
/** `undefined` until the route has answered — never confuse it with a
|
||||
* night that carried nothing. */
|
||||
data: SleepNightPayload | undefined;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the night a person woke from into `date`.
|
||||
*
|
||||
* `enabled` is the caller's gate — pass `false` when the user is signed
|
||||
* out or the sleep module is disabled, and nothing is fetched.
|
||||
*/
|
||||
export function useSleepNight(
|
||||
date: string | undefined,
|
||||
enabled: boolean,
|
||||
): UseSleepNightResult {
|
||||
const { data, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.sleepNight(date),
|
||||
queryFn: () =>
|
||||
apiGet<SleepNightPayload>(
|
||||
`/api/sleep/night?date=${encodeURIComponent(date as string)}`,
|
||||
),
|
||||
enabled: enabled && Boolean(date),
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return { data, isError, refetch: () => void refetch() };
|
||||
}
|
||||
@@ -32,6 +32,16 @@ export const moodKeys = {
|
||||
params.sortDir,
|
||||
] as const,
|
||||
|
||||
/**
|
||||
* v1.34.3 — every mood entry logged on ONE local day, read by the
|
||||
* workout detail page's day context. Rides under the `["mood-entries"]`
|
||||
* prefix so `moodDependentKeys` reaches it on a mood write, and carries
|
||||
* its own `"day"` scope segment so it can never share a slot with
|
||||
* `moodEntriesList` — same key with a different response shape silently
|
||||
* poisons the cache.
|
||||
*/
|
||||
moodEntriesDay: (date: string) => ["mood-entries", "day", date] as const,
|
||||
|
||||
moodAnalytics: () => ["mood-analytics"] as const,
|
||||
/**
|
||||
* v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution,
|
||||
|
||||
Reference in new issue
Block a user