From 23df72ac7187e005937c4ed5090a36aec3772b70 Mon Sep 17 00:00:00 2001 From: razyd Date: Wed, 14 Oct 2026 18:57:03 +0200 Subject: [PATCH 01/10] docs(session): record PR #30 in main and close the cron item --- docs/SESSION.md | 18 ++++++++++++------ docs/TODO.md | 10 +--------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/docs/SESSION.md b/docs/SESSION.md index 575da89..771d774 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -28,7 +28,7 @@ y auditado, publicación y demo. Sus documentos (`ROADMAP.md`, `GITCLEAN.md`, - **Local:** `pnpm setup:local` instala desde cero con admin completo, datos ficticios y la base de horarios de la demo (`scheduling-seed.sql`). - **Repositorio original:** `four-points-archive`, archivado y privado. -- **Decisiones:** `DECISIONS.md`, hasta ADR-044. +- **Decisiones:** `DECISIONS.md`, hasta ADR-046. - **Harness:** capa 1 en `backend/AGENTS.md` y `frontend/AGENTS.md`; el `AGENTS.md` raíz guarda lo común y los tres niveles se enlazan entre sí (ADR-042, ADR-043). La sesión se abre en la raíz y activa el Node de cada lado @@ -39,9 +39,14 @@ y auditado, publicación y demo. Sus documentos (`ROADMAP.md`, `GITCLEAN.md`, migraciones `20261011_keep_group_history_on_delete.sql` y `20261012_unique_invoice_supplier_number.sql`, aplicadas en Aiven. En `main` desde el PR #28. -- **`main` al día con la rama** desde el PR #29 (2026-10-14, merge `47f77b9`): - harness en los dos subproyectos, documentos de módulo alineados con el código - y Prettier 3.9.9 en los dos lados. +- **`main` al día con la rama** desde el PR #30 (2026-10-14, merge `9b33943`): + las tareas programadas se ponen al día al arrancar y el pago automático del + día 10 funciona (ADR-046, comprobado en preview y producción); los errores de + `auth` y de los middlewares llevan `code`; el historial de grupos solo anota + lo que cambia. +- **Rama `opencode-tasks`:** trabajo delegado a OpenCode con una directriz + cerrada (ficheros permitidos, tabla de cambios, verificación); se revisa, se + commitea y entra en la rama de preview con fast-forward. ## ⚠️ Empieza por aquí @@ -70,8 +75,7 @@ y auditado, publicación y demo. Sus documentos (`ROADMAP.md`, `GITCLEAN.md`, siguen funcionando. 2. **Fechas y «hoy» según la zona horaria de cada pieza**, como tarea propia (decidido el 2026-10-12). -3. Las de prioridad media, empezando por las comprobaciones cortas (cron en - Render) y el pago automático del día 10, que nunca ha funcionado. +3. Las de prioridad media. Los errores conocidos que se aceptan sin arreglarlos ahora están en `docs/_archive/TODO.md` (ADR-045). @@ -88,6 +92,8 @@ Nada ahora mismo. - **Preview y producción comparten la BD de Aiven** y las cookies de sesión: lo que se prueba en preview cambia datos de producción, y entrar en preview sustituye la sesión de producción en ese navegador. +- **Preview y producción comparten `cron_job_runs`:** el primer backend que + arranca después de la hora de una tarea la hace por los dos (ADR-046). - **La cuenta demo es un admin** (`users.is_demo = 1`): lo que no deba tocar se bloquea en la ruta con `denyDemo`/`denyDemoWrites` o en `demoRestriction`. Una ruta nueva que escriba fuera de lo que reinicia el mock (usuarios, catálogos, diff --git a/docs/TODO.md b/docs/TODO.md index 3341d9c..06c992a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -18,7 +18,7 @@ hace que alguien reimplemente lo que ya existe. ## Rama `claude/compassionate-planck-gh6aof` -Última fusión en `main`: 2026-10-14 (PR #29, commit de merge `47f77b9`). La rama +Última fusión en `main`: 2026-10-14 (PR #30, commit de merge `9b33943`). La rama sigue siendo donde se arregla este fichero y se prueba en el entorno preview. ## Después de publicar @@ -58,14 +58,6 @@ ahora están en `docs/_archive/TODO.md`, apartado «Errores conocidos pendientes reales — Aiven hace copias automáticas y existe `backend/db-mysql/backup/backup.ps1`, pero no consta que se haya probado a restaurar ninguna. Del plan archivado (`Global-Plan.md`, H3-8). -- [ ] **Cron en producción: desplegar y comprobarlo en Render** — en Render no - se ejecutaba ninguna tarea programada (7 días de logs sin una, 2026-10-14: - el plan gratuito duerme el backend) y el pago del día 10 fallaba por - `system-cron`. Arreglado en la rama `opencode-tasks`: se ponen al día al - arrancar (ADR-046). `20261014_cron_catch_up.sql` ya está en Aiven - (2026-10-14). Falta desplegar y ver en los logs de Render el - `[CRON] Ejecutando` de cada tarea y la tabla `cron_job_runs` con sus - periodos. El primer arranque paga las facturas validadas de septiembre. - [ ] **La retención de mensajes borra las conversaciones vacías** — el evento MySQL `cleanup_old_messages` borra cada día, además de los mensajes de más de 90 días, toda conversación sin mensajes, incluidas las recién creadas y From b941c0e70cd6e153dc88a2afc8fff7991eef24fb Mon Sep 17 00:00:00 2001 From: razyd Date: Wed, 14 Oct 2026 19:11:06 +0200 Subject: [PATCH 02/10] fix(api): run the backend and its MySQL sessions in UTC - Set TZ=UTC first thing in index.ts: Render already ran in UTC, but a machine in Madrid read every DATE one day early and each edit moved it. - Read and write dates in UTC in mysql2 and set time_zone to UTC on every MySQL connection, as Aiven already is, so NOW() and CURRENT_TIMESTAMP mean the same everywhere. - Run the tests in UTC on any machine. - Record the decision as ADR-047. --- backend/config/db-config.ts | 4 +++- backend/config/db.ts | 11 +++++++++ backend/config/timezone.ts | 6 +++++ backend/index.ts | 1 + backend/tests/security/env-example.test.ts | 12 ++++++++-- backend/vitest.config.ts | 2 ++ docs/DECISIONS.md | 28 ++++++++++++++++++++++ 7 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 backend/config/timezone.ts diff --git a/backend/config/db-config.ts b/backend/config/db-config.ts index 71ca80b..8883ae4 100644 --- a/backend/config/db-config.ts +++ b/backend/config/db-config.ts @@ -82,4 +82,6 @@ if (environment === 'aiven' && (!config.user || !Number.isInteger(config.port))) throw new Error('❌ Faltan AIVEN_DB_USER o AIVEN_DB_PORT en .env') } -export const dbConfig = config +// mysql2 reads and writes DATETIME and DATE in UTC, whatever the machine's zone +// (ADR-047); config/db.ts sets the MySQL session to UTC too +export const dbConfig = { ...config, timezone: 'Z' } diff --git a/backend/config/db.ts b/backend/config/db.ts index fa1c0cf..79d6bcb 100644 --- a/backend/config/db.ts +++ b/backend/config/db.ts @@ -25,6 +25,17 @@ logger.info( // ======================================== const pool: Pool = mysql.createPool(config) // ← MODIFICADO (añadido tipo) +// NOW(), CURDATE() and DEFAULT CURRENT_TIMESTAMP in UTC on every connection, as on +// Aiven (ADR-047). The event hands over the callback connection, not the promise one +pool.on('connection', (connection) => { + const raw = connection as unknown as { + query: (sql: string, callback: (err: Error | null) => void) => void + } + raw.query("SET time_zone = '+00:00'", (err) => { + if (err) logger.error({ err }, 'No se pudo fijar la zona horaria UTC en la sesión MySQL') + }) +}) + // Verify connection at startup with retries (Aiven can timeout on cold start) async function connectWithRetry(retries = 3, delayMs = 3000): Promise { for (let attempt = 1; attempt <= retries; attempt++) { diff --git a/backend/config/timezone.ts b/backend/config/timezone.ts new file mode 100644 index 0000000..5ce35f8 --- /dev/null +++ b/backend/config/timezone.ts @@ -0,0 +1,6 @@ +// config/timezone.ts +// The backend runs in UTC on every machine, as it does on Render (ADR-047). +// Imported first by index.ts: Node reads TZ when it next builds a Date. Business +// dates ("today", the hotel day) come from config/date-utils.ts, in Madrid. + +process.env.TZ = 'UTC' diff --git a/backend/index.ts b/backend/index.ts index ee8b27d..03cbc35 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -1,5 +1,6 @@ // index.ts +import './config/timezone.js' // first: the process runs in UTC (ADR-047) import express, { Request, Response, NextFunction } from 'express' import cookieParser from 'cookie-parser' import helmet from 'helmet' diff --git a/backend/tests/security/env-example.test.ts b/backend/tests/security/env-example.test.ts index 5c65363..3cdd9ef 100644 --- a/backend/tests/security/env-example.test.ts +++ b/backend/tests/security/env-example.test.ts @@ -11,8 +11,16 @@ import { fileURLToPath } from 'node:url' const backend = join(dirname(fileURLToPath(import.meta.url)), '..', '..') const example = readFileSync(join(backend, '.env.example'), 'utf8') -// vitest.config.ts reads SKIP_DB_TESTS for the test runner, not the app -const SKIP = new Set(['node_modules', 'tests', 'scheduling-solver', 'db-mysql', 'vitest.config.ts']) +// vitest.config.ts reads SKIP_DB_TESTS for the test runner, not the app; +// config/timezone.ts sets TZ, it is not something to configure (ADR-047) +const SKIP = new Set([ + 'node_modules', + 'tests', + 'scheduling-solver', + 'db-mysql', + 'vitest.config.ts', + 'timezone.ts', +]) function sources(dir: string): string[] { return readdirSync(dir).flatMap((name) => { const path = join(dir, name) diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index de8ec2a..0b70540 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -26,5 +26,7 @@ export default defineConfig({ include: ['services/scheduling/**/*.ts'], }, testTimeout: 10000, + // The backend runs in UTC everywhere (ADR-047): tests too, on any machine + env: { TZ: 'UTC' }, }, }) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 322951d..be82dbd 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1040,3 +1040,31 @@ de 2026 (periodo `2026-10`). - La migración tiene que estar en Aiven antes del despliegue; sin la tabla, cada tarea anota el error en el log y no se ejecuta. + +## ADR-047 — El backend corre en UTC; el «hoy» del hotel se calcula en Madrid + +- **Estado:** ✅ aceptada (2026-10-14) +- **Fecha:** 2026-10-14 +- **Decisión:** el proceso del backend fija `TZ=UTC` al arrancar + (`config/timezone.ts`, primer import de `index.ts`), `mysql2` lee y escribe + las fechas en UTC (`timezone: 'Z'`) y cada sesión de MySQL fija + `time_zone = '+00:00'`. Las fechas de negocio («hoy», el día del hotel) se + calculan siempre en Node con `config/date-utils.ts` (`getTodayMadrid`) y se + pasan como parámetro; el SQL no usa `CURDATE()` para «hoy» ni zonas con + nombre. En el frontend, el «hoy» sale de `getMadridDate()`. Completa el canon + del refactor de mayo: UTC para las marcas de auditoría, Madrid para la lógica + de negocio. +- **Motivo:** Render y Aiven ya estaban en UTC, pero el portátil y el MySQL + local en hora de Madrid: el mismo código daba otra fecha según la máquina (una + `DATE` llegaba con un día menos en local y cada edición la movía). Y en + producción, entre las 00:00 y las 02:00 de Madrid, `CURDATE()` y + `toISOString()` daban todavía el día anterior. Se descartó devolver las `DATE` + como texto (`dateStrings`): cambia la forma de los datos en decenas de sitios + para el mismo resultado. Se descartó `Europe/Madrid` en el SQL: el MySQL local + no tiene cargadas las zonas con nombre. +- **Consecuencias:** + - Local se comporta como producción; en producción no cambia la forma de + ningún dato. + - Los `DATETIME` locales escritos antes con hora de Madrid se ven 2 horas + desplazados (datos de prueba). + - Los tests corren en UTC en cualquier máquina (`vitest.config.ts`). From 525c2cad38f7582f85335837973e1f8e5ee77e92 Mon Sep 17 00:00:00 2001 From: razyd Date: Wed, 14 Oct 2026 19:21:50 +0200 Subject: [PATCH 03/10] fix(api): use the hotel's today and wall clock instead of the UTC one - Replace CURDATE() and NOW() with Madrid's date and time, built in Node and inlined as literals (repositories/shared/hotel-clock.ts): between 00:00 and 02:00 in Madrid the UTC date was still yesterday in parking filters, group arrivals, group payments and overdue invoices. - Treat parking check-in/out times as the hotel's wall clock: they were stored as typed but read back as UTC, so the screens showed every booking two hours late and each edit moved it two more. The API now answers them without Z, and the default times are Madrid's. - Take the maintenance report prefix, the activity range, the backoffice ZIP name and the batch payment's default month from Madrid's date. - Date the parking booking code in Madrid (20261014_booking_code_hotel_date.sql, applied on local and Aiven). - Test the helpers at 00:30 in Madrid, summer and winter. --- backend/config/date-utils.ts | 21 ++++++++ .../activity/activity-controller.ts | 3 +- .../backoffice/backoffice-controller.ts | 2 +- .../parking/bookings.controller.ts | 18 +++---- backend/db-mysql/INDEX.md | 1 + .../20261014_booking_code_hotel_date.sql | 54 +++++++++++++++++++ backend/models/parking/index.ts | 23 ++++---- .../backoffice/backoffice-repository.ts | 3 +- .../group/group-payment-repository.ts | 9 ++-- .../repositories/group/group-repository.ts | 9 ++-- .../maintenance/maintenance-repository.ts | 8 ++- .../parking/bookings.repository.ts | 47 ++++++++-------- .../repositories/parking/stats.repository.ts | 21 ++++---- .../repositories/search/search-repository.ts | 5 +- backend/repositories/shared/hotel-clock.ts | 13 +++++ backend/services/cron/cron-service.ts | 18 +++---- backend/tests/config/hotel-clock.test.ts | 50 +++++++++++++++++ 17 files changed, 227 insertions(+), 78 deletions(-) create mode 100644 backend/db-mysql/scripts/20261014_booking_code_hotel_date.sql create mode 100644 backend/repositories/shared/hotel-clock.ts create mode 100644 backend/tests/config/hotel-clock.test.ts diff --git a/backend/config/date-utils.ts b/backend/config/date-utils.ts index ddffc2c..bae230b 100644 --- a/backend/config/date-utils.ts +++ b/backend/config/date-utils.ts @@ -74,6 +74,27 @@ export const getLastDayOfMonth = (year: number, month: number): string => { .format('YYYY-MM-DD') } +// ── Hotel wall-clock times (ADR-047) ───────────────────────────────────────── +// Parking check-in and check-out times are the hotel's clock: "12:00" is noon in +// Madrid, stored as 12:00:00. mysql2 reads such a DATETIME as a Date whose UTC +// fields are the stored value, so these helpers never go through the local zone. + +/** The hotel's current wall-clock time, as a Date to store in such a column */ +export const getHotelNowDate = (): Date => + new Date(`${getNowMadrid().format('YYYY-MM-DDTHH:mm:ss')}Z`) + +/** A wall-clock time typed by the user ('YYYY-MM-DD HH:mm' or 'YYYY-MM-DDTHH:mm'), as stored */ +export const parseHotelTime = (value: string): Date => dayjs.utc(value).toDate() + +/** A stored wall-clock time for the API: no Z, so the browser reads it as local time */ +export function toHotelTime(value: Date | string): string +export function toHotelTime(value: Date | string | null | undefined): string | null +export function toHotelTime(value: Date | string | null | undefined): string | null { + if (value === null || value === undefined) return null + const date = value instanceof Date ? value : parseHotelTime(value) + return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 19) +} + export default { getTodayMadrid, getNowMadrid, diff --git a/backend/controllers/activity/activity-controller.ts b/backend/controllers/activity/activity-controller.ts index 2b0cedb..c1d3fc5 100644 --- a/backend/controllers/activity/activity-controller.ts +++ b/backend/controllers/activity/activity-controller.ts @@ -15,6 +15,7 @@ import { mergeByTimestamp, } from '../../services/activity/activity-access.js' import { logger } from '../../config/logger.js' +import { getTodayMadrid } from '../../config/date-utils.js' import { isCalendarDate } from '../../validations/common/calendar-date.js' export class ActivityController { @@ -81,7 +82,7 @@ export class ActivityController { const fetchActivity = (src?: ActivitySource) => { if (dateFrom || dateTo) { - const to = dateTo || new Date().toISOString().slice(0, 10) + const to = dateTo || getTodayMadrid() let from = dateFrom if (!from) { const d = new Date(to) diff --git a/backend/controllers/backoffice/backoffice-controller.ts b/backend/controllers/backoffice/backoffice-controller.ts index 6a5faac..400bdc4 100644 --- a/backend/controllers/backoffice/backoffice-controller.ts +++ b/backend/controllers/backoffice/backoffice-controller.ts @@ -1548,7 +1548,7 @@ export class BackofficeController { const archive = archiver('zip', { zlib: { level: 5 } }) // Set response headers - const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '') + const timestamp = getTodayMadrid().replace(/-/g, '') res.setHeader('Content-Type', 'application/zip') res.setHeader( 'Content-Disposition', diff --git a/backend/controllers/parking/bookings.controller.ts b/backend/controllers/parking/bookings.controller.ts index f9b0425..f11b352 100644 --- a/backend/controllers/parking/bookings.controller.ts +++ b/backend/controllers/parking/bookings.controller.ts @@ -9,7 +9,7 @@ import { createBookingSchema, updateBookingBodySchema, } from '../../validations/parking/booking-validation.js' -import { getNowMadrid } from '../../config/date-utils.js' +import { getHotelNowDate, parseHotelTime } from '../../config/date-utils.js' import { ERROR_CODES, SUCCESS_CODES } from '../../config/error-codes.js' import type { BookingFilters, BookingSource, UpdateBookingDTO } from '../../models/parking/index.js' import { logger } from '../../config/logger.js' @@ -298,7 +298,7 @@ class ParkingBookingsController { // Convertir code → id const id = await this._getBookingIdFromCode(code) - const checkinDate = actual_checkin ? new Date(actual_checkin) : getNowMadrid().toDate() + const checkinDate = actual_checkin ? parseHotelTime(actual_checkin) : getHotelNowDate() if (actual_checkin && isNaN(checkinDate.getTime())) { res.status(400).json({ @@ -396,7 +396,7 @@ class ParkingBookingsController { const id = await this._getBookingIdFromCode(code) - const checkoutDate = actual_checkout ? new Date(actual_checkout) : getNowMadrid().toDate() + const checkoutDate = actual_checkout ? parseHotelTime(actual_checkout) : getHotelNowDate() if (actual_checkout && isNaN(checkoutDate.getTime())) { res.status(400).json({ @@ -679,11 +679,11 @@ class ParkingBookingsController { // Validar fechas si se proporcionan if (expected_checkin || expected_checkout) { const checkin = expected_checkin - ? new Date(expected_checkin) - : new Date(existingBooking.schedule.expected_checkin) + ? parseHotelTime(expected_checkin) + : parseHotelTime(existingBooking.schedule.expected_checkin) const checkout = expected_checkout - ? new Date(expected_checkout) - : new Date(existingBooking.schedule.expected_checkout) + ? parseHotelTime(expected_checkout) + : parseHotelTime(existingBooking.schedule.expected_checkout) if (isNaN(checkin.getTime()) || isNaN(checkout.getTime())) { res.status(400).json({ @@ -820,8 +820,8 @@ class ParkingBookingsController { return } - const now = getNowMadrid().toDate() - const checkin = new Date(booking.schedule.expected_checkin) + const now = getHotelNowDate() + const checkin = parseHotelTime(booking.schedule.expected_checkin) if (now >= checkin) { res.status(400).json({ diff --git a/backend/db-mysql/INDEX.md b/backend/db-mysql/INDEX.md index 525ede1..dc0a05d 100644 --- a/backend/db-mysql/INDEX.md +++ b/backend/db-mysql/INDEX.md @@ -117,6 +117,7 @@ db-mysql/ | 2026-10-11 | `20261011_keep_group_history_on_delete.sql` | Quita la clave foránea `group_history_ibfk_1` (`ON DELETE CASCADE` a `hotel_groups`): borrar un grupo ya no borra su historial, y el borrado deja una fila `deleted` con la foto del grupo. **Aplicar en Aiven antes de desplegar el código**: con la clave dentro, borrar un grupo falla. | ✅ local · ✅ Aiven | | 2026-10-12 | `20261012_unique_invoice_supplier_number.sql` | Añade `UNIQUE KEY uk_invoice_supplier_number (supplier_id, invoice_number)` a `bo_invoices`: la misma factura de un proveedor se podía registrar dos veces y se pagaban las dos en el lote mensual. El backend responde 409 `BACKOFFICE_INVOICE_DUPLICATE`. Antes del `ALTER` lista las duplicadas; si hay alguna, el `ALTER` falla y hay que corregirlas. Idempotente. El código funciona sin ella (no rechaza nada hasta que exista). | ✅ local · ✅ Aiven | | 2026-10-14 | `20261014_cron_catch_up.sql` | Crea el usuario inactivo `system-cron` (el pago automático del día 10 firmaba con él y no existía: fallaba la clave foránea de `updated_by`) y la tabla `cron_job_runs`, con el último periodo de cada tarea programada para ponerlas al día al arrancar sin repetirlas (ADR-046). Idempotente. **Aplicar en Aiven antes de desplegar el código**: sin la tabla las tareas no se ejecutan. | ✅ local · ✅ Aiven | +| 2026-10-14 | `20261014_booking_code_hotel_date.sql` | `trg_generate_booking_code` toma la fecha de `PK-AAAAMMDD-NNNN` en hora de Madrid (`CONVERT_TZ` de `UTC_TIMESTAMP()`): con `NOW()` en UTC, una reserva entre las 00:00 y las 02:00 llevaba la fecha de ayer (ADR-047). Sin zonas con nombre (MySQL local) usa la fecha UTC, como antes. Idempotente. | ✅ local · ✅ Aiven | ### Verificación (99) diff --git a/backend/db-mysql/scripts/20261014_booking_code_hotel_date.sql b/backend/db-mysql/scripts/20261014_booking_code_hotel_date.sql new file mode 100644 index 0000000..7565cab --- /dev/null +++ b/backend/db-mysql/scripts/20261014_booking_code_hotel_date.sql @@ -0,0 +1,54 @@ +-- ============================================================================= +-- 20261014_booking_code_hotel_date.sql +-- ============================================================================= +-- trg_generate_booking_code took the date of PK-YYYYMMDD-NNNN from NOW(), which +-- is UTC on Aiven and in every session (ADR-047): a booking made between 00:00 +-- and 02:00 in Madrid got yesterday's code. It now converts UTC_TIMESTAMP() +-- (UTC whatever the session zone) to Madrid. +-- Where MySQL has no named time zones loaded (a local install), CONVERT_TZ +-- gives NULL and the code falls back to the UTC date, as before. +-- Only the date changes; the numbering is the same. +-- +-- Idempotent: DROP TRIGGER IF EXISTS + CREATE. +-- +-- Applies to: LOCAL + AIVEN. The code works with either version. +-- Rollback: recreate it with DATE_FORMAT(NOW(), '%Y%m%d') +-- (aiven/05_parking_functions_triggers.sql). +-- ============================================================================= + +DROP TRIGGER IF EXISTS trg_generate_booking_code; + +DELIMITER $$ + +CREATE TRIGGER trg_generate_booking_code +BEFORE INSERT ON parking_bookings +FOR EACH ROW +BEGIN + DECLARE next_number INT; + DECLARE date_part VARCHAR(8); + + -- Formato: PK-YYYYMMDD-0001, con la fecha de Madrid + SET date_part = DATE_FORMAT( + COALESCE(CONVERT_TZ(UTC_TIMESTAMP(), '+00:00', 'Europe/Madrid'), UTC_TIMESTAMP()), + '%Y%m%d' + ); + + SELECT COALESCE(MAX( + CAST(SUBSTRING(booking_code, -4) AS UNSIGNED) + ), 0) + 1 + INTO next_number + FROM parking_bookings + WHERE booking_code LIKE CONCAT('PK-', date_part, '-%'); + + SET NEW.booking_code = CONCAT( + 'PK-', + date_part, + '-', + LPAD(next_number, 4, '0') + ); +END$$ + +DELIMITER ; + +SELECT DATE_FORMAT(COALESCE(CONVERT_TZ(UTC_TIMESTAMP(), '+00:00', 'Europe/Madrid'), UTC_TIMESTAMP()), '%Y%m%d') + AS booking_code_date_now; diff --git a/backend/models/parking/index.ts b/backend/models/parking/index.ts index efe8a9b..4d126a5 100644 --- a/backend/models/parking/index.ts +++ b/backend/models/parking/index.ts @@ -300,10 +300,11 @@ export interface FormattedOperator { } export interface FormattedSchedule { - expected_checkin: Date - expected_checkout: Date - actual_checkin: Date | null - actual_checkout: Date | null + // Hotel wall-clock times without Z: YYYY-MM-DDTHH:mm:ss (ADR-047) + expected_checkin: string + expected_checkout: string + actual_checkin: string | null + actual_checkout: string | null planned_days: number actual_days: number | null } @@ -356,8 +357,8 @@ export interface FormattedOverdueBooking { owner: string | null model: string | null } - actual_checkin: Date - expected_checkout: Date + actual_checkin: string + expected_checkout: string horas_retraso: number status: BookingStatus notes: string | null @@ -413,8 +414,8 @@ export interface FormattedPendingCheckin { model: string | null } | null schedule: { - expected_checkin: Date - expected_checkout: Date + expected_checkin: string + expected_checkout: string days: number } booking_info: { @@ -438,9 +439,9 @@ export interface FormattedPendingCheckout { model: string | null } | null schedule: { - expected_checkin: Date - actual_checkin: Date | null - expected_checkout: Date + expected_checkin: string + actual_checkin: string | null + expected_checkout: string planned_days: number actual_days: number is_overdue: boolean diff --git a/backend/repositories/backoffice/backoffice-repository.ts b/backend/repositories/backoffice/backoffice-repository.ts index d62a29d..f4b0c3f 100644 --- a/backend/repositories/backoffice/backoffice-repository.ts +++ b/backend/repositories/backoffice/backoffice-repository.ts @@ -24,6 +24,7 @@ import { Asset, SummaryStats, } from '../../models/backoffice/index.js' +import { sqlHotelToday } from '../shared/hotel-clock.js' import { likeContains } from '../shared/like.js' // Re-exportar tipos para mantener compatibilidad @@ -960,7 +961,7 @@ export class BackofficeRepository { `SELECT COUNT(*) as count FROM bo_invoices WHERE status IN ('pending', 'validated') AND is_deleted = 0 - AND due_date < CURDATE()` + AND due_date < ${sqlHotelToday()}` ) const [suppliers] = await pool.query( diff --git a/backend/repositories/group/group-payment-repository.ts b/backend/repositories/group/group-payment-repository.ts index bf00891..71e44a6 100644 --- a/backend/repositories/group/group-payment-repository.ts +++ b/backend/repositories/group/group-payment-repository.ts @@ -10,6 +10,7 @@ import { PaymentsSummary, } from '../../models/group/index' import { ResultSetHeader, RowDataPacket } from 'mysql2' +import { sqlHotelToday } from '../shared/hotel-clock.js' import { buildSetClause } from '../shared/update-columns.js' export class GroupPaymentRepository { @@ -199,11 +200,11 @@ export class GroupPaymentRepository { gp.*, g.name as group_name, g.agency, - DATEDIFF(gp.due_date, CURDATE()) as days_until_due + DATEDIFF(gp.due_date, ${sqlHotelToday()}) as days_until_due FROM group_payments gp INNER JOIN hotel_groups g ON gp.group_id = g.id WHERE gp.status IN ('pending', 'requested', 'partial') - AND gp.due_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) + AND gp.due_date BETWEEN ${sqlHotelToday()} AND DATE_ADD(${sqlHotelToday()}, INTERVAL ? DAY) ORDER BY gp.due_date ASC ` @@ -220,11 +221,11 @@ export class GroupPaymentRepository { gp.*, g.name as group_name, g.agency, - DATEDIFF(CURDATE(), gp.due_date) as days_overdue + DATEDIFF(${sqlHotelToday()}, gp.due_date) as days_overdue FROM group_payments gp INNER JOIN hotel_groups g ON gp.group_id = g.id WHERE gp.status IN ('pending', 'requested', 'partial') - AND gp.due_date < CURDATE() + AND gp.due_date < ${sqlHotelToday()} ORDER BY gp.due_date ASC ` diff --git a/backend/repositories/group/group-repository.ts b/backend/repositories/group/group-repository.ts index 13bfa0f..995600a 100644 --- a/backend/repositories/group/group-repository.ts +++ b/backend/repositories/group/group-repository.ts @@ -24,6 +24,7 @@ import { import { ResultSetHeader } from 'mysql2' import { buildSetClause } from '../shared/update-columns.js' import { GROUP_SORT_FIELDS } from '../../validations/group/group-schemas.js' +import { sqlHotelToday } from '../shared/hotel-clock.js' import { likeContains } from '../shared/like.js' export class GroupRepository { @@ -354,11 +355,11 @@ export class GroupRepository { g.*, gs.rooming_status, gs.rooming_received_date, - DATEDIFF(g.arrival_date, CURDATE()) as days_until_arrival + DATEDIFF(g.arrival_date, ${sqlHotelToday()}) as days_until_arrival FROM hotel_groups g LEFT JOIN group_status gs ON g.id = gs.group_id WHERE g.status IN ('confirmed', 'in_progress') - AND g.arrival_date = DATE_ADD(CURDATE(), INTERVAL ? DAY) + AND g.arrival_date = DATE_ADD(${sqlHotelToday()}, INTERVAL ? DAY) ORDER BY g.arrival_date ASC ` @@ -375,12 +376,12 @@ export class GroupRepository { SELECT g.*, gs.rooming_status, - DATEDIFF(g.arrival_date, CURDATE()) as days_until_arrival + DATEDIFF(g.arrival_date, ${sqlHotelToday()}) as days_until_arrival FROM hotel_groups g LEFT JOIN group_status gs ON g.id = gs.group_id WHERE g.status IN ('confirmed', 'in_progress') AND (gs.rooming_status IS NULL OR gs.rooming_status != 'received') - AND g.arrival_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) + AND g.arrival_date BETWEEN ${sqlHotelToday()} AND DATE_ADD(${sqlHotelToday()}, INTERVAL ? DAY) ORDER BY g.arrival_date ASC ` diff --git a/backend/repositories/maintenance/maintenance-repository.ts b/backend/repositories/maintenance/maintenance-repository.ts index af3b1a9..963e558 100644 --- a/backend/repositories/maintenance/maintenance-repository.ts +++ b/backend/repositories/maintenance/maintenance-repository.ts @@ -19,6 +19,7 @@ import type { HistoryAction, AddImageInput, } from '../../models/maintenance/index.js' +import { getNowMadrid } from '../../config/date-utils.js' import { likeContains } from '../shared/like.js' // ======================================== @@ -103,11 +104,8 @@ export const MAX_REPORT_IMAGES = 5 * Ejemplo: 120625-001, 120625-002, etc. */ async function generateReportId(): Promise { - const now = new Date() - const day = String(now.getDate()).padStart(2, '0') - const month = String(now.getMonth() + 1).padStart(2, '0') - const year = String(now.getFullYear()).slice(-2) - const datePrefix = `${day}${month}${year}` + // The hotel day in Madrid: in UTC a report at 00:30 would carry yesterday (ADR-047) + const datePrefix = getNowMadrid().format('DDMMYY') // Buscar el último número del día const [rows] = await dbx().query( diff --git a/backend/repositories/parking/bookings.repository.ts b/backend/repositories/parking/bookings.repository.ts index d2d5632..4451c28 100644 --- a/backend/repositories/parking/bookings.repository.ts +++ b/backend/repositories/parking/bookings.repository.ts @@ -20,6 +20,8 @@ import type { BookingStatus, } from '../../models/parking/index.js' import { likeContains } from '../shared/like.js' +import { sqlHotelNow, sqlHotelToday } from '../shared/hotel-clock.js' +import { getHotelNowDate, toHotelTime } from '../../config/date-utils.js' interface CountRow extends RowDataPacket { total: number @@ -202,7 +204,7 @@ class ParkingBookingsRepository { WHEN b.actual_checkin IS NOT NULL AND b.actual_checkout IS NOT NULL THEN DATEDIFF(DATE(b.actual_checkout), DATE(b.actual_checkin)) WHEN b.actual_checkin IS NOT NULL - THEN DATEDIFF(CURDATE(), DATE(b.actual_checkin)) + THEN DATEDIFF(${sqlHotelToday()}, DATE(b.actual_checkin)) ELSE NULL END AS actual_days FROM parking_bookings b @@ -279,7 +281,7 @@ class ParkingBookingsRepository { WHEN b.actual_checkin IS NOT NULL AND b.actual_checkout IS NOT NULL THEN DATEDIFF(DATE(b.actual_checkout), DATE(b.actual_checkin)) WHEN b.actual_checkin IS NOT NULL - THEN DATEDIFF(CURDATE(), DATE(b.actual_checkin)) + THEN DATEDIFF(${sqlHotelToday()}, DATE(b.actual_checkin)) ELSE NULL END AS actual_days FROM parking_bookings b @@ -384,7 +386,10 @@ class ParkingBookingsRepository { // ============================================ // CHECK-IN // ============================================ - async checkIn(id: number, actual_checkin: Date = new Date()): Promise { + async checkIn( + id: number, + actual_checkin: Date = getHotelNowDate() + ): Promise { const connection = await pool.getConnection() try { @@ -414,7 +419,7 @@ class ParkingBookingsRepository { WHERE spot_id = ? AND id != ? AND status = 'checked_in' - AND (actual_checkout IS NULL OR actual_checkout > NOW()) + AND (actual_checkout IS NULL OR actual_checkout > ${sqlHotelNow()}) FOR SHARE`, [booking[0].spot_id, id] ) @@ -444,7 +449,7 @@ class ParkingBookingsRepository { // ============================================ async checkOut(id: number, checkoutData: CheckoutDTO): Promise { const { - actual_checkout = new Date(), + actual_checkout = getHotelNowDate(), payment_amount = null, payment_method = null, payment_reference = null, @@ -777,7 +782,7 @@ class ParkingBookingsRepository { switch (quickFilter) { case 'arrivals_pending': // Llegadas en espera: reserved + entrada hoy - whereClause = `WHERE b.status = 'reserved' AND DATE(b.expected_checkin) = CURDATE()` + whereClause = `WHERE b.status = 'reserved' AND DATE(b.expected_checkin) = ${sqlHotelToday()}` break case 'arrivals_inside': @@ -789,25 +794,25 @@ class ParkingBookingsRepository { // Total llegadas: reserved entrada hoy + checked_in whereClause = `WHERE ( b.status = 'checked_in' - OR (b.status = 'reserved' AND DATE(b.expected_checkin) = CURDATE()) + OR (b.status = 'reserved' AND DATE(b.expected_checkin) = ${sqlHotelToday()}) )` break case 'departures_pending': // Salidas en espera: checked_in + salida hoy - whereClause = `WHERE b.status = 'checked_in' AND DATE(b.expected_checkout) = CURDATE()` + whereClause = `WHERE b.status = 'checked_in' AND DATE(b.expected_checkout) = ${sqlHotelToday()}` break case 'departures_completed': // Completadas hoy (por fecha de checkout real) - whereClause = `WHERE b.status = 'completed' AND DATE(b.actual_checkout) = CURDATE()` + whereClause = `WHERE b.status = 'completed' AND DATE(b.actual_checkout) = ${sqlHotelToday()}` break case 'departures_total': // Total salidas: checked_in salida hoy + completed hoy whereClause = `WHERE ( - (b.status = 'checked_in' AND DATE(b.expected_checkout) = CURDATE()) - OR (b.status = 'completed' AND DATE(b.actual_checkout) = CURDATE()) + (b.status = 'checked_in' AND DATE(b.expected_checkout) = ${sqlHotelToday()}) + OR (b.status = 'completed' AND DATE(b.actual_checkout) = ${sqlHotelToday()}) )` break @@ -866,7 +871,7 @@ class ParkingBookingsRepository { WHEN b.actual_checkin IS NOT NULL AND b.actual_checkout IS NOT NULL THEN DATEDIFF(DATE(b.actual_checkout), DATE(b.actual_checkin)) WHEN b.actual_checkin IS NOT NULL - THEN DATEDIFF(CURDATE(), DATE(b.actual_checkin)) + THEN DATEDIFF(${sqlHotelToday()}, DATE(b.actual_checkin)) ELSE NULL END AS actual_days FROM parking_bookings b @@ -982,7 +987,7 @@ class ParkingBookingsRepository { WHEN b.actual_checkin IS NOT NULL AND b.actual_checkout IS NOT NULL THEN DATEDIFF(DATE(b.actual_checkout), DATE(b.actual_checkin)) WHEN b.actual_checkin IS NOT NULL - THEN DATEDIFF(CURDATE(), DATE(b.actual_checkin)) + THEN DATEDIFF(${sqlHotelToday()}, DATE(b.actual_checkin)) ELSE NULL END AS actual_days FROM parking_bookings b @@ -1028,7 +1033,7 @@ class ParkingBookingsRepository { b.actual_checkin, b.expected_checkout, b.status, - TIMESTAMPDIFF(HOUR, b.expected_checkout, NOW()) AS horas_retraso, + TIMESTAMPDIFF(HOUR, b.expected_checkout, ${sqlHotelNow()}) AS horas_retraso, b.notes, b.created_at, b.updated_at @@ -1037,7 +1042,7 @@ class ParkingBookingsRepository { LEFT JOIN parking_vehicles v ON b.vehicle_id = v.id WHERE b.status = 'checked_in' AND b.actual_checkout IS NULL - AND b.expected_checkout < NOW() + AND b.expected_checkout < ${sqlHotelNow()} ORDER BY b.expected_checkout ASC ` @@ -1077,10 +1082,10 @@ class ParkingBookingsRepository { } : null, schedule: { - expected_checkin: row.expected_checkin, - expected_checkout: row.expected_checkout, - actual_checkin: row.actual_checkin, - actual_checkout: row.actual_checkout, + expected_checkin: toHotelTime(row.expected_checkin), + expected_checkout: toHotelTime(row.expected_checkout), + actual_checkin: toHotelTime(row.actual_checkin), + actual_checkout: toHotelTime(row.actual_checkout), planned_days: row.planned_days, actual_days: row.actual_days, }, @@ -1139,8 +1144,8 @@ class ParkingBookingsRepository { owner: row.owner_name, model: row.vehicle_model, }, - actual_checkin: row.actual_checkin, - expected_checkout: row.expected_checkout, + actual_checkin: toHotelTime(row.actual_checkin), + expected_checkout: toHotelTime(row.expected_checkout), horas_retraso: row.horas_retraso, status: row.status, notes: row.notes, diff --git a/backend/repositories/parking/stats.repository.ts b/backend/repositories/parking/stats.repository.ts index 2cc8db8..2197a8c 100644 --- a/backend/repositories/parking/stats.repository.ts +++ b/backend/repositories/parking/stats.repository.ts @@ -3,7 +3,8 @@ // ============================================ import pool from '../../config/db.js' import { RowDataPacket } from 'mysql2/promise' -import { formatDateMadrid } from '../../config/date-utils.js' +import { formatDateMadrid, toHotelTime } from '../../config/date-utils.js' +import { sqlHotelNow } from '../shared/hotel-clock.js' import type { DailyStatsRow, OccupancyByLevelRow, @@ -320,8 +321,8 @@ class ParkingStatsRepository { } : null, schedule: { - expected_checkin: row.expected_checkin, - expected_checkout: row.expected_checkout, + expected_checkin: toHotelTime(row.expected_checkin), + expected_checkout: toHotelTime(row.expected_checkout), days: row.days, }, booking_info: { @@ -366,7 +367,7 @@ class ParkingStatsRepository { b.payment_method, b.notes, CASE - WHEN NOW() > b.expected_checkout THEN 1 + WHEN ${sqlHotelNow()} > b.expected_checkout THEN 1 ELSE 0 END AS is_overdue FROM parking_bookings b @@ -375,7 +376,7 @@ class ParkingStatsRepository { WHERE b.status = 'checked_in' AND DATE(b.expected_checkout) BETWEEN ? AND ? ORDER BY - CASE WHEN NOW() > b.expected_checkout THEN 0 ELSE 1 END, + CASE WHEN ${sqlHotelNow()} > b.expected_checkout THEN 0 ELSE 1 END, b.expected_checkout, ps.level_code, ps.spot_number @@ -405,7 +406,7 @@ class ParkingStatsRepository { b.payment_method, b.notes, CASE - WHEN NOW() > b.expected_checkout THEN 1 + WHEN ${sqlHotelNow()} > b.expected_checkout THEN 1 ELSE 0 END AS is_overdue FROM parking_bookings b @@ -414,7 +415,7 @@ class ParkingStatsRepository { WHERE b.status = 'checked_in' AND DATE(b.expected_checkout) = ? ORDER BY - CASE WHEN NOW() > b.expected_checkout THEN 0 ELSE 1 END, + CASE WHEN ${sqlHotelNow()} > b.expected_checkout THEN 0 ELSE 1 END, b.expected_checkout, ps.level_code, ps.spot_number @@ -439,9 +440,9 @@ class ParkingStatsRepository { } : null, schedule: { - expected_checkin: row.expected_checkin, - actual_checkin: row.actual_checkin, - expected_checkout: row.expected_checkout, + expected_checkin: toHotelTime(row.expected_checkin), + actual_checkin: toHotelTime(row.actual_checkin), + expected_checkout: toHotelTime(row.expected_checkout), planned_days: row.planned_days, actual_days: row.actual_days, is_overdue: row.is_overdue === 1, diff --git a/backend/repositories/search/search-repository.ts b/backend/repositories/search/search-repository.ts index f8d3fee..97955e0 100644 --- a/backend/repositories/search/search-repository.ts +++ b/backend/repositories/search/search-repository.ts @@ -7,6 +7,7 @@ import db from '../../config/db.js' import type { RowDataPacket } from 'mysql2' import { type SearchModule } from '../../services/search/search-access.js' +import { toHotelTime } from '../../config/date-utils.js' import { likeContains } from '../shared/like.js' // ======================================== @@ -133,8 +134,8 @@ async function searchParking(query: string): Promise { plate_number: row.plate_number, owner_name: row.owner_name, status: row.status, - expected_checkin: row.expected_checkin, - expected_checkout: row.expected_checkout, + expected_checkin: toHotelTime(row.expected_checkin), + expected_checkout: toHotelTime(row.expected_checkout), })) } diff --git a/backend/repositories/shared/hotel-clock.ts b/backend/repositories/shared/hotel-clock.ts new file mode 100644 index 0000000..6604c0b --- /dev/null +++ b/backend/repositories/shared/hotel-clock.ts @@ -0,0 +1,13 @@ +// repositories/shared/hotel-clock.ts +// The hotel's "today" and "now" as SQL literals, in place of CURDATE() and NOW(): +// the MySQL session runs in UTC, and the business day is Madrid's (ADR-047). +// The values come from the server clock, never from a request. + +import mysql from 'mysql2' +import { getNowMadrid, getTodayMadrid } from '../../config/date-utils.js' + +/** Madrid's date as a quoted 'YYYY-MM-DD', for DATE columns and DATE(...) of wall-clock times */ +export const sqlHotelToday = (): string => mysql.escape(getTodayMadrid()) + +/** Madrid's wall-clock time as a quoted 'YYYY-MM-DD HH:mm:ss', for parking check-in/out times */ +export const sqlHotelNow = (): string => mysql.escape(getNowMadrid().format('YYYY-MM-DD HH:mm:ss')) diff --git a/backend/services/cron/cron-service.ts b/backend/services/cron/cron-service.ts index 28ed7f7..7c6e750 100644 --- a/backend/services/cron/cron-service.ts +++ b/backend/services/cron/cron-service.ts @@ -8,6 +8,7 @@ import { purgeOldEventLogs, } from '../checklist/checklist.service.js' import { logger } from '../../config/logger.js' +import { getNowMadrid } from '../../config/date-utils.js' import { extendAvailability } from '../../repositories/parking/parking.repository.js' import { claimRun, finishRun } from '../../repositories/cron/cron-job-runs-repository.js' import { @@ -208,15 +209,14 @@ export class CronService { const startTime = Date.now() // Calculate previous month if not specified - const now = new Date() let year = targetYear let month = targetMonth if (!year || !month) { - // Get previous month - const previousMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1) - year = previousMonth.getFullYear() - month = previousMonth.getMonth() + 1 // getMonth() is 0-indexed + // The previous month in Madrid, not in UTC (ADR-047) + const previousMonth = getNowMadrid().subtract(1, 'month') + year = previousMonth.year() + month = previousMonth.month() + 1 } const executingUserId = userId || this.SYSTEM_USER_ID @@ -287,14 +287,14 @@ export class CronService { count: number total_amount: number }> { - const now = new Date() let year = targetYear let month = targetMonth if (!year || !month) { - const previousMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1) - year = previousMonth.getFullYear() - month = previousMonth.getMonth() + 1 + // The previous month in Madrid, not in UTC (ADR-047) + const previousMonth = getNowMadrid().subtract(1, 'month') + year = previousMonth.year() + month = previousMonth.month() + 1 } const preview = await BackofficeRepository.getValidatedInvoicesCountByMonth(year, month) diff --git a/backend/tests/config/hotel-clock.test.ts b/backend/tests/config/hotel-clock.test.ts new file mode 100644 index 0000000..d1112b1 --- /dev/null +++ b/backend/tests/config/hotel-clock.test.ts @@ -0,0 +1,50 @@ +// tests/config/hotel-clock.test.ts +// "Today" and "now" are the hotel's (Madrid), not UTC's (ADR-047). At 00:30 in +// Madrid it is still yesterday in UTC: CURDATE(), NOW() and toISOString() gave +// the wrong day, and parking showed every booking time two hours late. + +import { describe, it, expect, vi, afterEach } from 'vitest' +import { + getHotelNowDate, + getTodayMadrid, + parseHotelTime, + toHotelTime, +} from '../../config/date-utils.js' +import { sqlHotelNow, sqlHotelToday } from '../../repositories/shared/hotel-clock.js' + +afterEach(() => vi.useRealTimers()) + +describe('at 00:30 in Madrid', () => { + it('summer: today is already the 15th', () => { + vi.useFakeTimers({ now: new Date('2026-10-14T22:30:00Z') }) + expect(new Date().toISOString().slice(0, 10)).toBe('2026-10-14') + expect(getTodayMadrid()).toBe('2026-10-15') + expect(sqlHotelToday()).toBe("'2026-10-15'") + expect(sqlHotelNow()).toBe("'2026-10-15 00:30:00'") + }) + + it('winter: the offset is one hour', () => { + vi.useFakeTimers({ now: new Date('2026-12-14T23:30:00Z') }) + expect(getTodayMadrid()).toBe('2026-12-15') + expect(sqlHotelNow()).toBe("'2026-12-15 00:30:00'") + }) + + it('the wall-clock now is stored as the Madrid time', () => { + vi.useFakeTimers({ now: new Date('2026-10-14T22:30:00Z') }) + expect(getHotelNowDate().toISOString()).toBe('2026-10-15T00:30:00.000Z') + }) +}) + +describe('parking wall-clock times', () => { + it('stores what the user typed, with or without the T', () => { + expect(parseHotelTime('2026-10-14T12:00').toISOString()).toBe('2026-10-14T12:00:00.000Z') + expect(parseHotelTime('2026-10-14 12:00:00').toISOString()).toBe('2026-10-14T12:00:00.000Z') + }) + + it('answers the stored time without Z, so the browser reads 12:00', () => { + // mysql2 (timezone Z) reads the stored 12:00:00 as this Date + expect(toHotelTime(new Date('2026-10-14T12:00:00.000Z'))).toBe('2026-10-14T12:00:00') + expect(toHotelTime('2026-10-14 12:00:00')).toBe('2026-10-14T12:00:00') + expect(toHotelTime(null)).toBeNull() + }) +}) From 5e44baecefa27db0da6f69abbd7ca1e91a80ee4f Mon Sep 17 00:00:00 2001 From: razyd Date: Wed, 14 Oct 2026 19:25:56 +0200 Subject: [PATCH 04/10] fix(api): filter timestamps by their Madrid day - Replace DATE(column) on UTC timestamps with the column's Madrid date (sqlMadridDay, CONVERT_TZ to Europe/Madrid): an action at 00:30 in Madrid was listed under the day before in the cashier history and vouchers, the activity feed, logbooks without a date, maintenance reports, parking stats and the demo activity log. - Fall back to the UTC date where MySQL has no named time zones (a local install), as before; Aiven has them. --- .../cashier/cashier-history-controller.ts | 9 +++++---- .../repositories/activity/activity-repository.ts | 9 +++++---- .../cashier/cashier-history-repository.ts | 9 +++++---- .../cashier/cashier-voucher-repository.ts | 13 +++++++------ .../repositories/demo/demo-activity-repository.ts | 9 +++++---- backend/repositories/logbook/logbook-repository.ts | 7 ++++--- .../maintenance/maintenance-repository.ts | 7 ++++--- backend/repositories/parking/stats.repository.ts | 10 +++++----- backend/repositories/shared/hotel-clock.ts | 9 +++++++++ 9 files changed, 49 insertions(+), 33 deletions(-) diff --git a/backend/controllers/cashier/cashier-history-controller.ts b/backend/controllers/cashier/cashier-history-controller.ts index 0ad9472..487009d 100644 --- a/backend/controllers/cashier/cashier-history-controller.ts +++ b/backend/controllers/cashier/cashier-history-controller.ts @@ -12,6 +12,7 @@ import { recentHistoryQuerySchema, validationError, } from '../../validations/cashier/cashier-validation.js' +import { sqlMadridDay } from '../../repositories/shared/hotel-clock.js' export class CashierHistoryController { /** @@ -94,8 +95,8 @@ export class CashierHistoryController { COUNT(*) as count FROM cashier_history WHERE 1=1 - ${from_date ? 'AND DATE(changed_at) >= ?' : ''} - ${to_date ? 'AND DATE(changed_at) <= ?' : ''} + ${from_date ? `AND ${sqlMadridDay('changed_at')} >= ?` : ''} + ${to_date ? `AND ${sqlMadridDay('changed_at')} <= ?` : ''} GROUP BY action ORDER BY count DESC ` @@ -113,8 +114,8 @@ export class CashierHistoryController { COUNT(*) as actions_count FROM cashier_history WHERE changed_by IS NOT NULL - ${from_date ? 'AND DATE(changed_at) >= ?' : ''} - ${to_date ? 'AND DATE(changed_at) <= ?' : ''} + ${from_date ? `AND ${sqlMadridDay('changed_at')} >= ?` : ''} + ${to_date ? `AND ${sqlMadridDay('changed_at')} <= ?` : ''} GROUP BY changed_by ORDER BY actions_count DESC LIMIT 10 diff --git a/backend/repositories/activity/activity-repository.ts b/backend/repositories/activity/activity-repository.ts index 375feee..d36408b 100644 --- a/backend/repositories/activity/activity-repository.ts +++ b/backend/repositories/activity/activity-repository.ts @@ -6,6 +6,7 @@ import db from '../../config/db.js' import { RowDataPacket } from 'mysql2' +import { sqlMadridDay } from '../shared/hotel-clock.js' // ======================================== // TYPES @@ -235,7 +236,7 @@ export class ActivityRepository { CAST(ch.shift_id AS CHAR) COLLATE utf8mb4_0900_ai_ci as record_id FROM cashier_history ch LEFT JOIN users u1 ON ch.changed_by = u1.id - WHERE DATE(ch.changed_at) = ? + WHERE ${sqlMadridDay('ch.changed_at')} = ? UNION ALL @@ -249,7 +250,7 @@ export class ActivityRepository { CAST(gh.group_id AS CHAR) COLLATE utf8mb4_0900_ai_ci as record_id FROM group_history gh LEFT JOIN users u2 ON gh.changed_by = u2.id - WHERE DATE(gh.changed_at) = ? + WHERE ${sqlMadridDay('gh.changed_at')} = ? UNION ALL @@ -263,7 +264,7 @@ export class ActivityRepository { CAST(lh.logbook_id AS CHAR) COLLATE utf8mb4_0900_ai_ci as record_id FROM logbook_history lh LEFT JOIN users u3 ON lh.editor_id = u3.id - WHERE DATE(lh.created_at) = ? + WHERE ${sqlMadridDay('lh.created_at')} = ? UNION ALL @@ -277,7 +278,7 @@ export class ActivityRepository { mh.report_id COLLATE utf8mb4_0900_ai_ci as record_id FROM maintenance_history mh LEFT JOIN users u4 ON mh.changed_by = u4.id - WHERE DATE(mh.changed_at) = ? + WHERE ${sqlMadridDay('mh.changed_at')} = ? ) AS combined ORDER BY timestamp DESC LIMIT ? diff --git a/backend/repositories/cashier/cashier-history-repository.ts b/backend/repositories/cashier/cashier-history-repository.ts index f801d35..f01102a 100644 --- a/backend/repositories/cashier/cashier-history-repository.ts +++ b/backend/repositories/cashier/cashier-history-repository.ts @@ -4,6 +4,7 @@ import db from '../../config/db.js' import { SORT_FIELDS, safeSort, safeOrder } from '../../validations/cashier/cashier-validation.js' import { ResultSetHeader, RowDataPacket } from 'mysql2' import { CashierHistory, CreateHistoryDTO, HistoryFilters } from '../../models/cashier/index.js' +import { sqlMadridDay } from '../shared/hotel-clock.js' export class CashierHistoryRepository { /** @@ -94,12 +95,12 @@ export class CashierHistoryRepository { } if (filters.from_date) { - query += ' AND DATE(changed_at) >= ?' + query += ` AND ${sqlMadridDay('changed_at')} >= ?` params.push(filters.from_date) } if (filters.to_date) { - query += ' AND DATE(changed_at) <= ?' + query += ` AND ${sqlMadridDay('changed_at')} <= ?` params.push(filters.to_date) } @@ -148,12 +149,12 @@ export class CashierHistoryRepository { } if (filters.from_date) { - query += ' AND DATE(changed_at) >= ?' + query += ` AND ${sqlMadridDay('changed_at')} >= ?` params.push(filters.from_date) } if (filters.to_date) { - query += ' AND DATE(changed_at) <= ?' + query += ` AND ${sqlMadridDay('changed_at')} <= ?` params.push(filters.to_date) } diff --git a/backend/repositories/cashier/cashier-voucher-repository.ts b/backend/repositories/cashier/cashier-voucher-repository.ts index 4dd944c..7841986 100644 --- a/backend/repositories/cashier/cashier-voucher-repository.ts +++ b/backend/repositories/cashier/cashier-voucher-repository.ts @@ -9,6 +9,7 @@ import { UpdateVoucherDTO, VoucherFilters, } from '../../models/cashier/index.js' +import { sqlMadridDay } from '../shared/hotel-clock.js' export class CashierVoucherRepository { /** @@ -51,12 +52,12 @@ export class CashierVoucherRepository { } if (filters.from_date) { - query += ' AND DATE(v.created_at) >= ?' + query += ` AND ${sqlMadridDay('v.created_at')} >= ?` params.push(filters.from_date) } if (filters.to_date) { - query += ' AND DATE(v.created_at) <= ?' + query += ` AND ${sqlMadridDay('v.created_at')} <= ?` params.push(filters.to_date) } @@ -115,12 +116,12 @@ export class CashierVoucherRepository { } if (filters.from_date) { - query += ' AND DATE(v.created_at) >= ?' + query += ` AND ${sqlMadridDay('v.created_at')} >= ?` params.push(filters.from_date) } if (filters.to_date) { - query += ' AND DATE(v.created_at) <= ?' + query += ` AND ${sqlMadridDay('v.created_at')} <= ?` params.push(filters.to_date) } @@ -429,12 +430,12 @@ export class CashierVoucherRepository { const params: (string | number)[] = [] if (fromDate) { - query += ' AND DATE(created_at) >= ?' + query += ` AND ${sqlMadridDay('created_at')} >= ?` params.push(fromDate) } if (toDate) { - query += ' AND DATE(created_at) <= ?' + query += ` AND ${sqlMadridDay('created_at')} <= ?` params.push(toDate) } diff --git a/backend/repositories/demo/demo-activity-repository.ts b/backend/repositories/demo/demo-activity-repository.ts index 2c44fc0..24cf3f9 100644 --- a/backend/repositories/demo/demo-activity-repository.ts +++ b/backend/repositories/demo/demo-activity-repository.ts @@ -7,6 +7,7 @@ import db from '../../config/db.js' import { RowDataPacket, ResultSetHeader } from 'mysql2' import { likeContains } from '../shared/like.js' +import { sqlMadridDay } from '../shared/hotel-clock.js' // ======================================== // TYPES @@ -133,12 +134,12 @@ export class DemoActivityRepository { } if (filters?.startDate) { - whereClause += ' AND DATE(timestamp) >= ?' + whereClause += ` AND ${sqlMadridDay('timestamp')} >= ?` params.push(filters.startDate) } if (filters?.endDate) { - whereClause += ' AND DATE(timestamp) <= ?' + whereClause += ` AND ${sqlMadridDay('timestamp')} <= ?` params.push(filters.endDate) } @@ -213,10 +214,10 @@ export class DemoActivityRepository { // Activity by day const [daysResult] = await db.query( - `SELECT DATE(timestamp) as date, COUNT(*) as count + `SELECT ${sqlMadridDay('timestamp')} as date, COUNT(*) as count FROM demo_activity_log WHERE timestamp >= DATE_SUB(NOW(), INTERVAL ? DAY) - GROUP BY DATE(timestamp) + GROUP BY ${sqlMadridDay('timestamp')} ORDER BY date DESC`, [days] ) diff --git a/backend/repositories/logbook/logbook-repository.ts b/backend/repositories/logbook/logbook-repository.ts index 0447c32..abed4f8 100644 --- a/backend/repositories/logbook/logbook-repository.ts +++ b/backend/repositories/logbook/logbook-repository.ts @@ -10,6 +10,7 @@ import type { CreatedLogbook, ResultSetHeader, } from '../../models/logbook/index.js' +import { sqlMadridDay } from '../shared/hotel-clock.js' // ============================================ // PAGINATION DEFAULTS @@ -133,12 +134,12 @@ export async function getLogbooksFiltered( } if (options.date_from) { - conditions.push('(l.date >= ? OR (l.date IS NULL AND DATE(l.created_at) >= ?))') + conditions.push(`(l.date >= ? OR (l.date IS NULL AND ${sqlMadridDay('l.created_at')} >= ?))`) params.push(options.date_from, options.date_from) } if (options.date_to) { - conditions.push('(l.date <= ? OR (l.date IS NULL AND DATE(l.created_at) <= ?))') + conditions.push(`(l.date <= ? OR (l.date IS NULL AND ${sqlMadridDay('l.created_at')} <= ?))`) params.push(options.date_to, options.date_to) } @@ -249,7 +250,7 @@ export async function getLogbooksByDay( FROM logbooks l LEFT JOIN users u ON l.author_id = u.id LEFT JOIN departments d ON l.department_id = d.id - WHERE (l.date = ? OR (l.date IS NULL AND DATE(l.created_at) = ?)) + WHERE (l.date = ? OR (l.date IS NULL AND ${sqlMadridDay('l.created_at')} = ?)) AND l.deleted_at IS NULL ORDER BY l.created_at DESC LIMIT ? OFFSET ?`, diff --git a/backend/repositories/maintenance/maintenance-repository.ts b/backend/repositories/maintenance/maintenance-repository.ts index 963e558..e2b9092 100644 --- a/backend/repositories/maintenance/maintenance-repository.ts +++ b/backend/repositories/maintenance/maintenance-repository.ts @@ -21,6 +21,7 @@ import type { } from '../../models/maintenance/index.js' import { getNowMadrid } from '../../config/date-utils.js' import { likeContains } from '../shared/like.js' +import { sqlMadridDay } from '../shared/hotel-clock.js' // ======================================== // INTERFACES INTERNAS (para tipado de queries) @@ -320,17 +321,17 @@ export class MaintenanceRepository { // Filtro por fecha específica (single day) if (date) { - conditions.push(`DATE(r.report_date) = ?`) + conditions.push(`${sqlMadridDay('r.report_date')} = ?`) whereParams.push(date) } // Filtro por rango de fechas (inclusivo en ambos extremos) if (date_from && !date) { - conditions.push(`DATE(r.report_date) >= ?`) + conditions.push(`${sqlMadridDay('r.report_date')} >= ?`) whereParams.push(date_from) } if (date_to && !date) { - conditions.push(`DATE(r.report_date) <= ?`) + conditions.push(`${sqlMadridDay('r.report_date')} <= ?`) whereParams.push(date_to) } diff --git a/backend/repositories/parking/stats.repository.ts b/backend/repositories/parking/stats.repository.ts index 2197a8c..d1d1a2a 100644 --- a/backend/repositories/parking/stats.repository.ts +++ b/backend/repositories/parking/stats.repository.ts @@ -4,7 +4,7 @@ import pool from '../../config/db.js' import { RowDataPacket } from 'mysql2/promise' import { formatDateMadrid, toHotelTime } from '../../config/date-utils.js' -import { sqlHotelNow } from '../shared/hotel-clock.js' +import { sqlHotelNow, sqlMadridDay } from '../shared/hotel-clock.js' import type { DailyStatsRow, OccupancyByLevelRow, @@ -101,14 +101,14 @@ class ParkingStatsRepository { (SELECT COUNT(*) FROM parking_bookings WHERE status = 'canceled' - AND DATE(updated_at) = ? + AND ${sqlMadridDay('updated_at')} = ? ) AS canceled_today, -- No-shows hoy (SELECT COUNT(*) FROM parking_bookings WHERE status = 'no_show' - AND DATE(updated_at) = ? + AND ${sqlMadridDay('updated_at')} = ? ) AS no_shows_today ` @@ -622,14 +622,14 @@ class ParkingStatsRepository { (SELECT COUNT(*) FROM parking_bookings WHERE status = 'canceled' - AND DATE(updated_at) BETWEEN ? AND ? + AND ${sqlMadridDay('updated_at')} BETWEEN ? AND ? ) AS canceled_in_range, -- No-shows en el rango (SELECT COUNT(*) FROM parking_bookings WHERE status = 'no_show' - AND DATE(updated_at) BETWEEN ? AND ? + AND ${sqlMadridDay('updated_at')} BETWEEN ? AND ? ) AS no_shows_in_range ` diff --git a/backend/repositories/shared/hotel-clock.ts b/backend/repositories/shared/hotel-clock.ts index 6604c0b..0de0e22 100644 --- a/backend/repositories/shared/hotel-clock.ts +++ b/backend/repositories/shared/hotel-clock.ts @@ -11,3 +11,12 @@ export const sqlHotelToday = (): string => mysql.escape(getTodayMadrid()) /** Madrid's wall-clock time as a quoted 'YYYY-MM-DD HH:mm:ss', for parking check-in/out times */ export const sqlHotelNow = (): string => mysql.escape(getNowMadrid().format('YYYY-MM-DD HH:mm:ss')) + +/** + * The Madrid date of an instant column (stored in UTC), in place of DATE(column): + * at 00:30 in Madrid the UTC date is still yesterday. Without named time zones + * (a local MySQL) CONVERT_TZ gives NULL and it falls back to the UTC date. + * `column` is a column name from the code, never input. + */ +export const sqlMadridDay = (column: string): string => + `DATE(COALESCE(CONVERT_TZ(${column}, '+00:00', 'Europe/Madrid'), ${column}))` From 2afaa9da250aacd2e0d5eaa0be1aaf5a9bb8c045 Mon Sep 17 00:00:00 2001 From: razyd Date: Wed, 14 Oct 2026 19:25:57 +0200 Subject: [PATCH 05/10] fix(frontend): take today from Madrid, not from the UTC date - Replace new Date().toISOString() as "today" with getMadridDate(): between 00:00 and 02:00 in Madrid it gave yesterday as the default date of a new invoice, the cashier log day, the parking edit form and the blacklist form, and in the names of the CSV and ZIP exports. --- .../app/components/blacklist/mains/BlacklistForm.tsx | 5 +++-- frontend/app/components/bo/modals/InvoiceFormModal.tsx | 9 +++++---- frontend/app/components/bo/tabs/PaidInvoicesTabLazy.tsx | 6 +++--- .../app/components/bo/tabs/PendingInvoicesTabLazy.tsx | 4 ++-- .../app/components/parking/bookings/EditBookingModal.tsx | 5 ++--- frontend/app/lib/backoffice/export-utils.ts | 5 +++-- frontend/app/stores/useCashierStore.ts | 6 +++--- 7 files changed, 21 insertions(+), 19 deletions(-) diff --git a/frontend/app/components/blacklist/mains/BlacklistForm.tsx b/frontend/app/components/blacklist/mains/BlacklistForm.tsx index f0453b1..294aabc 100644 --- a/frontend/app/components/blacklist/mains/BlacklistForm.tsx +++ b/frontend/app/components/blacklist/mains/BlacklistForm.tsx @@ -41,6 +41,7 @@ type BlacklistFormValues = { // ✅ Y también las constantes import { DOCUMENT_TYPES, SEVERITY_LEVELS } from '@/app/lib/blacklist/types' import { privateFileUrl } from '@/app/lib/helpers/private-file' +import { getMadridDate } from '@/app/lib/helpers/date' interface BlacklistFormProps { mode: 'create' | 'edit' @@ -82,8 +83,8 @@ export function BlacklistForm({ mode, initialData, onSuccess }: BlacklistFormPro guest_name: '', document_type: 'DNI', document_number: '', - check_in_date: new Date(), - check_out_date: new Date(), + check_in_date: new Date(getMadridDate()), + check_out_date: new Date(getMadridDate()), reason: '', severity: 'MEDIUM', comments: '', diff --git a/frontend/app/components/bo/modals/InvoiceFormModal.tsx b/frontend/app/components/bo/modals/InvoiceFormModal.tsx index 5d13794..98a3577 100644 --- a/frontend/app/components/bo/modals/InvoiceFormModal.tsx +++ b/frontend/app/components/bo/modals/InvoiceFormModal.tsx @@ -21,6 +21,7 @@ import { applyVat } from '@/app/lib/backoffice/types' import { backofficeApi } from '@/app/lib/backoffice/backofficeApi' import DatePickerInput from '@/app/ui/calendar/DatePickerInput' import toast from 'react-hot-toast' +import { getMadridDate } from '@/app/lib/helpers/date' interface InvoiceFormModalProps { isOpen: boolean @@ -59,8 +60,8 @@ export function InvoiceFormModal({ amount_without_vat: 0, amount_with_vat: 0, vat_percentage: 21, - invoice_date: new Date().toISOString().split('T')[0], - received_date: new Date().toISOString().split('T')[0], + invoice_date: getMadridDate(), + received_date: getMadridDate(), billing_period_start: undefined, billing_period_end: undefined, due_date: undefined, @@ -97,8 +98,8 @@ export function InvoiceFormModal({ amount_without_vat: 0, amount_with_vat: 0, vat_percentage: 21, - invoice_date: new Date().toISOString().split('T')[0], - received_date: new Date().toISOString().split('T')[0], + invoice_date: getMadridDate(), + received_date: getMadridDate(), billing_period_start: undefined, billing_period_end: undefined, due_date: undefined, diff --git a/frontend/app/components/bo/tabs/PaidInvoicesTabLazy.tsx b/frontend/app/components/bo/tabs/PaidInvoicesTabLazy.tsx index 8227ed2..04010a6 100644 --- a/frontend/app/components/bo/tabs/PaidInvoicesTabLazy.tsx +++ b/frontend/app/components/bo/tabs/PaidInvoicesTabLazy.tsx @@ -10,7 +10,7 @@ import { useMemo, useState, useCallback } from 'react' import { useTranslations } from 'next-intl' -import { formatDateDisplayShort } from '@/app/lib/helpers/date' +import { formatDateDisplayShort, getMadridDate } from '@/app/lib/helpers/date' import { FiSearch, FiDownload, @@ -397,7 +397,7 @@ export function PaidInvoicesTabLazy({ const url = window.URL.createObjectURL(blob) const link = document.createElement('a') link.href = url - link.download = `facturas_pagadas_${new Date().toISOString().split('T')[0]}.csv` + link.download = `facturas_pagadas_${getMadridDate()}.csv` document.body.appendChild(link) link.click() document.body.removeChild(link) @@ -444,7 +444,7 @@ export function PaidInvoicesTabLazy({ const url = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '') + const timestamp = getMadridDate().replace(/-/g, '') a.download = `facturas_pagadas_${timestamp}.zip` document.body.appendChild(a) a.click() diff --git a/frontend/app/components/bo/tabs/PendingInvoicesTabLazy.tsx b/frontend/app/components/bo/tabs/PendingInvoicesTabLazy.tsx index 4120b9c..e138f82 100644 --- a/frontend/app/components/bo/tabs/PendingInvoicesTabLazy.tsx +++ b/frontend/app/components/bo/tabs/PendingInvoicesTabLazy.tsx @@ -11,7 +11,7 @@ import React, { useMemo, useState, useTransition } from 'react' import { useTranslations } from 'next-intl' -import { formatDateDisplayShort } from '@/app/lib/helpers/date' +import { formatDateDisplayShort, getMadridDate } from '@/app/lib/helpers/date' import { FiSearch, FiPlus, @@ -398,7 +398,7 @@ export function PendingInvoicesTabLazy({ const url = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '') + const timestamp = getMadridDate().replace(/-/g, '') a.download = `facturas_validadas_${timestamp}.zip` document.body.appendChild(a) a.click() diff --git a/frontend/app/components/parking/bookings/EditBookingModal.tsx b/frontend/app/components/parking/bookings/EditBookingModal.tsx index 47382db..b59ed3c 100644 --- a/frontend/app/components/parking/bookings/EditBookingModal.tsx +++ b/frontend/app/components/parking/bookings/EditBookingModal.tsx @@ -8,6 +8,7 @@ import { parkingApi } from '@/app/lib/parking' import { formatDateTimeLocal } from '../helpers' import { FiEdit2, FiX, FiSearch, FiMapPin, FiTruck, FiSlash, FiUserX } from 'react-icons/fi' import { SelectDropdown } from '@/app/ui/components/SelectDropdown' +import { getMadridDate } from '@/app/lib/helpers/date' interface EditBookingModalProps { booking: ParkingBooking @@ -86,9 +87,7 @@ export function EditBookingModal({ const loadAvailableSpots = async () => { // Si la fecha de entrada está en el pasado, usar hoy como start_date - const startDate = isCheckinInPast - ? new Date().toISOString().split('T')[0] - : data.expected_checkin.split('T')[0] + const startDate = isCheckinInPast ? getMadridDate() : data.expected_checkin.split('T')[0] const endDate = data.expected_checkout.split('T')[0] // Validar que start_date < end_date antes de hacer la llamada diff --git a/frontend/app/lib/backoffice/export-utils.ts b/frontend/app/lib/backoffice/export-utils.ts index 9fbb506..51815c7 100644 --- a/frontend/app/lib/backoffice/export-utils.ts +++ b/frontend/app/lib/backoffice/export-utils.ts @@ -9,6 +9,7 @@ import { PDFDocument, rgb, StandardFonts } from 'pdf-lib' import { encodableFor } from '@/app/lib/helpers/pdfText' import type { InvoiceWithDetails } from './types' import { INVOICE_STATUS_LABELS, PAYMENT_METHOD_LABELS, formatCurrency } from './types' +import { getMadridDate } from '@/app/lib/helpers/date' /** * Format date for display in exports (DD/MM/YYYY Madrid). Returns '-' for null. @@ -100,7 +101,7 @@ export async function exportToExcel( XLSX.utils.book_append_sheet(wb, wsSummary, 'Resumen') // Generate filename - const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '') + const timestamp = getMadridDate().replace(/-/g, '') const finalFilename = filename || `facturas_pendientes_${timestamp}.xlsx` // Download file @@ -314,7 +315,7 @@ export async function exportToPdf( }) // Generate filename - const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '') + const timestamp = getMadridDate().replace(/-/g, '') const finalFilename = filename || `facturas_pendientes_${timestamp}.pdf` // Save and download diff --git a/frontend/app/stores/useCashierStore.ts b/frontend/app/stores/useCashierStore.ts index eb5322c..e2859d4 100644 --- a/frontend/app/stores/useCashierStore.ts +++ b/frontend/app/stores/useCashierStore.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' import { useShallow } from 'zustand/react/shallow' -import { formatDateForInput } from '@/app/lib/helpers/date' +import { formatDateForInput, getMadridDate } from '@/app/lib/helpers/date' import type { ShiftType, HistoryAction } from '@/app/lib/cashier/types' // ═══════════════════════════════════════════════════════ @@ -125,7 +125,7 @@ const initialState = { modalData: null, // Logs - logsDate: today.toISOString().split('T')[0], + logsDate: getMadridDate(), logsActionFilter: 'all' as HistoryAction | 'all', logsUserFilter: '', logsLimit: 50, @@ -268,7 +268,7 @@ export const useCashierStore = create((set, get) => ({ logsGoToToday: () => { set({ - logsDate: new Date().toISOString().split('T')[0], + logsDate: getMadridDate(), logsOffset: 0, }) }, From 5c9ffeb7a8da7802198494d3ad03367e0b430c06 Mon Sep 17 00:00:00 2001 From: razyd Date: Wed, 14 Oct 2026 19:26:30 +0200 Subject: [PATCH 06/10] docs(agents): record the date rules of ADR-047 in the backend and parking docs --- backend/AGENTS.md | 5 ++++ backend/services/parking/AGENTS.md | 6 ++++ docs/DECISIONS.md | 48 +++++++++++++++++++----------- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 49632e9..9fd7115 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -250,6 +250,11 @@ index.ts → routes → controllers → services → repositories → models outside production, the production and Vercel domains — plus `FRONTEND_URL`. Credentials enabled. It is also the CSRF defence: any other origin gets a 403 before reaching a route. +- **Dates (ADR-047)**: the process and every MySQL session run in UTC; + timestamps are UTC instants. The hotel's "today" and "now" come from + `config/date-utils.ts` and `repositories/shared/hotel-clock.ts` + (`sqlHotelToday`, `sqlHotelNow`, `sqlMadridDay`), never from `CURDATE()`, + `NOW()`, `DATE(col)` or `toISOString()`. - **Cron jobs**: `services/cron/cron-service.ts`, started from `index.ts`. The times are in `cron-schedule.ts` (Europe/Madrid). Render's free plan sleeps, so on every start the jobs whose period is due and not done run, and diff --git a/backend/services/parking/AGENTS.md b/backend/services/parking/AGENTS.md index 9514d63..a377ac2 100644 --- a/backend/services/parking/AGENTS.md +++ b/backend/services/parking/AGENTS.md @@ -73,6 +73,12 @@ a method, **update both sides**. (`normal`, `ancha`, `mas_ancha`, `esquina`, `accesible`, `estrecha_bicis`). If either side changes, update the other. +`expected_checkin`, `expected_checkout`, `actual_checkin` and `actual_checkout` +are the hotel's wall clock: stored as typed (`12:00` is noon in Madrid), read +with `parseHotelTime` and answered without `Z` by `toHotelTime`, so the browser +shows them as typed. `payment_date` and the audit columns are UTC instants +(ADR-047). + ### Booking status - **`reserved`**: Booking created, waiting for arrival diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index be82dbd..bb59926 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1041,30 +1041,42 @@ - La migración tiene que estar en Aiven antes del despliegue; sin la tabla, cada tarea anota el error en el log y no se ejecuta. -## ADR-047 — El backend corre en UTC; el «hoy» del hotel se calcula en Madrid +## ADR-047 — El backend corre en UTC; el «hoy» y la hora del hotel, en Madrid - **Estado:** ✅ aceptada (2026-10-14) - **Fecha:** 2026-10-14 -- **Decisión:** el proceso del backend fija `TZ=UTC` al arrancar - (`config/timezone.ts`, primer import de `index.ts`), `mysql2` lee y escribe - las fechas en UTC (`timezone: 'Z'`) y cada sesión de MySQL fija - `time_zone = '+00:00'`. Las fechas de negocio («hoy», el día del hotel) se - calculan siempre en Node con `config/date-utils.ts` (`getTodayMadrid`) y se - pasan como parámetro; el SQL no usa `CURDATE()` para «hoy» ni zonas con - nombre. En el frontend, el «hoy» sale de `getMadridDate()`. Completa el canon - del refactor de mayo: UTC para las marcas de auditoría, Madrid para la lógica - de negocio. -- **Motivo:** Render y Aiven ya estaban en UTC, pero el portátil y el MySQL - local en hora de Madrid: el mismo código daba otra fecha según la máquina (una - `DATE` llegaba con un día menos en local y cada edición la movía). Y en +- **Decisión:** tres reglas, con una única fuente en `config/date-utils.ts` y + `repositories/shared/hotel-clock.ts`. + - **Instantes en UTC:** el backend fija `TZ=UTC` al arrancar + (`config/timezone.ts`, primer import de `index.ts`), `mysql2` usa + `timezone: 'Z'` y cada sesión de MySQL `time_zone = '+00:00'`. `created_at`, + `updated_at`, `changed_at`… son instantes UTC y el navegador los muestra en + su hora. + - **«Hoy» en Madrid:** se calcula en Node (`getTodayMadrid`, `sqlHotelToday`, + `sqlHotelNow`) en lugar de `CURDATE()`, `NOW()` o `toISOString()`; en el + frontend, `getMadridDate()`. Para saber el día de Madrid de un instante + guardado, `sqlMadridDay(col)` (`CONVERT_TZ` a `Europe/Madrid`, que Aiven + tiene cargado; sin zonas con nombre recurre a la fecha UTC). El trigger del + código de parking hace lo mismo. + - **Horas de parking = reloj del hotel:** la entrada y salida previstas y + reales se guardan tal como se escriben (`12:00` es mediodía en Madrid), se + leen con `parseHotelTime` y la API las devuelve sin `Z` (`toHotelTime`), así + que el navegador las lee como hora local. +- **Motivo:** Render y Aiven estaban en UTC, pero el portátil y el MySQL local + en hora de Madrid: el mismo código daba otra fecha según la máquina. En producción, entre las 00:00 y las 02:00 de Madrid, `CURDATE()` y - `toISOString()` daban todavía el día anterior. Se descartó devolver las `DATE` + `toISOString()` daban todavía el día anterior, y `DATE(col)` ponía en el día + anterior lo hecho a esas horas (historial de caja, actividad…). Parking + guardaba la hora escrita pero la devolvía como UTC: las pantallas la mostraban + 2 horas tarde y cada edición la movía otras 2. Se descartó devolver las `DATE` como texto (`dateStrings`): cambia la forma de los datos en decenas de sitios - para el mismo resultado. Se descartó `Europe/Madrid` en el SQL: el MySQL local - no tiene cargadas las zonas con nombre. + para el mismo resultado. Se descartó pasar parking a UTC con una migración: no + se puede distinguir qué filas escribió el usuario y cuáles el servidor. - **Consecuencias:** - Local se comporta como producción; en producción no cambia la forma de - ningún dato. + ningún dato salvo las horas de parking, que llegan sin `Z`. - Los `DATETIME` locales escritos antes con hora de Madrid se ven 2 horas - desplazados (datos de prueba). + desplazados, y en local `sqlMadridDay` usa la fecha UTC (datos de prueba). + - Queda con `CURDATE()` la vista `v_bo_suppliers_stats` (total del año en + curso): solo falla la primera hora del 1 de enero. - Los tests corren en UTC en cualquier máquina (`vitest.config.ts`). From 7e537b933bb6588cfb49b58077080d5bcf10d11a Mon Sep 17 00:00:00 2001 From: razyd Date: Wed, 14 Oct 2026 21:16:36 +0200 Subject: [PATCH 07/10] fix(groups): show changed dates in the history as dates - Store a changed date as plain ISO text: JSON.stringify quoted it, and the history tab parsed the quoted string as an object and listed one change per character. - Compare field by field only when both values are records, and strip the quotes of the rows already stored, so old entries read as dates too. - Show history times and dates in Madrid time. --- backend/services/group/group-history-service.ts | 5 +++++ .../tests/group/group-history-update.test.ts | 15 +++++++++++++++ .../components/groups/history/HistoryItem.tsx | 17 +++++++++++++++-- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/backend/services/group/group-history-service.ts b/backend/services/group/group-history-service.ts index 43ce372..d23a81b 100644 --- a/backend/services/group/group-history-service.ts +++ b/backend/services/group/group-history-service.ts @@ -238,6 +238,11 @@ export class GroupHistoryService { return null } + // A date as plain ISO text: JSON.stringify quoted it and the screen split it into characters + if (value instanceof Date) { + return value.toISOString() + } + if (typeof value === 'object') { return JSON.stringify(value) } diff --git a/backend/tests/group/group-history-update.test.ts b/backend/tests/group/group-history-update.test.ts index eb67e68..034b661 100644 --- a/backend/tests/group/group-history-update.test.ts +++ b/backend/tests/group/group-history-update.test.ts @@ -83,4 +83,19 @@ describe('GroupHistoryService.logGroupUpdated', () => { }) ) }) + + it('stores a changed date as plain ISO text, not quoted JSON', async () => { + const oldData = { arrival_date: new Date('2026-10-24T00:00:00.000Z') } + const newData = { arrival_date: new Date('2026-10-26T00:00:00.000Z') } + + await GroupHistoryService.logGroupUpdated(1, 'user1', oldData, newData) + + expect(GroupHistoryRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + field_changed: 'arrival_date', + old_value: '2026-10-24T00:00:00.000Z', + new_value: '2026-10-26T00:00:00.000Z', + }) + ) + }) }) diff --git a/frontend/app/components/groups/history/HistoryItem.tsx b/frontend/app/components/groups/history/HistoryItem.tsx index 2f03d46..ac417d0 100644 --- a/frontend/app/components/groups/history/HistoryItem.tsx +++ b/frontend/app/components/groups/history/HistoryItem.tsx @@ -126,6 +126,7 @@ export function HistoryItem({ record }: HistoryItemProps) { year: 'numeric', hour: '2-digit', minute: '2-digit', + timeZone: 'Europe/Madrid', }) } @@ -141,11 +142,18 @@ export function HistoryItem({ record }: HistoryItemProps) { .join(' ') } - const formatValue = (value: string, field?: string) => { + const formatValue = (rawValue: string, field?: string) => { // Check for null/undefined - if (value === null || value === undefined || value === 'null' || value === 'undefined') { + if ( + rawValue === null || + rawValue === undefined || + rawValue === 'null' || + rawValue === 'undefined' + ) { return '—' } + // Rows written before 2026-10-14 keep dates in quotes ("2026-10-24T22:00:00.000Z") + const value = rawValue.replace(/^"(.*)"$/, '$1') // Check if it's a date field if (field && field.includes('date') && value && value.match(/^\d{4}-\d{2}-\d{2}/)) { @@ -153,6 +161,7 @@ export function HistoryItem({ record }: HistoryItemProps) { day: '2-digit', month: 'short', year: 'numeric', + timeZone: 'Europe/Madrid', }) } @@ -185,6 +194,10 @@ export function HistoryItem({ record }: HistoryItemProps) { try { const oldObj = oldValue ? JSON.parse(oldValue) : {} const newObj = newValue ? JSON.parse(newValue) : {} + // Only whole records are compared field by field; a single value (a number, + // a quoted date) is shown as it is below + const isRecord = (v: unknown) => typeof v === 'object' && v !== null && !Array.isArray(v) + if (!isRecord(oldObj) || !isRecord(newObj)) return null // Find changed fields const changes: Array<{ field: string; oldVal: unknown; newVal: unknown }> = [] From c152d49744654a352868cb6c4be251516d8a8ff0 Mon Sep 17 00:00:00 2001 From: razyd Date: Wed, 14 Oct 2026 21:16:36 +0200 Subject: [PATCH 08/10] fix(frontend): show history values in words and times in Madrid - Maintenance history: statuses and priorities through their translations, and the assignee by name (the API now joins the user of an assignment) instead of in_progress or a user id. - Cashier history: shift and voucher statuses translated, and dates and times fixed to Madrid. - Blacklist audit trail: dates fixed to Madrid. --- backend/models/maintenance/index.ts | 3 +++ .../maintenance/maintenance-repository.ts | 10 ++++++++- .../components/blacklist/mains/AuditTrail.tsx | 1 + .../components/cashier/logs/HistoryTable.tsx | 21 +++++++++++++++--- .../maintenance/tabs/HistoryTab.tsx | 22 +++++++++++++++++-- frontend/app/lib/maintenance/maintenance.ts | 3 +++ 6 files changed, 54 insertions(+), 6 deletions(-) diff --git a/backend/models/maintenance/index.ts b/backend/models/maintenance/index.ts index f42683c..e3b6996 100644 --- a/backend/models/maintenance/index.ts +++ b/backend/models/maintenance/index.ts @@ -106,6 +106,9 @@ export interface MaintenanceHistory { changed_at: string // Campos adicionales (JOINs) user_name?: string + // Usernames when the change is an assignment (old_value/new_value are user ids) + old_value_name?: string + new_value_name?: string } // ======================================== diff --git a/backend/repositories/maintenance/maintenance-repository.ts b/backend/repositories/maintenance/maintenance-repository.ts index e2b9092..d2a7c08 100644 --- a/backend/repositories/maintenance/maintenance-repository.ts +++ b/backend/repositories/maintenance/maintenance-repository.ts @@ -74,6 +74,8 @@ interface ImageRow extends RowDataPacket { } interface HistoryRow extends RowDataPacket { + old_value_name?: string | null + new_value_name?: string | null id: number report_id: string action: string @@ -226,6 +228,8 @@ function parseHistoryRow(row: HistoryRow): MaintenanceHistory { changed_at: typeof row.changed_at === 'string' ? row.changed_at : new Date(row.changed_at).toISOString(), user_name: row.user_name || undefined, + old_value_name: row.old_value_name || undefined, + new_value_name: row.new_value_name || undefined, } } @@ -935,9 +939,13 @@ export class MaintenanceRepository { */ static async getHistoryByReportId(reportId: string): Promise { const query = ` - SELECT h.*, u.username as user_name + SELECT h.*, u.username as user_name, + ou.username AS old_value_name, nu.username AS new_value_name FROM maintenance_history h LEFT JOIN users u ON h.changed_by = u.id + -- An assignment logs user ids: their names, for the screen + LEFT JOIN users ou ON h.field_changed = 'assigned_to' AND ou.id = h.old_value + LEFT JOIN users nu ON h.field_changed = 'assigned_to' AND nu.id = h.new_value WHERE h.report_id = ? ORDER BY h.changed_at DESC ` diff --git a/frontend/app/components/blacklist/mains/AuditTrail.tsx b/frontend/app/components/blacklist/mains/AuditTrail.tsx index 8b34e7f..d261d35 100644 --- a/frontend/app/components/blacklist/mains/AuditTrail.tsx +++ b/frontend/app/components/blacklist/mains/AuditTrail.tsx @@ -219,6 +219,7 @@ function formatValue(value: unknown, t: any): string { try { const date = new Date(value) return new Intl.DateTimeFormat('es-ES', { + timeZone: 'Europe/Madrid', day: '2-digit', month: '2-digit', year: 'numeric', diff --git a/frontend/app/components/cashier/logs/HistoryTable.tsx b/frontend/app/components/cashier/logs/HistoryTable.tsx index 27a2ca1..d807ca6 100644 --- a/frontend/app/components/cashier/logs/HistoryTable.tsx +++ b/frontend/app/components/cashier/logs/HistoryTable.tsx @@ -25,6 +25,16 @@ export default function HistoryTable({ }: HistoryTableProps) { const t = useTranslations('cashier') + // Shift and voucher statuses are stored as codes (open, pending…): show them in words + const STATUS_KEYS: Record = { + open: 'summary.open', + closed: 'summary.closed', + pending: 'voucher.pending', + justified: 'voucher.justified', + cancelled: 'voucher.cancelled', + } + const formatStatusValue = (value: string) => (STATUS_KEYS[value] ? t(STATUS_KEYS[value]) : value) + const getActionBadge = (action: string) => { const badges: Record = { created: { @@ -167,12 +177,14 @@ export default function HistoryTable({ day: '2-digit', month: '2-digit', year: 'numeric', + timeZone: 'Europe/Madrid', })}
{new Date(log.changed_at).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit', + timeZone: 'Europe/Madrid', })}
@@ -189,6 +201,7 @@ export default function HistoryTable({ {new Date(log.shift_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', + timeZone: 'Europe/Madrid', })}
@@ -209,7 +222,9 @@ export default function HistoryTable({ {/* Campo */} - {log.field_changed || -} + {log.field_changed === 'status' + ? t('summary.status') + : log.field_changed || -} {/* Cambio */} @@ -218,12 +233,12 @@ export default function HistoryTable({
{log.old_value && (
- - {log.old_value} + - {formatStatusValue(log.old_value)}
)} {log.new_value && (
- + {log.new_value} + + {formatStatusValue(log.new_value)}
)}
diff --git a/frontend/app/components/maintenance/tabs/HistoryTab.tsx b/frontend/app/components/maintenance/tabs/HistoryTab.tsx index e8980c3..741596d 100644 --- a/frontend/app/components/maintenance/tabs/HistoryTab.tsx +++ b/frontend/app/components/maintenance/tabs/HistoryTab.tsx @@ -96,9 +96,19 @@ export function HistoryTab() { year: 'numeric', hour: '2-digit', minute: '2-digit', + timeZone: 'Europe/Madrid', }) } + // Statuses and priorities in words, and the assignee by name, not as stored + const formatValue = (field: string | null, value: string, name?: string) => { + if (name) return name + const camel = value.replace(/_(w)/g, (_, c: string) => c.toUpperCase()) + if (field === 'status' && t.has(`status.${camel}`)) return t(`status.${camel}`) + if (field === 'priority' && t.has(`priority.${camel}`)) return t(`priority.${camel}`) + return value + } + return (
@@ -143,13 +153,21 @@ export function HistoryTab() { {entry.old_value && (

{t('history.previous')}{' '} - {entry.old_value} + {formatValue( + entry.field_changed, + entry.old_value, + entry.old_value_name + )}

)} {entry.new_value && (

{t('history.new')}{' '} - {entry.new_value} + {formatValue( + entry.field_changed, + entry.new_value, + entry.new_value_name + )}

)}
diff --git a/frontend/app/lib/maintenance/maintenance.ts b/frontend/app/lib/maintenance/maintenance.ts index e53e8e5..c79be61 100644 --- a/frontend/app/lib/maintenance/maintenance.ts +++ b/frontend/app/lib/maintenance/maintenance.ts @@ -68,6 +68,9 @@ export interface MaintenanceHistory { changed_by: string changed_at: string user_name?: string + // Usernames when the change is an assignment (old_value/new_value are user ids) + old_value_name?: string + new_value_name?: string } export interface ReportWithDetails extends MaintenanceReport { From c5944770f0be9b66c5e045db107f6aa5f8d20c7b Mon Sep 17 00:00:00 2001 From: razyd Date: Wed, 14 Oct 2026 21:45:37 +0200 Subject: [PATCH 09/10] fix(maintenance): match the underscore in history statuses - The regex lost its backslash and never matched, so in_progress stayed untranslated. --- frontend/app/components/maintenance/tabs/HistoryTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/components/maintenance/tabs/HistoryTab.tsx b/frontend/app/components/maintenance/tabs/HistoryTab.tsx index 741596d..690da0b 100644 --- a/frontend/app/components/maintenance/tabs/HistoryTab.tsx +++ b/frontend/app/components/maintenance/tabs/HistoryTab.tsx @@ -103,7 +103,7 @@ export function HistoryTab() { // Statuses and priorities in words, and the assignee by name, not as stored const formatValue = (field: string | null, value: string, name?: string) => { if (name) return name - const camel = value.replace(/_(w)/g, (_, c: string) => c.toUpperCase()) + const camel = value.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) if (field === 'status' && t.has(`status.${camel}`)) return t(`status.${camel}`) if (field === 'priority' && t.has(`priority.${camel}`)) return t(`priority.${camel}`) return value From a0de96fbe718d7a41befc205e45eed53abe1d243 Mon Sep 17 00:00:00 2001 From: razyd Date: Wed, 14 Oct 2026 21:47:00 +0200 Subject: [PATCH 10/10] docs(todo): close the time zone item and record ADR-047 in the session --- docs/SESSION.md | 10 ++++++---- docs/TODO.md | 27 --------------------------- 2 files changed, 6 insertions(+), 31 deletions(-) diff --git a/docs/SESSION.md b/docs/SESSION.md index 771d774..0553694 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -28,7 +28,7 @@ y auditado, publicación y demo. Sus documentos (`ROADMAP.md`, `GITCLEAN.md`, - **Local:** `pnpm setup:local` instala desde cero con admin completo, datos ficticios y la base de horarios de la demo (`scheduling-seed.sql`). - **Repositorio original:** `four-points-archive`, archivado y privado. -- **Decisiones:** `DECISIONS.md`, hasta ADR-046. +- **Decisiones:** `DECISIONS.md`, hasta ADR-047. - **Harness:** capa 1 en `backend/AGENTS.md` y `frontend/AGENTS.md`; el `AGENTS.md` raíz guarda lo común y los tres niveles se enlazan entre sí (ADR-042, ADR-043). La sesión se abre en la raíz y activa el Node de cada lado @@ -44,6 +44,10 @@ y auditado, publicación y demo. Sus documentos (`ROADMAP.md`, `GITCLEAN.md`, día 10 funciona (ADR-046, comprobado en preview y producción); los errores de `auth` y de los middlewares llevan `code`; el historial de grupos solo anota lo que cambia. +- **Fechas y horas (ADR-047, en la rama, pendiente de `main`):** el backend + corre en UTC en cualquier máquina; «hoy» y el día de un instante se calculan + en Madrid; las horas de parking son el reloj del hotel. Los historiales + muestran fechas legibles y estados traducidos. - **Rama `opencode-tasks`:** trabajo delegado a OpenCode con una directriz cerrada (ficheros permitidos, tabla de cambios, verificación); se revisa, se commitea y entra en la rama de preview con fast-forward. @@ -73,9 +77,7 @@ y auditado, publicación y demo. Sus documentos (`ROADMAP.md`, `GITCLEAN.md`, de un usuario de la aplicación (`test-login.js`, commit `8595ec0`): cambiarla también. Se hace en los paneles y se comprueba que producción y preview siguen funcionando. -2. **Fechas y «hoy» según la zona horaria de cada pieza**, como tarea propia - (decidido el 2026-10-12). -3. Las de prioridad media. +2. Las de prioridad media. Los errores conocidos que se aceptan sin arreglarlos ahora están en `docs/_archive/TODO.md` (ADR-045). diff --git a/docs/TODO.md b/docs/TODO.md index 06c992a..21a8ecb 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -64,33 +64,6 @@ ahora están en `docs/_archive/TODO.md`, apartado «Errores conocidos pendientes aún sin estrenar. _Comprobado el 2026-09-28 en local y Aiven: `event_scheduler=ON` y el evento está `ENABLED` (última ejecución en Aiven: 2026-09-27)._ -- [ ] **Fechas y «hoy» dependen de la zona horaria de cada pieza** — navegador - en Madrid, Node en la del proceso (Madrid en local; Render sin comprobar, - UTC si no tiene `TZ`) y MySQL en la suya (Madrid en local; Aiven en UTC, - `time_zone=SYSTEM`, comprobado el 2026-09-28). No son relojes - desincronizados: - El pool (`config/db.ts`) no fija `timezone` ni - `dateStrings`: una columna `DATE` llega como instante a medianoche de - Node. Con Node en Madrid, el 30/09 viaja como `2026-09-29T22:00:00Z`, y - quien lo corta por la `T` se queda con el 29. Así, en local, cada edición - de una factura le resta un día a sus fechas - (`InvoiceFormModal.tsx:83-87`). Con Node en UTC sale bien. - Si Node y - MySQL no están en la misma zona, los `DATETIME` se desplazan: con Node en - UTC contra la MySQL local, `NOW()` sale 2 h movido (p. ej. - `pnpm dev:aiven` desde un equipo en Madrid). - «Hoy» en UTC está mal entre - las 00:00 y las 02:00 de Madrid: `toISOString()` en 26 sitios del frontend - y 6 del backend, `CURDATE()`/`NOW()` en Aiven (19 consultas con - `CURDATE()`: filtros de parking, código `PK-AAAAMMDD` del trigger, - `CURRENT_TIMESTAMP`) y el identificador `DDMMAA-NNN` de Maintenance (fecha - de Node). - 15 sitios del frontend cortan por la `T` fechas del servidor - (grupos, lista negra, parking, horarios, checklist, caja, backoffice). - - Plan: `DATE` como texto `AAAA-MM-DD` (`dateStrings`), «hoy» siempre en - Madrid (un helper en el frontend, `getTodayMadrid` en el backend) y zona - fija entre Node y MySQL. Antes: mirar si Render tiene `TZ` y si alguna - fecha de Aiven se ha movido ya por ediciones. Tarea propia, decidida el - 2026-10-12. _Medido el 2026-10-12 contra la BD local con Node en Madrid y - en UTC._ - - [ ] **Logbook: papelera e historial solo ocultos en la pantalla** — el backend da `/trashed`, `include_trashed`, el historial y los comentarios de entradas borradas a cualquier rol con acceso. _Según la revisión