Compare commits

...

23 Commits

Author SHA1 Message Date
egutierrez 472fa25bae build(frontend): rebuild dist with XSS scheme guard 2026-05-27 11:04:20 +02:00
egutierrez aab4f12fc4 fix(0128): XSS scheme allowlist + drop dead fileID
review findings:
- MessageBody: only http(s) and relative paths allowed for links;
  data:image/* allowed for inline images. Rejects javascript:,
  data:text/html, vbscript: which would execute via <a href>.
  Unsafe matches fall back to plain text.
- files.go: remove unused fileID var generated then discarded.
2026-05-27 11:04:20 +02:00
egutierrez e86c93cb73 build(frontend): rebuild embedded dist with files UI 2026-05-27 10:52:15 +02:00
egutierrez 489d2bbef6 chore: bump kanban 0.1.0 -> 0.2.0 + e2e smoke (issue 0128)
- app.md: descripcion, e2e_checks smoke_files, doc Archivos, capability growth log
- .gitignore: uploads/
- e2e/files_smoke.sh: build, login, upload PNG, list, serve, delete
2026-05-27 10:52:06 +02:00
egutierrez ac5f016e7e feat(frontend): UI archivos en cards (issue 0128)
- CardFilesPanel: tab Archivos con grid thumbs + boton subir/borrar
- CardForm: drag&drop en descripcion, inserta ref markdown en cursor
- CardChatPanel: drag&drop + boton paperclip, sube y envia ref como mensaje
- MessageBody: renderer markdown minimo (img inline + link chip)
- api.ts: listCardFiles, uploadCardFile (multipart), deleteCardFile
- types.ts: CardFile
2026-05-27 10:52:01 +02:00
egutierrez 2401eb5abc feat(backend): card file attachments (issue 0128)
- migration 014_card_files: tabla con soft-delete + index activo
- handlers POST/GET/DELETE en backend/files.go
- routes /api/cards/{id}/files, /api/files/{id}
- limite 10MB, storage en uploads/<card_id>/<random>__<safe>
2026-05-27 10:51:52 +02:00
egutierrez 1923fd31a4 chore: auto-commit (1 archivos)
- registry.db

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:37:33 +02:00
egutierrez b599090876 chore: auto-commit (1 archivos)
- app.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:32:55 +02:00
egutierrez 69a0d351fc Merge issue 0094: kanban daily summary + pdf 2026-05-14 18:08:09 +02:00
egutierrez 9c5e76e03f feat(kanban): bocadillo agente + PDF descargable en reporte diario (issue 0094)
Anade tres capas sobre el reporte diario del issue 0093:

1) Bocadillo del agente: cuadro azul encima de "Tareas hechas" con un
   resumen en lenguaje natural (max 4 frases) generado por claude -p
   sobre el JSON del reporte. Botones Regenerar e icono Settings.

2) Settings del prompt: modal con textarea editable para el template
   del agente (key=daily_report_prompt). Compartido por todos los
   usuarios. Boton Restablecer por defecto.

3) PDF descargable: boton que abre ventana nueva con HTML imprimible
   (estilo A4, KPIs filtrados, tabla con enlaces absolutos por card).
   Permite compartir el listado de tareas hechas con los solicitantes.

Backend:
- Migration 013 anade tablas daily_summaries y settings; seed del
  prompt por defecto en castellano.
- daily_summary.go con GetSetting/SetSetting, GetDailySummary/Upsert,
  runClaudePrompt (envuelve claude -p) y GenerateDailySummary que
  orquesta DailyReportFor + plantilla + claude + persist.
- Nuevos endpoints:
  * GET  /api/reports/daily/summary
  * POST /api/reports/daily/summary
  * GET  /api/settings/{key}
  * PUT  /api/settings/{key}

Frontend:
- api.ts: getDailySummary, generateDailySummary, getSetting, setSetting.
- DailyReport.tsx: estado de summary, settingsOpen, promptDraft,
  filterRequester, filterAssignee, filteredDoneCards, exportPDF.
- Bocadillo con IconSparkles + IconRefresh + IconSettings.
- Modal de prompt con Guardar/Cancelar/Reset.
- Filtros Select por solicitante y asignado encima de la tabla.
- exportPDF abre window.open con HTML self-contained que incluye
  enlaces ${origin}/?card=${id} y window.print() automatico.

E2E nuevo (daily-summary-pdf.spec.ts): CRUD del setting, GET summary
shape, presencia del boton PDF/Settings/Regenerar en el modal. No
invoca claude real (binario externo, no disponible en CI).

Suite completa 11/11 pasa.
2026-05-14 18:08:09 +02:00
egutierrez fc7e6a34a7 feat(kanban): reporte diario al click en dia del calendario (issue 0093)
Adds a daily report dashboard accessible by clicking a day number in the
calendar view. Renders inside a full-width modal (90% width).

Backend (new file backend/reports.go):
- Type DailyReport with KPIs, rankings, done_cards list, reopened cards,
  3-bucket stale list (7/14/30d), lead time avg+p50+p95, 24-hour
  movement histogram, deadlines met/missed list, tag distribution and
  archived count.
- DB.DailyReportFor(date, tz) uses Europe/Madrid by default; computes
  [start,end) in local time, converts to UTC and queries:
  * cards.completed_at in range  -> done list
  * card_events kind=created in range -> created counts
  * card_column_history.entered_at in range -> moves + hourly
  * previousColumnWasDone() -> reopened detection
  * card_lock_history overlapping the day -> blocked_ms
  * stale buckets: open history entries on non-done columns aged >=7d
- New route GET /api/reports/daily?date=YYYY-MM-DD&tz=Europe/Madrid.

Frontend:
- api.ts: DailyReport type + dailyReport(date, tz?) call.
- New component DailyReportView (components/DailyReport.tsx):
  * 6 KPI cards (Hechas, Creadas, Movimientos, Bloqueado, Reabiertas,
    Deadlines on-time %).
  * 4 ranking cards (Top assignees done, Top assignees created,
    Top requesters atendidas, Top requesters aportadas).
  * Done cards table with click-to-jump (links open the card in board).
  * Mantine BarChart with movements per hour.
  * Tag chips, reopened list, deadlines list with late_ms, stale buckets.
- CalendarView wraps the day number in UnstyledButton with data-test
  attribute and forwards onOpenDailyReport.
- App.handleOpenDailyReport opens modals.open size 90% with the view;
  click on a card title closes the modal and jumps to the board with
  highlight (reuses existing handleJumpToCard).

Tests (e2e/daily-report.spec.ts):
- Endpoint shape: kpis, done_cards, hourly_moves[24], stale buckets.
- Calendar day click opens the modal with "Reporte diario" title and
  KPI labels visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez 9d3ab5f0f3 feat(kanban): hide "Seleccionar Aleatorio" in done columns
The random-pick menu entry is meaningless for done columns — cards there
are already finished and now get auto-archived after 30 days (issue
0092). Gate the Menu.Item on !column.is_done so the action only appears
in active columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez 9b503f0555 feat(kanban): archive automatico para cards Done +30 dias (issue 0092)
Adds an archive layer separate from the trash. Cards in is_done columns
that have been there for more than 30 days are auto-archived on the next
board load (throttled to once every 30 minutes). Archived cards leave
the board but stay in the DB and are listed in a new sidebar drawer
"Hecho (archivo)" below the existing Papelera, with a one-click restore.

Schema (migration 012_card_archived.sql):
- ALTER TABLE cards ADD COLUMN archived_at TEXT;
- NULL = active, ISO timestamp = archived. Independent from deleted_at.

Backend:
- Card.ArchivedAt + JSON; ListCardsWithTime filters archived_at IS NULL.
- New methods: ArchiveCard, UnarchiveCard, ListArchivedCards,
  AutoArchiveDoneOlderThan.
- New endpoints: GET /api/archive, POST /api/cards/:id/archive,
  POST /api/cards/:id/unarchive.
- handleGetBoard invokes maybeAutoArchive (atomic throttle, 30 min sweep,
  30 day cutoff). Errors logged but never block the board response.

Frontend:
- Card type + api.ts add the new field and helpers.
- App.tsx state for archive list, reload, archive/unarchive handlers.
- New sidebar drawer with toggle, count badge, restore button.
- KanbanCard gains an "Archivar" menu item (gated on isDone +
  onArchive prop) for manual archiving of any done card.

Tests:
- Playwright e2e/archive.spec.ts: manual archive via menu, drawer
  toggle, unarchive. Picks a done card via /api/board introspection so
  it stays stable regardless of board state.
- Auto-archive of >30d cards: not under e2e (real time travel needed);
  covered by code review of the SQL query and the throttle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez c4caff85be perf(kanban): split KanbanCard body into memoized child (dnd lag fix)
Drag perf measured via new Playwright spec drag-perf.spec.ts which drives
a slow drag across the biggest column (~35 cards) while capturing per-frame
durations via rAF inside the page. Pre-fix metrics in HECHO column:

  wrapper-renders=1942 body-renders=N/A
  p50=16.7ms p95=83.3ms max=116.7ms (12fps stalls)

Root cause: useSortable inside KanbanCardImpl subscribes to dnd-kit context;
every pointermove during a drag re-renders ALL cards in the SortableContext.
With the old monolithic component, each re-render rebuilt the full Stack +
Menu + 4 Popovers JSX tree — even though no data had changed.

Fix: split KanbanCardImpl into a thin outer (useSortable + Paper wrapper +
sticker overlay handler + style) and a memoed KanbanCardBody (Stack +
sticker overlay + popover state). All popover/requesterDraft local state
lives inside the body now, so its props are stable across drag and
React.memo skips the body work entirely.

Post-fix metrics:

  wrapper-renders=1943 body-renders=0
  p50=16.7ms p95=16.8ms max=50.0ms (steady 60fps with a single 33ms spike)

E2E thresholds tightened: p50<20, p95<50, max<60, body-renders<5. Regression
in any of these will fail CI.

Probe helpers (_probeRender / _probeBodyRender) are no-ops unless
window._cardRenderProbe is set. Production cost: ~3ns per render call.

Also: `now` clock interval already pauses while dragging (previous commit
e656e8c). `animateLayoutChanges:() => false` kept; it does not visibly
change reorder UX with this codebase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez 7ba18f9114 fix(kanban): infinite ref-merge loop + drag lag
Two issues:

1. "Maximum update depth exceeded" inside Mantine's useMergedRef during
   drag. Root cause: the `data` object passed to dnd-kit's `useSortable`
   in KanbanCard and KanbanColumn was re-created on every render. Mantine
   Paper composes its internal ref with the consumer ref via
   useMergedRef, which uses a useCallback whose deps array contains the
   refs themselves. Whenever the underlying setNodeRef from useSortable
   became unstable (because dnd-kit's internal state churned on data
   identity change), the merged ref was reassigned each commit -> setState
   inside the ref callback -> next render -> new data identity -> loop.
   Wrap the sortable data in useMemo keyed on its real inputs.

2. Drag feels laggy. Each card listens to a 1-second `now` clock that
   re-renders the entire board to refresh the "time in column" label.
   Pause that interval while a drag is active so dnd-kit's per-pixel
   reconciliation does not also re-mount/re-layout every card every
   second. Tick resumes the moment the drag ends.

Also move the Select `data` array for the column max-time popover from
an inline expression to a module-level constant; same array identity
across all column instances and renders -> Mantine Combobox stops
re-running its diffing effect for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez 76d85959f1 feat(kanban): sidebar edge zone now toggles (open + close)
The 32px left drag zone now also closes the sidebar when it is open.
Symmetric behaviour: dwell ≥400ms while dragging closes if open / opens
if closed.

To prevent a drag that starts with the pointer already inside the
sidebar (e.g. dragging a sidebar card itself) from immediately auto-
closing, the close action requires the pointer to have left the strip
at least once after the drag started. So:
- closed + drag-to-edge -> opens (unchanged).
- open + drag a card out, then move the card back to the edge -> closes.
- open + drag a sidebar card directly to the board -> nothing happens.

After a successful toggle the dwell flag resets, so the user must leave
the strip and re-enter to trigger another action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez 257858a1f3 feat(kanban): drag-aware sidebar dropzone (issue 0091)
Add a 32px invisible strip on the left edge of the viewport that
auto-opens the sidebar when the user drags a card and dwells near the
edge for >=400ms. Removes the manual toggle step when moving cards to
sidebar-located columns.

- App.tsx: global mousemove listener while drag is active; 400ms hover
  timer; sets navOpen(true) when triggered; cancels on pointer leave or
  drag end. No auto-close on drag end (user keeps sidebar open).
- dropzone.css: subtle inset blue glow with pulse animation while
  pointer is inside the strip and a drag is active.
- KanbanColumn.tsx: add data-column-id and data-column-location to the
  Paper root for stable e2e selectors.
- e2e/sidebar-dropzone.spec.ts: Playwright test driving a slow drag
  to the left edge, asserting the strip arms, sidebar opens, and the
  card moves to a sidebar column via /api/board.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez 30def13c55 feat(kanban): mejoras historial card — DONE check + tiempo por columna
HistoryModal now receives the board's columns via prop to enrich
history events with column metadata.

Timeline:
- Entries for column moves into a column with is_done=true now render
  with a green IconCheck bullet and the kind label "Hecho en columna"
  instead of the generic blue arrows / "Mueve a columna". Makes the
  card's "done" moments scannable at a glance.

Footer (below the timeline):
- Replaces the single Group-of-badges with a structured table showing
  one row per visited column: name, visits (entry count) and total time
  in column. DONE columns are flagged with a green check + bold.
- Total locked time keeps the same source (total_locked_ms) but moved
  to a header line above the table to declutter.
- Currently-active entry (exited_at=null) contributes now - entered_at
  to its row, so the table reflects live time.

App.tsx passes columns from board state when opening the history modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez bc502df48a feat(kanban): tiempo maximo via popover con unidad (issue 0089 followup)
Replace the native window.prompt with a Popover that mirrors the deadline
picker pattern: NumberInput + unit Select (minutos/horas/dias/semanas/meses).
The selected unit converts to minutes at save time; the column's stored
unit on the backend stays unchanged (max_time_minutes).

On open the popover pre-selects the largest unit that yields a clean
integer for the current value (e.g. 1440 -> 1 dia, 60 -> 1 hora).

Includes a trash icon to clear the limit and a Guardar button.
data-test selectors added for future e2e:
- column-max-time, column-max-time-input, column-max-time-unit,
  column-max-time-save.

Menu label now shows "(N dias)" / "(N semanas)" / etc. instead of "(N min)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez c93ac46c37 feat(kanban): Seleccionar Aleatorio en columna con ruleta (issue 0090)
Adds a column-level "Seleccionar Aleatorio" context menu entry that picks
a card at random from the column with a roulette-style highlight
animation that decelerates to the winner.

Selection respects existing filters (uses cardsByColumn which is the
post-filter view) and always excludes locked cards. Menu entry is
disabled when nothing pickable is left.

Implementation:
- KanbanColumn: new Menu.Item with IconDice5; data-test selector for e2e.
- onPickRandom prop wired from KanbanColumn -> App.
- handlePickRandom in App.tsx: cryptographically random winner via
  crypto.getRandomValues, 2 full laps + offset, cubic decay 50ms -> 220ms,
  follows the active card with scrollIntoView.
- src/styles/roulette.css: .kanban-roulette-active (blue pulse, single
  step) and .kanban-roulette-winner (green pulse + scale, ~1.6s).
  Imported globally from main.tsx.

Manual verification only (visual timing + needs real cards). Backend
untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez 9f4fd85db3 feat(kanban): tiempo maximo por columna con borde rojo (issue 0089)
Adds column-level max time limit. Cards whose time_in_column_ms exceeds
the limit show a red border + red halo. Columns marked as Done never
trigger the visual regardless of the limit (per spec).

Backend:
- Migration 011_column_max_time.sql adds columns.max_time_minutes
  INTEGER NOT NULL DEFAULT 0 (0 = no limit). Aditiva, idempotente.
- Column struct + ColumnPatch + UpdateColumn handle the new field;
  negatives clamp to 0; listing query includes it.
- handleUpdateColumn (PATCH /api/columns/:id) accepts max_time_minutes
  in the JSON body.

Frontend:
- Column TS interface + UpdateColumnInput updated.
- KanbanColumn context menu: new entry "Tiempo maximo" using
  window.prompt for low-friction config; shows current value when >0.
- KanbanCard receives columnOverdue prop calculated from the column
  state and card.time_in_column_ms; renders red border (var
  --mantine-color-red-6) with 2px width + 2px red halo when overdue.
- data-card-id, data-column-overdue, data-locked attributes on the card
  paper element so e2e tests / scripts can query state.

Tests: TestColumnMaxTimeMinutes_Defaults + _Update verify the schema
default, the clamp on negative input, and that updating max_time leaves
other fields untouched.

Visual regression of the red border kept out of automated e2e because
it requires either clock control or real cards aged > N minutes; will be
verified manually after merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez eb1c13d82c feat(kanban): requester input empty + keyboard nav (issue 0088)
CardForm: drop pre-fill of requester from logged user; Enter inside the
Autocomplete no longer submits the form (Mantine handles dropdown
selection; arrows + Enter pick option without closing modal). Submit
remains via "Crear" button or Ctrl+Enter from description.

Adds data-field="requester" and data-test="add-card" selectors for stable
e2e queries.

Tests:
- vitest component test (CardForm.test.tsx): empty input, Enter does not
  submit, submit only via button. Dropdown arrow nav covered by e2e
  (jsdom portal handling is brittle).
- Playwright e2e (requester-input.spec.ts) using new browser capability
  group (pw_kanban_login, pw_keyboard_sequence) from registry.
- seed_e2e_user CLI to create deterministic test user against
  operations.db (bcrypt via standard backend hash).

Setup additions (frontend/):
- vitest + @testing-library + jsdom devDeps
- @playwright/test devDep + playwright.config.ts
- src/test/setup.ts polyfills jsdom for Mantine (matchMedia,
  visualViewport, document.fonts, ResizeObserver)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:57:00 +02:00
egutierrez a34a8142cc chore: auto-commit (23 archivos)
- app.md
- backend/auth.go
- backend/db.go
- backend/dist/assets/index-CPqSy0gZ.js
- backend/dist/index.html
- backend/handlers.go
- backend/main.go
- frontend/src/App.tsx
- frontend/src/api.ts
- frontend/src/components/KanbanCard.tsx
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:40:22 +02:00
57 changed files with 7913 additions and 1532 deletions
+5
View File
@@ -16,5 +16,10 @@ frontend/tsconfig.tsbuildinfo
# Local files
local_files/
# Card file attachments (issue 0128) — binarios en disco; metadata en card_files
uploads/
# Logs
*.log
frontend/test-results/
frontend/playwright-report/
+56 -2
View File
@@ -2,7 +2,8 @@
name: kanban
lang: go
domain: tools
description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas (dnd-kit) y tracking del tiempo que cada tarjeta pasa en cada columna. Frontend Vite + React + Mantine v9 embebido en el binario Go."
version: 0.2.0
description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas (dnd-kit), tracking del tiempo por columna y adjuntos de archivos por card (drag&drop en descripcion y chat). Frontend Vite + React + Mantine v9 embebido en el binario Go."
tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking]
uses_functions:
- random_hex_id_go_core
@@ -43,6 +44,17 @@ uses_types:
framework: "net/http + vite + react + mantine + dnd-kit"
entry_point: "backend/main.go"
dir_path: "apps/kanban"
service:
port: 8095
health_endpoint: /api/board
health_timeout_s: 3
systemd_unit: kanban.service
systemd_scope: user
restart_policy: always
runtime: systemd-user
pc_targets:
- aurgi-pc
is_local_only: false
# Validacion end-to-end (fase 4 del bucle reactivo). Ver issue 0068.
e2e_checks:
@@ -69,6 +81,10 @@ e2e_checks:
cmd: "go test -tags fts5 -count=1 ./..."
timeout_s: 120
expect_exit: 0
- id: smoke_files
cmd: "bash e2e/files_smoke.sh"
timeout_s: 30
expect_exit: 0
---
## Arquitectura
@@ -79,7 +95,7 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
./kanban --port 8095 --db kanban.db
```
### Schema SQLite (`migrations/001_init.sql`)
### Schema SQLite (`migrations/001_init.sql` … `010_card_messages.sql`)
- **columns** — id, name, position, created_at
- **cards** — id, title, description, column_id (FK), position, created_at, updated_at
@@ -87,6 +103,7 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
- Una entrada con `exited_at IS NULL` = posicion actual
- Al mover una tarjeta a otra columna: cierra la entrada activa (`exited_at = now`) e inserta una nueva
- El borrado de tarjeta hace CASCADE sobre el historial
- **card_messages** (migration 010) — id, card_id (FK CASCADE), author_id (nullable), body, created_at. Comentarios humano-a-humano por card; distintos de `card_events` (sistema) y `/api/chat` (LLM global).
### API REST
@@ -101,7 +118,21 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
| PATCH | `/api/cards/{id}` | `{title?, description?}` |
| DELETE | `/api/cards/{id}` | — |
| POST | `/api/cards/{id}/move` | `{column_id, ordered_ids: [...]}` |
| POST | `/api/cards/{id}/duplicate` | — (clona la card en la misma columna al final; copia titulo+" (copia)", descripcion, color, requester, assignee, tags, stickers, deadline; NO copia historial ni mensajes) |
| GET | `/api/cards/{id}/messages` | — (lista de comentarios humano-a-humano de la card) |
| POST | `/api/cards/{id}/messages` | `{body}` (crea comentario; author = usuario de la sesion) |
| DELETE | `/api/cards/{cid}/messages/{mid}` | — (solo el autor puede borrar su mensaje) |
| GET | `/api/cards/{id}/history` | — (timeline con duraciones por columna) |
| GET | `/api/flags` | — (retorna `{ <name>: bool }` con los feature flags efectivos en esta instancia) |
| POST | `/api/auth/register` | `{username, password, display_name?}` (devuelve 403 `registration_disabled` si el flag `registration-enabled` esta en `false`) |
### Feature flags
`dev/feature_flags.json` (lado del repo) define los flags por instancia. Se cargan al arrancar (override con `--flags <path>`); fichero ausente equivale a "todos los flags en `false`". El endpoint `GET /api/flags` expone el estado actual para que el frontend oculte UI condicional (ej. el toggle de "Registrate" en `LoginPage` solo aparece cuando `registration-enabled` es `true`).
| Flag | Default | Efecto cuando esta en `true` |
|---|---|---|
| `registration-enabled` | `false` | Permite crear cuentas nuevas via `POST /api/auth/register` y muestra el toggle "Registrate" en la pantalla de login. |
### Frontend
@@ -110,6 +141,18 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
- **Modales** con `@mantine/modals` (confirmacion borrado, history timeline).
- Time-in-column live: `time_in_column_ms` del backend + tick local cada segundo para que el badge se actualice sin reload.
- DnD con `closestCorners` + `DragOverlay` para feedback visual al arrastrar.
- **Auto-refresh:** el board se recarga cada 30s (`api.getBoard`) sin interaccion del usuario; equivalente a pulsar el boton de refresco. El tick de 1s del time-in-column es independiente y no toca red.
- **Modal de card en dos columnas** (`CardEditPanel`): izquierda mantiene `CardForm` (titulo, solicitante, descripcion, asignacion, tags); derecha es un `Tabs` con `Chat` (por defecto) | `Enlaces` | `Archivos` (proximamente). Tamaño del modal: 85% del viewport.
- **Chat per-card** (`CardChatPanel`): lista de comentarios humano-a-humano persistidos en `card_messages`. Enter envia, Shift+Enter salto de linea. Solo el autor puede borrar su propio mensaje.
- **Enlaces** (`CardLinksPanel`): extrae URLs (`https?://...`) de titulo, descripcion y cuerpo de cada mensaje del chat. Deduplica, muestra hostname + URL completa + badge de origen. Click abre en pestaña nueva (`target="_blank"`).
- **Duplicar card:** click derecho sobre la card abre el menu contextual (mismo que el boton `⋮`), donde aparece el item "Duplicar". Al pulsarlo invoca `POST /api/cards/{id}/duplicate`. La copia se inserta al final de la misma columna con titulo + " (copia)".
- **Sesion obligatoria para chat:** `POST/DELETE /api/cards/{id}/messages` exige sesion activa (401 si falta). `author_id` siempre poblado; no hay comentarios anonimos.
- **Archivos** (`CardFilesPanel`): adjuntos por card almacenados en `apps/kanban/uploads/<card_id>/<random>__<safe_filename>` (filesystem, gitignored). Tabla `card_files` con soft-delete. Limite 10 MB por archivo. Tres vias de upload:
1. Drag&drop en el editor de descripcion (`CardForm`) → inserta `![name](url)` (imagen) o `[name](url)` (resto) en la posicion del cursor.
2. Drag&drop o boton paperclip en el chat (`CardChatPanel`) → crea un mensaje cuyo cuerpo es la ref markdown.
3. Boton "Subir" en el tab Archivos → sube sin embed.
- El renderer de mensajes (`MessageBody`) reconoce `![alt](url)` -> `<Image>` thumb 220px y `[name](url)` -> `<Anchor>`. Texto plano se renderiza con `whiteSpace: pre-wrap`.
- Endpoints: `POST /api/cards/{id}/files` (multipart, 10 MB max), `GET /api/cards/{id}/files`, `GET /api/files/{id}` (sirve binario con `inline` o `attachment` segun MIME), `DELETE /api/files/{id}` (soft delete).
### Build
@@ -138,3 +181,14 @@ cd frontend && pnpm dev
- IDs de columnas y tarjetas: 16 chars hex (8 bytes random) via `random_hex_id_go_core`.
- El historial conserva la cronologia exacta — incluso despues de cerrar y reabrir el server, los tiempos vivos siguen contando desde `entered_at`.
- El borrado de columna hace CASCADE: las tarjetas se borran y su historial tambien. Si se quiere preservar el historial al borrar, deberia archivarse en lugar de borrar.
## Capability growth log
Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`:
- `major`: breaking observable (CLI args, schema BBDD propia, formato wire).
- `minor`: feature aditiva (nuevo panel, endpoint, opcion).
- `patch`: bugfix sin cambio observable.
- v0.1.0 (2026-05-18) — baseline.
- v0.2.0 (2026-05-27) — adjuntos de archivos por card (issue 0128): tabla `card_files` con soft-delete, endpoints REST (`POST/GET/DELETE /api/cards/{id}/files`, `GET/DELETE /api/files/{id}`), tres vias de upload (drag&drop en descripcion y chat, boton en tab Archivos), render inline de imagenes via `MessageBody`. Limite 10 MB.
+5 -1
View File
@@ -30,8 +30,12 @@ func tokenFromRequest(r *http.Request) string {
}
// POST /api/auth/register {username, password, display_name?}
func handleRegister(db *DB) http.HandlerFunc {
func handleRegister(db *DB, flags *FeatureFlags) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !flags.Enabled("registration-enabled") {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusForbidden, Code: "registration_disabled", Message: "user registration is disabled on this instance"})
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
+71
View File
@@ -0,0 +1,71 @@
// seed_e2e_user creates or updates a deterministic test user for Playwright e2e.
// Usage: go run ./backend/cmd/seed_e2e_user --db apps/kanban/operations.db
//
// Idempotent: safe to run repeatedly. The user "e2e_user" / password "e2e_test_pw_2026"
// is intentional and used by apps/kanban/frontend/e2e/*.spec.ts when env vars are not set.
package main
import (
"database/sql"
"errors"
"flag"
"fmt"
"os"
"time"
_ "github.com/mattn/go-sqlite3"
"golang.org/x/crypto/bcrypt"
)
func main() {
dbPath := flag.String("db", "operations.db", "path to kanban operations.db")
username := flag.String("username", "e2e_user", "username")
password := flag.String("password", "e2e_test_pw_2026", "password")
displayName := flag.String("display", "E2E Test", "display name")
flag.Parse()
db, err := sql.Open("sqlite3", *dbPath)
if err != nil {
fail(err)
}
defer db.Close()
hash, err := bcrypt.GenerateFromPassword([]byte(*password), bcrypt.DefaultCost)
if err != nil {
fail(err)
}
now := time.Now().UTC().Format(time.RFC3339Nano)
id := "e2etest" + fmt.Sprintf("%x", time.Now().UnixNano())[:9]
// Try update first
res, err := db.Exec(
`UPDATE users SET password_hash=?, display_name=? WHERE username=?`,
string(hash), *displayName, *username,
)
if err != nil {
fail(err)
}
n, _ := res.RowsAffected()
if n > 0 {
fmt.Printf("updated existing user %q\n", *username)
return
}
_, err = db.Exec(
`INSERT INTO users (id, username, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)`,
id, *username, string(hash), *displayName, now,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
fail(err)
}
fail(err)
}
fmt.Printf("created user %q (id=%s)\n", *username, id)
}
func fail(err error) {
fmt.Fprintln(os.Stderr, "seed_e2e_user:", err)
os.Exit(1)
}
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// Issue 0089: tiempo maximo por columna.
func openTestDB(t *testing.T) *DB {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test.db")
db, err := openDB(path)
if err != nil {
t.Fatalf("openDB: %v", err)
}
t.Cleanup(func() {
db.Close()
_ = os.Remove(path)
})
return db
}
func TestColumnMaxTimeMinutes_Defaults(t *testing.T) {
db := openTestDB(t)
c, err := db.CreateColumn("col1")
if err != nil {
t.Fatalf("CreateColumn: %v", err)
}
if c.MaxTimeMinutes != 0 {
t.Fatalf("new column max_time_minutes = %d, want 0", c.MaxTimeMinutes)
}
cols, err := db.ListColumns()
if err != nil {
t.Fatalf("ListColumns: %v", err)
}
if len(cols) == 0 || cols[0].MaxTimeMinutes != 0 {
t.Fatalf("listed col max_time_minutes = %d, want 0", cols[0].MaxTimeMinutes)
}
}
func TestColumnMaxTimeMinutes_Update(t *testing.T) {
db := openTestDB(t)
c, _ := db.CreateColumn("c")
v := 30
if err := db.UpdateColumn(c.ID, ColumnPatch{MaxTimeMinutes: &v}); err != nil {
t.Fatalf("UpdateColumn set 30: %v", err)
}
cols, _ := db.ListColumns()
if cols[0].MaxTimeMinutes != 30 {
t.Fatalf("after set max=30 got %d", cols[0].MaxTimeMinutes)
}
// Negative clamps to 0.
neg := -5
if err := db.UpdateColumn(c.ID, ColumnPatch{MaxTimeMinutes: &neg}); err != nil {
t.Fatalf("UpdateColumn neg: %v", err)
}
cols, _ = db.ListColumns()
if cols[0].MaxTimeMinutes != 0 {
t.Fatalf("negative should clamp to 0, got %d", cols[0].MaxTimeMinutes)
}
// Other fields untouched.
w := 555
if err := db.UpdateColumn(c.ID, ColumnPatch{Width: &w}); err != nil {
t.Fatalf("UpdateColumn width: %v", err)
}
cols, _ = db.ListColumns()
if cols[0].MaxTimeMinutes != 0 {
t.Fatalf("max_time should still be 0 after width update, got %d", cols[0].MaxTimeMinutes)
}
if cols[0].Width != 555 {
t.Fatalf("width = %d, want 555", cols[0].Width)
}
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
// DailySummary persisted row.
type DailySummary struct {
Date string `json:"date"`
Summary string `json:"summary"`
Prompt string `json:"prompt"`
Model string `json:"model"`
GeneratedAt string `json:"generated_at"`
GeneratedBy *string `json:"generated_by"`
}
func (db *DB) GetDailySummary(date string) (*DailySummary, error) {
row := db.conn.QueryRow(`SELECT date, summary, prompt, model, generated_at, generated_by FROM daily_summaries WHERE date=?`, date)
var s DailySummary
var by sql.NullString
if err := row.Scan(&s.Date, &s.Summary, &s.Prompt, &s.Model, &s.GeneratedAt, &by); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
if by.Valid {
v := by.String
s.GeneratedBy = &v
}
return &s, nil
}
func (db *DB) UpsertDailySummary(s DailySummary) error {
var by any = nil
if s.GeneratedBy != nil {
by = *s.GeneratedBy
}
_, err := db.conn.Exec(`
INSERT INTO daily_summaries (date, summary, prompt, model, generated_at, generated_by)
VALUES (?,?,?,?,?,?)
ON CONFLICT(date) DO UPDATE SET
summary=excluded.summary,
prompt=excluded.prompt,
model=excluded.model,
generated_at=excluded.generated_at,
generated_by=excluded.generated_by
`, s.Date, s.Summary, s.Prompt, s.Model, s.GeneratedAt, by)
return err
}
func (db *DB) GetSetting(key string) (string, error) {
var v string
err := db.conn.QueryRow(`SELECT value FROM settings WHERE key=?`, key).Scan(&v)
if err == sql.ErrNoRows {
return "", nil
}
return v, err
}
func (db *DB) SetSetting(key, value string, by *string) error {
now := nowRFC3339()
var byArg any = nil
if by != nil {
byArg = *by
}
_, err := db.conn.Exec(`
INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?,?,?,?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at, updated_by=excluded.updated_by
`, key, value, now, byArg)
return err
}
// runClaudePrompt executes `claude -p` with the given user prompt; returns
// stdout trimmed. Times out via claudeTimeout from chat.go.
func runClaudePrompt(ctx context.Context, prompt string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, claudeTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, claudeBinary(), "-p", "--model", claudeModel())
cmd.Stdin = strings.NewReader(prompt)
var out, errb bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("claude -p failed: %v: %s", err, strings.TrimSpace(errb.String()))
}
return strings.TrimSpace(out.String()), nil
}
// BuildDailySummaryPrompt composes the prompt for the LLM by interpolating the
// configurable instruction template with the JSON of the report.
func BuildDailySummaryPrompt(template string, report *DailyReport) (string, error) {
js, err := json.MarshalIndent(report, "", " ")
if err != nil {
return "", err
}
return fmt.Sprintf("%s\n\n<reporte_json>\n%s\n</reporte_json>\n", template, string(js)), nil
}
// GenerateDailySummary builds the prompt, calls Claude, persists and returns
// the resulting summary. actorID is optional (empty = anon/system).
func (db *DB) GenerateDailySummary(ctx context.Context, date, tz, actorID string) (*DailySummary, error) {
rep, err := db.DailyReportFor(date, tz)
if err != nil {
return nil, err
}
tmpl, err := db.GetSetting("daily_report_prompt")
if err != nil {
return nil, err
}
if tmpl == "" {
tmpl = "Resume el reporte diario en 4 frases cortas, en castellano, sin inventar datos."
}
prompt, err := BuildDailySummaryPrompt(tmpl, rep)
if err != nil {
return nil, err
}
summary, err := runClaudePrompt(ctx, prompt)
if err != nil {
return nil, err
}
rec := DailySummary{
Date: date,
Summary: summary,
Prompt: tmpl,
Model: claudeModel(),
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
}
if actorID != "" {
rec.GeneratedBy = &actorID
}
if err := db.UpsertDailySummary(rec); err != nil {
return nil, err
}
return &rec, nil
}
+288 -19
View File
@@ -17,14 +17,15 @@ import (
var migrationsFS embed.FS
type Column struct {
ID string `json:"id"`
Name string `json:"name"`
Position int `json:"position"`
Location string `json:"location"`
Width int `json:"width"`
WIPLimit int `json:"wip_limit"`
IsDone bool `json:"is_done"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
Name string `json:"name"`
Position int `json:"position"`
Location string `json:"location"`
Width int `json:"width"`
WIPLimit int `json:"wip_limit"`
IsDone bool `json:"is_done"`
MaxTimeMinutes int `json:"max_time_minutes"`
CreatedAt string `json:"created_at"`
}
type Sticker struct {
@@ -46,6 +47,7 @@ type Card struct {
AssigneeID *string `json:"assignee_id"`
CompletedAt *string `json:"completed_at"`
DeletedAt *string `json:"deleted_at"`
ArchivedAt *string `json:"archived_at"`
Tags []string `json:"tags"`
Stickers []Sticker `json:"stickers"`
Deadline *string `json:"deadline"`
@@ -305,7 +307,7 @@ func insertCardEvent(execer interface {
// --- Columns ---
func (db *DB) ListColumns() ([]Column, error) {
rows, err := db.conn.Query(`SELECT id, name, position, location, width, wip_limit, is_done, created_at FROM columns ORDER BY position, created_at`)
rows, err := db.conn.Query(`SELECT id, name, position, location, width, wip_limit, is_done, max_time_minutes, created_at FROM columns ORDER BY position, created_at`)
if err != nil {
return nil, err
}
@@ -314,7 +316,7 @@ func (db *DB) ListColumns() ([]Column, error) {
for rows.Next() {
var c Column
var isDone int
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.WIPLimit, &isDone, &c.CreatedAt); err != nil {
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.WIPLimit, &isDone, &c.MaxTimeMinutes, &c.CreatedAt); err != nil {
return nil, err
}
c.IsDone = isDone != 0
@@ -344,12 +346,13 @@ func (db *DB) CreateColumn(name string) (*Column, error) {
}
type ColumnPatch struct {
Name *string
Position *int
Location *string
Width *int
WIPLimit *int
IsDone *bool
Name *string
Position *int
Location *string
Width *int
WIPLimit *int
IsDone *bool
MaxTimeMinutes *int
}
func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
@@ -411,6 +414,15 @@ func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
}
}
}
if patch.MaxTimeMinutes != nil {
m := *patch.MaxTimeMinutes
if m < 0 {
m = 0
}
if _, err := db.conn.Exec(`UPDATE columns SET max_time_minutes=? WHERE id=?`, m, id); err != nil {
return err
}
}
return nil
}
@@ -437,7 +449,7 @@ func (db *DB) ReorderColumns(ids []string) error {
func (db *DB) ListCardsWithTime() ([]Card, error) {
rows, err := db.conn.Query(`
SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at,
SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.archived_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at,
h.entered_at, l.locked_at,
COALESCE((
SELECT CAST(SUM((julianday(COALESCE(unlocked_at, ?)) - julianday(locked_at)) * 86400000) AS INTEGER)
@@ -448,7 +460,7 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
ON h.card_id = c.id AND h.exited_at IS NULL
LEFT JOIN card_lock_history l
ON l.card_id = c.id AND l.unlocked_at IS NULL
WHERE c.deleted_at IS NULL
WHERE c.deleted_at IS NULL AND c.archived_at IS NULL
ORDER BY c.column_id, c.position, c.created_at
`, time.Now().UTC().Format(time.RFC3339Nano))
if err != nil {
@@ -463,12 +475,13 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
var assignee sql.NullString
var completed sql.NullString
var deleted sql.NullString
var archived sql.NullString
var tagsJSON string
var stickersJSON string
var deadline sql.NullString
var lockedAt sql.NullString
var locked int
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt, &c.TotalLockedMs); err != nil {
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &archived, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt, &c.TotalLockedMs); err != nil {
return nil, err
}
c.Stickers = parseStickers(stickersJSON)
@@ -493,6 +506,10 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
s := deleted.String
c.DeletedAt = &s
}
if archived.Valid && archived.String != "" {
s := archived.String
c.ArchivedAt = &s
}
c.Tags = parseTags(tagsJSON)
if entered.Valid {
c.EnteredAt = entered.String
@@ -816,6 +833,90 @@ func (db *DB) ListDeletedCards() ([]Card, error) {
return out, rows.Err()
}
// ArchiveCard moves a card to the archive (out of the board, retrievable).
// Used both manually and by AutoArchiveDoneOlderThan.
func (db *DB) ArchiveCard(id string) error {
now := nowRFC3339()
_, err := db.conn.Exec(`UPDATE cards SET archived_at=?, updated_at=? WHERE id=? AND archived_at IS NULL AND deleted_at IS NULL`, now, now, id)
return err
}
// UnarchiveCard pulls a card out of the archive back into its column.
func (db *DB) UnarchiveCard(id string) error {
now := nowRFC3339()
_, err := db.conn.Exec(`UPDATE cards SET archived_at=NULL, updated_at=? WHERE id=? AND archived_at IS NOT NULL`, now, id)
return err
}
// AutoArchiveDoneOlderThan archives every card whose column is is_done=1 AND
// whose entered_at in that column is older than `older`. Idempotent: cards
// already archived or deleted are skipped. Returns the count affected.
func (db *DB) AutoArchiveDoneOlderThan(older time.Duration) (int64, error) {
cutoff := time.Now().UTC().Add(-older).Format(time.RFC3339Nano)
now := nowRFC3339()
res, err := db.conn.Exec(`
UPDATE cards SET archived_at=?, updated_at=?
WHERE archived_at IS NULL
AND deleted_at IS NULL
AND column_id IN (SELECT id FROM columns WHERE is_done=1)
AND id IN (
SELECT card_id FROM card_column_history
WHERE exited_at IS NULL AND entered_at < ?
)
`, now, now, cutoff)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, nil
}
// ListArchivedCards returns cards in the archive, newest first.
func (db *DB) ListArchivedCards() ([]Card, error) {
rows, err := db.conn.Query(`
SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.archived_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at
FROM cards c
WHERE c.archived_at IS NOT NULL AND c.deleted_at IS NULL
ORDER BY c.archived_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Card{}
for rows.Next() {
var c Card
var assignee, completed, deleted, archived sql.NullString
var tagsJSON, stickersJSON string
var deadline sql.NullString
var locked int
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &archived, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
c.Stickers = parseStickers(stickersJSON)
if deadline.Valid && deadline.String != "" {
s := deadline.String
c.Deadline = &s
}
c.Locked = locked != 0
if assignee.Valid && assignee.String != "" {
s := assignee.String
c.AssigneeID = &s
}
if completed.Valid && completed.String != "" {
s := completed.String
c.CompletedAt = &s
}
if archived.Valid && archived.String != "" {
s := archived.String
c.ArchivedAt = &s
}
c.Tags = parseTags(tagsJSON)
out = append(out, c)
}
return out, rows.Err()
}
// MoveCard updates the card's column and/or position. If the column changes,
// the open history entry is closed and a new one is opened.
// orderedIDs is the new order of cards in the destination column (including this card).
@@ -1031,3 +1132,171 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
CurrentlyLock: currently,
}, nil
}
type CardMessage struct {
ID string `json:"id"`
CardID string `json:"card_id"`
AuthorID *string `json:"author_id"`
Body string `json:"body"`
CreatedAt string `json:"created_at"`
}
func (db *DB) ListCardMessages(cardID string) ([]CardMessage, error) {
rows, err := db.conn.Query(
`SELECT id, card_id, author_id, body, created_at FROM card_messages WHERE card_id=? ORDER BY created_at`,
cardID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CardMessage{}
for rows.Next() {
var m CardMessage
var author sql.NullString
if err := rows.Scan(&m.ID, &m.CardID, &author, &m.Body, &m.CreatedAt); err != nil {
return nil, err
}
if author.Valid && author.String != "" {
s := author.String
m.AuthorID = &s
}
out = append(out, m)
}
return out, rows.Err()
}
func (db *DB) CreateCardMessage(cardID, authorID, body string) (*CardMessage, error) {
body = strings.TrimSpace(body)
if body == "" {
return nil, fmt.Errorf("body required")
}
if authorID == "" {
return nil, fmt.Errorf("author required")
}
var exists int
if err := db.conn.QueryRow(`SELECT 1 FROM cards WHERE id=?`, cardID).Scan(&exists); err != nil {
return nil, fmt.Errorf("card not found: %w", err)
}
s := authorID
m := &CardMessage{ID: newID(), CardID: cardID, AuthorID: &s, Body: body, CreatedAt: nowRFC3339()}
if _, err := db.conn.Exec(
`INSERT INTO card_messages (id, card_id, author_id, body, created_at) VALUES (?, ?, ?, ?, ?)`,
m.ID, m.CardID, authorID, m.Body, m.CreatedAt,
); err != nil {
return nil, err
}
return m, nil
}
func (db *DB) DeleteCardMessage(id, requesterID string) error {
if requesterID == "" {
return fmt.Errorf("session required")
}
res, err := db.conn.Exec(`DELETE FROM card_messages WHERE id=? AND author_id=?`, id, requesterID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("not found or not author")
}
return nil
}
// DuplicateCard clones a card into the same column at the end of the list.
// Copies title, description, color, requester, assignee, tags, deadline, stickers.
// Does NOT copy card_column_history, card_lock_history, card_events, card_messages.
// Title gets " (copia)" suffix.
func (db *DB) DuplicateCard(srcID, actorID string) (*Card, error) {
tx, err := db.conn.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
var src Card
var assignee sql.NullString
var deadline sql.NullString
var tagsJSON, stickersJSON string
if err := tx.QueryRow(
`SELECT requester, title, description, color, column_id, assignee_id, tags, stickers, deadline
FROM cards WHERE id=? AND deleted_at IS NULL`, srcID,
).Scan(&src.Requester, &src.Title, &src.Description, &src.Color, &src.ColumnID, &assignee, &tagsJSON, &stickersJSON, &deadline); err != nil {
return nil, fmt.Errorf("card not found: %w", err)
}
if assignee.Valid && assignee.String != "" {
s := assignee.String
src.AssigneeID = &s
}
if deadline.Valid && deadline.String != "" {
s := deadline.String
src.Deadline = &s
}
src.Tags = parseTags(tagsJSON)
src.Stickers = parseStickers(stickersJSON)
var maxPos sql.NullInt64
if err := tx.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, src.ColumnID).Scan(&maxPos); err != nil {
return nil, err
}
pos := 0
if maxPos.Valid {
pos = int(maxPos.Int64) + 1
}
var maxSeq sql.NullInt64
if err := tx.QueryRow(`SELECT MAX(seq_num) FROM cards`).Scan(&maxSeq); err != nil {
return nil, err
}
seqNum := 1
if maxSeq.Valid {
seqNum = int(maxSeq.Int64) + 1
}
now := nowRFC3339()
newTitle := src.Title + " (copia)"
c := Card{
ID: newID(), SeqNum: seqNum, Requester: src.Requester, Title: newTitle,
Description: src.Description, Color: src.Color, ColumnID: src.ColumnID, Position: pos,
AssigneeID: src.AssigneeID, Tags: src.Tags, Stickers: src.Stickers, Deadline: src.Deadline,
CreatedAt: now, UpdatedAt: now, EnteredAt: now,
}
var assigneeVal any
if c.AssigneeID != nil && *c.AssigneeID != "" {
assigneeVal = *c.AssigneeID
}
var deadlineVal any
if c.Deadline != nil && *c.Deadline != "" {
deadlineVal = *c.Deadline
}
if _, err := tx.Exec(
`INSERT INTO cards (id, seq_num, requester, title, description, color, column_id, position, assignee_id, tags, stickers, deadline, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.SeqNum, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position,
assigneeVal, encodeTags(c.Tags), encodeStickers(c.Stickers), deadlineVal, c.CreatedAt, c.UpdatedAt,
); err != nil {
return nil, err
}
if _, err := tx.Exec(
`INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
newID(), c.ID, c.ColumnID, now, nullableActor(actorID),
); err != nil {
return nil, err
}
var destDone int
if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, c.ColumnID).Scan(&destDone); err != nil {
return nil, err
}
if destDone == 1 {
if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=?`, now, c.ID); err != nil {
return nil, err
}
c.CompletedAt = &now
}
if err := insertCardEvent(tx, c.ID, "created", actorID, map[string]any{"title": newTitle, "column_id": c.ColumnID, "duplicated_from": srcID}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &c, nil
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanban</title>
<script type="module" crossorigin src="/assets/index-CPqSy0gZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
<script type="module" crossorigin src="/assets/index-DT3pghXY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
</head>
<body>
<div id="root"></div>
+346
View File
@@ -0,0 +1,346 @@
package main
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"fn-registry/functions/infra"
)
// Issue 0128: adjuntos de archivos por card.
const (
maxUploadBytes = 10 << 20 // 10 MiB
uploadsSubdir = "uploads"
)
type CardFile struct {
ID string `json:"id"`
CardID string `json:"card_id"`
UploaderID string `json:"uploader_id"`
Filename string `json:"filename"`
MIME string `json:"mime"`
Size int64 `json:"size"`
Source string `json:"source"`
URL string `json:"url"`
CreatedAt string `json:"created_at"`
}
func (db *DB) CreateCardFile(cardID, uploaderID, filename, mimeType, storedPath, source string, size int64) (*CardFile, error) {
id := newID()
now := nowRFC3339()
if source == "" {
source = "upload"
}
_, err := db.conn.Exec(
`INSERT INTO card_files
(id, card_id, uploader_id, filename, mime, size, stored_path, source, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, cardID, uploaderID, filename, mimeType, size, storedPath, source, now,
)
if err != nil {
return nil, err
}
return &CardFile{
ID: id,
CardID: cardID,
UploaderID: uploaderID,
Filename: filename,
MIME: mimeType,
Size: size,
Source: source,
URL: "/api/files/" + id,
CreatedAt: now,
}, nil
}
func (db *DB) ListCardFiles(cardID string) ([]CardFile, error) {
rows, err := db.conn.Query(
`SELECT id, card_id, uploader_id, filename, mime, size, source, created_at
FROM card_files
WHERE card_id = ? AND deleted_at IS NULL
ORDER BY created_at ASC`,
cardID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CardFile{}
for rows.Next() {
var f CardFile
if err := rows.Scan(&f.ID, &f.CardID, &f.UploaderID, &f.Filename, &f.MIME, &f.Size, &f.Source, &f.CreatedAt); err != nil {
return nil, err
}
f.URL = "/api/files/" + f.ID
out = append(out, f)
}
return out, rows.Err()
}
type storedCardFile struct {
ID string
CardID string
Filename string
MIME string
Size int64
StoredPath string
}
func (db *DB) GetCardFile(id string) (*storedCardFile, error) {
var f storedCardFile
err := db.conn.QueryRow(
`SELECT id, card_id, filename, mime, size, stored_path
FROM card_files
WHERE id = ? AND deleted_at IS NULL`,
id,
).Scan(&f.ID, &f.CardID, &f.Filename, &f.MIME, &f.Size, &f.StoredPath)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &f, nil
}
func (db *DB) SoftDeleteCardFile(id string) (int64, error) {
res, err := db.conn.Exec(
`UPDATE card_files SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`,
nowRFC3339(), id,
)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func uploadsDir(workdir string) string {
return filepath.Join(workdir, uploadsSubdir)
}
func safeFilename(name string) string {
name = filepath.Base(name)
name = strings.ReplaceAll(name, string(os.PathSeparator), "_")
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
name = strings.TrimSpace(name)
if name == "" || name == "." || name == ".." {
return "file"
}
return name
}
func randomFilePrefix() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
// POST /api/cards/{id}/files (multipart, field "file", optional "source")
func handleUploadCardFile(db *DB, workdir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cardID := r.PathValue("id")
if cardID == "" {
badRequest(w, "card id required")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+1<<20)
if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
badRequest(w, "multipart parse: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
badRequest(w, "missing 'file' field: "+err.Error())
return
}
defer file.Close()
if header.Size > maxUploadBytes {
infra.HTTPErrorResponse(w, infra.HTTPError{
Status: http.StatusRequestEntityTooLarge,
Code: "file_too_large",
Message: fmt.Sprintf("file exceeds %d bytes", maxUploadBytes),
})
return
}
source := r.FormValue("source")
switch source {
case "", "upload":
source = "upload"
case "description", "chat":
// keep
default:
source = "upload"
}
dir := filepath.Join(uploadsDir(workdir), cardID)
if err := os.MkdirAll(dir, 0o755); err != nil {
serverError(w, fmt.Errorf("mkdir uploads: %w", err))
return
}
fname := safeFilename(header.Filename)
storedPath := filepath.Join(dir, randomFilePrefix()+"__"+fname)
out, err := os.Create(storedPath)
if err != nil {
serverError(w, fmt.Errorf("create file: %w", err))
return
}
written, copyErr := io.Copy(out, file)
closeErr := out.Close()
if copyErr != nil {
os.Remove(storedPath)
serverError(w, fmt.Errorf("write file: %w", copyErr))
return
}
if closeErr != nil {
os.Remove(storedPath)
serverError(w, fmt.Errorf("close file: %w", closeErr))
return
}
if written > maxUploadBytes {
os.Remove(storedPath)
infra.HTTPErrorResponse(w, infra.HTTPError{
Status: http.StatusRequestEntityTooLarge,
Code: "file_too_large",
Message: fmt.Sprintf("file exceeds %d bytes", maxUploadBytes),
})
return
}
mimeType := header.Header.Get("Content-Type")
if mimeType == "" {
mimeType = mime.TypeByExtension(filepath.Ext(fname))
}
if mimeType == "" {
mimeType = "application/octet-stream"
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
cf, err := db.CreateCardFile(cardID, actor, fname, mimeType, storedPath, source, written)
if err != nil {
os.Remove(storedPath)
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, cf)
}
}
// GET /api/cards/{id}/files
func handleListCardFiles(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cardID := r.PathValue("id")
if cardID == "" {
badRequest(w, "card id required")
return
}
files, err := db.ListCardFiles(cardID)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, files)
}
}
// GET /api/files/{id}
func handleServeFile(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
badRequest(w, "file id required")
return
}
f, err := db.GetCardFile(id)
if err != nil {
serverError(w, err)
return
}
if f == nil {
notFound(w, "file not found")
return
}
fh, err := os.Open(f.StoredPath)
if err != nil {
notFound(w, "file missing on disk")
return
}
defer fh.Close()
if f.MIME != "" {
w.Header().Set("Content-Type", f.MIME)
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
disposition := "inline"
if !isInlineMIME(f.MIME) {
disposition = "attachment"
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, sanitizeHeaderFilename(f.Filename)))
w.Header().Set("Cache-Control", "private, max-age=3600")
_, _ = io.Copy(w, fh)
}
}
// DELETE /api/files/{id}
func handleDeleteCardFile(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
badRequest(w, "file id required")
return
}
n, err := db.SoftDeleteCardFile(id)
if err != nil {
serverError(w, err)
return
}
if n == 0 {
notFound(w, "file not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func isInlineMIME(m string) bool {
if m == "" {
return false
}
m = strings.ToLower(m)
switch {
case strings.HasPrefix(m, "image/"):
return true
case m == "application/pdf":
return true
case strings.HasPrefix(m, "text/"):
return true
}
return false
}
func sanitizeHeaderFilename(name string) string {
name = strings.ReplaceAll(name, `"`, "")
name = strings.ReplaceAll(name, "\n", "")
name = strings.ReplaceAll(name, "\r", "")
return name
}
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"encoding/json"
"net/http"
"os"
"fn-registry/functions/infra"
)
type FeatureFlag struct {
Enabled bool `json:"enabled"`
Issue string `json:"issue,omitempty"`
Description string `json:"description"`
Added string `json:"added,omitempty"`
EnabledAt string `json:"enabled_at,omitempty"`
}
type FeatureFlags struct {
Flags map[string]FeatureFlag `json:"flags"`
}
func (f FeatureFlags) Enabled(name string) bool {
flag, ok := f.Flags[name]
return ok && flag.Enabled
}
func loadFeatureFlags(path string) (FeatureFlags, error) {
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return FeatureFlags{Flags: map[string]FeatureFlag{}}, nil
}
return FeatureFlags{}, err
}
var f FeatureFlags
if err := json.Unmarshal(b, &f); err != nil {
return FeatureFlags{}, err
}
if f.Flags == nil {
f.Flags = map[string]FeatureFlag{}
}
return f, nil
}
// GET /api/flags → { "<name>": true/false, ... }
func handleListFlags(flags *FeatureFlags) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
out := make(map[string]bool, len(flags.Flags))
for name, fl := range flags.Flags {
out[name] = fl.Enabled
}
infra.HTTPJSONResponse(w, http.StatusOK, out)
}
}
+285 -9
View File
@@ -1,14 +1,46 @@
package main
import (
"log"
"net/http"
"strings"
"sync/atomic"
"time"
"fn-registry/functions/infra"
)
const maxBodyBytes = 1 << 20 // 1 MiB
// Auto-archive: cards en columnas Done con >30 dias se mueven al cajon.
// Issue 0092. Lo dispara handleGetBoard de forma "lazy" pero solo cada
// archiveSweepEvery minutos para no martillear el UPDATE.
const (
archiveAfter = 30 * 24 * time.Hour
archiveSweepEvery = 30 * time.Minute
)
var lastArchiveSweepNs atomic.Int64
func maybeAutoArchive(db *DB) {
now := time.Now().UnixNano()
last := lastArchiveSweepNs.Load()
if last != 0 && time.Duration(now-last) < archiveSweepEvery {
return
}
if !lastArchiveSweepNs.CompareAndSwap(last, now) {
return
}
n, err := db.AutoArchiveDoneOlderThan(archiveAfter)
if err != nil {
log.Printf("auto-archive failed: %v", err)
return
}
if n > 0 {
log.Printf("auto-archive moved %d done card(s) older than %s", n, archiveAfter)
}
}
func badRequest(w http.ResponseWriter, msg string) {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: msg})
}
@@ -24,6 +56,7 @@ func serverError(w http.ResponseWriter, err error) {
// GET /api/board → { columns: [...], cards: [...] }
func handleGetBoard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
maybeAutoArchive(db)
cols, err := db.ListColumns()
if err != nil {
serverError(w, err)
@@ -67,18 +100,19 @@ func handleUpdateColumn(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Name *string `json:"name"`
Position *int `json:"position"`
Location *string `json:"location"`
Width *int `json:"width"`
WIPLimit *int `json:"wip_limit"`
IsDone *bool `json:"is_done"`
Name *string `json:"name"`
Position *int `json:"position"`
Location *string `json:"location"`
Width *int `json:"width"`
WIPLimit *int `json:"wip_limit"`
IsDone *bool `json:"is_done"`
MaxTimeMinutes *int `json:"max_time_minutes"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width, WIPLimit: body.WIPLimit, IsDone: body.IsDone}); err != nil {
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width, WIPLimit: body.WIPLimit, IsDone: body.IsDone, MaxTimeMinutes: body.MaxTimeMinutes}); err != nil {
serverError(w, err)
return
}
@@ -280,6 +314,91 @@ func handleMoveCard(db *DB) http.HandlerFunc {
}
}
// GET /api/cards/{id}/messages → [CardMessage, ...]
func handleListCardMessages(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
msgs, err := db.ListCardMessages(id)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, msgs)
}
}
// POST /api/cards/{id}/messages { body }
func handleCreateCardMessage(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Body string `json:"body"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if strings.TrimSpace(body.Body) == "" {
badRequest(w, "body required")
return
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if actor == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
m, err := db.CreateCardMessage(id, actor, body.Body)
if err != nil {
if strings.Contains(err.Error(), "not found") {
notFound(w, err.Error())
return
}
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, m)
}
}
// DELETE /api/cards/{cid}/messages/{mid}
func handleDeleteCardMessage(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
mid := r.PathValue("mid")
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if actor == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
if err := db.DeleteCardMessage(mid, actor); err != nil {
if strings.Contains(err.Error(), "not found") {
notFound(w, err.Error())
return
}
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// POST /api/cards/{id}/duplicate
func handleDuplicateCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
c, err := db.DuplicateCard(id, actor)
if err != nil {
if strings.Contains(err.Error(), "not found") {
notFound(w, "card not found")
return
}
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, c)
}
}
// GET /api/cards/{id}/history → [HistoryEntry, ...]
func handleCardHistory(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -318,6 +437,145 @@ func handleRestoreCard(db *DB) http.HandlerFunc {
}
}
// GET /api/reports/daily?date=YYYY-MM-DD&tz=Europe/Madrid
func handleDailyReport(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().UTC().Format("2006-01-02")
}
tz := r.URL.Query().Get("tz")
if tz == "" {
tz = "Europe/Madrid"
}
rep, err := db.DailyReportFor(date, tz)
if err != nil {
badRequest(w, err.Error())
return
}
infra.HTTPJSONResponse(w, http.StatusOK, rep)
}
}
// GET /api/reports/daily/summary?date=YYYY-MM-DD
func handleGetDailySummary(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().UTC().Format("2006-01-02")
}
s, err := db.GetDailySummary(date)
if err != nil {
serverError(w, err)
return
}
if s == nil {
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{"date": date, "summary": "", "exists": false})
return
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{
"date": s.Date, "summary": s.Summary, "prompt": s.Prompt,
"model": s.Model, "generated_at": s.GeneratedAt, "generated_by": s.GeneratedBy,
"exists": true,
})
}
}
// POST /api/reports/daily/summary?date=YYYY-MM-DD&tz=Europe/Madrid
// Regenera el resumen del dia y lo persiste.
func handleGenerateDailySummary(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().UTC().Format("2006-01-02")
}
tz := r.URL.Query().Get("tz")
if tz == "" {
tz = "Europe/Madrid"
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
rec, err := db.GenerateDailySummary(r.Context(), date, tz, actor)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, rec)
}
}
// GET /api/settings/{key}
func handleGetSetting(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
v, err := db.GetSetting(key)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{"key": key, "value": v})
}
}
// PUT /api/settings/{key} body: {"value": "..."}
func handlePutSetting(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
var body struct {
Value string `json:"value"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
var actorPtr *string
if actor != "" {
actorPtr = &actor
}
if err := db.SetSetting(key, body.Value, actorPtr); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// GET /api/archive
func handleListArchive(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cards, err := db.ListArchivedCards()
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, cards)
}
}
// POST /api/cards/{id}/archive
func handleArchiveCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.ArchiveCard(id); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// POST /api/cards/{id}/unarchive
func handleUnarchiveCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.UnarchiveCard(id); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DELETE /api/cards/{id}/purge
func handlePurgeCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -330,9 +588,10 @@ func handlePurgeCard(db *DB) http.HandlerFunc {
}
}
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string) []infra.Route {
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string, flags *FeatureFlags) []infra.Route {
return []infra.Route{
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db)},
{Method: "GET", Path: "/api/flags", Handler: handleListFlags(flags)},
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db, flags)},
{Method: "POST", Path: "/api/auth/login", Handler: handleLogin(db)},
{Method: "POST", Path: "/api/auth/logout", Handler: handleLogout(db)},
{Method: "GET", Path: "/api/me", Handler: handleMe(db)},
@@ -348,9 +607,21 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
{Method: "PUT", Path: "/api/cards/{id}/stickers", Handler: handleUpdateCardStickers(db)},
{Method: "DELETE", Path: "/api/cards/{id}", Handler: handleDeleteCard(db)},
{Method: "POST", Path: "/api/cards/{id}/move", Handler: handleMoveCard(db)},
{Method: "POST", Path: "/api/cards/{id}/duplicate", Handler: handleDuplicateCard(db)},
{Method: "GET", Path: "/api/cards/{id}/messages", Handler: handleListCardMessages(db)},
{Method: "POST", Path: "/api/cards/{id}/messages", Handler: handleCreateCardMessage(db)},
{Method: "DELETE", Path: "/api/cards/{id}/messages/{mid}", Handler: handleDeleteCardMessage(db)},
{Method: "GET", Path: "/api/cards/{id}/history", Handler: handleCardHistory(db)},
{Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)},
{Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)},
{Method: "GET", Path: "/api/reports/daily", Handler: handleDailyReport(db)},
{Method: "GET", Path: "/api/reports/daily/summary", Handler: handleGetDailySummary(db)},
{Method: "POST", Path: "/api/reports/daily/summary", Handler: handleGenerateDailySummary(db)},
{Method: "GET", Path: "/api/settings/{key}", Handler: handleGetSetting(db)},
{Method: "PUT", Path: "/api/settings/{key}", Handler: handlePutSetting(db)},
{Method: "GET", Path: "/api/archive", Handler: handleListArchive(db)},
{Method: "POST", Path: "/api/cards/{id}/archive", Handler: handleArchiveCard(db)},
{Method: "POST", Path: "/api/cards/{id}/unarchive", Handler: handleUnarchiveCard(db)},
{Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db)},
{Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)},
{Method: "GET", Path: "/api/chat/ws", Handler: handleChatWS(db, chatWorkdir, logger, internalToken)},
@@ -358,6 +629,11 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
{Method: "GET", Path: "/api/metrics", Handler: handleMetrics(db)},
{Method: "GET", Path: "/api/tags", Handler: handleListTags(db)},
{Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)},
// Issue 0128: adjuntos de archivos.
{Method: "POST", Path: "/api/cards/{id}/files", Handler: handleUploadCardFile(db, chatWorkdir)},
{Method: "GET", Path: "/api/cards/{id}/files", Handler: handleListCardFiles(db)},
{Method: "GET", Path: "/api/files/{id}", Handler: handleServeFile(db)},
{Method: "DELETE", Path: "/api/files/{id}", Handler: handleDeleteCardFile(db)},
}
}
+11 -2
View File
@@ -35,8 +35,17 @@ func main() {
port := flags.Int("port", 8095, "HTTP port")
dbPath := flags.String("db", "operations.db", "SQLite database path")
initialAdmin := flags.String("initial-admin", os.Getenv("KANBAN_INITIAL_ADMIN"), "Bootstrap admin in user:pass form (only if no users yet)")
flagsPath := flags.String("flags", "dev/feature_flags.json", "Feature flags JSON path (missing file → all disabled)")
flags.Parse(os.Args[1:])
featureFlags, err := loadFeatureFlags(*flagsPath)
if err != nil {
log.Fatalf("load feature flags: %v", err)
}
for name, fl := range featureFlags.Flags {
log.Printf("feature flag %q enabled=%v", name, fl.Enabled)
}
db, err := openDB(*dbPath)
if err != nil {
log.Fatalf("open db: %v", err)
@@ -54,7 +63,7 @@ func main() {
wd := chatWorkdir(*dbPath)
logger := newChatLogger(filepath.Join(wd, "chat.log"))
log.Printf("chat tool log: %s", logger.path)
mux := infra.HTTPRouter(apiRoutes(db, wd, logger, internalToken))
mux := infra.HTTPRouter(apiRoutes(db, wd, logger, internalToken, &featureFlags))
feHandler := frontendHandler()
if feHandler != nil {
@@ -67,7 +76,7 @@ func main() {
authMW := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{
DB: db.conn,
CookieName: cookieName,
SkipPaths: []string{"/api/auth/", "/api/tool/", "/health", "/assets/", "/index.html"},
SkipPaths: []string{"/api/auth/", "/api/tool/", "/api/flags", "/health", "/assets/", "/index.html"},
UserCtxKey: userCtxKey,
})
+14
View File
@@ -0,0 +1,14 @@
-- Per-card chat messages (human-to-human comments).
-- Distinct from card_events (which records system events like title_changed)
-- and from /api/chat (which is the board-level LLM chat).
CREATE TABLE IF NOT EXISTS card_messages (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL,
author_id TEXT,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_card_messages_card ON card_messages(card_id, created_at);
@@ -0,0 +1,4 @@
-- Issue 0089: tiempo maximo por columna.
-- NULL/0 = sin limite. >0 = minutos antes de marcar como vencida la card.
-- Cards en columnas con is_done=1 nunca se marcan como vencidas.
ALTER TABLE columns ADD COLUMN max_time_minutes INTEGER NOT NULL DEFAULT 0;
+5
View File
@@ -0,0 +1,5 @@
-- Issue 0092: archivo automatico para cards en columnas Done con +30 dias.
-- archived_at NULL = card activa. archived_at = timestamp ISO = card en cajon.
-- Independiente de deleted_at (papelera): una card puede estar archived sin
-- haber sido borrada. Restaurar = vuelve a su columna original sin deletear.
ALTER TABLE cards ADD COLUMN archived_at TEXT;
@@ -0,0 +1,23 @@
-- Issue 0094: resumen de IA por dia + tabla settings clave/valor.
CREATE TABLE IF NOT EXISTS daily_summaries (
date TEXT PRIMARY KEY,
summary TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
generated_at TEXT NOT NULL,
generated_by TEXT
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL,
updated_by TEXT
);
INSERT OR IGNORE INTO settings (key, value, updated_at)
VALUES (
'daily_report_prompt',
'Eres un coach de equipo. Resume el reporte diario en un MAXIMO de 4 frases cortas, mencionando: (1) total de tareas hechas y quien destaco, (2) cualquier card reabierta o deadline vencido que merezca atencion, (3) cards estancadas criticas (30+ dias) si las hay, (4) una frase corta de animo o aviso si toca. Tono natural, primera persona del plural, sin emojis. No inventes datos; usa solo los del JSON del reporte.',
datetime('now')
);
+16
View File
@@ -0,0 +1,16 @@
-- Issue 0128: adjuntos de archivos por card.
CREATE TABLE IF NOT EXISTS card_files (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL,
uploader_id TEXT NOT NULL DEFAULT '',
filename TEXT NOT NULL,
mime TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
stored_path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'upload',
created_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_card_files_card_active
ON card_files(card_id, deleted_at);
+588
View File
@@ -0,0 +1,588 @@
package main
import (
"database/sql"
"fmt"
"sort"
"time"
)
// DailyReport — agregaciones por dia natural (TZ del servidor a menos que el
// caller pase una TZ explicita). Issue 0093.
type DailyReport struct {
Date string `json:"date"`
TZ string `json:"tz"`
StartTs string `json:"start_ts"`
EndTs string `json:"end_ts"`
KPIs DailyKPIs `json:"kpis"`
TopAssigneesDone []UserCount `json:"top_assignees_done"`
TopAssigneesCreated []UserCount `json:"top_assignees_created"`
TopRequestersAdded []NamedCount `json:"top_requesters_added"`
TopRequestersDone []NamedCount `json:"top_requesters_done"`
DoneCards []DoneCard `json:"done_cards"`
ReopenedCards []ReopenedEntry `json:"reopened_cards"`
StaleCards StaleBuckets `json:"stale_cards"`
LeadTime LeadTimeStats `json:"lead_time"`
HourlyMoves [24]int `json:"hourly_moves"`
Deadlines DeadlineSummary `json:"deadlines"`
TagsDone []NamedCount `json:"tags_done"`
ArchivedToday int `json:"archived_today"`
}
type DailyKPIs struct {
Done int `json:"done"`
Created int `json:"created"`
Moves int `json:"moves"`
BlockedMs int64 `json:"blocked_ms"`
DeadlinesMet int `json:"deadlines_met"`
DeadlinesMissed int `json:"deadlines_missed"`
Reopened int `json:"reopened"`
ArchivedAuto int `json:"archived_auto"`
ArchivedManual int `json:"archived_manual"`
}
type UserCount struct {
UserID string `json:"user_id"`
Name string `json:"name"`
Count int `json:"count"`
}
type NamedCount struct {
Name string `json:"name"`
Count int `json:"count"`
}
type DoneCard struct {
ID string `json:"id"`
SeqNum int `json:"seq_num"`
Title string `json:"title"`
Requester string `json:"requester"`
AssigneeID *string `json:"assignee_id"`
AssigneeName *string `json:"assignee_name"`
Tags []string `json:"tags"`
ColumnID string `json:"column_id"`
ColumnName string `json:"column_name"`
CompletedAt string `json:"completed_at"`
CreatedAt string `json:"created_at"`
LeadTimeMs int64 `json:"lead_time_ms"`
Color string `json:"color"`
}
type ReopenedEntry struct {
CardID string `json:"card_id"`
Title string `json:"title"`
SeqNum int `json:"seq_num"`
FromColumn string `json:"from_column"`
ToColumn string `json:"to_column"`
Ts string `json:"ts"`
ActorID *string `json:"actor_id"`
ActorName *string `json:"actor_name"`
}
type StaleEntry struct {
CardID string `json:"card_id"`
Title string `json:"title"`
SeqNum int `json:"seq_num"`
ColumnID string `json:"column_id"`
ColumnName string `json:"column_name"`
EnteredAt string `json:"entered_at"`
Days int `json:"days"`
}
type StaleBuckets struct {
D7 []StaleEntry `json:"d7"`
D14 []StaleEntry `json:"d14"`
D30 []StaleEntry `json:"d30"`
}
type LeadTimeStats struct {
AvgMs int64 `json:"avg_ms"`
P50Ms int64 `json:"p50_ms"`
P95Ms int64 `json:"p95_ms"`
Samples int `json:"samples"`
}
type DeadlineSummary struct {
Met int `json:"met"`
Missed int `json:"missed"`
List []DeadlineMissEntry `json:"list"`
}
type DeadlineMissEntry struct {
CardID string `json:"card_id"`
Title string `json:"title"`
SeqNum int `json:"seq_num"`
Deadline string `json:"deadline"`
CompletedAt string `json:"completed_at"`
LateMs int64 `json:"late_ms"`
}
// DailyReportFor computes the report for the local day specified by date+tz.
func (db *DB) DailyReportFor(date, tz string) (*DailyReport, error) {
loc, err := time.LoadLocation(tz)
if err != nil {
loc = time.UTC
tz = "UTC"
}
t, err := time.ParseInLocation("2006-01-02", date, loc)
if err != nil {
return nil, fmt.Errorf("invalid date %q: %w", date, err)
}
start := t
end := t.Add(24 * time.Hour)
startUTC := start.UTC().Format(time.RFC3339Nano)
endUTC := end.UTC().Format(time.RFC3339Nano)
r := &DailyReport{
Date: date,
TZ: tz,
StartTs: startUTC,
EndTs: endUTC,
StaleCards: StaleBuckets{
D7: []StaleEntry{},
D14: []StaleEntry{},
D30: []StaleEntry{},
},
Deadlines: DeadlineSummary{List: []DeadlineMissEntry{}},
DoneCards: []DoneCard{},
ReopenedCards: []ReopenedEntry{},
TopAssigneesDone: []UserCount{},
TopAssigneesCreated: []UserCount{},
TopRequestersAdded: []NamedCount{},
TopRequestersDone: []NamedCount{},
TagsDone: []NamedCount{},
}
users, err := db.userNameMap()
if err != nil {
return nil, err
}
doneColIDs, doneColNames, err := db.doneColumnIDs()
if err != nil {
return nil, err
}
allColNames, err := db.allColumnNames()
if err != nil {
return nil, err
}
// --- Done cards ----------------------------------------------------------
rows, err := db.conn.Query(`
SELECT c.id, c.seq_num, c.title, c.requester, c.assignee_id, c.tags, c.column_id, c.completed_at, c.created_at, c.color, c.deadline
FROM cards c
WHERE c.completed_at IS NOT NULL
AND c.completed_at >= ? AND c.completed_at < ?
AND c.deleted_at IS NULL
ORDER BY c.completed_at DESC
`, startUTC, endUTC)
if err != nil {
return nil, err
}
leadSamples := []int64{}
assigneeDoneCount := map[string]int{}
requesterDoneCount := map[string]int{}
tagCount := map[string]int{}
for rows.Next() {
var c DoneCard
var assignee, deadline sql.NullString
var tagsJSON string
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Title, &c.Requester, &assignee, &tagsJSON, &c.ColumnID, &c.CompletedAt, &c.CreatedAt, &c.Color, &deadline); err != nil {
rows.Close()
return nil, err
}
c.Tags = parseTags(tagsJSON)
if assignee.Valid && assignee.String != "" {
a := assignee.String
c.AssigneeID = &a
if n, ok := users[a]; ok {
nm := n
c.AssigneeName = &nm
}
assigneeDoneCount[a]++
}
if c.Requester != "" {
requesterDoneCount[c.Requester]++
}
for _, tag := range c.Tags {
tagCount[tag]++
}
c.ColumnName = allColNames[c.ColumnID]
// Lead time created -> completed.
if ct, err := time.Parse(time.RFC3339Nano, c.CreatedAt); err == nil {
if compt, err := time.Parse(time.RFC3339Nano, c.CompletedAt); err == nil {
c.LeadTimeMs = compt.Sub(ct).Milliseconds()
if c.LeadTimeMs >= 0 {
leadSamples = append(leadSamples, c.LeadTimeMs)
}
}
}
// Deadlines.
if deadline.Valid && deadline.String != "" {
if dlt, err := time.Parse(time.RFC3339Nano, deadline.String); err == nil {
if compt, err := time.Parse(time.RFC3339Nano, c.CompletedAt); err == nil {
if compt.After(dlt) {
r.Deadlines.Missed++
r.Deadlines.List = append(r.Deadlines.List, DeadlineMissEntry{
CardID: c.ID, Title: c.Title, SeqNum: c.SeqNum,
Deadline: deadline.String, CompletedAt: c.CompletedAt,
LateMs: compt.Sub(dlt).Milliseconds(),
})
} else {
r.Deadlines.Met++
}
}
}
}
r.DoneCards = append(r.DoneCards, c)
}
rows.Close()
r.KPIs.Done = len(r.DoneCards)
r.LeadTime = computeLeadTime(leadSamples)
r.TopAssigneesDone = topUsersFromCount(assigneeDoneCount, users, 5)
r.TopRequestersDone = topNamedFromCount(requesterDoneCount, 5)
r.TagsDone = topNamedFromCount(tagCount, 10)
_ = doneColIDs
_ = doneColNames
// --- Created (card_events kind=created) ----------------------------------
rows, err = db.conn.Query(`
SELECT e.card_id, e.actor_id, COALESCE(c.requester, '')
FROM card_events e
LEFT JOIN cards c ON c.id = e.card_id
WHERE e.kind = 'created'
AND e.created_at >= ? AND e.created_at < ?
`, startUTC, endUTC)
if err != nil {
return nil, err
}
assigneeCreatedCount := map[string]int{}
requesterAddedCount := map[string]int{}
createdN := 0
for rows.Next() {
var cardID string
var actor sql.NullString
var requester string
if err := rows.Scan(&cardID, &actor, &requester); err != nil {
rows.Close()
return nil, err
}
createdN++
if actor.Valid && actor.String != "" {
assigneeCreatedCount[actor.String]++
}
if requester != "" {
requesterAddedCount[requester]++
}
}
rows.Close()
r.KPIs.Created = createdN
r.TopAssigneesCreated = topUsersFromCount(assigneeCreatedCount, users, 5)
r.TopRequestersAdded = topNamedFromCount(requesterAddedCount, 5)
// --- Moves del dia + hourly + reopened -----------------------------------
// Reopened = card que el dia X entro a una columna NO done HABIENDO estado
// en una done previa. Detectable comparando entered_at del dia con la
// entrada previa (mismo card_id).
rows, err = db.conn.Query(`
SELECT h.card_id, h.column_id, h.entered_at, h.actor_id, c.title, c.seq_num
FROM card_column_history h
JOIN cards c ON c.id = h.card_id
WHERE h.entered_at >= ? AND h.entered_at < ?
AND c.deleted_at IS NULL
ORDER BY h.entered_at ASC
`, startUTC, endUTC)
if err != nil {
return nil, err
}
hourly := [24]int{}
type moveRow struct {
cardID, columnID, enteredAt, title string
actor sql.NullString
seqNum int
}
var moves []moveRow
for rows.Next() {
var m moveRow
if err := rows.Scan(&m.cardID, &m.columnID, &m.enteredAt, &m.actor, &m.title, &m.seqNum); err != nil {
rows.Close()
return nil, err
}
moves = append(moves, m)
if ts, err := time.Parse(time.RFC3339Nano, m.enteredAt); err == nil {
h := ts.In(loc).Hour()
if h >= 0 && h < 24 {
hourly[h]++
}
}
}
rows.Close()
r.HourlyMoves = hourly
r.KPIs.Moves = len(moves)
for _, m := range moves {
// Solo interesa si la columna actual NO es done.
isDone := doneColIDs[m.columnID]
if isDone {
continue
}
// Hubo entrada previa en una columna done?
prevWasDone, prevColID := db.previousColumnWasDone(m.cardID, m.enteredAt, doneColIDs)
if prevWasDone {
entry := ReopenedEntry{
CardID: m.cardID,
Title: m.title,
SeqNum: m.seqNum,
FromColumn: allColNames[prevColID],
ToColumn: allColNames[m.columnID],
Ts: m.enteredAt,
}
if m.actor.Valid && m.actor.String != "" {
a := m.actor.String
entry.ActorID = &a
if n, ok := users[a]; ok {
nm := n
entry.ActorName = &nm
}
}
r.ReopenedCards = append(r.ReopenedCards, entry)
}
}
r.KPIs.Reopened = len(r.ReopenedCards)
// --- Stale buckets (cards activas hoy con N dias en misma columna) -------
r.StaleCards = db.staleBucketsAt(end, doneColIDs, allColNames)
// --- Bloqueado ms (lock_history que solapa con el dia) -------------------
r.KPIs.BlockedMs = db.blockedMsInRange(startUTC, endUTC)
// --- Archivadas hoy ------------------------------------------------------
var autoN, manualN int
if err := db.conn.QueryRow(`
SELECT COUNT(*) FROM cards
WHERE archived_at IS NOT NULL
AND archived_at >= ? AND archived_at < ?
AND deleted_at IS NULL
`, startUTC, endUTC).Scan(&autoN); err == nil {
// Heuristica: auto vs manual no se diferencia (no log explicito). Si
// la columna actual es is_done, asumimos auto. Mejor que nada.
_ = manualN
r.KPIs.ArchivedAuto = autoN
r.ArchivedToday = autoN
}
r.KPIs.DeadlinesMet = r.Deadlines.Met
r.KPIs.DeadlinesMissed = r.Deadlines.Missed
return r, nil
}
func (db *DB) userNameMap() (map[string]string, error) {
rows, err := db.conn.Query(`SELECT id, COALESCE(display_name,''), username FROM users`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var id, dn, un string
if err := rows.Scan(&id, &dn, &un); err != nil {
return nil, err
}
if dn != "" {
out[id] = dn
} else {
out[id] = un
}
}
return out, nil
}
func (db *DB) doneColumnIDs() (map[string]bool, map[string]string, error) {
rows, err := db.conn.Query(`SELECT id, name FROM columns WHERE is_done=1`)
if err != nil {
return nil, nil, err
}
defer rows.Close()
ids := map[string]bool{}
names := map[string]string{}
for rows.Next() {
var id, n string
if err := rows.Scan(&id, &n); err != nil {
return nil, nil, err
}
ids[id] = true
names[id] = n
}
return ids, names, nil
}
func (db *DB) allColumnNames() (map[string]string, error) {
rows, err := db.conn.Query(`SELECT id, name FROM columns`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var id, n string
if err := rows.Scan(&id, &n); err != nil {
return nil, err
}
out[id] = n
}
return out, nil
}
// previousColumnWasDone returns whether the entry of `cardID` immediately
// before `enteredAt` was in a done column.
func (db *DB) previousColumnWasDone(cardID, enteredAt string, doneColIDs map[string]bool) (bool, string) {
var colID string
err := db.conn.QueryRow(`
SELECT column_id FROM card_column_history
WHERE card_id=? AND entered_at < ?
ORDER BY entered_at DESC
LIMIT 1
`, cardID, enteredAt).Scan(&colID)
if err != nil {
return false, ""
}
return doneColIDs[colID], colID
}
func (db *DB) staleBucketsAt(asOf time.Time, doneColIDs map[string]bool, colNames map[string]string) StaleBuckets {
out := StaleBuckets{D7: []StaleEntry{}, D14: []StaleEntry{}, D30: []StaleEntry{}}
rows, err := db.conn.Query(`
SELECT h.card_id, c.title, c.seq_num, h.column_id, h.entered_at
FROM card_column_history h
JOIN cards c ON c.id = h.card_id
WHERE h.exited_at IS NULL
AND c.deleted_at IS NULL
AND c.archived_at IS NULL
`)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var s StaleEntry
if err := rows.Scan(&s.CardID, &s.Title, &s.SeqNum, &s.ColumnID, &s.EnteredAt); err != nil {
continue
}
// Skip done columns (esos se auto-archivan; no son "estancados" activos).
if doneColIDs[s.ColumnID] {
continue
}
entered, err := time.Parse(time.RFC3339Nano, s.EnteredAt)
if err != nil {
continue
}
days := int(asOf.Sub(entered).Hours() / 24)
if days < 7 {
continue
}
s.Days = days
s.ColumnName = colNames[s.ColumnID]
switch {
case days >= 30:
out.D30 = append(out.D30, s)
case days >= 14:
out.D14 = append(out.D14, s)
default:
out.D7 = append(out.D7, s)
}
}
sort.Slice(out.D7, func(i, j int) bool { return out.D7[i].Days > out.D7[j].Days })
sort.Slice(out.D14, func(i, j int) bool { return out.D14[i].Days > out.D14[j].Days })
sort.Slice(out.D30, func(i, j int) bool { return out.D30[i].Days > out.D30[j].Days })
return out
}
func (db *DB) blockedMsInRange(startUTC, endUTC string) int64 {
// Para cada periodo de lock, contar la interseccion con [start,end].
rows, err := db.conn.Query(`
SELECT locked_at, COALESCE(unlocked_at, ?) FROM card_lock_history
WHERE locked_at < ? AND COALESCE(unlocked_at, ?) > ?
`, endUTC, endUTC, endUTC, startUTC)
if err != nil {
return 0
}
defer rows.Close()
start, _ := time.Parse(time.RFC3339Nano, startUTC)
end, _ := time.Parse(time.RFC3339Nano, endUTC)
var total time.Duration
for rows.Next() {
var lstr, ustr string
if err := rows.Scan(&lstr, &ustr); err != nil {
continue
}
l, err := time.Parse(time.RFC3339Nano, lstr)
if err != nil {
continue
}
u, err := time.Parse(time.RFC3339Nano, ustr)
if err != nil {
continue
}
if l.Before(start) {
l = start
}
if u.After(end) {
u = end
}
if u.After(l) {
total += u.Sub(l)
}
}
return total.Milliseconds()
}
func topUsersFromCount(m map[string]int, names map[string]string, k int) []UserCount {
out := make([]UserCount, 0, len(m))
for id, n := range m {
out = append(out, UserCount{UserID: id, Name: names[id], Count: n})
}
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
if len(out) > k {
out = out[:k]
}
return out
}
func topNamedFromCount(m map[string]int, k int) []NamedCount {
out := make([]NamedCount, 0, len(m))
for n, c := range m {
out = append(out, NamedCount{Name: n, Count: c})
}
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
if len(out) > k {
out = out[:k]
}
return out
}
func computeLeadTime(samples []int64) LeadTimeStats {
if len(samples) == 0 {
return LeadTimeStats{}
}
sorted := make([]int64, len(samples))
copy(sorted, samples)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
var sum int64
for _, v := range sorted {
sum += v
}
p := func(q float64) int64 {
if len(sorted) == 0 {
return 0
}
idx := int(float64(len(sorted)-1) * q)
return sorted[idx]
}
return LeadTimeStats{
AvgMs: sum / int64(len(sorted)),
P50Ms: p(0.5),
P95Ms: p(0.95),
Samples: len(sorted),
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"flags": {
"registration-enabled": {
"enabled": false,
"issue": null,
"description": "Allows new users to register via POST /api/auth/register and the LoginPage register toggle.",
"added": "2026-05-12",
"enabled_at": null
}
}
}
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Issue 0128 — smoke test of card file attachments.
# Builds kanban (assumes ./kanban present or builds it), boots on ephemeral port,
# exercises POST upload, GET list, GET serve, DELETE, GET list-after-delete.
# Exits 0 on success, non-zero on any failure.
set -euo pipefail
PORT=${PORT:-18095}
BASE="http://127.0.0.1:${PORT}"
DB=$(mktemp /tmp/kanban_files_smoke.XXXXXX.db)
COOKIE=$(mktemp /tmp/kanban_files_smoke.cookie.XXXXXX)
UPLOAD_DIR=$(dirname "$DB")/uploads
PNG=$(mktemp /tmp/kanban_files_smoke.XXXXXX.png)
PID_FILE=$(mktemp /tmp/kanban_files_smoke.pid.XXXXXX)
cleanup() {
if [ -s "$PID_FILE" ]; then
kill "$(cat "$PID_FILE")" 2>/dev/null || true
fi
rm -f "$DB" "$DB-shm" "$DB-wal" "$COOKIE" "$PNG" "$PID_FILE"
rm -rf "$UPLOAD_DIR"
}
trap cleanup EXIT
# Build if missing.
if [ ! -x ./kanban ]; then
echo "[smoke] building kanban binary..."
(cd backend && CGO_ENABLED=1 go build -tags fts5 -o ../kanban .)
fi
# Boot.
./kanban --port "$PORT" --db "$DB" --initial-admin admin:adminpw \
> /tmp/kanban_files_smoke.log 2>&1 &
echo $! > "$PID_FILE"
# Wait for /api/board.
for _ in $(seq 1 30); do
if curl -sf -o /dev/null "$BASE/api/board"; then break; fi
sleep 0.2
done
# Login.
curl -sf -c "$COOKIE" -X POST "$BASE/api/auth/login" \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"adminpw"}' > /dev/null
# Column + card.
COL=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/columns" \
-H 'Content-Type: application/json' \
-d '{"name":"To Do"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
CARD=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/cards" \
-H 'Content-Type: application/json' \
-d "{\"column_id\":\"$COL\",\"title\":\"smoke\",\"requester\":\"r\"}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
# Minimal PNG.
printf '\x89PNG\r\n\x1a\n' > "$PNG"
# Upload.
UP=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/cards/$CARD/files" \
-F "file=@$PNG;type=image/png")
FID=$(echo "$UP" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
[ -n "$FID" ] || { echo "[smoke] upload missing id"; exit 1; }
# List active.
N=$(curl -sf -b "$COOKIE" "$BASE/api/cards/$CARD/files" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')
[ "$N" = "1" ] || { echo "[smoke] expected 1 file, got $N"; exit 1; }
# Serve.
CT=$(curl -sf -b "$COOKIE" -I "$BASE/api/files/$FID" | awk '/^[Cc]ontent-[Tt]ype/ {print $2}' | tr -d '\r\n')
echo "$CT" | grep -q image/png || { echo "[smoke] wrong content-type: $CT"; exit 1; }
# Delete.
HTTP=$(curl -sb "$COOKIE" -X DELETE -o /dev/null -w "%{http_code}" "$BASE/api/files/$FID")
[ "$HTTP" = "204" ] || { echo "[smoke] expected 204 on delete, got $HTTP"; exit 1; }
# List after delete.
N=$(curl -sf -b "$COOKIE" "$BASE/api/cards/$CARD/files" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')
[ "$N" = "0" ] || { echo "[smoke] expected 0 after delete, got $N"; exit 1; }
echo "[smoke] OK"
+57
View File
@@ -0,0 +1,57 @@
import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
const USER = process.env.KANBAN_USER || "e2e_user";
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
/**
* Issue 0092: cards en columnas DONE con >30 dias se mueven al cajon "Hecho".
* Test cubre: archivar via menu manual, listar archivo, des-archivar.
*/
test.describe("kanban archive (issue 0092)", () => {
test("archiva una done card via menu y la des-archiva desde el cajon", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Pick a card from a done column (queried directly from the API).
const board = await page.request.get("/api/board").then((r) => r.json());
const doneCol = (board.columns as Array<{ id: string; is_done: boolean }>).find((c) => c.is_done);
if (!doneCol) test.skip(true, "no done column in board");
const cardInDone = (board.cards as Array<{ id: string; column_id: string }>).find(
(c) => c.column_id === doneCol!.id
);
if (!cardInDone) test.skip(true, "no card in a done column");
const targetId = cardInDone!.id;
const cardSel = `[data-card-id="${targetId}"]`;
const card = page.locator(cardSel).first();
await expect(card).toBeVisible();
// Open the per-card menu. Use dispatchEvent so we ignore viewport scroll constraints.
await card.locator('button[aria-label="Acciones"]').dispatchEvent("click");
const archiveItem = page.getByRole("menuitem", { name: /Archivar/i }).first();
await expect(archiveItem).toBeVisible();
await archiveItem.click();
// Card disappears from board.
await expect(card).toHaveCount(0, { timeout: 5000 });
// Archive drawer toggle visible + opens.
const archiveToggle = page.locator('[data-test="archive-toggle"]');
await archiveToggle.scrollIntoViewIfNeeded();
await archiveToggle.dispatchEvent("click");
// Archived row appears in the drawer.
const archivedRow = page.locator(`[data-archived-card-id="${targetId}"]`);
await expect(archivedRow).toBeVisible({ timeout: 5000 });
// Restore from archive (force click — sidebar can be scrollable / off-viewport).
await archivedRow.locator("button").first().dispatchEvent("click");
// Back on board.
await expect(page.locator(cardSel).first()).toBeVisible({ timeout: 5000 });
// No longer in archive.
await expect(archivedRow).toHaveCount(0);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
const USER = process.env.KANBAN_USER || "e2e_user";
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
/**
* Issue 0093: reporte diario al pulsar numero del dia en el calendario.
* Verifica: endpoint responde, calendario abre modal con titulo "Reporte diario",
* KPIs visibles, tabla de hechas presente.
*/
test.describe("daily report (issue 0093)", () => {
test("endpoint /api/reports/daily devuelve estructura esperada", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
const today = new Date().toISOString().slice(0, 10);
const res = await page.request.get(`/api/reports/daily?date=${today}`);
expect(res.status()).toBe(200);
const data = await res.json();
expect(data).toHaveProperty("kpis");
expect(data).toHaveProperty("done_cards");
expect(data).toHaveProperty("hourly_moves");
expect(Array.isArray(data.hourly_moves)).toBe(true);
expect(data.hourly_moves.length).toBe(24);
expect(data).toHaveProperty("stale_cards");
expect(data.stale_cards).toHaveProperty("d7");
expect(data.stale_cards).toHaveProperty("d14");
expect(data.stale_cards).toHaveProperty("d30");
});
test("click en numero del dia del calendario abre modal del reporte", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Switch to Calendario tab.
await page.getByRole("tab", { name: /Calendario/i }).click();
// Wait until the calendar cells render.
await page.waitForSelector('[data-test^="calendar-day-"]', { timeout: 5000 });
// Use yesterday — the seeded DB has activity there.
const yesterday = new Date(Date.now() - 24 * 3600 * 1000).toISOString().slice(0, 10);
const cellBtn = page.locator(`[data-test="calendar-day-${yesterday}"]`);
if ((await cellBtn.count()) === 0) {
// Fallback: click any visible day.
await page.locator('[data-test^="calendar-day-"]').first().dispatchEvent("click");
} else {
await cellBtn.dispatchEvent("click");
}
// Modal opens.
const modal = page.locator('[role="dialog"]').filter({ hasText: /Reporte diario/i });
await expect(modal).toBeVisible({ timeout: 5000 });
await expect(modal.getByText("Hechas", { exact: false }).first()).toBeVisible();
await expect(modal.getByText("Movimientos", { exact: false }).first()).toBeVisible();
});
});
+56
View File
@@ -0,0 +1,56 @@
import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
const USER = process.env.KANBAN_USER || "e2e_user";
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
/**
* Issue 0094: bocadillo del agente + settings de prompt + PDF.
* No invocamos claude binario; testeamos endpoints settings y la UI estatica.
*/
test.describe("daily summary + pdf (issue 0094)", () => {
test("settings prompt CRUD roundtrip", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Lectura inicial: existe seed.
const initial = await page.request.get("/api/settings/daily_report_prompt").then((r) => r.json());
expect(initial.value).toContain("MAXIMO");
// Cambio.
const newVal = "test prompt " + Date.now();
const put = await page.request.put("/api/settings/daily_report_prompt", { data: { value: newVal } });
expect([200, 204]).toContain(put.status());
// Verifica.
const after = await page.request.get("/api/settings/daily_report_prompt").then((r) => r.json());
expect(after.value).toBe(newVal);
// Restaurar.
await page.request.put("/api/settings/daily_report_prompt", { data: { value: initial.value } });
});
test("daily summary GET vacio inicialmente, persiste si guardas manualmente", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
const today = new Date().toISOString().slice(0, 10);
const before = await page.request.get(`/api/reports/daily/summary?date=${today}`).then((r) => r.json());
// Either exists=false OR exists=true with a string summary. Both valid.
expect(typeof before.exists).toBe("boolean");
expect(typeof before.summary === "string").toBe(true);
});
test("UI: bocadillo + boton PDF + boton settings visibles en modal", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
await page.getByRole("tab", { name: /Calendario/i }).click();
await page.waitForSelector('[data-test^="calendar-day-"]', { timeout: 5000 });
await page.locator('[data-test^="calendar-day-"]').first().dispatchEvent("click");
const modal = page.locator('[role="dialog"]').filter({ hasText: /Reporte diario/i });
await expect(modal).toBeVisible();
await expect(modal.locator('[data-test="daily-report-pdf"]')).toBeVisible();
await expect(modal.getByRole("button", { name: /Configurar prompt/i })).toBeVisible();
await expect(modal.getByRole("button", { name: /Regenerar|Generar/i }).first()).toBeVisible();
});
});
+142
View File
@@ -0,0 +1,142 @@
import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
const USER = process.env.KANBAN_USER || "e2e_user";
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
/**
* Issue followup: drag lag.
* Capture per-frame durations via requestAnimationFrame while a card is dragged
* across reorder positions inside a populated column. Asserts p50 < 32ms and
* max < 120ms so a regression visibly slower than ~30 fps fails the suite.
*
* Read each measurement printed to console to track changes over time.
*/
test.describe("kanban drag perf", () => {
test("reorder inside HACIENDO does not drop below 30 fps", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Wait until at least one card is mounted.
await page.waitForSelector("[data-card-id]", { timeout: 10_000 });
// Inject a tiny frame-time recorder.
await page.evaluate(() => {
const w = window as unknown as {
_frames: number[];
_capturing: boolean;
_startCapture: () => void;
_stopCapture: () => number[];
};
w._frames = [];
w._capturing = false;
let prev = 0;
const tick = (t: number) => {
if (!w._capturing) return;
if (prev !== 0) w._frames.push(t - prev);
prev = t;
requestAnimationFrame(tick);
};
w._startCapture = () => {
w._frames = [];
w._capturing = true;
prev = 0;
requestAnimationFrame(tick);
};
w._stopCapture = () => {
w._capturing = false;
return w._frames.slice();
};
});
// Pick the column with the MOST cards (worst-case reorder cost).
const target = await page.evaluate(() => {
const cols = Array.from(document.querySelectorAll<HTMLElement>("[data-column-id]"));
let best: { columnId: string | null; cardIds: string[] } | null = null;
for (const col of cols) {
const cards = Array.from(col.querySelectorAll<HTMLElement>("[data-card-id]"));
if (!best || cards.length > best.cardIds.length) {
best = {
columnId: col.getAttribute("data-column-id"),
cardIds: cards
.map((c) => c.getAttribute("data-card-id"))
.filter((x): x is string => x !== null),
};
}
}
return best && best.cardIds.length >= 3 ? best : null;
});
if (!target) test.skip(true, "need a column with >= 3 cards");
const firstId = target!.cardIds[0]!;
const lastId = target!.cardIds[target!.cardIds.length - 1]!;
const source = page.locator(`[data-card-id="${firstId}"]`);
const targetEl = page.locator(`[data-card-id="${lastId}"]`);
const sb = await source.boundingBox();
const tb = await targetEl.boundingBox();
if (!sb || !tb) throw new Error("no bounding box");
const sx = sb.x + sb.width / 2;
const sy = sb.y + sb.height / 2;
const tx = tb.x + tb.width / 2;
const ty = tb.y + tb.height / 2;
await page.mouse.move(sx, sy);
await page.mouse.down();
// dnd-kit pointer-sensor activation threshold: 8px; nudge horizontally first.
await page.mouse.move(sx + 12, sy, { steps: 2 });
// Probe how many KanbanCard renders happen during the drag.
await page.evaluate(() => {
const w = window as unknown as { _cardRenderCount: number; _cardRenderProbe: boolean };
w._cardRenderCount = 0;
w._cardRenderProbe = true;
});
await page.evaluate(() => (window as unknown as { _startCapture: () => void })._startCapture());
// Move slowly across the column to trigger reorder swaps; steps=40 gives
// dnd-kit time to recompute positions.
await page.mouse.move(tx, ty, { steps: 40 });
// Hover so any final layout animation captures into the trace.
await page.waitForTimeout(120);
const frames = (await page.evaluate(() =>
(window as unknown as { _stopCapture: () => number[] })._stopCapture()
)) as number[];
const renderCount = (await page.evaluate(
() => (window as unknown as { _cardRenderCount: number })._cardRenderCount
)) as number;
const bodyCount = (await page.evaluate(
() => (window as unknown as { _cardBodyRenderCount: number })._cardBodyRenderCount || 0
)) as number;
console.log(`drag-perf wrapper-renders=${renderCount} body-renders=${bodyCount} over ${frames.length} frames`);
await page.mouse.up();
const sorted = [...frames].sort((a, b) => a - b);
const p50 = sorted[Math.floor(sorted.length * 0.5)] ?? 0;
const p95 = sorted[Math.floor(sorted.length * 0.95)] ?? 0;
const max = sorted.length > 0 ? sorted[sorted.length - 1] : 0;
const avg = sorted.reduce((a, b) => a + b, 0) / Math.max(1, sorted.length);
console.log(
`drag-perf frames=${sorted.length} avg=${avg.toFixed(1)}ms p50=${p50.toFixed(1)}ms p95=${p95.toFixed(1)}ms max=${max.toFixed(1)}ms`
);
// Save a stable artefact so we can compare runs.
test.info().annotations.push({
type: "drag-perf",
description: JSON.stringify({ count: sorted.length, avg, p50, p95, max }),
});
// Thresholds tuned tras separar el body memoizado (issue dnd-lag-fix
// followup). Pre-fix: p95=83ms / max=117ms. Post-fix: p95=33 / max=33.
expect(p50).toBeLessThan(20);
expect(p95).toBeLessThan(50);
expect(max).toBeLessThan(60);
// Body memoizado: durante el drag no debe re-renderizar.
expect(bodyCount).toBeLessThan(5);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
import { pw_keyboard_sequence } from "../../../../frontend/functions/browser/pw_keyboard_sequence";
import { pw_wait_predicate } from "../../../../frontend/functions/browser/pw_wait_predicate";
const USER = process.env.KANBAN_USER || "egutierrez";
const PWD = process.env.KANBAN_PWD || "egutierrez";
test.describe("Issue 0088 — requester input vacio + nav teclado", () => {
test("input solicitante entra vacio y ArrowDown+Enter no cierra modal", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Abrir Nueva tarjeta del primer "+" disponible en alguna columna del board.
const addBtn = page.locator('[data-test="add-card"]').first();
await addBtn.dispatchEvent("click");
// Modal de Mantine abierto.
const dialog = page.locator("[role=dialog]");
await expect(dialog).toBeVisible();
// Solicitante vacio.
const requester = dialog.locator('input[data-field="requester"]');
await expect(requester).toHaveValue("");
// Necesario titulo para que un eventual submit no se descarte por el guard.
await dialog.locator("textarea").first().fill("e2e test card");
// Tipear + navegar dropdown + Enter.
await requester.focus();
await pw_keyboard_sequence(page, [
{ kind: "type", text: "a", delayMs: 50 },
{ kind: "wait", ms: 300 },
{ kind: "press", key: "ArrowDown" },
{ kind: "press", key: "Enter" },
]);
// Modal sigue visible: Enter no ha cerrado el form.
await page.waitForTimeout(300);
await expect(dialog).toBeVisible();
// Cancelar para limpiar estado.
await dialog.locator("button:has-text('Cancelar')").click();
await expect(dialog).toBeHidden();
});
test("Enter en requester con dropdown cerrado NO cierra modal", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
const addBtn = page.locator('[data-test="add-card"]').first();
await addBtn.dispatchEvent("click");
const dialog = page.locator("[role=dialog]");
await expect(dialog).toBeVisible();
await dialog.locator("textarea").first().fill("e2e test card 2");
const requester = dialog.locator('input[data-field="requester"]');
await requester.focus();
// Press Escape para asegurar dropdown cerrado, luego Enter.
await page.keyboard.press("Escape");
await page.keyboard.press("Enter");
await page.waitForTimeout(200);
await expect(dialog).toBeVisible();
await dialog.locator("button:has-text('Cancelar')").click();
await expect(dialog).toBeHidden();
});
});
+198
View File
@@ -0,0 +1,198 @@
import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
import { pw_drag_drop } from "../../../../frontend/functions/browser/pw_drag_drop";
import { pw_wait_predicate } from "../../../../frontend/functions/browser/pw_wait_predicate";
const USER = process.env.KANBAN_USER || "e2e_user";
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
interface BoardColumn {
id: string;
name: string;
location: "board" | "sidebar";
position: number;
}
interface BoardCard {
id: string;
column_id: string;
title: string;
}
interface BoardResponse {
columns: BoardColumn[];
cards: BoardCard[];
}
test.describe("Issue 0091 — sidebar drag dropzone", () => {
test("drag near left edge opens sidebar and drop moves card to sidebar column", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Pre-req: ensure there is at least one sidebar column and a card on the board.
const initialBoard: BoardResponse = await page.request
.get("/api/board")
.then((r) => r.json());
let sidebarCol = initialBoard.columns.find((c) => c.location === "sidebar");
if (!sidebarCol) {
const created = await page.request
.post("/api/columns", {
data: { name: "E2E Sidebar", location: "sidebar" },
})
.then((r) => r.json());
sidebarCol = created as BoardColumn;
}
const boardCol = initialBoard.columns.find((c) => c.location !== "sidebar");
if (!boardCol) {
test.skip(true, "no board column to drag a card from");
return;
}
// Ensure at least one card exists in a board column we can drag.
let card = initialBoard.cards.find((c) => c.column_id === boardCol.id);
if (!card) {
const created = await page.request
.post("/api/cards", {
data: {
column_id: boardCol.id,
title: `e2e dropzone card ${Date.now()}`,
requester: "e2e",
},
})
.then((r) => r.json());
card = created as BoardCard;
// Reload UI so the new card appears.
await page.reload();
await page.waitForLoadState("networkidle");
}
// Sanity: side bar should start closed. The toggle button has aria-label="Toggle sidebar".
const toggleBtn = page.locator('button[aria-label="Toggle sidebar"]');
await expect(toggleBtn).toBeVisible();
// The Mantine Navbar has a known data attribute (data-mantine-component=AppShellNavbar)
// but the simplest check is: when collapsed, the desktop navbar is hidden via display:none.
// We use the strip element's visibility too.
const strip = page.locator('[data-test="kanban-drag-edge"]');
await expect(strip).toHaveCount(1);
// While not dragging, strip is_active=0.
await expect(strip).toHaveAttribute("data-active", "0");
const cardLocator = page.locator(`[data-card-id="${card!.id}"]`);
await expect(cardLocator).toBeVisible();
// Build a "left edge" target by creating a 1x100 box near x=10 to drop on.
// pw_drag_drop expects a Locator for the target; we use the strip itself
// even though pointer-events:none — page.mouse.move works against the
// viewport so its bounding box only drives where the pointer goes.
// We override hoverMs=700 so the 400ms timer fires well within the hover.
// Get the card bounding box.
const cardBox = await cardLocator.boundingBox();
if (!cardBox) throw new Error("card has no bounding box");
// Manually drive the pointer: press down on card, drag to x=10, dwell 700ms,
// assert sidebar opened (via predicate on toggle button aria-pressed OR the
// strip's data-active attribute observed), then drop on sidebar column.
const sx = cardBox.x + cardBox.width / 2;
const sy = cardBox.y + cardBox.height / 2;
await page.mouse.move(sx, sy);
await page.mouse.down();
// Cross dnd-kit's 5px activation threshold (we configured PointerSensor distance:5).
await page.mouse.move(sx + 15, sy, { steps: 4 });
// Glide towards x=10 (inside the 32px strip).
const edgeX = 10;
const edgeY = sy; // keep vertical, change horizontal.
const steps = 25;
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const xi = (sx + 15) + (edgeX - (sx + 15)) * t;
const yi = sy + (edgeY - sy) * t;
await page.mouse.move(xi, yi);
await page.waitForTimeout(16);
}
// Now dwell inside the strip — the 400ms timer should fire.
// While dwelling, every ~50ms we nudge the mouse 1px to keep dnd-kit pointer events alive
// but stay inside the strip.
const dwellMs = 700;
const nudgeStart = Date.now();
while (Date.now() - nudgeStart < dwellMs) {
await page.mouse.move(edgeX + ((Date.now() / 50) % 2), edgeY);
await page.waitForTimeout(50);
}
// Assert: the strip is now armed AND the sidebar opened.
await expect(strip).toHaveAttribute("data-armed", "1");
// Wait for sidebar column header text to appear (sidebar opened).
await pw_wait_predicate(
page,
(sidebarName: string) => {
const els = Array.from(document.querySelectorAll('[data-column-location="sidebar"]'));
// Element must be visible (offsetParent != null is a good proxy for display!=none).
return els.some((el) => (el as HTMLElement).offsetParent !== null);
},
{
arg: sidebarCol!.name,
timeoutMs: 3000,
pollMs: 100,
message: "sidebar column did not become visible after dwell",
}
);
// Now move pointer to the sidebar column and release.
const sidebarColLoc = page.locator(`[data-column-id="${sidebarCol!.id}"]`).first();
await expect(sidebarColLoc).toBeVisible();
const sbBox = await sidebarColLoc.boundingBox();
if (!sbBox) throw new Error("sidebar column has no bounding box");
const tx = sbBox.x + sbBox.width / 2;
const ty = sbBox.y + sbBox.height / 2;
const dropSteps = 15;
let lastX = edgeX;
let lastY = edgeY;
for (let i = 1; i <= dropSteps; i++) {
const t = i / dropSteps;
const xi = lastX + (tx - lastX) * t;
const yi = lastY + (ty - lastY) * t;
await page.mouse.move(xi, yi);
await page.waitForTimeout(20);
}
await page.waitForTimeout(150);
await page.mouse.up();
// Validate via API the card moved to the sidebar column.
await pw_wait_predicate(
page,
async (args: { id: string; col: string }) => {
const res = await fetch("/api/board", { credentials: "same-origin" });
const b = await res.json();
const c = (b.cards as BoardCard[]).find((x) => x.id === args.id);
return c?.column_id === args.col;
},
{
arg: { id: card!.id, col: sidebarCol!.id },
timeoutMs: 5000,
pollMs: 200,
message: "card did not land in sidebar column after drop",
}
);
});
test("strip stays inactive when there is no drag", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
const strip = page.locator('[data-test="kanban-drag-edge"]');
await expect(strip).toHaveCount(1);
await expect(strip).toHaveAttribute("data-active", "0");
await expect(strip).toHaveAttribute("data-armed", "0");
// Move the pointer over the left edge — without a drag, strip must stay disarmed.
await page.mouse.move(10, 200);
await page.waitForTimeout(600);
await expect(strip).toHaveAttribute("data-armed", "0");
});
});
+11 -2
View File
@@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -30,12 +32,19 @@
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"@vitest/ui": "^4.1.6",
"jsdom": "^29.1.1",
"postcss": "^8.5.4",
"postcss-preset-mantine": "^1.17.0",
"typescript": "~5.8.3",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vitest": "^4.1.6"
}
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
retries: 0,
workers: 1,
reporter: [["list"]],
use: {
baseURL: process.env.KANBAN_BASE_URL || "http://localhost:5180",
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "off",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+841
View File
File diff suppressed because it is too large Load Diff
+291 -16
View File
@@ -50,6 +50,7 @@ import {
IconArrowBackUp,
IconCalendar,
IconChartBar,
IconCheck,
IconChevronDown,
IconChevronRight,
IconLayoutKanban,
@@ -68,8 +69,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as api from "./api";
import { useAuth } from "./auth";
import { CardForm } from "./components/CardForm";
import { CardEditPanel } from "./components/CardEditPanel";
import { ChatPanel } from "./components/ChatPanel";
import { CalendarView } from "./components/CalendarView";
import { DailyReportView } from "./components/DailyReport";
import { Dashboard } from "./components/Dashboard";
import { HistoryModal } from "./components/HistoryModal";
import { KanbanCard } from "./components/KanbanCard";
@@ -117,6 +120,8 @@ export function App() {
const [activeTab, setActiveTab] = useState<string>("board");
const [trash, setTrash] = useState<Card[]>([]);
const [trashOpen, setTrashOpen] = useState(false);
const [archive, setArchive] = useState<Card[]>([]);
const [archiveOpen, setArchiveOpen] = useState(false);
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState("");
@@ -171,6 +176,72 @@ export function App() {
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
// -------- Issue 0091 — drag-aware sidebar dropzone --------
// While a card or column is being dragged, watch the global pointer.
// If it dwells inside the 32px left strip for >=400ms, auto-open the sidebar.
// We listen to mousemove globally because dnd-kit owns the pointer during
// drag, and the strip itself has pointer-events:none so dnd-kit keeps
// detecting drop targets underneath.
const DRAG_EDGE_WIDTH = 32;
const DRAG_EDGE_HOVER_MS = 400;
const isDragging = activeCard !== null || activeColumnId !== null;
const [edgeArmed, setEdgeArmed] = useState(false);
const navOpenRef = useRef(navOpen);
useEffect(() => {
navOpenRef.current = navOpen;
}, [navOpen]);
useEffect(() => {
if (!isDragging) {
setEdgeArmed(false);
return;
}
let timer: number | null = null;
let inside = false;
// Para evitar que un drag iniciado dentro del sidebar abierto dispare un
// cierre inmediato, exigimos que el puntero haya salido de la franja al
// menos una vez tras empezar el drag. Asi: abrir = entrar a la franja
// tras empezar fuera (que ya pasaba); cerrar = salir de la franja y
// volver a entrar.
let hasLeftStrip = false;
const clear = () => {
if (timer !== null) {
window.clearTimeout(timer);
timer = null;
}
};
const onMove = (ev: MouseEvent) => {
const nowInside = ev.clientX <= DRAG_EDGE_WIDTH;
if (nowInside === inside) return;
inside = nowInside;
// Brillo visible siempre que el puntero este en la franja y haya drag.
setEdgeArmed(nowInside);
if (!nowInside) {
hasLeftStrip = true;
clear();
return;
}
// nowInside = true. Para cerrar (navOpen=true) exigimos que el puntero
// haya salido al menos una vez de la franja desde que empezo el drag;
// asi un drag que arranca dentro del sidebar abierto no auto-cierra.
const armable = !navOpenRef.current || hasLeftStrip;
if (!armable) return;
clear();
const willOpen = !navOpenRef.current;
timer = window.setTimeout(() => {
setNavOpen(willOpen);
// Tras toggle, resetea el flag para no encadenar otra accion sin
// que el usuario salga + vuelva.
hasLeftStrip = false;
}, DRAG_EDGE_HOVER_MS);
};
document.addEventListener("mousemove", onMove);
return () => {
document.removeEventListener("mousemove", onMove);
clear();
setEdgeArmed(false);
};
}, [isDragging]);
const reload = useCallback(async () => {
try {
const b = await api.getBoard();
@@ -202,6 +273,15 @@ export function App() {
}
}, []);
const reloadArchive = useCallback(async () => {
try {
const a = await api.listArchive();
setArchive(a);
} catch (e) {
console.warn("listArchive failed", e);
}
}, []);
const reloadTags = useCallback(async () => {
try {
const t = await api.listTags();
@@ -228,15 +308,30 @@ export function App() {
reloadTrash();
}, [reloadTrash]);
useEffect(() => {
reloadArchive();
}, [reloadArchive]);
useEffect(() => {
reloadTags();
reloadRequesters();
}, [reloadTags, reloadRequesters]);
// Tick de reloj para "tiempo en columna" en cards. Pausamos durante drag
// porque dispara re-render de TODAS las cards cada segundo y el drag de
// dnd-kit sufre tirones serios con muchos elementos.
useEffect(() => {
if (activeCard || activeColumnId) return;
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
}, [activeCard, activeColumnId]);
useEffect(() => {
const t = setInterval(() => {
reload();
}, 30000);
return () => clearInterval(t);
}, [reload]);
useEffect(() => {
if (!activeSticker) return;
@@ -537,7 +632,7 @@ export function App() {
users={users}
requesterOptions={requesterOptions}
tagOptions={tagOptions}
initial={{ requester: auth.user?.display_name || auth.user?.username || "" }}
initial={{ requester: "" }}
submitLabel="Crear"
onCancel={() => modals.close(id)}
onSubmit={async (v) => {
@@ -566,20 +661,14 @@ export function App() {
const openEditCard = useCallback((card: Card) => {
const id = modals.open({
title: "Editar tarjeta",
size: "md",
size: "85%",
children: (
<CardForm
<CardEditPanel
card={card}
users={users}
currentUserId={auth.user?.id}
requesterOptions={requesterOptions}
tagOptions={tagOptions}
initial={{
requester: card.requester,
title: card.title,
description: card.description,
assignee_id: card.assignee_id,
tags: card.tags || [],
}}
submitLabel="Guardar"
onCancel={() => modals.close(id)}
onSubmit={async (v) => {
try {
@@ -601,7 +690,17 @@ export function App() {
/>
),
});
}, [reload, users, requesterOptions, tagOptions]);
}, [reload, users, auth.user, requesterOptions, tagOptions]);
const handleDuplicateCard = useCallback(async (cardId: string) => {
try {
const dup = await api.duplicateCard(cardId);
await reload();
notifications.show({ color: "teal", message: `Duplicada: ${dup.title}` });
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}, [reload]);
const handleSetRequester = useCallback(async (id: string, requester: string) => {
setBoard((prev) => {
@@ -622,6 +721,22 @@ export function App() {
window.setTimeout(() => setHighlightCardId(null), 3000);
}, []);
const handleOpenDailyReport = useCallback((date: string) => {
const id = modals.open({
title: "Reporte diario",
size: "90%",
children: (
<DailyReportView
date={date}
onJumpToCard={(cardId) => {
modals.close(id);
handleJumpToCard(cardId);
}}
/>
),
});
}, [handleJumpToCard]);
const handleSetCardDeadline = useCallback(async (id: string, deadline: string | null) => {
setBoard((prev) => {
if (!prev) return prev;
@@ -668,6 +783,26 @@ export function App() {
}
}, [reload, reloadTrash]);
const handleUnarchiveCard = useCallback(async (id: string) => {
try {
await api.unarchiveCard(id);
reload();
reloadArchive();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}, [reload, reloadArchive]);
const handleArchiveCard = useCallback(async (id: string) => {
try {
await api.archiveCard(id);
reload();
reloadArchive();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}, [reload, reloadArchive]);
const handlePurgeCard = useCallback(async (id: string) => {
modals.openConfirmModal({
title: "Borrar permanentemente",
@@ -769,9 +904,9 @@ export function App() {
modals.open({
title: card.title,
size: "md",
children: <HistoryModal card={card} />,
children: <HistoryModal card={card} columns={board?.columns ?? []} />,
});
}, []);
}, [board?.columns]);
const handleToggleCardLock = useCallback(async (id: string, locked: boolean) => {
setBoard((prev) => {
@@ -799,6 +934,81 @@ export function App() {
}
}, [reload]);
// Issue 0090: ruleta de seleccion aleatoria por columna.
// Recorre las cards visibles (post-filtro) no bloqueadas con highlight
// acelerado-decelerado y termina con flash verde sobre la ganadora.
const handlePickRandom = useCallback((columnId: string) => {
const cards = (cardsByColumn.get(columnId) || []).filter((c) => !c.locked);
if (cards.length === 0) {
notifications.show({ color: "yellow", message: "No hay cards disponibles (filtro y bloqueadas excluidas)" });
return;
}
if (cards.length === 1) {
const el = document.querySelector<HTMLElement>(`[data-card-id="${cards[0].id}"]`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("kanban-roulette-winner");
setTimeout(() => el.classList.remove("kanban-roulette-winner"), 1700);
}
return;
}
// Decide ganadora con seguridad criptografica.
const winnerIdx = (() => {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return buf[0] % cards.length;
})();
// Total steps: minimo 2 vueltas completas + offset hasta la ganadora.
const baseLaps = 2;
const totalSteps = baseLaps * cards.length + ((winnerIdx - 0 + cards.length) % cards.length);
// Decay temporal: empieza rapido (50ms), termina lento (220ms).
const startMs = 50;
const endMs = 220;
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
let step = 0;
const tick = () => {
const idx = step % cards.length;
const prevIdx = (idx - 1 + cards.length) % cards.length;
const prevEl = document.querySelector<HTMLElement>(`[data-card-id="${cards[prevIdx].id}"]`);
const currEl = document.querySelector<HTMLElement>(`[data-card-id="${cards[idx].id}"]`);
if (prevEl) prevEl.classList.remove("kanban-roulette-active");
if (currEl) {
currEl.classList.add("kanban-roulette-active");
currEl.scrollIntoView({ behavior: "smooth", block: "center" });
}
step++;
if (step > totalSteps) {
if (currEl) {
currEl.classList.remove("kanban-roulette-active");
currEl.classList.add("kanban-roulette-winner");
setTimeout(() => currEl.classList.remove("kanban-roulette-winner"), 1700);
}
return;
}
const t = totalSteps > 0 ? step / totalSteps : 1;
const delay = startMs + (endMs - startMs) * easeOut(t);
setTimeout(tick, delay);
};
tick();
}, [cardsByColumn]);
const handleSetMaxTimeMinutes = useCallback(async (id: string, max_time_minutes: number) => {
setBoard((prev) => {
if (!prev) return prev;
return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, max_time_minutes } : c)) };
});
try {
await api.updateColumn(id, { max_time_minutes });
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
reload();
}
}, [reload]);
const handleToggleDone = useCallback(async (id: string, is_done: boolean) => {
setBoard((prev) => {
if (!prev) return prev;
@@ -854,6 +1064,18 @@ export function App() {
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
{/* Issue 0091 — drag-aware left edge strip; opens sidebar on hover>=400ms */}
<div
className={
"kanban-drag-edge" +
(isDragging ? " is-active" : "") +
(edgeArmed ? " is-armed" : "")
}
data-test="kanban-drag-edge"
data-active={isDragging ? "1" : "0"}
data-armed={edgeArmed ? "1" : "0"}
aria-hidden="true"
/>
<AppShell
header={headerConfig}
navbar={navbarConfig}
@@ -983,9 +1205,12 @@ export function App() {
onMoveColumnLocation={handleMoveColumnLocation}
onDeleteColumn={handleDeleteColumn}
onSetWIPLimit={handleSetWIPLimit}
onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
onPickRandom={handlePickRandom}
onToggleDone={handleToggleDone}
onEditCard={openEditCard}
onDeleteCard={handleDeleteCard}
onDuplicateCard={handleDuplicateCard}
onChangeCardColor={handleChangeCardColor}
onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
@@ -993,6 +1218,7 @@ export function App() {
onSetCardDeadline={handleSetCardDeadline}
highlightCardId={highlightCardId}
onSetRequester={handleSetRequester}
onArchiveCard={handleArchiveCard}
requesterOptions={requesterOptions}
onOpenCustomCardColor={(cardId, current) => setCardColorModal({ cardId, color: current })}
activeSticker={activeSticker}
@@ -1056,6 +1282,51 @@ export function App() {
</Stack>
)}
</Box>
<Box style={{ borderTop: "1px solid var(--mantine-color-dark-5)", paddingTop: 8 }}>
<Button
variant="subtle"
color="gray"
size="xs"
fullWidth
justify="space-between"
leftSection={<IconCheck size={14} />}
rightSection={
<Group gap={4}>
<Badge size="xs" variant="light" color={archive.length > 0 ? "teal" : "gray"}>
{archive.length}
</Badge>
{archiveOpen ? <IconChevronDown size={12} /> : <IconChevronRight size={12} />}
</Group>
}
onClick={() => setArchiveOpen((v) => !v)}
data-test="archive-toggle"
>
Hecho (archivo)
</Button>
{archiveOpen && (
<Stack gap={4} mt={4} style={{ maxHeight: 220, overflowY: "auto" }}>
{archive.length === 0 && (
<Text size="xs" c="dimmed" px="xs">
Sin cards archivadas.
</Text>
)}
{archive.map((c) => (
<Paper key={c.id} p={6} withBorder radius="sm" bg="dark.7" data-archived-card-id={c.id}>
<Group justify="space-between" gap={4} wrap="nowrap">
<Text size="xs" truncate style={{ flex: 1 }} title={c.title}>
{c.title}
</Text>
<Tooltip label="Sacar del archivo (volver a Hecho)" withArrow>
<ActionIcon size="xs" variant="subtle" color="teal" onClick={() => handleUnarchiveCard(c.id)}>
<IconArrowBackUp size={12} />
</ActionIcon>
</Tooltip>
</Group>
</Paper>
))}
</Stack>
)}
</Box>
</Stack>
</AppShell.Navbar>
@@ -1070,7 +1341,7 @@ export function App() {
</Box>
) : activeTab === "calendar" ? (
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
<CalendarView users={users} cards={board.cards} onJumpToCard={handleJumpToCard} />
<CalendarView users={users} cards={board.cards} onJumpToCard={handleJumpToCard} onOpenDailyReport={handleOpenDailyReport} />
</Box>
) : (
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
@@ -1250,9 +1521,12 @@ export function App() {
onMoveColumnLocation={handleMoveColumnLocation}
onDeleteColumn={handleDeleteColumn}
onSetWIPLimit={handleSetWIPLimit}
onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
onPickRandom={handlePickRandom}
onToggleDone={handleToggleDone}
onEditCard={openEditCard}
onDeleteCard={handleDeleteCard}
onDuplicateCard={handleDuplicateCard}
onChangeCardColor={handleChangeCardColor}
onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
@@ -1260,6 +1534,7 @@ export function App() {
onSetCardDeadline={handleSetCardDeadline}
highlightCardId={highlightCardId}
onSetRequester={handleSetRequester}
onArchiveCard={handleArchiveCard}
requesterOptions={requesterOptions}
activeSticker={activeSticker}
onAddSticker={handleAddSticker}
+189
View File
@@ -1,7 +1,9 @@
import type {
Board,
Card,
CardFile,
CardHistoryResponse,
CardMessage,
Column,
Metrics,
MetricsFilter,
@@ -22,6 +24,10 @@ export function getBoard(): Promise<Board> {
return fetchJSON("/board");
}
export function getFlags(): Promise<Record<string, boolean>> {
return fetchJSON("/flags");
}
export function createColumn(name: string): Promise<Column> {
return fetchJSON("/columns", { method: "POST", body: JSON.stringify({ name }) });
}
@@ -33,6 +39,7 @@ export interface UpdateColumnInput {
width?: number;
wip_limit?: number;
is_done?: boolean;
max_time_minutes?: number;
}
export function updateColumn(id: string, patch: UpdateColumnInput): Promise<void> {
@@ -101,6 +108,133 @@ export function purgeCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/purge`, { method: "DELETE" });
}
export function listArchive(): Promise<Card[]> {
return fetchJSON("/archive");
}
export function archiveCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/archive`, { method: "POST" });
}
export function unarchiveCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/unarchive`, { method: "POST" });
}
export interface DailyReport {
date: string;
tz: string;
start_ts: string;
end_ts: string;
kpis: {
done: number;
created: number;
moves: number;
blocked_ms: number;
deadlines_met: number;
deadlines_missed: number;
reopened: number;
archived_auto: number;
archived_manual: number;
};
top_assignees_done: { user_id: string; name: string; count: number }[];
top_assignees_created: { user_id: string; name: string; count: number }[];
top_requesters_added: { name: string; count: number }[];
top_requesters_done: { name: string; count: number }[];
done_cards: {
id: string;
seq_num: number;
title: string;
requester: string;
assignee_id: string | null;
assignee_name: string | null;
tags: string[];
column_id: string;
column_name: string;
completed_at: string;
created_at: string;
lead_time_ms: number;
color: string;
}[];
reopened_cards: {
card_id: string;
title: string;
seq_num: number;
from_column: string;
to_column: string;
ts: string;
actor_id: string | null;
actor_name: string | null;
}[];
stale_cards: {
d7: StaleEntry[];
d14: StaleEntry[];
d30: StaleEntry[];
};
lead_time: { avg_ms: number; p50_ms: number; p95_ms: number; samples: number };
hourly_moves: number[];
deadlines: {
met: number;
missed: number;
list: {
card_id: string;
title: string;
seq_num: number;
deadline: string;
completed_at: string;
late_ms: number;
}[];
};
tags_done: { name: string; count: number }[];
archived_today: number;
}
export interface StaleEntry {
card_id: string;
title: string;
seq_num: number;
column_id: string;
column_name: string;
entered_at: string;
days: number;
}
export function dailyReport(date: string, tz?: string): Promise<DailyReport> {
const params = new URLSearchParams({ date });
if (tz) params.set("tz", tz);
return fetchJSON(`/reports/daily?${params.toString()}`);
}
export interface DailySummary {
date: string;
summary: string;
prompt?: string;
model?: string;
generated_at?: string;
generated_by?: string | null;
exists: boolean;
}
export function getDailySummary(date: string): Promise<DailySummary> {
return fetchJSON(`/reports/daily/summary?date=${encodeURIComponent(date)}`);
}
export function generateDailySummary(date: string, tz?: string): Promise<DailySummary> {
const params = new URLSearchParams({ date });
if (tz) params.set("tz", tz);
return fetchJSON(`/reports/daily/summary?${params.toString()}`, { method: "POST" });
}
export function getSetting(key: string): Promise<{ key: string; value: string }> {
return fetchJSON(`/settings/${encodeURIComponent(key)}`);
}
export function setSetting(key: string, value: string): Promise<void> {
return fetchJSON(`/settings/${encodeURIComponent(key)}`, {
method: "PUT",
body: JSON.stringify({ value }),
});
}
export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> {
return fetchJSON(`/cards/${id}/move`, {
method: "POST",
@@ -112,6 +246,25 @@ export function cardHistory(id: string): Promise<CardHistoryResponse> {
return fetchJSON(`/cards/${id}/history`);
}
export function listCardMessages(id: string): Promise<CardMessage[]> {
return fetchJSON(`/cards/${id}/messages`);
}
export function createCardMessage(id: string, body: string): Promise<CardMessage> {
return fetchJSON(`/cards/${id}/messages`, {
method: "POST",
body: JSON.stringify({ body }),
});
}
export function deleteCardMessage(cardId: string, messageId: string): Promise<void> {
return fetchJSON(`/cards/${cardId}/messages/${messageId}`, { method: "DELETE" });
}
export function duplicateCard(id: string): Promise<Card> {
return fetchJSON(`/cards/${id}/duplicate`, { method: "POST" });
}
export interface ChatMessage {
role: "user" | "assistant";
content: string;
@@ -228,6 +381,42 @@ export function listRequesters(): Promise<string[]> {
return fetchJSON("/requesters");
}
// --- Files (issue 0128) -----------------------------------------------------
export function listCardFiles(cardId: string): Promise<CardFile[]> {
return fetchJSON(`/cards/${cardId}/files`);
}
export async function uploadCardFile(
cardId: string,
file: File,
source: "upload" | "description" | "chat" = "upload"
): Promise<CardFile> {
const fd = new FormData();
fd.append("file", file);
fd.append("source", source);
const res = await fetch(`${BASE}/cards/${cardId}/files`, {
method: "POST",
credentials: "same-origin",
body: fd,
});
if (!res.ok) {
let msg = `upload failed: ${res.status}`;
try {
const body = (await res.json()) as { Message?: string; message?: string };
if (body.Message || body.message) msg = body.Message || body.message || msg;
} catch {
/* ignore */
}
throw new HTTPError(res.status, msg);
}
return (await res.json()) as CardFile;
}
export function deleteCardFile(fileId: string): Promise<void> {
return fetchJSON(`/files/${fileId}`, { method: "DELETE" });
}
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
const qs = new URLSearchParams();
if (f.from) qs.set("from", f.from);
+19 -4
View File
@@ -19,15 +19,17 @@ import { useEffect, useMemo, useState } from "react";
import * as api from "../api";
import type { Card, Metrics, User } from "../types";
// Hace clickable el numero del dia para abrir el reporte diario (issue 0093).
interface Props {
users: User[];
cards: Card[];
onJumpToCard?: (cardId: string) => void;
onOpenDailyReport?: (date: string) => void;
}
const DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"];
export function CalendarView({ users, cards, onJumpToCard }: Props) {
export function CalendarView({ users, cards, onJumpToCard, onOpenDailyReport }: Props) {
const [openDate, setOpenDate] = useState<string | null>(null);
const [month, setMonth] = useState<Date>(new Date());
const [assigneeId, setAssigneeId] = useState<string | null>(null);
@@ -199,9 +201,22 @@ export function CalendarView({ users, cards, onJumpToCard }: Props) {
}}
>
<Stack gap={2}>
<Text size="xs" fw={isToday ? 700 : 500} c={isToday ? "blue" : undefined}>
{dayNum}
</Text>
<UnstyledButton
onClick={() => cell.date && onOpenDailyReport?.(cell.date as string)}
title="Ver reporte diario"
style={{ alignSelf: "flex-start" }}
data-test={`calendar-day-${cell.date}`}
>
<Text
size="xs"
fw={isToday ? 700 : 500}
c={isToday ? "blue" : undefined}
td={onOpenDailyReport ? "underline" : undefined}
style={{ cursor: onOpenDailyReport ? "pointer" : "default" }}
>
{dayNum}
</Text>
</UnstyledButton>
{stats.created > 0 && (
<Group gap={3} wrap="nowrap">
<IconPlus size={10} color="var(--mantine-color-blue-5)" />
+277
View File
@@ -0,0 +1,277 @@
import {
ActionIcon,
Avatar,
Box,
FileButton,
Group,
Loader,
Paper,
ScrollArea,
Stack,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import { IconPaperclip, IconSend, IconTrash } from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import { DragEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from "react";
import * as api from "../api";
import type { CardMessage, User } from "../types";
import { tagColor } from "./colors";
import { formatDateTimeShort } from "./format";
import { MessageBody } from "./MessageBody";
interface Props {
cardId: string;
users: User[];
currentUserId?: string;
onMessagesChange?: (messages: CardMessage[]) => void;
onFileUploaded?: () => void;
}
function refForFile(filename: string, url: string, mime: string): string {
const safe = filename.replace(/]/g, "");
return mime.startsWith("image/") ? `![${safe}](${url})` : `[${safe}](${url})`;
}
export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange, onFileUploaded }: Props) {
const [messages, setMessages] = useState<CardMessage[]>([]);
const [loading, setLoading] = useState(true);
const [body, setBody] = useState("");
const [sending, setSending] = useState(false);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const viewportRef = useRef<HTMLDivElement | null>(null);
const usersById = new Map(users.map((u) => [u.id, u]));
const reload = useCallback(async () => {
try {
const ms = await api.listCardMessages(cardId);
setMessages(ms);
onMessagesChange?.(ms);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setLoading(false);
}
}, [cardId, onMessagesChange]);
useEffect(() => {
reload();
}, [reload]);
useEffect(() => {
if (viewportRef.current) {
viewportRef.current.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" });
}
}, [messages.length]);
const send = async () => {
const text = body.trim();
if (!text || sending) return;
setSending(true);
try {
const m = await api.createCardMessage(cardId, text);
const next = [...messages, m];
setMessages(next);
onMessagesChange?.(next);
setBody("");
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setSending(false);
}
};
const remove = async (mid: string) => {
try {
await api.deleteCardMessage(cardId, mid);
const next = messages.filter((m) => m.id !== mid);
setMessages(next);
onMessagesChange?.(next);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
const handleFiles = async (files: FileList | File[]) => {
setUploading(true);
try {
for (const file of Array.from(files)) {
try {
const cf = await api.uploadCardFile(cardId, file, "chat");
const ref = refForFile(cf.filename, cf.url, cf.mime);
const m = await api.createCardMessage(cardId, ref);
setMessages((prev) => {
const next = [...prev, m];
onMessagesChange?.(next);
return next;
});
onFileUploaded?.();
} catch (e) {
notifications.show({ color: "red", message: `${file.name}: ${(e as Error).message}` });
}
}
} finally {
setUploading(false);
}
};
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
handleFiles(e.dataTransfer.files);
};
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
setDragOver(true);
};
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
};
return (
<Stack
gap="xs"
style={{
height: "100%",
minHeight: 0,
position: "relative",
outline: dragOver ? "2px dashed var(--mantine-color-blue-5)" : undefined,
outlineOffset: dragOver ? -2 : undefined,
borderRadius: 4,
}}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
>
<ScrollArea
viewportRef={viewportRef}
style={{ flex: 1, minHeight: 200 }}
type="auto"
offsetScrollbars
>
{loading ? (
<Group justify="center" p="md"><Loader size="sm" /></Group>
) : messages.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" p="md">
Sin mensajes aun. Escribe el primero o arrastra un archivo.
</Text>
) : (
<Stack gap={6} p={4}>
{messages.map((m) => {
const author = m.author_id ? usersById.get(m.author_id) : null;
const isMe = m.author_id && m.author_id === currentUserId;
const label = author ? author.display_name || author.username : "Anonimo";
return (
<Paper
key={m.id}
withBorder
p="xs"
radius="sm"
bg={isMe ? "var(--mantine-color-blue-light)" : undefined}
>
<Group gap={6} wrap="nowrap" align="flex-start">
<Avatar size={22} radius="xl" color={author?.color || tagColor(label)}>
{label.slice(0, 2).toUpperCase()}
</Avatar>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600}>{label}</Text>
<Text size="xs" c="dimmed">{formatDateTimeShort(m.created_at)}</Text>
</Group>
{isMe && (
<Tooltip label="Borrar" withArrow>
<ActionIcon size="xs" variant="subtle" color="red" onClick={() => remove(m.id)}>
<IconTrash size={12} />
</ActionIcon>
</Tooltip>
)}
</Group>
<Stack gap={4}>
<MessageBody text={m.body} />
</Stack>
</Box>
</Group>
</Paper>
);
})}
</Stack>
)}
</ScrollArea>
<Group gap="xs" align="flex-end">
<Textarea
value={body}
onChange={(e) => setBody(e.currentTarget.value)}
onKeyDown={onKeyDown}
placeholder="Escribe un mensaje. Arrastra archivos o usa el clip."
autosize
minRows={1}
maxRows={6}
style={{ flex: 1 }}
disabled={sending}
/>
<FileButton onChange={(file) => file && handleFiles([file])} disabled={uploading}>
{(props) => (
<Tooltip label="Adjuntar archivo" withArrow>
<ActionIcon
size="lg"
variant="subtle"
color="gray"
aria-label="Adjuntar"
loading={uploading}
{...props}
>
<IconPaperclip size={16} />
</ActionIcon>
</Tooltip>
)}
</FileButton>
<Tooltip label="Enviar" withArrow>
<ActionIcon
size="lg"
variant="filled"
color="blue"
onClick={send}
disabled={!body.trim() || sending}
aria-label="Enviar"
>
<IconSend size={16} />
</ActionIcon>
</Tooltip>
</Group>
{(dragOver || uploading) && (
<Box
style={{
position: "absolute",
inset: 0,
background: "rgba(34,139,230,0.08)",
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
borderRadius: 4,
}}
>
<Text size="sm" fw={500} c="blue">
{uploading ? "Subiendo..." : "Suelta para adjuntar"}
</Text>
</Box>
)}
</Stack>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { Box, Divider, Group, Tabs } from "@mantine/core";
import { IconLink, IconMessage, IconPaperclip } from "@tabler/icons-react";
import { useState } from "react";
import type { Card, CardMessage, User } from "../types";
import { CardChatPanel } from "./CardChatPanel";
import { CardFilesPanel } from "./CardFilesPanel";
import { CardLinksPanel } from "./CardLinksPanel";
import { CardForm, CardFormValues } from "./CardForm";
interface Props {
card: Card;
users: User[];
currentUserId?: string;
requesterOptions: string[];
tagOptions: string[];
onSubmit: (v: CardFormValues) => Promise<void> | void;
onCancel: () => void;
}
export function CardEditPanel({
card,
users,
currentUserId,
requesterOptions,
tagOptions,
onSubmit,
onCancel,
}: Props) {
const [messages, setMessages] = useState<CardMessage[]>([]);
const [liveCard, setLiveCard] = useState(card);
const [filesRefreshKey, setFilesRefreshKey] = useState(0);
const wrappedSubmit = async (v: CardFormValues) => {
setLiveCard((c) => ({ ...c, title: v.title, description: v.description, requester: v.requester, tags: v.tags, assignee_id: v.assignee_id }));
await onSubmit(v);
};
const bumpFiles = () => setFilesRefreshKey((k) => k + 1);
return (
<Group align="stretch" gap="md" wrap="nowrap" style={{ minHeight: 460 }}>
<Box style={{ flex: "1 1 0", minWidth: 320 }}>
<CardForm
users={users}
requesterOptions={requesterOptions}
tagOptions={tagOptions}
initial={{
requester: liveCard.requester,
title: liveCard.title,
description: liveCard.description,
assignee_id: liveCard.assignee_id,
tags: liveCard.tags || [],
}}
submitLabel="Guardar"
cardId={liveCard.id}
onFileUploaded={bumpFiles}
onSubmit={wrappedSubmit}
onCancel={onCancel}
/>
</Box>
<Divider orientation="vertical" />
<Box style={{ flex: "1 1 0", minWidth: 320, display: "flex", flexDirection: "column" }}>
<Tabs defaultValue="chat" keepMounted={false} style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
<Tabs.List>
<Tabs.Tab value="chat" leftSection={<IconMessage size={14} />}>Chat</Tabs.Tab>
<Tabs.Tab value="links" leftSection={<IconLink size={14} />}>Enlaces</Tabs.Tab>
<Tabs.Tab value="files" leftSection={<IconPaperclip size={14} />}>Archivos</Tabs.Tab>
</Tabs.List>
<Box pt="xs" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<Tabs.Panel value="chat" style={{ flex: 1, minHeight: 0, display: "flex" }}>
<Box style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", width: "100%" }}>
<CardChatPanel
cardId={liveCard.id}
users={users}
currentUserId={currentUserId}
onMessagesChange={setMessages}
onFileUploaded={bumpFiles}
/>
</Box>
</Tabs.Panel>
<Tabs.Panel value="links">
<CardLinksPanel card={liveCard} messages={messages} />
</Tabs.Panel>
<Tabs.Panel value="files">
<CardFilesPanel cardId={liveCard.id} refreshKey={filesRefreshKey} />
</Tabs.Panel>
</Box>
</Tabs>
</Box>
</Group>
);
}
+223
View File
@@ -0,0 +1,223 @@
import {
ActionIcon,
Anchor,
Badge,
Box,
Button,
FileButton,
Group,
Image,
Loader,
Paper,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import {
IconDownload,
IconFile,
IconFileSpreadsheet,
IconFileText,
IconFileTypePdf,
IconPhoto,
IconTrash,
IconUpload,
} from "@tabler/icons-react";
import { useCallback, useEffect, useState } from "react";
import * as api from "../api";
import type { CardFile } from "../types";
interface Props {
cardId: string;
refreshKey?: number;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
function isImage(mime: string): boolean {
return mime.startsWith("image/");
}
function fileIcon(mime: string, size = 18) {
const m = mime.toLowerCase();
if (m.startsWith("image/")) return <IconPhoto size={size} />;
if (m === "application/pdf") return <IconFileTypePdf size={size} />;
if (
m.includes("spreadsheet") ||
m.includes("excel") ||
m === "text/csv" ||
m === "application/vnd.ms-excel"
) {
return <IconFileSpreadsheet size={size} />;
}
if (m.startsWith("text/")) return <IconFileText size={size} />;
return <IconFile size={size} />;
}
function sourceBadge(s: CardFile["source"]) {
if (s === "description") return { color: "blue", label: "descripcion" };
if (s === "chat") return { color: "teal", label: "chat" };
return { color: "gray", label: "subido" };
}
export function CardFilesPanel({ cardId, refreshKey }: Props) {
const [files, setFiles] = useState<CardFile[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const reload = useCallback(async () => {
try {
const list = await api.listCardFiles(cardId);
setFiles(list);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setLoading(false);
}
}, [cardId]);
useEffect(() => {
reload();
}, [reload, refreshKey]);
const onUpload = async (file: File | null) => {
if (!file) return;
setUploading(true);
try {
const cf = await api.uploadCardFile(cardId, file, "upload");
setFiles((prev) => [...prev, cf]);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setUploading(false);
}
};
const onDelete = async (id: string) => {
if (!window.confirm("¿Borrar este archivo?")) return;
try {
await api.deleteCardFile(id);
setFiles((prev) => prev.filter((f) => f.id !== id));
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
};
return (
<Stack gap="xs" p={4}>
<Group justify="space-between">
<Text size="xs" c="dimmed">
{files.length} archivo{files.length === 1 ? "" : "s"}
</Text>
<FileButton onChange={onUpload} disabled={uploading}>
{(props) => (
<Button
size="xs"
variant="light"
leftSection={<IconUpload size={14} />}
loading={uploading}
{...props}
>
Subir
</Button>
)}
</FileButton>
</Group>
{loading ? (
<Group justify="center" p="md">
<Loader size="sm" />
</Group>
) : files.length === 0 ? (
<Stack gap="xs" p="md" align="center" justify="center" style={{ minHeight: 160 }}>
<Text size="sm" c="dimmed">
Sin archivos
</Text>
<Text size="xs" c="dimmed" ta="center">
Sube archivos con el boton, arrastra al chat o a la descripcion.
</Text>
</Stack>
) : (
<Stack gap={6}>
{files.map((f) => {
const badge = sourceBadge(f.source);
return (
<Paper key={f.id} withBorder p="xs" radius="sm">
<Group gap="xs" wrap="nowrap" align="flex-start">
{isImage(f.mime) ? (
<Anchor href={f.url} target="_blank" rel="noopener noreferrer">
<Image
src={f.url}
alt={f.filename}
w={64}
h={64}
fit="cover"
radius="sm"
/>
</Anchor>
) : (
<Box
style={{
width: 64,
height: 64,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--mantine-color-gray-1)",
borderRadius: 4,
}}
>
{fileIcon(f.mime, 28)}
</Box>
)}
<Box style={{ flex: 1, minWidth: 0 }}>
<Anchor href={f.url} target="_blank" rel="noopener noreferrer" size="sm" style={{ wordBreak: "break-all" }}>
{f.filename}
</Anchor>
<Group gap={6} mt={4}>
<Badge size="xs" variant="light" color={badge.color}>
{badge.label}
</Badge>
<Text size="xs" c="dimmed">{formatSize(f.size)}</Text>
<Text size="xs" c="dimmed">{f.mime || "?"}</Text>
</Group>
</Box>
<Group gap={4} wrap="nowrap">
<Tooltip label="Descargar" withArrow>
<ActionIcon
component="a"
href={f.url}
download={f.filename}
variant="subtle"
size="sm"
aria-label="Descargar"
>
<IconDownload size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label="Borrar" withArrow>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => onDelete(f.id)}
aria-label="Borrar"
>
<IconTrash size={14} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
);
})}
</Stack>
)}
</Stack>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MantineProvider } from "@mantine/core";
import { CardForm } from "./CardForm";
function renderForm(overrides: Partial<Parameters<typeof CardForm>[0]> = {}) {
const onSubmit = vi.fn();
const onCancel = vi.fn();
render(
<MantineProvider>
<CardForm
requesterOptions={["Alice", "Anna", "Bob", "Enmanuel"]}
onSubmit={onSubmit}
onCancel={onCancel}
{...overrides}
/>
</MantineProvider>
);
return { onSubmit, onCancel };
}
describe("CardForm — requester input (issue 0088)", () => {
it("solicitante entra vacio cuando initial.requester no se pasa", () => {
renderForm();
const requesterInput = (document.querySelector('input[data-field="requester"]') as HTMLInputElement) as HTMLInputElement;
expect(requesterInput.value).toBe("");
});
it("Enter dentro del requester NO dispara onSubmit (dropdown cerrado o abierto)", async () => {
const user = userEvent.setup();
const { onSubmit } = renderForm();
// Necesita un titulo valido para que un eventual submit no se ignore por el guard.
const title = screen.getByLabelText(/Tarea/i);
await user.type(title, "Mi tarea");
const requester = (document.querySelector('input[data-field="requester"]') as HTMLInputElement);
await user.click(requester);
await user.keyboard("{Enter}");
expect(onSubmit).not.toHaveBeenCalled();
await user.type(requester, "An");
await user.keyboard("{Enter}");
expect(onSubmit).not.toHaveBeenCalled();
});
// Navegacion ArrowDown + Enter del dropdown la maneja Mantine internamente.
// Validar eso en jsdom es fragil (portals + virtual focus). Cubierto en e2e
// Playwright donde corre browser real.
it("submit solo via boton Crear", async () => {
const user = userEvent.setup();
const { onSubmit } = renderForm({ submitLabel: "Crear" });
const title = screen.getByLabelText(/Tarea/i);
await user.type(title, "Mi tarea");
const requester = (document.querySelector('input[data-field="requester"]') as HTMLInputElement);
await user.type(requester, "Anna");
await user.click(screen.getByRole("button", { name: /Crear/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
expect(onSubmit.mock.calls[0][0]).toMatchObject({
title: "Mi tarea",
requester: "Anna",
});
});
});
+127 -20
View File
@@ -1,7 +1,14 @@
import { Autocomplete, Button, Group, Select, Stack, TagsInput, Textarea } from "@mantine/core";
import { FormEvent, KeyboardEvent, useState } from "react";
import { Autocomplete, Box, Button, Group, Select, Stack, TagsInput, Text, Textarea } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { DragEvent, FormEvent, KeyboardEvent, useRef, useState } from "react";
import * as api from "../api";
import type { User } from "../types";
// CardForm: Solicitante (Autocomplete) intencionadamente NO hace submit en Enter.
// Enter dentro del Autocomplete deja que Mantine seleccione el item resaltado del
// dropdown sin cerrar el formulario. Submit solo via boton "Crear" o Ctrl+Enter
// en descripcion. Ver issue 0088.
export interface CardFormValues {
requester: string;
title: string;
@@ -16,16 +23,25 @@ interface Props {
users?: User[];
requesterOptions?: string[];
tagOptions?: string[];
cardId?: string;
onFileUploaded?: () => void;
onSubmit: (v: CardFormValues) => Promise<void> | void;
onCancel: () => void;
}
function markdownRef(filename: string, url: string, isImage: boolean): string {
const safeName = filename.replace(/]/g, "");
return isImage ? `![${safeName}](${url})` : `[${safeName}](${url})`;
}
export function CardForm({
initial,
submitLabel = "Guardar",
users = [],
requesterOptions = [],
tagOptions = [],
cardId,
onFileUploaded,
onSubmit,
onCancel,
}: Props) {
@@ -34,6 +50,9 @@ export function CardForm({
const [description, setDescription] = useState(initial?.description ?? "");
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const submit = async (e?: FormEvent) => {
e?.preventDefault();
@@ -48,12 +67,6 @@ export function CardForm({
});
};
const enterSubmit = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const textareaEnter = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
@@ -61,6 +74,66 @@ export function CardForm({
}
};
const insertAtCursor = (snippet: string) => {
const ta = textareaRef.current;
if (!ta) {
setDescription((d) => (d ? d + "\n" + snippet : snippet));
return;
}
const start = ta.selectionStart ?? description.length;
const end = ta.selectionEnd ?? description.length;
const before = description.slice(0, start);
const after = description.slice(end);
const sep = before && !before.endsWith("\n") ? "\n" : "";
const next = before + sep + snippet + after;
setDescription(next);
queueMicrotask(() => {
ta.focus();
const pos = (before + sep + snippet).length;
ta.setSelectionRange(pos, pos);
});
};
const handleFiles = async (files: FileList | File[]) => {
if (!cardId) {
notifications.show({ color: "yellow", message: "Guarda la tarjeta antes de subir archivos." });
return;
}
setUploading(true);
try {
for (const file of Array.from(files)) {
try {
const cf = await api.uploadCardFile(cardId, file, "description");
insertAtCursor(markdownRef(cf.filename, cf.url, cf.mime.startsWith("image/")));
onFileUploaded?.();
} catch (e) {
notifications.show({ color: "red", message: `${file.name}: ${(e as Error).message}` });
}
}
} finally {
setUploading(false);
}
};
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
handleFiles(e.dataTransfer.files);
};
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!cardId) return;
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
setDragOver(true);
};
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
};
return (
<form onSubmit={submit}>
<Stack gap="sm">
@@ -89,21 +162,55 @@ export function CardForm({
data={requesterOptions}
tabIndex={2}
autoComplete="off"
onKeyDown={enterSubmit}
data-field="requester"
placeholder="Empieza a escribir y elige uno existente"
limit={10}
onKeyDown={(e) => {
if (e.key === "Enter") e.preventDefault();
}}
/>
<Textarea
label="Descripcion"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
tabIndex={3}
autosize
minRows={3}
maxRows={8}
onKeyDown={textareaEnter}
description="Ctrl+Enter para guardar"
/>
<Box
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
style={{
position: "relative",
outline: dragOver ? "2px dashed var(--mantine-color-blue-5)" : undefined,
outlineOffset: dragOver ? 2 : undefined,
borderRadius: 4,
}}
>
<Textarea
ref={textareaRef}
label="Descripcion"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
tabIndex={3}
autosize
minRows={3}
maxRows={8}
onKeyDown={textareaEnter}
description={cardId ? "Ctrl+Enter para guardar. Arrastra archivos para adjuntar." : "Ctrl+Enter para guardar"}
/>
{(dragOver || uploading) && (
<Box
style={{
position: "absolute",
inset: 0,
background: "rgba(34,139,230,0.08)",
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
borderRadius: 4,
}}
>
<Text size="sm" fw={500} c="blue">
{uploading ? "Subiendo..." : "Suelta para adjuntar"}
</Text>
</Box>
)}
</Box>
<Select
label="Asignar a"
placeholder="Sin asignar"
+104
View File
@@ -0,0 +1,104 @@
import { Anchor, Badge, Box, Group, Paper, Stack, Text } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { useMemo } from "react";
import type { Card, CardMessage } from "../types";
interface ExtractedLink {
url: string;
source: "title" | "description" | "chat";
context: string;
}
const URL_RE = /(https?:\/\/[^\s<>()"']+)/gi;
function extract(source: ExtractedLink["source"], text: string): ExtractedLink[] {
if (!text) return [];
const out: ExtractedLink[] = [];
const seen = new Set<string>();
let m: RegExpExecArray | null;
URL_RE.lastIndex = 0;
while ((m = URL_RE.exec(text)) !== null) {
let url = m[1];
// Strip common trailing punctuation that isn't part of a URL.
url = url.replace(/[.,;:!?)\]}>]+$/, "");
if (seen.has(url)) continue;
seen.add(url);
out.push({ url, source, context: text });
}
return out;
}
function hostname(u: string): string {
try {
return new URL(u).hostname;
} catch {
return u;
}
}
interface Props {
card: Card;
messages: CardMessage[];
}
export function CardLinksPanel({ card, messages }: Props) {
const links = useMemo<ExtractedLink[]>(() => {
const all: ExtractedLink[] = [
...extract("title", card.title),
...extract("description", card.description),
...messages.flatMap((m) => extract("chat", m.body)),
];
const seen = new Set<string>();
return all.filter((l) => {
if (seen.has(l.url)) return false;
seen.add(l.url);
return true;
});
}, [card.title, card.description, messages]);
if (links.length === 0) {
return (
<Stack gap="xs" p="md" align="center" justify="center" style={{ minHeight: 200 }}>
<Text size="sm" c="dimmed">Sin enlaces detectados</Text>
<Text size="xs" c="dimmed" ta="center">
Pega URLs en el titulo, descripcion o chat y apareceran aqui.
</Text>
</Stack>
);
}
const badgeColor = (s: ExtractedLink["source"]): string => {
if (s === "title") return "grape";
if (s === "description") return "blue";
return "teal";
};
const badgeLabel = (s: ExtractedLink["source"]): string => {
if (s === "title") return "titulo";
if (s === "description") return "descripcion";
return "chat";
};
return (
<Stack gap={6} p={4}>
{links.map((l) => (
<Paper key={l.url} withBorder p="xs" radius="sm">
<Group gap="xs" wrap="nowrap" justify="space-between" align="flex-start">
<Box style={{ flex: 1, minWidth: 0 }}>
<Anchor href={l.url} target="_blank" rel="noopener noreferrer" size="sm" style={{ wordBreak: "break-all" }}>
<Group gap={4} wrap="nowrap" align="center">
<IconExternalLink size={12} />
<span>{hostname(l.url)}</span>
</Group>
</Anchor>
<Text size="xs" c="dimmed" style={{ wordBreak: "break-all" }}>{l.url}</Text>
</Box>
<Badge size="xs" variant="light" color={badgeColor(l.source)}>
{badgeLabel(l.source)}
</Badge>
</Group>
</Paper>
))}
</Stack>
);
}
+810
View File
@@ -0,0 +1,810 @@
import {
ActionIcon,
Alert,
Avatar,
Badge,
Box,
Button,
Card as MCard,
Divider,
Group,
Loader,
Modal,
Paper,
ScrollArea,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
Title,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { BarChart } from "@mantine/charts";
import {
IconAlertTriangle,
IconArrowBackUp,
IconCalendarStats,
IconCheck,
IconClock,
IconDownload,
IconHourglass,
IconLock,
IconPlus,
IconRefresh,
IconSettings,
IconSparkles,
IconTrendingUp,
} from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import {
dailyReport,
generateDailySummary,
getDailySummary,
getSetting,
setSetting,
type DailyReport as Report,
type DailySummary,
} from "../api";
import { formatDuration } from "./format";
import { tagColor } from "./colors";
interface Props {
date: string; // YYYY-MM-DD
onJumpToCard?: (cardId: string) => void;
}
const PROMPT_KEY = "daily_report_prompt";
const PROMPT_DEFAULT =
"Eres un coach de equipo. Resume el reporte diario en un MAXIMO de 4 frases cortas, mencionando: (1) total de tareas hechas y quien destaco, (2) cualquier card reabierta o deadline vencido que merezca atencion, (3) cards estancadas criticas (30+ dias) si las hay, (4) una frase corta de animo o aviso si toca. Tono natural, primera persona del plural, sin emojis. No inventes datos; usa solo los del JSON del reporte.";
function fmtDate(s: string): string {
try {
const d = new Date(s + "T00:00:00");
return d.toLocaleDateString("es-ES", {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
});
} catch {
return s;
}
}
function KPI({
label,
value,
color,
icon,
sub,
}: {
label: string;
value: string | number;
color?: string;
icon?: React.ReactNode;
sub?: string;
}) {
return (
<Paper p="sm" withBorder radius="md">
<Group gap={6} mb={2} align="center">
{icon}
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
</Group>
<Text fz={28} fw={700} c={color}>
{value}
</Text>
{sub && (
<Text size="xs" c="dimmed">
{sub}
</Text>
)}
</Paper>
);
}
function RankingList<T extends { name: string; count: number; user_id?: string }>({
title,
rows,
emptyText,
withAvatar = false,
}: {
title: string;
rows: T[];
emptyText: string;
withAvatar?: boolean;
}) {
return (
<MCard withBorder radius="md" p="sm">
<Text fw={600} size="sm" mb={6}>
{title}
</Text>
{rows.length === 0 ? (
<Text size="xs" c="dimmed">
{emptyText}
</Text>
) : (
<Stack gap={4}>
{rows.map((r, i) => (
<Group key={(r.user_id || r.name) + i} gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
{withAvatar && (
<Avatar size={22} radius="xl" color={tagColor(r.name || String(i))}>
{(r.name || "?").slice(0, 2).toUpperCase()}
</Avatar>
)}
<Text size="sm" truncate>
{r.name || "(sin nombre)"}
</Text>
</Group>
<Badge size="sm" variant="light" color={i === 0 ? "teal" : "gray"}>
{r.count}
</Badge>
</Group>
))}
</Stack>
)}
</MCard>
);
}
export function DailyReportView({ date, onJumpToCard }: Props) {
const [data, setData] = useState<Report | null>(null);
const [err, setErr] = useState<string | null>(null);
const [summary, setSummary] = useState<DailySummary | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryErr, setSummaryErr] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [promptDraft, setPromptDraft] = useState("");
const [filterRequester, setFilterRequester] = useState<string | null>(null);
const [filterAssignee, setFilterAssignee] = useState<string | null>(null);
useEffect(() => {
setData(null);
setErr(null);
dailyReport(date)
.then(setData)
.catch((e) => setErr((e as Error).message));
setSummary(null);
setSummaryErr(null);
getDailySummary(date)
.then((s) => setSummary(s.exists ? s : null))
.catch(() => {});
}, [date]);
const regenerateSummary = async () => {
setSummaryLoading(true);
setSummaryErr(null);
try {
const s = await generateDailySummary(date);
setSummary({ ...s, exists: true });
} catch (e) {
setSummaryErr((e as Error).message);
} finally {
setSummaryLoading(false);
}
};
const openSettings = async () => {
try {
const s = await getSetting(PROMPT_KEY);
setPromptDraft(s.value || PROMPT_DEFAULT);
} catch {
setPromptDraft(PROMPT_DEFAULT);
}
setSettingsOpen(true);
};
const saveSettings = async () => {
await setSetting(PROMPT_KEY, promptDraft);
setSettingsOpen(false);
};
const resetSettings = () => setPromptDraft(PROMPT_DEFAULT);
const hourlyChartData = useMemo(() => {
if (!data) return [];
return data.hourly_moves.map((n, h) => ({
hora: String(h).padStart(2, "0") + ":00",
movimientos: n,
}));
}, [data]);
const requesterOptions = useMemo(() => {
if (!data) return [];
const set = new Set<string>();
for (const c of data.done_cards) if (c.requester) set.add(c.requester);
return Array.from(set).sort();
}, [data]);
const assigneeOptions = useMemo(() => {
if (!data) return [];
const m = new Map<string, string>();
for (const c of data.done_cards) {
if (c.assignee_id) m.set(c.assignee_id, c.assignee_name || c.assignee_id);
}
return Array.from(m.entries()).map(([value, label]) => ({ value, label }));
}, [data]);
const filteredDoneCards = useMemo(() => {
if (!data) return [];
return data.done_cards.filter((c) => {
if (filterRequester && c.requester !== filterRequester) return false;
if (filterAssignee && c.assignee_id !== filterAssignee) return false;
return true;
});
}, [data, filterRequester, filterAssignee]);
const exportPDF = () => {
if (!data) return;
const win = window.open("", "_blank");
if (!win) return;
const origin = window.location.origin;
const dateLabel = (() => {
try {
return new Date(data.date + "T00:00:00").toLocaleDateString("es-ES", {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
});
} catch {
return data.date;
}
})();
const filterSub: string[] = [];
if (filterRequester) filterSub.push(`solicitante=${filterRequester}`);
if (filterAssignee) {
const a = assigneeOptions.find((o) => o.value === filterAssignee);
filterSub.push(`asignado=${a?.label || filterAssignee}`);
}
const escape = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const rows = filteredDoneCards
.map((c) => {
const tags = (c.tags || []).map(escape).join(", ");
const link = `${origin}/?card=${c.id}`;
return `<tr>
<td class="num">${String(c.seq_num).padStart(5, "0")}</td>
<td><a href="${link}">${escape(c.title)}</a></td>
<td>${escape(c.requester || "")}</td>
<td>${escape(c.assignee_name || "")}</td>
<td>${escape(tags)}</td>
<td class="num">${formatDuration(c.lead_time_ms)}</td>
</tr>`;
})
.join("");
const html = `<!doctype html>
<html lang="es"><head><meta charset="utf-8" />
<title>Reporte ${data.date}</title>
<style>
@page { margin: 18mm 15mm; }
body { font-family: system-ui, sans-serif; color: #222; }
h1 { font-size: 18pt; margin-bottom: 4px; }
.sub { color: #666; font-size: 10pt; margin-bottom: 16px; }
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 18px; }
.kpi { border: 1px solid #ddd; border-radius: 6px; padding: 8px; }
.kpi .l { font-size: 8pt; color: #888; text-transform: uppercase; }
.kpi .v { font-size: 16pt; font-weight: 700; }
table { width: 100%; border-collapse: collapse; font-size: 9pt; }
th, td { border-bottom: 1px solid #e5e5e5; padding: 6px 4px; text-align: left; vertical-align: top; }
th { background: #f5f5f5; font-weight: 600; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
a { color: #1c7ed6; text-decoration: none; }
a:hover { text-decoration: underline; }
footer { margin-top: 20px; font-size: 8pt; color: #888; }
</style></head><body>
<h1>Reporte diario · ${escape(dateLabel)}</h1>
<div class="sub">${escape(data.date)} · ${escape(data.tz)}${
filterSub.length ? " · filtros: " + filterSub.map(escape).join(", ") : ""
}</div>
<div class="kpis">
<div class="kpi"><div class="l">Hechas</div><div class="v">${filteredDoneCards.length}</div></div>
<div class="kpi"><div class="l">Lead time avg</div><div class="v">${formatDuration(data.lead_time.avg_ms)}</div></div>
<div class="kpi"><div class="l">Deadlines on-time</div><div class="v">${data.deadlines.met}/${data.deadlines.met + data.deadlines.missed}</div></div>
<div class="kpi"><div class="l">Reabiertas</div><div class="v">${data.kpis.reopened}</div></div>
</div>
${summary?.summary ? `<p style="border-left:4px solid #1c7ed6; padding:8px 12px; background:#eef6fd; border-radius:4px;">${escape(summary.summary)}</p>` : ""}
<table>
<thead><tr>
<th class="num">#</th>
<th>Titulo</th>
<th>Solicitante</th>
<th>Asignado</th>
<th>Tags</th>
<th class="num">Lead time</th>
</tr></thead>
<tbody>${rows || '<tr><td colspan="6" style="text-align:center;color:#888;">Sin tareas que cumplan el filtro.</td></tr>'}</tbody>
</table>
<footer>Generado por kanban · ${escape(origin)}</footer>
<script>window.addEventListener("load", () => setTimeout(() => window.print(), 250));</script>
</body></html>`;
win.document.write(html);
win.document.close();
};
if (err) {
return (
<Alert color="red" icon={<IconAlertTriangle size={14} />}>
{err}
</Alert>
);
}
if (!data) {
return (
<Group justify="center" p="xl">
<Loader size="sm" />
</Group>
);
}
const k = data.kpis;
const onTimePct =
k.deadlines_met + k.deadlines_missed > 0
? Math.round((k.deadlines_met / (k.deadlines_met + k.deadlines_missed)) * 100)
: null;
return (
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Group gap={6}>
<IconCalendarStats size={20} />
<Title order={4}>Reporte diario</Title>
</Group>
<Text size="sm" c="dimmed" tt="capitalize">
{fmtDate(data.date)}
</Text>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4, md: 6 }} spacing="xs">
<KPI label="Hechas" value={k.done} color="teal" icon={<IconCheck size={14} color="var(--mantine-color-teal-6)" />} />
<KPI label="Creadas" value={k.created} icon={<IconPlus size={14} />} />
<KPI label="Movimientos" value={k.moves} icon={<IconRefresh size={14} />} />
<KPI
label="Bloqueado"
value={formatDuration(k.blocked_ms)}
color="yellow"
icon={<IconLock size={14} color="var(--mantine-color-yellow-6)" />}
/>
<KPI
label="Reabiertas"
value={k.reopened}
color={k.reopened > 0 ? "orange" : undefined}
icon={<IconArrowBackUp size={14} />}
/>
<KPI
label="Deadlines"
value={onTimePct != null ? `${onTimePct}%` : "—"}
color={onTimePct == null ? "dimmed" : onTimePct >= 80 ? "teal" : "red"}
sub={`${k.deadlines_met} on-time / ${k.deadlines_missed} vencidos`}
icon={<IconHourglass size={14} />}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="xs">
<RankingList
title="Asignado: mas hechas"
rows={data.top_assignees_done}
emptyText="Sin hechas con asignado."
withAvatar
/>
<RankingList
title="Asignado: mas creadas"
rows={data.top_assignees_created}
emptyText="Sin actor en creadas."
withAvatar
/>
<RankingList
title="Solicitante: mas atendidas"
rows={data.top_requesters_done}
emptyText="Sin solicitantes con hechas."
/>
<RankingList
title="Solicitante: mas aportadas"
rows={data.top_requesters_added}
emptyText="Sin nuevas con solicitante."
/>
</SimpleGrid>
{/* Bocadillo de agente — encima de tareas hechas */}
<Paper
withBorder
radius="md"
p="sm"
bg="var(--mantine-color-blue-light)"
style={{ borderLeftWidth: 4, borderLeftColor: "var(--mantine-color-blue-6)" }}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={6} align="flex-start" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<IconSparkles size={18} color="var(--mantine-color-blue-6)" style={{ flexShrink: 0, marginTop: 2 }} />
<Box style={{ flex: 1 }}>
{summaryErr && (
<Alert color="red" mb={4} icon={<IconAlertTriangle size={14} />}>
{summaryErr}
</Alert>
)}
{summaryLoading ? (
<Group gap={6}><Loader size="xs" /><Text size="sm" c="dimmed">Generando resumen</Text></Group>
) : summary?.summary ? (
<>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>{summary.summary}</Text>
{summary.generated_at && (
<Text size="xs" c="dimmed" mt={4}>
Generado {new Date(summary.generated_at).toLocaleString()} · {summary.model}
</Text>
)}
</>
) : (
<Text size="sm" c="dimmed" fs="italic">Aun no hay resumen del dia. Pulsa "Generar".</Text>
)}
</Box>
</Group>
<Group gap={4} wrap="nowrap">
<Tooltip label={summary?.exists ? "Regenerar" : "Generar"} withArrow>
<ActionIcon variant="subtle" color="blue" onClick={regenerateSummary} loading={summaryLoading} aria-label="Regenerar resumen">
<IconRefresh size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Configurar prompt" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={openSettings} aria-label="Configurar prompt">
<IconSettings size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
<MCard withBorder radius="md" p="sm">
<Group justify="space-between" mb="xs" wrap="wrap" gap={6}>
<Group gap={6} wrap="wrap">
<Text fw={600} size="sm">
Tareas hechas
</Text>
<Badge size="xs" variant="light">
N {filteredDoneCards.length}
{filteredDoneCards.length !== data.done_cards.length ? ` / ${data.done_cards.length}` : ""}
</Badge>
<Text size="xs" c="dimmed">
Lead time avg {data.lead_time.samples > 0 ? formatDuration(data.lead_time.avg_ms) : "—"} · p50{" "}
{data.lead_time.samples > 0 ? formatDuration(data.lead_time.p50_ms) : "—"} · p95{" "}
{data.lead_time.samples > 0 ? formatDuration(data.lead_time.p95_ms) : "—"}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
<Select
size="xs"
placeholder="Solicitante"
data={requesterOptions}
value={filterRequester}
onChange={setFilterRequester}
clearable
searchable
style={{ width: 160 }}
aria-label="Filtrar por solicitante"
/>
<Select
size="xs"
placeholder="Asignado"
data={assigneeOptions}
value={filterAssignee}
onChange={setFilterAssignee}
clearable
searchable
style={{ width: 160 }}
aria-label="Filtrar por asignado"
/>
<Button
size="xs"
leftSection={<IconDownload size={14} />}
variant="light"
onClick={exportPDF}
data-test="daily-report-pdf"
>
PDF
</Button>
</Group>
</Group>
{filteredDoneCards.length === 0 ? (
<Text size="xs" c="dimmed">
Sin hechas en este dia.
</Text>
) : (
<ScrollArea style={{ maxHeight: 280 }} type="auto">
<Table verticalSpacing={4} fz="xs" highlightOnHover striped="even">
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 70 }}>#</Table.Th>
<Table.Th>Titulo</Table.Th>
<Table.Th>Solicitante</Table.Th>
<Table.Th>Asignado</Table.Th>
<Table.Th>Tags</Table.Th>
<Table.Th style={{ width: 110 }}>Lead time</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredDoneCards.map((c) => (
<Table.Tr key={c.id}>
<Table.Td>
<Text size="xs" c="dimmed">
{String(c.seq_num).padStart(5, "0")}
</Text>
</Table.Td>
<Table.Td>
<UnstyledButton onClick={() => onJumpToCard?.(c.id)} style={{ textAlign: "left" }}>
<Text size="xs" fw={500} td="underline">
{c.title}
</Text>
</UnstyledButton>
</Table.Td>
<Table.Td>
<Text size="xs">{c.requester || "—"}</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{c.assignee_name || "—"}</Text>
</Table.Td>
<Table.Td>
<Group gap={2} wrap="wrap">
{(c.tags || []).slice(0, 3).map((t) => (
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">
{t}
</Badge>
))}
</Group>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{formatDuration(c.lead_time_ms)}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
)}
</MCard>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
<MCard withBorder radius="md" p="sm">
<Group justify="space-between" mb={6}>
<Text fw={600} size="sm">
Movimientos por hora
</Text>
<Badge size="xs" variant="light">
{k.moves}
</Badge>
</Group>
{k.moves === 0 ? (
<Text size="xs" c="dimmed">
Sin movimientos.
</Text>
) : (
<BarChart
h={160}
data={hourlyChartData}
dataKey="hora"
series={[{ name: "movimientos", color: "blue.6" }]}
tickLine="y"
withTooltip
valueFormatter={(v: number) => String(v)}
/>
)}
</MCard>
<MCard withBorder radius="md" p="sm">
<Text fw={600} size="sm" mb={6}>
Tags trabajadas
</Text>
{data.tags_done.length === 0 ? (
<Text size="xs" c="dimmed">
Sin tags.
</Text>
) : (
<Group gap={4} wrap="wrap">
{data.tags_done.map((t) => (
<Badge key={t.name} variant="light" color={tagColor(t.name)} size="sm">
{t.name} · {t.count}
</Badge>
))}
</Group>
)}
</MCard>
</SimpleGrid>
{data.reopened_cards.length > 0 && (
<MCard withBorder radius="md" p="sm">
<Group gap={6} mb={6}>
<IconArrowBackUp size={14} color="var(--mantine-color-orange-6)" />
<Text fw={600} size="sm">
Reabiertas (Done otra)
</Text>
<Badge size="xs" variant="light" color="orange">
{data.reopened_cards.length}
</Badge>
</Group>
<Stack gap={4}>
{data.reopened_cards.map((r) => (
<Group key={r.card_id + r.ts} gap={6} wrap="nowrap" justify="space-between">
<UnstyledButton onClick={() => onJumpToCard?.(r.card_id)} style={{ minWidth: 0, flex: 1 }}>
<Text size="xs" truncate td="underline">
{r.title}
</Text>
</UnstyledButton>
<Text size="xs" c="dimmed">
{r.from_column} {r.to_column}
</Text>
{r.actor_name && (
<Badge size="xs" variant="light" color="cyan">
{r.actor_name}
</Badge>
)}
</Group>
))}
</Stack>
</MCard>
)}
{(data.deadlines.missed > 0 || data.deadlines.met > 0) && (
<MCard withBorder radius="md" p="sm">
<Group gap={6} mb={6}>
<IconHourglass size={14} />
<Text fw={600} size="sm">
Deadlines
</Text>
<Badge size="xs" variant="light" color="teal">
{data.deadlines.met} on-time
</Badge>
<Badge size="xs" variant="light" color="red">
{data.deadlines.missed} vencidos
</Badge>
</Group>
{data.deadlines.list.length > 0 && (
<Stack gap={4}>
{data.deadlines.list.map((d) => (
<Group key={d.card_id} gap={6} justify="space-between" wrap="nowrap">
<UnstyledButton onClick={() => onJumpToCard?.(d.card_id)} style={{ minWidth: 0, flex: 1 }}>
<Text size="xs" truncate td="underline">
{d.title}
</Text>
</UnstyledButton>
<Text size="xs" c="red">
+{formatDuration(d.late_ms)} tarde
</Text>
</Group>
))}
</Stack>
)}
</MCard>
)}
<MCard withBorder radius="md" p="sm">
<Group gap={6} mb={6}>
<IconTrendingUp size={14} />
<Text fw={600} size="sm">
Cards estancadas (al final del dia)
</Text>
<Badge size="xs" variant="light" color="orange">
{data.stale_cards.d7.length}d7
</Badge>
<Badge size="xs" variant="light" color="red">
{data.stale_cards.d14.length}d14
</Badge>
<Badge size="xs" variant="filled" color="red">
{data.stale_cards.d30.length}d30
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
<Box>
<Text size="xs" fw={500} c="orange" mb={4}>
7-13 dias
</Text>
<Stack gap={2}>
{data.stale_cards.d7.slice(0, 8).map((s) => (
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
<Text size="xs" truncate>
{s.title}{" "}
<Text span c="dimmed" size="xs">
· {s.column_name} · {s.days}d
</Text>
</Text>
</UnstyledButton>
))}
{data.stale_cards.d7.length === 0 && (
<Text size="xs" c="dimmed">
Ninguna.
</Text>
)}
</Stack>
</Box>
<Box>
<Text size="xs" fw={500} c="red" mb={4}>
14-29 dias
</Text>
<Stack gap={2}>
{data.stale_cards.d14.slice(0, 8).map((s) => (
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
<Text size="xs" truncate>
{s.title}{" "}
<Text span c="dimmed" size="xs">
· {s.column_name} · {s.days}d
</Text>
</Text>
</UnstyledButton>
))}
{data.stale_cards.d14.length === 0 && (
<Text size="xs" c="dimmed">
Ninguna.
</Text>
)}
</Stack>
</Box>
<Box>
<Text size="xs" fw={500} c="red.8" mb={4}>
30+ dias
</Text>
<Stack gap={2}>
{data.stale_cards.d30.slice(0, 8).map((s) => (
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
<Text size="xs" truncate fw={600}>
{s.title}{" "}
<Text span c="dimmed" size="xs" fw={400}>
· {s.column_name} · {s.days}d
</Text>
</Text>
</UnstyledButton>
))}
{data.stale_cards.d30.length === 0 && (
<Text size="xs" c="dimmed">
Ninguna.
</Text>
)}
</Stack>
</Box>
</SimpleGrid>
</MCard>
<Divider />
<Group gap={6} justify="space-between">
<Group gap={4}>
<IconClock size={14} />
<Text size="xs" c="dimmed">
TZ: {data.tz} · cards archivadas hoy: {data.archived_today}
</Text>
</Group>
</Group>
<Modal opened={settingsOpen} onClose={() => setSettingsOpen(false)} title="Prompt del agente diario" size="lg" zIndex={500}>
<Stack gap="sm">
<Text size="xs" c="dimmed">
Plantilla que el agente recibe junto al JSON del reporte. Compartida por todos los usuarios.
</Text>
<Textarea
autosize
minRows={6}
maxRows={20}
value={promptDraft}
onChange={(e) => setPromptDraft(e.currentTarget.value)}
data-test="daily-report-prompt"
/>
<Group justify="space-between">
<Button size="xs" variant="subtle" onClick={resetSettings}>
Restablecer por defecto
</Button>
<Group gap={6}>
<Button size="xs" variant="subtle" color="gray" onClick={() => setSettingsOpen(false)}>
Cancelar
</Button>
<Button size="xs" onClick={saveSettings} data-test="daily-report-prompt-save">
Guardar
</Button>
</Group>
</Group>
</Stack>
</Modal>
</Stack>
);
}
+82 -17
View File
@@ -1,8 +1,9 @@
import { Badge, Divider, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
import { Badge, Divider, Group, Loader, Stack, Table, Text, Timeline } from "@mantine/core";
import {
IconArrowsHorizontal,
IconCalendarDue,
IconCalendarOff,
IconCheck,
IconColumns3,
IconEdit,
IconLock,
@@ -16,11 +17,12 @@ import {
} from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import { cardHistory, listUsers } from "../api";
import type { Card, CardEvent, CardHistoryResponse, User } from "../types";
import type { Card, CardEvent, CardHistoryResponse, Column, User } from "../types";
import { formatDuration } from "./format";
interface Props {
card: Card;
columns?: Column[];
}
interface UnifiedEvent {
@@ -31,6 +33,7 @@ interface UnifiedEvent {
detail: string;
icon: React.ReactNode;
color: string;
doneColumn?: boolean;
}
function parsePayload(p: string): Record<string, unknown> {
@@ -67,10 +70,18 @@ function eventToUnified(e: CardEvent): UnifiedEvent {
}
}
export function HistoryModal({ card }: Props) {
export function HistoryModal({ card, columns = [] }: Props) {
const [data, setData] = useState<CardHistoryResponse | null>(null);
const [users, setUsers] = useState<User[]>([]);
const columnById = useMemo(() => {
const m = new Map<string, Column>();
for (const c of columns) m.set(c.id, c);
return m;
}, [columns]);
const isDoneColumn = (columnId: string) => columnById.get(columnId)?.is_done === true;
useEffect(() => {
cardHistory(card.id)
.then(setData)
@@ -91,14 +102,16 @@ export function HistoryModal({ card }: Props) {
const out: UnifiedEvent[] = [];
for (const e of data.events || []) out.push(eventToUnified(e));
for (const h of data.column_history || []) {
const done = isDoneColumn(h.column_id);
out.push({
id: "h_in_" + h.id,
ts: h.entered_at,
kind: "Mueve a columna",
kind: done ? "Hecho en columna" : "Mueve a columna",
actorID: h.actor_id,
detail: h.column_name || h.column_id,
icon: <IconArrowsHorizontal size={12} />,
color: "blue",
icon: done ? <IconCheck size={12} /> : <IconArrowsHorizontal size={12} />,
color: done ? "green" : "blue",
doneColumn: done,
});
}
for (const p of data.lock_periods || []) {
@@ -108,7 +121,7 @@ export function HistoryModal({ card }: Props) {
}
}
return out.sort((a, b) => a.ts.localeCompare(b.ts));
}, [data]);
}, [data, columnById]);
if (!data) {
return (
@@ -124,6 +137,26 @@ export function HistoryModal({ card }: Props) {
return <Text c="dimmed">Sin historial.</Text>;
}
// Per-column time stats: sum duration_ms by column_id from column_history.
// Currently-active entry (exited_at=null) gets duration_ms = now - entered_at.
const nowMs = Date.now();
const perColumnMs = new Map<string, { name: string; isDone: boolean; ms: number; visits: number }>();
for (const h of column_history) {
const dur = h.exited_at ? h.duration_ms : Math.max(0, nowMs - new Date(h.entered_at).getTime());
const key = h.column_id;
const prev = perColumnMs.get(key);
const meta = columnById.get(key);
perColumnMs.set(key, {
name: h.column_name || meta?.name || key,
isDone: meta?.is_done ?? false,
ms: (prev?.ms ?? 0) + dur,
visits: (prev?.visits ?? 0) + 1,
});
}
const perColumnRows = Array.from(perColumnMs.entries())
.map(([id, v]) => ({ id, ...v }))
.sort((a, b) => b.ms - a.ms);
const userLabel = (id: string | null): string => {
if (!id) return "";
const u = userById.get(id);
@@ -140,6 +173,7 @@ export function HistoryModal({ card }: Props) {
key={e.id}
bullet={e.icon}
color={e.color}
lineVariant={e.doneColumn ? "solid" : undefined}
title={
<Group gap={6} wrap="wrap">
<Text fw={500} size="sm">{e.kind}</Text>
@@ -163,16 +197,47 @@ export function HistoryModal({ card }: Props) {
<Divider />
<Group gap={6} align="center">
<IconColumns3 size={14} />
<Text fw={500} size="sm">Columnas visitadas</Text>
<Badge size="xs" variant="light" color="gray">{column_history.length}</Badge>
<IconLock size={14} color="var(--mantine-color-yellow-6)" />
<Badge size="xs" variant="light" color={total_locked_ms > 0 ? "yellow" : "gray"}>
{formatDuration(total_locked_ms)}
</Badge>
{currently_locked && <Badge size="xs" variant="filled" color="yellow">bloqueada</Badge>}
</Group>
<Stack gap={6}>
<Group gap={6} align="center" wrap="wrap">
<IconColumns3 size={14} />
<Text fw={500} size="sm">Tiempo por columna</Text>
<Badge size="xs" variant="light" color="gray">{column_history.length} entradas</Badge>
<Text size="xs" c="dimmed" ml="auto">
<IconLock size={11} style={{ verticalAlign: "middle" }} />{" "}
<Text span size="xs" fw={500} c={total_locked_ms > 0 ? "yellow" : "dimmed"}>
{formatDuration(total_locked_ms)}
</Text>{" "}
bloqueada{currently_locked ? " (en curso)" : ""}
</Text>
</Group>
{perColumnRows.length > 0 ? (
<Table withTableBorder withColumnBorders striped="even" verticalSpacing={4} fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Columna</Table.Th>
<Table.Th style={{ width: 60 }}>Visitas</Table.Th>
<Table.Th style={{ width: 130 }}>Tiempo total</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{perColumnRows.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>
<Group gap={4} wrap="nowrap">
{r.isDone && <IconCheck size={12} color="var(--mantine-color-green-6)" />}
<Text size="xs" fw={r.isDone ? 600 : 400}>{r.name}</Text>
</Group>
</Table.Td>
<Table.Td>{r.visits}</Table.Td>
<Table.Td>{formatDuration(r.ms)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text size="xs" c="dimmed">Sin movimientos entre columnas.</Text>
)}
</Stack>
</Stack>
);
}
+279 -269
View File
@@ -15,9 +15,11 @@ import {
Tooltip,
} from "@mantine/core";
import {
IconArchive,
IconCalendarDue,
IconCheck,
IconClock,
IconCopy,
IconDotsVertical,
IconEdit,
IconGripVertical,
@@ -31,7 +33,7 @@ import {
IconUserCircle,
} from "@tabler/icons-react";
import { DatePickerInput } from "@mantine/dates";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Card, CardColor, User } from "../types";
import { colorBg, colorBorder, tagColor } from "./colors";
import { ColorPickerGrid } from "./ColorPickerGrid";
@@ -42,12 +44,14 @@ interface Props {
now: number;
onDelete: (id: string) => void;
onEdit: (card: Card) => void;
onDuplicate?: (id: string) => void;
onChangeColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => void;
onToggleLock: (id: string, locked: boolean) => void;
onAssign: (id: string, assignee_id: string | null) => void;
onSetDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
onArchive?: (id: string) => void;
requesterOptions?: string[];
onOpenCustomColor?: (cardId: string, current: string) => void;
activeSticker?: string | null;
@@ -58,69 +62,107 @@ interface Props {
users: User[];
assignee?: User;
inDoneColumn?: boolean;
columnOverdue?: boolean;
isOverlay?: boolean;
highlight?: boolean;
}
function KanbanCardImpl({
// PERF debug helpers (gated): cuentan renders por capa durante drag.
function _probeRender() {
const w = window as unknown as { _cardRenderProbe?: boolean; _cardRenderCount?: number };
if (w._cardRenderProbe) w._cardRenderCount = (w._cardRenderCount || 0) + 1;
}
function _probeBodyRender() {
const w = window as unknown as { _cardRenderProbe?: boolean; _cardBodyRenderCount?: number };
if (w._cardRenderProbe) w._cardBodyRenderCount = (w._cardBodyRenderCount || 0) + 1;
}
// KanbanCardBody — contiene Stack + sticker overlay + states locales (popovers,
// requesterDraft). Memoizado para que dnd-kit re-render del wrapper exterior
// (provocado por useSortable cada pointermove) NO rebote a este tree.
interface CardBodyProps {
card: Card;
isDone: boolean;
isOverlay?: boolean;
highlight?: boolean;
activeSticker?: string | null;
cardElRef: React.MutableRefObject<HTMLElement | null>;
now: number;
users: User[];
assignee?: User;
requesterOptions?: string[];
menuOpen: boolean;
setMenuOpen: (v: boolean | ((p: boolean) => boolean)) => void;
onDelete: (id: string) => void;
onEdit: (card: Card) => void;
onDuplicate?: (id: string) => void;
onChangeColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => void;
onToggleLock: (id: string, locked: boolean) => void;
onAssign: (id: string, assignee_id: string | null) => void;
onSetDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
onArchive?: (id: string) => void;
onOpenCustomColor?: (cardId: string, current: string) => void;
onRemoveSticker?: (cardId: string, index: number) => void;
onMoveSticker?: (cardId: string, index: number, x: number, y: number) => void;
onCommitSticker?: (cardId: string) => void;
}
const KanbanCardBody = memo(function KanbanCardBody({
card,
isDone,
isOverlay,
activeSticker,
cardElRef,
now,
users,
assignee,
requesterOptions,
menuOpen,
setMenuOpen,
onDelete,
onEdit,
onDuplicate,
onChangeColor,
onShowHistory,
onToggleLock,
onAssign,
onSetDeadline,
onSetRequester,
requesterOptions,
onArchive,
onOpenCustomColor,
activeSticker,
onAddSticker,
onRemoveSticker,
onMoveSticker,
onCommitSticker,
users,
assignee,
inDoneColumn,
isOverlay,
highlight,
}: Props) {
const isDone = inDoneColumn || !!card.completed_at;
}: CardBodyProps) {
_probeBodyRender();
const stickerMode = !!activeSticker;
const [colorPopOpen, setColorPopOpen] = useState(false);
const [assigneePopOpen, setAssigneePopOpen] = useState(false);
const [requesterPopOpen, setRequesterPopOpen] = useState(false);
const [deadlinePopOpen, setDeadlinePopOpen] = useState(false);
const [requesterDraft, setRequesterDraft] = useState(card.requester || "");
const [menuOpen, setMenuOpen] = useState(false);
const cardElRef = useRef<HTMLElement | null>(null);
const draggingStickerRef = useRef<number | null>(null);
const stickerMode = !!activeSticker;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: card.id,
data: { type: "card", columnId: card.column_id, locked: card.locked },
disabled: stickerMode,
});
const setCardRef = useCallback((el: HTMLElement | null) => {
cardElRef.current = el;
setNodeRef(el);
}, [setNodeRef]);
useEffect(() => {
if (highlight && cardElRef.current) {
cardElRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [highlight]);
const onCardClickAddSticker = (e: React.MouseEvent<HTMLDivElement>) => {
if (!stickerMode || !onAddSticker || isOverlay) return;
if ((e.target as HTMLElement).closest("[data-sticker-overlay]")) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
onAddSticker(card.id, Math.max(0, Math.min(1, x)), Math.max(0, Math.min(1, y)));
};
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
const liveMs = Math.max(0, now - enteredAt);
const deadlineAt = card.deadline ? new Date(card.deadline).getTime() : 0;
const deadlineRemainingMs = deadlineAt ? deadlineAt - now : 0;
const overdue = deadlineAt ? deadlineRemainingMs < 0 : false;
const createdAtMs = card.created_at ? new Date(card.created_at).getTime() : 0;
const deadlineTotalMs = deadlineAt && createdAtMs ? deadlineAt - createdAtMs : 0;
const deadlineRatio = deadlineTotalMs > 0 ? deadlineRemainingMs / deadlineTotalMs : 0;
let dlColor: string = "blue";
let dlVariant: "light" | "filled" = "light";
if (overdue) { dlColor = "red.9"; dlVariant = "filled"; }
else if (deadlineRatio < 0.1) { dlColor = "red"; dlVariant = "filled"; }
else if (deadlineRatio < 0.5) { dlColor = "yellow"; dlVariant = "light"; }
const lockedAt = card.locked_at ? new Date(card.locked_at).getTime() : 0;
const lockedMs = card.locked && lockedAt ? Math.max(0, now - lockedAt) : 0;
const createdAt = card.created_at ? new Date(card.created_at).getTime() : 0;
const completedAt = card.completed_at ? new Date(card.completed_at).getTime() : 0;
const totalDoneMs = isDone && createdAt && completedAt ? Math.max(0, completedAt - createdAt) : 0;
const startStickerDrag = (index: number) => (e: React.PointerEvent<HTMLSpanElement>) => {
if (!stickerMode || isOverlay || !onMoveSticker) return;
@@ -159,68 +201,18 @@ function KanbanCardImpl({
onRemoveSticker?.(card.id, index);
};
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
background: colorBg(card.color),
borderColor: highlight ? "var(--mantine-color-blue-5)" : card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
borderWidth: highlight || card.locked ? 2 : 1,
boxShadow: highlight ? "0 0 0 3px var(--mantine-color-blue-4)" : undefined,
filter: isDone ? "brightness(0.55) saturate(0.7)" : undefined,
};
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
const liveMs = Math.max(0, now - enteredAt);
const deadlineAt = card.deadline ? new Date(card.deadline).getTime() : 0;
const deadlineRemainingMs = deadlineAt ? deadlineAt - now : 0;
const overdue = deadlineAt ? deadlineRemainingMs < 0 : false;
const createdAtMs = card.created_at ? new Date(card.created_at).getTime() : 0;
const deadlineTotalMs = deadlineAt && createdAtMs ? deadlineAt - createdAtMs : 0;
const deadlineRatio = deadlineTotalMs > 0 ? deadlineRemainingMs / deadlineTotalMs : 0;
let dlColor: string = "blue";
let dlVariant: "light" | "filled" = "light";
if (overdue) { dlColor = "red.9"; dlVariant = "filled"; }
else if (deadlineRatio < 0.1) { dlColor = "red"; dlVariant = "filled"; }
else if (deadlineRatio < 0.5) { dlColor = "yellow"; dlVariant = "light"; }
const lockedAt = card.locked_at ? new Date(card.locked_at).getTime() : 0;
const lockedMs = card.locked && lockedAt ? Math.max(0, now - lockedAt) : 0;
const createdAt = card.created_at ? new Date(card.created_at).getTime() : 0;
const completedAt = card.completed_at ? new Date(card.completed_at).getTime() : 0;
const totalDoneMs = isDone && createdAt && completedAt ? Math.max(0, completedAt - createdAt) : 0;
const onContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setMenuOpen(true);
};
const menuItems = (
const menuItems = !menuOpen ? null : (
<>
<Menu.Label>Acciones</Menu.Label>
<Menu.Item
leftSection={<IconEdit size={14} />}
onClick={() => {
setMenuOpen(false);
onEdit(card);
}}
>
Editar
</Menu.Item>
<Popover
opened={colorPopOpen}
onChange={setColorPopOpen}
position="right-start"
withArrow
shadow="md"
>
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => { setMenuOpen(false); onEdit(card); }}>Editar</Menu.Item>
{onDuplicate && (
<Menu.Item leftSection={<IconCopy size={14} />} onClick={() => { setMenuOpen(false); onDuplicate(card.id); }}>Duplicar</Menu.Item>
)}
<Popover opened={colorPopOpen} onChange={setColorPopOpen} position="right-start" withArrow shadow="md">
<Popover.Target>
<Menu.Item
leftSection={<IconPalette size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setColorPopOpen((v) => !v);
}}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setColorPopOpen((v) => !v); }}
closeMenuOnClick={false}
>
Color
@@ -234,22 +226,11 @@ function KanbanCardImpl({
/>
</Popover.Dropdown>
</Popover>
<Popover
opened={assigneePopOpen}
onChange={setAssigneePopOpen}
position="right-start"
withArrow
shadow="md"
withinPortal={false}
>
<Popover opened={assigneePopOpen} onChange={setAssigneePopOpen} position="right-start" withArrow shadow="md" withinPortal={false}>
<Popover.Target>
<Menu.Item
leftSection={<IconUserCircle size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAssigneePopOpen((v) => !v);
}}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setAssigneePopOpen((v) => !v); }}
closeMenuOnClick={false}
>
Asignar a {assignee ? `(${assignee.display_name || assignee.username})` : "..."}
@@ -259,11 +240,7 @@ function KanbanCardImpl({
<Select
placeholder="Sin asignar"
value={card.assignee_id ?? null}
onChange={(v) => {
onAssign(card.id, v);
setAssigneePopOpen(false);
setMenuOpen(false);
}}
onChange={(v) => { onAssign(card.id, v); setAssigneePopOpen(false); setMenuOpen(false); }}
data={users.map((u) => ({ value: u.id, label: u.display_name || u.username }))}
clearable
searchable
@@ -272,23 +249,11 @@ function KanbanCardImpl({
/>
</Popover.Dropdown>
</Popover>
<Popover
opened={requesterPopOpen}
onChange={setRequesterPopOpen}
position="right-start"
withArrow
shadow="md"
withinPortal={false}
>
<Popover opened={requesterPopOpen} onChange={setRequesterPopOpen} position="right-start" withArrow shadow="md" withinPortal={false}>
<Popover.Target>
<Menu.Item
leftSection={<IconUserSquare size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setRequesterDraft(card.requester || "");
setRequesterPopOpen((v) => !v);
}}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setRequesterDraft(card.requester || ""); setRequesterPopOpen((v) => !v); }}
closeMenuOnClick={false}
>
Solicitante {card.requester ? `(${card.requester})` : "..."}
@@ -312,51 +277,24 @@ function KanbanCardImpl({
setRequesterPopOpen(false);
}
}}
onOptionSubmit={(v) => {
setRequesterDraft(v);
onSetRequester?.(card.id, v);
setRequesterPopOpen(false);
setMenuOpen(false);
}}
onOptionSubmit={(v) => { setRequesterDraft(v); onSetRequester?.(card.id, v); setRequesterPopOpen(false); setMenuOpen(false); }}
/>
</Popover.Dropdown>
</Popover>
<Menu.Item
leftSection={card.locked ? <IconLockOpen size={14} /> : <IconLock size={14} />}
color={card.locked ? "yellow" : undefined}
onClick={() => {
setMenuOpen(false);
onToggleLock(card.id, !card.locked);
}}
onClick={() => { setMenuOpen(false); onToggleLock(card.id, !card.locked); }}
>
{card.locked ? "Desbloquear" : "Bloquear"}
</Menu.Item>
<Menu.Item
leftSection={<IconHistory size={14} />}
onClick={() => {
setMenuOpen(false);
onShowHistory(card);
}}
>
Historial
</Menu.Item>
<Menu.Item leftSection={<IconHistory size={14} />} onClick={() => { setMenuOpen(false); onShowHistory(card); }}>Historial</Menu.Item>
{onSetDeadline && (
<Popover
opened={deadlinePopOpen}
onChange={setDeadlinePopOpen}
position="right-start"
withArrow
shadow="md"
withinPortal={false}
>
<Popover opened={deadlinePopOpen} onChange={setDeadlinePopOpen} position="right-start" withArrow shadow="md" withinPortal={false}>
<Popover.Target>
<Menu.Item
leftSection={<IconCalendarDue size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDeadlinePopOpen((v) => !v);
}}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setDeadlinePopOpen((v) => !v); }}
closeMenuOnClick={false}
>
{card.deadline ? `Deadline (${card.deadline.slice(0, 10)})` : "Deadline..."}
@@ -379,17 +317,7 @@ function KanbanCardImpl({
/>
{card.deadline && (
<Tooltip label="Quitar deadline" withArrow>
<ActionIcon
size="sm"
variant="subtle"
color="red"
mt={6}
onClick={() => {
onSetDeadline(card.id, null);
setDeadlinePopOpen(false);
setMenuOpen(false);
}}
>
<ActionIcon size="sm" variant="subtle" color="red" mt={6} onClick={() => { onSetDeadline(card.id, null); setDeadlinePopOpen(false); setMenuOpen(false); }}>
<IconTrash size={12} />
</ActionIcon>
</Tooltip>
@@ -397,87 +325,46 @@ function KanbanCardImpl({
</Popover.Dropdown>
</Popover>
)}
{isDone && onArchive && (
<Menu.Item
leftSection={<IconArchive size={14} />}
color="teal"
onClick={() => { setMenuOpen(false); onArchive(card.id); }}
>
Archivar
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={() => {
setMenuOpen(false);
onDelete(card.id);
}}
>
Borrar
</Menu.Item>
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={() => { setMenuOpen(false); onDelete(card.id); }}>Borrar</Menu.Item>
</>
);
return (
<Paper
ref={setCardRef}
style={{ ...style, position: "relative", cursor: stickerMode ? "copy" : "grab", touchAction: "none" }}
withBorder
p="xs"
shadow={isOverlay ? "lg" : "xs"}
radius="md"
onContextMenu={onContextMenu}
onClick={onCardClickAddSticker}
onDoubleClick={(e) => {
e.stopPropagation();
onEdit(card);
}}
{...attributes}
{...(stickerMode ? {} : listeners)}
>
<>
<Stack gap={6} style={{ position: "relative", zIndex: 1, pointerEvents: stickerMode ? "none" : undefined }}>
<Group justify="space-between" gap={4} wrap="nowrap" align="flex-start">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }} align="flex-start">
<IconGripVertical
size={14}
color="var(--mantine-color-dark-2)"
style={{ flexShrink: 0, marginTop: 4 }}
/>
<IconGripVertical size={14} color="var(--mantine-color-dark-2)" style={{ flexShrink: 0, marginTop: 4 }} />
{card.locked && (
<Tooltip label="Bloqueada" withArrow>
<IconLock
size={14}
color="var(--mantine-color-yellow-6)"
style={{ flexShrink: 0, marginTop: 4 }}
/>
<IconLock size={14} color="var(--mantine-color-yellow-6)" style={{ flexShrink: 0, marginTop: 4 }} />
</Tooltip>
)}
<Text
size="sm"
fw={500}
style={{
flex: 1,
wordBreak: "break-word",
whiteSpace: "normal",
textDecoration: isDone ? "line-through" : "none",
opacity: isDone ? 0.7 : 1,
}}
style={{ flex: 1, wordBreak: "break-word", whiteSpace: "normal", textDecoration: isDone ? "line-through" : "none", opacity: isDone ? 0.7 : 1 }}
>
{card.title}
</Text>
</Group>
<Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label="Acciones"
style={{ flexShrink: 0 }}
onPointerDown={(e) => e.stopPropagation()}
>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones" style={{ flexShrink: 0 }} onPointerDown={(e) => e.stopPropagation()}>
<IconDotsVertical size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown
onDoubleClick={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onContextMenu={(e) => e.stopPropagation()}
>
<Menu.Dropdown onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
{menuItems}
</Menu.Dropdown>
</Menu>
@@ -500,83 +387,53 @@ function KanbanCardImpl({
<Avatar size={18} radius="xl" color={assignee.color || "blue"} style={{ flexShrink: 0 }}>
{(assignee.display_name || assignee.username).slice(0, 2).toUpperCase()}
</Avatar>
<Text size="xs" c="dimmed" truncate>
{assignee.display_name || assignee.username}
</Text>
<Text size="xs" c="dimmed" truncate>{assignee.display_name || assignee.username}</Text>
</>
)}
</Group>
)}
{card.description && (
<Text size="xs" c="dimmed" lineClamp={3}>
{card.description}
</Text>
<Text size="xs" c="dimmed" lineClamp={3}>{card.description}</Text>
)}
{card.tags && card.tags.length > 0 && (
<Group gap={4} wrap="wrap">
{card.tags.map((t) => (
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">
{t}
</Badge>
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">{t}</Badge>
))}
</Group>
)}
<Group gap={4} wrap="wrap">
{card.locked && (
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>
{formatDuration(lockedMs)}
</Badge>
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>{formatDuration(lockedMs)}</Badge>
)}
{!card.locked && isDone && card.completed_at ? (
<>
<Badge size="xs" variant="light" color="teal" leftSection={<IconCheck size={10} />}>
{formatDateTimeShort(card.completed_at)}
</Badge>
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
Total: {formatDuration(totalDoneMs)}
</Badge>
<Badge size="xs" variant="light" color="teal" leftSection={<IconCheck size={10} />}>{formatDateTimeShort(card.completed_at)}</Badge>
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>Total: {formatDuration(totalDoneMs)}</Badge>
{card.total_locked_ms > 0 && (
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>
{formatDuration(card.total_locked_ms)}
</Badge>
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>{formatDuration(card.total_locked_ms)}</Badge>
)}
</>
) : !card.locked ? (
card.deadline ? (
<Tooltip label={`Vence: ${formatDateTimeShort(card.deadline)}`} withArrow>
<Badge
size="xs"
variant={dlVariant}
color={dlColor}
leftSection={<IconHourglass size={10} />}
>
<Badge size="xs" variant={dlVariant} color={dlColor} leftSection={<IconHourglass size={10} />}>
{overdue ? `-${formatDuration(-deadlineRemainingMs)}` : formatDuration(deadlineRemainingMs)}
</Badge>
</Tooltip>
) : (
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
{formatDuration(liveMs)}
</Badge>
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>{formatDuration(liveMs)}</Badge>
)
) : null}
</Group>
{card.seq_num > 0 && (
<Text size="xs" c="dimmed" style={{ marginTop: -2 }}>
#{String(card.seq_num).padStart(5, "0")}
</Text>
<Text size="xs" c="dimmed" style={{ marginTop: -2 }}>#{String(card.seq_num).padStart(5, "0")}</Text>
)}
</Stack>
{card.stickers && card.stickers.length > 0 && (
<div
data-sticker-overlay
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
overflow: "hidden",
borderRadius: "inherit",
zIndex: 0,
}}
style={{ position: "absolute", inset: 0, pointerEvents: "none", overflow: "hidden", borderRadius: "inherit", zIndex: 0 }}
>
{card.stickers.map((s, i) => (
<span
@@ -603,6 +460,159 @@ function KanbanCardImpl({
))}
</div>
)}
</>
);
});
function KanbanCardImpl({
card,
now,
onDelete,
onEdit,
onDuplicate,
onChangeColor,
onShowHistory,
onToggleLock,
onAssign,
onSetDeadline,
onSetRequester,
onArchive,
requesterOptions,
onOpenCustomColor,
activeSticker,
onAddSticker,
onRemoveSticker,
onMoveSticker,
onCommitSticker,
users,
assignee,
inDoneColumn,
columnOverdue,
isOverlay,
highlight,
}: Props) {
_probeRender();
const isDone = inDoneColumn || !!card.completed_at;
const [menuOpen, setMenuOpen] = useState(false);
const cardElRef = useRef<HTMLElement | null>(null);
const stickerMode = !!activeSticker;
// Memo: useSortable es sensible a la identidad del objeto `data`. Si lo
// re-creamos cada render, el setNodeRef interno se vuelve inestable y
// dispara loops por useMergedRef de Mantine (Paper). Issue: maximum
// update depth visto durante drag.
const sortableData = useMemo(
() => ({ type: "card" as const, columnId: card.column_id, locked: card.locked }),
[card.column_id, card.locked]
);
// Perf: disable layout animations. dnd-kit's default animates the slide of
// non-dragged items into their new sort position via an FLIP-like loop that
// re-runs useSortable on every pointermove for ALL cards in the
// SortableContext. With dozens of cards that drops frames hard (p95>=80ms).
// Disabling animations keeps the visual shift driven only by the active
// card's transform.
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: card.id,
data: sortableData,
disabled: stickerMode,
animateLayoutChanges: () => false,
});
const setCardRef = useCallback((el: HTMLElement | null) => {
cardElRef.current = el;
setNodeRef(el);
}, [setNodeRef]);
useEffect(() => {
if (highlight && cardElRef.current) {
cardElRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [highlight]);
const onCardClickAddSticker = (e: React.MouseEvent<HTMLDivElement>) => {
if (!stickerMode || !onAddSticker || isOverlay) return;
if ((e.target as HTMLElement).closest("[data-sticker-overlay]")) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
onAddSticker(card.id, Math.max(0, Math.min(1, x)), Math.max(0, Math.min(1, y)));
};
const borderColorPicked = highlight
? "var(--mantine-color-blue-5)"
: columnOverdue
? "var(--mantine-color-red-6)"
: card.locked
? "var(--mantine-color-yellow-6)"
: colorBorder(card.color);
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
background: colorBg(card.color),
borderColor: borderColorPicked,
borderWidth: highlight || card.locked || columnOverdue ? 2 : 1,
boxShadow: highlight
? "0 0 0 3px var(--mantine-color-blue-4)"
: columnOverdue
? "0 0 0 2px var(--mantine-color-red-3)"
: undefined,
filter: isDone ? "brightness(0.55) saturate(0.7)" : undefined,
};
const onContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setMenuOpen(true);
};
return (
<Paper
ref={setCardRef}
style={{ ...style, position: "relative", cursor: stickerMode ? "copy" : "grab", touchAction: "none" }}
withBorder
p="xs"
shadow={isOverlay ? "lg" : "xs"}
radius="md"
data-card-id={card.id}
data-column-overdue={columnOverdue ? "true" : "false"}
data-locked={card.locked ? "true" : "false"}
onContextMenu={onContextMenu}
onClick={onCardClickAddSticker}
onDoubleClick={(e) => {
e.stopPropagation();
onEdit(card);
}}
{...attributes}
{...(stickerMode ? {} : listeners)}
>
<KanbanCardBody
card={card}
isDone={isDone}
isOverlay={isOverlay}
highlight={highlight}
activeSticker={activeSticker}
cardElRef={cardElRef}
now={now}
users={users}
assignee={assignee}
requesterOptions={requesterOptions}
menuOpen={menuOpen}
setMenuOpen={setMenuOpen}
onDelete={onDelete}
onEdit={onEdit}
onDuplicate={onDuplicate}
onChangeColor={onChangeColor}
onShowHistory={onShowHistory}
onToggleLock={onToggleLock}
onAssign={onAssign}
onSetDeadline={onSetDeadline}
onSetRequester={onSetRequester}
onArchive={onArchive}
onOpenCustomColor={onOpenCustomColor}
onRemoveSticker={onRemoveSticker}
onMoveSticker={onMoveSticker}
onCommitSticker={onCommitSticker}
/>
</Paper>
);
}
+179 -2
View File
@@ -12,6 +12,7 @@ import {
Paper,
Popover,
ScrollArea,
Select,
Stack,
Text,
TextInput,
@@ -25,6 +26,8 @@ import {
IconCheckbox,
IconChevronDown,
IconChevronRight,
IconClock,
IconDice5,
IconDotsVertical,
IconGripVertical,
IconPencil,
@@ -32,10 +35,30 @@ import {
IconTrash,
IconX,
} from "@tabler/icons-react";
import { memo, MouseEvent, useEffect, useRef, useState } from "react";
import { memo, MouseEvent, useEffect, useMemo, useRef, useState } from "react";
import type { Card, CardColor, Column, User } from "../types";
import { KanbanCard } from "./KanbanCard";
type MaxTimeUnit = "minutes" | "hours" | "days" | "weeks" | "months";
const MAX_TIME_UNIT_MIN: Record<MaxTimeUnit, number> = {
minutes: 1,
hours: 60,
days: 60 * 24,
weeks: 60 * 24 * 7,
months: 60 * 24 * 30,
};
const MAX_TIME_UNIT_LABEL: Record<MaxTimeUnit, string> = {
minutes: "minutos",
hours: "horas",
days: "dias",
weeks: "semanas",
months: "meses",
};
const MAX_TIME_UNIT_SELECT_DATA = (Object.keys(MAX_TIME_UNIT_LABEL) as MaxTimeUnit[]).map((u) => ({
value: u,
label: MAX_TIME_UNIT_LABEL[u],
}));
interface Props {
column: Column;
cards: Card[];
@@ -47,15 +70,19 @@ interface Props {
onMoveColumnLocation: (id: string, location: "board" | "sidebar") => void;
onDeleteColumn: (id: string) => void;
onSetWIPLimit: (id: string, limit: number) => void;
onSetMaxTimeMinutes: (id: string, minutes: number) => void;
onPickRandom: (columnId: string) => void;
onToggleDone: (id: string, is_done: boolean) => void;
onEditCard: (card: Card) => void;
onDeleteCard: (id: string) => void;
onDuplicateCard: (id: string) => void;
onChangeCardColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => void;
onToggleCardLock: (id: string, locked: boolean) => void;
onAssignCard: (id: string, assignee_id: string | null) => void;
onSetCardDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
onArchiveCard?: (id: string) => void;
requesterOptions?: string[];
onOpenCustomCardColor?: (cardId: string, current: string) => void;
activeSticker?: string | null;
@@ -79,15 +106,19 @@ function KanbanColumnImpl({
onMoveColumnLocation,
onDeleteColumn,
onSetWIPLimit,
onSetMaxTimeMinutes,
onPickRandom,
onToggleDone,
onEditCard,
onDeleteCard,
onDuplicateCard,
onChangeCardColor,
onShowHistory,
onToggleCardLock,
onAssignCard,
onSetCardDeadline,
onSetRequester,
onArchiveCard,
requesterOptions,
onOpenCustomCardColor,
activeSticker,
@@ -104,6 +135,24 @@ function KanbanColumnImpl({
const [localWidth, setLocalWidth] = useState<number | null>(null);
const [wipPopOpen, setWipPopOpen] = useState(false);
const [wipDraft, setWipDraft] = useState<number | string>(column.wip_limit);
const [maxTimePopOpen, setMaxTimePopOpen] = useState(false);
// Initial unit picked from current value: largest unit that yields >=1
const pickInitialUnit = (mins: number): MaxTimeUnit => {
if (mins <= 0) return "minutes";
if (mins % 43200 === 0) return "months";
if (mins % 10080 === 0) return "weeks";
if (mins % 1440 === 0) return "days";
if (mins % 60 === 0) return "hours";
return "minutes";
};
const minutesToUnit = (mins: number, u: MaxTimeUnit): number => {
const div = MAX_TIME_UNIT_MIN[u];
return mins > 0 ? Math.max(1, Math.round(mins / div)) : 0;
};
const [maxTimeUnit, setMaxTimeUnit] = useState<MaxTimeUnit>(() => pickInitialUnit(column.max_time_minutes || 0));
const [maxTimeDraft, setMaxTimeDraft] = useState<number | string>(() =>
minutesToUnit(column.max_time_minutes || 0, pickInitialUnit(column.max_time_minutes || 0))
);
const [bodyHidden, setBodyHidden] = useState(() => {
if (!collapsed) return false;
return localStorage.getItem(`kanban_col_body_${column.id}`) === "1";
@@ -122,9 +171,13 @@ function KanbanColumnImpl({
setLocalWidth(null);
}, [column.width]);
const sortableData = useMemo(
() => ({ type: "column" as const, columnId: column.id, location: column.location }),
[column.id, column.location]
);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `column-${column.id}`,
data: { type: "column", columnId: column.id, location: column.location },
data: sortableData,
});
const effectiveWidth = collapsed ? "100%" : localWidth ?? column.width;
@@ -218,6 +271,8 @@ function KanbanColumnImpl({
withBorder
radius="md"
p="sm"
data-column-id={column.id}
data-column-location={column.location}
>
<Group justify="space-between" mb="xs" wrap="nowrap">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
@@ -388,6 +443,120 @@ function KanbanColumnImpl({
>
{column.is_done ? "Quitar marca Done" : "Marcar como Done"}
</Menu.Item>
<Popover
opened={maxTimePopOpen}
onChange={(o) => {
setMaxTimePopOpen(o);
if (o) {
const u = pickInitialUnit(column.max_time_minutes || 0);
setMaxTimeUnit(u);
setMaxTimeDraft(minutesToUnit(column.max_time_minutes || 0, u));
}
}}
position="right-start"
withArrow
shadow="md"
withinPortal={false}
>
<Popover.Target>
<Menu.Item
leftSection={<IconClock size={14} />}
data-test="column-max-time"
closeMenuOnClick={false}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMaxTimePopOpen((v) => !v);
}}
>
Tiempo maximo
{column.max_time_minutes > 0
? ` (${(() => {
const u = pickInitialUnit(column.max_time_minutes);
return `${minutesToUnit(column.max_time_minutes, u)} ${MAX_TIME_UNIT_LABEL[u]}`;
})()})`
: ""}
</Menu.Item>
</Popover.Target>
<Popover.Dropdown
p="xs"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
<Stack gap={6} style={{ minWidth: 240 }}>
<Text size="xs" c="dimmed">
Cards que pasen este tiempo se pintaran con borde rojo. 0 = sin limite. Columnas Done no aplican.
</Text>
<Group gap={6} wrap="nowrap">
<NumberInput
size="xs"
min={0}
max={999}
value={maxTimeDraft}
onChange={setMaxTimeDraft}
placeholder="0"
style={{ width: 90 }}
data-test="column-max-time-input"
/>
<Select
size="xs"
value={maxTimeUnit}
onChange={(v) => v && setMaxTimeUnit(v as MaxTimeUnit)}
data={MAX_TIME_UNIT_SELECT_DATA}
style={{ width: 130 }}
allowDeselect={false}
data-test="column-max-time-unit"
/>
</Group>
<Group justify="space-between" gap={6}>
<Tooltip label="Quitar limite" withArrow disabled={!column.max_time_minutes}>
<ActionIcon
size="sm"
variant="subtle"
color="red"
disabled={!column.max_time_minutes}
onClick={() => {
onSetMaxTimeMinutes(column.id, 0);
setMaxTimeDraft(0);
setMaxTimePopOpen(false);
}}
>
<IconTrash size={12} />
</ActionIcon>
</Tooltip>
<Button
size="xs"
data-test="column-max-time-save"
onClick={() => {
const raw =
typeof maxTimeDraft === "number"
? maxTimeDraft
: parseInt(String(maxTimeDraft), 10);
const n = Number.isFinite(raw) && raw >= 0 ? raw : 0;
const mins = n * MAX_TIME_UNIT_MIN[maxTimeUnit];
if (mins !== column.max_time_minutes) {
onSetMaxTimeMinutes(column.id, mins);
}
setMaxTimePopOpen(false);
}}
>
Guardar
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
{!column.is_done && (
<Menu.Item
leftSection={<IconDice5 size={14} />}
data-test="column-random-pick"
disabled={cards.filter((c) => !c.locked).length === 0}
onClick={() => onPickRandom(column.id)}
>
Seleccionar Aleatorio
</Menu.Item>
)}
<Menu.Item
leftSection={<ArchiveIcon size={14} />}
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
@@ -421,17 +590,24 @@ function KanbanColumnImpl({
now={now}
onDelete={onDeleteCard}
onEdit={onEditCard}
onDuplicate={onDuplicateCard}
onChangeColor={onChangeCardColor}
onShowHistory={onShowHistory}
onToggleLock={onToggleCardLock}
onAssign={onAssignCard}
onSetDeadline={onSetCardDeadline}
onSetRequester={onSetRequester}
onArchive={onArchiveCard}
requesterOptions={requesterOptions}
onOpenCustomColor={onOpenCustomCardColor}
users={users}
assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined}
inDoneColumn={column.is_done}
columnOverdue={
!column.is_done &&
column.max_time_minutes > 0 &&
c.time_in_column_ms > column.max_time_minutes * 60_000
}
highlight={highlightCardId === c.id}
activeSticker={activeSticker}
onAddSticker={onAddSticker}
@@ -452,6 +628,7 @@ function KanbanColumnImpl({
onClick={() => onAddCard(column.id)}
mt="xs"
fullWidth
data-test="add-card"
>
Anadir tarjeta
</Button>
+34 -15
View File
@@ -10,8 +10,9 @@ import {
Title,
} from "@mantine/core";
import { IconLayoutKanban } from "@tabler/icons-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useAuth } from "../auth";
import * as api from "../api";
type Mode = "login" | "register";
@@ -23,6 +24,18 @@ export function LoginPage() {
const [displayName, setDisplayName] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [registrationEnabled, setRegistrationEnabled] = useState(false);
useEffect(() => {
api
.getFlags()
.then((f) => setRegistrationEnabled(!!f["registration-enabled"]))
.catch(() => setRegistrationEnabled(false));
}, []);
useEffect(() => {
if (!registrationEnabled && mode === "register") setMode("login");
}, [registrationEnabled, mode]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -84,20 +97,26 @@ export function LoginPage() {
<Button type="submit" loading={submitting} fullWidth>
{mode === "login" ? "Entrar" : "Registrar"}
</Button>
<Text size="xs" c="dimmed" ta="center">
{mode === "login" ? "No tienes cuenta?" : "Ya tienes cuenta?"}{" "}
<Anchor
component="button"
type="button"
size="xs"
onClick={() => {
setError(null);
setMode(mode === "login" ? "register" : "login");
}}
>
{mode === "login" ? "Registrate" : "Inicia sesion"}
</Anchor>
</Text>
{registrationEnabled ? (
<Text size="xs" c="dimmed" ta="center">
{mode === "login" ? "No tienes cuenta?" : "Ya tienes cuenta?"}{" "}
<Anchor
component="button"
type="button"
size="xs"
onClick={() => {
setError(null);
setMode(mode === "login" ? "register" : "login");
}}
>
{mode === "login" ? "Registrate" : "Inicia sesion"}
</Anchor>
</Text>
) : (
<Text size="xs" c="dimmed" ta="center">
Registro de nuevos usuarios deshabilitado.
</Text>
)}
</Stack>
</form>
</Paper>
+126
View File
@@ -0,0 +1,126 @@
import { Anchor, Image, Text } from "@mantine/core";
import { Fragment, ReactNode } from "react";
// Minimal markdown renderer for chat/desc embeds (issue 0128).
// Recognises:
// ![alt](url) -> <Image>
// [name](url) -> <Anchor>name</Anchor>
// Everything else is plain text with preserved whitespace.
interface ImgToken { kind: "img"; alt: string; url: string }
interface LinkToken { kind: "link"; label: string; url: string }
interface TextToken { kind: "text"; value: string }
type Token = ImgToken | LinkToken | TextToken;
const TOKEN_RE = /(!\[([^\]\n]*)\]\(([^)\s]+)\))|(\[([^\]\n]+)\]\(([^)\s]+)\))/g;
// Allow only safe URL schemes. Reject javascript:, data:text/html, vbscript:, etc.
// Accepts: absolute http(s), protocol-relative //, and same-origin paths (/...).
function safeURL(url: string): string | null {
const u = url.trim();
if (u.startsWith("/")) return u;
if (/^https?:\/\//i.test(u)) return u;
return null;
}
// data: scheme is allowed only when the MIME prefix is image/.
function safeImageURL(url: string): string | null {
const safe = safeURL(url);
if (safe) return safe;
const u = url.trim();
if (/^data:image\/[a-z0-9.+-]+(;[a-z0-9-]+=[^,]+)*;base64,/i.test(u)) return u;
return null;
}
function tokenize(input: string): Token[] {
const out: Token[] = [];
let last = 0;
let m: RegExpExecArray | null;
TOKEN_RE.lastIndex = 0;
while ((m = TOKEN_RE.exec(input)) !== null) {
if (m.index > last) {
out.push({ kind: "text", value: input.slice(last, m.index) });
}
if (m[1]) {
const url = safeImageURL(m[3]);
if (url) {
out.push({ kind: "img", alt: m[2] || "", url });
} else {
out.push({ kind: "text", value: m[0] });
}
} else if (m[4]) {
const url = safeURL(m[6]);
if (url) {
out.push({ kind: "link", label: m[5], url });
} else {
out.push({ kind: "text", value: m[0] });
}
}
last = TOKEN_RE.lastIndex;
}
if (last < input.length) {
out.push({ kind: "text", value: input.slice(last) });
}
return out;
}
interface Props {
text: string;
size?: string;
}
export function MessageBody({ text, size = "sm" }: Props): ReactNode {
const tokens = tokenize(text);
if (tokens.length === 0) {
return null;
}
const inlineNodes: ReactNode[] = [];
const blocks: ReactNode[] = [];
let key = 0;
const flushInline = () => {
if (inlineNodes.length === 0) return;
blocks.push(
<Text
key={`t-${key++}`}
size={size}
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
>
{inlineNodes.map((n, i) => (
<Fragment key={i}>{n}</Fragment>
))}
</Text>
);
inlineNodes.length = 0;
};
for (const t of tokens) {
if (t.kind === "img") {
flushInline();
blocks.push(
<Anchor key={`i-${key++}`} href={t.url} target="_blank" rel="noopener noreferrer">
<Image
src={t.url}
alt={t.alt}
maw={220}
mah={220}
fit="contain"
radius="sm"
/>
</Anchor>
);
} else if (t.kind === "link") {
inlineNodes.push(
<Anchor key={`l-${key++}`} href={t.url} target="_blank" rel="noopener noreferrer">
{t.label}
</Anchor>
);
} else {
inlineNodes.push(t.value);
}
}
flushInline();
return <>{blocks}</>;
}
+2
View File
@@ -1,5 +1,7 @@
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
import "./styles/roulette.css";
import "./styles/dropzone.css";
import { MantineProvider, createTheme } from "@mantine/core";
import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications";
+52
View File
@@ -0,0 +1,52 @@
/* Drag-aware dropzone strip on the left edge.
* Issue 0091 — auto-open sidebar when dragging a card near the left edge.
*
* The strip is only visible while a drag is active. When the pointer is
* inside the strip, we add the `is-armed` class to show a subtle inset
* glow that pulses, so the user knows the zone is going to fire.
*/
.kanban-drag-edge {
position: fixed;
left: 0;
top: 50px; /* AppShell.Header height */
bottom: 0;
width: 32px;
z-index: 200;
pointer-events: none; /* let dnd-kit keep capturing the pointer */
opacity: 0;
transition: opacity 120ms ease-out, box-shadow 160ms ease-out, background 160ms ease-out;
background: transparent;
}
.kanban-drag-edge.is-active {
opacity: 1;
/* Very subtle hint that the strip exists during any drag. */
box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.04);
}
.kanban-drag-edge.is-armed {
background: linear-gradient(
90deg,
rgba(34, 139, 230, 0.18) 0%,
rgba(34, 139, 230, 0.06) 60%,
transparent 100%
);
box-shadow: inset 4px 0 0 var(--mantine-color-blue-4);
animation: kanban-drag-edge-pulse 1100ms ease-in-out infinite;
}
@keyframes kanban-drag-edge-pulse {
0% {
box-shadow: inset 4px 0 0 var(--mantine-color-blue-4),
inset 0 0 0 0 rgba(34, 139, 230, 0.0);
}
50% {
box-shadow: inset 4px 0 0 var(--mantine-color-blue-5),
inset 16px 0 22px -10px rgba(34, 139, 230, 0.35);
}
100% {
box-shadow: inset 4px 0 0 var(--mantine-color-blue-4),
inset 0 0 0 0 rgba(34, 139, 230, 0.0);
}
}
+30
View File
@@ -0,0 +1,30 @@
/* Issue 0090: ruleta de seleccion aleatoria por columna. */
@keyframes kanban-roulette-pulse {
0% { box-shadow: 0 0 0 0 rgba(34, 139, 230, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(34, 139, 230, 0); }
100% { box-shadow: 0 0 0 0 rgba(34, 139, 230, 0); }
}
@keyframes kanban-roulette-winner {
0% { box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.95); transform: scale(1); }
30% { box-shadow: 0 0 0 16px rgba(82, 196, 26, 0.55); transform: scale(1.03); }
60% { box-shadow: 0 0 0 22px rgba(82, 196, 26, 0); transform: scale(1.05); }
100% { box-shadow: 0 0 0 0 rgba(82, 196, 26, 0); transform: scale(1); }
}
.kanban-roulette-active {
outline: 3px solid var(--mantine-color-blue-6) !important;
outline-offset: -2px;
animation: kanban-roulette-pulse 200ms ease-out 1;
z-index: 5;
position: relative;
}
.kanban-roulette-winner {
outline: 3px solid var(--mantine-color-green-7) !important;
outline-offset: -2px;
animation: kanban-roulette-winner 1600ms ease-out 1;
z-index: 6;
position: relative;
}
+58
View File
@@ -0,0 +1,58 @@
import "@testing-library/jest-dom/vitest";
// jsdom does not implement matchMedia; Mantine reads it on mount.
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
// Mantine Textarea autosize reads window.visualViewport on mount; jsdom lacks it.
if (typeof window !== "undefined" && !window.visualViewport) {
Object.defineProperty(window, "visualViewport", {
writable: true,
value: {
width: 1280,
height: 800,
offsetLeft: 0,
offsetTop: 0,
pageLeft: 0,
pageTop: 0,
scale: 1,
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
},
});
}
// jsdom does not implement document.fonts; Mantine Autosize reads it on mount.
if (typeof document !== "undefined" && !(document as Document & { fonts?: unknown }).fonts) {
Object.defineProperty(document, "fonts", {
writable: true,
value: {
ready: Promise.resolve(),
addEventListener: () => {},
removeEventListener: () => {},
},
});
}
// ResizeObserver is used by some Mantine components and is not in jsdom.
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
+22
View File
@@ -8,6 +8,7 @@ export interface Column {
width: number;
wip_limit: number;
is_done: boolean;
max_time_minutes: number;
created_at: string;
}
@@ -33,6 +34,7 @@ export interface Card {
assignee_id: string | null;
completed_at: string | null;
deleted_at: string | null;
archived_at: string | null;
tags: string[];
stickers: Sticker[];
deadline: string | null;
@@ -44,6 +46,18 @@ export interface Card {
total_locked_ms: number;
}
export interface CardFile {
id: string;
card_id: string;
uploader_id: string;
filename: string;
mime: string;
size: number;
source: "upload" | "description" | "chat";
url: string;
created_at: string;
}
export interface User {
id: string;
username: string;
@@ -188,3 +202,11 @@ export interface CardHistoryResponse {
total_locked_ms: number;
currently_locked: boolean;
}
export interface CardMessage {
id: string;
card_id: string;
author_id: string | null;
body: string;
created_at: string;
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@fn_library": path.resolve(__dirname, "../../../frontend/functions"),
},
},
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
exclude: ["e2e/**", "node_modules/**"],
},
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File