From ca4ce7cad69e5681d2feee5c8957a2a27f9f3788 Mon Sep 17 00:00:00 2001
From: Michael Chihlas
Date: Wed, 11 Feb 2026 17:52:12 -0500
Subject: [PATCH] Implement session outcomes, step timing, and live timer fixes
---
.../versions/029_add_session_outcomes.py | 31 +++++
backend/app/api/endpoints/sessions.py | 14 +-
backend/app/models/session.py | 2 +
backend/app/schemas/session.py | 12 ++
backend/app/services/export_service.py | 96 +++++++++++++-
backend/tests/test_sessions.py | 80 ++++++++++++
frontend/src/api/sessions.ts | 6 +-
.../session/SessionOutcomeModal.tsx | 122 +++++++++++++++++
frontend/src/components/session/index.ts | 1 +
frontend/src/hooks/useCustomStepFlow.ts | 57 ++++----
frontend/src/hooks/useSessionTimer.ts | 13 +-
frontend/src/pages/SessionDetailPage.tsx | 43 ++++++
frontend/src/pages/SessionHistoryPage.tsx | 21 +++
frontend/src/pages/TreeNavigationPage.tsx | 123 +++++++++++++++---
frontend/src/types/session.ts | 12 ++
15 files changed, 574 insertions(+), 59 deletions(-)
create mode 100644 backend/alembic/versions/029_add_session_outcomes.py
create mode 100644 frontend/src/components/session/SessionOutcomeModal.tsx
diff --git a/backend/alembic/versions/029_add_session_outcomes.py b/backend/alembic/versions/029_add_session_outcomes.py
new file mode 100644
index 00000000..ae6a3a07
--- /dev/null
+++ b/backend/alembic/versions/029_add_session_outcomes.py
@@ -0,0 +1,31 @@
+"""add outcome fields to sessions
+
+Revision ID: 029
+Revises: 028
+Create Date: 2026-02-11
+
+Adds outcome and outcome_notes columns for session completion tracking.
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = "029"
+down_revision: Union[str, None] = "028"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column("sessions", sa.Column("outcome", sa.String(length=20), nullable=True))
+ op.add_column("sessions", sa.Column("outcome_notes", sa.Text(), nullable=True))
+ op.create_index(op.f("ix_sessions_outcome"), "sessions", ["outcome"], unique=False)
+
+
+def downgrade() -> None:
+ op.drop_index(op.f("ix_sessions_outcome"), table_name="sessions")
+ op.drop_column("sessions", "outcome_notes")
+ op.drop_column("sessions", "outcome")
diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py
index d411e368..0e829fdd 100644
--- a/backend/app/api/endpoints/sessions.py
+++ b/backend/app/api/endpoints/sessions.py
@@ -10,7 +10,16 @@ from app.core.database import get_db
from app.models.tree import Tree
from app.models.session import Session
from app.models.user import User
-from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate, SaveAsTreeRequest, SaveAsTreeResponse
+from app.schemas.session import (
+ SessionCreate,
+ SessionUpdate,
+ SessionResponse,
+ SessionExport,
+ ScratchpadUpdate,
+ SaveAsTreeRequest,
+ SaveAsTreeResponse,
+ SessionComplete,
+)
from app.api.deps import get_current_active_user
from app.core.permissions import can_access_tree
from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export, generate_psa_export
@@ -198,6 +207,7 @@ async def update_session(
@router.post("/{session_id}/complete", response_model=SessionResponse)
async def complete_session(
session_id: UUID,
+ completion_data: SessionComplete,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
@@ -224,6 +234,8 @@ async def complete_session(
)
session.completed_at = datetime.now(timezone.utc)
+ session.outcome = completion_data.outcome
+ session.outcome_notes = completion_data.outcome_notes
await db.commit()
await db.refresh(session)
return session
diff --git a/backend/app/models/session.py b/backend/app/models/session.py
index 9131d2b1..a8984d8a 100644
--- a/backend/app/models/session.py
+++ b/backend/app/models/session.py
@@ -45,6 +45,8 @@ class Session(Base):
nullable=True,
index=True
)
+ outcome: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, index=True)
+ outcome_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
ticket_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
exported: Mapped[bool] = mapped_column(Boolean, default=False)
diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py
index 37e394d3..45958b1b 100644
--- a/backend/app/schemas/session.py
+++ b/backend/app/schemas/session.py
@@ -3,6 +3,8 @@ from typing import Optional, Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field, validator
+SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved"]
+
class CustomStepSchema(BaseModel):
"""Enhanced custom step with source tracking.
@@ -28,6 +30,9 @@ class DecisionRecord(BaseModel):
notes: Optional[str] = None
automation_used: Optional[bool] = False
timestamp: datetime
+ entered_at: Optional[datetime] = None
+ exited_at: Optional[datetime] = None
+ duration_seconds: Optional[int] = Field(None, ge=0)
attachments: list[str] = Field(default_factory=list)
@@ -57,6 +62,8 @@ class SessionResponse(BaseModel):
custom_steps: list[dict[str, Any]] = Field(default_factory=list)
started_at: datetime
completed_at: Optional[datetime] = None
+ outcome: Optional[SessionOutcome] = None
+ outcome_notes: Optional[str] = None
ticket_number: Optional[str] = None
client_name: Optional[str] = None
exported: bool
@@ -77,6 +84,11 @@ class SessionExport(BaseModel):
include_tree_info: bool = True
+class SessionComplete(BaseModel):
+ outcome: SessionOutcome
+ outcome_notes: Optional[str] = None
+
+
class ScratchpadUpdate(BaseModel):
scratchpad: str
diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py
index 0fb84acc..6c826e88 100644
--- a/backend/app/services/export_service.py
+++ b/backend/app/services/export_service.py
@@ -5,12 +5,21 @@ Provides markdown, plain text, HTML, and PSA/ticket note export formatters
for troubleshooting sessions.
"""
import html
-from datetime import datetime, timezone
+from datetime import datetime
+from typing import Any
from app.models.session import Session
from app.schemas.session import SessionExport
+OUTCOME_LABELS = {
+ "resolved": "Resolved",
+ "escalated": "Escalated",
+ "workaround": "Workaround Applied",
+ "unresolved": "Unresolved",
+}
+
+
def _format_duration(started_at: datetime, completed_at: datetime | None) -> str:
"""Format duration between two datetimes as human-readable string."""
if not completed_at:
@@ -26,9 +35,64 @@ def _format_duration(started_at: datetime, completed_at: datetime | None) -> str
return f"{minutes} minutes"
+def _format_step_duration(duration_seconds: int) -> str:
+ """Format step duration seconds as compact human-readable text."""
+ if duration_seconds < 0:
+ return "0s"
+ if duration_seconds < 60:
+ return f"{duration_seconds}s"
+ hours, remainder = divmod(duration_seconds, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ if hours > 0:
+ if seconds > 0:
+ return f"{hours}h {minutes}m {seconds}s"
+ return f"{hours}h {minutes}m"
+ if seconds > 0:
+ return f"{minutes}m {seconds}s"
+ return f"{minutes}m"
+
+
+def _parse_iso_datetime(value: Any) -> datetime | None:
+ """Parse ISO datetime strings from JSONB with support for trailing Z."""
+ if isinstance(value, datetime):
+ return value
+ if not isinstance(value, str) or not value:
+ return None
+ try:
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ return None
+
+
+def _get_step_duration_seconds(decision: dict[str, Any]) -> int | None:
+ """Get step duration from explicit field or entered/exited timestamps."""
+ explicit_duration = decision.get("duration_seconds")
+ if isinstance(explicit_duration, (int, float)):
+ duration = int(explicit_duration)
+ if duration >= 0:
+ return duration
+
+ entered_at = _parse_iso_datetime(decision.get("entered_at"))
+ exited_at = _parse_iso_datetime(decision.get("exited_at"))
+ if entered_at and exited_at:
+ total_seconds = int((exited_at - entered_at).total_seconds())
+ if total_seconds >= 0:
+ return total_seconds
+ return None
+
+
+def _get_outcome_label(session: Session) -> str | None:
+ """Map stored outcome enum to human-friendly label."""
+ outcome = getattr(session, "outcome", None)
+ if not outcome:
+ return None
+ return OUTCOME_LABELS.get(outcome, str(outcome).replace("_", " ").title())
+
+
def generate_markdown_export(session: Session, options: SessionExport) -> str:
"""Generate markdown export."""
lines = []
+ outcome_label = _get_outcome_label(session)
if options.include_tree_info:
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
@@ -42,6 +106,9 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
lines.append(f"**Started:** {session.started_at.strftime('%Y-%m-%d %H:%M')}")
if session.completed_at:
lines.append(f"**Completed:** {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
+ lines.append(f"**Duration:** {_format_duration(session.started_at, session.completed_at)}")
+ if outcome_label:
+ lines.append(f"**Outcome:** {outcome_label}")
lines.append("")
lines.append("---")
lines.append("")
@@ -63,12 +130,15 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
+ duration_seconds = _get_step_duration_seconds(decision)
lines.append(f"### Step {i}: {question}")
if answer:
lines.append(f"**Answer:** {answer}")
if notes:
lines.append(f"**Notes:** {notes}")
+ if duration_seconds is not None:
+ lines.append(f"**Duration:** {_format_step_duration(duration_seconds)}")
if options.include_timestamps and decision.get("timestamp"):
lines.append(f"*{decision['timestamp']}*")
lines.append("")
@@ -79,6 +149,7 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
def generate_text_export(session: Session, options: SessionExport) -> str:
"""Generate plain text export."""
lines = []
+ outcome_label = _get_outcome_label(session)
if options.include_tree_info:
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
@@ -92,6 +163,9 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
lines.append(f"Started: {session.started_at.strftime('%Y-%m-%d %H:%M')}")
if session.completed_at:
lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
+ lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
+ if outcome_label:
+ lines.append(f"Outcome: {outcome_label}")
lines.append("")
# Scratchpad / Evidence section
@@ -109,12 +183,15 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
+ duration_seconds = _get_step_duration_seconds(decision)
lines.append(f"\n{i}. {question}")
if answer:
lines.append(f" Answer: {answer}")
if notes:
lines.append(f" Notes: {notes}")
+ if duration_seconds is not None:
+ lines.append(f" Duration: {_format_step_duration(duration_seconds)}")
return "\n".join(lines)
@@ -122,6 +199,7 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
def generate_html_export(session: Session, options: SessionExport) -> str:
"""Generate HTML export."""
tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session"))
+ outcome_label = _get_outcome_label(session)
html_parts = ['', '', '',
'',
@@ -134,6 +212,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
'.step h3 { margin: 0 0 10px 0; color: #444; }',
'.answer { font-weight: bold; }',
'.notes { font-style: italic; color: #555; }',
+ '.duration { color: #444; margin-top: 6px; }',
'.timestamp { font-size: 0.85em; color: #888; }',
'',
'', '']
@@ -149,6 +228,9 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
html_parts.append(f'Started: {session.started_at.strftime("%Y-%m-%d %H:%M")}
')
if session.completed_at:
html_parts.append(f'Completed: {session.completed_at.strftime("%Y-%m-%d %H:%M")}
')
+ html_parts.append(f'Duration: {_format_duration(session.started_at, session.completed_at)}
')
+ if outcome_label:
+ html_parts.append(f'Outcome: {html.escape(outcome_label)}
')
html_parts.append('')
# Scratchpad / Evidence section
@@ -163,6 +245,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
question = html.escape(decision.get("question") or decision.get("action_performed", "Step"))
answer = html.escape(decision.get("answer", ""))
notes = html.escape(decision.get("notes", ""))
+ duration_seconds = _get_step_duration_seconds(decision)
html_parts.append('')
html_parts.append(f'
Step {i}: {question}
')
@@ -170,6 +253,8 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
html_parts.append(f'
Answer: {answer}
')
if notes:
html_parts.append(f'
Notes: {notes}
')
+ if duration_seconds is not None:
+ html_parts.append(f'
Duration: {_format_step_duration(duration_seconds)}
')
if options.include_timestamps and decision.get("timestamp"):
html_parts.append(f'
{html.escape(str(decision["timestamp"]))}
')
html_parts.append('
')
@@ -181,6 +266,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
def generate_psa_export(session: Session, options: SessionExport) -> str:
"""Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools."""
lines = []
+ outcome_label = _get_outcome_label(session)
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
tree_description = session.tree_snapshot.get("description", "")
@@ -191,6 +277,9 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
lines.append("=== TROUBLESHOOTING NOTES ===")
lines.append(f"Ticket: {ticket_number} | Client: {client_name}")
lines.append(f"Tree: {tree_name} | Date: {date_str}")
+ lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
+ if outcome_label:
+ lines.append(f"Outcome: {outcome_label}")
lines.append("")
# Problem section
@@ -205,10 +294,13 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
+ duration_seconds = _get_step_duration_seconds(decision)
line = f"{i}. {question}"
if answer:
line += f" -> {answer}"
+ if duration_seconds is not None:
+ line += f" ({_format_step_duration(duration_seconds)})"
lines.append(line)
if notes:
lines.append(f" Notes: {notes}")
@@ -224,6 +316,8 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
lines.append(resolution)
else:
lines.append("No resolution recorded.")
+ if outcome_label:
+ lines.append(f"Outcome: {outcome_label}")
lines.append("")
# Time spent
diff --git a/backend/tests/test_sessions.py b/backend/tests/test_sessions.py
index f6a3b687..f2dfa351 100644
--- a/backend/tests/test_sessions.py
+++ b/backend/tests/test_sessions.py
@@ -153,12 +153,34 @@ class TestSessions:
# Complete session
response = await client.post(
f"/api/v1/sessions/{session_id}/complete",
+ json={"outcome": "resolved", "outcome_notes": "Issue fixed after restarting service"},
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["completed_at"] is not None
+ assert data["outcome"] == "resolved"
+ assert data["outcome_notes"] == "Issue fixed after restarting service"
+
+ @pytest.mark.asyncio
+ async def test_complete_session_requires_outcome(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test that completion requires an outcome payload."""
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ response = await client.post(
+ f"/api/v1/sessions/{session_id}/complete",
+ headers=auth_headers
+ )
+
+ assert response.status_code == 422
@pytest.mark.asyncio
async def test_complete_already_completed_session(
@@ -175,12 +197,14 @@ class TestSessions:
await client.post(
f"/api/v1/sessions/{session_id}/complete",
+ json={"outcome": "resolved"},
headers=auth_headers
)
# Try to complete again
response = await client.post(
f"/api/v1/sessions/{session_id}/complete",
+ json={"outcome": "resolved"},
headers=auth_headers
)
@@ -217,6 +241,59 @@ class TestSessions:
assert "EXP-001" in content # Should contain ticket number
assert "#" in content # Markdown headers
+ @pytest.mark.asyncio
+ async def test_export_markdown_includes_outcome_and_step_duration(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test markdown export includes session outcome and per-step duration."""
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"], "ticket_number": "EXP-OUTCOME-001"},
+ headers=auth_headers
+ )
+ session_id = create_response.json()["id"]
+
+ step_timestamp = "2026-02-11T10:10:00Z"
+ update_response = await client.put(
+ f"/api/v1/sessions/{session_id}",
+ json={
+ "decisions": [{
+ "node_id": "root",
+ "question": "Is this a test?",
+ "answer": "Yes",
+ "action_performed": None,
+ "notes": "Validated quickly",
+ "automation_used": False,
+ "timestamp": step_timestamp,
+ "entered_at": "2026-02-11T10:08:30Z",
+ "exited_at": step_timestamp,
+ "duration_seconds": 90,
+ "attachments": []
+ }]
+ },
+ headers=auth_headers
+ )
+ assert update_response.status_code == 200
+
+ complete_response = await client.post(
+ f"/api/v1/sessions/{session_id}/complete",
+ json={"outcome": "workaround", "outcome_notes": "Temporary mitigation applied"},
+ headers=auth_headers
+ )
+ assert complete_response.status_code == 200
+
+ export_response = await client.post(
+ f"/api/v1/sessions/{session_id}/export",
+ json={"format": "markdown", "include_timestamps": True, "include_tree_info": True},
+ headers=auth_headers
+ )
+
+ assert export_response.status_code == 200
+ content = export_response.text
+ assert "**Outcome:** Workaround Applied" in content
+ assert "**Duration:**" in content
+ assert "**Duration:** 1m 30s" in content
+
@pytest.mark.asyncio
async def test_export_session_text(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
@@ -291,6 +368,7 @@ class TestSessions:
# Complete first session
await client.post(
f"/api/v1/sessions/{session1_id}/complete",
+ json={"outcome": "resolved"},
headers=auth_headers
)
@@ -449,6 +527,7 @@ class TestSessions:
await client.post(
f"/api/v1/sessions/{session_id}/complete",
+ json={"outcome": "resolved"},
headers=auth_headers
)
@@ -857,6 +936,7 @@ class TestSessions:
await client.post(
f"/api/v1/sessions/{session_id}/complete",
+ json={"outcome": "resolved"},
headers=auth_headers
)
diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts
index 0c10296e..2c62f66c 100644
--- a/frontend/src/api/sessions.ts
+++ b/frontend/src/api/sessions.ts
@@ -1,5 +1,5 @@
import apiClient from './client'
-import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse } from '@/types'
+import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete } from '@/types'
export interface SessionListParams {
page?: number
@@ -44,8 +44,8 @@ export const sessionsApi = {
return response.data
},
- async complete(id: string): Promise {
- const response = await apiClient.post(`/sessions/${id}/complete`)
+ async complete(id: string, data: SessionComplete): Promise {
+ const response = await apiClient.post(`/sessions/${id}/complete`, data)
return response.data
},
diff --git a/frontend/src/components/session/SessionOutcomeModal.tsx b/frontend/src/components/session/SessionOutcomeModal.tsx
new file mode 100644
index 00000000..423baa67
--- /dev/null
+++ b/frontend/src/components/session/SessionOutcomeModal.tsx
@@ -0,0 +1,122 @@
+import { useEffect, useState } from 'react'
+import { Modal } from '@/components/common/Modal'
+import { cn } from '@/lib/utils'
+import type { SessionOutcome } from '@/types'
+
+interface SessionOutcomeModalProps {
+ isOpen: boolean
+ onClose: () => void
+ onSubmit: (data: { outcome: SessionOutcome; outcome_notes?: string }) => Promise
+ isSubmitting?: boolean
+}
+
+const OUTCOME_OPTIONS: Array<{ value: SessionOutcome; label: string; description: string }> = [
+ { value: 'resolved', label: 'Resolved', description: 'Issue fully resolved in this session.' },
+ { value: 'workaround', label: 'Workaround', description: 'Temporary fix applied, root cause remains.' },
+ { value: 'escalated', label: 'Escalated', description: 'Handed off to another engineer/team.' },
+ { value: 'unresolved', label: 'Unresolved', description: 'No fix or workaround identified yet.' },
+]
+
+export function SessionOutcomeModal({
+ isOpen,
+ onClose,
+ onSubmit,
+ isSubmitting = false,
+}: SessionOutcomeModalProps) {
+ const [outcome, setOutcome] = useState('resolved')
+ const [outcomeNotes, setOutcomeNotes] = useState('')
+
+ useEffect(() => {
+ if (!isOpen) return
+ setOutcome('resolved')
+ setOutcomeNotes('')
+ }, [isOpen])
+
+ const handleSubmit = async () => {
+ await onSubmit({
+ outcome,
+ outcome_notes: outcomeNotes.trim() || undefined,
+ })
+ }
+
+ return (
+
+
+
+
+ )}
+ >
+
+
+ Select the session outcome before completion.
+
+
+ {OUTCOME_OPTIONS.map((option) => (
+
+ ))}
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/session/index.ts b/frontend/src/components/session/index.ts
index 83a67464..78be3134 100644
--- a/frontend/src/components/session/index.ts
+++ b/frontend/src/components/session/index.ts
@@ -2,3 +2,4 @@ export { PostStepActionModal } from './PostStepActionModal'
export { ContinuationModal, type DescendantNode } from './ContinuationModal'
export { ForkTreeModal } from './ForkTreeModal'
export { ScratchpadSidebar } from './ScratchpadSidebar'
+export { SessionOutcomeModal } from './SessionOutcomeModal'
diff --git a/frontend/src/hooks/useCustomStepFlow.ts b/frontend/src/hooks/useCustomStepFlow.ts
index b528f152..b2b37f04 100644
--- a/frontend/src/hooks/useCustomStepFlow.ts
+++ b/frontend/src/hooks/useCustomStepFlow.ts
@@ -19,9 +19,10 @@ interface UseCustomStepFlowParams {
setPathTaken: (path: string[]) => void
setDecisions: (decisions: DecisionRecord[]) => void
setNotes: (notes: string) => void
- setIsCompleting: (completing: boolean) => void
setError: (error: string | null) => void
+ onEnterNode: (enteredAtIso: string) => void
isCompleting: boolean
+ onRequestCompletion: (completionDecision: DecisionRecord, source: 'custom') => void
}
export function useCustomStepFlow({
@@ -36,9 +37,10 @@ export function useCustomStepFlow({
setPathTaken,
setDecisions,
setNotes,
- setIsCompleting,
setError,
+ onEnterNode,
isCompleting,
+ onRequestCompletion,
}: UseCustomStepFlowParams) {
const navigate = useNavigate()
@@ -112,9 +114,11 @@ export function useCustomStepFlow({
// Navigate back to a previously-created custom step from the decision node
const handleNavigateToCustomStep = (customStep: CustomStep) => {
+ const enteredAt = new Date().toISOString()
const newPath = [...pathTaken, customStep.id]
setPathTaken(newPath)
setCurrentNodeId(customStep.id)
+ onEnterNode(enteredAt)
}
// Called when CustomStepModal submits - show action modal instead of inserting directly
@@ -169,6 +173,7 @@ export function useCustomStepFlow({
timestamp: new Date().toISOString()
}
+ const decisionTimestamp = new Date().toISOString()
const newDecision: DecisionRecord = {
node_id: customStep.id,
question: null,
@@ -176,7 +181,10 @@ export function useCustomStepFlow({
action_performed: `Custom Step: ${pendingStep.title}`,
notes: pendingStep.content.instructions || null,
automation_used: false,
- timestamp: new Date().toISOString(),
+ timestamp: decisionTimestamp,
+ entered_at: decisionTimestamp,
+ exited_at: decisionTimestamp,
+ duration_seconds: 0,
attachments: []
}
@@ -188,6 +196,7 @@ export function useCustomStepFlow({
setDecisions(newDecisions)
setPathTaken(newPath)
setCurrentNodeId(customStep.id)
+ onEnterNode(decisionTimestamp)
await sessionsApi.update(session.id, {
path_taken: newPath,
@@ -236,9 +245,11 @@ export function useCustomStepFlow({
const handleContinueToDescendant = async () => {
if (!pendingContinuationNodeId || !session) return
+ const enteredAt = new Date().toISOString()
const newPath = [...pathTaken, pendingContinuationNodeId]
setPathTaken(newPath)
setCurrentNodeId(pendingContinuationNodeId)
+ onEnterNode(enteredAt)
setNotes('')
setPendingContinuationNodeId(null)
@@ -259,38 +270,18 @@ export function useCustomStepFlow({
const handleCustomBranchComplete = async () => {
if (!session) return
- setIsCompleting(true)
setError(null)
-
- try {
- const completionDecision: DecisionRecord = {
- node_id: currentNodeId,
- question: null,
- answer: null,
- action_performed: 'Custom Branch Completed',
- notes: notes || 'Issue resolved via custom troubleshooting steps',
- automation_used: false,
- timestamp: new Date().toISOString(),
- attachments: []
- }
-
- await sessionsApi.update(session.id, {
- decisions: [...decisions, completionDecision]
- })
-
- await sessionsApi.complete(session.id)
-
- if (customSteps.length > 0) {
- setShowForkModal(true)
- } else {
- navigate(`/sessions/${session.id}`)
- }
- } catch (err) {
- console.error('Failed to complete session:', err)
- setError('Failed to complete session. Please try again.')
- } finally {
- setIsCompleting(false)
+ const completionDecision: DecisionRecord = {
+ node_id: currentNodeId,
+ question: null,
+ answer: null,
+ action_performed: 'Custom Branch Completed',
+ notes: notes || 'Issue resolved via custom troubleshooting steps',
+ automation_used: false,
+ timestamp: new Date().toISOString(),
+ attachments: []
}
+ onRequestCompletion(completionDecision, 'custom')
}
// Fork tree with custom branch
diff --git a/frontend/src/hooks/useSessionTimer.ts b/frontend/src/hooks/useSessionTimer.ts
index d8e216b4..c2147dd8 100644
--- a/frontend/src/hooks/useSessionTimer.ts
+++ b/frontend/src/hooks/useSessionTimer.ts
@@ -5,12 +5,23 @@ export function useSessionTimer(startedAt: string | undefined | null): string |
const intervalRef = useRef | null>(null)
useEffect(() => {
+ // Always clear any previous interval before (re)initializing.
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ intervalRef.current = null
+ }
+
if (!startedAt) {
setElapsed(null)
return
}
- const startTime = new Date(startedAt).getTime()
+ const parsedStartTime = new Date(startedAt).getTime()
+ // If the server timestamp is invalid or ahead of the local clock, fall back to "now"
+ // so the timer still starts ticking immediately for the user.
+ const startTime = Number.isNaN(parsedStartTime) || parsedStartTime > Date.now()
+ ? Date.now()
+ : parsedStartTime
const tick = () => {
const diff = Math.max(0, Math.floor((Date.now() - startTime) / 1000))
diff --git a/frontend/src/pages/SessionDetailPage.tsx b/frontend/src/pages/SessionDetailPage.tsx
index 34bc83f2..5bfaabc0 100644
--- a/frontend/src/pages/SessionDetailPage.tsx
+++ b/frontend/src/pages/SessionDetailPage.tsx
@@ -237,6 +237,31 @@ export function SessionDetailPage() {
return new Date(dateString).toLocaleString()
}
+ const formatDuration = (durationSeconds: number | null | undefined) => {
+ if (durationSeconds == null || durationSeconds < 0) return null
+ if (durationSeconds < 60) return `${durationSeconds}s`
+ const hours = Math.floor(durationSeconds / 3600)
+ const minutes = Math.floor((durationSeconds % 3600) / 60)
+ const seconds = durationSeconds % 60
+ if (hours > 0) return seconds > 0 ? `${hours}h ${minutes}m ${seconds}s` : `${hours}h ${minutes}m`
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
+ }
+
+ const getTotalDuration = () => {
+ if (!session?.completed_at) return 'In progress'
+ const startedAtMs = new Date(session.started_at).getTime()
+ const completedAtMs = new Date(session.completed_at).getTime()
+ if (Number.isNaN(startedAtMs) || Number.isNaN(completedAtMs)) return 'Unknown'
+ const seconds = Math.max(0, Math.floor((completedAtMs - startedAtMs) / 1000))
+ return formatDuration(seconds) || '0s'
+ }
+
+ const outcomeLabel = session?.outcome
+ ? session.outcome === 'workaround'
+ ? 'Workaround'
+ : session.outcome.charAt(0).toUpperCase() + session.outcome.slice(1)
+ : null
+
if (isLoading) {
return (
@@ -292,7 +317,20 @@ export function SessionDetailPage() {
{session.completed_at ? 'Completed' : 'In Progress'}
{session.client_name && Client: {session.client_name}}
+ {session.completed_at && (
+
+ Duration: {getTotalDuration()}
+
+ )}
+ {outcomeLabel && (
+
+ Outcome: {outcomeLabel}
+
+ )}
+ {session.outcome_notes && (
+ Outcome Notes: {session.outcome_notes}
+ )}
{/* Actions */}
@@ -401,6 +439,11 @@ export function SessionDetailPage() {
Notes: {decision.notes}
)}
+ {decision.duration_seconds != null && (
+
+ Duration: {formatDuration(decision.duration_seconds)}
+
+ )}
{formatDate(decision.timestamp)}
diff --git a/frontend/src/pages/SessionHistoryPage.tsx b/frontend/src/pages/SessionHistoryPage.tsx
index 1e2d35b8..cefead91 100644
--- a/frontend/src/pages/SessionHistoryPage.tsx
+++ b/frontend/src/pages/SessionHistoryPage.tsx
@@ -140,6 +140,13 @@ export function SessionHistoryPage() {
return session.tree_snapshot?.name || 'Unknown Tree'
}
+ const formatOutcomeLabel = (outcome: Session['outcome']): string => {
+ if (!outcome) return 'Not set'
+ return outcome === 'workaround'
+ ? 'Workaround'
+ : outcome.charAt(0).toUpperCase() + outcome.slice(1)
+ }
+
return (
@@ -226,6 +233,20 @@ export function SessionHistoryPage() {
{session.client_name}
)}
+ {session.completed_at && (
+
+ {formatOutcomeLabel(session.outcome)}
+
+ )}
{/* Tree Name */}
diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx
index edb48c38..40e141f8 100644
--- a/frontend/src/pages/TreeNavigationPage.tsx
+++ b/frontend/src/pages/TreeNavigationPage.tsx
@@ -5,11 +5,11 @@ import { sessionsApi } from '@/api/sessions'
import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts'
import { useCustomStepFlow } from '@/hooks/useCustomStepFlow'
import { useSessionTimer } from '@/hooks/useSessionTimer'
-import type { Tree, Session, DecisionRecord, TreeStructure } from '@/types'
+import type { Tree, Session, DecisionRecord, TreeStructure, SessionOutcome } from '@/types'
import { cn, safeGetItem, safeSetItem } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
-import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar } from '@/components/session'
+import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session'
import { Plus, CheckCircle, ArrowRight, Clock } from 'lucide-react'
interface LocationState {
@@ -18,6 +18,8 @@ interface LocationState {
prefillTicketNumber?: string
}
+type CompletionSource = 'standard' | 'custom'
+
export function TreeNavigationPage() {
const { id: treeId } = useParams<{ id: string }>()
const navigate = useNavigate()
@@ -29,10 +31,14 @@ export function TreeNavigationPage() {
const [currentNodeId, setCurrentNodeId] = useState
('root')
const [pathTaken, setPathTaken] = useState(['root'])
const [decisions, setDecisions] = useState([])
+ const [currentStepEnteredAt, setCurrentStepEnteredAt] = useState(new Date().toISOString())
const [notes, setNotes] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(null)
const [isCompleting, setIsCompleting] = useState(false)
+ const [showOutcomeModal, setShowOutcomeModal] = useState(false)
+ const [pendingCompletionDecision, setPendingCompletionDecision] = useState(null)
+ const [completionSource, setCompletionSource] = useState('standard')
// Session metadata (prefill from Repeat Last Session)
const [ticketNumber, setTicketNumber] = useState(locationState?.prefillTicketNumber || '')
@@ -59,6 +65,42 @@ export function TreeNavigationPage() {
return null
}
+ const calculateDurationSeconds = (enteredAtIso: string, exitedAtIso: string): number => {
+ const enteredAtMs = new Date(enteredAtIso).getTime()
+ const exitedAtMs = new Date(exitedAtIso).getTime()
+ if (Number.isNaN(enteredAtMs) || Number.isNaN(exitedAtMs)) return 0
+ return Math.max(0, Math.floor((exitedAtMs - enteredAtMs) / 1000))
+ }
+
+ const deriveCurrentStepEnteredAt = (sessionData: Session): string => {
+ if (!sessionData.decisions || sessionData.decisions.length === 0) {
+ return sessionData.started_at
+ }
+ const lastDecision = sessionData.decisions[sessionData.decisions.length - 1]
+ return lastDecision.exited_at || lastDecision.timestamp || sessionData.started_at
+ }
+
+ const openCompletionModal = (completionDecision: DecisionRecord, source: CompletionSource) => {
+ const exitedAt = new Date().toISOString()
+ const enteredAt = currentStepEnteredAt || session?.started_at || exitedAt
+ setPendingCompletionDecision({
+ ...completionDecision,
+ timestamp: exitedAt,
+ entered_at: enteredAt,
+ exited_at: exitedAt,
+ duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
+ })
+ setCompletionSource(source)
+ setShowOutcomeModal(true)
+ }
+
+ const closeOutcomeModal = () => {
+ if (isCompleting) return
+ setShowOutcomeModal(false)
+ setPendingCompletionDecision(null)
+ setCompletionSource('standard')
+ }
+
// Custom step flow (creation, post-step actions, continuation, branching, forking)
const customStepFlow = useCustomStepFlow({
tree,
@@ -72,9 +114,12 @@ export function TreeNavigationPage() {
setPathTaken,
setDecisions,
setNotes,
- setIsCompleting,
setError,
+ onEnterNode: setCurrentStepEnteredAt,
isCompleting,
+ onRequestCompletion: (completionDecision) => {
+ openCompletionModal(completionDecision, 'custom')
+ },
})
const handleScratchpadSave = async (content: string) => {
@@ -102,6 +147,7 @@ export function TreeNavigationPage() {
setPathTaken(sessionData.path_taken)
setCurrentNodeId(sessionData.path_taken[sessionData.path_taken.length - 1] || 'root')
setDecisions(sessionData.decisions as DecisionRecord[])
+ setCurrentStepEnteredAt(deriveCurrentStepEnteredAt(sessionData))
customStepFlow.initCustomSteps(sessionData.custom_steps || [])
setTicketNumber(sessionData.ticket_number || '')
setClientName(sessionData.client_name || '')
@@ -125,6 +171,7 @@ export function TreeNavigationPage() {
client_name: clientName || undefined,
})
setSession(newSession)
+ setCurrentStepEnteredAt(newSession.started_at)
setShowMetadataForm(false)
// Save for "Repeat Last Session"
safeSetItem('last-session', JSON.stringify({
@@ -147,6 +194,8 @@ export function TreeNavigationPage() {
const node = findNode(currentNodeId, tree.tree_structure)
if (!node) return
+ const exitedAt = new Date().toISOString()
+ const enteredAt = currentStepEnteredAt || session.started_at || exitedAt
const newDecision: DecisionRecord = {
node_id: currentNodeId,
question: node.question || null,
@@ -154,7 +203,10 @@ export function TreeNavigationPage() {
action_performed: null,
notes: notes || null,
automation_used: false,
- timestamp: new Date().toISOString(),
+ timestamp: exitedAt,
+ entered_at: enteredAt,
+ exited_at: exitedAt,
+ duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
attachments: [],
}
@@ -164,6 +216,7 @@ export function TreeNavigationPage() {
setPathTaken(newPath)
setDecisions(newDecisions)
setCurrentNodeId(nextNodeId)
+ setCurrentStepEnteredAt(exitedAt)
setNotes('')
try {
@@ -182,6 +235,8 @@ export function TreeNavigationPage() {
const node = findNode(currentNodeId, tree.tree_structure)
if (!node || !node.next_node_id) return
+ const exitedAt = new Date().toISOString()
+ const enteredAt = currentStepEnteredAt || session.started_at || exitedAt
const newDecision: DecisionRecord = {
node_id: currentNodeId,
question: null,
@@ -189,7 +244,10 @@ export function TreeNavigationPage() {
action_performed: actionPerformed || node.title || 'Action completed',
notes: notes || null,
automation_used: false,
- timestamp: new Date().toISOString(),
+ timestamp: exitedAt,
+ entered_at: enteredAt,
+ exited_at: exitedAt,
+ duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
attachments: [],
}
@@ -199,6 +257,7 @@ export function TreeNavigationPage() {
setPathTaken(newPath)
setDecisions(newDecisions)
setCurrentNodeId(node.next_node_id)
+ setCurrentStepEnteredAt(exitedAt)
setNotes('')
try {
@@ -213,28 +272,44 @@ export function TreeNavigationPage() {
const handleComplete = async () => {
if (!session || !tree) return
+ const node = findNode(currentNodeId, tree.tree_structure)
+ if (!node) return
+ const completionDecision: DecisionRecord = {
+ node_id: currentNodeId,
+ question: null,
+ answer: null,
+ action_performed: node.title || 'Session completed',
+ notes: notes || null,
+ automation_used: false,
+ timestamp: new Date().toISOString(),
+ attachments: [],
+ }
+ openCompletionModal(completionDecision, 'standard')
+ }
+
+ const handleSubmitOutcome = async (data: { outcome: SessionOutcome; outcome_notes?: string }) => {
+ if (!session) return
setIsCompleting(true)
setError(null)
try {
- const node = findNode(currentNodeId, tree.tree_structure)
- if (node) {
- const finalDecision: DecisionRecord = {
- node_id: currentNodeId,
- question: null,
- answer: null,
- action_performed: node.title || 'Session completed',
- notes: notes || null,
- automation_used: false,
- timestamp: new Date().toISOString(),
- attachments: [],
- }
+ let finalDecisions = decisions
+ if (pendingCompletionDecision) {
+ finalDecisions = [...decisions, pendingCompletionDecision]
+ setDecisions(finalDecisions)
await sessionsApi.update(session.id, {
- decisions: [...decisions, finalDecision],
+ decisions: finalDecisions,
})
}
- await sessionsApi.complete(session.id)
- navigate(`/sessions/${session.id}`)
+ await sessionsApi.complete(session.id, data)
+
+ setShowOutcomeModal(false)
+ setPendingCompletionDecision(null)
+ if (completionSource === 'custom' && customStepFlow.customSteps.length > 0) {
+ customStepFlow.setShowForkModal(true)
+ } else {
+ navigate(`/sessions/${session.id}`)
+ }
} catch (err) {
console.error('Failed to complete session:', err)
setError('Failed to complete session. Check console for details.')
@@ -250,6 +325,7 @@ export function TreeNavigationPage() {
setPathTaken(newPath)
setDecisions(newDecisions)
setCurrentNodeId(newPath[newPath.length - 1])
+ setCurrentStepEnteredAt(new Date().toISOString())
}
// Compute current node for keyboard shortcuts (must be before any returns for hooks rules)
@@ -763,6 +839,13 @@ export function TreeNavigationPage() {
onFork={customStepFlow.handleForkTree}
onSkip={customStepFlow.handleSkipFork}
/>
+
+
diff --git a/frontend/src/types/session.ts b/frontend/src/types/session.ts
index a06bd0d8..4ea0252d 100644
--- a/frontend/src/types/session.ts
+++ b/frontend/src/types/session.ts
@@ -1,6 +1,8 @@
import type { TreeStructure } from './tree'
import type { Step, StepContent } from './step'
+export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved'
+
export interface DecisionRecord {
node_id: string
question: string | null
@@ -9,6 +11,9 @@ export interface DecisionRecord {
notes: string | null
automation_used: boolean
timestamp: string
+ entered_at?: string | null
+ exited_at?: string | null
+ duration_seconds?: number | null
attachments: string[]
}
@@ -45,6 +50,8 @@ export interface Session {
custom_steps: CustomStep[]
started_at: string
completed_at: string | null
+ outcome: SessionOutcome | null
+ outcome_notes: string | null
ticket_number: string | null
client_name: string | null
exported: boolean
@@ -72,6 +79,11 @@ export interface SessionExport {
include_tree_info?: boolean
}
+export interface SessionComplete {
+ outcome: SessionOutcome
+ outcome_notes?: string
+}
+
// Navigation state for active session
export interface SessionNavigationState {
activeSession: Session | null