From c7a56f25255b6df27c71f3616ee2959ad68f3940 Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Tue, 1 Sep 2026 14:44:17 +1200 Subject: [PATCH] Add issue workflow progress tracker --- backend/app/routers/portal.py | 92 +++++++++++++ backend/tests/test_backend_quality.py | 23 ++++ frontend/app/ops-redesign.css | 181 ++++++++++++++++++++++++++ frontend/app/portal/PortalClient.tsx | 73 ++++++++++- 4 files changed, 363 insertions(+), 6 deletions(-) diff --git a/backend/app/routers/portal.py b/backend/app/routers/portal.py index 319da68..27ce1f1 100644 --- a/backend/app/routers/portal.py +++ b/backend/app/routers/portal.py @@ -258,6 +258,97 @@ def _stage_label_for_workflow(request_status: str, media_status: str) -> str: return "Approved" +ISSUE_WORKFLOW_STAGES = ( + ("reported", "Reported"), + ("review", "Under review"), + ("planned", "Fix planned"), + ("repair", "Fix underway"), + ("confirmation", "Confirm fix"), + ("resolved", "Resolved"), +) + +ISSUE_STATUS_TO_STAGE: Dict[str, Tuple[int, str, str, str]] = { + "new": ( + 0, + "Issue received", + "Your report has been logged and is waiting for the support team to review it.", + "active", + ), + "triaging": ( + 1, + "Being investigated", + "The support team is checking the report and identifying the right fix.", + "active", + ), + "planned": ( + 2, + "Fix ready to begin", + "The problem has been reviewed and the next action has been selected.", + "active", + ), + "in_progress": ( + 3, + "Fix in progress", + "Work is underway on the affected content or service.", + "active", + ), + "blocked": ( + 3, + "Fix needs attention", + "Work has paused because the support team needs another service, resource, or decision before continuing.", + "attention", + ), + "awaiting_confirmation": ( + 4, + "Waiting for confirmation", + "A fix has been applied. Magent is waiting for the reporter to confirm that the problem is gone.", + "active", + ), + "done": ( + 5, + "Issue resolved", + "The reported problem has been fixed and the issue is complete.", + "complete", + ), + "closed": ( + 5, + "Issue resolved", + "The reported problem has been fixed and the issue is closed.", + "complete", + ), +} + + +def _issue_workflow_payload(status: Any) -> Dict[str, Any]: + normalized_status = str(status or "new").strip().lower() + stage_index, headline, message, state = ISSUE_STATUS_TO_STAGE.get( + normalized_status, + ISSUE_STATUS_TO_STAGE["new"], + ) + steps = [] + for index, (key, label) in enumerate(ISSUE_WORKFLOW_STAGES): + step_state = ( + "complete" + if index < stage_index or (index == stage_index and state == "complete") + else "active" + if index == stage_index + else "waiting" + ) + if index == stage_index and state == "attention": + step_state = "attention" + steps.append({"key": key, "label": label, "state": step_state}) + return { + "current_step": stage_index + 1, + "total_steps": len(ISSUE_WORKFLOW_STAGES), + "stage": ISSUE_WORKFLOW_STAGES[stage_index][0], + "stage_label": ISSUE_WORKFLOW_STAGES[stage_index][1], + "headline": headline, + "message": message, + "state": state, + "steps": steps, + } + + def _normalize_request_pipeline( request_status: Optional[str], media_status: Optional[str], @@ -419,6 +510,7 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any "related_item_id": _normalize_int(item.get("related_item_id"), "related_item_id"), "is_resolved": bool(_clean_text(item.get("issue_resolved_at"))), "resolved_at": _clean_text(item.get("issue_resolved_at")), + "workflow": _issue_workflow_payload(item.get("status")), "confirmation": { "status": resolution.get("status"), "attempts_sent": int(resolution.get("attemptsSent") or 0), diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index dac29cd..5408a31 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -1592,6 +1592,29 @@ class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase): self.assertEqual(workflow.get("request_status"), "approved") self.assertEqual(workflow.get("media_status"), "processing") + def test_issue_status_maps_to_public_workflow_progress(self) -> None: + item = { + "kind": "issue", + "status": "blocked", + "issue_type": "playback", + "created_by_username": "tester", + } + serialized = portal_router._serialize_item(item, {"username": "tester", "role": "user"}) + workflow = (serialized.get("issue") or {}).get("workflow") or {} + + self.assertEqual(workflow.get("current_step"), 4) + self.assertEqual(workflow.get("stage"), "repair") + self.assertEqual(workflow.get("state"), "attention") + self.assertEqual(len(workflow.get("steps") or []), 6) + self.assertEqual((workflow.get("steps") or [])[3].get("state"), "attention") + + def test_resolved_issue_completes_the_public_workflow(self) -> None: + workflow = portal_router._issue_workflow_payload("closed") + + self.assertEqual(workflow.get("current_step"), 6) + self.assertEqual(workflow.get("state"), "complete") + self.assertTrue(all(step.get("state") == "complete" for step in workflow.get("steps") or [])) + def test_invalid_pipeline_transition_is_rejected(self) -> None: with self.assertRaises(HTTPException) as context: portal_router._validate_pipeline_transition( diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css index 0d3a4e0..3b4896e 100644 --- a/frontend/app/ops-redesign.css +++ b/frontend/app/ops-redesign.css @@ -2657,6 +2657,58 @@ button:disabled, font-size: 0.62rem; } +.issue-card-progress { + display: grid; + gap: 6px; + margin: 8px 0; +} + +.issue-card-progress > span { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.issue-card-progress strong { + color: var(--ops-cyan); + font-size: 0.68rem; +} + +.issue-card-progress small { + color: var(--ops-muted); + font-family: "JetBrains Mono", Consolas, monospace; + font-size: 0.58rem; + white-space: nowrap; +} + +.issue-card-progress > i { + display: block; + height: 3px; + overflow: hidden; + border-radius: 999px; + background: rgba(126, 215, 255, 0.12); +} + +.issue-card-progress > i > b { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--ops-cyan), var(--request-green)); +} + +.issue-card-progress.is-attention strong { + color: var(--request-amber); +} + +.issue-card-progress.is-attention > i > b { + background: var(--request-amber); +} + +.issue-card-progress.is-complete strong { + color: var(--request-green); +} + .issue-modal-backdrop { position: fixed; z-index: 998; @@ -2709,6 +2761,131 @@ button:disabled, gap: 2px; } +.issue-pipeline-card { + display: grid; + gap: 16px; + padding: 16px; + border: 1px solid rgba(126, 215, 255, 0.3); + border-radius: var(--ops-radius-lg); + background: linear-gradient(120deg, rgba(14, 165, 233, 0.07), rgba(126, 215, 255, 0.025)); +} + +.issue-pipeline-card.is-attention { + border-color: rgba(255, 192, 84, 0.42); + background: linear-gradient(120deg, rgba(255, 192, 84, 0.08), rgba(255, 192, 84, 0.025)); +} + +.issue-pipeline-card.is-complete { + border-color: rgba(72, 224, 178, 0.38); + background: linear-gradient(120deg, rgba(72, 224, 178, 0.075), rgba(72, 224, 178, 0.025)); +} + +.issue-pipeline-card > header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.issue-pipeline-card > header > div { + display: grid; + gap: 4px; +} + +.issue-pipeline-card h3, +.issue-pipeline-card p { + margin: 0; +} + +.issue-pipeline-card p { + color: var(--ops-muted); + font-size: 0.75rem; + line-height: 1.5; +} + +.issue-pipeline-card > ol { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.issue-pipeline-card li { + position: relative; + display: grid; + justify-items: center; + gap: 7px; + min-width: 0; + color: var(--ops-muted); + font-size: 0.62rem; + text-align: center; +} + +.issue-pipeline-card li::before { + position: absolute; + z-index: 0; + top: 12px; + right: 50%; + left: -50%; + height: 2px; + background: var(--ops-line-soft); + content: ""; +} + +.issue-pipeline-card li:first-child::before { + display: none; +} + +.issue-pipeline-card li > i { + position: relative; + z-index: 1; + display: grid; + width: 25px; + height: 25px; + place-items: center; + border: 1px solid var(--ops-line); + border-radius: 50%; + background: var(--ops-panel); + font-family: "JetBrains Mono", Consolas, monospace; + font-size: 0.58rem; + font-style: normal; +} + +.issue-pipeline-card li.is-complete, +.issue-pipeline-card li.is-active { + color: var(--ops-text); +} + +.issue-pipeline-card li.is-complete::before, +.issue-pipeline-card li.is-active::before, +.issue-pipeline-card li.is-attention::before { + background: var(--ops-cyan); +} + +.issue-pipeline-card li.is-complete > i { + border-color: var(--request-green); + color: #061813; + background: var(--request-green); +} + +.issue-pipeline-card li.is-active > i { + border-color: var(--ops-cyan); + color: var(--ops-cyan); + box-shadow: 0 0 14px rgba(126, 215, 255, 0.28); +} + +.issue-pipeline-card li.is-attention { + color: var(--request-amber); +} + +.issue-pipeline-card li.is-attention > i { + border-color: var(--request-amber); + color: var(--request-amber); + box-shadow: 0 0 14px rgba(255, 192, 84, 0.25); +} + .issue-modal-toolbar strong { font-size: 0.86rem; } @@ -3608,4 +3785,8 @@ button:disabled, padding-right: 13px; padding-left: 13px; } + .issue-pipeline-card > header { flex-direction: column; } + .issue-pipeline-card > ol { grid-template-columns: 1fr; gap: 8px; } + .issue-pipeline-card li { grid-template-columns: 25px minmax(0, 1fr); align-items: center; justify-items: start; text-align: left; } + .issue-pipeline-card li::before { top: -9px; right: auto; left: 12px; width: 2px; height: 10px; } } diff --git a/frontend/app/portal/PortalClient.tsx b/frontend/app/portal/PortalClient.tsx index 0f6797e..bbbf307 100644 --- a/frontend/app/portal/PortalClient.tsx +++ b/frontend/app/portal/PortalClient.tsx @@ -40,6 +40,20 @@ type PortalItem = { related_item_id?: number | null is_resolved?: boolean resolved_at?: string | null + workflow?: { + current_step?: number + total_steps?: number + stage?: string + stage_label?: string + headline?: string + message?: string + state?: 'active' | 'attention' | 'complete' + steps?: Array<{ + key: string + label: string + state: 'waiting' | 'active' | 'attention' | 'complete' + }> + } confirmation?: { status?: string | null attempts_sent?: number @@ -345,6 +359,53 @@ const formatIssueStatus = (value?: string | null) => { return labels[String(value ?? '').toLowerCase()] ?? String(value ?? 'Unknown').replaceAll('_', ' ') } +function IssuePipeline({ item, compact = false }: { item: PortalItem; compact?: boolean }) { + const workflow = item.issue?.workflow + const steps = workflow?.steps ?? [] + const currentStep = workflow?.current_step ?? 1 + const totalSteps = workflow?.total_steps ?? 6 + const state = workflow?.state ?? 'active' + + if (compact) { + return ( +
+ + {workflow?.stage_label ?? formatIssueStatus(item.status)} + Step {currentStep} of {totalSteps} + + +
+ ) + } + + return ( +
+
+
+ Issue progress +

{workflow?.headline ?? formatIssueStatus(item.status)}

+

{workflow?.message ?? 'The support team will update this issue as work progresses.'}

+
+ Step {currentStep} of {totalSteps} +
+
    + {steps.map((step, index) => ( +
  1. + + {step.label} +
  2. + ))} +
+
+ ) +} + const toPositiveInt = (value: string) => { const parsed = Number.parseInt(value, 10) if (Number.isNaN(parsed) || parsed <= 0) return null @@ -1999,14 +2060,12 @@ export default function PortalClient({ workspace }: PortalClientProps) { {item.priority}

{item.description}

+ {item.kind === 'issue' ? : null}
#{item.id} - - Status:{' '} - {item.kind === 'request' - ? item.workflow?.stage_label ?? item.status - : formatIssueStatus(item.status)} - + {item.kind === 'request' ? ( + Status: {item.workflow?.stage_label ?? item.status} + ) : null} {isAdmin ? By: {item.created_by_username} : null} Updated: {formatDate(item.last_activity_at)}
@@ -2086,6 +2145,8 @@ export default function PortalClient({ workspace }: PortalClientProps) { + {selectedItem.kind === 'issue' ? : null} + {selectedItem.kind === 'issue' && selectedItem.external_ref?.startsWith('/requests/') ? (