feat(tempo): blue team Tempo time from pick-up, not queue entry
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
16
backend/alembic/versions/b045_blue_work_started_at.py
Normal file
16
backend/alembic/versions/b045_blue_work_started_at.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""Add blue_work_started_at to tests table."""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "b045"
|
||||||
|
down_revision = "b044"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("tests", sa.Column("blue_work_started_at", sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("tests", "blue_work_started_at")
|
||||||
@@ -50,6 +50,7 @@ class Test(Base):
|
|||||||
# ── Phase timing fields (for automatic Tempo worklogs) ──────────
|
# ── Phase timing fields (for automatic Tempo worklogs) ──────────
|
||||||
red_started_at = Column(DateTime, nullable=True)
|
red_started_at = Column(DateTime, nullable=True)
|
||||||
blue_started_at = Column(DateTime, nullable=True)
|
blue_started_at = Column(DateTime, nullable=True)
|
||||||
|
blue_work_started_at = Column(DateTime, nullable=True) # when blue tech picks up (Tempo start)
|
||||||
paused_at = Column(DateTime, nullable=True)
|
paused_at = Column(DateTime, nullable=True)
|
||||||
red_paused_seconds = Column(Integer, default=0)
|
red_paused_seconds = Column(Integer, default=0)
|
||||||
blue_paused_seconds = Column(Integer, default=0)
|
blue_paused_seconds = Column(Integer, default=0)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ PATCH /tests/{id}/red — Red Team updates (draft, red_executing)
|
|||||||
PATCH /tests/{id}/blue — Blue Team updates (blue_evaluating)
|
PATCH /tests/{id}/blue — Blue Team updates (blue_evaluating)
|
||||||
POST /tests/{id}/start-execution — draft → red_executing
|
POST /tests/{id}/start-execution — draft → red_executing
|
||||||
POST /tests/{id}/submit-red — red_executing → blue_evaluating
|
POST /tests/{id}/submit-red — red_executing → blue_evaluating
|
||||||
|
POST /tests/{id}/start-blue-work — blue tech picks up (sets Tempo timer)
|
||||||
POST /tests/{id}/submit-blue — blue_evaluating → in_review
|
POST /tests/{id}/submit-blue — blue_evaluating → in_review
|
||||||
POST /tests/{id}/validate-red — Red Lead validates
|
POST /tests/{id}/validate-red — Red Lead validates
|
||||||
POST /tests/{id}/validate-blue — Blue Lead validates
|
POST /tests/{id}/validate-blue — Blue Lead validates
|
||||||
@@ -62,6 +63,7 @@ from app.services.test_workflow_service import (
|
|||||||
start_execution as wf_start_execution,
|
start_execution as wf_start_execution,
|
||||||
submit_red_evidence as wf_submit_red,
|
submit_red_evidence as wf_submit_red,
|
||||||
submit_blue_evidence as wf_submit_blue,
|
submit_blue_evidence as wf_submit_blue,
|
||||||
|
start_blue_work as wf_start_blue_work,
|
||||||
validate_as_red_lead as wf_validate_red,
|
validate_as_red_lead as wf_validate_red,
|
||||||
validate_as_blue_lead as wf_validate_blue,
|
validate_as_blue_lead as wf_validate_blue,
|
||||||
reopen_test as wf_reopen,
|
reopen_test as wf_reopen,
|
||||||
@@ -415,6 +417,26 @@ def submit_blue(
|
|||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /tests/{id}/start-blue-work — blue tech picks up test for evaluation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{test_id}/start-blue-work", response_model=TestOut)
|
||||||
|
def start_blue_work(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
|
||||||
|
):
|
||||||
|
"""Blue tech picks up the test to start evaluating. Sets the Tempo timer start."""
|
||||||
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
test = wf_start_blue_work(db, test, current_user)
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /tests/{id}/pause-timer — pause the active phase timer
|
# POST /tests/{id}/pause-timer — pause the active phase timer
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ class TestOut(BaseModel):
|
|||||||
# Phase timing fields (for Tempo worklogs)
|
# Phase timing fields (for Tempo worklogs)
|
||||||
red_started_at: datetime | None = None
|
red_started_at: datetime | None = None
|
||||||
blue_started_at: datetime | None = None
|
blue_started_at: datetime | None = None
|
||||||
|
blue_work_started_at: datetime | None = None
|
||||||
paused_at: datetime | None = None
|
paused_at: datetime | None = None
|
||||||
red_paused_seconds: int = 0
|
red_paused_seconds: int = 0
|
||||||
blue_paused_seconds: int = 0
|
blue_paused_seconds: int = 0
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ all Tempo calls are silently skipped regardless of whether users have tokens.
|
|||||||
|
|
||||||
What goes to Tempo
|
What goes to Tempo
|
||||||
------------------
|
------------------
|
||||||
Only **red team execution** time is logged to Tempo. This reflects the time
|
Both **red team execution** and **blue team evaluation** time are logged to
|
||||||
the operator (red_tech) spends executing the attack technique — from the moment
|
Tempo. Red team time runs from when the red_tech clicks "Start" to "Done".
|
||||||
they click "Start" to when they click "Done". Blue team evaluation time is
|
Blue team time runs from when the blue_tech clicks "Start Evaluation" (sets
|
||||||
tracked internally in Aegis but is NOT sent to Tempo.
|
blue_work_started_at) to when they submit, so it reflects actual working time
|
||||||
|
rather than queue time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -28,10 +29,8 @@ from app.models.jira_link import JiraLink, JiraLinkEntityType
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Only these activity types are forwarded to Tempo.
|
# Activity types forwarded to Tempo.
|
||||||
# "blue_team_evaluation" is intentionally excluded — it is tracked in Aegis
|
_TEMPO_ACTIVITY_TYPES = {"red_team_execution", "blue_team_evaluation"}
|
||||||
# but does not represent operator execution work.
|
|
||||||
_TEMPO_ACTIVITY_TYPES = {"red_team_execution"}
|
|
||||||
|
|
||||||
|
|
||||||
def has_tempo_configured(user) -> bool:
|
def has_tempo_configured(user) -> bool:
|
||||||
@@ -104,7 +103,7 @@ def auto_log_test_worklog(
|
|||||||
Returns the Tempo worklog response dict, or ``None`` if skipped.
|
Returns the Tempo worklog response dict, or ``None`` if skipped.
|
||||||
Completely non-fatal — errors are logged and swallowed.
|
Completely non-fatal — errors are logged and swallowed.
|
||||||
"""
|
"""
|
||||||
# Only operator execution time goes to Tempo
|
# Only whitelisted activity types go to Tempo
|
||||||
if activity_type not in _TEMPO_ACTIVITY_TYPES:
|
if activity_type not in _TEMPO_ACTIVITY_TYPES:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Skipping Tempo sync for activity_type=%s (not in whitelist)", activity_type
|
"Skipping Tempo sync for activity_type=%s (not in whitelist)", activity_type
|
||||||
@@ -152,19 +151,27 @@ def auto_log_test_worklog(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use red_started_at date as the worklog date so it matches when the
|
# Use the phase start timestamp as the worklog date so it matches when
|
||||||
# work actually happened (not the submission timestamp).
|
# work actually happened (not the submission timestamp).
|
||||||
|
if activity_type == "blue_team_evaluation":
|
||||||
|
work_date = (
|
||||||
|
(test.blue_work_started_at or test.blue_started_at or test.created_at)
|
||||||
|
.strftime("%Y-%m-%d")
|
||||||
|
)
|
||||||
|
description = f"[Aegis] Blue Team evaluation: {test.name}"
|
||||||
|
else:
|
||||||
work_date = (
|
work_date = (
|
||||||
(test.red_started_at or getattr(test, "updated_at", None) or test.created_at)
|
(test.red_started_at or getattr(test, "updated_at", None) or test.created_at)
|
||||||
.strftime("%Y-%m-%d")
|
.strftime("%Y-%m-%d")
|
||||||
)
|
)
|
||||||
|
description = f"[Aegis] Red Team execution: {test.name}"
|
||||||
result = log_worklog(
|
result = log_worklog(
|
||||||
user=user,
|
user=user,
|
||||||
jira_issue_id=int(link.jira_issue_id),
|
jira_issue_id=int(link.jira_issue_id),
|
||||||
author_account_id=jira_account_id,
|
author_account_id=jira_account_id,
|
||||||
date=work_date,
|
date=work_date,
|
||||||
time_spent_seconds=duration_seconds,
|
time_spent_seconds=duration_seconds,
|
||||||
description=f"[Aegis] Red Team execution: {test.name}",
|
description=description,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Tempo worklog created for test %s by user %s: %ds on %s",
|
"Tempo worklog created for test %s by user %s: %ds on %s",
|
||||||
|
|||||||
@@ -187,11 +187,46 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def start_blue_work(db: Session, test: Test, user: User) -> Test:
|
||||||
|
"""Mark that a blue tech has picked up this test to start evaluating.
|
||||||
|
|
||||||
|
Sets blue_work_started_at. Only valid in blue_evaluating state and
|
||||||
|
only if blue_work_started_at is not already set.
|
||||||
|
"""
|
||||||
|
if test.state != TestState.blue_evaluating:
|
||||||
|
raise InvalidOperationError(
|
||||||
|
f"Cannot start blue work in '{test.state.value}' state"
|
||||||
|
)
|
||||||
|
if test.blue_work_started_at is not None:
|
||||||
|
raise InvalidOperationError("Blue work already started")
|
||||||
|
|
||||||
|
test.blue_work_started_at = datetime.utcnow()
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=user.id,
|
||||||
|
action="start_blue_work",
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=test.id,
|
||||||
|
details={"test_name": test.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
notify_test_state_change(db, test, "blue_work_started")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
||||||
"""Move from ``blue_evaluating`` → ``in_review``.
|
"""Move from ``blue_evaluating`` → ``in_review``.
|
||||||
|
|
||||||
Called by **blue_tech** once they have finished documenting detection.
|
Called by **blue_tech** once they have finished documenting detection.
|
||||||
Stops the Blue Team timer and creates an automatic worklog.
|
Stops the Blue Team timer and creates an automatic worklog.
|
||||||
|
Uses blue_work_started_at as the phase start for Tempo if available,
|
||||||
|
otherwise falls back to blue_started_at (queue-entry timestamp).
|
||||||
"""
|
"""
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
@@ -206,12 +241,14 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
action_name="submit_blue_evidence",
|
action_name="submit_blue_evidence",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create automatic worklog for Blue Team phase (subtract paused time)
|
# Create automatic worklog for Blue Team phase (subtract paused time).
|
||||||
|
# Use blue_work_started_at (actual pick-up time) when available so the
|
||||||
|
# Tempo worklog reflects real working time, not just queue time.
|
||||||
_create_phase_worklog(
|
_create_phase_worklog(
|
||||||
db,
|
db,
|
||||||
test=test,
|
test=test,
|
||||||
user=user,
|
user=user,
|
||||||
phase_started_at=test.blue_started_at,
|
phase_started_at=test.blue_work_started_at or test.blue_started_at,
|
||||||
phase_ended_at=now,
|
phase_ended_at=now,
|
||||||
paused_seconds=(test.blue_paused_seconds or 0) + paused_extra,
|
paused_seconds=(test.blue_paused_seconds or 0) + paused_extra,
|
||||||
activity_type="blue_team_evaluation",
|
activity_type="blue_team_evaluation",
|
||||||
@@ -612,6 +649,7 @@ def reopen_test(db: Session, test: Test, user: User) -> Test:
|
|||||||
# Clear phase timing fields
|
# Clear phase timing fields
|
||||||
test.red_started_at = None
|
test.red_started_at = None
|
||||||
test.blue_started_at = None
|
test.blue_started_at = None
|
||||||
|
test.blue_work_started_at = None
|
||||||
test.paused_at = None
|
test.paused_at = None
|
||||||
test.red_paused_seconds = 0
|
test.red_paused_seconds = 0
|
||||||
test.blue_paused_seconds = 0
|
test.blue_paused_seconds = 0
|
||||||
|
|||||||
@@ -172,6 +172,12 @@ export async function submitBlueEvidence(testId: string): Promise<Test> {
|
|||||||
return data;
|
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 ────────────────────────────────────────────────
|
// ── Lead Validation ────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Red Lead approves/rejects the red side. */
|
/** Red Lead approves/rejects the red side. */
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ interface TestDetailHeaderProps {
|
|||||||
onStartExecution: () => void;
|
onStartExecution: () => void;
|
||||||
onSubmitRed: () => void;
|
onSubmitRed: () => void;
|
||||||
onSubmitBlue: () => void;
|
onSubmitBlue: () => void;
|
||||||
|
onStartBlueWork: () => void;
|
||||||
onOpenValidateModal: (side: "red" | "blue") => void;
|
onOpenValidateModal: (side: "red" | "blue") => void;
|
||||||
onReopen: () => void;
|
onReopen: () => void;
|
||||||
onPauseTimer: () => void;
|
onPauseTimer: () => void;
|
||||||
@@ -67,6 +68,7 @@ export default function TestDetailHeader({
|
|||||||
onStartExecution,
|
onStartExecution,
|
||||||
onSubmitRed,
|
onSubmitRed,
|
||||||
onSubmitBlue,
|
onSubmitBlue,
|
||||||
|
onStartBlueWork,
|
||||||
onOpenValidateModal,
|
onOpenValidateModal,
|
||||||
onReopen,
|
onReopen,
|
||||||
onPauseTimer,
|
onPauseTimer,
|
||||||
@@ -127,11 +129,26 @@ 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 (
|
if (
|
||||||
test.state === "blue_evaluating" &&
|
test.state === "blue_evaluating" &&
|
||||||
(role === "blue_tech" || role === "blue_lead" || role === "admin")
|
(role === "blue_tech" || role === "blue_lead" || role === "admin")
|
||||||
) {
|
) {
|
||||||
|
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(
|
buttons.push(
|
||||||
<button
|
<button
|
||||||
key="submit-blue"
|
key="submit-blue"
|
||||||
@@ -144,6 +161,7 @@ export default function TestDetailHeader({
|
|||||||
</button>,
|
</button>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Red Lead in in_review -> Validate Red
|
// Red Lead in in_review -> Validate Red
|
||||||
if (
|
if (
|
||||||
@@ -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 (
|
return (
|
||||||
<LiveTimer
|
<LiveTimer
|
||||||
startedAt={test.blue_started_at}
|
startedAt={test.blue_work_started_at}
|
||||||
pausedAt={test.paused_at}
|
pausedAt={test.paused_at}
|
||||||
pausedSeconds={test.blue_paused_seconds}
|
pausedSeconds={test.blue_paused_seconds}
|
||||||
label="Blue Team Timer"
|
label="Blue Team Timer"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
startExecution,
|
startExecution,
|
||||||
submitRedEvidence,
|
submitRedEvidence,
|
||||||
submitBlueEvidence,
|
submitBlueEvidence,
|
||||||
|
startBlueWork,
|
||||||
validateAsRedLead,
|
validateAsRedLead,
|
||||||
validateAsBlueLead,
|
validateAsBlueLead,
|
||||||
reopenTest,
|
reopenTest,
|
||||||
@@ -190,6 +191,15 @@ export default function TestDetailPage() {
|
|||||||
onError: (err: unknown) => showToast(extractError(err), "error"),
|
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({
|
const validateRedLeadMutation = useMutation({
|
||||||
mutationFn: (payload: { red_validation_status: "approved" | "rejected"; red_validation_notes?: string }) =>
|
mutationFn: (payload: { red_validation_status: "approved" | "rejected"; red_validation_notes?: string }) =>
|
||||||
validateAsRedLead(testId!, payload),
|
validateAsRedLead(testId!, payload),
|
||||||
@@ -302,6 +312,7 @@ export default function TestDetailPage() {
|
|||||||
startExecMutation.isPending ||
|
startExecMutation.isPending ||
|
||||||
submitRedMutation.isPending ||
|
submitRedMutation.isPending ||
|
||||||
submitBlueMutation.isPending ||
|
submitBlueMutation.isPending ||
|
||||||
|
startBlueWorkMutation.isPending ||
|
||||||
reopenMutation.isPending;
|
reopenMutation.isPending;
|
||||||
|
|
||||||
// ── Loading / Error states ─────────────────────────────────────
|
// ── Loading / Error states ─────────────────────────────────────
|
||||||
@@ -370,6 +381,7 @@ export default function TestDetailPage() {
|
|||||||
onStartExecution={() => startExecMutation.mutate()}
|
onStartExecution={() => startExecMutation.mutate()}
|
||||||
onSubmitRed={() => submitRedMutation.mutate()}
|
onSubmitRed={() => submitRedMutation.mutate()}
|
||||||
onSubmitBlue={() => submitBlueMutation.mutate()}
|
onSubmitBlue={() => submitBlueMutation.mutate()}
|
||||||
|
onStartBlueWork={() => startBlueWorkMutation.mutate()}
|
||||||
onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
|
onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
|
||||||
onReopen={() => setConfirmReopen(true)}
|
onReopen={() => setConfirmReopen(true)}
|
||||||
onPauseTimer={() => pauseTimerMutation.mutate()}
|
onPauseTimer={() => pauseTimerMutation.mutate()}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export interface Test {
|
|||||||
// Phase timing fields (for automatic Tempo worklogs)
|
// Phase timing fields (for automatic Tempo worklogs)
|
||||||
red_started_at: string | null;
|
red_started_at: string | null;
|
||||||
blue_started_at: string | null;
|
blue_started_at: string | null;
|
||||||
|
blue_work_started_at: string | null;
|
||||||
paused_at: string | null;
|
paused_at: string | null;
|
||||||
red_paused_seconds: number;
|
red_paused_seconds: number;
|
||||||
blue_paused_seconds: number;
|
blue_paused_seconds: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user