Merge pull request #313 from MBombeck/release/v1.16.7

Release v1.16.7 — insights answer instantly, every evening
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-11 19:52:02 +02:00
commit 9fa4962314
37 files changed
+782 -144

No files matched your search

+14
View File
@@ -2,6 +2,20 @@
## [Unreleased]
## [1.16.7] — 2026-06-11 — insights answer instantly, every evening
### Changed
- **Insights always answer instantly.** A stale briefing is served immediately with a regeneration kicked off in the background — the response carries a revalidating flag and the open page polls briefly until the fresh content lands, instead of blocking the visit on up to ten seconds of inline generation.
- **The nightly pre-warm no longer skips evening readers.** The staleness threshold drops from twenty hours to one — previously an evening visit stamped the cache fresh, the night run skipped it, and it expired exactly the next evening, so the people who read insights after work were the ones who always hit cold generation. The visit-triggered warm fills both locale families and collapses to one job per user.
- **The session resolves faster.** The profile read inside `/api/auth/me` runs in parallel with the cookie refresh, the bug-report status answer comes from a ten-minute cache without a write per request, and the medications page is prefetched the moment its nav entry is touched.
- **The insights route ships less JavaScript.** The schema validator loads lazily and the mood charts split out of the main chunk, trimming the cold download that dominated an uncached first visit.
### Fixed
- A manually entered measurement is reflected in the analytics aggregates on the next read — interactive writes evict the cache instead of marking it stale, while background sync keeps the cheaper path.
- The analytics script proxy degrades to a silent no-op instead of failing every page when the upstream is unreachable, and records the failure reason where the operator can find it.
## [1.16.6] — 2026-06-11 — corrected eras, an honest weekly status and a faster first paint
### Added
+6 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.16.6
version: 1.16.7
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -11769,6 +11769,11 @@ components:
propertyNames:
type: string
additionalProperties: {}
revalidating:
description: True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs
in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the
fresh body.
type: boolean
required:
- summary
- recommendations
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.16.6",
"version": "1.16.7",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+11 -3
View File
@@ -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),
+14 -14
View File
@@ -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
+7 -8
View File
@@ -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 10 min (the bucket's TTL) 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
@@ -91,7 +91,7 @@ import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { __resetAllCachesForTests, caches } from "@/lib/cache/server-cache";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -438,3 +438,64 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
expect(body.data.weightBpCorrelation?.n).toBe(5);
});
});
// v1.16.7 — the route serves a marked-stale cache entry immediately
// (stale-while-revalidate) and must mark that body `revalidating: true`
// so the client can poll until the background rebuild lands. A fresh
// (cold or warm) read carries `revalidating: false`.
describe("GET /api/insights/comprehensive — stale-while-revalidate marker", () => {
const EMPTY_AGGREGATE = {
summaries: {},
bpRawRows: { sys: [], dia: [] },
dailyByType: {},
firstMeasurementAt: null,
totalMeasurements: 0,
};
it("marks a cold (inline-computed) read revalidating: false", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
).mockResolvedValue(EMPTY_AGGREGATE);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { revalidating: boolean } };
expect(body.data.revalidating).toBe(false);
});
it("marks a stale-served read revalidating: true and a converged read false again", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
).mockResolvedValue(EMPTY_AGGREGATE);
// Prime the cache entry (cold compute).
await callGet(makeReq());
// A measurement sync marks the bucket stale (the invalidate helper's
// default posture) — the next read serves the prior body + the flag.
caches.analytics.markStaleByPrefix(`${SESSION_OK.user.id}|`);
const staleRes = await callGet(makeReq());
expect(staleRes.status).toBe(200);
const staleBody = (await staleRes.json()) as {
data: { revalidating: boolean };
};
expect(staleBody.data.revalidating).toBe(true);
// The stale read kicked off a background rebuild; once it settles
// (the cache entry is fresh again) the next read is a plain hit and
// the flag drops so the poll stops.
await vi.waitFor(() => {
const entry = caches.analytics.getAllowStale(
`${SESSION_OK.user.id}|comprehensive`,
);
expect(entry?.stale).toBe(false);
});
const freshRes = await callGet(makeReq());
const freshBody = (await freshRes.json()) as {
data: { revalidating: boolean };
};
expect(freshBody.data.revalidating).toBe(false);
});
});
+24 -5
View File
@@ -25,7 +25,11 @@ 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 {
cachedSwrWithMeta,
caches,
type ServerCache,
} from "@/lib/cache/server-cache";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
import {
ensureUserMoodRollupsFresh,
@@ -52,9 +56,24 @@ 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.
//
// The `revalidating` flag marks a stale-served body honestly so the
// client can poll (bounded) until the background rebuild lands —
// mirroring the `/api/insights/generate` GET contract. It rides on
// the response, never inside the cached body, so a warmed entry
// doesn't freeze the flag.
const { value: body, outcome } = await cachedSwrWithMeta(
caches.analytics as ServerCache<Awaited<
ReturnType<typeof buildComprehensiveResponse>
>>,
@@ -63,7 +82,7 @@ export const GET = apiHandler(async () => {
annotate,
);
return apiSuccess(body);
return apiSuccess({ ...body, revalidating: outcome === "stale" });
});
type AuthedUser = AuthContext["user"];
@@ -443,17 +443,21 @@ 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 };
data: { insights: unknown; cached: boolean; revalidating: boolean };
};
expect(body.data.cached).toBe(true);
expect(body.data.insights).toEqual(cached);
// No completion is ever run on the read path.
expect(resolveProvider).not.toHaveBeenCalled();
// A fresh cache (just now) does not trigger a warm.
// A fresh cache (just now) does not trigger a warm — and the payload
// says so, so the client never starts a convergence poll.
expect(enqueueForceWarm).not.toHaveBeenCalled();
expect(body.data.revalidating).toBe(false);
});
it("enqueues an out-of-band warm when the cache is stale and a provider exists", async () => {
@@ -464,13 +468,19 @@ 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",
locale: "en",
});
expect(resolveProvider).not.toHaveBeenCalled();
// The stale serve is honest: `revalidating: true` rides on the
// payload so the client polls (bounded) until the warm lands.
const body = (await res.json()) as { data: { revalidating: boolean } };
expect(body.data.revalidating).toBe(true);
});
it("returns an empty payload (no warm) on a cold cache without a provider", async () => {
@@ -481,13 +491,17 @@ 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 };
data: { insights: unknown; cached: boolean; revalidating: boolean };
};
expect(body.data.cached).toBe(false);
expect(body.data.insights).toBeNull();
expect(enqueueForceWarm).not.toHaveBeenCalled();
// No warm enqueued → no convergence poll for the client to run.
expect(body.data.revalidating).toBe(false);
});
});
+24 -3
View File
@@ -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;
}
@@ -302,6 +313,11 @@ export const GET = apiHandler(async () => {
cached: true,
cachedAt,
legacyPayload,
// Honest stale-serve marker: true while the out-of-band warm is
// in flight, so the client can poll (bounded) until the fresh
// briefing lands instead of sitting on the stale payload for the
// rest of the session.
revalidating,
});
} catch {
// Invalid cache row — fall through to the empty payload below. The
@@ -314,7 +330,12 @@ export const GET = apiHandler(async () => {
action: { name: "insights.generate.read" },
meta: { cached: false, revalidating },
});
return apiSuccess({ insights: null, cached: false, legacyPayload: false });
return apiSuccess({
insights: null,
cached: false,
legacyPayload: false,
revalidating,
});
});
export const POST = apiHandler(async (request: NextRequest) => {
+7 -4
View File
@@ -148,8 +148,9 @@ export const PUT = apiHandler(
});
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches so subsequent reads reflect the edited row.
invalidateUserMeasurements(user.id);
// caches so subsequent reads reflect the edited row. Interactive
// edit — hard-evict so the SWR readers don't serve the pre-edit body.
invalidateUserMeasurements(user.id, { evict: true });
// v1.5.0 — refresh the rollup row for the affected day. When the
// measuredAt moved across day boundaries (or the row was re-typed)
@@ -236,8 +237,10 @@ export const DELETE = apiHandler(
});
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches so subsequent reads reflect the deletion.
invalidateUserMeasurements(user.id);
// caches so subsequent reads reflect the deletion. Interactive
// delete — hard-evict so the SWR readers don't serve the pre-delete
// body.
invalidateUserMeasurements(user.id, { evict: true });
// v1.5.0 — refresh the rollup row for the affected day (the
// recompute drops the row when the day's measurement count goes
@@ -125,7 +125,11 @@ describe("POST /api/measurements/bulk-delete", () => {
});
expect(call.data.deletedAt).toBeInstanceOf(Date);
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1");
// v1.16.7 — interactive write: hard-evict so the SWR readers
// never serve the pre-write analytics body back to the user.
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => {
@@ -105,8 +105,10 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
if (count > 0) {
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches so subsequent reads reflect the deletions.
invalidateUserMeasurements(user.id);
// caches so subsequent reads reflect the deletions. Interactive
// multi-select delete from the management list — hard-evict so the
// SWR readers don't serve the pre-delete body.
invalidateUserMeasurements(user.id, { evict: true });
// Collapse the deleted rows to the unique `(type, day)` set BEFORE
// recomputing so a 200-row delete spanning one day fires ~1 recompute
@@ -122,7 +122,11 @@ describe("POST /api/measurements/restore", () => {
syncVersion: { increment: 1 },
});
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1");
// v1.16.7 — interactive write: hard-evict so the SWR readers
// never serve the pre-write analytics body back to the user.
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => {
+3 -2
View File
@@ -106,8 +106,9 @@ async function postRestore(request: NextRequest): Promise<Response> {
if (count > 0) {
// Bust per-user analytics + achievements + workouts caches so
// subsequent reads reflect the restored rows.
invalidateUserMeasurements(user.id);
// subsequent reads reflect the restored rows. Interactive undo —
// hard-evict so the SWR readers don't serve the pre-restore body.
invalidateUserMeasurements(user.id, { evict: true });
// Collapse the restored rows to the unique `(type, day)` set BEFORE
// recomputing — mirrors the bulk-delete path. Best-effort: a
+8 -4
View File
@@ -916,8 +916,10 @@ async function postMeasurement(request: NextRequest) {
});
// v1.4.34 IW-G — flush every cache that reflects this user's
// measurement set so the next read paints the new rows.
invalidateUserMeasurements(user.id);
// measurement set so the next read paints the new rows. Interactive
// multi-entry create (combined BP + pulse form) — hard-evict so the
// SWR readers don't serve the pre-write body back to the user.
invalidateUserMeasurements(user.id, { evict: true });
// v1.5.0 — refresh the persistent rollup table for every distinct
// (type, day) the batch touched so the next analytics / coach read
@@ -1027,8 +1029,10 @@ async function postMeasurement(request: NextRequest) {
});
// v1.4.34 IW-G — flush every cache that reflects this user's
// measurement set so the next read paints the new row.
invalidateUserMeasurements(user.id);
// measurement set so the next read paints the new row. Interactive
// single-entry create — hard-evict so the SWR readers don't serve the
// pre-write body back to the user.
invalidateUserMeasurements(user.id, { evict: true });
// v1.5.0 — refresh the persistent rollup row for the affected
// (type, day) tuple. Runs inline so the next read of the
@@ -0,0 +1,100 @@
/**
* Fail-soft contract for the Umami proxy route: an unreachable / refused
* upstream degrades to the noop script (200, never a 500) AND stays
* observable — the catch annotates a proper wide-event action
* (`monitoring.umami_script.fetch_failed`) with a truncated reason so
* dashboards can see the degraded state.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/monitoring-settings", () => ({
getPublicMonitoringSettings: vi.fn(),
}));
vi.mock("@/lib/safe-fetch", () => ({
safeFetch: vi.fn(),
}));
import { GET } from "../route";
import { annotate } from "@/lib/logging/context";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { safeFetch } from "@/lib/safe-fetch";
const callGet = GET as unknown as () => Promise<Response>;
const ENABLED_SETTINGS = {
umamiEnabled: true,
umamiScriptUrl: "https://stats.example.com/script.js",
umamiWebsiteId: "site-1",
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getPublicMonitoringSettings).mockResolvedValue(
ENABLED_SETTINGS as never,
);
});
describe("GET /api/monitoring/umami-script — fail-soft observability", () => {
it("serves the noop script (200) and annotates the failure action when the upstream fetch throws", async () => {
vi.mocked(safeFetch).mockRejectedValue(
new Error("connect ETIMEDOUT 192.0.2.10:443"),
);
const res = await callGet();
expect(res.status).toBe(200);
expect(await res.text()).toContain("umami disabled");
// The fail-soft must stay observable: a proper
// `<surface>.<noun>.<verb>` action + a truncated reason, not a bare
// meta flag.
expect(annotate).toHaveBeenCalledWith({
action: { name: "monitoring.umami_script.fetch_failed" },
meta: { reason: "connect ETIMEDOUT 192.0.2.10:443" },
});
});
it("truncates an oversized error message in the reason meta", async () => {
vi.mocked(safeFetch).mockRejectedValue(new Error("x".repeat(500)));
const res = await callGet();
expect(res.status).toBe(200);
const failureCall = vi
.mocked(annotate)
.mock.calls.find(
(call) =>
call[0]?.action?.name === "monitoring.umami_script.fetch_failed",
);
expect(failureCall).toBeTruthy();
expect(
(failureCall?.[0]?.meta as { reason: string }).reason,
).toHaveLength(200);
});
it("serves the upstream script untouched on a healthy fetch (no failure action)", async () => {
vi.mocked(safeFetch).mockResolvedValue(
new Response("/* real script */", { status: 200 }) as never,
);
const res = await callGet();
expect(res.status).toBe(200);
expect(await res.text()).toBe("/* real script */");
const failureCall = vi
.mocked(annotate)
.mock.calls.find(
(call) =>
call[0]?.action?.name === "monitoring.umami_script.fetch_failed",
);
expect(failureCall).toBeUndefined();
});
});
+38 -10
View File
@@ -26,16 +26,44 @@ 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 (err) {
// Fail-soft stays a 200, but the failure must stay observable:
// a proper action name keeps the degraded state visible on the
// wide-event dashboards instead of hiding behind a bare meta flag.
annotate({
action: { name: "monitoring.umami_script.fetch_failed" },
meta: {
reason:
err instanceof Error
? err.message.slice(0, 200)
: String(err).slice(0, 200),
},
});
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,
@@ -78,3 +78,36 @@ describe("fetchAdvisor abort timeout — v1.4.31", () => {
expect(result).toBe("untouched");
});
});
// v1.16.7 — the read query polls (bounded) while the server reports a
// stale-served briefing (`revalidating: true`) so a stale serve converges
// in-session despite the 1 h staleTime + focus-refetch-off defaults.
describe("nextAdvisorPollInterval — bounded revalidation poll", () => {
it("returns the interval while revalidating is true and under the cap", async () => {
const {
nextAdvisorPollInterval,
ADVISOR_REVALIDATE_POLL_MS,
} = await import("../use-insights-advisor");
expect(nextAdvisorPollInterval(true, 1)).toBe(ADVISOR_REVALIDATE_POLL_MS);
expect(nextAdvisorPollInterval(true, 5)).toBe(ADVISOR_REVALIDATE_POLL_MS);
});
it("stops once a response comes back with revalidating falsy", async () => {
const { nextAdvisorPollInterval } = await import("../use-insights-advisor");
expect(nextAdvisorPollInterval(false, 1)).toBe(false);
expect(nextAdvisorPollInterval(undefined, 1)).toBe(false);
});
it("stops at the attempt ceiling even while still revalidating", async () => {
const {
nextAdvisorPollInterval,
ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS,
} = await import("../use-insights-advisor");
expect(
nextAdvisorPollInterval(true, ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS),
).toBe(false);
expect(
nextAdvisorPollInterval(true, ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS + 5),
).toBe(false);
});
});
@@ -10,7 +10,10 @@ import { useMounted } from "@/hooks/use-mounted";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useWorkouts } from "@/hooks/use-workouts";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import {
nextAdvisorPollInterval,
useInsightsAdvisorQuery,
} from "@/components/insights/use-insights-advisor";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { queryKeys } from "@/lib/query-keys";
@@ -41,6 +44,12 @@ import { apiGet } from "@/lib/api/api-fetch";
interface ComprehensivePayload {
moodSummary: { count: number } | null;
medications: Array<{ id: string }>;
/**
* v1.16.7 — true when the route served a stale cache body while a
* background rebuild runs (stale-while-revalidate). The query polls
* (bounded) while set so the fresh aggregate lands in-session.
*/
revalidating?: boolean;
}
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
@@ -91,6 +100,14 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
return apiGet<ComprehensivePayload>("/api/insights/comprehensive");
},
enabled: isAuthenticated,
// v1.16.7 — converge a stale-served (SWR) body in-session: while the
// route reports `revalidating: true`, poll on the same bounded
// cadence the advisor query uses until a fresh body lands.
refetchInterval: (query) =>
nextAdvisorPollInterval(
query.state.data?.revalidating,
query.state.dataUpdateCount,
),
});
// v1.4.32 — workout-presence gate. The workouts pill gates on at
@@ -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";
@@ -49,6 +54,13 @@ export interface InsightAdvisorPayload {
* left null when the cached payload predates PROMPT_VERSION 4.20.1.
*/
trendAnnotations?: TrendAnnotations | null;
/**
* v1.16.7 — true when the GET served a stale / missing briefing AND
* enqueued an out-of-band warm. The query polls (bounded) while this
* is set so the fresh briefing reaches the open page in-session
* instead of waiting for the next mount.
*/
revalidating?: boolean;
}
/**
@@ -75,6 +87,35 @@ const ADVISOR_TIMEOUT_MS = 8_000;
*/
const FORCE_ADVISOR_TIMEOUT_MS = 45_000;
/**
* v1.16.7 — poll cadence + ceiling while the server reports
* `revalidating: true` (stale briefing served, out-of-band warm in
* flight). The query's 1 h `staleTime` plus the app-default
* `refetchOnWindowFocus: false` means a stale-served briefing would
* otherwise never refresh in-session. 25 s comfortably covers the warm
* job's 45 s budget within two polls; the attempt ceiling stops a
* persistently failing generation from polling an open page forever.
* Same bounded-poll shape as `nextStatusPollInterval`
* (`src/hooks/use-insight-status.ts`).
*/
export const ADVISOR_REVALIDATE_POLL_MS = 25_000;
export const ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS = 10;
/**
* Decide whether the advisor query schedules its next poll. Pure so the
* ceiling + stop conditions are unit-testable: returns the interval
* while the last payload carries `revalidating: true`, `false` once a
* response comes back with the flag falsy OR the attempt cap is hit.
*/
export function nextAdvisorPollInterval(
revalidating: boolean | undefined,
dataUpdateCount: number,
): number | false {
if (!revalidating) return false;
if (dataUpdateCount >= ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS) return false;
return ADVISOR_REVALIDATE_POLL_MS;
}
/**
* v1.15.18 — the outcome of a force regenerate, so the UI can be HONEST:
* only a `fresh` outcome should toast "refreshed". A `timeout` (slow gen
@@ -157,6 +198,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.
@@ -233,6 +280,14 @@ export function useInsightsAdvisorQuery(
// 24h cache window matches the server-side `insightsCachedAt` TTL.
staleTime: 60 * 60 * 1000,
retry: false,
// v1.16.7 — converge a stale-served briefing in-session: while the
// last GET reported `revalidating: true` (warm enqueued), poll on a
// bounded interval until a response comes back with the flag falsy.
refetchInterval: (query) =>
nextAdvisorPollInterval(
query.state.data?.payload?.revalidating,
query.state.dataUpdateCount,
),
});
const mutation = useMutation({
@@ -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>,
);
}
+7
View File
@@ -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.
+9
View File
@@ -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
+10
View File
@@ -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;
}
+38
View File
@@ -117,6 +117,44 @@ describe("invalidateUserMeasurements", () => {
// Mood-analytics not touched by a measurement write.
expect(caches.moodAnalytics.get(USER_A)).not.toBeNull();
});
it("marks the analytics bucket stale (SWR-serveable) by default — background sync posture", async () => {
await primeAllCaches();
invalidateUserMeasurements(USER_A);
// v1.16.7 — the default (batch sync) path marks stale: the entry is
// gone for plain `get()` (asserted above) but stays serveable for
// the `cachedSwr` readers, so a high-frequency sync never busts the
// snapshot / comprehensive into an inline cold rebuild.
expect(caches.analytics.getAllowStale(`${USER_A}|default`)).toEqual({
value: { a: 1 },
stale: true,
});
expect(
caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)),
).toEqual({ value: { snap: 1 }, stale: true });
});
it("hard-evicts the analytics bucket with { evict: true } — interactive write posture", async () => {
await primeAllCaches();
invalidateUserMeasurements(USER_A, { evict: true });
// v1.16.7 — an interactive single-entry write must NOT leave a
// stale-serveable body behind: the SWR readers would hand the user
// back the pre-write payload. The evict drops the entries entirely.
expect(caches.analytics.getAllowStale(`${USER_A}|default`)).toBeNull();
expect(caches.analytics.getAllowStale(`${USER_A}|summaries`)).toBeNull();
expect(
caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)),
).toBeNull();
// User B stays warm; the non-analytics buckets evict as before.
expect(
caches.analytics.getAllowStale(`${USER_B}|default`),
).not.toBeNull();
expect(caches.achievements.get(USER_A)).toBeNull();
expect(caches.workouts.get(`${USER_A}|3|0||`)).toBeNull();
});
});
describe("invalidateUserMood", () => {
+29 -12
View File
@@ -47,22 +47,39 @@ export function invalidateUserDashboardSnapshot(userId: string): void {
* Covers: analytics aggregate, achievement progress, workouts cache.
* Mood-analytics is invalidated separately via `invalidateUserMood`
* because mood writes don't change measurement rows.
*
* v1.16.7 — `evict` splits the analytics bucket by write origin. The
* `cachedSwr` readers (dashboard snapshot, comprehensive) serve a
* marked-stale entry as-is, so a mark-stale right after an interactive
* write would hand the user back the pre-write body. Interactive
* single-entry routes (manual create / update / delete, bulk-delete,
* restore) pass `{ evict: true }` so the user's own entry is reflected
* on the very next read — matching the mood / medication hard-evict
* posture. Background batch syncs (Apple Health, Withings, workouts)
* keep the default mark-stale so a high-frequency sync never busts the
* bucket into inline cold-rebuild storms.
*/
export function invalidateUserMeasurements(userId: string): void {
export function invalidateUserMeasurements(
userId: string,
opts?: { evict?: boolean },
): void {
// The `${userId}|` prefix covers the slim / thick analytics cells, the
// iOS summary cell, AND the v1.7.0 dashboard snapshot
// (`${userId}|dashboard-snapshot`) in one pass.
//
// v1.12.7 — mark stale rather than hard-evict. Measurement writes are
// the highest-frequency dirty signal (every iOS Apple-Health sync
// posts a batch), and a hard evict busts the snapshot into a cold
// rebuild on the next read all day long. Marking stale lets the
// `cachedSwr` snapshot read serve the prior value immediately while a
// single background recompute warms a fresh one. The slim / thick /
// summary cells read via plain `cached` are unaffected: a marked-stale
// entry has `expiresAt === now`, so their next read is a clean miss and
// rebuilds fresh — identical to the old evict for those keys.
caches.analytics.markStaleByPrefix(`${userId}|`);
if (opts?.evict) {
caches.analytics.deleteByPrefix(`${userId}|`);
} else {
// v1.12.7 — mark stale rather than hard-evict. Measurement writes are
// the highest-frequency dirty signal (every iOS Apple-Health sync
// posts a batch), and a hard evict busts the snapshot into a cold
// rebuild on the next read all day long. Marking stale lets the
// `cachedSwr` snapshot read serve the prior value immediately while a
// single background recompute warms a fresh one. The slim / thick /
// summary cells read via plain `cached` are unaffected: a marked-stale
// entry has `expiresAt === now`, so their next read is a clean miss and
// rebuilds fresh — identical to the old evict for those keys.
caches.analytics.markStaleByPrefix(`${userId}|`);
}
caches.achievements.deleteByPrefix(userId);
caches.workouts.deleteByPrefix(`${userId}|`);
// v1.4.36 W1 — measurement writes change the per-target consistency
+24 -1
View File
@@ -522,6 +522,29 @@ export async function cachedSwr<T>(
annotateFn?: (fields: { meta: Record<string, unknown> }) => void,
ttlMsOverride?: number,
): Promise<T> {
const { value } = await cachedSwrWithMeta(
cache,
key,
builder,
annotateFn,
ttlMsOverride,
);
return value;
}
/**
* v1.16.7 — `cachedSwr` variant that also surfaces the read outcome so
* a route can mark a stale-served response (`outcome === "stale"`) as
* `revalidating` for the client. Non-breaking addition: `cachedSwr`
* keeps its value-only shape and delegates here.
*/
export async function cachedSwrWithMeta<T>(
cache: ServerCache<T>,
key: string,
builder: () => Promise<T>,
annotateFn?: (fields: { meta: Record<string, unknown> }) => void,
ttlMsOverride?: number,
): Promise<{ value: T; outcome: "hit" | "stale" | "miss" | "stampede" }> {
const { value, outcome } = await cache.wrapSwr(key, builder, ttlMsOverride);
if (annotateFn) {
const name = cache.stats().name;
@@ -532,7 +555,7 @@ export async function cachedSwr<T>(
},
});
}
return value;
return { value, outcome };
}
/** Test helper — reset every cache in the registry. */
@@ -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();
+12 -7
View File
@@ -35,12 +35,17 @@ export interface InsightPregeneratePayload {
/**
* Fire-and-forget enqueue used by the on-demand warm route. A
* `singletonKey` per `(user, locale)` collapses repeated requests within
* a short window into one queued job, so a client that taps the button
* twice (or the auto-trigger races a manual tap) can't double-warm. No-ops
* cleanly when the global boss instance is not available (e.g. a web
* process without an embedded worker) — the nightly cron remains the
* catch-net.
* `singletonKey` per user collapses repeated requests within a short
* window into one queued job, so a client that taps the button twice
* (or the auto-trigger races a manual tap) can't double-warm. The key
* is deliberately locale-free: `forceWarmUser` warms BOTH locale
* families per job (v1.16.7), so a per-(user, locale) key would let a
* German web session and an English Accept-Language client enqueue two
* near-identical full warms back to back — the second one force-
* regenerating the comprehensive briefing and re-evicting the
* per-status rows the first job just wrote. No-ops cleanly when the
* global boss instance is not available (e.g. a web process without an
* embedded worker) — the nightly cron remains the catch-net.
*/
export async function enqueueForceWarm(payload: {
userId: string;
@@ -63,7 +68,7 @@ export async function enqueueForceWarm(payload: {
locale: payload.locale,
} satisfies InsightPregeneratePayload,
{
singletonKey: `force:${payload.userId}:${payload.locale}`,
singletonKey: `force:${payload.userId}`,
// De-dupe within a 2-minute window so a double tap or an
// auto-trigger racing a manual tap enqueues one warm, not two.
singletonSeconds: 120,
+51 -11
View File
@@ -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`
// comprehensive ran `evictPerStatusInsightCache`, so only then must
// every card re-generate. (The forced generation above never returns
// `cached` — `force: true` skips the 24 h cache-hit branch.) 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.assessmentsWarmed = await warmPerStatusCaches(
userId,
locales,
statusGenerators,
evicted,
);
result.metricAssessmentsWarmed = await warmGenericMetrics(
userId,
locales,
evicted,
);
result.metricAssessmentsWarmed = await warmGenericMetrics(userId, locales);
}
annotate({
+10 -8
View File
@@ -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
+6
View File
@@ -28,6 +28,12 @@ const insightsComprehensiveResponse = z
.array(z.record(z.string(), z.unknown()))
.optional(),
metricSource: z.record(z.string(), z.unknown()).optional(),
revalidating: z
.boolean()
.optional()
.describe(
"True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body.",
),
})
.meta({
id: "InsightsComprehensiveResponse",
+45
View File
@@ -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 };
}