mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
perf(insights,api): always answer instantly and regenerate in the background
The insights read path served a cold cache by generating inline — up to ten seconds on first visit — and the nightly warm pass structurally excluded evening visitors: their visit stamped the cache, the half-four run skipped it as fresh, and it expired exactly at the next evening's visit. Stale content now serves immediately with its timestamp while one background job revalidates, the warm threshold drops to an hour, forced warming fills both message locales, the analytics caches serve stale-while-revalidate, the profile endpoint parallelises its reads, the bug-report status drops a write per call, the umami proxy degrades to a no-op script instead of an error, the insights route sheds its heaviest static imports, and the medication page prefetches on intent.
This commit is contained in:
1 parent
ceeb818f41
commit
78fe82e348
19 files changed
+319
-101
No files matched your search
@@ -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<Awaited<ReturnType<typeof computeSummariesSlice>>>,
|
||||
`${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<Awaited<ReturnType<typeof buildAnalyticsResponse>>>,
|
||||
`${user.id}|default`,
|
||||
() => buildAnalyticsResponse(user),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<BugreportStatusCacheValue> {
|
||||
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
|
||||
|
||||
@@ -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<Awaited<
|
||||
ReturnType<typeof buildComprehensiveResponse>
|
||||
>>,
|
||||
|
||||
@@ -443,7 +443,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
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<Response>)();
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
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<Response>)();
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/insights/generate"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { insights: unknown; cached: boolean };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
})),
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<BottomNav />
|
||||
</I18nProvider>,
|
||||
// The nav reads `useQueryClient()` for the medications intent
|
||||
// prefetch (v1.16.7); a fresh client per render keeps the SSR
|
||||
// markup assertions isolated.
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<BottomNav />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SidebarNav />
|
||||
</I18nProvider>,
|
||||
// The nav reads `useQueryClient()` for the medications intent
|
||||
// prefetch (v1.16.7); a fresh client per render keeps the SSR
|
||||
// markup assertions isolated.
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<SidebarNav />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<number>;
|
||||
} = {},
|
||||
): Promise<ForceWarmResult> {
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user