feat(kanban): badges done/locked + drag locked en mismo column + migrations + UX stickers

- badges: locked → tiempo bloqueado; done → fecha completion + total lead time; otherwise → tiempo en columna
- locked cards: drag permitido dentro de mismo column (cross-column rejected con notification)
- card field: locked_at desde JOIN card_lock_history (open period)
- migrations: refactor a embed.FS, archivos 002-005 extraidos de ensureColumns; ensureColumns queda como backstop
- stickers UX: opacidad 1, debajo del texto, picker estable (useRef), boton entra directo a modo con 😀, popover cierra outside, cards done filter brightness
- format: formatDateTimeShort

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 00:44:43 +02:00
parent 9931890d9b
commit 5ba0254e57
10 changed files with 175 additions and 28 deletions
+71 -7
View File
@@ -2,9 +2,10 @@ package main
import (
"database/sql"
_ "embed"
"embed"
"encoding/json"
"fmt"
"io/fs"
"sort"
"strings"
"time"
@@ -13,8 +14,8 @@ import (
"fn-registry/functions/infra"
)
//go:embed migrations/001_init.sql
var migrationSQL string
//go:embed migrations/*.sql
var migrationsFS embed.FS
type Column struct {
ID string `json:"id"`
@@ -51,6 +52,7 @@ type Card struct {
UpdatedAt string `json:"updated_at"`
EnteredAt string `json:"entered_at"`
TimeInColumn int64 `json:"time_in_column_ms"`
LockedAt *string `json:"locked_at"`
}
type HistoryEntry struct {
@@ -85,11 +87,12 @@ func openDB(path string) (*DB, error) {
if err != nil {
return nil, err
}
if _, err := conn.Exec(migrationSQL); err != nil {
if err := applyMigrations(conn); err != nil {
conn.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
// Idempotent column adds for forward-compat with older DBs.
// Idempotent backstop for very old DBs whose schema diverged before
// migration files existed. New columns SIEMPRE se añaden via migracion.
if err := ensureColumns(conn); err != nil {
conn.Close()
return nil, fmt.Errorf("ensure columns: %w", err)
@@ -97,6 +100,60 @@ func openDB(path string) (*DB, error) {
return &DB{conn: conn}, nil
}
func applyMigrations(conn *sql.DB) error {
files, err := fs.Glob(migrationsFS, "migrations/*.sql")
if err != nil {
return err
}
sort.Strings(files)
for _, f := range files {
b, err := migrationsFS.ReadFile(f)
if err != nil {
return err
}
for _, stmt := range splitSQLStatements(string(b)) {
s := strings.TrimSpace(stmt)
if s == "" {
continue
}
if _, err := conn.Exec(s); err != nil {
if isIdempotentMigrationError(err) {
continue
}
return fmt.Errorf("%s: %w", f, err)
}
}
}
return nil
}
func splitSQLStatements(s string) []string {
out := []string{}
cur := strings.Builder{}
for _, line := range strings.Split(s, "\n") {
trim := strings.TrimSpace(line)
if strings.HasPrefix(trim, "--") || trim == "" {
continue
}
cur.WriteString(line)
cur.WriteString("\n")
if strings.HasSuffix(trim, ";") {
out = append(out, cur.String())
cur.Reset()
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
func isIdempotentMigrationError(err error) bool {
msg := err.Error()
return strings.Contains(msg, "duplicate column") ||
strings.Contains(msg, "already exists")
}
// ensureColumns adds columns missing from older schemas without dropping data.
// SQLite ALTER TABLE ADD COLUMN supports NOT NULL with literal DEFAULT but not CHECK,
// so location's CHECK is enforced in Go (UpdateColumn) when the column is added later.
@@ -430,10 +487,12 @@ func (db *DB) ReorderColumns(ids []string) error {
func (db *DB) ListCardsWithTime() ([]Card, error) {
rows, err := db.conn.Query(`
SELECT c.id, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.stickers, c.created_at, c.updated_at,
h.entered_at
h.entered_at, l.locked_at
FROM cards c
LEFT JOIN card_column_history h
ON h.card_id = c.id AND h.exited_at IS NULL
LEFT JOIN card_lock_history l
ON l.card_id = c.id AND l.unlocked_at IS NULL
WHERE c.deleted_at IS NULL
ORDER BY c.column_id, c.position, c.created_at
`)
@@ -451,11 +510,16 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
var deleted sql.NullString
var tagsJSON string
var stickersJSON string
var lockedAt sql.NullString
var locked int
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &stickersJSON, &c.CreatedAt, &c.UpdatedAt, &entered); err != nil {
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &stickersJSON, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt); err != nil {
return nil, err
}
c.Stickers = parseStickers(stickersJSON)
if lockedAt.Valid && lockedAt.String != "" {
s := lockedAt.String
c.LockedAt = &s
}
c.Locked = locked != 0
if assignee.Valid && assignee.String != "" {
s := assignee.String