add car reservation feature

This commit is contained in:
root
2026-05-09 21:10:38 -04:00
parent 09b0e3b55f
commit 6322b7d2a1
37 changed files with 1169 additions and 119 deletions
@@ -0,0 +1,150 @@
import { prisma } from '../lib/prisma'
const BLOCKING_RESERVATION_STATUSES = ['DRAFT', 'CONFIRMED', 'ACTIVE'] as const
type AvailabilityStatus = 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
type DateRange = {
startDate: Date
endDate: Date
}
type AvailabilitySource = {
startDate: Date
endDate: Date
kind: 'RESERVATION' | 'MAINTENANCE' | 'BLOCK'
}
export interface VehicleAvailabilitySummary {
available: boolean
status: AvailabilityStatus
nextAvailableAt: Date | null
blockingReason: 'RESERVATION' | 'MAINTENANCE' | 'BLOCK' | 'STATUS' | null
}
function startOfTodayUtc() {
const now = new Date()
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
}
function overlaps(range: DateRange, source: AvailabilitySource) {
return source.startDate < range.endDate && source.endDate > range.startDate
}
function getNextAvailableAt(anchor: Date, sources: AvailabilitySource[]) {
const sorted = sources
.filter((source) => source.endDate > anchor)
.sort((a, b) => {
const startDelta = a.startDate.getTime() - b.startDate.getTime()
if (startDelta !== 0) return startDelta
return a.endDate.getTime() - b.endDate.getTime()
})
let candidate = anchor
let advanced = false
for (const source of sorted) {
if (source.endDate <= candidate) continue
if (source.startDate > candidate) break
candidate = new Date(Math.max(candidate.getTime(), source.endDate.getTime()))
advanced = true
}
return advanced ? candidate : null
}
export async function getVehicleAvailabilitySummary(
vehicleId: string,
{
range,
includeDraftReservations = true,
}: {
range?: DateRange
includeDraftReservations?: boolean
} = {},
): Promise<VehicleAvailabilitySummary> {
const vehicle = await prisma.vehicle.findUniqueOrThrow({
where: { id: vehicleId },
select: { status: true },
})
if (vehicle.status === 'OUT_OF_SERVICE') {
return {
available: false,
status: 'UNAVAILABLE',
nextAvailableAt: null,
blockingReason: 'STATUS',
}
}
const anchor = range?.startDate ?? startOfTodayUtc()
const reservationStatuses = includeDraftReservations
? [...BLOCKING_RESERVATION_STATUSES]
: BLOCKING_RESERVATION_STATUSES.filter((status) => status !== 'DRAFT')
const [reservations, blocks] = await Promise.all([
prisma.reservation.findMany({
where: {
vehicleId,
status: { in: reservationStatuses },
endDate: { gt: anchor },
},
select: { startDate: true, endDate: true },
orderBy: { startDate: 'asc' },
}),
prisma.vehicleCalendarBlock.findMany({
where: {
vehicleId,
endDate: { gt: anchor },
},
select: { startDate: true, endDate: true, type: true },
orderBy: { startDate: 'asc' },
}),
])
const sources: AvailabilitySource[] = [
...reservations.map((reservation) => ({
startDate: reservation.startDate,
endDate: reservation.endDate,
kind: 'RESERVATION' as const,
})),
...blocks.map((block) => ({
startDate: block.startDate,
endDate: block.endDate,
kind: block.type === 'MAINTENANCE' ? 'MAINTENANCE' as const : 'BLOCK' as const,
})),
]
const overlappingSources = range ? sources.filter((source) => overlaps(range, source)) : sources.filter((source) => source.startDate <= anchor && source.endDate > anchor)
const currentSource = overlappingSources.sort((a, b) => {
const priority = { MAINTENANCE: 0, BLOCK: 1, RESERVATION: 2 }
return priority[a.kind] - priority[b.kind]
})[0]
if (vehicle.status === 'MAINTENANCE') {
return {
available: false,
status: 'MAINTENANCE',
nextAvailableAt: getNextAvailableAt(anchor, sources) ?? null,
blockingReason: currentSource?.kind ?? 'STATUS',
}
}
if (!currentSource) {
return {
available: true,
status: 'AVAILABLE',
nextAvailableAt: null,
blockingReason: null,
}
}
const nextAvailableAnchor = currentSource.startDate > anchor ? currentSource.startDate : anchor
return {
available: false,
status: currentSource.kind === 'RESERVATION' ? 'RESERVED' : currentSource.kind === 'MAINTENANCE' ? 'MAINTENANCE' : 'UNAVAILABLE',
nextAvailableAt: getNextAvailableAt(nextAvailableAnchor, sources),
blockingReason: currentSource.kind,
}
}