diff --git a/CHANGELOG.md b/CHANGELOG.md index 379b630f4..1ea5ffed1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +## [1.29.4] — 2026-07-17 + +- **The workouts list shows the most recent first.** The list was ordered + oldest-first, so on a longer history the newest sessions never made the first + page. It now leads with the latest workout. +- **Past-year dates show the year.** A workout from a previous year now carries + its year in the list, so a December date is never mistaken for this year's. + ## [1.29.3] — 2026-07-17 - **Hydration and micronutrients now have a home.** Your fluid intake and the diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1965f713d..db9fade75 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.29.3 + version: 1.29.4 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 48cfb4e95..068f91e17 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.29.3", + "version": "1.29.4", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 898e2e542..bbd60981a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.29.3"; + /* @sw-version-fallback */ "v1.29.4"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index 50836cb9c..5e7a1eacf 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -147,6 +147,28 @@ describe("GET /api/workouts — canonical dedup", () => { expect(body.data.meta.droppedDuplicates).toBe(1); }); + it("returns the most recent workouts first", async () => { + // The canonical picker re-sorts its output to startedAt ASC (its rollup + // contract), so the list route must re-establish newest-first AFTER the + // pick — otherwise offset/limit slice the OLDEST page and recent workouts + // never surface. These two never cluster (different day + slot). + const older = makeRow("w-old", "APPLE_HEALTH", "2026-01-01T07:00:00Z"); + const newer = makeRow("w-new", "APPLE_HEALTH", "2026-07-01T07:00:00Z"); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + newer, + older, + ] as never); + + const res = await GET(makeRequest()); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.workouts.map((w: { id: string }) => w.id)).toEqual([ + "w-new", + "w-old", + ]); + }); + it("returns 401 without a session", async () => { vi.mocked(getSession).mockResolvedValueOnce(null); const res = await GET(makeRequest()); diff --git a/src/app/api/workouts/route.ts b/src/app/api/workouts/route.ts index 098a99990..f05224994 100644 --- a/src/app/api/workouts/route.ts +++ b/src/app/api/workouts/route.ts @@ -143,6 +143,17 @@ async function buildWorkoutsResponse( userRow?.sourcePriorityJson ?? null, ); + // `pickCanonicalWorkoutRows` re-sorts its output to `startedAt ASC` (its + // documented contract for the rollup callers). This surface is a + // most-recent-first list, so the DB `orderBy startedAt desc` must be + // re-established AFTER the pick — otherwise `offset`/`limit` slice the + // OLDEST page and a user with a long history never sees recent workouts. + canonical.sort((a, b) => { + const delta = (b.startedAt?.getTime() ?? 0) - (a.startedAt?.getTime() ?? 0); + if (delta !== 0) return delta; + return a.id.localeCompare(b.id); + }); + const page = canonical.slice(offset, offset + limit).map((row) => ({ id: row.id, sportType: row.sportType, diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index 712aaf77b..6cdfcf319 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -87,10 +87,14 @@ function formatDate( timeFormat: TimeFormatPreference, ): string { const d = new Date(iso); + // Show the year whenever the workout is not from the current year, so a + // "Do., 16. Dez." from a previous year is never mistaken for this year's. + const showYear = d.getFullYear() !== new Date().getFullYear(); return getDateTimeFormat(locale, { weekday: "short", day: "2-digit", month: "short", + ...(showYear ? { year: "numeric" as const } : {}), hour: "2-digit", minute: "2-digit", ...hourCycleOptions(timeFormat),