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:
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue 0089: tiempo maximo por columna.
|
||||
|
||||
func openTestDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
db, err := openDB(path)
|
||||
if err != nil {
|
||||
t.Fatalf("openDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Close()
|
||||
_ = os.Remove(path)
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
func TestColumnMaxTimeMinutes_Defaults(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
c, err := db.CreateColumn("col1")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateColumn: %v", err)
|
||||
}
|
||||
if c.MaxTimeMinutes != 0 {
|
||||
t.Fatalf("new column max_time_minutes = %d, want 0", c.MaxTimeMinutes)
|
||||
}
|
||||
|
||||
cols, err := db.ListColumns()
|
||||
if err != nil {
|
||||
t.Fatalf("ListColumns: %v", err)
|
||||
}
|
||||
if len(cols) == 0 || cols[0].MaxTimeMinutes != 0 {
|
||||
t.Fatalf("listed col max_time_minutes = %d, want 0", cols[0].MaxTimeMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColumnMaxTimeMinutes_Update(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
c, _ := db.CreateColumn("c")
|
||||
v := 30
|
||||
if err := db.UpdateColumn(c.ID, ColumnPatch{MaxTimeMinutes: &v}); err != nil {
|
||||
t.Fatalf("UpdateColumn set 30: %v", err)
|
||||
}
|
||||
|
||||
cols, _ := db.ListColumns()
|
||||
if cols[0].MaxTimeMinutes != 30 {
|
||||
t.Fatalf("after set max=30 got %d", cols[0].MaxTimeMinutes)
|
||||
}
|
||||
|
||||
// Negative clamps to 0.
|
||||
neg := -5
|
||||
if err := db.UpdateColumn(c.ID, ColumnPatch{MaxTimeMinutes: &neg}); err != nil {
|
||||
t.Fatalf("UpdateColumn neg: %v", err)
|
||||
}
|
||||
cols, _ = db.ListColumns()
|
||||
if cols[0].MaxTimeMinutes != 0 {
|
||||
t.Fatalf("negative should clamp to 0, got %d", cols[0].MaxTimeMinutes)
|
||||
}
|
||||
|
||||
// Other fields untouched.
|
||||
w := 555
|
||||
if err := db.UpdateColumn(c.ID, ColumnPatch{Width: &w}); err != nil {
|
||||
t.Fatalf("UpdateColumn width: %v", err)
|
||||
}
|
||||
cols, _ = db.ListColumns()
|
||||
if cols[0].MaxTimeMinutes != 0 {
|
||||
t.Fatalf("max_time should still be 0 after width update, got %d", cols[0].MaxTimeMinutes)
|
||||
}
|
||||
if cols[0].Width != 555 {
|
||||
t.Fatalf("width = %d, want 555", cols[0].Width)
|
||||
}
|
||||
}
|
||||
+27
-16
@@ -17,14 +17,15 @@ import (
|
||||
var migrationsFS embed.FS
|
||||
|
||||
type Column struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
Location string `json:"location"`
|
||||
Width int `json:"width"`
|
||||
WIPLimit int `json:"wip_limit"`
|
||||
IsDone bool `json:"is_done"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
Location string `json:"location"`
|
||||
Width int `json:"width"`
|
||||
WIPLimit int `json:"wip_limit"`
|
||||
IsDone bool `json:"is_done"`
|
||||
MaxTimeMinutes int `json:"max_time_minutes"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type Sticker struct {
|
||||
@@ -305,7 +306,7 @@ func insertCardEvent(execer interface {
|
||||
// --- Columns ---
|
||||
|
||||
func (db *DB) ListColumns() ([]Column, error) {
|
||||
rows, err := db.conn.Query(`SELECT id, name, position, location, width, wip_limit, is_done, created_at FROM columns ORDER BY position, created_at`)
|
||||
rows, err := db.conn.Query(`SELECT id, name, position, location, width, wip_limit, is_done, max_time_minutes, created_at FROM columns ORDER BY position, created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -314,7 +315,7 @@ func (db *DB) ListColumns() ([]Column, error) {
|
||||
for rows.Next() {
|
||||
var c Column
|
||||
var isDone int
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.WIPLimit, &isDone, &c.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.WIPLimit, &isDone, &c.MaxTimeMinutes, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.IsDone = isDone != 0
|
||||
@@ -344,12 +345,13 @@ func (db *DB) CreateColumn(name string) (*Column, error) {
|
||||
}
|
||||
|
||||
type ColumnPatch struct {
|
||||
Name *string
|
||||
Position *int
|
||||
Location *string
|
||||
Width *int
|
||||
WIPLimit *int
|
||||
IsDone *bool
|
||||
Name *string
|
||||
Position *int
|
||||
Location *string
|
||||
Width *int
|
||||
WIPLimit *int
|
||||
IsDone *bool
|
||||
MaxTimeMinutes *int
|
||||
}
|
||||
|
||||
func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
|
||||
@@ -411,6 +413,15 @@ func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if patch.MaxTimeMinutes != nil {
|
||||
m := *patch.MaxTimeMinutes
|
||||
if m < 0 {
|
||||
m = 0
|
||||
}
|
||||
if _, err := db.conn.Exec(`UPDATE columns SET max_time_minutes=? WHERE id=?`, m, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
-1176
File diff suppressed because one or more lines are too long
+1176
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kanban</title>
|
||||
<script type="module" crossorigin src="/assets/index-CUPtTPZl.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-D1wc-P9j.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+8
-7
@@ -67,18 +67,19 @@ func handleUpdateColumn(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Position *int `json:"position"`
|
||||
Location *string `json:"location"`
|
||||
Width *int `json:"width"`
|
||||
WIPLimit *int `json:"wip_limit"`
|
||||
IsDone *bool `json:"is_done"`
|
||||
Name *string `json:"name"`
|
||||
Position *int `json:"position"`
|
||||
Location *string `json:"location"`
|
||||
Width *int `json:"width"`
|
||||
WIPLimit *int `json:"wip_limit"`
|
||||
IsDone *bool `json:"is_done"`
|
||||
MaxTimeMinutes *int `json:"max_time_minutes"`
|
||||
}
|
||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width, WIPLimit: body.WIPLimit, IsDone: body.IsDone}); err != nil {
|
||||
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width, WIPLimit: body.WIPLimit, IsDone: body.IsDone, MaxTimeMinutes: body.MaxTimeMinutes}); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Issue 0089: tiempo maximo por columna.
|
||||
-- NULL/0 = sin limite. >0 = minutos antes de marcar como vencida la card.
|
||||
-- Cards en columnas con is_done=1 nunca se marcan como vencidas.
|
||||
ALTER TABLE columns ADD COLUMN max_time_minutes INTEGER NOT NULL DEFAULT 0;
|
||||
Reference in New Issue
Block a user