mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge pull request #426 from MBombeck/release/v1.27.16
Release v1.27.16 — offline that survives, and a faster first paint
This commit is contained in:
60 files changed
+3079
-2098
No files matched your search
@@ -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/<locale>.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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
+5
-3
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
+12
-1
@@ -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/<locale>?v=<build>`) 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));
|
||||
|
||||
@@ -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/<locale>).
|
||||
*
|
||||
* 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/<locale> + 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.");
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate the locale-catalog boot scripts (`public/i18n/<locale>.js`).
|
||||
*
|
||||
* The root layout emits `<script src="/i18n/<locale>.js?v=<version>" defer>`
|
||||
* ahead of Next's hydration scripts; the body assigns the active locale's
|
||||
* full message bundle to `self.__HL_I18N`, which `src/lib/i18n/load-locale.ts`
|
||||
* reads in its browser cache initializer. This replaces the former RSC-prop
|
||||
* handoff that serialized the ENTIRE active catalog into every document's
|
||||
* flight payload (392 KB of a 505 KB dashboard HTML, re-downloaded on every
|
||||
* hard load).
|
||||
*
|
||||
* Static `public/` files (rather than a route handler) because Next serves
|
||||
* them with an ETag — repeat loads revalidate to a 304 instead of
|
||||
* re-downloading the catalog — and the service worker caches the versioned
|
||||
* URL cache-first for the offline relaunch. The `?v=` query the layout
|
||||
* appends busts both caches per deploy.
|
||||
*
|
||||
* Runs as a `prebuild`/`predev` step (the `sw-version.js` pattern); output
|
||||
* is gitignored. Content is the same public catalog JSON that ships in the
|
||||
* repository — no tenant data, no secrets.
|
||||
*/
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = join(__dirname, "..");
|
||||
const outDir = join(repoRoot, "public", "i18n");
|
||||
|
||||
const LOCALES = ["de", "en", "fr", "es", "it", "pl"];
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const banner =
|
||||
"// AUTO-GENERATED by scripts/generate-i18n-boot.mjs — do not edit.\n";
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const messages = JSON.parse(
|
||||
readFileSync(join(repoRoot, "messages", `${locale}.json`), "utf8"),
|
||||
);
|
||||
const payload = JSON.stringify({ locale, messages });
|
||||
writeFileSync(
|
||||
join(outDir, `${locale}.js`),
|
||||
`${banner}self.__HL_I18N = ${payload};\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`generate-i18n-boot: wrote ${LOCALES.length} catalogs to public/i18n/\n`,
|
||||
);
|
||||
@@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Reads `.next/analyze/client.json` (emitted when `pnpm analyze` runs
|
||||
* `@next/bundle-analyzer` with `ANALYZE=1`) and prints a sorted table
|
||||
* of the largest client chunks. Turbopack dropped the webpack
|
||||
* "First Load JS" summary line from `next build`; this script restores
|
||||
* an equivalent at-a-glance signal so release rounds can spot
|
||||
* client-bundle regressions without re-running Lighthouse.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm analyze # writes .next/analyze/client.json
|
||||
* pnpm bundle-report # reads it back, prints top chunks
|
||||
*
|
||||
* Output columns: rank, chunk label, parsed (post-minify) size, gzip
|
||||
* size, stat (pre-minify) size. Sizes report in KiB to two decimals.
|
||||
* Exits 0 unless `client.json` is missing or unreadable.
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const REPORT_PATH = resolve(process.cwd(), ".next", "analyze", "client.json");
|
||||
const DEFAULT_TOP_N = 10;
|
||||
|
||||
function formatKib(bytes) {
|
||||
if (typeof bytes !== "number" || Number.isNaN(bytes)) return "—";
|
||||
return `${(bytes / 1024).toFixed(2)} KiB`;
|
||||
}
|
||||
|
||||
function padCell(value, width) {
|
||||
const str = String(value ?? "");
|
||||
return str.length >= width ? str : str + " ".repeat(width - str.length);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const argTopN = Number(process.argv[2]);
|
||||
const topN =
|
||||
Number.isFinite(argTopN) && argTopN > 0
|
||||
? Math.floor(argTopN)
|
||||
: DEFAULT_TOP_N;
|
||||
|
||||
let raw;
|
||||
try {
|
||||
raw = await readFile(REPORT_PATH, "utf8");
|
||||
} catch (err) {
|
||||
if (err && err.code === "ENOENT") {
|
||||
console.error(
|
||||
`bundle-report: ${REPORT_PATH} not found. Run \`pnpm analyze\` first.`,
|
||||
);
|
||||
} else {
|
||||
console.error(`bundle-report: failed to read ${REPORT_PATH}:`, err);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let chunks;
|
||||
try {
|
||||
chunks = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.error(`bundle-report: failed to parse ${REPORT_PATH}:`, err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!Array.isArray(chunks)) {
|
||||
console.error(
|
||||
"bundle-report: client.json is not an array of chunks — analyzer output shape changed?",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sorted = [...chunks].sort(
|
||||
(a, b) => (b.parsedSize ?? 0) - (a.parsedSize ?? 0),
|
||||
);
|
||||
const top = sorted.slice(0, topN);
|
||||
|
||||
const totals = chunks.reduce(
|
||||
(acc, c) => ({
|
||||
parsed: acc.parsed + (c.parsedSize ?? 0),
|
||||
gzip: acc.gzip + (c.gzipSize ?? 0),
|
||||
stat: acc.stat + (c.statSize ?? 0),
|
||||
}),
|
||||
{ parsed: 0, gzip: 0, stat: 0 },
|
||||
);
|
||||
|
||||
const labelWidth = Math.max(
|
||||
20,
|
||||
...top.map((c) => String(c.label ?? "").length),
|
||||
);
|
||||
const header =
|
||||
`${padCell("#", 3)} ${padCell("chunk", labelWidth)} ` +
|
||||
`${padCell("parsed", 12)} ${padCell("gzip", 12)} ${padCell("stat", 12)}`;
|
||||
|
||||
console.log(
|
||||
`Top ${top.length} client chunks by parsed size (of ${chunks.length} total):`,
|
||||
);
|
||||
console.log(header);
|
||||
console.log("-".repeat(header.length));
|
||||
top.forEach((chunk, idx) => {
|
||||
console.log(
|
||||
`${padCell(idx + 1, 3)} ${padCell(chunk.label ?? "(unnamed)", labelWidth)} ` +
|
||||
`${padCell(formatKib(chunk.parsedSize), 12)} ` +
|
||||
`${padCell(formatKib(chunk.gzipSize), 12)} ` +
|
||||
`${padCell(formatKib(chunk.statSize), 12)}`,
|
||||
);
|
||||
});
|
||||
console.log("-".repeat(header.length));
|
||||
console.log(
|
||||
`${padCell("Σ", 3)} ${padCell("all chunks", labelWidth)} ` +
|
||||
`${padCell(formatKib(totals.parsed), 12)} ` +
|
||||
`${padCell(formatKib(totals.gzip), 12)} ` +
|
||||
`${padCell(formatKib(totals.stat), 12)}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("bundle-report: unexpected failure:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -27,7 +27,7 @@ import { join } from "node:path";
|
||||
* holds; the matchers only relaxed to tolerate the `enabled` arg.
|
||||
*/
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const PAGE_PATH = join(ROOT, "src/app/page.tsx");
|
||||
const PAGE_PATH = join(ROOT, "src/app/page-client.tsx");
|
||||
|
||||
function load(path: string): string {
|
||||
return readFileSync(path, "utf8");
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
|
||||
const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx");
|
||||
|
||||
describe("resolveChartRevealState", () => {
|
||||
it("stays hidden while the gate is not armed (no visible charts yet)", () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { join } from "node:path";
|
||||
* layout toggle) the header takes it back behind `showGreeting`,
|
||||
* fed from the page's hero gate — the greeting never disappears.
|
||||
*/
|
||||
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
|
||||
const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx");
|
||||
const HEADER_PATH = join(
|
||||
process.cwd(),
|
||||
"src/components/dashboard/dashboard-header.tsx",
|
||||
|
||||
@@ -23,7 +23,7 @@ import { join } from "node:path";
|
||||
* rather than silently regressing the streaming-composition contract.
|
||||
*/
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const PAGE_PATH = join(ROOT, "src/app/page.tsx");
|
||||
const PAGE_PATH = join(ROOT, "src/app/page-client.tsx");
|
||||
|
||||
function load(path: string): string {
|
||||
return readFileSync(path, "utf8");
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
resolveChartRowPlaceholderCount,
|
||||
resolveConfiguredTileCount,
|
||||
resolveDashboardFirstPaintGate,
|
||||
} from "../page";
|
||||
} from "../page-client";
|
||||
import {
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
type DashboardLayout,
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
* snapshot fetch. The pure `resolveDashboardFirstPaintGate` carries the
|
||||
* behaviour; textual pins keep the wiring honest.
|
||||
*/
|
||||
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
|
||||
const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx");
|
||||
|
||||
describe("resolveDashboardFirstPaintGate", () => {
|
||||
const base = {
|
||||
|
||||
@@ -28,7 +28,7 @@ import { join } from "node:path";
|
||||
*/
|
||||
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const DASHBOARD_PATH = join(ROOT, "src/app/page.tsx");
|
||||
const DASHBOARD_PATH = join(ROOT, "src/app/page-client.tsx");
|
||||
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page.tsx");
|
||||
const INSIGHTS_LAYOUT_PATH = join(ROOT, "src/app/insights/layout.tsx");
|
||||
|
||||
|
||||
@@ -86,7 +86,10 @@ describe("RootLoading — single skeleton source (wiring pins)", () => {
|
||||
join(process.cwd(), "src/app/loading.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const pageSrc = readFileSync(join(process.cwd(), "src/app/page.tsx"), "utf8");
|
||||
const pageSrc = readFileSync(
|
||||
join(process.cwd(), "src/app/page-client.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("imports the same silhouette components the page's loading phase uses", () => {
|
||||
expect(loadingSrc).toContain(
|
||||
|
||||
@@ -29,34 +29,14 @@
|
||||
* read-only from `User.insightsCachedText`. The nightly
|
||||
* `insight-pregenerate` cron keeps that cache warm.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
|
||||
import {
|
||||
buildDashboardSnapshot,
|
||||
type DashboardSnapshot,
|
||||
type SnapshotUserInput,
|
||||
} from "@/lib/dashboard/snapshot";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Per-key TTL for the snapshot cache entry. Strictly greater than the
|
||||
* client's 120 s refetch interval so a scheduled poll lands on a warm
|
||||
* entry instead of a guaranteed miss that re-runs the full builder. The
|
||||
* 60 s headroom absorbs interval jitter / a poll firing a touch late.
|
||||
* The analytics bucket's 60 s default still governs the slim / thick /
|
||||
* mood cells; only this key is lengthened. Eviction on writes is
|
||||
* unchanged (the `${userId}|` prefix sweep / point-delete both ignore
|
||||
* the TTL).
|
||||
*/
|
||||
const SNAPSHOT_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dashboard.snapshot" } });
|
||||
@@ -72,45 +52,10 @@ export const GET = apiHandler(async () => {
|
||||
return result;
|
||||
};
|
||||
|
||||
// `requireAuth().user` is the full Prisma `User` row, so every field
|
||||
// the builder needs is already present — no extra round-trip.
|
||||
const snapshotUser: SnapshotUserInput = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
timezone: user.timezone,
|
||||
heightCm: user.heightCm,
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
glucoseUnit: user.glucoseUnit,
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
disableCoach: user.disableCoach,
|
||||
insightsCachedText: user.insightsCachedText,
|
||||
insightsCachedAt: user.insightsCachedAt,
|
||||
dashboardWidgetsJson: user.dashboardWidgetsJson,
|
||||
};
|
||||
|
||||
// v1.21.2 (A4) — the briefing recall + forward-look is locale-specific prose,
|
||||
// so the snapshot cache key carries the resolved locale: an EN and a DE
|
||||
// session never share a snapshot cell (and never see each other's memory
|
||||
// wording). The `${user.id}|` prefix still covers the key under the
|
||||
// measurement-write stale-sweep.
|
||||
const locale = await resolveServerLocale({ userLocale: user.locale });
|
||||
|
||||
// Stale-while-revalidate: a measurement write marks the analytics
|
||||
// bucket stale rather than hard-evicting the snapshot, so a busy iOS
|
||||
// sync serves the prior snapshot immediately (within the bucket's
|
||||
// stale window) while a single background recompute warms a fresh one
|
||||
// — the foreground request never pays the cold rebuild. Hard-evicting
|
||||
// writes (widget reorder) still drop the key outright, forcing a clean
|
||||
// miss + synchronous rebuild here.
|
||||
const body = await cachedSwr(
|
||||
caches.analytics as ServerCache<DashboardSnapshot>,
|
||||
`${user.id}|dashboard-snapshot|${locale}`,
|
||||
() => buildDashboardSnapshot(prisma, snapshotUser, { time, locale }),
|
||||
annotate,
|
||||
SNAPSHOT_CACHE_TTL_MS,
|
||||
);
|
||||
// The cached read (user-row mapping, locale resolution, SWR cell) is
|
||||
// shared with the dashboard RSC prefetch — see
|
||||
// `src/lib/dashboard/snapshot-read.ts` for the cache-key/TTL contract.
|
||||
const { body } = await readDashboardSnapshotCached(user, time);
|
||||
|
||||
// Only surface timings on the cache-miss path (the hit path skips the
|
||||
// whole builder and leaves `timings` empty).
|
||||
|
||||
@@ -30,7 +30,7 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
*/
|
||||
const MoodChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/mood-chart").then((mod) => ({
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.MoodChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
|
||||
+19
-12
@@ -7,7 +7,6 @@ import { AuthShell } from "@/components/layout/auth-shell";
|
||||
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
|
||||
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
|
||||
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
|
||||
import { allMessages } from "@/lib/i18n/shared-resolve";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-sans",
|
||||
@@ -89,13 +88,6 @@ export default async function RootLayout({
|
||||
: undefined;
|
||||
|
||||
const initialLocale = await resolveInitialLocale();
|
||||
// RSC handoff for the i18n bundle split: only EN ships statically in
|
||||
// the client chunk, so a non-EN first paint needs its bundle inlined
|
||||
// into the payload here — that is what keeps the split free of the
|
||||
// EN→DE hydration flash. EN passes nothing (the client already holds
|
||||
// the static fallback floor).
|
||||
const initialMessages =
|
||||
initialLocale === "en" ? undefined : allMessages[initialLocale];
|
||||
|
||||
return (
|
||||
<html lang={initialLocale} suppressHydrationWarning>
|
||||
@@ -105,12 +97,27 @@ export default async function RootLayout({
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: themeScript }}
|
||||
/>
|
||||
{/*
|
||||
Locale-catalog boot script (generated into public/i18n/ by
|
||||
scripts/generate-i18n-boot.mjs, a prebuild/predev step).
|
||||
Replaces the former RSC-prop handoff that inlined the whole
|
||||
active catalog into every document's flight payload (392 KB of a
|
||||
505 KB dashboard HTML). `defer` keeps it off the parse/paint
|
||||
critical path while still executing before Next's hydration
|
||||
module scripts (both ride the after-parse in-order queue, and
|
||||
this tag comes first), so `load-locale.ts` finds `self.__HL_I18N`
|
||||
set when the client bundle initializes — SSR text and first
|
||||
client render stay identical. Served as a static asset: ETag
|
||||
revalidation (304 on repeat loads) + SW cache-first on the
|
||||
versioned URL replace the per-document payload.
|
||||
*/}
|
||||
<script
|
||||
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}`}
|
||||
defer
|
||||
/>
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<Providers
|
||||
initialLocale={initialLocale}
|
||||
initialMessages={initialMessages}
|
||||
>
|
||||
<Providers initialLocale={initialLocale}>
|
||||
<MonitoringBootstrap />
|
||||
<WebVitalsReporter />
|
||||
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+67
-1716
File diff suppressed because it is too large.
Load diff
@@ -267,8 +267,8 @@ export function HostMetricsChart() {
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
labelStyle={{ color: "var(--dracula-fg)" }}
|
||||
itemStyle={{ color: "var(--dracula-fg)" }}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
formatter={(value, name) => {
|
||||
if (typeof value !== "number") return [String(value), name];
|
||||
if (name === t("admin.hostMetrics.load1")) {
|
||||
|
||||
@@ -32,7 +32,7 @@ import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
||||
// working — same pattern the insights page uses for the scatter chart.
|
||||
const HostMetricsChart = dynamic(
|
||||
() =>
|
||||
import("./host-metrics-chart").then((mod) => ({
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.HostMetricsChart,
|
||||
})),
|
||||
{
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* The ONE async boundary for every recharts-rendering component.
|
||||
*
|
||||
* Every `next/dynamic` chart import in the tree points at THIS module
|
||||
* (`import("@/components/charts/chart-runtime")`), never at the individual
|
||||
* chart files. Pointing N dynamic boundaries at N different modules made
|
||||
* Turbopack emit a separate chunk group per boundary, each carrying its own
|
||||
* full copy of the recharts module graph — the production build shipped
|
||||
* EIGHT ~312 KB copies of the library, and /insights alone downloaded two
|
||||
* (~92 KB gz of pure duplication on the worst-LCP route). One shared import
|
||||
* target ⇒ one shared chunk group ⇒ recharts exists exactly once, cached
|
||||
* across every route.
|
||||
*
|
||||
* Extends the v1.16.7 `mood-charts.ts` precedent (which did this for the
|
||||
* mood trio) to the whole chart surface; that scoped barrel is folded in
|
||||
* here.
|
||||
*
|
||||
* Rules:
|
||||
* - A new recharts consumer is added HERE and loaded through
|
||||
* `dynamic(() => import("@/components/charts/chart-runtime").then(...))`
|
||||
* at its call site — with the call site's own matching skeleton.
|
||||
* - No component outside this module's static import graph may import
|
||||
* "recharts" directly.
|
||||
* - Types stay importable from the component files directly (type-only
|
||||
* imports are value-free and don't drag the chunk in).
|
||||
*/
|
||||
|
||||
export { HealthChart } from "./health-chart";
|
||||
export { MoodChart } from "./mood-chart";
|
||||
export { MedicationComplianceChart } from "./medication-compliance-chart";
|
||||
export { ScatterCorrelationChart } from "./scatter-correlation-chart";
|
||||
export { HostMetricsChart } from "@/components/admin/host-metrics-chart";
|
||||
export { CustomMetricChart } from "@/components/custom-metrics/custom-metric-chart";
|
||||
export { BbtChart } from "@/components/cycle/bbt-chart";
|
||||
export { DeltaSparkline } from "@/components/insights/derived/delta-sparkline";
|
||||
export { MoodDistributionChart } from "@/components/insights/mood/mood-distribution-chart";
|
||||
export { MoodTimeOfDayChart } from "@/components/insights/mood/mood-time-of-day-chart";
|
||||
export { MoodWeekdayChart } from "@/components/insights/mood/mood-weekday-chart";
|
||||
export { SleepStageStackedBar } from "@/components/insights/sleep-stage-stacked-bar";
|
||||
export { LabBiomarkerChart } from "@/components/labs/lab-biomarker-chart";
|
||||
export { DoseStrengthCurve } from "@/components/medications/dose-strength-curve";
|
||||
export { DrugLevelChart } from "@/components/medications/drug-level-chart";
|
||||
export { AssessmentHistoryChart } from "@/components/mental-health/assessment-history-chart";
|
||||
@@ -14,7 +14,7 @@ import { importWithRetry } from "@/lib/retry-import";
|
||||
* Pre-fix, eight call sites (`src/app/page.tsx`, five insights
|
||||
* sub-pages, `sleep-duration-chart.tsx`, `trends-row.tsx`, and
|
||||
* `vo2-max-chart-row.tsx`) duplicated the same
|
||||
* `dynamic(() => import("@/components/charts/health-chart"), { ... })`
|
||||
* `dynamic(() => import("@/components/charts/chart-runtime"), { ... })`
|
||||
* incantation. The duplication meant the loading skeleton + `ssr: false`
|
||||
* flag had to be re-wired on every caller.
|
||||
*
|
||||
@@ -31,7 +31,7 @@ import { importWithRetry } from "@/lib/retry-import";
|
||||
*/
|
||||
const HealthChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("@/components/charts/health-chart")).then(
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({ default: mod.HealthChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
@@ -59,7 +59,7 @@ export function HealthChartDynamic(
|
||||
*/
|
||||
const HealthChartLazyMini = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("@/components/charts/health-chart")).then(
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({ default: mod.HealthChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton mini /> },
|
||||
|
||||
@@ -128,12 +128,12 @@ export function ScatterCorrelationChart<T extends Record<string, number>>({
|
||||
type="number"
|
||||
name={xAxis.name}
|
||||
unit={xAxis.unit}
|
||||
tick={{ fontSize: 12, fill: "var(--dracula-fg)" }}
|
||||
tick={{ fontSize: 12, fill: "var(--foreground)" }}
|
||||
tickMargin={8}
|
||||
height={52}
|
||||
interval="preserveStartEnd"
|
||||
padding={{ left: 8, right: 8 }}
|
||||
stroke="var(--dracula-comment)"
|
||||
stroke="var(--muted-foreground)"
|
||||
domain={xAxis.domain}
|
||||
ticks={xAxis.ticks}
|
||||
tickFormatter={xAxis.tickFormatter}
|
||||
@@ -143,7 +143,7 @@ export function ScatterCorrelationChart<T extends Record<string, number>>({
|
||||
value: xAxis.label,
|
||||
position: "bottom",
|
||||
fontSize: 12,
|
||||
fill: "var(--dracula-comment)",
|
||||
fill: "var(--muted-foreground)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -153,8 +153,8 @@ export function ScatterCorrelationChart<T extends Record<string, number>>({
|
||||
type="number"
|
||||
name={yAxis.name}
|
||||
unit={yAxis.unit}
|
||||
tick={{ fontSize: 12, fill: "var(--dracula-fg)" }}
|
||||
stroke="var(--dracula-comment)"
|
||||
tick={{ fontSize: 12, fill: "var(--foreground)" }}
|
||||
stroke="var(--muted-foreground)"
|
||||
domain={yAxis.domain}
|
||||
ticks={yAxis.ticks}
|
||||
tickFormatter={yAxis.tickFormatter}
|
||||
@@ -166,8 +166,8 @@ export function ScatterCorrelationChart<T extends Record<string, number>>({
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
itemStyle={{ color: "var(--dracula-fg)" }}
|
||||
labelStyle={{ color: "var(--dracula-fg)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
formatter={tooltipFormatter}
|
||||
/>
|
||||
<Scatter data={data} fill={fill} opacity={0.8} />
|
||||
|
||||
@@ -42,9 +42,11 @@ import type {
|
||||
// off the detail page's first-load JS; the skeleton shell holds the layout.
|
||||
const CustomMetricChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("./custom-metric-chart")).then((mod) => ({
|
||||
default: mod.CustomMetricChart,
|
||||
})),
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({
|
||||
default: mod.CustomMetricChart,
|
||||
}),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
function CustomMetricChart(
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CycleRing } from "./cycle-ring";
|
||||
import { BbtChart } from "./bbt-chart";
|
||||
import { CycleCalendar } from "./cycle-calendar";
|
||||
import { CycleHistoryChart } from "./cycle-history-chart";
|
||||
import { LogDaySheet } from "./log-day-sheet";
|
||||
@@ -54,6 +55,33 @@ function shiftToday(days: number): string {
|
||||
|
||||
/** The four cycle tabs, in render order. The deep-link value is validated
|
||||
* against this set so a junk `?tab=` falls back to the calendar tab. */
|
||||
// The BBT chart is the only recharts consumer on /cycle — load it through
|
||||
// the shared chart-runtime boundary so the route stops carrying its own
|
||||
// copy of the recharts module graph. The loading shell mirrors the card
|
||||
// the chart paints (header row + caption + 210 px chart band inside the
|
||||
// standard card chrome) so the tab layout holds still while the shared
|
||||
// chunk streams.
|
||||
const BbtChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.BbtChart,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-card border-border rounded-xl border py-6">
|
||||
<div className="px-6">
|
||||
<Skeleton className="h-5 w-44" />
|
||||
</div>
|
||||
<div className="mt-6 px-6">
|
||||
<Skeleton className="mb-2 h-4 w-64" />
|
||||
<Skeleton className="h-[210px] w-full rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const CYCLE_TABS = ["calendar", "predictions", "insights", "settings"] as const;
|
||||
type CycleTab = (typeof CYCLE_TABS)[number];
|
||||
|
||||
|
||||
@@ -254,7 +254,10 @@ export function DashboardHero({
|
||||
"min-h-[8.75rem] p-4 md:min-h-[9.5rem] md:p-6",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
{/* Vertical rhythm: the section gap (greeting/ring row → briefing)
|
||||
reads one step larger than the intra-block spacing so the two
|
||||
zones separate cleanly; the card's own padding stays symmetric. */}
|
||||
<div className="flex h-full flex-col gap-4 md:gap-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
{/* Greeting leads the typographic hierarchy: larger + heavier
|
||||
@@ -320,7 +323,17 @@ export function DashboardHero({
|
||||
collapses; missing selected rings render nothing. */}
|
||||
<div
|
||||
data-slot="dashboard-hero-rings"
|
||||
className="flex shrink-0 flex-wrap items-center justify-center gap-3 md:justify-end"
|
||||
className={cn(
|
||||
// Mobile: the rings stack under the greeting as a full-width
|
||||
// row that wraps to a 2-up grid at four rings. `justify-evenly`
|
||||
// spreads them with equal space before / between / after so
|
||||
// the block reads symmetric edge-to-edge instead of a centred
|
||||
// cluster with dead outer margin; `gap-y-6` gives the wrapped
|
||||
// rows the same breathing room as the horizontal spread.
|
||||
// Desktop: back to a right-aligned inline row beside the copy.
|
||||
"flex w-full flex-wrap items-center justify-evenly gap-x-3 gap-y-6",
|
||||
"md:w-auto md:shrink-0 md:justify-end md:gap-6",
|
||||
)}
|
||||
>
|
||||
{scoreRings.map((ring) => (
|
||||
<div
|
||||
|
||||
@@ -47,10 +47,12 @@ vi.mock("next/dynamic", async () => {
|
||||
const mood = await import("@/components/charts/mood-chart");
|
||||
return {
|
||||
default: (loader: unknown) => {
|
||||
// Identify which chart the loader targets by its source text.
|
||||
// Identify which chart the loader targets by its source text. Every
|
||||
// loader now resolves the shared chart-runtime boundary and picks a
|
||||
// named export off it, so the export name is the discriminator.
|
||||
const src = String(loader);
|
||||
const Comp = (
|
||||
src.includes("mood-chart") ? mood.MoodChart : health.HealthChart
|
||||
src.includes("MoodChart") ? mood.MoodChart : health.HealthChart
|
||||
) as React.ComponentType<Record<string, unknown>>;
|
||||
return (props: Record<string, unknown>) => <Comp {...props} />;
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ import { AskCoachAction } from "./ask-coach-action";
|
||||
|
||||
const ScatterCorrelationChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/scatter-correlation-chart").then((mod) => ({
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.ScatterCorrelationChart,
|
||||
})),
|
||||
{
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
|
||||
|
||||
/**
|
||||
* The recharts body of the `<SparklineDeltaTile>` inline sparkline,
|
||||
* extracted so the tile itself carries no static recharts import. The tile
|
||||
* loads this through the shared chart-runtime boundary
|
||||
* (`@/components/charts/chart-runtime`); its fixed 40 px container is owned
|
||||
* by the tile, so the async gap paints an empty band of identical size —
|
||||
* zero layout shift, pixel-identical once mounted.
|
||||
*/
|
||||
export function DeltaSparkline({
|
||||
data,
|
||||
strokeVar,
|
||||
}: {
|
||||
/** `{ i, v }` points, already length-guarded (≥2) by the tile. */
|
||||
data: Array<{ i: number; v: number }>;
|
||||
/** CSS var expression for the sentiment stroke, e.g. `var(--success)`. */
|
||||
strokeVar: string;
|
||||
}) {
|
||||
// A stable per-instance id for the gradient <defs>. Deriving it from a
|
||||
// label slug collides when two tiles share a localized label (the gradient
|
||||
// fill on the second tile would not resolve); useId() is collision-free.
|
||||
const fillId = useId();
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 2, right: 0, bottom: 2, left: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id={fillId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={strokeVar} stopOpacity={0.28} />
|
||||
<stop offset="100%" stopColor={strokeVar} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis hide domain={["dataMin", "dataMax"]} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="v"
|
||||
stroke={strokeVar}
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#${fillId})`}
|
||||
isAnimationActive={false}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ArrowDown, ArrowRight, ArrowUp, Minus } from "lucide-react";
|
||||
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
@@ -28,6 +27,19 @@ import {
|
||||
* Fixed footprint so a tile never reflows its neighbours.
|
||||
*/
|
||||
|
||||
// The recharts sparkline body loads through the shared chart-runtime
|
||||
// boundary so this tile (statically imported by the vitals dashboard)
|
||||
// carries no recharts in its own chunk group. The tile owns the fixed
|
||||
// 40 px container, so the async gap is an identically-sized empty band —
|
||||
// no layout shift, no skeleton needed.
|
||||
const DeltaSparkline = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.DeltaSparkline,
|
||||
})),
|
||||
{ ssr: false, loading: () => null },
|
||||
);
|
||||
|
||||
export interface SparklineDeltaTileProps {
|
||||
label: string;
|
||||
/** Latest reading; `null` renders the empty state. */
|
||||
@@ -88,10 +100,6 @@ export function SparklineDeltaTile({
|
||||
}: SparklineDeltaTileProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
// A stable per-instance id for the gradient <defs>. Deriving it from the
|
||||
// label slug collides when two tiles share a localized label (the gradient
|
||||
// fill on the second tile would not resolve); useId() is collision-free.
|
||||
const fillId = useId();
|
||||
|
||||
const arrowSentiment = getTrendSentiment(delta ?? null, directionSentiment);
|
||||
const trendColor = sentimentColorClass(arrowSentiment);
|
||||
@@ -190,29 +198,7 @@ export function SparklineDeltaTile({
|
||||
className="mt-3 h-10 w-full"
|
||||
data-slot="sparkline-delta-tile-spark"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={sparkData}
|
||||
margin={{ top: 2, right: 0, bottom: 2, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={fillId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={strokeVar} stopOpacity={0.28} />
|
||||
<stop offset="100%" stopColor={strokeVar} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis hide domain={["dataMin", "dataMax"]} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="v"
|
||||
stroke={strokeVar}
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#${fillId})`}
|
||||
isAnimationActive={false}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<DeltaSparkline data={sparkData} strokeVar={strokeVar} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* v1.16.7 — single async boundary for the three mood mini-charts.
|
||||
*
|
||||
* The distribution / weekday / time-of-day charts were each deferred
|
||||
* through their own `next/dynamic` import, which produced three separate
|
||||
* chunk groups: the cards popped in one after another as each chunk
|
||||
* landed (incoherent reveal on the mood insights page), and each group
|
||||
* carried its own copy of the Recharts module graph. Funnelling all
|
||||
* three through this one barrel gives them a single shared chunk — they
|
||||
* arrive (and reveal) together, and Recharts is bundled once for the
|
||||
* trio.
|
||||
*
|
||||
* Consumers keep importing the chart types directly from the component
|
||||
* files (type-only imports are value-free and don't drag the chunk in).
|
||||
*/
|
||||
export { MoodDistributionChart } from "./mood-distribution-chart";
|
||||
export { MoodWeekdayChart } from "./mood-weekday-chart";
|
||||
export { MoodTimeOfDayChart } from "./mood-time-of-day-chart";
|
||||
@@ -25,7 +25,7 @@ import { MoodExplainerIcon } from "./mood-explainer-icon";
|
||||
|
||||
const ScatterCorrelationChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/scatter-correlation-chart").then((mod) => ({
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.ScatterCorrelationChart,
|
||||
})),
|
||||
{
|
||||
@@ -105,7 +105,13 @@ function MoodCorrelationCard({
|
||||
title={t(TITLE_KEY[kind])}
|
||||
right={
|
||||
hasResult && data.result ? (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
// No `shrink-0` here: the slot kept its icon-era rigidity after
|
||||
// v1.22 swapped the info glyph for an inline caption, and the
|
||||
// unshrinkable badge+caption row escaped the card's right edge
|
||||
// by 14–39 px on phone viewports. The caption itself moves
|
||||
// below the title on <sm (see the sm:hidden twin under the
|
||||
// header); only the compact badge stays pinned right.
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t(STRENGTH_KEY[data.result.strength])}
|
||||
</Badge>
|
||||
@@ -113,6 +119,7 @@ function MoodCorrelationCard({
|
||||
scatter as a half-empty row. Move it into an explainer icon
|
||||
in the header so the card footprint stays tight. */}
|
||||
<MoodExplainerIcon
|
||||
className="hidden whitespace-nowrap sm:inline"
|
||||
label={t("insights.mood.correlation.sourceLabel")}
|
||||
detail={t("insights.mood.correlation.source", {
|
||||
n: data.n,
|
||||
@@ -123,6 +130,18 @@ function MoodCorrelationCard({
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{/* <sm twin of the header caption: stacked under the title so the
|
||||
title keeps the row width and nothing can escape the tile. */}
|
||||
{hasResult && data.result ? (
|
||||
<MoodExplainerIcon
|
||||
className="sm:hidden"
|
||||
label={t("insights.mood.correlation.sourceLabel")}
|
||||
detail={t("insights.mood.correlation.source", {
|
||||
n: data.n,
|
||||
r: data.result.r.toFixed(2),
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{hasResult && data.result ? (
|
||||
|
||||
@@ -90,7 +90,10 @@ export function MoodDiscoveredRelations({
|
||||
// observational disclaimer fold into the one tooltip) so the surface stops
|
||||
// spending a muted paragraph on a footnote.
|
||||
<div data-slot="mood-discovered-relations" className="space-y-1">
|
||||
<div className="text-foreground flex items-center gap-1.5 text-sm font-medium">
|
||||
{/* <sm stacks the caveat caption under the subheading — beside a
|
||||
2-line heading it wrapped to 9 lines at ~58 % of the row width on
|
||||
phone viewports. */}
|
||||
<div className="text-foreground flex flex-col gap-1 text-sm font-medium sm:flex-row sm:items-center sm:gap-1.5">
|
||||
<span>{t("insights.mood.discovery.subheading")}</span>
|
||||
<MoodExplainerIcon
|
||||
label={t("insights.mood.discovery.explainerLabel")}
|
||||
@@ -121,10 +124,11 @@ export function MoodDiscoveredRelations({
|
||||
data-direction={up ? "up" : "down"}
|
||||
>
|
||||
{/* The {n} paired days · r · q numeric detail rides an explainer
|
||||
icon on the same line as the finding so the list stays one
|
||||
row per pair. The finding itself reads in the standard
|
||||
foreground colour. */}
|
||||
<span className="text-foreground flex items-center gap-1.5">
|
||||
caption beside the finding on ≥sm; on phone widths it stacks
|
||||
below the sentence instead of squeezing to a 4-line sliver
|
||||
at ~25 % of the row. The finding itself reads in the
|
||||
standard foreground colour. */}
|
||||
<span className="text-foreground flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-1.5">
|
||||
<span>{t(sentenceKey, { factor: factorLabel })}</span>
|
||||
<MoodExplainerIcon
|
||||
label={t("insights.mood.discovery.statLabel")}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function MoodDistributionChart({
|
||||
// unchanged. The mood-hue bar Cells stay on `--dracula-*` — only the
|
||||
// axis text legibility changes here.
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--dracula-comment)"
|
||||
stroke="var(--muted-foreground)"
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
@@ -83,7 +83,7 @@ export function MoodDistributionChart({
|
||||
// unchanged. The mood-hue bar Cells stay on `--dracula-*` — only the
|
||||
// axis text legibility changes here.
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--dracula-comment)"
|
||||
stroke="var(--muted-foreground)"
|
||||
width={28}
|
||||
/>
|
||||
<Tooltip
|
||||
@@ -94,8 +94,8 @@ export function MoodDistributionChart({
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
itemStyle={{ color: "var(--dracula-fg)" }}
|
||||
labelStyle={{ color: "var(--dracula-fg)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
formatter={(value) =>
|
||||
[
|
||||
`${value ?? 0} ${t("insights.mood.distributionDaysUnit")}`,
|
||||
|
||||
@@ -36,12 +36,13 @@ import { MoodHeatmap } from "@/components/charts/mood-heatmap";
|
||||
import type { MoodDistributionRow } from "./mood-distribution-chart";
|
||||
import type { MoodWeekdayRow } from "./mood-weekday-chart";
|
||||
import type { MoodTimeOfDayPattern } from "./mood-time-of-day-chart";
|
||||
// v1.16.7 — all three loaders resolve the ONE `./mood-charts` barrel so
|
||||
// they share a single chunk: the cards reveal together instead of
|
||||
// popping in chunk-by-chunk, and Recharts ships once for the trio.
|
||||
// v1.16.7 established the shared-barrel pattern for the trio; all three
|
||||
// loaders now resolve the app-wide chart-runtime boundary, so they share
|
||||
// the ONE recharts chunk with every other chart surface: the cards reveal
|
||||
// together, and recharts ships exactly once for the whole app.
|
||||
const MoodDistributionChart = dynamic(
|
||||
() =>
|
||||
import("./mood-charts").then((mod) => ({
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.MoodDistributionChart,
|
||||
})),
|
||||
{
|
||||
@@ -53,7 +54,7 @@ const MoodDistributionChart = dynamic(
|
||||
);
|
||||
const MoodWeekdayChart = dynamic(
|
||||
() =>
|
||||
import("./mood-charts").then((mod) => ({
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.MoodWeekdayChart,
|
||||
})),
|
||||
{
|
||||
@@ -65,7 +66,7 @@ const MoodWeekdayChart = dynamic(
|
||||
);
|
||||
const MoodTimeOfDayChart = dynamic(
|
||||
() =>
|
||||
import("./mood-charts").then((mod) => ({
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.MoodTimeOfDayChart,
|
||||
})),
|
||||
{
|
||||
|
||||
@@ -90,19 +90,19 @@ export function MoodTimeOfDayChart({
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--dracula-comment)"
|
||||
stroke="var(--muted-foreground)"
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[1, 5]}
|
||||
ticks={[1, 2, 3, 4, 5]}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--dracula-comment)"
|
||||
stroke="var(--muted-foreground)"
|
||||
width={24}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={3.5}
|
||||
stroke="var(--dracula-comment)"
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
@@ -114,8 +114,8 @@ export function MoodTimeOfDayChart({
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
itemStyle={{ color: "var(--dracula-fg)" }}
|
||||
labelStyle={{ color: "var(--dracula-fg)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
formatter={(_value, _name, item) => {
|
||||
const payload = item?.payload as
|
||||
{ avgScore: number | null; count: number } | undefined;
|
||||
|
||||
@@ -90,7 +90,7 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
|
||||
// to the shared `--muted-foreground` axis-tick token. Bar mood
|
||||
// hues stay `--dracula-*`; dark mode is unchanged.
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--dracula-comment)"
|
||||
stroke="var(--muted-foreground)"
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
@@ -101,7 +101,7 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
|
||||
// to the shared `--muted-foreground` axis-tick token. Bar mood
|
||||
// hues stay `--dracula-*`; dark mode is unchanged.
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--dracula-comment)"
|
||||
stroke="var(--muted-foreground)"
|
||||
width={24}
|
||||
/>
|
||||
{/* v1.15.14 — calmer target guide: a muted comment-grey dashed
|
||||
@@ -109,7 +109,7 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
|
||||
reference rather than competing with the bars. */}
|
||||
<ReferenceLine
|
||||
y={3.5}
|
||||
stroke="var(--dracula-comment)"
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
@@ -121,8 +121,8 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
itemStyle={{ color: "var(--dracula-fg)" }}
|
||||
labelStyle={{ color: "var(--dracula-fg)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
formatter={(_value, _name, item) => {
|
||||
const payload = item?.payload as
|
||||
{ avgScore: number | null; count: number } | undefined;
|
||||
|
||||
@@ -35,7 +35,7 @@ import { ConfidenceMeter } from "./confidence-meter";
|
||||
*/
|
||||
const HealthChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("@/components/charts/health-chart")).then(
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({ default: mod.HealthChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton mini /> },
|
||||
@@ -48,7 +48,7 @@ const HealthChart = (props: ComponentProps<typeof HealthChartLazy>) => (
|
||||
|
||||
const MoodChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("@/components/charts/mood-chart")).then(
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({ default: mod.MoodChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton mini /> },
|
||||
|
||||
@@ -25,9 +25,11 @@ import { SleepDurationChart } from "./sleep-duration-chart";
|
||||
// matches the card the bar paints so the layout stays stable.
|
||||
const SleepStageStackedBarLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("./sleep-stage-stacked-bar")).then((mod) => ({
|
||||
default: mod.SleepStageStackedBar,
|
||||
})),
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({
|
||||
default: mod.SleepStageStackedBar,
|
||||
}),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
function SleepStageStackedBar(
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
|
||||
const MoodChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/mood-chart").then((mod) => ({
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.MoodChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <ChartSkeleton mini /> },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -27,7 +27,11 @@ import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { summarize, type DataSummary } from "@/lib/analytics/trends";
|
||||
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { fallbackMessages } from "@/lib/i18n/load-locale";
|
||||
import {
|
||||
getFallbackMessages,
|
||||
loadMessages,
|
||||
type MessageBundle,
|
||||
} from "@/lib/i18n/load-locale";
|
||||
import { resolveKey } from "@/lib/i18n/resolve-key";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -51,9 +55,11 @@ import type {
|
||||
// loading shell matches the in-card chart footprint so the layout is stable.
|
||||
const LabBiomarkerChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("./lab-biomarker-chart")).then((mod) => ({
|
||||
default: mod.LabBiomarkerChart,
|
||||
})),
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({
|
||||
default: mod.LabBiomarkerChart,
|
||||
}),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
function LabBiomarkerChart(
|
||||
@@ -92,23 +98,42 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
// marker minted under the English default and then viewed in another locale
|
||||
// never matched a map built only from the current locale's names — the rich
|
||||
// description silently fell through to the generic line. The English bundle
|
||||
// is always in the client chunk (`fallbackMessages`), so adding its names
|
||||
// makes resolution independent of the locale the marker was created in.
|
||||
// resolves through `getFallbackMessages()` (already in hand on the server
|
||||
// and for EN sessions; lazily fetched below otherwise — the EN catalog is
|
||||
// no longer a static every-route import), so adding its names makes
|
||||
// resolution independent of the locale the marker was created in.
|
||||
const [enBundle, setEnBundle] = useState<MessageBundle | undefined>(
|
||||
getFallbackMessages,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (enBundle) return;
|
||||
let cancelled = false;
|
||||
void loadMessages("en")
|
||||
.then((loaded) => {
|
||||
if (!cancelled) setEnBundle(loaded);
|
||||
})
|
||||
.catch(() => {
|
||||
// Offline — the current-locale names still resolve; only markers
|
||||
// minted under another locale keep the generic description.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enBundle]);
|
||||
const slugByName = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const seed of BIOMARKER_CATALOG) {
|
||||
const localized = t(`labs.catalog.${seed.slug}`).trim().toLowerCase();
|
||||
if (localized) map.set(localized, seed.slug);
|
||||
const canonical = resolveKey(
|
||||
fallbackMessages,
|
||||
`labs.catalog.${seed.slug}`,
|
||||
)
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
const canonical = enBundle
|
||||
? resolveKey(enBundle, `labs.catalog.${seed.slug}`)
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
: undefined;
|
||||
if (canonical && !map.has(canonical)) map.set(canonical, seed.slug);
|
||||
}
|
||||
return map;
|
||||
}, [t]);
|
||||
}, [t, enBundle]);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
// Sticky-footer slot for the add-value sheet (the form portals here).
|
||||
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -48,7 +48,7 @@ export function AuthShell({
|
||||
*/
|
||||
demoMode?: boolean;
|
||||
}) {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const { user, isAuthenticated, isAuthUnknown, isLoading } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
@@ -129,17 +129,35 @@ export function AuthShell({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated && !isPublicPage) {
|
||||
// Session END (expired / invalidated cookie → `/api/auth/me` 401). Wipe
|
||||
// the in-memory query cache (the cross-user guard — the QueryClient
|
||||
// outlives the navigation on this SPA and the health-data families are
|
||||
// not user-scoped) plus the persisted IndexedDB + SW caches, so nothing
|
||||
// leaks to the next account on a shared browser. Logout does the same
|
||||
// from `useLogout`; this covers the expiry path logout never runs
|
||||
// through. Best-effort; the redirect proceeds regardless.
|
||||
clearCachesForSessionEnd(queryClient);
|
||||
// Session END (expired / invalidated cookie → `/api/auth/me` 401/403).
|
||||
// Wipe the in-memory query cache (the cross-user guard — the
|
||||
// QueryClient outlives the navigation on this SPA and the health-data
|
||||
// families are not user-scoped) plus the persisted IndexedDB + SW
|
||||
// caches, so nothing leaks to the next account on a shared browser.
|
||||
// Logout does the same from `useLogout`; this covers the expiry path
|
||||
// logout never runs through. Best-effort; the redirect proceeds
|
||||
// regardless.
|
||||
//
|
||||
// `isAuthUnknown` = the auth probe failed WITHOUT a session verdict
|
||||
// (offline relaunch, network blip, 5xx) and no prior-session marker
|
||||
// exists. Nothing to protect and nothing to render — send the visitor
|
||||
// to the login gate, but never run the wipe off a non-verdict: the
|
||||
// wipe on a network failure is exactly what used to destroy the
|
||||
// offline caches. (With the marker present, `isAuthenticated` holds
|
||||
// the last-known state and this branch never runs.)
|
||||
if (!isAuthUnknown) {
|
||||
clearCachesForSessionEnd(queryClient);
|
||||
}
|
||||
router.replace("/auth/login");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, isPublicPage, router, queryClient]);
|
||||
}, [
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
isAuthUnknown,
|
||||
isPublicPage,
|
||||
router,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
// Redirect non-admins away from /admin
|
||||
useEffect(() => {
|
||||
|
||||
@@ -89,14 +89,14 @@ import type { ComplianceDisplay } from "@/lib/analytics/compliance";
|
||||
// shared chart skeleton standing in while the chunk streams.
|
||||
const DrugLevelChart = dynamic(
|
||||
() =>
|
||||
import("@/components/medications/drug-level-chart").then((mod) => ({
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.DrugLevelChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
const DoseStrengthCurve = dynamic(
|
||||
() =>
|
||||
import("@/components/medications/dose-strength-curve").then((mod) => ({
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.DoseStrengthCurve,
|
||||
})),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
|
||||
@@ -38,9 +38,11 @@ import type { AssessmentRow, CrisisSet, InstrumentId } from "./types";
|
||||
// holds the layout.
|
||||
const AssessmentHistoryChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("./assessment-history-chart")).then((mod) => ({
|
||||
default: mod.AssessmentHistoryChart,
|
||||
})),
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({
|
||||
default: mod.AssessmentHistoryChart,
|
||||
}),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
function AssessmentHistoryChart(
|
||||
|
||||
@@ -182,12 +182,9 @@ function QueryPersistenceBridge() {
|
||||
export function Providers({
|
||||
children,
|
||||
initialLocale,
|
||||
initialMessages,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
initialLocale?: Locale;
|
||||
/** Active locale's bundle, server-resolved by the root layout (RSC handoff). */
|
||||
initialMessages?: Record<string, unknown>;
|
||||
}) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
@@ -206,10 +203,7 @@ export function Providers({
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<I18nProvider
|
||||
initialLocale={initialLocale}
|
||||
initialMessages={initialMessages}
|
||||
>
|
||||
<I18nProvider initialLocale={initialLocale}>
|
||||
<QueryPersistenceBridge />
|
||||
<DashboardSnapshotPreloader />
|
||||
{children}
|
||||
|
||||
@@ -25,7 +25,14 @@ function Checkbox({
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-border/70 size-4 shrink-0 rounded border",
|
||||
// The visual box stays 16×16, but the effective touch target is
|
||||
// 32×32 via the switch's `::before` hit-area trick (v1.4.43 W5-H1):
|
||||
// `relative` + an absolutely positioned 8 px halo that receives
|
||||
// pointer events without disturbing layout. 16 px alone sits below
|
||||
// the WCAG 2.5.8 24 px floor on the measurement/mood row selectors;
|
||||
// 32 px clears it with margin while staying small enough not to
|
||||
// blanket neighbouring controls in dense table rows.
|
||||
"peer border-border/70 relative size-4 shrink-0 rounded border before:absolute before:inset-[-8px] before:content-['']",
|
||||
"data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-background",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Offline auth gate — a failed `/api/auth/me` is only a session END when the
|
||||
* server said so (401/403).
|
||||
*
|
||||
* Pre-fix, ANY auth-probe error read as "logged out": an airplane-mode
|
||||
* relaunch redirected to /auth/login and `clearCachesForSessionEnd()` wiped
|
||||
* the SW data/page caches and the IndexedDB query snapshot — destroying
|
||||
* exactly the installed-PWA-opened-offline scenario the offline plumbing
|
||||
* exists for. These tests pin the classification (`isAuthVerdictUnknown`),
|
||||
* the last-known-state fallback (`readWasAuthenticated` marker), and the
|
||||
* marker's session-end lifecycle.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
const clearOfflineCachesForSessionEnd = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/pwa/query-persister", () => ({
|
||||
clearOfflineCachesForSessionEnd: () => clearOfflineCachesForSessionEnd(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Drivable query state so the hook's classification can be exercised for
|
||||
// success / network-error / 401 without a fetch layer.
|
||||
interface MockQueryState {
|
||||
data: unknown;
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
isLoading: boolean;
|
||||
}
|
||||
let queryState: MockQueryState;
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
|
||||
"@tanstack/react-query",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useQuery: () => ({ ...queryState, refetch: vi.fn() }),
|
||||
useQueryClient: () => ({}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
};
|
||||
});
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
clearCachesForSessionEnd,
|
||||
isAuthVerdictUnknown,
|
||||
readWasAuthenticated,
|
||||
useAuth,
|
||||
} from "../use-auth";
|
||||
|
||||
// Node test env has no localStorage — back it with a Map.
|
||||
const store = new Map<string, string>();
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
queryState = {
|
||||
data: undefined,
|
||||
isError: false,
|
||||
error: null,
|
||||
isLoading: false,
|
||||
};
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
clearOfflineCachesForSessionEnd.mockClear();
|
||||
});
|
||||
|
||||
describe("isAuthVerdictUnknown — failure classification", () => {
|
||||
it("treats a fetch TypeError (offline / DNS) as no verdict", () => {
|
||||
expect(isAuthVerdictUnknown(new TypeError("Failed to fetch"))).toBe(true);
|
||||
});
|
||||
|
||||
it("treats the SW's synthetic 503 offline envelope as no verdict", () => {
|
||||
expect(
|
||||
isAuthVerdictUnknown(new ApiError("offline", 503, { offline: true })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an abort/timeout as no verdict", () => {
|
||||
expect(
|
||||
isAuthVerdictUnknown(new DOMException("timeout", "TimeoutError")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a 5xx as no verdict — a server hiccup is not a logout", () => {
|
||||
expect(isAuthVerdictUnknown(new ApiError("boom", 500))).toBe(true);
|
||||
});
|
||||
|
||||
it("treats 401 and 403 as the real session-end verdict", () => {
|
||||
expect(isAuthVerdictUnknown(new ApiError("unauthorized", 401))).toBe(false);
|
||||
expect(isAuthVerdictUnknown(new ApiError("forbidden", 403))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAuth — last-known state on a non-verdict failure", () => {
|
||||
it("stays authenticated on a network failure when a prior session marker exists", () => {
|
||||
store.set("healthlog-was-authenticated", "1");
|
||||
queryState.isError = true;
|
||||
queryState.error = new TypeError("Failed to fetch");
|
||||
|
||||
const auth = useAuth();
|
||||
expect(auth.isAuthenticated).toBe(true);
|
||||
expect(auth.isAuthUnknown).toBe(true);
|
||||
expect(auth.user).toBeNull();
|
||||
});
|
||||
|
||||
it("reads unauthenticated-but-unknown on a network failure without a marker", () => {
|
||||
queryState.isError = true;
|
||||
queryState.error = new TypeError("Failed to fetch");
|
||||
|
||||
const auth = useAuth();
|
||||
expect(auth.isAuthenticated).toBe(false);
|
||||
// The shell must NOT run the session-end wipe off this state.
|
||||
expect(auth.isAuthUnknown).toBe(true);
|
||||
});
|
||||
|
||||
it("reads a true 401 as session end — wipe and redirect may proceed", () => {
|
||||
store.set("healthlog-was-authenticated", "1");
|
||||
queryState.isError = true;
|
||||
queryState.error = new ApiError("unauthorized", 401);
|
||||
|
||||
const auth = useAuth();
|
||||
expect(auth.isAuthenticated).toBe(false);
|
||||
expect(auth.isAuthUnknown).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session-end marker lifecycle", () => {
|
||||
it("clearCachesForSessionEnd drops the marker so the next cold offline launch lands on the login gate", () => {
|
||||
store.set("healthlog-was-authenticated", "1");
|
||||
expect(readWasAuthenticated()).toBe(true);
|
||||
|
||||
clearCachesForSessionEnd(new QueryClient());
|
||||
|
||||
expect(readWasAuthenticated()).toBe(false);
|
||||
expect(clearOfflineCachesForSessionEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+86
-2
@@ -9,7 +9,7 @@ import {
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch";
|
||||
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type {
|
||||
@@ -149,6 +149,64 @@ export interface AuthUser {
|
||||
moduleAvailability?: Partial<Record<ModuleKey, boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-sensitive "a session existed here" marker (localStorage boolean, no
|
||||
* identity data). A cold offline launch cannot reach `/api/auth/me`, so the
|
||||
* auth query errors without ever answering the session question — the marker
|
||||
* is the last-known state that lets the shell render the cached app chrome
|
||||
* instead of bouncing to the login gate and wiping the offline caches. Set on
|
||||
* every successful `/api/auth/me`, cleared on every session END (logout and
|
||||
* true 401/403).
|
||||
*/
|
||||
const WAS_AUTHENTICATED_KEY = "healthlog-was-authenticated";
|
||||
|
||||
export function readWasAuthenticated(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(WAS_AUTHENTICATED_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function storeWasAuthenticated(): void {
|
||||
try {
|
||||
localStorage.setItem(WAS_AUTHENTICATED_KEY, "1");
|
||||
} catch {
|
||||
/* storage unavailable — the marker is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
function clearWasAuthenticated(): void {
|
||||
try {
|
||||
localStorage.removeItem(WAS_AUTHENTICATED_KEY);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an `/api/auth/me` failure carries NO verdict on the session —
|
||||
* a network-level failure (fetch `TypeError` offline, abort/timeout), the
|
||||
* service worker's synthetic 503 offline envelope (`meta.offline`), the
|
||||
* browser reporting itself offline, or a server-side 5xx. None of these mean
|
||||
* "logged out"; only an explicit 401/403 does. Treating them as session end
|
||||
* used to redirect an offline relaunch to /auth/login and wipe the offline
|
||||
* caches — destroying exactly the installed-PWA-opened-offline scenario the
|
||||
* SW + query persister exist for.
|
||||
*/
|
||||
export function isAuthVerdictUnknown(error: unknown): boolean {
|
||||
if (error instanceof ApiError) {
|
||||
// 5xx (incl. the SW's synthetic 503 with `meta.offline: true`) and any
|
||||
// other non-auth status: the server never ruled on the session. Only an
|
||||
// explicit 401/403 is a verdict.
|
||||
return error.status !== 401 && error.status !== 403;
|
||||
}
|
||||
// Everything below the HTTP layer is transport-level by construction:
|
||||
// fetch `TypeError` (offline / DNS / connection refused), `AbortError` /
|
||||
// `TimeoutError` DOMException from the 15 s default signal.
|
||||
return true;
|
||||
}
|
||||
|
||||
async function fetchMe(): Promise<AuthUser> {
|
||||
// v1.16.4 — routed through the typed wrapper; a non-OK /me (401)
|
||||
// throws `ApiError`, which `useAuth` treats as "not authenticated"
|
||||
@@ -183,6 +241,8 @@ async function fetchMe(): Promise<AuthUser> {
|
||||
? data.dateFormat
|
||||
: "AUTO";
|
||||
storeDateFormat(dateFormat);
|
||||
// A confirmed session: refresh the non-sensitive offline-relaunch marker.
|
||||
storeWasAuthenticated();
|
||||
return {
|
||||
...(data as AuthUser),
|
||||
disableCoach: data.disableCoach ?? false,
|
||||
@@ -227,10 +287,30 @@ export function useAuth() {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// v1.27.x offline fix — classify a failed `/api/auth/me` before acting on
|
||||
// it. Only an explicit 401/403 means "session ended"; a network-level
|
||||
// failure (offline relaunch, SW 503 offline envelope, timeout) or a 5xx
|
||||
// carries no verdict, so the hook holds the LAST-KNOWN auth state (the
|
||||
// localStorage marker) instead of flipping to unauthenticated. Pre-fix, an
|
||||
// airplane-mode relaunch read as a logout: the shell redirected to
|
||||
// /auth/login and wiped every offline cache (data 7→0 entries, pages 1→0
|
||||
// in the audit measurement), making the whole offline story unreachable.
|
||||
const verdictUnknown = query.isError && isAuthVerdictUnknown(query.error);
|
||||
const isAuthenticated =
|
||||
!!query.data || (verdictUnknown && readWasAuthenticated());
|
||||
|
||||
return {
|
||||
user: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isAuthenticated: !!query.data,
|
||||
isAuthenticated,
|
||||
/**
|
||||
* True while the session question is genuinely unanswered — the auth
|
||||
* probe failed at the transport level (or 5xx) and no cached `/me`
|
||||
* payload exists. The shell uses it to suppress the session-end cache
|
||||
* wipe: a wipe without a 401/403 verdict would destroy the offline
|
||||
* caches on every network blip.
|
||||
*/
|
||||
isAuthUnknown: !query.data && verdictUnknown,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
@@ -257,6 +337,10 @@ export function useAuth() {
|
||||
*/
|
||||
export function clearCachesForSessionEnd(queryClient: QueryClient): void {
|
||||
queryClient.clear();
|
||||
// The offline-relaunch marker is session state too: after a real session
|
||||
// end the next cold offline launch must land on the login gate, not the
|
||||
// cached shell.
|
||||
clearWasAuthenticated();
|
||||
void clearOfflineCachesForSessionEnd();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Shared cached-snapshot read for the two dashboard entry points:
|
||||
*
|
||||
* - `GET /api/dashboard/snapshot` (the client cell's endpoint), and
|
||||
* - the dashboard RSC wrapper (`src/app/page.tsx`), which server-
|
||||
* prefetches the same payload into a dehydrated TanStack cache so the
|
||||
* first HTML paints real tiles instead of skeletons-until-JS.
|
||||
*
|
||||
* Both ride the SAME `caches.analytics` SWR cell (key
|
||||
* `${userId}|dashboard-snapshot|${locale}`), so an RSC prefetch warms the
|
||||
* API path and vice versa — the builder never runs twice for one user
|
||||
* within the TTL, and the write-invalidation semantics (stale-sweep on
|
||||
* measurement writes, hard evict on widget reorder) cover both readers.
|
||||
*/
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
|
||||
import {
|
||||
buildDashboardSnapshot,
|
||||
type DashboardSnapshot,
|
||||
type SnapshotUserInput,
|
||||
} from "@/lib/dashboard/snapshot";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
/**
|
||||
* Per-key TTL for the snapshot cache entry. Strictly greater than the
|
||||
* client's 120 s refetch interval so a scheduled poll lands on a warm
|
||||
* entry instead of a guaranteed miss that re-runs the full builder. The
|
||||
* 60 s headroom absorbs interval jitter / a poll firing a touch late.
|
||||
* The analytics bucket's 60 s default still governs the slim / thick /
|
||||
* mood cells; only this key is lengthened. Eviction on writes is
|
||||
* unchanged (the `${userId}|` prefix sweep / point-delete both ignore
|
||||
* the TTL).
|
||||
*/
|
||||
export const SNAPSHOT_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000;
|
||||
|
||||
export interface SnapshotReadResult {
|
||||
body: DashboardSnapshot;
|
||||
/** The locale the snapshot was resolved (and cache-keyed) under. */
|
||||
locale: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve locale + read the snapshot through the SWR cache for an already
|
||||
* authenticated user row. `time` lets the API route keep its per-sub-query
|
||||
* timing surface; the RSC prefetch passes none.
|
||||
*/
|
||||
export async function readDashboardSnapshotCached(
|
||||
user: User,
|
||||
time?: <T>(label: string, builder: () => Promise<T>) => Promise<T>,
|
||||
): Promise<SnapshotReadResult> {
|
||||
// `User` row is already in hand at both call sites — no extra round-trip.
|
||||
const snapshotUser: SnapshotUserInput = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
timezone: user.timezone,
|
||||
heightCm: user.heightCm,
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
glucoseUnit: user.glucoseUnit,
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
disableCoach: user.disableCoach,
|
||||
insightsCachedText: user.insightsCachedText,
|
||||
insightsCachedAt: user.insightsCachedAt,
|
||||
dashboardWidgetsJson: user.dashboardWidgetsJson,
|
||||
};
|
||||
|
||||
// v1.21.2 (A4) — the briefing recall + forward-look is locale-specific
|
||||
// prose, so the snapshot cache key carries the resolved locale: an EN and
|
||||
// a DE session never share a snapshot cell (and never see each other's
|
||||
// memory wording). The `${user.id}|` prefix still covers the key under
|
||||
// the measurement-write stale-sweep.
|
||||
const locale = await resolveServerLocale({ userLocale: user.locale });
|
||||
|
||||
// Stale-while-revalidate: a measurement write marks the analytics
|
||||
// bucket stale rather than hard-evicting the snapshot, so a busy iOS
|
||||
// sync serves the prior snapshot immediately (within the bucket's
|
||||
// stale window) while a single background recompute warms a fresh one
|
||||
// — the foreground request never pays the cold rebuild. Hard-evicting
|
||||
// writes (widget reorder) still drop the key outright, forcing a clean
|
||||
// miss + synchronous rebuild here.
|
||||
const body = await cachedSwr(
|
||||
caches.analytics as ServerCache<DashboardSnapshot>,
|
||||
`${user.id}|dashboard-snapshot|${locale}`,
|
||||
() => buildDashboardSnapshot(prisma, snapshotUser, { time, locale }),
|
||||
annotate,
|
||||
SNAPSHOT_CACHE_TTL_MS,
|
||||
);
|
||||
|
||||
return { body, locale };
|
||||
}
|
||||
@@ -323,11 +323,12 @@ describe("Coach per-user disableCoach invariant", () => {
|
||||
// v1.7.0 W6 — the unified dashboard snapshot builder reads
|
||||
// `user.disableCoach` to gate the embedded daily briefing to
|
||||
// `briefingState: "disabled"`. Covered by the briefingState matrix
|
||||
// in `src/lib/dashboard/__tests__/snapshot.test.ts`. The route is
|
||||
// a thin pass-through of the resolved session user into the
|
||||
// builder.
|
||||
// in `src/lib/dashboard/__tests__/snapshot.test.ts`. The shared
|
||||
// cached-read helper (used by BOTH the API route and the dashboard
|
||||
// RSC prefetch) maps the session user's flag into the builder
|
||||
// input; the route itself no longer touches the flag.
|
||||
"src/lib/dashboard/snapshot.ts",
|
||||
"src/app/api/dashboard/snapshot/route.ts",
|
||||
"src/lib/dashboard/snapshot-read.ts",
|
||||
// v1.18.0 — the module enable/disable gate delegates the `coach`
|
||||
// module to the SAME two-layer source of truth (`user.disableCoach`
|
||||
// AND the operator assistant master flag) rather than owning a
|
||||
|
||||
@@ -22,8 +22,8 @@ import { readStoredTimeFormat, subscribeTimeFormat } from "../time-format";
|
||||
import { readStoredDateFormat, subscribeDateFormat } from "../date-format";
|
||||
import { resolveKey } from "./resolve-key";
|
||||
import {
|
||||
fallbackMessages,
|
||||
getCachedMessages,
|
||||
getFallbackMessages,
|
||||
loadMessages,
|
||||
primeMessages,
|
||||
type MessageBundle,
|
||||
@@ -117,12 +117,13 @@ export function I18nProvider({
|
||||
children: ReactNode;
|
||||
initialLocale?: Locale;
|
||||
/**
|
||||
* The active locale's message bundle, resolved server-side by the
|
||||
* root layout and passed through the RSC payload. This is what keeps
|
||||
* the client chunk free of every non-EN bundle WITHOUT reintroducing
|
||||
* the EN→DE hydration flash: the first client render already holds
|
||||
* the right strings, no async fetch on first paint. Only EN ships
|
||||
* statically (the synchronous fallback floor of the t() chain).
|
||||
* Optional pre-resolved bundle for standalone mounts (tests, embedded
|
||||
* providers). The APP no longer passes this: the active bundle reaches
|
||||
* the provider through the `load-locale` cache — seeded server-side from
|
||||
* the full catalog map during SSR and client-side by the layout's
|
||||
* versioned `/i18n/<locale>` boot script — so the catalog is never
|
||||
* serialized into the RSC flight payload (it used to be 392 KB of every
|
||||
* dashboard document).
|
||||
*/
|
||||
initialMessages?: MessageBundle;
|
||||
}) {
|
||||
@@ -146,10 +147,25 @@ export function I18nProvider({
|
||||
primeMessages(locale, initialMessages);
|
||||
return { locale, messages: initialMessages };
|
||||
}
|
||||
return { locale, messages: getCachedMessages(locale) ?? fallbackMessages };
|
||||
// Cache miss on every ladder rung (boot script failed AND the locale
|
||||
// isn't EN-cached): render with an empty bundle for one frame — the
|
||||
// backfill effect below dynamic-imports the bundle and flips state.
|
||||
// In the app this only happens when the boot script is unreachable
|
||||
// (evicted HTTP cache offline); keys render raw briefly rather than
|
||||
// in the wrong language.
|
||||
return {
|
||||
locale,
|
||||
messages: getCachedMessages(locale) ?? getFallbackMessages() ?? {},
|
||||
};
|
||||
});
|
||||
const { locale, messages } = active;
|
||||
const [pendingLocale, setPendingLocale] = useState<Locale | null>(null);
|
||||
// Bumped when the lazily-loaded EN fallback bundle lands so every t()
|
||||
// consumer re-renders with the fallback floor in place. The EN catalog is
|
||||
// no longer a static import (it cost ~106 KB gz on every route); it loads
|
||||
// on demand the first time a key actually misses the active bundle.
|
||||
const [, setFallbackTick] = useState(0);
|
||||
const fallbackRequestedRef = useRef(false);
|
||||
// Tracks the most recent switch request so a slow bundle load can't
|
||||
// clobber a newer one (last click wins).
|
||||
const requestedLocaleRef = useRef<Locale | null>(null);
|
||||
@@ -228,7 +244,22 @@ export function I18nProvider({
|
||||
|
||||
// Fallback to English if key missing in current locale
|
||||
if (value === undefined && locale !== "en") {
|
||||
value = resolveKey(fallbackMessages, key);
|
||||
const fallback = getFallbackMessages();
|
||||
if (fallback) {
|
||||
value = resolveKey(fallback, key);
|
||||
} else if (!fallbackRequestedRef.current) {
|
||||
// First miss without the EN floor in hand: fetch it once, then
|
||||
// re-render so this key (and any sibling misses) resolve. The
|
||||
// locale-integrity guard keeps every locale key-complete, so in
|
||||
// practice this path only runs against a drifted cached bundle.
|
||||
fallbackRequestedRef.current = true;
|
||||
void loadMessages("en")
|
||||
.then(() => setFallbackTick((n) => n + 1))
|
||||
.catch(() => {
|
||||
// Leave the ref set — retrying every render would hammer a
|
||||
// dead network; the raw key remains the last resort.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to key itself
|
||||
|
||||
+75
-22
@@ -1,33 +1,44 @@
|
||||
/**
|
||||
* Client-side message-bundle loading.
|
||||
*
|
||||
* Only English ships statically in the client chunk — it is the
|
||||
* synchronous fallback floor of the t() chain (active locale → EN →
|
||||
* raw key) and the bundle the provider can always resolve against
|
||||
* without an async gap. Every other locale reaches the client one of
|
||||
* two ways:
|
||||
* NO catalog ships statically in the client chunk — not even English.
|
||||
* The static EN import that used to live here was the single largest
|
||||
* every-route client cost (334 KB raw / ~106 KB gz on every page load,
|
||||
* for every user, in every language). The t() chain (active locale →
|
||||
* EN → raw key) now resolves its bundles through three paths:
|
||||
*
|
||||
* 1. First paint: the root layout (a server component) resolves the
|
||||
* active locale from the `healthlog-locale` cookie / the
|
||||
* Accept-Language header and threads that locale's messages into
|
||||
* `I18nProvider` as a serialized RSC prop — so the first client
|
||||
* render already holds the right bundle and there is no EN→DE
|
||||
* hydration flash (the historical reason all six bundles used to
|
||||
* ship statically).
|
||||
* 2. Locale switch: `loadMessages()` dynamic-imports the target
|
||||
* bundle on demand; each locale becomes its own async chunk that
|
||||
* only the users who switch to it ever download.
|
||||
* 1. SSR pass: this module seeds its cache with the full server-side
|
||||
* catalog map (`shared-resolve`) inside a `typeof window ===
|
||||
* "undefined"` branch. Turbopack compiles the branch away for the
|
||||
* browser bundle (verified — zero catalog bytes reach any client
|
||||
* chunk), while the server render of every client component
|
||||
* resolves the active locale synchronously — SSR text stays
|
||||
* correct with nothing serialized into the flight payload (the
|
||||
* previous RSC-prop handoff inlined the whole active catalog into
|
||||
* EVERY document: 392 KB of a 505 KB dashboard HTML).
|
||||
* 2. First client paint: the root layout emits a deferred, versioned,
|
||||
* immutable-cacheable `<script src="/i18n/<locale>?v=…">` that
|
||||
* assigns `self.__HL_I18N` before Next's hydration scripts run.
|
||||
* The initializer below reads it — the first client render holds
|
||||
* the same bundle the server rendered with (no EN→DE hydration
|
||||
* flash), and repeat visits serve the catalog from HTTP/SW cache
|
||||
* instead of re-downloading it inside each document.
|
||||
* 3. Locale switch / EN fallback: `loadMessages()` dynamic-imports
|
||||
* the target bundle on demand; each locale becomes its own async
|
||||
* chunk that only the users who need it ever download.
|
||||
*
|
||||
* The module-level cache makes repeat switches instant and lets the
|
||||
* provider seed it with the server-passed bundle (`primeMessages`).
|
||||
* The module-level cache makes repeat switches instant and lets tests
|
||||
* seed bundles directly (`primeMessages`).
|
||||
*/
|
||||
import enMessages from "../../../messages/en.json";
|
||||
import { type Locale } from "./config";
|
||||
|
||||
export type MessageBundle = Record<string, unknown>;
|
||||
|
||||
/** Synchronous fallback floor — always available, never awaited. */
|
||||
export const fallbackMessages: MessageBundle = enMessages;
|
||||
/** Shape of the boot global the /i18n/<locale> script assigns. */
|
||||
interface I18nBootGlobal {
|
||||
locale?: string;
|
||||
messages?: MessageBundle;
|
||||
}
|
||||
|
||||
const loaders: Record<Locale, () => Promise<{ default: MessageBundle }>> = {
|
||||
de: () => import("../../../messages/de.json"),
|
||||
@@ -38,13 +49,55 @@ const loaders: Record<Locale, () => Promise<{ default: MessageBundle }>> = {
|
||||
pl: () => import("../../../messages/pl.json"),
|
||||
};
|
||||
|
||||
const cache: Partial<Record<Locale, MessageBundle>> = { en: enMessages };
|
||||
function initialCache(): Partial<Record<Locale, MessageBundle>> {
|
||||
if (typeof window === "undefined") {
|
||||
// Server pass (SSR of client components): seed every locale from the
|
||||
// server-side catalog map. The branch is dead code in the browser
|
||||
// bundle — Turbopack eliminates it together with the require, so none
|
||||
// of the six catalogs reaches a client chunk. The try/catch covers
|
||||
// non-bundled node runtimes (vitest transforms this module to ESM,
|
||||
// where a bare require of a TS module throws) — there the cache starts
|
||||
// empty and `vitest.setup.ts` primes every bundle up front.
|
||||
try {
|
||||
const { allMessages } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require("./shared-resolve") as typeof import("./shared-resolve");
|
||||
return { ...allMessages };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
// Browser: the deferred boot script (emitted by the root layout ahead of
|
||||
// the hydration scripts) has already assigned the active locale's bundle.
|
||||
const boot = (self as { __HL_I18N?: I18nBootGlobal }).__HL_I18N;
|
||||
if (
|
||||
boot &&
|
||||
typeof boot.locale === "string" &&
|
||||
boot.messages &&
|
||||
typeof boot.messages === "object"
|
||||
) {
|
||||
return { [boot.locale as Locale]: boot.messages };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const cache: Partial<Record<Locale, MessageBundle>> = initialCache();
|
||||
|
||||
export function getCachedMessages(locale: Locale): MessageBundle | undefined {
|
||||
return cache[locale];
|
||||
}
|
||||
|
||||
/** Seed the cache with a bundle that arrived as a server prop. */
|
||||
/**
|
||||
* The EN fallback floor of the t() chain, when it is already in hand —
|
||||
* always on the server, on the client once EN is the active/boot locale or
|
||||
* `loadMessages("en")` has resolved. `undefined` means "not loaded yet";
|
||||
* callers kick off the async load and re-render when it lands.
|
||||
*/
|
||||
export function getFallbackMessages(): MessageBundle | undefined {
|
||||
return cache.en;
|
||||
}
|
||||
|
||||
/** Seed the cache with a bundle that arrived out of band (tests, boot). */
|
||||
export function primeMessages(locale: Locale, messages: MessageBundle): void {
|
||||
cache[locale] = messages;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ const PUBLIC_PATHS = [
|
||||
// and is not an enumeration oracle — so it must reach the page without
|
||||
// an auth gate, like the `/auth/` register surface it forwards to.
|
||||
"/invite/",
|
||||
// Locale-catalog boot script (`/i18n/<locale>?v=…`). The login page loads
|
||||
// it pre-auth, and the body is the same public catalog JSON that ships in
|
||||
// the repository — no tenant data, no secrets. Immutable-cacheable.
|
||||
"/i18n/",
|
||||
// `/onboarding` itself + its subroutes are matched exactly via
|
||||
// `isPublicPath()` so we don't admit `/onboarding-export` etc.
|
||||
"/robots.txt",
|
||||
|
||||
Reference in new issue
Block a user