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:
2026-05-14 16:11:59 +02:00
parent c4caff85be
commit 9b503f0555
12 changed files with 1529 additions and 1191 deletions
+72
View File
@@ -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)},