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