diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 90dcfc9bd..987fc94bd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -69,7 +69,19 @@ jobs: - run: pnpm exec playwright install --with-deps chromium - - run: pnpm exec next build + # `pnpm build` (not bare `next build`): the prebuild hook generates + # the versioned service-worker stamp AND the static i18n catalog + # assets (public/i18n/.js) the client boots from — without + # them every UI interaction waits on missing catalogs. + - run: pnpm build + + # Client-bundle budget gate — rides this job because it is the one + # place CI already has a production build on disk. Catches the + # regressions the dead webpack-era tooling (ANALYZE=1, the old + # bundle-report) let through: a statically imported message catalog + # (~100 KB gz on every route) or a chart import bypassing the shared + # chart-runtime boundary (a duplicated ~92 KB gz recharts chunk). + - run: pnpm bundle-check - run: pnpm exec playwright test env: diff --git a/.gitignore b/.gitignore index 132725d27..fe011fcaa 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ next-env.d.ts # scripts/generate-sw-version.mjs; loaded by public/sw.js to key # the cache-version baseline. /public/sw-version.js +/public/i18n/ # v1.4.27 B3 — MaxMind GeoLite2 databases are fetched at build time # via `scripts/fetch-geolite2.sh`. Too large for git; license key diff --git a/CHANGELOG.md b/CHANGELOG.md index 1169040e6..ef877f577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## [Unreleased] +## [1.27.16] — 2026-07-07 — Offline that survives, and a faster first paint + +### Fixed + +- The installable app now survives being offline. A network failure on the auth probe was treated as a logged-out session — it wiped the offline caches and redirected to the login page, defeating the entire offline layer. Offline relaunches now render the dashboard from cache with the offline banner; only a real 401/403 or an explicit logout clears anything. +- Chart text is readable in the light theme again — 23 chart surfaces drew their ticks and axis text in colours measuring near-invisible contrast; they now use the semantic text tokens (measured 13.2:1 in light, dark byte-identical). +- On the mood insights page, correlation captions no longer escape their tiles on phone widths; the density guard now covers that route too. Row checkboxes gained a 32 px touch target at unchanged visual size. +- The dashboard hero breathes: the score rings space at 24 px on desktop, on phones the four rings spread evenly across the full width instead of clustering in the centre, and the briefing section separates from the greeting row at the same rhythm as the card padding. + +### Changed + +- Every page sheds roughly 100 KB of compressed JavaScript: the translation catalog no longer ships twice (once per route bundle, once inlined into the page document — the dashboard document alone shrinks from 139 KB to 25 KB compressed); catalogs now load once as a cached static asset. All sixteen charts share a single chart runtime chunk instead of up to eight copies. +- The dashboard's first paint carries real tile values straight from the server (measured −25 % largest-contentful-paint), and a new bundle-size budget gate in CI keeps route weights from regressing silently. + ## [1.27.15] — 2026-07-07 — Complete Google Health coverage ### Fixed diff --git a/bundle-budget.json b/bundle-budget.json new file mode 100644 index 000000000..80ad488e7 --- /dev/null +++ b/bundle-budget.json @@ -0,0 +1,11 @@ +{ + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Measured at v1.27.15 + the mobile/PWA fix wave: / 417, /insights 402, /measurements 405, /insights/mood 419, total 2683, baseline 129 — budgets carry ~10-15% headroom so the gate catches step changes (a statically imported ~100 KB catalog, a ~90 KB duplicated library chunk), not noise. Raise a number consciously, in the PR that pays the cost, with the reason in the PR body.", + "routesKbGz": { + "/page": 460, + "/insights/page": 445, + "/measurements/page": 445, + "/insights/mood/page": 460 + }, + "totalClientKbGz": 2950, + "maxRechartsChunks": 1 +} diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 868b433ec..2794af751 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.15 + version: 1.27.16 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/e2e/mobile-density.spec.ts b/e2e/mobile-density.spec.ts index 5478a0919..9a56f140c 100644 --- a/e2e/mobile-density.spec.ts +++ b/e2e/mobile-density.spec.ts @@ -7,6 +7,7 @@ import { mockDashboardSnapshot, POPULATED_SUMMARIES, } from "./utils/mock-dashboard-snapshot"; +import { mockMoodInsights } from "./utils/mock-mood-insights"; /** * Phone-width density guard (390×844 iPhone class, 360×780 small @@ -36,7 +37,7 @@ const VIEWPORTS = [ { width: 360, height: 780 }, ] as const; -const ROUTES = ["/", "/insights"] as const; +const ROUTES = ["/", "/insights", "/insights/mood"] as const; interface TileEscape { tile: string; @@ -116,6 +117,9 @@ test.describe("phone-width density guard", () => { briefing: LONG_HEADLINE_BRIEFING, scoreRings: MOCK_SCORE_RINGS, }); + if (route === "/insights/mood") { + await mockMoodInsights(page); + } await page.goto(route, { waitUntil: "domcontentloaded" }); await page.waitForLoadState("networkidle"); @@ -130,6 +134,19 @@ test.describe("phone-width density guard", () => { ).toHaveCount(3); } + if (route === "/insights/mood") { + // Non-vacuous: the five populated correlation cards and the + // discovered-relations list — the audit's only user-facing + // tile-escape cluster — must actually render before the guard + // sweeps them. + await expect( + page.locator('[data-slot="mood-correlation-card"]'), + ).toHaveCount(5); + await expect( + page.locator('[data-slot="mood-discovered-relations"]'), + ).toBeVisible(); + } + const dims = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, bodyScrollWidth: document.body.scrollWidth, diff --git a/e2e/utils/mock-mood-insights.ts b/e2e/utils/mock-mood-insights.ts new file mode 100644 index 000000000..36fe5c144 --- /dev/null +++ b/e2e/utils/mock-mood-insights.ts @@ -0,0 +1,122 @@ +import type { Page } from "@playwright/test"; + +/** + * Route mocks for the /insights/mood density guard. + * + * The correlation cards and the discovered-relations list only render with + * data, and the seeded e2e account has none — an unmocked run would pass the + * tile-escape guard vacuously against empty states. These payloads pin the + * measured worst case: five populated correlation cards whose header caption + * ("269 gepaarte Tage · r = 0.37") used to escape the card edge by 14–39 px + * at 390/360, plus mood-involving discovered pairs whose stat caption wrapped + * to a 4-line sliver beside the finding sentence. + */ + +function correlation(r: number, strength: string, n: number) { + return { + result: { r, strength, n }, + n, + points: Array.from({ length: 24 }, (_, i) => ({ + x: 1 + (i % 5), + y: 40 + ((i * 7) % 55), + })), + }; +} + +const MOOD_INSIGHTS_PAYLOAD = { + summary: { totalEntries: 269, inTargetPct: 62 }, + heatmap: { + windowDays: 90, + cells: [ + { date: "2026-07-01", score: 4, samples: 2 }, + { date: "2026-07-02", score: 3, samples: 1 }, + ], + }, + distribution: [], + weekday: [], + timeOfDay: { reliable: false }, + stability: null, + tags: [], + structuredTags: [], + tagInfluence: { flat: [], structured: [] }, + betterDays: [], + tagMetricCrosstab: [], + factorCrosstab: [], + narratives: [], + correlations: { + sleep: correlation(0.37, "schwach", 269), + steps: correlation(0.52, "moderat", 231), + pulse: correlation(-0.44, "moderat", 258), + weight: correlation(0.21, "schwach", 244), + bloodPressureSystolic: correlation(-0.73, "stark", 212), + }, +}; + +const CORRELATION_DISCOVERY_PAYLOAD = { + discovered: [ + { + behaviour: "SLEEP_DURATION", + outcome: "MOOD", + n: 269, + r: 0.37, + pValue: 0.001, + qValue: 0.012, + interpretation: "", + lagDays: 1, + }, + { + behaviour: "MOOD", + outcome: "RESTING_HEART_RATE", + n: 231, + r: -0.41, + pValue: 0.002, + qValue: 0.031, + interpretation: "", + lagDays: 1, + }, + { + behaviour: "TIME_IN_DAYLIGHT", + outcome: "MOOD", + n: 187, + r: 0.29, + pValue: 0.004, + qValue: 0.048, + interpretation: "", + lagDays: 0, + }, + ], + pairsTested: 42, + fdrQ: 0.1, + minPairs: 20, +}; + +/** Serve populated mood-insights + discovery payloads for the density guard. */ +export async function mockMoodInsights(page: Page): Promise { + await page.route(/\/api\/insights\/comprehensive(\?|$)/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { moodSummary: { count: 269 } }, + error: null, + }), + }); + }); + await page.route(/\/api\/mood\/insights(\?|$)/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: MOOD_INSIGHTS_PAYLOAD, error: null }), + }); + }); + await page.route(/\/api\/insights\/correlations(\?|$)/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: CORRELATION_DISCOVERY_PAYLOAD, + error: null, + }), + }); + }); +} diff --git a/package.json b/package.json index 6d2ad5b5d..7043ed477 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.27.15", + "version": "1.27.16", "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", @@ -26,10 +26,12 @@ "scripts": { "prepare": "lefthook install || echo 'lefthook install skipped (no git repo or binary unavailable)'", "dev": "next dev", - "prebuild": "node scripts/generate-sw-version.mjs", + "predev": "node scripts/generate-i18n-boot.mjs", + "prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs", "build": "next build", "analyze": "ANALYZE=1 next build", - "bundle-report": "node scripts/print-bundle-report.mjs", + "bundle-report": "node scripts/check-bundle-budget.mjs", + "bundle-check": "node scripts/check-bundle-budget.mjs --check", "start": "next start", "lint": "eslint", "db:generate": "prisma generate", diff --git a/playwright.config.ts b/playwright.config.ts index 1c9773571..43dd69d7b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -88,5 +88,17 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, stdout: "ignore", stderr: "pipe", + env: { + ...process.env, + // The dashboard RSC wrapper server-prefetches the snapshot into + // the first HTML (HydrationBoundary). Playwright's route mocks + // only see CLIENT fetches, so an SSR-embedded snapshot would + // bypass `mockDashboardSnapshot` and every dashboard spec would + // assert against the seeded account instead of its fixture. + // Disable the prefetch for the e2e server — the suite keeps the + // deterministic client-fetch path; the SSR fast path is + // verified by Lighthouse/manual passes. + DASHBOARD_SSR_PREFETCH: "false", + }, }, }); diff --git a/public/sw.js b/public/sw.js index 8c8141415..b8d416146 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.15"; + /* @sw-version-fallback */ "v1.27.16"; 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/*` @@ -168,6 +168,17 @@ self.addEventListener("fetch", (event) => { return; } + // Locale-catalog boot script — cache-first. The URL is versioned + // (`/i18n/?v=`) and served immutable, so cache-first is + // correct and keeps the catalog available on an offline relaunch (the + // cached shell HTML references the same versioned URL it was rendered + // with). Carries no user data — same public strings as the repository's + // messages/*.json. + if (url.pathname.startsWith("/i18n/")) { + event.respondWith(cacheFirst(request, STATIC_CACHE)); + return; + } + // Static files (fonts, icons, images) — cache-first with long TTL if (/\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname)) { event.respondWith(cacheFirst(request, STATIC_CACHE)); diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs new file mode 100644 index 000000000..d38c17174 --- /dev/null +++ b/scripts/check-bundle-budget.mjs @@ -0,0 +1,218 @@ +#!/usr/bin/env node +/** + * Client-bundle report + budget gate for the Turbopack build. + * + * The webpack-era tooling died on Next 16: `ANALYZE=1` is a silent no-op + * under Turbopack and the old `.next/analyze/client.json` reader had + * nothing to read — so bundle regressions (a statically imported message + * catalog, a duplicated recharts chunk group) shipped invisibly. This + * script derives the signal straight from the build output instead: + * + * - per-route client-JS totals (gzip) from the Turbopack + * `*_client-reference-manifest.js` files plus the shared + * `rootMainFiles` baseline from `build-manifest.json`; + * - the total gzip weight of every emitted client chunk; + * - a recharts-duplication guard: the number of chunks carrying the + * recharts library fingerprint must stay exactly 1 (the shared + * chart-runtime chunk group); + * - a catalog guard: no message-catalog-fingerprinted chunk may be + * referenced by any route's client-reference manifest or by the + * shared baseline (catalogs load only lazily / via /i18n/). + * + * Usage: + * pnpm bundle-report # print the report table + * pnpm bundle-report --check # enforce bundle-budget.json (CI gate) + * + * Budgets live in `bundle-budget.json` (repo root). Numbers are KB gzip + * with deliberate headroom over the measured value — the gate exists to + * catch step-change regressions (a ~100 KB catalog, a ~90 KB duplicate + * library), not day-to-day noise. Raise a budget consciously, in the same + * PR that pays the cost, with the reason in the PR body. + */ + +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { gzipSync } from "node:zlib"; + +const CHECK = process.argv.includes("--check"); +const BUILD_DIR = join(process.cwd(), ".next"); +const APP_DIR = join(BUILD_DIR, "server", "app"); +const BUDGET_PATH = join(process.cwd(), "bundle-budget.json"); + +if (!existsSync(join(BUILD_DIR, "build-manifest.json"))) { + console.error( + "check-bundle-budget: no .next/build-manifest.json — run `pnpm build` first.", + ); + process.exit(2); +} + +/** Recharts library fingerprint (present once in the shared runtime chunk). */ +const RECHARTS_MARK = "CartesianGrid"; +/** + * Message-catalog fingerprint. The key set is identical across all six + * locales, so a KEY-position match ("typeTimeInDaylight" directly followed + * by a colon, in either the plain or the JSON.parse-escaped chunk encoding) + * marks a bundled catalog. Source modules only ever carry the namespaced + * call-site form (`measurements.typeTimeInDaylight`), which matches neither. + */ +const CATALOG_MARKS = ['"typeTimeInDaylight":', 'typeTimeInDaylight\\":']; + +const sizeCache = new Map(); +function chunkInfo(file) { + if (!sizeCache.has(file)) { + try { + const buf = readFileSync(join(BUILD_DIR, file)); + sizeCache.set(file, { + raw: buf.length, + gz: gzipSync(buf, { level: 9 }).length, + recharts: buf.includes(RECHARTS_MARK), + catalog: CATALOG_MARKS.some((m) => buf.includes(m)), + }); + } catch { + sizeCache.set(file, { raw: 0, gz: 0, recharts: false, catalog: false }); + } + } + return sizeCache.get(file); +} + +function routeChunks(manifestFile) { + const src = readFileSync(manifestFile, "utf8"); + const idx = src.indexOf("= {"); + const json = JSON.parse(src.slice(idx + 2).replace(/;\s*$/, "")); + const files = new Set(); + for (const key of Object.keys(json.clientModules ?? {})) { + for (const chunk of json.clientModules[key].chunks ?? []) { + if (chunk.endsWith(".js")) files.add(chunk.replace(/^\/_next\//, "")); + } + } + return files; +} + +function findRouteManifests(dir, prefix = "") { + let out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) { + out = out.concat(findRouteManifests(p, `${prefix}/${entry.name}`)); + } else if (entry.name.endsWith("_client-reference-manifest.js")) { + out.push({ + route: `${prefix}/${entry.name.replace("_client-reference-manifest.js", "")}`, + file: p, + }); + } + } + return out; +} + +const kb = (bytes) => bytes / 1024; +const fmt = (bytes) => `${kb(bytes).toFixed(0)} KB`; + +// ── Shared baseline ───────────────────────────────────────────────────────── +const buildManifest = JSON.parse( + readFileSync(join(BUILD_DIR, "build-manifest.json"), "utf8"), +); +const rootFiles = buildManifest.rootMainFiles ?? []; +let rootGz = 0; +for (const f of rootFiles) rootGz += chunkInfo(f).gz; + +// ── Per-route totals ──────────────────────────────────────────────────────── +const manifests = findRouteManifests(APP_DIR); +const routeTotals = new Map(); +for (const { route, file } of manifests) { + let gz = 0; + let catalogRef = false; + for (const chunk of routeChunks(file)) { + const info = chunkInfo(chunk); + gz += info.gz; + if (info.catalog) catalogRef = true; + } + routeTotals.set(route, { gz: gz + rootGz, catalogRef }); +} + +// ── Whole-build chunk scan ────────────────────────────────────────────────── +const chunkDir = join(BUILD_DIR, "static", "chunks"); +let totalGz = 0; +let rechartsChunks = 0; +let largest = { file: "-", gz: 0 }; +for (const f of readdirSync(chunkDir)) { + if (!f.endsWith(".js")) continue; + const info = chunkInfo(join("static", "chunks", f)); + totalGz += info.gz; + if (info.recharts) rechartsChunks += 1; + if (info.gz > largest.gz) largest = { file: f, gz: info.gz }; +} +const rootCatalogRef = rootFiles.some((f) => chunkInfo(f).catalog); + +// ── Report ────────────────────────────────────────────────────────────────── +const budget = existsSync(BUDGET_PATH) + ? JSON.parse(readFileSync(BUDGET_PATH, "utf8")) + : null; + +console.log(`shared baseline (rootMainFiles): ${fmt(rootGz)} gz`); +console.log(`all client chunks: ${fmt(totalGz)} gz`); +console.log( + `largest chunk: ${fmt(largest.gz)} gz ${largest.file}`, +); +console.log(`recharts-fingerprint chunks: ${rechartsChunks}`); +console.log(""); + +const watched = budget ? Object.keys(budget.routesKbGz ?? {}) : []; +const rows = [...routeTotals.entries()] + .filter(([route]) => !watched.length || watched.includes(route)) + .sort((a, b) => b[1].gz - a[1].gz); +for (const [route, { gz }] of rows.slice(0, watched.length || 15)) { + const cap = budget?.routesKbGz?.[route]; + console.log( + `${fmt(gz).padStart(8)} gz ${route}${cap ? ` (budget ${cap} KB)` : ""}`, + ); +} + +if (!CHECK) process.exit(0); + +// ── Budget gate ───────────────────────────────────────────────────────────── +if (!budget) { + console.error("check-bundle-budget: bundle-budget.json missing."); + process.exit(2); +} + +const failures = []; +for (const [route, capKb] of Object.entries(budget.routesKbGz ?? {})) { + const actual = routeTotals.get(route); + if (!actual) { + failures.push(`route ${route} not found in the build output`); + continue; + } + if (kb(actual.gz) > capKb) { + failures.push( + `${route}: ${fmt(actual.gz)} gz exceeds the ${capKb} KB budget`, + ); + } +} +if (budget.totalClientKbGz && kb(totalGz) > budget.totalClientKbGz) { + failures.push( + `total client JS ${fmt(totalGz)} gz exceeds the ${budget.totalClientKbGz} KB budget`, + ); +} +if ( + budget.maxRechartsChunks != null && + rechartsChunks > budget.maxRechartsChunks +) { + failures.push( + `${rechartsChunks} recharts-fingerprint chunks (budget ${budget.maxRechartsChunks}) — a chart import bypassed the shared chart-runtime boundary`, + ); +} +const catalogRefRoutes = [...routeTotals.entries()] + .filter(([, v]) => v.catalogRef) + .map(([r]) => r); +if (catalogRefRoutes.length > 0 || rootCatalogRef) { + failures.push( + `message catalog statically referenced by ${rootCatalogRef ? "the shared baseline" : catalogRefRoutes.join(", ")} — catalogs must stay lazy (/i18n/ + dynamic import)`, + ); +} + +if (failures.length > 0) { + console.error("\nBundle budget check FAILED:"); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); +} +console.log("\nBundle budget check passed."); diff --git a/scripts/generate-i18n-boot.mjs b/scripts/generate-i18n-boot.mjs new file mode 100644 index 000000000..74bd331c0 --- /dev/null +++ b/scripts/generate-i18n-boot.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * Generate the locale-catalog boot scripts (`public/i18n/.js`). + * + * The root layout emits `