feat(kanban): deadlines en cards (context menu, badges, calendario, history)

- migration 009 + columna deadline TEXT en cards
- backend: CardPatch.HasDeadline, eventos deadline_set/deadline_cleared
- KanbanCard: menu derecho con DatePicker, badge countdown con colores por ratio (azul>=50%, amarillo<50%, rojo<10%, red.9 overdue)
- App.tsx: filtro "Con deadline", handleSetCardDeadline optimista, jump-to-card + highlight
- CalendarView: popover por dia con seq_num + titulo, click navega a card en tablero
- HistoryModal: render eventos deadline_set/deadline_cleared
- .gitignore: *.log

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 03:45:36 +02:00
parent 5ba0254e57
commit 7ce227ddea
54 changed files with 3066 additions and 496 deletions
+3
View File
@@ -15,3 +15,6 @@ frontend/tsconfig.tsbuildinfo
# Local files # Local files
local_files/ local_files/
# Logs
*.log
+18 -2
View File
@@ -6,7 +6,10 @@ description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas
tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking] tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking]
uses_functions: uses_functions:
- random_hex_id_go_core - random_hex_id_go_core
- parse_date_or_default_go_core
- sqlite_open_go_infra - sqlite_open_go_infra
- sqlite_apply_migrations_go_infra
- sqlite_column_exists_go_infra
- spa_handler_go_infra - spa_handler_go_infra
- http_router_go_infra - http_router_go_infra
- http_serve_go_infra - http_serve_go_infra
@@ -17,13 +20,26 @@ uses_functions:
- http_error_response_go_infra - http_error_response_go_infra
- http_parse_body_go_infra - http_parse_body_go_infra
- http_session_cookie_middleware_go_infra - http_session_cookie_middleware_go_infra
- http_session_token_extract_go_infra
- http_session_cookie_set_go_infra
- http_session_cookie_clear_go_infra
- password_hash_go_infra - password_hash_go_infra
- password_verify_go_infra - password_verify_go_infra
- session_create_go_infra - session_create_go_infra
- session_cleanup_go_infra - session_cleanup_go_infra
uses_types: [] - percentile_int64_go_datascience
- duration_stats_go_datascience
- format_duration_ts_core
- format_datetime_short_ts_core
- string_hash_palette_ts_core
- color_bg_ts_ui
- color_border_ts_ui
- color_swatch_ts_ui
- fetch_json_ts_infra
uses_types:
- DurationStats_go_datascience
framework: "net/http + vite + react + mantine + dnd-kit" framework: "net/http + vite + react + mantine + dnd-kit"
entry_point: "main.go" entry_point: "backend/main.go"
dir_path: "apps/kanban" dir_path: "apps/kanban"
--- ---
+33 -24
View File
@@ -18,36 +18,15 @@ type ctxKey string
const userCtxKey ctxKey = "kanban_user_id" const userCtxKey ctxKey = "kanban_user_id"
func setSessionCookie(w http.ResponseWriter, token string, expiresAt int64) { func setSessionCookie(w http.ResponseWriter, token string, expiresAt int64) {
http.SetCookie(w, &http.Cookie{ infra.SessionCookieSet(w, cookieName, token, expiresAt)
Name: cookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(expiresAt, 0),
})
} }
func clearSessionCookie(w http.ResponseWriter) { func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{ infra.SessionCookieClear(w, cookieName)
Name: cookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
} }
func tokenFromRequest(r *http.Request) string { func tokenFromRequest(r *http.Request) string {
if c, err := r.Cookie(cookieName); err == nil && c.Value != "" { return infra.SessionTokenExtract(r, cookieName)
return c.Value
}
auth := r.Header.Get("Authorization")
if len(auth) > 7 && auth[:7] == "Bearer " {
return auth[7:]
}
return ""
} }
// POST /api/auth/register {username, password, display_name?} // POST /api/auth/register {username, password, display_name?}
@@ -130,6 +109,36 @@ func handleMe(db *DB) http.HandlerFunc {
} }
} }
// PATCH /api/me { color? }
func handlePatchMe(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, ok := infra.UserIDFromContext(r.Context(), userCtxKey)
if !ok {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "no session"})
return
}
var body struct {
Color *string `json:"color"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if body.Color != nil {
if err := db.UpdateUserColor(uid, *body.Color); err != nil {
serverError(w, err)
return
}
}
u, err := db.GetUserByID(uid)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, u)
}
}
// GET /api/users // GET /api/users
func handleListUsers(db *DB) http.HandlerFunc { func handleListUsers(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
View File
View File
+207 -104
View File
@@ -5,7 +5,6 @@ import (
"embed" "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -36,6 +35,7 @@ type Sticker struct {
type Card struct { type Card struct {
ID string `json:"id"` ID string `json:"id"`
SeqNum int `json:"seq_num"`
Requester string `json:"requester"` Requester string `json:"requester"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
@@ -48,11 +48,13 @@ type Card struct {
DeletedAt *string `json:"deleted_at"` DeletedAt *string `json:"deleted_at"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Stickers []Sticker `json:"stickers"` Stickers []Sticker `json:"stickers"`
Deadline *string `json:"deadline"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
EnteredAt string `json:"entered_at"` EnteredAt string `json:"entered_at"`
TimeInColumn int64 `json:"time_in_column_ms"` TimeInColumn int64 `json:"time_in_column_ms"`
LockedAt *string `json:"locked_at"` LockedAt *string `json:"locked_at"`
TotalLockedMs int64 `json:"total_locked_ms"`
} }
type HistoryEntry struct { type HistoryEntry struct {
@@ -63,6 +65,7 @@ type HistoryEntry struct {
EnteredAt string `json:"entered_at"` EnteredAt string `json:"entered_at"`
ExitedAt *string `json:"exited_at"` ExitedAt *string `json:"exited_at"`
DurationMs int64 `json:"duration_ms"` DurationMs int64 `json:"duration_ms"`
ActorID *string `json:"actor_id"`
} }
type LockPeriod struct { type LockPeriod struct {
@@ -71,15 +74,26 @@ type LockPeriod struct {
LockedAt string `json:"locked_at"` LockedAt string `json:"locked_at"`
UnlockedAt *string `json:"unlocked_at"` UnlockedAt *string `json:"unlocked_at"`
DurationMs int64 `json:"duration_ms"` DurationMs int64 `json:"duration_ms"`
ActorID *string `json:"actor_id"`
} }
type CardHistoryResponse struct { type CardHistoryResponse struct {
ColumnHistory []HistoryEntry `json:"column_history"` ColumnHistory []HistoryEntry `json:"column_history"`
LockPeriods []LockPeriod `json:"lock_periods"` LockPeriods []LockPeriod `json:"lock_periods"`
Events []CardEvent `json:"events"`
TotalLockedMs int64 `json:"total_locked_ms"` TotalLockedMs int64 `json:"total_locked_ms"`
CurrentlyLock bool `json:"currently_locked"` CurrentlyLock bool `json:"currently_locked"`
} }
type CardEvent struct {
ID string `json:"id"`
CardID string `json:"card_id"`
Kind string `json:"kind"`
ActorID *string `json:"actor_id"`
Payload string `json:"payload"`
CreatedAt string `json:"created_at"`
}
type DB struct{ conn *sql.DB } type DB struct{ conn *sql.DB }
func openDB(path string) (*DB, error) { func openDB(path string) (*DB, error) {
@@ -87,7 +101,7 @@ func openDB(path string) (*DB, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := applyMigrations(conn); err != nil { if err := infra.ApplyMigrations(conn, migrationsFS, "migrations/*.sql"); err != nil {
conn.Close() conn.Close()
return nil, fmt.Errorf("migrate: %w", err) return nil, fmt.Errorf("migrate: %w", err)
} }
@@ -100,60 +114,6 @@ func openDB(path string) (*DB, error) {
return &DB{conn: conn}, nil return &DB{conn: conn}, nil
} }
func applyMigrations(conn *sql.DB) error {
files, err := fs.Glob(migrationsFS, "migrations/*.sql")
if err != nil {
return err
}
sort.Strings(files)
for _, f := range files {
b, err := migrationsFS.ReadFile(f)
if err != nil {
return err
}
for _, stmt := range splitSQLStatements(string(b)) {
s := strings.TrimSpace(stmt)
if s == "" {
continue
}
if _, err := conn.Exec(s); err != nil {
if isIdempotentMigrationError(err) {
continue
}
return fmt.Errorf("%s: %w", f, err)
}
}
}
return nil
}
func splitSQLStatements(s string) []string {
out := []string{}
cur := strings.Builder{}
for _, line := range strings.Split(s, "\n") {
trim := strings.TrimSpace(line)
if strings.HasPrefix(trim, "--") || trim == "" {
continue
}
cur.WriteString(line)
cur.WriteString("\n")
if strings.HasSuffix(trim, ";") {
out = append(out, cur.String())
cur.Reset()
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
func isIdempotentMigrationError(err error) bool {
msg := err.Error()
return strings.Contains(msg, "duplicate column") ||
strings.Contains(msg, "already exists")
}
// ensureColumns adds columns missing from older schemas without dropping data. // ensureColumns adds columns missing from older schemas without dropping data.
// SQLite ALTER TABLE ADD COLUMN supports NOT NULL with literal DEFAULT but not CHECK, // SQLite ALTER TABLE ADD COLUMN supports NOT NULL with literal DEFAULT but not CHECK,
// so location's CHECK is enforced in Go (UpdateColumn) when the column is added later. // so location's CHECK is enforced in Go (UpdateColumn) when the column is added later.
@@ -171,11 +131,12 @@ func ensureColumns(conn *sql.DB) error {
{"cards", "deleted_at", "TEXT"}, {"cards", "deleted_at", "TEXT"},
{"cards", "tags", "TEXT NOT NULL DEFAULT '[]'"}, {"cards", "tags", "TEXT NOT NULL DEFAULT '[]'"},
{"cards", "stickers", "TEXT NOT NULL DEFAULT '[]'"}, {"cards", "stickers", "TEXT NOT NULL DEFAULT '[]'"},
{"cards", "deadline", "TEXT"},
{"card_column_history", "actor_id", "TEXT"}, {"card_column_history", "actor_id", "TEXT"},
{"card_lock_history", "actor_id", "TEXT"}, {"card_lock_history", "actor_id", "TEXT"},
} }
for _, s := range specs { for _, s := range specs {
exists, err := columnExists(conn, s.table, s.name) exists, err := infra.ColumnExists(conn, s.table, s.name)
if err != nil { if err != nil {
return err return err
} }
@@ -192,28 +153,6 @@ func ensureColumns(conn *sql.DB) error {
return nil return nil
} }
func columnExists(conn *sql.DB, table, name string) (bool, error) {
rows, err := conn.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var cid int
var colName, ctype string
var notnull int
var dflt sql.NullString
var pk int
if err := rows.Scan(&cid, &colName, &ctype, &notnull, &dflt, &pk); err != nil {
return false, err
}
if colName == name {
return true, nil
}
}
return false, rows.Err()
}
func (db *DB) Close() error { return db.conn.Close() } func (db *DB) Close() error { return db.conn.Close() }
func newID() string { func newID() string {
@@ -351,6 +290,18 @@ func nullableActor(actorID string) any {
return actorID return actorID
} }
// insertEvent registra un evento timeline de la card. tx puede ser nil para usar conn.
func insertCardEvent(execer interface {
Exec(string, ...any) (sql.Result, error)
}, cardID, kind, actorID string, payload any) error {
pj, _ := json.Marshal(payload)
_, err := execer.Exec(
`INSERT INTO card_events (id, card_id, kind, actor_id, payload, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
newID(), cardID, kind, nullableActor(actorID), string(pj), nowRFC3339(),
)
return err
}
// --- Columns --- // --- Columns ---
func (db *DB) ListColumns() ([]Column, error) { func (db *DB) ListColumns() ([]Column, error) {
@@ -486,8 +437,12 @@ func (db *DB) ReorderColumns(ids []string) error {
func (db *DB) ListCardsWithTime() ([]Card, error) { func (db *DB) ListCardsWithTime() ([]Card, error) {
rows, err := db.conn.Query(` 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.tags, c.stickers, 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.tags, c.stickers, c.deadline, c.created_at, c.updated_at,
h.entered_at, l.locked_at h.entered_at, l.locked_at,
COALESCE((
SELECT CAST(SUM((julianday(COALESCE(unlocked_at, ?)) - julianday(locked_at)) * 86400000) AS INTEGER)
FROM card_lock_history WHERE card_id = c.id
), 0) AS total_locked_ms
FROM cards c FROM cards c
LEFT JOIN card_column_history h LEFT JOIN card_column_history h
ON h.card_id = c.id AND h.exited_at IS NULL ON h.card_id = c.id AND h.exited_at IS NULL
@@ -495,7 +450,7 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
ON l.card_id = c.id AND l.unlocked_at IS NULL ON l.card_id = c.id AND l.unlocked_at IS NULL
WHERE c.deleted_at IS NULL WHERE c.deleted_at IS NULL
ORDER BY c.column_id, c.position, c.created_at ORDER BY c.column_id, c.position, c.created_at
`) `, time.Now().UTC().Format(time.RFC3339Nano))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -510,12 +465,17 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
var deleted sql.NullString var deleted sql.NullString
var tagsJSON string var tagsJSON string
var stickersJSON string var stickersJSON string
var deadline sql.NullString
var lockedAt sql.NullString var lockedAt sql.NullString
var locked int 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, &tagsJSON, &stickersJSON, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt); 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, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt, &c.TotalLockedMs); err != nil {
return nil, err return nil, err
} }
c.Stickers = parseStickers(stickersJSON) c.Stickers = parseStickers(stickersJSON)
if deadline.Valid && deadline.String != "" {
s := deadline.String
c.Deadline = &s
}
if lockedAt.Valid && lockedAt.String != "" { if lockedAt.Valid && lockedAt.String != "" {
s := lockedAt.String s := lockedAt.String
c.LockedAt = &s c.LockedAt = &s
@@ -555,20 +515,28 @@ func (db *DB) CreateCard(columnID, requester, title, description, actorID string
pos = int(maxPos.Int64) + 1 pos = int(maxPos.Int64) + 1
} }
now := nowRFC3339() now := nowRFC3339()
c := Card{
ID: newID(), Requester: requester, Title: title, Description: description, ColumnID: columnID, Position: pos,
Tags: []string{},
Stickers: []Sticker{},
CreatedAt: now, UpdatedAt: now, EnteredAt: now,
}
tx, err := db.conn.Begin() tx, err := db.conn.Begin()
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer tx.Rollback() defer tx.Rollback()
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
}
c := Card{
ID: newID(), SeqNum: seqNum, Requester: requester, Title: title, Description: description, ColumnID: columnID, Position: pos,
Tags: []string{},
Stickers: []Sticker{},
CreatedAt: now, UpdatedAt: now, EnteredAt: now,
}
if _, err := tx.Exec( if _, err := tx.Exec(
`INSERT INTO cards (id, requester, title, description, color, column_id, position, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, `INSERT INTO cards (id, seq_num, 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, c.ID, c.SeqNum, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position, encodeTags(c.Tags), c.CreatedAt, c.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -589,6 +557,9 @@ func (db *DB) CreateCard(columnID, requester, title, description, actorID string
} }
c.CompletedAt = &now c.CompletedAt = &now
} }
if err := insertCardEvent(tx, c.ID, "created", actorID, map[string]any{"title": title, "column_id": columnID}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return nil, err return nil, err
} }
@@ -604,6 +575,8 @@ type CardPatch struct {
AssigneeID *string // empty string clears assignment AssigneeID *string // empty string clears assignment
HasAssignee bool // distinguishes "set to null" from "not provided" HasAssignee bool // distinguishes "set to null" from "not provided"
Tags *[]string Tags *[]string
Deadline *string // empty string clears deadline
HasDeadline bool // distinguishes "set to null" from "not provided"
} }
func (db *DB) UpdateCard(id string, patch CardPatch) error { func (db *DB) UpdateCard(id string, patch CardPatch) error {
@@ -617,40 +590,102 @@ func (db *DB) UpdateCardWithActor(id string, patch CardPatch, actorID string) er
} }
defer tx.Rollback() defer tx.Rollback()
if patch.Requester != nil { if patch.Requester != nil {
var oldReq string
_ = tx.QueryRow(`SELECT requester FROM cards WHERE id=?`, id).Scan(&oldReq)
if _, err := tx.Exec(`UPDATE cards SET requester=?, updated_at=? WHERE id=?`, *patch.Requester, nowRFC3339(), id); err != nil { if _, err := tx.Exec(`UPDATE cards SET requester=?, updated_at=? WHERE id=?`, *patch.Requester, nowRFC3339(), id); err != nil {
return err return err
} }
if oldReq != *patch.Requester {
if err := insertCardEvent(tx, id, "requester_changed", actorID, map[string]any{"old": oldReq, "new": *patch.Requester}); err != nil {
return err
}
}
} }
if patch.Title != nil { if patch.Title != nil {
var oldTitle string
_ = tx.QueryRow(`SELECT title FROM cards WHERE id=?`, id).Scan(&oldTitle)
if _, err := tx.Exec(`UPDATE cards SET title=?, updated_at=? WHERE id=?`, *patch.Title, nowRFC3339(), id); err != nil { if _, err := tx.Exec(`UPDATE cards SET title=?, updated_at=? WHERE id=?`, *patch.Title, nowRFC3339(), id); err != nil {
return err return err
} }
if oldTitle != *patch.Title {
if err := insertCardEvent(tx, id, "title_changed", actorID, map[string]any{"old": oldTitle, "new": *patch.Title}); err != nil {
return err
}
}
} }
if patch.Description != nil { if patch.Description != nil {
var oldDesc string
_ = tx.QueryRow(`SELECT description FROM cards WHERE id=?`, id).Scan(&oldDesc)
if _, err := tx.Exec(`UPDATE cards SET description=?, updated_at=? WHERE id=?`, *patch.Description, nowRFC3339(), id); err != nil { if _, err := tx.Exec(`UPDATE cards SET description=?, updated_at=? WHERE id=?`, *patch.Description, nowRFC3339(), id); err != nil {
return err return err
} }
if oldDesc != *patch.Description {
if err := insertCardEvent(tx, id, "description_changed", actorID, map[string]any{}); err != nil {
return err
}
}
} }
if patch.Color != nil { if patch.Color != nil {
if _, err := tx.Exec(`UPDATE cards SET color=?, updated_at=? WHERE id=?`, *patch.Color, nowRFC3339(), id); err != nil { if _, err := tx.Exec(`UPDATE cards SET color=?, updated_at=? WHERE id=?`, *patch.Color, nowRFC3339(), id); err != nil {
return err return err
} }
if err := insertCardEvent(tx, id, "color_changed", actorID, map[string]any{"color": *patch.Color}); err != nil {
return err
}
} }
if patch.HasAssignee { if patch.HasAssignee {
var oldAssignee sql.NullString
_ = tx.QueryRow(`SELECT assignee_id FROM cards WHERE id=?`, id).Scan(&oldAssignee)
if patch.AssigneeID == nil || *patch.AssigneeID == "" { if patch.AssigneeID == nil || *patch.AssigneeID == "" {
if _, err := tx.Exec(`UPDATE cards SET assignee_id=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil { if _, err := tx.Exec(`UPDATE cards SET assignee_id=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
return err return err
} }
if oldAssignee.Valid && oldAssignee.String != "" {
if err := insertCardEvent(tx, id, "unassigned", actorID, map[string]any{"prev": oldAssignee.String}); err != nil {
return err
}
}
} else { } else {
if _, err := tx.Exec(`UPDATE cards SET assignee_id=?, updated_at=? WHERE id=?`, *patch.AssigneeID, nowRFC3339(), id); err != nil { if _, err := tx.Exec(`UPDATE cards SET assignee_id=?, updated_at=? WHERE id=?`, *patch.AssigneeID, nowRFC3339(), id); err != nil {
return err return err
} }
if oldAssignee.String != *patch.AssigneeID {
if err := insertCardEvent(tx, id, "assigned", actorID, map[string]any{"assignee_id": *patch.AssigneeID}); err != nil {
return err
}
}
} }
} }
if patch.Tags != nil { if patch.Tags != nil {
if _, err := tx.Exec(`UPDATE cards SET tags=?, updated_at=? WHERE id=?`, encodeTags(*patch.Tags), nowRFC3339(), id); err != nil { if _, err := tx.Exec(`UPDATE cards SET tags=?, updated_at=? WHERE id=?`, encodeTags(*patch.Tags), nowRFC3339(), id); err != nil {
return err return err
} }
if err := insertCardEvent(tx, id, "tags_changed", actorID, map[string]any{"tags": *patch.Tags}); err != nil {
return err
}
}
if patch.HasDeadline {
var oldDeadline sql.NullString
_ = tx.QueryRow(`SELECT deadline FROM cards WHERE id=?`, id).Scan(&oldDeadline)
if patch.Deadline == nil || *patch.Deadline == "" {
if _, err := tx.Exec(`UPDATE cards SET deadline=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
return err
}
if oldDeadline.Valid && oldDeadline.String != "" {
if err := insertCardEvent(tx, id, "deadline_cleared", actorID, map[string]any{"prev": oldDeadline.String}); err != nil {
return err
}
}
} else {
if _, err := tx.Exec(`UPDATE cards SET deadline=?, updated_at=? WHERE id=?`, *patch.Deadline, nowRFC3339(), id); err != nil {
return err
}
if oldDeadline.String != *patch.Deadline {
if err := insertCardEvent(tx, id, "deadline_set", actorID, map[string]any{"deadline": *patch.Deadline}); err != nil {
return err
}
}
}
} }
if patch.Locked != nil { if patch.Locked != nil {
var current int var current int
@@ -688,14 +723,42 @@ func (db *DB) UpdateCardWithActor(id string, patch CardPatch, actorID string) er
// DeleteCard soft-deletes the card (moves it to trash). // DeleteCard soft-deletes the card (moves it to trash).
func (db *DB) DeleteCard(id string) error { func (db *DB) DeleteCard(id string) error {
_, err := db.conn.Exec(`UPDATE cards SET deleted_at=?, updated_at=? WHERE id=?`, nowRFC3339(), nowRFC3339(), id) return db.DeleteCardWithActor(id, "")
return err }
func (db *DB) DeleteCardWithActor(id, actorID string) error {
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE cards SET deleted_at=?, updated_at=? WHERE id=?`, nowRFC3339(), nowRFC3339(), id); err != nil {
return err
}
if err := insertCardEvent(tx, id, "deleted", actorID, map[string]any{}); err != nil {
return err
}
return tx.Commit()
} }
// RestoreCard removes the deleted_at flag. // RestoreCard removes the deleted_at flag.
func (db *DB) RestoreCard(id string) error { func (db *DB) RestoreCard(id string) error {
_, err := db.conn.Exec(`UPDATE cards SET deleted_at=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id) return db.RestoreCardWithActor(id, "")
return err }
func (db *DB) RestoreCardWithActor(id, actorID string) error {
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE cards SET deleted_at=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
return err
}
if err := insertCardEvent(tx, id, "restored", actorID, map[string]any{}); err != nil {
return err
}
return tx.Commit()
} }
// PurgeCard permanently removes the card from the DB. // PurgeCard permanently removes the card from the DB.
@@ -707,7 +770,7 @@ func (db *DB) PurgeCard(id string) error {
// ListDeletedCards returns cards in the trash, newest first. // ListDeletedCards returns cards in the trash, newest first.
func (db *DB) ListDeletedCards() ([]Card, error) { func (db *DB) ListDeletedCards() ([]Card, error) {
rows, err := db.conn.Query(` 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.tags, c.stickers, 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.tags, c.stickers, c.deadline, c.created_at, c.updated_at
FROM cards c FROM cards c
WHERE c.deleted_at IS NOT NULL WHERE c.deleted_at IS NOT NULL
ORDER BY c.deleted_at DESC ORDER BY c.deleted_at DESC
@@ -724,11 +787,16 @@ func (db *DB) ListDeletedCards() ([]Card, error) {
var deleted sql.NullString var deleted sql.NullString
var tagsJSON string var tagsJSON string
var stickersJSON string var stickersJSON string
var deadline sql.NullString
var locked int 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, &tagsJSON, &stickersJSON, &c.CreatedAt, &c.UpdatedAt); 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, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err return nil, err
} }
c.Stickers = parseStickers(stickersJSON) c.Stickers = parseStickers(stickersJSON)
if deadline.Valid && deadline.String != "" {
s := deadline.String
c.Deadline = &s
}
c.Locked = locked != 0 c.Locked = locked != 0
if assignee.Valid && assignee.String != "" { if assignee.Valid && assignee.String != "" {
s := assignee.String s := assignee.String
@@ -846,7 +914,7 @@ func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string, actorID
func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) { func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
rows, err := db.conn.Query(` rows, err := db.conn.Query(`
SELECT h.id, h.card_id, h.column_id, COALESCE(c.name, ''), h.entered_at, h.exited_at SELECT h.id, h.card_id, h.column_id, COALESCE(c.name, ''), h.entered_at, h.exited_at, h.actor_id
FROM card_column_history h FROM card_column_history h
LEFT JOIN columns c ON c.id = h.column_id LEFT JOIN columns c ON c.id = h.column_id
WHERE h.card_id=? WHERE h.card_id=?
@@ -861,9 +929,14 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
for rows.Next() { for rows.Next() {
var h HistoryEntry var h HistoryEntry
var exited sql.NullString var exited sql.NullString
if err := rows.Scan(&h.ID, &h.CardID, &h.ColumnID, &h.ColumnName, &h.EnteredAt, &exited); err != nil { var actor sql.NullString
if err := rows.Scan(&h.ID, &h.CardID, &h.ColumnID, &h.ColumnName, &h.EnteredAt, &exited, &actor); err != nil {
return nil, err return nil, err
} }
if actor.Valid && actor.String != "" {
s := actor.String
h.ActorID = &s
}
entered, err := time.Parse(time.RFC3339Nano, h.EnteredAt) entered, err := time.Parse(time.RFC3339Nano, h.EnteredAt)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -883,7 +956,7 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
} }
lockRows, err := db.conn.Query(` lockRows, err := db.conn.Query(`
SELECT id, card_id, locked_at, unlocked_at SELECT id, card_id, locked_at, unlocked_at, actor_id
FROM card_lock_history FROM card_lock_history
WHERE card_id=? WHERE card_id=?
ORDER BY locked_at ORDER BY locked_at
@@ -898,9 +971,14 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
for lockRows.Next() { for lockRows.Next() {
var lp LockPeriod var lp LockPeriod
var unlocked sql.NullString var unlocked sql.NullString
if err := lockRows.Scan(&lp.ID, &lp.CardID, &lp.LockedAt, &unlocked); err != nil { var actor sql.NullString
if err := lockRows.Scan(&lp.ID, &lp.CardID, &lp.LockedAt, &unlocked, &actor); err != nil {
return nil, err return nil, err
} }
if actor.Valid && actor.String != "" {
s := actor.String
lp.ActorID = &s
}
start, err := time.Parse(time.RFC3339Nano, lp.LockedAt) start, err := time.Parse(time.RFC3339Nano, lp.LockedAt)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -921,9 +999,34 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
return nil, err return nil, err
} }
evRows, err := db.conn.Query(`
SELECT id, card_id, kind, actor_id, payload, created_at
FROM card_events
WHERE card_id=?
ORDER BY created_at
`, cardID)
if err != nil {
return nil, err
}
defer evRows.Close()
events := []CardEvent{}
for evRows.Next() {
var e CardEvent
var actor sql.NullString
if err := evRows.Scan(&e.ID, &e.CardID, &e.Kind, &actor, &e.Payload, &e.CreatedAt); err != nil {
return nil, err
}
if actor.Valid && actor.String != "" {
s := actor.String
e.ActorID = &s
}
events = append(events, e)
}
return &CardHistoryResponse{ return &CardHistoryResponse{
ColumnHistory: cols, ColumnHistory: cols,
LockPeriods: locks, LockPeriods: locks,
Events: events,
TotalLockedMs: totalLocked, TotalLockedMs: totalLocked,
CurrentlyLock: currently, CurrentlyLock: currently,
}, nil }, nil
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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-BKxzRoLi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+1 -1
View File
@@ -46,4 +46,4 @@ require (
nhooyr.io/websocket v1.8.17 // indirect nhooyr.io/websocket v1.8.17 // indirect
) )
replace fn-registry => ../.. replace fn-registry => ../../..
View File
+14 -2
View File
@@ -190,6 +190,15 @@ func handleUpdateCard(db *DB) http.HandlerFunc {
patch.AssigneeID = &s patch.AssigneeID = &s
} }
} }
if v, present := raw["deadline"]; present {
patch.HasDeadline = true
if v == nil {
empty := ""
patch.Deadline = &empty
} else if s, ok := v.(string); ok {
patch.Deadline = &s
}
}
if v, present := raw["tags"]; present { if v, present := raw["tags"]; present {
tags := []string{} tags := []string{}
if arr, ok := v.([]any); ok { if arr, ok := v.([]any); ok {
@@ -233,7 +242,8 @@ func handleUpdateCardStickers(db *DB) http.HandlerFunc {
func handleDeleteCard(db *DB) http.HandlerFunc { func handleDeleteCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")
if err := db.DeleteCard(id); err != nil { actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if err := db.DeleteCardWithActor(id, actor); err != nil {
serverError(w, err) serverError(w, err)
return return
} }
@@ -299,7 +309,8 @@ func handleListTrash(db *DB) http.HandlerFunc {
func handleRestoreCard(db *DB) http.HandlerFunc { func handleRestoreCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")
if err := db.RestoreCard(id); err != nil { actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if err := db.RestoreCardWithActor(id, actor); err != nil {
serverError(w, err) serverError(w, err)
return return
} }
@@ -325,6 +336,7 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger) []infra.Route {
{Method: "POST", Path: "/api/auth/login", Handler: handleLogin(db)}, {Method: "POST", Path: "/api/auth/login", Handler: handleLogin(db)},
{Method: "POST", Path: "/api/auth/logout", Handler: handleLogout(db)}, {Method: "POST", Path: "/api/auth/logout", Handler: handleLogout(db)},
{Method: "GET", Path: "/api/me", Handler: handleMe(db)}, {Method: "GET", Path: "/api/me", Handler: handleMe(db)},
{Method: "PATCH", Path: "/api/me", Handler: handlePatchMe(db)},
{Method: "GET", Path: "/api/users", Handler: handleListUsers(db)}, {Method: "GET", Path: "/api/users", Handler: handleListUsers(db)},
{Method: "GET", Path: "/api/board", Handler: handleGetBoard(db)}, {Method: "GET", Path: "/api/board", Handler: handleGetBoard(db)},
{Method: "POST", Path: "/api/columns", Handler: handleCreateColumn(db)}, {Method: "POST", Path: "/api/columns", Handler: handleCreateColumn(db)},
+2 -2
View File
@@ -18,7 +18,7 @@ import (
"fn-registry/functions/infra" "fn-registry/functions/infra"
) )
//go:embed all:frontend/dist //go:embed all:dist
var frontendDist embed.FS var frontendDist embed.FS
func main() { func main() {
@@ -132,7 +132,7 @@ func startSessionCleanup(db *DB) {
} }
func frontendHandler() http.Handler { func frontendHandler() http.Handler {
sub, err := fs.Sub(frontendDist, "frontend/dist") sub, err := fs.Sub(frontendDist, "dist")
if err != nil { if err != nil {
return nil return nil
} }
+23 -60
View File
@@ -3,11 +3,16 @@ package main
import ( import (
"net/http" "net/http"
"sort" "sort"
"strings"
"time" "time"
"fn-registry/functions/core"
"fn-registry/functions/datascience"
"fn-registry/functions/infra" "fn-registry/functions/infra"
) )
type DurationStats = datascience.DurationStats
type Metrics struct { type Metrics struct {
Range DateRange `json:"range"` Range DateRange `json:"range"`
Totals Totals `json:"totals"` Totals Totals `json:"totals"`
@@ -58,14 +63,6 @@ type DailyCount struct {
Count int `json:"count"` Count int `json:"count"`
} }
type DurationStats struct {
N int `json:"n"`
AvgMs int64 `json:"avg_ms"`
P50Ms int64 `json:"p50_ms"`
P90Ms int64 `json:"p90_ms"`
P99Ms int64 `json:"p99_ms"`
}
type ColumnDuration struct { type ColumnDuration struct {
ColumnID string `json:"column_id"` ColumnID string `json:"column_id"`
Name string `json:"name"` Name string `json:"name"`
@@ -95,63 +92,16 @@ type MovementStat struct {
Moves int `json:"moves"` Moves int `json:"moves"`
} }
func percentile(sorted []int64, p float64) int64 {
if len(sorted) == 0 {
return 0
}
idx := int(float64(len(sorted)-1) * p)
if idx < 0 {
idx = 0
}
if idx >= len(sorted) {
idx = len(sorted) - 1
}
return sorted[idx]
}
func computeStats(durations []int64) DurationStats { func computeStats(durations []int64) DurationStats {
n := len(durations) return datascience.DurationStatsFrom(durations)
if n == 0 {
return DurationStats{}
}
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
var sum int64
for _, d := range durations {
sum += d
}
return DurationStats{
N: n,
AvgMs: sum / int64(n),
P50Ms: percentile(durations, 0.5),
P90Ms: percentile(durations, 0.9),
P99Ms: percentile(durations, 0.99),
}
} }
func parseDateOrDefault(s string, dflt time.Time) time.Time { func parseDateOrDefault(s string, dflt time.Time) time.Time {
if s == "" { return core.ParseDateOrDefault(s, dflt, false)
return dflt
}
if t, err := time.Parse("2006-01-02", s); err == nil {
return t
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t
}
return dflt
} }
func parseEndDateOrDefault(s string, dflt time.Time) time.Time { func parseEndDateOrDefault(s string, dflt time.Time) time.Time {
if s == "" { return core.ParseDateOrDefault(s, dflt, true)
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=... // GET /api/metrics?from=YYYY-MM-DD&to=YYYY-MM-DD&assignee_id=...&requester=...
@@ -162,8 +112,17 @@ func handleMetrics(db *DB) http.HandlerFunc {
to := parseEndDateOrDefault(r.URL.Query().Get("to"), now) to := parseEndDateOrDefault(r.URL.Query().Get("to"), now)
assignee := r.URL.Query().Get("assignee_id") assignee := r.URL.Query().Get("assignee_id")
requester := r.URL.Query().Get("requester") requester := r.URL.Query().Get("requester")
tagsRaw := r.URL.Query().Get("tags")
var tags []string
if tagsRaw != "" {
for _, t := range strings.Split(tagsRaw, ",") {
if t = strings.TrimSpace(t); t != "" {
tags = append(tags, t)
}
}
}
m, err := computeMetrics(db, from, to, assignee, requester) m, err := computeMetrics(db, from, to, assignee, requester, tags)
if err != nil { if err != nil {
serverError(w, err) serverError(w, err)
return return
@@ -172,7 +131,7 @@ func handleMetrics(db *DB) http.HandlerFunc {
} }
} }
func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Metrics, error) { func computeMetrics(db *DB, from, to time.Time, assignee, requester string, tags []string) (*Metrics, error) {
fromStr := from.Format(time.RFC3339Nano) fromStr := from.Format(time.RFC3339Nano)
toStr := to.Format(time.RFC3339Nano) toStr := to.Format(time.RFC3339Nano)
@@ -198,6 +157,10 @@ func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Me
cardWhere += " AND requester=?" cardWhere += " AND requester=?"
args = append(args, requester) args = append(args, requester)
} }
for _, t := range tags {
cardWhere += " AND tags LIKE ?"
args = append(args, `%"`+t+`"%`)
}
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM cards `+cardWhere, args...).Scan(&m.Totals.Cards); err != nil { if err := db.conn.QueryRow(`SELECT COUNT(*) FROM cards `+cardWhere, args...).Scan(&m.Totals.Cards); err != nil {
return nil, err return nil, err
+2
View File
@@ -0,0 +1,2 @@
-- Color del avatar del usuario (Mantine color name o '#rrggbb' personalizado).
ALTER TABLE users ADD COLUMN color TEXT NOT NULL DEFAULT '';
+11
View File
@@ -0,0 +1,11 @@
-- Eventos cronologicos por card. Complementa column_history (moves) y lock_history (locks).
-- Captura: created, assigned, unassigned, title_changed, description_changed, color_changed, tags_changed.
CREATE TABLE IF NOT EXISTS card_events (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
actor_id TEXT,
payload TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_card_events_card ON card_events(card_id, created_at);
+7
View File
@@ -0,0 +1,7 @@
-- ID secuencial humano por card. Distinto del id hex (PK interna).
-- Backfill por orden de creacion.
ALTER TABLE cards ADD COLUMN seq_num INTEGER NOT NULL DEFAULT 0;
UPDATE cards SET seq_num = (
SELECT COUNT(*) FROM cards c2 WHERE c2.created_at <= cards.created_at
) WHERE seq_num = 0;
CREATE UNIQUE INDEX IF NOT EXISTS idx_cards_seq_num ON cards(seq_num) WHERE seq_num > 0;
+4
View File
@@ -0,0 +1,4 @@
-- Deadline opcional por card. Fecha RFC3339 (precision dia o instante).
-- NULL = sin deadline (default). El frontend muestra countdown hasta la fecha.
ALTER TABLE cards ADD COLUMN deadline TEXT;
CREATE INDEX IF NOT EXISTS idx_cards_deadline ON cards(deadline) WHERE deadline IS NOT NULL;
View File
+12 -6
View File
@@ -13,6 +13,7 @@ type User struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Color string `json:"color"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
@@ -51,8 +52,8 @@ func (db *DB) CreateUser(username, password, displayName string) (*User, error)
func (db *DB) GetUserByID(id string) (*User, error) { func (db *DB) GetUserByID(id string) (*User, error) {
var u User var u User
err := db.conn.QueryRow( err := db.conn.QueryRow(
`SELECT id, username, display_name, created_at FROM users WHERE id=?`, id, `SELECT id, username, display_name, color, created_at FROM users WHERE id=?`, id,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.CreatedAt) ).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, errUserNotFound return nil, errUserNotFound
} }
@@ -67,8 +68,8 @@ func (db *DB) GetUserByUsername(username string) (*User, string, error) {
var u User var u User
var hash string var hash string
err := db.conn.QueryRow( err := db.conn.QueryRow(
`SELECT id, username, display_name, created_at, password_hash FROM users WHERE username=?`, username, `SELECT id, username, display_name, color, created_at, password_hash FROM users WHERE username=?`, username,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.CreatedAt, &hash) ).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt, &hash)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, "", errUserNotFound return nil, "", errUserNotFound
} }
@@ -79,7 +80,7 @@ func (db *DB) GetUserByUsername(username string) (*User, string, error) {
} }
func (db *DB) ListUsers() ([]User, error) { func (db *DB) ListUsers() ([]User, error) {
rows, err := db.conn.Query(`SELECT id, username, display_name, created_at FROM users ORDER BY username`) rows, err := db.conn.Query(`SELECT id, username, display_name, color, created_at FROM users ORDER BY username`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -87,7 +88,7 @@ func (db *DB) ListUsers() ([]User, error) {
out := []User{} out := []User{}
for rows.Next() { for rows.Next() {
var u User var u User
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.CreatedAt); err != nil { if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt); err != nil {
return nil, err return nil, err
} }
out = append(out, u) out = append(out, u)
@@ -117,6 +118,11 @@ func (db *DB) CountUsers() (int, error) {
return n, nil return n, nil
} }
func (db *DB) UpdateUserColor(id, color string) error {
_, err := db.conn.Exec(`UPDATE users SET color=? WHERE id=?`, color, id)
return err
}
func (db *DB) DeleteSessionByToken(token string) error { func (db *DB) DeleteSessionByToken(token string) error {
_, err := db.conn.Exec(`DELETE FROM sessions WHERE token=?`, token) _, err := db.conn.Exec(`DELETE FROM sessions WHERE token=?`, token)
return err return err
-69
View File
@@ -1,69 +0,0 @@
{"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"}
+575
View File
@@ -0,0 +1,575 @@
// Tests del color picker (Modal personalizado dentro de Menu/Popover de Mantine).
// Reproduce el bug: click en el circulo "Color personalizado" abre Modal pero
// se cierra inmediatamente. Comprueba que el Modal permanezca visible >300ms.
package e2e
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
"fn-registry/functions/browser"
)
// loginAndGetCookie registra (idempotente) y hace login. Retorna valor de la cookie kanban_session.
func loginAndGetCookie(t *testing.T, baseURL, user, pass string) string {
t.Helper()
body := fmt.Sprintf(`{"username":%q,"password":%q,"display_name":%q}`, user, pass, user)
// Registro: 200 OK la primera vez, error si ya existe (ignorable).
_, _ = http.Post(baseURL+"/api/auth/register", "application/json", strings.NewReader(body))
loginBody := fmt.Sprintf(`{"username":%q,"password":%q}`, user, pass)
resp, err := http.Post(baseURL+"/api/auth/login", "application/json", strings.NewReader(loginBody))
if err != nil {
t.Fatalf("login http: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
buf := new(bytes.Buffer)
buf.ReadFrom(resp.Body)
t.Fatalf("login status %d: %s", resp.StatusCode, buf.String())
}
for _, ck := range resp.Cookies() {
if ck.Name == "kanban_session" {
return ck.Value
}
}
t.Fatalf("login no devolvio cookie kanban_session")
return ""
}
// ensureBoardSeed crea una columna y card si la BD esta vacia. Usa la cookie autenticada.
func ensureBoardSeed(t *testing.T, baseURL, cookie string) {
t.Helper()
client := &http.Client{}
mk := func(method, url string, body string) *http.Response {
req, _ := http.NewRequest(method, baseURL+url, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: "kanban_session", Value: cookie})
resp, err := client.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, url, err)
}
return resp
}
// Lee board.
resp := mk("GET", "/api/board", "")
defer resp.Body.Close()
var board struct {
Columns []map[string]any `json:"columns"`
Cards []map[string]any `json:"cards"`
}
json.NewDecoder(resp.Body).Decode(&board)
var colID string
if len(board.Columns) == 0 {
r := mk("POST", "/api/columns", `{"name":"e2e"}`)
var c map[string]any
json.NewDecoder(r.Body).Decode(&c)
r.Body.Close()
colID = c["id"].(string)
} else {
colID = board.Columns[0]["id"].(string)
}
if len(board.Cards) == 0 {
r := mk("POST", "/api/cards", fmt.Sprintf(`{"column_id":%q,"title":"e2e card"}`, colID))
r.Body.Close()
}
}
// authedSetup hace login + inyecta cookie en el browser CDP.
func authedSetup(t *testing.T) (*ctx, string) {
t.Helper()
c := setup(t)
user := envOr("KANBAN_USER", "e2etest")
pass := envOr("KANBAN_PASS", "e2etest")
cookie := loginAndGetCookie(t, c.baseURL, user, pass)
ensureBoardSeed(t, c.baseURL, cookie)
// Navegar a la home primero para que el browser tenga el dominio en su jar.
c.navigate("/")
host := strings.TrimPrefix(strings.TrimPrefix(c.baseURL, "http://"), "https://")
host = strings.SplitN(host, ":", 2)[0]
if err := browser.CdpSetCookie(c.conn, "kanban_session", cookie, host, "/", true); err != nil {
t.Fatalf("set_cookie: %v", err)
}
c.navigate("/")
return c, cookie
}
func TestColorPicker_AvatarMenu_ModalStaysOpen(t *testing.T) {
c, _ := authedSetup(t)
// Esperar avatar (header).
if err := browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second); err != nil {
c.screenshot("debug_no_avatar")
t.Fatalf("avatar no aparecio (login fallo?): %v", err)
}
if err := browser.CdpClick(c.conn, "[aria-label='Usuario']"); err != nil {
t.Fatalf("click avatar: %v", err)
}
// Esperar el grid de colores.
if err := browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second); err != nil {
c.screenshot("debug_no_picker_grid")
t.Fatalf("picker grid no visible: %v", err)
}
c.screenshot("avatar_menu_open")
// Click "+".
if err := browser.CdpClick(c.conn, "[aria-label='Color personalizado']"); err != nil {
t.Fatalf("click +: %v", err)
}
// Esperar 350ms — si el bug persiste, el modal habra desaparecido.
time.Sleep(350 * time.Millisecond)
c.screenshot("avatar_after_plus_click")
// Mantine Modal renderiza con role="dialog". Comprobar visible.
val := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
t.Logf("dialogs en DOM tras 350ms: %s", val)
if strings.Contains(val, "0") {
// Bug confirmado: modal cerro.
t.Errorf("BUG: Modal del color picker se cerro inmediatamente (avatar menu)")
}
// Comprobar header del modal.
val = c.eval(`(() => { const m = document.querySelector('[role="dialog"] .mantine-Modal-title'); return m ? m.textContent : 'NULL'; })()`)
t.Logf("modal title: %s", val)
}
func TestColorPicker_AvatarModal_ClicksInsideKeepOpen(t *testing.T) {
c, _ := authedSetup(t)
if err := browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second); err != nil {
t.Fatalf("avatar: %v", err)
}
if err := browser.CdpClick(c.conn, "[aria-label='Usuario']"); err != nil {
t.Fatalf("click avatar: %v", err)
}
if err := browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second); err != nil {
t.Fatalf("picker grid: %v", err)
}
if err := browser.CdpClick(c.conn, "[aria-label='Color personalizado']"); err != nil {
t.Fatalf("click +: %v", err)
}
if err := browser.CdpWaitElement(c.conn, "[role='dialog']", 2*time.Second); err != nil {
t.Fatalf("modal: %v", err)
}
// Click 1: input hex
if err := browser.CdpClick(c.conn, "[role='dialog'] input"); err != nil {
t.Fatalf("click input: %v", err)
}
time.Sleep(150 * time.Millisecond)
val := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if strings.Contains(val, "0") {
c.screenshot("modal_closed_after_input_click")
t.Errorf("BUG: modal cerro tras click en input hex (dialogs=%s)", val)
}
// Click 2: zona saturation del ColorPicker
if err := browser.CdpClick(c.conn, "[role='dialog'] .mantine-ColorPicker-saturation"); err != nil {
t.Logf("click saturation no fue posible: %v", err)
} else {
time.Sleep(150 * time.Millisecond)
val = c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if strings.Contains(val, "0") {
c.screenshot("modal_closed_after_saturation")
t.Errorf("BUG: modal cerro tras click en saturation (dialogs=%s)", val)
}
}
// Click 3: swatch
val = c.eval(`(() => { const s = document.querySelector('[role="dialog"] .mantine-ColorPicker-swatch'); if (!s) return 'NO_SWATCH'; s.click(); return 'OK'; })()`)
t.Logf("swatch click: %s", val)
time.Sleep(150 * time.Millisecond)
val = c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if strings.Contains(val, "0") {
c.screenshot("modal_closed_after_swatch")
t.Errorf("BUG: modal cerro tras click en swatch (dialogs=%s)", val)
}
// Click 4: titulo del modal (zona muerta)
val = c.eval(`(() => { const t = document.querySelector('.mantine-Modal-title'); if (!t) return 'NO_TITLE'; t.click(); return 'OK'; })()`)
t.Logf("title click: %s", val)
time.Sleep(150 * time.Millisecond)
val = c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if strings.Contains(val, "0") {
c.screenshot("modal_closed_after_title")
t.Errorf("BUG: modal cerro tras click en title (dialogs=%s)", val)
}
c.screenshot("modal_after_all_clicks")
}
// Simula drag desde dentro del ColorPicker hasta fuera del modal,
// que es el patron de uso humano cuando arrastra el saturation.
func TestColorPicker_DragInsideThenOutside_StaysOpen(t *testing.T) {
c, _ := authedSetup(t)
if err := browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second); err != nil {
t.Fatalf("avatar: %v", err)
}
if err := browser.CdpClick(c.conn, "[aria-label='Usuario']"); err != nil {
t.Fatalf("click avatar: %v", err)
}
if err := browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second); err != nil {
t.Fatalf("picker grid: %v", err)
}
if err := browser.CdpClick(c.conn, "[aria-label='Color personalizado']"); err != nil {
t.Fatalf("click +: %v", err)
}
if err := browser.CdpWaitElement(c.conn, "[role='dialog'] .mantine-ColorPicker-saturation", 3*time.Second); err != nil {
t.Fatalf("saturation no aparecio: %v", err)
}
// Despachar drag manual via JS: pointerdown sat, pointermove out, pointerup out.
out := c.eval(`(() => {
const sat = document.querySelector('[role="dialog"] .mantine-ColorPicker-saturation');
if (!sat) return 'NO_SAT';
const r = sat.getBoundingClientRect();
const startX = r.left + r.width / 2;
const startY = r.top + r.height / 2;
const endX = r.left - 200; // fuera del modal por la izquierda
const endY = r.top - 200; // fuera por arriba
const fire = (target, type, x, y) => {
const ev = new PointerEvent(type, {
bubbles: true, cancelable: true, composed: true,
clientX: x, clientY: y, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1,
});
target.dispatchEvent(ev);
const m = new MouseEvent(type === 'pointerdown' ? 'mousedown' : type === 'pointerup' ? 'mouseup' : 'mousemove', {
bubbles: true, cancelable: true, view: window,
clientX: x, clientY: y, button: 0, buttons: type === 'pointerup' ? 0 : 1,
});
target.dispatchEvent(m);
};
fire(sat, 'pointerdown', startX, startY);
// Mover en pasos
for (let i = 1; i <= 10; i++) {
const x = startX + (endX - startX) * i / 10;
const y = startY + (endY - startY) * i / 10;
const elAt = document.elementFromPoint(x, y) || document;
fire(elAt, 'pointermove', x, y);
}
const finalEl = document.elementFromPoint(endX, endY) || document;
fire(finalEl, 'pointerup', endX, endY);
return 'OK';
})()`)
t.Logf("drag result: %s", out)
time.Sleep(200 * time.Millisecond)
val := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
t.Logf("dialogs tras drag fuera: %s", val)
c.screenshot("modal_after_drag_outside")
if strings.Contains(val, "0") {
t.Errorf("BUG: modal cerro tras drag desde saturation hasta fuera del modal")
}
// Click en una zona vacia del modal (no input, no buttons).
val = c.eval(`(() => {
const dlg = document.querySelector('[role="dialog"]');
if (!dlg) return 'NO_DLG';
const r = dlg.getBoundingClientRect();
// Click en el header del modal (margen superior).
const x = r.left + 10;
const y = r.top + 10;
const target = document.elementFromPoint(x, y);
if (!target) return 'NO_TARGET';
const ev = new MouseEvent('click', { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y });
target.dispatchEvent(ev);
return 'OK target=' + target.tagName + '.' + target.className;
})()`)
t.Logf("click header zone: %s", val)
time.Sleep(150 * time.Millisecond)
val = c.eval(`document.querySelectorAll('[role="dialog"]').length`)
t.Logf("dialogs tras click header: %s", val)
if strings.Contains(val, "0") {
c.screenshot("modal_closed_after_header_click")
t.Errorf("BUG: modal cerro tras click en header del modal")
}
}
func jsonQuote(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// Click en cada region clickeable del modal — verifica que ninguna cierre.
func TestColorPicker_AllRegionsKeepModalOpen(t *testing.T) {
c, _ := authedSetup(t)
if err := browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second); err != nil {
t.Fatalf("avatar: %v", err)
}
browser.CdpClick(c.conn, "[aria-label='Usuario']")
browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second)
browser.CdpClick(c.conn, "[aria-label='Color personalizado']")
browser.CdpWaitElement(c.conn, "[role='dialog']", 3*time.Second)
regions := []struct {
name string
selector string
}{
{"body padding", "[role='dialog'] .mantine-Modal-body"},
{"saturation", "[role='dialog'] .mantine-ColorPicker-saturation"},
{"hue slider", "[role='dialog'] .mantine-ColorPicker-slider"},
{"swatch 0", "[role='dialog'] .mantine-ColorPicker-swatch"},
{"hex input", "[role='dialog'] input"},
{"hex label", "[role='dialog'] .mantine-TextInput-label"},
{"stack gap", "[role='dialog'] .mantine-Stack-root"},
}
for _, r := range regions {
v := c.eval(`(() => {
const el = document.querySelector(` + jsonQuote(r.selector) + `);
if (!el) return 'NO_EL';
const rect = el.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const target = document.elementFromPoint(x, y);
if (!target) return 'NO_TARGET';
['pointerdown','mousedown','pointerup','mouseup','click'].forEach(type => {
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent;
const ev = new Ctor(type, { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, button: 0, view: window });
target.dispatchEvent(ev);
});
return 'OK ' + target.tagName + '.' + (target.className || '').slice(0, 40);
})()`)
t.Logf("region %s: %s", r.name, v)
time.Sleep(80 * time.Millisecond)
count := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if strings.Contains(count, "0") {
c.screenshot("modal_closed_at_" + strings.ReplaceAll(r.name, " ", "_"))
t.Errorf("BUG: modal cerro tras click en region %q", r.name)
break
}
}
}
// Verifica que clicar el ColorPicker en zonas de uso real (dragging del saturation,
// click en el slider de hue, click en swatches) NO cierre el modal.
// Sleep extra de 600ms tras cada accion para esperar transiciones Mantine.
func TestColorPicker_RealisticInteractions(t *testing.T) {
c, _ := authedSetup(t)
browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second)
browser.CdpClick(c.conn, "[aria-label='Usuario']")
time.Sleep(300 * time.Millisecond)
browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second)
browser.CdpClick(c.conn, "[aria-label='Color personalizado']")
browser.CdpWaitElement(c.conn, "[role='dialog'] .mantine-ColorPicker-saturation", 3*time.Second)
time.Sleep(500 * time.Millisecond) // animacion modal entrada
// 1. Drag DENTRO del saturation (movimientos cortos, sin salir)
out := c.eval(`(() => {
const sat = document.querySelector('[role="dialog"] .mantine-ColorPicker-saturation');
if (!sat) return 'NO_SAT';
const r = sat.getBoundingClientRect();
const mid = (axis) => axis === 'x' ? r.left + r.width / 2 : r.top + r.height / 2;
const fire = (target, type, x, y) => {
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent;
target.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, button: 0, buttons: type === 'pointerup' || type === 'mouseup' ? 0 : 1, view: window, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
};
fire(sat, 'pointerdown', mid('x'), mid('y'));
fire(sat, 'mousedown', mid('x'), mid('y'));
for (let i = 0; i < 5; i++) {
const x = r.left + (r.width * (0.3 + i * 0.1));
const y = r.top + (r.height * (0.3 + i * 0.1));
fire(sat, 'pointermove', x, y);
fire(sat, 'mousemove', x, y);
}
fire(sat, 'pointerup', mid('x'), mid('y'));
fire(sat, 'mouseup', mid('x'), mid('y'));
return 'OK';
})()`)
t.Logf("drag interno saturation: %s", out)
time.Sleep(600 * time.Millisecond)
c.screenshot("after_drag_internal")
val := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
t.Logf("dialogs tras drag interno (600ms): %s", val)
if strings.Contains(val, "0") {
t.Errorf("BUG: modal cerro tras drag interno de saturation")
}
// 2. Verificar que Mantine NO añade close button (X) — debe estar deshabilitado.
closeBtn := c.eval(`document.querySelectorAll('[role="dialog"] .mantine-Modal-close').length`)
t.Logf("close buttons en modal: %s", closeBtn)
if !strings.Contains(closeBtn, "0") {
t.Errorf("BUG: modal tiene close button (X). Click accidental cierra. Usar withCloseButton={false}")
}
}
// Helper: abre modal avatar y devuelve ctx con modal listo.
func openAvatarColorModal(t *testing.T) *ctx {
t.Helper()
c, _ := authedSetup(t)
browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second)
browser.CdpClick(c.conn, "[aria-label='Usuario']")
time.Sleep(300 * time.Millisecond)
browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second)
browser.CdpClick(c.conn, "[aria-label='Color personalizado']")
browser.CdpWaitElement(c.conn, "[role='dialog']", 3*time.Second)
time.Sleep(500 * time.Millisecond)
return c
}
// Comportamiento deseado: clicks DENTRO del modal NO cierran. Tests granulares
// con sleep generoso para esperar animaciones de Mantine.
func TestColorPicker_InsideClicks_DoNotClose(t *testing.T) {
c := openAvatarColorModal(t)
regions := []struct {
name string
selector string
}{
{"hex_input", "[role='dialog'] input"},
{"hex_label", "[role='dialog'] .mantine-TextInput-label"},
{"saturation_center", "[role='dialog'] .mantine-ColorPicker-saturation"},
{"hue_slider", "[role='dialog'] .mantine-ColorPicker-slider"},
{"swatch_first", "[role='dialog'] .mantine-ColorPicker-swatch"},
{"body", "[role='dialog'] .mantine-Modal-body"},
{"stack", "[role='dialog'] .mantine-Stack-root"},
{"title", "[role='dialog'] .mantine-Modal-title"},
{"header", "[role='dialog'] .mantine-Modal-header"},
{"content", "[role='dialog']"},
}
for _, r := range regions {
t.Run(r.name, func(t *testing.T) {
res := c.eval(`(() => {
const el = document.querySelector(` + jsonQuote(r.selector) + `);
if (!el) return 'NO_EL';
const rc = el.getBoundingClientRect();
const x = rc.left + Math.min(rc.width / 2, 30);
const y = rc.top + Math.min(rc.height / 2, 12);
const target = document.elementFromPoint(x, y);
if (!target) return 'NO_TARGET';
['pointerdown','mousedown','pointerup','mouseup','click'].forEach(type => {
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent;
target.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, button: 0, buttons: type.includes('up') ? 0 : 1, view: window, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
});
return 'OK ' + target.tagName;
})()`)
t.Logf("region %s: %s", r.name, res)
time.Sleep(500 * time.Millisecond) // esperar animaciones
count := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if strings.Contains(count, "0") {
c.screenshot("inside_closed_" + r.name)
t.Errorf("BUG: modal cerro tras click en %q", r.name)
}
})
}
}
// Click en overlay (zona oscura fuera del panel del modal) DEBE cerrar.
func TestColorPicker_OverlayClick_Closes(t *testing.T) {
c := openAvatarColorModal(t)
res := c.eval(`(() => {
const overlay = document.querySelector('.mantine-Overlay-root, .mantine-Modal-overlay');
if (!overlay) return 'NO_OVERLAY';
const rc = overlay.getBoundingClientRect();
// click en esquina superior izquierda del overlay (lejos del modal centrado)
const x = rc.left + 10;
const y = rc.top + 10;
const target = document.elementFromPoint(x, y);
if (!target) return 'NO_TARGET';
['pointerdown','mousedown','pointerup','mouseup','click'].forEach(type => {
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent;
target.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, button: 0, view: window, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
});
return 'OK ' + target.tagName + '.' + (target.className || '').slice(0,30);
})()`)
t.Logf("overlay click: %s", res)
time.Sleep(500 * time.Millisecond)
count := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if !strings.Contains(count, "0") {
c.screenshot("overlay_did_not_close")
t.Errorf("BUG: modal NO cerro tras click en overlay (esperado: cierra)")
}
}
// Boton Cancelar DEBE cerrar.
func TestColorPicker_CancelButton_Closes(t *testing.T) {
c := openAvatarColorModal(t)
res := c.eval(`(() => { const b = [...document.querySelectorAll('[role="dialog"] button')].find(x => x.textContent.trim() === 'Cancelar'); if (!b) return 'NO_BTN'; b.click(); return 'OK'; })()`)
t.Logf("cancelar: %s", res)
time.Sleep(500 * time.Millisecond)
count := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if !strings.Contains(count, "0") {
t.Errorf("BUG: modal NO cerro tras Cancelar")
}
}
// Boton Aceptar DEBE cerrar.
func TestColorPicker_AcceptButton_Closes(t *testing.T) {
c := openAvatarColorModal(t)
res := c.eval(`(() => { const b = [...document.querySelectorAll('[role="dialog"] button')].find(x => x.textContent.trim() === 'Aceptar'); if (!b) return 'NO_BTN'; b.click(); return 'OK'; })()`)
t.Logf("aceptar: %s", res)
time.Sleep(500 * time.Millisecond)
count := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if !strings.Contains(count, "0") {
t.Errorf("BUG: modal NO cerro tras Aceptar")
}
}
// Modal NO debe tener close button (X) — clicks accidentales cierran.
func TestColorPicker_NoXCloseButton(t *testing.T) {
c := openAvatarColorModal(t)
count := c.eval(`document.querySelectorAll('[role="dialog"] .mantine-Modal-close').length`)
t.Logf("X buttons: %s", count)
if !strings.Contains(count, "0") {
t.Errorf("BUG: modal tiene close button (X). Quitar withCloseButton={false}")
}
}
func TestColorPicker_CardMenu_ModalStaysOpen(t *testing.T) {
c, _ := authedSetup(t)
// Esperar al menos una card.
if err := browser.CdpWaitElement(c.conn, "[aria-label='Acciones']", 8*time.Second); err != nil {
c.screenshot("debug_no_card")
t.Fatalf("card menu trigger no visible: %v", err)
}
if err := browser.CdpClick(c.conn, "[aria-label='Acciones']"); err != nil {
t.Fatalf("click card menu: %v", err)
}
time.Sleep(150 * time.Millisecond)
// Click submenu Color.
val := c.eval(`(() => { const items = [...document.querySelectorAll('.mantine-Menu-item')]; const t = items.find(i => i.textContent.trim() === 'Color'); if (!t) return 'NOT_FOUND'; t.click(); return 'OK'; })()`)
if !strings.Contains(val, "OK") {
c.screenshot("debug_no_color_item")
t.Fatalf("item Color no encontrado en menu: %s", val)
}
time.Sleep(200 * time.Millisecond)
c.screenshot("card_color_popover_open")
// Inyectar capturador de logs ahora (despues de la nav, antes del click).
c.eval(`(() => { window.__logs = []; const orig = console.log; console.log = function(...a) { window.__logs.push(a.map(x => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(' ')); orig.apply(console, a); }; })()`)
// Diagnostico DOM: cuantos "+" hay y donde estan?
plus := c.eval(`document.querySelectorAll('[aria-label="Color personalizado"]').length`)
t.Logf("'+' en DOM: %s", plus)
plusVisible := c.eval(`(() => { const el = document.querySelector('[aria-label="Color personalizado"]'); if (!el) return 'NO_EL'; const r = el.getBoundingClientRect(); return JSON.stringify({x: r.x, y: r.y, w: r.width, h: r.height}); })()`)
t.Logf("'+' rect: %s", plusVisible)
// Click "+" custom.
if err := browser.CdpClick(c.conn, "[aria-label='Color personalizado']"); err != nil {
t.Fatalf("click + en card popover: %v", err)
}
// Sondear cada 50ms hasta 800ms para ver si el modal aparece y luego desaparece.
for i := 0; i < 16; i++ {
time.Sleep(50 * time.Millisecond)
v := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
t.Logf("[%dms] dialogs=%s", (i+1)*50, v)
}
c.screenshot("card_after_plus_click")
val = c.eval(`document.querySelectorAll('[role="dialog"]').length`)
t.Logf("dialogs final (card): %s", val)
logs := c.eval(`JSON.stringify(window.__logs || [])`)
t.Logf("console logs: %s", logs)
if strings.Contains(val, "0") {
t.Errorf("BUG: Modal del color picker se cerro inmediatamente (card menu)")
}
}
+7
View File
@@ -0,0 +1,7 @@
module kanban-e2e
go 1.25.0
require fn-registry v0.0.0-00010101000000-000000000000
replace fn-registry => ../../..
+178
View File
@@ -0,0 +1,178 @@
// Tests e2e contra kanban server (puerto 8095) usando funciones del registry.
// Requiere kanban backend corriendo + Chrome accesible (WSL2 o Linux).
//
// Ejecucion:
// cd e2e && go test -v -tags fts5 ./...
// o: BASE_URL=http://localhost:5180 go test -v ./... (modo dev con Vite)
//
// Variables de entorno:
// BASE_URL — default http://localhost:8095
// KANBAN_USER — default e2e
// KANBAN_PASS — default e2etest
// HEADLESS — "1" para headless. Default "1"
//
// Reusa funciones del registry: chrome_launch, cdp_*. NO duplica logica.
package e2e
import (
"fmt"
"net/http"
"os"
"strings"
"testing"
"time"
"fn-registry/functions/browser"
)
const cdpPort = 9335
type ctx struct {
t *testing.T
conn *browser.CDPConn
chromePID int
baseURL string
}
func envOr(k, dflt string) string {
if v := os.Getenv(k); v != "" {
return v
}
return dflt
}
func setup(t *testing.T) *ctx {
t.Helper()
baseURL := envOr("BASE_URL", "http://localhost:8095")
// Verificar que el backend responde antes de lanzar Chrome.
resp, err := http.Get(baseURL + "/api/board")
if err != nil {
t.Skipf("backend no accesible en %s: %v", baseURL, err)
}
resp.Body.Close()
headless := envOr("HEADLESS", "1") == "1"
pid, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{
Port: cdpPort,
UserDataDir: "/tmp/kanban-e2e-profile",
Headless: headless,
})
if err != nil {
t.Skipf("chrome_launch fallo: %v", err)
}
conn, err := browser.CdpConnect(cdpPort)
if err != nil {
_ = browser.CdpClose(nil, pid)
t.Fatalf("cdp_connect fallo: %v", err)
}
c := &ctx{t: t, conn: conn, chromePID: pid, baseURL: baseURL}
t.Cleanup(func() { _ = browser.CdpClose(c.conn, c.chromePID) })
return c
}
func (c *ctx) navigate(path string) {
c.t.Helper()
if err := browser.CdpNavigate(c.conn, c.baseURL+path); err != nil {
c.t.Fatalf("navigate %s: %v", path, err)
}
if err := browser.CdpWaitLoad(c.conn, 10*time.Second); err != nil {
c.t.Fatalf("wait_load %s: %v", path, err)
}
}
func (c *ctx) screenshot(name string) {
c.t.Helper()
dir := "screenshots"
_ = os.MkdirAll(dir, 0o755)
out := fmt.Sprintf("%s/%s.png", dir, name)
if err := browser.CdpScreenshot(c.conn, out, browser.CdpScreenshotOpts{Format: "png"}); err != nil {
c.t.Logf("screenshot fallo (%s): %v", name, err)
return
}
c.t.Logf("screenshot: %s", out)
}
func (c *ctx) eval(expr string) string {
c.t.Helper()
out, err := browser.CdpEvaluate(c.conn, expr)
if err != nil {
c.t.Fatalf("eval (%s): %v", expr, err)
}
return out
}
// --- Tests ---
func TestE2E_HomeLoads(t *testing.T) {
c := setup(t)
c.navigate("/")
// Login form o board (segun haya sesion previa). Busca cualquier rasgo visible.
if err := browser.CdpWaitElement(c.conn, "body", 5*time.Second); err != nil {
t.Fatalf("body no aparecio: %v", err)
}
html, err := browser.CdpGetHTML(c.conn)
if err != nil {
t.Fatalf("get_html: %v", err)
}
if !strings.Contains(strings.ToLower(html), "kanban") &&
!strings.Contains(strings.ToLower(html), "iniciar") &&
!strings.Contains(strings.ToLower(html), "login") {
t.Errorf("home no contiene rastros esperados (kanban/login). HTML[:200]=%s", html[:min(len(html), 200)])
}
c.screenshot("01_home")
}
func TestE2E_ApiBoardResponds(t *testing.T) {
baseURL := envOr("BASE_URL", "http://localhost:8095")
resp, err := http.Get(baseURL + "/api/board")
if err != nil {
t.Skipf("backend no accesible: %v", err)
}
defer resp.Body.Close()
// 401 (sin sesion) o 200 (sesion activa) — ambos validos.
if resp.StatusCode != 200 && resp.StatusCode != 401 {
t.Errorf("/api/board status inesperado: %d", resp.StatusCode)
}
}
func TestE2E_FlagsEndpoint_DoesNotExist(t *testing.T) {
// Smoke: endpoint /api/me devuelve 401 sin auth (no 5xx).
baseURL := envOr("BASE_URL", "http://localhost:8095")
resp, err := http.Get(baseURL + "/api/me")
if err != nil {
t.Skipf("backend no accesible: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
t.Errorf("/api/me devolvio 5xx: %d", resp.StatusCode)
}
}
func TestE2E_FrontendBundleHasNoConsoleErrors(t *testing.T) {
c := setup(t)
c.navigate("/")
if err := browser.CdpWaitElement(c.conn, "body", 5*time.Second); err != nil {
t.Fatalf("body: %v", err)
}
// Comprueba que no hay errores graves en el DOM.
val := c.eval(`document.querySelectorAll('script[src*="error"]').length`)
if !strings.Contains(val, "0") {
t.Errorf("scripts de error detectados: %s", val)
}
// Verifica que el bundle se cargo (algun script de assets).
val = c.eval(`document.querySelectorAll('script[src*="/assets/"]').length`)
if strings.Contains(val, "0") {
t.Errorf("bundle no cargado: %s", val)
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+108 -5
View File
@@ -75,6 +75,8 @@ import { HistoryModal } from "./components/HistoryModal";
import { KanbanCard } from "./components/KanbanCard"; import { KanbanCard } from "./components/KanbanCard";
import { KanbanColumn } from "./components/KanbanColumn"; import { KanbanColumn } from "./components/KanbanColumn";
import { StickerPicker } from "./components/StickerPicker"; import { StickerPicker } from "./components/StickerPicker";
import { ColorPickerGrid, CustomColorModal } from "./components/ColorPickerGrid";
import { AVATAR_COLORS } from "./components/colors";
import { colorBg, colorBorder } from "./components/colors"; import { colorBg, colorBorder } from "./components/colors";
import type { Board, Card, CardColor, Column, ColumnLocation, User } from "./types"; import type { Board, Card, CardColor, Column, ColumnLocation, User } from "./types";
@@ -124,8 +126,13 @@ export function App() {
const [filterUnassigned, setFilterUnassigned] = useState(false); const [filterUnassigned, setFilterUnassigned] = useState(false);
const [filterDateFrom, setFilterDateFrom] = useState<Date | null>(null); const [filterDateFrom, setFilterDateFrom] = useState<Date | null>(null);
const [filterDateTo, setFilterDateTo] = useState<Date | null>(null); const [filterDateTo, setFilterDateTo] = useState<Date | null>(null);
const [filterDeadlineOnly, setFilterDeadlineOnly] = useState(false);
const [highlightCardId, setHighlightCardId] = useState<string | null>(null);
const [stickerPickerOpen, setStickerPickerOpen] = useState(false); const [stickerPickerOpen, setStickerPickerOpen] = useState(false);
const [activeSticker, setActiveSticker] = useState<string | null>(null); const [activeSticker, setActiveSticker] = useState<string | null>(null);
const [avatarColorModalOpen, setAvatarColorModalOpen] = useState(false);
const [avatarCustomColor, setAvatarCustomColor] = useState("#888888");
const [cardColorModal, setCardColorModal] = useState<{ cardId: string; color: string } | null>(null);
const [navOpen, setNavOpen] = useState(false); const [navOpen, setNavOpen] = useState(false);
const [navWidth, setNavWidth] = useState<number>(() => { const [navWidth, setNavWidth] = useState<number>(() => {
const stored = localStorage.getItem("kanban_nav_width"); const stored = localStorage.getItem("kanban_nav_width");
@@ -279,6 +286,7 @@ export function App() {
const cardTags = new Set(c.tags || []); const cardTags = new Set(c.tags || []);
for (const t of filterTags) if (!cardTags.has(t)) return false; for (const t of filterTags) if (!cardTags.has(t)) return false;
} }
if (filterDeadlineOnly && !c.deadline) return false;
if (filterDateFrom || filterDateTo) { if (filterDateFrom || filterDateTo) {
const fromMs = filterDateFrom ? new Date(filterDateFrom).setHours(0, 0, 0, 0) : -Infinity; 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 toMs = filterDateTo ? new Date(filterDateTo).setHours(23, 59, 59, 999) : Infinity;
@@ -289,7 +297,7 @@ export function App() {
} }
return true; return true;
}, },
[searchTerm, filterAssigneeId, filterUnassigned, filterRequester, filterTags, filterDateFrom, filterDateTo] [searchTerm, filterAssigneeId, filterUnassigned, filterRequester, filterTags, filterDateFrom, filterDateTo, filterDeadlineOnly]
); );
const cardsByColumn = useMemo(() => { const cardsByColumn = useMemo(() => {
@@ -311,7 +319,8 @@ export function App() {
!!filterRequester || !!filterRequester ||
filterTags.length > 0 || filterTags.length > 0 ||
!!filterDateFrom || !!filterDateFrom ||
!!filterDateTo; !!filterDateTo ||
filterDeadlineOnly;
const findCard = (id: string): Card | undefined => board?.cards.find((c) => c.id === id); 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); const findColumn = (id: string): Column | undefined => board?.columns.find((c) => c.id === id);
@@ -594,6 +603,38 @@ export function App() {
}); });
}, [reload, users, requesterOptions, tagOptions]); }, [reload, users, requesterOptions, tagOptions]);
const handleSetRequester = useCallback(async (id: string, requester: string) => {
setBoard((prev) => {
if (!prev) return prev;
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, requester } : c)) };
});
try {
await api.updateCard(id, { requester });
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
reload();
}
}, [reload]);
const handleJumpToCard = useCallback((cardId: string) => {
setActiveTab("board");
setHighlightCardId(cardId);
window.setTimeout(() => setHighlightCardId(null), 3000);
}, []);
const handleSetCardDeadline = useCallback(async (id: string, deadline: string | null) => {
setBoard((prev) => {
if (!prev) return prev;
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, deadline } : c)) };
});
try {
await api.updateCard(id, { deadline });
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
reload();
}
}, [reload]);
const handleAssignCard = useCallback(async (id: string, assignee_id: string | null) => { const handleAssignCard = useCallback(async (id: string, assignee_id: string | null) => {
setBoard((prev) => { setBoard((prev) => {
if (!prev) return prev; if (!prev) return prev;
@@ -858,16 +899,36 @@ export function App() {
<IconMessageChatbot size={16} /> <IconMessageChatbot size={16} />
</ActionIcon> </ActionIcon>
{auth.user && ( {auth.user && (
<Menu position="bottom-end" shadow="md" withArrow> <Menu position="bottom-end" shadow="md" withArrow closeOnItemClick={false}>
<Menu.Target> <Menu.Target>
<ActionIcon variant="subtle" aria-label="Usuario"> <ActionIcon variant="subtle" aria-label="Usuario">
<Avatar size={26} radius="xl" color="blue"> <Avatar size={26} radius="xl" color={auth.user.color || "blue"}>
{(auth.user.display_name || auth.user.username).slice(0, 2).toUpperCase()} {(auth.user.display_name || auth.user.username).slice(0, 2).toUpperCase()}
</Avatar> </Avatar>
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Label>{auth.user.display_name || auth.user.username}</Menu.Label> <Menu.Label>{auth.user.display_name || auth.user.username}</Menu.Label>
<Box p="xs">
<Text size="xs" c="dimmed" mb={4}>Color del avatar</Text>
<ColorPickerGrid
value={auth.user.color || ""}
onChange={async (c) => {
try {
const u = await api.updateMe({ color: c });
auth.setUser(u);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}}
options={AVATAR_COLORS}
onOpenCustom={() => {
setAvatarCustomColor(auth.user?.color?.startsWith("#") ? auth.user.color : "#888888");
setAvatarColorModalOpen(true);
}}
/>
</Box>
<Menu.Divider />
<Menu.Item <Menu.Item
leftSection={<IconLogout size={14} />} leftSection={<IconLogout size={14} />}
color="red" color="red"
@@ -929,6 +990,11 @@ export function App() {
onShowHistory={handleShowHistory} onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock} onToggleCardLock={handleToggleCardLock}
onAssignCard={handleAssignCard} onAssignCard={handleAssignCard}
onSetCardDeadline={handleSetCardDeadline}
highlightCardId={highlightCardId}
onSetRequester={handleSetRequester}
requesterOptions={requesterOptions}
onOpenCustomCardColor={(cardId, current) => setCardColorModal({ cardId, color: current })}
activeSticker={activeSticker} activeSticker={activeSticker}
onAddSticker={handleAddSticker} onAddSticker={handleAddSticker}
onRemoveSticker={handleRemoveSticker} onRemoveSticker={handleRemoveSticker}
@@ -1004,7 +1070,7 @@ export function App() {
</Box> </Box>
) : activeTab === "calendar" ? ( ) : activeTab === "calendar" ? (
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}> <Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
<CalendarView users={users} /> <CalendarView users={users} cards={board.cards} onJumpToCard={handleJumpToCard} />
</Box> </Box>
) : ( ) : (
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}> <Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
@@ -1045,6 +1111,12 @@ export function App() {
if (v) setFilterAssigneeId(null); if (v) setFilterAssigneeId(null);
}} }}
/> />
<Checkbox
size="xs"
label="Con deadline"
checked={filterDeadlineOnly}
onChange={(e) => setFilterDeadlineOnly(e.currentTarget.checked)}
/>
<Select <Select
placeholder="Solicitante" placeholder="Solicitante"
value={filterRequester} value={filterRequester}
@@ -1151,6 +1223,7 @@ export function App() {
setFilterTags([]); setFilterTags([]);
setFilterDateFrom(null); setFilterDateFrom(null);
setFilterDateTo(null); setFilterDateTo(null);
setFilterDeadlineOnly(false);
}} }}
> >
Limpiar Limpiar
@@ -1184,6 +1257,10 @@ export function App() {
onShowHistory={handleShowHistory} onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock} onToggleCardLock={handleToggleCardLock}
onAssignCard={handleAssignCard} onAssignCard={handleAssignCard}
onSetCardDeadline={handleSetCardDeadline}
highlightCardId={highlightCardId}
onSetRequester={handleSetRequester}
requesterOptions={requesterOptions}
activeSticker={activeSticker} activeSticker={activeSticker}
onAddSticker={handleAddSticker} onAddSticker={handleAddSticker}
onRemoveSticker={handleRemoveSticker} onRemoveSticker={handleRemoveSticker}
@@ -1271,6 +1348,32 @@ export function App() {
</Box> </Box>
) : null} ) : null}
</DragOverlay> </DragOverlay>
<CustomColorModal
opened={avatarColorModalOpen}
onClose={() => setAvatarColorModalOpen(false)}
value={avatarCustomColor}
onAccept={async (c) => {
setAvatarCustomColor(c);
try {
const u = await api.updateMe({ color: c });
auth.setUser(u);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}}
/>
<CustomColorModal
opened={!!cardColorModal}
onClose={() => setCardColorModal(null)}
value={cardColorModal?.color || "#888888"}
onAccept={(c) => {
if (!cardColorModal) return;
handleChangeCardColor(cardColorModal.cardId, c);
}}
/>
</DndContext> </DndContext>
); );
} }
+11 -18
View File
@@ -8,27 +8,14 @@ import type {
Sticker, Sticker,
User, User,
} from "./types"; } from "./types";
import { fetchJSON as registryFetchJSON, HTTPError } from "@fn_library/infra/fetch_json";
export { HTTPError };
const BASE = "/api"; const BASE = "/api";
export class HTTPError extends Error { function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
constructor(public status: number, message: string) { return registryFetchJSON<T>(path, init, BASE);
super(message);
}
}
async function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: "include",
...init,
headers: { "Content-Type": "application/json", ...(init?.headers || {}) },
});
if (!res.ok) {
const err = await res.json().catch(() => ({ Message: res.statusText }));
throw new HTTPError(res.status, err.Message || err.message || res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json();
} }
export function getBoard(): Promise<Board> { export function getBoard(): Promise<Board> {
@@ -84,6 +71,7 @@ export interface UpdateCardInput {
locked?: boolean; locked?: boolean;
assignee_id?: string | null; assignee_id?: string | null;
tags?: string[]; tags?: string[];
deadline?: string | null;
} }
export function updateCard(id: string, patch: UpdateCardInput): Promise<void> { export function updateCard(id: string, patch: UpdateCardInput): Promise<void> {
@@ -168,6 +156,10 @@ export function getMe(): Promise<User> {
return fetchJSON("/me"); return fetchJSON("/me");
} }
export function updateMe(patch: { color?: string }): Promise<User> {
return fetchJSON("/me", { method: "PATCH", body: JSON.stringify(patch) });
}
export function listUsers(): Promise<User[]> { export function listUsers(): Promise<User[]> {
return fetchJSON("/users"); return fetchJSON("/users");
} }
@@ -186,6 +178,7 @@ export function getMetrics(f: MetricsFilter): Promise<Metrics> {
if (f.to) qs.set("to", f.to); if (f.to) qs.set("to", f.to);
if (f.assignee_id) qs.set("assignee_id", f.assignee_id); if (f.assignee_id) qs.set("assignee_id", f.assignee_id);
if (f.requester) qs.set("requester", f.requester); if (f.requester) qs.set("requester", f.requester);
if (f.tags && f.tags.length > 0) qs.set("tags", f.tags.join(","));
const q = qs.toString(); const q = qs.toString();
return fetchJSON(`/metrics${q ? `?${q}` : ""}`); return fetchJSON(`/metrics${q ? `?${q}` : ""}`);
} }
+2 -1
View File
@@ -9,6 +9,7 @@ interface AuthCtx {
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string, displayName: string) => Promise<void>; register: (username: string, password: string, displayName: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
setUser: (u: User | null) => void;
} }
const Ctx = createContext<AuthCtx | null>(null); const Ctx = createContext<AuthCtx | null>(null);
@@ -45,7 +46,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null); setUser(null);
}, []); }, []);
return <Ctx.Provider value={{ user, loading, login, register, logout }}>{children}</Ctx.Provider>; return <Ctx.Provider value={{ user, loading, login, register, logout, setUser }}>{children}</Ctx.Provider>;
} }
export function useAuth(): AuthCtx { export function useAuth(): AuthCtx {
+79 -8
View File
@@ -4,26 +4,31 @@ import {
Group, Group,
Loader, Loader,
Paper, Paper,
Popover,
Select, Select,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text, Text,
Title, Title,
UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { MonthPickerInput } from "@mantine/dates"; import { MonthPickerInput } from "@mantine/dates";
import { IconCheckbox, IconPlus } from "@tabler/icons-react"; import { IconCheckbox, IconHourglass, IconPlus } from "@tabler/icons-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import * as api from "../api"; import * as api from "../api";
import type { Metrics, User } from "../types"; import type { Card, Metrics, User } from "../types";
interface Props { interface Props {
users: User[]; users: User[];
cards: Card[];
onJumpToCard?: (cardId: string) => void;
} }
const DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"]; const DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"];
export function CalendarView({ users }: Props) { export function CalendarView({ users, cards, onJumpToCard }: Props) {
const [openDate, setOpenDate] = useState<string | null>(null);
const [month, setMonth] = useState<Date>(new Date()); const [month, setMonth] = useState<Date>(new Date());
const [assigneeId, setAssigneeId] = useState<string | null>(null); const [assigneeId, setAssigneeId] = useState<string | null>(null);
const [data, setData] = useState<Metrics | null>(null); const [data, setData] = useState<Metrics | null>(null);
@@ -53,20 +58,27 @@ export function CalendarView({ users }: Props) {
); );
const dayMap = useMemo(() => { const dayMap = useMemo(() => {
const m = new Map<string, { created: number; done: number }>(); const m = new Map<string, { created: number; done: number; deadlines: Card[] }>();
if (!data) return m; if (!data) return m;
for (const d of data.created_daily) { for (const d of data.created_daily) {
const cur = m.get(d.date) ?? { created: 0, done: 0 }; const cur = m.get(d.date) ?? { created: 0, done: 0, deadlines: [] };
cur.created = d.count; cur.created = d.count;
m.set(d.date, cur); m.set(d.date, cur);
} }
for (const d of data.throughput_daily) { for (const d of data.throughput_daily) {
const cur = m.get(d.date) ?? { created: 0, done: 0 }; const cur = m.get(d.date) ?? { created: 0, done: 0, deadlines: [] };
cur.done = d.count; cur.done = d.count;
m.set(d.date, cur); m.set(d.date, cur);
} }
for (const c of cards) {
if (!c.deadline || c.deleted_at) continue;
const date = c.deadline.slice(0, 10);
const cur = m.get(date) ?? { created: 0, done: 0, deadlines: [] };
cur.deadlines.push(c);
m.set(date, cur);
}
return m; return m;
}, [data]); }, [data, cards]);
// Build month grid (Mon-first). // Build month grid (Mon-first).
const grid = useMemo(() => { const grid = useMemo(() => {
@@ -163,9 +175,12 @@ export function CalendarView({ users }: Props) {
if (!cell.date) { if (!cell.date) {
return <Box key={i} style={{ minHeight: 72 }} />; return <Box key={i} style={{ minHeight: 72 }} />;
} }
const stats = dayMap.get(cell.date) ?? { created: 0, done: 0 }; const stats = dayMap.get(cell.date) ?? { created: 0, done: 0, deadlines: [] as Card[] };
const dayNum = parseInt(cell.date.slice(8, 10), 10); const dayNum = parseInt(cell.date.slice(8, 10), 10);
const isToday = cell.date === dayjs().format("YYYY-MM-DD"); const isToday = cell.date === dayjs().format("YYYY-MM-DD");
const todayMs = dayjs().startOf("day").valueOf();
const cellMs = dayjs(cell.date).startOf("day").valueOf();
const overdueDay = cellMs < todayMs;
return ( return (
<Paper <Paper
key={i} key={i}
@@ -203,6 +218,62 @@ export function CalendarView({ users }: Props) {
</Text> </Text>
</Group> </Group>
)} )}
{stats.deadlines.length > 0 && (
<Popover
opened={openDate === cell.date}
onChange={(o) => setOpenDate(o ? cell.date : null)}
position="bottom"
withArrow
shadow="md"
width={280}
>
<Popover.Target>
<UnstyledButton
onClick={() => setOpenDate(openDate === cell.date ? null : cell.date)}
style={{ textAlign: "left" }}
>
<Stack gap={1}>
<Group gap={3} wrap="nowrap">
<IconHourglass size={10} color={overdueDay ? "var(--mantine-color-red-5)" : "var(--mantine-color-orange-5)"} />
<Text size="xs" c={overdueDay ? "red" : "orange"} fw={700} td="underline">
{stats.deadlines.length} deadline{stats.deadlines.length === 1 ? "" : "s"}
</Text>
</Group>
</Stack>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown p={6}>
<Stack gap={2}>
<Text size="xs" c="dimmed" fw={600} mb={2}>
Vencen el {dayjs(cell.date).format("DD/MM/YYYY")}
</Text>
{stats.deadlines.map((c) => (
<UnstyledButton
key={c.id}
onClick={() => {
setOpenDate(null);
onJumpToCard?.(c.id);
}}
style={{
padding: "4px 6px",
borderRadius: 4,
background: "var(--mantine-color-dark-6)",
}}
>
<Group gap={6} wrap="nowrap">
<Text size="xs" c="dimmed" ff="monospace">
#{String(c.seq_num).padStart(5, "0")}
</Text>
<Text size="xs" lineClamp={1} title={c.title}>
{c.title}
</Text>
</Group>
</UnstyledButton>
))}
</Stack>
</Popover.Dropdown>
</Popover>
)}
</Stack> </Stack>
</Paper> </Paper>
); );
+180
View File
@@ -0,0 +1,180 @@
import { Box, Button, ColorPicker, Group, Modal, Stack, TextInput, Tooltip } from "@mantine/core";
import { IconPalette } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { CARD_COLORS, colorBorder, colorSwatch } from "./colors";
interface Props {
value: string;
onChange: (color: string) => void;
options?: { value: string; label: string }[];
// Si se da, el "+" delega en el padre (recomendado dentro de Menu/Popover).
// Sin esto, ColorPickerGrid abre Modal interno (puede colisionar con cierres del padre).
onOpenCustom?: () => void;
}
const SWATCH = 26;
export function ColorPickerGrid({ value, onChange, options = CARD_COLORS, onOpenCustom }: Props) {
const [pickerOpen, setPickerOpen] = useState(false);
const [custom, setCustom] = useState(value && value.startsWith("#") ? value : "#888888");
const isCustomActive = !!value && value.startsWith("#") && !options.some((o) => o.value === value);
return (
<>
<Group gap={6} maw={280}>
{options.map((c) => {
const selected = value === c.value;
return (
<Tooltip key={c.value || "default"} label={c.label} withArrow>
<Box
role="button"
onClick={(e) => { e.stopPropagation(); onChange(c.value); }}
aria-label={c.label}
style={{
width: SWATCH,
height: SWATCH,
borderRadius: "50%",
background: colorSwatch(c.value),
border: `2px solid ${selected ? "var(--mantine-color-white)" : colorBorder(c.value)}`,
boxShadow: selected ? "0 0 0 2px var(--mantine-color-blue-5)" : undefined,
cursor: "pointer",
flexShrink: 0,
transition: "transform .1s",
}}
/>
</Tooltip>
);
})}
<Tooltip label="Color personalizado" withArrow>
<Box
role="button"
onMouseDown={(e) => { e.stopPropagation(); }}
onClick={(e) => {
e.stopPropagation();
if (onOpenCustom) onOpenCustom();
else setPickerOpen(true);
}}
aria-label="Color personalizado"
style={{
width: SWATCH,
height: SWATCH,
borderRadius: "50%",
background: isCustomActive ? custom : "transparent",
border: `2px dashed ${isCustomActive ? custom : "var(--mantine-color-gray-5)"}`,
boxShadow: isCustomActive ? "0 0 0 2px var(--mantine-color-blue-5)" : undefined,
cursor: "pointer",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--mantine-color-gray-3)",
}}
>
<IconPalette size={14} />
</Box>
</Tooltip>
</Group>
{!onOpenCustom && (
<CustomColorModal
opened={pickerOpen}
onClose={() => setPickerOpen(false)}
value={custom}
onAccept={(v) => { setCustom(v); onChange(v); }}
/>
)}
</>
);
}
interface ModalProps {
opened: boolean;
onClose: () => void;
value: string;
// Disparado solo cuando el usuario pulsa "Aceptar". Mientras arrastra el picker
// el cambio queda local — no fuga al resto de la app.
onAccept: (v: string) => void;
}
const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
export function CustomColorModal({ opened, onClose, value, onAccept }: ModalProps) {
const [local, setLocal] = useState(value || "#888888");
const [hexInput, setHexInput] = useState(value || "#888888");
// Reset state cuando abre con un value nuevo (cada vez que se abre).
useEffect(() => {
if (opened) {
const v = value && HEX_RE.test(value) ? value : "#888888";
setLocal(v);
setHexInput(v);
}
}, [opened, value]);
const onHexChange = (v: string) => {
let s = v.trim();
if (s && !s.startsWith("#")) s = "#" + s;
setHexInput(s);
if (HEX_RE.test(s)) setLocal(s);
};
const onPickerChange = (v: string) => {
setLocal(v);
setHexInput(v);
};
const accept = () => { onAccept(local); onClose(); };
return (
<Modal
opened={opened}
onClose={onClose}
title="Color personalizado"
size="auto"
centered
withinPortal
zIndex={2000}
closeOnClickOutside
closeOnEscape={false}
trapFocus={false}
withCloseButton={false}
>
<Stack
gap="sm"
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<ColorPicker
value={local}
onChange={onPickerChange}
format="hex"
swatches={["#1c7ed6", "#15aabf", "#12b886", "#37b24d", "#82c91e", "#fab005", "#fd7e14", "#fa5252", "#e64980", "#be4bdb", "#7950f2", "#4c6ef5", "#868e96", "#212529"]}
fullWidth
/>
<Group align="end" gap="xs">
<TextInput
label="Hex"
value={hexInput}
onChange={(e) => onHexChange(e.currentTarget.value)}
error={hexInput && !HEX_RE.test(hexInput) ? "Hex invalido" : undefined}
size="xs"
style={{ flex: 1 }}
placeholder="#rrggbb"
/>
<Box
style={{
width: 32,
height: 32,
borderRadius: 4,
background: HEX_RE.test(hexInput) ? hexInput : "transparent",
border: "1px solid var(--mantine-color-dark-4)",
}}
/>
</Group>
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={onClose}>Cancelar</Button>
<Button size="xs" onClick={accept} disabled={!HEX_RE.test(local)}>Aceptar</Button>
</Group>
</Stack>
</Modal>
);
}
+20 -1
View File
@@ -17,6 +17,7 @@ import {
Grid, Grid,
Group, Group,
Loader, Loader,
MultiSelect,
Paper, Paper,
Select, Select,
SimpleGrid, SimpleGrid,
@@ -89,10 +90,16 @@ export function Dashboard({ users }: Props) {
const [to, setTo] = useState<Date | null>(() => new Date()); const [to, setTo] = useState<Date | null>(() => new Date());
const [assigneeId, setAssigneeId] = useState<string | null>(null); const [assigneeId, setAssigneeId] = useState<string | null>(null);
const [requester, setRequester] = useState<string | null>(null); const [requester, setRequester] = useState<string | null>(null);
const [tags, setTags] = useState<string[]>([]);
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [data, setData] = useState<Metrics | null>(null); const [data, setData] = useState<Metrics | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [requesterOptions, setRequesterOptions] = useState<string[]>([]); const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
useEffect(() => {
api.listTags().then(setTagOptions).catch(() => {});
}, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
@@ -102,6 +109,7 @@ export function Dashboard({ users }: Props) {
to: fmtDate(to), to: fmtDate(to),
assignee_id: assigneeId || undefined, assignee_id: assigneeId || undefined,
requester: requester || undefined, requester: requester || undefined,
tags: tags.length > 0 ? tags : undefined,
}) })
.then((m) => { .then((m) => {
if (cancelled) return; if (cancelled) return;
@@ -119,7 +127,7 @@ export function Dashboard({ users }: Props) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [from, to, assigneeId, requester]); }, [from, to, assigneeId, requester, tags]);
const userOptions = useMemo( const userOptions = useMemo(
() => users.map((u) => ({ value: u.id, label: u.display_name || u.username })), () => users.map((u) => ({ value: u.id, label: u.display_name || u.username })),
@@ -240,6 +248,17 @@ export function Dashboard({ users }: Props) {
searchable searchable
style={{ minWidth: 160 }} style={{ minWidth: 160 }}
/> />
<MultiSelect
label="Tags"
size="xs"
placeholder="Todas"
value={tags}
onChange={setTags}
data={tagOptions}
clearable
searchable
style={{ minWidth: 200 }}
/>
</Group> </Group>
</Group> </Group>
+124 -71
View File
@@ -1,25 +1,115 @@
import { Badge, Divider, Group, Loader, Stack, Text, Timeline } from "@mantine/core"; import { Badge, Divider, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
import { IconColumns3, IconLock } from "@tabler/icons-react"; import {
import { useEffect, useState } from "react"; IconArrowsHorizontal,
import { cardHistory } from "../api"; IconCalendarDue,
import type { Card, CardHistoryResponse } from "../types"; IconCalendarOff,
IconColumns3,
IconEdit,
IconLock,
IconLockOpen,
IconPalette,
IconPlus,
IconTag,
IconUser,
IconUserMinus,
IconUserPlus,
} from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import { cardHistory, listUsers } from "../api";
import type { Card, CardEvent, CardHistoryResponse, User } from "../types";
import { formatDuration } from "./format"; import { formatDuration } from "./format";
interface Props { interface Props {
card: Card; card: Card;
} }
interface UnifiedEvent {
id: string;
ts: string;
kind: string;
actorID: string | null;
detail: string;
icon: React.ReactNode;
color: string;
}
function parsePayload(p: string): Record<string, unknown> {
try { return JSON.parse(p); } catch { return {}; }
}
function eventToUnified(e: CardEvent): UnifiedEvent {
const p = parsePayload(e.payload);
switch (e.kind) {
case "created":
return { id: e.id, ts: e.created_at, kind: "Creada", actorID: e.actor_id, detail: String(p.title || ""), icon: <IconPlus size={12} />, color: "green" };
case "title_changed":
return { id: e.id, ts: e.created_at, kind: "Titulo", actorID: e.actor_id, detail: `"${p.old}" → "${p.new}"`, icon: <IconEdit size={12} />, color: "blue" };
case "requester_changed":
return { id: e.id, ts: e.created_at, kind: "Solicitante", actorID: e.actor_id, detail: `"${p.old || "(vacio)"}" → "${p.new || "(vacio)"}"`, icon: <IconEdit size={12} />, color: "orange" };
case "description_changed":
return { id: e.id, ts: e.created_at, kind: "Descripcion", actorID: e.actor_id, detail: "edicion", icon: <IconEdit size={12} />, color: "blue" };
case "color_changed":
return { id: e.id, ts: e.created_at, kind: "Color", actorID: e.actor_id, detail: String(p.color || ""), icon: <IconPalette size={12} />, color: "violet" };
case "tags_changed":
return { id: e.id, ts: e.created_at, kind: "Tags", actorID: e.actor_id, detail: Array.isArray(p.tags) ? (p.tags as string[]).join(", ") || "(sin tags)" : "", icon: <IconTag size={12} />, color: "grape" };
case "assigned":
return { id: e.id, ts: e.created_at, kind: "Asignada", actorID: e.actor_id, detail: String(p.assignee_id || ""), icon: <IconUserPlus size={12} />, color: "teal" };
case "unassigned":
return { id: e.id, ts: e.created_at, kind: "Sin asignar", actorID: e.actor_id, detail: "", icon: <IconUserMinus size={12} />, color: "gray" };
case "deadline_set": {
const d = String(p.deadline || "");
return { id: e.id, ts: e.created_at, kind: "Deadline", actorID: e.actor_id, detail: d ? d.slice(0, 10) : "", icon: <IconCalendarDue size={12} />, color: "orange" };
}
case "deadline_cleared":
return { id: e.id, ts: e.created_at, kind: "Deadline quitado", actorID: e.actor_id, detail: p.prev ? String(p.prev).slice(0, 10) : "", icon: <IconCalendarOff size={12} />, color: "gray" };
default:
return { id: e.id, ts: e.created_at, kind: e.kind, actorID: e.actor_id, detail: e.payload, icon: <IconEdit size={12} />, color: "gray" };
}
}
export function HistoryModal({ card }: Props) { export function HistoryModal({ card }: Props) {
const [data, setData] = useState<CardHistoryResponse | null>(null); const [data, setData] = useState<CardHistoryResponse | null>(null);
const [users, setUsers] = useState<User[]>([]);
useEffect(() => { useEffect(() => {
cardHistory(card.id) cardHistory(card.id)
.then(setData) .then(setData)
.catch(() => .catch(() =>
setData({ column_history: [], lock_periods: [], total_locked_ms: 0, currently_locked: false }) setData({ column_history: [], lock_periods: [], events: [], total_locked_ms: 0, currently_locked: false })
); );
listUsers().then(setUsers).catch(() => {});
}, [card.id]); }, [card.id]);
const userById = useMemo(() => {
const m = new Map<string, User>();
for (const u of users) m.set(u.id, u);
return m;
}, [users]);
const unified = useMemo(() => {
if (!data) return [] as UnifiedEvent[];
const out: UnifiedEvent[] = [];
for (const e of data.events || []) out.push(eventToUnified(e));
for (const h of data.column_history || []) {
out.push({
id: "h_in_" + h.id,
ts: h.entered_at,
kind: "Mueve a columna",
actorID: h.actor_id,
detail: h.column_name || h.column_id,
icon: <IconArrowsHorizontal size={12} />,
color: "blue",
});
}
for (const p of data.lock_periods || []) {
out.push({ id: "lk_" + p.id, ts: p.locked_at, kind: "Bloqueada", actorID: p.actor_id, detail: "", icon: <IconLock size={12} />, color: "yellow" });
if (p.unlocked_at) {
out.push({ id: "lku_" + p.id, ts: p.unlocked_at, kind: "Desbloqueada", actorID: p.actor_id, detail: formatDuration(p.duration_ms), icon: <IconLockOpen size={12} />, color: "yellow" });
}
}
return out.sort((a, b) => a.ts.localeCompare(b.ts));
}, [data]);
if (!data) { if (!data) {
return ( return (
<Group justify="center" p="xl"> <Group justify="center" p="xl">
@@ -28,42 +118,45 @@ export function HistoryModal({ card }: Props) {
); );
} }
const { column_history, lock_periods, total_locked_ms, currently_locked } = data; const { column_history, total_locked_ms, currently_locked } = data;
if (column_history.length === 0 && lock_periods.length === 0) { if (unified.length === 0) {
return <Text c="dimmed">Sin historial.</Text>; return <Text c="dimmed">Sin historial.</Text>;
} }
const userLabel = (id: string | null): string => {
if (!id) return "";
const u = userById.get(id);
if (!u) return id;
return u.display_name || u.username;
};
return ( return (
<Stack gap="md"> <Stack gap="md">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">Linea de tiempo completa de la tarjeta.</Text>
Tiempo total en cada columna desde que se creo la tarjeta. <Timeline active={unified.length} bulletSize={22} lineWidth={2}>
</Text> {unified.map((e) => (
<Timeline active={column_history.length} bulletSize={22} lineWidth={2}>
{column_history.map((e) => (
<Timeline.Item <Timeline.Item
key={e.id} key={e.id}
bullet={<IconColumns3 size={12} />} bullet={e.icon}
color={e.color}
title={ title={
<Group gap={6}> <Group gap={6} wrap="wrap">
<Text fw={500} size="sm"> <Text fw={500} size="sm">{e.kind}</Text>
{e.column_name || e.column_id} {e.actorID && (
</Text> <Badge size="xs" variant="light" color="cyan" leftSection={<IconUser size={10} />}>
<Badge size="xs" variant="light" color={e.exited_at ? "gray" : "blue"}> {userLabel(e.actorID)}
{formatDuration(e.duration_ms)} </Badge>
</Badge> )}
{!e.exited_at && ( {e.detail && (
<Badge size="xs" variant="filled" color="blue"> <Badge size="xs" variant="outline" color={e.color}>
actual {e.detail}
</Badge> </Badge>
)} )}
</Group> </Group>
} }
> >
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">{new Date(e.ts).toLocaleString()}</Text>
{new Date(e.entered_at).toLocaleString()}
{e.exited_at && ` -> ${new Date(e.exited_at).toLocaleString()}`}
</Text>
</Timeline.Item> </Timeline.Item>
))} ))}
</Timeline> </Timeline>
@@ -71,55 +164,15 @@ export function HistoryModal({ card }: Props) {
<Divider /> <Divider />
<Group gap={6} align="center"> <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)" /> <IconLock size={14} color="var(--mantine-color-yellow-6)" />
<Text fw={500} size="sm">
Tiempo bloqueada
</Text>
<Badge size="xs" variant="light" color={total_locked_ms > 0 ? "yellow" : "gray"}> <Badge size="xs" variant="light" color={total_locked_ms > 0 ? "yellow" : "gray"}>
{formatDuration(total_locked_ms)} {formatDuration(total_locked_ms)}
</Badge> </Badge>
{currently_locked && ( {currently_locked && <Badge size="xs" variant="filled" color="yellow">bloqueada</Badge>}
<Badge size="xs" variant="filled" color="yellow">
actualmente bloqueada
</Badge>
)}
</Group> </Group>
{lock_periods.length === 0 ? (
<Text size="xs" c="dimmed">
Nunca ha sido bloqueada.
</Text>
) : (
<Timeline active={lock_periods.length} bulletSize={22} lineWidth={2}>
{lock_periods.map((p) => (
<Timeline.Item
key={p.id}
bullet={<IconLock size={12} />}
title={
<Group gap={6}>
<Badge
size="xs"
variant="light"
color={p.unlocked_at ? "gray" : "yellow"}
>
{formatDuration(p.duration_ms)}
</Badge>
{!p.unlocked_at && (
<Badge size="xs" variant="filled" color="yellow">
en curso
</Badge>
)}
</Group>
}
>
<Text size="xs" c="dimmed">
{new Date(p.locked_at).toLocaleString()}
{p.unlocked_at && ` -> ${new Date(p.unlocked_at).toLocaleString()}`}
</Text>
</Timeline.Item>
))}
</Timeline>
)}
</Stack> </Stack>
); );
} }
+217 -53
View File
@@ -2,6 +2,7 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { import {
ActionIcon, ActionIcon,
Autocomplete,
Avatar, Avatar,
Badge, Badge,
Group, Group,
@@ -14,22 +15,26 @@ import {
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { import {
IconCalendarDue,
IconCheck, IconCheck,
IconClock, IconClock,
IconDotsVertical, IconDotsVertical,
IconEdit, IconEdit,
IconGripVertical, IconGripVertical,
IconHistory, IconHistory,
IconHourglass,
IconLock, IconLock,
IconLockOpen, IconLockOpen,
IconPalette, IconPalette,
IconUserSquare,
IconTrash, IconTrash,
IconUser,
IconUserCircle, IconUserCircle,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { memo, useCallback, useRef, useState } from "react"; import { DatePickerInput } from "@mantine/dates";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import type { Card, CardColor, User } from "../types"; import type { Card, CardColor, User } from "../types";
import { CARD_COLORS, colorBg, colorBorder, colorSwatch } from "./colors"; import { colorBg, colorBorder, tagColor } from "./colors";
import { ColorPickerGrid } from "./ColorPickerGrid";
import { formatDateTimeShort, formatDuration } from "./format"; import { formatDateTimeShort, formatDuration } from "./format";
interface Props { interface Props {
@@ -41,6 +46,10 @@ interface Props {
onShowHistory: (card: Card) => void; onShowHistory: (card: Card) => void;
onToggleLock: (id: string, locked: boolean) => void; onToggleLock: (id: string, locked: boolean) => void;
onAssign: (id: string, assignee_id: string | null) => void; onAssign: (id: string, assignee_id: string | null) => void;
onSetDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
requesterOptions?: string[];
onOpenCustomColor?: (cardId: string, current: string) => void;
activeSticker?: string | null; activeSticker?: string | null;
onAddSticker?: (cardId: string, x: number, y: number) => void; onAddSticker?: (cardId: string, x: number, y: number) => void;
onRemoveSticker?: (cardId: string, index: number) => void; onRemoveSticker?: (cardId: string, index: number) => void;
@@ -50,6 +59,7 @@ interface Props {
assignee?: User; assignee?: User;
inDoneColumn?: boolean; inDoneColumn?: boolean;
isOverlay?: boolean; isOverlay?: boolean;
highlight?: boolean;
} }
function KanbanCardImpl({ function KanbanCardImpl({
@@ -61,6 +71,10 @@ function KanbanCardImpl({
onShowHistory, onShowHistory,
onToggleLock, onToggleLock,
onAssign, onAssign,
onSetDeadline,
onSetRequester,
requesterOptions,
onOpenCustomColor,
activeSticker, activeSticker,
onAddSticker, onAddSticker,
onRemoveSticker, onRemoveSticker,
@@ -70,10 +84,14 @@ function KanbanCardImpl({
assignee, assignee,
inDoneColumn, inDoneColumn,
isOverlay, isOverlay,
highlight,
}: Props) { }: Props) {
const isDone = inDoneColumn || !!card.completed_at; const isDone = inDoneColumn || !!card.completed_at;
const [colorPopOpen, setColorPopOpen] = useState(false); const [colorPopOpen, setColorPopOpen] = useState(false);
const [assigneePopOpen, setAssigneePopOpen] = 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 [menuOpen, setMenuOpen] = useState(false);
const cardElRef = useRef<HTMLElement | null>(null); const cardElRef = useRef<HTMLElement | null>(null);
const draggingStickerRef = useRef<number | null>(null); const draggingStickerRef = useRef<number | null>(null);
@@ -89,6 +107,12 @@ function KanbanCardImpl({
setNodeRef(el); setNodeRef(el);
}, [setNodeRef]); }, [setNodeRef]);
useEffect(() => {
if (highlight && cardElRef.current) {
cardElRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [highlight]);
const onCardClickAddSticker = (e: React.MouseEvent<HTMLDivElement>) => { const onCardClickAddSticker = (e: React.MouseEvent<HTMLDivElement>) => {
if (!stickerMode || !onAddSticker || isOverlay) return; if (!stickerMode || !onAddSticker || isOverlay) return;
if ((e.target as HTMLElement).closest("[data-sticker-overlay]")) return; if ((e.target as HTMLElement).closest("[data-sticker-overlay]")) return;
@@ -140,13 +164,25 @@ function KanbanCardImpl({
transition, transition,
opacity: isDragging ? 0.4 : 1, opacity: isDragging ? 0.4 : 1,
background: colorBg(card.color), background: colorBg(card.color),
borderColor: card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color), borderColor: highlight ? "var(--mantine-color-blue-5)" : card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
borderWidth: card.locked ? 2 : 1, 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, filter: isDone ? "brightness(0.55) saturate(0.7)" : undefined,
}; };
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now; const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
const liveMs = Math.max(0, now - enteredAt); 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 lockedAt = card.locked_at ? new Date(card.locked_at).getTime() : 0;
const lockedMs = card.locked && lockedAt ? Math.max(0, now - lockedAt) : 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 createdAt = card.created_at ? new Date(card.created_at).getTime() : 0;
@@ -190,28 +226,12 @@ function KanbanCardImpl({
Color Color
</Menu.Item> </Menu.Item>
</Popover.Target> </Popover.Target>
<Popover.Dropdown p="xs"> <Popover.Dropdown p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<Group gap={4} maw={200}> <ColorPickerGrid
{CARD_COLORS.map((c) => ( value={card.color}
<Tooltip key={c.value} label={c.label} withArrow> onChange={(c) => onChangeColor(card.id, c as CardColor)}
<ActionIcon onOpenCustom={onOpenCustomColor ? () => onOpenCustomColor(card.id, card.color || "#888888") : undefined}
variant={card.color === c.value ? "filled" : "default"} />
size="md"
radius="xl"
style={{
background: colorSwatch(c.value),
borderColor: colorBorder(c.value),
}}
onClick={() => {
onChangeColor(card.id, c.value);
setColorPopOpen(false);
setMenuOpen(false);
}}
aria-label={c.label}
/>
</Tooltip>
))}
</Group>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
<Popover <Popover
@@ -235,7 +255,7 @@ function KanbanCardImpl({
Asignar a {assignee ? `(${assignee.display_name || assignee.username})` : "..."} Asignar a {assignee ? `(${assignee.display_name || assignee.username})` : "..."}
</Menu.Item> </Menu.Item>
</Popover.Target> </Popover.Target>
<Popover.Dropdown p="xs"> <Popover.Dropdown p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<Select <Select
placeholder="Sin asignar" placeholder="Sin asignar"
value={card.assignee_id ?? null} value={card.assignee_id ?? null}
@@ -252,6 +272,55 @@ function KanbanCardImpl({
/> />
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
<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);
}}
closeMenuOnClick={false}
>
Solicitante {card.requester ? `(${card.requester})` : "..."}
</Menu.Item>
</Popover.Target>
<Popover.Dropdown p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<Autocomplete
placeholder="Sin solicitante"
value={requesterDraft}
onChange={setRequesterDraft}
data={requesterOptions || []}
autoFocus
comboboxProps={{ withinPortal: false }}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSetRequester?.(card.id, requesterDraft.trim());
setRequesterPopOpen(false);
setMenuOpen(false);
} else if (e.key === "Escape") {
setRequesterPopOpen(false);
}
}}
onOptionSubmit={(v) => {
setRequesterDraft(v);
onSetRequester?.(card.id, v);
setRequesterPopOpen(false);
setMenuOpen(false);
}}
/>
</Popover.Dropdown>
</Popover>
<Menu.Item <Menu.Item
leftSection={card.locked ? <IconLockOpen size={14} /> : <IconLock size={14} />} leftSection={card.locked ? <IconLockOpen size={14} /> : <IconLock size={14} />}
color={card.locked ? "yellow" : undefined} color={card.locked ? "yellow" : undefined}
@@ -271,6 +340,63 @@ function KanbanCardImpl({
> >
Historial Historial
</Menu.Item> </Menu.Item>
{onSetDeadline && (
<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);
}}
closeMenuOnClick={false}
>
{card.deadline ? `Deadline (${card.deadline.slice(0, 10)})` : "Deadline..."}
</Menu.Item>
</Popover.Target>
<Popover.Dropdown p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<DatePickerInput
value={card.deadline ? card.deadline.slice(0, 10) : null}
onChange={(v) => {
const s = v ? (typeof v === "string" ? v.slice(0, 10) : new Date(v as unknown as string).toISOString().slice(0, 10)) : null;
onSetDeadline(card.id, s ? `${s}T23:59:59Z` : null);
setDeadlinePopOpen(false);
setMenuOpen(false);
}}
clearable
valueFormat="DD/MM/YYYY"
size="xs"
placeholder="Elegir fecha"
popoverProps={{ withinPortal: false }}
/>
{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);
}}
>
<IconTrash size={12} />
</ActionIcon>
</Tooltip>
)}
</Popover.Dropdown>
</Popover>
)}
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item
leftSection={<IconTrash size={14} />} leftSection={<IconTrash size={14} />}
@@ -346,25 +472,39 @@ function KanbanCardImpl({
<IconDotsVertical size={14} /> <IconDotsVertical size={14} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown>{menuItems}</Menu.Dropdown> <Menu.Dropdown
onDoubleClick={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onContextMenu={(e) => e.stopPropagation()}
>
{menuItems}
</Menu.Dropdown>
</Menu> </Menu>
</Group> </Group>
{card.requester && ( {(card.requester || assignee) && (
<Group gap={4}> <Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<IconUser size={12} /> {card.requester && (
<Text size="xs" c="dimmed"> <>
{card.requester} <Avatar size={18} radius="xs" color={tagColor(card.requester)} style={{ flexShrink: 0 }}>
</Text> {card.requester.slice(0, 2).toUpperCase()}
</Group> </Avatar>
)} <Text size="xs" c="dimmed" truncate>{card.requester}</Text>
{assignee && ( </>
<Group gap={6} wrap="nowrap"> )}
<Avatar size={18} radius="xl" color="blue"> {card.requester && assignee && (
{(assignee.display_name || assignee.username).slice(0, 2).toUpperCase()} <Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>-</Text>
</Avatar> )}
<Text size="xs" c="dimmed"> {assignee && (
{assignee.display_name || assignee.username} <>
</Text> <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>
</>
)}
</Group> </Group>
)} )}
{card.description && ( {card.description && (
@@ -375,18 +515,19 @@ function KanbanCardImpl({
{card.tags && card.tags.length > 0 && ( {card.tags && card.tags.length > 0 && (
<Group gap={4} wrap="wrap"> <Group gap={4} wrap="wrap">
{card.tags.map((t) => ( {card.tags.map((t) => (
<Badge key={t} size="xs" variant="outline" color="violet" radius="sm"> <Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">
{t} {t}
</Badge> </Badge>
))} ))}
</Group> </Group>
)} )}
<Group gap={4} wrap="wrap"> <Group gap={4} wrap="wrap">
{card.locked ? ( {card.locked && (
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}> <Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>
{formatDuration(lockedMs)} {formatDuration(lockedMs)}
</Badge> </Badge>
) : isDone && card.completed_at ? ( )}
{!card.locked && isDone && card.completed_at ? (
<> <>
<Badge size="xs" variant="light" color="teal" leftSection={<IconCheck size={10} />}> <Badge size="xs" variant="light" color="teal" leftSection={<IconCheck size={10} />}>
{formatDateTimeShort(card.completed_at)} {formatDateTimeShort(card.completed_at)}
@@ -394,13 +535,36 @@ function KanbanCardImpl({
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}> <Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
Total: {formatDuration(totalDoneMs)} Total: {formatDuration(totalDoneMs)}
</Badge> </Badge>
{card.total_locked_ms > 0 && (
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>
{formatDuration(card.total_locked_ms)}
</Badge>
)}
</> </>
) : ( ) : !card.locked ? (
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}> card.deadline ? (
{formatDuration(liveMs)} <Tooltip label={`Vence: ${formatDateTimeShort(card.deadline)}`} withArrow>
</Badge> <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>
)
) : null}
</Group> </Group>
{card.seq_num > 0 && (
<Text size="xs" c="dimmed" style={{ marginTop: -2 }}>
#{String(card.seq_num).padStart(5, "0")}
</Text>
)}
</Stack> </Stack>
{card.stickers && card.stickers.length > 0 && ( {card.stickers && card.stickers.length > 0 && (
<div <div
+15
View File
@@ -54,6 +54,10 @@ interface Props {
onShowHistory: (card: Card) => void; onShowHistory: (card: Card) => void;
onToggleCardLock: (id: string, locked: boolean) => void; onToggleCardLock: (id: string, locked: boolean) => void;
onAssignCard: (id: string, assignee_id: string | null) => void; onAssignCard: (id: string, assignee_id: string | null) => void;
onSetCardDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
requesterOptions?: string[];
onOpenCustomCardColor?: (cardId: string, current: string) => void;
activeSticker?: string | null; activeSticker?: string | null;
onAddSticker?: (cardId: string, x: number, y: number) => void; onAddSticker?: (cardId: string, x: number, y: number) => void;
onRemoveSticker?: (cardId: string, index: number) => void; onRemoveSticker?: (cardId: string, index: number) => void;
@@ -61,6 +65,7 @@ interface Props {
onCommitSticker?: (cardId: string) => void; onCommitSticker?: (cardId: string) => void;
users: User[]; users: User[];
usersById: Map<string, User>; usersById: Map<string, User>;
highlightCardId?: string | null;
} }
function KanbanColumnImpl({ function KanbanColumnImpl({
@@ -81,6 +86,10 @@ function KanbanColumnImpl({
onShowHistory, onShowHistory,
onToggleCardLock, onToggleCardLock,
onAssignCard, onAssignCard,
onSetCardDeadline,
onSetRequester,
requesterOptions,
onOpenCustomCardColor,
activeSticker, activeSticker,
onAddSticker, onAddSticker,
onRemoveSticker, onRemoveSticker,
@@ -88,6 +97,7 @@ function KanbanColumnImpl({
onCommitSticker, onCommitSticker,
users, users,
usersById, usersById,
highlightCardId,
}: Props) { }: Props) {
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
const [name, setName] = useState(column.name); const [name, setName] = useState(column.name);
@@ -415,9 +425,14 @@ function KanbanColumnImpl({
onShowHistory={onShowHistory} onShowHistory={onShowHistory}
onToggleLock={onToggleCardLock} onToggleLock={onToggleCardLock}
onAssign={onAssignCard} onAssign={onAssignCard}
onSetDeadline={onSetCardDeadline}
onSetRequester={onSetRequester}
requesterOptions={requesterOptions}
onOpenCustomColor={onOpenCustomCardColor}
users={users} users={users}
assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined} assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined}
inDoneColumn={column.is_done} inDoneColumn={column.is_done}
highlight={highlightCardId === c.id}
activeSticker={activeSticker} activeSticker={activeSticker}
onAddSticker={onAddSticker} onAddSticker={onAddSticker}
onRemoveSticker={onRemoveSticker} onRemoveSticker={onRemoveSticker}
+28 -14
View File
@@ -1,32 +1,46 @@
import type { CardColor } from "../types"; import type { CardColor } from "../types";
import { stringHashPalette } from "@fn_library/core/string_hash_palette";
export { colorBg } from "@fn_library/ui/color_bg";
export { colorBorder } from "@fn_library/ui/color_border";
export { colorSwatch } from "@fn_library/ui/color_swatch";
// 22 colores fijos (default + 21 distintos). El ColorPickerGrid añade un 23º circulo "+"
// que abre el ColorPicker libre para hex personalizado.
export const CARD_COLORS: { value: CardColor; label: string }[] = [ export const CARD_COLORS: { value: CardColor; label: string }[] = [
{ value: "", label: "Default" }, { value: "", label: "Default" },
{ value: "blue", label: "Azul" }, { value: "blue", label: "Azul" },
{ value: "cyan", label: "Cian" },
{ value: "teal", label: "Teal" }, { value: "teal", label: "Teal" },
{ value: "green", label: "Verde" }, { value: "green", label: "Verde" },
{ value: "lime", label: "Lima" },
{ value: "yellow", label: "Amarillo" }, { value: "yellow", label: "Amarillo" },
{ value: "orange", label: "Naranja" }, { value: "orange", label: "Naranja" },
{ value: "red", label: "Rojo" }, { value: "red", label: "Rojo" },
{ value: "pink", label: "Rosa" }, { value: "pink", label: "Rosa" },
{ value: "grape", label: "Uva" },
{ value: "violet", label: "Violeta" }, { value: "violet", label: "Violeta" },
{ value: "indigo", label: "Indigo" }, { value: "indigo", label: "Indigo" },
{ value: "gray", label: "Gris" },
{ value: "#0ea5e9", label: "Sky" },
{ value: "#14b8a6", label: "Esmeralda" },
{ value: "#84cc16", label: "Lima fluor" },
{ value: "#ec4899", label: "Magenta" },
{ value: "#a855f7", label: "Lavanda" },
{ value: "#f97316", label: "Mandarina" },
{ value: "#dc2626", label: "Rubi" },
{ value: "#0891b2", label: "Petroleo" },
{ value: "#fde047", label: "Limon" },
{ value: "#10b981", label: "Menta" },
{ value: "#fb7185", label: "Coral" },
{ value: "#6366f1", label: "Iris" },
{ value: "#94a3b8", label: "Pizarra" },
]; ];
// color-mix mezcla 18% del tono base con dark.6 → suave en dark mode. export const AVATAR_COLORS = CARD_COLORS;
// Border 30% del tono mas claro con dark.4 para definicion sutil.
// Swatch (boton picker) usa tono pleno -7 para que sea visible.
export function colorBg(color: CardColor): string {
if (color === "") return "var(--mantine-color-dark-6)";
return `color-mix(in srgb, var(--mantine-color-${color}-9) 18%, var(--mantine-color-dark-6))`;
}
export function colorBorder(color: CardColor): string { const TAG_PALETTE = ["blue", "cyan", "teal", "green", "lime", "yellow", "orange", "red", "pink", "grape", "violet", "indigo"];
if (color === "") return "var(--mantine-color-dark-4)";
return `color-mix(in srgb, var(--mantine-color-${color}-7) 30%, var(--mantine-color-dark-4))`;
}
export function colorSwatch(color: CardColor): string { export function tagColor(tag: string): string {
if (color === "") return "var(--mantine-color-dark-3)"; return stringHashPalette(tag, TAG_PALETTE);
return `var(--mantine-color-${color}-7)`;
} }
+2 -41
View File
@@ -1,41 +1,2 @@
// Escala unidades segun magnitud: m | h Xm | D Xh | S XD | M XS. export { formatDuration } from "@fn_library/core/format_duration";
// <1 minuto cae como "0m" para mantener la unidad mas pequena coherente. export { formatDateTimeShort } from "@fn_library/core/format_datetime_short";
const MIN = 60_000;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
const MONTH = 30 * DAY;
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return "0m";
if (ms < HOUR) return `${Math.floor(ms / MIN)}m`;
if (ms < DAY) {
const h = Math.floor(ms / HOUR);
const m = Math.floor((ms % HOUR) / MIN);
return m === 0 ? `${h}h` : `${h}h ${m}m`;
}
if (ms < WEEK) {
const d = Math.floor(ms / DAY);
const h = Math.floor((ms % DAY) / HOUR);
return h === 0 ? `${d}D` : `${d}D ${h}h`;
}
if (ms < MONTH) {
const w = Math.floor(ms / WEEK);
const d = Math.floor((ms % WEEK) / DAY);
return d === 0 ? `${w}S` : `${w}S ${d}D`;
}
const m = Math.floor(ms / MONTH);
const w = Math.floor((ms % MONTH) / WEEK);
return w === 0 ? `${m}M` : `${m}M ${w}S`;
}
export function formatDateTimeShort(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yy = String(d.getFullYear()).slice(-2);
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}/${mm}/${yy} ${hh}:${mi}`;
}
+19 -1
View File
@@ -11,7 +11,8 @@ export interface Column {
created_at: string; created_at: string;
} }
export type CardColor = "" | "blue" | "teal" | "green" | "yellow" | "orange" | "red" | "pink" | "violet" | "indigo"; // "" | mantine color name | "#rrggbb"
export type CardColor = string;
export interface Sticker { export interface Sticker {
emoji: string; emoji: string;
@@ -21,6 +22,7 @@ export interface Sticker {
export interface Card { export interface Card {
id: string; id: string;
seq_num: number;
requester: string; requester: string;
title: string; title: string;
description: string; description: string;
@@ -33,17 +35,20 @@ export interface Card {
deleted_at: string | null; deleted_at: string | null;
tags: string[]; tags: string[];
stickers: Sticker[]; stickers: Sticker[];
deadline: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
entered_at: string; entered_at: string;
time_in_column_ms: number; time_in_column_ms: number;
locked_at: string | null; locked_at: string | null;
total_locked_ms: number;
} }
export interface User { export interface User {
id: string; id: string;
username: string; username: string;
display_name: string; display_name: string;
color: string;
created_at: string; created_at: string;
} }
@@ -139,6 +144,7 @@ export interface MetricsFilter {
to?: string; to?: string;
assignee_id?: string; assignee_id?: string;
requester?: string; requester?: string;
tags?: string[];
} }
export interface Board { export interface Board {
@@ -154,6 +160,7 @@ export interface HistoryEntry {
entered_at: string; entered_at: string;
exited_at: string | null; exited_at: string | null;
duration_ms: number; duration_ms: number;
actor_id: string | null;
} }
export interface LockPeriod { export interface LockPeriod {
@@ -162,11 +169,22 @@ export interface LockPeriod {
locked_at: string; locked_at: string;
unlocked_at: string | null; unlocked_at: string | null;
duration_ms: number; duration_ms: number;
actor_id: string | null;
}
export interface CardEvent {
id: string;
card_id: string;
kind: string;
actor_id: string | null;
payload: string;
created_at: string;
} }
export interface CardHistoryResponse { export interface CardHistoryResponse {
column_history: HistoryEntry[]; column_history: HistoryEntry[];
lock_periods: LockPeriod[]; lock_periods: LockPeriod[];
events: CardEvent[];
total_locked_ms: number; total_locked_ms: number;
currently_locked: boolean; currently_locked: boolean;
} }
+2 -2
View File
@@ -17,8 +17,8 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@fn_library": ["../../../frontend/functions/ui"], "@fn_library": ["../../../frontend/functions"],
"@fn_library/*": ["../../../frontend/functions/ui/*"] "@fn_library/*": ["../../../frontend/functions/*"]
} }
}, },
"include": ["src"] "include": ["src"]
+3 -2
View File
@@ -6,7 +6,7 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
"@fn_library": path.resolve(__dirname, "../../../frontend/functions/ui"), "@fn_library": path.resolve(__dirname, "../../../frontend/functions"),
}, },
}, },
server: { server: {
@@ -16,6 +16,7 @@ export default defineConfig({
}, },
}, },
build: { build: {
outDir: "dist", outDir: "../backend/dist",
emptyOutDir: true,
}, },
}); });
+9 -9
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Lanza backend Go (puerto 8095) + frontend Vite dev (puerto 5180) en paralelo. # Lanza backend Go (puerto 8095) + frontend Vite dev (puerto 5180) en paralelo.
# Vite hace proxy /api -> 8095, asi que abrir http://localhost:5180 # Vite hace proxy /api -> 8095, asi que abrir http://localhost:5180
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR" BACK_DIR="$ROOT/backend"
FRONT_DIR="$ROOT/frontend"
PORT_BACK="${PORT_BACK:-8095}" PORT_BACK="${PORT_BACK:-8095}"
PORT_FRONT="${PORT_FRONT:-5180}" PORT_FRONT="${PORT_FRONT:-5180}"
@@ -22,25 +22,25 @@ cleanup() {
trap cleanup INT TERM EXIT trap cleanup INT TERM EXIT
# 1. Build backend si no existe o si los .go/.sql son mas nuevos que el binario # 1. Build backend si no existe o si los .go/.sql son mas nuevos que el binario
if [[ ! -x ./kanban ]] || [[ -n "$(find . -maxdepth 3 \( -name '*.go' -o -name '*.sql' \) -newer ./kanban 2>/dev/null)" ]]; then if [[ ! -x "$BACK_DIR/kanban" ]] || [[ -n "$(find "$BACK_DIR" -maxdepth 3 \( -name '*.go' -o -name '*.sql' \) -newer "$BACK_DIR/kanban" 2>/dev/null)" ]]; then
echo ">>> Building backend..." echo ">>> Building backend..."
CGO_ENABLED=1 go build -tags fts5 -o kanban . (cd "$BACK_DIR" && CGO_ENABLED=1 go build -tags fts5 -o kanban .)
fi fi
# 2. Asegurar deps frontend # 2. Asegurar deps frontend
if [[ ! -d frontend/node_modules ]]; then if [[ ! -d "$FRONT_DIR/node_modules" ]]; then
echo ">>> Installing frontend deps..." echo ">>> Installing frontend deps..."
(cd frontend && pnpm install) (cd "$FRONT_DIR" && pnpm install)
fi fi
# 3. Lanzar backend # 3. Lanzar backend
echo ">>> Backend http://localhost:$PORT_BACK (db=$DB_PATH)" echo ">>> Backend http://localhost:$PORT_BACK (db=$DB_PATH)"
./kanban --port "$PORT_BACK" --db "$DB_PATH" & (cd "$BACK_DIR" && ./kanban --port "$PORT_BACK" --db "$DB_PATH") &
BACK_PID=$! BACK_PID=$!
# 4. Lanzar frontend (Vite con HMR + proxy a backend) # 4. Lanzar frontend (Vite con HMR + proxy a backend)
echo ">>> Frontend http://localhost:$PORT_FRONT (HMR)" echo ">>> Frontend http://localhost:$PORT_FRONT (HMR)"
(cd frontend && pnpm dev --port "$PORT_FRONT" --strictPort) & (cd "$FRONT_DIR" && pnpm dev --port "$PORT_FRONT" --strictPort) &
FRONT_PID=$! FRONT_PID=$!
echo "" echo ""