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:
+138
-138
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Kanban</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -322,6 +322,10 @@ func handleMoveCard(db *DB, hub *EventHub) http.HandlerFunc {
|
|||||||
badRequest(w, "column_id required")
|
badRequest(w, "column_id required")
|
||||||
return
|
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)
|
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
||||||
if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs, actor); err != nil {
|
if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs, actor); err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
@@ -331,6 +335,17 @@ func handleMoveCard(db *DB, hub *EventHub) http.HandlerFunc {
|
|||||||
serverError(w, err)
|
serverError(w, err)
|
||||||
return
|
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)
|
publishInvalidated(hub, id, body.ColumnID)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,24 @@ func (db *DB) listColumnsByName() (map[string]Column, error) {
|
|||||||
return out, nil
|
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
|
// findCardByJiraKey returns the id of the card linked to jiraKey, or "" if
|
||||||
// no card carries that link. The lookup ignores soft-deleted cards.
|
// no card carries that link. The lookup ignores soft-deleted cards.
|
||||||
func (db *DB) findCardByJiraKey(jiraKey string) (string, error) {
|
func (db *DB) findCardByJiraKey(jiraKey string) (string, error) {
|
||||||
|
|||||||
@@ -652,6 +652,10 @@ export function App() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await api.moveCard(activeId, destCol, orderedIds);
|
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) {
|
} catch (err) {
|
||||||
notifications.show({ color: "red", message: (err as Error).message });
|
notifications.show({ color: "red", message: (err as Error).message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import * as api from "../api";
|
|||||||
import type { CardJiraSyncState } from "../api";
|
import type { CardJiraSyncState } from "../api";
|
||||||
import { formatDateTimeShort } from "./format";
|
import { formatDateTimeShort } from "./format";
|
||||||
|
|
||||||
// Pull state every POLL_MS so the indicator catches up with async dispatcher
|
// Adaptive polling: 5s steady-state, 1s while the card is mid-sync. The fast
|
||||||
// pushes without needing SSE. 10s is a reasonable balance: short enough that a
|
// cadence catches the yellow → green transition right after a column drag;
|
||||||
// drag-to-Jira shows green within one tick, long enough that the polling is
|
// the slow cadence keeps the per-card load manageable when the board is idle.
|
||||||
// not a noticeable load.
|
const POLL_MS_STEADY = 5000;
|
||||||
const POLL_MS = 10000;
|
const POLL_MS_INFLIGHT = 1000;
|
||||||
|
|
||||||
type Tone = "gray" | "yellow" | "green" | "red";
|
type Tone = "gray" | "yellow" | "green" | "red";
|
||||||
|
|
||||||
@@ -47,22 +47,43 @@ export function JiraSyncIndicator({ cardId, refreshTick }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
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 () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const s = await api.getCardJiraSync(cardId);
|
const s = await api.getCardJiraSync(cardId);
|
||||||
if (!cancelled) {
|
if (cancelled) return;
|
||||||
setState(s);
|
setState(s);
|
||||||
setErr(null);
|
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) {
|
} 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();
|
load();
|
||||||
const t = setInterval(load, POLL_MS);
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(t);
|
if (timer) clearTimeout(timer);
|
||||||
|
window.removeEventListener("kanban-card-moved", onMoved);
|
||||||
};
|
};
|
||||||
}, [cardId, refreshTick]);
|
}, [cardId, refreshTick]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user