diff --git a/CHANGELOG.md b/CHANGELOG.md index 12cd735..b6f2742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ 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.26.3] - 2026-08-23 + +### Fixed + +- **No se podían poner horas estimadas al crear un ticket**, y el hueco estaba en las dos capas: el formulario no tenía el campo y `IssueService.create()` ni siquiera incluía la columna en su `INSERT`, mientras `update()` sí la tenía entre sus campos permitidos. La única forma de estimar era crear el ticket y volver a abrirlo. `due_date`, un campo idéntico en dificultad, sí estaba: fue un olvido, no una decisión. +- El número se limpia en el servidor. Llega de fuera, así que un texto, un negativo o un `Infinity` habrían entrado tal cual en una columna `REAL` y habrían salido después en los totales del sprint. +- Dos mensajes del formulario que estaban escritos en inglés a mano —«Title is required» y «Creating...»— pasan por el diccionario. + ## [1.26.2] - 2026-08-20 ### Fixed diff --git a/package-lock.json b/package-lock.json index 7980346..a639acc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forge-js", - "version": "1.26.2", + "version": "1.26.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forge-js", - "version": "1.26.2", + "version": "1.26.3", "dependencies": { "@astrojs/check": "^0.9.10", "@astrojs/node": "^11.1.2", diff --git a/package.json b/package.json index 5177990..c91332c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "forge-js", "type": "module", - "version": "1.26.2", + "version": "1.26.3", "engines": { "node": ">=22.12.0" }, diff --git a/src/i18n/en/board.ts b/src/i18n/en/board.ts index 43ebec5..ce10387 100644 --- a/src/i18n/en/board.ts +++ b/src/i18n/en/board.ts @@ -93,5 +93,7 @@ export const en = { 'sprint.err_delete': 'The sprint could not be deleted.', 'board.sprint_actions': 'Sprint', 'board.status_filter': 'Filter by status', + 'issue.err_title': 'The ticket needs a title.', + 'issue.creating': 'Creating…', 'card.move_to': 'Move to', } as const; diff --git a/src/i18n/es/board.ts b/src/i18n/es/board.ts index d447859..74e7846 100644 --- a/src/i18n/es/board.ts +++ b/src/i18n/es/board.ts @@ -93,5 +93,7 @@ export const es = { 'sprint.err_delete': 'No se ha podido borrar el sprint.', 'board.sprint_actions': 'Sprint', 'board.status_filter': 'Filtrar por estado', + 'issue.err_title': 'El ticket necesita un título.', + 'issue.creating': 'Creando…', 'card.move_to': 'Mover a', } as const; diff --git a/src/lib/IssueService.ts b/src/lib/IssueService.ts index 6e0a9c3..40d5305 100644 --- a/src/lib/IssueService.ts +++ b/src/lib/IssueService.ts @@ -127,10 +127,25 @@ export class IssueService { position = lastIssue.position + 100000; } + /** + * Las horas estimadas se insertan al crear, no solo al editar. + * + * Faltaban en este `INSERT` mientras `update()` sí las tenía entre sus + * `allowedFields`, así que la única forma de estimar un ticket era crearlo + * y volver a abrirlo. `due_date`, que es un campo idéntico en dificultad, + * sí estaba: fue un olvido, no una decisión. + * + * Se limpia aquí porque el número llega de fuera: un texto, un negativo o + * un `Infinity` entrarían tal cual en una columna REAL y saldrían luego en + * los totales del sprint. + */ + const estimadas = Number(data.estimated_hours); + const horas = Number.isFinite(estimadas) && estimadas > 0 ? estimadas : 0; + db.prepare(` - INSERT INTO issues (id, workspace_id, sprint_id, title, type, status, reporter_id, position, assignee_id, due_date, description) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(issueId, data.workspace_id, data.sprint_id || null, data.title, data.type || 'task', status, userId, position, data.assignee_id || null, data.due_date || null, data.description || null); + INSERT INTO issues (id, workspace_id, sprint_id, title, type, status, reporter_id, position, assignee_id, due_date, description, estimated_hours) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(issueId, data.workspace_id, data.sprint_id || null, data.title, data.type || 'task', status, userId, position, data.assignee_id || null, data.due_date || null, data.description || null, horas); // Avisar a quien queda asignado **al crear**, no solo al editar después. // diff --git a/src/pages/w/[sys_tag]/board.astro b/src/pages/w/[sys_tag]/board.astro index 7d2eeef..7ea4ebd 100644 --- a/src/pages/w/[sys_tag]/board.astro +++ b/src/pages/w/[sys_tag]/board.astro @@ -437,6 +437,8 @@ const metaDescription = `${workspace.name} board on Forge OS. Track issues, spri data-t-deactivate={t('sprint.deactivate_confirm')} data-t-err-deactivate={t('sprint.err_deactivate')} data-t-err-create={t('issue.err_create')} + data-t-err-title={t('issue.err_title')} + data-t-creating={t('issue.creating')} data-t-err-delete={t('sprint.err_delete')} data-t-err={t('sprint.err_close')} data-t-no-target={t('sprint.err_no_target')} @@ -513,6 +515,10 @@ const metaDescription = `${workspace.name} board on Forge OS. Track issues, spri +
+ + +
@@ -623,10 +629,11 @@ const metaDescription = `${workspace.name} board on Forge OS. Track issues, spri const assigneeId = (document.getElementById('new-issue-assignee') as HTMLSelectElement).value; const dueDate = (document.getElementById('new-issue-due-date') as HTMLInputElement).value; const description = (document.getElementById('new-issue-desc') as HTMLTextAreaElement).value; + const estimated = (document.getElementById('new-issue-estimated') as HTMLInputElement)?.value; - if (!title) return window.showToast?.('Title is required', 'error'); + if (!title) return window.showToast?.(dialogoCierre?.getAttribute('data-t-err-title') || '', 'error'); - saveIssueBtn.textContent = 'Creating...'; + saveIssueBtn.textContent = dialogoCierre?.getAttribute('data-t-creating') || ''; const sysTag = window.location.pathname.split('/')[2]; const res = await fetch(`/api/w/${sysTag}/issues`, { method: 'POST', @@ -637,7 +644,8 @@ const metaDescription = `${workspace.name} board on Forge OS. Track issues, spri sprint_id: sprintId || null, assignee_id: assigneeId || null, due_date: dueDate || null, - description: description || null + description: description || null, + estimated_hours: estimated ? Number(estimated) : 0 }) }); diff --git a/tests/e2e/estimado-al-crear.spec.ts b/tests/e2e/estimado-al-crear.spec.ts new file mode 100644 index 0000000..8813b49 --- /dev/null +++ b/tests/e2e/estimado-al-crear.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; +import { getTestDb } from './test-utils'; + +/** + * Las horas estimadas se pueden poner al crear el ticket. + * + * El hueco estaba en las dos capas: el formulario no tenía el campo, y + * `IssueService.create()` ni siquiera incluía la columna en su `INSERT`, + * mientras `update()` sí la tenía entre sus `allowedFields`. La única forma de + * estimar era crear el ticket y volver a abrirlo. + * + * `due_date`, un campo idéntico en dificultad, sí estaba — fue un olvido. + */ +const ESPACIO = 'ws-estimado'; + +async function entrar(page: any) { + await page.goto('/login'); + await page.fill('input[name="username"]', 'jose'); + await page.fill('input[name="password"]', process.env.TEST_PASSWORD || 'LocalDevPass123!'); + await page.click('button[type="submit"]'); + await page.waitForURL('**/'); +} + +function conEspacio() { + const db = getTestDb(); + const yo = db.prepare("SELECT id FROM users WHERE username = 'jose'").get() as any; + db.prepare('INSERT OR IGNORE INTO workspaces (id, name, sys_tag, created_by) VALUES (?,?,?,?)') + .run(crypto.randomUUID(), 'Estimado', ESPACIO, yo.id); + const ws = db.prepare('SELECT id FROM workspaces WHERE sys_tag = ?').get(ESPACIO) as any; + db.prepare("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, ws_role) VALUES (?,?,'owner')") + .run(ws.id, yo.id); + return ws.id; +} + +test('el formulario guarda las horas estimadas', async ({ page }) => { + await entrar(page); + const wsId = conEspacio(); + const db = getTestDb(); + db.prepare("DELETE FROM issues WHERE title = 'Con estimación'").run(); + + await page.goto(`/w/${ESPACIO}/board`); + await page.locator('#btn-new-issue').click(); + + await page.fill('#new-issue-title', 'Con estimación'); + await page.fill('#new-issue-estimated', '3.5'); + await page.locator('#btn-save-issue').click(); + + await expect + .poll(() => { + const fila = db + .prepare("SELECT estimated_hours FROM issues WHERE workspace_id = ? AND title = 'Con estimación'") + .get(wsId) as any; + return fila?.estimated_hours ?? null; + }, { message: 'las horas no llegaron a la base' }) + .toBe(3.5); +}); + +test('sin estimación se guarda cero, no basura', async ({ page }) => { + await entrar(page); + const wsId = conEspacio(); + const db = getTestDb(); + db.prepare("DELETE FROM issues WHERE title = 'Sin estimación'").run(); + + // El número llega de fuera, así que un texto no puede entrar en la columna. + const res = await page.request.post(`/api/w/${ESPACIO}/issues`, { + data: { title: 'Sin estimación', type: 'task', estimated_hours: 'muchas' }, + headers: { Origin: 'http://localhost:4322' }, + }); + expect([200, 201]).toContain(res.status()); + + const fila = db + .prepare("SELECT estimated_hours FROM issues WHERE workspace_id = ? AND title = 'Sin estimación'") + .get(wsId) as any; + expect(fila.estimated_hours).toBe(0); +});