chore: auto-commit (28 archivos)

- app.md
- auth.go
- chat.go
- chat.log
- db.go
- frontend/package.json
- frontend/pnpm-lock.yaml
- frontend/src/App.tsx
- frontend/src/Root.tsx
- frontend/src/api.ts
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 00:27:18 +02:00
parent c915e721af
commit bee688e574
28 changed files with 3601 additions and 300 deletions
+5
View File
@@ -16,6 +16,11 @@ uses_functions:
- http_json_response_go_infra
- http_error_response_go_infra
- http_parse_body_go_infra
- http_session_cookie_middleware_go_infra
- password_hash_go_infra
- password_verify_go_infra
- session_create_go_infra
- session_cleanup_go_infra
uses_types: []
framework: "net/http + vite + react + mantine + dnd-kit"
entry_point: "main.go"
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"errors"
"net/http"
"time"
"fn-registry/functions/infra"
)
const (
cookieName = "kanban_session"
sessionTTL = 7 * 24 * time.Hour
)
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),
})
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
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 ""
}
// POST /api/auth/register {username, password, display_name?}
func handleRegister(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
u, err := db.CreateUser(body.Username, body.Password, body.DisplayName)
if err != nil {
if errors.Is(err, errUserAlreadyExists) {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusConflict, Code: "user_exists", Message: err.Error()})
return
}
badRequest(w, err.Error())
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, u)
}
}
// POST /api/auth/login {username, password}
func handleLogin(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
u, err := db.Authenticate(body.Username, body.Password)
if err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "invalid_credentials", Message: "invalid username or password"})
return
}
sess, err := infra.SessionCreate(db.conn, u.ID, sessionTTL, map[string]any{"username": u.Username})
if err != nil {
serverError(w, err)
return
}
setSessionCookie(w, sess.Token, sess.ExpiresAt)
infra.HTTPJSONResponse(w, http.StatusOK, u)
}
}
// POST /api/auth/logout
func handleLogout(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := tokenFromRequest(r)
if token != "" {
_ = db.DeleteSessionByToken(token)
}
clearSessionCookie(w)
w.WriteHeader(http.StatusNoContent)
}
}
// GET /api/me
func handleMe(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
}
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) {
users, err := db.ListUsers()
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, users)
}
}
+4 -2
View File
@@ -30,15 +30,17 @@ Ejemplo:
Tools disponibles (todas con sus inputs):
- list_board {} -> {columns, cards}
- create_column {name}
- update_column {id, name?, location?, width?} // location: "board" | "sidebar". width: 200..800 px.
- update_column {id, name?, location?, width?, wip_limit?, is_done?} // location: "board" | "sidebar". width: 200..800 px. wip_limit: max tarjetas (0 = sin limite). is_done: marca columna como terminal (cards dentro se cuentan como completadas para metricas y se muestran tachadas).
- delete_column {id}
- reorder_columns {ids:[...]}
- create_card {column_id, requester?, title, description?}
- update_card {id, requester?, title?, description?, color?} // color: "blue", "teal", "violet", "pink", "orange", "green", "yellow", "red", "" (default)
- update_card {id, requester?, title?, description?, color?, locked?, assignee_id?} // color: "blue", "teal", "violet", "pink", "orange", "green", "yellow", "red", "" (default). locked: true bloquea la tarjeta (no se puede mover entre columnas hasta desbloquear). assignee_id: ID del usuario asignado o null para desasignar.
- delete_card {id}
- move_card {id, column_id, ordered_ids?} // si omites ordered_ids la tarjeta se anade al final
- card_history {id}
- find_cards {query?, column_id?, requester?}
- list_users {} -> [{id, username, display_name}]
- assign_card {id, assignee_id} // alias rapido de update_card. assignee_id puede ser null para desasignar.
Si el usuario solo conversa o pide informacion (sin pedir cambios), responde texto natural en markdown SIN bloque <actions>.
+3
View File
@@ -0,0 +1,3 @@
{"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}
+306 -29
View File
@@ -19,21 +19,27 @@ type Column struct {
Position int `json:"position"`
Location string `json:"location"`
Width int `json:"width"`
WIPLimit int `json:"wip_limit"`
IsDone bool `json:"is_done"`
CreatedAt string `json:"created_at"`
}
type Card struct {
ID string `json:"id"`
Requester string `json:"requester"`
Title string `json:"title"`
Description string `json:"description"`
Color string `json:"color"`
ColumnID string `json:"column_id"`
Position int `json:"position"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
EnteredAt string `json:"entered_at"`
TimeInColumn int64 `json:"time_in_column_ms"`
ID string `json:"id"`
Requester string `json:"requester"`
Title string `json:"title"`
Description string `json:"description"`
Color string `json:"color"`
ColumnID string `json:"column_id"`
Position int `json:"position"`
Locked bool `json:"locked"`
AssigneeID *string `json:"assignee_id"`
CompletedAt *string `json:"completed_at"`
DeletedAt *string `json:"deleted_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
EnteredAt string `json:"entered_at"`
TimeInColumn int64 `json:"time_in_column_ms"`
}
type HistoryEntry struct {
@@ -46,6 +52,21 @@ type HistoryEntry struct {
DurationMs int64 `json:"duration_ms"`
}
type LockPeriod struct {
ID string `json:"id"`
CardID string `json:"card_id"`
LockedAt string `json:"locked_at"`
UnlockedAt *string `json:"unlocked_at"`
DurationMs int64 `json:"duration_ms"`
}
type CardHistoryResponse struct {
ColumnHistory []HistoryEntry `json:"column_history"`
LockPeriods []LockPeriod `json:"lock_periods"`
TotalLockedMs int64 `json:"total_locked_ms"`
CurrentlyLock bool `json:"currently_locked"`
}
type DB struct{ conn *sql.DB }
func openDB(path string) (*DB, error) {
@@ -73,7 +94,15 @@ func ensureColumns(conn *sql.DB) error {
specs := []colSpec{
{"columns", "location", "TEXT NOT NULL DEFAULT 'board'"},
{"columns", "width", "INTEGER NOT NULL DEFAULT 300"},
{"columns", "wip_limit", "INTEGER NOT NULL DEFAULT 0"},
{"columns", "is_done", "INTEGER NOT NULL DEFAULT 0"},
{"cards", "color", "TEXT NOT NULL DEFAULT ''"},
{"cards", "locked", "INTEGER NOT NULL DEFAULT 0"},
{"cards", "assignee_id", "TEXT"},
{"cards", "completed_at", "TEXT"},
{"cards", "deleted_at", "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)
@@ -87,6 +116,9 @@ func ensureColumns(conn *sql.DB) error {
return fmt.Errorf("add %s.%s: %w", s.table, s.name, err)
}
}
if _, err := conn.Exec(`CREATE INDEX IF NOT EXISTS idx_cards_assignee ON cards(assignee_id)`); err != nil {
return fmt.Errorf("create assignee index: %w", err)
}
return nil
}
@@ -124,10 +156,17 @@ func newID() string {
func nowRFC3339() string { return time.Now().UTC().Format(time.RFC3339Nano) }
func nullableActor(actorID string) any {
if actorID == "" {
return nil
}
return actorID
}
// --- Columns ---
func (db *DB) ListColumns() ([]Column, error) {
rows, err := db.conn.Query(`SELECT id, name, position, location, width, created_at FROM columns ORDER BY position, created_at`)
rows, err := db.conn.Query(`SELECT id, name, position, location, width, wip_limit, is_done, created_at FROM columns ORDER BY position, created_at`)
if err != nil {
return nil, err
}
@@ -135,9 +174,11 @@ func (db *DB) ListColumns() ([]Column, error) {
out := []Column{}
for rows.Next() {
var c Column
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.CreatedAt); err != nil {
var isDone int
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.WIPLimit, &isDone, &c.CreatedAt); err != nil {
return nil, err
}
c.IsDone = isDone != 0
out = append(out, c)
}
return out, rows.Err()
@@ -152,10 +193,10 @@ func (db *DB) CreateColumn(name string) (*Column, error) {
if maxPos.Valid {
pos = int(maxPos.Int64) + 1
}
c := Column{ID: newID(), Name: name, Position: pos, Location: "board", Width: 300, CreatedAt: nowRFC3339()}
c := Column{ID: newID(), Name: name, Position: pos, Location: "board", Width: 300, WIPLimit: 0, IsDone: false, CreatedAt: nowRFC3339()}
_, err := db.conn.Exec(
`INSERT INTO columns (id, name, position, location, width, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
c.ID, c.Name, c.Position, c.Location, c.Width, c.CreatedAt,
`INSERT INTO columns (id, name, position, location, width, wip_limit, is_done, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.Name, c.Position, c.Location, c.Width, c.WIPLimit, 0, c.CreatedAt,
)
if err != nil {
return nil, err
@@ -168,6 +209,8 @@ type ColumnPatch struct {
Position *int
Location *string
Width *int
WIPLimit *int
IsDone *bool
}
func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
@@ -200,6 +243,35 @@ func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
return err
}
}
if patch.WIPLimit != nil {
l := *patch.WIPLimit
if l < 0 {
l = 0
}
if _, err := db.conn.Exec(`UPDATE columns SET wip_limit=? WHERE id=?`, l, id); err != nil {
return err
}
}
if patch.IsDone != nil {
v := 0
if *patch.IsDone {
v = 1
}
if _, err := db.conn.Exec(`UPDATE columns SET is_done=? WHERE id=?`, v, id); err != nil {
return err
}
// Re-evaluate completed_at for cards in this column.
now := nowRFC3339()
if v == 1 {
if _, err := db.conn.Exec(`UPDATE cards SET completed_at=? WHERE column_id=? AND completed_at IS NULL`, now, id); err != nil {
return err
}
} else {
if _, err := db.conn.Exec(`UPDATE cards SET completed_at=NULL WHERE column_id=?`, id); err != nil {
return err
}
}
}
return nil
}
@@ -226,11 +298,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.created_at, c.updated_at,
SELECT c.id, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.created_at, c.updated_at,
h.entered_at
FROM cards c
LEFT JOIN card_column_history h
ON h.card_id = c.id AND h.exited_at IS NULL
WHERE c.deleted_at IS NULL
ORDER BY c.column_id, c.position, c.created_at
`)
if err != nil {
@@ -242,9 +315,26 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
for rows.Next() {
var c Card
var entered sql.NullString
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &c.CreatedAt, &c.UpdatedAt, &entered); err != nil {
var assignee sql.NullString
var completed sql.NullString
var deleted 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, &c.CreatedAt, &c.UpdatedAt, &entered); err != nil {
return nil, err
}
c.Locked = locked != 0
if assignee.Valid && assignee.String != "" {
s := assignee.String
c.AssigneeID = &s
}
if completed.Valid && completed.String != "" {
s := completed.String
c.CompletedAt = &s
}
if deleted.Valid && deleted.String != "" {
s := deleted.String
c.DeletedAt = &s
}
if entered.Valid {
c.EnteredAt = entered.String
if t, err := time.Parse(time.RFC3339Nano, entered.String); err == nil {
@@ -256,7 +346,7 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
return out, rows.Err()
}
func (db *DB) CreateCard(columnID, requester, title, description string) (*Card, error) {
func (db *DB) CreateCard(columnID, requester, title, description, actorID string) (*Card, error) {
var maxPos sql.NullInt64
if err := db.conn.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, columnID).Scan(&maxPos); err != nil {
return nil, err
@@ -282,11 +372,22 @@ func (db *DB) CreateCard(columnID, requester, title, description string) (*Card,
return nil, err
}
if _, err := tx.Exec(
`INSERT INTO card_column_history (id, card_id, column_id, entered_at) VALUES (?, ?, ?, ?)`,
newID(), c.ID, c.ColumnID, now,
`INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
newID(), c.ID, c.ColumnID, now, nullableActor(actorID),
); err != nil {
return nil, err
}
// If the destination column is_done, set completed_at.
var destDone int
if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, columnID).Scan(&destDone); err != nil {
return nil, err
}
if destDone == 1 {
if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=?`, now, c.ID); err != nil {
return nil, err
}
c.CompletedAt = &now
}
if err := tx.Commit(); err != nil {
return nil, err
}
@@ -298,9 +399,16 @@ type CardPatch struct {
Title *string
Description *string
Color *string
Locked *bool
AssigneeID *string // empty string clears assignment
HasAssignee bool // distinguishes "set to null" from "not provided"
}
func (db *DB) UpdateCard(id string, patch CardPatch) error {
return db.UpdateCardWithActor(id, patch, "")
}
func (db *DB) UpdateCardWithActor(id string, patch CardPatch, actorID string) error {
tx, err := db.conn.Begin()
if err != nil {
return err
@@ -326,18 +434,114 @@ func (db *DB) UpdateCard(id string, patch CardPatch) error {
return err
}
}
if patch.HasAssignee {
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
}
} else {
if _, err := tx.Exec(`UPDATE cards SET assignee_id=?, updated_at=? WHERE id=?`, *patch.AssigneeID, nowRFC3339(), id); err != nil {
return err
}
}
}
if patch.Locked != nil {
var current int
if err := tx.QueryRow(`SELECT locked FROM cards WHERE id=?`, id).Scan(&current); err != nil {
return err
}
desired := 0
if *patch.Locked {
desired = 1
}
if current != desired {
now := nowRFC3339()
if _, err := tx.Exec(`UPDATE cards SET locked=?, updated_at=? WHERE id=?`, desired, now, id); err != nil {
return err
}
if desired == 1 {
if _, err := tx.Exec(
`INSERT INTO card_lock_history (id, card_id, locked_at, actor_id) VALUES (?, ?, ?, ?)`,
newID(), id, now, nullableActor(actorID),
); err != nil {
return err
}
} else {
if _, err := tx.Exec(
`UPDATE card_lock_history SET unlocked_at=? WHERE card_id=? AND unlocked_at IS NULL`,
now, id,
); err != nil {
return err
}
}
}
}
return tx.Commit()
}
// 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
}
// 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
}
// PurgeCard permanently removes the card from the DB.
func (db *DB) PurgeCard(id string) error {
_, err := db.conn.Exec(`DELETE FROM cards WHERE id=?`, id)
return err
}
// ListDeletedCards returns cards in the trash, newest first.
func (db *DB) ListDeletedCards() ([]Card, error) {
rows, err := db.conn.Query(`
SELECT c.id, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.created_at, c.updated_at
FROM cards c
WHERE c.deleted_at IS NOT NULL
ORDER BY c.deleted_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Card{}
for rows.Next() {
var c Card
var assignee sql.NullString
var completed sql.NullString
var deleted 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, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
c.Locked = locked != 0
if assignee.Valid && assignee.String != "" {
s := assignee.String
c.AssigneeID = &s
}
if completed.Valid && completed.String != "" {
s := completed.String
c.CompletedAt = &s
}
if deleted.Valid {
s := deleted.String
c.DeletedAt = &s
}
out = append(out, c)
}
return out, rows.Err()
}
// MoveCard updates the card's column and/or position. If the column changes,
// the open history entry is closed and a new one is opened.
// orderedIDs is the new order of cards in the destination column (including this card).
func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error {
// actorID is the user performing the move (empty string for system/anonymous).
func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string, actorID string) error {
tx, err := db.conn.Begin()
if err != nil {
return err
@@ -345,9 +549,13 @@ func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error {
defer tx.Rollback()
var srcColumnID string
if err := tx.QueryRow(`SELECT column_id FROM cards WHERE id=?`, cardID).Scan(&srcColumnID); err != nil {
var locked int
if err := tx.QueryRow(`SELECT column_id, locked FROM cards WHERE id=?`, cardID).Scan(&srcColumnID, &locked); err != nil {
return fmt.Errorf("card not found: %w", err)
}
if locked != 0 && srcColumnID != destColumnID {
return fmt.Errorf("card locked: cannot move between columns")
}
now := nowRFC3339()
@@ -359,17 +567,38 @@ func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error {
return err
}
if _, err := tx.Exec(
`INSERT INTO card_column_history (id, card_id, column_id, entered_at) VALUES (?, ?, ?, ?)`,
newID(), cardID, destColumnID, now,
`INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
newID(), cardID, destColumnID, now, nullableActor(actorID),
); err != nil {
return err
}
_ = actorID
if _, err := tx.Exec(
`UPDATE cards SET column_id=?, updated_at=? WHERE id=?`,
destColumnID, now, cardID,
); err != nil {
return err
}
// Recompute completed_at based on destination column's is_done flag.
var destDone int
if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, destColumnID).Scan(&destDone); err != nil {
return err
}
if destDone == 1 {
if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=? AND completed_at IS NULL`, now, cardID); err != nil {
return err
}
// Auto-assign: if card had no assignee and an actor is moving it, claim it.
if actorID != "" {
if _, err := tx.Exec(`UPDATE cards SET assignee_id=? WHERE id=? AND (assignee_id IS NULL OR assignee_id='')`, actorID, cardID); err != nil {
return err
}
}
} else {
if _, err := tx.Exec(`UPDATE cards SET completed_at=NULL WHERE id=?`, cardID); err != nil {
return err
}
}
}
for i, id := range orderedIDs {
@@ -404,7 +633,7 @@ func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error {
return tx.Commit()
}
func (db *DB) CardHistory(cardID string) ([]HistoryEntry, error) {
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
FROM card_column_history h
@@ -417,7 +646,7 @@ func (db *DB) CardHistory(cardID string) ([]HistoryEntry, error) {
}
defer rows.Close()
now := time.Now().UTC()
out := []HistoryEntry{}
cols := []HistoryEntry{}
for rows.Next() {
var h HistoryEntry
var exited sql.NullString
@@ -436,7 +665,55 @@ func (db *DB) CardHistory(cardID string) ([]HistoryEntry, error) {
end = now
}
h.DurationMs = end.Sub(entered).Milliseconds()
out = append(out, h)
cols = append(cols, h)
}
return out, rows.Err()
if err := rows.Err(); err != nil {
return nil, err
}
lockRows, err := db.conn.Query(`
SELECT id, card_id, locked_at, unlocked_at
FROM card_lock_history
WHERE card_id=?
ORDER BY locked_at
`, cardID)
if err != nil {
return nil, err
}
defer lockRows.Close()
locks := []LockPeriod{}
var totalLocked int64
currently := false
for lockRows.Next() {
var lp LockPeriod
var unlocked sql.NullString
if err := lockRows.Scan(&lp.ID, &lp.CardID, &lp.LockedAt, &unlocked); err != nil {
return nil, err
}
start, err := time.Parse(time.RFC3339Nano, lp.LockedAt)
if err != nil {
return nil, err
}
var end time.Time
if unlocked.Valid {
lp.UnlockedAt = &unlocked.String
end, _ = time.Parse(time.RFC3339Nano, unlocked.String)
} else {
end = now
currently = true
}
lp.DurationMs = end.Sub(start).Milliseconds()
totalLocked += lp.DurationMs
locks = append(locks, lp)
}
if err := lockRows.Err(); err != nil {
return nil, err
}
return &CardHistoryResponse{
ColumnHistory: cols,
LockPeriods: locks,
TotalLockedMs: totalLocked,
CurrentlyLock: currently,
}, nil
}
+4
View File
@@ -12,14 +12,18 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@mantine/charts": "^9.1.1",
"@mantine/core": "^9.0.2",
"@mantine/dates": "^9.1.1",
"@mantine/hooks": "^9.0.2",
"@mantine/modals": "^9.0.2",
"@mantine/notifications": "^9.0.2",
"@tabler/icons-react": "^3.31.0",
"dayjs": "^1.11.20",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
+283
View File
@@ -17,9 +17,15 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.5)
'@mantine/charts':
specifier: ^9.1.1
version: 9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@9.1.1(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(recharts@2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5))
'@mantine/core':
specifier: ^9.0.2
version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@mantine/dates':
specifier: ^9.1.1
version: 9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@9.1.1(react@19.2.5))(dayjs@1.11.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@mantine/hooks':
specifier: ^9.0.2
version: 9.1.1(react@19.2.5)
@@ -32,6 +38,9 @@ importers:
'@tabler/icons-react':
specifier: ^3.31.0
version: 3.42.0(react@19.2.5)
dayjs:
specifier: ^1.11.20
version: 1.11.20
react:
specifier: ^19.1.0
version: 19.2.5
@@ -41,6 +50,9 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
recharts:
specifier: ^2.15.4
version: 2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
@@ -371,6 +383,15 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@mantine/charts@9.1.1':
resolution: {integrity: sha512-f+RbCe9ULHGmhF2KYw8PIwL3xWUDulhVrZ2lNW/2sKUTaAobSBNmwBXy4kbN6gOHtxAbZx8YnVXH2F6mSaYtcg==}
peerDependencies:
'@mantine/core': 9.1.1
'@mantine/hooks': 9.1.1
react: ^19.2.0
react-dom: ^19.2.0
recharts: '>=3.2.1'
'@mantine/core@9.1.1':
resolution: {integrity: sha512-vClOZdCeZ4oLYuA/3jAOgKGQ6dXbF6ZkzpYz09Gied9nZpB7HcQeb3dcMh8UPBE4f+EM7KlYWk6dch7GoASeaA==}
peerDependencies:
@@ -378,6 +399,15 @@ packages:
react: ^19.2.0
react-dom: ^19.2.0
'@mantine/dates@9.1.1':
resolution: {integrity: sha512-P1tr/Hr+EVxppbOVpTLvaZZnM1W/r0TNpqNNMeM81xfyuKYzd7zt2/SQYb6BuudgEQfRJnAee+7bIJLEsrb0uA==}
peerDependencies:
'@mantine/core': 9.1.1
'@mantine/hooks': 9.1.1
dayjs: '>=1.0.0'
react: ^19.2.0
react-dom: ^19.2.0
'@mantine/hooks@9.1.1':
resolution: {integrity: sha512-tTJK73nGFyy1v214TLdvBq0be7QCoc6osfbXVuJgOH3YG85lWk9Mvvor6k+w6hC6HXSqKMqLKePyiGm83xGcMg==}
peerDependencies:
@@ -552,6 +582,33 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
@@ -646,6 +703,53 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
dayjs@1.11.20:
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -655,6 +759,9 @@ packages:
supports-color:
optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
@@ -690,9 +797,16 @@ packages:
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
fast-equals@5.4.0:
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
engines: {node: '>=6.0.0'}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -727,6 +841,10 @@ packages:
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
@@ -756,6 +874,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -974,6 +1095,9 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
@@ -1010,6 +1134,12 @@ packages:
'@types/react':
optional: true
react-smooth@4.0.4:
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -1030,6 +1160,16 @@ packages:
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'}
recharts-scale@0.4.5:
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
recharts@2.15.4:
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
engines: {node: '>=14'}
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -1083,6 +1223,9 @@ packages:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
@@ -1158,6 +1301,9 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
vite@6.4.2:
resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -1467,6 +1613,14 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@mantine/charts@9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@9.1.1(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(recharts@2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5))':
dependencies:
'@mantine/core': 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@mantine/hooks': 9.1.1(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
recharts: 2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@floating-ui/react': 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -1480,6 +1634,15 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
'@mantine/dates@9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@9.1.1(react@19.2.5))(dayjs@1.11.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@mantine/core': 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@mantine/hooks': 9.1.1(react@19.2.5)
clsx: 2.1.1
dayjs: 1.11.20
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
'@mantine/hooks@9.1.1(react@19.2.5)':
dependencies:
react: 19.2.5
@@ -1609,6 +1772,30 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
@@ -1691,10 +1878,52 @@ snapshots:
csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
dayjs@1.11.20: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
decimal.js-light@2.5.1: {}
decode-named-character-reference@1.3.0:
dependencies:
character-entities: 2.0.2
@@ -1749,8 +1978,12 @@ snapshots:
estree-util-is-identifier-name@3.0.0: {}
eventemitter3@4.0.7: {}
extend@3.0.2: {}
fast-equals@5.4.0: {}
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -1790,6 +2023,8 @@ snapshots:
inline-style-parser@0.2.7: {}
internmap@2.0.3: {}
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
@@ -1809,6 +2044,8 @@ snapshots:
json5@2.2.3: {}
lodash@4.18.1: {}
longest-streak@3.1.0: {}
loose-envify@1.4.0:
@@ -2241,6 +2478,8 @@ snapshots:
react-is@16.13.1: {}
react-is@18.3.1: {}
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5):
dependencies:
'@types/hast': 3.0.4
@@ -2285,6 +2524,14 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
react-smooth@4.0.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
fast-equals: 5.4.0
prop-types: 15.8.1
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-transition-group: 4.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5):
dependencies:
get-nonce: 1.0.1
@@ -2304,6 +2551,23 @@ snapshots:
react@19.2.5: {}
recharts-scale@0.4.5:
dependencies:
decimal.js-light: 2.5.1
recharts@2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
clsx: 2.1.1
eventemitter3: 4.0.7
lodash: 4.18.1
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-is: 18.3.1
react-smooth: 4.0.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
recharts-scale: 0.4.5
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
@@ -2398,6 +2662,8 @@ snapshots:
tagged-tag@1.0.0: {}
tiny-invariant@1.3.3: {}
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
@@ -2481,6 +2747,23 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
victory-vendor@36.9.2:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite@6.4.2(sugarss@5.0.1(postcss@8.5.14)):
dependencies:
esbuild: 0.25.12
+257 -65
View File
@@ -24,11 +24,16 @@ import {
import {
ActionIcon,
AppShell,
Avatar,
Badge,
Box,
Button,
Group,
Loader,
Menu,
Paper,
Stack,
Tabs,
Text,
TextInput,
Title,
@@ -37,65 +42,36 @@ import {
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import {
IconColumnInsertRight,
IconArrowBackUp,
IconCalendar,
IconChartBar,
IconChevronDown,
IconChevronRight,
IconLayoutKanban,
IconLogout,
IconMenu2,
IconMessageChatbot,
IconPlus,
IconRefresh,
IconTrash,
IconTrashX,
IconX,
} from "@tabler/icons-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as api from "./api";
import { useAuth } from "./auth";
import { CardForm } from "./components/CardForm";
import { ChatPanel } from "./components/ChatPanel";
import { CalendarView } from "./components/CalendarView";
import { Dashboard } from "./components/Dashboard";
import { HistoryModal } from "./components/HistoryModal";
import { KanbanCard } from "./components/KanbanCard";
import { KanbanColumn } from "./components/KanbanColumn";
import { colorBg, colorBorder } from "./components/colors";
import type { Board, Card, CardColor, Column, ColumnLocation } from "./types";
import type { Board, Card, CardColor, Column, ColumnLocation, User } from "./types";
const COL_PREFIX = "column-";
function AddColumnDialog({
onSubmit,
onCancel,
}: {
onSubmit: (name: string) => Promise<void> | void;
onCancel: () => void;
}) {
const [name, setName] = useState("");
const submit = () => {
const n = name.trim();
if (n) onSubmit(n);
};
return (
<Stack gap="sm">
<TextInput
label="Nombre"
value={name}
onChange={(e) => setName(e.currentTarget.value)}
data-autofocus
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submit();
}
}}
/>
<Group justify="flex-end" gap="xs">
<Button variant="subtle" color="gray" onClick={onCancel}>
Cancelar
</Button>
<Button onClick={submit} disabled={!name.trim()}>
Crear
</Button>
</Group>
</Stack>
);
}
// Custom collision detection: prefiere otras columnas como destino al arrastrar
// columnas; al arrastrar cards prefiere cards/columnas via closestCorners.
function makeCollisionDetection(activeType: string | undefined): CollisionDetection {
@@ -118,7 +94,9 @@ function makeCollisionDetection(activeType: string | undefined): CollisionDetect
}
export function App() {
const auth = useAuth();
const [board, setBoard] = useState<Board | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [activeCard, setActiveCard] = useState<Card | null>(null);
const [activeColumnId, setActiveColumnId] = useState<string | null>(null);
const [activeType, setActiveType] = useState<string | undefined>(undefined);
@@ -126,6 +104,9 @@ export function App() {
const [colName, setColName] = useState("");
const [now, setNow] = useState(Date.now());
const [chatOpen, setChatOpen] = useState(false);
const [activeTab, setActiveTab] = useState<string>("board");
const [trash, setTrash] = useState<Card[]>([]);
const [trashOpen, setTrashOpen] = useState(false);
const [navOpen, setNavOpen] = useState(false);
const [navWidth, setNavWidth] = useState<number>(() => {
const stored = localStorage.getItem("kanban_nav_width");
@@ -177,11 +158,43 @@ export function App() {
reload();
}, [reload]);
const reloadUsers = useCallback(async () => {
try {
const us = await api.listUsers();
setUsers(us);
} catch (e) {
console.warn("listUsers failed", e);
}
}, []);
const reloadTrash = useCallback(async () => {
try {
const t = await api.listTrash();
setTrash(t);
} catch (e) {
console.warn("listTrash failed", e);
}
}, []);
useEffect(() => {
reloadUsers();
}, [reloadUsers]);
useEffect(() => {
reloadTrash();
}, [reloadTrash]);
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
const usersById = useMemo(() => {
const m = new Map<string, User>();
for (const u of users) m.set(u.id, u);
return m;
}, [users]);
const sortedColumns = useMemo(() => {
if (!board) return [];
return [...board.columns].sort((a, b) => a.position - b.position);
@@ -360,22 +373,6 @@ export function App() {
}
};
const openAddColumnModal = useCallback(() => {
const id = modals.open({
title: "Nueva columna",
size: "sm",
children: <AddColumnDialog onSubmit={async (name) => {
try {
await api.createColumn(name);
modals.close(id);
reload();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}} onCancel={() => modals.close(id)} />,
});
}, [reload]);
const handleRenameColumn = useCallback(async (id: string, name: string) => {
try {
await api.updateColumn(id, { name });
@@ -426,6 +423,8 @@ export function App() {
size: "md",
children: (
<CardForm
users={users}
initial={{ requester: auth.user?.display_name || auth.user?.username || "" }}
submitLabel="Crear"
onCancel={() => modals.close(id)}
onSubmit={async (v) => {
@@ -435,6 +434,7 @@ export function App() {
requester: v.requester,
title: v.title,
description: v.description,
assignee_id: v.assignee_id,
});
modals.close(id);
reload();
@@ -445,7 +445,7 @@ export function App() {
/>
),
});
}, [reload]);
}, [reload, users, auth.user]);
const openEditCard = useCallback((card: Card) => {
const id = modals.open({
@@ -453,7 +453,13 @@ export function App() {
size: "md",
children: (
<CardForm
initial={{ requester: card.requester, title: card.title, description: card.description }}
users={users}
initial={{
requester: card.requester,
title: card.title,
description: card.description,
assignee_id: card.assignee_id,
}}
submitLabel="Guardar"
onCancel={() => modals.close(id)}
onSubmit={async (v) => {
@@ -462,6 +468,7 @@ export function App() {
requester: v.requester,
title: v.title,
description: v.description,
assignee_id: v.assignee_id,
});
modals.close(id);
reload();
@@ -472,16 +479,57 @@ export function App() {
/>
),
});
}, [reload, users]);
const handleAssignCard = useCallback(async (id: string, assignee_id: string | null) => {
setBoard((prev) => {
if (!prev) return prev;
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, assignee_id } : c)) };
});
try {
await api.updateCard(id, { assignee_id });
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
reload();
}
}, [reload]);
const handleDeleteCard = useCallback(async (id: string) => {
try {
await api.deleteCard(id);
reload();
reloadTrash();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}, [reload]);
}, [reload, reloadTrash]);
const handleRestoreCard = useCallback(async (id: string) => {
try {
await api.restoreCard(id);
reload();
reloadTrash();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}, [reload, reloadTrash]);
const handlePurgeCard = useCallback(async (id: string) => {
modals.openConfirmModal({
title: "Borrar permanentemente",
children: <Text size="sm">Esta accion no se puede deshacer.</Text>,
labels: { confirm: "Borrar", cancel: "Cancelar" },
confirmProps: { color: "red" },
onConfirm: async () => {
try {
await api.purgeCard(id);
reloadTrash();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
},
});
}, [reloadTrash]);
const handleChangeCardColor = useCallback(async (id: string, color: CardColor) => {
setBoard((prev) => {
@@ -504,6 +552,46 @@ export function App() {
});
}, []);
const handleToggleCardLock = useCallback(async (id: string, locked: boolean) => {
setBoard((prev) => {
if (!prev) return prev;
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, locked } : c)) };
});
try {
await api.updateCard(id, { locked });
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
reload();
}
}, [reload]);
const handleSetWIPLimit = useCallback(async (id: string, wip_limit: number) => {
setBoard((prev) => {
if (!prev) return prev;
return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, wip_limit } : c)) };
});
try {
await api.updateColumn(id, { wip_limit });
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
reload();
}
}, [reload]);
const handleToggleDone = useCallback(async (id: string, is_done: boolean) => {
setBoard((prev) => {
if (!prev) return prev;
return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, is_done } : c)) };
});
try {
await api.updateColumn(id, { is_done });
reload();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
reload();
}
}, [reload]);
const headerConfig = useMemo(() => ({ height: 50 }), []);
const navbarConfig = useMemo(
() => ({
@@ -564,13 +652,21 @@ export function App() {
</ActionIcon>
<IconLayoutKanban size={22} />
<Title order={4}>Kanban</Title>
<Tabs value={activeTab} onChange={(v) => v && setActiveTab(v)} variant="pills" ml="md">
<Tabs.List>
<Tabs.Tab value="board" leftSection={<IconLayoutKanban size={14} />}>
Tablero
</Tabs.Tab>
<Tabs.Tab value="dashboard" leftSection={<IconChartBar size={14} />}>
Dashboard
</Tabs.Tab>
<Tabs.Tab value="calendar" leftSection={<IconCalendar size={14} />}>
Calendario
</Tabs.Tab>
</Tabs.List>
</Tabs>
</Group>
<Group gap={4}>
<Tooltip label="Nueva columna" withArrow>
<ActionIcon variant="subtle" onClick={openAddColumnModal} aria-label="Add column">
<IconColumnInsertRight size={16} />
</ActionIcon>
</Tooltip>
<ActionIcon variant="subtle" onClick={reload} aria-label="Refresh">
<IconRefresh size={16} />
</ActionIcon>
@@ -581,6 +677,27 @@ export function App() {
>
<IconMessageChatbot size={16} />
</ActionIcon>
{auth.user && (
<Menu position="bottom-end" shadow="md" withArrow>
<Menu.Target>
<ActionIcon variant="subtle" aria-label="Usuario">
<Avatar size={26} radius="xl" 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>
<Menu.Item
leftSection={<IconLogout size={14} />}
color="red"
onClick={() => auth.logout()}
>
Cerrar sesion
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Group>
</Group>
</AppShell.Header>
@@ -624,15 +741,70 @@ export function App() {
onResizeColumn={handleResizeColumn}
onMoveColumnLocation={handleMoveColumnLocation}
onDeleteColumn={handleDeleteColumn}
onSetWIPLimit={handleSetWIPLimit}
onToggleDone={handleToggleDone}
onEditCard={openEditCard}
onDeleteCard={handleDeleteCard}
onChangeCardColor={handleChangeCardColor}
onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
onAssignCard={handleAssignCard}
users={users}
usersById={usersById}
/>
))}
</Stack>
</SortableContext>
</Box>
<Box style={{ borderTop: "1px solid var(--mantine-color-dark-5)", paddingTop: 8 }}>
<Button
variant="subtle"
color="gray"
size="xs"
fullWidth
justify="space-between"
leftSection={<IconTrash size={14} />}
rightSection={
<Group gap={4}>
<Badge size="xs" variant="light" color={trash.length > 0 ? "red" : "gray"}>
{trash.length}
</Badge>
{trashOpen ? <IconChevronDown size={12} /> : <IconChevronRight size={12} />}
</Group>
}
onClick={() => setTrashOpen((v) => !v)}
>
Papelera
</Button>
{trashOpen && (
<Stack gap={4} mt={4} style={{ maxHeight: 220, overflowY: "auto" }}>
{trash.length === 0 && (
<Text size="xs" c="dimmed" px="xs">
Vacia.
</Text>
)}
{trash.map((c) => (
<Paper key={c.id} p={6} withBorder radius="sm" bg="dark.7">
<Group justify="space-between" gap={4} wrap="nowrap">
<Text size="xs" truncate style={{ flex: 1 }} title={c.title}>
{c.title}
</Text>
<Tooltip label="Restaurar" withArrow>
<ActionIcon size="xs" variant="subtle" color="green" onClick={() => handleRestoreCard(c.id)}>
<IconArrowBackUp size={12} />
</ActionIcon>
</Tooltip>
<Tooltip label="Borrar permanentemente" withArrow>
<ActionIcon size="xs" variant="subtle" color="red" onClick={() => handlePurgeCard(c.id)}>
<IconTrashX size={12} />
</ActionIcon>
</Tooltip>
</Group>
</Paper>
))}
</Stack>
)}
</Box>
</Stack>
</AppShell.Navbar>
@@ -641,6 +813,15 @@ export function App() {
</AppShell.Aside>
<AppShell.Main>
{activeTab === "dashboard" ? (
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
<Dashboard users={users} />
</Box>
) : activeTab === "calendar" ? (
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
<CalendarView users={users} />
</Box>
) : (
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden" }}>
<SortableContext items={boardSortableIds} strategy={horizontalListSortingStrategy}>
<Group
@@ -661,10 +842,16 @@ export function App() {
onResizeColumn={handleResizeColumn}
onMoveColumnLocation={handleMoveColumnLocation}
onDeleteColumn={handleDeleteColumn}
onSetWIPLimit={handleSetWIPLimit}
onToggleDone={handleToggleDone}
onEditCard={openEditCard}
onDeleteCard={handleDeleteCard}
onChangeCardColor={handleChangeCardColor}
onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
onAssignCard={handleAssignCard}
users={users}
usersById={usersById}
/>
))}
@@ -708,6 +895,7 @@ export function App() {
</Group>
</SortableContext>
</Box>
)}
</AppShell.Main>
</AppShell>
@@ -721,6 +909,10 @@ export function App() {
onEdit={() => {}}
onChangeColor={() => {}}
onShowHistory={() => {}}
onToggleLock={() => {}}
onAssign={() => {}}
users={users}
assignee={dragOverlayCard.assignee_id ? usersById.get(dragOverlayCard.assignee_id) : undefined}
isOverlay
/>
) : dragOverlayColumn ? (
+17
View File
@@ -0,0 +1,17 @@
import { Center, Loader } from "@mantine/core";
import { App } from "./App";
import { useAuth } from "./auth";
import { LoginPage } from "./components/LoginPage";
export function Root() {
const { user, loading } = useAuth();
if (loading) {
return (
<Center style={{ minHeight: "100vh" }}>
<Loader />
</Center>
);
}
if (!user) return <LoginPage />;
return <App />;
}
+71 -3
View File
@@ -1,15 +1,30 @@
import type { Board, Card, Column, HistoryEntry } from "./types";
import type {
Board,
Card,
CardHistoryResponse,
Column,
Metrics,
MetricsFilter,
User,
} from "./types";
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 Error(err.Message || err.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();
@@ -28,6 +43,8 @@ export interface UpdateColumnInput {
position?: number;
location?: "board" | "sidebar";
width?: number;
wip_limit?: number;
is_done?: boolean;
}
export function updateColumn(id: string, patch: UpdateColumnInput): Promise<void> {
@@ -50,6 +67,7 @@ export interface CreateCardInput {
requester?: string;
title: string;
description?: string;
assignee_id?: string | null;
}
export function createCard(input: CreateCardInput): Promise<Card> {
@@ -61,6 +79,8 @@ export interface UpdateCardInput {
title?: string;
description?: string;
color?: string;
locked?: boolean;
assignee_id?: string | null;
}
export function updateCard(id: string, patch: UpdateCardInput): Promise<void> {
@@ -71,6 +91,18 @@ export function deleteCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}`, { method: "DELETE" });
}
export function listTrash(): Promise<Card[]> {
return fetchJSON("/trash");
}
export function restoreCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/restore`, { method: "POST" });
}
export function purgeCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/purge`, { method: "DELETE" });
}
export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> {
return fetchJSON(`/cards/${id}/move`, {
method: "POST",
@@ -78,7 +110,7 @@ export function moveCard(id: string, column_id: string, ordered_ids: string[]):
});
}
export function cardHistory(id: string): Promise<HistoryEntry[]> {
export function cardHistory(id: string): Promise<CardHistoryResponse> {
return fetchJSON(`/cards/${id}/history`);
}
@@ -103,3 +135,39 @@ export interface ChatResponse {
export function sendChat(messages: ChatMessage[]): Promise<ChatResponse> {
return fetchJSON("/chat", { method: "POST", body: JSON.stringify({ messages }) });
}
export function login(username: string, password: string): Promise<User> {
return fetchJSON("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
}
export function register(username: string, password: string, display_name?: string): Promise<User> {
return fetchJSON("/auth/register", {
method: "POST",
body: JSON.stringify({ username, password, display_name }),
});
}
export function logout(): Promise<void> {
return fetchJSON("/auth/logout", { method: "POST" });
}
export function getMe(): Promise<User> {
return fetchJSON("/me");
}
export function listUsers(): Promise<User[]> {
return fetchJSON("/users");
}
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
const qs = new URLSearchParams();
if (f.from) qs.set("from", f.from);
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);
const q = qs.toString();
return fetchJSON(`/metrics${q ? `?${q}` : ""}`);
}
+55
View File
@@ -0,0 +1,55 @@
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from "react";
import * as api from "./api";
import { HTTPError } from "./api";
import type { User } from "./types";
interface AuthCtx {
user: User | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string, displayName: string) => Promise<void>;
logout: () => Promise<void>;
}
const Ctx = createContext<AuthCtx | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.getMe()
.then(setUser)
.catch((e) => {
if (!(e instanceof HTTPError) || e.status !== 401) {
console.warn("getMe failed", e);
}
})
.finally(() => setLoading(false));
}, []);
const login = useCallback(async (username: string, password: string) => {
const u = await api.login(username, password);
setUser(u);
}, []);
const register = useCallback(async (username: string, password: string, displayName: string) => {
await api.register(username, password, displayName);
const u = await api.login(username, password);
setUser(u);
}, []);
const logout = useCallback(async () => {
await api.logout();
setUser(null);
}, []);
return <Ctx.Provider value={{ user, loading, login, register, logout }}>{children}</Ctx.Provider>;
}
export function useAuth(): AuthCtx {
const v = useContext(Ctx);
if (!v) throw new Error("useAuth: missing AuthProvider");
return v;
}
+216
View File
@@ -0,0 +1,216 @@
import {
Box,
Center,
Group,
Loader,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Title,
} from "@mantine/core";
import { MonthPickerInput } from "@mantine/dates";
import { IconCheckbox, 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";
interface Props {
users: User[];
}
const DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"];
export function CalendarView({ users }: Props) {
const [month, setMonth] = useState<Date>(new Date());
const [assigneeId, setAssigneeId] = useState<string | null>(null);
const [data, setData] = useState<Metrics | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
const start = dayjs(month).startOf("month").format("YYYY-MM-DD");
const end = dayjs(month).endOf("month").format("YYYY-MM-DD");
api
.getMetrics({ from: start, to: end, assignee_id: assigneeId || undefined })
.then((m) => {
if (!cancelled) setData(m);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [month, assigneeId]);
const userOptions = useMemo(
() => users.map((u) => ({ value: u.id, label: u.display_name || u.username })),
[users]
);
const dayMap = useMemo(() => {
const m = new Map<string, { created: number; done: number }>();
if (!data) return m;
for (const d of data.created_daily) {
const cur = m.get(d.date) ?? { created: 0, done: 0 };
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 };
cur.done = d.count;
m.set(d.date, cur);
}
return m;
}, [data]);
// Build month grid (Mon-first).
const grid = useMemo(() => {
const start = dayjs(month).startOf("month");
const end = dayjs(month).endOf("month");
// Day-of-week, ISO Mon=1..Sun=7. We want first cell to be Mon.
const firstDow = (start.day() + 6) % 7; // 0=Mon
const cells: { date: string | null; inMonth: boolean }[] = [];
for (let i = 0; i < firstDow; i++) cells.push({ date: null, inMonth: false });
for (let d = start; !d.isAfter(end); d = d.add(1, "day")) {
cells.push({ date: d.format("YYYY-MM-DD"), inMonth: true });
}
while (cells.length % 7 !== 0) cells.push({ date: null, inMonth: false });
return cells;
}, [month]);
const totalCreated = useMemo(
() => Array.from(dayMap.values()).reduce((s, v) => s + v.created, 0),
[dayMap]
);
const totalDone = useMemo(
() => Array.from(dayMap.values()).reduce((s, v) => s + v.done, 0),
[dayMap]
);
return (
<Box p="md">
<Stack gap="md">
<Group justify="space-between">
<Title order={3}>Calendario</Title>
<Group gap="xs" wrap="nowrap">
<MonthPickerInput
label="Mes"
size="xs"
value={month}
onChange={(v) => v && setMonth(typeof v === "string" ? new Date(v) : v)}
style={{ minWidth: 160 }}
clearable={false}
/>
<Select
label="Asignado"
size="xs"
placeholder="Todos"
value={assigneeId}
onChange={setAssigneeId}
data={userOptions}
clearable
searchable
style={{ minWidth: 180 }}
/>
</Group>
</Group>
<Group gap="md">
<Paper withBorder p="sm" radius="md">
<Group gap={6}>
<IconPlus size={14} color="var(--mantine-color-blue-5)" />
<Text size="sm" fw={600}>
{totalCreated}
</Text>
<Text size="xs" c="dimmed">
creadas
</Text>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group gap={6}>
<IconCheckbox size={14} color="var(--mantine-color-green-5)" />
<Text size="sm" fw={600}>
{totalDone}
</Text>
<Text size="xs" c="dimmed">
hechas
</Text>
</Group>
</Paper>
</Group>
{loading && !data ? (
<Center p="xl">
<Loader />
</Center>
) : (
<Paper withBorder p="md" radius="md">
<SimpleGrid cols={7} spacing={4} mb={4}>
{DAY_LABELS.map((d) => (
<Text key={d} size="xs" c="dimmed" ta="center" fw={600}>
{d}
</Text>
))}
</SimpleGrid>
<SimpleGrid cols={7} spacing={4}>
{grid.map((cell, i) => {
if (!cell.date) {
return <Box key={i} style={{ minHeight: 72 }} />;
}
const stats = dayMap.get(cell.date) ?? { created: 0, done: 0 };
const dayNum = parseInt(cell.date.slice(8, 10), 10);
const isToday = cell.date === dayjs().format("YYYY-MM-DD");
return (
<Paper
key={i}
p={6}
withBorder
radius="sm"
style={{
minHeight: 72,
borderColor: isToday ? "var(--mantine-color-blue-5)" : undefined,
background:
stats.done > 0
? "rgba(81, 207, 102, 0.08)"
: stats.created > 0
? "rgba(34, 139, 230, 0.06)"
: undefined,
}}
>
<Stack gap={2}>
<Text size="xs" fw={isToday ? 700 : 500} c={isToday ? "blue" : undefined}>
{dayNum}
</Text>
{stats.created > 0 && (
<Group gap={3} wrap="nowrap">
<IconPlus size={10} color="var(--mantine-color-blue-5)" />
<Text size="xs" c="blue">
{stats.created}
</Text>
</Group>
)}
{stats.done > 0 && (
<Group gap={3} wrap="nowrap">
<IconCheckbox size={10} color="var(--mantine-color-green-5)" />
<Text size="xs" c="green">
{stats.done}
</Text>
</Group>
)}
</Stack>
</Paper>
);
})}
</SimpleGrid>
</Paper>
)}
</Stack>
</Box>
);
}
+34 -13
View File
@@ -1,32 +1,40 @@
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { Button, Group, Select, Stack, Textarea, TextInput } from "@mantine/core";
import { FormEvent, KeyboardEvent, useState } from "react";
import type { User } from "../types";
export interface CardFormValues {
requester: string;
title: string;
description: string;
assignee_id: string | null;
}
interface Props {
initial?: Partial<CardFormValues>;
submitLabel?: string;
users?: User[];
onSubmit: (v: CardFormValues) => Promise<void> | void;
onCancel: () => void;
}
export function CardForm({ initial, submitLabel = "Guardar", onSubmit, onCancel }: Props) {
export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmit, onCancel }: Props) {
const [requester, setRequester] = useState(initial?.requester ?? "");
const [title, setTitle] = useState(initial?.title ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
const submit = async (e?: FormEvent) => {
e?.preventDefault();
const t = title.trim();
if (!t) return;
await onSubmit({ requester: requester.trim(), title: t, description });
await onSubmit({
requester: requester.trim(),
title: t,
description,
assignee_id: assigneeId,
});
};
// Enter en TextInput envia el form. Enter en Textarea inserta newline; Ctrl/Cmd+Enter envia.
const enterSubmit = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@@ -44,20 +52,20 @@ export function CardForm({ initial, submitLabel = "Guardar", onSubmit, onCancel
<form onSubmit={submit}>
<Stack gap="sm">
<TextInput
label="Solicitante"
value={requester}
onChange={(e) => setRequester(e.currentTarget.value)}
label="Tarea"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
tabIndex={1}
required
autoComplete="off"
data-autofocus
onKeyDown={enterSubmit}
/>
<TextInput
label="Tarea"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
label="Solicitante"
value={requester}
onChange={(e) => setRequester(e.currentTarget.value)}
tabIndex={2}
required
autoComplete="off"
onKeyDown={enterSubmit}
/>
@@ -72,11 +80,24 @@ export function CardForm({ initial, submitLabel = "Guardar", onSubmit, onCancel
onKeyDown={textareaEnter}
description="Ctrl+Enter para guardar"
/>
<Select
label="Asignar a"
placeholder="Sin asignar"
value={assigneeId}
onChange={(v) => setAssigneeId(v)}
data={users.map((u) => ({
value: u.id,
label: u.display_name || u.username,
}))}
clearable
searchable
tabIndex={4}
/>
<Group justify="flex-end" gap="xs" mt="xs">
<Button variant="subtle" color="gray" tabIndex={5} type="button" onClick={onCancel}>
<Button variant="subtle" color="gray" tabIndex={6} type="button" onClick={onCancel}>
Cancelar
</Button>
<Button tabIndex={4} type="submit" disabled={!title.trim()}>
<Button tabIndex={5} type="submit" disabled={!title.trim()}>
{submitLabel}
</Button>
</Group>
+467
View File
@@ -0,0 +1,467 @@
import { AreaChart, BarChart, LineChart } from "@mantine/charts";
import "@mantine/charts/styles.css";
import {
Badge,
Box,
Center,
Grid,
Group,
Loader,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import "@mantine/dates/styles.css";
import {
IconCheckbox,
IconClipboardList,
IconClockHour4,
IconLock,
IconTrendingUp,
} 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 { formatDuration } from "./format";
interface Props {
users: User[];
}
function fmtDate(d: Date | null): string | undefined {
if (!d) return undefined;
return dayjs(d).format("YYYY-MM-DD");
}
function KPI({
icon,
label,
value,
hint,
color,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
hint?: string;
color?: string;
}) {
return (
<Paper withBorder p="md" radius="md">
<Stack gap={4}>
<Group gap={6} c="dimmed">
{icon}
<Text size="xs" tt="uppercase" fw={600}>
{label}
</Text>
</Group>
<Text size="xl" fw={700} c={color}>
{value}
</Text>
{hint && (
<Text size="xs" c="dimmed">
{hint}
</Text>
)}
</Stack>
</Paper>
);
}
export function Dashboard({ users }: Props) {
const [from, setFrom] = useState<Date | null>(() => dayjs().subtract(30, "day").toDate());
const [to, setTo] = useState<Date | null>(() => new Date());
const [assigneeId, setAssigneeId] = useState<string | null>(null);
const [requester, setRequester] = useState<string | null>(null);
const [data, setData] = useState<Metrics | null>(null);
const [loading, setLoading] = useState(false);
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
setLoading(true);
api
.getMetrics({
from: fmtDate(from),
to: fmtDate(to),
assignee_id: assigneeId || undefined,
requester: requester || undefined,
})
.then((m) => {
if (cancelled) return;
setData(m);
setRequesterOptions((prev) => {
const set = new Set(prev);
for (const r of m.top_requesters) set.add(r.requester);
return Array.from(set).sort();
});
})
.catch(() => {})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [from, to, assigneeId, requester]);
const userOptions = useMemo(
() => users.map((u) => ({ value: u.id, label: u.display_name || u.username })),
[users]
);
const cumulativeFlow = useMemo(() => {
if (!data) return [];
const arr = data.cumulative_flow;
const firstIdx = arr.findIndex((p) => p.total > 0 || p.done > 0);
if (firstIdx <= 0) return arr;
return arr.slice(Math.max(0, firstIdx - 1));
}, [data]);
const throughputSeries = useMemo(() => {
if (!data) return [];
const map = new Map<string, { date: string; completed: number; created: number }>();
for (const d of data.throughput_daily) {
map.set(d.date, { date: d.date, completed: d.count, created: 0 });
}
for (const d of data.created_daily) {
const cur = map.get(d.date) ?? { date: d.date, completed: 0, created: 0 };
cur.created = d.count;
map.set(d.date, cur);
}
return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date));
}, [data]);
const byColumnSeries = useMemo(() => {
if (!data) return [];
return data.by_column.map((c) => ({
column: c.name + (c.is_done ? " ✓" : ""),
tarjetas: c.count,
}));
}, [data]);
const topAssigneeSeries = useMemo(() => {
if (!data) return [];
return data.top_assignees
.slice()
.sort((a, b) => b.completed_in_range + b.active - (a.completed_in_range + a.active))
.slice(0, 8)
.map((a) => ({
usuario: a.display_name || a.username,
completadas: a.completed_in_range,
activas: a.active,
}));
}, [data]);
const topRequesterSeries = useMemo(() => {
if (!data) return [];
return data.top_requesters.map((r) => ({
solicitante: r.requester,
tarjetas: r.total,
}));
}, [data]);
const movementsSeries = useMemo(() => {
if (!data) return [];
return data.movements_by_user
.filter((m) => m.moves > 0)
.slice(0, 8)
.map((m) => ({
usuario: m.display_name || m.username,
movimientos: m.moves,
}));
}, [data]);
return (
<Box p="md">
<Stack gap="md">
<Group justify="space-between">
<Title order={3}>Dashboard</Title>
<Group gap="xs" wrap="nowrap">
<DatePickerInput
label="Desde"
value={from}
onChange={(v) => setFrom(v as Date | null)}
size="xs"
clearable={false}
valueFormat="YYYY-MM-DD"
style={{ minWidth: 140 }}
/>
<DatePickerInput
label="Hasta"
value={to}
onChange={(v) => setTo(v as Date | null)}
size="xs"
clearable={false}
valueFormat="YYYY-MM-DD"
style={{ minWidth: 140 }}
/>
<Select
label="Asignado"
size="xs"
placeholder="Todos"
value={assigneeId}
onChange={setAssigneeId}
data={userOptions}
clearable
searchable
style={{ minWidth: 160 }}
/>
<Select
label="Solicitante"
size="xs"
placeholder="Todos"
value={requester}
onChange={setRequester}
data={requesterOptions.map((r) => ({ value: r, label: r }))}
clearable
searchable
style={{ minWidth: 160 }}
/>
</Group>
</Group>
{loading && !data && (
<Center p="xl">
<Loader />
</Center>
)}
{data && (
<>
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
<KPI
icon={<IconClipboardList size={14} />}
label="Tarjetas totales"
value={data.totals.cards}
hint={`${data.totals.columns} columnas, ${data.totals.users} usuarios`}
/>
<KPI
icon={<IconCheckbox size={14} />}
label="Completadas (rango)"
value={data.totals.cards_completed_in_range}
hint={`${data.totals.cards_created_in_range} creadas en rango`}
color="green"
/>
<KPI
icon={<IconClockHour4 size={14} />}
label="Lead time p50"
value={formatDuration(data.lead_time.p50_ms)}
hint={`p90 ${formatDuration(data.lead_time.p90_ms)} · n=${data.lead_time.n}`}
/>
<KPI
icon={<IconLock size={14} />}
label="Bloqueos activos"
value={data.totals.active_locks}
hint={`Total bloqueado: ${formatDuration(data.lock_total_ms)}`}
color={data.totals.active_locks > 0 ? "yellow" : undefined}
/>
</SimpleGrid>
<Paper withBorder p="md" radius="md">
<Group gap={6} mb="sm">
<IconTrendingUp size={16} />
<Text fw={600}>Cumulative Flow Diagram</Text>
<Text size="xs" c="dimmed">
total vs hechas (acumulado)
</Text>
</Group>
{cumulativeFlow.length === 0 ? (
<Text c="dimmed" size="sm">
Sin datos.
</Text>
) : (
<AreaChart
h={260}
data={cumulativeFlow}
dataKey="date"
withLegend
withDots
withGradient
fillOpacity={0.5}
strokeWidth={2.5}
curveType="monotone"
gridAxis="xy"
series={[
{ name: "total", label: "Total", color: "blue.6" },
{ name: "done", label: "Hechas", color: "green.6" },
]}
yAxisProps={{ allowDecimals: false }}
/>
)}
</Paper>
<Grid>
<Grid.Col span={{ base: 12, md: 8 }}>
<Paper withBorder p="md" radius="md">
<Group gap={6} mb="sm">
<IconTrendingUp size={16} />
<Text fw={600}>Throughput diario</Text>
</Group>
{throughputSeries.length === 0 ? (
<Text c="dimmed" size="sm">
Sin datos en el rango.
</Text>
) : (
<LineChart
h={240}
data={throughputSeries}
dataKey="date"
curveType="monotone"
withLegend
series={[
{ name: "completed", label: "Completadas", color: "green.6" },
{ name: "created", label: "Creadas", color: "blue.6" },
]}
/>
)}
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<Paper withBorder p="md" radius="md">
<Text fw={600} mb="sm">
Tarjetas por columna
</Text>
{byColumnSeries.length === 0 ? (
<Text c="dimmed" size="sm">
Sin columnas.
</Text>
) : (
<BarChart
h={240}
data={byColumnSeries}
dataKey="column"
orientation="vertical"
yAxisProps={{ width: 100 }}
series={[{ name: "tarjetas", label: "Tarjetas", color: "blue.6" }]}
/>
)}
</Paper>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<Paper withBorder p="md" radius="md">
<Text fw={600} mb="sm">
Top asignados
</Text>
{topAssigneeSeries.length === 0 ? (
<Text c="dimmed" size="sm">
Sin asignaciones.
</Text>
) : (
<BarChart
h={240}
data={topAssigneeSeries}
dataKey="usuario"
orientation="vertical"
yAxisProps={{ width: 120 }}
withLegend
series={[
{ name: "completadas", label: "Completadas", color: "green.6" },
{ name: "activas", label: "Activas", color: "blue.6" },
]}
type="stacked"
/>
)}
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Paper withBorder p="md" radius="md">
<Text fw={600} mb="sm">
Top solicitantes
</Text>
{topRequesterSeries.length === 0 ? (
<Text c="dimmed" size="sm">
Sin solicitantes en el rango.
</Text>
) : (
<BarChart
h={240}
data={topRequesterSeries}
dataKey="solicitante"
orientation="vertical"
yAxisProps={{ width: 120 }}
series={[{ name: "tarjetas", label: "Tarjetas", color: "violet.6" }]}
/>
)}
</Paper>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<Paper withBorder p="md" radius="md">
<Text fw={600} mb="sm">
Movimientos por usuario (rango)
</Text>
{movementsSeries.length === 0 ? (
<Text c="dimmed" size="sm">
Sin movimientos registrados.
</Text>
) : (
<BarChart
h={240}
data={movementsSeries}
dataKey="usuario"
orientation="vertical"
yAxisProps={{ width: 120 }}
series={[{ name: "movimientos", label: "Movimientos", color: "orange.6" }]}
/>
)}
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Paper withBorder p="md" radius="md">
<Text fw={600} mb="sm">
Tiempo en columna (cycle time)
</Text>
<Table striped highlightOnHover withTableBorder withColumnBorders fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Columna</Table.Th>
<Table.Th>n</Table.Th>
<Table.Th>p50</Table.Th>
<Table.Th>p90</Table.Th>
<Table.Th>avg</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.cycle_time_per_column.map((c) => (
<Table.Tr key={c.column_id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={500}>
{c.name}
</Text>
{c.is_done && (
<Badge size="xs" color="green" variant="light">
done
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>{c.stats.n}</Table.Td>
<Table.Td>{c.stats.n > 0 ? formatDuration(c.stats.p50_ms) : "—"}</Table.Td>
<Table.Td>{c.stats.n > 0 ? formatDuration(c.stats.p90_ms) : "—"}</Table.Td>
<Table.Td>{c.stats.n > 0 ? formatDuration(c.stats.avg_ms) : "—"}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
</Grid.Col>
</Grid>
</>
)}
</Stack>
</Box>
);
}
+68 -9
View File
@@ -1,8 +1,8 @@
import { Badge, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
import { IconColumns3 } from "@tabler/icons-react";
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, HistoryEntry } from "../types";
import type { Card, CardHistoryResponse } from "../types";
import { formatDuration } from "./format";
interface Props {
@@ -10,13 +10,17 @@ interface Props {
}
export function HistoryModal({ card }: Props) {
const [entries, setEntries] = useState<HistoryEntry[] | null>(null);
const [data, setData] = useState<CardHistoryResponse | null>(null);
useEffect(() => {
cardHistory(card.id).then(setEntries).catch(() => setEntries([]));
cardHistory(card.id)
.then(setData)
.catch(() =>
setData({ column_history: [], lock_periods: [], total_locked_ms: 0, currently_locked: false })
);
}, [card.id]);
if (!entries) {
if (!data) {
return (
<Group justify="center" p="xl">
<Loader size="sm" />
@@ -24,7 +28,9 @@ export function HistoryModal({ card }: Props) {
);
}
if (entries.length === 0) {
const { column_history, lock_periods, total_locked_ms, currently_locked } = data;
if (column_history.length === 0 && lock_periods.length === 0) {
return <Text c="dimmed">Sin historial.</Text>;
}
@@ -33,8 +39,8 @@ export function HistoryModal({ card }: Props) {
<Text size="sm" c="dimmed">
Tiempo total en cada columna desde que se creo la tarjeta.
</Text>
<Timeline active={entries.length} bulletSize={22} lineWidth={2}>
{entries.map((e) => (
<Timeline active={column_history.length} bulletSize={22} lineWidth={2}>
{column_history.map((e) => (
<Timeline.Item
key={e.id}
bullet={<IconColumns3 size={12} />}
@@ -61,6 +67,59 @@ export function HistoryModal({ card }: Props) {
</Timeline.Item>
))}
</Timeline>
<Divider />
<Group gap={6} align="center">
<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>
)}
</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>
);
}
+229 -71
View File
@@ -2,25 +2,32 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ActionIcon,
Avatar,
Badge,
Group,
Menu,
Paper,
Popover,
Select,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import {
IconClock,
IconDotsVertical,
IconEdit,
IconGripVertical,
IconHistory,
IconLock,
IconLockOpen,
IconPalette,
IconTrash,
IconUser,
IconUserCircle,
} from "@tabler/icons-react";
import { memo, useState } from "react";
import type { Card, CardColor } from "../types";
import type { Card, CardColor, User } from "../types";
import { CARD_COLORS, colorBg, colorBorder, colorSwatch } from "./colors";
import { formatDuration } from "./format";
@@ -31,14 +38,36 @@ interface Props {
onEdit: (card: Card) => void;
onChangeColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => void;
onToggleLock: (id: string, locked: boolean) => void;
onAssign: (id: string, assignee_id: string | null) => void;
users: User[];
assignee?: User;
inDoneColumn?: boolean;
isOverlay?: boolean;
}
function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHistory, isOverlay }: Props) {
const [popOpen, setPopOpen] = useState(false);
function KanbanCardImpl({
card,
now,
onDelete,
onEdit,
onChangeColor,
onShowHistory,
onToggleLock,
onAssign,
users,
assignee,
inDoneColumn,
isOverlay,
}: Props) {
const isDone = inDoneColumn || !!card.completed_at;
const [colorPopOpen, setColorPopOpen] = useState(false);
const [assigneePopOpen, setAssigneePopOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: card.id,
data: { type: "card", columnId: card.column_id },
disabled: card.locked,
});
const style: React.CSSProperties = {
@@ -46,84 +75,205 @@ function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHist
transition,
opacity: isDragging ? 0.4 : 1,
background: colorBg(card.color),
borderColor: colorBorder(card.color),
borderColor: card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
borderWidth: card.locked ? 2 : 1,
};
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
const liveMs = Math.max(0, now - enteredAt);
const onContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setMenuOpen(true);
};
const menuItems = (
<>
<Menu.Label>Acciones</Menu.Label>
<Menu.Item
leftSection={<IconEdit size={14} />}
onClick={() => {
setMenuOpen(false);
onEdit(card);
}}
>
Editar
</Menu.Item>
<Popover
opened={colorPopOpen}
onChange={setColorPopOpen}
position="right-start"
withArrow
shadow="md"
>
<Popover.Target>
<Menu.Item
leftSection={<IconPalette size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setColorPopOpen((v) => !v);
}}
closeMenuOnClick={false}
>
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>
</Popover>
<Popover
opened={assigneePopOpen}
onChange={setAssigneePopOpen}
position="right-start"
withArrow
shadow="md"
>
<Popover.Target>
<Menu.Item
leftSection={<IconUserCircle size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAssigneePopOpen((v) => !v);
}}
closeMenuOnClick={false}
>
Asignar a {assignee ? `(${assignee.display_name || assignee.username})` : "..."}
</Menu.Item>
</Popover.Target>
<Popover.Dropdown p="xs">
<Select
placeholder="Sin asignar"
value={card.assignee_id ?? null}
onChange={(v) => {
onAssign(card.id, v);
setAssigneePopOpen(false);
setMenuOpen(false);
}}
data={users.map((u) => ({ value: u.id, label: u.display_name || u.username }))}
clearable
searchable
autoFocus
/>
</Popover.Dropdown>
</Popover>
<Menu.Item
leftSection={card.locked ? <IconLockOpen size={14} /> : <IconLock size={14} />}
color={card.locked ? "yellow" : undefined}
onClick={() => {
setMenuOpen(false);
onToggleLock(card.id, !card.locked);
}}
>
{card.locked ? "Desbloquear" : "Bloquear"}
</Menu.Item>
<Menu.Item
leftSection={<IconHistory size={14} />}
onClick={() => {
setMenuOpen(false);
onShowHistory(card);
}}
>
Historial
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={() => {
setMenuOpen(false);
onDelete(card.id);
}}
>
Borrar
</Menu.Item>
</>
);
return (
<Paper ref={setNodeRef} style={style} withBorder p="xs" shadow={isOverlay ? "lg" : "xs"} radius="md">
<Paper
ref={setNodeRef}
style={{ ...style, cursor: card.locked ? "default" : "grab", touchAction: "none" }}
withBorder
p="xs"
shadow={isOverlay ? "lg" : "xs"}
radius="md"
onContextMenu={onContextMenu}
onDoubleClick={(e) => {
e.stopPropagation();
onEdit(card);
}}
{...attributes}
{...(card.locked ? {} : listeners)}
>
<Stack gap={6}>
<Group justify="space-between" gap={4} wrap="nowrap">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<ActionIcon
variant="subtle"
color="gray"
<Group justify="space-between" gap={4} wrap="nowrap" align="flex-start">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }} align="flex-start">
<IconGripVertical
size={14}
color="var(--mantine-color-dark-2)"
style={{ flexShrink: 0, marginTop: 4 }}
/>
{card.locked && (
<Tooltip label="Bloqueada" withArrow>
<IconLock
size={14}
color="var(--mantine-color-yellow-6)"
style={{ flexShrink: 0, marginTop: 4 }}
/>
</Tooltip>
)}
<Text
size="sm"
{...attributes}
{...listeners}
style={{ cursor: "grab" }}
aria-label="Drag"
fw={500}
style={{
flex: 1,
wordBreak: "break-word",
whiteSpace: "normal",
textDecoration: isDone ? "line-through" : "none",
opacity: isDone ? 0.7 : 1,
}}
>
<IconGripVertical size={14} />
</ActionIcon>
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>
{card.title}
</Text>
</Group>
<Group gap={2} wrap="nowrap">
<Popover opened={popOpen} onChange={setPopOpen} withArrow shadow="md" position="bottom-end">
<Popover.Target>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => setPopOpen((v) => !v)}
aria-label="Color"
>
<IconPalette size={14} />
</ActionIcon>
</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);
setPopOpen(false);
}}
aria-label={c.label}
/>
</Tooltip>
))}
</Group>
</Popover.Dropdown>
</Popover>
<ActionIcon variant="subtle" color="gray" size="sm" onClick={() => onEdit(card)} aria-label="Edit">
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => onShowHistory(card)}
aria-label="History"
>
<IconHistory size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => onDelete(card.id)} aria-label="Delete">
<IconTrash size={14} />
</ActionIcon>
</Group>
<Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label="Acciones"
style={{ flexShrink: 0 }}
onPointerDown={(e) => e.stopPropagation()}
>
<IconDotsVertical size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>{menuItems}</Menu.Dropdown>
</Menu>
</Group>
{card.requester && (
<Group gap={4}>
@@ -133,6 +283,16 @@ function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHist
</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>
</Group>
)}
{card.description && (
<Text size="xs" c="dimmed" lineClamp={3}>
{card.description}
@@ -148,6 +308,4 @@ function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHist
);
}
// memo: re-render solo cuando cambian props relevantes (card, now). Evita rerenders
// en cascada cuando otra columna cambia durante drag-over.
export const KanbanCard = memo(KanbanCardImpl);
+233 -78
View File
@@ -7,7 +7,10 @@ import {
Box,
Button,
Group,
Menu,
NumberInput,
Paper,
Popover,
ScrollArea,
Stack,
Text,
@@ -17,7 +20,12 @@ import {
import {
IconArchive,
IconArchiveOff,
IconAlertTriangle,
IconCheck,
IconCheckbox,
IconChevronDown,
IconChevronRight,
IconDotsVertical,
IconGripVertical,
IconPencil,
IconPlus,
@@ -25,7 +33,7 @@ import {
IconX,
} from "@tabler/icons-react";
import { memo, MouseEvent, useEffect, useRef, useState } from "react";
import type { Card, CardColor, Column } from "../types";
import type { Card, CardColor, Column, User } from "../types";
import { KanbanCard } from "./KanbanCard";
interface Props {
@@ -38,10 +46,16 @@ interface Props {
onResizeColumn: (id: string, width: number) => void;
onMoveColumnLocation: (id: string, location: "board" | "sidebar") => void;
onDeleteColumn: (id: string) => void;
onSetWIPLimit: (id: string, limit: number) => void;
onToggleDone: (id: string, is_done: boolean) => void;
onEditCard: (card: Card) => void;
onDeleteCard: (id: string) => void;
onChangeCardColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => void;
onToggleCardLock: (id: string, locked: boolean) => void;
onAssignCard: (id: string, assignee_id: string | null) => void;
users: User[];
usersById: Map<string, User>;
}
function KanbanColumnImpl({
@@ -54,14 +68,34 @@ function KanbanColumnImpl({
onResizeColumn,
onMoveColumnLocation,
onDeleteColumn,
onSetWIPLimit,
onToggleDone,
onEditCard,
onDeleteCard,
onChangeCardColor,
onShowHistory,
onToggleCardLock,
onAssignCard,
users,
usersById,
}: Props) {
const [renaming, setRenaming] = useState(false);
const [name, setName] = useState(column.name);
const [localWidth, setLocalWidth] = useState<number | null>(null);
const [wipPopOpen, setWipPopOpen] = useState(false);
const [wipDraft, setWipDraft] = useState<number | string>(column.wip_limit);
const [bodyHidden, setBodyHidden] = useState(() => {
if (!collapsed) return false;
return localStorage.getItem(`kanban_col_body_${column.id}`) === "1";
});
useEffect(() => {
if (collapsed) {
localStorage.setItem(`kanban_col_body_${column.id}`, bodyHidden ? "1" : "0");
}
}, [bodyHidden, collapsed, column.id]);
const wipLimit = column.wip_limit;
const overLimit = wipLimit > 0 && cards.length > wipLimit;
// sync local width when column.width changes from outside (other clients).
useEffect(() => {
@@ -73,20 +107,32 @@ function KanbanColumnImpl({
data: { type: "column", columnId: column.id, location: column.location },
});
const effectiveWidth = collapsed ? 220 : localWidth ?? column.width;
const effectiveWidth = collapsed ? "100%" : localWidth ?? column.width;
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
width: effectiveWidth,
minWidth: effectiveWidth,
maxWidth: effectiveWidth,
display: "flex",
flexDirection: "column",
height: "100%",
position: "relative",
};
const style: React.CSSProperties = collapsed
? {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
width: "100%",
display: "flex",
flexDirection: "column",
position: "relative",
flex: bodyHidden ? "0 0 auto" : "1 1 auto",
minHeight: 0,
}
: {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
width: effectiveWidth,
minWidth: effectiveWidth,
maxWidth: effectiveWidth,
display: "flex",
flexDirection: "column",
height: "100%",
position: "relative",
};
const cardIds = cards.map((c) => c.id);
@@ -135,8 +181,24 @@ function KanbanColumnImpl({
const archiveLabel = isInSidebar ? "Restaurar al board" : "Mover al sidebar";
const ArchiveIcon = isInSidebar ? IconArchiveOff : IconArchive;
const submitWIP = () => {
const n = typeof wipDraft === "number" ? wipDraft : parseInt(String(wipDraft), 10);
const safe = Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
if (safe !== column.wip_limit) onSetWIPLimit(column.id, safe);
setWipPopOpen(false);
};
const paperBg = overLimit ? "var(--mantine-color-red-9)" : "var(--mantine-color-dark-7)";
const paperBorderColor = overLimit ? "var(--mantine-color-red-6)" : undefined;
return (
<Paper ref={setNodeRef} style={style} withBorder radius="md" p="sm" bg="dark.7">
<Paper
ref={setNodeRef}
style={{ ...style, background: paperBg, borderColor: paperBorderColor, borderWidth: overLimit ? 2 : 1 }}
withBorder
radius="md"
p="sm"
>
<Group justify="space-between" mb="xs" wrap="nowrap">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<ActionIcon
@@ -181,9 +243,65 @@ function KanbanColumnImpl({
{column.name}
</Text>
)}
<Badge size="xs" variant="light" color="gray">
{cards.length}
</Badge>
<Popover
opened={wipPopOpen}
onChange={(o) => {
setWipPopOpen(o);
if (o) setWipDraft(column.wip_limit);
}}
position="bottom"
withArrow
shadow="md"
>
<Popover.Target>
<Tooltip
label={
wipLimit > 0
? `WIP ${cards.length}/${wipLimit}${overLimit ? " (excedido)" : ""}`
: "Click para limitar WIP"
}
withArrow
>
<Badge
size="xs"
variant={overLimit ? "filled" : "light"}
color={overLimit ? "red" : wipLimit > 0 ? "yellow" : "gray"}
leftSection={overLimit ? <IconAlertTriangle size={10} /> : null}
style={{ cursor: "pointer" }}
onClick={() => setWipPopOpen((v) => !v)}
>
{wipLimit > 0 ? `${cards.length}/${wipLimit}` : cards.length}
</Badge>
</Tooltip>
</Popover.Target>
<Popover.Dropdown p="xs">
<Stack gap="xs">
<Text size="xs" c="dimmed">
Maximo de tarjetas (0 = sin limite)
</Text>
<NumberInput
size="xs"
value={wipDraft}
onChange={setWipDraft}
min={0}
max={999}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") submitWIP();
if (e.key === "Escape") setWipPopOpen(false);
}}
/>
<Group justify="flex-end" gap={4}>
<Button size="xs" variant="subtle" onClick={() => setWipPopOpen(false)}>
Cancelar
</Button>
<Button size="xs" onClick={submitWIP}>
Guardar
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
<Group gap={2} wrap="nowrap">
{renaming ? (
@@ -206,72 +324,109 @@ function KanbanColumnImpl({
</>
) : (
<>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => {
setName(column.name);
setRenaming(true);
}}
aria-label="Rename"
>
<IconPencil size={14} />
</ActionIcon>
<Tooltip label={archiveLabel} withArrow>
<ActionIcon
variant="subtle"
color="blue"
size="sm"
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
aria-label={archiveLabel}
>
<ArchiveIcon size={14} />
</ActionIcon>
</Tooltip>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => onDeleteColumn(column.id)}
aria-label="Delete column"
>
<IconTrash size={14} />
</ActionIcon>
{collapsed && (
<Tooltip label={bodyHidden ? "Expandir" : "Colapsar"} withArrow>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => setBodyHidden((v) => !v)}
aria-label={bodyHidden ? "Expandir columna" : "Colapsar columna"}
>
{bodyHidden ? <IconChevronRight size={14} /> : <IconChevronDown size={14} />}
</ActionIcon>
</Tooltip>
)}
{column.is_done && (
<Tooltip label="Columna Done" withArrow>
<Badge size="xs" color="green" variant="filled" leftSection={<IconCheckbox size={10} />}>
done
</Badge>
</Tooltip>
)}
<Menu position="bottom-end" shadow="md" withArrow>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones columna">
<IconDotsVertical size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Columna</Menu.Label>
<Menu.Item
leftSection={<IconPencil size={14} />}
onClick={() => {
setName(column.name);
setRenaming(true);
}}
>
Renombrar
</Menu.Item>
<Menu.Item
leftSection={<IconCheckbox size={14} />}
color={column.is_done ? "yellow" : "green"}
onClick={() => onToggleDone(column.id, !column.is_done)}
>
{column.is_done ? "Quitar marca Done" : "Marcar como Done"}
</Menu.Item>
<Menu.Item
leftSection={<ArchiveIcon size={14} />}
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
>
{archiveLabel}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={() => onDeleteColumn(column.id)}
>
Borrar columna
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
)}
</Group>
</Group>
<ScrollArea style={{ flex: 1 }} type="auto">
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
<Stack gap="xs" pb="xs" style={{ minHeight: 40 }}>
{cards.map((c) => (
<KanbanCard
key={c.id}
card={c}
now={now}
onDelete={onDeleteCard}
onEdit={onEditCard}
onChangeColor={onChangeCardColor}
onShowHistory={onShowHistory}
/>
))}
</Stack>
</SortableContext>
</ScrollArea>
{!(collapsed && bodyHidden) && (
<>
<ScrollArea style={{ flex: 1 }} type="auto">
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
<Stack gap="xs" pb="xs" style={{ minHeight: 40 }}>
{cards.map((c) => (
<KanbanCard
key={c.id}
card={c}
now={now}
onDelete={onDeleteCard}
onEdit={onEditCard}
onChangeColor={onChangeCardColor}
onShowHistory={onShowHistory}
onToggleLock={onToggleCardLock}
onAssign={onAssignCard}
users={users}
assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined}
inDoneColumn={column.is_done}
/>
))}
</Stack>
</SortableContext>
</ScrollArea>
<Button
variant="subtle"
color="gray"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() => onAddCard(column.id)}
mt="xs"
fullWidth
>
Anadir tarjeta
</Button>
<Button
variant="subtle"
color="gray"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() => onAddCard(column.id)}
mt="xs"
fullWidth
>
Anadir tarjeta
</Button>
</>
)}
{/* Resize handle (only on board, not sidebar) */}
{!isInSidebar && (
+106
View File
@@ -0,0 +1,106 @@
import {
Anchor,
Button,
Center,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { IconLayoutKanban } from "@tabler/icons-react";
import { useState } from "react";
import { useAuth } from "../auth";
type Mode = "login" | "register";
export function LoginPage() {
const auth = useAuth();
const [mode, setMode] = useState<Mode>("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [displayName, setDisplayName] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
if (mode === "login") {
await auth.login(username.trim(), password);
} else {
await auth.register(username.trim(), password, displayName.trim() || username.trim());
}
} catch (err) {
setError((err as Error).message);
} finally {
setSubmitting(false);
}
};
return (
<Center style={{ minHeight: "100vh" }} p="md">
<Paper p="xl" withBorder radius="md" shadow="md" style={{ width: 360, maxWidth: "100%" }}>
<form onSubmit={submit}>
<Stack gap="md">
<Stack gap={4} align="center">
<IconLayoutKanban size={36} />
<Title order={3}>Kanban</Title>
<Text size="sm" c="dimmed">
{mode === "login" ? "Inicia sesion" : "Crea una cuenta"}
</Text>
</Stack>
<TextInput
label="Usuario"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
required
autoFocus
autoComplete="username"
/>
{mode === "register" && (
<TextInput
label="Nombre (opcional)"
value={displayName}
onChange={(e) => setDisplayName(e.currentTarget.value)}
autoComplete="name"
/>
)}
<PasswordInput
label="Contrasena"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
autoComplete={mode === "login" ? "current-password" : "new-password"}
/>
{error && (
<Text size="sm" c="red">
{error}
</Text>
)}
<Button type="submit" loading={submitting} fullWidth>
{mode === "login" ? "Entrar" : "Registrar"}
</Button>
<Text size="xs" c="dimmed" ta="center">
{mode === "login" ? "No tienes cuenta?" : "Ya tienes cuenta?"}{" "}
<Anchor
component="button"
type="button"
size="xs"
onClick={() => {
setError(null);
setMode(mode === "login" ? "register" : "login");
}}
>
{mode === "login" ? "Registrate" : "Inicia sesion"}
</Anchor>
</Text>
</Stack>
</form>
</Paper>
</Center>
);
}
+5 -2
View File
@@ -4,7 +4,8 @@ import { MantineProvider, createTheme } from "@mantine/core";
import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { AuthProvider } from "./auth";
import { Root } from "./Root";
const theme = createTheme({
primaryColor: "blue",
@@ -15,7 +16,9 @@ createRoot(document.getElementById("root")!).render(
<MantineProvider theme={theme} defaultColorScheme="dark">
<ModalsProvider>
<Notifications position="top-right" />
<App />
<AuthProvider>
<Root />
</AuthProvider>
</ModalsProvider>
</MantineProvider>
);
+118
View File
@@ -6,6 +6,8 @@ export interface Column {
position: number;
location: ColumnLocation;
width: number;
wip_limit: number;
is_done: boolean;
created_at: string;
}
@@ -19,12 +21,113 @@ export interface Card {
color: CardColor;
column_id: string;
position: number;
locked: boolean;
assignee_id: string | null;
completed_at: string | null;
deleted_at: string | null;
created_at: string;
updated_at: string;
entered_at: string;
time_in_column_ms: number;
}
export interface User {
id: string;
username: string;
display_name: string;
created_at: string;
}
export interface MetricsRange {
from: string;
to: string;
}
export interface MetricsTotals {
cards: number;
cards_completed_in_range: number;
cards_created_in_range: number;
columns: number;
users: number;
active_locks: number;
}
export interface MetricsColumnCount {
column_id: string;
name: string;
is_done: boolean;
count: number;
}
export interface MetricsDailyCount {
date: string;
count: number;
}
export interface MetricsDurationStats {
n: number;
avg_ms: number;
p50_ms: number;
p90_ms: number;
p99_ms: number;
}
export interface MetricsColumnDuration {
column_id: string;
name: string;
is_done: boolean;
stats: MetricsDurationStats;
}
export interface MetricsAssignee {
user_id: string;
username: string;
display_name: string;
active: number;
completed_in_range: number;
}
export interface MetricsRequester {
requester: string;
total: number;
}
export interface MetricsMovement {
user_id: string;
username: string;
display_name: string;
moves: number;
}
export interface MetricsCumulativePoint {
date: string;
total: number;
done: number;
}
export interface Metrics {
range: MetricsRange;
totals: MetricsTotals;
by_column: MetricsColumnCount[];
throughput_daily: MetricsDailyCount[];
created_daily: MetricsDailyCount[];
lead_time: MetricsDurationStats;
cycle_time_per_column: MetricsColumnDuration[];
top_assignees: MetricsAssignee[];
top_requesters: MetricsRequester[];
movements_by_user: MetricsMovement[];
lock_total_ms: number;
lock_active_count: number;
cumulative_flow: MetricsCumulativePoint[];
}
export interface MetricsFilter {
from?: string;
to?: string;
assignee_id?: string;
requester?: string;
}
export interface Board {
columns: Column[];
cards: Card[];
@@ -39,3 +142,18 @@ export interface HistoryEntry {
exited_at: string | null;
duration_ms: number;
}
export interface LockPeriod {
id: string;
card_id: string;
locked_at: string;
unlocked_at: string | null;
duration_ms: number;
}
export interface CardHistoryResponse {
column_history: HistoryEntry[];
lock_periods: LockPeriod[];
total_locked_ms: number;
currently_locked: boolean;
}
+6 -1
View File
@@ -14,7 +14,12 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@fn_library": ["../../../frontend/functions/ui"],
"@fn_library/*": ["../../../frontend/functions/ui/*"]
}
},
"include": ["src"]
}
+6
View File
@@ -1,8 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@fn_library": path.resolve(__dirname, "../../../frontend/functions/ui"),
},
},
server: {
port: 5180,
proxy: {
+92 -15
View File
@@ -71,12 +71,14 @@ func handleUpdateColumn(db *DB) http.HandlerFunc {
Position *int `json:"position"`
Location *string `json:"location"`
Width *int `json:"width"`
WIPLimit *int `json:"wip_limit"`
IsDone *bool `json:"is_done"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width}); err != nil {
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width, WIPLimit: body.WIPLimit, IsDone: body.IsDone}); err != nil {
serverError(w, err)
return
}
@@ -116,10 +118,11 @@ func handleReorderColumns(db *DB) http.HandlerFunc {
func handleCreateCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
ColumnID string `json:"column_id"`
Requester string `json:"requester"`
Title string `json:"title"`
Description string `json:"description"`
ColumnID string `json:"column_id"`
Requester string `json:"requester"`
Title string `json:"title"`
Description string `json:"description"`
AssigneeID *string `json:"assignee_id"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
@@ -129,7 +132,14 @@ func handleCreateCard(db *DB) http.HandlerFunc {
badRequest(w, "column_id and title required")
return
}
c, err := db.CreateCard(body.ColumnID, body.Requester, body.Title, body.Description)
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
c, err := db.CreateCard(body.ColumnID, body.Requester, body.Title, body.Description, actor)
if err == nil && body.AssigneeID != nil && *body.AssigneeID != "" {
err = db.UpdateCardWithActor(c.ID, CardPatch{AssigneeID: body.AssigneeID, HasAssignee: true}, actor)
if err == nil {
c.AssigneeID = body.AssigneeID
}
}
if err != nil {
serverError(w, err)
return
@@ -142,17 +152,38 @@ func handleCreateCard(db *DB) http.HandlerFunc {
func handleUpdateCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Requester *string `json:"requester"`
Title *string `json:"title"`
Description *string `json:"description"`
Color *string `json:"color"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
var raw map[string]any
if err := infra.HTTPParseBody(r, &raw, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if err := db.UpdateCard(id, CardPatch{Requester: body.Requester, Title: body.Title, Description: body.Description, Color: body.Color}); err != nil {
patch := CardPatch{}
if v, ok := raw["requester"].(string); ok {
patch.Requester = &v
}
if v, ok := raw["title"].(string); ok {
patch.Title = &v
}
if v, ok := raw["description"].(string); ok {
patch.Description = &v
}
if v, ok := raw["color"].(string); ok {
patch.Color = &v
}
if v, ok := raw["locked"].(bool); ok {
patch.Locked = &v
}
if v, present := raw["assignee_id"]; present {
patch.HasAssignee = true
if v == nil {
empty := ""
patch.AssigneeID = &empty
} else if s, ok := v.(string); ok {
patch.AssigneeID = &s
}
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if err := db.UpdateCardWithActor(id, patch, actor); err != nil {
serverError(w, err)
return
}
@@ -188,7 +219,8 @@ func handleMoveCard(db *DB) http.HandlerFunc {
badRequest(w, "column_id required")
return
}
if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs); err != nil {
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs, actor); err != nil {
if strings.Contains(err.Error(), "not found") {
notFound(w, "card not found")
return
@@ -213,8 +245,49 @@ func handleCardHistory(db *DB) http.HandlerFunc {
}
}
// GET /api/trash
func handleListTrash(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cards, err := db.ListDeletedCards()
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, cards)
}
}
// POST /api/cards/{id}/restore
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 {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DELETE /api/cards/{id}/purge
func handlePurgeCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.PurgeCard(id); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger) []infra.Route {
return []infra.Route{
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db)},
{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: "GET", Path: "/api/users", Handler: handleListUsers(db)},
{Method: "GET", Path: "/api/board", Handler: handleGetBoard(db)},
{Method: "POST", Path: "/api/columns", Handler: handleCreateColumn(db)},
{Method: "POST", Path: "/api/columns/reorder", Handler: handleReorderColumns(db)},
@@ -225,6 +298,10 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger) []infra.Route {
{Method: "DELETE", Path: "/api/cards/{id}", Handler: handleDeleteCard(db)},
{Method: "POST", Path: "/api/cards/{id}/move", Handler: handleMoveCard(db)},
{Method: "GET", Path: "/api/cards/{id}/history", Handler: handleCardHistory(db)},
{Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)},
{Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)},
{Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db)},
{Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)},
{Method: "GET", Path: "/api/metrics", Handler: handleMetrics(db)},
}
}
+69
View File
@@ -11,7 +11,9 @@ import (
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"fn-registry/functions/infra"
)
@@ -23,6 +25,7 @@ func main() {
flags := flag.NewFlagSet("kanban", flag.ExitOnError)
port := flags.Int("port", 8095, "HTTP port")
dbPath := flags.String("db", "operations.db", "SQLite database path")
initialAdmin := flags.String("initial-admin", os.Getenv("KANBAN_INITIAL_ADMIN"), "Bootstrap admin in user:pass form (only if no users yet)")
flags.Parse(os.Args[1:])
db, err := openDB(*dbPath)
@@ -31,6 +34,9 @@ func main() {
}
defer db.Close()
bootstrapAdmin(db, *initialAdmin)
startSessionCleanup(db)
wd := chatWorkdir(*dbPath)
logger := newChatLogger(filepath.Join(wd, "chat.log"))
log.Printf("chat tool log: %s", logger.path)
@@ -44,9 +50,17 @@ func main() {
log.Printf("no frontend build found, API-only mode")
}
authMW := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{
DB: db.conn,
CookieName: cookieName,
SkipPaths: []string{"/api/auth/", "/health", "/assets/", "/index.html"},
UserCtxKey: userCtxKey,
})
chain := infra.HTTPMiddlewareChain(
infra.HTTPLoggerMiddleware(os.Stdout),
infra.HTTPCORSMiddleware([]string{"*"}, []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"}),
apiOnlyAuth(authMW),
)
handler := chain(mux)
@@ -62,6 +76,61 @@ func main() {
}
}
// apiOnlyAuth applies auth middleware only to /api/* paths so the SPA shell
// can be served without a session (the SPA itself handles login UI).
func apiOnlyAuth(mw infra.Middleware) infra.Middleware {
return func(next http.Handler) http.Handler {
gated := mw(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
gated.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
}
}
func bootstrapAdmin(db *DB, spec string) {
spec = strings.TrimSpace(spec)
if spec == "" {
return
}
count, err := db.CountUsers()
if err != nil {
log.Printf("bootstrap admin: count users: %v", err)
return
}
if count > 0 {
return
}
parts := strings.SplitN(spec, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
log.Printf("bootstrap admin: invalid spec, expected user:pass")
return
}
u, err := db.CreateUser(parts[0], parts[1], parts[0])
if err != nil {
log.Printf("bootstrap admin: %v", err)
return
}
log.Printf("bootstrap admin: created user %q", u.Username)
}
func startSessionCleanup(db *DB) {
go func() {
t := time.NewTicker(1 * time.Hour)
defer t.Stop()
for range t.C {
if n, err := infra.SessionCleanup(db.conn); err != nil {
log.Printf("session cleanup: %v", err)
} else if n > 0 {
log.Printf("session cleanup: purged %d expired", n)
}
}
}()
}
func frontendHandler() http.Handler {
sub, err := fs.Sub(frontendDist, "frontend/dist")
if err != nil {
+592
View File
@@ -0,0 +1,592 @@
package main
import (
"net/http"
"sort"
"time"
"fn-registry/functions/infra"
)
type Metrics struct {
Range DateRange `json:"range"`
Totals Totals `json:"totals"`
ByColumn []ColumnCount `json:"by_column"`
ThroughputDaily []DailyCount `json:"throughput_daily"`
CreatedDaily []DailyCount `json:"created_daily"`
LeadTime DurationStats `json:"lead_time"`
CycleTimeColumn []ColumnDuration `json:"cycle_time_per_column"`
TopAssignees []AssigneeStat `json:"top_assignees"`
TopRequesters []RequesterStat `json:"top_requesters"`
MovementsByUser []MovementStat `json:"movements_by_user"`
LockTotalMs int64 `json:"lock_total_ms"`
LockActiveCount int `json:"lock_active_count"`
CumulativeFlow []CumulativePoint `json:"cumulative_flow"`
}
type CumulativePoint struct {
Date string `json:"date"`
Total int `json:"total"`
Done int `json:"done"`
}
type DateRange struct {
From string `json:"from"`
To string `json:"to"`
}
type Totals struct {
Cards int `json:"cards"`
CardsCompleted int `json:"cards_completed_in_range"`
CardsCreated int `json:"cards_created_in_range"`
Columns int `json:"columns"`
Users int `json:"users"`
ActiveLocks int `json:"active_locks"`
}
type ColumnCount struct {
ColumnID string `json:"column_id"`
Name string `json:"name"`
IsDone bool `json:"is_done"`
Count int `json:"count"`
}
type DailyCount struct {
Date string `json:"date"`
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"`
IsDone bool `json:"is_done"`
Stats DurationStats `json:"stats"`
}
type AssigneeStat struct {
UserID string `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Active int `json:"active"`
Completed int `json:"completed_in_range"`
}
type RequesterStat struct {
Requester string `json:"requester"`
Total int `json:"total"`
}
type MovementStat struct {
UserID string `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
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),
}
}
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
}
// GET /api/metrics?from=YYYY-MM-DD&to=YYYY-MM-DD&assignee_id=...&requester=...
func handleMetrics(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC()
from := parseDateOrDefault(r.URL.Query().Get("from"), now.AddDate(0, 0, -30))
to := parseDateOrDefault(r.URL.Query().Get("to"), now)
assignee := r.URL.Query().Get("assignee_id")
requester := r.URL.Query().Get("requester")
m, err := computeMetrics(db, from, to, assignee, requester)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, m)
}
}
func computeMetrics(db *DB, from, to time.Time, assignee, requester string) (*Metrics, error) {
fromStr := from.Format(time.RFC3339Nano)
toStr := to.Format(time.RFC3339Nano)
m := &Metrics{
Range: DateRange{From: from.Format("2006-01-02"), To: to.Format("2006-01-02")},
}
cardWhere := "WHERE deleted_at IS NULL"
args := []any{}
if assignee != "" {
cardWhere += " AND assignee_id=?"
args = append(args, assignee)
}
if requester != "" {
cardWhere += " AND requester=?"
args = append(args, requester)
}
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM cards `+cardWhere, args...).Scan(&m.Totals.Cards); err != nil {
return nil, err
}
completedArgs := append([]any{fromStr, toStr}, args...)
if err := db.conn.QueryRow(
`SELECT COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at>=? AND completed_at<=?`,
append(args, fromStr, toStr)...,
).Scan(&m.Totals.CardsCompleted); err != nil {
return nil, err
}
if err := db.conn.QueryRow(
`SELECT COUNT(*) FROM cards `+cardWhere+` AND created_at>=? AND created_at<=?`,
append(args, fromStr, toStr)...,
).Scan(&m.Totals.CardsCreated); err != nil {
return nil, err
}
_ = completedArgs
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM columns`).Scan(&m.Totals.Columns); err != nil {
return nil, err
}
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&m.Totals.Users); err != nil {
return nil, err
}
lockActiveQ := `SELECT COUNT(*) FROM card_lock_history h JOIN cards c ON c.id=h.card_id WHERE h.unlocked_at IS NULL AND c.deleted_at IS NULL`
if assignee != "" {
lockActiveQ += ` AND c.assignee_id=?`
}
if requester != "" {
lockActiveQ += ` AND c.requester=?`
}
if err := db.conn.QueryRow(lockActiveQ, args...).Scan(&m.Totals.ActiveLocks); err != nil {
return nil, err
}
// By column.
rows, err := db.conn.Query(
`SELECT col.id, col.name, col.is_done, COUNT(c.id)
FROM columns col
LEFT JOIN cards c ON c.column_id=col.id`+
condFromCard(assignee, requester, "c", "WHERE")+
` GROUP BY col.id ORDER BY col.position`,
colArgs(assignee, requester)...,
)
if err != nil {
return nil, err
}
for rows.Next() {
var cc ColumnCount
var isDone int
if err := rows.Scan(&cc.ColumnID, &cc.Name, &isDone, &cc.Count); err != nil {
rows.Close()
return nil, err
}
cc.IsDone = isDone != 0
m.ByColumn = append(m.ByColumn, cc)
}
rows.Close()
// Throughput daily (completed_at within range).
m.ThroughputDaily, err = dailyBucket(db, "completed_at", fromStr, toStr, assignee, requester, true)
if err != nil {
return nil, err
}
m.CreatedDaily, err = dailyBucket(db, "created_at", fromStr, toStr, assignee, requester, false)
if err != nil {
return nil, err
}
// Lead time (cards completed in range, completed_at - created_at).
leadDurs, err := collectDurations(db,
`SELECT (julianday(completed_at) - julianday(created_at)) * 86400000 FROM cards `+
cardWhere+` AND completed_at IS NOT NULL AND completed_at>=? AND completed_at<=?`,
append(args, fromStr, toStr)...,
)
if err != nil {
return nil, err
}
m.LeadTime = computeStats(leadDurs)
// Cycle time per column.
colRows, err := db.conn.Query(`SELECT id, name, is_done FROM columns ORDER BY position`)
if err != nil {
return nil, err
}
type colInfo struct {
id, name string
isDone bool
}
var cols []colInfo
for colRows.Next() {
var ci colInfo
var d int
if err := colRows.Scan(&ci.id, &ci.name, &d); err != nil {
colRows.Close()
return nil, err
}
ci.isDone = d != 0
cols = append(cols, ci)
}
colRows.Close()
for _, ci := range cols {
histArgs := []any{ci.id, fromStr, toStr}
histQ := `SELECT (julianday(COALESCE(h.exited_at, ?)) - julianday(h.entered_at)) * 86400000
FROM card_column_history h JOIN cards c ON c.id=h.card_id
WHERE h.column_id=? AND h.entered_at>=? AND h.entered_at<=?`
histArgs = append([]any{toStr}, histArgs...)
if assignee != "" {
histQ += ` AND c.assignee_id=?`
histArgs = append(histArgs, assignee)
}
if requester != "" {
histQ += ` AND c.requester=?`
histArgs = append(histArgs, requester)
}
durs, err := collectDurations(db, histQ, histArgs...)
if err != nil {
return nil, err
}
m.CycleTimeColumn = append(m.CycleTimeColumn, ColumnDuration{
ColumnID: ci.id, Name: ci.name, IsDone: ci.isDone,
Stats: computeStats(durs),
})
}
// Top assignees.
asRows, err := db.conn.Query(
`SELECT u.id, u.username, u.display_name,
SUM(CASE WHEN c.completed_at IS NULL OR c.completed_at='' THEN 1 ELSE 0 END) as active,
SUM(CASE WHEN c.completed_at IS NOT NULL AND c.completed_at>=? AND c.completed_at<=? THEN 1 ELSE 0 END) as completed
FROM users u
LEFT JOIN cards c ON c.assignee_id=u.id` + cardJoinFilter(requester) +
` GROUP BY u.id ORDER BY completed DESC, active DESC`,
topAssigneeArgs(fromStr, toStr, requester)...,
)
if err != nil {
return nil, err
}
for asRows.Next() {
var s AssigneeStat
if err := asRows.Scan(&s.UserID, &s.Username, &s.DisplayName, &s.Active, &s.Completed); err != nil {
asRows.Close()
return nil, err
}
m.TopAssignees = append(m.TopAssignees, s)
}
asRows.Close()
// Top requesters.
reqRows, err := db.conn.Query(
`SELECT requester, COUNT(*) as n FROM cards WHERE deleted_at IS NULL AND requester != '' AND created_at>=? AND created_at<=?`+
condFromCard(assignee, "", "", "AND")+
` GROUP BY requester ORDER BY n DESC LIMIT 10`,
topReqArgs(fromStr, toStr, assignee)...,
)
if err != nil {
return nil, err
}
for reqRows.Next() {
var s RequesterStat
if err := reqRows.Scan(&s.Requester, &s.Total); err != nil {
reqRows.Close()
return nil, err
}
m.TopRequesters = append(m.TopRequesters, s)
}
reqRows.Close()
// Movements by user.
mvRows, err := db.conn.Query(
`SELECT u.id, u.username, u.display_name, COUNT(h.id) as moves
FROM users u
LEFT JOIN card_column_history h ON h.actor_id=u.id AND h.entered_at>=? AND h.entered_at<=?
GROUP BY u.id ORDER BY moves DESC`,
fromStr, toStr,
)
if err != nil {
return nil, err
}
for mvRows.Next() {
var s MovementStat
if err := mvRows.Scan(&s.UserID, &s.Username, &s.DisplayName, &s.Moves); err != nil {
mvRows.Close()
return nil, err
}
m.MovementsByUser = append(m.MovementsByUser, s)
}
mvRows.Close()
// Lock total in range.
var lockMs float64
if err := db.conn.QueryRow(
`SELECT COALESCE(SUM(
(julianday(COALESCE(h.unlocked_at, ?)) - julianday(h.locked_at)) * 86400000
), 0) FROM card_lock_history h JOIN cards c ON c.id=h.card_id
WHERE h.locked_at>=? AND h.locked_at<=?`+condFromCard(assignee, requester, "c", "AND"),
append([]any{toStr, fromStr, toStr}, colArgs(assignee, requester)...)...,
).Scan(&lockMs); err != nil {
return nil, err
}
m.LockTotalMs = int64(lockMs)
// Cumulative flow: walk daily from→to, count cards created<=day and done<=day.
cfd, err := computeCumulativeFlow(db, from, to, assignee, requester)
if err != nil {
return nil, err
}
m.CumulativeFlow = cfd
return m, nil
}
func computeCumulativeFlow(db *DB, from, to time.Time, assignee, requester string) ([]CumulativePoint, error) {
creates := map[string]int{}
dones := map[string]int{}
cardWhere := "WHERE deleted_at IS NULL"
args := []any{}
if assignee != "" {
cardWhere += " AND assignee_id=?"
args = append(args, assignee)
}
if requester != "" {
cardWhere += " AND requester=?"
args = append(args, requester)
}
rows, err := db.conn.Query(`SELECT substr(created_at,1,10), COUNT(*) FROM cards `+cardWhere+` GROUP BY substr(created_at,1,10)`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var d string
var n int
if err := rows.Scan(&d, &n); err != nil {
rows.Close()
return nil, err
}
creates[d] = n
}
rows.Close()
rows, err = db.conn.Query(`SELECT substr(completed_at,1,10), COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at != '' GROUP BY substr(completed_at,1,10)`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var d string
var n int
if err := rows.Scan(&d, &n); err != nil {
rows.Close()
return nil, err
}
dones[d] = n
}
rows.Close()
out := []CumulativePoint{}
totalAcc := 0
doneAcc := 0
day := from
end := to
if end.Before(day) {
return out, nil
}
for d := day; !d.After(end); d = d.AddDate(0, 0, 1) {
ds := d.Format("2006-01-02")
// Sum all creates with key <= ds, all dones with key <= ds.
// Optimize: track keys already accounted; here we just do once per loop using map sums.
_ = ds
}
// Simpler: collect and sort all create/done dates, sweep.
type ev struct {
date string
creates int
dones int
}
all := map[string]*ev{}
for d, n := range creates {
all[d] = &ev{date: d, creates: n}
}
for d, n := range dones {
if e, ok := all[d]; ok {
e.dones = n
} else {
all[d] = &ev{date: d, dones: n}
}
}
dates := make([]string, 0, len(all))
for d := range all {
dates = append(dates, d)
}
sort.Strings(dates)
// Accumulate up to `from` first.
fromS := from.Format("2006-01-02")
idx := 0
for idx < len(dates) && dates[idx] < fromS {
totalAcc += all[dates[idx]].creates
doneAcc += all[dates[idx]].dones
idx++
}
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
ds := d.Format("2006-01-02")
for idx < len(dates) && dates[idx] <= ds {
totalAcc += all[dates[idx]].creates
doneAcc += all[dates[idx]].dones
idx++
}
out = append(out, CumulativePoint{Date: ds, Total: totalAcc, Done: doneAcc})
}
return out, nil
}
func condFromCard(assignee, requester, alias, leadKw string) string {
pref := alias
if pref != "" {
pref += "."
}
out := ""
if assignee != "" {
out += " " + leadKw + " " + pref + "assignee_id=?"
leadKw = "AND"
}
if requester != "" {
out += " " + leadKw + " " + pref + "requester=?"
}
return out
}
func colArgs(assignee, requester string) []any {
args := []any{}
if assignee != "" {
args = append(args, assignee)
}
if requester != "" {
args = append(args, requester)
}
return args
}
func cardJoinFilter(requester string) string {
if requester != "" {
return " AND c.requester=?"
}
return ""
}
func topAssigneeArgs(fromStr, toStr, requester string) []any {
args := []any{fromStr, toStr}
if requester != "" {
args = append(args, requester)
}
return args
}
func topReqArgs(fromStr, toStr, assignee string) []any {
args := []any{fromStr, toStr}
if assignee != "" {
args = append(args, assignee)
}
return args
}
func collectDurations(db *DB, query string, args ...any) ([]int64, error) {
rows, err := db.conn.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []int64{}
for rows.Next() {
var v float64
if err := rows.Scan(&v); err != nil {
return nil, err
}
if v < 0 {
v = 0
}
out = append(out, int64(v))
}
return out, rows.Err()
}
func dailyBucket(db *DB, dateCol, fromStr, toStr, assignee, requester string, requireNonNull bool) ([]DailyCount, error) {
cardWhere := "deleted_at IS NULL"
if requireNonNull {
cardWhere += " AND " + dateCol + " IS NOT NULL AND " + dateCol + " != ''"
}
cardWhere += " AND " + dateCol + ">=? AND " + dateCol + "<=?"
args := []any{fromStr, toStr}
if assignee != "" {
cardWhere += " AND assignee_id=?"
args = append(args, assignee)
}
if requester != "" {
cardWhere += " AND requester=?"
args = append(args, requester)
}
q := `SELECT substr(` + dateCol + `, 1, 10) as d, COUNT(*) FROM cards WHERE ` + cardWhere + ` GROUP BY d ORDER BY d`
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []DailyCount{}
for rows.Next() {
var dc DailyCount
if err := rows.Scan(&dc.Date, &dc.Count); err != nil {
return nil, err
}
out = append(out, dc)
}
return out, rows.Err()
}
+18
View File
@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS columns (
position INTEGER NOT NULL DEFAULT 0,
location TEXT NOT NULL DEFAULT 'board' CHECK(location IN ('board','sidebar')),
width INTEGER NOT NULL DEFAULT 300,
wip_limit INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
@@ -15,6 +16,7 @@ CREATE TABLE IF NOT EXISTS cards (
color TEXT NOT NULL DEFAULT '',
column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
locked INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -27,7 +29,23 @@ CREATE TABLE IF NOT EXISTS card_column_history (
exited_at TEXT
);
CREATE TABLE IF NOT EXISTS card_lock_history (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
locked_at TEXT NOT NULL,
unlocked_at TEXT
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cards_column ON cards(column_id);
CREATE INDEX IF NOT EXISTS idx_cards_position ON cards(column_id, position);
CREATE INDEX IF NOT EXISTS idx_history_card ON card_column_history(card_id);
CREATE INDEX IF NOT EXISTS idx_columns_position ON columns(position);
CREATE INDEX IF NOT EXISTS idx_lock_history_card ON card_lock_history(card_id);
+71 -12
View File
@@ -46,6 +46,10 @@ func executeTool(db *DB, name string, input json.RawMessage) ToolResult {
return toolCardHistory(db, input)
case "find_cards":
return toolFindCards(db, input)
case "list_users":
return toolListUsers(db)
case "assign_card":
return toolAssignCard(db, input)
default:
return errMsg("unknown tool: " + name)
}
@@ -55,7 +59,7 @@ func executeTool(db *DB, name string, input json.RawMessage) ToolResult {
func toolMutates(name string) bool {
switch name {
case "create_column", "update_column", "rename_column", "delete_column", "reorder_columns",
"create_card", "update_card", "delete_card", "move_card":
"create_card", "update_card", "delete_card", "move_card", "assign_card":
return true
}
return false
@@ -94,6 +98,8 @@ func toolUpdateColumn(db *DB, input json.RawMessage) ToolResult {
Name *string `json:"name"`
Location *string `json:"location"`
Width *int `json:"width"`
WIPLimit *int `json:"wip_limit"`
IsDone *bool `json:"is_done"`
}
if err := json.Unmarshal(input, &in); err != nil {
return errResult(err)
@@ -101,10 +107,10 @@ func toolUpdateColumn(db *DB, input json.RawMessage) ToolResult {
if in.ID == "" {
return errMsg("id required")
}
if in.Name == nil && in.Location == nil && in.Width == nil {
return errMsg("at least one of name/location/width required")
if in.Name == nil && in.Location == nil && in.Width == nil && in.WIPLimit == nil && in.IsDone == nil {
return errMsg("at least one of name/location/width/wip_limit/is_done required")
}
if err := db.UpdateColumn(in.ID, ColumnPatch{Name: in.Name, Location: in.Location, Width: in.Width}); err != nil {
if err := db.UpdateColumn(in.ID, ColumnPatch{Name: in.Name, Location: in.Location, Width: in.Width, WIPLimit: in.WIPLimit, IsDone: in.IsDone}); err != nil {
return errResult(err)
}
return okResult(nil)
@@ -151,7 +157,7 @@ func toolCreateCard(db *DB, input json.RawMessage) ToolResult {
if in.ColumnID == "" || strings.TrimSpace(in.Title) == "" {
return errMsg("column_id and title required")
}
c, err := db.CreateCard(in.ColumnID, in.Requester, in.Title, in.Description)
c, err := db.CreateCard(in.ColumnID, in.Requester, in.Title, in.Description, "")
if err != nil {
return errResult(err)
}
@@ -159,12 +165,57 @@ func toolCreateCard(db *DB, input json.RawMessage) ToolResult {
}
func toolUpdateCard(db *DB, input json.RawMessage) ToolResult {
var raw map[string]any
if err := json.Unmarshal(input, &raw); err != nil {
return errResult(err)
}
id, _ := raw["id"].(string)
if id == "" {
return errMsg("id required")
}
patch := CardPatch{}
if v, ok := raw["requester"].(string); ok {
patch.Requester = &v
}
if v, ok := raw["title"].(string); ok {
patch.Title = &v
}
if v, ok := raw["description"].(string); ok {
patch.Description = &v
}
if v, ok := raw["color"].(string); ok {
patch.Color = &v
}
if v, ok := raw["locked"].(bool); ok {
patch.Locked = &v
}
if v, present := raw["assignee_id"]; present {
patch.HasAssignee = true
if v == nil {
empty := ""
patch.AssigneeID = &empty
} else if s, ok := v.(string); ok {
patch.AssigneeID = &s
}
}
if err := db.UpdateCard(id, patch); err != nil {
return errResult(err)
}
return okResult(nil)
}
func toolListUsers(db *DB) ToolResult {
users, err := db.ListUsers()
if err != nil {
return errResult(err)
}
return okResult(users)
}
func toolAssignCard(db *DB, input json.RawMessage) ToolResult {
var in struct {
ID string `json:"id"`
Requester *string `json:"requester"`
Title *string `json:"title"`
Description *string `json:"description"`
Color *string `json:"color"`
ID string `json:"id"`
AssigneeID *string `json:"assignee_id"`
}
if err := json.Unmarshal(input, &in); err != nil {
return errResult(err)
@@ -172,7 +223,14 @@ func toolUpdateCard(db *DB, input json.RawMessage) ToolResult {
if in.ID == "" {
return errMsg("id required")
}
if err := db.UpdateCard(in.ID, CardPatch{Requester: in.Requester, Title: in.Title, Description: in.Description, Color: in.Color}); err != nil {
patch := CardPatch{HasAssignee: true}
if in.AssigneeID == nil {
empty := ""
patch.AssigneeID = &empty
} else {
patch.AssigneeID = in.AssigneeID
}
if err := db.UpdateCard(in.ID, patch); err != nil {
return errResult(err)
}
return okResult(nil)
@@ -225,7 +283,7 @@ func toolMoveCard(db *DB, input json.RawMessage) ToolResult {
ids = append(ids, in.ID)
in.OrderedIDs = ids
}
if err := db.MoveCard(in.ID, in.ColumnID, in.OrderedIDs); err != nil {
if err := db.MoveCard(in.ID, in.ColumnID, in.OrderedIDs, ""); err != nil {
return errResult(err)
}
return okResult(nil)
@@ -309,6 +367,7 @@ func validateToolName(name string) error {
"delete_column": true, "reorder_columns": true, "create_card": true,
"update_card": true, "delete_card": true, "move_card": true,
"card_history": true, "find_cards": true,
"list_users": true, "assign_card": true,
}
if !known[name] {
return fmt.Errorf("unknown tool: %s", name)
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"database/sql"
"errors"
"fmt"
"strings"
"fn-registry/functions/infra"
)
type User struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
CreatedAt string `json:"created_at"`
}
var (
errUserNotFound = errors.New("user not found")
errUserAlreadyExists = errors.New("username already exists")
errInvalidCredentials = errors.New("invalid credentials")
)
func (db *DB) CreateUser(username, password, displayName string) (*User, error) {
username = strings.TrimSpace(strings.ToLower(username))
if username == "" {
return nil, fmt.Errorf("username required")
}
if len(password) < 4 {
return nil, fmt.Errorf("password must be at least 4 characters")
}
hash, err := infra.PasswordHash(password, 0)
if err != nil {
return nil, fmt.Errorf("hash: %w", err)
}
u := User{ID: newID(), Username: username, DisplayName: displayName, CreatedAt: nowRFC3339()}
_, err = db.conn.Exec(
`INSERT INTO users (id, username, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)`,
u.ID, u.Username, hash, u.DisplayName, u.CreatedAt,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return nil, errUserAlreadyExists
}
return nil, err
}
return &u, nil
}
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)
if errors.Is(err, sql.ErrNoRows) {
return nil, errUserNotFound
}
if err != nil {
return nil, err
}
return &u, nil
}
func (db *DB) GetUserByUsername(username string) (*User, string, error) {
username = strings.TrimSpace(strings.ToLower(username))
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)
if errors.Is(err, sql.ErrNoRows) {
return nil, "", errUserNotFound
}
if err != nil {
return nil, "", err
}
return &u, hash, nil
}
func (db *DB) ListUsers() ([]User, error) {
rows, err := db.conn.Query(`SELECT id, username, display_name, created_at FROM users ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []User{}
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.CreatedAt); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
func (db *DB) Authenticate(username, password string) (*User, error) {
u, hash, err := db.GetUserByUsername(username)
if err != nil {
if errors.Is(err, errUserNotFound) {
return nil, errInvalidCredentials
}
return nil, err
}
if err := infra.PasswordVerify(password, hash); err != nil {
return nil, errInvalidCredentials
}
return u, nil
}
func (db *DB) CountUsers() (int, error) {
var n int
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
return 0, err
}
return n, nil
}
func (db *DB) DeleteSessionByToken(token string) error {
_, err := db.conn.Exec(`DELETE FROM sessions WHERE token=?`, token)
return err
}