chore: auto-commit (10 archivos)

- chat.log
- db.go
- frontend/src/App.tsx
- frontend/src/api.ts
- frontend/src/components/CardForm.tsx
- frontend/src/components/Dashboard.tsx
- frontend/src/components/KanbanCard.tsx
- frontend/src/types.ts
- handlers.go
- metrics.go

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 15:55:35 +02:00
parent 9290a0b2d0
commit 2a727eb7c1
10 changed files with 583 additions and 52 deletions
+66
View File
@@ -1,3 +1,69 @@
{"ts":"2026-05-06T22:48:54.982377303Z","tool":"delete_card","input":{"id":"1cdfc05e20c51430"},"ok":true}
{"ts":"2026-05-06T22:48:54.982541766Z","tool":"delete_card","input":{"id":"0d4b8afab5344cbd"},"ok":true}
{"ts":"2026-05-06T22:48:54.982583432Z","tool":"delete_card","input":{"id":"88551589d2f7abd0"},"ok":true}
{"ts":"2026-05-08T11:05:19.870107956Z","tool":"create_column","input":{"name":"HACIENDO 🚧"},"ok":true,"result_summary":"column a5f7f05963bbf3ed name=\"HACIENDO 🚧\""}
{"ts":"2026-05-08T11:05:19.879303459Z","tool":"create_column","input":{"name":"PNDNT FEEDBACK ▶️"},"ok":true,"result_summary":"column 61e44ab592ce223a name=\"PNDNT FEEDBACK ▶️\""}
{"ts":"2026-05-08T11:05:19.879427883Z","tool":"create_column","input":{"name":"HECHO ✅"},"ok":true,"result_summary":"column 06ac391eb6d8ce8b name=\"HECHO ✅\""}
{"ts":"2026-05-08T11:05:19.879530269Z","tool":"create_column","input":{"name":"IDEAS 💡"},"ok":true,"result_summary":"column 63974019466e3f1d name=\"IDEAS 💡\""}
{"ts":"2026-05-08T11:05:19.879639469Z","tool":"create_column","input":{"name":"DEUDA TÉCNICA 🔄"},"ok":true,"result_summary":"column 635506c9aaac540a name=\"DEUDA TÉCNICA 🔄\""}
{"ts":"2026-05-08T11:05:40.973634884Z","tool":"update_column","input":{"id":"06ac391eb6d8ce8b","is_done":true},"ok":true}
{"ts":"2026-05-08T11:05:40.974205892Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Simon","title":"MCP","locked":true},"ok":true,"result_summary":"card f6efaa13146787dd title=\"MCP\" col=a5f7f05963bbf3ed"}
{"ts":"2026-05-08T11:05:40.974526775Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Alvaro Calvo","title":"Footprint: Arreglar centros"},"ok":true,"result_summary":"card cbc358b5c0cac316 title=\"Footprint: Arreglar centros\" col=a5f7f05963bbf3ed"}
{"ts":"2026-05-08T11:05:40.974963613Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Sofia","title":"Añadir precaweb + centros web a informe de venta v1.2 — Cuadrar con datos de Diego 😰"},"ok":true,"result_summary":"card 6a8f39dc0e8e7218 title=\"Añadir precaweb + centros web a informe de venta v1.2 — Cuadrar con datos de Diego 😰\" col=a5f7f05963bbf3ed"}
{"ts":"2026-05-08T11:05:40.975384857Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Alberto Frias","title":"Mejorar el informe de ventas (Nat)","description":"Todos los detalles menos rehacer indicadores. Preguntar a Andrés."},"ok":true,"result_summary":"card 5d44483861cbdda3 title=\"Mejorar el informe de ventas (Nat)\" col=a5f7f05963bbf3ed"}
{"ts":"2026-05-08T11:05:40.975700632Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"MariClaire","title":"Media de ticket medio por día, semana y hora (Enma)"},"ok":true,"result_summary":"card eed928c34ccb85a2 title=\"Media de ticket medio por día, semana y hora (Enma)\" col=a5f7f05963bbf3ed"}
{"ts":"2026-05-08T11:05:40.975969784Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Data","title":"Visualizaciones de dashboards (Alfon)"},"ok":true,"result_summary":"card f960cc196dd2ab0a title=\"Visualizaciones de dashboards (Alfon)\" col=a5f7f05963bbf3ed"}
{"ts":"2026-05-08T11:05:40.976229693Z","tool":"create_card","input":{"column_id":"61e44ab592ce223a","requester":"Emilio","title":"Conversión OTRS centros de glass"},"ok":true,"result_summary":"card c3c867025281c088 title=\"Conversión OTRS centros de glass\" col=61e44ab592ce223a"}
{"ts":"2026-05-08T11:05:40.976519357Z","tool":"create_card","input":{"column_id":"61e44ab592ce223a","requester":"Alberto Frias","title":"Informe de Car"},"ok":true,"result_summary":"card 66ae0108656a731e title=\"Informe de Car\" col=61e44ab592ce223a"}
{"ts":"2026-05-08T11:05:40.976869903Z","tool":"create_card","input":{"column_id":"61e44ab592ce223a","requester":"Marta","title":"Lean n3, n2, n1: Dashboard"},"ok":true,"result_summary":"card bdd86aa84645b3f3 title=\"Lean n3, n2, n1: Dashboard\" col=61e44ab592ce223a"}
{"ts":"2026-05-08T11:05:40.977235162Z","tool":"create_card","input":{"column_id":"61e44ab592ce223a","requester":"Santiago","title":"% de callcenter sobre total (Alfon)"},"ok":true,"result_summary":"card 69615eb998a5705d title=\"% de callcenter sobre total (Alfon)\" col=61e44ab592ce223a"}
{"ts":"2026-05-08T11:05:40.977586792Z","tool":"create_card","input":{"column_id":"61e44ab592ce223a","requester":"Alvaro Calvo","title":"Tasaciones de Galicia"},"ok":true,"result_summary":"card 81e756341403a4d7 title=\"Tasaciones de Galicia\" col=61e44ab592ce223a"}
{"ts":"2026-05-08T11:05:40.977901945Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Javi","title":"Añadir usuarios"},"ok":true,"result_summary":"card 1e13d5da79a9bae2 title=\"Añadir usuarios\" col=06ac391eb6d8ce8b"}
{"ts":"2026-05-08T11:05:40.978247494Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Pilar RRHH","title":"DNIs de trabajadores"},"ok":true,"result_summary":"card 56a4b2b4ac5e8251 title=\"DNIs de trabajadores\" col=06ac391eb6d8ce8b"}
{"ts":"2026-05-08T11:05:40.978587684Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Angel","title":"Dashboard de servicios"},"ok":true,"result_summary":"card 6017f8cb1d6c4d8c title=\"Dashboard de servicios\" col=06ac391eb6d8ce8b"}
{"ts":"2026-05-08T11:05:40.978902225Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Fer","title":"Venta de neu por día desde CallCenter y precio medio para obtener facturación real"},"ok":true,"result_summary":"card b1e820b29afa5cdf title=\"Venta de neu por día desde CallCenter y precio medio para obtener facturación real\" col=06ac391eb6d8ce8b"}
{"ts":"2026-05-08T11:05:40.979238803Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"MariClaire","title":"Tiempo de empleados por hora"},"ok":true,"result_summary":"card 2a67ec283a40dd1a title=\"Tiempo de empleados por hora\" col=06ac391eb6d8ce8b"}
{"ts":"2026-05-08T11:05:40.979544021Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Simon","title":"Promoción Ceat en mano de obra"},"ok":true,"result_summary":"card 1eba435104d4391a title=\"Promoción Ceat en mano de obra\" col=06ac391eb6d8ce8b"}
{"ts":"2026-05-08T11:05:40.979853172Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Data","title":"Preparar informe de transformación"},"ok":true,"result_summary":"card 47dc1a64d4811539 title=\"Preparar informe de transformación\" col=06ac391eb6d8ce8b"}
{"ts":"2026-05-08T11:05:40.980121612Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Andres","title":"Facturación Marcajes 2026"},"ok":true,"result_summary":"card 18fa5511fb0c8095 title=\"Facturación Marcajes 2026\" col=06ac391eb6d8ce8b"}
{"ts":"2026-05-08T11:05:40.980594215Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","requester":"Paco","title":"Permisos Metabase (Enma)"},"ok":true,"result_summary":"card 442714f56f74b1f0 title=\"Permisos Metabase (Enma)\" col=06ac391eb6d8ce8b"}
{"ts":"2026-05-08T11:05:40.981228033Z","tool":"create_card","input":{"column_id":"06ac391eb6d8ce8b","title":"Herramienta sencilla alternativa a Jira"},"ok":true,"result_summary":"card 2268e2cd44a587fb title=\"Herramienta sencilla alternativa a Jira\" col=06ac391eb6d8ce8b"}
{"ts":"2026-05-08T11:05:40.981582925Z","tool":"create_card","input":{"column_id":"635506c9aaac540a","title":"Reinventar informe de CAR"},"ok":true,"result_summary":"card 7043c7f97b2e9c43 title=\"Reinventar informe de CAR\" col=635506c9aaac540a"}
{"ts":"2026-05-08T11:05:40.981924049Z","tool":"create_card","input":{"column_id":"635506c9aaac540a","title":"Limpiar tablas con datos erróneos"},"ok":true,"result_summary":"card e6863961ad8648f9 title=\"Limpiar tablas con datos erróneos\" col=635506c9aaac540a"}
{"ts":"2026-05-08T11:05:40.982232277Z","tool":"create_card","input":{"column_id":"635506c9aaac540a","title":"Unificar DATACLAW"},"ok":true,"result_summary":"card 3662ba02fdae93bf title=\"Unificar DATACLAW\" col=635506c9aaac540a"}
{"ts":"2026-05-08T11:05:40.982524229Z","tool":"create_card","input":{"column_id":"635506c9aaac540a","title":"Unificar la ontología"},"ok":true,"result_summary":"card 47237e4a0c55fcff title=\"Unificar la ontología\" col=635506c9aaac540a"}
{"ts":"2026-05-08T11:05:54.670085952Z","tool":"update_card","input":{"id":"f6efaa13146787dd","locked":true},"ok":true}
{"ts":"2026-05-08T11:05:54.670203761Z","tool":"update_column","input":{"id":"63974019466e3f1d","location":"sidebar"},"ok":true}
{"ts":"2026-05-08T11:06:09.679110703Z","tool":"update_column","input":{"id":"635506c9aaac540a","location":"sidebar"},"ok":true}
{"ts":"2026-05-08T11:16:12.355764942Z","tool":"update_card","input":{"id":"1e13d5da79a9bae2","color":"blue"},"ok":true}
{"ts":"2026-05-08T11:16:12.355959344Z","tool":"update_card","input":{"id":"2a67ec283a40dd1a","color":"blue"},"ok":true}
{"ts":"2026-05-08T11:16:12.35612212Z","tool":"update_card","input":{"id":"47dc1a64d4811539","color":"blue"},"ok":true}
{"ts":"2026-05-08T11:16:12.356239725Z","tool":"update_card","input":{"id":"442714f56f74b1f0","color":"blue"},"ok":true}
{"ts":"2026-05-08T11:16:12.356354693Z","tool":"update_card","input":{"id":"2268e2cd44a587fb","color":"blue"},"ok":true}
{"ts":"2026-05-08T11:16:12.356462581Z","tool":"update_card","input":{"id":"f6efaa13146787dd","color":"blue"},"ok":true}
{"ts":"2026-05-08T11:16:12.356572287Z","tool":"update_card","input":{"id":"6a8f39dc0e8e7218","color":"blue"},"ok":true}
{"ts":"2026-05-08T11:16:12.356699579Z","tool":"update_card","input":{"id":"eed928c34ccb85a2","color":"blue"},"ok":true}
{"ts":"2026-05-08T11:16:12.356824315Z","tool":"update_card","input":{"id":"56a4b2b4ac5e8251","color":"green"},"ok":true}
{"ts":"2026-05-08T11:16:12.356935364Z","tool":"update_card","input":{"id":"6017f8cb1d6c4d8c","color":"green"},"ok":true}
{"ts":"2026-05-08T11:16:12.357046515Z","tool":"update_card","input":{"id":"1eba435104d4391a","color":"green"},"ok":true}
{"ts":"2026-05-08T11:16:12.357157261Z","tool":"update_card","input":{"id":"18fa5511fb0c8095","color":"green"},"ok":true}
{"ts":"2026-05-08T11:16:12.35726429Z","tool":"update_card","input":{"id":"81e756341403a4d7","color":"green"},"ok":true}
{"ts":"2026-05-08T11:16:12.357372562Z","tool":"update_card","input":{"id":"b1e820b29afa5cdf","color":"orange"},"ok":true}
{"ts":"2026-05-08T11:16:12.357496076Z","tool":"update_card","input":{"id":"c3c867025281c088","color":"orange"},"ok":true}
{"ts":"2026-05-08T11:16:12.357605751Z","tool":"update_card","input":{"id":"66ae0108656a731e","color":"orange"},"ok":true}
{"ts":"2026-05-08T11:16:12.357748295Z","tool":"update_card","input":{"id":"69615eb998a5705d","color":"orange"},"ok":true}
{"ts":"2026-05-08T11:16:12.35784585Z","tool":"update_card","input":{"id":"b83087eb4162fdac","color":"orange"},"ok":true}
{"ts":"2026-05-08T11:16:12.358008545Z","tool":"update_card","input":{"id":"f960cc196dd2ab0a","color":"orange"},"ok":true}
{"ts":"2026-05-08T11:23:48.494485056Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Enmanuel","title":"Dashboard con totales — líneas TPV/Quote/OTR/Invoice por vendedor, diag y mecánico","description":"Analizar tabla y sacar totales de forma sencilla.\nhttps://reports.autingo.es/question/9754-lineas-tpv-quote-otr-invoice-actores-vendedor-diag-mecanico-producto?con_mecanico=No\u0026fecha=\u0026con_diagnosticador=No\u0026producto_nav_id=","color":"orange"},"ok":true,"result_summary":"card a33c10a6600db235 title=\"Dashboard con totales — líneas TPV/Quote/OTR/Invoice por vendedor, diag y mecánico\" col=a5f7f05963bbf3ed"}
{"ts":"2026-05-08T11:23:59.15054959Z","tool":"list_users","input":{},"ok":true,"result_summary":"[{\"id\":\"039c97acf1869393\",\"username\":\"amassaguer\",\"display_name\":\"alfon\",\"created_at\":\"2026-05-08T11:03:27.358308764Z\"},{\"id\":\"6a75edc6e99d8405\",\"username\":\"egutierrez\",\"display_name\":\"Enmaa\",\"created..."}
{"ts":"2026-05-08T11:24:05.428419675Z","tool":"assign_card","input":{"id":"a33c10a6600db235","assignee_id":"039c97acf1869393"},"ok":true}
{"ts":"2026-05-08T11:28:42.163127804Z","tool":"create_card","input":{"column_id":"a5f7f05963bbf3ed","requester":"Danny Sanchez","title":"MB: estado y número de OTRs por presupuesto","description":"","assignee_id":"9e91db261084d529"},"ok":true,"result_summary":"card 11d55b6752f10bdd title=\"MB: estado y número de OTRs por presupuesto\" col=a5f7f05963bbf3ed"}
{"ts":"2026-05-08T11:28:50.498149425Z","tool":"assign_card","input":{"id":"11d55b6752f10bdd","assignee_id":"9e91db261084d529"},"ok":true}
{"ts":"2026-05-08T11:50:31.549537256Z","tool":"update_card","input":{"id":"b1e820b29afa5cdf","color":"pink"},"ok":true}
{"ts":"2026-05-08T11:50:31.54980371Z","tool":"update_card","input":{"id":"c3c867025281c088","color":"pink"},"ok":true}
{"ts":"2026-05-08T11:50:31.550312442Z","tool":"update_card","input":{"id":"66ae0108656a731e","color":"pink"},"ok":true}
{"ts":"2026-05-08T11:50:31.550427194Z","tool":"update_card","input":{"id":"69615eb998a5705d","color":"pink"},"ok":true}
{"ts":"2026-05-08T11:50:31.550564181Z","tool":"update_card","input":{"id":"b83087eb4162fdac","color":"pink"},"ok":true}
{"ts":"2026-05-08T11:50:31.550752616Z","tool":"update_card","input":{"id":"f960cc196dd2ab0a","color":"pink"},"ok":true}
{"ts":"2026-05-08T12:46:10.901190181Z","tool":"move_card","input":{"id":"1e13d5da79a9bae2","column_id":"06ac391eb6d8ce8b","ordered_ids":["1e13d5da79a9bae2","2a67ec283a40dd1a","47dc1a64d4811539","442714f56f74b1f0","2268e2cd44a587fb","56a4b2b4ac5e8251","6017f8cb1d6c4d8c","1eba435104d4391a","18fa5511fb0c8095","c3c867025281c088","b1e820b29afa5cdf"]},"ok":true}
{"ts":"2026-05-08T13:00:55.650201794Z","tool":"create_card","input":{"column_id":"63974019466e3f1d","title":"Mezclar dashboard de fichajes con productividad","description":"https://reports.autingo.es/dashboard/994?centro=\u0026dni=\u0026fecha=thisday\u0026provincia=\u0026tipo=\u0026usuario="},"ok":true,"result_summary":"card acf64523865f23d0 title=\"Mezclar dashboard de fichajes con productividad\" col=63974019466e3f1d"}
+112 -21
View File
@@ -3,7 +3,10 @@ package main
import (
"database/sql"
_ "embed"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"fn-registry/functions/core"
@@ -25,21 +28,22 @@ type Column struct {
}
type Card struct {
ID string `json:"id"`
Requester string `json:"requester"`
Title string `json:"title"`
Description string `json:"description"`
Color string `json:"color"`
ColumnID string `json:"column_id"`
Position int `json:"position"`
Locked bool `json:"locked"`
AssigneeID *string `json:"assignee_id"`
CompletedAt *string `json:"completed_at"`
DeletedAt *string `json:"deleted_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
EnteredAt string `json:"entered_at"`
TimeInColumn int64 `json:"time_in_column_ms"`
ID string `json:"id"`
Requester string `json:"requester"`
Title string `json:"title"`
Description string `json:"description"`
Color string `json:"color"`
ColumnID string `json:"column_id"`
Position int `json:"position"`
Locked bool `json:"locked"`
AssigneeID *string `json:"assignee_id"`
CompletedAt *string `json:"completed_at"`
DeletedAt *string `json:"deleted_at"`
Tags []string `json:"tags"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
EnteredAt string `json:"entered_at"`
TimeInColumn int64 `json:"time_in_column_ms"`
}
type HistoryEntry struct {
@@ -101,6 +105,7 @@ func ensureColumns(conn *sql.DB) error {
{"cards", "assignee_id", "TEXT"},
{"cards", "completed_at", "TEXT"},
{"cards", "deleted_at", "TEXT"},
{"cards", "tags", "TEXT NOT NULL DEFAULT '[]'"},
{"card_column_history", "actor_id", "TEXT"},
{"card_lock_history", "actor_id", "TEXT"},
}
@@ -156,6 +161,81 @@ func newID() string {
func nowRFC3339() string { return time.Now().UTC().Format(time.RFC3339Nano) }
func parseTags(s string) []string {
out := []string{}
if s == "" {
return out
}
if err := json.Unmarshal([]byte(s), &out); err != nil {
return []string{}
}
return out
}
func normalizeTags(in []string) []string {
seen := map[string]struct{}{}
out := []string{}
for _, t := range in {
t = strings.TrimSpace(t)
if t == "" {
continue
}
if _, ok := seen[t]; ok {
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
sort.Strings(out)
return out
}
func encodeTags(in []string) string {
b, _ := json.Marshal(normalizeTags(in))
return string(b)
}
func (db *DB) ListAllTags() ([]string, error) {
rows, err := db.conn.Query(`SELECT DISTINCT tags FROM cards WHERE deleted_at IS NULL`)
if err != nil {
return nil, err
}
defer rows.Close()
seen := map[string]struct{}{}
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
for _, t := range parseTags(s) {
seen[t] = struct{}{}
}
}
out := make([]string, 0, len(seen))
for k := range seen {
out = append(out, k)
}
sort.Strings(out)
return out, nil
}
func (db *DB) ListDistinctRequesters() ([]string, error) {
rows, err := db.conn.Query(`SELECT DISTINCT requester FROM cards WHERE deleted_at IS NULL AND requester != '' ORDER BY requester`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func nullableActor(actorID string) any {
if actorID == "" {
return nil
@@ -298,7 +378,7 @@ func (db *DB) ReorderColumns(ids []string) error {
func (db *DB) ListCardsWithTime() ([]Card, error) {
rows, err := db.conn.Query(`
SELECT c.id, 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.created_at, c.updated_at,
SELECT c.id, 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.created_at, c.updated_at,
h.entered_at
FROM cards c
LEFT JOIN card_column_history h
@@ -318,8 +398,9 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
var assignee sql.NullString
var completed sql.NullString
var deleted sql.NullString
var tagsJSON string
var locked int
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &c.CreatedAt, &c.UpdatedAt, &entered); err != nil {
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &c.CreatedAt, &c.UpdatedAt, &entered); err != nil {
return nil, err
}
c.Locked = locked != 0
@@ -335,6 +416,7 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
s := deleted.String
c.DeletedAt = &s
}
c.Tags = parseTags(tagsJSON)
if entered.Valid {
c.EnteredAt = entered.String
if t, err := time.Parse(time.RFC3339Nano, entered.String); err == nil {
@@ -358,6 +440,7 @@ func (db *DB) CreateCard(columnID, requester, title, description, actorID string
now := nowRFC3339()
c := Card{
ID: newID(), Requester: requester, Title: title, Description: description, ColumnID: columnID, Position: pos,
Tags: []string{},
CreatedAt: now, UpdatedAt: now, EnteredAt: now,
}
tx, err := db.conn.Begin()
@@ -366,8 +449,8 @@ func (db *DB) CreateCard(columnID, requester, title, description, actorID string
}
defer tx.Rollback()
if _, err := tx.Exec(
`INSERT INTO cards (id, requester, title, description, color, column_id, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position, c.CreatedAt, c.UpdatedAt,
`INSERT INTO cards (id, requester, title, description, color, column_id, position, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position, encodeTags(c.Tags), c.CreatedAt, c.UpdatedAt,
); err != nil {
return nil, err
}
@@ -402,6 +485,7 @@ type CardPatch struct {
Locked *bool
AssigneeID *string // empty string clears assignment
HasAssignee bool // distinguishes "set to null" from "not provided"
Tags *[]string
}
func (db *DB) UpdateCard(id string, patch CardPatch) error {
@@ -445,6 +529,11 @@ func (db *DB) UpdateCardWithActor(id string, patch CardPatch, actorID string) er
}
}
}
if patch.Tags != nil {
if _, err := tx.Exec(`UPDATE cards SET tags=?, updated_at=? WHERE id=?`, encodeTags(*patch.Tags), nowRFC3339(), id); err != nil {
return err
}
}
if patch.Locked != nil {
var current int
if err := tx.QueryRow(`SELECT locked FROM cards WHERE id=?`, id).Scan(&current); err != nil {
@@ -500,7 +589,7 @@ func (db *DB) PurgeCard(id string) error {
// ListDeletedCards returns cards in the trash, newest first.
func (db *DB) ListDeletedCards() ([]Card, error) {
rows, err := db.conn.Query(`
SELECT c.id, 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.created_at, c.updated_at
SELECT c.id, 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.created_at, c.updated_at
FROM cards c
WHERE c.deleted_at IS NOT NULL
ORDER BY c.deleted_at DESC
@@ -515,8 +604,9 @@ func (db *DB) ListDeletedCards() ([]Card, error) {
var assignee sql.NullString
var completed sql.NullString
var deleted sql.NullString
var tagsJSON string
var locked int
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &c.CreatedAt, &c.UpdatedAt); err != nil {
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
c.Locked = locked != 0
@@ -532,6 +622,7 @@ func (db *DB) ListDeletedCards() ([]Card, error) {
s := deleted.String
c.DeletedAt = &s
}
c.Tags = parseTags(tagsJSON)
out = append(out, c)
}
return out, rows.Err()
+215 -5
View File
@@ -28,10 +28,13 @@ import {
Badge,
Box,
Button,
Checkbox,
Group,
Loader,
Menu,
MultiSelect,
Paper,
Select,
Stack,
Tabs,
Text,
@@ -39,6 +42,8 @@ import {
Title,
Tooltip,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import "@mantine/dates/styles.css";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import {
@@ -53,6 +58,7 @@ import {
IconMessageChatbot,
IconPlus,
IconRefresh,
IconSearch,
IconTrash,
IconTrashX,
IconX,
@@ -107,6 +113,15 @@ export function App() {
const [activeTab, setActiveTab] = useState<string>("board");
const [trash, setTrash] = useState<Card[]>([]);
const [trashOpen, setTrashOpen] = useState(false);
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [filterAssigneeId, setFilterAssigneeId] = useState<string | null>(null);
const [filterRequester, setFilterRequester] = useState<string | null>(null);
const [filterTags, setFilterTags] = useState<string[]>([]);
const [filterUnassigned, setFilterUnassigned] = useState(false);
const [filterDateFrom, setFilterDateFrom] = useState<Date | null>(null);
const [filterDateTo, setFilterDateTo] = useState<Date | null>(null);
const [navOpen, setNavOpen] = useState(false);
const [navWidth, setNavWidth] = useState<number>(() => {
const stored = localStorage.getItem("kanban_nav_width");
@@ -176,6 +191,24 @@ export function App() {
}
}, []);
const reloadTags = useCallback(async () => {
try {
const t = await api.listTags();
setTagOptions(t);
} catch (e) {
console.warn("listTags failed", e);
}
}, []);
const reloadRequesters = useCallback(async () => {
try {
const r = await api.listRequesters();
setRequesterOptions(r);
} catch (e) {
console.warn("listRequesters failed", e);
}
}, []);
useEffect(() => {
reloadUsers();
}, [reloadUsers]);
@@ -184,6 +217,11 @@ export function App() {
reloadTrash();
}, [reloadTrash]);
useEffect(() => {
reloadTags();
reloadRequesters();
}, [reloadTags, reloadRequesters]);
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
@@ -206,16 +244,61 @@ export function App() {
const boardSortableIds = useMemo(() => boardColumns.map((c) => `${COL_PREFIX}${c.id}`), [boardColumns]);
const sidebarSortableIds = useMemo(() => sidebarColumns.map((c) => `${COL_PREFIX}${c.id}`), [sidebarColumns]);
const cardMatches = useCallback(
(c: Card): boolean => {
const term = searchTerm.trim().toLowerCase();
if (term) {
const hay = [
c.title,
c.description,
c.requester,
...(c.tags || []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (!hay.includes(term)) return false;
}
if (filterAssigneeId && c.assignee_id !== filterAssigneeId) return false;
if (filterUnassigned && c.assignee_id) return false;
if (filterRequester && c.requester !== filterRequester) return false;
if (filterTags.length > 0) {
const cardTags = new Set(c.tags || []);
for (const t of filterTags) if (!cardTags.has(t)) return false;
}
if (filterDateFrom || filterDateTo) {
const fromMs = filterDateFrom ? new Date(filterDateFrom).setHours(0, 0, 0, 0) : -Infinity;
const toMs = filterDateTo ? new Date(filterDateTo).setHours(23, 59, 59, 999) : Infinity;
const created = c.created_at ? new Date(c.created_at).getTime() : NaN;
const moved = c.entered_at ? new Date(c.entered_at).getTime() : NaN;
const inRange = (t: number) => !isNaN(t) && t >= fromMs && t <= toMs;
if (!inRange(created) && !inRange(moved)) return false;
}
return true;
},
[searchTerm, filterAssigneeId, filterUnassigned, filterRequester, filterTags, filterDateFrom, filterDateTo]
);
const cardsByColumn = useMemo(() => {
const map = new Map<string, Card[]>();
if (!board) return map;
for (const col of board.columns) map.set(col.id, []);
for (const c of [...board.cards].sort((a, b) => a.position - b.position)) {
if (!cardMatches(c)) continue;
const arr = map.get(c.column_id);
if (arr) arr.push(c);
}
return map;
}, [board]);
}, [board, cardMatches]);
const filtersActive =
!!searchTerm.trim() ||
!!filterAssigneeId ||
filterUnassigned ||
!!filterRequester ||
filterTags.length > 0 ||
!!filterDateFrom ||
!!filterDateTo;
const findCard = (id: string): Card | undefined => board?.cards.find((c) => c.id === id);
const findColumn = (id: string): Column | undefined => board?.columns.find((c) => c.id === id);
@@ -424,6 +507,8 @@ export function App() {
children: (
<CardForm
users={users}
requesterOptions={requesterOptions}
tagOptions={tagOptions}
initial={{ requester: auth.user?.display_name || auth.user?.username || "" }}
submitLabel="Crear"
onCancel={() => modals.close(id)}
@@ -435,9 +520,12 @@ export function App() {
title: v.title,
description: v.description,
assignee_id: v.assignee_id,
tags: v.tags,
});
modals.close(id);
reload();
reloadTags();
reloadRequesters();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
@@ -445,7 +533,7 @@ export function App() {
/>
),
});
}, [reload, users, auth.user]);
}, [reload, users, auth.user, requesterOptions, tagOptions]);
const openEditCard = useCallback((card: Card) => {
const id = modals.open({
@@ -454,11 +542,14 @@ export function App() {
children: (
<CardForm
users={users}
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)}
@@ -469,9 +560,12 @@ export function App() {
title: v.title,
description: v.description,
assignee_id: v.assignee_id,
tags: v.tags,
});
modals.close(id);
reload();
reloadTags();
reloadRequesters();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
@@ -479,7 +573,7 @@ export function App() {
/>
),
});
}, [reload, users]);
}, [reload, users, requesterOptions, tagOptions]);
const handleAssignCard = useCallback(async (id: string, assignee_id: string | null) => {
setBoard((prev) => {
@@ -822,14 +916,130 @@ export function App() {
<CalendarView users={users} />
</Box>
) : (
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden" }}>
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
<Group gap="xs" p="xs" wrap="wrap" align="end" style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}>
<TextInput
leftSection={<IconSearch size={14} />}
placeholder="Buscar (titulo, descripcion, solicitante, tag)"
value={searchTerm}
onChange={(e) => setSearchTerm(e.currentTarget.value)}
rightSection={
searchTerm ? (
<ActionIcon size="sm" variant="subtle" color="gray" onClick={() => setSearchTerm("")} aria-label="Limpiar">
<IconX size={14} />
</ActionIcon>
) : null
}
style={{ minWidth: 280, flex: 1 }}
size="xs"
/>
<Select
placeholder="Asignado"
value={filterAssigneeId}
onChange={setFilterAssigneeId}
data={users.map((u) => ({ value: u.id, label: u.display_name || u.username }))}
clearable
searchable
size="xs"
style={{ minWidth: 160 }}
disabled={filterUnassigned}
/>
<Checkbox
size="xs"
label="Sin asignar"
checked={filterUnassigned}
onChange={(e) => {
const v = e.currentTarget.checked;
setFilterUnassigned(v);
if (v) setFilterAssigneeId(null);
}}
/>
<Select
placeholder="Solicitante"
value={filterRequester}
onChange={setFilterRequester}
data={requesterOptions}
clearable
searchable
size="xs"
style={{ minWidth: 160 }}
/>
<MultiSelect
placeholder="Tags"
value={filterTags}
onChange={setFilterTags}
data={tagOptions}
clearable
searchable
size="xs"
style={{ minWidth: 200 }}
/>
<DatePickerInput
placeholder="Desde"
value={filterDateFrom}
onChange={(v) => setFilterDateFrom(v ? new Date(v as unknown as string) : null)}
clearable
size="xs"
style={{ minWidth: 130 }}
valueFormat="DD/MM/YY"
/>
<DatePickerInput
placeholder="Hasta"
value={filterDateTo}
onChange={(v) => setFilterDateTo(v ? new Date(v as unknown as string) : null)}
clearable
size="xs"
style={{ minWidth: 130 }}
valueFormat="DD/MM/YY"
/>
<Group gap={4}>
<Button size="xs" variant="default" onClick={() => {
const t = new Date();
setFilterDateFrom(t);
setFilterDateTo(t);
}}>Hoy</Button>
<Button size="xs" variant="default" onClick={() => {
const t = new Date();
const f = new Date();
f.setDate(f.getDate() - 7);
setFilterDateFrom(f);
setFilterDateTo(t);
}}>7d</Button>
<Button size="xs" variant="default" onClick={() => {
const t = new Date();
const f = new Date();
f.setDate(f.getDate() - 30);
setFilterDateFrom(f);
setFilterDateTo(t);
}}>30d</Button>
</Group>
{filtersActive && (
<Button
size="xs"
variant="subtle"
color="gray"
leftSection={<IconX size={12} />}
onClick={() => {
setSearchTerm("");
setFilterAssigneeId(null);
setFilterUnassigned(false);
setFilterRequester(null);
setFilterTags([]);
setFilterDateFrom(null);
setFilterDateTo(null);
}}
>
Limpiar
</Button>
)}
</Group>
<SortableContext items={boardSortableIds} strategy={horizontalListSortingStrategy}>
<Group
align="stretch"
wrap="nowrap"
gap="md"
p="md"
style={{ height: "100%", overflowX: "auto" }}
style={{ flex: 1, overflowX: "auto", overflowY: "hidden" }}
>
{boardColumns.map((col) => (
<KanbanColumn
+10
View File
@@ -68,6 +68,7 @@ export interface CreateCardInput {
title: string;
description?: string;
assignee_id?: string | null;
tags?: string[];
}
export function createCard(input: CreateCardInput): Promise<Card> {
@@ -81,6 +82,7 @@ export interface UpdateCardInput {
color?: string;
locked?: boolean;
assignee_id?: string | null;
tags?: string[];
}
export function updateCard(id: string, patch: UpdateCardInput): Promise<void> {
@@ -162,6 +164,14 @@ export function listUsers(): Promise<User[]> {
return fetchJSON("/users");
}
export function listTags(): Promise<string[]> {
return fetchJSON("/tags");
}
export function listRequesters(): Promise<string[]> {
return fetchJSON("/requesters");
}
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
const qs = new URLSearchParams();
if (f.from) qs.set("from", f.from);
+42 -8
View File
@@ -1,4 +1,4 @@
import { Button, Group, Select, Stack, Textarea, TextInput } from "@mantine/core";
import { Autocomplete, Button, Group, Select, Stack, TagsInput, Textarea } from "@mantine/core";
import { FormEvent, KeyboardEvent, useState } from "react";
import type { User } from "../types";
@@ -7,21 +7,33 @@ export interface CardFormValues {
title: string;
description: string;
assignee_id: string | null;
tags: string[];
}
interface Props {
initial?: Partial<CardFormValues>;
submitLabel?: string;
users?: User[];
requesterOptions?: string[];
tagOptions?: string[];
onSubmit: (v: CardFormValues) => Promise<void> | void;
onCancel: () => void;
}
export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmit, onCancel }: Props) {
export function CardForm({
initial,
submitLabel = "Guardar",
users = [],
requesterOptions = [],
tagOptions = [],
onSubmit,
onCancel,
}: Props) {
const [requester, setRequester] = useState(initial?.requester ?? "");
const [title, setTitle] = useState(initial?.title ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
const submit = async (e?: FormEvent) => {
e?.preventDefault();
@@ -32,6 +44,7 @@ export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmi
title: t,
description,
assignee_id: assigneeId,
tags,
});
};
@@ -51,7 +64,7 @@ export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmi
return (
<form onSubmit={submit}>
<Stack gap="sm">
<TextInput
<Textarea
label="Tarea"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
@@ -59,15 +72,26 @@ export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmi
required
autoComplete="off"
data-autofocus
onKeyDown={enterSubmit}
autosize
minRows={1}
maxRows={4}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
/>
<TextInput
<Autocomplete
label="Solicitante"
value={requester}
onChange={(e) => setRequester(e.currentTarget.value)}
onChange={setRequester}
data={requesterOptions}
tabIndex={2}
autoComplete="off"
onKeyDown={enterSubmit}
placeholder="Empieza a escribir y elige uno existente"
limit={10}
/>
<Textarea
label="Descripcion"
@@ -93,11 +117,21 @@ export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmi
searchable
tabIndex={4}
/>
<TagsInput
label="Tags"
value={tags}
onChange={setTags}
data={tagOptions}
clearable
tabIndex={5}
placeholder="Enter para añadir; sugiere existentes"
splitChars={[",", " "]}
/>
<Group justify="flex-end" gap="xs" mt="xs">
<Button variant="subtle" color="gray" tabIndex={6} type="button" onClick={onCancel}>
<Button variant="subtle" color="gray" tabIndex={7} type="button" onClick={onCancel}>
Cancelar
</Button>
<Button tabIndex={5} type="submit" disabled={!title.trim()}>
<Button tabIndex={6} type="submit" disabled={!title.trim()}>
{submitLabel}
</Button>
</Group>
+20 -7
View File
@@ -178,7 +178,8 @@ export function Dashboard({ users }: Props) {
if (!data) return [];
return data.top_requesters.map((r) => ({
solicitante: r.requester,
tarjetas: r.total,
activas: r.active,
completadas: r.completed_in_range,
}));
}, [data]);
@@ -250,18 +251,25 @@ export function Dashboard({ users }: Props) {
{data && (
<>
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
<SimpleGrid cols={{ base: 2, md: 5 }} spacing="md">
<KPI
icon={<IconClipboardList size={14} />}
label="Tarjetas totales"
label="Totales"
value={data.totals.cards}
hint={`${data.totals.columns} columnas, ${data.totals.users} usuarios`}
/>
<KPI
icon={<IconClipboardList size={14} />}
label="Activas"
value={data.totals.cards_active}
hint={`Sin completar`}
color="blue"
/>
<KPI
icon={<IconCheckbox size={14} />}
label="Completadas (rango)"
value={data.totals.cards_completed_in_range}
hint={`${data.totals.cards_created_in_range} creadas en rango`}
hint={`${data.totals.cards_done} completadas total · ${data.totals.cards_created_in_range} creadas rango`}
color="green"
/>
<KPI
@@ -426,12 +434,17 @@ export function Dashboard({ users }: Props) {
</Text>
) : (
<BarChart
h={240}
h={Math.max(240, topRequesterSeries.length * 32)}
data={topRequesterSeries}
dataKey="solicitante"
orientation="vertical"
yAxisProps={{ width: 120 }}
series={[{ name: "tarjetas", label: "Tarjetas", color: "violet.6" }]}
yAxisProps={{ width: 160, interval: 0 }}
withLegend
series={[
{ name: "completadas", label: "Completadas", color: "green.6" },
{ name: "activas", label: "Activas", color: "violet.6" },
]}
type="stacked"
/>
)}
</Paper>
+11
View File
@@ -149,6 +149,7 @@ function KanbanCardImpl({
position="right-start"
withArrow
shadow="md"
withinPortal={false}
>
<Popover.Target>
<Menu.Item
@@ -176,6 +177,7 @@ function KanbanCardImpl({
clearable
searchable
autoFocus
comboboxProps={{ withinPortal: false }}
/>
</Popover.Dropdown>
</Popover>
@@ -298,6 +300,15 @@ function KanbanCardImpl({
{card.description}
</Text>
)}
{card.tags && card.tags.length > 0 && (
<Group gap={4} wrap="wrap">
{card.tags.map((t) => (
<Badge key={t} size="xs" variant="outline" color="violet" radius="sm">
{t}
</Badge>
))}
</Group>
)}
<Group gap={4}>
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
{formatDuration(liveMs)}
+5
View File
@@ -25,6 +25,7 @@ export interface Card {
assignee_id: string | null;
completed_at: string | null;
deleted_at: string | null;
tags: string[];
created_at: string;
updated_at: string;
entered_at: string;
@@ -47,6 +48,8 @@ export interface MetricsTotals {
cards: number;
cards_completed_in_range: number;
cards_created_in_range: number;
cards_active: number;
cards_done: number;
columns: number;
users: number;
active_locks: number;
@@ -90,6 +93,8 @@ export interface MetricsAssignee {
export interface MetricsRequester {
requester: string;
total: number;
active: number;
completed_in_range: number;
}
export interface MetricsMovement {
+48 -5
View File
@@ -118,11 +118,12 @@ func handleReorderColumns(db *DB) http.HandlerFunc {
func handleCreateCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
ColumnID string `json:"column_id"`
Requester string `json:"requester"`
Title string `json:"title"`
Description string `json:"description"`
AssigneeID *string `json:"assignee_id"`
ColumnID string `json:"column_id"`
Requester string `json:"requester"`
Title string `json:"title"`
Description string `json:"description"`
AssigneeID *string `json:"assignee_id"`
Tags []string `json:"tags"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
@@ -140,6 +141,13 @@ func handleCreateCard(db *DB) http.HandlerFunc {
c.AssigneeID = body.AssigneeID
}
}
if err == nil && len(body.Tags) > 0 {
tags := body.Tags
err = db.UpdateCardWithActor(c.ID, CardPatch{Tags: &tags}, actor)
if err == nil {
c.Tags = tags
}
}
if err != nil {
serverError(w, err)
return
@@ -182,6 +190,17 @@ func handleUpdateCard(db *DB) http.HandlerFunc {
patch.AssigneeID = &s
}
}
if v, present := raw["tags"]; present {
tags := []string{}
if arr, ok := v.([]any); ok {
for _, t := range arr {
if s, ok := t.(string); ok {
tags = append(tags, s)
}
}
}
patch.Tags = &tags
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if err := db.UpdateCardWithActor(id, patch, actor); err != nil {
serverError(w, err)
@@ -303,5 +322,29 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger) []infra.Route {
{Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db)},
{Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)},
{Method: "GET", Path: "/api/metrics", Handler: handleMetrics(db)},
{Method: "GET", Path: "/api/tags", Handler: handleListTags(db)},
{Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)},
}
}
func handleListTags(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tags, err := db.ListAllTags()
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, tags)
}
}
func handleListRequesters(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
out, err := db.ListDistinctRequesters()
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, out)
}
}
+54 -6
View File
@@ -39,6 +39,8 @@ type Totals struct {
Cards int `json:"cards"`
CardsCompleted int `json:"cards_completed_in_range"`
CardsCreated int `json:"cards_created_in_range"`
CardsActive int `json:"cards_active"`
CardsDone int `json:"cards_done"`
Columns int `json:"columns"`
Users int `json:"users"`
ActiveLocks int `json:"active_locks"`
@@ -82,6 +84,8 @@ type AssigneeStat struct {
type RequesterStat struct {
Requester string `json:"requester"`
Total int `json:"total"`
Active int `json:"active"`
Completed int `json:"completed_in_range"`
}
type MovementStat struct {
@@ -137,12 +141,25 @@ func parseDateOrDefault(s string, dflt time.Time) time.Time {
return dflt
}
func parseEndDateOrDefault(s string, dflt time.Time) time.Time {
if s == "" {
return dflt
}
if t, err := time.Parse("2006-01-02", s); err == nil {
return t.Add(24*time.Hour - time.Nanosecond)
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t
}
return dflt
}
// GET /api/metrics?from=YYYY-MM-DD&to=YYYY-MM-DD&assignee_id=...&requester=...
func handleMetrics(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC()
from := parseDateOrDefault(r.URL.Query().Get("from"), now.AddDate(0, 0, -30))
to := parseDateOrDefault(r.URL.Query().Get("to"), now)
to := parseEndDateOrDefault(r.URL.Query().Get("to"), now)
assignee := r.URL.Query().Get("assignee_id")
requester := r.URL.Query().Get("requester")
@@ -160,7 +177,15 @@ func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Me
toStr := to.Format(time.RFC3339Nano)
m := &Metrics{
Range: DateRange{From: from.Format("2006-01-02"), To: to.Format("2006-01-02")},
Range: DateRange{From: from.Format("2006-01-02"), To: to.Format("2006-01-02")},
ByColumn: []ColumnCount{},
ThroughputDaily: []DailyCount{},
CreatedDaily: []DailyCount{},
CycleTimeColumn: []ColumnDuration{},
TopAssignees: []AssigneeStat{},
TopRequesters: []RequesterStat{},
MovementsByUser: []MovementStat{},
CumulativeFlow: []CumulativePoint{},
}
cardWhere := "WHERE deleted_at IS NULL"
@@ -190,6 +215,18 @@ func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Me
).Scan(&m.Totals.CardsCreated); err != nil {
return nil, err
}
if err := db.conn.QueryRow(
`SELECT COUNT(*) FROM cards `+cardWhere+` AND (completed_at IS NULL OR completed_at='')`,
args...,
).Scan(&m.Totals.CardsActive); err != nil {
return nil, err
}
if err := db.conn.QueryRow(
`SELECT COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at!=''`,
args...,
).Scan(&m.Totals.CardsDone); err != nil {
return nil, err
}
_ = completedArgs
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM columns`).Scan(&m.Totals.Columns); err != nil {
@@ -276,12 +313,18 @@ func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Me
}
colRows.Close()
now := time.Now().UTC()
cap := to
if now.Before(cap) {
cap = now
}
capStr := cap.Format(time.RFC3339Nano)
for _, ci := range cols {
histArgs := []any{ci.id, fromStr, toStr}
histQ := `SELECT (julianday(COALESCE(h.exited_at, ?)) - julianday(h.entered_at)) * 86400000
FROM card_column_history h JOIN cards c ON c.id=h.card_id
WHERE h.column_id=? AND h.entered_at>=? AND h.entered_at<=?`
histArgs = append([]any{toStr}, histArgs...)
histArgs = append([]any{capStr}, histArgs...)
if assignee != "" {
histQ += ` AND c.assignee_id=?`
histArgs = append(histArgs, assignee)
@@ -325,9 +368,14 @@ func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Me
// Top requesters.
reqRows, err := db.conn.Query(
`SELECT requester, COUNT(*) as n FROM cards WHERE deleted_at IS NULL AND requester != '' AND created_at>=? AND created_at<=?`+
`SELECT requester,
COUNT(*) as total,
SUM(CASE WHEN completed_at IS NULL OR completed_at='' THEN 1 ELSE 0 END) as active,
SUM(CASE WHEN completed_at IS NOT NULL AND completed_at>=? AND completed_at<=? THEN 1 ELSE 0 END) as completed
FROM cards
WHERE deleted_at IS NULL AND requester != ''`+
condFromCard(assignee, "", "", "AND")+
` GROUP BY requester ORDER BY n DESC LIMIT 10`,
` GROUP BY requester ORDER BY total DESC LIMIT 10`,
topReqArgs(fromStr, toStr, assignee)...,
)
if err != nil {
@@ -335,7 +383,7 @@ func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Me
}
for reqRows.Next() {
var s RequesterStat
if err := reqRows.Scan(&s.Requester, &s.Total); err != nil {
if err := reqRows.Scan(&s.Requester, &s.Total, &s.Active, &s.Completed); err != nil {
reqRows.Close()
return nil, err
}