Merge v189/recon-a into release/v1.18.9

Hard-evict the dashboard snapshot on batch and Withings background sync so the
Startseite reflects synced readings on focus; withdraw the Oura daily_stress
mapping pending source-priority wiring; make OCR in-batch dedup independent of DB
visibility; widen the vision allowlist to Claude 3; null-guard the reasoning
frame; correct OpenAPI and cache-TTL drift.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 13:02:04 +02:00
commit 0798373877
14 files changed
+217 -130

No files matched your search

+1 -1
View File
@@ -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."
@@ -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:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...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);
});
});
+27 -1
View File
@@ -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<typeof serialiseLabResult>[] = [];
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<string>();
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));
}
+6 -1
View File
@@ -648,7 +648,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
// 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
@@ -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) {
@@ -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",
+9 -2
View File
@@ -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") ||
+1 -1
View File
@@ -438,7 +438,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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: <json>\\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: <json>\\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: {
-40
View File
@@ -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");
+3 -13
View File
@@ -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 () => {
+4 -57
View File
@@ -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<OuraDailyStress[]> {
return fetchCollection<OuraDailyStress>(
"/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",
},
];
}
+9 -8
View File
@@ -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<OuraMeasurementUpsert[]> => {
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;
};
+3 -2
View File
@@ -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
+10
View File
@@ -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}`,