diff --git a/.env.production.example b/.env.production.example index c5f56207c..bc8df0b6f 100644 --- a/.env.production.example +++ b/.env.production.example @@ -327,6 +327,13 @@ API_TOKEN_HMAC_KEY="" # docs/integrations/ai-providers.md. # DOCUMENT_AI_LIMIT_PER_HOUR="" +# Minimum hours between two regenerations of a status card after a sync +# (optional). A watch or an integration delivers readings all day, and each +# delivery would otherwise regenerate the cards it touches every hour. Hand +# entries keep a one-hour gap. Default 6, whole hours from 1 to 48. The value +# under Admin > AI wins over this one. See docs/integrations/ai-providers.md. +# AI_SYNC_REGEN_GAP_HOURS="" + # Hourly upload ceiling for each document-upload token (optional). Applies # only to uploads made with a token minted under Settings > API & Tokens > # Document tokens: a Paperless workflow or the import script. Every upload diff --git a/docker-compose.yml b/docker-compose.yml index eaacd11d4..c8a65b987 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -332,6 +332,11 @@ x-healthlog-env: &healthlog_env # (the `environment:` block is a whitelist). See # docs/integrations/ai-providers.md. DOCUMENT_AI_LIMIT_PER_HOUR: "${DOCUMENT_AI_LIMIT_PER_HOUR:-}" + # Minimum hours between two status-card regenerations after a sync + # (optional; default 6, whole hours 1-48; hand entries keep one hour). The + # value under Admin > AI wins. Listed here so an operator value in .env + # reaches the container. See docs/integrations/ai-providers.md. + AI_SYNC_REGEN_GAP_HOURS: "${AI_SYNC_REGEN_GAP_HOURS:-}" # Hourly upload ceiling for each narrow document-upload token (a Paperless # workflow, the import script; optional; default 120, clamped 1-1000). # Uploads from the web app and the phone keep their own bucket. Listed here diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md index a097541e5..34a6dbda1 100644 --- a/docs/integrations/ai-providers.md +++ b/docs/integrations/ai-providers.md @@ -431,6 +431,14 @@ many documents against a generous provider budget; lower it on a tight one. The 429 names the actual wait (the window is an hour) and carries the reset instant in `X-RateLimit-Reset`. +`AI_SYNC_REGEN_GAP_HOURS` (default `6`, whole hours from 1 to 48) is +the minimum time between two regenerations of a status card after a sync. +A watch or an integration delivers readings all day, and each delivery +moves today's figures, so without a gap every touched card regenerated +about once an hour. A reading entered by hand keeps a one-hour gap, so it +is still narrated the same day. The value set under Admin > AI wins over +the environment. + `LABS_OCR_LIMIT_PER_HOUR` (default `6`, clamped to 1–1000) does the same for lab-report scans. The photo or PDF scan and the browser-OCR text scan share one bucket per user, and a scan that fails before the diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 08a6796ec..52b63fa38 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -142,6 +142,10 @@ "name": "DOCUMENT_AI_LIMIT_PER_HOUR", "purpose": "Per-user hourly ceiling for the document-AI actions (read/index, extract, suggest, summarise share one bucket). Default 6, clamped to 1-1000." }, + { + "name": "AI_SYNC_REGEN_GAP_HOURS", + "purpose": "Minimum hours between two status-card regenerations after a sync. Default 6, whole hours 1-48; hand entries keep one hour. The admin AI setting wins." + }, { "name": "DOCUMENT_UPLOAD_LIMIT_PER_HOUR", "purpose": "Hourly upload ceiling per document-upload token (Paperless workflow, import script). Default 120, clamped to 1-1000. Web and phone uploads keep their own bucket." diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index e6990a216..d39ccc3e4 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -443,7 +443,10 @@ export const POST = apiHandler(async (request: NextRequest) => { // CSV pays at most ~N (type, day) recomputes, not 10 000 per-row hooks // — and the status re-warm for the touched types. Best-effort: a // populator hiccup never fails the importer. - await afterMeasurementMutation(userId, touchedMeasurements, "import.csv"); + await afterMeasurementMutation(userId, touchedMeasurements, { + source: "manual", + label: "import.csv", + }); } await auditLog("import.csv.upload", { diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index e827cc674..b64ff48ce 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -483,7 +483,12 @@ export const POST = apiHandler(async (request: NextRequest) => { // (type, day) the import touched (collapsed, so a 10 000-row restore pays // at most ~N recomputes) and re-warm the touched types' status // assessments. Best-effort: a populator hiccup never fails the importer. - await afterMeasurementMutation(userId, touchedMeasurements, "import"); + await afterMeasurementMutation(userId, touchedMeasurements, { + // A file the person chose to import: a deliberate act, narrated like a + // hand entry rather than held to the sync gap. + source: "manual", + label: "import", + }); annotate({ meta: { diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index d1a29a746..5b0431332 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -248,10 +248,14 @@ export const PUT = apiHandler( // NEW identity ride the list: an edit can move the row across day // boundaries or re-type it, and the helper collapses identical // (type, day) pairs so the common in-place edit fires once. - await afterMeasurementMutation(user.id, [ - { type: measurement.type, measuredAt: measurement.measuredAt }, - { type: existing.type, measuredAt: existing.measuredAt }, - ]); + await afterMeasurementMutation( + user.id, + [ + { type: measurement.type, measuredAt: measurement.measuredAt }, + { type: existing.type, measuredAt: existing.measuredAt }, + ], + { source: "manual" }, + ); return apiSuccess(shapeMeasurementNotes(measurement)); }, @@ -312,9 +316,11 @@ export const DELETE = apiHandler( // v1.37.19 (C2-F1) — shared post-mutation tail (the recompute drops // the rollup row when the day's measurement count goes to zero). - await afterMeasurementMutation(user.id, [ - { type: existing.type, measuredAt: existing.measuredAt }, - ]); + await afterMeasurementMutation( + user.id, + [{ type: existing.type, measuredAt: existing.measuredAt }], + { source: "manual" }, + ); return apiSuccess({ deleted: true }); }, diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 299b0ff1b..43de22c21 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -1209,7 +1209,12 @@ async function postBatch(request: NextRequest): Promise { // v1.37.19 (C2-F1) — shared post-mutation tail for every distinct // (type, day) the batch touched. Updates participate as well as // inserts, so this deliberately retains the prepared-row scope. - await afterMeasurementMutation(user.id, writtenIdentities); + // A batch is the phone's sync, held to the sync gap, unless it wrote one + // of the person's own hand-entered readings, which is narrated like any + // other hand entry. + await afterMeasurementMutation(user.id, writtenIdentities, { + source: wroteManualRow ? "manual" : "sync", + }); // S4 — Apple-Health sleep is the third sleep transport. If any SLEEP_DURATION // row for last night just landed, kick the debounced morning refresh so the diff --git a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts index 32ff2f171..3a3438a12 100644 --- a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts @@ -154,10 +154,12 @@ describe("POST /api/measurements/bulk-delete", () => { expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3); // And one status re-warm over the distinct types. expect(invalidateStatusInsightsForTypes).toHaveBeenCalledTimes(1); - expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [ - "WEIGHT", - "PULSE", - ]); + // A delete is the person's own act: narrated like a hand entry. + expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith( + "user-1", + ["WEIGHT", "PULSE"], + { source: "manual" }, + ); }); it("is a no-op (deleted: 0) when no id is owned — no rollup, no invalidate", async () => { diff --git a/src/app/api/measurements/bulk-delete/route.ts b/src/app/api/measurements/bulk-delete/route.ts index 7d341b65c..fd378ea9d 100644 --- a/src/app/api/measurements/bulk-delete/route.ts +++ b/src/app/api/measurements/bulk-delete/route.ts @@ -123,7 +123,7 @@ async function postBulkDelete(request: NextRequest): Promise { type: row.type as MeasurementType, measuredAt: row.measuredAt, })), - "measurements bulk-delete", + { source: "manual", label: "measurements bulk-delete" }, ); } diff --git a/src/app/api/measurements/by-external-ids/__tests__/route.test.ts b/src/app/api/measurements/by-external-ids/__tests__/route.test.ts index 072608711..afb091c8e 100644 --- a/src/app/api/measurements/by-external-ids/__tests__/route.test.ts +++ b/src/app/api/measurements/by-external-ids/__tests__/route.test.ts @@ -118,9 +118,12 @@ describe("DELETE /api/measurements/by-external-ids", () => { // The deleted row's type is re-warmed; the zero-row case below is the // twin that must not reach it. - expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [ - "WEIGHT", - ]); + // The phone's HealthKit deletions arrive with its sync: the sync gap. + expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith( + "user-1", + ["WEIGHT"], + { source: "sync" }, + ); }); it("is a no-op for a foreign-origin externalId that collides on a non-app-minted row", async () => { diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts index 387db7ed4..3a1caf88d 100644 --- a/src/app/api/measurements/by-external-ids/route.ts +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -169,7 +169,7 @@ async function deleteByExternalIds(request: NextRequest): Promise { // v1.37.19 (C2-F1) — shared post-mutation tail (identities collected // BEFORE the destructive statement, while readable). - await afterMeasurementMutation(user.id, affectedRows); + await afterMeasurementMutation(user.id, affectedRows, { source: "sync" }); } return apiSuccess({ deletedCount: result.count }); diff --git a/src/app/api/measurements/restore/__tests__/route.test.ts b/src/app/api/measurements/restore/__tests__/route.test.ts index dcd8a0f15..8ef10c8c3 100644 --- a/src/app/api/measurements/restore/__tests__/route.test.ts +++ b/src/app/api/measurements/restore/__tests__/route.test.ts @@ -149,10 +149,12 @@ describe("POST /api/measurements/restore", () => { expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3); // And one status re-warm over the distinct types. expect(invalidateStatusInsightsForTypes).toHaveBeenCalledTimes(1); - expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [ - "WEIGHT", - "PULSE", - ]); + // A restore is the person's own act: narrated like a hand entry. + expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith( + "user-1", + ["WEIGHT", "PULSE"], + { source: "manual" }, + ); }); it("is a no-op (restored: 0) when no id is owned + tombstoned — no rollup, no invalidate", async () => { diff --git a/src/app/api/measurements/restore/route.ts b/src/app/api/measurements/restore/route.ts index cbef0acdf..be8a4321a 100644 --- a/src/app/api/measurements/restore/route.ts +++ b/src/app/api/measurements/restore/route.ts @@ -122,7 +122,7 @@ async function postRestore(request: NextRequest): Promise { type: row.type as MeasurementType, measuredAt: row.measuredAt, })), - "measurements restore", + { source: "manual", label: "measurements restore" }, ); } diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 278ddb899..fa46fa1f7 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -963,6 +963,7 @@ async function postMeasurement(request: NextRequest) { await afterMeasurementMutation( user.id, results.map((r) => ({ type: r.type, measuredAt: r.measuredAt })), + { source: "manual" }, ); return apiSuccess(shapedResults, 201); @@ -1116,9 +1117,11 @@ async function postMeasurement(request: NextRequest) { // pg-boss; WEEK / MONTH / YEAR recomputes are enqueued under the // hood. Best-effort — a populator hiccup never fails the user's // write. - await afterMeasurementMutation(user.id, [ - { type: measurement.type, measuredAt: measurement.measuredAt }, - ]); + await afterMeasurementMutation( + user.id, + [{ type: measurement.type, measuredAt: measurement.measuredAt }], + { source: "manual" }, + ); return apiSuccess(shapeMeasurementNotes(measurement), 201); } @@ -1259,10 +1262,11 @@ async function postNamedSleepStage( ).catch(() => {}); void maybeEnqueueMorningRefresh(userId, [segment.end]).catch(() => {}); } - await afterMeasurementMutation(userId, [ - { type, measuredAt: segment.end }, - ...(verdict.dirtyIdentities ?? []), - ]); + await afterMeasurementMutation( + userId, + [{ type, measuredAt: segment.end }, ...(verdict.dirtyIdentities ?? [])], + { source: "manual" }, + ); } return namedSleepStageResponse(verdict.row.id, status, { diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 74c81f181..c1ad8cc72 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -374,12 +374,16 @@ async function postAssessment(request: NextRequest): Promise { // route it through the shared post-mutation tail so the (type, day) // rollup bucket and the cached status assessment converge without // waiting for a nightly discovery pass. - await afterMeasurementMutation(user.id, [ - { - type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType, - measuredAt: when, - }, - ]); + await afterMeasurementMutation( + user.id, + [ + { + type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType, + measuredAt: when, + }, + ], + { source: "manual" }, + ); // v1.27.6 — a screening can be planned as a Vorsorge reminder keyed on // PHQ9_SCORE / GAD7_SCORE. Kick the eventful satisfy worker so completing diff --git a/src/lib/fitbit/sync-core.ts b/src/lib/fitbit/sync-core.ts index d25ac2487..c636b3071 100644 --- a/src/lib/fitbit/sync-core.ts +++ b/src/lib/fitbit/sync-core.ts @@ -900,6 +900,7 @@ export async function upsertFitbitMeasurements( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `fitbit: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/fitbit/sync.ts b/src/lib/fitbit/sync.ts index 449efbff2..84f1f84e5 100644 --- a/src/lib/fitbit/sync.ts +++ b/src/lib/fitbit/sync.ts @@ -129,7 +129,9 @@ export async function syncUserFitbit( // the aggregator's `< to` upper bound covers it. const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000); await recomputeUserRollups(userId, { types, from, to }); - invalidateStatusInsightsForTypes(userId, types).catch((err) => { + invalidateStatusInsightsForTypes(userId, types, { + source: "sync", + }).catch((err) => { getEvent()?.addWarning( `fitbit: status-insight invalidate failed for ${userId}: ${err}`, ); diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index 9bf3bf29c..4940e43ea 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -1606,6 +1606,7 @@ export async function upsertGoogleHealthMeasurements( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `google-health: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/google-health/sync.ts b/src/lib/google-health/sync.ts index 85ba41422..0caff675a 100644 --- a/src/lib/google-health/sync.ts +++ b/src/lib/google-health/sync.ts @@ -370,7 +370,9 @@ async function runGoogleHealthSyncCycle( const from = new Date(sorted[0]!); const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000); await recomputeUserRollups(userId, { types, from, to }); - invalidateStatusInsightsForTypes(userId, types).catch((err) => { + invalidateStatusInsightsForTypes(userId, types, { + source: "sync", + }).catch((err) => { getEvent()?.addWarning( `google-health: status-insight invalidate failed for ${userId}: ${err}`, ); diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index e47f34ee0..08a6a8a51 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -19,6 +19,7 @@ const noteFindMany = vi.fn(); const aiCapabilityForRecord = vi.fn(); const enqueueStatusGeneration = vi.fn(); const userFindUnique = vi.fn(); +const appSettingsFindUnique = vi.fn(); const measurementFindMany = vi.fn(); // v1.42 — type discovery goes through the loose-index-scan helper; the // rows it answers with are the types themselves. @@ -40,6 +41,9 @@ vi.mock("@/lib/db", () => ({ findMany: (...a: unknown[]) => noteFindMany(...a), }, user: { findUnique: (...a: unknown[]) => userFindUnique(...a) }, + appSettings: { + findUnique: (...a: unknown[]) => appSettingsFindUnique(...a), + }, measurement: { findMany: (...a: unknown[]) => measurementFindMany(...a), }, @@ -85,10 +89,19 @@ beforeEach(() => { }); enqueueStatusGeneration.mockResolvedValue(undefined); userFindUnique.mockResolvedValue({ locale: "de" }); + appSettingsFindUnique.mockResolvedValue({ aiSyncRegenGapHours: null }); measurementFindMany.mockResolvedValue([]); measurementGroupBy.mockResolvedValue([]); + delete process.env.AI_SYNC_REGEN_GAP_HOURS; }); +/** How far in the past the freshness probe's cutoff sat, in hours. */ +function probedGapHours(before: number): number { + const where = noteFindMany.mock.calls[0][0].where; + const cutoff = (where.generatedAt.gte as Date).getTime(); + return Math.round((before - cutoff) / (60 * 60 * 1000)); +} + /** Distinct scopes the invalidator enqueued a regenerate for. */ function enqueuedScopes(): string[] { return [ @@ -102,7 +115,9 @@ function enqueuedScopes(): string[] { describe("invalidateStatusInsightsForTypes", () => { it("maps WEIGHT to weight + bmi + general", async () => { - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); expect( enqueueStatusGeneration.mock.calls.every( @@ -112,7 +127,9 @@ describe("invalidateStatusInsightsForTypes", () => { }); it("never deletes the cached assessment rows (v1.16.8 — hash gate decides)", async () => { - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); // The rows stay for stale-while-revalidate AND for the content-hash // gate the worker's forced regeneration runs; deleting them here would // force a full LLM regeneration even for a re-synced unchanged batch. @@ -120,18 +137,20 @@ describe("invalidateStatusInsightsForTypes", () => { }); it("maps both blood-pressure components to blood-pressure + general", async () => { - await invalidateStatusInsightsForTypes("u1", [ - "BLOOD_PRESSURE_SYS", - "BLOOD_PRESSURE_DIA", - ]); + await invalidateStatusInsightsForTypes( + "u1", + ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + { source: "manual" }, + ); expect(enqueuedScopes()).toEqual(["blood-pressure", "general"]); }); it("maps PULSE and RESTING_HEART_RATE to pulse + general (+ the resting-HR generic scope)", async () => { - await invalidateStatusInsightsForTypes("u1", [ - "PULSE", - "RESTING_HEART_RATE", - ]); + await invalidateStatusInsightsForTypes( + "u1", + ["PULSE", "RESTING_HEART_RATE"], + { source: "manual" }, + ); // PULSE feeds the specialised pulse + general scopes; RESTING_HEART_RATE // feeds those too AND carries its own generic assessment card // (`metric:RESTING_HEART_RATE`), so all three are dirtied. @@ -146,16 +165,18 @@ describe("invalidateStatusInsightsForTypes", () => { // BLOOD_GLUCOSE has no specialised assessment, but v1.8.7.1 gives it a // generic `metric:BLOOD_GLUCOSE` card, so a fresh reading dirties the // general overview AND that generic scope. - await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]); + await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); }); it("dedupes scopes across a mixed batch", async () => { - await invalidateStatusInsightsForTypes("u1", [ - "WEIGHT", - "PULSE", - "BLOOD_GLUCOSE", - ]); + await invalidateStatusInsightsForTypes( + "u1", + ["WEIGHT", "PULSE", "BLOOD_GLUCOSE"], + { source: "manual" }, + ); // weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪ // general + metric:BLOOD_GLUCOSE (BLOOD_GLUCOSE) expect(enqueuedScopes()).toEqual([ @@ -168,14 +189,16 @@ describe("invalidateStatusInsightsForTypes", () => { }); it("is a no-op for an empty type set (no DB call)", async () => { - await invalidateStatusInsightsForTypes("u1", []); + await invalidateStatusInsightsForTypes("u1", [], { source: "manual" }); expect(noteFindMany).not.toHaveBeenCalled(); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); it("enqueues nothing while statusText is unavailable for the record", async () => { aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT", "PULSE"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT", "PULSE"], { + source: "manual", + }); expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "statusText"); expect(noteFindMany).not.toHaveBeenCalled(); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); @@ -183,7 +206,9 @@ describe("invalidateStatusInsightsForTypes", () => { it("enqueues a debounced regenerate for every dirtied scope, the user's locale only", async () => { userFindUnique.mockResolvedValue({ locale: "de" }); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); // weight + bmi + general, each warmed once for the user's resolved locale. expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); expect(enqueueStatusGeneration).toHaveBeenCalledTimes(3); @@ -197,7 +222,9 @@ describe("invalidateStatusInsightsForTypes", () => { it("resolves the user's locale (en) for the regenerate, never the unused one", async () => { userFindUnique.mockResolvedValue({ locale: "en" }); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); const locales = new Set( enqueueStatusGeneration.mock.calls.map( (c) => (c[0] as { locale: string }).locale, @@ -210,7 +237,9 @@ describe("invalidateStatusInsightsForTypes", () => { // A steps sample touches only the general overview, never the weight / // pulse / bp scopes — the constant Apple-Health sync must not thrash // every card's cache. - await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"]); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["general", "metric:STEPS"]); }); @@ -219,14 +248,18 @@ describe("invalidateStatusInsightsForTypes", () => { // its own generic assessment card (`metric:BLOOD_GLUCOSE`), so the // freshly-synced metric's card refreshes in the background instead of // lagging until the nightly warm pass. - await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]); + await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); }); it("maps a remapped MeasurementType to its registry metric id", async () => { // ACTIVE_ENERGY_BURNED is stored under that DB type but the generic // metric id (route param + cache scope) is ACTIVE_ENERGY. - await invalidateStatusInsightsForTypes("u1", ["ACTIVE_ENERGY_BURNED"]); + await invalidateStatusInsightsForTypes("u1", ["ACTIVE_ENERGY_BURNED"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["general", "metric:ACTIVE_ENERGY"]); }); @@ -234,7 +267,9 @@ describe("invalidateStatusInsightsForTypes", () => { // A weight sample feeds weight + bmi + general; WEIGHT is one of the // seven specialised metrics and has no generic registry entry, so no // `metric:` scope is enqueued. - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); expect(enqueuedScopes().some((s) => s.startsWith("metric:"))).toBe(false); }); @@ -249,7 +284,9 @@ describe("invalidateStatusInsightsForTypes", () => { // `general` was regenerated within the gap; a fresh WEIGHT sample // must not re-enqueue it. weight + bmi are still stale, so they refresh. noteFindMany.mockResolvedValue([freshRow("general")]); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["bmi", "weight"]); }); @@ -261,7 +298,9 @@ describe("invalidateStatusInsightsForTypes", () => { // this is what lets same-day data be narrated same-day without // reopening the per-batch regeneration storm. noteFindMany.mockResolvedValue([]); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); }); @@ -271,7 +310,9 @@ describe("invalidateStatusInsightsForTypes", () => { freshRow("bmi"), freshRow("general"), ]); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); @@ -280,7 +321,9 @@ describe("invalidateStatusInsightsForTypes", () => { // scope that recently stalled must retry rather than be debounced into // staying cold. The probe asks the database for noted rows only; with // none returned, every dirtied scope refreshes. - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); const where = noteFindMany.mock.calls[0][0].where; expect(where.textEncrypted).toEqual({ not: null }); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); @@ -289,7 +332,9 @@ describe("invalidateStatusInsightsForTypes", () => { it("scopes the freshness probe to the user's resolved locale and a 1 h gap cutoff", async () => { userFindUnique.mockResolvedValue({ locale: "en" }); const before = Date.now(); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); const where = noteFindMany.mock.calls[0][0].where; expect(where.userId).toBe("u1"); // Only the en notes for the dirtied scopes are probed. @@ -387,3 +432,64 @@ describe("enqueueStatusRefillForUser", () => { ]); }); }); + +// #1217 — the gap is chosen by where the batch came from. A watch drips +// syncs all day and every drip moves today's partial bucket, so the content +// hash cannot hold them back; a hand entry still has to be narrated the +// same day. +describe("regeneration gap by ingest source", () => { + it("holds a sync to six hours by default", async () => { + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(6); + }); + + it("keeps a hand entry at one hour", async () => { + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); + expect(probedGapHours(before)).toBe(1); + expect(appSettingsFindUnique).not.toHaveBeenCalled(); + }); + + it("takes the sync gap from AI_SYNC_REGEN_GAP_HOURS when the operator set no value", async () => { + process.env.AI_SYNC_REGEN_GAP_HOURS = "12"; + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(12); + }); + + it("prefers the operator's stored gap over the environment", async () => { + process.env.AI_SYNC_REGEN_GAP_HOURS = "12"; + appSettingsFindUnique.mockResolvedValue({ aiSyncRegenGapHours: 3 }); + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(3); + }); + + it("ignores an out-of-range environment value", async () => { + process.env.AI_SYNC_REGEN_GAP_HOURS = "0"; + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(6); + }); + + it("falls back to the environment when the settings row cannot be read", async () => { + process.env.AI_SYNC_REGEN_GAP_HOURS = "24"; + appSettingsFindUnique.mockRejectedValue(new Error("db down")); + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(24); + }); +}); diff --git a/src/lib/insights/__tests__/score-row.test.ts b/src/lib/insights/__tests__/score-row.test.ts index b21f2b2c9..68b211f84 100644 --- a/src/lib/insights/__tests__/score-row.test.ts +++ b/src/lib/insights/__tests__/score-row.test.ts @@ -131,12 +131,17 @@ describe("upsertScoreRow", () => { now: new Date("2026-06-02T03:00:00Z"), }); - expect(afterMeasurementMutation).toHaveBeenCalledWith("u1", [ - { - type: "STRAIN_SCORE", - measuredAt: new Date("2026-06-01T12:00:00.000Z"), - }, - ]); + expect(afterMeasurementMutation).toHaveBeenCalledWith( + "u1", + [ + { + type: "STRAIN_SCORE", + measuredAt: new Date("2026-06-01T12:00:00.000Z"), + }, + ], + // A computed score is not a hand entry: it waits the sync gap. + { source: "sync", label: "score" }, + ); }); }); diff --git a/src/lib/insights/score-row.ts b/src/lib/insights/score-row.ts index c82b07487..e49d57a31 100644 --- a/src/lib/insights/score-row.ts +++ b/src/lib/insights/score-row.ts @@ -105,9 +105,11 @@ export async function upsertScoreRow( // it through the shared post-mutation tail so the (type, day) rollup // bucket and the cached status assessment converge on the same tick that // wrote the score, instead of waiting for a discovery pass. - await afterMeasurementMutation(args.userId, [ - { type: args.type, measuredAt }, - ]); + await afterMeasurementMutation( + args.userId, + [{ type: args.type, measuredAt }], + { source: "sync", label: "score" }, + ); } /** The tally every score pass returns. */ diff --git a/src/lib/insights/status-invalidation.ts b/src/lib/insights/status-invalidation.ts index b12d59023..aafcc7d47 100644 --- a/src/lib/insights/status-invalidation.ts +++ b/src/lib/insights/status-invalidation.ts @@ -34,39 +34,88 @@ import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; import type { MeasurementType } from "@/generated/prisma/client"; /** - * Minimum gap between a scope's last cached (re)generation and the next - * ingest-driven re-enqueue. A scope whose cached assessment was - * (re)generated within this window is left intact on a fresh measurement - * ingest — not re-enqueued. + * Where a batch of fresh measurements came from, which decides how soon the + * status cards it dirties may regenerate. * - * A constantly-syncing client (Apple Health drips batches every few - * minutes) used to delete + re-enqueue every dirtied scope on every batch, - * and because each delete dropped the cache row the per-(user,scope,locale) - * enqueue singleton (120 s) could not coalesce across batches that arrive - * minutes apart — so STEPS / general / a metric card regenerated several - * times an hour and the assessment felt "regenerated on every visit". This - * window coalesces the storm: a genuinely stale scope (no fresh assessment) - * still refreshes immediately, but a scope refreshed inside the window is - * skipped, so a fresh assessment survives the day's sync drip. - * - * v1.16.8 — first widened from 30 min to 6 h to bound provider spend, but - * a 6 h wall meant same-day data was not narrated same-day: the nightly - * warm at 04:30 restarted the window, so a notable 08:00 reading never - * re-enqueued its scopes and the card showed the pre-reading text until - * the next nightly tick. The window is now a ONE-HOUR minimum gap, and - * the budget role the 6 h wall carried moves to the worker's content-hash - * gate: every ingest that lands past the gap re-enqueues the dirtied - * scopes, the worker's forced run re-gathers the snapshot, and the gate - * (`refreshUnchangedStatusInsight`) turns an unchanged snapshot into a - * timestamp refresh with zero provider cost. So this clock only bounds - * the SQL-gather frequency — at most one worker run per scope per hour - * under a constant sync drip — while provider spend tracks actual data - * change, which is what the gate exists to meter. Comparing the hash - * inline at invalidation time was rejected: it would run the full - * per-scope data gather inside the ingest path, which is exactly the - * work the queue exists to keep off that path. + * - `manual`: a reading the person entered or changed by hand (the web + * form, the phone's entry sheet, a Telegram reply, an MCP write, a + * delete or restore). It should be narrated the same day, so its gap + * stays short. + * - `sync`: rows a device or an integration delivered on its own schedule + * (Apple Health, Health Connect, Withings, Oura, Fitbit, Polar, WHOOP, + * Nightscout, Google Health, a file import, a computed score). A watch + * drips these all day, and every drip moves today's partial bucket, so + * the content-hash gate cannot catch them; only a longer gap does. */ -const INGEST_INVALIDATE_MIN_GAP_MS = 60 * 60 * 1000; +export type StatusIngestSource = "manual" | "sync"; + +/** + * Minimum gap between a scope's last cached (re)generation and the next + * ingest-driven re-enqueue, for a hand entry. A scope whose cached + * assessment was (re)generated within the gap is left intact and not + * re-enqueued. + * + * History: v1.9.0 introduced the window (30 min) because a constantly + * syncing client re-enqueued every dirtied scope on every batch. v1.16.8 + * widened it to 6 h for every ingest, which was rolled back to 1 h because a + * lone 08:00 reading after the 04:30 nightly warm was not narrated the same + * day. The gap is now chosen by source: a hand entry keeps the hour, a sync + * gets {@link DEFAULT_SYNC_REGEN_GAP_HOURS} (or the operator's value). + */ +const MANUAL_INGEST_MIN_GAP_MS = 60 * 60 * 1000; + +/** + * The sync gap when neither the operator's setting + * (`AppSettings.aiSyncRegenGapHours`) nor `AI_SYNC_REGEN_GAP_HOURS` names one. + * Six hours turns a watch's ~16 regenerations per scope and day into ~3. + */ +export const DEFAULT_SYNC_REGEN_GAP_HOURS = 6; + +/** The operator's sync gap bounds, shared with the admin setting. */ +export const SYNC_REGEN_GAP_HOURS_MIN = 1; +export const SYNC_REGEN_GAP_HOURS_MAX = 48; + +/** A gap in hours, or null when the value is not a usable whole hour count. */ +function parseGapHours(value: unknown): number | null { + const n = + typeof value === "number" + ? value + : typeof value === "string" && value.trim() !== "" + ? Number(value) + : NaN; + if (!Number.isInteger(n)) return null; + if (n < SYNC_REGEN_GAP_HOURS_MIN || n > SYNC_REGEN_GAP_HOURS_MAX) return null; + return n; +} + +/** + * The instance's sync gap in hours: the operator's admin setting, else + * `AI_SYNC_REGEN_GAP_HOURS`, else six. An unreadable settings row falls back + * to the environment, never to a shorter gap. + */ +export async function loadSyncRegenGapHours(): Promise { + let stored: number | null = null; + try { + const row = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { aiSyncRegenGapHours: true }, + }); + stored = parseGapHours(row?.aiSyncRegenGapHours); + } catch { + stored = null; + } + return ( + stored ?? + parseGapHours(process.env.AI_SYNC_REGEN_GAP_HOURS) ?? + DEFAULT_SYNC_REGEN_GAP_HOURS + ); +} + +/** The regeneration gap for one ingest, in milliseconds. */ +async function ingestRegenGapMs(source: StatusIngestSource): Promise { + if (source === "manual") return MANUAL_INGEST_MIN_GAP_MS; + return (await loadSyncRegenGapHours()) * 60 * 60 * 1000; +} /** * The seven per-metric assessment scopes. Each generator persists its @@ -130,6 +179,7 @@ function statusScopesForMeasurementType( export async function invalidateStatusInsightsForTypes( userId: string, types: Iterable, + options: { source: StatusIngestSource }, ): Promise { // One ordered set of every scope the batch dirties. The seven specialised // scopes are bare slugs (`weight`, `general`, …); the generic HealthKit @@ -184,14 +234,20 @@ export async function invalidateStatusInsightsForTypes( // only the redundant churn is removed. Past the gap the enqueue always // goes through — the worker's content-hash gate decides whether the // batch actually changed anything (see the constant's doc). - const freshScopes = await findRecentlyWarmedScopes(userId, locale, scopes); + const gapMs = await ingestRegenGapMs(options.source); + const freshScopes = await findRecentlyWarmedScopes( + userId, + locale, + scopes, + gapMs, + ); const staleScopes = Array.from(scopes).filter( (scope) => !freshScopes.has(scope), ); if (staleScopes.length === 0) { annotate({ action: { name: "insights.status.invalidate.debounced" }, - meta: { skipped: scopes.size, refreshed: 0 }, + meta: { skipped: scopes.size, refreshed: 0, source: options.source }, }); return; } @@ -210,7 +266,11 @@ export async function invalidateStatusInsightsForTypes( annotate({ action: { name: "insights.status.invalidate.debounced" }, - meta: { skipped: freshScopes.size, refreshed: staleScopes.length }, + meta: { + skipped: freshScopes.size, + refreshed: staleScopes.length, + source: options.source, + }, }); } @@ -268,7 +328,7 @@ export async function enqueueStatusRefillForUser( /** * v1.9.0 — return the subset of `scopes` whose cached assessment for - * `locale` was generated within `INGEST_INVALIDATE_MIN_GAP_MS` and is a + * `locale` was generated within `gapMs` and is a * real (non-stub) assessment. Those scopes are skipped by the ingest * invalidator so a fresh assessment survives the sync drip. * @@ -281,8 +341,9 @@ async function findRecentlyWarmedScopes( userId: string, locale: SupportedLocale, scopes: ReadonlySet, + gapMs: number, ): Promise> { - const cutoff = new Date(Date.now() - INGEST_INVALIDATE_MIN_GAP_MS); + const cutoff = new Date(Date.now() - gapMs); // Match exactly the cache actions for the candidate scopes in this locale. const candidateActions = Array.from(scopes, (scope) => statusCacheAction(scope, locale), diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index ff5c64e2b..f90e09300 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -605,7 +605,11 @@ export async function handleAppleHealthImport( if (importedTypes.length > 0) { // Registered so the test suite can await the detached handle. trackBackgroundTask( - invalidateStatusInsightsForTypes(userId, importedTypes).catch((err) => { + invalidateStatusInsightsForTypes(userId, importedTypes, { + // An export file the person chose to import, narrated like any + // other import rather than held to the sync gap. + source: "manual", + }).catch((err) => { console.warn( `[apple-health-import] status-insight invalidate failed for user ${userId}`, err, diff --git a/src/lib/jobs/health-connect-import-worker.ts b/src/lib/jobs/health-connect-import-worker.ts index 58ba0c6db..b4928e022 100644 --- a/src/lib/jobs/health-connect-import-worker.ts +++ b/src/lib/jobs/health-connect-import-worker.ts @@ -228,7 +228,11 @@ export async function runHealthConnectImport( .map(([type]) => type as MeasurementType); if (touched.length > 0) { trackBackgroundTask( - invalidateStatusInsightsForTypes(userId, touched).catch((err) => { + invalidateStatusInsightsForTypes(userId, touched, { + // An export file the person chose to import, like the Apple Health + // import: narrated like a hand entry, not held to the sync gap. + source: "manual", + }).catch((err) => { console.warn( `[health-connect-import] status-insight invalidate failed for user ${userId}`, err, diff --git a/src/lib/mcp/__tests__/writes.test.ts b/src/lib/mcp/__tests__/writes.test.ts index a762f1f4c..9d5445c9f 100644 --- a/src/lib/mcp/__tests__/writes.test.ts +++ b/src/lib/mcp/__tests__/writes.test.ts @@ -478,7 +478,7 @@ describe("MCP writes — post-write tail", () => { expect(afterMeasurementMutation).toHaveBeenCalledWith( "u-1", [expect.objectContaining({ type: "WEIGHT" })], - "mcp", + { source: "manual", label: "mcp" }, ); }); @@ -495,7 +495,7 @@ describe("MCP writes — post-write tail", () => { expect.objectContaining({ type: "BLOOD_PRESSURE_SYS" }), expect.objectContaining({ type: "BLOOD_PRESSURE_DIA" }), ], - "mcp", + { source: "manual", label: "mcp" }, ); }); }); diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts index 49e3365bd..196f0b291 100644 --- a/src/lib/mcp/writes.ts +++ b/src/lib/mcp/writes.ts @@ -355,7 +355,7 @@ export async function logMcpMeasurement(input: { await afterMeasurementMutation( input.userId, [{ type: input.type, measuredAt }], - "mcp", + { source: "manual", label: "mcp" }, ); await auditLog("mcp.write.measurement", { @@ -529,7 +529,7 @@ export async function logMcpBloodPressure(input: { { type: "BLOOD_PRESSURE_SYS", measuredAt }, { type: "BLOOD_PRESSURE_DIA", measuredAt }, ], - "mcp", + { source: "manual", label: "mcp" }, ); await auditLog("mcp.write.blood_pressure", { diff --git a/src/lib/measurements/__tests__/create-from-telegram.test.ts b/src/lib/measurements/__tests__/create-from-telegram.test.ts index 54fcbd717..b9a98ff09 100644 --- a/src/lib/measurements/__tests__/create-from-telegram.test.ts +++ b/src/lib/measurements/__tests__/create-from-telegram.test.ts @@ -96,7 +96,7 @@ describe("logTelegramMeasurement", () => { expect(afterMeasurementMutation).toHaveBeenCalledWith( "user-1", [expect.objectContaining({ type: "WEIGHT" })], - "telegram-measurement", + { source: "manual", label: "telegram-measurement" }, ); }); diff --git a/src/lib/measurements/create-from-telegram.ts b/src/lib/measurements/create-from-telegram.ts index 9bbe84543..d2f08a71c 100644 --- a/src/lib/measurements/create-from-telegram.ts +++ b/src/lib/measurements/create-from-telegram.ts @@ -162,7 +162,7 @@ export async function logTelegramMeasurement(input: { await afterMeasurementMutation( input.userId, [{ type: input.type, measuredAt }], - "telegram-measurement", + { source: "manual", label: "telegram-measurement" }, ); return { status: "ok" }; diff --git a/src/lib/measurements/repair-apple-health-distance.ts b/src/lib/measurements/repair-apple-health-distance.ts index 9b0178cbf..7a9881da9 100644 --- a/src/lib/measurements/repair-apple-health-distance.ts +++ b/src/lib/measurements/repair-apple-health-distance.ts @@ -364,7 +364,7 @@ export async function applyAppleHealthDistanceRepair( type: REPAIRED_TYPE, measuredAt: row.measuredAt, })), - "repair-apple-health-distance", + { source: "sync", label: "repair-apple-health-distance" }, ); await client.auditLog.update({ where: { id: auditId }, diff --git a/src/lib/nightscout/sync.ts b/src/lib/nightscout/sync.ts index 3af82a725..c1c0edc92 100644 --- a/src/lib/nightscout/sync.ts +++ b/src/lib/nightscout/sync.ts @@ -333,6 +333,7 @@ export async function upsertNightscoutEntries( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `nightscout: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 51a785ef3..855646c89 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -573,7 +573,10 @@ export async function retireOuraSleepRecords( where: { userId, id: { in: rows.map((r) => r.id) }, deletedAt: null }, data: { deletedAt: new Date() }, }); - await afterMeasurementMutation(userId, rows, "oura"); + await afterMeasurementMutation(userId, rows, { + source: "sync", + label: "oura", + }); invalidateUserMeasurements(userId); annotate({ meta: { oura_sleep_retired_rows: res.count } }); return res.count; @@ -808,6 +811,7 @@ export async function upsertOuraMeasurements( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `oura: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index 23db25dbf..6cd575dd0 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -404,6 +404,7 @@ export async function upsertPolarMeasurements( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `polar: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/rollups/__tests__/after-measurement-mutation.test.ts b/src/lib/rollups/__tests__/after-measurement-mutation.test.ts index 6a983e08e..681356457 100644 --- a/src/lib/rollups/__tests__/after-measurement-mutation.test.ts +++ b/src/lib/rollups/__tests__/after-measurement-mutation.test.ts @@ -47,20 +47,24 @@ const day = (iso: string) => new Date(iso); describe("afterMeasurementMutation", () => { it("recomputes one bucket per distinct (type, day) and invalidates each type once", async () => { - await afterMeasurementMutation("user-1", [ - { - type: "WEIGHT" as MeasurementType, - measuredAt: day("2026-05-10T08:00:00Z"), - }, - { - type: "WEIGHT" as MeasurementType, - measuredAt: day("2026-05-10T21:00:00Z"), - }, - { - type: "PULSE" as MeasurementType, - measuredAt: day("2026-05-10T09:00:00Z"), - }, - ]); + await afterMeasurementMutation( + "user-1", + [ + { + type: "WEIGHT" as MeasurementType, + measuredAt: day("2026-05-10T08:00:00Z"), + }, + { + type: "WEIGHT" as MeasurementType, + measuredAt: day("2026-05-10T21:00:00Z"), + }, + { + type: "PULSE" as MeasurementType, + measuredAt: day("2026-05-10T09:00:00Z"), + }, + ], + { source: "sync" }, + ); // Two rows on the same (WEIGHT, day) collapse into one recompute. expect(mocks.recompute).toHaveBeenCalledTimes(2); @@ -76,13 +80,15 @@ describe("afterMeasurementMutation", () => { ); expect(mocks.invalidate).toHaveBeenCalledTimes(1); - const [userId, types] = mocks.invalidate.mock.calls[0]; + const [userId, types, options] = mocks.invalidate.mock.calls[0]; expect(userId).toBe("user-1"); expect([...types].sort()).toEqual(["PULSE", "WEIGHT"]); + // The caller's source reaches the invalidator, which picks the gap by it. + expect(options).toEqual({ source: "sync" }); }); it("is a no-op for an empty row set", async () => { - await afterMeasurementMutation("user-1", []); + await afterMeasurementMutation("user-1", [], { source: "manual" }); expect(mocks.recompute).not.toHaveBeenCalled(); expect(mocks.invalidate).not.toHaveBeenCalled(); }); @@ -99,7 +105,7 @@ describe("afterMeasurementMutation", () => { measuredAt: day("2026-05-10T08:00:00Z"), }, ], - "test.surface", + { source: "manual", label: "test.surface" }, ); } finally { warn.mockRestore(); @@ -113,12 +119,16 @@ describe("afterMeasurementMutation", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { await expect( - afterMeasurementMutation("user-1", [ - { - type: "WEIGHT" as MeasurementType, - measuredAt: day("2026-05-10T08:00:00Z"), - }, - ]), + afterMeasurementMutation( + "user-1", + [ + { + type: "WEIGHT" as MeasurementType, + measuredAt: day("2026-05-10T08:00:00Z"), + }, + ], + { source: "manual" }, + ), ).resolves.toBeUndefined(); // Let the fire-and-forget rejection settle inside the helper's catch. await new Promise((resolve) => setImmediate(resolve)); diff --git a/src/lib/rollups/after-measurement-mutation.ts b/src/lib/rollups/after-measurement-mutation.ts index d4ff65816..f12c17160 100644 --- a/src/lib/rollups/after-measurement-mutation.ts +++ b/src/lib/rollups/after-measurement-mutation.ts @@ -22,16 +22,28 @@ import { collapseToTypeDayKeys, recomputeBucketsForMeasurement, } from "@/lib/rollups/measurement-rollups"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation"; +import { + invalidateStatusInsightsForTypes, + type StatusIngestSource, +} from "@/lib/insights/status-invalidation"; import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import type { MeasurementType } from "@/generated/prisma/client"; export async function afterMeasurementMutation( userId: string, rows: Array<{ type: MeasurementType; measuredAt: Date }>, - /** Log prefix identifying the calling surface in the warn lines. */ - label = "measurements", + options: { + /** + * Where the rows came from: a hand entry (`manual`) or a device or + * integration (`sync`). Decides how soon the status cards they dirty may + * regenerate (`StatusIngestSource`). + */ + source: StatusIngestSource; + /** Log prefix identifying the calling surface in the warn lines. */ + label?: string; + }, ): Promise { + const label = options.label ?? "measurements"; if (rows.length === 0) return; // Collapse to the distinct `(type, day)` set BEFORE recomputing so a @@ -53,7 +65,9 @@ export async function afterMeasurementMutation( // write on a detached handle, and one that lands while a vitest worker is // closing its RPC channel fails the whole run. No-op outside test. trackBackgroundTask( - invalidateStatusInsightsForTypes(userId, types).catch((err) => { + invalidateStatusInsightsForTypes(userId, types, { + source: options.source, + }).catch((err) => { console.warn(`[${label}] status-insight invalidate failed`, err); }), ); diff --git a/src/lib/whoop/__tests__/sync-body.test.ts b/src/lib/whoop/__tests__/sync-body.test.ts index 2002fd38b..4237ff0fc 100644 --- a/src/lib/whoop/__tests__/sync-body.test.ts +++ b/src/lib/whoop/__tests__/sync-body.test.ts @@ -149,7 +149,7 @@ describe("syncUserBody — profile weight is not ingested (v1.16.11)", () => { expect(afterMeasurementMutation).toHaveBeenCalledWith( "user1", [legacyRow], - "whoop-body", + { source: "sync", label: "whoop-body" }, ); expect(prismaMock.measurement.deleteMany).toHaveBeenCalledWith({ where: { diff --git a/src/lib/whoop/__tests__/webhook-notification.test.ts b/src/lib/whoop/__tests__/webhook-notification.test.ts index 2e9db8f19..f118932bb 100644 --- a/src/lib/whoop/__tests__/webhook-notification.test.ts +++ b/src/lib/whoop/__tests__/webhook-notification.test.ts @@ -144,7 +144,7 @@ describe("processWhoopNotification — fetch-by-id enqueue", () => { expect(afterMutation).toHaveBeenCalledWith( "user-1", [{ type: "SLEEP_DURATION", measuredAt }], - "whoop.webhook", + { source: "sync", label: "whoop.webhook" }, ); }); }); diff --git a/src/lib/whoop/sync-body.ts b/src/lib/whoop/sync-body.ts index 2600b2687..5a3ca040a 100644 --- a/src/lib/whoop/sync-body.ts +++ b/src/lib/whoop/sync-body.ts @@ -78,7 +78,10 @@ export async function syncUserBody( }); if (legacy.length > 0) { await prisma.measurement.deleteMany({ where: legacyWhere }); - await afterMeasurementMutation(userId, legacy, "whoop-body"); + await afterMeasurementMutation(userId, legacy, { + source: "sync", + label: "whoop-body", + }); } } catch (err) { getEvent()?.addWarning( diff --git a/src/lib/whoop/sync-core.ts b/src/lib/whoop/sync-core.ts index 464ab6718..bb6da4b42 100644 --- a/src/lib/whoop/sync-core.ts +++ b/src/lib/whoop/sync-core.ts @@ -421,13 +421,13 @@ export async function upsertWhoopMeasurements( const invalidateTouchedTypes = (): void => { if (touchedTypes.size === 0) return; - void invalidateStatusInsightsForTypes(userId, [...touchedTypes]).catch( - (err) => { - getEvent()?.addWarning( - `whoop: status-insight invalidate failed for ${userId}: ${err}`, - ); - }, - ); + void invalidateStatusInsightsForTypes(userId, [...touchedTypes], { + source: "sync", + }).catch((err) => { + getEvent()?.addWarning( + `whoop: status-insight invalidate failed for ${userId}: ${err}`, + ); + }); }; for ( diff --git a/src/lib/whoop/webhook-handler.ts b/src/lib/whoop/webhook-handler.ts index 55790ef77..6fe2cb8a5 100644 --- a/src/lib/whoop/webhook-handler.ts +++ b/src/lib/whoop/webhook-handler.ts @@ -197,7 +197,7 @@ export async function processWhoopNotification( type: row.type as MeasurementType, measuredAt: row.measuredAt, })), - "whoop.webhook", + { source: "sync", label: "whoop.webhook" }, ); if (resource === "workout") { // The Workout model carries no soft-delete column, so a deleted diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts index 49a92cc29..1f2284f06 100644 --- a/src/lib/withings/sync-activity.ts +++ b/src/lib/withings/sync-activity.ts @@ -550,6 +550,7 @@ export async function syncUserActivity( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `withings activity: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/withings/sync-ecg.ts b/src/lib/withings/sync-ecg.ts index 0e6812e46..4721853fe 100644 --- a/src/lib/withings/sync-ecg.ts +++ b/src/lib/withings/sync-ecg.ts @@ -528,6 +528,7 @@ export async function syncUserEcg( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `withings ecg: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 1f8719950..252332887 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -638,6 +638,7 @@ export async function syncUserSleep( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `withings sleep: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 10bdc24b3..af4525e23 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -457,6 +457,7 @@ export async function syncUserMeasurements( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `withings: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/tests/integration/sharing-provider-origin.test.ts b/tests/integration/sharing-provider-origin.test.ts index e764a2002..d433804e9 100644 --- a/tests/integration/sharing-provider-origin.test.ts +++ b/tests/integration/sharing-provider-origin.test.ts @@ -126,7 +126,9 @@ async function grantManage(input: { const enqueueFromSharedMutation: (request: NextRequest) => Promise = apiHandler(async () => { const { user } = await requireRecordAuth("manage", "medications"); - await invalidateStatusInsightsForTypes(user.id, ["WEIGHT"]); + await invalidateStatusInsightsForTypes(user.id, ["WEIGHT"], { + source: "manual", + }); return NextResponse.json({ data: { queued: true }, error: null }); }); diff --git a/tests/integration/summaries-pre-window-buckets.test.ts b/tests/integration/summaries-pre-window-buckets.test.ts index e8c95dea0..7c02ac22b 100644 --- a/tests/integration/summaries-pre-window-buckets.test.ts +++ b/tests/integration/summaries-pre-window-buckets.test.ts @@ -116,9 +116,11 @@ describe("all-time summaries with DAY buckets older than the fold window", () => await recomputeUserRollups(userId); // A backdated reading, as a manual entry or a sync of old samples writes // it: the hook folds that one day, window or not. - await afterMeasurementMutation(userId, [ - { type: "WEIGHT", measuredAt: old.at }, - ]); + await afterMeasurementMutation( + userId, + [{ type: "WEIGHT", measuredAt: old.at }], + { source: "manual" }, + ); await expectTrueFigures(userId, [64, 80]); });