From a61cdae4370349411b8ecb5f3a2063d268876f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 30 Jul 2026 23:13:26 +0200 Subject: [PATCH] Offer the previous session of the same sport from the workout page "How does this compare to last time?" is the most likely next question after reading a workout, and answering it meant going back to the list and scanning for the one before. Resolve the previous session server-side, through the same canonical picker the list uses, so the link can never land on a duplicate row recorded by a second watch, and render it beside the own-history average line. Null when nothing older exists, and then no link is shown. The stats card's hand-rolled heading moves to the icon-less TileHeader while the file is open. --- docs/api/openapi.yaml | 5 ++ messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + src/app/api/workouts/[id]/route.ts | 45 ++++++++++++++ src/app/api/workouts/__tests__/detail.test.ts | 61 +++++++++++++++++++ .../__tests__/workout-detail.test.tsx | 13 ++++ .../insights/workout-detail/stats.tsx | 49 ++++++++++----- src/hooks/use-workouts.ts | 6 ++ src/lib/openapi/routes/workouts.ts | 6 ++ 13 files changed, 177 insertions(+), 14 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0af40fc6b..20f4bde2b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -20073,6 +20073,10 @@ components: - type: "null" canonicalId: type: string + previousWorkoutId: + anyOf: + - type: string + - type: "null" required: - id - sportType @@ -20099,6 +20103,7 @@ components: - sportContext - aiInsight - canonicalId + - previousWorkoutId additionalProperties: false WorkoutRouteGeometry: type: object diff --git a/messages/de.json b/messages/de.json index ece11b710..27f059216 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3110,6 +3110,7 @@ "dayMoodNone": "An diesem Tag ist keine Stimmung erfasst", "sleepTrendsLink": "Schlaf-Trends", "moodHistoryLink": "Stimmungsverlauf", + "previousSessionLink": "Vorherige Einheit", "insightTitle": "Aktivitäts-Einblick" }, "sport": { diff --git a/messages/en.json b/messages/en.json index 2065f34c2..44fe331ae 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3110,6 +3110,7 @@ "dayMoodNone": "No mood logged that day", "sleepTrendsLink": "Sleep trends", "moodHistoryLink": "Mood history", + "previousSessionLink": "Previous session", "insightTitle": "Activity insight" }, "sport": { diff --git a/messages/es.json b/messages/es.json index 5efb5a296..44bc7720f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3110,6 +3110,7 @@ "dayMoodNone": "No hay ánimo registrado ese día", "sleepTrendsLink": "Tendencias de sueño", "moodHistoryLink": "Historial de ánimo", + "previousSessionLink": "Sesión anterior", "insightTitle": "Análisis de la actividad" }, "sport": { diff --git a/messages/fr.json b/messages/fr.json index dad4fee78..12f32134a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3110,6 +3110,7 @@ "dayMoodNone": "Aucune humeur enregistrée ce jour-là", "sleepTrendsLink": "Tendances du sommeil", "moodHistoryLink": "Historique d'humeur", + "previousSessionLink": "Séance précédente", "insightTitle": "Analyse de l'activité" }, "sport": { diff --git a/messages/it.json b/messages/it.json index 2d30bf499..58730aeee 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3110,6 +3110,7 @@ "dayMoodNone": "Nessun umore registrato quel giorno", "sleepTrendsLink": "Andamento del sonno", "moodHistoryLink": "Cronologia dell'umore", + "previousSessionLink": "Sessione precedente", "insightTitle": "Analisi dell'attività" }, "sport": { diff --git a/messages/pl.json b/messages/pl.json index e4d4927c8..aa015a58b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3110,6 +3110,7 @@ "dayMoodNone": "Brak zapisanego nastroju tego dnia", "sleepTrendsLink": "Trendy snu", "moodHistoryLink": "Historia nastroju", + "previousSessionLink": "Poprzedni trening", "insightTitle": "Analiza aktywności" }, "sport": { diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index beaeeba12..d6c348171 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -211,6 +211,47 @@ export const GET = apiHandler( row.id, ); + // The session before this one in the same sport. "How does this compare + // to last time?" is the most likely next question after reading a + // workout, and today it costs a trip back to the list plus a scan. + // Scoped to rows that start before this cluster's slot, so a twin of THIS + // session can never be offered as the previous one, and run through the + // same canonical picker so the link lands on the row the list shows. + const previousRows = await prisma.workout.findMany({ + where: { + userId: user.id, + sportType: row.sportType, + startedAt: { lt: slotStart }, + }, + orderBy: [{ startedAt: "desc" }, { id: "desc" }], + // Enough to survive a run of same-session twins and re-sends without + // reading the whole history; the picker collapses them to a handful. + take: 20, + select: { + id: true, + source: true, + startedAt: true, + sportType: true, + durationSec: true, + avgHeartRate: true, + maxHeartRate: true, + metadata: true, + }, + }); + // The picker takes rows in ascending order and does not promise to hand + // them back sorted, so pick the newest survivor explicitly. + const previousWorkoutId = + pickCanonicalWorkoutRows( + [...previousRows].reverse(), + userRow?.sourcePriorityJson ?? null, + ).reduce<{ id: string; startedAt: Date } | null>( + (newest, candidate) => + newest === null || candidate.startedAt > newest.startedAt + ? candidate + : newest, + null, + )?.id ?? null; + const route = row.route ? { geometry: row.route.geometry, @@ -295,6 +336,10 @@ export const GET = apiHandler( // winner. `canonicalId === id` when the requested row already // is the winner. canonicalId: canonical?.id ?? row.id, + // The canonical previous session in this sport, or null when this is + // the first one on record. Additive; a client that does not read it is + // unaffected. + previousWorkoutId, }); }, ); diff --git a/src/app/api/workouts/__tests__/detail.test.ts b/src/app/api/workouts/__tests__/detail.test.ts index 830812867..05bd9a6fe 100644 --- a/src/app/api/workouts/__tests__/detail.test.ts +++ b/src/app/api/workouts/__tests__/detail.test.ts @@ -158,6 +158,67 @@ describe("GET /api/workouts/{id}", () => { expect(body.data.maxHr).toBe(170); expect(body.data.canonicalId).toBe("w-1"); expect(body.data.route).toBeNull(); + // Nothing older on record: absence, not a link to nowhere. + expect(body.data.previousWorkoutId).toBeNull(); + }); + + it("points previousWorkoutId at the canonical session before this one", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce( + BASE_ROW as never, + ); + vi.mocked(prisma.workout.findMany) + // 1) the deep-link cluster + .mockResolvedValueOnce([ + { + id: "w-1", + source: "APPLE_HEALTH", + startedAt: BASE_ROW.startedAt, + sportType: "RUNNING", + }, + ] as never) + // 2) the sport-context baseline + .mockResolvedValueOnce([] as never) + // 3) the older sessions, newest first + .mockResolvedValueOnce([ + { + id: "w-0", + source: "APPLE_HEALTH", + startedAt: new Date("2026-05-13T07:00:00Z"), + sportType: "RUNNING", + durationSec: 1800, + avgHeartRate: null, + maxHeartRate: null, + metadata: null, + }, + // The same session as seen by the second watch. It must never win + // the link — the list would never show it. + { + id: "w-0-twin", + source: "WITHINGS", + startedAt: new Date("2026-05-13T07:01:00Z"), + sportType: "RUNNING", + durationSec: 1800, + avgHeartRate: null, + maxHeartRate: null, + metadata: null, + }, + { + id: "w-older", + source: "APPLE_HEALTH", + startedAt: new Date("2026-05-10T07:00:00Z"), + sportType: "RUNNING", + durationSec: 1800, + avgHeartRate: null, + maxHeartRate: null, + metadata: null, + }, + ] as never); + + const res = await GET(makeRequest(), makeParams("w-1")); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.previousWorkoutId).toBe("w-0"); }); it("dates the session by the user's timezone, not by UTC", async () => { diff --git a/src/components/insights/__tests__/workout-detail.test.tsx b/src/components/insights/__tests__/workout-detail.test.tsx index 1b3a49719..aa6306131 100644 --- a/src/components/insights/__tests__/workout-detail.test.tsx +++ b/src/components/insights/__tests__/workout-detail.test.tsx @@ -80,6 +80,7 @@ const FIXTURE: WorkoutDetailPayload = { sportContext: null, aiInsight: null, canonicalId: "w-1", + previousWorkoutId: null, }; describe("", () => { @@ -104,6 +105,18 @@ describe("", () => { expect(html).toContain("Pace"); }); + it("links to the previous session only when the server resolved one", () => { + expect(render()).not.toContain( + 'data-slot="workout-detail-previous-link"', + ); + + const html = render( + , + ); + expect(html).toContain('href="/insights/workouts/w-0"'); + expect(html).toContain("Previous session"); + }); + it("omits tiles for null fields", () => { const minimal: WorkoutDetailPayload = { ...FIXTURE, diff --git a/src/components/insights/workout-detail/stats.tsx b/src/components/insights/workout-detail/stats.tsx index 0550e1b01..72767d043 100644 --- a/src/components/insights/workout-detail/stats.tsx +++ b/src/components/insights/workout-detail/stats.tsx @@ -1,7 +1,9 @@ "use client"; import type { ReactNode } from "react"; +import Link from "next/link"; import { + ArrowRight, Flame, Footprints, HeartPulse, @@ -11,6 +13,7 @@ import { } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; @@ -157,12 +160,12 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { return ( -

