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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.2] - 2026-10-18

### Fixed

- **Las fechas de entrega salían un día antes al oeste de UTC.** Una entrega puesta para el 9 de septiembre se veía «8 sept 2026» en Colombia. `due_date` se guarda como `'2026-09-09'` —un día del calendario, sin hora ni zona— y `deSQLite()` le añadía la `Z` que sí necesitan las marcas de `CURRENT_TIMESTAMP`, así que pasaba a medianoche UTC, que en Bogotá son las siete de la tarde del día anterior. Ahora una fecha solo-día se lee como ese día en la zona de quien mira, tanto en la tarjeta que pinta el servidor como en el modal del navegador, y también en la lista de «Mis Tareas» y en cualquier otra fecha guardada solo con el día que pase por `fecha()`. Las marcas de tiempo siguen leyéndose en UTC.
- **Una entrega de hoy se ponía en rojo al tocar el ticket.** El modal recalculaba «vencida» con su propia comparación —medianoche del día de entrega contra la hora actual—, que la daba por vencida desde las 00:00 de ese mismo día, mientras la tarjeta servida decía que no. Ahora los dos usan `vencida()`.
- Al abrir un ticket, el campo de fecha del modal se rellena con el día guardado tal cual, en vez de pasarlo por `toISOString()`, que da el día en UTC.
- Las pruebas e2e corren el servidor y el navegador en `America/Bogota`. En UTC, que es la zona de CI, este fallo no se veía.

## [1.28.1] - 2026-10-18

