fix(insights): gate the tab strip on one presence read

The tab strip on every Insights page read the whole overview (22 KB),
a workouts page, the ECG list and the analytics summaries to learn which
pills to show. GET /api/insights/presence answers just that, for the
record on screen: the reading count per type after the record's module
switches, and whether it has mood entries in the last 90 days, a tracked
scheduled medication, workouts and ECG recordings. A flag whose page a
switched-off module hides is false and its rows are not read. The strip
now reads this and the saved layout; each page keeps loading its own data.
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 12:56:16 +01:00
1 parent 05b5eb6942
commit 367fcd5332
12 files changed
+497 -129

No files matched your search

@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2876,
"total": 9172,
"total": 9164,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -982,7 +982,7 @@
"components/insights/hero-strip.tsx": 3,
"components/insights/insight-status-card.tsx": 12,
"components/insights/insights-edit-mode.tsx": 5,
"components/insights/insights-layout-shell.tsx": 11,
"components/insights/insights-layout-shell.tsx": 3,
"components/insights/insights-tab-strip.tsx": 35,
"components/insights/intraday-pulse-chart.tsx": 1,
"components/insights/labs-changes-card.tsx": 1,
@@ -136,11 +136,6 @@ const INVENTORY: Readonly<Record<string, Poll>> = {
cadence:
"25 s, nextAdvisorPollInterval, attempts from the response timeout so the warm can land (advisor); 10 while settling",
},
"components/insights/insights-layout-shell.tsx": {
sites: 1,
shape: "bounded",
cadence: "25 s, nextAdvisorPollInterval, 10 attempts (briefing body)",
},
};
const SITE = /\brefetchInterval\s*:/g;
+13 -1
View File
@@ -1511,6 +1511,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "record",
why: "The heaviest SQL aggregation in the product, cached per record; no provider on the path. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/insights/presence/route.ts": {
domain: "record",
why: "Which Insights pages have data: reading counts per type and four presence flags, the gate of the tab strip over the overview. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/insights/correlations/route.ts": {
domain: "record",
why: "Correlations over the record's own series. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
@@ -2049,6 +2053,11 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
conditions: [],
why: "The heaviest SQL aggregation in the product, cached per record; no provider on the path.",
},
"app/api/insights/presence/route.ts": {
domain: "record",
conditions: [],
why: "Counts and flags only, no values, from the cells the overview and the summaries slice already serve; no provider on the path.",
},
"app/api/insights/correlations/route.ts": {
domain: "record",
conditions: [],
@@ -2304,8 +2313,11 @@ const ACTOR_ROUTES: Record<string, string> = {
* list: a standing credential to a third-party calendar is the owner's to
* make, or a managed profile's guardian's, never an adult record's delegate's.
* 276 -> 278.
*
* The Insights presence read joins the record list and the manage literal:
* the tab strip's gate over the overview, counts and flags only. 278 -> 280.
*/
const FROZEN_ENTRY_COUNT = 278;
const FROZEN_ENTRY_COUNT = 280;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
+97
View File
@@ -0,0 +1,97 @@
/**
* GET /api/insights/presence — which Insights pages have something to show.
*
* The tab strip on every Insights page gates its pills on this answer alone:
* the reading count per measurement type, and whether the record has mood
* entries, a scheduled medication, workouts and ECG recordings. The counts are
* the summaries slice's cell, after the record's module switches; mood and
* medication mean what the overview means by them. A flag whose page a
* switched-off module hides (`src/lib/modules/surface.ts`) is false, and its
* rows are not read.
*
* No AI gate: every field is computed from the record. MANAGE-level read over
* the whole record, like the overview the strip sits on.
*/
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { readSummariesSlice } from "@/lib/analytics/summaries-slice";
import { gateSummariesByModules } from "@/lib/dashboard/widget-modules";
import { resolveModuleMap } from "@/lib/modules/gate";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
export const dynamic = "force-dynamic";
/** The overview's mood window: a mood pill needs an entry inside it. */
const MOOD_WINDOW_MS = 90 * 24 * 60 * 60 * 1000;
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("manage", "record");
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
return apiError("Too many analytics requests. Please retry later.", 429);
}
const modules = await resolveModuleMap(user.id);
const shows = (slug: string) =>
isSurfaceVisible(`insights-page:${slug}`, modules);
const userId = user.id;
const [slice, mood, medication, workout, ecg] = await Promise.all([
readSummariesSlice(userId, { serveStale: true }),
shows("mood")
? prisma.moodEntry.findFirst({
where: {
userId,
deletedAt: null,
moodLoggedAt: { gte: new Date(Date.now() - MOOD_WINDOW_MS) },
},
select: { id: true },
})
: null,
// A medication with no schedule, an as-needed one or one whose intake is
// not tracked expects no dose, so the overview lists no rate for it.
shows("medications")
? prisma.medication.findFirst({
where: {
userId,
active: true,
asNeeded: false,
...TRACKED_INTAKE_WHERE,
schedules: { some: {} },
},
select: { id: true },
})
: null,
shows("workouts")
? prisma.workout.findFirst({ where: { userId }, select: { id: true } })
: null,
prisma.ecgRecording.findFirst({ where: { userId }, select: { id: true } }),
]);
const { summaries } = gateSummariesByModules(
slice.summaries,
slice.lastSeenByType,
modules,
);
const metrics: Record<string, { count: number }> = {};
for (const [type, summary] of Object.entries(summaries)) {
if (summary.count > 0) metrics[type] = { count: summary.count };
}
annotate({
action: { name: "insights.presence.read" },
meta: { metrics: Object.keys(metrics).length },
});
return apiSuccess({
metrics,
hasMood: mood !== null,
hasMedication: medication !== null,
hasWorkouts: workout !== null,
hasEcgRecordings: ecg !== null,
});
});
@@ -6,6 +6,10 @@ import { renderToStaticMarkup } from "react-dom/server";
* on the tab strip but shows no briefing, so it must not read the briefing
* on entry: on a metric page that read was a request (and the schema chunk
* that parses its answer) for a button. The button itself stays.
*
* The strip's pills are gated on one small presence read and the saved
* layout. The overview read, a workouts page and the ECG list it used to
* send on every page for four flags are gone.
*/
const queries: Array<{ queryKey: readonly unknown[]; enabled?: boolean }> = [];
@@ -29,23 +33,12 @@ vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: () => ({ available: true }),
useAiProviderState: () => ({ responseTimeoutMs: null }),
}));
vi.mock("@/hooks/use-surface-visible", () => ({
useSurfaceVisible: () => true,
}));
vi.mock("@/hooks/use-workouts", () => ({
useWorkouts: () => ({ data: undefined }),
}));
vi.mock("@/lib/queries/use-analytics-query", () => ({
useAnalyticsQuery: () => ({ data: undefined }),
}));
const layoutReads: unknown[] = [];
vi.mock("@/hooks/use-insights-layout", () => ({
useInsightsLayoutQuery: () => ({
layout: { tiles: [] },
isSuccess: false,
}),
}));
vi.mock("@/components/insights/use-ecg-list", () => ({
useEcgList: () => ({ data: undefined }),
useInsightsLayoutQuery: (enabled: unknown) => {
layoutReads.push(enabled);
return { layout: { tiles: [] }, isSuccess: false };
},
}));
vi.mock("@/components/insights/insights-tab-strip", () => ({
InsightsTabStrip: (props: { onRegenerate?: unknown }) => {
@@ -73,4 +66,20 @@ describe("InsightsLayoutShell — what it reads on entry", () => {
expect(advisor[0].enabled).toBe(false);
expect(typeof stripProps[0]?.onRegenerate).toBe("function");
});
it("reads the presence answer and the layout, nothing else", () => {
queries.length = 0;
layoutReads.length = 0;
renderToStaticMarkup(
<InsightsLayoutShell>
<div />
</InsightsLayoutShell>,
);
const sent = queries
.filter((q) => q.enabled !== false)
.map((q) => JSON.stringify(q.queryKey));
expect(sent).toEqual([JSON.stringify(queryKeys.insightsPresence())]);
expect(layoutReads).toEqual([true]);
});
});
@@ -6,20 +6,12 @@ import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { useSurfaceVisible } from "@/hooks/use-surface-visible";
import { useWorkouts } from "@/hooks/use-workouts";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
import {
nextAdvisorPollInterval,
useInsightsAdvisorQuery,
} from "@/components/insights/use-insights-advisor";
import { pollAttemptCount } from "@/lib/queries/refetch-interval";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { queryKeys } from "@/lib/query-keys";
import type { InsightInputs } from "@/lib/insights/metric-availability";
import { apiGet } from "@/lib/api/api-fetch";
import { useEcgList } from "@/components/insights/use-ecg-list";
/**
* v1.4.25 — client shell for `src/app/insights/layout.tsx`.
@@ -31,26 +23,22 @@ import { useEcgList } from "@/components/insights/use-ecg-list";
* page used to send the briefing read (and load the schema chunk that
* parses it) for a button.
*
* v1.4.27 — also owns the analytics + comprehensive reads that
* power the tab-strip availability gate. The analytics consumer goes
* through the shared `useAnalyticsQuery({ slice: "summaries" })` hook
* (v1.4.33 IW2); the comprehensive consumer still owns its bespoke
* fetch but shares its cache key with the sub-page consumers so the
* payload lands once per route, dedup-shared across consumers.
* The tab-strip gate reads one small answer, `/api/insights/presence`
* (reading counts per type, and whether mood, medication, workout and ECG
* rows exist), plus the saved layout. Each page loads its own data; the
* strip used to read the overview, a workouts page and the ECG list on
* every page for four flags.
*
* CRITICAL — the `<CoachDrawer>` does NOT mount here. It lives only in
* `src/app/insights/page.tsx` body so navigating into a sub-page
* unmounts the drawer.
*/
interface ComprehensivePayload {
moodSummary: { count: number } | null;
medications: Array<{ id: string }>;
/**
* v1.16.7 — true when the route served a stale cache body while a
* background rebuild runs (stale-while-revalidate). The query polls
* (bounded) while set so the fresh aggregate lands in-session.
*/
revalidating?: boolean;
interface InsightsPresence {
metrics: Record<string, { count: number }>;
hasMood: boolean;
hasMedication: boolean;
hasWorkouts: boolean;
hasEcgRecordings: boolean;
}
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
@@ -70,83 +58,33 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
const advisor = useInsightsAdvisorQuery(false);
// Shared analytics fetch — the layout shell only reads
// `summaries[METRIC].count` for the tab-strip availability gate, so
// it lands on IW1's slim `?slice=summaries` branch (2 SQL passes,
// no correlations / health-score / bp-in-target tail).
const analyticsQuery = useAnalyticsQuery({ slice: "summaries" });
// Shared comprehensive fetch — mood + medication signals for the
// event-driven gating branches. Sub-pages read the same key so the
// payload lands once per route.
const comprehensiveQuery = useQuery({
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
return apiGet<ComprehensivePayload>("/api/insights/comprehensive");
},
// Answered for the record on screen. A page whose module is off reads
// false here, and the strip hides its pill through the surface map anyway.
const presenceQuery = useQuery({
queryKey: queryKeys.insightsPresence(),
queryFn: () => apiGet<InsightsPresence>("/api/insights/presence"),
enabled: isAuthenticated,
// v1.16.7 — converge a stale-served (SWR) body in-session: while the
// route reports `revalidating: true`, poll on the same bounded
// cadence the advisor query uses until a fresh body lands.
refetchInterval: (query) =>
nextAdvisorPollInterval(
query.state.data?.revalidating,
pollAttemptCount(query.state),
),
});
const presence = presenceQuery.data;
// v1.4.32 — workout-presence gate. The workouts pill gates on at
// least one canonical row in the list-endpoint response. The
// single-row probe shares its cache slot with the list page +
// dashboard tile so navigation between surfaces is a free cache hit.
// Only while the Workouts module shows: a switched-off module is never
// probed (its pill is hidden by the surface map regardless).
const workoutsVisible = useSurfaceVisible("insights-page:workouts");
const workoutsProbe = useWorkouts({ limit: 1, enabled: workoutsVisible });
// Nutrients is opt-in, so its pill follows the module toggle alone: the
// in-context enable CTA lives on `/insights/nutrients` itself, and gating
// on a first row as well left that page unreachable before data existed.
const hasNutrients = user?.modules?.nutrients === true;
const hasEcgRecordings = presence?.hasEcgRecordings === true;
// v1.29 — nutrients gate. `nutrients` is opt-in (default off), unlike
// the always-on metric clusters, so the pill's floor is the module
// toggle alone — not a data-presence probe. Gating on BOTH the toggle
// AND a first row was a catch-22: the in-context enable CTA lives on
// `/insights/nutrients` itself, and that page was only reachable once
// data already existed. A user who just flipped the module on (or
// wants to) can now always reach the page; the page itself still
// degrades from "enable" → "no rows yet" → the three-card spine.
const nutrientsEnabled = user?.modules?.nutrients === true;
// v1.30 — ECG recording presence for the Heart-group ECG
// pill. ECG carries no `MeasurementType`, so the metric `availability`
// model can't gate it; it rides its own `hasRecordings` probe instead. The
// query shares `queryKeys.insightsEcgList()` with `<EcgSection>` and the
// ECG page, so the flag lands once per route (dedup-shared, no extra
// traffic). Undefined until it resolves → the pill stays off on first paint,
// exactly like the metric pills light up as their summaries land.
const ecgProbe = useEcgList();
const hasEcgRecordings = ecgProbe.data?.data.hasRecordings === true;
// v1.4.31 — memoise the `availability` prop so an unchanged
// payload doesn't recreate the object on every cache-write of
// analytics or comprehensive. The strip is wrapped in
// `React.memo` and would still rerun `buildTabs(availability)` on
// every parent render if this prop kept arriving as a fresh
// reference. Per
// `.planning/research/v15-insights-blocking-bug.md` fix 2.
const summaries = analyticsQuery.data?.summaries;
const hasMood = (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0;
const hasMedication = (comprehensiveQuery.data?.medications?.length ?? 0) > 0;
const hasWorkouts = (workoutsProbe.data?.workouts.length ?? 0) > 0;
const hasNutrients = nutrientsEnabled;
// Memoised so an unchanged answer keeps the same reference: the strip is
// wrapped in `React.memo` and reruns `buildTabs` on a new object.
const availability: InsightInputs | undefined = useMemo(() => {
if (!isAuthenticated) return undefined;
return { summaries, hasMood, hasMedication, hasWorkouts, hasNutrients };
}, [
isAuthenticated,
summaries,
hasMood,
hasMedication,
hasWorkouts,
hasNutrients,
]);
return {
summaries: presence?.metrics,
hasMood: presence?.hasMood === true,
hasMedication: presence?.hasMedication === true,
hasWorkouts: presence?.hasWorkouts === true,
hasNutrients,
};
}, [isAuthenticated, presence, hasNutrients]);
// v1.15.14 — couple the tab strip to the saved insights layout.
// A sub-page pill now shows iff its metric has data AND its slug is
+8 -10
View File
@@ -19,9 +19,8 @@
* `summaries[METRIC].count` from the analytics endpoint.
* - `BMI` is derived from `WEIGHT` so it inherits the weight count.
* - Event-driven metrics (`MOOD`, `MEDICATION`) read the boolean
* `hasMood` / `hasMedication` flags the caller threads in from
* `/api/insights/comprehensive` (mood summary count > 0,
* medications array length > 0).
* `hasMood` / `hasMedication` flags the caller threads in (the tab
* strip from `/api/insights/presence`).
*
* Auto-light-up behaviour: once the iOS native client uploads its
* first Apple-Health measurement, `summaries[METRIC].count` flips
@@ -29,7 +28,6 @@
* pill, sub-page, and dashboard tile — no separate feature flag
* needed. (F18.)
*/
import type { DataSummary } from "@/lib/analytics/trends";
/**
* Metric keys the gating helper distinguishes. Mirrors the
@@ -133,14 +131,14 @@ export type InsightMetric =
| "NUTRIENTS";
/**
* Inputs the gating helper consumes. The `summaries` shape mirrors
* the `/api/analytics` response (`Record<MeasurementType, DataSummary>`)
* — undefined means the caller hasn't loaded analytics yet, which
* the helper treats as "not available" so loading shells aren't
* accidentally lit up.
* Inputs the gating helper consumes. `summaries` is keyed by
* `MeasurementType` and only its `count` is read, so an analytics
* summaries map and the presence read's `metrics` both fit. Undefined
* means the caller hasn't loaded it yet, which the helper treats as
* "not available" so loading shells aren't accidentally lit up.
*/
export interface InsightInputs {
summaries: Record<string, DataSummary> | undefined;
summaries: Readonly<Record<string, { count: number }>> | undefined;
/** Whether the user has logged at least one mood entry. */
hasMood: boolean;
/** Whether the user has at least one active medication. */
+24
View File
@@ -22,6 +22,7 @@ import {
import {
insightsCardsResponse,
insightsComprehensiveResponse,
insightsPresenceResponse,
metricStatusQuery,
metricStatusResponse,
biomarkerAssessmentQuery,
@@ -126,6 +127,29 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/insights/presence": {
get: {
tags: ["Insights"],
summary: "Which Insights pages have data",
description:
"What the Insights tab strip gates its pills on, for the record on screen: the reading count per measurement type (after the record's module switches) and whether the record has mood entries in the last 90 days, a tracked scheduled medication, workouts and ECG recordings. A flag whose module is off is false. Counts and flags only; each page reads its own data. No provider is called and no AI switch refuses it. Delegable at MANAGE level over the whole record. Cookie or Bearer auth.",
responses: {
"200": {
description: "The presence answer.",
content: {
"application/json": {
schema: dataEnvelope(
insightsPresenceResponse,
"InsightsPresenceEnvelope",
),
},
},
},
...stdResponses,
...recordRefusal(),
},
},
},
"/api/insights/cards": {
get: {
tags: ["Insights"],
@@ -1691,6 +1691,36 @@ export const ecgListResponse = z
"Metadata-only ECG recording list. Never carries the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Read-only; no LLM call. Reflects only the device's own classification, never a HealthLog interpretation.",
});
export const insightsPresenceResponse = z
.object({
metrics: z
.record(z.string(), z.object({ count: z.number().int() }))
.describe(
"Keyed by `MeasurementType`: the reading count of every type with at least one reading, after the record's module switches. The same counts `GET /api/analytics?slice=summaries` reports.",
),
hasMood: z
.boolean()
.describe(
"A mood entry in the last 90 days. False while the mood module is off.",
),
hasMedication: z
.boolean()
.describe(
"An active medication with a schedule whose intake is tracked. False while the medications module is off.",
),
hasWorkouts: z
.boolean()
.describe(
"At least one workout. False while the workouts module is off.",
),
hasEcgRecordings: z.boolean().describe("At least one ECG recording."),
})
.meta({
id: "InsightsPresence",
description:
"Which Insights pages have something to show for the record: what the tab strip gates its pills on. Counts and flags only, no values.",
});
export const ecgDetailQuery = z
.object({
full: z
+2
View File
@@ -6,6 +6,8 @@
export const insightsKeys = {
insightsRoot: () => ["insights"] as const,
insightsComprehensive: () => ["insights", "comprehensive"] as const,
/** Which Insights pages have data (`/api/insights/presence`), for the tab strip. */
insightsPresence: () => ["insights", "presence"] as const,
insightsTargets: () => ["insights", "targets"] as const,
/**
* Shared cache key for the rich `/api/insights/generate` advisor