diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index 2479d4cf3..5fcb7f363 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -78,6 +78,8 @@ import { Buffer } from "node:buffer"; import { createHash } from "node:crypto"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import pg from "pg"; import { encryptToBytes } from "../src/lib/ai/coach/bytes-codec"; @@ -88,6 +90,7 @@ import { getKeyFingerprint, } from "../src/lib/crypto"; import { ENVIRONMENT_LOCATION_AAD } from "../src/lib/crypto/encrypted-columns"; +import { withBriefingGeneratedAt } from "../src/lib/insights/briefing-generated-at"; import { sealInsightsCachedText } from "../src/lib/insights/cached-text-cipher"; import { DEFAULT_CHART_OVERLAY_PREFS, @@ -416,7 +419,11 @@ function daylightHours(lat: number, doy: number): number { // ── Main ───────────────────────────────────────── -async function seed() { +/** + * Seed the demo tenant. Exported so an integration test can run the real + * seed and read its output back through the app's own readers. + */ +export async function seed() { const client = await pool.connect(); try { @@ -3369,11 +3376,15 @@ async function seed() { const bakedProvider = "anthropic"; // ── Comprehensive insight + daily briefing ── - // Stored sealed on users.insights_cached_text_encrypted (JSON). The GET - // read opens and parses it and - // returns it as-is; setting insights_cached_at = NOW() keeps it inside the - // 24 h freshness window for the dashboard-snapshot briefing path. The shape - // is the union of insightResultSchema (summary / findings / correlations / + // Stored sealed on users.insights_cached_text_encrypted (JSON), written + // the way a generation writes it (`briefing-request/commit.ts`): the + // payload stamped with its generation moment, the cache stamp, and the + // language tag. Every briefing reader serves `dailyBriefing` only when + // that `briefingGeneratedAt` falls on the reader's today, so a payload + // without it reads as text of unknown age: the advisor card and the + // dashboard headline stay empty and a warm is asked for, which on the + // demo can only fail against the placeholder key. The shape is the union + // of insightResultSchema (summary / findings / correlations / // recommendations / dataQuality / disclaimer / classification) and the // richer optional blocks the dashboard validates (dailyBriefing with // paragraph + signalsOfDay + keyFindings, trendAnnotations). @@ -3461,8 +3472,10 @@ async function seed() { "This is a reasoned observation of your own data, not medical advice " + "or a diagnosis. Discuss any concerns with your doctor.", dailyBriefing: { + // No salutation: the hero above it already greets by the hour, and + // a demo filmed in the evening read "Good evening" over "Good morning". paragraph: - "Good morning. Today's picture is a calm one: your most recent " + + "Today's picture is a calm one: your most recent " + "blood pressure sits comfortably in the optimal band, resting heart " + "rate is low, and last night's sleep landed right around your " + "target. Weight is holding at its new steady place and your mood " + @@ -3552,10 +3565,12 @@ async function seed() { }, }; + const briefingWrittenAt = new Date(); await client.query( `UPDATE users SET insights_cached_text_encrypted = $2, - insights_cached_at = NOW(), + insights_cached_at = $5, + insights_cached_locale = 'en', insights_snapshot_hash = $3, insights_briefing_reroll_date = $4 WHERE id = $1`, @@ -3563,7 +3578,11 @@ async function seed() { userId, // Sealed like every write since v1.43.7. Buffer.from( - sealInsightsCachedText(JSON.stringify(comprehensiveInsight)), + sealInsightsCachedText( + JSON.stringify( + withBriefingGeneratedAt(comprehensiveInsight, briefingWrittenAt), + ), + ), ), // A stable fingerprint. The 24 h cache window keeps the row fresh on // its own; this value just has to be present + non-null so a @@ -3573,6 +3592,7 @@ async function seed() { // Mark today's briefing re-roll as already done so the once-per-day // re-roll path is a no-op (it would fail against the placeholder key). todayKey, + briefingWrittenAt, ], ); @@ -3841,4 +3861,9 @@ async function seed() { } } -seed(); +if ( + process.argv[1] && + resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)) +) { + seed(); +} diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index c07126ce1..378d40f6e 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -302,7 +302,7 @@ describe("", () => { const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString(); const html = render( , @@ -314,6 +314,32 @@ describe("", () => { } }); + /** + * The caption dates the subtitle. The read withholds a briefing written on + * an earlier day while the cache stamp still reads today (a warm that kept + * the old text re-stamps the row), so with no briefing the hero showed + * "Updated today" under the generic fallback sentence and the card below + * it said there was no briefing. + */ + it("withholds the freshness caption when there is no briefing", () => { + vi.useFakeTimers(); + vi.setSystemTime(morningLocal); + try { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString(); + const html = render( + , + ); + expect(html).not.toContain('data-slot="insights-hero-strip-generated"'); + expect(html).not.toContain("Updated today"); + } finally { + vi.useRealTimers(); + } + }); + it("does NOT render the generated caption when updatedAt is missing", () => { const html = render(); expect(html).not.toContain('data-slot="insights-hero-strip-generated"'); diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 1ec661886..279727f52 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -135,9 +135,14 @@ export function HeroStrip({ // "yesterday" / "on DD.MM."), matching the briefing + per-metric cards so the // hero no longer reads relative while the cards below it read absolute. The // day boundary follows the user's profile timezone. - const generatedLine = updatedAt - ? formatUpdatedLabel(updatedAt, t, fmt.dateShort, fmt.time, displayTz) - : null; + // The caption dates the SUBTITLE, so it shows only when the subtitle is a + // briefing. The cache stamp moves on every warm, including one that keeps + // yesterday's text, which the read then withholds: without a briefing it + // put "Updated today" under the generic fallback sentence. + const generatedLine = + briefing && updatedAt + ? formatUpdatedLabel(updatedAt, t, fmt.dateShort, fmt.time, displayTz) + : null; return (
- {briefing && ( - - )} + {generatedLine} diff --git a/tests/integration/demo-seed-ai-texts.test.ts b/tests/integration/demo-seed-ai-texts.test.ts new file mode 100644 index 000000000..e4481f1c6 --- /dev/null +++ b/tests/integration/demo-seed-ai-texts.test.ts @@ -0,0 +1,151 @@ +/** + * The demo seed bakes model-written text into the same rows a generation + * writes, because the public demo has no live provider. That only works if + * the seed writes exactly what the readers expect: a payload without its + * `briefingGeneratedAt` stamp reads as text of unknown age, and every + * briefing surface then serves nothing while a "today" timestamp sits on + * the page. + * + * This runs the real `scripts/seed-demo.ts` against the test database and + * reads its output back through the app's own readers: the advisor briefing + * behind the `/insights` card and hero, the dashboard snapshot headline, the + * per-metric status cards and the period narratives. + */ +import { NextRequest } from "next/server"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +// A warm on the demo can only fail against the seeded placeholder key, so a +// fresh seed must not ask for one. +const enqueueForceWarm = vi.fn(); +vi.mock("@/lib/jobs/insight-pregenerate-shared", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/jobs/insight-pregenerate-shared") + >()), + enqueueForceWarm: (...args: unknown[]) => enqueueForceWarm(...args), +})); + +let userId: string; + +beforeAll(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + headerJar.clear(); + const { seed } = await import("../../scripts/seed-demo"); + await seed(); + const user = await prisma.user.findUniqueOrThrow({ + where: { username: "demo" }, + select: { id: true }, + }); + userId = user.id; +}, 180_000); + +afterAll(() => { + vi.restoreAllMocks(); +}); + +describe("demo seed, baked AI texts read back through the app", () => { + it("serves today's briefing on the advisor read, with no warm asked for", async () => { + const { readAdvisorBriefing } = + await import("@/lib/insights/briefing-request/read"); + + const read = await readAdvisorBriefing({ + request: new NextRequest("http://localhost/api/insights/generate"), + userId, + userLocale: "en", + }); + + const briefing = (read.insights as Record | null) + ?.dailyBriefing as { + paragraph: string; + keyFindings: unknown[]; + signalsOfDay?: Array<{ sourceMetric: string }>; + } | null; + expect(briefing).not.toBeNull(); + expect(briefing?.paragraph).toMatch(/^Today's picture is a calm one/); + expect(briefing?.keyFindings).toHaveLength(4); + // Sleep rests on no single dated reading, so it is kept whatever the + // hour; the reading-backed signals follow today's seeded readings. + expect(briefing?.signalsOfDay?.map((s) => s.sourceMetric)).toContain( + "sleep", + ); + expect(read.revalidating).toBe(false); + expect(enqueueForceWarm).not.toHaveBeenCalled(); + }); + + it("serves the same briefing as the dashboard headline", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); + const { readDashboardSnapshotCached } = + await import("@/lib/dashboard/snapshot-read"); + + const { body, locale } = await readDashboardSnapshotCached(user); + + expect(locale).toBe("en"); + expect(body.briefingState).toBe("ready"); + expect(body.briefing?.paragraph).toMatch(/^Today's picture is a calm one/); + }); + + it("serves every baked status card as today's note", async () => { + const { readFreshStatusText, statusCacheAction } = + await import("@/lib/insights/status-cache"); + const { userDayKey } = await import("@/lib/tz/format"); + const todayKey = userDayKey(new Date(), "Europe/Berlin"); + + for (const scope of [ + "blood-pressure", + "pulse", + "weight", + "bmi", + "mood", + "medication-compliance", + "general", + ]) { + const hit = await readFreshStatusText({ + userId, + cacheAction: statusCacheAction(scope, "en"), + todayKey, + force: false, + }); + expect(hit?.text, scope).toBeTruthy(); + } + }); + + it("serves both baked period narratives", async () => { + const { readPeriodNarrative } = + await import("@/lib/insights/narrative/period-narrative-generate"); + + for (const period of ["week", "month"] as const) { + const read = await readPeriodNarrative(userId, period, "en"); + expect(read?.text, period).toBeTruthy(); + } + }); +});