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:
65
frontend/src/components/test-detail/LiveTimer.tsx
Normal file
65
frontend/src/components/test-detail/LiveTimer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user