diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 679c837c7..ed87905d3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4152,7 +4152,7 @@ paths: (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, - completionTokens?, model }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, + completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer." diff --git a/src/app/api/labs/ocr/commit/__tests__/route.test.ts b/src/app/api/labs/ocr/commit/__tests__/route.test.ts new file mode 100644 index 000000000..ed84c475c --- /dev/null +++ b/src/app/api/labs/ocr/commit/__tests__/route.test.ts @@ -0,0 +1,135 @@ +/** + * v1.18.9 — POST /api/labs/ocr/commit. + * + * Focus: dedup correctness. A row that duplicates a live reading is skipped, + * and — the regression this guards — two identical rows inside ONE confirmed + * document collapse to a single write even though neither is yet visible to a + * live query. The in-memory seen-set makes in-batch dedup independent of DB + * visibility, rather than relying on the prior row autocommitting first. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + labResult: { + findMany: vi.fn(), + create: vi.fn(), + }, + auditLog: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/idempotency", () => ({ + withIdempotency: + (fn: (...args: Args) => Promise) => + (...args: Args) => + fn(...args), +})); +vi.mock("@/lib/jobs/reminder-satisfy", () => ({ + enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/labs/biomarker-store", () => ({ + resolveOrMintBiomarker: vi.fn(), +})); +vi.mock("@/lib/labs/serialise", () => ({ + serialiseLabResult: (created: { id: string }) => ({ id: created.id }), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; +import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +const TAKEN_AT = "2026-06-10T08:00:00.000Z"; + +function numericRow(value: number) { + return { + analyte: "Glucose", + value, + unit: "mg/dL", + takenAt: TAKEN_AT, + }; +} + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/labs/ocr/commit", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(enqueueReminderSatisfy).mockResolvedValue(undefined as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // No live reading collides at commit time — the in-batch path is what's + // under test, so the DB-visibility check returns empty for every row. + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); + let nextId = 0; + vi.mocked(prisma.labResult.create).mockImplementation((() => + Promise.resolve({ id: `lr-${++nextId}` })) as never); + vi.mocked(resolveOrMintBiomarker).mockResolvedValue({ + id: "bm-1", + name: "Glucose", + unit: "mg/dL", + panel: null, + lowerBound: null, + upperBound: null, + } as never); +}); + +describe("POST /api/labs/ocr/commit dedup", () => { + it("collapses identical in-document rows to a single write", async () => { + const res = await POST(postReq({ rows: [numericRow(95), numericRow(95)] })); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { inserted: unknown[]; skipped: Array<{ reason: string }> }; + }; + + // Only the first row writes; the second is skipped as an in-batch + // duplicate even though the first never became visible to a live query. + expect(prisma.labResult.create).toHaveBeenCalledTimes(1); + expect(body.data.inserted).toHaveLength(1); + expect(body.data.skipped).toEqual([ + { analyte: "Glucose", reason: "duplicate" }, + ]); + }); + + it("writes distinct values for the same analyte and day", async () => { + const res = await POST( + postReq({ rows: [numericRow(95), numericRow(110)] }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { inserted: unknown[]; skipped: unknown[] }; + }; + expect(prisma.labResult.create).toHaveBeenCalledTimes(2); + expect(body.data.inserted).toHaveLength(2); + expect(body.data.skipped).toHaveLength(0); + }); +}); diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 3f2b715a9..1607186c0 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -70,6 +70,25 @@ async function isDuplicate( return false; } +/** + * Stable in-batch dedup key for a confirmed row: analyte (lower+trim) + UTC day + * + the value dimension that `isDuplicate` matches on. Two rows in ONE document + * that resolve to the same key are the same reading; only the first is written. + * Mirrors the `isDuplicate` match semantics so in-batch dedup no longer depends + * on a prior row autocommitting before the next row's live query runs. + */ +function inBatchKey(row: OcrCommitRow): string { + const analyte = row.analyte.trim().toLowerCase(); + const dayKey = new Date(row.takenAt).toISOString().slice(0, 10); + const valuePart = + row.value !== undefined + ? `n:${row.value}` + : row.valueText !== undefined + ? `t:${row.valueText.trim().toLowerCase()}` + : "∅"; + return `${analyte}|${dayKey}|${valuePart}`; +} + async function commitOcrRows(request: NextRequest) { const { user } = await requireAuth(); @@ -102,9 +121,15 @@ async function commitOcrRows(request: NextRequest) { const inserted: ReturnType[] = []; const skipped: OcrSkippedRowDto[] = []; + // Tracks the keys already written in THIS request so an in-document duplicate + // is caught even before the prior row is visible to a live query. The + // mint-then-create on a mid-row failure can leave an orphan biomarker, which + // is benign and self-healing — the manual lab-write path mints the same way. + const writtenInBatch = new Set(); for (const row of parsed.data.rows) { - if (await isDuplicate(user.id, row)) { + const key = inBatchKey(row); + if (writtenInBatch.has(key) || (await isDuplicate(user.id, row))) { skipped.push({ analyte: row.analyte.trim(), reason: "duplicate" }); continue; } @@ -138,6 +163,7 @@ async function commitOcrRows(request: NextRequest) { }, }); + writtenInBatch.add(key); inserted.push(serialiseLabResult(created, biomarker)); } diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 0974fab9b..4cfb40d7d 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -648,7 +648,12 @@ async function postBatch(request: NextRequest): Promise { // and must invalidate every consumer that reads through this user's // measurements. if (insertedCount > 0 || updatedCount > 0) { - invalidateUserMeasurements(user.id); + // v1.18.9 (#38) — hard-evict so a focus-refetch after a background + // iOS / Apple-Health batch sync returns post-sync data. A mark-stale + // would let the `cachedSwr` snapshot serve the pre-batch body on the + // very next read, leaving the dashboard up to ~180 s stale; the manual + // measurement routes already pass `{ evict: true }` for the same reason. + invalidateUserMeasurements(user.id, { evict: true }); // v1.5.0 — refresh the persistent rollup table for every distinct // (type, day) the batch touched. We use the `prepared` rows diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 7c59eb0e0..5517d5237 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -515,10 +515,12 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { // v1.18.9 — additive reasoning-summary frame. Append so // multiple chunks accumulate; the disclosure renders it // when present, else falls back to the elapsed-time label. - collectedReasoning += evt.text; + // Guard the text: a `reasoning` frame with no `text` must not + // append a literal "undefined". + collectedReasoning += evt.text ?? ""; setStreaming((prev) => ({ ...prev, - reasoning: prev.reasoning + evt.text, + reasoning: prev.reasoning + (evt.text ?? ""), })); break; case "done": @@ -543,7 +545,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { } else if (evt.type === "error") { lastError = evt.code; } else if (evt.type === "reasoning") { - collectedReasoning += evt.text; + collectedReasoning += evt.text ?? ""; } } } catch (err) { diff --git a/src/lib/ai/__tests__/vision-capability.test.ts b/src/lib/ai/__tests__/vision-capability.test.ts index 7742ae293..362a27d8e 100644 --- a/src/lib/ai/__tests__/vision-capability.test.ts +++ b/src/lib/ai/__tests__/vision-capability.test.ts @@ -6,8 +6,11 @@ import { } from "../vision-capability"; describe("supportsVisionForConfig", () => { - it("accepts Claude 3.5+/4 family models for anthropic", () => { + it("accepts the Claude 3 / 3.5 / 3.7 / 4 families for anthropic", () => { for (const model of [ + "claude-3-opus-20240229", + "claude-3-sonnet-20240229", + "claude-3-haiku-20240307", "claude-3-5-sonnet-latest", "claude-3-7-sonnet", "claude-sonnet-4-6", diff --git a/src/lib/ai/vision-capability.ts b/src/lib/ai/vision-capability.ts index 2ab906afb..360b87160 100644 --- a/src/lib/ai/vision-capability.ts +++ b/src/lib/ai/vision-capability.ts @@ -15,12 +15,19 @@ /** * Anthropic vision models. The Messages API `image` + `document` blocks are - * accepted by the Claude 3.5+ / 3.7 / 4 families. An older `claude-2*` / - * `claude-instant*` pin is text-only. + * accepted by the whole Claude 3 family (3 / 3.5 / 3.7) and the Claude 4 + * family. An older `claude-2*` / `claude-instant*` pin is text-only. + * + * The bare `claude-3-opus` / `claude-3-sonnet` / `claude-3-haiku` prefixes are + * listed explicitly: a self-hoster pinned to e.g. `claude-3-opus-20240229` + * reads images, but the `claude-3-5` prefix does not match it. */ function anthropicSupportsVision(model: string): boolean { const m = model.toLowerCase(); return ( + m.startsWith("claude-3-opus") || + m.startsWith("claude-3-sonnet") || + m.startsWith("claude-3-haiku") || m.startsWith("claude-3-5") || m.startsWith("claude-3.5") || m.startsWith("claude-3-7") || diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 8a091018d..dcc6250c5 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -438,7 +438,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Send a Coach turn (streaming reply)", description: - "v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: \\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer.", + "v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: \\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer.", requestBody: { required: true, content: { diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index d1a87cbf0..81b86ab6a 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -10,7 +10,6 @@ import { mapDailyActivity, mapDailySleep, mapDailySpo2, - mapDailyStress, mapReadiness, mapSleep, mapVo2Max, @@ -18,7 +17,6 @@ import { type OuraDailyActivity, type OuraDailySleep, type OuraDailySpo2, - type OuraDailyStress, type OuraReadiness, type OuraSleep, type OuraVo2Max, @@ -355,44 +353,6 @@ describe("mapVo2Max", () => { }); }); -describe("mapDailyStress", () => { - it("derives a 0-100 STRESS_SCORE from the high-stress share of charged time", () => { - const s: OuraDailyStress = { - id: "1", - day: "2026-06-10", - stress_high: 90, - recovery_high: 30, - }; - const mapped = mapDailyStress(s); - expect(mapped).toHaveLength(1); - // 90 / (90 + 30) = 75 %. - expect(mapped[0]).toMatchObject({ - type: "STRESS_SCORE", - value: 75, - unit: "score", - fieldTag: "stress", - }); - }); - it("treats a missing recovery field as zero recovery", () => { - const mapped = mapDailyStress({ - id: "1", - day: "2026-06-10", - stress_high: 60, - }); - expect(mapped[0]?.value).toBe(100); - }); - it("skips a day with no charged minutes (no signal)", () => { - expect( - mapDailyStress({ - id: "1", - day: "2026-06-10", - stress_high: 0, - recovery_high: 0, - }), - ).toEqual([]); - }); -}); - describe("OURA_OAUTH_SCOPE", () => { it("requests daily + personal", () => { expect(OURA_OAUTH_SCOPE).toBe("daily personal"); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index c345fdcef..6ab30d4ac 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -9,7 +9,6 @@ const { fetchActivityMock, fetchDailySleepMock, fetchSpo2Mock, - fetchStressMock, fetchVo2MaxMock, refreshMock, upsertMock, @@ -26,7 +25,6 @@ const { fetchActivityMock: vi.fn(), fetchDailySleepMock: vi.fn(), fetchSpo2Mock: vi.fn(), - fetchStressMock: vi.fn(), fetchVo2MaxMock: vi.fn(), refreshMock: vi.fn(), upsertMock: vi.fn(), @@ -71,7 +69,6 @@ vi.mock("../client", async (importOriginal) => { fetchDailyActivity: fetchActivityMock, fetchDailySleep: fetchDailySleepMock, fetchDailySpo2: fetchSpo2Mock, - fetchDailyStress: fetchStressMock, fetchVo2Max: fetchVo2MaxMock, refreshAccessToken: refreshMock, }; @@ -93,7 +90,6 @@ beforeEach(() => { fetchActivityMock.mockReset().mockResolvedValue([]); fetchDailySleepMock.mockReset().mockResolvedValue([]); fetchSpo2Mock.mockReset().mockResolvedValue([]); - fetchStressMock.mockReset().mockResolvedValue([]); fetchVo2MaxMock.mockReset().mockResolvedValue([]); refreshMock.mockReset(); upsertMock.mockReset().mockResolvedValue({}); @@ -218,14 +214,11 @@ describe("syncUserOura", () => { }); }); - it("writes VO2_MAX and STRESS_SCORE from the new collections", async () => { + it("writes VO2_MAX from the dedicated collection", async () => { getConnMock.mockResolvedValue(CONN); fetchVo2MaxMock.mockResolvedValue([ { id: "v", day: "2026-06-10", vo2_max: 47.3 }, ]); - fetchStressMock.mockResolvedValue([ - { id: "s", day: "2026-06-10", stress_high: 90, recovery_high: 30 }, - ]); await syncUserOura("u1"); const written = upsertMock.mock.calls.map((c) => ({ type: c[0].where.userId_type_source_externalId.type, @@ -237,11 +230,8 @@ describe("syncUserOura", () => { externalId: "vo2max:2026-06-10:vo2_max", value: 47.3, }); - expect(written).toContainEqual({ - type: "STRESS_SCORE", - externalId: "stress:2026-06-10:stress", - value: 75, - }); + // daily_stress → STRESS_SCORE is withdrawn pending ladder wiring. + expect(written.some((w) => w.type === "STRESS_SCORE")).toBe(false); }); it("does NOT refresh on a 403 (not an expiry case)", async () => { diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 6dece968c..35ca9c241 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -218,24 +218,6 @@ export interface OuraDailySpo2 { spo2_percentage?: { average?: number | null } | null; } -/** - * Oura `daily_stress` — minutes spent in high stress vs. high recovery for the - * day (mutually exclusive). The collection carries no headline 0–100 score; the - * actionable scalars are the two durations plus a categorical `day_summary` - * (`restored` | `normal` | `stressed`), which has no enum and is dropped. - */ -export interface OuraDailyStress { - id: string; - day: string; - timestamp?: string; - /** Minutes spent in high stress. */ - stress_high?: number | null; - /** Minutes spent in high recovery. */ - recovery_high?: number | null; - /** `restored` | `normal` | `stressed` — categorical; no enum, not mapped. */ - day_summary?: string | null; -} - /** Oura `vO2_max` — the dedicated cardio-fitness collection, mL/(kg·min). */ export interface OuraVo2Max { id: string; @@ -374,17 +356,10 @@ export function fetchDailySpo2( ); } -export function fetchDailyStress( - accessToken: string, - query: DateRangeQuery, -): Promise { - return fetchCollection( - "/v2/usercollection/daily_stress", - accessToken, - "fetchDailyStress", - query, - ); -} +// Oura `daily_stress` → STRESS_SCORE is deferred pending STRESS_SCORE +// source-priority + graded-series wiring (an HRV-derived COMPUTED producer +// already exists; a second producer here would double-count). The mapping is +// withdrawn until the ladder is in place. export function fetchVo2Max( accessToken: string, @@ -725,31 +700,3 @@ export function mapVo2Max(v: OuraVo2Max): MappedMeasurement[] { }, ]; } - -/** - * Map one Oura `daily_stress` record → `STRESS_SCORE`. Oura exposes no headline - * 0–100 stress number — only minutes spent in high stress vs. high recovery. - * We derive a bounded 0–100 score from the share of the day's "charged" time - * (stress + recovery minutes) spent in high stress, which keeps the canonical - * `STRESS_SCORE` contract (0–100, unit "score", higher = more stress) and lines - * up with how RECOVERY_SCORE is stored. A day with no charged minutes carries - * no signal and is skipped; the categorical `day_summary` has no enum and is - * dropped. - */ -export function mapDailyStress(s: OuraDailyStress): MappedMeasurement[] { - const stress = typeof s.stress_high === "number" ? s.stress_high : 0; - const recovery = typeof s.recovery_high === "number" ? s.recovery_high : 0; - const charged = stress + recovery; - if (charged <= 0) return []; - const measuredAt = dayAnchor(s.day, s.timestamp); - if (!measuredAt) return []; - return [ - { - type: "STRESS_SCORE", - value: Math.round((stress / charged) * 100), - unit: "score", - measuredAt, - fieldTag: "stress", - }, - ]; -} diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 3b95d8c5c..64726c859 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -5,9 +5,14 @@ * (real per-segment hypnogram timeline when present, else per-stage totals; * efficiency, HRV, RHR, respiratory rate), daily activity (steps, active * energy, equivalent walking distance), the daily Sleep Score (→ SLEEP_SCORE), - * daily SpO2 (→ OXYGEN_SATURATION), daily stress (→ STRESS_SCORE), and the - * dedicated vO2_max collection (→ VO2_MAX) for one connected user, mapping each - * into `Measurement` rows tagged `source = OURA`. + * daily SpO2 (→ OXYGEN_SATURATION), and the dedicated vO2_max collection (→ + * VO2_MAX) for one connected user, mapping each into `Measurement` rows tagged + * `source = OURA`. + * + * daily_stress → STRESS_SCORE is deferred: STRESS_SCORE already has an + * HRV-derived COMPUTED producer that is not yet wired into the source-priority + * ladder or the weekly graded-series collapse, so a second producer here would + * double-count nondeterministically. Re-add once STRESS_SCORE is laddered. * * Token model: Oura uses refresh tokens. The merged schema has no expiry * column, so the sync refreshes REACTIVELY — the first read that 401s triggers @@ -41,14 +46,12 @@ import { fetchDailyActivity, fetchDailySleep, fetchDailySpo2, - fetchDailyStress, fetchReadiness, fetchSleep, fetchVo2Max, mapDailyActivity, mapDailySleep, mapDailySpo2, - mapDailyStress, mapReadiness, mapSleep, mapVo2Max, @@ -119,14 +122,13 @@ async function fetchAll( const query = { startDate: ymd(start), endDate: ymd(now) }; const run = async (token: string): Promise => { - const [readiness, sleeps, activities, dailySleep, spo2, stress, vo2max] = + const [readiness, sleeps, activities, dailySleep, spo2, vo2max] = await Promise.all([ fetchReadiness(token, query), fetchSleep(token, query), fetchDailyActivity(token, query), fetchDailySleep(token, query), fetchDailySpo2(token, query), - fetchDailyStress(token, query), fetchVo2Max(token, query), ]); const out: OuraMeasurementUpsert[] = []; @@ -138,7 +140,6 @@ async function fetchAll( for (const d of dailySleep) out.push(...toUpsert(mapDailySleep(d), "daily_sleep")); for (const s of spo2) out.push(...toUpsert(mapDailySpo2(s), "spo2")); - for (const s of stress) out.push(...toUpsert(mapDailyStress(s), "stress")); for (const v of vo2max) out.push(...toUpsert(mapVo2Max(v), "vo2max")); return out; }; diff --git a/src/lib/queries/use-dashboard-snapshot.ts b/src/lib/queries/use-dashboard-snapshot.ts index 77c8e093d..0c34da32e 100644 --- a/src/lib/queries/use-dashboard-snapshot.ts +++ b/src/lib/queries/use-dashboard-snapshot.ts @@ -24,11 +24,12 @@ * returning to the tab after a sync surfaces the new readings within * a frame. The `staleTime: 60_000` gate makes rapid focus toggles * cheap (a refetch fires only once the slot is stale), and the warm - * 60 s server SWR cache keeps the focus refetch sub-cost. + * server SWR cache (~180 s fresh TTL, `DASHBOARD_REFETCH_INTERVAL_MS + * + 60_000`) keeps the focus refetch sub-cost. * - `refetchInterval: 120_000` + `refetchIntervalInBackground: false` * keep an open dashboard live: an idle tab polls the snapshot every * two minutes so freshly-synced Withings / HealthKit readings appear - * without a manual reload. The poll hits the warm 60 s server cache + * without a manual reload. The poll hits the warm ~180 s server cache * cheaply and only triggers a sub-second rollup rebuild when the * underlying data actually changed — never the LLM surfaces, which * stay daily / pre-generated. The interval pauses while the tab is diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 5679565ac..999d70d60 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -29,6 +29,7 @@ import { recomputeBucketsForMeasurement, } from "@/lib/rollups/measurement-rollups"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; /** * Build the callback URL handed to Withings at `Notify.subscribe` time. @@ -264,6 +265,15 @@ export async function syncUserMeasurements( `withings: status-insight invalidate failed for ${userId}: ${err}`, ); }); + + // v1.18.9 (#38) — hard-evict the dashboard snapshot so a focus-refetch + // after this server-side sync returns the freshly-imported readings. + // The server-side Withings path produces no client mutation event, so + // without this the snapshot's `cachedSwr` entry kept serving the + // pre-sync body until its ~180 s TTL lapsed. + if (keys.length > 0) { + invalidateUserDashboardSnapshot(userId); + } } catch (err) { getEvent()?.addWarning( `withings: rollup recompute failed for ${userId}: ${err}`,