Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ 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.5] - 2026-10-18

> La 1.28.3 no existió: este cambio llevaba ese número en su PR, pero entró en `main` después de la 1.28.4.

### 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.4] - 2026-10-18

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "forge-js",
"type": "module",
"version": "1.28.4",
"version": "1.28.5",
"engines": {
"node": ">=22.12.0"
},
Expand Down
14 changes: 10 additions & 4 deletions src/components/jira/IssueDetailsModal.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? '') : '';
}
}

Expand Down
10 changes: 9 additions & 1 deletion src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions src/lib/fechas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
19 changes: 14 additions & 5 deletions src/lib/sprintSnapshots.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import db from './db';
import crypto from 'crypto';
import { hoyLocal } from './fechas';

/**
* Fotos diarias de un sprint, para el burndown.
Expand All @@ -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();
}

/**
Expand Down Expand Up @@ -60,7 +69,7 @@ export function medirSprint(sprintId: string): Omit<Foto, 'sprintId' | 'takenOn'
* día actualice en vez de duplicar: puede ejecutarse tantas veces como haga
* falta sin ensuciar la serie.
*/
export function tomarFoto(sprintId: string, fecha = hoyUTC()): Foto {
export function tomarFoto(sprintId: string, fecha = hoy()): Foto {
const m = medirSprint(sprintId);
db.prepare(`
INSERT INTO sprint_snapshots (id, sprint_id, taken_on, points_total, points_done, issues_total, issues_done)
Expand Down Expand Up @@ -92,7 +101,7 @@ export function serie(sprintId: string): Foto[] {
* todas las fotos del día o ninguna, porque una serie a la que le falta un
* sprint suelto es peor que una serie que no se tomó.
*/
export function fotografiarSprintsActivos(fecha = hoyUTC()): number {
export function fotografiarSprintsActivos(fecha = hoy()): number {
const activos = db.prepare("SELECT id FROM sprints WHERE status = 'active'").all() as Array<{ id: string }>;
const tx = db.transaction(() => {
for (const s of activos) tomarFoto(s.id, fecha);
Expand Down
24 changes: 14 additions & 10 deletions src/pages/w/[sys_tag]/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -298,11 +298,15 @@ const priorityColor: Record<string, string> = {
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 {
Expand Down
62 changes: 62 additions & 0 deletions tests/e2e/fecha-y-cancelar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((r) => { soltar = r; });
let pedida!: () => void;
const segundaPedida = new Promise<void>((r) => { pedida = r; });
let entregada!: () => void;
const segundaEntregada = new Promise<void>((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.
*
Expand Down
131 changes: 131 additions & 0 deletions tests/fechas-sprint.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading