fix(jira): emitir card.moved al cambiar columna + adaptive indicator polling

Bug: handleMoveCard solo emitia board.invalidated. Dispatcher mapeaba a
update() (PUT summary/description/labels) NUNCA a transition(), asi que
mover una card en kanban no transicionaba su Jira issue de columna. Solo
los labels reflejaban el cambio.

Fix backend (handlers.go):
- handleMoveCard ahora lee column_id antes del MoveCard. Si la card crusa
  columnas (prev != new) publica 'card.moved' antes de 'board.invalidated'.
  El dispatcher reconoce 'card.moved' y ejecuta transition() -> Jira status
  cambia + labels sincronizan.
- Reorder dentro de la misma columna sigue como antes: solo board.invalidated
  para refetch del cliente sin tocar Jira.
- nuevo helper db.lookupCardColumnID(cardID).

UX frontend (JiraSyncIndicator):
- Polling adaptativo: 5s steady, 1s mientras inflight=true. El usuario VE
  el yellow durante el sync.
- Listener de window CustomEvent 'kanban-card-moved' (cardId match) que
  fuerza un refetch inmediato (~150ms) tras drop. App.tsx dispara el evento
  tras api.moveCard resolve. Yellow visible casi instantaneo en lugar de
  esperar al proximo tick steady.
This commit is contained in:
egutierrez
2026-05-29 15:01:51 +02:00
parent c5113f75a5
commit 9b0b6e516c
6 changed files with 209 additions and 151 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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-BbedqQPY.js"></script>
<script type="module" crossorigin src="/assets/index-Zozqj0rw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
</head>
<body>
+15
View File
@@ -322,6 +322,10 @@ func handleMoveCard(db *DB, hub *EventHub) http.HandlerFunc {
badRequest(w, "column_id required")
return
}
// Read the previous column BEFORE mutating so we can decide whether
// this is an actual column move (vs a same-column reorder). Outbound
// modules (Jira) only care about the former.
prevColumnID, _ := db.lookupCardColumnID(id)
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs, actor); err != nil {
if strings.Contains(err.Error(), "not found") {
@@ -331,6 +335,17 @@ func handleMoveCard(db *DB, hub *EventHub) http.HandlerFunc {
serverError(w, err)
return
}
// Distinct event when the card crossed columns so the Jira module
// runs transition() instead of plain update(). Reorder-only goes
// straight to board.invalidated (frontend refetch) without a Jira
// roundtrip.
if prevColumnID != "" && prevColumnID != body.ColumnID {
hub.PublishJSON("card.moved", id, "", map[string]string{
"card_id": id,
"from_column_id": prevColumnID,
"to_column_id": body.ColumnID,
})
}
publishInvalidated(hub, id, body.ColumnID)
w.WriteHeader(http.StatusNoContent)
}
+18
View File
@@ -229,6 +229,24 @@ func (db *DB) listColumnsByName() (map[string]Column, error) {
return out, nil
}
// lookupCardColumnID returns the current column_id for a card, or "" if the
// card does not exist. Used by handleMoveCard to detect column changes vs
// same-column reorders before publishing card.moved events.
func (db *DB) lookupCardColumnID(cardID string) (string, error) {
var col sql.NullString
err := db.conn.QueryRow(`SELECT column_id FROM cards WHERE id = ?`, cardID).Scan(&col)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", err
}
if !col.Valid {
return "", nil
}
return col.String, nil
}
// findCardByJiraKey returns the id of the card linked to jiraKey, or "" if
// no card carries that link. The lookup ignores soft-deleted cards.
func (db *DB) findCardByJiraKey(jiraKey string) (string, error) {
+4
View File
@@ -652,6 +652,10 @@ export function App() {
try {
await api.moveCard(activeId, destCol, orderedIds);
// Nudge the moved card's Jira sync indicator to refetch immediately
// so the operator sees the yellow "syncing" state without waiting for
// the steady-state poll tick (5s).
window.dispatchEvent(new CustomEvent("kanban-card-moved", { detail: { cardId: activeId } }));
} catch (err) {
notifications.show({ color: "red", message: (err as Error).message });
}
+33 -12
View File
@@ -5,11 +5,11 @@ import * as api from "../api";
import type { CardJiraSyncState } from "../api";
import { formatDateTimeShort } from "./format";
// Pull state every POLL_MS so the indicator catches up with async dispatcher
// pushes without needing SSE. 10s is a reasonable balance: short enough that a
// drag-to-Jira shows green within one tick, long enough that the polling is
// not a noticeable load.
const POLL_MS = 10000;
// Adaptive polling: 5s steady-state, 1s while the card is mid-sync. The fast
// cadence catches the yellow → green transition right after a column drag;
// the slow cadence keeps the per-card load manageable when the board is idle.
const POLL_MS_STEADY = 5000;
const POLL_MS_INFLIGHT = 1000;
type Tone = "gray" | "yellow" | "green" | "red";
@@ -47,22 +47,43 @@ export function JiraSyncIndicator({ cardId, refreshTick }: Props) {
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const schedule = (ms: number) => {
if (cancelled) return;
if (timer) clearTimeout(timer);
timer = setTimeout(load, ms);
};
const load = async () => {
try {
const s = await api.getCardJiraSync(cardId);
if (!cancelled) {
setState(s);
setErr(null);
}
if (cancelled) return;
setState(s);
setErr(null);
// Adaptive cadence: fast while the dispatcher is actively processing
// this card, slow otherwise. Re-arms on every fetch so the moment
// inflight flips off we drop back to the steady cadence.
schedule(s.inflight ? POLL_MS_INFLIGHT : POLL_MS_STEADY);
} catch (e) {
if (!cancelled) setErr((e as Error).message);
if (cancelled) return;
setErr((e as Error).message);
schedule(POLL_MS_STEADY);
}
};
// Window event fired by the App's drag-end handler so the indicator
// refetches immediately after a move (and so the user sees yellow within
// ~200ms instead of waiting up to POLL_MS_STEADY).
const onMoved = (e: Event) => {
const detail = (e as CustomEvent).detail as { cardId?: string } | undefined;
if (detail?.cardId === cardId) {
schedule(150);
}
};
window.addEventListener("kanban-card-moved", onMoved);
load();
const t = setInterval(load, POLL_MS);
return () => {
cancelled = true;
clearInterval(t);
if (timer) clearTimeout(timer);
window.removeEventListener("kanban-card-moved", onMoved);
};
}, [cardId, refreshTick]);