From dc66dfcfdcec2771d813e3e221590030ba801af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 17:12:38 +0200 Subject: [PATCH] refactor(types): consolidate AnalyticsData into src/types/analytics.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three structurally-distinct `interface AnalyticsData {…}` declarations shared a name across `src/app/page.tsx` (dashboard), `src/app/insights/ page.tsx` (insights mother), and `src/components/onboarding/ getting-started-checklist.tsx` (checklist). The collision made the shapes un-discoverable from any one site and forced every iOS-contract addition to land in three places. Hoist all three into `src/types/analytics.ts` as named exports: `DashboardAnalyticsData`, `InsightsAnalyticsData`, `ChecklistAnalytics Data`. Each call site re-imports its shape under the original alias to keep downstream code readable; the slim `SubPageAnalyticsData` that was already there stays put. We deliberately keep the four shapes distinct (rather than collapsing to one with everything optional) so call sites can't cross-read fields they shouldn't reach for — closes v1.4.39 org- audit recommendation #2. --- src/app/insights/page.tsx | 46 +----- src/app/page.tsx | 41 +---- .../onboarding/getting-started-checklist.tsx | 6 +- src/types/analytics.ts | 153 ++++++++++++++++-- 4 files changed, 148 insertions(+), 98 deletions(-) diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 3b2e3ec05..3d9009746 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -96,48 +96,10 @@ interface ComprehensiveData { totalMeasurements: number; } -interface AnalyticsData { - summaries: Record; - correlations?: { - bpCompliance: CorrelationResult; - moodPulse: CorrelationResult; - weightWeekday: CorrelationResult; - } | null; - healthScore?: { - score: number; - band: "green" | "yellow" | "red"; - components: { - // v1.4.25 W8e — the optional `source`/`asOf` slots feed the - // provenance accordion. Older clients reading this payload - // happily ignore the extras (additive contract). - bp: { - value: number | null; - weight: number; - source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; - asOf?: string; - }; - weight: { - value: number | null; - weight: number; - source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; - asOf?: string; - }; - mood: { - value: number | null; - weight: number; - source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; - asOf?: string; - }; - compliance: { - value: number | null; - weight: number; - source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; - asOf?: string; - }; - }; - delta: number | null; - } | null; -} +// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as +// `InsightsAnalyticsData`; aliased back to the local name to keep the +// rest of this file readable. +import type { InsightsAnalyticsData as AnalyticsData } from "@/types/analytics"; export default function InsightsPage() { const { isAuthenticated, user } = useAuth(); diff --git a/src/app/page.tsx b/src/app/page.tsx index e42ccc10e..b265eeb93 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -25,6 +25,7 @@ import { type DashboardLayout, } from "@/lib/dashboard-layout"; import type { DataSummary as DataSummaryType } from "@/lib/analytics/trends"; +import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics"; import dynamic from "next/dynamic"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; @@ -91,46 +92,6 @@ import { getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; -interface AnalyticsData { - summaries: Record; - bpInTargetPct: number | null; - /** - * v1.4.18 A1 — share of paired BP readings inside target over the - * last 7 / 30 days. Drive the BD-Zielbereich tile's `7T:` / `30T:` - * sub-values; render "—" when the field is null (no paired readings - * in the window). - */ - bpInTargetPct7d?: number | null; - bpInTargetPct30d?: number | null; - /** - * v1.4.22 A1 — long-arc all-time aggregate. After the headline - * re-anchor to last-30-days the all-time number lives as a sub-value - * on the BD-Zielbereich tile (alongside `7d` and `30d`). - */ - bpInTargetPctAllTime?: number | null; - /** - * v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window - * pcts. The BD-Zielbereich tile's comparison-overlay caption picks - * `priorMonth` for `comparisonBaseline === "lastMonth"` and - * `priorYear` for `lastYear` so the rendered "Δ X% vs. last month" - * stays honest. Null when the prior window has no paired readings. - */ - bpInTargetPctPriorMonth?: number | null; - bpInTargetPctPriorYear?: number | null; - glucoseByContext?: Record; - /** - * v1.4.34 IW-B — per-type freshness map from `/api/analytics`. The - * tile-strip helper reads `lastSeenByType[type]?.daysAgo` and forwards - * it to each `` so a metric the user hasn't - * logged in a while keeps its tile visible (with an explicit - * "Letzter Wert vor …" caption) instead of disappearing. - */ - lastSeenByType?: Record< - string, - { lastSeenAt: string; daysAgo: number } | null - >; -} - interface RangeDisplayConfig { range: TrafficRange | null; } diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx index c034868ba..5f7d4236a 100644 --- a/src/components/onboarding/getting-started-checklist.tsx +++ b/src/components/onboarding/getting-started-checklist.tsx @@ -70,9 +70,9 @@ const ITEM_LABEL_KEYS: Record< }, }; -interface AnalyticsData { - summaries?: Record; -} +// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as +// `ChecklistAnalyticsData`; aliased back to the local name. +import type { ChecklistAnalyticsData as AnalyticsData } from "@/types/analytics"; interface WithingsStatus { connected?: boolean; diff --git a/src/types/analytics.ts b/src/types/analytics.ts index 12b5f971f..2dad35b06 100644 --- a/src/types/analytics.ts +++ b/src/types/analytics.ts @@ -1,23 +1,150 @@ /** - * v1.4.28 R3d (BK-F-M1) — shared shape for the trimmed `/api/analytics` - * payload that every insights sub-page consumes. + * Shared analytics DTOs. * - * Pre-fix, each of the seven `/insights//page.tsx` modules - * declared its own `interface AnalyticsData { summaries: Record<...> }` - * inline. They were structurally identical so the TanStack-Query cache - * unified anyway, but maintaining seven copies meant every iOS-contract - * addition (e.g. a new `MeasurementType`) showed up only after the - * matching sub-page was touched. Hoisting the interface here is a - * single-place change. + * Pre-v1.4.41, three structurally-distinct `interface AnalyticsData {…}` + * declarations lived inline in `src/app/page.tsx` (dashboard), in + * `src/app/insights/page.tsx` (insights-mother), and in + * `src/components/onboarding/getting-started-checklist.tsx` (checklist). + * The names collided across files even though the shapes did not, which + * made "where does the analytics payload live?" un-discoverable. * - * NOTE: the dashboard's `AnalyticsData` (in `src/app/page.tsx`) is a - * wider type — it carries `bpInTargetPct*` aggregates the sub-pages - * don't use. The dashboard keeps its own declaration; we'd lose more - * than we gain by trying to merge them. + * v1.4.41 W-ORG (org-audit rec #2) hoists every shape into this module + * under a name that says which surface it describes. The header below + * documents the relationships so a future contract change has one + * place to land. + * + * - `SubPageAnalyticsData` — slim shape consumed by every + * `/insights//page.tsx`; just `summaries`. + * - `DashboardAnalyticsData` — dashboard root (`src/app/page.tsx`); + * carries the `bpInTargetPct*` aggregates the sub-pages don't use + * plus `glucoseByContext` and `lastSeenByType`. + * - `InsightsAnalyticsData` — insights mother page + * (`src/app/insights/page.tsx`); carries `correlations` + + * `healthScore` blocks that the sub-pages also don't use. + * - `ChecklistAnalyticsData` — onboarding checklist component; + * ultra-loose shape that only checks per-type `count` for + * "do you have any data of this kind yet?". + * + * NOTE: we keep four shapes (rather than collapsing into one with + * everything optional) because the call-sites use TypeScript control + * flow to *demand* the fields they need — the slim sub-pages should + * not be able to type-pass an access to `bpInTargetPct`, the checklist + * should not be able to read `correlations`, and the dashboard should + * not be reading `healthScore` without going through the insights + * surface. Three named shapes capture the contract; one swiss-army + * shape loses it. */ import type { DataSummary } from "@/lib/analytics/trends"; +import type { DataSummary as DataSummaryType } from "@/lib/analytics/trends"; +import type { CorrelationResult } from "@/lib/insights/correlations"; +/** Shape consumed by every `/insights//page.tsx`. */ export interface SubPageAnalyticsData { /** `Record` — sentinel value for "not loaded yet" is `undefined`. */ summaries: Record; } + +/** + * Shape consumed by the dashboard root (`src/app/page.tsx`). + * + * `bpInTargetPct*` drive the BD-Zielbereich tile; `lastSeenByType` + * keeps stale-but-still-relevant tiles visible with an explicit + * "Letzter Wert vor …" caption. + */ +export interface DashboardAnalyticsData { + summaries: Record; + bpInTargetPct: number | null; + /** + * v1.4.18 A1 — share of paired BP readings inside target over the + * last 7 / 30 days. Drive the BD-Zielbereich tile's `7T:` / `30T:` + * sub-values; render "—" when the field is null (no paired readings + * in the window). + */ + bpInTargetPct7d?: number | null; + bpInTargetPct30d?: number | null; + /** + * v1.4.22 A1 — long-arc all-time aggregate. After the headline + * re-anchor to last-30-days the all-time number lives as a sub-value + * on the BD-Zielbereich tile (alongside `7d` and `30d`). + */ + bpInTargetPctAllTime?: number | null; + /** + * v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window + * pcts. The BD-Zielbereich tile's comparison-overlay caption picks + * `priorMonth` for `comparisonBaseline === "lastMonth"` and + * `priorYear` for `lastYear` so the rendered "Δ X% vs. last month" + * stays honest. Null when the prior window has no paired readings. + */ + bpInTargetPctPriorMonth?: number | null; + bpInTargetPctPriorYear?: number | null; + glucoseByContext?: Record; + /** + * v1.4.34 IW-B — per-type freshness map from `/api/analytics`. The + * tile-strip helper reads `lastSeenByType[type]?.daysAgo` and forwards + * it to each `` so a metric the user hasn't + * logged in a while keeps its tile visible (with an explicit + * "Letzter Wert vor …" caption) instead of disappearing. + */ + lastSeenByType?: Record< + string, + { lastSeenAt: string; daysAgo: number } | null + >; +} + +/** + * Shape consumed by the insights mother page + * (`src/app/insights/page.tsx`). Carries the correlation + health-score + * blocks the sub-pages don't use. + */ +export interface InsightsAnalyticsData { + summaries: Record; + correlations?: { + bpCompliance: CorrelationResult; + moodPulse: CorrelationResult; + weightWeekday: CorrelationResult; + } | null; + healthScore?: { + score: number; + band: "green" | "yellow" | "red"; + components: { + // v1.4.25 W8e — the optional `source`/`asOf` slots feed the + // provenance accordion. Older clients reading this payload + // happily ignore the extras (additive contract). + bp: { + value: number | null; + weight: number; + source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; + asOf?: string; + }; + weight: { + value: number | null; + weight: number; + source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; + asOf?: string; + }; + mood: { + value: number | null; + weight: number; + source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; + asOf?: string; + }; + compliance: { + value: number | null; + weight: number; + source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; + asOf?: string; + }; + }; + delta: number | null; + } | null; +} + +/** + * Shape consumed by the onboarding checklist + * (`src/components/onboarding/getting-started-checklist.tsx`). + * Intentionally loose — the checklist only asks + * "does any per-type bucket have a non-zero count?". + */ +export interface ChecklistAnalyticsData { + summaries?: Record; +}