mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
fix(daily): serve the briefing as it stands today on every surface
Only the digest applied briefingForToday. The dashboard snapshot (the
web dashboard, its server prefetch and the iOS cold-launch read) and
the /insights advisor payload still served an earlier day's text and
the raw model deltas ("+33.72 bpm").
The snapshot read now resolves the briefing after the cache, beside
the capability decision, from the payload's generation moment and the
snapshot's own newest reading per type; a withheld briefing reads
"preparing", never "ready" with nothing in it. The digest takes the
resolved briefing from that read on its own clock instead of resolving
again. The advisor route resolves dailyBriefing on every branch it
returns, reading the newest reading per signal type in one grouped
query only when a reading-backed signal is present; text generated on
an earlier day no longer counts as fresh on the read GET (so the warm
is enqueued) and no longer short-circuits a provider-backed POST.
The OpenAPI descriptions of both advisor responses say so.
This commit is contained in:
1 parent
491d542f58
commit
f6b0131731
10 files changed
+462
-91
No files matched your search
@@ -49,6 +49,8 @@ vi.mock("@/lib/db", () => ({
|
||||
})),
|
||||
updateMany: vi.fn(async () => ({ count: 1 })),
|
||||
},
|
||||
// The newest reading per signal type (`readSignalLastSeen`).
|
||||
measurement: { groupBy: vi.fn(async () => []) },
|
||||
auditLog: {
|
||||
// v1.4.16 A7: route now evicts stale per-status cache rows
|
||||
// (`insights.<scope>-status.<locale>`) on every successful
|
||||
@@ -593,6 +595,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
insightsCachedText: JSON.stringify({
|
||||
changed: "still fresh",
|
||||
dailyBriefing: { paragraph: "ok", keyFindings: [] },
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
}),
|
||||
locale: "en",
|
||||
} as never);
|
||||
@@ -632,6 +635,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
insightsCachedText: JSON.stringify({
|
||||
changed: "still fresh",
|
||||
dailyBriefing: { paragraph: "ok", keyFindings: [] },
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
}),
|
||||
locale: "en",
|
||||
} as never);
|
||||
@@ -680,6 +684,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
|
||||
insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000),
|
||||
insightsCachedText: JSON.stringify({
|
||||
dailyBriefing: { paragraph: "today", keyFindings: [] },
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
}),
|
||||
locale: "en",
|
||||
} as never);
|
||||
@@ -802,7 +807,10 @@ describe("POST /api/insights/generate — payload-size hard downgrade (H1)", ()
|
||||
|
||||
describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
it("serves the cached briefing without ever calling the provider", async () => {
|
||||
const cached = { dailyBriefing: { paragraph: "ok", keyFindings: [] } };
|
||||
const cached = {
|
||||
dailyBriefing: { paragraph: "ok", keyFindings: [] },
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(cached),
|
||||
@@ -878,7 +886,10 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
});
|
||||
|
||||
it("serves a fresh cache whose tag matches the reader's language", async () => {
|
||||
const cached = { dailyBriefing: { paragraph: "English", keyFindings: [] } };
|
||||
const cached = {
|
||||
dailyBriefing: { paragraph: "English", keyFindings: [] },
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(cached),
|
||||
@@ -898,6 +909,87 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
expect(enqueueForceWarm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats yesterday's text that a warm only re-stamped as stale, and asks for a warm", async () => {
|
||||
// Stamped a minute ago, generated yesterday in the reader's zone.
|
||||
const yesterday = new Date(Date.now() - 30 * 60 * 60 * 1000);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(Date.now() - 60_000),
|
||||
insightsCachedText: JSON.stringify({
|
||||
dailyBriefing: { paragraph: "Yesterday's read.", keyFindings: [] },
|
||||
briefingGeneratedAt: yesterday.toISOString(),
|
||||
}),
|
||||
insightsCachedLocale: "en",
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
} as never);
|
||||
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/insights/generate"),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
insights: { dailyBriefing: unknown };
|
||||
revalidating: boolean;
|
||||
};
|
||||
};
|
||||
expect(body.data.insights.dailyBriefing).toBeNull();
|
||||
expect(body.data.revalidating).toBe(true);
|
||||
expect(enqueueForceWarm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("serves today's signals with deltas at display precision", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify({
|
||||
dailyBriefing: {
|
||||
paragraph: "ok",
|
||||
keyFindings: [],
|
||||
signalsOfDay: [
|
||||
{
|
||||
sourceMetric: "sleep",
|
||||
tone: "info",
|
||||
headline: "Shorter night",
|
||||
nudge: "An earlier night.",
|
||||
delta: null,
|
||||
},
|
||||
{
|
||||
sourceMetric: "pulse",
|
||||
tone: "watch",
|
||||
headline: "Pulse up",
|
||||
nudge: "Rest.",
|
||||
delta: "+33.72 bpm vs your 30-day average",
|
||||
},
|
||||
],
|
||||
},
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
}),
|
||||
insightsCachedLocale: "en",
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
} as never);
|
||||
// The pulse was measured yesterday: its signal is not today's.
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValueOnce([
|
||||
{
|
||||
type: "PULSE",
|
||||
_max: { measuredAt: new Date(Date.now() - 36 * 60 * 60 * 1000) },
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/insights/generate"),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
insights: {
|
||||
dailyBriefing: { signalsOfDay: Array<{ sourceMetric: string }> };
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(
|
||||
body.data.insights.dailyBriefing.signalsOfDay.map((s) => s.sourceMetric),
|
||||
).toEqual(["sleep"]);
|
||||
});
|
||||
|
||||
it("returns an empty payload (no warm) on a cold cache without a provider", async () => {
|
||||
vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
* write breaks that convergence — the settling seam tests pin the client
|
||||
* half, this comment is the server half of the contract.
|
||||
*/
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at";
|
||||
import {
|
||||
generatedOnLocalToday,
|
||||
insightsPayloadForToday,
|
||||
} from "@/lib/daily/briefing-today-read";
|
||||
import { withBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -168,6 +174,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
insightsCachedText: true,
|
||||
insightsCachedLocale: true,
|
||||
locale: true,
|
||||
timezone: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -194,9 +201,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
dbUser.insightsCachedLocale !== readerLocale;
|
||||
|
||||
const cachedAt = dbUser?.insightsCachedAt ?? null;
|
||||
const timezone = dbUser?.timezone ?? DEFAULT_TIMEZONE;
|
||||
// Fresh means written today on the reader's calendar, by the moment the
|
||||
// text was generated: `insightsCachedAt` also moves when an unchanged
|
||||
// warm only re-stamps yesterday's text.
|
||||
const generatedToday = generatedOnLocalToday(
|
||||
readBriefingGeneratedAt(dbUser?.insightsCachedText ?? null),
|
||||
timezone,
|
||||
new Date(),
|
||||
);
|
||||
const isFresh =
|
||||
!languageMismatch &&
|
||||
cachedAt !== null &&
|
||||
generatedToday &&
|
||||
Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS;
|
||||
|
||||
// v1.18.9 (#4) — resolve provider availability once, up front. The
|
||||
@@ -254,7 +271,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: { cached: true, legacyPayload, revalidating, hasProvider },
|
||||
});
|
||||
return apiSuccess({
|
||||
insights: cached,
|
||||
// The briefing as every surface serves it: today's text only, its
|
||||
// signals measured today, its deltas at display precision.
|
||||
insights: await insightsPayloadForToday(cached, {
|
||||
userId,
|
||||
timezone,
|
||||
language: readerLocale,
|
||||
}),
|
||||
cached: true,
|
||||
cachedAt,
|
||||
legacyPayload,
|
||||
@@ -329,6 +352,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
insightsCachedAt: true,
|
||||
insightsCachedText: true,
|
||||
insightsCachedLocale: true,
|
||||
timezone: true,
|
||||
// v1.4.36 W3 T3 — per-user opt-out list mirroring the Coach
|
||||
// settings. Filtered off `features` before serialisation so the
|
||||
// LLM never sees the excluded blocks.
|
||||
@@ -376,6 +400,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
request,
|
||||
userLocale: dbUser?.locale ?? user.locale ?? null,
|
||||
});
|
||||
const postTimezone = dbUser?.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<{
|
||||
force?: unknown;
|
||||
@@ -420,9 +445,16 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
// We don't auto-regenerate — that would burn rate-limit tokens
|
||||
// on a cache-hit silently. User-initiated only.
|
||||
const legacyPayload = isLegacyInsightPayload(cached);
|
||||
const carriesBriefing = cachedPayloadCarriesBriefing(
|
||||
dbUser.insightsCachedText,
|
||||
);
|
||||
// A briefing generated on an earlier day is not today's read: like
|
||||
// a briefingless cache, it does not short-circuit a provider-backed
|
||||
// account, and degrades to the cached payload on a 429 below.
|
||||
const carriesBriefing =
|
||||
cachedPayloadCarriesBriefing(dbUser.insightsCachedText) &&
|
||||
generatedOnLocalToday(
|
||||
readBriefingGeneratedAt(dbUser.insightsCachedText),
|
||||
postTimezone,
|
||||
new Date(),
|
||||
);
|
||||
if (carriesBriefing || !(await hasUsableStatusProvider(userId))) {
|
||||
annotate({
|
||||
action: { name: "insights.generate" },
|
||||
@@ -433,7 +465,11 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
insights: cached,
|
||||
insights: await insightsPayloadForToday(cached, {
|
||||
userId,
|
||||
timezone: postTimezone,
|
||||
language: locale,
|
||||
}),
|
||||
cached: true,
|
||||
cachedAt: dbUser.insightsCachedAt,
|
||||
legacyPayload,
|
||||
@@ -480,7 +516,10 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
meta: { cached: true, rate_limited_fallback: true },
|
||||
});
|
||||
return apiSuccess({
|
||||
insights: briefinglessCacheFallback.cached,
|
||||
insights: await insightsPayloadForToday(
|
||||
briefinglessCacheFallback.cached,
|
||||
{ userId, timezone: postTimezone, language: locale },
|
||||
),
|
||||
cached: true,
|
||||
cachedAt: briefinglessCacheFallback.cachedAt,
|
||||
legacyPayload: briefinglessCacheFallback.legacyPayload,
|
||||
@@ -1039,6 +1078,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
errorCode: "insights.generate.profileScopeChanged",
|
||||
});
|
||||
}
|
||||
const stored = withBriefingGeneratedAt(insights, new Date());
|
||||
const committed = await prisma.user.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
@@ -1052,9 +1092,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
},
|
||||
data: {
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(
|
||||
withBriefingGeneratedAt(insights, new Date()),
|
||||
),
|
||||
insightsCachedText: JSON.stringify(stored),
|
||||
insightsCachedLocale: locale,
|
||||
insightsSnapshotHash: hashInsightSnapshot({
|
||||
features: compactFeatures,
|
||||
@@ -1134,7 +1172,11 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
insights,
|
||||
insights: await insightsPayloadForToday(stored, {
|
||||
userId,
|
||||
timezone: postTimezone,
|
||||
language: locale,
|
||||
}),
|
||||
cached: false,
|
||||
legacyPayload: false,
|
||||
// v1.28.28 (#470) — additive: non-null when the grounding gate stripped
|
||||
|
||||
Reference in new issue
Block a user