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 ? ( +
+
+ ) : null} + +
+
+
+
+
+
+ ); +} diff --git a/src/components/insights/workout-detail/day-links.tsx b/src/components/insights/workout-detail/day-links.tsx deleted file mode 100644 index 3f9e582f0..000000000 --- a/src/components/insights/workout-detail/day-links.tsx +++ /dev/null @@ -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 ( - - - - - -
- {LINKS.map(({ href, labelKey, icon: Icon }) => ( - -
-
-
- ); -} 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,