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:
2026-05-11 15:40:44 +02:00
parent 880c5cbafc
commit f9980900b1

View File

@@ -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 };
}