/** * Phase B analytics service. Reads pre-computed snapshots from * `analytics_snapshots` keyed by `metric_id` and recomputes on demand if * the cached row is older than `SNAPSHOT_TTL_MS`. The recomputation jobs * land in `analytics-snapshot-job.ts` (PR3). */ import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { analyticsSnapshots } from '@/lib/db/schema/insights'; export type DateRange = '7d' | '30d' | '90d' | 'today'; export type MetricId = | `pipeline_funnel.${DateRange}` | `occupancy_timeline.${DateRange}` | `revenue_breakdown.${DateRange}` | `lead_source_attribution.${DateRange}`; export const SNAPSHOT_TTL_MS = 15 * 60 * 1000; // 15 minutes export interface PipelineFunnelData { stages: Array<{ stage: string; count: number; conversionPct: number }>; } export interface OccupancyTimelineData { points: Array<{ date: string; available: number; underOffer: number; sold: number }>; } export interface RevenueBreakdownData { bars: Array<{ category: string; amount: number; currency: string }>; } export interface LeadSourceAttributionData { slices: Array<{ source: string; count: number }>; } export type SnapshotData = | PipelineFunnelData | OccupancyTimelineData | RevenueBreakdownData | LeadSourceAttributionData; /** * Read a snapshot by `(portId, metricId)`. Returns null when missing or * stale; the caller should request a recompute (or the recurring job * eventually fills it). */ export async function readSnapshot( portId: string, metricId: MetricId, ): Promise { const row = await db.query.analyticsSnapshots.findFirst({ where: and(eq(analyticsSnapshots.portId, portId), eq(analyticsSnapshots.metricId, metricId)), }); if (!row) return null; const age = Date.now() - row.computedAt.getTime(); if (age > SNAPSHOT_TTL_MS) return null; return row.data as T; } export async function writeSnapshot( portId: string, metricId: MetricId, data: SnapshotData, ): Promise { await db .insert(analyticsSnapshots) .values({ portId, metricId, data }) .onConflictDoUpdate({ target: [analyticsSnapshots.portId, analyticsSnapshots.metricId], set: { data, computedAt: new Date() }, }); } // Computation entrypoints — bodies land in PR3 along with the recurring // snapshot job. Exported as no-op stubs so PR1's tsc/lint stay green. export async function computePipelineFunnel( _portId: string, _range: DateRange, ): Promise { return { stages: [] }; } export async function computeOccupancyTimeline( _portId: string, _range: DateRange, ): Promise { return { points: [] }; } export async function computeRevenueBreakdown( _portId: string, _range: DateRange, ): Promise { return { bars: [] }; } export async function computeLeadSourceAttribution( _portId: string, _range: DateRange, ): Promise { return { slices: [] }; }