diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts
index e6ac14953..913bbbf6a 100644
--- a/e2e/a11y.spec.ts
+++ b/e2e/a11y.spec.ts
@@ -2,7 +2,10 @@ import AxeBuilder from "@axe-core/playwright";
import type { Locator, Page, Route } from "@playwright/test";
import { expect, test } from "./setup/test";
-import { STORAGE_STATE_PATH } from "./setup/global-setup";
+import {
+ MOBILE_ROUTES_STORAGE_STATE_PATH,
+ STORAGE_STATE_PATH,
+} from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import { openMenu } from "./open-menu";
import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot";
@@ -979,6 +982,49 @@ const ADMIN_AND_BASELINE_ROUTES: readonly RouteCase[] = [
},
];
+/**
+ * The topic pages (preview), on the phone-sweep account: its preview is on
+ * and never switched, and its record holds a little of every kind, so the
+ * overview draws tiles and the pages draw both their data and their
+ * below-threshold states. Gated on the grid and the page body, which live in
+ * other components than the `
` the scan checks.
+ */
+const TOPIC_ROUTES: readonly RouteCase[] = [
+ {
+ name: "/topics overview",
+ path: "/topics",
+ painted: (page) => page.locator('[data-slot="topics-grid"]:visible'),
+ },
+ {
+ name: "/topics/heart",
+ path: "/topics/heart",
+ painted: (page) => page.locator('[data-slot="topic-detail-body"]:visible'),
+ },
+ {
+ name: "/topics/metabolism",
+ path: "/topics/metabolism",
+ painted: (page) => page.locator('[data-slot="topic-detail-body"]:visible'),
+ },
+];
+
+test.describe("axe-core topic pages (preview)", () => {
+ test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
+
+ for (const theme of THEMES) {
+ test(`theme=${theme} routes: /topics, /topics/heart, /topics/metabolism`, async ({
+ page,
+ }) => {
+ test.setTimeout(120_000);
+ await useExplicitTheme(page, theme);
+ const blocking: AxeViolation[] = [];
+ for (const routeCase of TOPIC_ROUTES) {
+ blocking.push(...(await visitAndScan(page, theme, routeCase)));
+ }
+ expectNoViolations(theme, "topic route matrix", blocking);
+ });
+ }
+});
+
test.describe("axe-core public surfaces", () => {
for (const theme of THEMES) {
test(`theme=${theme} /auth/login`, async ({ page }) => {
diff --git a/e2e/mobile-route-overflow.spec.ts b/e2e/mobile-route-overflow.spec.ts
index 127ad3a3c..66e8249c7 100644
--- a/e2e/mobile-route-overflow.spec.ts
+++ b/e2e/mobile-route-overflow.spec.ts
@@ -6,6 +6,7 @@ import type { APIRequestContext } from "@playwright/test";
import { ADMIN_SECTION_SLUGS } from "@/components/admin/section-slugs";
import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids";
import { SETTINGS_SECTION_SLUGS } from "@/lib/settings/section-slugs";
+import { TOPIC_KEYS } from "@/lib/topics/registry";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -172,6 +173,9 @@ const DYNAMIC_ROUTES: Record = {
)}`,
];
},
+ // Every topic (preview). The sweep account has the preview on; a topic its
+ // modules do not offer still renders its frame and the refusal card.
+ "/topics/[topic]": async () => TOPIC_KEYS.map((topic) => `/topics/${topic}`),
// An ECG strip only arrives through a device sync; no route writes one. The
// detail page is swept in its not-found state, which is still the frame,
// header and back link a phone renders.
diff --git a/e2e/settings-admin-one-piece.spec.ts b/e2e/settings-admin-one-piece.spec.ts
index 2b8e70726..3f10f6540 100644
--- a/e2e/settings-admin-one-piece.spec.ts
+++ b/e2e/settings-admin-one-piece.spec.ts
@@ -58,6 +58,7 @@ const SETTINGS_ROUTES = [
"/settings/api",
"/settings/mcp",
"/settings/advanced",
+ "/settings/preview",
"/settings/about",
] as const;
diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts
index faf10ceb1..fd6ba2966 100644
--- a/e2e/setup/global-setup.ts
+++ b/e2e/setup/global-setup.ts
@@ -35,6 +35,7 @@ import pg from "pg";
import { seedDayDoorsRecord } from "./day-doors-fixture";
import { seedMobileRoutesRecord } from "./mobile-routes-fixture";
+import { seedTopicsRecord } from "./topics-fixture";
// Resolve relative to the project root rather than __dirname/import.meta.url —
// Playwright's TS loader runs files as CJS without `"type":"module"`, and
@@ -289,6 +290,27 @@ export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve(
"e2e/setup/storageStateScoreHistory.json",
);
+/**
+ * The topic pages' own account (`topics-preview.spec.ts`).
+ *
+ * The journey switches the Topics preview on and off, which adds and removes
+ * a navigation entry for the whole account; on the shared account a parallel
+ * spec would see its More sheet change underneath it. The preview starts off,
+ * the way every account starts, and the record is written after the login
+ * capture (`topics-fixture.ts`).
+ */
+export const E2E_TOPICS = {
+ email: "e2e-topics@healthlog.test",
+ username: "e2e-topics",
+ password: "Tp8!Hq4nVw7zKc3M",
+ role: "USER",
+} as const;
+
+export const TOPICS_STORAGE_STATE_PATH = resolve(
+ process.cwd(),
+ "e2e/setup/storageStateTopics.json",
+);
+
export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateAiOptional.json",
@@ -908,15 +930,17 @@ export default async function globalSetup(config: FullConfig): Promise {
// The phone-width route sweep's account. Every opt-in module is on, so
// the sweep reaches the glucose, documents, wellbeing, nutrients and
- // environment surfaces; its record is written after the login capture.
+ // environment surfaces, and the Topics preview is on, so the topic pages
+ // render rather than leave for Insights; its record is written after the
+ // login capture.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
height_cm, date_of_birth, gender,
onboarding_completed_at, onboarding_tour_completed,
- module_preferences_json)
+ module_preferences_json, preview_features_json)
VALUES ($1, $2, $3, $4, $5, $6, $6, 168, $7, 'FEMALE', $6, true,
- $8::jsonb)
+ $8::jsonb, '{"topics": true}'::jsonb)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
@@ -924,7 +948,8 @@ export default async function globalSetup(config: FullConfig): Promise {
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
- module_preferences_json = EXCLUDED.module_preferences_json`,
+ module_preferences_json = EXCLUDED.module_preferences_json,
+ preview_features_json = EXCLUDED.preview_features_json`,
[
cuid(),
E2E_MOBILE_ROUTES.username,
@@ -990,6 +1015,31 @@ export default async function globalSetup(config: FullConfig): Promise {
],
);
+ // The topic pages' account. Default modules, the preview off; its record
+ // is written after the login capture.
+ await pool.query(
+ `INSERT INTO users
+ (id, username, email, password_hash, role, created_at, updated_at,
+ onboarding_completed_at, onboarding_tour_completed,
+ module_preferences_json, preview_features_json)
+ VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL, NULL)
+ ON CONFLICT (username) DO UPDATE SET
+ email = EXCLUDED.email,
+ password_hash = EXCLUDED.password_hash,
+ updated_at = EXCLUDED.updated_at,
+ onboarding_completed_at = EXCLUDED.onboarding_completed_at,
+ onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
+ module_preferences_json = NULL,
+ preview_features_json = NULL`,
+ [
+ cuid(),
+ E2E_TOPICS.username,
+ E2E_TOPICS.email,
+ await hashPassword(E2E_TOPICS.password),
+ now,
+ ],
+ );
+
// The day-doors journey's account. Every module the journey reaches is
// on; its record is written after the login capture.
await pool.query(
@@ -1650,6 +1700,14 @@ export default async function globalSetup(config: FullConfig): Promise {
await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH);
await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH);
+ // The topic pages' jar, then its record — see `E2E_TOPICS`.
+ await capture(E2E_TOPICS, TOPICS_STORAGE_STATE_PATH);
+ await seedTopicsRecord(
+ baseURL,
+ TOPICS_STORAGE_STATE_PATH,
+ E2E_TOPICS.username,
+ );
+
// The score-history journey's jar — see `E2E_SCORE_HISTORY`.
await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH);
diff --git a/e2e/setup/topics-fixture.ts b/e2e/setup/topics-fixture.ts
new file mode 100644
index 000000000..927f844c9
--- /dev/null
+++ b/e2e/setup/topics-fixture.ts
@@ -0,0 +1,138 @@
+/**
+ * The record `topics-preview.spec.ts` reads its topics from.
+ *
+ * Enough of each kind that every topic the account is offered has something
+ * to show, and some of it deliberately short of the thresholds, so the page
+ * states what is missing rather than a figure:
+ *
+ * - 40 days of resting heart rate, heart-rate variability and sleep, with
+ * every fourth night under seven hours and the morning after it higher:
+ * the heart pairs reach 14 paired days and say something;
+ * - 30 days of weight, 25 of blood pressure;
+ * - 12 days of steps: the steps pairs stay below 14 and say so;
+ * - 10 mood entries: the mind pairs stay below 14 and say so;
+ * - LDL three times over two years (a trend line) and vitamin D once
+ * (below its range, no trend yet).
+ *
+ * The readings go straight into Postgres: the topics read the live table, and
+ * a few hundred route calls would only slow `globalSetup` down. Mood entries
+ * and lab results go through the app's routes, which own their encryption and
+ * the marker catalogue. An account that already holds the LDL marker is taken
+ * as seeded, so a local re-run against a reused database does not double it.
+ */
+import { randomUUID } from "node:crypto";
+
+import { request as playwrightRequest } from "@playwright/test";
+import pg from "pg";
+
+export const TOPICS_LAB_MARKER = "LDL";
+
+const DAY_MS = 86_400_000;
+
+type Row = [type: string, unit: string, value: number, measuredAt: Date];
+
+/** The readings, one per local morning, ending yesterday. */
+function readings(now: number): Row[] {
+ const at = (daysAgo: number, hourUtc: number) => {
+ const d = new Date(now - daysAgo * DAY_MS);
+ d.setUTCHours(hourUtc, 30, 0, 0);
+ return d;
+ };
+ const rows: Row[] = [];
+ for (let i = 1; i <= 40; i++) {
+ const shortNight = i % 4 === 0;
+ rows.push(["SLEEP_DURATION", "min", shortNight ? 360 : 480, at(i, 5)]);
+ rows.push([
+ "RESTING_HEART_RATE",
+ "bpm",
+ 55 + (i % 10) + (shortNight ? 5 : 0),
+ at(i, 6),
+ ]);
+ rows.push(["HEART_RATE_VARIABILITY", "ms", 80 - (i % 10) * 3, at(i, 6)]);
+ }
+ for (let i = 1; i <= 30; i++) {
+ rows.push(["WEIGHT", "kg", 82 - (30 - i) * 0.03, at(i, 6)]);
+ }
+ for (let i = 1; i <= 25; i++) {
+ rows.push(["BLOOD_PRESSURE_SYS", "mmHg", 118 + (i % 5), at(i, 7)]);
+ rows.push(["BLOOD_PRESSURE_DIA", "mmHg", 76 + (i % 3), at(i, 7)]);
+ rows.push(["PULSE", "bpm", 64 + (i % 4), at(i, 7)]);
+ }
+ for (let i = 1; i <= 12; i++) {
+ rows.push(["ACTIVITY_STEPS", "steps", 7000 + (i % 5) * 900, at(i, 18)]);
+ }
+ return rows;
+}
+
+export async function seedTopicsRecord(
+ baseURL: string,
+ storageStatePath: string,
+ username: string,
+): Promise {
+ const ctx = await playwrightRequest.newContext({
+ baseURL,
+ storageState: storageStatePath,
+ });
+ const url = process.env.DATABASE_URL;
+ if (!url) throw new Error("[topics-fixture] DATABASE_URL is not set");
+ const pool = new pg.Pool({ connectionString: url });
+ try {
+ const labs = await ctx.get("/api/labs");
+ const body = (await labs.json()) as { data: unknown };
+ if (JSON.stringify(body.data ?? null).includes(`"${TOPICS_LAB_MARKER}"`)) {
+ return;
+ }
+
+ const user = await pool.query<{ id: string }>(
+ "SELECT id FROM users WHERE username = $1",
+ [username],
+ );
+ const userId = user.rows[0]?.id;
+ if (!userId) throw new Error("[topics-fixture] topics account not seeded");
+
+ const now = Date.now();
+ for (const [type, unit, value, measuredAt] of readings(now)) {
+ await pool.query(
+ `INSERT INTO measurements
+ (id, user_id, type, value, unit, source, measured_at, updated_at)
+ VALUES ($1, $2, $3::"measurement_type", $4, $5, 'MANUAL', $6, now())`,
+ [randomUUID(), userId, type, value, unit, measuredAt],
+ );
+ }
+
+ const post = async (path: string, data: unknown): Promise => {
+ const res = await ctx.post(path, { data });
+ if (res.status() === 409 || res.ok()) return;
+ throw new Error(
+ `[topics-fixture] ${path} answered ${res.status()}: ${(await res.text()).slice(0, 200)}`,
+ );
+ };
+ for (let i = 1; i <= 10; i++) {
+ const at = new Date(now - i * DAY_MS);
+ at.setUTCHours(19, 0, 0, 0);
+ await post("/api/mood-entries", {
+ mood: i % 3 === 0 ? "OKAY" : "GUT",
+ moodLoggedAt: at.toISOString(),
+ });
+ }
+ for (const [analyte, unit, value, daysAgo, low, high] of [
+ [TOPICS_LAB_MARKER, "mg/dL", 140, 760, 0, 130],
+ [TOPICS_LAB_MARKER, "mg/dL", 121, 390, 0, 130],
+ [TOPICS_LAB_MARKER, "mg/dL", 98, 20, 0, 130],
+ ["Vitamin D", "ng/mL", 24, 25, 30, 100],
+ ] as const) {
+ await post("/api/labs", {
+ analyte,
+ panel: "Topics",
+ value,
+ unit,
+ referenceLow: low,
+ referenceHigh: high,
+ takenAt: new Date(now - daysAgo * DAY_MS).toISOString(),
+ });
+ }
+ } finally {
+ await pool.end();
+ await ctx.dispose();
+ }
+}
diff --git a/e2e/topics-preview.spec.ts b/e2e/topics-preview.spec.ts
new file mode 100644
index 000000000..a7fb35f8f
--- /dev/null
+++ b/e2e/topics-preview.spec.ts
@@ -0,0 +1,219 @@
+import type { Page } from "@playwright/test";
+
+import { expect, test } from "./setup/test";
+import { TOPICS_STORAGE_STATE_PATH } from "./setup/global-setup";
+import { expectNoHorizontalOverflow } from "./utils/horizontal-overflow";
+
+/**
+ * The Topics preview end to end, on its own account (`E2E_TOPICS`, seeded by
+ * `topics-fixture.ts`):
+ *
+ * - switched off (every account's start): no More tile, no sidebar entry,
+ * the pages leave for Insights and the routes answer 404;
+ * - switched on in Settings > System > Preview features: the More sheet
+ * leads with a Topics tile carrying the Beta badge, the desktop sidebar
+ * has the entry directly below Insights;
+ * - every offered topic renders from the seeded record, with the relationship
+ * that has fourteen paired days stated and the ones short of it saying how
+ * far they are, the lab trend drawn for the marker with three readings over
+ * a year and named as missing for the one with a single reading.
+ *
+ * Phone project only, in one serial run: the switch is account-wide, so two
+ * projects toggling it at once would read each other's state.
+ */
+test.describe.configure({ mode: "serial" });
+
+async function setPreview(page: Page, topics: boolean) {
+ const res = await page.request.patch("/api/settings/preview-features", {
+ data: { topics },
+ });
+ expect(res.ok(), `preview PATCH answered ${res.status()}`).toBe(true);
+}
+
+async function openMoreSheet(page: Page) {
+ await page.locator('[data-testid="bottom-nav-more"]').click();
+ const sheet = page.locator('[data-testid="bottom-nav-more-sheet"]');
+ await expect(sheet).toBeVisible();
+ return sheet;
+}
+
+test.describe("Topics (preview)", () => {
+ test.use({
+ storageState: TOPICS_STORAGE_STATE_PATH,
+ viewport: { width: 390, height: 844 },
+ });
+
+ test.beforeEach(async ({}, testInfo) => {
+ test.skip(testInfo.project.name !== "chromium-mobile", "phone journey");
+ });
+
+ test.afterAll(async ({ browser }, testInfo) => {
+ if (testInfo.project.name !== "chromium-mobile") return;
+ const context = await browser.newContext({
+ storageState: TOPICS_STORAGE_STATE_PATH,
+ baseURL: testInfo.project.use.baseURL,
+ });
+ // Leave the account as every account starts: the preview off.
+ await context.request.patch("/api/settings/preview-features", {
+ data: { topics: false },
+ });
+ await context.close();
+ });
+
+ test("switched off: no tile, no entry, the pages leave and the routes refuse", async ({
+ page,
+ }) => {
+ await setPreview(page, false);
+ await page.goto("/");
+ const sheet = await openMoreSheet(page);
+ await expect(sheet.locator('a[href="/topics"]')).toHaveCount(0);
+ await page.keyboard.press("Escape");
+
+ await page.goto("/topics");
+ await page.waitForURL(/\/insights$/);
+ await page.goto("/topics/heart");
+ await page.waitForURL(/\/insights$/);
+
+ const api = await page.request.get("/api/topics");
+ expect(api.status()).toBe(404);
+ expect(
+ ((await api.json()) as { meta?: { errorCode?: string } }).meta,
+ ).toMatchObject({ errorCode: "topics.preview_off" });
+ });
+
+ test("switched on in Settings: the More sheet leads with the Topics tile", async ({
+ page,
+ }) => {
+ await setPreview(page, false);
+ await page.goto("/settings/preview");
+ const toggle = page.locator('[data-slot="settings-preview-topics-switch"]');
+ await expect(toggle).toHaveAttribute("aria-checked", "false");
+ await toggle.click();
+ await expect(toggle).toHaveAttribute("aria-checked", "true");
+ await expect(
+ page.locator('[data-slot="settings-preview-topics-open"]'),
+ ).toBeVisible();
+
+ const sheet = await openMoreSheet(page);
+ const tiles = sheet.locator("a");
+ await expect(tiles.first()).toHaveAttribute("href", "/topics");
+ await expect(
+ tiles.first().locator('[data-slot="preview-badge"]'),
+ ).toBeVisible();
+ await tiles.first().click();
+ await page.waitForURL(/\/topics$/);
+ await expect(page.locator('[data-slot="topics-grid"]')).toBeVisible();
+ });
+
+ test("every offered topic renders from the record, below-threshold states included", async ({
+ page,
+ }) => {
+ await setPreview(page, true);
+ await page.goto("/topics");
+ const grid = page.locator('[data-slot="topics-grid"]');
+ await expect(grid).toBeVisible();
+ for (const key of [
+ "heart",
+ "body",
+ "blood-pressure",
+ "sleep",
+ "activity",
+ "metabolism",
+ "mind",
+ ]) {
+ await expect(
+ grid.locator(`[data-slot="topic-tile"][data-topic="${key}"]`),
+ ).toBeVisible();
+ }
+ // No cycle module for this account: named once, never a tile.
+ await expect(
+ grid.locator('[data-slot="topic-tile"][data-topic="cycle"]'),
+ ).toHaveCount(0);
+ await expect(
+ page.locator('[data-slot="topics-hidden-list"]'),
+ ).toBeVisible();
+ await expectNoHorizontalOverflow(page, "/topics");
+
+ // The heart: the pair with 40 paired days speaks, the steps pair cannot.
+ await grid
+ .locator('[data-topic="heart"] [data-slot="topic-tile-link"]')
+ .click();
+ await page.waitForURL(/\/topics\/heart$/);
+ const body = page.locator('[data-slot="topic-detail-body"]');
+ await expect(body).toBeVisible();
+ await expect(
+ body.locator(
+ '[data-slot="topic-relationship"][data-relationship="hrv-resting-hr"]',
+ ),
+ ).toHaveAttribute("data-status", "linked");
+ await expect(
+ body.locator(
+ '[data-slot="topic-signal"][data-signal="RESTING_HEART_RATE"]',
+ ),
+ ).toBeVisible();
+ await expect(
+ body.locator('[data-slot="topic-chart-row"]').first(),
+ ).toBeVisible();
+ await expect(body.locator('[data-slot="topic-missing"]')).toBeVisible();
+ await expectNoHorizontalOverflow(page, "/topics/heart");
+
+ // The range switch re-reads the window.
+ await page
+ .locator('[data-slot="topic-range"]')
+ .getByRole("radio", { name: /90/ })
+ .click();
+ await expect(body).toBeVisible();
+
+ await page.goto("/topics/activity");
+ await expect(
+ page.locator(
+ '[data-slot="topic-relationship"][data-relationship="steps-sleep"]',
+ ),
+ ).toHaveAttribute("data-status", "insufficient");
+
+ await page.goto("/topics/mind");
+ await expect(
+ page.locator(
+ '[data-slot="topic-relationship"][data-relationship="short-nights-mood"]',
+ ),
+ ).toHaveAttribute("data-status", "insufficient");
+
+ await page.goto("/topics/metabolism");
+ const labs = page.locator('[data-slot="topic-labs"]');
+ await expect(labs).toBeVisible();
+ await expect(labs.locator('[data-slot="topic-lab-trend"]')).toHaveCount(1);
+ await expect(
+ labs.locator('[data-slot="topic-lab-trend-short"]'),
+ ).toBeVisible();
+ await expect(
+ labs.locator('[data-slot="topic-lab-marker"][data-status="below"]'),
+ ).toHaveCount(1);
+ await expectNoHorizontalOverflow(page, "/topics/metabolism");
+
+ for (const key of ["body", "blood-pressure", "sleep"]) {
+ await page.goto(`/topics/${key}`);
+ await expect(
+ page.locator('[data-slot="topic-detail-body"]'),
+ ).toBeVisible();
+ await expectNoHorizontalOverflow(page, `/topics/${key}`);
+ }
+ });
+
+ test("the desktop sidebar carries the entry directly below Insights", async ({
+ page,
+ }) => {
+ await setPreview(page, true);
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await page.goto("/topics");
+ const nav = page.locator('[data-slot="sidebar-column"] nav');
+ const links = nav.locator("a");
+ await expect(nav.locator('a[href="/topics"]')).toBeVisible();
+ const hrefs = await links.evaluateAll((els) =>
+ els.map((el) => el.getAttribute("href")),
+ );
+ expect(hrefs[hrefs.indexOf("/insights") + 1]).toBe("/topics");
+ await expect(
+ nav.locator('a[href="/topics"] [data-slot="preview-badge"]'),
+ ).toBeVisible();
+ });
+});