diff --git a/src/components/insights/metric-range-delta.tsx b/src/components/insights/metric-range-delta.tsx index 070e71eec..477ce1aea 100644 --- a/src/components/insights/metric-range-delta.tsx +++ b/src/components/insights/metric-range-delta.tsx @@ -10,7 +10,7 @@ import { type TrendDirectionSentiment, } from "@/lib/insights/trend-sentiment"; import type { AnalyticsRangeData } from "@/hooks/use-analytics-range"; -import type { AnalyticsRange } from "@/lib/analytics/range-delta"; +import type { AnalyticsRange } from "@/lib/analytics/range-shared"; /** * v1.9.0 — period-over-period delta caption beside the time-range pills. diff --git a/src/components/insights/time-range-pills.tsx b/src/components/insights/time-range-pills.tsx index 32630c9ff..06015d9f2 100644 --- a/src/components/insights/time-range-pills.tsx +++ b/src/components/insights/time-range-pills.tsx @@ -7,7 +7,7 @@ import { cn } from "@/lib/utils"; import { ANALYTICS_RANGES, type AnalyticsRange, -} from "@/lib/analytics/range-delta"; +} from "@/lib/analytics/range-shared"; /** * v1.9.0 — `7d / 30d / 90d / 1y` segmented control for the Insights metric diff --git a/src/hooks/use-analytics-range.ts b/src/hooks/use-analytics-range.ts index dfb38bc3d..93ac680c0 100644 --- a/src/hooks/use-analytics-range.ts +++ b/src/hooks/use-analytics-range.ts @@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; -import type { AnalyticsRange } from "@/lib/analytics/range-delta"; +import type { AnalyticsRange } from "@/lib/analytics/range-shared"; /** * v1.9.0 — client reader for the single-metric period-over-period range diff --git a/src/hooks/use-insights-layout-prefs.ts b/src/hooks/use-insights-layout-prefs.ts index c863d24b1..bf347e758 100644 --- a/src/hooks/use-insights-layout-prefs.ts +++ b/src/hooks/use-insights-layout-prefs.ts @@ -12,7 +12,7 @@ import { import { ANALYTICS_RANGES, type AnalyticsRange, -} from "@/lib/analytics/range-delta"; +} from "@/lib/analytics/range-shared"; const RANGE_STORAGE_KEY = "healthlog.insights.range"; const DEFAULT_RANGE: AnalyticsRange = "30d"; diff --git a/src/lib/analytics/range-delta.ts b/src/lib/analytics/range-delta.ts index 2132e4778..88d675c53 100644 --- a/src/lib/analytics/range-delta.ts +++ b/src/lib/analytics/range-delta.ts @@ -33,53 +33,27 @@ import { readBestGranularityRollups, type RollupBucketRow, } from "@/lib/rollups/measurement-read-wmy"; +import { + ANALYTICS_RANGES, + rangeWindowDays, + type AnalyticsRange, + type RangeDeltaResult, + type WindowAggregate, +} from "@/lib/analytics/range-shared"; -const DAY_MS = 24 * 60 * 60 * 1000; - -/** The user-selectable ranges. */ -export const ANALYTICS_RANGES = ["7d", "30d", "90d", "1y"] as const; -export type AnalyticsRange = (typeof ANALYTICS_RANGES)[number]; - -/** Trailing-window length, in days, for each range. */ -const RANGE_WINDOW_DAYS: Record = { - "7d": 7, - "30d": 30, - "90d": 90, - "1y": 365, +// The range constants + result shapes live in the client-safe +// `range-shared` module so the insights client bundle never pulls the +// server-only rollup readers (and `pg`) through a range import. Re-exported +// here so existing server-side callers keep importing from `range-delta`. +export { + ANALYTICS_RANGES, + rangeWindowDays, + type AnalyticsRange, + type RangeDeltaResult, + type WindowAggregate, }; -export function rangeWindowDays(range: AnalyticsRange): number { - return RANGE_WINDOW_DAYS[range]; -} - -/** A composed window aggregate. `mean`/etc. are null when the window is empty. */ -export interface WindowAggregate { - count: number; - min: number | null; - max: number | null; - mean: number | null; - sum: number | null; -} - -export interface RangeDeltaResult { - range: AnalyticsRange; - windowDays: number; - /** The granularity the underlying rollup read resolved against. */ - granularity: string; - current: WindowAggregate; - previous: WindowAggregate; - /** - * `current.mean - previous.mean`, or null when either window has no mean - * (no data in the current or prior window) — never a misleading 0. - */ - delta: number | null; - /** - * `delta / previous.mean` as a fraction (0.03 = +3 %), or null when the - * prior window has no mean / a zero mean (no divide-by-zero, no misleading - * 0 %). The client renders "no prior-period data" in that case. - */ - deltaPct: number | null; -} +const DAY_MS = 24 * 60 * 60 * 1000; /** * Slice the resolved buckets into the current half (`[now-N, now)`) and the diff --git a/src/lib/analytics/range-shared.ts b/src/lib/analytics/range-shared.ts new file mode 100644 index 000000000..1aa355c91 --- /dev/null +++ b/src/lib/analytics/range-shared.ts @@ -0,0 +1,58 @@ +/** + * v1.9.0 — client-safe range constants and types for the Insights metric + * pages. + * + * The user-selectable range list, its type, the per-range window length, and + * the period-over-period result shapes are shared between the client (the + * pills, the delta caption, the range hook, the layout-prefs hook) and the + * server (`/api/analytics/range` + `range-delta.ts`). This module imports + * NOTHING server-side — no rollup readers, no `db`, no Prisma client — so the + * client bundle never transitively pulls `pg` (dns/fs/net/tls) through a range + * import. The server-only window-composition logic lives in `range-delta.ts`, + * which re-exports these names for its own callers. + */ + +/** The user-selectable ranges. */ +export const ANALYTICS_RANGES = ["7d", "30d", "90d", "1y"] as const; +export type AnalyticsRange = (typeof ANALYTICS_RANGES)[number]; + +/** Trailing-window length, in days, for each range. */ +const RANGE_WINDOW_DAYS: Record = { + "7d": 7, + "30d": 30, + "90d": 90, + "1y": 365, +}; + +export function rangeWindowDays(range: AnalyticsRange): number { + return RANGE_WINDOW_DAYS[range]; +} + +/** A composed window aggregate. `mean`/etc. are null when the window is empty. */ +export interface WindowAggregate { + count: number; + min: number | null; + max: number | null; + mean: number | null; + sum: number | null; +} + +export interface RangeDeltaResult { + range: AnalyticsRange; + windowDays: number; + /** The granularity the underlying rollup read resolved against. */ + granularity: string; + current: WindowAggregate; + previous: WindowAggregate; + /** + * `current.mean - previous.mean`, or null when either window has no mean + * (no data in the current or prior window) — never a misleading 0. + */ + delta: number | null; + /** + * `delta / previous.mean` as a fraction (0.03 = +3 %), or null when the + * prior window has no mean / a zero mean (no divide-by-zero, no misleading + * 0 %). The client renders "no prior-period data" in that case. + */ + deltaPct: number | null; +}