feat(analytics): real computations + 15-min snapshot refresh job
PR3 of Phase B. Replaces the no-op stubs in analytics.service.ts with
working drizzle queries and adds the recurring BullMQ job that warms
the cache.
Computations:
- computePipelineFunnel: groups interests by pipeline_stage filtered by
port + range + not archived; emits 8-row stages array with conversion
pct relative to 'open' as the funnel top.
- computeOccupancyTimeline: per day in range, counts berths covered by
an active reservation (start_date ≤ day, end_date IS NULL OR ≥ day);
emits {date, occupied, total, occupancyPct}.
- computeRevenueBreakdown: sums invoices.total grouped by status +
currency; filters out archived rows.
- computeLeadSourceAttribution: counts interests by source descending;
null source bucketed as 'unspecified'.
Public API (getPipelineFunnel, getOccupancyTimeline, etc.) reads
analytics_snapshots first; falls back to compute + writeSnapshot. TTL
15 minutes (matches the cron interval).
Cron:
- queue/scheduler.ts registers 'analytics-refresh' on maintenance with
pattern '*/15 * * * *'.
- queue/workers/maintenance.ts dispatches to refreshSnapshotsForPort
for every port; per-port try/catch so one bad port doesn't kill the
sweep.
Tests: tests/integration/analytics-service.test.ts (9 cases). Pipeline
funnel math (incl. zero state), occupancy timeline shape/percentages
with seeded reservations, revenue grouped by status + currency, lead
source attribution incl. null bucketing, cache hit (mutate snapshot
directly → next read returns mutated value), refreshSnapshotsForPort
warms every metric×range combo.
Vitest 690/690 (+9). tsc + lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,20 @@ export const maintenanceWorker = new Worker(
|
||||
logger.info(summary, 'Alert engine sweep complete');
|
||||
break;
|
||||
}
|
||||
case 'analytics-refresh': {
|
||||
const { ports } = await import('@/lib/db/schema/ports');
|
||||
const { refreshSnapshotsForPort } = await import('@/lib/services/analytics.service');
|
||||
const allPorts = await db.select({ id: ports.id }).from(ports);
|
||||
for (const p of allPorts) {
|
||||
try {
|
||||
await refreshSnapshotsForPort(p.id);
|
||||
} catch (err) {
|
||||
logger.warn({ portId: p.id, err }, 'Analytics refresh failed for port');
|
||||
}
|
||||
}
|
||||
logger.info({ count: allPorts.length }, 'Analytics snapshot refresh complete');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
logger.warn({ jobName: job.name }, 'Unknown maintenance job');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user