mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
1 parent
f75a2fe343
commit
ae8b614dd0
47 files changed
+452
-165
No files matched your search
@@ -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", {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user