feat(phase-36): automatic Tempo time tracking via workflow buttons + fix campaign test management

- Add red_started_at/blue_started_at timing fields to Test model with Alembic migration

- Modify workflow transitions to auto-create integrity-hashed worklogs: Start Execution records red_started_at, Submit to Blue Team stops Red timer and creates worklog then starts Blue timer, Submit for Review stops Blue timer and creates worklog

- Auto-sync worklogs to Tempo when test has a Jira link

- Add LiveTimer component showing real-time elapsed counter during active phases

- Clear timing fields on test reopen

- Fix campaign test management: replace broken navigate-to-tests flow with AddTestToCampaignModal that lets users search and add existing tests directly from the campaign detail page
This commit is contained in:
2026-02-17 16:59:19 +01:00
parent 005a09b42f
commit febf460580
10 changed files with 461 additions and 5 deletions

View File

@@ -0,0 +1,65 @@
import { useState, useEffect } from "react";
import { Timer } from "lucide-react";
interface LiveTimerProps {
startedAt: string;
label: string;
variant: "red" | "blue";
}
/**
* Real-time elapsed timer that counts up from a given start timestamp.
* Shown while a Red/Blue Team phase is active so users can see
* exactly how long they've been working. This time is recorded
* as an automatic worklog when the phase ends.
*/
export default function LiveTimer({ startedAt, label, variant }: LiveTimerProps) {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const start = new Date(startedAt).getTime();
const tick = () => {
const now = Date.now();
setElapsed(Math.max(0, Math.floor((now - start) / 1000)));
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [startedAt]);
const hours = Math.floor(elapsed / 3600);
const minutes = Math.floor((elapsed % 3600) / 60);
const seconds = elapsed % 60;
const pad = (n: number) => String(n).padStart(2, "0");
const colors =
variant === "red"
? "border-orange-500/40 bg-orange-900/30 text-orange-300"
: "border-indigo-500/40 bg-indigo-900/30 text-indigo-300";
const dotColor = variant === "red" ? "bg-orange-400" : "bg-indigo-400";
return (
<div
className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 ${colors}`}
>
<div className="relative flex items-center">
<Timer className="h-4 w-4" />
<span
className={`absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full ${dotColor} animate-pulse`}
/>
</div>
<div className="flex flex-col">
<span className="text-[10px] font-medium uppercase tracking-wider opacity-70">
{label}
</span>
<span className="font-mono text-sm font-bold tabular-nums">
{pad(hours)}:{pad(minutes)}:{pad(seconds)}
</span>
</div>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import {
ShieldCheck,
} from "lucide-react";
import type { Test, TestState, User } from "../../types/models";
import LiveTimer from "./LiveTimer";
// ── Progress steps ─────────────────────────────────────────────────
@@ -235,6 +236,30 @@ export default function TestDetailHeader({
);
};
// ── Live timer ───────────────────────────────────────────────────
const renderLiveTimer = () => {
if (test.state === "red_executing" && test.red_started_at) {
return (
<LiveTimer
startedAt={test.red_started_at}
label="Red Team Timer"
variant="red"
/>
);
}
if (test.state === "blue_evaluating" && test.blue_started_at) {
return (
<LiveTimer
startedAt={test.blue_started_at}
label="Blue Team Timer"
variant="blue"
/>
);
}
return null;
};
// ── Render ───────────────────────────────────────────────────────
return (
@@ -263,7 +288,10 @@ export default function TestDetailHeader({
</div>
</div>
{renderActions()}
<div className="flex flex-col items-end gap-2">
{renderLiveTimer()}
{renderActions()}
</div>
</div>
{/* Progress bar */}