chore(release): v1.28.0 — document intelligence

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 08:42:23 +02:00
1 parent 1c7ab080f0
commit b143576627
7 files changed
+490 -3

No files matched your search

+150
View File
@@ -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();
+150
View File
@@ -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();
+150
View File
@@ -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();
+37
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+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.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/*`