mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(insights): wire CorrelationRow + TrendsRow into /insights
Compute the three pre-defined correlation hypotheses (BP × compliance, mood × pulse, weight × weekday) on the trailing 30-day window in `/api/analytics` and surface the typed result shapes on the response payload. Each runner is gated on n >= 14 + p < 0.05; below the bar the result.status === "insufficient" and the per-card EmptyState paints in place of a misleading near-zero scatter. The insights page now mounts: - `<CorrelationRow>` between the Daily Briefing and the Advisor card — the layout slot the artboard reserves for the patterns surface. - `<TrendsRow>` immediately below it, fed from `advisor.payload.trendAnnotations` (lifted client-side via `trendAnnotationsSchema.safeParse`). Legacy cached payloads predating PROMPT_VERSION 4.20.1 surface the per-metric empty-state hint instead of a half-rendered annotation. The wide-event annotation captures per-hypothesis status so admin observability can attribute coverage gaps without re-querying the DB. 10 source-scan tests pin the load-bearing imports, the JSX mounts, the analytics-route surfacing, and the malformed-payload null fallback in the advisor hook. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
42591c6cec
commit
c83cb2311d
4 files changed
+359
No files matched your search
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.20 phase B3 — guards for the `/insights` wiring of the new
|
||||||
|
* Trends row + Correlation row.
|
||||||
|
*
|
||||||
|
* The page-source scan pattern mirrors `insights-polish.test.ts` —
|
||||||
|
* each test pins one load-bearing import / JSX mount so a future
|
||||||
|
* refactor can't accidentally drop a wave of features without
|
||||||
|
* breaking a CI lane.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ROOT = join(__dirname, "../../..");
|
||||||
|
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page.tsx");
|
||||||
|
const ANALYTICS_ROUTE_PATH = join(
|
||||||
|
ROOT,
|
||||||
|
"src/app/api/analytics/route.ts",
|
||||||
|
);
|
||||||
|
const ADVISOR_HOOK_PATH = join(
|
||||||
|
ROOT,
|
||||||
|
"src/components/insights/use-insights-advisor.ts",
|
||||||
|
);
|
||||||
|
|
||||||
|
function load(path: string): string {
|
||||||
|
return readFileSync(path, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("v1.4.20 B3 — /insights mounts CorrelationRow + TrendsRow", () => {
|
||||||
|
it("imports both new components", () => {
|
||||||
|
const src = load(INSIGHTS_PATH);
|
||||||
|
expect(src).toContain(
|
||||||
|
'from "@/components/insights/correlation-row"',
|
||||||
|
);
|
||||||
|
expect(src).toContain(
|
||||||
|
'from "@/components/insights/trends-row"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts <CorrelationRow> behind a non-null analytics.correlations guard", () => {
|
||||||
|
const src = load(INSIGHTS_PATH);
|
||||||
|
expect(src).toMatch(/analytics\?\.correlations\s*&&\s*\(/);
|
||||||
|
expect(src).toMatch(/<CorrelationRow\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts <TrendsRow> with annotations from the advisor payload", () => {
|
||||||
|
const src = load(INSIGHTS_PATH);
|
||||||
|
expect(src).toMatch(/<TrendsRow[\s\S]*?annotations=/);
|
||||||
|
expect(src).toMatch(/advisor\.payload\?\.trendAnnotations/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legacy advisor payload (null trendAnnotations) is handled with `?? null`", () => {
|
||||||
|
const src = load(INSIGHTS_PATH);
|
||||||
|
expect(src).toMatch(/trendAnnotations\s*\?\?\s*null/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("v1.4.20 B3 — /api/analytics surfaces correlations", () => {
|
||||||
|
it("imports the three correlation runners", () => {
|
||||||
|
const src = load(ANALYTICS_ROUTE_PATH);
|
||||||
|
expect(src).toContain("correlateBpCompliance");
|
||||||
|
expect(src).toContain("correlateMoodPulse");
|
||||||
|
expect(src).toContain("correlateWeightWeekday");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a `correlations` block on the success payload", () => {
|
||||||
|
const src = load(ANALYTICS_ROUTE_PATH);
|
||||||
|
expect(src).toMatch(/correlations,/);
|
||||||
|
expect(src).toMatch(/computeCorrelationHypotheses/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annotates the wide event with per-hypothesis status", () => {
|
||||||
|
const src = load(ANALYTICS_ROUTE_PATH);
|
||||||
|
expect(src).toMatch(
|
||||||
|
/annotate\(\{[\s\S]*correlations:[\s\S]*bpCompliance:[\s\S]*moodPulse:[\s\S]*weightWeekday:/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("v1.4.20 B3 — useInsightsAdvisorQuery lifts trendAnnotations", () => {
|
||||||
|
it("imports trendAnnotationsSchema for client-side validation", () => {
|
||||||
|
const src = load(ADVISOR_HOOK_PATH);
|
||||||
|
expect(src).toContain("trendAnnotationsSchema");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the lifted payload exposes `trendAnnotations`", () => {
|
||||||
|
const src = load(ADVISOR_HOOK_PATH);
|
||||||
|
expect(src).toMatch(/trendAnnotations\?:/);
|
||||||
|
expect(src).toMatch(/payload\.trendAnnotations\s*=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("malformed cached annotations resolve to null (not a half-render)", () => {
|
||||||
|
const src = load(ADVISOR_HOOK_PATH);
|
||||||
|
expect(src).toMatch(/parsed\.success\s*\?\s*parsed\.data\s*:\s*null/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,12 @@ import { apiSuccess } from "@/lib/api-response";
|
|||||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||||
import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target";
|
import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target";
|
||||||
|
import {
|
||||||
|
correlateBpCompliance,
|
||||||
|
correlateMoodPulse,
|
||||||
|
correlateWeightWeekday,
|
||||||
|
type CorrelationResult,
|
||||||
|
} from "@/lib/insights/correlations";
|
||||||
import type { MeasurementType } from "@/generated/prisma/client";
|
import type { MeasurementType } from "@/generated/prisma/client";
|
||||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||||
|
|
||||||
@@ -103,6 +109,13 @@ export const GET = apiHandler(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.4.20 phase B3 — three pre-defined correlation hypotheses.
|
||||||
|
// All three run on the trailing 30 days so a sparse account doesn't
|
||||||
|
// burn the n >= 14 gate on a stale window. Each runner gates on
|
||||||
|
// n >= 14 + p < 0.05; below the bar the result.status === "insufficient"
|
||||||
|
// and the UI paints an EmptyState.
|
||||||
|
const correlations = await computeCorrelationHypotheses(user.id);
|
||||||
|
|
||||||
return apiSuccess({
|
return apiSuccess({
|
||||||
summaries: results,
|
summaries: results,
|
||||||
bmi,
|
bmi,
|
||||||
@@ -110,5 +123,202 @@ export const GET = apiHandler(async () => {
|
|||||||
bpInTargetPct7d,
|
bpInTargetPct7d,
|
||||||
bpInTargetPct30d,
|
bpInTargetPct30d,
|
||||||
glucoseByContext,
|
glucoseByContext,
|
||||||
|
correlations,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build inputs for the three pre-defined hypotheses + run them.
|
||||||
|
* Pure-ish — only Prisma reads, no external calls.
|
||||||
|
*
|
||||||
|
* Window: trailing 30 days. Anything older falls outside the surface
|
||||||
|
* because the user-facing "based on N paired readings · last 30 days"
|
||||||
|
* source-chip has to remain truthful.
|
||||||
|
*/
|
||||||
|
async function computeCorrelationHypotheses(userId: string): Promise<{
|
||||||
|
bpCompliance: CorrelationResult;
|
||||||
|
moodPulse: CorrelationResult;
|
||||||
|
weightWeekday: CorrelationResult;
|
||||||
|
}> {
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const since = new Date(Date.now() - 30 * DAY_MS);
|
||||||
|
|
||||||
|
const [sysRows, diaRows, pulseRows, weightRows, moodRows, intakeRows] =
|
||||||
|
await Promise.all([
|
||||||
|
prisma.measurement.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
type: "BLOOD_PRESSURE_SYS",
|
||||||
|
measuredAt: { gte: since },
|
||||||
|
},
|
||||||
|
select: { value: true, measuredAt: true },
|
||||||
|
}),
|
||||||
|
prisma.measurement.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
type: "BLOOD_PRESSURE_DIA",
|
||||||
|
measuredAt: { gte: since },
|
||||||
|
},
|
||||||
|
select: { value: true, measuredAt: true },
|
||||||
|
}),
|
||||||
|
prisma.measurement.findMany({
|
||||||
|
where: { userId, type: "PULSE", measuredAt: { gte: since } },
|
||||||
|
select: { value: true, measuredAt: true },
|
||||||
|
}),
|
||||||
|
prisma.measurement.findMany({
|
||||||
|
where: { userId, type: "WEIGHT", measuredAt: { gte: since } },
|
||||||
|
select: { value: true, measuredAt: true },
|
||||||
|
}),
|
||||||
|
prisma.moodEntry.findMany({
|
||||||
|
where: { userId, moodLoggedAt: { gte: since } },
|
||||||
|
select: { score: true, moodLoggedAt: true, date: true },
|
||||||
|
}),
|
||||||
|
prisma.medicationIntakeEvent.findMany({
|
||||||
|
where: { userId, scheduledFor: { gte: since } },
|
||||||
|
select: { scheduledFor: true, takenAt: true, skipped: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Hypothesis 1: BP × medication compliance ────────────────
|
||||||
|
// Aggregate by Berlin-day key so DST + UTC boundary issues don't split
|
||||||
|
// a day's readings. The day's "compliance %" is taken / expected for
|
||||||
|
// that calendar day across all medications.
|
||||||
|
const dayKey = (d: Date): string => berlinDayKey(d);
|
||||||
|
|
||||||
|
const dailySys = new Map<string, number[]>();
|
||||||
|
for (const row of sysRows) {
|
||||||
|
const key = dayKey(row.measuredAt);
|
||||||
|
const list = dailySys.get(key) ?? [];
|
||||||
|
list.push(row.value);
|
||||||
|
dailySys.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyCompliance = new Map<string, { expected: number; taken: number }>();
|
||||||
|
for (const event of intakeRows) {
|
||||||
|
const key = dayKey(event.scheduledFor);
|
||||||
|
const slot = dailyCompliance.get(key) ?? { expected: 0, taken: 0 };
|
||||||
|
slot.expected += 1;
|
||||||
|
if (event.takenAt && !event.skipped) slot.taken += 1;
|
||||||
|
dailyCompliance.set(key, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bpCompliancePairs: Array<{
|
||||||
|
date: Date;
|
||||||
|
systolic: number;
|
||||||
|
compliancePct: number;
|
||||||
|
}> = [];
|
||||||
|
for (const [key, sysValues] of dailySys.entries()) {
|
||||||
|
const slot = dailyCompliance.get(key);
|
||||||
|
if (!slot || slot.expected === 0) continue;
|
||||||
|
const compliancePct = (slot.taken / slot.expected) * 100;
|
||||||
|
const meanSys = sysValues.reduce((s, v) => s + v, 0) / sysValues.length;
|
||||||
|
bpCompliancePairs.push({
|
||||||
|
date: dateFromBerlinKey(key),
|
||||||
|
systolic: meanSys,
|
||||||
|
compliancePct,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const bpCompliance = correlateBpCompliance({ daily: bpCompliancePairs });
|
||||||
|
|
||||||
|
// ── Hypothesis 2: Mood × resting pulse ──────────────────────
|
||||||
|
// Same-day pairing: take the day's mean mood vs the day's mean pulse.
|
||||||
|
// "Resting" is approximated by mean — HealthLog has no separate
|
||||||
|
// resting-pulse field, so we accept the noise rather than skip.
|
||||||
|
const dailyMood = new Map<string, number[]>();
|
||||||
|
for (const row of moodRows) {
|
||||||
|
const key = berlinDayKey(row.moodLoggedAt);
|
||||||
|
const list = dailyMood.get(key) ?? [];
|
||||||
|
list.push(row.score);
|
||||||
|
dailyMood.set(key, list);
|
||||||
|
}
|
||||||
|
const dailyPulse = new Map<string, number[]>();
|
||||||
|
for (const row of pulseRows) {
|
||||||
|
const key = berlinDayKey(row.measuredAt);
|
||||||
|
const list = dailyPulse.get(key) ?? [];
|
||||||
|
list.push(row.value);
|
||||||
|
dailyPulse.set(key, list);
|
||||||
|
}
|
||||||
|
const moodPulsePairs: Array<{
|
||||||
|
date: Date;
|
||||||
|
mood: number;
|
||||||
|
restingPulse: number;
|
||||||
|
}> = [];
|
||||||
|
for (const [key, moodScores] of dailyMood.entries()) {
|
||||||
|
const pulseValues = dailyPulse.get(key);
|
||||||
|
if (!pulseValues || pulseValues.length === 0) continue;
|
||||||
|
const meanMood =
|
||||||
|
moodScores.reduce((s, v) => s + v, 0) / moodScores.length;
|
||||||
|
const meanPulse =
|
||||||
|
pulseValues.reduce((s, v) => s + v, 0) / pulseValues.length;
|
||||||
|
moodPulsePairs.push({
|
||||||
|
date: dateFromBerlinKey(key),
|
||||||
|
mood: meanMood,
|
||||||
|
restingPulse: meanPulse,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const moodPulse = correlateMoodPulse({ daily: moodPulsePairs });
|
||||||
|
|
||||||
|
// ── Hypothesis 3: Weight × weekday ──────────────────────────
|
||||||
|
// 0 = Monday … 6 = Sunday. ISO weekday minus 1.
|
||||||
|
const weightWeekdayPairs: Array<{ weekday: number; weight: number }> = [];
|
||||||
|
for (const row of weightRows) {
|
||||||
|
const isoWeekday = berlinIsoWeekday(row.measuredAt); // 1..7, 1=Mon
|
||||||
|
weightWeekdayPairs.push({
|
||||||
|
weekday: isoWeekday - 1,
|
||||||
|
weight: row.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const weightWeekday = correlateWeightWeekday({ daily: weightWeekdayPairs });
|
||||||
|
|
||||||
|
// Annotate so admin observability can attribute coverage to the
|
||||||
|
// corresponding wide-event rather than chasing it via DB queries.
|
||||||
|
annotate({
|
||||||
|
meta: {
|
||||||
|
correlations: {
|
||||||
|
bpCompliance: bpCompliance.status,
|
||||||
|
moodPulse: moodPulse.status,
|
||||||
|
weightWeekday: weightWeekday.status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { bpCompliance, moodPulse, weightWeekday };
|
||||||
|
}
|
||||||
|
|
||||||
|
const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
weekday: "short",
|
||||||
|
});
|
||||||
|
|
||||||
|
function berlinDayKey(d: Date): string {
|
||||||
|
const parts = BERLIN_DATE_PARTS.formatToParts(d);
|
||||||
|
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
|
||||||
|
const m = parts.find((p) => p.type === "month")?.value ?? "01";
|
||||||
|
const day = parts.find((p) => p.type === "day")?.value ?? "01";
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateFromBerlinKey(key: string): Date {
|
||||||
|
// Anchor to UTC midnight — the date is a sortable bucket label rather
|
||||||
|
// than a wall-clock timestamp, so DST drift is irrelevant.
|
||||||
|
return new Date(`${key}T00:00:00.000Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ISO_WEEKDAY: Record<string, number> = {
|
||||||
|
Mon: 1,
|
||||||
|
Tue: 2,
|
||||||
|
Wed: 3,
|
||||||
|
Thu: 4,
|
||||||
|
Fri: 5,
|
||||||
|
Sat: 6,
|
||||||
|
Sun: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
function berlinIsoWeekday(d: Date): number {
|
||||||
|
const parts = BERLIN_DATE_PARTS.formatToParts(d);
|
||||||
|
const weekday = parts.find((p) => p.type === "weekday")?.value ?? "Mon";
|
||||||
|
return ISO_WEEKDAY[weekday] ?? 1;
|
||||||
|
}
|
||||||
@@ -44,7 +44,10 @@ import { InsightAdvisorCard } from "@/components/insights/insight-advisor-card";
|
|||||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||||
import { DailyBriefing } from "@/components/insights/daily-briefing";
|
import { DailyBriefing } from "@/components/insights/daily-briefing";
|
||||||
import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
|
import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
|
||||||
|
import { TrendsRow } from "@/components/insights/trends-row";
|
||||||
|
import { CorrelationRow } from "@/components/insights/correlation-row";
|
||||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||||
|
import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||||
import { CompareToggle } from "@/components/comparison/compare-toggle";
|
import { CompareToggle } from "@/components/comparison/compare-toggle";
|
||||||
// Recharts is ~108 KiB Brotli — defer-load it via a self-contained scatter
|
// Recharts is ~108 KiB Brotli — defer-load it via a self-contained scatter
|
||||||
// wrapper so the bundle only lands once a correlation card actually renders.
|
// wrapper so the bundle only lands once a correlation card actually renders.
|
||||||
@@ -155,6 +158,17 @@ interface ComprehensiveData {
|
|||||||
|
|
||||||
interface AnalyticsData {
|
interface AnalyticsData {
|
||||||
summaries: Record<string, DataSummary>;
|
summaries: Record<string, DataSummary>;
|
||||||
|
/**
|
||||||
|
* v1.4.20 phase B3 — three pre-defined correlation hypothesis runners
|
||||||
|
* computed server-side. Optional + nullable so the page handles the
|
||||||
|
* v1.4.19-style cached `analytics` payload without flashing an empty
|
||||||
|
* row before the next refetch.
|
||||||
|
*/
|
||||||
|
correlations?: {
|
||||||
|
bpCompliance: CorrelationResult;
|
||||||
|
moodPulse: CorrelationResult;
|
||||||
|
weightWeekday: CorrelationResult;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GeneralStatusData {
|
interface GeneralStatusData {
|
||||||
@@ -888,6 +902,23 @@ export default function InsightsPage() {
|
|||||||
metaSlot={<CompareToggle />}
|
metaSlot={<CompareToggle />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* v1.4.20 phase B3 — Correlation discovery row. Three pre-defined
|
||||||
|
hypotheses (BP × compliance, mood × pulse, weight × weekday) gated
|
||||||
|
on n >= 14 + p < 0.05; cards below the bar render a per-card
|
||||||
|
empty-state. The row-level disclaimer ("Patterns are observational,
|
||||||
|
not causal …") sits below the grid once. */}
|
||||||
|
{analytics?.correlations && (
|
||||||
|
<CorrelationRow results={analytics.correlations} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* v1.4.20 phase B3 — Trends row. Mini BP / Weight / Mood charts
|
||||||
|
with an inline AI-authored sentence below each. Annotations come
|
||||||
|
from `advisor.payload.trendAnnotations` (PROMPT_VERSION 4.20.1+);
|
||||||
|
legacy cached payloads simply paint the per-metric empty hint. */}
|
||||||
|
<TrendsRow
|
||||||
|
annotations={advisor.payload?.trendAnnotations ?? null}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* v1.4.16 phase D reconcile (CRITICAL C1) — wire the polished
|
{/* v1.4.16 phase D reconcile (CRITICAL C1) — wire the polished
|
||||||
`<InsightAdvisorCard>` (severity-ordered recommendations grid +
|
`<InsightAdvisorCard>` (severity-ordered recommendations grid +
|
||||||
per-rec rationale expand + confidence meter + thumbs feedback +
|
per-rec rationale expand + confidence meter + thumbs feedback +
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import type { InsightResult } from "@/lib/ai/types";
|
import type { InsightResult } from "@/lib/ai/types";
|
||||||
import {
|
import {
|
||||||
dailyBriefingSchema,
|
dailyBriefingSchema,
|
||||||
|
trendAnnotationsSchema,
|
||||||
type DailyBriefing as DailyBriefingPayload,
|
type DailyBriefing as DailyBriefingPayload,
|
||||||
|
type TrendAnnotations,
|
||||||
} from "@/lib/ai/schema";
|
} from "@/lib/ai/schema";
|
||||||
import { queryKeys } from "@/lib/query-keys";
|
import { queryKeys } from "@/lib/query-keys";
|
||||||
|
|
||||||
@@ -39,6 +41,12 @@ export interface InsightAdvisorPayload {
|
|||||||
* malformed payload from poisoning the briefing card.
|
* malformed payload from poisoning the briefing card.
|
||||||
*/
|
*/
|
||||||
dailyBriefing?: DailyBriefingPayload | null;
|
dailyBriefing?: DailyBriefingPayload | null;
|
||||||
|
/**
|
||||||
|
* v1.4.20 phase B3 — optional trend annotations for the Trends row.
|
||||||
|
* Same lift-pattern as `dailyBriefing` — validated client-side and
|
||||||
|
* left null when the cached payload predates PROMPT_VERSION 4.20.1.
|
||||||
|
*/
|
||||||
|
trendAnnotations?: TrendAnnotations | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAdvisor(
|
async function fetchAdvisor(
|
||||||
@@ -77,6 +85,19 @@ async function fetchAdvisor(
|
|||||||
} else {
|
} else {
|
||||||
payload.dailyBriefing = null;
|
payload.dailyBriefing = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.4.20 phase B3 — same lift for `trendAnnotations`. Cached payloads
|
||||||
|
// from the 4.20.0 line predate the field, so null is the expected
|
||||||
|
// default. A malformed candidate also resolves to null so the UI
|
||||||
|
// surfaces the per-metric empty hint instead of a half-rendered card.
|
||||||
|
const annotationsCandidate = (payload?.insights as Record<string, unknown>)
|
||||||
|
?.trendAnnotations;
|
||||||
|
if (annotationsCandidate != null) {
|
||||||
|
const parsed = trendAnnotationsSchema.safeParse(annotationsCandidate);
|
||||||
|
payload.trendAnnotations = parsed.success ? parsed.data : null;
|
||||||
|
} else {
|
||||||
|
payload.trendAnnotations = null;
|
||||||
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user