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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.27.0",
"engines": {
"node": ">=22.12.0"
},
Expand Down
34 changes: 29 additions & 5 deletions src/components/dashboard/Hub.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, { key: string; name: string; color: string; isBuiltin: boolean }>();
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;
---

<MainLayout title={t('hub.title')}>
Expand Down Expand Up @@ -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))}
</span>
</a>
<TaskTable tasks={g.tareas} sys_tag={g.sysTag} tipos={tiposPendientes} />
<TaskTable tasks={g.tareas} sys_tag={g.sysTag} tipos={tiposPendientes} etiquetas={etiquetasPendientes} />
</div>
))}

Expand Down
20 changes: 18 additions & 2 deletions src/components/dashboard/TaskTable.astro
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ interface Props {
sys_tag?: string;
emptyMessage?: string;
tipos?: Map<string, { key: string; name: string; color: string; isBuiltin: boolean }>;
/** Etiquetas por id de ticket. Se leen en lote fuera, no una por fila. */
etiquetas?: Map<string, Array<{ id: string; name: string; color: string }>>;
}
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);

Expand Down Expand Up @@ -70,7 +74,7 @@ const statusColor: Record<string, string> = {
// 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 (
<span
Expand All @@ -87,6 +91,18 @@ const statusColor: Record<string, string> = {
<a href={href} class="text-forge-text font-medium group-hover:text-forge-accent-text transition-colors block truncate" onclick="event.stopPropagation();">
{task.title}
</a>
{/* 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 ? (
<div class="flex flex-wrap gap-1 mt-1">
{etiquetas.get(task.id)!.map((e) => <LabelChip name={e.name} color={e.color} size="sm" />)}
</div>
) : null}
<div class="text-[10px] text-forge-muted font-mono truncate mt-0.5">
{(task.sys_tag || sys_tag) ? `${task.sys_tag || sys_tag}-${task.id.substring(0,8)}` : task.id.substring(0,8)}
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/components/jira/IssueCard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -85,7 +86,7 @@ const t = useTranslations(lang);
style={tipo ? `border-color: ${tipo.color}55` : undefined}>
{tipo && <span class="w-1.5 h-1.5 rounded-full shrink-0" style={`background:${tipo.color}`} aria-hidden="true"></span>}
<span class="text-forge-muted">
{tipo ? (tipo.isBuiltin ? (t(`type.${tipo.key}` as any) || tipo.name) : tipo.name) : issue.type}
{tipo ? nombreVisible(tipo, t) : issue.type}
</span>
</span>
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/components/jira/IssueDetailsModal.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
import { useTranslations } from '../../i18n/ui';
import { nombreVisible } from '../../lib/issueTypes';
import LabelPicker from '../labels/LabelPicker.astro';
interface Props {
sprints?: any[];
Expand Down Expand Up @@ -68,7 +69,7 @@ const t = useTranslations(lang);
<div class="flex items-center gap-3 min-w-0">
<select id="modal-issue-type" aria-label={t('a11y.issue_type')} class="px-2 py-1 rounded-md text-[11px] font-bold tracking-wider bg-forge-bg/50 text-forge-text border border-forge-border/50 focus:border-forge-secondary focus:ring-1 focus:ring-forge-secondary/50 focus:outline-none uppercase transition-all shadow-sm">
{issueTypes.map((ti) => (
<option value={ti.key}>{ti.isBuiltin ? (t(`type.${ti.key}` as any) || ti.name) : ti.name}</option>
<option value={ti.key}>{nombreVisible(ti, t)}</option>
))}
</select>
<span id="modal-issue-id" class="font-mono text-[13px] text-forge-muted tracking-wide font-medium truncate">ID-XXX</span>
Expand Down
20 changes: 15 additions & 5 deletions src/components/notion/EditorClient.astro
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,21 @@ function formatDate(dateStr: string | undefined) {
<div class="absolute right-0 top-full mt-2 w-56 bg-forge-panel border border-forge-border rounded-lg shadow-xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50 p-3 text-xs text-forge-text pointer-events-none">
<h4 class="font-bold text-forge-primary mb-2 border-b border-forge-border pb-1">{t('kb.shortcuts')}</h4>
<ul class="space-y-2 text-forge-muted">
<li class="flex justify-between items-center"><span>Add Block</span> <span class="font-mono text-[10px] bg-forge-bg px-1.5 py-0.5 rounded border border-forge-border/50">TAB / +</span></li>
<li class="flex justify-between items-center"><span>Move Block</span> <span class="text-[10px]">Drag <span class="bg-forge-bg px-1 py-0.5 rounded font-bold border border-forge-border/50">::</span></span></li>
<li class="flex justify-between items-center"><span>Undo/Redo</span> <span class="font-mono text-[10px] bg-forge-bg px-1.5 py-0.5 rounded border border-forge-border/50">Ctrl Z / Y</span></li>
<li class="flex justify-between items-center"><span>Styles</span> <span class="font-mono text-[10px] bg-forge-bg px-1.5 py-0.5 rounded border border-forge-border/50">Ctrl B/I/U</span></li>
<li class="flex justify-between items-center"><span>Link</span> <span class="font-mono text-[10px] bg-forge-bg px-1.5 py-0.5 rounded border border-forge-border/50">Ctrl K</span></li>
{/* Solo los atajos que el navegador cede.

Estaban aquí «Ctrl K» para enlace y «Ctrl B/I/U» para
estilos, y 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 el panel prometía algo que no pasa — peor que
no listarlo, porque quien lo prueba cree que la app falla.

Los cinco textos estaban además escritos en inglés a mano;
solo el título pasaba por el diccionario. */}
<li class="flex justify-between items-center"><span>{t('kb.sc.block')}</span> <span class="font-mono text-[10px] bg-forge-bg px-1.5 py-0.5 rounded border border-forge-border/50">TAB</span></li>
<li class="flex justify-between items-center"><span>{t('kb.sc.move')}</span> <span class="text-[10px]">{t('kb.sc.drag')} <span class="bg-forge-bg px-1 py-0.5 rounded font-bold border border-forge-border/50">::</span></span></li>
<li class="flex justify-between items-center"><span>{t('kb.sc.undo')}</span> <span class="font-mono text-[10px] bg-forge-bg px-1.5 py-0.5 rounded border border-forge-border/50">Ctrl Z / Y</span></li>
<li class="flex justify-between items-center"><span>{t('kb.sc.styles')}</span> <span class="font-mono text-[10px] bg-forge-bg px-1.5 py-0.5 rounded border border-forge-border/50">Ctrl B / I</span></li>
</ul>
</div>
</div>
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;
5 changes: 5 additions & 0 deletions src/i18n/en/kb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
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;
5 changes: 5 additions & 0 deletions src/i18n/es/kb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
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
Loading