diff --git a/CHANGELOG.md b/CHANGELOG.md index 12cd735..09bca91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ 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.27.0] - 2026-08-23 + +### Security + +- **Rechazar una solicitud de amistad que no existe devolvía la fila entera de la tabla.** El 404 incluía un `dbState` con el `SELECT *` de la amistad —`action_user_id`, el id de la otra persona, las marcas de tiempo— a quien acababa de fallar la comprobación de permisos, y había además un `console.log` de cada intento. Era depuración que se quedó puesta, y convertía un «no puedes» en un volcado de la tabla. Sus dos hermanos, aceptar y cancelar, responden con la frase a secas: este era el único de los tres que se salía del patrón. + +### Fixed + +- **El hub marcaba como vencida una tarea que vence hoy.** El contador comparaba con `new Date().toISOString()`, que da el día en UTC: en Bogotá, a partir de las siete de la tarde, el servidor ya cree que es mañana. `lib/fechas.ts` documenta ese caso exacto en su comentario y expone `vencida()` desde entonces —la tarjeta del tablero ya la usa—, pero este contador reimplementó la comparación por su cuenta y se trajo el fallo de vuelta. No falla nunca por la mañana, así que es de los que se descartan como manía de uno. +- **Renombrar un tipo de ticket de fábrica no se veía en ninguna parte.** La regla que decide qué nombre enseñar estaba copiada **cinco veces** —tarjeta, tabla del hub, modal, desplegable del tablero y ajustes— y las cinco ignoraban el nombre escrito cuando el tipo era de fábrica: siempre ganaba la traducción de la clave. Llamar «Incidencia» a «Task» solo se veía al volver a abrir el diálogo de renombrar, que lo lee de otro sitio. +- La regla buena es la que el proyecto ya aplica a las etiquetas y a los tipos propios: **lo que escribe una persona no se traduce**. Ahora se traduce mientras el tipo esté intacto —quien nunca renombra, que es casi todo el mundo, sigue viendo «Tarea» y «Task» según el idioma— y en cuanto alguien le cambia el nombre gana el suyo en los dos. Se decide comparando contra el nombre con el que nació, no mirando solo `is_builtin`. +- Las cinco copias pasan a una sola función, `nombreVisible()`, junto a `mapaPorClave()` que ya existía para esto mismo. Cinco copias de la misma expresión es exactamente cómo se llegó aquí. +- **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. + +- **Las etiquetas de una tarea no salían en «Mis Tareas» del hub**, aunque la misma tarea sí las enseñara en el tablero — y el hub es justo donde se mira para decidir qué tocar. Faltaban las dos mitades: la consulta no las traía y la tabla no tenía dónde ponerlas. Se leen con `deVarias()`, el mismo lector en lote que ya usa el tablero. +- **El panel de atajos de la base de conocimiento prometía teclas que el navegador se queda.** Listaba `Ctrl+K` para insertar un enlace —que va a la barra de búsqueda del navegador— y `Ctrl+U` para subrayar, que abre «ver código fuente»; ningún `preventDefault()` de la página los recupera de forma fiable, así que quien los probaba concluía que la aplicación falla. Se quitan los dos y se dejan los que sí funcionan. Sus cinco textos estaban además escritos en inglés a mano: solo el título pasaba por el diccionario. + ## [1.26.2] - 2026-08-20 ### Fixed diff --git a/package-lock.json b/package-lock.json index 7980346..537ee55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forge-js", - "version": "1.26.2", + "version": "1.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forge-js", - "version": "1.26.2", + "version": "1.27.0", "dependencies": { "@astrojs/check": "^0.9.10", "@astrojs/node": "^11.1.2", diff --git a/package.json b/package.json index 5177990..6d50dcd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "forge-js", "type": "module", - "version": "1.26.2", + "version": "1.27.0", "engines": { "node": ">=22.12.0" }, diff --git a/src/components/dashboard/Hub.astro b/src/components/dashboard/Hub.astro index 735eb73..784c51d 100644 --- a/src/components/dashboard/Hub.astro +++ b/src/components/dashboard/Hub.astro @@ -3,6 +3,8 @@ import Logo from '../brand/Logo.astro'; import WorkspaceIcon from '../brand/WorkspaceIcon.astro'; import { mapaPorClave } from '../../lib/issueTypes'; import { nombreDeRol } from '../../lib/roles'; +import { deVarias } from '../../lib/labels'; +import { vencida } from '../../lib/fechas'; import { espaciosDelHub, tareasPendientes } from '../../lib/consultas'; // src/pages/index.astro import MainLayout from '../../layouts/MainLayout.astro'; @@ -68,15 +70,37 @@ const gruposPendientes = [...porEspacio.values()]; * así que «incidencia» puede ser roja en uno y no existir en otro. Se consulta * una vez por espacio con tareas, no una por tarea. */ +/** + * Las etiquetas de las tareas pendientes. + * + * No salían por ninguna parte: ni la consulta las traía ni la tabla tenía + * dónde ponerlas, así que la misma tarea enseñaba sus etiquetas en el tablero + * y ninguna en el hub — que es justo donde se mira para decidir qué tocar. + * + * Se leen con `deVarias()`, el mismo lector en lote que usa el tablero: una + * consulta para todas en vez de una por fila. + */ +const etiquetasPendientes = deVarias('issue', pendientes.map((t) => t.id)); + const tiposPendientes = new Map(); for (const wsId of new Set(myTasks.map((t) => t.workspace_id))) { for (const [clave, tipo] of mapaPorClave(wsId)) tiposPendientes.set(`${wsId}:${clave}`, tipo); } -// Vencidas: comparando solo la fecha, no la hora. Una entrega «de hoy» no está -// vencida a las nueve de la mañana. -const hoy = new Date().toISOString().slice(0, 10); -const vencidas = pendientes.filter((tarea) => tarea.due_date && String(tarea.due_date).slice(0, 10) < hoy).length; +/** + * Vencidas, con el helper y no a mano. + * + * Aquí había un `new Date().toISOString().slice(0, 10)`, que da el día en + * **UTC**. `lib/fechas.ts` documenta justo este caso en el comentario de + * `hoyLocal()`: en Bogotá, entre las siete de la tarde y medianoche, el + * servidor ya cree que es mañana y marca como vencido lo que vence hoy. Se + * veía como una tarea vencida que no lo estaba, cada tarde. + * + * `vencida()` existía desde entonces y `IssueCard.astro` ya la usa; este + * contador reimplementó la comparación por su cuenta y se trajo el fallo de + * vuelta. + */ +const vencidas = pendientes.filter((tarea) => vencida(tarea.due_date)).length; --- @@ -212,7 +236,7 @@ const vencidas = pendientes.filter((tarea) => tarea.due_date && String(tarea.due {g.tareas.length === 1 ? t('hub.one_task') : t('hub.n_tasks').replace('{n}', String(g.tareas.length))} - + ))} diff --git a/src/components/dashboard/TaskTable.astro b/src/components/dashboard/TaskTable.astro index 5632738..cf21015 100644 --- a/src/components/dashboard/TaskTable.astro +++ b/src/components/dashboard/TaskTable.astro @@ -18,11 +18,15 @@ interface Props { sys_tag?: string; emptyMessage?: string; tipos?: Map; + /** Etiquetas por id de ticket. Se leen en lote fuera, no una por fila. */ + etiquetas?: Map>; } -const { tasks, sys_tag, emptyMessage, tipos } = Astro.props as Props; +const { tasks, sys_tag, emptyMessage, tipos, etiquetas } = Astro.props as Props; import { useTranslations } from '../../i18n/ui'; +import { nombreVisible } from '../../lib/issueTypes'; import { fecha } from '../../lib/fechas'; +import LabelChip from '../labels/LabelChip.astro'; const lang = Astro.locals.lang || 'en'; const t = useTranslations(lang); @@ -70,7 +74,7 @@ const statusColor: Record = { // no, porque lo escribió alguien. Sin tipo resuelto se enseña // la clave, que es fea pero es información. const nombre = ti - ? (ti.isBuiltin ? (t(`type.${ti.key}` as any) || ti.name) : ti.name) + ? nombreVisible(ti, t) : (task.type || 'task'); return ( = { {task.title} + {/* Las etiquetas, debajo del título. + + Van aquí y no en una columna propia porque la tabla ya + tiene cuatro y en el hub compiten con el nombre, que es lo + que se lee. Es el mismo sitio que ocupan en la tarjeta del + tablero, así que la misma tarea se reconoce igual en los + dos sitios. */} + {etiquetas?.get(task.id)?.length ? ( +
+ {etiquetas.get(task.id)!.map((e) => )} +
+ ) : null}
{(task.sys_tag || sys_tag) ? `${task.sys_tag || sys_tag}-${task.id.substring(0,8)}` : task.id.substring(0,8)}
diff --git a/src/components/jira/IssueCard.astro b/src/components/jira/IssueCard.astro index 1c5d723..5e55567 100644 --- a/src/components/jira/IssueCard.astro +++ b/src/components/jira/IssueCard.astro @@ -29,6 +29,7 @@ interface Props { const { issue, canEdit = false, arrastrable = canEdit, tipo = null } = Astro.props; import { useTranslations } from '../../i18n/ui'; +import { nombreVisible } from '../../lib/issueTypes'; import LabelChip from '../labels/LabelChip.astro'; import { fecha, vencida } from '../../lib/fechas'; const lang = Astro.locals.lang || 'en'; @@ -85,7 +86,7 @@ const t = useTranslations(lang); style={tipo ? `border-color: ${tipo.color}55` : undefined}> {tipo && } - {tipo ? (tipo.isBuiltin ? (t(`type.${tipo.key}` as any) || tipo.name) : tipo.name) : issue.type} + {tipo ? nombreVisible(tipo, t) : issue.type}
diff --git a/src/components/jira/IssueDetailsModal.astro b/src/components/jira/IssueDetailsModal.astro index 05650e1..ee4fcbb 100644 --- a/src/components/jira/IssueDetailsModal.astro +++ b/src/components/jira/IssueDetailsModal.astro @@ -1,5 +1,6 @@ --- import { useTranslations } from '../../i18n/ui'; +import { nombreVisible } from '../../lib/issueTypes'; import LabelPicker from '../labels/LabelPicker.astro'; interface Props { sprints?: any[]; @@ -68,7 +69,7 @@ const t = useTranslations(lang);
ID-XXX diff --git a/src/components/notion/EditorClient.astro b/src/components/notion/EditorClient.astro index 6c9e31d..8a90261 100644 --- a/src/components/notion/EditorClient.astro +++ b/src/components/notion/EditorClient.astro @@ -137,11 +137,21 @@ function formatDate(dateStr: string | undefined) {
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/en/kb.ts b/src/i18n/en/kb.ts index 069d61e..f3a394d 100644 --- a/src/i18n/en/kb.ts +++ b/src/i18n/en/kb.ts @@ -26,5 +26,10 @@ export const en = { 'kb.empty_cta': 'Click "New Page" in the sidebar to create one.', 'kb.open_tree': 'Open page index', 'kb.editor_tips': 'Editor tips', + 'kb.sc.block': 'Add block', + 'kb.sc.move': 'Move block', + 'kb.sc.undo': 'Undo / redo', + 'kb.sc.styles': 'Bold, italic', + 'kb.sc.drag': 'Drag', 'kb.shortcuts': 'Editor shortcuts', } 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/i18n/es/kb.ts b/src/i18n/es/kb.ts index a7a6241..71dffa7 100644 --- a/src/i18n/es/kb.ts +++ b/src/i18n/es/kb.ts @@ -26,5 +26,10 @@ export const es = { 'kb.empty_cta': 'Haz clic en "Nueva Página" en la barra lateral para crear una.', 'kb.open_tree': 'Abrir el índice de páginas', 'kb.editor_tips': 'Ayuda del editor', + 'kb.sc.block': 'Añadir bloque', + 'kb.sc.move': 'Mover bloque', + 'kb.sc.undo': 'Deshacer / rehacer', + 'kb.sc.styles': 'Negrita, cursiva', + 'kb.sc.drag': 'Arrastra', 'kb.shortcuts': 'Atajos del editor', } 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/lib/issueTypes.ts b/src/lib/issueTypes.ts index f0b5781..76729bc 100644 --- a/src/lib/issueTypes.ts +++ b/src/lib/issueTypes.ts @@ -107,14 +107,56 @@ export function listar(workspaceId: string): TipoTicket[] { ).all(workspaceId) as any[]).map(aTipo); } +/** + * Los cuatro de siempre, con el nombre con el que nacen. + * + * Está aquí arriba y no dentro de `sembrarDeFabrica()` porque `nombreVisible()` + * necesita saber cuál era el nombre original para distinguir un tipo intacto de + * uno renombrado. Dos listas separadas serían dos verdades sobre lo mismo. + */ +const DE_FABRICA = [ + { key: 'task', name: 'Task', color: '#0091FF' }, + { key: 'bug', name: 'Bug', color: '#E5484D' }, + { key: 'story', name: 'Story', color: '#30A46C' }, + { key: 'epic', name: 'Epic', color: '#8E4EC6' }, +] as const; + +/** El nombre con el que nació cada tipo de fábrica, por clave. */ +const NOMBRE_ORIGINAL = new Map(DE_FABRICA.map((t) => [t.key, t.name])); + +/** + * El nombre que se enseña de un tipo, en el idioma que toca. + * + * Esta regla estaba copiada **cinco veces**, y las cinco tenían el mismo fallo: + * + * ti.isBuiltin ? (t(`type.${ti.key}`) || ti.name) : ti.name + * + * Para un tipo de fábrica eso ignora `ti.name` **siempre**, así que renombrar + * «Tarea» a «Incidencia» no se veía en ninguna parte: ni en la tarjeta, ni en + * la tabla del hub, ni en los desplegables. El único sitio donde aparecía el + * nombre nuevo era el diálogo de renombrar, que lo lee de un `data-name`. + * + * La regla buena es la que el proyecto ya aplica a las etiquetas y a los tipos + * propios: **lo que escribe una persona no se traduce**. Así que se traduce + * solo mientras el tipo esté intacto; en cuanto alguien le cambia el nombre, + * gana el suyo en los dos idiomas. + * + * Se compara contra el nombre original y no contra un `is_builtin` a secas + * porque quien nunca renombra —la mayoría— tiene que seguir viendo «Tarea» y + * «Task» según el idioma, que es lo que hace útil la traducción. + */ +export function nombreVisible( + tipo: { key: string; name: string; isBuiltin: boolean } | null | undefined, + t: (clave: any) => any +): string { + if (!tipo) return ''; + if (!tipo.isBuiltin) return tipo.name; + if (tipo.name !== NOMBRE_ORIGINAL.get(tipo.key)) return tipo.name; + return (t(`type.${tipo.key}`) as string) || tipo.name; +} + /** Los cuatro de siempre, para un espacio que todavía no tiene ninguno. */ export function sembrarDeFabrica(workspaceId: string): void { - const DE_FABRICA = [ - { key: 'task', name: 'Task', color: '#0091FF' }, - { key: 'bug', name: 'Bug', color: '#E5484D' }, - { key: 'story', name: 'Story', color: '#30A46C' }, - { key: 'epic', name: 'Epic', color: '#8E4EC6' }, - ]; const insertar = db.prepare(` INSERT OR IGNORE INTO issue_types (id, workspace_id, key, name, color, position, is_builtin) VALUES (?, ?, ?, ?, ?, ?, 1) diff --git a/src/pages/api/friends/reject/[id].ts b/src/pages/api/friends/reject/[id].ts index dd5cfad..59c79b8 100644 --- a/src/pages/api/friends/reject/[id].ts +++ b/src/pages/api/friends/reject/[id].ts @@ -18,15 +18,20 @@ export const POST: APIRoute = async ({ request, params, locals }) => { AND status = 'pending' `).run(friendshipId, user.id, user.id, user.id); - console.log('Reject attempt:', { friendshipId, userId: user.id, changes: result.changes }); - + /** + * El 404 no cuenta nada de la fila. + * + * Aquí se devolvía `dbState` con el `SELECT *` entero de la amistad: + * `action_user_id`, el id de la otra persona y las marcas de tiempo, a + * quien acababa de fallar la comprobación de permisos. Era depuración que + * se quedó puesta —había también un `console.log` de cada intento— y + * convertía un «no puedes» en un volcado de la tabla. + * + * Sus dos hermanos, `accept/[id].ts` y `cancel/[id].ts`, responden con la + * frase a secas. Este era el único de los tres que se salía del patrón. + */ if (result.changes === 0) { - return new Response(JSON.stringify({ - error: 'Not Found or Unauthorized', - friendshipId, - userId: user.id, - dbState: db.prepare('SELECT * FROM friendships WHERE id = ?').get(friendshipId) - }), { status: 404 }); + return new Response('Not Found or Unauthorized', { status: 404 }); } return new Response(JSON.stringify({ success: true }), { status: 200 }); diff --git a/src/pages/w/[sys_tag]/board.astro b/src/pages/w/[sys_tag]/board.astro index 7d2eeef..683be16 100644 --- a/src/pages/w/[sys_tag]/board.astro +++ b/src/pages/w/[sys_tag]/board.astro @@ -5,7 +5,7 @@ import db from '../../../lib/db'; import { checkWorkspaceAccess } from '../../../lib/guard'; import { useTranslations } from '../../../i18n/ui'; import { deVarias, listar as listarEtiquetas } from '../../../lib/labels'; -import { listar as listarTipos } from '../../../lib/issueTypes'; +import { listar as listarTipos, nombreVisible } from '../../../lib/issueTypes'; import { abrirEspacioDePagina } from '../../../lib/paginaEspacio'; const { sys_tag } = Astro.params; @@ -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')} @@ -487,7 +489,7 @@ const metaDescription = `${workspace.name} board on Forge OS. Track issues, spri @@ -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/src/pages/w/[sys_tag]/settings.astro b/src/pages/w/[sys_tag]/settings.astro index fc173f3..d34f064 100644 --- a/src/pages/w/[sys_tag]/settings.astro +++ b/src/pages/w/[sys_tag]/settings.astro @@ -6,7 +6,7 @@ import { useTranslations } from '../../../i18n/ui'; import LabelChip from '../../../components/labels/LabelChip.astro'; import WorkspaceIcon from '../../../components/brand/WorkspaceIcon.astro'; import { COLORES, listar as listarEtiquetas } from '../../../lib/labels'; -import { listar as listarTipos } from '../../../lib/issueTypes'; +import { listar as listarTipos, nombreVisible } from '../../../lib/issueTypes'; import { abrirEspacioDePagina } from '../../../lib/paginaEspacio'; const user = Astro.locals.user!; @@ -367,7 +367,7 @@ const wsPendingInvites = (db.prepare(`
- {ti.isBuiltin ? (t(`type.${ti.key}` as any) || ti.name) : ti.name} + {nombreVisible(ti, t)} {ti.usos === 0 ? t('types.unused') : ti.usos === 1 ? t('types.used_in_one') : t('types.used_in').replace('{n}', String(ti.usos))} diff --git a/tests/e2e/atajos-kb.spec.ts b/tests/e2e/atajos-kb.spec.ts new file mode 100644 index 0000000..bb9a56c --- /dev/null +++ b/tests/e2e/atajos-kb.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; + +/** + * El panel de atajos de la base de conocimiento no promete lo que no puede dar. + * + * Listaba `Ctrl+K` para insertar un enlace y `Ctrl+B/I/U` para estilos. De esos + * el navegador se queda dos: `Ctrl+K` va a su barra de búsqueda y `Ctrl+U` abre + * «ver código fuente». Ningún `preventDefault()` de la página los recupera de + * forma fiable, así que quien los probaba concluía que la aplicación falla. + * + * (La paleta Cmd+K de la propia app **no** es la culpable: su manejador corta + * antes si el foco está en algo editable.) + */ +test('no lista atajos que el navegador intercepta, y está traducido', async ({ page }) => { + 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('**/'); + + await page.request.post('/api/lang', { + form: { lang: 'es', current_path: '/' }, + headers: { Origin: 'http://localhost:4322' }, + }); + + const db = (await import('./test-utils')).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(), 'Atajos', 'ws-atajos-kb', yo.id); + const ws = db.prepare("SELECT id FROM workspaces WHERE sys_tag = 'ws-atajos-kb'").get() as any; + db.prepare("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, ws_role) VALUES (?,?,'owner')") + .run(ws.id, yo.id); + db.prepare("DELETE FROM pages WHERE workspace_id = ?").run(ws.id); + const pid = crypto.randomUUID(); + db.prepare('INSERT INTO pages (id, workspace_id, title, content_json, created_by) VALUES (?,?,?,?,?)') + .run(pid, ws.id, 'Atajos', '{}', yo.id); + + await page.goto(`/w/ws-atajos-kb/p/${pid}`); + await page.waitForLoadState('networkidle'); + + const panel = page.locator('h4', { hasText: /atajos/i }).locator('..'); + const texto = await panel.evaluate((el: HTMLElement) => el.textContent ?? ''); + + // Los dos que el navegador se queda. + expect(texto, 'sigue prometiendo Ctrl+K, que va a la barra del navegador').not.toContain('Ctrl K'); + expect(texto, 'sigue prometiendo Ctrl+U, que abre el código fuente').not.toContain('U'); + + // Y ninguno de los cinco textos sigue en inglés a pelo. + for (const suelto of ['Add Block', 'Move Block', 'Undo/Redo', 'Styles', 'Link']) { + expect(texto, `«${suelto}» sigue sin traducir`).not.toContain(suelto); + } +}); 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); +}); diff --git a/tests/e2e/etiquetas-en-hub.spec.ts b/tests/e2e/etiquetas-en-hub.spec.ts new file mode 100644 index 0000000..fe7202e --- /dev/null +++ b/tests/e2e/etiquetas-en-hub.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; +import { getTestDb } from './test-utils'; + +/** + * Las etiquetas de una tarea también se ven en el hub. + * + * La misma tarea las enseñaba en el tablero y ninguna en «Mis Tareas», que es + * justo donde se mira para decidir qué tocar. Faltaban las dos mitades: la + * consulta no las traía y la tabla no tenía dónde ponerlas. + */ +const ESPACIO = 'ws-etiquetas-hub'; + +test('una tarea asignada enseña sus etiquetas en Mis Tareas', async ({ page }) => { + 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('**/'); + + 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(), 'Etiquetas hub', 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); + + db.prepare("DELETE FROM issues WHERE title = 'Tarea con etiqueta'").run(); + const issueId = crypto.randomUUID(); + db.prepare(`INSERT INTO issues (id, workspace_id, type, title, status, reporter_id, assignee_id, position) + VALUES (?, ?, 'task', 'Tarea con etiqueta', 'todo', ?, ?, 100000)`) + .run(issueId, ws.id, yo.id, yo.id); + + db.prepare("DELETE FROM labels WHERE workspace_id = ? AND name = 'Parcial 2'").run(ws.id); + const labelId = crypto.randomUUID(); + db.prepare('INSERT INTO labels (id, workspace_id, name, color) VALUES (?,?,?,?)') + .run(labelId, ws.id, 'Parcial 2', '#FF5D00'); + db.prepare("INSERT OR IGNORE INTO issue_labels (label_id, issue_id) VALUES (?,?)").run(labelId, issueId); + + await page.goto('/'); + + const fila = page.locator('.task-row', { hasText: 'Tarea con etiqueta' }).first(); + await expect(fila).toBeVisible(); + await expect(fila, 'la etiqueta no llega al hub').toContainText('Parcial 2'); +}); diff --git a/tests/e2e/rechazo-sin-fuga.spec.ts b/tests/e2e/rechazo-sin-fuga.spec.ts new file mode 100644 index 0000000..1eacccf --- /dev/null +++ b/tests/e2e/rechazo-sin-fuga.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; + +/** + * Un 404 no cuenta nada de la fila. + * + * `api/friends/reject/[id].ts` devolvía `dbState` con el `SELECT *` entero de + * la amistad —`action_user_id`, el id de la otra persona, las marcas de + * tiempo— a quien acababa de fallar la comprobación de permisos. Era + * depuración que se quedó puesta, y convertía un «no puedes» en un volcado de + * la tabla. Sus dos hermanos responden con la frase a secas. + */ +test('el rechazo de una solicitud inexistente no filtra la fila', async ({ page }) => { + 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('**/'); + + const res = await page.request.post('/api/friends/reject/no-existe-este-id', { + headers: { Origin: 'http://localhost:4322' }, + }); + expect(res.status()).toBe(404); + + const cuerpo = await res.text(); + + // Ni el volcado, ni las columnas que llevaba dentro. + for (const filtrado of ['dbState', 'action_user_id', 'user_a_id', 'user_b_id', 'updated_at']) { + expect(cuerpo, `el 404 sigue contando «${filtrado}»`).not.toContain(filtrado); + } + // Y tampoco el id de quien pregunta, que tampoco pintaba nada ahí. + expect(cuerpo).not.toContain('userId'); +}); diff --git a/tests/e2e/renombrar-tipo.spec.ts b/tests/e2e/renombrar-tipo.spec.ts new file mode 100644 index 0000000..f20f85d --- /dev/null +++ b/tests/e2e/renombrar-tipo.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; +import { getTestDb } from './test-utils'; + +/** + * Renombrar un tipo de fábrica se ve en toda la aplicación. + * + * Antes solo se veía en el diálogo de renombrar, que lee el nombre de un + * `data-name`. Los cinco sitios que lo pintan usaban la traducción de la clave + * e ignoraban el nombre escrito, así que el tablero, la tabla del hub y los + * desplegables seguían diciendo «Task» para siempre. + * + * Se comprueba en las pantallas, no en la función: la unidad ya está cubierta + * en `tests/nombre-visible-tipo.test.ts`, y lo que falló aquí fue que cinco + * copias no llamaran a nadie. + */ +const ESPACIO = 'ws-renombrar-tipo'; + +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('**/'); +} + +test('el nombre nuevo de un tipo de fábrica llega al tablero y al modal', async ({ page }) => { + await entrar(page); + + 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(), 'Renombrar', 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); + + // Los cuatro de fábrica se siembran al primer listado; se fuerza aquí. + await page.goto(`/w/${ESPACIO}/board`); + + // Y se renombra «Task» directamente, que es el caso del reporte. + db.prepare("UPDATE issue_types SET name = 'Incidencia' WHERE workspace_id = ? AND key = 'task'") + .run(ws.id); + + db.prepare('DELETE FROM issues WHERE workspace_id = ?').run(ws.id); + db.prepare(`INSERT INTO issues (id, workspace_id, type, title, status, reporter_id, position) + VALUES (?, ?, 'task', 'Ticket de prueba', 'todo', ?, 100000)`) + .run(crypto.randomUUID(), ws.id, yo.id); + + await page.goto(`/w/${ESPACIO}/board?sprint=backlog`); + + // 1. La insignia de la tarjeta. + const tarjeta = page.locator('.issue-card', { hasText: 'Ticket de prueba' }).first(); + await expect(tarjeta).toBeVisible(); + await expect(tarjeta.locator('.issue-type-badge')).toContainText('Incidencia'); + + // 2. El desplegable del modal de crear. + await expect(page.locator('#new-issue-type option').first()).toHaveText('Incidencia'); + + // Y lo que no puede romperse: los que nadie tocó siguen traduciéndose. + await expect(page.locator('#new-issue-type')).toContainText('Bug'); +}); diff --git a/tests/nombre-visible-tipo.test.ts b/tests/nombre-visible-tipo.test.ts new file mode 100644 index 0000000..0a7be18 --- /dev/null +++ b/tests/nombre-visible-tipo.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { nombreVisible } from '../src/lib/issueTypes'; + +/** + * Qué nombre se enseña de un tipo de ticket. + * + * La regla estaba copiada cinco veces y las cinco ignoraban `name` cuando el + * tipo era de fábrica, así que renombrar «Task» a «Incidencia» no se veía en + * ninguna parte salvo en el propio diálogo de renombrar. + * + * La regla buena es la que el proyecto ya aplica a etiquetas y tipos propios: + * lo que escribe una persona no se traduce. Pero hay que conservar la + * traducción para quien nunca renombra, que es casi todo el mundo — de ahí que + * se compare contra el nombre original en vez de mirar solo `isBuiltin`. + */ +const es = (clave: string) => ({ + 'type.task': 'Tarea', + 'type.bug': 'Error', + 'type.story': 'Historia', + 'type.epic': 'Épica', +}[clave]); + +const en = (clave: string) => ({ + 'type.task': 'Task', + 'type.bug': 'Bug', +}[clave]); + +describe('nombreVisible', () => { + it('un tipo de fábrica intacto se traduce', () => { + const tarea = { key: 'task', name: 'Task', isBuiltin: true }; + expect(nombreVisible(tarea, es)).toBe('Tarea'); + expect(nombreVisible(tarea, en)).toBe('Task'); + }); + + it('un tipo de fábrica renombrado gana en los dos idiomas', () => { + const renombrado = { key: 'task', name: 'Incidencia', isBuiltin: true }; + expect(nombreVisible(renombrado, es)).toBe('Incidencia'); + expect(nombreVisible(renombrado, en)).toBe('Incidencia'); + }); + + it('un tipo propio nunca se traduce', () => { + const propio = { key: 'preventivo', name: 'Preventivo', isBuiltin: false }; + expect(nombreVisible(propio, es)).toBe('Preventivo'); + }); + + it('si falta la traducción cae al nombre, no a la clave', () => { + const sinTraducir = { key: 'story', name: 'Story', isBuiltin: true }; + expect(nombreVisible(sinTraducir, en)).toBe('Story'); + }); + + it('sin tipo resuelto devuelve vacío, no «undefined»', () => { + expect(nombreVisible(null, es)).toBe(''); + expect(nombreVisible(undefined, es)).toBe(''); + }); +}); diff --git a/tests/vencidas-hub.test.ts b/tests/vencidas-hub.test.ts new file mode 100644 index 0000000..9204642 --- /dev/null +++ b/tests/vencidas-hub.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { vencida, hoyLocal } from '../src/lib/fechas'; + +/** + * El contador de vencidas del Hub. + * + * `Hub.astro` comparaba con `new Date().toISOString().slice(0, 10)`, que da el + * día en **UTC**. En una zona con desfase negativo —Bogotá es UTC-5— eso + * significa que a partir de las siete de la tarde el servidor ya cree que es + * mañana, y una entrega de hoy aparece como vencida. + * + * No falla nunca por la mañana, así que es de los que se descartan como «cosa + * mía». Se fija con la hora clavada, que es lo único que lo reproduce. + */ +describe('una entrega de hoy no está vencida por la tarde', () => { + // 22:00 en Bogotá = 03:00 UTC del día siguiente. + const laNoche = new Date('2026-08-21T03:00:00Z'); + + it('la comparación en UTC daba mañana; la local da hoy', () => { + const enUtc = laNoche.toISOString().slice(0, 10); + const enLocal = hoyLocal(laNoche); + + // Si esta prueba corre en UTC-5, los dos días son distintos: ahí estaba el + // fallo. En otra zona no se puede afirmar, pero la comparación de abajo + // vale igual. + if (enUtc !== enLocal) { + expect(enUtc > enLocal, 'UTC va por delante del día local').toBe(true); + } + }); + + it('una tarea que vence hoy no cuenta como vencida', () => { + const hoy = hoyLocal(laNoche); + expect(vencida(hoy, laNoche)).toBe(false); + }); + + it('una que venció ayer sí', () => { + const hoy = hoyLocal(laNoche); + const ayer = new Date(`${hoy}T12:00:00Z`); + ayer.setDate(ayer.getDate() - 1); + expect(vencida(ayer.toISOString().slice(0, 10), laNoche)).toBe(true); + }); + + it('sin fecha de entrega no está vencida', () => { + expect(vencida(null, laNoche)).toBe(false); + expect(vencida('', laNoche)).toBe(false); + }); +});