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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user