Merge pull request #668 from MBombeck/release/v1.34.2

release/v1.34.2
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-30 12:29:54 +02:00
commit 30afcd79f4
9 files changed
+309 -15

No files matched your search

+29
View File
@@ -2,6 +2,35 @@
## [Unreleased]
## [1.34.2] — 2026-07-30
Point release. One workout that arrived more than once, and several surfaces
that were reading the same record differently from one another.
### Fixed
- Badminton recorded through Google Health keeps its sport instead of arriving
as "Other". Reported by @tarantila.
- A workout the same source sent twice is now counted once everywhere. The
workout list already collapsed the repeat, and the Coach narration, the
per-sport comparison and the workout deep link did not, so one session could
be folded two or three times into the averages a person is measured against.
The three that missed it were fetching the workout without its duration, and
the identity check needed that field.
- Insights prose renders at full contrast, and the two assessment cards share
one internal rhythm again after one of them drifted away from it.
- The dashboard headline survives for anyone who has switched the score lead
off.
- The service worker's fallback cache version follows the release again. It had
slipped a version behind, which only shows up in a browser that cannot load
the version file, and that browser then kept serving the previous release's
cached pages.
### Credit
Reported by @tarantila, directly rather than on the tracker: the badminton
mapping, following on from the workout-type loss in v1.34.0.
## [1.34.1] — 2026-07-29
Security and reliability point release.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.34.1
version: 1.34.2
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.34.1",
"version": "1.34.2",
"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",
+1 -1
View File
@@ -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.34.0";
/* @sw-version-fallback */ "v1.34.2";
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/*`
@@ -0,0 +1,61 @@
/**
* The service worker's fallback version must equal the release version.
*
* `public/sw.js` derives its cache names from `self.__APP_VERSION__`, injected by
* `public/sw-version.js`, which `scripts/generate-sw-version.mjs` writes from
* `package.json` on every prebuild. When `importScripts('/sw-version.js')`
* fails, the SW falls back to a literal compiled into `sw.js` behind the
* `@sw-version-fallback` anchor, and the same prebuild step rewrites that
* literal too.
*
* Two places therefore declare one version, and until this file existed nothing
* made them agree. `scripts/__tests__/generate-sw-version.test.ts` checks the
* GENERATED file against `package.json`; the behavioural suite in
* `service-worker.test.ts` reads the fallback literal out of the source so its
* cache-name assertions "never drift from the literal it is exercising". Both
* are reasonable in isolation and together they leave the literal unchecked: a
* release whose version was bumped without a rebuild ships a fallback pointing
* at the PREVIOUS release, and every test stays green.
*
* The consequence is small but real, and it only bites on the degraded path. A
* client that cannot load `/sw-version.js` computes last release's cache names,
* so it keeps serving the previous shell and its cleanup step treats the current
* caches as the stale ones.
*
* This is a one-line equality, which makes it a gate rather than a review aid.
* Failing it means the tree needs a rebuild before it is committed, which is
* exactly the step whose absence caused the drift.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = process.cwd();
function swFallbackVersion(): string {
const source = readFileSync(join(ROOT, "public/sw.js"), "utf8");
const match = /\/\* @sw-version-fallback \*\/\s*"(v[^"]*)"/.exec(source);
if (!match) {
throw new Error(
"no @sw-version-fallback anchor in public/sw.js — the prebuild generator keys on it",
);
}
return match[1];
}
function packageVersion(): string {
const pkg = JSON.parse(
readFileSync(join(ROOT, "package.json"), "utf8"),
) as Record<string, unknown>;
const version = pkg.version;
if (typeof version !== "string") {
throw new Error("package.json carries no string version");
}
return version;
}
describe("the service-worker fallback version is anchored to the release", () => {
it("matches package.json", () => {
expect(swFallbackVersion()).toBe(`v${packageVersion()}`);
});
});
@@ -0,0 +1,120 @@
/**
* Structural guard on the canonical-workout session identity.
*
* `pickCanonicalWorkoutRows()` collapses a HealthKit session that arrived more
* than once under different external UUIDs. It can only do that when the row it
* is handed carries the identity fields, and the row shape comes from each
* caller's own Prisma `select`. Nothing in the type system connects the two:
* `durationSec` is optional on `WorkoutPickerRow`, so a caller that omits it
* gets silent passthrough rather than a compile error.
*
* That is exactly what happened. When the collapse first shipped, the key was
* `(sportType, startedAt, endedAt, durationSec)` and only the workout list
* selected `endedAt`. The list showed one workout while the Coach narrated the
* duplicate, the per-sport baseline averaged it twice and the deep-link cluster
* kept both rows. One record, four surfaces, two answers.
*
* These are tripwires, not proofs. They cannot show the collapse is correct —
* that is what the unit tests do. What they can do is refuse to let a new call
* site join the set without someone editing this file, and refuse to let the
* identity quietly grow a field that only some callers fetch.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { globSync } from "node:fs";
import { join, sep } from "node:path";
const SRC = join(process.cwd(), "src");
/** The picker module itself — declares the identity, does not consume it. */
const PICKER = "lib/measurements/pick-canonical-workout-rows.ts";
/**
* Every production call site of the read-time picker, frozen.
*
* Adding a surface that collapses workouts is a deliberate act: it must fetch
* the identity fields or it silently disagrees with every other surface. Adding
* the file here is the acknowledgement.
*/
const IDENTITY_OWNER: Record<string, string> = {
"app/api/workouts/[id]/route.ts": "app/api/workouts/[id]/route.ts",
"lib/jobs/workout-insight-generate.ts":
"lib/jobs/workout-insight-generate.ts",
"lib/workouts/list-read.ts": "lib/workouts/list-read.ts",
"lib/workouts/sport-context.ts": "lib/workouts/sport-context.ts",
// The only indirection in the set. The Coach block is handed rows it does not
// read itself; the snapshot builder fetches them in parallel with every other
// block's data. Recording the owner here rather than assuming the call site
// owns its own select is the whole point of this map.
"lib/ai/coach/snapshot-blocks/workouts-block.ts": "lib/ai/coach/snapshot.ts",
};
const EXPECTED_CALL_SITES = Object.keys(IDENTITY_OWNER).sort();
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC })
.map((p) => p.split(sep).join("/"))
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
/**
* Lines that actually invoke the picker. Comment lines are excluded because the
* write-time sibling (`lib/workouts/canonical-rows.ts`) names the read-time
* picker in its docblock to explain how the two layer, and that mention is
* documentation rather than a call.
*/
function invokesPicker(source: string): boolean {
return source
.split("\n")
.some(
(line) =>
line.includes("pickCanonicalWorkoutRows(") &&
!/^\s*(\*|\/\/|\/\*)/.test(line),
);
}
describe("the picker's call-site set is frozen", () => {
it("has exactly the call sites this file acknowledges", () => {
const found = sourceFiles()
.filter((rel) => rel !== PICKER)
.filter((rel) => invokesPicker(read(rel)));
expect(found).toEqual(EXPECTED_CALL_SITES);
});
});
describe("every call site fetches the session identity", () => {
it.each(EXPECTED_CALL_SITES)("%s gets rows carrying durationSec", (rel) => {
// File-level rather than per-select: a caller that fetches workout rows
// without their duration cannot collapse a re-send, whichever select the
// rows came from. Coarse in the safe direction — it can only fail to catch
// a second unrelated select in the same file, never pass a caller that
// fetches no duration at all.
expect(read(IDENTITY_OWNER[rel])).toContain("durationSec: true");
});
});
describe("the identity cannot quietly grow a field callers do not fetch", () => {
it("keys the session on sportType, startedAt and durationSec only", () => {
const picker = read(PICKER);
const body = picker.slice(
picker.indexOf("function exactSessionKey"),
picker.indexOf("function rowRichness"),
);
expect(body).toContain("row.sportType");
expect(body).toContain("row.startedAt");
expect(body).toContain("row.durationSec");
// `endedAt` is the specific field that split the callers. On a workout with
// pauses it disagrees with `durationSec` by design, so it rejected genuine
// re-sends while forcing every caller to fetch a fourth column.
expect(body).not.toContain("endedAt");
});
});
+5
View File
@@ -114,6 +114,11 @@ export const GET = apiHandler(
source: true,
startedAt: true,
sportType: true,
// Part of the canonical session identity consulted by
// `pickCanonicalWorkoutRows` below — without it a HealthKit re-send of
// this very workout stays in the cluster and the deep link can resolve
// to the duplicate instead of the row the list shows.
durationSec: true,
avgHeartRate: true,
maxHeartRate: true,
metadata: true,
@@ -277,6 +277,68 @@ describe("pickCanonicalWorkoutRows", () => {
expect(result[0].id).toBe("duplicate-b");
});
it("collapses a re-send whose endedAt disagrees with the first arrival", () => {
// A workout with pauses has a wall-clock end that is longer than the
// counted duration, and the two sources of that number do not always agree
// across re-sends. `endedAt` was part of the session key when the collapse
// first shipped, so this exact pair survived as two rows.
const base = {
startedAt: new Date("2026-07-29T12:54:00Z"),
durationSec: 480,
sportType: "cycling",
source: "APPLE_HEALTH" as const,
};
const result = pickCanonicalWorkoutRows<RowFixture>([
{
...base,
id: "first-arrival",
endedAt: new Date("2026-07-29T13:02:00Z"),
},
{
...base,
id: "resend",
endedAt: new Date("2026-07-29T13:09:00Z"),
avgHeartRate: 132,
},
]);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("resend");
});
it("keeps two same-source sessions whose durations differ", () => {
// The counterpart guarantee: the looser key must not merge genuinely
// distinct sessions that happen to share a bucket slot and a sport.
const base = {
startedAt: new Date("2026-07-29T12:54:00Z"),
sportType: "strength",
source: "APPLE_HEALTH" as const,
};
const result = pickCanonicalWorkoutRows<RowFixture>([
{ ...base, id: "short", durationSec: 480 },
{ ...base, id: "long", durationSec: 1800 },
]);
expect(result.map((row) => row.id).sort()).toEqual(["long", "short"]);
});
it("passes rows without a duration through rather than guessing", () => {
// Fixture-only path: a row with no duration carries no session identity, so
// it cannot be collapsed. Every production caller selects the field and
// `workout-canonical-identity-guard.test.ts` keeps that true.
const base = {
startedAt: new Date("2026-07-29T12:54:00Z"),
sportType: "yoga",
source: "APPLE_HEALTH" as const,
};
const result = pickCanonicalWorkoutRows<RowFixture>([
{ ...base, id: "no-duration-a" },
{ ...base, id: "no-duration-b" },
]);
expect(result).toHaveLength(2);
});
it("does not collapse or enrich rows belonging to different users", () => {
const result = pickCanonicalWorkoutRows<RowFixture>([
{
@@ -46,8 +46,12 @@ import {
export interface WorkoutPickerRow {
userId?: string;
startedAt: Date;
/** Optional exact-session fields used to collapse repeated same-source sync rows. */
endedAt?: Date;
/**
* Session length. Together with `sportType` + `startedAt` this is the
* exact-session identity used to collapse repeated same-source sync rows.
* Optional only so fixtures can omit it; every production call site MUST
* select it, and `workout-canonical-identity-guard.test.ts` freezes that set.
*/
durationSec?: number;
sportType: string;
source: MeasurementSource;
@@ -60,18 +64,31 @@ export interface WorkoutPickerRow {
* HealthKit can resend the same session with a new external UUID. The source
* ladder correctly chooses Apple over WHOOP, but previously kept every Apple
* row in that winning bucket, which made the web list show the same workout
* two or three times. Only collapse rows when the exact session identity is
* available; fixtures and legacy callers without end/duration remain
* governed by the original bucket contract.
* two or three times.
*
* The identity is `(sportType, startedAt, durationSec)`. `endedAt` was part of
* this key when the collapse first shipped and has been dropped deliberately:
*
* - It is not a free extra check. On a workout with pauses, `endedAt` is the
* wall-clock end while `durationSec` counts only moving time, so the two
* legitimately disagree, and a stored row can carry either convention. Two
* re-sends of one session can therefore differ in `endedAt` alone, and the
* stricter key let that duplicate through.
* - It bought nothing in exchange. Nobody starts a second workout of the
* same sport in the same instant with the same duration, so start plus
* duration already identifies the session uniquely.
* - Requiring it split the callers. Four of the five call sites select
* `durationSec` but not `endedAt`, so the collapse ran on the workout list
* and silently did nothing for the Coach narration, the per-sport baseline
* and the deep-link cluster. One list, one number, two answers.
*
* A row without `durationSec` cannot be identified and passes through
* uncollapsed. That is a fixture-only path: every production call site selects
* the field and `workout-canonical-identity-guard.test.ts` keeps it that way.
*/
function exactSessionKey(row: WorkoutPickerRow): string | null {
if (row.endedAt == null || row.durationSec == null) return null;
return [
row.sportType,
row.startedAt.getTime(),
row.endedAt.getTime(),
row.durationSec,
].join(":");
if (row.durationSec == null) return null;
return [row.sportType, row.startedAt.getTime(), row.durationSec].join(":");
}
function rowRichness(row: WorkoutPickerRow): number {