mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge pull request #982 from MBombeck/fix/insights-without-assistant
fix(insights): keep the overview reads open with the assistant switched off
This commit is contained in:
14 files changed
+128
-49
No files matched your search
@@ -10997,8 +10997,8 @@ paths:
|
||||
NEVER decrypts or returns the waveform — the per-recording strip is fetched on demand from GET
|
||||
/api/insights/ecg/{id}. Reflects only the recording device's certified on-device classification, verbatim;
|
||||
HealthLog never re-classifies an ECG or produces a diagnosis. Data-availability-gated: an empty account returns
|
||||
`hasRecordings: false`. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM
|
||||
call. Auth via cookie or Bearer."
|
||||
`hasRecordings: false`. Module-gated on `insights`; no assistant-surface gate and no LLM call. Auth via cookie
|
||||
or Bearer."
|
||||
responses:
|
||||
"200":
|
||||
description: The ECG recording list (possibly empty).
|
||||
@@ -11022,8 +11022,8 @@ paths:
|
||||
or revise one. `source` accepts `APPLE_HEALTH` only; `userId` comes from the session and is never a body field.
|
||||
Unknown body keys are rejected with a 422 naming them. No `Idempotency-Key` is needed: the recording carries its
|
||||
own identity, so a retry resolves to the same row by construction — see `status` for what a re-post reports.
|
||||
Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Module-gated on `insights` and the
|
||||
operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer."
|
||||
Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Module-gated on `insights`; no
|
||||
assistant-surface gate and no LLM call. Auth via cookie or Bearer."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -11058,8 +11058,8 @@ paths:
|
||||
foreign or unknown id 404s (existence sealed). The waveform is AES-256-GCM at rest, decrypted through the
|
||||
fail-closed codec. By default the ~9000-sample strip is min/max-decimated to ~2500 display points so R-wave
|
||||
peaks survive; `?full=1` returns the raw array. HealthLog does not interpret the trace, measure intervals,
|
||||
annotate beats, or emit a verdict of its own. Module-gated on `insights` and the operator `insightStatus`
|
||||
assistant surface; no LLM call. `no-store`. Auth via cookie or Bearer.
|
||||
annotate beats, or emit a verdict of its own. Module-gated on `insights`; no assistant-surface gate and no LLM
|
||||
call. `no-store`. Auth via cookie or Bearer.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -11107,8 +11107,8 @@ paths:
|
||||
it never re-classifies and never produces a HealthLog diagnosis. `classification` carries the full six-value
|
||||
verdict set (the three ECG verdicts plus the two walking-steadiness severities plus the neutral FIRED verdict) —
|
||||
a distinct, wider enum than the three-value one on GET /api/insights/ecg. Data-availability-gated: an account
|
||||
with no event rows returns `hasEvents: false`. Module-gated on `insights` and the operator `insightStatus`
|
||||
assistant surface; no LLM call. Auth via cookie or Bearer."
|
||||
with no event rows returns `hasEvents: false`. Module-gated on `insights`; no assistant-surface gate and no LLM
|
||||
call. Auth via cookie or Bearer."
|
||||
responses:
|
||||
"200":
|
||||
description: The device-flagged event timeline (possibly empty).
|
||||
|
||||
@@ -42,13 +42,6 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/insights/bmi-status/route.ts",
|
||||
"src/app/api/insights/cards/route.ts",
|
||||
"src/app/api/insights/correlations/route.ts",
|
||||
// v1.10.0 — generic derived-wellness-metric route. Pure compute over
|
||||
// the rollup tier; gates on the same `insightStatus` sub-flag as the
|
||||
// assessment routes (no Coach prose).
|
||||
"src/app/api/insights/derived/route.ts",
|
||||
// v1.10.0 — batched derived-metric route (the dashboard fan-out fix).
|
||||
// Same pure compute + `insightStatus` sub-flag as the single route.
|
||||
"src/app/api/insights/derived/batch/route.ts",
|
||||
"src/app/api/insights/medication-compliance-status/route.ts",
|
||||
// v1.8.7.1 — generic per-HealthKit-metric assessment. Gated on the
|
||||
// same `insightStatus` sub-flag as the seven specialised status routes.
|
||||
@@ -62,17 +55,6 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
|
||||
// `coach`: a user with assessments enabled but Coach disabled can warm.
|
||||
"src/app/api/insights/pregenerate/route.ts",
|
||||
"src/app/api/insights/pulse-status/route.ts",
|
||||
// v1.10.0 — device-flagged event awareness timeline (categorical
|
||||
// events, WX-B). Pure DB read of the device's own verdicts; gates on
|
||||
// the same `insightStatus` sub-flag as the assessment routes (no Coach
|
||||
// prose).
|
||||
"src/app/api/insights/rhythm-events/route.ts",
|
||||
// v1.28.50 — ECG recording surface (list + per-recording waveform). Pure
|
||||
// DB read of the device's own recordings + verdicts; gates on the same
|
||||
// `insightStatus` sub-flag as the assessment routes (no Coach prose — the
|
||||
// waveform is never interpreted).
|
||||
"src/app/api/insights/ecg/route.ts",
|
||||
"src/app/api/insights/ecg/[id]/route.ts",
|
||||
"src/app/api/insights/weight-status/route.ts",
|
||||
];
|
||||
|
||||
@@ -133,6 +115,18 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/insights/chat/fenced/route.ts",
|
||||
"src/app/api/insights/chat/[id]/attachments/route.ts",
|
||||
"src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts",
|
||||
// Deterministic reads behind the Insights overview: the comprehensive
|
||||
// overview query, the derived-metric tiles, the ECG list, strip and live
|
||||
// ingest, and the device-flagged rhythm events. None of them carries
|
||||
// assistant prose or calls a provider; they gate on the `insights` module
|
||||
// only. Switching the assistant off, the master or a sub-flag, must not
|
||||
// refuse them, or the overview fails to load.
|
||||
"src/app/api/insights/comprehensive/route.ts",
|
||||
"src/app/api/insights/derived/route.ts",
|
||||
"src/app/api/insights/derived/batch/route.ts",
|
||||
"src/app/api/insights/ecg/route.ts",
|
||||
"src/app/api/insights/ecg/[id]/route.ts",
|
||||
"src/app/api/insights/rhythm-events/route.ts",
|
||||
];
|
||||
|
||||
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
|
||||
|
||||
@@ -88,8 +88,18 @@ vi.mock("@/lib/medication-category", () => ({
|
||||
getMedicationCategories: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
// The route gates on the `insights` module. Mock it default-enabled so the
|
||||
// envelope assertions ride through; the module-off coverage lives in the
|
||||
// module route-gate inventory test.
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
resolveModuleMap: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
@@ -113,6 +123,8 @@ function makeReq(): NextRequest {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// resetAllMocks drops the module gate's default; restore it.
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
__resetAllCachesForTests();
|
||||
// v1.15.20 — default to an allowing analytics-read budget.
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
@@ -121,7 +133,7 @@ beforeEach(() => {
|
||||
remaining: 119,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
// Default to assistant-on so the gate doesn't 403 every test.
|
||||
// No stored settings row: the provider probe falls back to defaults.
|
||||
(prisma.appSettings.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
@@ -210,6 +222,34 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
expect(body.data.dataSpanDays).toBe(0);
|
||||
});
|
||||
|
||||
it("answers with the assistant switched off on the server", async () => {
|
||||
// The overview's main read carries no assistant prose, so an operator who
|
||||
// switches the assistant off (master flag) must still get the overview.
|
||||
(
|
||||
prisma.appSettings.findUnique as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
assistantEnabled: false,
|
||||
assistantCoachEnabled: false,
|
||||
assistantBriefingEnabled: false,
|
||||
assistantInsightStatusEnabled: false,
|
||||
assistantCorrelationsEnabled: false,
|
||||
});
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
{
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
weightRawRows: [],
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: null,
|
||||
totalMeasurements: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("computes BMI from aggregate WEIGHT.latest and user heightCm", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
|
||||
@@ -28,8 +28,8 @@ import {
|
||||
requireRecordAuth,
|
||||
type AuthContext,
|
||||
} from "@/lib/api-handler";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
cachedSwrWithMeta,
|
||||
@@ -49,6 +49,8 @@ export const GET = apiHandler(async () => {
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
@@ -56,9 +58,10 @@ export const GET = apiHandler(async () => {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
// v1.4.31 — comprehensive feeds the hero strip narration and the
|
||||
// recommendations grid that share the Coach gate.
|
||||
await requireAssistantSurface("coach");
|
||||
// No assistant-surface gate. Every field is computed from the record
|
||||
// (the provider chain is only probed for `hasProvider`), and this is the
|
||||
// main read of the Insights overview: an operator who switches the
|
||||
// assistant off still gets the overview.
|
||||
|
||||
// v1.4.35 — read-through the analytics cache keyed on
|
||||
// (userId, "comprehensive"). The /insights page mount routinely
|
||||
|
||||
@@ -28,7 +28,6 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { MeasurementType } from "@/generated/prisma/client";
|
||||
import {
|
||||
@@ -120,7 +119,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user, actor } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
// Same deterministic compute as the single route, so no
|
||||
// assistant-surface gate.
|
||||
|
||||
// Per-caller limiter, same posture as the compliance routes: the cold
|
||||
// build fans out up to 24 rollup walks, so an unthrottled caller could
|
||||
|
||||
@@ -24,7 +24,6 @@ import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled, type ModuleKey } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
@@ -98,7 +97,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
await requireAssistantSurface("insightStatus");
|
||||
// Pure compute over the rollup tier, so no assistant-surface gate:
|
||||
// switching the assistant off does not blank the derived tiles.
|
||||
|
||||
const parsed = derivedQuerySchema.safeParse({
|
||||
metric: request.nextUrl.searchParams.get("metric"),
|
||||
|
||||
@@ -28,7 +28,6 @@ import { NextRequest } from "next/server";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptWaveformFromBytes } from "@/lib/withings/ecg-waveform-codec";
|
||||
@@ -48,7 +47,7 @@ export const GET = apiHandler(
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
// The waveform is never interpreted, so no assistant-surface gate.
|
||||
|
||||
const { id } = await params;
|
||||
const full = request.nextUrl.searchParams.get("full") === "1";
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -113,7 +112,8 @@ export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
// A pure read of the device's own recordings, so no assistant-surface
|
||||
// gate.
|
||||
|
||||
const rows = await prisma.ecgRecording.findMany({
|
||||
where: { userId: user.id },
|
||||
@@ -181,7 +181,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
// A device ingest carries no assistant prose, so switching the assistant
|
||||
// off must not refuse a recording.
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`insights:ecg:ingest:${user.id}`,
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
|
||||
@@ -49,7 +48,7 @@ export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
await requireAssistantSurface("insightStatus");
|
||||
// A pure read of the device's own verdicts, so no assistant-surface gate.
|
||||
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
|
||||
@@ -74,6 +74,9 @@ function BlockSkeleton({
|
||||
);
|
||||
}
|
||||
|
||||
/** Stable reference so the trends row does not re-select on every render. */
|
||||
const HIDDEN_MOOD = ["mood"] as const;
|
||||
|
||||
const DailyBriefing = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/daily-briefing").then((mod) => ({
|
||||
@@ -513,6 +516,9 @@ export default function InsightsPageClient() {
|
||||
trends: (
|
||||
<TrendsRow
|
||||
briefing={briefingPayload}
|
||||
// Same module gate the tab strip applies to the Mood pill: with
|
||||
// the mood module off, the trends row does not chart mood either.
|
||||
hiddenMetrics={user?.modules?.mood === false ? HIDDEN_MOOD : undefined}
|
||||
annotations={advisor.payload?.trendAnnotations ?? null}
|
||||
loading={advisor.isLoading || advisor.isRegenerating}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
selectTrendCharts,
|
||||
type TrendAnnotationKey,
|
||||
type TrendChartConfig,
|
||||
type SelectTrendChartsOptions,
|
||||
} from "@/lib/insights/trend-chart-select";
|
||||
import {
|
||||
TrendAnnotation,
|
||||
@@ -65,6 +66,11 @@ const MoodChart = dynamic(
|
||||
);
|
||||
|
||||
interface TrendsRowProps {
|
||||
/**
|
||||
* Metrics to leave out because their module is switched off (for
|
||||
* example `mood`). Passed straight to `selectTrendCharts`.
|
||||
*/
|
||||
hiddenMetrics?: SelectTrendChartsOptions["hiddenMetrics"];
|
||||
/**
|
||||
* Daily briefing payload. Drives the chart set: the row charts the
|
||||
* metrics the briefing flags, in order, deduped + capped. `null` /
|
||||
@@ -132,13 +138,14 @@ export function TrendsRow({
|
||||
annotations,
|
||||
confidence,
|
||||
loading = false,
|
||||
hiddenMetrics,
|
||||
}: TrendsRowProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// v1.8.5 — derive the chart set from the briefing. No new fetch: the
|
||||
// briefing payload is already on the page (advisor cache), so this is
|
||||
// a pure read that respects the v1.8.3 anti-freeze contract.
|
||||
const charts = selectTrendCharts(briefing);
|
||||
const charts = selectTrendCharts(briefing, { hiddenMetrics });
|
||||
|
||||
// v1.4.36 W2 T3 — derive the tri-state status per metric from the
|
||||
// advisor's loading flag + the annotation presence. Pending wins
|
||||
|
||||
@@ -157,3 +157,24 @@ describe("selectTrendCharts", () => {
|
||||
expect(TREND_CHART_CONFIG.steps?.detailHref).toBe("/insights/steps");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectTrendCharts — hidden metrics", () => {
|
||||
it("drops mood from the fallback triple when the mood module is off", () => {
|
||||
const charts = selectTrendCharts(null, { hiddenMetrics: ["mood"] });
|
||||
expect(charts.map((c) => c.metric)).not.toContain("mood");
|
||||
expect(charts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("skips a hidden metric the briefing flags", () => {
|
||||
const charts = selectTrendCharts(briefing(["mood", "weight"]), {
|
||||
hiddenMetrics: ["mood"],
|
||||
});
|
||||
expect(charts.map((c) => c.metric)).not.toContain("mood");
|
||||
expect(charts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps mood when nothing is hidden", () => {
|
||||
const charts = selectTrendCharts(null, { hiddenMetrics: [] });
|
||||
expect(charts.map((c) => c.metric)).toContain("mood");
|
||||
});
|
||||
});
|
||||
@@ -267,16 +267,24 @@ export const DEFAULT_TREND_CHART_CAP = 3;
|
||||
export interface SelectTrendChartsOptions {
|
||||
/** Max number of charts to return. Defaults to {@link DEFAULT_TREND_CHART_CAP}. */
|
||||
cap?: number;
|
||||
/**
|
||||
* Metrics whose module is switched off. They are skipped on both the
|
||||
* briefing path and the fallback triple, so a disabled module never
|
||||
* charts on the overview.
|
||||
*/
|
||||
hiddenMetrics?: ReadonlyArray<DailyBriefingKeyFinding["sourceMetric"]>;
|
||||
}
|
||||
|
||||
function configsFor(
|
||||
metrics: ReadonlyArray<DailyBriefingKeyFinding["sourceMetric"]>,
|
||||
cap: number,
|
||||
hidden: ReadonlySet<string>,
|
||||
): TrendChartConfig[] {
|
||||
const seen = new Set<string>();
|
||||
const out: TrendChartConfig[] = [];
|
||||
for (const metric of metrics) {
|
||||
if (out.length >= cap) break;
|
||||
if (hidden.has(metric)) continue; // module switched off
|
||||
const config = TREND_CHART_CONFIG[metric];
|
||||
if (!config) continue; // metric has no standalone trend chart
|
||||
if (seen.has(config.metric)) continue; // dedupe
|
||||
@@ -301,14 +309,15 @@ export function selectTrendCharts(
|
||||
options: SelectTrendChartsOptions = {},
|
||||
): TrendChartConfig[] {
|
||||
const cap = Math.max(1, options.cap ?? DEFAULT_TREND_CHART_CAP);
|
||||
const hidden = new Set<string>(options.hiddenMetrics ?? []);
|
||||
|
||||
const findingMetrics =
|
||||
briefing?.keyFindings?.map((f) => f.sourceMetric) ?? [];
|
||||
const fromBriefing = configsFor(findingMetrics, cap);
|
||||
const fromBriefing = configsFor(findingMetrics, cap, hidden);
|
||||
|
||||
if (fromBriefing.length > 0) {
|
||||
return fromBriefing;
|
||||
}
|
||||
|
||||
return configsFor(DEFAULT_TREND_METRICS, cap);
|
||||
return configsFor(DEFAULT_TREND_METRICS, cap, hidden);
|
||||
}
|
||||
@@ -879,7 +879,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "ECG recording list (metadata only)",
|
||||
description:
|
||||
"v1.28.50 — the authenticated user's ECG recordings as a cheap, index-covered metadata list (recorded time, duration, sampling rate, sample count, average heart rate, lead, and the DEVICE's own rhythm classification). NEVER decrypts or returns the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Reflects only the recording device's certified on-device classification, verbatim; HealthLog never re-classifies an ECG or produces a diagnosis. Data-availability-gated: an empty account returns `hasRecordings: false`. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer.",
|
||||
"v1.28.50 — the authenticated user's ECG recordings as a cheap, index-covered metadata list (recorded time, duration, sampling rate, sample count, average heart rate, lead, and the DEVICE's own rhythm classification). NEVER decrypts or returns the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Reflects only the recording device's certified on-device classification, verbatim; HealthLog never re-classifies an ECG or produces a diagnosis. Data-availability-gated: an empty account returns `hasRecordings: false`. Module-gated on `insights`; no assistant-surface gate and no LLM call. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The ECG recording list (possibly empty).",
|
||||
@@ -897,7 +897,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Ingest one ECG recording",
|
||||
description:
|
||||
"The live ECG ingest: one Apple Watch recording per request, for a client draining its HealthKit ECG observer. Before this existed, a watch ECG could only reach HealthLog inside a full `export.zip`. One recording per request because a 30 s / 512 Hz strip is ~15 360 samples. Samples are INTEGER MICRO-VOLTS (convert from HealthKit's Volts), stored AES-256-GCM encrypted; `sampleCount` and `durationSeconds` are derived server-side. The `classification` is the device's own verdict stored verbatim — HealthLog never reads the waveform to produce or revise one. `source` accepts `APPLE_HEALTH` only; `userId` comes from the session and is never a body field. Unknown body keys are rejected with a 422 naming them. No `Idempotency-Key` is needed: the recording carries its own identity, so a retry resolves to the same row by construction — see `status` for what a re-post reports. Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer.",
|
||||
"The live ECG ingest: one Apple Watch recording per request, for a client draining its HealthKit ECG observer. Before this existed, a watch ECG could only reach HealthLog inside a full `export.zip`. One recording per request because a 30 s / 512 Hz strip is ~15 360 samples. Samples are INTEGER MICRO-VOLTS (convert from HealthKit's Volts), stored AES-256-GCM encrypted; `sampleCount` and `durationSeconds` are derived server-side. The `classification` is the device's own verdict stored verbatim — HealthLog never reads the waveform to produce or revise one. `source` accepts `APPLE_HEALTH` only; `userId` comes from the session and is never a body field. Unknown body keys are rejected with a 422 naming them. No `Idempotency-Key` is needed: the recording carries its own identity, so a retry resolves to the same row by construction — see `status` for what a re-post reports. Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Module-gated on `insights`; no assistant-surface gate and no LLM call. Auth via cookie or Bearer.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -938,7 +938,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "One ECG recording with waveform",
|
||||
description:
|
||||
"v1.28.50 — one recording's decrypted waveform plus metadata and the DEVICE's verbatim classification. Ownership is narrowed in the query where (`{ id, userId }`) so a cross-user read is structurally impossible; a foreign or unknown id 404s (existence sealed). The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. By default the ~9000-sample strip is min/max-decimated to ~2500 display points so R-wave peaks survive; `?full=1` returns the raw array. HealthLog does not interpret the trace, measure intervals, annotate beats, or emit a verdict of its own. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. `no-store`. Auth via cookie or Bearer.",
|
||||
"v1.28.50 — one recording's decrypted waveform plus metadata and the DEVICE's verbatim classification. Ownership is narrowed in the query where (`{ id, userId }`) so a cross-user read is structurally impossible; a foreign or unknown id 404s (existence sealed). The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. By default the ~9000-sample strip is min/max-decimated to ~2500 display points so R-wave peaks survive; `?full=1` returns the raw array. HealthLog does not interpret the trace, measure intervals, annotate beats, or emit a verdict of its own. Module-gated on `insights`; no assistant-surface gate and no LLM call. `no-store`. Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: z.string().describe("The ECG recording id (cuid)."),
|
||||
@@ -973,7 +973,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Device-flagged rhythm/HR/steadiness event timeline",
|
||||
description:
|
||||
"v1.10.0 (WX-B) — the authenticated user's timeline of device-flagged EVENT rows: irregular-rhythm / high-HR / low-HR / walking-steadiness / breathing-disturbance notifications the user's wearable (Apple Watch / Withings ScanWatch) already produced and synced. AWARENESS / SCREENING of the DEVICE's own decision — HealthLog stores and reflects ONLY the classification result the device's certified on-device algorithm emitted, verbatim; it never re-classifies and never produces a HealthLog diagnosis. `classification` carries the full six-value verdict set (the three ECG verdicts plus the two walking-steadiness severities plus the neutral FIRED verdict) — a distinct, wider enum than the three-value one on GET /api/insights/ecg. Data-availability-gated: an account with no event rows returns `hasEvents: false`. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer.",
|
||||
"v1.10.0 (WX-B) — the authenticated user's timeline of device-flagged EVENT rows: irregular-rhythm / high-HR / low-HR / walking-steadiness / breathing-disturbance notifications the user's wearable (Apple Watch / Withings ScanWatch) already produced and synced. AWARENESS / SCREENING of the DEVICE's own decision — HealthLog stores and reflects ONLY the classification result the device's certified on-device algorithm emitted, verbatim; it never re-classifies and never produces a HealthLog diagnosis. `classification` carries the full six-value verdict set (the three ECG verdicts plus the two walking-steadiness severities plus the neutral FIRED verdict) — a distinct, wider enum than the three-value one on GET /api/insights/ecg. Data-availability-gated: an account with no event rows returns `hasEvents: false`. Module-gated on `insights`; no assistant-surface gate and no LLM call. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The device-flagged event timeline (possibly empty).",
|
||||
|
||||
Reference in new issue
Block a user