mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
commit
0798373877
14 files changed
+217
-130
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in new issue
Block a user