9b0b6e516c
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.
179 lines
6.1 KiB
TypeScript
179 lines
6.1 KiB
TypeScript
import { Anchor, Box, Group, HoverCard, Stack, Text } from "@mantine/core";
|
|
import { IconBrandJira, IconAlertCircle } from "@tabler/icons-react";
|
|
import { CSSProperties, useEffect, useState } from "react";
|
|
import * as api from "../api";
|
|
import type { CardJiraSyncState } from "../api";
|
|
import { formatDateTimeShort } from "./format";
|
|
|
|
// 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";
|
|
|
|
function tone(state: CardJiraSyncState): Tone {
|
|
if (state.inflight) return "yellow";
|
|
if (state.last_error) return "red";
|
|
if (state.jira_key) return "green";
|
|
return "gray";
|
|
}
|
|
|
|
const TONE_COLOR: Record<Tone, string> = {
|
|
gray: "var(--mantine-color-gray-5)",
|
|
yellow: "var(--mantine-color-yellow-5)",
|
|
green: "var(--mantine-color-green-5)",
|
|
red: "var(--mantine-color-red-6)",
|
|
};
|
|
|
|
const TONE_LABEL: Record<Tone, string> = {
|
|
gray: "Sin sincronizar con Jira",
|
|
yellow: "Sincronizando...",
|
|
green: "Sincronizada con Jira",
|
|
red: "Error de sincronizacion",
|
|
};
|
|
|
|
interface Props {
|
|
cardId: string;
|
|
// Pollen-down so the parent can refresh when needed (e.g. after a move
|
|
// animation finishes) without waiting for the next tick.
|
|
refreshTick?: number;
|
|
}
|
|
|
|
export function JiraSyncIndicator({ cardId, refreshTick }: Props) {
|
|
const [state, setState] = useState<CardJiraSyncState | null>(null);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
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) 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) 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();
|
|
return () => {
|
|
cancelled = true;
|
|
if (timer) clearTimeout(timer);
|
|
window.removeEventListener("kanban-card-moved", onMoved);
|
|
};
|
|
}, [cardId, refreshTick]);
|
|
|
|
if (err && !state) {
|
|
return (
|
|
<Box title={err} style={dotStyle("var(--mantine-color-gray-3)")} aria-label="Jira sync state unavailable" />
|
|
);
|
|
}
|
|
if (!state) {
|
|
// Initial render — fade in a placeholder dot so the layout does not shift
|
|
// when the fetch resolves.
|
|
return <Box style={dotStyle("var(--mantine-color-gray-2)")} aria-label="Cargando estado Jira" />;
|
|
}
|
|
const t = tone(state);
|
|
return (
|
|
<HoverCard width={300} shadow="md" openDelay={150} closeDelay={120} withinPortal>
|
|
<HoverCard.Target>
|
|
<Box
|
|
role="status"
|
|
aria-label={TONE_LABEL[t]}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={dotStyle(TONE_COLOR[t])}
|
|
/>
|
|
</HoverCard.Target>
|
|
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
|
|
<Stack gap={6}>
|
|
<Group gap={6} wrap="nowrap" justify="space-between">
|
|
<Group gap={6} wrap="nowrap">
|
|
<IconBrandJira size={14} />
|
|
<Text size="sm" fw={600}>{TONE_LABEL[t]}</Text>
|
|
</Group>
|
|
{state.issue_url && (
|
|
<Anchor
|
|
size="xs"
|
|
href={state.issue_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
Abrir en Jira
|
|
</Anchor>
|
|
)}
|
|
</Group>
|
|
{state.jira_key && (
|
|
<Text size="xs">
|
|
<Text component="span" c="dimmed">Issue:</Text>{" "}
|
|
<Text component="span" fw={600}>{state.jira_key}</Text>
|
|
</Text>
|
|
)}
|
|
{state.last_status && (
|
|
<Text size="xs">
|
|
<Text component="span" c="dimmed">Status:</Text>{" "}
|
|
<Text component="span">{state.last_status}</Text>
|
|
</Text>
|
|
)}
|
|
{state.last_sync_at && (
|
|
<Text size="xs">
|
|
<Text component="span" c="dimmed">Ultimo sync:</Text>{" "}
|
|
<Text component="span">{formatDateTimeShort(state.last_sync_at)}</Text>
|
|
</Text>
|
|
)}
|
|
{state.last_error && (
|
|
<Group gap={6} wrap="nowrap" align="flex-start">
|
|
<IconAlertCircle size={14} color="var(--mantine-color-red-6)" />
|
|
<Text size="xs" c="red" style={{ wordBreak: "break-word" }}>{state.last_error}</Text>
|
|
</Group>
|
|
)}
|
|
{!state.jira_key && (
|
|
<Text size="xs" c="dimmed">
|
|
La card todavia no se ha empujado a Jira. Editala o muevela para
|
|
disparar el sync, o usa la opcion "Importar de Jira" si ya existe
|
|
alli.
|
|
</Text>
|
|
)}
|
|
</Stack>
|
|
</HoverCard.Dropdown>
|
|
</HoverCard>
|
|
);
|
|
}
|
|
|
|
function dotStyle(color: string): CSSProperties {
|
|
return {
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: "50%",
|
|
background: color,
|
|
cursor: "default",
|
|
boxShadow: "0 0 0 2px var(--mantine-color-body)",
|
|
transition: "background 120ms ease",
|
|
};
|
|
}
|