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
+89 -6
View File
@@ -50,6 +50,7 @@ import {
IconArrowBackUp,
IconCalendar,
IconChartBar,
IconCheck,
IconChevronDown,
IconChevronRight,
IconLayoutKanban,
@@ -118,6 +119,8 @@ export function App() {
const [activeTab, setActiveTab] = useState<string>("board");
const [trash, setTrash] = useState<Card[]>([]);
const [trashOpen, setTrashOpen] = useState(false);
const [archive, setArchive] = useState<Card[]>([]);
const [archiveOpen, setArchiveOpen] = useState(false);
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState("");
@@ -209,16 +212,17 @@ export function App() {
const nowInside = ev.clientX <= DRAG_EDGE_WIDTH;
if (nowInside === inside) return;
inside = nowInside;
// El brillo visible solo cuando "armable": fuera-y-luego-dentro (open
// siempre) o dentro con sidebar cerrado (open trigger).
const armable = nowInside && (!navOpenRef.current || hasLeftStrip);
setEdgeArmed(armable);
// Brillo visible siempre que el puntero este en la franja y haya drag.
setEdgeArmed(nowInside);
if (!nowInside) {
hasLeftStrip = true;
clear();
return;
}
// nowInside = true
// nowInside = true. Para cerrar (navOpen=true) exigimos que el puntero
// haya salido al menos una vez de la franja desde que empezo el drag;
// asi un drag que arranca dentro del sidebar abierto no auto-cierra.
const armable = !navOpenRef.current || hasLeftStrip;
if (!armable) return;
clear();
const willOpen = !navOpenRef.current;
@@ -227,7 +231,6 @@ export function App() {
// Tras toggle, resetea el flag para no encadenar otra accion sin
// que el usuario salga + vuelva.
hasLeftStrip = false;
setEdgeArmed(false);
}, DRAG_EDGE_HOVER_MS);
};
document.addEventListener("mousemove", onMove);
@@ -269,6 +272,15 @@ export function App() {
}
}, []);
const reloadArchive = useCallback(async () => {
try {
const a = await api.listArchive();
setArchive(a);
} catch (e) {
console.warn("listArchive failed", e);
}
}, []);
const reloadTags = useCallback(async () => {
try {
const t = await api.listTags();
@@ -295,6 +307,10 @@ export function App() {
reloadTrash();
}, [reloadTrash]);
useEffect(() => {
reloadArchive();
}, [reloadArchive]);
useEffect(() => {
reloadTags();
reloadRequesters();
@@ -750,6 +766,26 @@ export function App() {
}
}, [reload, reloadTrash]);
const handleUnarchiveCard = useCallback(async (id: string) => {
try {
await api.unarchiveCard(id);
reload();
reloadArchive();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}, [reload, reloadArchive]);
const handleArchiveCard = useCallback(async (id: string) => {
try {
await api.archiveCard(id);
reload();
reloadArchive();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}, [reload, reloadArchive]);
const handlePurgeCard = useCallback(async (id: string) => {
modals.openConfirmModal({
title: "Borrar permanentemente",
@@ -1165,6 +1201,7 @@ export function App() {
onSetCardDeadline={handleSetCardDeadline}
highlightCardId={highlightCardId}
onSetRequester={handleSetRequester}
onArchiveCard={handleArchiveCard}
requesterOptions={requesterOptions}
onOpenCustomCardColor={(cardId, current) => setCardColorModal({ cardId, color: current })}
activeSticker={activeSticker}
@@ -1228,6 +1265,51 @@ export function App() {
</Stack>
)}
</Box>
<Box style={{ borderTop: "1px solid var(--mantine-color-dark-5)", paddingTop: 8 }}>
<Button
variant="subtle"
color="gray"
size="xs"
fullWidth
justify="space-between"
leftSection={<IconCheck size={14} />}
rightSection={
<Group gap={4}>
<Badge size="xs" variant="light" color={archive.length > 0 ? "teal" : "gray"}>
{archive.length}
</Badge>
{archiveOpen ? <IconChevronDown size={12} /> : <IconChevronRight size={12} />}
</Group>
}
onClick={() => setArchiveOpen((v) => !v)}
data-test="archive-toggle"
>
Hecho (archivo)
</Button>
{archiveOpen && (
<Stack gap={4} mt={4} style={{ maxHeight: 220, overflowY: "auto" }}>
{archive.length === 0 && (
<Text size="xs" c="dimmed" px="xs">
Sin cards archivadas.
</Text>
)}
{archive.map((c) => (
<Paper key={c.id} p={6} withBorder radius="sm" bg="dark.7" data-archived-card-id={c.id}>
<Group justify="space-between" gap={4} wrap="nowrap">
<Text size="xs" truncate style={{ flex: 1 }} title={c.title}>
{c.title}
</Text>
<Tooltip label="Sacar del archivo (volver a Hecho)" withArrow>
<ActionIcon size="xs" variant="subtle" color="teal" onClick={() => handleUnarchiveCard(c.id)}>
<IconArrowBackUp size={12} />
</ActionIcon>
</Tooltip>
</Group>
</Paper>
))}
</Stack>
)}
</Box>
</Stack>
</AppShell.Navbar>
@@ -1435,6 +1517,7 @@ export function App() {
onSetCardDeadline={handleSetCardDeadline}
highlightCardId={highlightCardId}
onSetRequester={handleSetRequester}
onArchiveCard={handleArchiveCard}
requesterOptions={requesterOptions}
activeSticker={activeSticker}
onAddSticker={handleAddSticker}
+12
View File
@@ -107,6 +107,18 @@ export function purgeCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/purge`, { method: "DELETE" });
}
export function listArchive(): Promise<Card[]> {
return fetchJSON("/archive");
}
export function archiveCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/archive`, { method: "POST" });
}
export function unarchiveCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/unarchive`, { method: "POST" });
}
export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> {
return fetchJSON(`/cards/${id}/move`, {
method: "POST",
+15
View File
@@ -15,6 +15,7 @@ import {
Tooltip,
} from "@mantine/core";
import {
IconArchive,
IconCalendarDue,
IconCheck,
IconClock,
@@ -50,6 +51,7 @@ interface Props {
onAssign: (id: string, assignee_id: string | null) => void;
onSetDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
onArchive?: (id: string) => void;
requesterOptions?: string[];
onOpenCustomColor?: (cardId: string, current: string) => void;
activeSticker?: string | null;
@@ -100,6 +102,7 @@ interface CardBodyProps {
onAssign: (id: string, assignee_id: string | null) => void;
onSetDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
onArchive?: (id: string) => void;
onOpenCustomColor?: (cardId: string, current: string) => void;
onRemoveSticker?: (cardId: string, index: number) => void;
onMoveSticker?: (cardId: string, index: number, x: number, y: number) => void;
@@ -127,6 +130,7 @@ const KanbanCardBody = memo(function KanbanCardBody({
onAssign,
onSetDeadline,
onSetRequester,
onArchive,
onOpenCustomColor,
onRemoveSticker,
onMoveSticker,
@@ -321,6 +325,15 @@ const KanbanCardBody = memo(function KanbanCardBody({
</Popover.Dropdown>
</Popover>
)}
{isDone && onArchive && (
<Menu.Item
leftSection={<IconArchive size={14} />}
color="teal"
onClick={() => { setMenuOpen(false); onArchive(card.id); }}
>
Archivar
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={() => { setMenuOpen(false); onDelete(card.id); }}>Borrar</Menu.Item>
</>
@@ -463,6 +476,7 @@ function KanbanCardImpl({
onAssign,
onSetDeadline,
onSetRequester,
onArchive,
requesterOptions,
onOpenCustomColor,
activeSticker,
@@ -593,6 +607,7 @@ function KanbanCardImpl({
onAssign={onAssign}
onSetDeadline={onSetDeadline}
onSetRequester={onSetRequester}
onArchive={onArchive}
onOpenCustomColor={onOpenCustomColor}
onRemoveSticker={onRemoveSticker}
onMoveSticker={onMoveSticker}
+3
View File
@@ -82,6 +82,7 @@ interface Props {
onAssignCard: (id: string, assignee_id: string | null) => void;
onSetCardDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
onArchiveCard?: (id: string) => void;
requesterOptions?: string[];
onOpenCustomCardColor?: (cardId: string, current: string) => void;
activeSticker?: string | null;
@@ -117,6 +118,7 @@ function KanbanColumnImpl({
onAssignCard,
onSetCardDeadline,
onSetRequester,
onArchiveCard,
requesterOptions,
onOpenCustomCardColor,
activeSticker,
@@ -593,6 +595,7 @@ function KanbanColumnImpl({
onAssign={onAssignCard}
onSetDeadline={onSetCardDeadline}
onSetRequester={onSetRequester}
onArchive={onArchiveCard}
requesterOptions={requesterOptions}
onOpenCustomColor={onOpenCustomCardColor}
users={users}
+1
View File
@@ -34,6 +34,7 @@ export interface Card {
assignee_id: string | null;
completed_at: string | null;
deleted_at: string | null;
archived_at: string | null;
tags: string[];
stickers: Sticker[];
deadline: string | null;