mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(insights): re-warm generic metric assessments on measurement ingest
The ingest invalidation path mapped a fresh measurement only to the seven specialised assessment scopes, so a freshly-synced HealthKit metric's generic assessment card lagged its real-time data until the nightly warm pass — an asymmetry against the near-real-time specialised cards. Add a reverse `MeasurementType → metric id` index to the registry and extend `invalidateStatusInsightsForTypes` so a reading of a type that backs a registered generic metric also evicts and re-enqueues the matching `metric:<ID>` scope, using the same single-locale, singleton-debounced enqueue as the specialised scopes. Unregistered types and the seven specialised metrics resolve to no generic scope, so the constant sync cannot fan out to unwanted cards. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 9bc0f116b46950c8ffa79d127d5dcce32ea644a4)
This commit is contained in:
1 parent
bd8a4e7990
commit
746e002104
3 files changed
+110
-11
No files matched your search
@@ -69,17 +69,27 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
expect(deletedScopes()).toEqual(["blood-pressure", "general"]);
|
||||
});
|
||||
|
||||
it("maps PULSE and RESTING_HEART_RATE to pulse + general", async () => {
|
||||
it("maps PULSE and RESTING_HEART_RATE to pulse + general (+ the resting-HR generic scope)", async () => {
|
||||
await invalidateStatusInsightsForTypes("u1", [
|
||||
"PULSE",
|
||||
"RESTING_HEART_RATE",
|
||||
]);
|
||||
expect(deletedScopes()).toEqual(["general", "pulse"]);
|
||||
// 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.
|
||||
expect(deletedScopes()).toEqual([
|
||||
"general",
|
||||
"metric:RESTING_HEART_RATE",
|
||||
"pulse",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps an unmapped metric to general only", async () => {
|
||||
it("maps a metric with no specialised scope to general + its generic scope", async () => {
|
||||
// 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"]);
|
||||
expect(deletedScopes()).toEqual(["general"]);
|
||||
expect(deletedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]);
|
||||
});
|
||||
|
||||
it("dedupes scopes across a mixed batch", async () => {
|
||||
@@ -88,10 +98,12 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
]);
|
||||
// weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪ general
|
||||
// weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪
|
||||
// general + metric:BLOOD_GLUCOSE (BLOOD_GLUCOSE)
|
||||
expect(deletedScopes()).toEqual([
|
||||
"bmi",
|
||||
"general",
|
||||
"metric:BLOOD_GLUCOSE",
|
||||
"pulse",
|
||||
"weight",
|
||||
]);
|
||||
@@ -133,7 +145,35 @@ describe("invalidateStatusInsightsForTypes", () => {
|
||||
// pulse / bp scopes — the constant Apple-Health sync must not thrash
|
||||
// every card's cache.
|
||||
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"]);
|
||||
expect(deletedScopes()).toEqual(["general"]);
|
||||
expect(enqueuedScopes()).toEqual(["general"]);
|
||||
expect(deletedScopes()).toEqual(["general", "metric:STEPS"]);
|
||||
expect(enqueuedScopes()).toEqual(["general", "metric:STEPS"]);
|
||||
});
|
||||
|
||||
it("re-warms the matching generic metric scope for a registered type", async () => {
|
||||
// v1.8.7.1 — a blood-glucose sample dirties the general overview AND
|
||||
// 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"]);
|
||||
expect(deletedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]);
|
||||
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"]);
|
||||
expect(enqueuedScopes()).toEqual(["general", "metric:ACTIVE_ENERGY"]);
|
||||
});
|
||||
|
||||
it("does not emit a generic scope for an unregistered type", async () => {
|
||||
// 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"]);
|
||||
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
|
||||
expect(
|
||||
enqueuedScopes().some((s) => s.startsWith("metric:")),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,10 @@ import {
|
||||
import { invalidateUserInsights } from "@/lib/cache/invalidate";
|
||||
import { enqueueStatusGeneration } from "@/lib/jobs/insight-status-generate-shared";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
import {
|
||||
metricIdForMeasurementType,
|
||||
metricStatusScope,
|
||||
} from "@/lib/insights/metric-status-registry";
|
||||
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -218,19 +222,37 @@ export async function invalidateStatusInsightsForTypes(
|
||||
types: Iterable<MeasurementType>,
|
||||
): Promise<void> {
|
||||
const scopes = new Set<PerStatusScope>();
|
||||
// v1.8.7.1 — a fresh measurement of a type that backs a generic
|
||||
// HealthKit assessment card (the ~30 registry metrics) also dirties that
|
||||
// card's `metric:<ID>` scope. Collect those alongside the seven
|
||||
// specialised scopes so the generic tier is invalidated + re-warmed on
|
||||
// ingest with the same debounced enqueue, rather than lagging until the
|
||||
// nightly cron. Only registered, data-bearing types map here; the seven
|
||||
// specialised metrics and any unregistered type resolve to null and are
|
||||
// skipped, so the constant sync cannot fan out to unwanted scopes.
|
||||
const metricScopes = new Set<`metric:${string}`>();
|
||||
for (const type of types) {
|
||||
for (const scope of statusScopesForMeasurementType(type)) {
|
||||
scopes.add(scope);
|
||||
}
|
||||
const metricId = metricIdForMeasurementType(type);
|
||||
if (metricId) {
|
||||
metricScopes.add(metricStatusScope(metricId));
|
||||
}
|
||||
}
|
||||
if (scopes.size === 0) return;
|
||||
if (scopes.size === 0 && metricScopes.size === 0) return;
|
||||
|
||||
await prisma.auditLog.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
OR: Array.from(scopes, (scope) => ({
|
||||
action: { startsWith: `insights.${scope}-status.` },
|
||||
})),
|
||||
OR: [
|
||||
...Array.from(scopes, (scope) => ({
|
||||
action: { startsWith: `insights.${scope}-status.` },
|
||||
})),
|
||||
...Array.from(metricScopes, (scope) => ({
|
||||
action: { startsWith: `insights.${scope}-status.` },
|
||||
})),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -258,6 +280,13 @@ export async function invalidateStatusInsightsForTypes(
|
||||
for (const scope of scopes) {
|
||||
void enqueueStatusGeneration({ userId, metric: scope, locale });
|
||||
}
|
||||
// v1.8.7.1 — re-warm the matching generic metric scopes the same way, so
|
||||
// a freshly-synced HealthKit metric's assessment card refreshes in the
|
||||
// background instead of lagging until the nightly warm pass. Same
|
||||
// single-locale, singleton-debounced enqueue as the seven scopes above.
|
||||
for (const scope of metricScopes) {
|
||||
void enqueueStatusGeneration({ userId, metric: scope, locale });
|
||||
}
|
||||
}
|
||||
|
||||
interface GenerateOptions {
|
||||
|
||||
@@ -421,6 +421,36 @@ export function getMetricStatusMeta(
|
||||
return isMetricStatusId(metric) ? REGISTRY[metric] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse index: the DB `MeasurementType` → the generic metric id it backs
|
||||
* (built once at module load). A handful of types map under a different id
|
||||
* than their literal name (`ACTIVITY_STEPS` → `STEPS`,
|
||||
* `ACTIVE_ENERGY_BURNED` → `ACTIVE_ENERGY`); every other registered type
|
||||
* is its own id. Types with no registry entry (the seven specialised
|
||||
* metrics, WORKOUTS, …) are simply absent.
|
||||
*/
|
||||
const MEASUREMENT_TYPE_TO_METRIC_ID = new Map<
|
||||
MeasurementType,
|
||||
MetricStatusMetricId
|
||||
>(
|
||||
(Object.values(REGISTRY) as MetricStatusMeta[]).map((meta) => [
|
||||
meta.measurementType,
|
||||
meta.id,
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Resolve the generic metric id a `MeasurementType` feeds, or null when
|
||||
* the type carries no generic assessment card. Used by the ingest
|
||||
* invalidation path to re-warm the matching `metric:<ID>` scope when a
|
||||
* fresh measurement of a data-bearing type lands.
|
||||
*/
|
||||
export function metricIdForMeasurementType(
|
||||
type: MeasurementType,
|
||||
): MetricStatusMetricId | null {
|
||||
return MEASUREMENT_TYPE_TO_METRIC_ID.get(type) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cache/scope id for a metric assessment. The `metric:` prefix keeps
|
||||
* the generic scopes disjoint from the seven bare specialised scope
|
||||
|
||||
Reference in new issue
Block a user