mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
The sleep source-discrepancy annotation now reaches the surfaces: SleepNight carries the MAIN session's annotation (naps never cross-compare), the analytics slim slice / dashboard snapshot / iOS summary thread it additively, and the dashboard sleep tile renders the shared discreet marker (extracted from the sleep panel — one implementation, two surfaces). Served totals unchanged, pinned by tests. Apple Health medications groundwork (server side of the medication-import request): IntakeSource gains APPLE_HEALTH; medications accept an external mirror identity (source-exclusive by policy) and dose imports carry the HK dose-event UUID — both idempotent per stable key, with the concurrent-create race resolved to the winner. Migration 0239, additive only. Entries without the new fields behave byte-identically. Hardening: doctor-report aggregation + PDF charts compute ranges with loops instead of spread calls — a report over a sample-dense year cannot overflow the call stack (the class fixed for the Google Health full sync).
138 lines
5.7 KiB
TypeScript
138 lines
5.7 KiB
TypeScript
/**
|
|
* v1.4.39.3 — pure helper that merges the dashboard's slim
|
|
* (`/api/analytics?slice=summaries`) and thick (`/api/analytics`)
|
|
* payloads into the single shape the dashboard tile-strip + chart row
|
|
* consumes.
|
|
*
|
|
* Background — v1.4.39.2 split `useAnalyticsQuery` into two parallel
|
|
* mounts so the per-type tile strip paints as soon as the slim slice
|
|
* lands and the BD-Zielbereich + glucose tiles stream in from the
|
|
* thick slice afterwards. The inline `useMemo` that performed the
|
|
* merge originally used `slim?.summaries ?? thick?.summaries` —
|
|
* structurally correct on production where both routes agree, but
|
|
* silently buggy when slim resolves first with an *empty* `{}` (e.g.
|
|
* a freshly-seeded tenant whose rollup tier hasn't folded yet, or a
|
|
* test environment that mocks thick but not slim). The empty object
|
|
* is truthy by JS semantics so `??` short-circuited and the tile
|
|
* strip painted blank even though thick carried the full payload —
|
|
* exactly the regression the v1.4.39.3 e2e CI surfaced across eight
|
|
* dashboard / chart specs.
|
|
*
|
|
* Post-fix: object emptiness is the discriminator. A non-empty slim
|
|
* payload still wins on overlapping fields (the v1.4.39.2 progressive-
|
|
* paint contract). An empty slim falls through to thick. When both
|
|
* are empty, the caller receives an empty record and the dashboard's
|
|
* data-floor gates render the appropriate empty state.
|
|
*
|
|
* Kept as a pure helper so the regression is verified with a tight
|
|
* unit test (`merge-slim-thick.test.ts`) rather than a source-text
|
|
* snapshot — string matching against `src/app/page.tsx` would not
|
|
* have caught the empty-object short-circuit bug.
|
|
*/
|
|
import type { DataSummary } from "@/lib/analytics/trends";
|
|
import type { SleepSourceDiscrepancy } from "@/lib/analytics/sleep-night";
|
|
|
|
export interface AnalyticsSlimLike {
|
|
summaries?: Record<string, DataSummary> | null;
|
|
lastSeenByType?: Record<
|
|
string,
|
|
{ lastSeenAt: string; daysAgo: number } | null
|
|
> | null;
|
|
/**
|
|
* v1.28.x — latest-night source-discrepancy annotation, carried by
|
|
* both slices (the slim post-pass computes it; the thick body passes
|
|
* it through). Additive and null-safe.
|
|
*/
|
|
sleepSourceDiscrepancy?: SleepSourceDiscrepancy | null;
|
|
}
|
|
|
|
export interface AnalyticsThickLike extends AnalyticsSlimLike {
|
|
bpInTargetPct?: number | null;
|
|
bpInTargetPct7d?: number | null;
|
|
bpInTargetPct30d?: number | null;
|
|
bpInTargetPctAllTime?: number | null;
|
|
bpInTargetPctPriorMonth?: number | null;
|
|
bpInTargetPctPriorYear?: number | null;
|
|
bpInTargetCount90?: number | null;
|
|
bpInTargetSpanDays90?: number | null;
|
|
glucoseByContext?: Record<string, unknown> | null;
|
|
}
|
|
|
|
export interface MergedDashboardAnalytics {
|
|
summaries: Record<string, DataSummary>;
|
|
lastSeenByType?:
|
|
Record<string, { lastSeenAt: string; daysAgo: number } | null> | undefined;
|
|
bpInTargetPct: number | null;
|
|
bpInTargetPct7d: number | null;
|
|
bpInTargetPct30d: number | null;
|
|
bpInTargetPctAllTime: number | null;
|
|
bpInTargetPctPriorMonth: number | null;
|
|
bpInTargetPctPriorYear: number | null;
|
|
bpInTargetCount90: number | null;
|
|
bpInTargetSpanDays90: number | null;
|
|
glucoseByContext: Record<string, unknown> | undefined;
|
|
sleepSourceDiscrepancy: SleepSourceDiscrepancy | null;
|
|
}
|
|
|
|
function hasContent(
|
|
record: Record<string, unknown> | null | undefined,
|
|
): boolean {
|
|
return record != null && Object.keys(record).length > 0;
|
|
}
|
|
|
|
/**
|
|
* Merge a slim and a thick analytics payload into the shape the
|
|
* dashboard renders against. Either input may be `undefined`
|
|
* (TanStack query unresolved) or carry an empty record (zero-data
|
|
* tenant); the helper falls back to whichever side has content
|
|
* without ever returning a payload that ignores a populated source.
|
|
*
|
|
* Returns `undefined` when neither input has resolved yet so the
|
|
* caller can keep its loading-state branch unchanged.
|
|
*/
|
|
export function mergeSlimAndThickAnalytics(
|
|
slim: AnalyticsSlimLike | undefined,
|
|
thick: AnalyticsThickLike | undefined,
|
|
): MergedDashboardAnalytics | undefined {
|
|
if (!slim && !thick) return undefined;
|
|
|
|
const slimSummariesHaveContent = hasContent(slim?.summaries ?? null);
|
|
const summaries: Record<string, DataSummary> = slimSummariesHaveContent
|
|
? (slim!.summaries as Record<string, DataSummary>)
|
|
: ((thick?.summaries as Record<string, DataSummary> | null | undefined) ??
|
|
(slim?.summaries as Record<string, DataSummary> | null | undefined) ??
|
|
{});
|
|
|
|
const slimFreshnessHasContent = hasContent(slim?.lastSeenByType ?? null);
|
|
const lastSeenByType:
|
|
Record<string, { lastSeenAt: string; daysAgo: number } | null> | undefined =
|
|
slimFreshnessHasContent
|
|
? (slim!.lastSeenByType as Record<
|
|
string,
|
|
{ lastSeenAt: string; daysAgo: number } | null
|
|
>)
|
|
: (thick?.lastSeenByType ?? undefined);
|
|
|
|
return {
|
|
summaries,
|
|
lastSeenByType,
|
|
bpInTargetPct: thick?.bpInTargetPct ?? null,
|
|
bpInTargetPct7d: thick?.bpInTargetPct7d ?? null,
|
|
bpInTargetPct30d: thick?.bpInTargetPct30d ?? null,
|
|
bpInTargetPctAllTime: thick?.bpInTargetPctAllTime ?? null,
|
|
bpInTargetPctPriorMonth: thick?.bpInTargetPctPriorMonth ?? null,
|
|
bpInTargetPctPriorYear: thick?.bpInTargetPctPriorYear ?? null,
|
|
bpInTargetCount90: thick?.bpInTargetCount90 ?? null,
|
|
bpInTargetSpanDays90: thick?.bpInTargetSpanDays90 ?? null,
|
|
glucoseByContext: thick?.glucoseByContext ?? undefined,
|
|
// Rides the same discriminator as `summaries`: whichever slice
|
|
// supplied the summaries also supplies the sleep annotation, so the
|
|
// marker can never point at a night the rendered headline doesn't
|
|
// show. `?? null` tolerates older cached payloads without the field.
|
|
sleepSourceDiscrepancy:
|
|
(slimSummariesHaveContent
|
|
? slim!.sleepSourceDiscrepancy
|
|
: thick?.sleepSourceDiscrepancy) ?? null,
|
|
};
|
|
}
|