diff --git a/messages/de.json b/messages/de.json
index 460565c78..ece11b710 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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": {
diff --git a/messages/en.json b/messages/en.json
index 574dbe5e3..2065f34c2 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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": {
diff --git a/messages/es.json b/messages/es.json
index a236e7749..5efb5a296 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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": {
diff --git a/messages/fr.json b/messages/fr.json
index ccfb0d7a7..dad4fee78 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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": {
diff --git a/messages/it.json b/messages/it.json
index 7f2933e87..2d30bf499 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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": {
diff --git a/messages/pl.json b/messages/pl.json
index c171f69db..e4d4927c8 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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": {
diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx
index 7798f06a3..2780caf84 100644
--- a/src/app/insights/workouts/[id]/page.tsx
+++ b/src/app/insights/workouts/[id]/page.tsx
@@ -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 (
-
+
{/* 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
diff --git a/src/components/insights/__tests__/workout-detail.test.tsx b/src/components/insights/__tests__/workout-detail.test.tsx
index 9375a2ab1..1b3a49719 100644
--- a/src/components/insights/__tests__/workout-detail.test.tsx
+++ b/src/components/insights/__tests__/workout-detail.test.tsx
@@ -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(
+
+ {node}
+ ,
+ );
+}
+
function expectSingleSectionHeading(html: string, title: string) {
const headings = Array.from(
html.matchAll(/
]*)?>(.*?)<\/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("", () => {
});
});
-describe("", () => {
- it("renders the that-day navigation links", () => {
- const html = render();
+describe("", () => {
+ it("renders the day's own signals under the that-day heading", () => {
+ const html = renderWithQueryClient(
+ ,
+ );
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(
+ ,
+ );
+ 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(
+ ,
+ );
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"');
});
});
diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx
index 4d2c8a69e..965014e94 100644
--- a/src/components/insights/intraday-pulse-chart.tsx
+++ b/src/components/insights/intraday-pulse-chart.tsx
@@ -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 `` 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(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(
+ initialDateKey ?? null,
+ );
const dateKey = selectedDateKey ?? todayKey;
const isToday = dateKey === todayKey;
diff --git a/src/components/insights/workout-detail/day-context.tsx b/src/components/insights/workout-detail/day-context.tsx
new file mode 100644
index 000000000..2f1132e7e
--- /dev/null
+++ b/src/components/insights/workout-detail/day-context.tsx
@@ -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: () => },
+);
+
+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 (
+
+
+ {fmt.dateWithWeekdaySmart(new Date(workout.startedAt))}
+
+ }
+ />
+
+ {insightsEnabled ? (
+
+ ) : null}
+
+
+
+
+ {sleepEnabled ? (
+
+
+
+
+ {t("insights.workouts.detail.daySleepTitle")}
+
+ {sleep.isError ? (
+ // A failed read is not an absent night.
+
+ ) : sleep.data === undefined ? (
+ // Nothing has answered yet. The absence line below is
+ // only ever shown for an answer that HAD no night.
+
+ ) : night ? (
+
-
-
- );
-}
diff --git a/src/components/insights/workout-detail/index.ts b/src/components/insights/workout-detail/index.ts
index 4b6e0938e..1b77f8345 100644
--- a/src/components/insights/workout-detail/index.ts
+++ b/src/components/insights/workout-detail/index.ts
@@ -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,
diff --git a/src/hooks/use-mood-day.ts b/src/hooks/use-mood-day.ts
new file mode 100644
index 000000000..f43c19e74
--- /dev/null
+++ b/src/hooks/use-mood-day.ts
@@ -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=&to=`.
+ *
+ * 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(`/api/mood-entries?${params}`);
+ },
+ enabled: enabled && Boolean(date),
+ staleTime: 5 * 60_000,
+ refetchOnWindowFocus: false,
+ });
+
+ return {
+ entries: data?.entries,
+ isError,
+ refetch: () => void refetch(),
+ };
+}
diff --git a/src/hooks/use-sleep-night.ts b/src/hooks/use-sleep-night.ts
new file mode 100644
index 000000000..cf5837aa0
--- /dev/null
+++ b/src/hooks/use-sleep-night.ts
@@ -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;
+ 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(
+ `/api/sleep/night?date=${encodeURIComponent(date as string)}`,
+ ),
+ enabled: enabled && Boolean(date),
+ staleTime: 5 * 60_000,
+ refetchOnWindowFocus: false,
+ });
+
+ return { data, isError, refetch: () => void refetch() };
+}
diff --git a/src/lib/query-keys/mood.ts b/src/lib/query-keys/mood.ts
index 8a60c3013..0bc580a2c 100644
--- a/src/lib/query-keys/mood.ts
+++ b/src/lib/query-keys/mood.ts
@@ -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,