diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts
index 913bbbf6a..e6ac14953 100644
--- a/e2e/a11y.spec.ts
+++ b/e2e/a11y.spec.ts
@@ -2,10 +2,7 @@ import AxeBuilder from "@axe-core/playwright";
import type { Locator, Page, Route } from "@playwright/test";
import { expect, test } from "./setup/test";
-import {
- MOBILE_ROUTES_STORAGE_STATE_PATH,
- STORAGE_STATE_PATH,
-} from "./setup/global-setup";
+import { 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";
@@ -982,49 +979,6 @@ 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 66e8249c7..127ad3a3c 100644
--- a/e2e/mobile-route-overflow.spec.ts
+++ b/e2e/mobile-route-overflow.spec.ts
@@ -6,7 +6,6 @@ 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";
@@ -173,9 +172,6 @@ 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 3f10f6540..2b8e70726 100644
--- a/e2e/settings-admin-one-piece.spec.ts
+++ b/e2e/settings-admin-one-piece.spec.ts
@@ -58,7 +58,6 @@ 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 fd6ba2966..faf10ceb1 100644
--- a/e2e/setup/global-setup.ts
+++ b/e2e/setup/global-setup.ts
@@ -35,7 +35,6 @@ 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
@@ -290,27 +289,6 @@ 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",
@@ -930,17 +908,15 @@ 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, and the Topics preview is on, so the topic pages
- // render rather than leave for Insights; its record is written after the
- // login capture.
+ // environment surfaces; 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, preview_features_json)
+ module_preferences_json)
VALUES ($1, $2, $3, $4, $5, $6, $6, 168, $7, 'FEMALE', $6, true,
- $8::jsonb, '{"topics": true}'::jsonb)
+ $8::jsonb)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
@@ -948,8 +924,7 @@ 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,
- preview_features_json = EXCLUDED.preview_features_json`,
+ module_preferences_json = EXCLUDED.module_preferences_json`,
[
cuid(),
E2E_MOBILE_ROUTES.username,
@@ -1015,31 +990,6 @@ 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(
@@ -1700,14 +1650,6 @@ 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
deleted file mode 100644
index 927f844c9..000000000
--- a/e2e/setup/topics-fixture.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-/**
- * 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
deleted file mode 100644
index a7fb35f8f..000000000
--- a/e2e/topics-preview.spec.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-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();
- });
-});