diff --git a/src/lib/services/analytics.service.ts b/src/lib/services/analytics.service.ts index ee03aa9f..d5f09ca8 100644 --- a/src/lib/services/analytics.service.ts +++ b/src/lib/services/analytics.service.ts @@ -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 { 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`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 }; }