perf(analytics): collapse 30-day occupancy timeline into single GROUP BY query
The dashboard's occupancy-timeline metric was firing N separate queries (one per day, 30 for .30d / 90 for .90d) that saturated the postgres pool and stalled every other request in the app. Replace with a single query using generate_series for the date range + LEFT JOIN onto active reservations + COUNT(DISTINCT berth_id) GROUP BY day. Same data, ~30× fewer queries on .30d, ~90× fewer on .90d. The snapshot cache layer still applies, so cached reads are still zero-DB. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,6 @@ import { db } from '@/lib/db';
|
||||
import { analyticsSnapshots } from '@/lib/db/schema/insights';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { invoices } from '@/lib/db/schema/financial';
|
||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||
import { PIPELINE_STAGES } from '@/lib/constants';
|
||||
import {
|
||||
ALL_RANGES,
|
||||
@@ -172,37 +171,45 @@ export async function computeOccupancyTimeline(
|
||||
range: DateRange,
|
||||
): Promise<OccupancyTimelineData> {
|
||||
const { from, to } = rangeToBounds(range);
|
||||
const days = Math.max(1, Math.ceil((to.getTime() - from.getTime()) / 86_400_000));
|
||||
// Total berths per port (current count - assumes no churn).
|
||||
const totalRow = await db.execute<{ total: number }>(
|
||||
sql`SELECT count(*)::int AS total FROM berths WHERE port_id = ${portId}`,
|
||||
);
|
||||
const total = totalRow[0]?.total ?? 0;
|
||||
|
||||
// For each day in range, count berths that have an active reservation
|
||||
// covering that day. A reservation is "covering" if start_date <= day
|
||||
// AND (end_date IS NULL OR end_date >= day). Walk forward from `from`
|
||||
// so custom ranges produce the right calendar days, not just N
|
||||
// most-recent days from "now".
|
||||
const points: OccupancyTimelineData['points'] = [];
|
||||
for (let i = 0; i < days; i++) {
|
||||
const day = new Date(from.getTime() + i * 86_400_000);
|
||||
const dayStr = day.toISOString().slice(0, 10);
|
||||
const occRow = await db
|
||||
.select({ occupied: sql<number>`count(distinct ${berthReservations.berthId})::int` })
|
||||
.from(berthReservations)
|
||||
.where(
|
||||
and(
|
||||
eq(berthReservations.portId, portId),
|
||||
eq(berthReservations.status, 'active'),
|
||||
sql`${berthReservations.startDate} <= ${dayStr}::date`,
|
||||
sql`(${berthReservations.endDate} IS NULL OR ${berthReservations.endDate} >= ${dayStr}::date)`,
|
||||
),
|
||||
);
|
||||
const occupied = occRow[0]?.occupied ?? 0;
|
||||
// Single-query implementation: generate_series for the date range and
|
||||
// LEFT JOIN active reservations whose [start_date, end_date] window
|
||||
// covers each day. Returns every day's occupancy count in one round
|
||||
// trip; replaces the prior per-day loop that fired N queries (30 for
|
||||
// .30d, 90 for .90d) and saturated the postgres pool.
|
||||
const fromStr = from.toISOString().slice(0, 10);
|
||||
const toStr = new Date(to.getTime() - 86_400_000).toISOString().slice(0, 10);
|
||||
const rows = await db.execute<{ day: string; occupied: number }>(
|
||||
sql`
|
||||
WITH days AS (
|
||||
SELECT generate_series(${fromStr}::date, ${toStr}::date, '1 day'::interval)::date AS day
|
||||
),
|
||||
active_reservations AS (
|
||||
SELECT berth_id, start_date, end_date
|
||||
FROM berth_reservations
|
||||
WHERE port_id = ${portId} AND status = 'active'
|
||||
)
|
||||
SELECT
|
||||
to_char(days.day, 'YYYY-MM-DD') AS day,
|
||||
COUNT(DISTINCT ar.berth_id)::int AS occupied
|
||||
FROM days
|
||||
LEFT JOIN active_reservations ar
|
||||
ON ar.start_date <= days.day
|
||||
AND (ar.end_date IS NULL OR ar.end_date >= days.day)
|
||||
GROUP BY days.day
|
||||
ORDER BY days.day
|
||||
`,
|
||||
);
|
||||
const points: OccupancyTimelineData['points'] = rows.map((r) => {
|
||||
const occupied = Number(r.occupied) || 0;
|
||||
const occupancyPct = total === 0 ? 0 : Math.round((occupied / total) * 1000) / 10;
|
||||
points.push({ date: dayStr, occupied, total, occupancyPct });
|
||||
}
|
||||
return { date: r.day, occupied, total, occupancyPct };
|
||||
});
|
||||
return { points };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user