feat(kanban): archive automatico para cards Done +30 dias (issue 0092)
Adds an archive layer separate from the trash. Cards in is_done columns that have been there for more than 30 days are auto-archived on the next board load (throttled to once every 30 minutes). Archived cards leave the board but stay in the DB and are listed in a new sidebar drawer "Hecho (archivo)" below the existing Papelera, with a one-click restore. Schema (migration 012_card_archived.sql): - ALTER TABLE cards ADD COLUMN archived_at TEXT; - NULL = active, ISO timestamp = archived. Independent from deleted_at. Backend: - Card.ArchivedAt + JSON; ListCardsWithTime filters archived_at IS NULL. - New methods: ArchiveCard, UnarchiveCard, ListArchivedCards, AutoArchiveDoneOlderThan. - New endpoints: GET /api/archive, POST /api/cards/:id/archive, POST /api/cards/:id/unarchive. - handleGetBoard invokes maybeAutoArchive (atomic throttle, 30 min sweep, 30 day cutoff). Errors logged but never block the board response. Frontend: - Card type + api.ts add the new field and helpers. - App.tsx state for archive list, reload, archive/unarchive handlers. - New sidebar drawer with toggle, count badge, restore button. - KanbanCard gains an "Archivar" menu item (gated on isDone + onArchive prop) for manual archiving of any done card. Tests: - Playwright e2e/archive.spec.ts: manual archive via menu, drawer toggle, unarchive. Picks a done card via /api/board introspection so it stays stable regardless of board state. - Auto-archive of >30d cards: not under e2e (real time travel needed); covered by code review of the SQL query and the throttle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+93
-3
@@ -47,6 +47,7 @@ type Card struct {
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
DeletedAt *string `json:"deleted_at"`
|
||||
ArchivedAt *string `json:"archived_at"`
|
||||
Tags []string `json:"tags"`
|
||||
Stickers []Sticker `json:"stickers"`
|
||||
Deadline *string `json:"deadline"`
|
||||
@@ -448,7 +449,7 @@ func (db *DB) ReorderColumns(ids []string) error {
|
||||
|
||||
func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT c.id, c.seq_num, 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.deadline, c.created_at, c.updated_at,
|
||||
SELECT c.id, c.seq_num, 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.archived_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at,
|
||||
h.entered_at, l.locked_at,
|
||||
COALESCE((
|
||||
SELECT CAST(SUM((julianday(COALESCE(unlocked_at, ?)) - julianday(locked_at)) * 86400000) AS INTEGER)
|
||||
@@ -459,7 +460,7 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
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
|
||||
WHERE c.deleted_at IS NULL AND c.archived_at IS NULL
|
||||
ORDER BY c.column_id, c.position, c.created_at
|
||||
`, time.Now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
@@ -474,12 +475,13 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
var assignee sql.NullString
|
||||
var completed sql.NullString
|
||||
var deleted sql.NullString
|
||||
var archived sql.NullString
|
||||
var tagsJSON string
|
||||
var stickersJSON string
|
||||
var deadline sql.NullString
|
||||
var lockedAt sql.NullString
|
||||
var locked int
|
||||
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt, &c.TotalLockedMs); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &archived, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt, &c.TotalLockedMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Stickers = parseStickers(stickersJSON)
|
||||
@@ -504,6 +506,10 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
s := deleted.String
|
||||
c.DeletedAt = &s
|
||||
}
|
||||
if archived.Valid && archived.String != "" {
|
||||
s := archived.String
|
||||
c.ArchivedAt = &s
|
||||
}
|
||||
c.Tags = parseTags(tagsJSON)
|
||||
if entered.Valid {
|
||||
c.EnteredAt = entered.String
|
||||
@@ -827,6 +833,90 @@ func (db *DB) ListDeletedCards() ([]Card, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ArchiveCard moves a card to the archive (out of the board, retrievable).
|
||||
// Used both manually and by AutoArchiveDoneOlderThan.
|
||||
func (db *DB) ArchiveCard(id string) error {
|
||||
now := nowRFC3339()
|
||||
_, err := db.conn.Exec(`UPDATE cards SET archived_at=?, updated_at=? WHERE id=? AND archived_at IS NULL AND deleted_at IS NULL`, now, now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UnarchiveCard pulls a card out of the archive back into its column.
|
||||
func (db *DB) UnarchiveCard(id string) error {
|
||||
now := nowRFC3339()
|
||||
_, err := db.conn.Exec(`UPDATE cards SET archived_at=NULL, updated_at=? WHERE id=? AND archived_at IS NOT NULL`, now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// AutoArchiveDoneOlderThan archives every card whose column is is_done=1 AND
|
||||
// whose entered_at in that column is older than `older`. Idempotent: cards
|
||||
// already archived or deleted are skipped. Returns the count affected.
|
||||
func (db *DB) AutoArchiveDoneOlderThan(older time.Duration) (int64, error) {
|
||||
cutoff := time.Now().UTC().Add(-older).Format(time.RFC3339Nano)
|
||||
now := nowRFC3339()
|
||||
res, err := db.conn.Exec(`
|
||||
UPDATE cards SET archived_at=?, updated_at=?
|
||||
WHERE archived_at IS NULL
|
||||
AND deleted_at IS NULL
|
||||
AND column_id IN (SELECT id FROM columns WHERE is_done=1)
|
||||
AND id IN (
|
||||
SELECT card_id FROM card_column_history
|
||||
WHERE exited_at IS NULL AND entered_at < ?
|
||||
)
|
||||
`, now, now, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ListArchivedCards returns cards in the archive, newest first.
|
||||
func (db *DB) ListArchivedCards() ([]Card, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT c.id, c.seq_num, 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.archived_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at
|
||||
FROM cards c
|
||||
WHERE c.archived_at IS NOT NULL AND c.deleted_at IS NULL
|
||||
ORDER BY c.archived_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Card{}
|
||||
for rows.Next() {
|
||||
var c Card
|
||||
var assignee, completed, deleted, archived sql.NullString
|
||||
var tagsJSON, stickersJSON string
|
||||
var deadline sql.NullString
|
||||
var locked int
|
||||
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &archived, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Stickers = parseStickers(stickersJSON)
|
||||
if deadline.Valid && deadline.String != "" {
|
||||
s := deadline.String
|
||||
c.Deadline = &s
|
||||
}
|
||||
c.Locked = locked != 0
|
||||
if assignee.Valid && assignee.String != "" {
|
||||
s := assignee.String
|
||||
c.AssigneeID = &s
|
||||
}
|
||||
if completed.Valid && completed.String != "" {
|
||||
s := completed.String
|
||||
c.CompletedAt = &s
|
||||
}
|
||||
if archived.Valid && archived.String != "" {
|
||||
s := archived.String
|
||||
c.ArchivedAt = &s
|
||||
}
|
||||
c.Tags = parseTags(tagsJSON)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MoveCard updates the card's column and/or position. If the column changes,
|
||||
// the open history entry is closed and a new one is opened.
|
||||
// orderedIDs is the new order of cards in the destination column (including this card).
|
||||
|
||||
-1181
File diff suppressed because one or more lines are too long
+1181
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kanban</title>
|
||||
<script type="module" crossorigin src="/assets/index-Bb2Ri4SN.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-Cdqq92Kx.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
const maxBodyBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// Auto-archive: cards en columnas Done con >30 dias se mueven al cajon.
|
||||
// Issue 0092. Lo dispara handleGetBoard de forma "lazy" pero solo cada
|
||||
// archiveSweepEvery minutos para no martillear el UPDATE.
|
||||
const (
|
||||
archiveAfter = 30 * 24 * time.Hour
|
||||
archiveSweepEvery = 30 * time.Minute
|
||||
)
|
||||
|
||||
var lastArchiveSweepNs atomic.Int64
|
||||
|
||||
func maybeAutoArchive(db *DB) {
|
||||
now := time.Now().UnixNano()
|
||||
last := lastArchiveSweepNs.Load()
|
||||
if last != 0 && time.Duration(now-last) < archiveSweepEvery {
|
||||
return
|
||||
}
|
||||
if !lastArchiveSweepNs.CompareAndSwap(last, now) {
|
||||
return
|
||||
}
|
||||
n, err := db.AutoArchiveDoneOlderThan(archiveAfter)
|
||||
if err != nil {
|
||||
log.Printf("auto-archive failed: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("auto-archive moved %d done card(s) older than %s", n, archiveAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func badRequest(w http.ResponseWriter, msg string) {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: msg})
|
||||
}
|
||||
@@ -24,6 +56,7 @@ func serverError(w http.ResponseWriter, err error) {
|
||||
// GET /api/board → { columns: [...], cards: [...] }
|
||||
func handleGetBoard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
maybeAutoArchive(db)
|
||||
cols, err := db.ListColumns()
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
@@ -404,6 +437,42 @@ func handleRestoreCard(db *DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/archive
|
||||
func handleListArchive(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cards, err := db.ListArchivedCards()
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, cards)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/cards/{id}/archive
|
||||
func handleArchiveCard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := db.ArchiveCard(id); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/cards/{id}/unarchive
|
||||
func handleUnarchiveCard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := db.UnarchiveCard(id); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{id}/purge
|
||||
func handlePurgeCard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -442,6 +511,9 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
|
||||
{Method: "GET", Path: "/api/cards/{id}/history", Handler: handleCardHistory(db)},
|
||||
{Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)},
|
||||
{Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)},
|
||||
{Method: "GET", Path: "/api/archive", Handler: handleListArchive(db)},
|
||||
{Method: "POST", Path: "/api/cards/{id}/archive", Handler: handleArchiveCard(db)},
|
||||
{Method: "POST", Path: "/api/cards/{id}/unarchive", Handler: handleUnarchiveCard(db)},
|
||||
{Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db)},
|
||||
{Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)},
|
||||
{Method: "GET", Path: "/api/chat/ws", Handler: handleChatWS(db, chatWorkdir, logger, internalToken)},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Issue 0092: archivo automatico para cards en columnas Done con +30 dias.
|
||||
-- archived_at NULL = card activa. archived_at = timestamp ISO = card en cajon.
|
||||
-- Independiente de deleted_at (papelera): una card puede estar archived sin
|
||||
-- haber sido borrada. Restaurar = vuelve a su columna original sin deletear.
|
||||
ALTER TABLE cards ADD COLUMN archived_at TEXT;
|
||||
Reference in New Issue
Block a user