feat(kanban): mejoras historial card — DONE check + tiempo por columna

HistoryModal now receives the board's columns via prop to enrich
history events with column metadata.

Timeline:
- Entries for column moves into a column with is_done=true now render
  with a green IconCheck bullet and the kind label "Hecho en columna"
  instead of the generic blue arrows / "Mueve a columna". Makes the
  card's "done" moments scannable at a glance.

Footer (below the timeline):
- Replaces the single Group-of-badges with a structured table showing
  one row per visited column: name, visits (entry count) and total time
  in column. DONE columns are flagged with a green check + bold.
- Total locked time keeps the same source (total_locked_ms) but moved
  to a header line above the table to declutter.
- Currently-active entry (exited_at=null) contributes now - entered_at
  to its row, so the table reflects live time.

App.tsx passes columns from board state when opening the history modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 13:53:19 +02:00
parent bc502df48a
commit 30def13c55
2 changed files with 84 additions and 19 deletions
+2 -2
View File
@@ -781,9 +781,9 @@ export function App() {
modals.open({
title: card.title,
size: "md",
children: <HistoryModal card={card} />,
children: <HistoryModal card={card} columns={board?.columns ?? []} />,
});
}, []);
}, [board?.columns]);
const handleToggleCardLock = useCallback(async (id: string, locked: boolean) => {
setBoard((prev) => {
+82 -17
View File
@@ -1,8 +1,9 @@
import { Badge, Divider, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
import { Badge, Divider, Group, Loader, Stack, Table, Text, Timeline } from "@mantine/core";
import {
IconArrowsHorizontal,
IconCalendarDue,
IconCalendarOff,
IconCheck,
IconColumns3,
IconEdit,
IconLock,
@@ -16,11 +17,12 @@ import {
} from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import { cardHistory, listUsers } from "../api";
import type { Card, CardEvent, CardHistoryResponse, User } from "../types";
import type { Card, CardEvent, CardHistoryResponse, Column, User } from "../types";
import { formatDuration } from "./format";
interface Props {
card: Card;
columns?: Column[];
}
interface UnifiedEvent {
@@ -31,6 +33,7 @@ interface UnifiedEvent {
detail: string;
icon: React.ReactNode;
color: string;
doneColumn?: boolean;
}
function parsePayload(p: string): Record<string, unknown> {
@@ -67,10 +70,18 @@ function eventToUnified(e: CardEvent): UnifiedEvent {
}
}
export function HistoryModal({ card }: Props) {
export function HistoryModal({ card, columns = [] }: Props) {
const [data, setData] = useState<CardHistoryResponse | null>(null);
const [users, setUsers] = useState<User[]>([]);
const columnById = useMemo(() => {
const m = new Map<string, Column>();
for (const c of columns) m.set(c.id, c);
return m;
}, [columns]);
const isDoneColumn = (columnId: string) => columnById.get(columnId)?.is_done === true;
useEffect(() => {
cardHistory(card.id)
.then(setData)
@@ -91,14 +102,16 @@ export function HistoryModal({ card }: Props) {
const out: UnifiedEvent[] = [];
for (const e of data.events || []) out.push(eventToUnified(e));
for (const h of data.column_history || []) {
const done = isDoneColumn(h.column_id);
out.push({
id: "h_in_" + h.id,
ts: h.entered_at,
kind: "Mueve a columna",
kind: done ? "Hecho en columna" : "Mueve a columna",
actorID: h.actor_id,
detail: h.column_name || h.column_id,
icon: <IconArrowsHorizontal size={12} />,
color: "blue",
icon: done ? <IconCheck size={12} /> : <IconArrowsHorizontal size={12} />,
color: done ? "green" : "blue",
doneColumn: done,
});
}
for (const p of data.lock_periods || []) {
@@ -108,7 +121,7 @@ export function HistoryModal({ card }: Props) {
}
}
return out.sort((a, b) => a.ts.localeCompare(b.ts));
}, [data]);
}, [data, columnById]);
if (!data) {
return (
@@ -124,6 +137,26 @@ export function HistoryModal({ card }: Props) {
return <Text c="dimmed">Sin historial.</Text>;
}
// Per-column time stats: sum duration_ms by column_id from column_history.
// Currently-active entry (exited_at=null) gets duration_ms = now - entered_at.
const nowMs = Date.now();
const perColumnMs = new Map<string, { name: string; isDone: boolean; ms: number; visits: number }>();
for (const h of column_history) {
const dur = h.exited_at ? h.duration_ms : Math.max(0, nowMs - new Date(h.entered_at).getTime());
const key = h.column_id;
const prev = perColumnMs.get(key);
const meta = columnById.get(key);
perColumnMs.set(key, {
name: h.column_name || meta?.name || key,
isDone: meta?.is_done ?? false,
ms: (prev?.ms ?? 0) + dur,
visits: (prev?.visits ?? 0) + 1,
});
}
const perColumnRows = Array.from(perColumnMs.entries())
.map(([id, v]) => ({ id, ...v }))
.sort((a, b) => b.ms - a.ms);
const userLabel = (id: string | null): string => {
if (!id) return "";
const u = userById.get(id);
@@ -140,6 +173,7 @@ export function HistoryModal({ card }: Props) {
key={e.id}
bullet={e.icon}
color={e.color}
lineVariant={e.doneColumn ? "solid" : undefined}
title={
<Group gap={6} wrap="wrap">
<Text fw={500} size="sm">{e.kind}</Text>
@@ -163,16 +197,47 @@ export function HistoryModal({ card }: Props) {
<Divider />
<Group gap={6} align="center">
<IconColumns3 size={14} />
<Text fw={500} size="sm">Columnas visitadas</Text>
<Badge size="xs" variant="light" color="gray">{column_history.length}</Badge>
<IconLock size={14} color="var(--mantine-color-yellow-6)" />
<Badge size="xs" variant="light" color={total_locked_ms > 0 ? "yellow" : "gray"}>
{formatDuration(total_locked_ms)}
</Badge>
{currently_locked && <Badge size="xs" variant="filled" color="yellow">bloqueada</Badge>}
</Group>
<Stack gap={6}>
<Group gap={6} align="center" wrap="wrap">
<IconColumns3 size={14} />
<Text fw={500} size="sm">Tiempo por columna</Text>
<Badge size="xs" variant="light" color="gray">{column_history.length} entradas</Badge>
<Text size="xs" c="dimmed" ml="auto">
<IconLock size={11} style={{ verticalAlign: "middle" }} />{" "}
<Text span size="xs" fw={500} c={total_locked_ms > 0 ? "yellow" : "dimmed"}>
{formatDuration(total_locked_ms)}
</Text>{" "}
bloqueada{currently_locked ? " (en curso)" : ""}
</Text>
</Group>
{perColumnRows.length > 0 ? (
<Table withTableBorder withColumnBorders striped="even" verticalSpacing={4} fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Columna</Table.Th>
<Table.Th style={{ width: 60 }}>Visitas</Table.Th>
<Table.Th style={{ width: 130 }}>Tiempo total</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{perColumnRows.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>
<Group gap={4} wrap="nowrap">
{r.isDone && <IconCheck size={12} color="var(--mantine-color-green-6)" />}
<Text size="xs" fw={r.isDone ? 600 : 400}>{r.name}</Text>
</Group>
</Table.Td>
<Table.Td>{r.visits}</Table.Td>
<Table.Td>{formatDuration(r.ms)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text size="xs" c="dimmed">Sin movimientos entre columnas.</Text>
)}
</Stack>
</Stack>
);
}