diff --git a/CHANGELOG.md b/CHANGELOG.md index bfbb72455..53cb335cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog +## [1.39.9] — 2026-10-03 + +Workouts can be logged by hand, the body fat tile opens its own page, and +every AI call honours the response-timeout setting. + +### Added + +- **Manual workouts (#1085).** A short form (sport, start, duration, optional + distance and energy) from the dashboard "+ Add" menu, the capture picker and + the workouts page, saved through `POST /api/workouts/batch` with + `source: "MANUAL"` and a per-form `externalId`. New + `DELETE /api/workouts/{id}` for manual workouts only (409 + `workout.delete.synced_source` for synced ones). +- **Body fat page (#1084).** `/insights/body-fat`; insights layout id + `body-fat`. Tile destinations live in one table gated on the module surface + map. +- **Wide lab scan review (#1059).** The review stage uses a 72 rem dialog on + large screens, with a container-query row layout. + +### Fixed + +- **AI response timeout (#1090).** Document reading, summaries, extraction, + lab OCR, filing suggestions, medication extraction, the About-me questions, + the document chat and the provider test run under the user's setting + instead of a fixed 60 s; browser aborts derive from + `ai.provider.responseTimeoutMs` on `/api/auth/me`. The lab OCR text mode no + longer hits the 15 s client default. The settings placeholder shows the + real defaults. +- **Dashboard "+ Add" menu.** Mood and medication intake follow their + modules, like the capture picker. + +### Checks + +- Tile destination guard, AI call timeout guard, menu and picker parity. +- node-forge advisory GHSA-86w9-cpqp-85rv ignored until a patched release + exists; only the unused APNs certificate path depends on it. + ## [1.39.8] — 2026-10-02 New ECGs from the iPhone get through, a resting heart rate can be logged by diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b5aeaf86a..f66443ff6 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.39.8 + version: 1.39.9 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index 822a5d825..64e5a0e0b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3465,6 +3465,7 @@ "durationRequired": "Gib an, wie lange das Workout gedauert hat.", "durationInvalid": "Gib ganze Stunden und Minuten von 0 bis 59 ein.", "durationTooLong": "Ein Workout dauert höchstens 24 Stunden.", + "endInFuture": "Das Workout würde in der Zukunft enden. Stell den Beginn auf die Uhrzeit, zu der es angefangen hat.", "distanceInvalid": "Gib die Distanz als Zahl in einem realistischen Bereich ein.", "energyInvalid": "Gib die aktive Energie als Zahl bis 20.000 kcal ein." } diff --git a/messages/en.json b/messages/en.json index 41ffba7d9..2f5660ba4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3465,6 +3465,7 @@ "durationRequired": "Enter how long the workout lasted.", "durationInvalid": "Enter whole hours, and minutes from 0 to 59.", "durationTooLong": "A workout can last at most 24 hours.", + "endInFuture": "It would end in the future. Set the start to when the workout began.", "distanceInvalid": "Enter the distance as a number within a realistic range.", "energyInvalid": "Enter active energy as a number up to 20,000 kcal." } diff --git a/messages/es.json b/messages/es.json index e6c2d55df..8917fa569 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3465,6 +3465,7 @@ "durationRequired": "Indica cuánto duró el entrenamiento.", "durationInvalid": "Introduce horas enteras y minutos de 0 a 59.", "durationTooLong": "Un entrenamiento puede durar como máximo 24 horas.", + "endInFuture": "Terminaría en el futuro. Pon como inicio la hora en que empezó el entrenamiento.", "distanceInvalid": "Introduce la distancia como un número dentro de un rango realista.", "energyInvalid": "Introduce la energía activa como un número de hasta 20.000 kcal." } diff --git a/messages/fr.json b/messages/fr.json index a559cbce2..c7b83d06a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3465,6 +3465,7 @@ "durationRequired": "Indiquez la durée de l’entraînement.", "durationInvalid": "Saisissez des heures entières et des minutes de 0 à 59.", "durationTooLong": "Un entraînement dure au maximum 24 heures.", + "endInFuture": "Il se terminerait dans le futur. Indiquez comme début l’heure à laquelle l’entraînement a commencé.", "distanceInvalid": "Saisissez la distance sous forme de nombre, dans une plage réaliste.", "energyInvalid": "Saisissez l’énergie active sous forme de nombre, jusqu’à 20 000 kcal." } diff --git a/messages/it.json b/messages/it.json index d4210d9be..dc823ccee 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3465,6 +3465,7 @@ "durationRequired": "Indica quanto è durato l’allenamento.", "durationInvalid": "Inserisci ore intere e minuti da 0 a 59.", "durationTooLong": "Un allenamento può durare al massimo 24 ore.", + "endInFuture": "Finirebbe nel futuro. Imposta come inizio l’ora in cui è cominciato l’allenamento.", "distanceInvalid": "Inserisci la distanza come numero in un intervallo realistico.", "energyInvalid": "Inserisci l’energia attiva come numero fino a 20.000 kcal." } diff --git a/messages/ko.json b/messages/ko.json index 83eefb037..ad5942532 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3465,6 +3465,7 @@ "durationRequired": "운동한 시간을 입력하세요.", "durationInvalid": "시간은 정수로, 분은 0에서 59 사이로 입력하세요.", "durationTooLong": "운동은 최대 24시간까지 입력할 수 있습니다.", + "endInFuture": "종료 시간이 미래가 됩니다. 시작 시간을 운동을 시작한 시각으로 설정하세요.", "distanceInvalid": "거리를 현실적인 범위의 숫자로 입력하세요.", "energyInvalid": "활동 에너지는 20,000kcal 이하의 숫자로 입력하세요." } diff --git a/messages/pl.json b/messages/pl.json index 9b520a948..8fead53ee 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3465,6 +3465,7 @@ "durationRequired": "Podaj, jak długo trwał trening.", "durationInvalid": "Podaj pełne godziny i minuty od 0 do 59.", "durationTooLong": "Trening może trwać najwyżej 24 godziny.", + "endInFuture": "Trening kończyłby się w przyszłości. Ustaw początek na godzinę, o której się zaczął.", "distanceInvalid": "Podaj dystans jako liczbę w realistycznym zakresie.", "energyInvalid": "Podaj energię czynną jako liczbę do 20 000 kcal." } diff --git a/package.json b/package.json index d6451e91b..8f2a1b5f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.39.8", + "version": "1.39.9", "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 1cee2fcef..12e07572b 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.39.8"; + /* @sw-version-fallback */ "v1.39.9"; 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/lib/workouts/__tests__/manual-entry.test.ts b/src/lib/workouts/__tests__/manual-entry.test.ts index 7c3300475..db7e0f255 100644 --- a/src/lib/workouts/__tests__/manual-entry.test.ts +++ b/src/lib/workouts/__tests__/manual-entry.test.ts @@ -136,9 +136,9 @@ describe("buildManualWorkoutEntry — validation", () => { }); it("tolerates the minute the form was opened in", () => { - // 14:00:30 Berlin is 30 s past `now`: inside the slack. + // 13:15 Berlin + 45 min ends at 12:00Z, 30 s past `now`: inside the slack. const result = buildManualWorkoutEntry( - draft({ start: "2026-09-15T14:00" }), + draft({ start: "2026-09-15T13:15" }), { ...CTX, now: new Date("2026-09-15T11:59:30Z"), @@ -147,6 +147,13 @@ describe("buildManualWorkoutEntry — validation", () => { expect(result.ok).toBe(true); }); + it("refuses a workout that would end in the future", () => { + // Logged at 14:00 Berlin with the start left at 13:30: 45 min ends 14:15. + expect(errorsOf({ start: "2026-09-15T13:30" }).duration).toBe( + "insights.workouts.manual.errors.endInFuture", + ); + }); + it("asks for a start", () => { expect(errorsOf({ start: "" }).start).toBe( "insights.workouts.manual.errors.startRequired", diff --git a/src/lib/workouts/manual-entry.ts b/src/lib/workouts/manual-entry.ts index d356ecc47..07012deb9 100644 --- a/src/lib/workouts/manual-entry.ts +++ b/src/lib/workouts/manual-entry.ts @@ -235,6 +235,18 @@ function manualWorkoutSchema(ctx: ManualWorkoutContext) { path: ["duration"], message: `${ERR}.durationTooLong`, }); + } else if ( + // The default start is "now", so a workout logged right after it + // finished would otherwise be stored as ending in the future. + startedAt !== null && + startedAt.getTime() + sec * 1000 > + ctx.now.getTime() + FUTURE_TOLERANCE_MS + ) { + issue.addIssue({ + code: "custom", + path: ["duration"], + message: `${ERR}.endInFuture`, + }); } }