From b2f9a14b8730889789a492fcc60ccb6e541b3f41 Mon Sep 17 00:00:00 2001
From: Jose Selesan
Date: Mon, 8 Jun 2026 15:28:49 -0300
Subject: [PATCH 1/3] refactor(booking): remove maintenance-related metrics and
UI components
---
apps/frontend/src/components/ui/select.tsx | 2 +-
.../src/features/booking/booking-provider.tsx | 11 ++-----
.../src/features/booking/booking.types.ts | 7 ++--
.../components/booking-day-summary.tsx | 21 ++----------
.../booking/components/booking-mobile.tsx | 32 ++++---------------
.../components/booking-status-legend.tsx | 1 -
.../booking/components/booking-timeline.tsx | 9 ++----
.../booking/components/booking-toolbar.tsx | 10 ++----
.../public-booking/public-booking-page.tsx | 12 ++-----
apps/frontend/src/index.css | 9 ------
10 files changed, 19 insertions(+), 95 deletions(-)
diff --git a/apps/frontend/src/components/ui/select.tsx b/apps/frontend/src/components/ui/select.tsx
index 41f8c37..a911e86 100644
--- a/apps/frontend/src/components/ui/select.tsx
+++ b/apps/frontend/src/components/ui/select.tsx
@@ -35,7 +35,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
- "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "flex w-full items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
diff --git a/apps/frontend/src/features/booking/booking-provider.tsx b/apps/frontend/src/features/booking/booking-provider.tsx
index dd725a6..1b34655 100644
--- a/apps/frontend/src/features/booking/booking-provider.tsx
+++ b/apps/frontend/src/features/booking/booking-provider.tsx
@@ -210,20 +210,14 @@ function buildSegmentsForCourt(
function getSummary(schedules: BookingCourtSchedule[]): BookingSummary {
const freeSlots = schedules.reduce((total, schedule) => total + schedule.metrics.free, 0);
const reservedSlots = schedules.reduce((total, schedule) => total + schedule.metrics.reserved, 0);
- const maintenanceSlots = schedules.reduce(
- (total, schedule) => total + schedule.metrics.maintenance,
- 0
- );
- const totalSegments = freeSlots + reservedSlots + maintenanceSlots || 1;
+ const totalSegments = freeSlots + reservedSlots || 1;
return {
- totalReservations: reservedSlots + maintenanceSlots + freeSlots,
+ totalReservations: freeSlots + reservedSlots,
freeSlots,
reservedSlots,
- maintenanceSlots,
freePercent: Math.round((freeSlots / totalSegments) * 100),
reservedPercent: Math.round((reservedSlots / totalSegments) * 100),
- maintenancePercent: Math.round((maintenanceSlots / totalSegments) * 100),
};
}
@@ -275,7 +269,6 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
const metrics = {
free: segments.filter((segment) => segment.status === 'free').length,
reserved: segments.filter((segment) => segment.status === 'reserved').length,
- maintenance: segments.filter((segment) => segment.status === 'maintenance').length,
};
return { court, segments, metrics };
diff --git a/apps/frontend/src/features/booking/booking.types.ts b/apps/frontend/src/features/booking/booking.types.ts
index a933d99..9942861 100644
--- a/apps/frontend/src/features/booking/booking.types.ts
+++ b/apps/frontend/src/features/booking/booking.types.ts
@@ -1,8 +1,8 @@
import type { AdminBooking, Court } from '@repo/api-contract';
export type BookingViewMode = 'panel' | 'status';
-export type BookingStatusFilter = 'all' | 'free' | 'reserved' | 'maintenance';
-export type BookingSegmentStatus = 'free' | 'reserved' | 'maintenance';
+export type BookingStatusFilter = 'all' | 'free' | 'reserved';
+export type BookingSegmentStatus = 'free' | 'reserved';
export interface BookingTimeRange {
start: string;
@@ -13,16 +13,13 @@ export interface BookingSummary {
totalReservations: number;
freeSlots: number;
reservedSlots: number;
- maintenanceSlots: number;
freePercent: number;
reservedPercent: number;
- maintenancePercent: number;
}
export interface BookingCourtMetrics {
free: number;
reserved: number;
- maintenance: number;
}
export interface BookingTimelineSegment {
diff --git a/apps/frontend/src/features/booking/components/booking-day-summary.tsx b/apps/frontend/src/features/booking/components/booking-day-summary.tsx
index 939446b..b153a6e 100644
--- a/apps/frontend/src/features/booking/components/booking-day-summary.tsx
+++ b/apps/frontend/src/features/booking/components/booking-day-summary.tsx
@@ -1,4 +1,4 @@
-import { CalendarDays, Download, Drill, ShieldCheck, UsersRound } from 'lucide-react';
+import { CalendarDays, Download, ShieldCheck, UsersRound } from 'lucide-react';
import { useBooking } from '../booking-provider';
import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time';
@@ -14,7 +14,7 @@ export function BookingDaySummary() {
-
+
-
);
@@ -77,7 +70,6 @@ export function BookingQuickActions() {
useBooking();
const todayIso = toIsoDateLocal(new Date());
const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved';
- const isViewingMaintenance = selectedStatus === 'maintenance';
return (
@@ -95,15 +87,6 @@ export function BookingQuickActions() {
{isViewingTodayReservations ? 'Ver todos los estados' : 'Ver reservas de hoy'}
-
-
);
@@ -426,7 +420,6 @@ function MobileStatusTab() {
{ value: 'all', label: 'Todos' },
{ value: 'free', label: 'Libres' },
{ value: 'reserved', label: 'Reservados' },
- { value: 'maintenance', label: 'Mantenimiento' },
] as const;
const timeRangeOptions = [
{ label: '08:00 - 22:00', start: '08:00', end: '22:00' },
@@ -850,14 +843,10 @@ function MobileSegmentRow({
) : (
- {segment.booking?.customerName ?? 'Mantenimiento'}
+ {segment.booking?.customerName ?? 'Sin información'}
- {segment.status === 'maintenance' ? (
-
- ) : (
-
- )}
+
{segment.booking?.customerPhone ?? 'Personal'}
@@ -868,23 +857,21 @@ function MobileSegmentRow({
function SegmentStatusLine({ segment }: { segment: BookingTimelineSegment }) {
const status = segment.status;
- const label = status === 'free' ? 'Libre' : status === 'reserved' ? 'Reservado' : 'Mantenimiento';
+ const label = status === 'free' ? 'Libre' : 'Reservado';
return (
{label}
@@ -897,7 +884,6 @@ function StatusLegend() {
-
);
}
@@ -929,9 +915,7 @@ function MiniSlot({
className={cn(
'min-w-0 rounded-lg border px-1.5 py-2 text-center transition active:scale-[0.98] disabled:opacity-60',
segment.status === 'free' && 'border-primary/50 bg-primary/15 text-primary',
- segment.status === 'reserved' && 'border-reserved/50 bg-reserved/15 text-reserved',
- segment.status === 'maintenance' &&
- 'border-maintenance/50 bg-maintenance/15 text-maintenance'
+ segment.status === 'reserved' && 'border-reserved/50 bg-reserved/15 text-reserved'
)}
disabled={!isActionable}
onClick={() => {
@@ -981,10 +965,6 @@ function MetricDots({ schedule }: { schedule: BookingCourtSchedule }) {
{schedule.metrics.reserved}
-
-
- {schedule.metrics.maintenance}
-
);
}
diff --git a/apps/frontend/src/features/booking/components/booking-status-legend.tsx b/apps/frontend/src/features/booking/components/booking-status-legend.tsx
index 889e512..a3e396f 100644
--- a/apps/frontend/src/features/booking/components/booking-status-legend.tsx
+++ b/apps/frontend/src/features/booking/components/booking-status-legend.tsx
@@ -1,7 +1,6 @@
const items = [
{ label: 'Libre', className: 'bg-primary' },
{ label: 'Reservado', className: 'bg-reserved' },
- { label: 'Mantenimiento', className: 'bg-maintenance' },
];
export function BookingStatusLegend() {
diff --git a/apps/frontend/src/features/booking/components/booking-timeline.tsx b/apps/frontend/src/features/booking/components/booking-timeline.tsx
index 23cd311..e9ff8eb 100644
--- a/apps/frontend/src/features/booking/components/booking-timeline.tsx
+++ b/apps/frontend/src/features/booking/components/booking-timeline.tsx
@@ -215,10 +215,6 @@ function BookingCourtInfo({ schedule }: { schedule: BookingCourtSchedule }) {
{schedule.metrics.reserved}
-
-
- {schedule.metrics.maintenance}
-
@@ -247,8 +243,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
'border-primary/70 bg-primary/20 text-primary hover:bg-primary/25',
segment.status === 'reserved' &&
'border-reserved/70 bg-reserved/80 text-reserved-foreground',
- segment.status === 'maintenance' &&
- 'border-maintenance/80 bg-maintenance/75 text-maintenance-foreground',
+
segment.booking?.status === 'COMPLETED' &&
'border-reserved/70 bg-reserved/25 dark:text-reserved-foreground text-gray-600 line-through',
segment.booking?.status === 'NOSHOW' &&
@@ -300,7 +295,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
- {segment.booking?.customerName ?? 'Mantenimiento'}
+ {segment.booking?.customerName ?? 'Sin información'}
>
)}
diff --git a/apps/frontend/src/features/booking/components/booking-toolbar.tsx b/apps/frontend/src/features/booking/components/booking-toolbar.tsx
index 8569554..b3d1c00 100644
--- a/apps/frontend/src/features/booking/components/booking-toolbar.tsx
+++ b/apps/frontend/src/features/booking/components/booking-toolbar.tsx
@@ -8,7 +8,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
-import { ChevronLeft, ChevronRight, Clock, SlidersHorizontal } from 'lucide-react';
+import { ChevronLeft, ChevronRight, Clock } from 'lucide-react';
import { useBooking } from '../booking-provider';
import type { BookingStatusFilter } from '../booking.types';
import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time';
@@ -24,7 +24,6 @@ const statusOptions: Array<{ value: BookingStatusFilter; label: string }> = [
{ value: 'all', label: 'Todos' },
{ value: 'free', label: 'Libre' },
{ value: 'reserved', label: 'Reservado' },
- { value: 'maintenance', label: 'Mantenimiento' },
];
export function BookingToolbar() {
@@ -46,7 +45,7 @@ export function BookingToolbar() {
return (
-
+
Deporte
-
-
);
diff --git a/apps/frontend/src/features/public-booking/public-booking-page.tsx b/apps/frontend/src/features/public-booking/public-booking-page.tsx
index 5903b76..e1bce59 100644
--- a/apps/frontend/src/features/public-booking/public-booking-page.tsx
+++ b/apps/frontend/src/features/public-booking/public-booking-page.tsx
@@ -39,7 +39,6 @@ import {
Sun,
Trophy,
Users,
- Wrench,
Zap,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -376,7 +375,6 @@ function Legend() {
-
);
}
@@ -1134,19 +1132,13 @@ function BookingTimeline({
const left = ((end - range.start) / total) * 100;
const width = ((Math.min(nextStart, range.end) - end) / total) * 100;
- const maintenance = index % 2 === 1;
-
return (
- {maintenance ? : }
+
);
})}
diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css
index 369493e..5389b39 100644
--- a/apps/frontend/src/index.css
+++ b/apps/frontend/src/index.css
@@ -54,9 +54,6 @@ body,
--color-reserved: var(--reserved);
--color-reserved-foreground: var(--reserved-foreground);
- --color-maintenance: var(--maintenance);
- --color-maintenance-foreground: var(--maintenance-foreground);
-
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
@@ -117,9 +114,6 @@ body,
--reserved: oklch(0.58 0.19 255);
--reserved-foreground: oklch(0.985 0.01 255);
- --maintenance: oklch(0.64 0.22 25);
- --maintenance-foreground: oklch(0.985 0.01 25);
-
--warning: oklch(0.78 0.16 75);
--warning-foreground: oklch(0.18 0.04 75);
@@ -176,9 +170,6 @@ body,
--reserved: oklch(0.61 0.2 255);
--reserved-foreground: oklch(0.97 0.012 255);
- --maintenance: oklch(0.66 0.23 25);
- --maintenance-foreground: oklch(0.98 0.01 25);
-
--warning: oklch(0.78 0.17 75);
--warning-foreground: oklch(0.18 0.04 75);
--
2.49.1
From 49d2a136723ada21628969c2b143f90f0d2f6a58 Mon Sep 17 00:00:00 2001
From: Jose Selesan
Date: Mon, 8 Jun 2026 15:34:53 -0300
Subject: [PATCH 2/3] feat(booking): allow empty customer email in booking
forms and schemas
---
.../modules/admin-booking/services/admin-booking.service.ts | 2 +-
.../src/features/booking/components/booking-create-dialog.tsx | 2 +-
packages/api-contract/src/admin-booking.ts | 3 ++-
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts b/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts
index 2310d13..95eb35b 100644
--- a/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts
+++ b/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts
@@ -424,7 +424,7 @@ export async function createAdminBooking(
endTime: selectedSlot.endTime,
customerName: input.customerName.trim(),
customerPhone: input.customerPhone.trim(),
- customerEmail: input.customerEmail.trim(),
+ customerEmail: input.customerEmail?.trim() ?? '',
status: 'CONFIRMED',
},
include: {
diff --git a/apps/frontend/src/features/booking/components/booking-create-dialog.tsx b/apps/frontend/src/features/booking/components/booking-create-dialog.tsx
index 591dd00..e1ce1b8 100644
--- a/apps/frontend/src/features/booking/components/booking-create-dialog.tsx
+++ b/apps/frontend/src/features/booking/components/booking-create-dialog.tsx
@@ -42,7 +42,7 @@ const bookingFormSchema = z.object({
.trim()
.min(6, 'Ingresa un telefono válido.')
.max(30, 'El telefono no puede superar los 30 caracteres.'),
- customerEmail: z.string().email('Ingresá un email válido.'),
+ customerEmail: z.string().email('Ingresá un email válido.').or(z.literal('')),
});
type BookingForm = z.infer;
diff --git a/packages/api-contract/src/admin-booking.ts b/packages/api-contract/src/admin-booking.ts
index 08c9384..d291e79 100644
--- a/packages/api-contract/src/admin-booking.ts
+++ b/packages/api-contract/src/admin-booking.ts
@@ -56,7 +56,8 @@ export const createAdminBookingSchema = z.object({
.max(30, 'El telefono no puede superar los 30 caracteres.'),
customerEmail: z
.string()
- .email('El email ingresado no es valido.'),
+ .email('El email ingresado no es valido.')
+ .or(z.literal('')),
})
export const updateAdminBookingStatusSchema = z.object({
--
2.49.1
From 630dedb507a65e0c02533e69ee5da0c542619650 Mon Sep 17 00:00:00 2001
From: Jose Selesan
Date: Mon, 8 Jun 2026 15:46:17 -0300
Subject: [PATCH 3/3] feat(booking): add validation to prevent past bookings
and implement slot validation logic
---
apps/backend/src/lib/slot-validator.ts | 34 +++++++
.../services/admin-booking.service.ts | 5 ++
.../services/public-booking.service.ts | 5 ++
.../public-booking/slot-validator.test.ts | 90 +++++++++++++++++++
4 files changed, 134 insertions(+)
create mode 100644 apps/backend/src/lib/slot-validator.ts
create mode 100644 apps/backend/test/public-booking/slot-validator.test.ts
diff --git a/apps/backend/src/lib/slot-validator.ts b/apps/backend/src/lib/slot-validator.ts
new file mode 100644
index 0000000..aea8e16
--- /dev/null
+++ b/apps/backend/src/lib/slot-validator.ts
@@ -0,0 +1,34 @@
+function toMinutes(value: string): number {
+ const [hours, minutes] = value.split(':').map((part) => Number(part));
+ return hours * 60 + minutes;
+}
+
+export function isSlotInPast(
+ bookingDate: Date,
+ startTime: string,
+ slotDurationMinutes: number,
+ now?: Date
+): boolean {
+ const currentTime = now ?? new Date();
+
+ const todayStart = new Date(
+ currentTime.getFullYear(),
+ currentTime.getMonth(),
+ currentTime.getDate()
+ );
+
+ const bookingLocalStart = new Date(
+ bookingDate.getUTCFullYear(),
+ bookingDate.getUTCMonth(),
+ bookingDate.getUTCDate()
+ );
+
+ if (bookingLocalStart < todayStart) return true;
+ if (bookingLocalStart > todayStart) return false;
+
+ const currentMinutes = currentTime.getHours() * 60 + currentTime.getMinutes();
+ const slotStartMinutes = toMinutes(startTime);
+ const elapsed = currentMinutes - slotStartMinutes;
+
+ return elapsed > slotDurationMinutes / 2;
+}
diff --git a/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts b/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts
index 95eb35b..6cec611 100644
--- a/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts
+++ b/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts
@@ -1,6 +1,7 @@
import { randomInt } from 'node:crypto';
import { CourtBookingStatus } from '@/generated/prisma/enums';
import { db } from '@/lib/prisma';
+import { isSlotInPast } from '@/lib/slot-validator';
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type { DayOfWeek } from '@repo/api-contract';
import type {
@@ -361,6 +362,10 @@ export async function createAdminBooking(
);
}
+ if (isSlotInPast(bookingDate, input.startTime, court.slotDurationMinutes)) {
+ throw new AdminBookingServiceError('No se pueden crear reservas en el pasado.', 400);
+ }
+
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const booking = await db.$transaction(async (tx) => {
diff --git a/apps/backend/src/modules/public-booking/services/public-booking.service.ts b/apps/backend/src/modules/public-booking/services/public-booking.service.ts
index 7fd8368..4ce197d 100644
--- a/apps/backend/src/modules/public-booking/services/public-booking.service.ts
+++ b/apps/backend/src/modules/public-booking/services/public-booking.service.ts
@@ -1,5 +1,6 @@
import { randomInt } from 'node:crypto';
import { db } from '@/lib/prisma';
+import { isSlotInPast } from '@/lib/slot-validator';
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type {
CancelPublicBookingInput,
@@ -574,6 +575,10 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
);
}
+ if (isSlotInPast(bookingDate, input.startTime, selectedCourt.slotDurationMinutes)) {
+ throw new PublicBookingServiceError('No se pueden crear reservas en el pasado.', 400);
+ }
+
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const booking = await db.$transaction(async (tx) => {
diff --git a/apps/backend/test/public-booking/slot-validator.test.ts b/apps/backend/test/public-booking/slot-validator.test.ts
new file mode 100644
index 0000000..829aff7
--- /dev/null
+++ b/apps/backend/test/public-booking/slot-validator.test.ts
@@ -0,0 +1,90 @@
+import { expect, test } from 'bun:test';
+
+import { isSlotInPast } from '@/lib/slot-validator';
+
+/**
+ * Helper to create a bookingDate like parseIsoDate does (UTC midnight).
+ */
+function bookingDate(year: number, month: number, day: number): Date {
+ return new Date(Date.UTC(year, month - 1, day));
+}
+
+/**
+ * Helper to create a "now" date at a specific local time.
+ * Using local date constructor guarantees the date parts
+ * (getFullYear, getMonth, getDate) match the arguments
+ * regardless of the test runner's timezone.
+ */
+function localDate(year: number, month: number, day: number, hours: number, minutes: number): Date {
+ return new Date(year, month - 1, day, hours, minutes, 0, 0);
+}
+
+test('future date — always allowed', () => {
+ const future = bookingDate(2027, 6, 10); // 2027-06-10
+ const now = localDate(2026, 6, 8, 12, 0); // 2026-06-08 12:00
+
+ expect(isSlotInPast(future, '10:00', 60, now)).toBe(false);
+});
+
+test('past date — rejected', () => {
+ const past = bookingDate(2025, 6, 8); // 2025-06-08
+ const now = localDate(2026, 6, 8, 12, 0); // 2026-06-08 12:00
+
+ expect(isSlotInPast(past, '10:00', 60, now)).toBe(true);
+});
+
+test('today, slot not started yet — allowed', () => {
+ const today = bookingDate(2026, 6, 8); // 2026-06-08
+ const now = localDate(2026, 6, 8, 9, 0); // 09:00, slot empieza a las 10:00
+
+ expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
+});
+
+test('today, slot in progress less than half elapsed — allowed', () => {
+ const today = bookingDate(2026, 6, 8);
+ const now = localDate(2026, 6, 8, 10, 10); // 10:10, slot 10:00-11:00 (60 min), 10 min elapsed
+
+ expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
+});
+
+test('today, slot in progress more than half elapsed — rejected', () => {
+ const today = bookingDate(2026, 6, 8);
+ const now = localDate(2026, 6, 8, 10, 40); // 10:40, slot 10:00-11:00 (60 min), 40 min elapsed
+
+ expect(isSlotInPast(today, '10:00', 60, now)).toBe(true);
+});
+
+test('today, slot already ended — rejected', () => {
+ const today = bookingDate(2026, 6, 8);
+ const now = localDate(2026, 6, 8, 11, 1); // 11:01, slot 10:00-11:00 terminó
+
+ expect(isSlotInPast(today, '10:00', 60, now)).toBe(true);
+});
+
+test('today, exactly half elapsed — allowed (inclusive boundary)', () => {
+ const today = bookingDate(2026, 6, 8);
+ const now = localDate(2026, 6, 8, 10, 30); // 10:30, slot 10:00-11:00 (60 min), exactamente 30 min
+
+ expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
+});
+
+test('today, slot starts exactly now — allowed', () => {
+ const today = bookingDate(2026, 6, 8);
+ const now = localDate(2026, 6, 8, 10, 0); // 10:00 exacto
+
+ expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
+});
+
+test('90 min slot with 10 min elapsed — allowed', () => {
+ const today = bookingDate(2026, 6, 8);
+ const now = localDate(2026, 6, 8, 12, 10); // 12:10, slot 12:00-13:30, 10/90 elapsed
+
+ expect(isSlotInPast(today, '12:00', 90, now)).toBe(false);
+});
+
+test('90 min slot with 50 min elapsed — rejected', () => {
+ const today = bookingDate(2026, 6, 8);
+ const now = localDate(2026, 6, 8, 12, 50); // 12:50, slot 12:00-13:30, 50/90 elapsed
+
+ expect(isSlotInPast(today, '12:00', 90, now)).toBe(true);
+});
--
2.49.1