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/
# 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]
uses_functions:
- random_hex_id_go_core
- parse_date_or_default_go_core
- sqlite_open_go_infra
- sqlite_apply_migrations_go_infra
- sqlite_column_exists_go_infra
- spa_handler_go_infra
- http_router_go_infra
- http_serve_go_infra
@@ -17,13 +20,26 @@ uses_functions:
- http_error_response_go_infra
- http_parse_body_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_verify_go_infra
- session_create_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"
entry_point: "main.go"
entry_point: "backend/main.go"
dir_path: "apps/kanban"
---
+33 -24
View File
@@ -18,36 +18,15 @@ type ctxKey string
const userCtxKey ctxKey = "kanban_user_id"
func setSessionCookie(w http.ResponseWriter, token string, expiresAt int64) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(expiresAt, 0),
})
infra.SessionCookieSet(w, cookieName, token, expiresAt)
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
infra.SessionCookieClear(w, cookieName)
}
func tokenFromRequest(r *http.Request) string {
if c, err := r.Cookie(cookieName); err == nil && c.Value != "" {
return c.Value
}
auth := r.Header.Get("Authorization")
if len(auth) > 7 && auth[:7] == "Bearer " {
return auth[7:]
}
return ""
return infra.SessionTokenExtract(r, cookieName)
}
// 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
func handleListUsers(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
View File
View File
+207 -104
View File
@@ -5,7 +5,6 @@ import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"sort"
"strings"
"time"
@@ -36,6 +35,7 @@ type Sticker struct {
type Card struct {
ID string `json:"id"`
SeqNum int `json:"seq_num"`
Requester string `json:"requester"`
Title string `json:"title"`
Description string `json:"description"`
@@ -48,11 +48,13 @@ type Card struct {
DeletedAt *string `json:"deleted_at"`
Tags []string `json:"tags"`
Stickers []Sticker `json:"stickers"`
Deadline *string `json:"deadline"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
EnteredAt string `json:"entered_at"`
TimeInColumn int64 `json:"time_in_column_ms"`
LockedAt *string `json:"locked_at"`
EnteredAt string `json:"entered_at"`
TimeInColumn int64 `json:"time_in_column_ms"`
LockedAt *string `json:"locked_at"`
TotalLockedMs int64 `json:"total_locked_ms"`
}
type HistoryEntry struct {
@@ -63,6 +65,7 @@ type HistoryEntry struct {
EnteredAt string `json:"entered_at"`
ExitedAt *string `json:"exited_at"`
DurationMs int64 `json:"duration_ms"`
ActorID *string `json:"actor_id"`
}
type LockPeriod struct {
@@ -71,15 +74,26 @@ type LockPeriod struct {
LockedAt string `json:"locked_at"`
UnlockedAt *string `json:"unlocked_at"`
DurationMs int64 `json:"duration_ms"`
ActorID *string `json:"actor_id"`
}
type CardHistoryResponse struct {
ColumnHistory []HistoryEntry `json:"column_history"`
LockPeriods []LockPeriod `json:"lock_periods"`
Events []CardEvent `json:"events"`
TotalLockedMs int64 `json:"total_locked_ms"`
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 }
func openDB(path string) (*DB, error) {
@@ -87,7 +101,7 @@ func openDB(path string) (*DB, error) {
if err != nil {
return nil, err
}
if err := applyMigrations(conn); err != nil {
if err := infra.ApplyMigrations(conn, migrationsFS, "migrations/*.sql"); err != nil {
conn.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
@@ -100,60 +114,6 @@ func openDB(path string) (*DB, error) {
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.
// 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.
@@ -171,11 +131,12 @@ func ensureColumns(conn *sql.DB) error {
{"cards", "deleted_at", "TEXT"},
{"cards", "tags", "TEXT NOT NULL DEFAULT '[]'"},
{"cards", "stickers", "TEXT NOT NULL DEFAULT '[]'"},
{"cards", "deadline", "TEXT"},
{"card_column_history", "actor_id", "TEXT"},
{"card_lock_history", "actor_id", "TEXT"},
}
for _, s := range specs {
exists, err := columnExists(conn, s.table, s.name)
exists, err := infra.ColumnExists(conn, s.table, s.name)
if err != nil {
return err
}
@@ -192,28 +153,6 @@ func ensureColumns(conn *sql.DB) error {
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 newID() string {
@@ -351,6 +290,18 @@ func nullableActor(actorID string) any {
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 ---
func (db *DB) ListColumns() ([]Column, error) {
@@ -486,8 +437,12 @@ func (db *DB) ReorderColumns(ids []string) error {
func (db *DB) ListCardsWithTime() ([]Card, error) {
rows, err := db.conn.Query(`
SELECT c.id, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.stickers, c.created_at, c.updated_at,
h.entered_at, l.locked_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,
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
LEFT JOIN card_column_history h
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
WHERE c.deleted_at IS NULL
ORDER BY c.column_id, c.position, c.created_at
`)
`, time.Now().UTC().Format(time.RFC3339Nano))
if err != nil {
return nil, err
}
@@ -510,12 +465,17 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
var deleted sql.NullString
var tagsJSON string
var stickersJSON string
var deadline sql.NullString
var lockedAt sql.NullString
var locked int
if err := rows.Scan(&c.ID, &c.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
}
c.Stickers = parseStickers(stickersJSON)
if deadline.Valid && deadline.String != "" {
s := deadline.String
c.Deadline = &s
}
if lockedAt.Valid && lockedAt.String != "" {
s := lockedAt.String
c.LockedAt = &s
@@ -555,20 +515,28 @@ func (db *DB) CreateCard(columnID, requester, title, description, actorID string
pos = int(maxPos.Int64) + 1
}
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()
if err != nil {
return nil, err
}
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(
`INSERT INTO cards (id, requester, title, description, color, column_id, position, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position, encodeTags(c.Tags), c.CreatedAt, c.UpdatedAt,
`INSERT INTO cards (id, seq_num, requester, title, description, color, column_id, position, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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 {
return nil, err
}
@@ -589,6 +557,9 @@ func (db *DB) CreateCard(columnID, requester, title, description, actorID string
}
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 {
return nil, err
}
@@ -604,6 +575,8 @@ type CardPatch struct {
AssigneeID *string // empty string clears assignment
HasAssignee bool // distinguishes "set to null" from "not provided"
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 {
@@ -617,40 +590,102 @@ func (db *DB) UpdateCardWithActor(id string, patch CardPatch, actorID string) er
}
defer tx.Rollback()
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 {
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 {
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 {
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 {
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 {
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 _, err := tx.Exec(`UPDATE cards SET color=?, updated_at=? WHERE id=?`, *patch.Color, nowRFC3339(), id); err != nil {
return err
}
if err := insertCardEvent(tx, id, "color_changed", actorID, map[string]any{"color": *patch.Color}); err != nil {
return err
}
}
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 _, err := tx.Exec(`UPDATE cards SET assignee_id=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
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 {
if _, err := tx.Exec(`UPDATE cards SET assignee_id=?, updated_at=? WHERE id=?`, *patch.AssigneeID, nowRFC3339(), id); err != nil {
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 _, err := tx.Exec(`UPDATE cards SET tags=?, updated_at=? WHERE id=?`, encodeTags(*patch.Tags), nowRFC3339(), id); err != nil {
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 {
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).
func (db *DB) DeleteCard(id string) error {
_, err := db.conn.Exec(`UPDATE cards SET deleted_at=?, updated_at=? WHERE id=?`, nowRFC3339(), nowRFC3339(), id)
return err
return db.DeleteCardWithActor(id, "")
}
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.
func (db *DB) RestoreCard(id string) error {
_, err := db.conn.Exec(`UPDATE cards SET deleted_at=NULL, updated_at=? WHERE id=?`, nowRFC3339(), id)
return err
return db.RestoreCardWithActor(id, "")
}
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.
@@ -707,7 +770,7 @@ func (db *DB) PurgeCard(id string) error {
// ListDeletedCards returns cards in the trash, newest first.
func (db *DB) ListDeletedCards() ([]Card, error) {
rows, err := db.conn.Query(`
SELECT c.id, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.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
WHERE c.deleted_at IS NOT NULL
ORDER BY c.deleted_at DESC
@@ -724,11 +787,16 @@ func (db *DB) ListDeletedCards() ([]Card, error) {
var deleted sql.NullString
var tagsJSON string
var stickersJSON string
var deadline sql.NullString
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
}
c.Stickers = parseStickers(stickersJSON)
if deadline.Valid && deadline.String != "" {
s := deadline.String
c.Deadline = &s
}
c.Locked = locked != 0
if assignee.Valid && assignee.String != "" {
s := assignee.String
@@ -846,7 +914,7 @@ func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string, actorID
func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
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
LEFT JOIN columns c ON c.id = h.column_id
WHERE h.card_id=?
@@ -861,9 +929,14 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
for rows.Next() {
var h HistoryEntry
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
}
if actor.Valid && actor.String != "" {
s := actor.String
h.ActorID = &s
}
entered, err := time.Parse(time.RFC3339Nano, h.EnteredAt)
if err != nil {
return nil, err
@@ -883,7 +956,7 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
}
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
WHERE card_id=?
ORDER BY locked_at
@@ -898,9 +971,14 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
for lockRows.Next() {
var lp LockPeriod
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
}
if actor.Valid && actor.String != "" {
s := actor.String
lp.ActorID = &s
}
start, err := time.Parse(time.RFC3339Nano, lp.LockedAt)
if err != nil {
return nil, err
@@ -921,9 +999,34 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
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{
ColumnHistory: cols,
LockPeriods: locks,
Events: events,
TotalLockedMs: totalLocked,
CurrentlyLock: currently,
}, 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
)
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
}
}
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 {
tags := []string{}
if arr, ok := v.([]any); ok {
@@ -233,7 +242,8 @@ func handleUpdateCardStickers(db *DB) http.HandlerFunc {
func handleDeleteCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
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)
return
}
@@ -299,7 +309,8 @@ func handleListTrash(db *DB) http.HandlerFunc {
func handleRestoreCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
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)
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/logout", Handler: handleLogout(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/board", Handler: handleGetBoard(db)},
{Method: "POST", Path: "/api/columns", Handler: handleCreateColumn(db)},
+2 -2
View File
@@ -18,7 +18,7 @@ import (
"fn-registry/functions/infra"
)
//go:embed all:frontend/dist
//go:embed all:dist
var frontendDist embed.FS
func main() {
@@ -132,7 +132,7 @@ func startSessionCleanup(db *DB) {
}
func frontendHandler() http.Handler {
sub, err := fs.Sub(frontendDist, "frontend/dist")
sub, err := fs.Sub(frontendDist, "dist")
if err != nil {
return nil
}
+23 -60
View File
@@ -3,11 +3,16 @@ package main
import (
"net/http"
"sort"
"strings"
"time"
"fn-registry/functions/core"
"fn-registry/functions/datascience"
"fn-registry/functions/infra"
)
type DurationStats = datascience.DurationStats
type Metrics struct {
Range DateRange `json:"range"`
Totals Totals `json:"totals"`
@@ -58,14 +63,6 @@ type DailyCount struct {
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 {
ColumnID string `json:"column_id"`
Name string `json:"name"`
@@ -95,63 +92,16 @@ type MovementStat struct {
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 {
n := len(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),
}
return datascience.DurationStatsFrom(durations)
}
func parseDateOrDefault(s string, dflt time.Time) time.Time {
if s == "" {
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
return core.ParseDateOrDefault(s, dflt, false)
}
func parseEndDateOrDefault(s string, dflt time.Time) time.Time {
if s == "" {
return dflt
}
if t, err := time.Parse("2006-01-02", s); err == nil {
return t.Add(24*time.Hour - time.Nanosecond)
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t
}
return dflt
return core.ParseDateOrDefault(s, dflt, true)
}
// 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)
assignee := r.URL.Query().Get("assignee_id")
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 {
serverError(w, err)
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)
toStr := to.Format(time.RFC3339Nano)
@@ -198,6 +157,10 @@ func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Me
cardWhere += " AND 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 {
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"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Color string `json:"color"`
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) {
var u User
err := db.conn.QueryRow(
`SELECT id, username, display_name, created_at FROM users WHERE id=?`, id,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.CreatedAt)
`SELECT id, username, display_name, color, created_at FROM users WHERE id=?`, id,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, errUserNotFound
}
@@ -67,8 +68,8 @@ func (db *DB) GetUserByUsername(username string) (*User, string, error) {
var u User
var hash string
err := db.conn.QueryRow(
`SELECT id, username, display_name, created_at, password_hash FROM users WHERE username=?`, username,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.CreatedAt, &hash)
`SELECT id, username, display_name, color, created_at, password_hash FROM users WHERE username=?`, username,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt, &hash)
if errors.Is(err, sql.ErrNoRows) {
return nil, "", errUserNotFound
}
@@ -79,7 +80,7 @@ func (db *DB) GetUserByUsername(username string) (*User, string, 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 {
return nil, err
}
@@ -87,7 +88,7 @@ func (db *DB) ListUsers() ([]User, error) {
out := []User{}
for rows.Next() {
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
}
out = append(out, u)
@@ -117,6 +118,11 @@ func (db *DB) CountUsers() (int, error) {
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 {
_, err := db.conn.Exec(`DELETE FROM sessions WHERE token=?`, token)
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 { KanbanColumn } from "./components/KanbanColumn";
import { StickerPicker } from "./components/StickerPicker";
import { ColorPickerGrid, CustomColorModal } from "./components/ColorPickerGrid";
import { AVATAR_COLORS } from "./components/colors";
import { colorBg, colorBorder } from "./components/colors";
import type { Board, Card, CardColor, Column, ColumnLocation, User } from "./types";
@@ -124,8 +126,13 @@ export function App() {
const [filterUnassigned, setFilterUnassigned] = useState(false);
const [filterDateFrom, setFilterDateFrom] = 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 [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 [navWidth, setNavWidth] = useState<number>(() => {
const stored = localStorage.getItem("kanban_nav_width");
@@ -279,6 +286,7 @@ export function App() {
const cardTags = new Set(c.tags || []);
for (const t of filterTags) if (!cardTags.has(t)) return false;
}
if (filterDeadlineOnly && !c.deadline) return false;
if (filterDateFrom || filterDateTo) {
const fromMs = filterDateFrom ? new Date(filterDateFrom).setHours(0, 0, 0, 0) : -Infinity;
const toMs = filterDateTo ? new Date(filterDateTo).setHours(23, 59, 59, 999) : Infinity;
@@ -289,7 +297,7 @@ export function App() {
}
return true;
},
[searchTerm, filterAssigneeId, filterUnassigned, filterRequester, filterTags, filterDateFrom, filterDateTo]
[searchTerm, filterAssigneeId, filterUnassigned, filterRequester, filterTags, filterDateFrom, filterDateTo, filterDeadlineOnly]
);
const cardsByColumn = useMemo(() => {
@@ -311,7 +319,8 @@ export function App() {
!!filterRequester ||
filterTags.length > 0 ||
!!filterDateFrom ||
!!filterDateTo;
!!filterDateTo ||
filterDeadlineOnly;
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);
@@ -594,6 +603,38 @@ export function App() {
});
}, [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) => {
setBoard((prev) => {
if (!prev) return prev;
@@ -858,16 +899,36 @@ export function App() {
<IconMessageChatbot size={16} />
</ActionIcon>
{auth.user && (
<Menu position="bottom-end" shadow="md" withArrow>
<Menu position="bottom-end" shadow="md" withArrow closeOnItemClick={false}>
<Menu.Target>
<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()}
</Avatar>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<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
leftSection={<IconLogout size={14} />}
color="red"
@@ -929,6 +990,11 @@ export function App() {
onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
onAssignCard={handleAssignCard}
onSetCardDeadline={handleSetCardDeadline}
highlightCardId={highlightCardId}
onSetRequester={handleSetRequester}
requesterOptions={requesterOptions}
onOpenCustomCardColor={(cardId, current) => setCardColorModal({ cardId, color: current })}
activeSticker={activeSticker}
onAddSticker={handleAddSticker}
onRemoveSticker={handleRemoveSticker}
@@ -1004,7 +1070,7 @@ export function App() {
</Box>
) : activeTab === "calendar" ? (
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
<CalendarView users={users} />
<CalendarView users={users} cards={board.cards} onJumpToCard={handleJumpToCard} />
</Box>
) : (
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
@@ -1045,6 +1111,12 @@ export function App() {
if (v) setFilterAssigneeId(null);
}}
/>
<Checkbox
size="xs"
label="Con deadline"
checked={filterDeadlineOnly}
onChange={(e) => setFilterDeadlineOnly(e.currentTarget.checked)}
/>
<Select
placeholder="Solicitante"
value={filterRequester}
@@ -1151,6 +1223,7 @@ export function App() {
setFilterTags([]);
setFilterDateFrom(null);
setFilterDateTo(null);
setFilterDeadlineOnly(false);
}}
>
Limpiar
@@ -1184,6 +1257,10 @@ export function App() {
onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
onAssignCard={handleAssignCard}
onSetCardDeadline={handleSetCardDeadline}
highlightCardId={highlightCardId}
onSetRequester={handleSetRequester}
requesterOptions={requesterOptions}
activeSticker={activeSticker}
onAddSticker={handleAddSticker}
onRemoveSticker={handleRemoveSticker}
@@ -1271,6 +1348,32 @@ export function App() {
</Box>
) : null}
</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>
);
}
+11 -18
View File
@@ -8,27 +8,14 @@ import type {
Sticker,
User,
} from "./types";
import { fetchJSON as registryFetchJSON, HTTPError } from "@fn_library/infra/fetch_json";
export { HTTPError };
const BASE = "/api";
export class HTTPError extends Error {
constructor(public status: number, message: string) {
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();
function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
return registryFetchJSON<T>(path, init, BASE);
}
export function getBoard(): Promise<Board> {
@@ -84,6 +71,7 @@ export interface UpdateCardInput {
locked?: boolean;
assignee_id?: string | null;
tags?: string[];
deadline?: string | null;
}
export function updateCard(id: string, patch: UpdateCardInput): Promise<void> {
@@ -168,6 +156,10 @@ export function getMe(): Promise<User> {
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[]> {
return fetchJSON("/users");
}
@@ -186,6 +178,7 @@ export function getMetrics(f: MetricsFilter): Promise<Metrics> {
if (f.to) qs.set("to", f.to);
if (f.assignee_id) qs.set("assignee_id", f.assignee_id);
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();
return fetchJSON(`/metrics${q ? `?${q}` : ""}`);
}
+2 -1
View File
@@ -9,6 +9,7 @@ interface AuthCtx {
login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string, displayName: string) => Promise<void>;
logout: () => Promise<void>;
setUser: (u: User | null) => void;
}
const Ctx = createContext<AuthCtx | null>(null);
@@ -45,7 +46,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
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 {
+79 -8
View File
@@ -4,26 +4,31 @@ import {
Group,
Loader,
Paper,
Popover,
Select,
SimpleGrid,
Stack,
Text,
Title,
UnstyledButton,
} from "@mantine/core";
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 { useEffect, useMemo, useState } from "react";
import * as api from "../api";
import type { Metrics, User } from "../types";
import type { Card, Metrics, User } from "../types";
interface Props {
users: User[];
cards: Card[];
onJumpToCard?: (cardId: string) => void;
}
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 [assigneeId, setAssigneeId] = useState<string | null>(null);
const [data, setData] = useState<Metrics | null>(null);
@@ -53,20 +58,27 @@ export function CalendarView({ users }: Props) {
);
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;
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;
m.set(d.date, cur);
}
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;
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;
}, [data]);
}, [data, cards]);
// Build month grid (Mon-first).
const grid = useMemo(() => {
@@ -163,9 +175,12 @@ export function CalendarView({ users }: Props) {
if (!cell.date) {
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 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 (
<Paper
key={i}
@@ -203,6 +218,62 @@ export function CalendarView({ users }: Props) {
</Text>
</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>
</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,
Group,
Loader,
MultiSelect,
Paper,
Select,
SimpleGrid,
@@ -89,10 +90,16 @@ export function Dashboard({ users }: Props) {
const [to, setTo] = useState<Date | null>(() => new Date());
const [assigneeId, setAssigneeId] = 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 [loading, setLoading] = useState(false);
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
useEffect(() => {
api.listTags().then(setTagOptions).catch(() => {});
}, []);
useEffect(() => {
let cancelled = false;
setLoading(true);
@@ -102,6 +109,7 @@ export function Dashboard({ users }: Props) {
to: fmtDate(to),
assignee_id: assigneeId || undefined,
requester: requester || undefined,
tags: tags.length > 0 ? tags : undefined,
})
.then((m) => {
if (cancelled) return;
@@ -119,7 +127,7 @@ export function Dashboard({ users }: Props) {
return () => {
cancelled = true;
};
}, [from, to, assigneeId, requester]);
}, [from, to, assigneeId, requester, tags]);
const userOptions = useMemo(
() => users.map((u) => ({ value: u.id, label: u.display_name || u.username })),
@@ -240,6 +248,17 @@ export function Dashboard({ users }: Props) {
searchable
style={{ minWidth: 160 }}
/>
<MultiSelect
label="Tags"
size="xs"
placeholder="Todas"
value={tags}
onChange={setTags}
data={tagOptions}
clearable
searchable
style={{ minWidth: 200 }}
/>
</Group>
</Group>
+124 -71
View File
@@ -1,25 +1,115 @@
import { Badge, Divider, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
import { IconColumns3, IconLock } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { cardHistory } from "../api";
import type { Card, CardHistoryResponse } from "../types";
import {
IconArrowsHorizontal,
IconCalendarDue,
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";
interface Props {
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) {
const [data, setData] = useState<CardHistoryResponse | null>(null);
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
cardHistory(card.id)
.then(setData)
.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]);
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) {
return (
<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>;
}
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 (
<Stack gap="md">
<Text size="sm" c="dimmed">
Tiempo total en cada columna desde que se creo la tarjeta.
</Text>
<Timeline active={column_history.length} bulletSize={22} lineWidth={2}>
{column_history.map((e) => (
<Text size="sm" c="dimmed">Linea de tiempo completa de la tarjeta.</Text>
<Timeline active={unified.length} bulletSize={22} lineWidth={2}>
{unified.map((e) => (
<Timeline.Item
key={e.id}
bullet={<IconColumns3 size={12} />}
bullet={e.icon}
color={e.color}
title={
<Group gap={6}>
<Text fw={500} size="sm">
{e.column_name || e.column_id}
</Text>
<Badge size="xs" variant="light" color={e.exited_at ? "gray" : "blue"}>
{formatDuration(e.duration_ms)}
</Badge>
{!e.exited_at && (
<Badge size="xs" variant="filled" color="blue">
actual
<Group gap={6} wrap="wrap">
<Text fw={500} size="sm">{e.kind}</Text>
{e.actorID && (
<Badge size="xs" variant="light" color="cyan" leftSection={<IconUser size={10} />}>
{userLabel(e.actorID)}
</Badge>
)}
{e.detail && (
<Badge size="xs" variant="outline" color={e.color}>
{e.detail}
</Badge>
)}
</Group>
}
>
<Text size="xs" c="dimmed">
{new Date(e.entered_at).toLocaleString()}
{e.exited_at && ` -> ${new Date(e.exited_at).toLocaleString()}`}
</Text>
<Text size="xs" c="dimmed">{new Date(e.ts).toLocaleString()}</Text>
</Timeline.Item>
))}
</Timeline>
@@ -71,55 +164,15 @@ export function HistoryModal({ card }: Props) {
<Divider />
<Group gap={6} align="center">
<IconColumns3 size={14} />
<Text fw={500} size="sm">Columnas visitadas</Text>
<Badge size="xs" variant="light" color="gray">{column_history.length}</Badge>
<IconLock size={14} color="var(--mantine-color-yellow-6)" />
<Text fw={500} size="sm">
Tiempo bloqueada
</Text>
<Badge size="xs" variant="light" color={total_locked_ms > 0 ? "yellow" : "gray"}>
{formatDuration(total_locked_ms)}
</Badge>
{currently_locked && (
<Badge size="xs" variant="filled" color="yellow">
actualmente bloqueada
</Badge>
)}
{currently_locked && <Badge size="xs" variant="filled" color="yellow">bloqueada</Badge>}
</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>
);
}
+217 -53
View File
@@ -2,6 +2,7 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ActionIcon,
Autocomplete,
Avatar,
Badge,
Group,
@@ -14,22 +15,26 @@ import {
Tooltip,
} from "@mantine/core";
import {
IconCalendarDue,
IconCheck,
IconClock,
IconDotsVertical,
IconEdit,
IconGripVertical,
IconHistory,
IconHourglass,
IconLock,
IconLockOpen,
IconPalette,
IconUserSquare,
IconTrash,
IconUser,
IconUserCircle,
} 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 { CARD_COLORS, colorBg, colorBorder, colorSwatch } from "./colors";
import { colorBg, colorBorder, tagColor } from "./colors";
import { ColorPickerGrid } from "./ColorPickerGrid";
import { formatDateTimeShort, formatDuration } from "./format";
interface Props {
@@ -41,6 +46,10 @@ interface Props {
onShowHistory: (card: Card) => void;
onToggleLock: (id: string, locked: boolean) => void;
onAssign: (id: string, assignee_id: string | null) => void;
onSetDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
requesterOptions?: string[];
onOpenCustomColor?: (cardId: string, current: string) => void;
activeSticker?: string | null;
onAddSticker?: (cardId: string, x: number, y: number) => void;
onRemoveSticker?: (cardId: string, index: number) => void;
@@ -50,6 +59,7 @@ interface Props {
assignee?: User;
inDoneColumn?: boolean;
isOverlay?: boolean;
highlight?: boolean;
}
function KanbanCardImpl({
@@ -61,6 +71,10 @@ function KanbanCardImpl({
onShowHistory,
onToggleLock,
onAssign,
onSetDeadline,
onSetRequester,
requesterOptions,
onOpenCustomColor,
activeSticker,
onAddSticker,
onRemoveSticker,
@@ -70,10 +84,14 @@ function KanbanCardImpl({
assignee,
inDoneColumn,
isOverlay,
highlight,
}: Props) {
const isDone = inDoneColumn || !!card.completed_at;
const [colorPopOpen, setColorPopOpen] = useState(false);
const [assigneePopOpen, setAssigneePopOpen] = useState(false);
const [requesterPopOpen, setRequesterPopOpen] = useState(false);
const [deadlinePopOpen, setDeadlinePopOpen] = useState(false);
const [requesterDraft, setRequesterDraft] = useState(card.requester || "");
const [menuOpen, setMenuOpen] = useState(false);
const cardElRef = useRef<HTMLElement | null>(null);
const draggingStickerRef = useRef<number | null>(null);
@@ -89,6 +107,12 @@ function KanbanCardImpl({
setNodeRef(el);
}, [setNodeRef]);
useEffect(() => {
if (highlight && cardElRef.current) {
cardElRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [highlight]);
const onCardClickAddSticker = (e: React.MouseEvent<HTMLDivElement>) => {
if (!stickerMode || !onAddSticker || isOverlay) return;
if ((e.target as HTMLElement).closest("[data-sticker-overlay]")) return;
@@ -140,13 +164,25 @@ function KanbanCardImpl({
transition,
opacity: isDragging ? 0.4 : 1,
background: colorBg(card.color),
borderColor: card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
borderWidth: card.locked ? 2 : 1,
borderColor: highlight ? "var(--mantine-color-blue-5)" : card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
borderWidth: highlight || card.locked ? 2 : 1,
boxShadow: highlight ? "0 0 0 3px var(--mantine-color-blue-4)" : undefined,
filter: isDone ? "brightness(0.55) saturate(0.7)" : undefined,
};
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
const liveMs = Math.max(0, now - enteredAt);
const deadlineAt = card.deadline ? new Date(card.deadline).getTime() : 0;
const deadlineRemainingMs = deadlineAt ? deadlineAt - now : 0;
const overdue = deadlineAt ? deadlineRemainingMs < 0 : false;
const createdAtMs = card.created_at ? new Date(card.created_at).getTime() : 0;
const deadlineTotalMs = deadlineAt && createdAtMs ? deadlineAt - createdAtMs : 0;
const deadlineRatio = deadlineTotalMs > 0 ? deadlineRemainingMs / deadlineTotalMs : 0;
let dlColor: string = "blue";
let dlVariant: "light" | "filled" = "light";
if (overdue) { dlColor = "red.9"; dlVariant = "filled"; }
else if (deadlineRatio < 0.1) { dlColor = "red"; dlVariant = "filled"; }
else if (deadlineRatio < 0.5) { dlColor = "yellow"; dlVariant = "light"; }
const lockedAt = card.locked_at ? new Date(card.locked_at).getTime() : 0;
const lockedMs = card.locked && lockedAt ? Math.max(0, now - lockedAt) : 0;
const createdAt = card.created_at ? new Date(card.created_at).getTime() : 0;
@@ -190,28 +226,12 @@ function KanbanCardImpl({
Color
</Menu.Item>
</Popover.Target>
<Popover.Dropdown p="xs">
<Group gap={4} maw={200}>
{CARD_COLORS.map((c) => (
<Tooltip key={c.value} label={c.label} withArrow>
<ActionIcon
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 p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<ColorPickerGrid
value={card.color}
onChange={(c) => onChangeColor(card.id, c as CardColor)}
onOpenCustom={onOpenCustomColor ? () => onOpenCustomColor(card.id, card.color || "#888888") : undefined}
/>
</Popover.Dropdown>
</Popover>
<Popover
@@ -235,7 +255,7 @@ function KanbanCardImpl({
Asignar a {assignee ? `(${assignee.display_name || assignee.username})` : "..."}
</Menu.Item>
</Popover.Target>
<Popover.Dropdown p="xs">
<Popover.Dropdown p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<Select
placeholder="Sin asignar"
value={card.assignee_id ?? null}
@@ -252,6 +272,55 @@ function KanbanCardImpl({
/>
</Popover.Dropdown>
</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
leftSection={card.locked ? <IconLockOpen size={14} /> : <IconLock size={14} />}
color={card.locked ? "yellow" : undefined}
@@ -271,6 +340,63 @@ function KanbanCardImpl({
>
Historial
</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.Item
leftSection={<IconTrash size={14} />}
@@ -346,25 +472,39 @@ function KanbanCardImpl({
<IconDotsVertical size={14} />
</ActionIcon>
</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>
</Group>
{card.requester && (
<Group gap={4}>
<IconUser size={12} />
<Text size="xs" c="dimmed">
{card.requester}
</Text>
</Group>
)}
{assignee && (
<Group gap={6} wrap="nowrap">
<Avatar size={18} radius="xl" color="blue">
{(assignee.display_name || assignee.username).slice(0, 2).toUpperCase()}
</Avatar>
<Text size="xs" c="dimmed">
{assignee.display_name || assignee.username}
</Text>
{(card.requester || assignee) && (
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
{card.requester && (
<>
<Avatar size={18} radius="xs" color={tagColor(card.requester)} style={{ flexShrink: 0 }}>
{card.requester.slice(0, 2).toUpperCase()}
</Avatar>
<Text size="xs" c="dimmed" truncate>{card.requester}</Text>
</>
)}
{card.requester && assignee && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>-</Text>
)}
{assignee && (
<>
<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>
)}
{card.description && (
@@ -375,18 +515,19 @@ function KanbanCardImpl({
{card.tags && card.tags.length > 0 && (
<Group gap={4} wrap="wrap">
{card.tags.map((t) => (
<Badge key={t} size="xs" variant="outline" color="violet" radius="sm">
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">
{t}
</Badge>
))}
</Group>
)}
<Group gap={4} wrap="wrap">
{card.locked ? (
{card.locked && (
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>
{formatDuration(lockedMs)}
</Badge>
) : isDone && card.completed_at ? (
)}
{!card.locked && isDone && card.completed_at ? (
<>
<Badge size="xs" variant="light" color="teal" leftSection={<IconCheck size={10} />}>
{formatDateTimeShort(card.completed_at)}
@@ -394,13 +535,36 @@ function KanbanCardImpl({
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
Total: {formatDuration(totalDoneMs)}
</Badge>
{card.total_locked_ms > 0 && (
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>
{formatDuration(card.total_locked_ms)}
</Badge>
)}
</>
) : (
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
{formatDuration(liveMs)}
</Badge>
)}
) : !card.locked ? (
card.deadline ? (
<Tooltip label={`Vence: ${formatDateTimeShort(card.deadline)}`} withArrow>
<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>
{card.seq_num > 0 && (
<Text size="xs" c="dimmed" style={{ marginTop: -2 }}>
#{String(card.seq_num).padStart(5, "0")}
</Text>
)}
</Stack>
{card.stickers && card.stickers.length > 0 && (
<div
+15
View File
@@ -54,6 +54,10 @@ interface Props {
onShowHistory: (card: Card) => void;
onToggleCardLock: (id: string, locked: boolean) => void;
onAssignCard: (id: string, assignee_id: string | null) => void;
onSetCardDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
requesterOptions?: string[];
onOpenCustomCardColor?: (cardId: string, current: string) => void;
activeSticker?: string | null;
onAddSticker?: (cardId: string, x: number, y: number) => void;
onRemoveSticker?: (cardId: string, index: number) => void;
@@ -61,6 +65,7 @@ interface Props {
onCommitSticker?: (cardId: string) => void;
users: User[];
usersById: Map<string, User>;
highlightCardId?: string | null;
}
function KanbanColumnImpl({
@@ -81,6 +86,10 @@ function KanbanColumnImpl({
onShowHistory,
onToggleCardLock,
onAssignCard,
onSetCardDeadline,
onSetRequester,
requesterOptions,
onOpenCustomCardColor,
activeSticker,
onAddSticker,
onRemoveSticker,
@@ -88,6 +97,7 @@ function KanbanColumnImpl({
onCommitSticker,
users,
usersById,
highlightCardId,
}: Props) {
const [renaming, setRenaming] = useState(false);
const [name, setName] = useState(column.name);
@@ -415,9 +425,14 @@ function KanbanColumnImpl({
onShowHistory={onShowHistory}
onToggleLock={onToggleCardLock}
onAssign={onAssignCard}
onSetDeadline={onSetCardDeadline}
onSetRequester={onSetRequester}
requesterOptions={requesterOptions}
onOpenCustomColor={onOpenCustomCardColor}
users={users}
assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined}
inDoneColumn={column.is_done}
highlight={highlightCardId === c.id}
activeSticker={activeSticker}
onAddSticker={onAddSticker}
onRemoveSticker={onRemoveSticker}
+28 -14
View File
@@ -1,32 +1,46 @@
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 }[] = [
{ value: "", label: "Default" },
{ value: "blue", label: "Azul" },
{ value: "cyan", label: "Cian" },
{ value: "teal", label: "Teal" },
{ value: "green", label: "Verde" },
{ value: "lime", label: "Lima" },
{ value: "yellow", label: "Amarillo" },
{ value: "orange", label: "Naranja" },
{ value: "red", label: "Rojo" },
{ value: "pink", label: "Rosa" },
{ value: "grape", label: "Uva" },
{ value: "violet", label: "Violeta" },
{ 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.
// 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 const AVATAR_COLORS = CARD_COLORS;
export function colorBorder(color: CardColor): string {
if (color === "") return "var(--mantine-color-dark-4)";
return `color-mix(in srgb, var(--mantine-color-${color}-7) 30%, var(--mantine-color-dark-4))`;
}
const TAG_PALETTE = ["blue", "cyan", "teal", "green", "lime", "yellow", "orange", "red", "pink", "grape", "violet", "indigo"];
export function colorSwatch(color: CardColor): string {
if (color === "") return "var(--mantine-color-dark-3)";
return `var(--mantine-color-${color}-7)`;
export function tagColor(tag: string): string {
return stringHashPalette(tag, TAG_PALETTE);
}
+2 -41
View File
@@ -1,41 +1,2 @@
// Escala unidades segun magnitud: m | h Xm | D Xh | S XD | M XS.
// <1 minuto cae como "0m" para mantener la unidad mas pequena coherente.
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}`;
}
export { formatDuration } from "@fn_library/core/format_duration";
export { formatDateTimeShort } from "@fn_library/core/format_datetime_short";
+19 -1
View File
@@ -11,7 +11,8 @@ export interface Column {
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 {
emoji: string;
@@ -21,6 +22,7 @@ export interface Sticker {
export interface Card {
id: string;
seq_num: number;
requester: string;
title: string;
description: string;
@@ -33,17 +35,20 @@ export interface Card {
deleted_at: string | null;
tags: string[];
stickers: Sticker[];
deadline: string | null;
created_at: string;
updated_at: string;
entered_at: string;
time_in_column_ms: number;
locked_at: string | null;
total_locked_ms: number;
}
export interface User {
id: string;
username: string;
display_name: string;
color: string;
created_at: string;
}
@@ -139,6 +144,7 @@ export interface MetricsFilter {
to?: string;
assignee_id?: string;
requester?: string;
tags?: string[];
}
export interface Board {
@@ -154,6 +160,7 @@ export interface HistoryEntry {
entered_at: string;
exited_at: string | null;
duration_ms: number;
actor_id: string | null;
}
export interface LockPeriod {
@@ -162,11 +169,22 @@ export interface LockPeriod {
locked_at: string;
unlocked_at: string | null;
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 {
column_history: HistoryEntry[];
lock_periods: LockPeriod[];
events: CardEvent[];
total_locked_ms: number;
currently_locked: boolean;
}
+2 -2
View File
@@ -17,8 +17,8 @@
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@fn_library": ["../../../frontend/functions/ui"],
"@fn_library/*": ["../../../frontend/functions/ui/*"]
"@fn_library": ["../../../frontend/functions"],
"@fn_library/*": ["../../../frontend/functions/*"]
}
},
"include": ["src"]
+3 -2
View File
@@ -6,7 +6,7 @@ export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@fn_library": path.resolve(__dirname, "../../../frontend/functions/ui"),
"@fn_library": path.resolve(__dirname, "../../../frontend/functions"),
},
},
server: {
@@ -16,6 +16,7 @@ export default defineConfig({
},
},
build: {
outDir: "dist",
outDir: "../backend/dist",
emptyOutDir: true,
},
});
+9 -9
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env bash
# Lanza backend Go (puerto 8095) + frontend Vite dev (puerto 5180) en paralelo.
# Vite hace proxy /api -> 8095, asi que abrir http://localhost:5180
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACK_DIR="$ROOT/backend"
FRONT_DIR="$ROOT/frontend"
PORT_BACK="${PORT_BACK:-8095}"
PORT_FRONT="${PORT_FRONT:-5180}"
@@ -22,25 +22,25 @@ cleanup() {
trap cleanup INT TERM EXIT
# 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..."
CGO_ENABLED=1 go build -tags fts5 -o kanban .
(cd "$BACK_DIR" && CGO_ENABLED=1 go build -tags fts5 -o kanban .)
fi
# 2. Asegurar deps frontend
if [[ ! -d frontend/node_modules ]]; then
if [[ ! -d "$FRONT_DIR/node_modules" ]]; then
echo ">>> Installing frontend deps..."
(cd frontend && pnpm install)
(cd "$FRONT_DIR" && pnpm install)
fi
# 3. Lanzar backend
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=$!
# 4. Lanzar frontend (Vite con HMR + proxy a backend)
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=$!
echo ""