diff --git a/e2e/today-just-in.spec.ts b/e2e/today-just-in.spec.ts index 2583b460d..6c8853330 100644 --- a/e2e/today-just-in.spec.ts +++ b/e2e/today-just-in.spec.ts @@ -162,8 +162,17 @@ test("a fresh arrival flips the day in place", async ({ page }) => { // swept by the same `e2e-just-in-` purge on the next run. const priorNight = new Date(Date.now() - 3 * 24 * 60 * 60_000); const priorWoke = new Date(priorNight.getTime() + 7 * 60 * 60_000); + // + // Tagged `foreground`, the trigger an open app sends. Since v1.42 a batch + // without it only marks the dashboard snapshot stale, and `cachedSwr` + // serves the stale cell once while it rebuilds: a snapshot an earlier + // spec left for this account (no sleep at all, so no sleep expected) + // would then paint the BEFORE state as `final`, depending on which spec + // happened to run first in the shard. `foreground` evicts, so the first + // read below is built from the rows this spec just wrote. const priorBatch = await page.request.post("/api/measurements/batch", { data: { + syncTrigger: "foreground", entries: [ { hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis", @@ -229,8 +238,11 @@ test("a fresh arrival flips the day in place", async ({ page }) => { const wokeAt = new Date(now.getTime() - 60 * 60_000); const fellAsleepAt = new Date(wokeAt.getTime() - 7 * 60 * 60_000); + // `foreground` for the same reason as the prior night above: the page + // is open while it lands. const batch = await page.request.post("/api/measurements/batch", { data: { + syncTrigger: "foreground", entries: [ { hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis", diff --git a/e2e/v138-managed-record-capabilities.spec.ts b/e2e/v138-managed-record-capabilities.spec.ts index adedc711f..2a7207059 100644 --- a/e2e/v138-managed-record-capabilities.spec.ts +++ b/e2e/v138-managed-record-capabilities.spec.ts @@ -26,6 +26,7 @@ * the same ones, for the same reasons written there. */ import type { Page } from "@playwright/test"; +import pg from "pg"; import { expect, test } from "./setup/test"; import { @@ -111,15 +112,38 @@ test.describe.serial("a guardian's controls inside a managed profile", () => { if (!managed) throw new Error("managed record fixture is missing"); test("offers the mood add control and lands the entry", async ({ page }) => { + // The profile starts every run without mood entries. The form stamps an + // entry to the minute and `(user, date, moodLoggedAt)` is unique, so a + // second run inside the same minute (a retry, a repeat) met a 409 for the + // entry the first one wrote. An empty record also fixes which control the + // page offers: the empty state's add, not the header's. + const dbUrl = process.env.DATABASE_URL; + test.skip(!dbUrl, "DATABASE_URL is required to reset the profile's mood"); + const pool = new pg.Pool({ connectionString: dbUrl }); + try { + await pool.query( + `DELETE FROM mood_entries + WHERE user_id = (SELECT id FROM users WHERE username = $1)`, + [managed.username], + ); + } finally { + await pool.end(); + } + await openRecord(page, managed.username); const banner = page.locator('[data-slot="shared-record-banner"]'); await expect(banner).toHaveAttribute("data-record-kind", "managed"); await expect(banner).toHaveAttribute("data-access-level", "manage"); await page.goto("/mood"); - const add = page.locator('[data-slot="mood-add-entry"]'); - await expect(add).toBeVisible({ timeout: 30_000 }); - await add.click(); + // Since v1.42 an empty list carries the one add control in its empty + // state and the header drops its own; the header's returns once there is + // an entry to list. Both are asserted on their data-slots. + const addFirst = page.locator('[data-slot="mood-add-first"]'); + const addHeader = page.locator('[data-slot="mood-add-entry"]'); + await expect(addFirst).toBeVisible({ timeout: 30_000 }); + await expect(addHeader).toHaveCount(0); + await addFirst.click(); const face = page.locator('[data-slot="mood-face"][data-mood="GUT"]'); await expect(face).toBeVisible(); @@ -139,6 +163,8 @@ test.describe.serial("a guardian's controls inside a managed profile", () => { await expect(page.locator('[data-slot="mood-rows"]').first()).toBeVisible({ timeout: 30_000, }); + await expect(addHeader).toBeVisible(); + await expect(addFirst).toHaveCount(0); await leaveRecord(page); }); diff --git a/src/__tests__/retired-route-links-guard.test.ts b/src/__tests__/retired-route-links-guard.test.ts index b5d11d6d5..874c20989 100644 --- a/src/__tests__/retired-route-links-guard.test.ts +++ b/src/__tests__/retired-route-links-guard.test.ts @@ -24,6 +24,11 @@ function walk(dir: string): string[] { }); } +/** Every regex metacharacter escaped, the backslash included. */ +function escapeRegExp(text: string): string { + return text.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&"); +} + describe("no link targets a retired route", () => { const files = walk(join(ROOT, "src")); @@ -33,7 +38,7 @@ describe("no link targets a retired route", () => { it.each(Object.entries(RETIRED))("%s is linked as %s", (route) => { const pattern = new RegExp( - `(?:href|push|replace)[=(]\\s*\\{?\\s*["'\`]${route.replace(/\//g, "\\/")}["'\`?#]`, + `(?:href|push|replace)[=(]\\s*\\{?\\s*["'\`]${escapeRegExp(route)}["'\`?#]`, ); const offenders = files .filter((file) => pattern.test(readFileSync(file, "utf8"))) diff --git a/src/components/day/__tests__/use-day-value-format.test.tsx b/src/components/day/__tests__/use-day-value-format.test.tsx index 7fbca9381..e7f1722b4 100644 --- a/src/components/day/__tests__/use-day-value-format.test.tsx +++ b/src/components/day/__tests__/use-day-value-format.test.tsx @@ -42,22 +42,38 @@ function Probe({ ); } +/** + * The text of rendered markup: every character outside a `<…>` tag. A walk + * rather than a tag-stripping regex, which can leave a tag behind when one + * is split across another. + */ +function textOf(html: string): string { + let out = ""; + let inTag = false; + for (const ch of html) { + if (ch === "<") inTag = true; + else if (ch === ">") inTag = false; + else if (!inTag) out += ch; + } + return out; +} + function read( type: string, value: number, unit: string, locale: "en" | "de" = "en", ) { - return renderToStaticMarkup( - - - , - ) - .replace(/<[^>]+>/g, "") - .replace(//g, ""); + return textOf( + renderToStaticMarkup( + + + , + ), + ); } describe("a day's values in their charts' spelling", () => { diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index e27243b14..710eef051 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -48,6 +48,22 @@ const coverage = { missing: ["ACTIVITY", "SLEEP"], }; +/** + * The text of rendered markup: every character outside a `<…>` tag. A walk + * rather than a tag-stripping regex, which can leave a tag behind when one + * is split across another. + */ +function textOf(html: string): string { + let out = ""; + let inTag = false; + for (const ch of html) { + if (ch === "<") inTag = true; + else if (ch === ">") inTag = false; + else if (!inTag) out += ch; + } + return out; +} + function render(node: React.ReactNode, locale: Locale = "en"): string { return renderToStaticMarkup( {node}, @@ -642,9 +658,7 @@ describe(" composite states", () => { // The onset is an instant; it reads as the day it falls on in the // profile timezone, in the user's date format, never as raw ISO. // v1.42 — the date is a link to its day; the sentence reads the same. - expect(rest.replace(/<[^>]+>/g, "")).toMatch( - /Rest Mode active since \d{2}\/\d{2}\/2026\./, - ); + expect(textOf(rest)).toMatch(/Rest Mode active since \d{2}\/\d{2}\/2026\./); expect(rest).not.toContain("T23:30:00"); expect(rest).toContain('data-slot="health-score-tension"'); expect(rest).toContain('data-slot="health-score-return-to-band"'); @@ -672,7 +686,7 @@ describe(" composite states", () => { />, ); const rest = atRest(html); - expect(rest.replace(/<[^>]+>/g, "")).toMatch( + expect(textOf(rest)).toMatch( /Rest Mode active since (07\/20\/2026|20\/07\/2026)/, ); // The date opens the day it names, in the profile timezone. diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index eab8e9de8..d97fe4327 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -742,7 +742,11 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { {t("mood.emptyResetFilter")} ) : onAddFirst && canManageMind ? ( - diff --git a/src/components/settings/coach-nudge-card.tsx b/src/components/settings/coach-nudge-card.tsx index c675cbcb2..4db6cc25e 100644 --- a/src/components/settings/coach-nudge-card.tsx +++ b/src/components/settings/coach-nudge-card.tsx @@ -249,7 +249,10 @@ export function CoachNudgeCard({ /> ))} -
+ {/* Stacked on a phone: at 16 px touch type the longest option + ("At most every two weeks") plus the label no longer fits one + row inside a 390 px card. */} +