107 lines
2.9 KiB
TypeScript
107 lines
2.9 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<T extends SnapshotData>(
|
||
|
|
portId: string,
|
||
|
|
metricId: MetricId,
|
||
|
|
): Promise<T | null> {
|
||
|
|
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<void> {
|
||
|
|
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<PipelineFunnelData> {
|
||
|
|
return { stages: [] };
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function computeOccupancyTimeline(
|
||
|
|
_portId: string,
|
||
|
|
_range: DateRange,
|
||
|
|
): Promise<OccupancyTimelineData> {
|
||
|
|
return { points: [] };
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function computeRevenueBreakdown(
|
||
|
|
_portId: string,
|
||
|
|
_range: DateRange,
|
||
|
|
): Promise<RevenueBreakdownData> {
|
||
|
|
return { bars: [] };
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function computeLeadSourceAttribution(
|
||
|
|
_portId: string,
|
||
|
|
_range: DateRange,
|
||
|
|
): Promise<LeadSourceAttributionData> {
|
||
|
|
return { slices: [] };
|
||
|
|
}
|