From 5ba0254e57f85704c031c1b9f80373a18869a769 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 9 May 2026 00:44:43 +0200 Subject: [PATCH] feat(kanban): badges done/locked + drag locked en mismo column + migrations + UX stickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- db.go | 78 +++++++++++++++++++++-- frontend/src/App.tsx | 14 +++- frontend/src/components/KanbanCard.tsx | 55 +++++++++++----- frontend/src/components/StickerPicker.tsx | 22 +++++-- frontend/src/components/format.ts | 11 ++++ frontend/src/types.ts | 1 + migrations/002_add_stickers.sql | 4 ++ migrations/003_columns_extras.sql | 6 ++ migrations/004_cards_extras.sql | 9 +++ migrations/005_history_actor.sql | 3 + 10 files changed, 175 insertions(+), 28 deletions(-) create mode 100644 migrations/002_add_stickers.sql create mode 100644 migrations/003_columns_extras.sql create mode 100644 migrations/004_cards_extras.sql create mode 100644 migrations/005_history_actor.sql diff --git a/db.go b/db.go index 01e8a43..5a8b2cb 100644 --- a/db.go +++ b/db.go @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d429804..c1fb3b5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -423,6 +423,12 @@ export function App() { // Card drag const destCol = resolveColumnId(overId); if (!destCol) return; + const activeCard = board.cards.find((c) => c.id === activeId); + if (activeCard?.locked && activeCard.column_id !== destCol) { + notifications.show({ color: "yellow", message: "Card bloqueada: no se puede mover entre columnas" }); + reload(); + return; + } const destCards = board.cards .filter((c) => c.column_id === destCol) .sort((a, b) => a.position - b.position); @@ -1108,7 +1114,13 @@ export function App() { variant={activeSticker ? "filled" : "default"} color={activeSticker ? "yellow" : undefined} leftSection={} - onClick={() => setStickerPickerOpen((v) => !v)} + onClick={() => { + if (!activeSticker) { + setActiveSticker("😀"); + } else { + setStickerPickerOpen((v) => !v); + } + }} > {activeSticker ? `Modo sticker: ${activeSticker}` : "Stickers"} diff --git a/frontend/src/components/KanbanCard.tsx b/frontend/src/components/KanbanCard.tsx index 2e85d8e..99a0658 100644 --- a/frontend/src/components/KanbanCard.tsx +++ b/frontend/src/components/KanbanCard.tsx @@ -14,6 +14,7 @@ import { Tooltip, } from "@mantine/core"; import { + IconCheck, IconClock, IconDotsVertical, IconEdit, @@ -29,7 +30,7 @@ import { import { memo, useCallback, useRef, useState } from "react"; import type { Card, CardColor, User } from "../types"; import { CARD_COLORS, colorBg, colorBorder, colorSwatch } from "./colors"; -import { formatDuration } from "./format"; +import { formatDateTimeShort, formatDuration } from "./format"; interface Props { card: Card; @@ -79,8 +80,8 @@ function KanbanCardImpl({ const stickerMode = !!activeSticker; const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: card.id, - data: { type: "card", columnId: card.column_id }, - disabled: card.locked || stickerMode, + data: { type: "card", columnId: card.column_id, locked: card.locked }, + disabled: stickerMode, }); const setCardRef = useCallback((el: HTMLElement | null) => { @@ -98,7 +99,7 @@ function KanbanCardImpl({ }; const startStickerDrag = (index: number) => (e: React.PointerEvent) => { - if (stickerMode || isOverlay || !onMoveSticker) return; + if (!stickerMode || isOverlay || !onMoveSticker) return; if (e.button !== 0) return; e.stopPropagation(); e.preventDefault(); @@ -128,7 +129,7 @@ function KanbanCardImpl({ }; const onStickerContextMenu = (index: number) => (e: React.MouseEvent) => { - if (isOverlay) return; + if (!stickerMode || isOverlay) return; e.preventDefault(); e.stopPropagation(); onRemoveSticker?.(card.id, index); @@ -141,10 +142,16 @@ function KanbanCardImpl({ background: colorBg(card.color), borderColor: card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color), borderWidth: card.locked ? 2 : 1, + filter: isDone ? "brightness(0.55) saturate(0.7)" : undefined, }; const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now; const liveMs = Math.max(0, now - enteredAt); + const lockedAt = card.locked_at ? new Date(card.locked_at).getTime() : 0; + const lockedMs = card.locked && lockedAt ? Math.max(0, now - lockedAt) : 0; + const createdAt = card.created_at ? new Date(card.created_at).getTime() : 0; + const completedAt = card.completed_at ? new Date(card.completed_at).getTime() : 0; + const totalDoneMs = isDone && createdAt && completedAt ? Math.max(0, completedAt - createdAt) : 0; const onContextMenu = (e: React.MouseEvent) => { e.preventDefault(); @@ -281,7 +288,7 @@ function KanbanCardImpl({ return ( - + )} - - }> - {formatDuration(liveMs)} - + + {card.locked ? ( + }> + {formatDuration(lockedMs)} + + ) : isDone && card.completed_at ? ( + <> + }> + {formatDateTimeShort(card.completed_at)} + + }> + Total: {formatDuration(totalDoneMs)} + + + ) : ( + }> + {formatDuration(liveMs)} + + )} {card.stickers && card.stickers.length > 0 && ( @@ -389,6 +411,7 @@ function KanbanCardImpl({ pointerEvents: "none", overflow: "hidden", borderRadius: "inherit", + zIndex: 0, }} > {card.stickers.map((s, i) => ( @@ -396,7 +419,7 @@ function KanbanCardImpl({ key={i} onPointerDown={startStickerDrag(i)} onContextMenu={onStickerContextMenu(i)} - title="Arrastra para mover. Click derecho para borrar." + title={stickerMode ? "Arrastra para mover. Click derecho para borrar." : ""} style={{ position: "absolute", left: `${s.x * 100}%`, @@ -404,10 +427,10 @@ function KanbanCardImpl({ transform: "translate(-50%, -50%)", fontSize: 48, lineHeight: 1, - opacity: 0.6, + opacity: 1, userSelect: "none", - cursor: stickerMode || isOverlay ? "default" : "grab", - pointerEvents: stickerMode || isOverlay ? "none" : "auto", + cursor: stickerMode && !isOverlay ? "grab" : "default", + pointerEvents: stickerMode && !isOverlay ? "auto" : "none", touchAction: "none", }} > diff --git a/frontend/src/components/StickerPicker.tsx b/frontend/src/components/StickerPicker.tsx index 21f3910..40befbf 100644 --- a/frontend/src/components/StickerPicker.tsx +++ b/frontend/src/components/StickerPicker.tsx @@ -12,7 +12,18 @@ interface Props { export function StickerPicker({ opened, onClose, onSelect, target }: Props) { return ( - + { if (!o) onClose(); }} + onDismiss={onClose} + position="bottom-start" + withArrow + shadow="md" + withinPortal + closeOnClickOutside + closeOnEscape + trapFocus={false} + > {target} { onSelect(emoji); onClose(); }} /> @@ -24,14 +35,17 @@ export function StickerPicker({ opened, onClose, onSelect, target }: Props) { function PickerInner({ onSelect }: { onSelect: (emoji: string) => void }) { const ref = useRef(null); const instanceRef = useRef(null); + const onSelectRef = useRef(onSelect); + onSelectRef.current = onSelect; useEffect(() => { if (!ref.current) return; instanceRef.current = new Picker({ data, onEmojiSelect: (e: { native?: string; shortcodes?: string }) => { - if (e.native) onSelect(e.native); - else if (e.shortcodes) onSelect(e.shortcodes); + const cb = onSelectRef.current; + if (e.native) cb(e.native); + else if (e.shortcodes) cb(e.shortcodes); }, theme: "dark", previewPosition: "none", @@ -44,7 +58,7 @@ function PickerInner({ onSelect }: { onSelect: (emoji: string) => void }) { if (ref.current) ref.current.innerHTML = ""; instanceRef.current = null; }; - }, [onSelect]); + }, []); return
; } diff --git a/frontend/src/components/format.ts b/frontend/src/components/format.ts index 337e757..e80d8d2 100644 --- a/frontend/src/components/format.ts +++ b/frontend/src/components/format.ts @@ -28,3 +28,14 @@ export function formatDuration(ms: number): string { const w = Math.floor((ms % MONTH) / WEEK); return w === 0 ? `${m}M` : `${m}M ${w}S`; } + +export function formatDateTimeShort(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const dd = String(d.getDate()).padStart(2, "0"); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const yy = String(d.getFullYear()).slice(-2); + const hh = String(d.getHours()).padStart(2, "0"); + const mi = String(d.getMinutes()).padStart(2, "0"); + return `${dd}/${mm}/${yy} ${hh}:${mi}`; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 267f162..70c0bff 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -37,6 +37,7 @@ export interface Card { updated_at: string; entered_at: string; time_in_column_ms: number; + locked_at: string | null; } export interface User { diff --git a/migrations/002_add_stickers.sql b/migrations/002_add_stickers.sql new file mode 100644 index 0000000..9932db0 --- /dev/null +++ b/migrations/002_add_stickers.sql @@ -0,0 +1,4 @@ +-- Add stickers column to cards. Idempotent ALTER pattern in db.go ensureColumns. +-- Stickers persist as JSON array: [{"emoji":"🔥","x":0.5,"y":0.5}, ...] +-- x, y in [0, 1] relative to card dimensions for resize survival. +ALTER TABLE cards ADD COLUMN stickers TEXT NOT NULL DEFAULT '[]'; diff --git a/migrations/003_columns_extras.sql b/migrations/003_columns_extras.sql new file mode 100644 index 0000000..7fdf035 --- /dev/null +++ b/migrations/003_columns_extras.sql @@ -0,0 +1,6 @@ +-- Columnas extra de `columns` (location, width, wip_limit, is_done). +-- Antes vivian en ensureColumns Go. Reextraidas a migration por consistencia. +ALTER TABLE columns ADD COLUMN location TEXT NOT NULL DEFAULT 'board'; +ALTER TABLE columns ADD COLUMN width INTEGER NOT NULL DEFAULT 300; +ALTER TABLE columns ADD COLUMN wip_limit INTEGER NOT NULL DEFAULT 0; +ALTER TABLE columns ADD COLUMN is_done INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/004_cards_extras.sql b/migrations/004_cards_extras.sql new file mode 100644 index 0000000..e480392 --- /dev/null +++ b/migrations/004_cards_extras.sql @@ -0,0 +1,9 @@ +-- Columnas extra de `cards` (color, locked, assignee_id, completed_at, deleted_at, tags). +-- Antes vivian en ensureColumns Go. La columna stickers va aparte en 002. +ALTER TABLE cards ADD COLUMN color TEXT NOT NULL DEFAULT ''; +ALTER TABLE cards ADD COLUMN locked INTEGER NOT NULL DEFAULT 0; +ALTER TABLE cards ADD COLUMN assignee_id TEXT; +ALTER TABLE cards ADD COLUMN completed_at TEXT; +ALTER TABLE cards ADD COLUMN deleted_at TEXT; +ALTER TABLE cards ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'; +CREATE INDEX IF NOT EXISTS idx_cards_assignee ON cards(assignee_id); diff --git a/migrations/005_history_actor.sql b/migrations/005_history_actor.sql new file mode 100644 index 0000000..e60be65 --- /dev/null +++ b/migrations/005_history_actor.sql @@ -0,0 +1,3 @@ +-- actor_id en histories (quien movió la card / quien bloqueó). +ALTER TABLE card_column_history ADD COLUMN actor_id TEXT; +ALTER TABLE card_lock_history ADD COLUMN actor_id TEXT;