From 4f8b7dd7ca3bf070d4c14e8f36df4aefae03ebea Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sun, 8 Feb 2026 19:36:51 -0500 Subject: [PATCH 1/2] feat: add PSA ticket export format and Quick-Start landing page PSA Export: - New "PSA / Ticket Note" export format optimized for ConnectWise - Structured output: Problem, Steps Taken, Resolution, Time Spent, Notes - Prominent "Copy for Ticket" button on session detail page - 24 unit tests for PSA export generator Quick-Start Landing: - New default landing page with search-first UX - Auto-focused search bar with debounced tree search - "Continue Session" cards for active sessions - "Recent Trees" section from session history - Home nav item and logo links updated Co-Authored-By: Claude Opus 4.6 --- backend/app/api/endpoints/sessions.py | 5 +- backend/app/schemas/session.py | 2 +- backend/app/services/export_service.py | 80 +++++- backend/tests/test_psa_export.py | 233 ++++++++++++++++ frontend/src/components/layout/AppLayout.tsx | 9 +- .../components/session/ExportPreviewModal.tsx | 2 +- frontend/src/pages/QuickStartPage.tsx | 253 ++++++++++++++++++ frontend/src/pages/SessionDetailPage.tsx | 39 ++- frontend/src/router.tsx | 7 +- frontend/src/store/userPreferencesStore.ts | 2 +- frontend/src/types/session.ts | 2 +- 11 files changed, 621 insertions(+), 13 deletions(-) create mode 100644 backend/tests/test_psa_export.py create mode 100644 frontend/src/pages/QuickStartPage.tsx diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index aa770398..ef186766 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -13,7 +13,7 @@ from app.models.user import User from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate, SaveAsTreeRequest, SaveAsTreeResponse 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 +from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export, generate_psa_export router = APIRouter(prefix="/sessions", tags=["sessions"]) @@ -288,6 +288,9 @@ async def export_session( elif export_options.format == "html": content = generate_html_export(session, export_options) media_type = "text/html" + elif export_options.format == "psa": + content = generate_psa_export(session, export_options) + media_type = "text/plain" else: # text content = generate_text_export(session, export_options) media_type = "text/plain" diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index 1ebc6cdf..442243fe 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -70,7 +70,7 @@ class SessionResponse(BaseModel): class SessionExport(BaseModel): - format: str = Field(default="markdown", pattern="^(text|markdown|html)$") + format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$") include_timestamps: bool = True include_tree_info: bool = True diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index 4bd4b755..0fb84acc 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -1,15 +1,31 @@ """ Session export generators for ResolutionFlow. -Provides markdown, plain text, and HTML export formatters +Provides markdown, plain text, HTML, and PSA/ticket note export formatters for troubleshooting sessions. """ import html +from datetime import datetime, timezone from app.models.session import Session from app.schemas.session import SessionExport +def _format_duration(started_at: datetime, completed_at: datetime | None) -> str: + """Format duration between two datetimes as human-readable string.""" + if not completed_at: + return "In progress" + delta = completed_at - started_at + total_seconds = int(delta.total_seconds()) + if total_seconds < 0: + return "0 minutes" + hours, remainder = divmod(total_seconds, 3600) + minutes = remainder // 60 + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes} minutes" + + def generate_markdown_export(session: Session, options: SessionExport) -> str: """Generate markdown export.""" lines = [] @@ -160,3 +176,65 @@ def generate_html_export(session: Session, options: SessionExport) -> str: html_parts.extend(['', '']) return "\n".join(html_parts) + + +def generate_psa_export(session: Session, options: SessionExport) -> str: + """Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools.""" + lines = [] + + tree_name = session.tree_snapshot.get("name", "Troubleshooting Session") + tree_description = session.tree_snapshot.get("description", "") + ticket_number = session.ticket_number or "N/A" + client_name = session.client_name or "N/A" + date_str = session.started_at.strftime("%Y-%m-%d %H:%M") + + lines.append("=== TROUBLESHOOTING NOTES ===") + lines.append(f"Ticket: {ticket_number} | Client: {client_name}") + lines.append(f"Tree: {tree_name} | Date: {date_str}") + lines.append("") + + # Problem section + lines.append("--- PROBLEM ---") + lines.append(tree_description if tree_description else "No description provided.") + lines.append("") + + # Steps taken + lines.append("--- STEPS TAKEN ---") + if session.decisions: + for i, decision in enumerate(session.decisions, 1): + question = decision.get("question") or decision.get("action_performed", "Step") + answer = decision.get("answer", "") + notes = decision.get("notes", "") + + line = f"{i}. {question}" + if answer: + line += f" -> {answer}" + lines.append(line) + if notes: + lines.append(f" Notes: {notes}") + else: + lines.append("No steps recorded.") + lines.append("") + + # Resolution - last decision answer + lines.append("--- RESOLUTION ---") + if session.decisions: + last_decision = session.decisions[-1] + resolution = last_decision.get("answer") or last_decision.get("question", "No resolution recorded") + lines.append(resolution) + else: + lines.append("No resolution recorded.") + lines.append("") + + # Time spent + lines.append("--- TIME SPENT ---") + duration = _format_duration(session.started_at, session.completed_at) + lines.append(f"Duration: {duration}") + lines.append("") + + # Engineer notes (scratchpad) + lines.append("--- ENGINEER NOTES ---") + scratchpad = getattr(session, 'scratchpad', '') or '' + lines.append(scratchpad.strip() if scratchpad.strip() else "None") + + return "\n".join(lines) diff --git a/backend/tests/test_psa_export.py b/backend/tests/test_psa_export.py new file mode 100644 index 00000000..0bcc2a08 --- /dev/null +++ b/backend/tests/test_psa_export.py @@ -0,0 +1,233 @@ +"""Tests for PSA/ticket note export format. + +Covers: all fields present, missing optional fields, duration calculation, +incomplete sessions, and edge cases. +""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest + +from app.schemas.session import SessionExport +from app.services.export_service import generate_psa_export, _format_duration + + +def _make_session( + tree_name="Test Tree", + tree_description="A test problem description", + ticket_number=None, + client_name=None, + decisions=None, + scratchpad="", + started_at=None, + completed_at=None, +): + """Create a mock session object for PSA export testing.""" + session = MagicMock() + session.tree_snapshot = {"name": tree_name, "description": tree_description} + session.ticket_number = ticket_number + session.client_name = client_name + session.decisions = decisions or [] + session.scratchpad = scratchpad + session.started_at = started_at or datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc) + session.completed_at = completed_at or datetime(2026, 1, 15, 11, 30, tzinfo=timezone.utc) + return session + + +def _default_options(): + return SessionExport(format="psa", include_timestamps=True, include_tree_info=True) + + +class TestPsaExportAllFields: + """Test PSA export with all fields populated.""" + + def test_includes_header(self): + session = _make_session( + ticket_number="TK-5001", + client_name="Contoso Corp", + ) + result = generate_psa_export(session, _default_options()) + assert "=== TROUBLESHOOTING NOTES ===" in result + assert "Ticket: TK-5001" in result + assert "Client: Contoso Corp" in result + + def test_includes_tree_name_and_date(self): + session = _make_session(tree_name="DNS Troubleshooting") + result = generate_psa_export(session, _default_options()) + assert "Tree: DNS Troubleshooting" in result + assert "Date: 2026-01-15 10:00" in result + + def test_includes_problem_section(self): + session = _make_session(tree_description="Client cannot resolve DNS names") + result = generate_psa_export(session, _default_options()) + assert "--- PROBLEM ---" in result + assert "Client cannot resolve DNS names" in result + + def test_includes_steps_taken(self): + session = _make_session(decisions=[ + {"question": "Is the DNS service running?", "answer": "Yes", "notes": "Checked services"}, + {"question": "Can you ping the DNS server?", "answer": "No"}, + ]) + result = generate_psa_export(session, _default_options()) + assert "--- STEPS TAKEN ---" in result + assert "1. Is the DNS service running? -> Yes" in result + assert " Notes: Checked services" in result + assert "2. Can you ping the DNS server? -> No" in result + + def test_includes_resolution(self): + session = _make_session(decisions=[ + {"question": "Check cable", "answer": "Connected"}, + {"question": "Restart service", "answer": "Service restarted successfully"}, + ]) + result = generate_psa_export(session, _default_options()) + assert "--- RESOLUTION ---" in result + assert "Service restarted successfully" in result + + def test_includes_duration(self): + session = _make_session( + started_at=datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc), + completed_at=datetime(2026, 1, 15, 11, 30, tzinfo=timezone.utc), + ) + result = generate_psa_export(session, _default_options()) + assert "--- TIME SPENT ---" in result + assert "Duration: 1h 30m" in result + + def test_includes_engineer_notes(self): + session = _make_session(scratchpad="Checked firewall rules, port 53 was blocked") + result = generate_psa_export(session, _default_options()) + assert "--- ENGINEER NOTES ---" in result + assert "Checked firewall rules, port 53 was blocked" in result + + +class TestPsaExportMissingFields: + """Test PSA export gracefully handles missing optional fields.""" + + def test_missing_ticket_number(self): + session = _make_session(ticket_number=None) + result = generate_psa_export(session, _default_options()) + assert "Ticket: N/A" in result + + def test_missing_client_name(self): + session = _make_session(client_name=None) + result = generate_psa_export(session, _default_options()) + assert "Client: N/A" in result + + def test_missing_description(self): + session = _make_session(tree_description="") + result = generate_psa_export(session, _default_options()) + assert "No description provided." in result + + def test_empty_scratchpad(self): + session = _make_session(scratchpad="") + result = generate_psa_export(session, _default_options()) + assert "--- ENGINEER NOTES ---" in result + lines = result.split("\n") + notes_idx = lines.index("--- ENGINEER NOTES ---") + assert lines[notes_idx + 1] == "None" + + def test_whitespace_only_scratchpad(self): + session = _make_session(scratchpad=" \n \n ") + result = generate_psa_export(session, _default_options()) + lines = result.split("\n") + notes_idx = lines.index("--- ENGINEER NOTES ---") + assert lines[notes_idx + 1] == "None" + + def test_no_decisions(self): + session = _make_session(decisions=[]) + result = generate_psa_export(session, _default_options()) + assert "No steps recorded." in result + assert "No resolution recorded." in result + + def test_decision_with_action_performed_fallback(self): + session = _make_session(decisions=[ + {"action_performed": "Restarted the server", "answer": "Done"}, + ]) + result = generate_psa_export(session, _default_options()) + assert "1. Restarted the server -> Done" in result + + def test_decision_without_answer(self): + session = _make_session(decisions=[ + {"question": "Check logs"}, + ]) + result = generate_psa_export(session, _default_options()) + assert "1. Check logs" in result + # No arrow when answer is missing + assert "->" not in result + + +class TestDurationCalculation: + """Test the _format_duration helper.""" + + def test_hours_and_minutes(self): + start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc) + end = datetime(2026, 1, 15, 12, 45, tzinfo=timezone.utc) + assert _format_duration(start, end) == "2h 45m" + + def test_minutes_only(self): + start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc) + end = datetime(2026, 1, 15, 10, 25, tzinfo=timezone.utc) + assert _format_duration(start, end) == "25 minutes" + + def test_zero_minutes(self): + start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc) + end = datetime(2026, 1, 15, 10, 0, 30, tzinfo=timezone.utc) + assert _format_duration(start, end) == "0 minutes" + + def test_incomplete_session(self): + start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc) + assert _format_duration(start, None) == "In progress" + + def test_exact_hour(self): + start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc) + end = datetime(2026, 1, 15, 11, 0, tzinfo=timezone.utc) + assert _format_duration(start, end) == "1h 0m" + + +class TestPsaExportIncompleteSession: + """Test PSA export for sessions that are not yet completed.""" + + def test_incomplete_session_shows_in_progress_duration(self): + session = _make_session(completed_at=None) + # Override completed_at to None explicitly + session.completed_at = None + result = generate_psa_export(session, _default_options()) + assert "Duration: In progress" in result + + def test_incomplete_session_with_steps(self): + session = _make_session( + decisions=[{"question": "First step", "answer": "Done"}], + completed_at=None, + ) + session.completed_at = None + result = generate_psa_export(session, _default_options()) + assert "1. First step -> Done" in result + assert "Duration: In progress" in result + + +class TestPsaExportFormat: + """Test the overall format structure of PSA export.""" + + def test_section_order(self): + session = _make_session( + ticket_number="TK-100", + client_name="Acme", + decisions=[{"question": "Step 1", "answer": "Yes"}], + scratchpad="Some notes", + ) + result = generate_psa_export(session, _default_options()) + sections = [ + "=== TROUBLESHOOTING NOTES ===", + "--- PROBLEM ---", + "--- STEPS TAKEN ---", + "--- RESOLUTION ---", + "--- TIME SPENT ---", + "--- ENGINEER NOTES ---", + ] + positions = [result.index(s) for s in sections] + assert positions == sorted(positions), "Sections should appear in the expected order" + + def test_format_validation_accepts_psa(self): + """Verify the schema accepts 'psa' as a valid format.""" + export = SessionExport(format="psa") + assert export.format == "psa" diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index f74d56ca..3b28b8aa 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -47,6 +47,7 @@ export function AppLayout() { }, [mobileMenuOpen, handleKeyDown]) const navItems = [ + { path: '/', label: 'Home' }, { path: '/trees', label: 'Trees' }, { path: '/my-trees', label: 'My Trees' }, { path: '/sessions', label: 'Sessions' }, @@ -70,7 +71,7 @@ export function AppLayout() { - + @@ -81,7 +82,7 @@ export function AppLayout() { to={item.path} className={cn( 'relative rounded-md px-3 py-2 text-sm font-medium transition-colors', - location.pathname.startsWith(item.path) + (item.path === '/' ? location.pathname === '/' : location.pathname.startsWith(item.path)) ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground' )} @@ -137,7 +138,7 @@ export function AppLayout() { {/* Drawer */}