- {t("insights.workouts.detail.statsTitle")} -

+ {/* Icon-less `TileHeader`, not a hand-rolled h2 — the standards + allow exactly three header patterns and this card is a tile. */} +
))}
- {averageLine ? ( -

- {t("insights.workouts.detail.sportAverageLabel")}{" "} - {averageLine} -

+ {averageLine || workout.previousWorkoutId ? ( + // One row, per the inline-action shape: the sentence wraps + // internally, the link stays beside it. +
+ {averageLine ? ( +

+ {t("insights.workouts.detail.sportAverageLabel")}{" "} + {averageLine} +

+ ) : ( + + )} + {workout.previousWorkoutId ? ( + + {t("insights.workouts.detail.previousSessionLink")} +
) : null}
diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts index 1295f33cd..1a8401ea1 100644 --- a/src/hooks/use-workouts.ts +++ b/src/hooks/use-workouts.ts @@ -319,6 +319,12 @@ export interface WorkoutDetailPayload extends WorkoutListEntry { sportContext: WorkoutSportContextDto | null; aiInsight: WorkoutActivityInsight; canonicalId: string; + /** + * The canonical session before this one in the same sport, `null` when + * this is the first on record. Server-resolved through the same picker + * the list uses, so the link never lands on a duplicate row. + */ + previousWorkoutId: string | null; } async function fetchWorkoutDetail(id: string): Promise { diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index d23980ff2..60c29b569 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -199,6 +199,12 @@ const workoutDetailResponse = z // a provider reachable. Reading this endpoint never generates one. aiInsight: workoutActivityInsight.nullable(), canonicalId: z.string(), + /** + * The canonical session before this one in the same sport, or null when + * this is the first on record. Resolved through the same source-priority + * picker as the list, so the id always addresses a row the list shows. + */ + previousWorkoutId: z.string().nullable(), }) .meta({ id: "WorkoutDetailResponse" });