feat(kanban): tiempo maximo por columna con borde rojo (issue 0089)

Adds column-level max time limit. Cards whose time_in_column_ms exceeds
the limit show a red border + red halo. Columns marked as Done never
trigger the visual regardless of the limit (per spec).

Backend:
- Migration 011_column_max_time.sql adds columns.max_time_minutes
  INTEGER NOT NULL DEFAULT 0 (0 = no limit). Aditiva, idempotente.
- Column struct + ColumnPatch + UpdateColumn handle the new field;
  negatives clamp to 0; listing query includes it.
- handleUpdateColumn (PATCH /api/columns/:id) accepts max_time_minutes
  in the JSON body.

Frontend:
- Column TS interface + UpdateColumnInput updated.
- KanbanColumn context menu: new entry "Tiempo maximo" using
  window.prompt for low-friction config; shows current value when >0.
- KanbanCard receives columnOverdue prop calculated from the column
  state and card.time_in_column_ms; renders red border (var
  --mantine-color-red-6) with 2px width + 2px red halo when overdue.
- data-card-id, data-column-overdue, data-locked attributes on the card
  paper element so e2e tests / scripts can query state.

Tests: TestColumnMaxTimeMinutes_Defaults + _Update verify the schema
default, the clamp on negative input, and that updating max_time leaves
other fields untouched.

Visual regression of the red border kept out of automated e2e because
it requires either clock control or real cards aged > N minutes; will be
verified manually after merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 13:24:22 +02:00
parent eb1c13d82c
commit 9f4fd85db3
12 changed files with 1360 additions and 1203 deletions
+19 -3
View File
@@ -60,6 +60,7 @@ interface Props {
users: User[];
assignee?: User;
inDoneColumn?: boolean;
columnOverdue?: boolean;
isOverlay?: boolean;
highlight?: boolean;
}
@@ -86,6 +87,7 @@ function KanbanCardImpl({
users,
assignee,
inDoneColumn,
columnOverdue,
isOverlay,
highlight,
}: Props) {
@@ -162,14 +164,25 @@ function KanbanCardImpl({
onRemoveSticker?.(card.id, index);
};
const borderColorPicked = highlight
? "var(--mantine-color-blue-5)"
: columnOverdue
? "var(--mantine-color-red-6)"
: card.locked
? "var(--mantine-color-yellow-6)"
: colorBorder(card.color);
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
background: colorBg(card.color),
borderColor: highlight ? "var(--mantine-color-blue-5)" : card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
borderWidth: highlight || card.locked ? 2 : 1,
boxShadow: highlight ? "0 0 0 3px var(--mantine-color-blue-4)" : undefined,
borderColor: borderColorPicked,
borderWidth: highlight || card.locked || columnOverdue ? 2 : 1,
boxShadow: highlight
? "0 0 0 3px var(--mantine-color-blue-4)"
: columnOverdue
? "0 0 0 2px var(--mantine-color-red-3)"
: undefined,
filter: isDone ? "brightness(0.55) saturate(0.7)" : undefined,
};
@@ -433,6 +446,9 @@ function KanbanCardImpl({
p="xs"
shadow={isOverlay ? "lg" : "xs"}
radius="md"
data-card-id={card.id}
data-column-overdue={columnOverdue ? "true" : "false"}
data-locked={card.locked ? "true" : "false"}
onContextMenu={onContextMenu}
onClick={onCardClickAddSticker}
onDoubleClick={(e) => {
+26
View File
@@ -25,6 +25,7 @@ import {
IconCheckbox,
IconChevronDown,
IconChevronRight,
IconClock,
IconDotsVertical,
IconGripVertical,
IconPencil,
@@ -47,6 +48,7 @@ interface Props {
onMoveColumnLocation: (id: string, location: "board" | "sidebar") => void;
onDeleteColumn: (id: string) => void;
onSetWIPLimit: (id: string, limit: number) => void;
onSetMaxTimeMinutes: (id: string, minutes: number) => void;
onToggleDone: (id: string, is_done: boolean) => void;
onEditCard: (card: Card) => void;
onDeleteCard: (id: string) => void;
@@ -80,6 +82,7 @@ function KanbanColumnImpl({
onMoveColumnLocation,
onDeleteColumn,
onSetWIPLimit,
onSetMaxTimeMinutes,
onToggleDone,
onEditCard,
onDeleteCard,
@@ -390,6 +393,24 @@ function KanbanColumnImpl({
>
{column.is_done ? "Quitar marca Done" : "Marcar como Done"}
</Menu.Item>
<Menu.Item
leftSection={<IconClock size={14} />}
data-test="column-max-time"
onClick={() => {
const current = column.max_time_minutes || 0;
const raw = window.prompt(
"Tiempo maximo en minutos (0 = sin limite). Cards que pasen este tiempo en la columna mostraran borde rojo. Columnas Done no aplican.",
String(current)
);
if (raw === null) return;
const v = parseInt(raw.trim(), 10);
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
if (safe !== current) onSetMaxTimeMinutes(column.id, safe);
}}
>
Tiempo maximo
{column.max_time_minutes > 0 ? ` (${column.max_time_minutes} min)` : ""}
</Menu.Item>
<Menu.Item
leftSection={<ArchiveIcon size={14} />}
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
@@ -435,6 +456,11 @@ function KanbanColumnImpl({
users={users}
assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined}
inDoneColumn={column.is_done}
columnOverdue={
!column.is_done &&
column.max_time_minutes > 0 &&
c.time_in_column_ms > column.max_time_minutes * 60_000
}
highlight={highlightCardId === c.id}
activeSticker={activeSticker}
onAddSticker={onAddSticker}