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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-30 23:23:37 +02:00
1 parent 76b407b6de
commit a61cdae437
13 files changed
+177 -14

No files matched your search

+5
View File
@@ -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
+1
View File
@@ -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": {
+1
View File
@@ -3110,6 +3110,7 @@
"dayMoodNone": "No mood logged that day",
"sleepTrendsLink": "Sleep trends",
"moodHistoryLink": "Mood history",
"previousSessionLink": "Previous session",
"insightTitle": "Activity insight"
},
"sport": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -3110,6 +3110,7 @@
"dayMoodNone": "Brak zapisanego nastroju tego dnia",
"sleepTrendsLink": "Trendy snu",
"moodHistoryLink": "Historia nastroju",
"previousSessionLink": "Poprzedni trening",
"insightTitle": "Analiza aktywności"
},
"sport": {
+45
View File
@@ -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,
});
},
);
@@ -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 () => {
@@ -80,6 +80,7 @@ const FIXTURE: WorkoutDetailPayload = {
sportContext: null,
aiInsight: null,
canonicalId: "w-1",
previousWorkoutId: null,
};
describe("<WorkoutDetailHeader>", () => {
@@ -104,6 +105,18 @@ describe("<WorkoutDetailStats>", () => {
expect(html).toContain("Pace");
});
it("links to the previous session only when the server resolved one", () => {
expect(render(<WorkoutDetailStats workout={FIXTURE} />)).not.toContain(
'data-slot="workout-detail-previous-link"',
);
const html = render(
<WorkoutDetailStats workout={{ ...FIXTURE, previousWorkoutId: "w-0" }} />,
);
expect(html).toContain('href="/insights/workouts/w-0"');
expect(html).toContain("Previous session");
});
it("omits tiles for null fields", () => {
const minimal: WorkoutDetailPayload = {
...FIXTURE,
@@ -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 (
<Card data-slot="workout-detail-stats">
<CardHeader>
<h2
data-slot="card-title"
className="text-base leading-none font-semibold"
>
{t("insights.workouts.detail.statsTitle")}
</h2>
{/* Icon-less `TileHeader`, not a hand-rolled h2 — the standards
allow exactly three header patterns and this card is a tile. */}
<TileHeader
title={t("insights.workouts.detail.statsTitle")}
titleAs="h2"
/>
</CardHeader>
<CardContent className="space-y-3">
<div
@@ -175,14 +178,32 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
<StatTile key={tile.label} {...tile} />
))}
</div>
{averageLine ? (
<p
data-slot="workout-detail-sport-average"
className="text-muted-foreground text-xs"
>
{t("insights.workouts.detail.sportAverageLabel")}{" "}
<span className="tabular-nums">{averageLine}</span>
</p>
{averageLine || workout.previousWorkoutId ? (
// One row, per the inline-action shape: the sentence wraps
// internally, the link stays beside it.
<div className="flex items-start justify-between gap-3">
{averageLine ? (
<p
data-slot="workout-detail-sport-average"
className="text-muted-foreground min-w-0 flex-1 text-xs"
>
{t("insights.workouts.detail.sportAverageLabel")}{" "}
<span className="tabular-nums">{averageLine}</span>
</p>
) : (
<span className="min-w-0 flex-1" />
)}
{workout.previousWorkoutId ? (
<Link
href={`/insights/workouts/${workout.previousWorkoutId}`}
data-slot="workout-detail-previous-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex shrink-0 items-center gap-1.5 text-xs underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.workouts.detail.previousSessionLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
</div>
) : null}
</CardContent>
</Card>
+6
View File
@@ -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<WorkoutDetailPayload> {
+6
View File
@@ -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" });