177 lines
4.9 KiB
TypeScript
177 lines
4.9 KiB
TypeScript
import { randomInt } from 'node:crypto';
|
|
import type { DayOfWeek } from '@repo/api-contract';
|
|
|
|
export type Slot = {
|
|
startTime: string;
|
|
endTime: string;
|
|
};
|
|
|
|
const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
const BOOKING_CODE_LENGTH = 6;
|
|
|
|
export const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [
|
|
'SUNDAY',
|
|
'MONDAY',
|
|
'TUESDAY',
|
|
'WEDNESDAY',
|
|
'THURSDAY',
|
|
'FRIDAY',
|
|
'SATURDAY',
|
|
];
|
|
|
|
export function toMinutes(value: string): number {
|
|
const [hours, minutes] = value.split(':').map((part) => Number(part));
|
|
return hours * 60 + minutes;
|
|
}
|
|
|
|
export function minutesToTime(minutes: number): string {
|
|
const safeMinutes = Math.max(0, minutes);
|
|
const hours = Math.floor(safeMinutes / 60);
|
|
const mins = safeMinutes % 60;
|
|
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
|
}
|
|
|
|
export function parseIsoDate(date: string): { bookingDate: Date; dayOfWeek: DayOfWeek } {
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
|
|
|
if (!match) {
|
|
throw new Error('La fecha debe tener formato YYYY-MM-DD.');
|
|
}
|
|
|
|
const year = Number(match[1]);
|
|
const month = Number(match[2]);
|
|
const day = Number(match[3]);
|
|
|
|
const bookingDate = new Date(Date.UTC(year, month - 1, day));
|
|
|
|
if (
|
|
Number.isNaN(bookingDate.getTime()) ||
|
|
bookingDate.getUTCFullYear() !== year ||
|
|
bookingDate.getUTCMonth() + 1 !== month ||
|
|
bookingDate.getUTCDate() !== day
|
|
) {
|
|
throw new Error('La fecha enviada no es valida.');
|
|
}
|
|
|
|
const dayOfWeek = DAY_OF_WEEK_BY_INDEX[bookingDate.getUTCDay()];
|
|
|
|
if (!dayOfWeek) {
|
|
throw new Error('No se pudo resolver el dia de la semana.');
|
|
}
|
|
|
|
return { bookingDate, dayOfWeek };
|
|
}
|
|
|
|
export function formatIsoDate(date: Date): string {
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
export function getDayOfWeekValue(date: Date): DayOfWeek {
|
|
const dayOfWeek = DAY_OF_WEEK_BY_INDEX[date.getUTCDay()];
|
|
if (!dayOfWeek) {
|
|
throw new Error('No se pudo resolver el dia de la semana.');
|
|
}
|
|
return dayOfWeek;
|
|
}
|
|
|
|
export function generateBookingCode(): string {
|
|
let code = '';
|
|
for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) {
|
|
code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)];
|
|
}
|
|
return code;
|
|
}
|
|
|
|
export function buildSlots(
|
|
availability: Array<{ startTime: string; endTime: string }>,
|
|
slotDurationMinutes: number
|
|
): Slot[] {
|
|
const slots: Slot[] = [];
|
|
for (const range of availability) {
|
|
const start = toMinutes(range.startTime);
|
|
const end = toMinutes(range.endTime);
|
|
for (
|
|
let current = start;
|
|
current + slotDurationMinutes <= end;
|
|
current += slotDurationMinutes
|
|
) {
|
|
slots.push({
|
|
startTime: minutesToTime(current),
|
|
endTime: minutesToTime(current + slotDurationMinutes),
|
|
});
|
|
}
|
|
}
|
|
return slots;
|
|
}
|
|
|
|
export function hasOverlap(slot: Slot, existing: Slot): boolean {
|
|
return slot.startTime < existing.endTime && slot.endTime > existing.startTime;
|
|
}
|
|
|
|
type PriceableCourt = {
|
|
basePrice: unknown;
|
|
priceRules: Array<{
|
|
dayOfWeek: DayOfWeek | null;
|
|
startTime: string | null;
|
|
endTime: string | null;
|
|
price: unknown;
|
|
}>;
|
|
};
|
|
|
|
export function resolveSlotPrice(court: PriceableCourt, dayOfWeek: DayOfWeek, slot: Slot): number {
|
|
const slotStart = toMinutes(slot.startTime);
|
|
const slotEnd = toMinutes(slot.endTime);
|
|
|
|
const matchingRules = court.priceRules
|
|
.filter((rule) => {
|
|
if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) return false;
|
|
if (!rule.startTime || !rule.endTime) return true;
|
|
return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime);
|
|
})
|
|
.sort((first, second) => {
|
|
const firstSpecificity =
|
|
(first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0);
|
|
const secondSpecificity =
|
|
(second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0);
|
|
return secondSpecificity - firstSpecificity;
|
|
});
|
|
|
|
return Number(matchingRules[0]?.price ?? court.basePrice);
|
|
}
|
|
|
|
export function assertAvailabilityRanges(
|
|
availability: Array<{
|
|
dayOfWeek: DayOfWeek;
|
|
startTime: string;
|
|
endTime: string;
|
|
}>
|
|
) {
|
|
const grouped = new Map<DayOfWeek, Array<{ start: number; end: number }>>();
|
|
|
|
for (const range of availability) {
|
|
const start = toMinutes(range.startTime);
|
|
const end = toMinutes(range.endTime);
|
|
|
|
if (start >= end) {
|
|
throw new Error(`El rango ${range.startTime}-${range.endTime} es invalido.`);
|
|
}
|
|
|
|
const current = grouped.get(range.dayOfWeek) ?? [];
|
|
current.push({ start, end });
|
|
grouped.set(range.dayOfWeek, current);
|
|
}
|
|
|
|
for (const ranges of grouped.values()) {
|
|
ranges.sort((a, b) => a.start - b.start);
|
|
|
|
for (let index = 1; index < ranges.length; index += 1) {
|
|
const previous = ranges[index - 1];
|
|
const current = ranges[index];
|
|
|
|
if (previous.end > current.start) {
|
|
throw new Error('Hay rangos horarios superpuestos para el mismo dia.');
|
|
}
|
|
}
|
|
}
|
|
}
|