feat(kanban): stickers feature + dashboard null guards (#0063)

- backend: Sticker type, idempotent stickers column, PUT /api/cards/:id/stickers, 4 tests
- frontend: emoji-mart picker, toolbar button + ESC, draggable overlay with right-click delete, % coords for resize survival
- dashboard: null guards on metrics arrays

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 21:00:30 +02:00
parent 2a727eb7c1
commit 656516f219
12 changed files with 552 additions and 46 deletions
+76 -20
View File
@@ -27,23 +27,30 @@ type Column struct {
CreatedAt string `json:"created_at"`
}
type Sticker struct {
Emoji string `json:"emoji"`
X float64 `json:"x"`
Y float64 `json:"y"`
}
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"`
Locked bool `json:"locked"`
AssigneeID *string `json:"assignee_id"`
CompletedAt *string `json:"completed_at"`
DeletedAt *string `json:"deleted_at"`
Tags []string `json:"tags"`
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"`
Tags []string `json:"tags"`
Stickers []Sticker `json:"stickers"`
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 {
@@ -106,6 +113,7 @@ func ensureColumns(conn *sql.DB) error {
{"cards", "completed_at", "TEXT"},
{"cards", "deleted_at", "TEXT"},
{"cards", "tags", "TEXT NOT NULL DEFAULT '[]'"},
{"cards", "stickers", "TEXT NOT NULL DEFAULT '[]'"},
{"card_column_history", "actor_id", "TEXT"},
{"card_lock_history", "actor_id", "TEXT"},
}
@@ -195,6 +203,49 @@ func encodeTags(in []string) string {
return string(b)
}
func parseStickers(s string) []Sticker {
out := []Sticker{}
if s == "" {
return out
}
if err := json.Unmarshal([]byte(s), &out); err != nil {
return []Sticker{}
}
return out
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func normalizeStickers(in []Sticker) []Sticker {
out := make([]Sticker, 0, len(in))
for _, s := range in {
emoji := strings.TrimSpace(s.Emoji)
if emoji == "" {
continue
}
out = append(out, Sticker{Emoji: emoji, X: clamp01(s.X), Y: clamp01(s.Y)})
}
return out
}
func encodeStickers(in []Sticker) string {
b, _ := json.Marshal(normalizeStickers(in))
return string(b)
}
func (db *DB) UpdateStickers(id string, stickers []Sticker) error {
_, err := db.conn.Exec(`UPDATE cards SET stickers=?, updated_at=? WHERE id=?`, encodeStickers(stickers), nowRFC3339(), id)
return err
}
func (db *DB) ListAllTags() ([]string, error) {
rows, err := db.conn.Query(`SELECT DISTINCT tags FROM cards WHERE deleted_at IS NULL`)
if err != nil {
@@ -378,7 +429,7 @@ 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.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.tags, c.stickers, c.created_at, c.updated_at,
h.entered_at
FROM cards c
LEFT JOIN card_column_history h
@@ -399,10 +450,12 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
var completed sql.NullString
var deleted sql.NullString
var tagsJSON string
var stickersJSON string
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, &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); err != nil {
return nil, err
}
c.Stickers = parseStickers(stickersJSON)
c.Locked = locked != 0
if assignee.Valid && assignee.String != "" {
s := assignee.String
@@ -441,6 +494,7 @@ func (db *DB) CreateCard(columnID, requester, title, description, actorID string
c := Card{
ID: newID(), Requester: requester, Title: title, Description: description, ColumnID: columnID, Position: pos,
Tags: []string{},
Stickers: []Sticker{},
CreatedAt: now, UpdatedAt: now, EnteredAt: now,
}
tx, err := db.conn.Begin()
@@ -589,7 +643,7 @@ func (db *DB) PurgeCard(id string) error {
// 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.tags, 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.tags, c.stickers, c.created_at, c.updated_at
FROM cards c
WHERE c.deleted_at IS NOT NULL
ORDER BY c.deleted_at DESC
@@ -605,10 +659,12 @@ func (db *DB) ListDeletedCards() ([]Card, error) {
var completed sql.NullString
var deleted sql.NullString
var tagsJSON string
var stickersJSON string
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, &c.CreatedAt, &c.UpdatedAt); 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); err != nil {
return nil, err
}
c.Stickers = parseStickers(stickersJSON)
c.Locked = locked != 0
if assignee.Valid && assignee.String != "" {
s := assignee.String