feat(insights): choose the status regeneration gap by ingest source

A sync from a watch or an integration now waits the instance's sync gap
(AppSettings.aiSyncRegenGapHours, else AI_SYNC_REGEN_GAP_HOURS, else 6 h)
before it may regenerate a status card again; a hand entry keeps the hour.
Every caller of afterMeasurementMutation and invalidateStatusInsightsForTypes
names its source, so a new ingest path cannot skip the choice.

Refs #1217
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 22:49:00 +02:00
1 parent f75a2fe343
commit ae8b614dd0
47 files changed
+452 -165

No files matched your search

+4 -1
View File
@@ -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", {
+6 -1
View File
@@ -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: {
+13 -7
View File
@@ -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 });
},
+6 -1
View File
@@ -1209,7 +1209,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
// 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
@@ -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 () => {
@@ -123,7 +123,7 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
type: row.type as MeasurementType,
measuredAt: row.measuredAt,
})),
"measurements bulk-delete",
{ source: "manual", label: "measurements bulk-delete" },
);
}
@@ -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 () => {
@@ -169,7 +169,7 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
// 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 });
@@ -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 () => {
+1 -1
View File
@@ -122,7 +122,7 @@ async function postRestore(request: NextRequest): Promise<Response> {
type: row.type as MeasurementType,
measuredAt: row.measuredAt,
})),
"measurements restore",
{ source: "manual", label: "measurements restore" },
);
}
+11 -7
View File
@@ -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, {
+10 -6
View File
@@ -374,12 +374,16 @@ async function postAssessment(request: NextRequest): Promise<Response> {
// 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