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 { analyticsSnapshots } from '@/lib/db/schema/insights';
|
||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
import { invoices } from '@/lib/db/schema/financial';
|
import { invoices } from '@/lib/db/schema/financial';
|
||||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
|
||||||
import { PIPELINE_STAGES } from '@/lib/constants';
|
import { PIPELINE_STAGES } from '@/lib/constants';
|
||||||
import {
|
import {
|
||||||
ALL_RANGES,
|
ALL_RANGES,
|
||||||
@@ -172,37 +171,45 @@ export async function computeOccupancyTimeline(
|
|||||||
range: DateRange,
|
range: DateRange,
|
||||||
): Promise<OccupancyTimelineData> {
|
): Promise<OccupancyTimelineData> {
|
||||||
const { from, to } = rangeToBounds(range);
|
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).
|
// Total berths per port (current count - assumes no churn).
|
||||||
const totalRow = await db.execute<{ total: number }>(
|
const totalRow = await db.execute<{ total: number }>(
|
||||||
sql`SELECT count(*)::int AS total FROM berths WHERE port_id = ${portId}`,
|
sql`SELECT count(*)::int AS total FROM berths WHERE port_id = ${portId}`,
|
||||||
);
|
);
|
||||||
const total = totalRow[0]?.total ?? 0;
|
const total = totalRow[0]?.total ?? 0;
|
||||||
|
|
||||||
// For each day in range, count berths that have an active reservation
|
// Single-query implementation: generate_series for the date range and
|
||||||
// covering that day. A reservation is "covering" if start_date <= day
|
// LEFT JOIN active reservations whose [start_date, end_date] window
|
||||||
// AND (end_date IS NULL OR end_date >= day). Walk forward from `from`
|
// covers each day. Returns every day's occupancy count in one round
|
||||||
// so custom ranges produce the right calendar days, not just N
|
// trip; replaces the prior per-day loop that fired N queries (30 for
|
||||||
// most-recent days from "now".
|
// .30d, 90 for .90d) and saturated the postgres pool.
|
||||||
const points: OccupancyTimelineData['points'] = [];
|
const fromStr = from.toISOString().slice(0, 10);
|
||||||
for (let i = 0; i < days; i++) {
|
const toStr = new Date(to.getTime() - 86_400_000).toISOString().slice(0, 10);
|
||||||
const day = new Date(from.getTime() + i * 86_400_000);
|
const rows = await db.execute<{ day: string; occupied: number }>(
|
||||||
const dayStr = day.toISOString().slice(0, 10);
|
sql`
|
||||||
const occRow = await db
|
WITH days AS (
|
||||||
.select({ occupied: sql<number>`count(distinct ${berthReservations.berthId})::int` })
|
SELECT generate_series(${fromStr}::date, ${toStr}::date, '1 day'::interval)::date AS day
|
||||||
.from(berthReservations)
|
),
|
||||||
.where(
|
active_reservations AS (
|
||||||
and(
|
SELECT berth_id, start_date, end_date
|
||||||
eq(berthReservations.portId, portId),
|
FROM berth_reservations
|
||||||
eq(berthReservations.status, 'active'),
|
WHERE port_id = ${portId} AND status = 'active'
|
||||||
sql`${berthReservations.startDate} <= ${dayStr}::date`,
|
)
|
||||||
sql`(${berthReservations.endDate} IS NULL OR ${berthReservations.endDate} >= ${dayStr}::date)`,
|
SELECT
|
||||||
),
|
to_char(days.day, 'YYYY-MM-DD') AS day,
|
||||||
);
|
COUNT(DISTINCT ar.berth_id)::int AS occupied
|
||||||
const occupied = occRow[0]?.occupied ?? 0;
|
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;
|
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 };
|
return { points };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user