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
+93 -3
View File
@@ -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).