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