diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 47dd2c292..821671dfb 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -3,7 +3,7 @@ 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 { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; import { getBpTargets } from "@/lib/analytics/bp-targets"; @@ -55,7 +55,11 @@ export const GET = apiHandler(async (request?: Request) => { // (userId, slice). The slim slice is the dashboard tile strip's hot // path; multiple dashboard mounts inside a 60-second TTL all hit a // warm cache. - const slim = await cached( + // v1.16.7 — SWR read: a sync-write marks the cell stale, and the + // tile strip's next mount should repaint from the prior summaries + // instantly while one background recompute refreshes them, instead + // of paying the 2-SQL-pass rebuild inline. + const slim = await cachedSwr( caches.analytics as ServerCache>>, `${user.id}|summaries`, () => computeSummariesSlice(user.id), @@ -74,7 +78,11 @@ export const GET = apiHandler(async (request?: Request) => { // mount, and the Coach drawer all hit this endpoint within seconds // of each other; the 60s TTL converts the 7.99s combined dashboard // wait to a Map lookup on every subsequent caller. - const cachedBody = await cached( + // v1.16.7 — SWR read, same rationale as the slim slice above: the + // thick body is the 30-query chain feeding the dashboard + insights + // hero score; after a measurement sync the next mount serves the + // prior body instantly while one background recompute refreshes it. + const cachedBody = await cachedSwr( caches.analytics as ServerCache>>, `${user.id}|default`, () => buildAnalyticsResponse(user), diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 429d331d8..6538c5818 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -12,22 +12,22 @@ export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); - // v1.4.22 W5 reconcile (Sr-H1) — fall-back resync for legacy - // sessions that predate the cookie. New sessions anchor the cookie - // inside `createSession` itself, so this is no longer the primary - // write path; it just makes sure pre-v1.4.22 sessions get their - // cookie set on the first /me roundtrip after the upgrade. - await setOnboardingPendingCookie(user.onboardingCompletedAt == null); - annotate({ action: { name: "auth.me" } }); - // v1.15.0 — resolved cycle-tracking gate. Read the profile without - // forcing a row (a NULL toggle derives from gender); the resolver - // collapses both gates into the single boolean iOS hides the tab on. - const cycleProfile = await prisma.cycleProfile.findUnique({ - where: { userId: user.id }, - select: { cycleTrackingEnabled: true }, - }); + // Two independent awaits, overlapped: the onboarding-cookie resync + // (v1.4.22 W5 Sr-H1 — fall-back for legacy sessions that predate the + // cookie; new sessions anchor it inside `createSession`) touches only + // the cookie store, while the cycle-profile read (v1.15.0 — resolved + // cycle-tracking gate; no row is forced, a NULL toggle derives from + // gender) is a Postgres round-trip. Running them sequentially added + // the cookie hop to every /me — and /me sits on every app boot path. + const [, cycleProfile] = await Promise.all([ + setOnboardingPendingCookie(user.onboardingCompletedAt == null), + prisma.cycleProfile.findUnique({ + where: { userId: user.id }, + select: { cycleTrackingEnabled: true }, + }), + ]); const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile); // v1.7.0 — patient-identity fields for the health-record export. The diff --git a/src/app/api/bugreport/status/route.ts b/src/app/api/bugreport/status/route.ts index d7387fb4c..cb6944c04 100644 --- a/src/app/api/bugreport/status/route.ts +++ b/src/app/api/bugreport/status/route.ts @@ -8,9 +8,8 @@ * server-side (log annotation only) so we don't hand regular users a probe. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError } from "@/lib/api-response"; +import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; @@ -47,12 +46,12 @@ async function buildBugreportStatusValue(): Promise { export const GET = apiHandler(async () => { const { user } = await requireAuth(); - // Light rate limit — the endpoint is cheap, but no reason to let a logged-in - // client hammer Postgres in a loop. - const rl = await checkRateLimit(`bugreport-status:${user.id}`, 30, 60 * 1000); - if (!rl.allowed) { - return apiError("Rate limit exceeded", 429); - } + // No per-request rate limit: the payload rides the singleton server + // cache below, so a cache hit costs zero Postgres round-trips — while + // the former rate-limit bucket was an unconditional Postgres UPSERT + // per request, i.e. the limiter WAS the per-request DB load it claimed + // to prevent. The route stays authenticated; the only uncached work is + // one `appSettings` read per 60 s process-wide. // Cache the global app-settings shape on a singleton key (per blueprint §3). // `isAdmin` lives outside the cache because it varies per request. The diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index 8aef95fdb..40ce62fd1 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -25,7 +25,7 @@ import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; import { ensureUserMoodRollupsFresh, @@ -52,9 +52,18 @@ export const GET = apiHandler(async () => { // fans out to this endpoint alongside the Coach drawer + the // recommendations grid; the 60s TTL converts every duplicate // mount within the window to a Map lookup. Invalidation is handled - // by `invalidateUserMeasurements` which already evicts every key - // under the `${userId}|` prefix (see `lib/cache/invalidate.ts`). - const body = await cached( + // by `invalidateUserMeasurements`, which marks every key under the + // `${userId}|` prefix stale (see `lib/cache/invalidate.ts`). + // + // v1.16.7 — stale-while-revalidate. This is the single heaviest + // SQL-side aggregation on the /insights mount; serving the prior + // body instantly while ONE background recompute refreshes it keeps + // the page interactive across the bucket's 10-minute stale window + // (a measurement sync used to bust the entry into a blocking cold + // rebuild on the very next mount, all day). The truly-cold first + // read of the day still computes inline — there is no prior body to + // serve — but it no longer recurs per visit. + const body = await cachedSwr( caches.analytics as ServerCache >>, diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 2536a1e76..cb2273623 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -443,7 +443,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => { locale: "en", } as never); - const res = await (GET as () => Promise)(); + const res = await (GET as unknown as (req: Request) => Promise)( + new Request("http://localhost/api/insights/generate"), + ); expect(res.status).toBe(200); const body = (await res.json()) as { data: { insights: unknown; cached: boolean }; @@ -464,7 +466,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => { locale: "en", } as never); - const res = await (GET as () => Promise)(); + const res = await (GET as unknown as (req: Request) => Promise)( + new Request("http://localhost/api/insights/generate"), + ); expect(res.status).toBe(200); expect(enqueueForceWarm).toHaveBeenCalledWith({ userId: "u-1", @@ -481,7 +485,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => { locale: "en", } as never); - const res = await (GET as () => Promise)(); + const res = await (GET as unknown as (req: Request) => Promise)( + new Request("http://localhost/api/insights/generate"), + ); expect(res.status).toBe(200); const body = (await res.json()) as { data: { insights: unknown; cached: boolean }; diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 9bec76511..24d737739 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -257,7 +257,7 @@ async function buildComparisonSnapshotForUser( * * `userId` is narrowed from the session / Bearer — never a body field. */ -export const GET = apiHandler(async () => { +export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); // Same surface gate as the POST + the read-only status routes: a user // with assessments enabled but Coach disabled still reads the cached @@ -284,7 +284,18 @@ export const GET = apiHandler(async () => { // empty / connect-AI state instead of a wasted enqueue). let revalidating = false; if (!isFresh && (await hasUsableStatusProvider(userId))) { - const locale = (dbUser?.locale ?? user.locale) === "en" ? "en" : "de"; + // Resolve the locale the caller is actually reading (cookie / + // Accept-Language fall-back when `User.locale` is unset) and narrow + // non-German to English — the same convention the nightly's + // `normalizeLocale` and the status routes follow. The previous + // `(locale) === "en" ? "en" : "de"` collapsed a NULL `User.locale` + // to German even for a client reading the app in English, so the + // visit-triggered warm filled the wrong cache family. + const resolved = await resolveServerLocale({ + request, + userLocale: dbUser?.locale ?? user.locale ?? null, + }); + const locale = resolved === "de" ? "de" : "en"; void enqueueForceWarm({ userId, locale }); revalidating = true; } diff --git a/src/app/api/monitoring/umami-script/route.ts b/src/app/api/monitoring/umami-script/route.ts index 3c7d7925f..951d61ac2 100644 --- a/src/app/api/monitoring/umami-script/route.ts +++ b/src/app/api/monitoring/umami-script/route.ts @@ -26,16 +26,33 @@ export const GET = apiHandler(async () => { }); } - const response = await safeFetch( - settings.umamiScriptUrl, - { - next: { revalidate: 3600 }, - }, - // Operator-configured host — pin the connect-time IP so a low-TTL - // DNS record cannot rebind the fetch at an internal range between - // the input-time `isPublicUrl` accept and the socket connect. - { requirePublicHost: true }, - ); + // Fail-soft: an unreachable / slow / rebinding-refused upstream must + // degrade to the noop script, not 500 the proxy route — `safeFetch` + // THROWS on its 15 s timeout and on the connect-time public-host + // refusal, and an uncaught throw here surfaced as a 500 on every + // mount whenever the operator's Umami host was down or unresolvable. + let response: Response; + try { + response = await safeFetch( + settings.umamiScriptUrl, + { + next: { revalidate: 3600 }, + }, + // Operator-configured host — pin the connect-time IP so a low-TTL + // DNS record cannot rebind the fetch at an internal range between + // the input-time `isPublicUrl` accept and the socket connect. + { requirePublicHost: true }, + ); + } catch { + annotate({ meta: { umami_script_fetch_failed: true } }); + return new NextResponse(NOOP_SCRIPT, { + status: 200, + headers: { + "Content-Type": "application/javascript; charset=utf-8", + "Cache-Control": "public, max-age=60", + }, + }); + } if (!response.ok) { return new NextResponse(NOOP_SCRIPT, { status: 200, diff --git a/src/components/insights/mood/mood-charts.ts b/src/components/insights/mood/mood-charts.ts new file mode 100644 index 000000000..a1da1f78d --- /dev/null +++ b/src/components/insights/mood/mood-charts.ts @@ -0,0 +1,18 @@ +/** + * 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"; diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index c4828912d..4f309dc28 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -36,9 +36,12 @@ 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. const MoodDistributionChart = dynamic( () => - import("./mood-distribution-chart").then((mod) => ({ + import("./mood-charts").then((mod) => ({ default: mod.MoodDistributionChart, })), { @@ -48,7 +51,7 @@ const MoodDistributionChart = dynamic( ); const MoodWeekdayChart = dynamic( () => - import("./mood-weekday-chart").then((mod) => ({ + import("./mood-charts").then((mod) => ({ default: mod.MoodWeekdayChart, })), { @@ -58,7 +61,7 @@ const MoodWeekdayChart = dynamic( ); const MoodTimeOfDayChart = dynamic( () => - import("./mood-time-of-day-chart").then((mod) => ({ + import("./mood-charts").then((mod) => ({ default: mod.MoodTimeOfDayChart, })), { diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index 78adcddea..d4054fdfd 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -3,11 +3,16 @@ import { useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { InsightResult } from "@/lib/ai/types"; -import { - dailyBriefingSchema, - trendAnnotationsSchema, - type DailyBriefing as DailyBriefingPayload, - type TrendAnnotations, +// Type-only — the runtime schemas load lazily inside `fetchAdvisor` so +// the zod module graph (`@/lib/ai/schema` is ~550 lines of zod builders) +// stays out of the insights route-entry chunk. This hook is imported +// eagerly by BOTH the insights page and the layout shell, so a value +// import here landed zod in the chunk every insights visit downloads +// before first paint; the schemas are only needed once a payload has +// actually arrived, which is by definition after the network hop. +import type { + DailyBriefing as DailyBriefingPayload, + TrendAnnotations, } from "@/lib/ai/schema"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw } from "@/lib/api/api-fetch"; @@ -157,6 +162,12 @@ async function fetchAdvisor( } const json = await res.json(); const payload = json.data as InsightAdvisorPayload; + // Lazy schema load — see the type-only import note at the top. The + // module is cached after the first call, so this await is free from + // the second fetch on. + const { dailyBriefingSchema, trendAnnotationsSchema } = await import( + "@/lib/ai/schema" + ); // The cached `insights` blob may carry a `dailyBriefing` from a fresh // PROMPT_VERSION 4.20.x generation. Lift it onto the payload so // consumers don't have to know the legacy shape. diff --git a/src/components/layout/__tests__/bottom-nav.test.tsx b/src/components/layout/__tests__/bottom-nav.test.tsx index 469459ae9..7e7a602bd 100644 --- a/src/components/layout/__tests__/bottom-nav.test.tsx +++ b/src/components/layout/__tests__/bottom-nav.test.tsx @@ -41,14 +41,20 @@ vi.mock("@/hooks/use-auth", () => ({ }), })); +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; import { BottomNav } from "../bottom-nav"; function render() { return renderToStaticMarkup( - - - , + // The nav reads `useQueryClient()` for the medications intent + // prefetch (v1.16.7); a fresh client per render keeps the SSR + // markup assertions isolated. + + + + + , ); } diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 0acd04ef0..5d070b66a 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -47,6 +47,7 @@ vi.mock("@/components/app-settings-provider", () => ({ useAppSettings: () => mockSettingsRef.value, })); +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; import { SidebarNav } from "../sidebar-nav"; import { ADMIN_SECTIONS } from "@/components/admin/admin-shell"; @@ -66,9 +67,14 @@ function render({ mockSettingsRef.value = { bugReportEnabled }; mockUserRef.value = { ...mockUserRef.value, role, cycleTrackingEnabled }; return renderToStaticMarkup( - - - , + // The nav reads `useQueryClient()` for the medications intent + // prefetch (v1.16.7); a fresh client per render keeps the SSR + // markup assertions isolated. + + + + + , ); } diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 181e0d7e6..8f7c0a32b 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -17,6 +17,8 @@ import { } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications"; import { useState } from "react"; import { useAppSettings } from "@/components/app-settings-provider"; import { useAuth } from "@/hooks/use-auth"; @@ -96,6 +98,10 @@ export function BottomNav() { const pathname = usePathname(); const { t } = useTranslations(); const { user } = useAuth(); + const queryClient = useQueryClient(); + // v1.16.7 — touch intent on the medications tab starts the list + // request before the navigation commits (see sidebar-nav). + const medsIntent = medicationsPrefetchIntentProps(queryClient); const { bugReportEnabled } = useAppSettings(); const [moreOpen, setMoreOpen] = useState(false); const [captureOpen, setCaptureOpen] = useState(false); @@ -132,6 +138,7 @@ export function BottomNav() { href={item.href} aria-label={t(item.tKey)} aria-current={isActive ? "page" : undefined} + {...(item.href === "/medications" ? medsIntent : {})} // Touch target sized to WCAG 2.5.5 (44×44 CSS px). The // outer min-h-11 min-w-11 is the actual hit area; the icon // stays visually centered at 20px so the design doesn't shift. diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index cd778f980..4d53ce6d2 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -23,6 +23,8 @@ import { } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications"; import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; import { useAuth, useLogout } from "@/hooks/use-auth"; @@ -267,6 +269,11 @@ export function SidebarNav() { const pathname = usePathname(); const { t } = useTranslations(); const { user } = useAuth(); + const queryClient = useQueryClient(); + // v1.16.7 — hover / focus intent on the medications link starts the + // list request before the navigation commits, so the due-time cells + // hydrate from a warm cache instead of serialising behind the chunk. + const medsIntent = medicationsPrefetchIntentProps(queryClient); const { bugReportEnabled } = useAppSettings(); const isAdmin = user?.role === "ADMIN"; // Match `/admin` exactly or any `/admin/...` sub-route for active-link @@ -407,6 +414,7 @@ export function SidebarNav() { href={item.href} aria-current={isActive ? "page" : undefined} data-tour-id={item.tourId} + {...(item.href === "/medications" ? medsIntent : {})} className={cn( "flex items-center justify-center rounded-lg p-2.5 transition-colors", isActive @@ -430,6 +438,7 @@ export function SidebarNav() { href={item.href} aria-current={isActive ? "page" : undefined} data-tour-id={item.tourId} + {...(item.href === "/medications" ? medsIntent : {})} className={cn( "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors", isActive diff --git a/src/components/providers.tsx b/src/components/providers.tsx index b03e13772..69340dec8 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -21,6 +21,7 @@ import { AppSettingsProvider } from "@/components/app-settings-provider"; import { VersionPoller } from "@/components/version-poller"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; +import { prefetchMedicationsList } from "@/lib/queries/prefetch-medications"; // ── Theme Context ──────────────────────────────────── @@ -132,6 +133,15 @@ function DashboardSnapshotPreloader() { if (pathname === "/" && isDashboardSnapshotEnabled()) { prefetchDashboardSnapshot(queryClient); } + // v1.16.7 — same waterfall cut for the medications page: its list + // query (which carries the per-medication `nextDueAt` the due cells + // render) used to fire only after the page chunk mounted. Firing it + // at route commit rides the data hop in parallel with the chunk + // download; the nav links additionally prefetch on hover/touch + // intent, so this is the fallback for direct loads + reloads. + if (pathname === "/medications") { + prefetchMedicationsList(queryClient); + } }, [pathname, queryClient]); return null; } diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index f299d570d..440ae942b 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -500,7 +500,7 @@ describe("runInsightPregenerate — generic metric warm pass (v1.8.7.1)", () => }); describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { - it("warms ONLY the active locale, bypasses the budget gate, and tallies counts", async () => { + it("warms BOTH locale families, bypasses the budget gate, and tallies counts", async () => { const { prisma } = makePrisma([]); const generate = vi .fn() @@ -516,27 +516,31 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { warmGenericMetrics, }); - // Comprehensive forced once, in the active locale only. The forced path - // also threads an AbortSignal so a timeout can cut the generation off. + // Comprehensive forced once, in the caller's active locale. The forced + // path also threads an AbortSignal so a timeout can cut the generation + // off. expect(generate).toHaveBeenCalledTimes(1); expect(generate).toHaveBeenCalledWith("u1", { locale: "en", force: true, signal: expect.any(AbortSignal), }); - // Each specialised generator forced exactly once — the active locale, - // NOT both de+en like the nightly cron. + // v1.16.7 — each specialised generator runs once PER locale family + // (de+en, like the nightly cron): the comprehensive write above + // evicted both families, so refilling only one left the other cold. + // The generated comprehensive means eviction happened → force: true. for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: true }); expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: true }); } - // Generic warm pass runs for the single active locale. - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["en"]); + // Generic warm pass covers both locale families too. + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], true); // No 20 h budget bucket consulted on the forced path. expect(checkRateLimit).not.toHaveBeenCalled(); expect(result).toMatchObject({ comprehensive: "generated", - assessmentsWarmed: 7, + assessmentsWarmed: 14, metricAssessmentsWarmed: 5, }); }); @@ -565,10 +569,13 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { }); for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(1); - expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: true }); + // Both locale families, refill-only: a skipped comprehensive ran no + // eviction, so cards already generated today short-circuit cheaply. + expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); + expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: false }); } - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], false); expect(result).toMatchObject({ comprehensive: "skipped", assessmentsWarmed: 0, @@ -597,12 +604,12 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { }); for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledTimes(2); } - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], false); expect(result).toMatchObject({ comprehensive: "failed", - assessmentsWarmed: 7, + assessmentsWarmed: 14, metricAssessmentsWarmed: 4, }); }); @@ -641,12 +648,16 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { const result = await promise; for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledTimes(2); } - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); + expect(warmGenericMetrics).toHaveBeenCalledWith( + "u1", + ["de", "en"], + false, + ); expect(result).toMatchObject({ comprehensive: "timeout", - assessmentsWarmed: 7, + assessmentsWarmed: 14, metricAssessmentsWarmed: 3, }); } finally { @@ -704,14 +715,14 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { // Comprehensive belongs to the `briefing` surface — skipped here. expect(generate).not.toHaveBeenCalled(); - // But the per-status + generic passes warm. + // But the per-status + generic passes warm, both locale families. for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledTimes(2); } - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], false); expect(result).toMatchObject({ comprehensive: "skipped", - assessmentsWarmed: 7, + assessmentsWarmed: 14, metricAssessmentsWarmed: 2, }); }); @@ -757,9 +768,9 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { expect(result.comprehensive).toBe("timeout"); // The warm passes ran and are not undone by the abandoned comprehensive. for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledTimes(2); } - expect(result.assessmentsWarmed).toBe(7); + expect(result.assessmentsWarmed).toBe(14); expect(result.metricAssessmentsWarmed).toBe(3); } finally { vi.useRealTimers(); diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index 80dec808c..d6fd1d696 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -37,7 +37,8 @@ * * The discovery query selects only users that are worth a generation: * coach surface enabled (`disableCoach = false`) AND a stale-or-missing - * cache (`insightsCachedAt IS NULL OR < now - 20h`). Whether the user + * cache (`insightsCachedAt IS NULL OR < now - PREGENERATE_STALE_MS`, + * see the constant for why that window is one hour). Whether the user * has a configured provider is confirmed inside * `generateComprehensiveInsight` (returns `skipped: no-provider`), so a * provider-less account costs one cheap chain-resolve and no LLM call. @@ -88,10 +89,21 @@ export type { InsightPregeneratePayload }; */ export const INSIGHT_PREGENERATE_CRON = "30 4 * * *"; -/** Stale-cache threshold — a hair under the 24 h advisor TTL so the - * overnight run always refreshes a cache that will expire before the - * user's next likely visit. */ -export const PREGENERATE_STALE_MS = 20 * 60 * 60 * 1000; +/** Stale-cache threshold for the nightly discovery pass. + * + * The cron runs every 24 h and the advisor cache lives 24 h — so ANY + * cache not written within the last hour will expire before the next + * nightly tick, i.e. at some point during the user's day. The previous + * 20 h threshold structurally excluded every evening visitor: a visit + * at ~19:50 stamps `insightsCachedAt`, the 04:30 run sees a ~9 h-old + * cache and skips, and the cache then expires at ~19:50 the next day — + * exactly when the user returns. That recreated the on-visit warm storm + * (comprehensive feature extraction + the full per-card warm pass in + * the request-serving process) every single evening, which is what the + * nightly pass exists to prevent. One hour keeps the only sensible + * skip: a cache the on-demand path generated minutes before the tick. + */ +export const PREGENERATE_STALE_MS = 60 * 60 * 1000; /** Per-run cap on the number of users a single tick generates for. */ export const PREGENERATE_BATCH_CAP = 200; @@ -566,8 +578,11 @@ export async function runInsightPregenerate( * it just rewrites the same cache row; concurrent enqueues collapse via * the queue `singletonKey`. * - * Unlike the nightly cron (de+en), the forced path warms only the caller's - * active `locale` so a single-locale user pays half the provider cost. + * Like the nightly cron, the forced path warms BOTH locales (de+en): its + * own comprehensive step evicts every per-status row across both locale + * families, and the refill-only mode keeps the second family's cost at + * exactly the cards that are cold. The caller's `locale` still picks the + * language of the comprehensive briefing itself. * * The three sections are decoupled: the comprehensive step runs under its * own bounded budget (`COMPREHENSIVE_WARM_TIMEOUT_MS`) and its @@ -595,6 +610,7 @@ export async function forceWarmUser( warmGenericMetrics?: ( userId: string, locales: ReadonlyArray<"de" | "en">, + force?: boolean, ) => Promise; } = {}, ): Promise { @@ -603,8 +619,8 @@ export async function forceWarmUser( options.statusGenerators ?? DEFAULT_STATUS_GENERATORS; const warmGenericMetrics = options.warmGenericMetrics ?? - ((id: string, locales: ReadonlyArray<"de" | "en">) => - warmGenericMetricCaches(prisma, id, locales)); + ((id: string, locales: ReadonlyArray<"de" | "en">, force?: boolean) => + warmGenericMetricCaches(prisma, id, locales, force)); const result: ForceWarmResult = { comprehensive: "skipped", @@ -624,7 +640,17 @@ export async function forceWarmUser( const flags = await getAssistantFlags(); if (!flags.briefing && !flags.insightStatus) return result; - const locales: ReadonlyArray<"de" | "en"> = [locale]; + // Warm BOTH supported locales, matching the nightly pass. A fresh + // comprehensive generation evicts every per-status cache row in both + // locale families (`evictPerStatusInsightCache` deletes by scope, not + // by locale) — refilling only the caller's active locale left the + // other family cold, so an account read through two surfaces (German + // web UI + an English Accept-Language client) regenerated the second + // family card-by-card on its next visits, all day, every day. The + // refill mode below short-circuits to the cache for any card already + // generated today, so the second locale costs only the cards that are + // actually cold. + const locales: ReadonlyArray<"de" | "en"> = ["de", "en"]; // The comprehensive step gets its own bounded budget and its failure is // non-fatal. A slow or stalled briefing generation must not short-circuit @@ -665,12 +691,26 @@ export async function forceWarmUser( // outcome that has nothing to warm is a missing provider, and the // generators detect that themselves at near-zero cost. if (flags.insightStatus) { + // Mirror the nightly loop's force/refill split: only a `generated` / + // `cached` comprehensive ran `evictPerStatusInsightCache`, so only + // then must every card re-generate. On a failed / timed-out / + // skipped comprehensive the rows survived — refill-only (`force: + // false`) lets a card already generated today short-circuit to its + // cache, which is what keeps the second locale family cheap. + const evicted = + result.comprehensive === "generated" || + result.comprehensive === "cached"; result.assessmentsWarmed = await warmPerStatusCaches( userId, locales, statusGenerators, + evicted, + ); + result.metricAssessmentsWarmed = await warmGenericMetrics( + userId, + locales, + evicted, ); - result.metricAssessmentsWarmed = await warmGenericMetrics(userId, locales); } annotate({ diff --git a/src/lib/jobs/status-cron-candidates.ts b/src/lib/jobs/status-cron-candidates.ts index 17d5c53b3..55a5d74eb 100644 --- a/src/lib/jobs/status-cron-candidates.ts +++ b/src/lib/jobs/status-cron-candidates.ts @@ -13,15 +13,17 @@ * handlers): * * - 02:xx status crons → users WITHOUT a comprehensive-pregenerate - * claim: no configured provider, or a comprehensive cache fresh - * enough (< 20 h) that the 04:30 pass will skip them — their - * per-status caches still age out daily and only these crons - * re-fill them. + * claim: no configured provider, or a comprehensive cache fresher + * than `PREGENERATE_STALE_MS` so the 04:30 pass will skip them — + * their per-status caches still age out daily and only these crons + * re-fill them. With the one-hour threshold this cohort is, in + * practice, the provider-less accounts. * - 04:30 insight-pregenerate → the rest: coach-enabled users with a - * configured provider and a stale (>= 20 h) comprehensive cache. - * That pass regenerates the comprehensive insight AND force-warms - * every per-status cache, so a 02:xx generation for those users - * would be evicted and redone two hours later. + * configured provider and a comprehensive cache older than + * `PREGENERATE_STALE_MS`. That pass regenerates the comprehensive + * insight AND force-warms every per-status cache, so a 02:xx + * generation for those users would be evicted and redone two hours + * later. * * Gates, in order: * 1. Operator kill-switch via `getAssistantFlags()` — the same source diff --git a/src/lib/queries/prefetch-medications.ts b/src/lib/queries/prefetch-medications.ts new file mode 100644 index 000000000..e6f1bd6b6 --- /dev/null +++ b/src/lib/queries/prefetch-medications.ts @@ -0,0 +1,45 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; + +/** + * v1.16.7 — intent-based data prefetch for the medications page. + * + * The nav links already prefetch the route's JS chunk (next/link + * default in production); what still serialised the first visit was + * the data hop — the list query only fired once the page chunk had + * mounted. Wiring this to the nav link's hover / touch / focus intent + * puts `/api/medications` (the response carries the per-medication + * `nextDueAt` due times) in flight while the navigation commits. + * + * The 15 s prefetch window only bounds how old a cache entry may be + * before an intent re-fires the request — the mounted page query rides + * the provider-default `staleTime` (5 min) and consumes whatever this + * prefetch parked. The server caches the list 60 s per user, so even a + * missed window stays cheap. + */ +export const MEDICATIONS_LIST_STALE_TIME_MS = 15_000; + +export function prefetchMedicationsList(queryClient: QueryClient): void { + void queryClient.prefetchQuery({ + queryKey: queryKeys.medications(), + queryFn: () => apiGet("/api/medications"), + staleTime: MEDICATIONS_LIST_STALE_TIME_MS, + }); +} + +/** + * Intent props for a nav link pointing at `/medications` — fires the + * list prefetch on hover / touch / keyboard focus, i.e. before the + * router even commits. `prefetchQuery` dedupes internally (in-flight + * promise reuse + the stale window above), so the three handlers and a + * subsequent route-commit prefetch collapse into at most one request. + */ +export function medicationsPrefetchIntentProps(queryClient: QueryClient): { + onPointerEnter: () => void; + onTouchStart: () => void; + onFocus: () => void; +} { + const fire = () => prefetchMedicationsList(queryClient); + return { onPointerEnter: fire, onTouchStart: fire, onFocus: fire }; +}