Skip to content
Closed
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.26.2",
"version": "1.26.3",
"engines": {
"node": ">=22.12.0"
},
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/en/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 2 additions & 0 deletions src/i18n/es/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
21 changes: 18 additions & 3 deletions src/lib/IssueService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
14 changes: 11 additions & 3 deletions src/pages/w/[sys_tag]/board.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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')}
Expand Down Expand Up @@ -513,6 +515,10 @@ const metaDescription = `${workspace.name} board on Forge OS. Track issues, spri
<label class="block text-xs uppercase tracking-wider text-forge-muted mb-1" for="new-issue-due-date">{t('modal.due_date') || 'Due Date'}</label>
<input type="date" id="new-issue-due-date" class="w-full bg-forge-bg border border-forge-border rounded p-2 text-forge-text focus:border-forge-secondary outline-none font-mono" />
</div>
<div>
<label class="block text-xs uppercase tracking-wider text-forge-muted mb-1" for="new-issue-estimated">{t('modal.estimated_hours')}</label>
<input type="number" id="new-issue-estimated" min="0" step="0.5" placeholder="0" class="w-full bg-forge-bg border border-forge-border rounded p-2 text-forge-text focus:border-forge-secondary outline-none font-mono" />
</div>
<div>
<label class="block text-xs uppercase tracking-wider text-forge-muted mb-1" for="new-issue-desc">{t('modal.description') || 'Description'}</label>
<textarea id="new-issue-desc" rows="3" class="w-full bg-forge-bg border border-forge-border rounded p-2 text-sm text-forge-text focus:border-forge-secondary outline-none placeholder:text-forge-muted" placeholder={t('modal.desc_placeholder') as string}></textarea>
Expand Down Expand Up @@ -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',
Expand All @@ -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
})
});

Expand Down
75 changes: 75 additions & 0 deletions tests/e2e/estimado-al-crear.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});