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;