feat(tempo): blue team Tempo time from pick-up, not queue entry
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Previously blue_started_at was set when the RED team submitted evidence
(= queue open time), so Tempo was getting total queue wait time instead
of actual work time.

Changes:
- DB: add blue_work_started_at column (migration b045), set when a blue
  tech explicitly picks up the test (mirrors red_started_at for red team)
- Workflow: new start_blue_work() function + POST /tests/{id}/start-blue-work
  endpoint (blue_tech / blue_lead roles). Cannot be called twice.
- submit_blue_evidence: uses blue_work_started_at (when available) as the
  phase start for the Tempo worklog, falls back to blue_started_at
- reopen_test: clears blue_work_started_at alongside other timing fields
- Tempo: both red_team_execution and blue_team_evaluation now synced;
  correct work_date and description per activity type
- Frontend: "Start Evaluation" button shown in blue_evaluating state when
  blue_work_started_at is null; live timer shows from pick-up time

What each timestamp tracks:
  blue_started_at      = queue entry (SLA / internal tracking)
  blue_work_started_at = pick-up by blue tech (Tempo start)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-27 11:50:15 +02:00
parent 0e6cec4d07
commit 398e279116
10 changed files with 153 additions and 31 deletions

View File

@@ -172,6 +172,12 @@ export async function submitBlueEvidence(testId: string): Promise<Test> {
return data;
}
/** Blue tech picks up the test to start evaluating — sets the Tempo timer start. */
export async function startBlueWork(testId: string): Promise<Test> {
const { data } = await client.post<Test>(`/tests/${testId}/start-blue-work`);
return data;
}
// ── Lead Validation ────────────────────────────────────────────────
/** Red Lead approves/rejects the red side. */

View File

@@ -51,6 +51,7 @@ interface TestDetailHeaderProps {
onStartExecution: () => void;
onSubmitRed: () => void;
onSubmitBlue: () => void;
onStartBlueWork: () => void;
onOpenValidateModal: (side: "red" | "blue") => void;
onReopen: () => void;
onPauseTimer: () => void;
@@ -67,6 +68,7 @@ export default function TestDetailHeader({
onStartExecution,
onSubmitRed,
onSubmitBlue,
onStartBlueWork,
onOpenValidateModal,
onReopen,
onPauseTimer,
@@ -127,22 +129,38 @@ export default function TestDetailHeader({
);
}
// Blue Team in blue_evaluating -> Submit for Review
// Blue Team in blue_evaluating:
// - if not picked up yet: show "Start Evaluation" button
// - if already picked up: show "Submit for Review" button
if (
test.state === "blue_evaluating" &&
(role === "blue_tech" || role === "blue_lead" || role === "admin")
) {
buttons.push(
<button
key="submit-blue"
onClick={onSubmitBlue}
disabled={isTransitioning}
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 transition-colors disabled:opacity-50"
>
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Submit for Review
</button>,
);
if (!test.blue_work_started_at) {
buttons.push(
<button
key="start-blue-work"
onClick={onStartBlueWork}
disabled={isTransitioning}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors disabled:opacity-50"
>
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
Start Evaluation
</button>,
);
} else {
buttons.push(
<button
key="submit-blue"
onClick={onSubmitBlue}
disabled={isTransitioning}
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 transition-colors disabled:opacity-50"
>
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Submit for Review
</button>,
);
}
}
// Red Lead in in_review -> Validate Red
@@ -264,10 +282,10 @@ export default function TestDetailHeader({
/>
);
}
if (test.state === "blue_evaluating" && test.blue_started_at) {
if (test.state === "blue_evaluating" && test.blue_work_started_at) {
return (
<LiveTimer
startedAt={test.blue_started_at}
startedAt={test.blue_work_started_at}
pausedAt={test.paused_at}
pausedSeconds={test.blue_paused_seconds}
label="Blue Team Timer"

View File

@@ -10,6 +10,7 @@ import {
startExecution,
submitRedEvidence,
submitBlueEvidence,
startBlueWork,
validateAsRedLead,
validateAsBlueLead,
reopenTest,
@@ -190,6 +191,15 @@ export default function TestDetailPage() {
onError: (err: unknown) => showToast(extractError(err), "error"),
});
const startBlueWorkMutation = useMutation({
mutationFn: () => startBlueWork(testId!),
onSuccess: () => {
invalidateAll();
showToast("Blue evaluation started", "success");
},
onError: (err: unknown) => showToast(extractError(err), "error"),
});
const validateRedLeadMutation = useMutation({
mutationFn: (payload: { red_validation_status: "approved" | "rejected"; red_validation_notes?: string }) =>
validateAsRedLead(testId!, payload),
@@ -302,6 +312,7 @@ export default function TestDetailPage() {
startExecMutation.isPending ||
submitRedMutation.isPending ||
submitBlueMutation.isPending ||
startBlueWorkMutation.isPending ||
reopenMutation.isPending;
// ── Loading / Error states ─────────────────────────────────────
@@ -370,6 +381,7 @@ export default function TestDetailPage() {
onStartExecution={() => startExecMutation.mutate()}
onSubmitRed={() => submitRedMutation.mutate()}
onSubmitBlue={() => submitBlueMutation.mutate()}
onStartBlueWork={() => startBlueWorkMutation.mutate()}
onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
onReopen={() => setConfirmReopen(true)}
onPauseTimer={() => pauseTimerMutation.mutate()}

View File

@@ -90,6 +90,7 @@ export interface Test {
// Phase timing fields (for automatic Tempo worklogs)
red_started_at: string | null;
blue_started_at: string | null;
blue_work_started_at: string | null;
paused_at: string | null;
red_paused_seconds: number;
blue_paused_seconds: number;