diff --git a/.gitignore b/.gitignore
index 868bfeb..9fb823d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,9 @@ dist
dist-ssr
*.local
+# Local email preview artifacts
+apps/api/preview-out/
+
# Env files
.env
.env.*
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..b8ef435
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,71 @@
+# CLAUDE.md — Naturcalabacera App Reservas
+
+Guía operativa para agentes de Claude Code trabajando en este repo. Lee este archivo antes de tocar código o sugerir SQL.
+
+---
+
+## 1. Base de datos (Supabase) — CRÍTICO
+
+La instancia de Supabase es **compartida** entre varios proyectos. Hay dos schemas:
+
+- `public` → **otro proyecto, NO tocar**.
+- `natur_reservas` → este proyecto.
+
+> **Toda sentencia SQL debe cualificar el schema explícitamente**: `natur_reservas.reservations`, `natur_reservas.contracts`, etc.
+> Nunca escribir `reservations` a secas en migraciones, queries ad-hoc, RLS, triggers, vistas o funciones. Aplica también al SQL Editor del dashboard.
+
+### Ejemplo correcto
+
+```sql
+ALTER TABLE natur_reservas.reservations
+ ADD COLUMN IF NOT EXISTS start_time TIME;
+```
+
+### Migraciones
+
+Viven en `supabase/migrations/NNN_*.sql`. Se aplican con `supabase db push` o pegando el SQL en el dashboard. **Antes de añadir una nueva**, revisar las anteriores y replicar la convención de cualificar con `natur_reservas.`.
+
+---
+
+## 2. Stack y estructura
+
+Monorepo pnpm con workspaces:
+
+- `apps/web` — Frontend React + TypeScript + Vite + Tailwind. Vista principal: calendario de reservas (mensual + anual).
+- `apps/api` — Backend Express. Webhooks (n8n relay para correos), jobs.
+- `packages/shared` — Tipos (`Reservation`, `Property`, etc.) y utilidades (`PROPERTY_CONFIG`, pricing).
+
+Comandos raíz:
+
+```bash
+pnpm install
+pnpm dev:web # frontend (http://localhost:5173)
+pnpm dev:api # API
+pnpm build # build web + api
+pnpm lint
+pnpm test # tests del paquete shared
+```
+
+---
+
+## 3. Dominio
+
+- 2 propiedades: **Los Dragos** (`los_dragos`) y **La Esquinita** (`la_esquinita`).
+- 2 orígenes de reserva: **Teneriffa2000** (alquiler estándar) y **Naturcalabacera** (vacacional + eventos).
+- Reservas Naturcalabacera pueden marcarse `is_event = true` (boda, cumpleaños, etc.) con su propio cálculo de canon (precio base por noche + extra por pax sobre `includedPersons`, tarifa por año, IGIC).
+- Horarios opcionales `start_time` / `end_time` (cualquier reserva, cualquier origen): cuando ambas reservas en conflicto los tienen, el solapamiento se calcula a nivel de momento (fecha + hora), no solo de fecha. Permite encajar varios eventos el mismo día.
+
+---
+
+## 4. Despliegue
+
+Gitea + Dokploy en `72.62.155.93`. URLs con `traefik.me` (HTTP). Dockerfile por app.
+
+---
+
+## 5. Convenciones del repo
+
+- Sin commits sin permiso explícito del usuario.
+- Calendario empieza en **lunes** (`weekStartsOn: 1`) en todas las vistas.
+- Comentarios mínimos: solo cuando el "por qué" no es obvio del código.
+- Idioma de interfaz: español. Identificadores en inglés.
diff --git a/apps/api/scripts/preview-teneriffa-email.mjs b/apps/api/scripts/preview-teneriffa-email.mjs
new file mode 100644
index 0000000..9cdc171
--- /dev/null
+++ b/apps/api/scripts/preview-teneriffa-email.mjs
@@ -0,0 +1,43 @@
+// Genera previews HTML del correo minimalista para Teneriffa
+// (las 2 reservas del 19 de mayo de Los Dragos).
+// Uso: node apps/api/scripts/preview-teneriffa-email.mjs
+
+import { writeFileSync, mkdirSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const OUT_DIR = join(__dirname, '../preview-out');
+mkdirSync(OUT_DIR, { recursive: true });
+
+function renderTeneriffaMinimal(actionLabel, dateRange, property, cancelled = false) {
+ const accent = cancelled ? '#ef4444' : actionLabel === 'Nueva Reserva' ? '#3b82f6' : '#f59e0b';
+ return `
+
+
+
+
+
${actionLabel}
+
+
+
Fechas
+
${dateRange}
+
Propiedad
+
${property}
+
+
+`;
+}
+
+const dateRange = '19 may 2026 – 19 may 2026';
+const property = 'Los Dragos';
+
+const f1 = join(OUT_DIR, 'teneriffa-email-19may-evento1.html');
+writeFileSync(f1, renderTeneriffaMinimal('Nueva Reserva', dateRange, property));
+console.log('→', f1);
+
+const f2 = join(OUT_DIR, 'teneriffa-email-19may-evento2.html');
+writeFileSync(f2, renderTeneriffaMinimal('Nueva Reserva', dateRange, property));
+console.log('→', f2);
+
+console.log('\nAbre los archivos en el navegador para ver cómo se renderiza el correo en Teneriffa.');
diff --git a/apps/api/scripts/send-test-teneriffa-email.mjs b/apps/api/scripts/send-test-teneriffa-email.mjs
new file mode 100644
index 0000000..8f98cf9
--- /dev/null
+++ b/apps/api/scripts/send-test-teneriffa-email.mjs
@@ -0,0 +1,60 @@
+// Script de prueba: envía dos correos minimalistas de Teneriffa para las dos
+// reservas del 19 de mayo de 2026 (Los Dragos), tal como las recibiría Teneriffa
+// con el nuevo formato (solo fechas + propiedad).
+// Uso: node apps/api/scripts/send-test-teneriffa-email.mjs
+
+// .env se carga vía `node --env-file=.env`
+const WEBHOOK = process.env.N8N_EMAIL_WEBHOOK_URL;
+const FROM = process.env.EMAIL_FROM ?? 'Naturcalabacera ';
+const TO = process.argv[2] ?? 'kilian.parraga@gmail.com';
+
+if (!WEBHOOK) {
+ console.error('Falta N8N_EMAIL_WEBHOOK_URL en el .env');
+ process.exit(1);
+}
+
+function renderTeneriffaMinimal(actionLabel, dateRange, property, cancelled = false) {
+ const accent = cancelled ? '#ef4444' : actionLabel === 'Nueva Reserva' ? '#3b82f6' : '#f59e0b';
+ return `
+
+
+
{daysInThisWeek > 1 && (
@@ -315,6 +343,76 @@ export function CalendarGrid({
}
});
+ // 2) Una-celda: si hay >1 en la misma celda, se divide solo esa celda en N franjas.
+ singleDayByCell.forEach((list, dayIdx) => {
+ const weekIndex = Math.floor(dayIdx / 7);
+ const dayOfWeek = dayIdx % 7;
+ const lanes = list.length;
+ const isSplit = lanes > 1;
+ list.forEach((res, lane) => {
+ const { gradient, borderClass } = stylesForRes(res);
+ const hasTimes = !!res.start_time && !!res.end_time;
+ // Lane 0 comparte espacio horizontal con el badge del número del día.
+ // Le damos padding-left para que el contenido no se solape con el número.
+ const needsBadgeOffset = isSplit && lane === 0;
+ blocks.push(
+
diff --git a/packages/shared/src/types/reservation.ts b/packages/shared/src/types/reservation.ts
index c6c4f44..7dbe870 100644
--- a/packages/shared/src/types/reservation.ts
+++ b/packages/shared/src/types/reservation.ts
@@ -49,6 +49,11 @@ export interface Reservation {
event_type?: string; // 'Boda' | 'Comunión' | 'Cumpleaños' | 'Evento privado' | 'Corporativo' | 'Otro'
event_type_other?: string; // descripción libre cuando event_type === 'Otro'
attendees_count?: number; // número de asistentes al evento
+ // Horarios opcionales de entrada/salida (HH:mm). Aplicables a cualquier reserva.
+ // Cuando ambos están definidos, dos reservas pueden compartir el mismo día/fecha
+ // siempre que sus rangos horarios no se solapen.
+ start_time?: string; // HH:mm
+ end_time?: string; // HH:mm
}
export type NewReservation = Omit;
diff --git a/supabase/migrations/011_add_event_times.sql b/supabase/migrations/011_add_event_times.sql
new file mode 100644
index 0000000..44a7659
--- /dev/null
+++ b/supabase/migrations/011_add_event_times.sql
@@ -0,0 +1,11 @@
+-- Horarios opcionales de entrada/salida para cualquier reserva.
+-- Permite que dos reservas compartan el mismo día siempre que sus rangos horarios no se solapen
+-- (p. ej. una boda que termina a las 12:00 y un cumpleaños que empieza a las 17:00).
+-- Aplicable tanto a Naturcalabacera (eventos) como a Teneriffa.
+
+ALTER TABLE natur_reservas.reservations
+ ADD COLUMN IF NOT EXISTS start_time TIME,
+ ADD COLUMN IF NOT EXISTS end_time TIME;
+
+COMMENT ON COLUMN natur_reservas.reservations.start_time IS 'Hora de entrada (HH:mm). Opcional. Si está presente junto con end_time, se usa para detectar solapamientos por horario.';
+COMMENT ON COLUMN natur_reservas.reservations.end_time IS 'Hora de salida (HH:mm). Opcional. Si está presente junto con start_time, se usa para detectar solapamientos por horario.';