mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
76b407b6de
commit
a61cdae437
13 files changed
+177
-14
No files matched your search
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -3110,6 +3110,7 @@
|
||||
"dayMoodNone": "No mood logged that day",
|
||||
"sleepTrendsLink": "Sleep trends",
|
||||
"moodHistoryLink": "Mood history",
|
||||
"previousSessionLink": "Previous session",
|
||||
"insightTitle": "Activity insight"
|
||||
},
|
||||
"sport": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -3110,6 +3110,7 @@
|
||||
"dayMoodNone": "Brak zapisanego nastroju tego dnia",
|
||||
"sleepTrendsLink": "Trendy snu",
|
||||
"moodHistoryLink": "Historia nastroju",
|
||||
"previousSessionLink": "Poprzedni trening",
|
||||
"insightTitle": "Analiza aktywności"
|
||||
},
|
||||
"sport": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
Reference in new issue
Block a user