diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index 9fe03980b..c6c38c9e8 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -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); }); }); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index dd5203b33..aaa54229b 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -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, ): Promise { const scopes = new Set(); + // 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:` 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 { diff --git a/src/lib/insights/metric-status-registry.ts b/src/lib/insights/metric-status-registry.ts index 9484de5cb..eac071d10 100644 --- a/src/lib/insights/metric-status-registry.ts +++ b/src/lib/insights/metric-status-registry.ts @@ -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:` 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