From b14357662720e2808a2d42795ca919e1aaaf34bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 08:42:23 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.28.0=20=E2=80=94=20documen?= =?UTF-8?q?t=20intelligence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marks the document line as one coherent milestone: the vault becomes a place to understand files, not just store them — read, indexed, searched, shared, and answered — with a local-first / consented AI posture and a vendor-blind egress notice held at every step. Ships alongside a full correctness, resilience, and quality sweep down to the low-priority findings. --- .audit-hero-360.mjs | 150 +++++++++++++++++++++++++++++++++++++++++ .audit-hero-mobile.mjs | 150 +++++++++++++++++++++++++++++++++++++++++ .audit-hero.mjs | 150 +++++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 37 ++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 7 files changed, 490 insertions(+), 3 deletions(-) create mode 100644 .audit-hero-360.mjs create mode 100644 .audit-hero-mobile.mjs create mode 100644 .audit-hero.mjs diff --git a/.audit-hero-360.mjs b/.audit-hero-360.mjs new file mode 100644 index 000000000..49495229f --- /dev/null +++ b/.audit-hero-360.mjs @@ -0,0 +1,150 @@ +// Hero spacing screenshot harness (WQHD) — uncommitted, repo root for module resolution. +import { chromium } from "@playwright/test"; +import { + buildMockSnapshot, + LONG_HEADLINE_BRIEFING, + MOCK_SCORE_RINGS, +} from "./e2e/utils/mock-dashboard-snapshot.ts"; + +const OUT = + process.env.OUT_DIR ?? + "/private/tmp/claude-501/-Users-marc-Projects-HealthLog/78b12842-5404-4de0-9a00-4fdac6a4a7ed/scratchpad/hero"; +const TAG = process.env.TAG ?? "before"; + +import { existsSync } from "node:fs"; +const STATE = `${OUT}/state.json`; +const browser = await chromium.launch(); +const ctxOpts = { + viewport: { width: 360, height: 780 }, + deviceScaleFactor: 1, +}; +let ctx; +if (existsSync(STATE)) { + ctx = await browser.newContext({ ...ctxOpts, storageState: STATE }); +} else { + ctx = await browser.newContext(ctxOpts); + const login = await ctx.request.post("http://localhost:3000/api/auth/login", { + data: { email: "e2e@healthlog.test", password: "ZJ4hN8x!Pq3vMr2C" }, + }); + if (login.status() !== 200) + throw new Error(`login failed: ${login.status()} ${await login.text()}`); + await ctx.storageState({ path: STATE }); +} +const page = await ctx.newPage(); + +const snapshot = buildMockSnapshot({ + heroVisible: true, + briefing: LONG_HEADLINE_BRIEFING, + scoreRings: MOCK_SCORE_RINGS, +}); +snapshot.user.username = "Marc"; +snapshot.healthScore = { score: 78, band: "green", components: [] }; + +await page.route(/\/api\/dashboard\/snapshot(\?|$)/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: snapshot, error: null }), + }), +); + +page.on("response", (r) => { + if (r.url().includes("dashboard/snapshot")) + console.log("SNAPSHOT RESPONSE:", r.status(), r.url()); +}); +await page.goto("http://localhost:3000/"); +await page + .waitForSelector('[data-verdict-variant], [data-slot="dashboard-hero"]', { + timeout: 20000, + }) + .catch(() => {}); +await page.waitForTimeout(1500); +console.log( + "BODY SLOTS:", + await page.evaluate(() => + Array.from(document.querySelectorAll("[data-slot]")) + .slice(0, 12) + .map((e) => e.getAttribute("data-slot")), + ), +); +console.log("URL:", page.url()); + +const hero = page.locator('[data-slot="dashboard-hero"]').first(); +const heroBox = await hero.boundingBox().catch(() => null); +await page.screenshot({ path: `${OUT}/${TAG}-full-360.png` }); +if (heroBox) { + await page.screenshot({ + path: `${OUT}/${TAG}-hero-360.png`, + clip: { + x: Math.max(0, heroBox.x - 8), + y: Math.max(0, heroBox.y - 8), + width: Math.min(360, heroBox.width + 16), + height: heroBox.height + 16, + }, + }); +} + +// Geometry: paddings of the hero card + ring row gaps + greeting offset +const geo = await page.evaluate(() => { + const hero = document.querySelector('[data-slot="dashboard-hero"]'); + if (!hero) return { error: "no hero" }; + const hb = hero.getBoundingClientRect(); + const cs = getComputedStyle(hero); + const first = hero.firstElementChild?.getBoundingClientRect(); + const kids = Array.from(hero.querySelectorAll("*")); + // ring row: element containing multiple svg rings + const rings = kids + .filter((el) => el.querySelectorAll?.("svg circle").length >= 2) + .pop(); + const ringBox = rings?.getBoundingClientRect(); + const ringKids = rings + ? Array.from(rings.children).map((c) => c.getBoundingClientRect()) + : []; + const gaps = []; + for (let i = 1; i < ringKids.length; i++) + gaps.push( + Math.round(ringKids[i].x - (ringKids[i - 1].x + ringKids[i - 1].width)), + ); + const greeting = kids.find( + (el) => + /Willkommen|willkommen|Hallo/.test(el.textContent || "") && + el.children.length === 0, + ); + const gB = greeting?.getBoundingClientRect(); + // lowest content bottom inside hero + let maxBottom = 0; + kids.forEach((el) => { + const r = el.getBoundingClientRect(); + if (r.height > 0 && r.bottom > maxBottom && r.bottom <= hb.bottom + 1) + maxBottom = r.bottom; + }); + let minTop = Infinity; + kids.forEach((el) => { + const r = el.getBoundingClientRect(); + if (r.height > 0 && r.top < minTop && r.top >= hb.top - 1) minTop = r.top; + }); + return { + heroPadding: { + t: cs.paddingTop, + r: cs.paddingRight, + b: cs.paddingBottom, + l: cs.paddingLeft, + }, + contentInsets: { + top: Math.round(minTop - hb.top), + bottom: Math.round(hb.bottom - maxBottom), + }, + ringGaps: gaps, + ringRow: ringBox + ? { + top: Math.round(ringBox.top - hb.top), + right: Math.round(hb.right - ringBox.right), + } + : null, + greetingTopInHero: gB ? Math.round(gB.top - hb.top) : null, + heroSize: { w: Math.round(hb.width), h: Math.round(hb.height) }, + }; +}); +console.log(JSON.stringify(geo, null, 1)); + +await browser.close(); diff --git a/.audit-hero-mobile.mjs b/.audit-hero-mobile.mjs new file mode 100644 index 000000000..c7651ef60 --- /dev/null +++ b/.audit-hero-mobile.mjs @@ -0,0 +1,150 @@ +// Hero spacing screenshot harness (WQHD) — uncommitted, repo root for module resolution. +import { chromium } from "@playwright/test"; +import { + buildMockSnapshot, + LONG_HEADLINE_BRIEFING, + MOCK_SCORE_RINGS, +} from "./e2e/utils/mock-dashboard-snapshot.ts"; + +const OUT = + process.env.OUT_DIR ?? + "/private/tmp/claude-501/-Users-marc-Projects-HealthLog/78b12842-5404-4de0-9a00-4fdac6a4a7ed/scratchpad/hero"; +const TAG = process.env.TAG ?? "before"; + +import { existsSync } from "node:fs"; +const STATE = `${OUT}/state.json`; +const browser = await chromium.launch(); +const ctxOpts = { + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 1, +}; +let ctx; +if (existsSync(STATE)) { + ctx = await browser.newContext({ ...ctxOpts, storageState: STATE }); +} else { + ctx = await browser.newContext(ctxOpts); + const login = await ctx.request.post("http://localhost:3000/api/auth/login", { + data: { email: "e2e@healthlog.test", password: "ZJ4hN8x!Pq3vMr2C" }, + }); + if (login.status() !== 200) + throw new Error(`login failed: ${login.status()} ${await login.text()}`); + await ctx.storageState({ path: STATE }); +} +const page = await ctx.newPage(); + +const snapshot = buildMockSnapshot({ + heroVisible: true, + briefing: LONG_HEADLINE_BRIEFING, + scoreRings: MOCK_SCORE_RINGS, +}); +snapshot.user.username = "Marc"; +snapshot.healthScore = { score: 78, band: "green", components: [] }; + +await page.route(/\/api\/dashboard\/snapshot(\?|$)/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: snapshot, error: null }), + }), +); + +page.on("response", (r) => { + if (r.url().includes("dashboard/snapshot")) + console.log("SNAPSHOT RESPONSE:", r.status(), r.url()); +}); +await page.goto("http://localhost:3000/"); +await page + .waitForSelector('[data-verdict-variant], [data-slot="dashboard-hero"]', { + timeout: 20000, + }) + .catch(() => {}); +await page.waitForTimeout(1500); +console.log( + "BODY SLOTS:", + await page.evaluate(() => + Array.from(document.querySelectorAll("[data-slot]")) + .slice(0, 12) + .map((e) => e.getAttribute("data-slot")), + ), +); +console.log("URL:", page.url()); + +const hero = page.locator('[data-slot="dashboard-hero"]').first(); +const heroBox = await hero.boundingBox().catch(() => null); +await page.screenshot({ path: `${OUT}/${TAG}-full-390.png` }); +if (heroBox) { + await page.screenshot({ + path: `${OUT}/${TAG}-hero-390.png`, + clip: { + x: Math.max(0, heroBox.x - 8), + y: Math.max(0, heroBox.y - 8), + width: Math.min(390, heroBox.width + 16), + height: heroBox.height + 16, + }, + }); +} + +// Geometry: paddings of the hero card + ring row gaps + greeting offset +const geo = await page.evaluate(() => { + const hero = document.querySelector('[data-slot="dashboard-hero"]'); + if (!hero) return { error: "no hero" }; + const hb = hero.getBoundingClientRect(); + const cs = getComputedStyle(hero); + const first = hero.firstElementChild?.getBoundingClientRect(); + const kids = Array.from(hero.querySelectorAll("*")); + // ring row: element containing multiple svg rings + const rings = kids + .filter((el) => el.querySelectorAll?.("svg circle").length >= 2) + .pop(); + const ringBox = rings?.getBoundingClientRect(); + const ringKids = rings + ? Array.from(rings.children).map((c) => c.getBoundingClientRect()) + : []; + const gaps = []; + for (let i = 1; i < ringKids.length; i++) + gaps.push( + Math.round(ringKids[i].x - (ringKids[i - 1].x + ringKids[i - 1].width)), + ); + const greeting = kids.find( + (el) => + /Willkommen|willkommen|Hallo/.test(el.textContent || "") && + el.children.length === 0, + ); + const gB = greeting?.getBoundingClientRect(); + // lowest content bottom inside hero + let maxBottom = 0; + kids.forEach((el) => { + const r = el.getBoundingClientRect(); + if (r.height > 0 && r.bottom > maxBottom && r.bottom <= hb.bottom + 1) + maxBottom = r.bottom; + }); + let minTop = Infinity; + kids.forEach((el) => { + const r = el.getBoundingClientRect(); + if (r.height > 0 && r.top < minTop && r.top >= hb.top - 1) minTop = r.top; + }); + return { + heroPadding: { + t: cs.paddingTop, + r: cs.paddingRight, + b: cs.paddingBottom, + l: cs.paddingLeft, + }, + contentInsets: { + top: Math.round(minTop - hb.top), + bottom: Math.round(hb.bottom - maxBottom), + }, + ringGaps: gaps, + ringRow: ringBox + ? { + top: Math.round(ringBox.top - hb.top), + right: Math.round(hb.right - ringBox.right), + } + : null, + greetingTopInHero: gB ? Math.round(gB.top - hb.top) : null, + heroSize: { w: Math.round(hb.width), h: Math.round(hb.height) }, + }; +}); +console.log(JSON.stringify(geo, null, 1)); + +await browser.close(); diff --git a/.audit-hero.mjs b/.audit-hero.mjs new file mode 100644 index 000000000..da4b0f3fc --- /dev/null +++ b/.audit-hero.mjs @@ -0,0 +1,150 @@ +// Hero spacing screenshot harness (WQHD) — uncommitted, repo root for module resolution. +import { chromium } from "@playwright/test"; +import { + buildMockSnapshot, + LONG_HEADLINE_BRIEFING, + MOCK_SCORE_RINGS, +} from "./e2e/utils/mock-dashboard-snapshot.ts"; + +const OUT = + process.env.OUT_DIR ?? + "/private/tmp/claude-501/-Users-marc-Projects-HealthLog/78b12842-5404-4de0-9a00-4fdac6a4a7ed/scratchpad/hero"; +const TAG = process.env.TAG ?? "before"; + +import { existsSync } from "node:fs"; +const STATE = `${OUT}/state.json`; +const browser = await chromium.launch(); +const ctxOpts = { + viewport: { width: 2560, height: 1440 }, + deviceScaleFactor: 1, +}; +let ctx; +if (existsSync(STATE)) { + ctx = await browser.newContext({ ...ctxOpts, storageState: STATE }); +} else { + ctx = await browser.newContext(ctxOpts); + const login = await ctx.request.post("http://localhost:3000/api/auth/login", { + data: { email: "e2e@healthlog.test", password: "ZJ4hN8x!Pq3vMr2C" }, + }); + if (login.status() !== 200) + throw new Error(`login failed: ${login.status()} ${await login.text()}`); + await ctx.storageState({ path: STATE }); +} +const page = await ctx.newPage(); + +const snapshot = buildMockSnapshot({ + heroVisible: true, + briefing: LONG_HEADLINE_BRIEFING, + scoreRings: MOCK_SCORE_RINGS, +}); +snapshot.user.username = "Marc"; +snapshot.healthScore = { score: 78, band: "green", components: [] }; + +await page.route(/\/api\/dashboard\/snapshot(\?|$)/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: snapshot, error: null }), + }), +); + +page.on("response", (r) => { + if (r.url().includes("dashboard/snapshot")) + console.log("SNAPSHOT RESPONSE:", r.status(), r.url()); +}); +await page.goto("http://localhost:3000/"); +await page + .waitForSelector('[data-verdict-variant], [data-slot="dashboard-hero"]', { + timeout: 20000, + }) + .catch(() => {}); +await page.waitForTimeout(1500); +console.log( + "BODY SLOTS:", + await page.evaluate(() => + Array.from(document.querySelectorAll("[data-slot]")) + .slice(0, 12) + .map((e) => e.getAttribute("data-slot")), + ), +); +console.log("URL:", page.url()); + +const hero = page.locator('[data-slot="dashboard-hero"]').first(); +const heroBox = await hero.boundingBox().catch(() => null); +await page.screenshot({ path: `${OUT}/${TAG}-full-2560.png` }); +if (heroBox) { + await page.screenshot({ + path: `${OUT}/${TAG}-hero-2560.png`, + clip: { + x: Math.max(0, heroBox.x - 8), + y: Math.max(0, heroBox.y - 8), + width: Math.min(2560, heroBox.width + 16), + height: heroBox.height + 16, + }, + }); +} + +// Geometry: paddings of the hero card + ring row gaps + greeting offset +const geo = await page.evaluate(() => { + const hero = document.querySelector('[data-slot="dashboard-hero"]'); + if (!hero) return { error: "no hero" }; + const hb = hero.getBoundingClientRect(); + const cs = getComputedStyle(hero); + const first = hero.firstElementChild?.getBoundingClientRect(); + const kids = Array.from(hero.querySelectorAll("*")); + // ring row: element containing multiple svg rings + const rings = kids + .filter((el) => el.querySelectorAll?.("svg circle").length >= 2) + .pop(); + const ringBox = rings?.getBoundingClientRect(); + const ringKids = rings + ? Array.from(rings.children).map((c) => c.getBoundingClientRect()) + : []; + const gaps = []; + for (let i = 1; i < ringKids.length; i++) + gaps.push( + Math.round(ringKids[i].x - (ringKids[i - 1].x + ringKids[i - 1].width)), + ); + const greeting = kids.find( + (el) => + /Willkommen|willkommen|Hallo/.test(el.textContent || "") && + el.children.length === 0, + ); + const gB = greeting?.getBoundingClientRect(); + // lowest content bottom inside hero + let maxBottom = 0; + kids.forEach((el) => { + const r = el.getBoundingClientRect(); + if (r.height > 0 && r.bottom > maxBottom && r.bottom <= hb.bottom + 1) + maxBottom = r.bottom; + }); + let minTop = Infinity; + kids.forEach((el) => { + const r = el.getBoundingClientRect(); + if (r.height > 0 && r.top < minTop && r.top >= hb.top - 1) minTop = r.top; + }); + return { + heroPadding: { + t: cs.paddingTop, + r: cs.paddingRight, + b: cs.paddingBottom, + l: cs.paddingLeft, + }, + contentInsets: { + top: Math.round(minTop - hb.top), + bottom: Math.round(hb.bottom - maxBottom), + }, + ringGaps: gaps, + ringRow: ringBox + ? { + top: Math.round(ringBox.top - hb.top), + right: Math.round(hb.right - ringBox.right), + } + : null, + greetingTopInHero: gB ? Math.round(gB.top - hb.top) : null, + heroSize: { w: Math.round(hb.width), h: Math.round(hb.height) }, + }; +}); +console.log(JSON.stringify(geo, null, 1)); + +await browser.close(); diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc33ae62..e2ec3b060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,43 @@ ## [Unreleased] +## [1.28.0] — 2026-07-09 — Document intelligence + +A milestone that closes the document line end to end. The vault stopped being a +place to _store_ files and became a place to _understand_ them — read, +searched, shared, and answered — with the privacy posture held at every step. +The document capabilities shipped incrementally across v1.27.17–v1.27.33; this +release marks them, together with a full correctness/resilience/quality pass, as +one coherent whole. + +### The document intelligence era + +- **Vault** — every letter, report, and scan in one place, encrypted at rest, + byte-classified serving (inline vs download), opt-in. +- **AI reading + automatic search** — documents are read and indexed + automatically on upload; a local reader keeps the file on the machine, an AI + provider reads richer (including scans) with consent. Content search matches + whole words _inside_ documents over an encrypted blind token index — nothing + readable is ever stored. +- **Sharing** — hand a clinician a time-boxed, revocable link with a mobile QR + code; camera metadata (EXIF/GPS) stripped on the way out. +- **Chat about a document** — ask a document questions in plain language, + grounded and cited, with a security model that treats the document as + untrusted: fenced as data, no tools, no health-record context, numeric-grounded, + never a diagnosis. +- **AI provider governance** — for documents, a local-first / no-training-API / + subscription-last provider order, explicit consent for any external egress, + and a vendor-blind notice before a document leaves the machine. + +### Correctness, resilience and polish + +The same line carried a full audit sweep: DST-safe medication windows and +user-timezone compliance; graceful degradation (worker timeouts, per-metric sync +freshness, offline-write safety, per-source isolation, honest "unreadable" +markers); no more dashboard dead-ends and honest error/empty states; accessibility +contrast and semantics; and cold-start + performance fixes — down to the low-priority +findings. + ## [1.27.33] — 2026-07-09 — Chat about a document ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d6e627ca7..f97b2277b 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.27.33 + version: 1.28.0 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 8e4212528..7a3e4b911 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.27.33", + "version": "1.28.0", "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 12d81e0b3..9aba131b3 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.27.33"; + /* @sw-version-fallback */ "v1.28.0"; 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/*`