### Security
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.1",
"version": "1.28.2",
"engines": {
"node": ">=22.12.0"
},
Expand Down
10 changes: 8 additions & 2 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,15 @@ export default defineConfig({
// },
],

/* Run your local dev server before starting the tests */
/*
* Run your local dev server before starting the tests.
*
* `TZ=America/Bogota` porque el servidor pinta las fechas en su zona, y en
* UTC —la de los runners de CI— una fecha de entrega que sale un día antes
* al oeste de Greenwich se ve bien. Es la zona de quien usa esto a diario.
*/
webServer: {
command: 'NODE_ENV=test PORT=4322 npm run build && NODE_ENV=test PORT=4322 node server.mjs',
command: 'NODE_ENV=test PORT=4322 npm run build && TZ=America/Bogota NODE_ENV=test PORT=4322 node server.mjs',
url: 'http://localhost:4322/login', // Use /login so it waits for the astro server to actually be ready to handle requests
reuseExistingServer: false,
timeout: 120 * 1000,
Expand Down
17 changes: 11 additions & 6 deletions src/components/jira/IssueDetailsModal.astro
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ const t = useTranslations(lang);

<script>
import { escaparHtml } from '../../lib/texto';
import { fecha } from '../../lib/fechas';
import { fecha, vencida, diaDeCalendario } from '../../lib/fechas';
const closeBtn = document.getElementById('close-modal-btn');
const modal = document.getElementById('issue-details-modal');
const backdrop = document.getElementById('issue-details-backdrop');
Expand Down Expand Up @@ -671,10 +671,15 @@ const t = useTranslations(lang);
fechaEl.textContent = `${etiqueta} ${fecha(iso, raizModal?.dataset.lang || 'en')}`;
// El rojo de vencida se recalcula aquí: si no, una fecha movida al futuro
// seguiría pintada en rojo hasta recargar.
const vencida = card.dataset.status !== 'done' && new Date(iso + 'T00:00:00') < new Date();
fechaEl.classList.toggle('text-forge-error', vencida);
fechaEl.classList.toggle('font-bold', vencida);
fechaEl.classList.toggle('text-forge-muted', !vencida);
//
// Con `vencida()`, la misma regla que la tarjeta servida. Aquí había un
// `new Date(iso + 'T00:00:00') < new Date()`, que da por vencida una
// entrega de hoy desde la medianoche: la tarjeta salía en gris y, al
// tocar cualquier campo del modal, pasaba a rojo.
const estaVencida = card.dataset.status !== 'done' && vencida(iso);
fechaEl.classList.toggle('text-forge-error', estaVencida);
fechaEl.classList.toggle('font-bold', estaVencida);
fechaEl.classList.toggle('text-forge-muted', !estaVencida);
} else if (fechaEl) {
fechaEl.remove();
}
Expand Down Expand Up @@ -1118,7 +1123,7 @@ const t = useTranslations(lang);
// Due date update
const dueDateInput = document.getElementById('modal-issue-due-date') as HTMLInputElement;
if (dueDateInput && issue.due_date) {
dueDateInput.value = new Date(issue.due_date).toISOString().split('T')[0];
dueDateInput.value = diaDeCalendario(issue.due_date) ?? '';
} else if (dueDateInput) {
dueDateInput.value = '';
}
Expand Down
46 changes: 43 additions & 3 deletions src/lib/fechas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,29 @@
*
* Además todo el formateo estaba clavado a `'en-US'`, así que una interfaz en
* español enseñaba «Aug 17, 2026».
*
* Y hay un segundo tipo de valor que no es una marca de tiempo: las fechas
* **solo-día**, como `issues.due_date`, que guarda un `<input type="date">`
* como `'2026-09-09'`. Eso es un día del
* calendario, no un instante, y no tiene zona. Tratarlo como UTC lo ponía a
* medianoche en Greenwich, que en Bogotá todavía es el día 8 a las siete de la
* tarde: cada entrega salía un día antes de lo que se había puesto.
*/

/** `'2026-09-09'`, sin hora ni zona. */
const SOLO_DIA = /^(\d{4})-(\d{2})-(\d{2})$/;

/**
* Convierte lo que venga de la base en un `Date` real.
*
* Acepta las tres formas que hay guardadas: `'2026-08-17 14:13:06'` (lo normal),
* la misma con `T`, y la que ya trae `Z` o un desfase explícito. Devuelve `null`
* ante cualquier cosa que no sepa leer, en vez de un `Invalid Date` que se
* propaga y acaba pintando «NaN» en pantalla.
*
* Una fecha solo-día (`'2026-09-09'`) da la **medianoche local** de ese día, no
* la de UTC: así se formatea y se compara como el mismo día en cualquier zona,
* en el servidor y en el navegador.
*/
export function deSQLite(valor: unknown): Date | null {
if (valor instanceof Date) return Number.isNaN(valor.getTime()) ? null : valor;
Expand All @@ -38,6 +52,16 @@ export function deSQLite(valor: unknown): Date | null {
const limpio = valor.trim();
if (!limpio) return null;

const dia = SOLO_DIA.exec(limpio);
if (dia) {
const [a, m, d] = [Number(dia[1]), Number(dia[2]), Number(dia[3])];
const local = new Date(a, m - 1, d);
// `new Date(2026, 1, 30)` no falla: salta al 2 de marzo. Un día que no
// existe se rechaza igual que cualquier otra cosa ilegible.
if (local.getFullYear() !== a || local.getMonth() !== m - 1 || local.getDate() !== d) return null;
return local;
}

// Si ya dice su zona —`Z` o `+05:00`— se respeta: tocarla la movería.
const tieneZona = /(?:Z|[+-]\d{2}:?\d{2})$/.test(limpio);
const iso = tieneZona ? limpio.replace(' ', 'T') : `${limpio.replace(' ', 'T')}Z`;
Expand Down Expand Up @@ -115,10 +139,26 @@ export function hoyLocal(ahora: Date = new Date()): string {
return `${y}-${m}-${d}`;
}

/**
* El día del calendario de un valor, como `AAAA-MM-DD`, o `null`.
*
* Una fecha solo-día sale tal cual: no hay nada que convertir, y pasarla por
* `Date` es justo cómo se movía. Una marca de tiempo da el día **local** de ese
* instante. Es lo que espera un `<input type="date">`; el modal hacía
* `new Date(x).toISOString().split('T')[0]`, que da el día en UTC.
*/
export function diaDeCalendario(valor: unknown): string | null {
if (typeof valor === 'string' && SOLO_DIA.test(valor.trim())) {
return deSQLite(valor) ? valor.trim() : null;
}
const d = deSQLite(valor);
return d ? hoyLocal(d) : null;
}

/** ¿Esta fecha de entrega ya pasó? Compara días, no instantes. */
export function vencida(valor: unknown, ahora: Date = new Date()): boolean {
const d = deSQLite(valor);
if (!d) return false;
const dia = diaDeCalendario(valor);
if (!dia) return false;
// Una entrega «de hoy» no está vencida a las nueve de la mañana.
return String(valor).slice(0, 10) < hoyLocal(ahora);
return dia < hoyLocal(ahora);
}
18 changes: 18 additions & 0 deletions tests/e2e/fecha-y-cancelar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import { getTestDb } from './test-utils';

const ESPACIO = 'ws-fecha-cancelar';

// El navegador, en la misma zona que el servidor (`playwright.config.ts`): al
// oeste de UTC es donde una fecha solo-día se corría al día anterior.
test.use({ timezoneId: 'America/Bogota' });

/** «Sep 9, 2026» o «9 sept 2026»: el día exacto, en cualquiera de los dos idiomas. */
const elDia = (d: number) => new RegExp(`(Sep ${d}, 2026|\\b${d} sept? 2026)`);

async function entrar(page: any) {
await page.goto('/login');
await page.fill('input[name="username"]', 'jose');
Expand Down Expand Up @@ -45,6 +52,15 @@ test('la fecha de entrega se ve igual en la tarjeta y en el modal', async ({ pag
// Como la pinta el servidor, con el idioma de la aplicación.
const antes = (await tarjeta.locator('.card-due').textContent())?.trim() ?? '';

/*
* El día exacto que se guardó, no uno parecido.
*
* `'2026-09-09'` se leía como medianoche UTC, que en Bogotá es el día 8 a
* las siete de la tarde: la tarjeta decía «Sep 8, 2026». Comparar solo la
* forma de las dos fechas, como hace el final de esta prueba, no lo veía.
*/
expect(antes).toMatch(elDia(9));

// Y ahora se edita desde el modal, que es cuando se reescribía con el
// formato del navegador: el modal escribe de vuelta en la propia tarjeta.
await tarjeta.locator('h4').click();
Expand All @@ -63,6 +79,8 @@ test('la fecha de entrega se ve igual en la tarjeta y en el modal', async ({ pag
.not.toBe(antes);

const despues = (await tarjeta.locator('.card-due').textContent())?.trim() ?? '';
// Y el del navegador, que repinta con la misma `fecha()`, tampoco se corre.
expect(despues).toMatch(elDia(10));

/*
* No se fija el formato concreto, sino que los dos se escriban igual: el
Expand Down
82 changes: 82 additions & 0 deletions tests/fechas-solo-dia.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { deSQLite, fecha, diaDeCalendario, vencida, hoyLocal } from '../src/lib/fechas';

/**
* Las fechas de entrega salían un día antes en Colombia.
*
* `issues.due_date` se guarda como `'2026-09-09'`: un día del calendario, sin
* hora ni zona. `deSQLite()` le añadía la `Z` que sí necesitan las marcas de
* `CURRENT_TIMESTAMP`, así que pasaba a medianoche **UTC**, y en Bogotá
* (UTC−5) eso son las siete de la tarde del día 8. La tarjeta enseñaba
* «Sep 8, 2026» para una entrega del 9.
*
* En la máquina de quien lo escribió no se veía distinto según la hora: se
* veía mal siempre, pero solo al oeste de Greenwich. Por eso cada caso se
* corre en dos zonas, una a cada lado: Bogotá (−5) y Tokio (+9). Un arreglo que
* solo funcione en una de ellas —p. ej. restar el desfase a mano— cae en la otra.
*/
const ZONAS = ['America/Bogota', 'Asia/Tokyo'] as const;
const DESFASE_MIN = { 'America/Bogota': 300, 'Asia/Tokyo': -540 } as const;

describe.each(ZONAS)('fechas solo-día en %s', (zona) => {
let previa: string | undefined;

beforeAll(() => {
previa = process.env.TZ;
process.env.TZ = zona;
// Si el cambio de zona no llegara a aplicarse, estas pruebas pasarían en
// la zona de la máquina y no demostrarían nada. Se comprueba primero.
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;
});

it('se lee como ese día en la zona local, no como medianoche UTC', () => {
const d = deSQLite('2026-09-09')!;
expect([d.getFullYear(), d.getMonth() + 1, d.getDate()]).toEqual([2026, 9, 9]);
expect([d.getHours(), d.getMinutes()]).toEqual([0, 0]);
});

it('se formatea como el día que se guardó, en los dos idiomas', () => {
expect(fecha('2026-09-09', 'en')).toBe('Sep 9, 2026');
expect(fecha('2026-09-09', 'es')).toMatch(/^9 sept? 2026$/);
});

it('los bordes del año no se cruzan', () => {
// El 1 de enero en Bogotá salía «Dec 31, 2025»: cambiaba hasta el año.
expect(fecha('2026-01-01', 'en')).toBe('Jan 1, 2026');
expect(fecha('2026-12-31', 'en')).toBe('Dec 31, 2026');
});

it('el día de calendario sale tal cual, para el <input type="date">', () => {
expect(diaDeCalendario('2026-09-09')).toBe('2026-09-09');
expect(diaDeCalendario(' 2026-09-09 ')).toBe('2026-09-09');
});

it('vence el día siguiente, no antes ni después', () => {
expect(vencida('2026-09-09', new Date(2026, 8, 9, 0, 1))).toBe(false);
expect(vencida('2026-09-09', new Date(2026, 8, 9, 23, 59))).toBe(false);
expect(vencida('2026-09-09', new Date(2026, 8, 10, 0, 1))).toBe(true);
});

it('un día que no existe es ilegible, no el 2 de marzo', () => {
expect(deSQLite('2026-02-30')).toBeNull();
expect(fecha('2026-02-30')).toBe('—');
expect(diaDeCalendario('2026-02-30')).toBeNull();
});

it('las marcas de CURRENT_TIMESTAMP siguen siendo UTC', () => {
// Lo que no es solo-día no cambia: sigue llevando su `Z`.
expect(deSQLite('2026-09-09 03:00:00')!.toISOString()).toBe('2026-09-09T03:00:00.000Z');
});

it('una marca de tiempo da el día local de ese instante', () => {
// 03:00 UTC del 9 es el 8 por la noche en Bogotá y el 9 por la mañana en Tokio.
const esperado = zona === 'America/Bogota' ? '2026-09-08' : '2026-09-09';
expect(diaDeCalendario('2026-09-09 03:00:00')).toBe(esperado);
expect(hoyLocal(deSQLite('2026-09-09 03:00:00')!)).toBe(esperado);
});
});
Loading