From b22dedb23c4fbc744ea3818b662742e06ab3ca2b Mon Sep 17 00:00:00 2001 From: JoseSerpaCode <124005402+JoseSerpaCode@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:30:29 -0500 Subject: [PATCH] =?UTF-8?q?fix:=20d=C3=ADas=20de=20sprint,=20fotos=20del?= =?UTF-8?q?=20burndown=20y=20la=20fecha=20que=20se=20deshac=C3=ADa=20al=20?= =?UTF-8?q?abrir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El resumen del espacio contaba los días que le quedan al sprint restando horas a un `new Date(end_date)`, que para una fecha solo-día es la medianoche UTC: en Bogotá decía «termina hoy» desde las siete de la tarde del día anterior. Ahora cuenta días del calendario con `diasHasta()`. La foto diaria del burndown se guardaba con el día en UTC, así que por la tarde en Bogotá salía con la fecha de mañana. Ahora usa el día local del servidor, como `vencida()`. Y el modal cargaba el ticket dos veces al abrirse; si la segunda respuesta llegaba después de elegir la fecha, el campo volvía a la anterior. Era lo que hacía fallar de vez en cuando `fecha-y-cancelar.spec.ts`. Ahora el campo no se toca mientras se edita, como las horas estimadas. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++ package-lock.json | 4 +- package.json | 2 +- src/components/jira/IssueDetailsModal.astro | 14 ++- src/lib/db.ts | 10 +- src/lib/fechas.ts | 22 ++++ src/lib/sprintSnapshots.ts | 19 ++- src/pages/w/[sys_tag]/index.astro | 24 ++-- tests/e2e/fecha-y-cancelar.spec.ts | 62 +++++++++ tests/fechas-sprint.test.ts | 131 ++++++++++++++++++++ 10 files changed, 274 insertions(+), 23 deletions(-) create mode 100644 tests/fechas-sprint.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d066ae..7922cf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1 > Las entradas entre la 0.6.0 y la 1.4.0 se reconstruyeron a posteriori a partir del historial de git, agrupadas por los saltos de versión que realmente ocurrieron en `package.json`. La 1.1.0 nunca existió: se pasó directamente de la 1.0.0 a la 1.2.0. +## [1.28.3] - 2026-10-18 + +### Fixed + +- **«Termina hoy» llegaba un día antes.** El resumen del espacio contaba los días que le quedan al sprint activo restando horas a un `new Date(end_date)`, que para una fecha solo-día es la medianoche UTC. En Bogotá decía «termina hoy» desde las siete de la tarde del día anterior, y en Tokio, el último día por la mañana, todavía decía «termina en 1 día». Ahora cuenta días del calendario con `diasHasta()`, que tampoco se equivoca en los días de 23 o 25 horas del cambio de hora. Hoy la interfaz crea los sprints sin fechas, así que solo se veía en los que las reciben por la API o por el seed. +- **La foto diaria del burndown se guardaba con la fecha de mañana por la tarde.** El día de la foto salía en UTC, así que desde las siete de la tarde en Bogotá la de hoy se guardaba como la de mañana, y la del día real se quedaba como estaba a las 18:59. Ahora usa el día en la zona del servidor, igual que `vencida()`. +- **Elegir una fecha de entrega justo al abrir un ticket a veces no se quedaba en el campo.** Al abrir el modal, los datos del ticket se cargan dos veces, y si la segunda respuesta llegaba después de elegir la fecha, el campo volvía a la anterior aunque la nueva ya se hubiera guardado. Ahora el campo no se toca mientras se está editando, como ya pasaba con las horas estimadas. Era también la causa de que `fecha-y-cancelar.spec.ts` fallara de vez en cuando con la suite entera. +- La línea de tiempo de sprints del resumen lee las fechas con `deSQLite()`, como el resto, en vez de `new Date()`, que leía una fecha solo-día como UTC y una marca de SQLite como hora local. + ## [1.28.2] - 2026-10-18 ### Fixed diff --git a/package-lock.json b/package-lock.json index 0eba124..d829e5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forge-js", - "version": "1.28.2", + "version": "1.28.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forge-js", - "version": "1.28.2", + "version": "1.28.3", "dependencies": { "@astrojs/check": "^0.9.10", "@astrojs/node": "^11.1.8", diff --git a/package.json b/package.json index 430ffa7..1f14f48 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "forge-js", "type": "module", - "version": "1.28.2", + "version": "1.28.3", "engines": { "node": ">=22.12.0" }, diff --git a/src/components/jira/IssueDetailsModal.astro b/src/components/jira/IssueDetailsModal.astro index bccaaf1..bfb459f 100644 --- a/src/components/jira/IssueDetailsModal.astro +++ b/src/components/jira/IssueDetailsModal.astro @@ -1121,11 +1121,17 @@ const t = useTranslations(lang); } // Due date update + // + // Sin tocarla si se está editando, igual que las horas estimadas de + // arriba. Al abrir el modal esta función corre **dos veces** —una + // directa y otra cuando terminan de cargar los registros de trabajo—, + // y si la segunda respuesta llegaba después de elegir una fecha, el + // campo volvía a la anterior. El PATCH ya se había mandado con la + // nueva, así que la tarjeta y el campo acababan diciendo cosas + // distintas. const dueDateInput = document.getElementById('modal-issue-due-date') as HTMLInputElement; - if (dueDateInput && issue.due_date) { - dueDateInput.value = diaDeCalendario(issue.due_date) ?? ''; - } else if (dueDateInput) { - dueDateInput.value = ''; + if (dueDateInput && document.activeElement !== dueDateInput) { + dueDateInput.value = issue.due_date ? (diaDeCalendario(issue.due_date) ?? '') : ''; } } diff --git a/src/lib/db.ts b/src/lib/db.ts index 8148463..8356126 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1233,7 +1233,15 @@ function fotografiarHoy() { const activos = db.prepare("SELECT id FROM sprints WHERE status = 'active'").all() as Array<{ id: string }>; if (activos.length === 0) return; - const hoy = new Date().toISOString().slice(0, 10); + // El día en la zona del servidor, como `hoyLocal()` de `lib/fechas`. En + // UTC, desde las siete de la tarde en Bogotá la foto salía con la fecha de + // mañana. + // + // Se calcula aquí en vez de importarlo: este módulo lo carga Node sin + // compilar (`tests/e2e/reset-db.ts`), y ahí `import './fechas'` sin + // extensión no se resuelve y el servidor de pruebas no llega a arrancar. + const ahora = new Date(); + const hoy = `${ahora.getFullYear()}-${String(ahora.getMonth() + 1).padStart(2, '0')}-${String(ahora.getDate()).padStart(2, '0')}`; const medir = db.prepare(` SELECT COUNT(*) AS issuesTotal, diff --git a/src/lib/fechas.ts b/src/lib/fechas.ts index 792c646..7e9a670 100644 --- a/src/lib/fechas.ts +++ b/src/lib/fechas.ts @@ -162,3 +162,25 @@ export function vencida(valor: unknown, ahora: Date = new Date()): boolean { // Una entrega «de hoy» no está vencida a las nueve de la mañana. return dia < hoyLocal(ahora); } + +/** + * Cuántos días del calendario faltan hasta una fecha: 0 si es hoy, negativo si + * ya pasó, `null` si no se puede leer. + * + * Cuenta días, no horas. El resumen del espacio hacía + * `Math.ceil((fin - ahora) / 86400000)` sobre un `new Date('2026-09-09')`, que + * es la medianoche **UTC**: en Bogotá, desde las siete de la tarde del día + * anterior ya decía «termina hoy», y en Tokio el último día por la mañana + * todavía decía «termina en 1 día». + */ +export function diasHasta(valor: unknown, ahora: Date = new Date()): number | null { + const dia = diaDeCalendario(valor); + if (!dia) return null; + // Las dos fechas pasan a medianoche UTC solo para restarlas: así un cambio de + // horario de verano no deja un día de 23 horas que redondee mal. + const comoUTC = (iso: string) => { + const [a, m, d] = iso.split('-').map(Number); + return Date.UTC(a, m - 1, d); + }; + return Math.round((comoUTC(dia) - comoUTC(hoyLocal(ahora))) / 86_400_000); +} diff --git a/src/lib/sprintSnapshots.ts b/src/lib/sprintSnapshots.ts index 3d27371..01f33f0 100644 --- a/src/lib/sprintSnapshots.ts +++ b/src/lib/sprintSnapshots.ts @@ -1,5 +1,6 @@ import db from './db'; import crypto from 'crypto'; +import { hoyLocal } from './fechas'; /** * Fotos diarias de un sprint, para el burndown. @@ -23,9 +24,17 @@ export type Foto = { issuesDone: number; }; -/** Fecha en formato YYYY-MM-DD, en UTC, que es como guarda SQLite. */ -function hoyUTC(): string { - return new Date().toISOString().slice(0, 10); +/** + * El día de la foto, en formato YYYY-MM-DD, en la zona del servidor. + * + * Era el día en UTC. En Bogotá eso significa que, desde las siete de la tarde, + * la foto de hoy se guardaba con la fecha de mañana: el trabajo cerrado esa + * noche aparecía en el día siguiente, y la foto real del día se quedaba + * congelada a las 18:59. `taken_on` es un día del calendario, no un instante; + * se compara siempre como cadena y no pasa por SQLite. + */ +function hoy(): string { + return hoyLocal(); } /** @@ -60,7 +69,7 @@ export function medirSprint(sprintId: string): Omit; const tx = db.transaction(() => { for (const s of activos) tomarFoto(s.id, fecha); diff --git a/src/pages/w/[sys_tag]/index.astro b/src/pages/w/[sys_tag]/index.astro index 202e8df..ac5b5a2 100644 --- a/src/pages/w/[sys_tag]/index.astro +++ b/src/pages/w/[sys_tag]/index.astro @@ -10,7 +10,7 @@ const user = Astro.locals.user!; if (!user) return Astro.redirect('/login'); import { useTranslations } from '../../../i18n/ui'; -import { fecha, relativo } from '../../../lib/fechas'; +import { fecha, relativo, deSQLite, diasHasta } from '../../../lib/fechas'; import { mapaPorClave } from '../../../lib/issueTypes'; import TaskTable from '../../../components/dashboard/TaskTable.astro'; import { abrirEspacioDePagina } from '../../../lib/paginaEspacio'; @@ -61,11 +61,11 @@ if (activeSprint) { ? Math.round((doneInSprint.count / totalInSprint.count) * 100) : 0; - if (activeSprint.end_date) { - const endDate = new Date(activeSprint.end_date); - const now = new Date(); - sprintDaysLeft = Math.max(0, Math.ceil((endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))); - } + // Días del calendario, no horas: con `new Date(end_date)` una fecha + // solo-día era la medianoche UTC y el «termina hoy» llegaba un día antes en + // Bogotá. Ver `diasHasta()`. + const faltan = diasHasta(activeSprint.end_date); + if (faltan !== null) sprintDaysLeft = Math.max(0, faltan); } else { pendingIssues = db.prepare(` SELECT COUNT(*) as count FROM issues @@ -298,11 +298,15 @@ const priorityColor: Record = { let barWidth = 50; // default if (firstStart && lastEnd && sprint.start_date && sprint.end_date) { - const rangeStart = new Date(firstStart).getTime(); - const rangeEnd = new Date(lastEnd).getTime(); + // Con `deSQLite`, como el resto de fechas: `new Date()` leía una + // fecha solo-día como UTC y una marca de SQLite como hora local, + // y en una misma barra podían convivir las dos. + const ms = (v: unknown) => deSQLite(v)?.getTime() ?? NaN; + const rangeStart = ms(firstStart); + const rangeEnd = ms(lastEnd); const range = rangeEnd - rangeStart || 1; - const sprintStart = new Date(sprint.start_date).getTime(); - const sprintEnd = new Date(sprint.end_date).getTime(); + const sprintStart = ms(sprint.start_date); + const sprintEnd = ms(sprint.end_date); barLeft = Math.max(0, Math.min(100, ((sprintStart - rangeStart) / range) * 100)); barWidth = Math.max(8, Math.min(100 - barLeft, ((sprintEnd - sprintStart) / range) * 100)); } else { diff --git a/tests/e2e/fecha-y-cancelar.spec.ts b/tests/e2e/fecha-y-cancelar.spec.ts index df7d9d8..6e7828a 100644 --- a/tests/e2e/fecha-y-cancelar.spec.ts +++ b/tests/e2e/fecha-y-cancelar.spec.ts @@ -91,6 +91,68 @@ test('la fecha de entrega se ve igual en la tarjeta y en el modal', async ({ pag expect(forma(despues), `antes «${antes}» y después «${despues}»`).toBe(forma(antes)); }); +/** + * La fecha elegida no la pisa una carga que llega tarde. + * + * Al abrir el modal, los datos del ticket se piden dos veces: una directa y + * otra cuando terminan de cargar los registros de trabajo. La segunda + * reescribía el campo de fecha con lo que había en el servidor *antes* de + * elegir la nueva, así que el campo volvía a la fecha vieja. Con la suite en + * paralelo pasaba de vez en cuando y hacía fallar la prueba de arriba; aquí se + * fuerza retrasando los registros de trabajo. + */ +test('elegir la fecha justo al abrir el ticket no se deshace', async ({ page }) => { + await entrar(page); + const { db, yo, ws } = conEspacio(); + db.prepare("DELETE FROM issues WHERE title = 'Con fecha lenta'").run(); + db.prepare(`INSERT INTO issues (id, workspace_id, type, title, status, reporter_id, position, due_date) + VALUES (?, ?, 'task', 'Con fecha lenta', 'todo', ?, 100001, '2026-09-09')`) + .run(crypto.randomUUID(), ws.id, yo.id); + + /* + * Se deja pasar la primera carga y se retiene la **respuesta** de la segunda: + * la petición sale antes de elegir la fecha, así que trae la vieja, y llega + * después. Retener la petición en vez de la respuesta no reproduce nada — + * saldría tras el PATCH y ya traería la nueva—. + */ + let cargas = 0; + let soltar!: () => void; + const retenido = new Promise((r) => { soltar = r; }); + let pedida!: () => void; + const segundaPedida = new Promise((r) => { pedida = r; }); + let entregada!: () => void; + const segundaEntregada = new Promise((r) => { entregada = r; }); + await page.route(/\/api\/issues\/[^/]+$/, async (ruta) => { + if (ruta.request().method() !== 'GET' || ++cargas !== 2) return ruta.continue(); + const vieja = await ruta.fetch(); + pedida(); + await retenido; + await ruta.fulfill({ response: vieja }); + entregada(); + }); + + await page.goto(`/w/${ESPACIO}/board?sprint=backlog`); + const tarjeta = page.locator('.issue-card', { hasText: 'Con fecha lenta' }).first(); + await tarjeta.locator('h4').click(); + const campo = page.locator('#modal-issue-due-date'); + await expect(campo).toHaveValue('2026-09-09'); + await segundaPedida; + + const guardado = page.waitForResponse((r) => r.request().method() === 'PATCH' && r.url().includes('/api/issues/')); + await campo.fill('2026-09-10'); + await campo.dispatchEvent('change'); + await guardado; + + // Ahora llega la segunda carga, con lo que había antes de elegir. + soltar(); + await segundaEntregada; + // Un respiro para que el manejador de la respuesta escriba en el campo. + await page.evaluate(() => new Promise((r) => setTimeout(r, 100))); + + await expect(campo).toHaveValue('2026-09-10'); + await expect(tarjeta.locator('.card-due')).toHaveText(/(Sep 10, 2026|\b10 sept? 2026)/); +}); + /** * «Cancelar» deshace un paso; la equis cierra. * diff --git a/tests/fechas-sprint.test.ts b/tests/fechas-sprint.test.ts new file mode 100644 index 0000000..8447c9f --- /dev/null +++ b/tests/fechas-sprint.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { diasHasta } from '../src/lib/fechas'; + +/** + * Las fechas de sprint y el día de las fotos del burndown. + * + * Es el mismo fallo que el de las fechas de entrega, en otros sitios: + * + * - El resumen del espacio contaba los días que le quedan al sprint con + * `Math.ceil((new Date(end_date) - ahora) / 86400000)`. Con una fecha + * solo-día eso es la medianoche **UTC**, así que en Bogotá decía «termina + * hoy» desde las siete de la tarde del día anterior, y en Tokio, el último + * día por la mañana, todavía decía «termina en 1 día». + * - La foto diaria del burndown se guardaba con el día en UTC: desde las siete + * de la tarde en Bogotá, con la fecha de mañana. + * + * Como en `fechas-solo-dia.test.ts`, cada caso corre a los dos lados de + * Greenwich. + */ +const ZONAS = ['America/Bogota', 'Asia/Tokyo'] as const; +const DESFASE_MIN = { 'America/Bogota': 300, 'Asia/Tokyo': -540 } as const; + +const DB = path.join(process.cwd(), `forge_sprint_tz_${process.pid}.db`); +let db: any; +let snap: typeof import('../src/lib/sprintSnapshots'); + +beforeAll(async () => { + fs.rmSync(DB, { force: true }); + process.env.DATABASE_URL = DB; + process.env.NODE_ENV = 'test'; + snap = await import('../src/lib/sprintSnapshots'); + db = new Database(DB); + db.prepare("INSERT INTO users (id, username, password_hash) VALUES ('u1','tz','x')").run(); + db.prepare("INSERT INTO workspaces (id, name, sys_tag, created_by) VALUES ('w1','TZ','ws-tz','u1')").run(); + db.prepare("INSERT INTO sprints (id, workspace_id, name, status) VALUES ('s1','w1','S','active')").run(); +}); + +afterAll(() => { + db?.close(); + for (const s of ['', '-wal', '-shm']) fs.rmSync(DB + s, { force: true }); +}); + +describe.each(ZONAS)('fechas de sprint en %s', (zona) => { + let previa: string | undefined; + + beforeAll(() => { + previa = process.env.TZ; + process.env.TZ = zona; + expect(new Date(2026, 8, 9).getTimezoneOffset(), `TZ=${zona} no se aplicó`).toBe(DESFASE_MIN[zona]); + }); + + afterAll(() => { + if (previa === undefined) delete process.env.TZ; + else process.env.TZ = previa; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('el día anterior al fin quedan 1 día, a cualquier hora', () => { + for (const hora of [0, 8, 12, 20, 23]) { + expect(diasHasta('2026-09-09', new Date(2026, 8, 8, hora, 30)), `a las ${hora}:30`).toBe(1); + } + }); + + it('el último día es «hoy» desde la medianoche hasta el final', () => { + for (const hora of [0, 8, 12, 20, 23]) { + expect(diasHasta('2026-09-09', new Date(2026, 8, 9, hora, 30)), `a las ${hora}:30`).toBe(0); + } + }); + + it('un sprint terminado da días negativos, no cero', () => { + // El resumen recorta a 0 con `Math.max`; la función no esconde el pasado. + expect(diasHasta('2026-09-09', new Date(2026, 8, 12, 10))).toBe(-3); + }); + + it('cruza meses y años', () => { + expect(diasHasta('2026-10-01', new Date(2026, 8, 30, 22))).toBe(1); + expect(diasHasta('2027-01-01', new Date(2026, 11, 31, 23, 59))).toBe(1); + }); + + it('lo ilegible da null, no NaN', () => { + expect(diasHasta(null)).toBeNull(); + expect(diasHasta('')).toBeNull(); + expect(diasHasta('2026-02-30')).toBeNull(); + }); + + it('una marca de tiempo cuenta desde su día local', () => { + // 03:00 UTC del 9: en Bogotá es todavía el 8 por la noche. + const ahora = new Date(2026, 8, 8, 10); + expect(diasHasta('2026-09-09T03:00:00Z', ahora)).toBe(zona === 'America/Bogota' ? 0 : 1); + }); + + it('la foto del burndown lleva el día local, no el de UTC', () => { + // 01:00 UTC del 9 de septiembre: en Bogotá son las 20:00 del 8; en Tokio, + // las 10:00 del 9. Con el día en UTC, la de Bogotá salía con fecha del 9. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-09-09T01:00:00Z')); + + const foto = snap.tomarFoto('s1'); + expect(foto.takenOn).toBe(zona === 'America/Bogota' ? '2026-09-08' : '2026-09-09'); + }); +}); + +/** + * Bogotá y Tokio no cambian de hora, así que un día de 23 o 25 horas no sale en + * las pruebas de arriba. En Madrid, la noche del 24 al 25 de octubre de 2026 el + * reloj se atrasa: restar instantes y dividir entre 24 horas da 2,04 días. + */ +describe('con cambio de hora (Europe/Madrid)', () => { + let previa: string | undefined; + beforeAll(() => { + previa = process.env.TZ; + process.env.TZ = 'Europe/Madrid'; + expect(new Date(2026, 9, 24).getTimezoneOffset()).toBe(-120); + expect(new Date(2026, 9, 26).getTimezoneOffset()).toBe(-60); + }); + afterAll(() => { + if (previa === undefined) delete process.env.TZ; + else process.env.TZ = previa; + }); + + it('cuenta los días del calendario, no bloques de 24 horas', () => { + expect(diasHasta('2026-10-26', new Date(2026, 9, 24, 0, 0))).toBe(2); + expect(diasHasta('2026-10-26', new Date(2026, 9, 24, 23, 59))).toBe(2); + }); +});