{% endif %}
```
- [ ] **Step 5: Commit**
```bash
git add backend/requirements.txt backend/Dockerfile backend/app/templates/
git commit -m "feat: add WeasyPrint dependency, Dockerfile system deps, and PDF template"
```
---
### Task 8: PDF Generation in Export Service + Endpoint
**Files:**
- Modify: `backend/app/services/export_service.py`
- Modify: `backend/app/api/endpoints/sessions.py:371-440`
- Create: `backend/tests/test_pdf_export.py`
- [ ] **Step 1: Write PDF export tests**
Create `backend/tests/test_pdf_export.py`:
```python
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_export_pdf_format(client: AsyncClient, auth_headers: dict, test_session_id: str):
"""Export as PDF returns application/pdf content type."""
response = await client.post(
f"/api/v1/sessions/{test_session_id}/export",
headers=auth_headers,
json={"format": "pdf"},
)
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
assert response.content[:4] == b"%PDF"
@pytest.mark.asyncio
async def test_export_pdf_no_supporting_data(client: AsyncClient, auth_headers: dict, test_session_id: str):
"""PDF export works when session has no supporting data."""
response = await client.post(
f"/api/v1/sessions/{test_session_id}/export",
headers=auth_headers,
json={"format": "pdf"},
)
assert response.status_code == 200
assert response.content[:4] == b"%PDF"
@pytest.mark.asyncio
async def test_export_markdown_still_works(client: AsyncClient, auth_headers: dict, test_session_id: str):
"""Existing markdown export still works after PDF addition."""
response = await client.post(
f"/api/v1/sessions/{test_session_id}/export",
headers=auth_headers,
json={"format": "markdown"},
)
assert response.status_code == 200
assert "text/plain" in response.headers["content-type"]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd backend && pytest tests/test_pdf_export.py -v --override-ini="addopts="`
Expected: FAIL
- [ ] **Step 3: Add generate_pdf_export function to export_service.py**
**Important:** The export service uses standalone module-level functions (NOT a class). Add this as a standalone `async def` matching the pattern of `generate_markdown_export()`, `generate_text_export()`, etc.
Add the following function to `backend/app/services/export_service.py`:
```python
async def generate_pdf_export(
session: "Session",
options: "SessionExport",
db: "AsyncSession",
) -> bytes:
"""Generate a branded PDF export using WeasyPrint."""
import weasyprint
from jinja2 import Environment, FileSystemLoader
from pathlib import Path
from datetime import datetime, timezone
from app.models.supporting_data import SessionSupportingData
from sqlalchemy import select
# Load template
template_dir = Path(__file__).parent.parent / "templates"
env = Environment(loader=FileSystemLoader(str(template_dir)))
template = env.get_template("export_pdf.html")
# Get tree snapshot data
tree_snapshot = session.tree_snapshot or {}
flow_title = tree_snapshot.get("title", "Untitled Flow")
tree_type = tree_snapshot.get("tree_type", "troubleshooting")
report_type_map = {
"troubleshooting": "Troubleshooting Report",
"procedural": "Project Report",
"maintenance": "Maintenance Report",
}
report_type = report_type_map.get(tree_type, "Session Report")
# Get branding
logo_data = None
logo_content_type = None
company_name = None
has_custom_logo = False
user = session.user
if user and user.team_id:
from app.models.team import Team
team = await db.get(Team, user.team_id)
if team:
if team.logo_data:
logo_data = team.logo_data
logo_content_type = team.logo_content_type
has_custom_logo = True
company_name = team.company_display_name or team.name
elif user:
if user.logo_data:
logo_data = user.logo_data
logo_content_type = user.logo_content_type
has_custom_logo = True
company_name = user.company_display_name
# Build steps from decisions
steps = []
for decision in (session.decisions or []):
steps.append({
"title": decision.get("title") or decision.get("question") or decision.get("description", "Step"),
"decision": decision.get("selected_option") or decision.get("answer", ""),
})
# Get supporting data
result = await db.execute(
select(SessionSupportingData)
.where(SessionSupportingData.session_id == session.id)
.order_by(SessionSupportingData.sort_order)
)
supporting_data_items = result.scalars().all()
# Calculate duration
duration = "—"
if session.started_at and session.completed_at:
delta = session.completed_at - session.started_at
minutes = int(delta.total_seconds() / 60)
if minutes < 60:
duration = f"{minutes} min"
else:
hours = minutes // 60
remaining = minutes % 60
duration = f"{hours}h {remaining}m"
# Outcome display
outcome = session.outcome or "In Progress"
outcome_class = "resolved" if outcome == "resolved" else "unresolved" if outcome == "unresolved" else "escalated"
outcome_display = f"✓ {outcome.title()}" if outcome == "resolved" else outcome.title()
# Session date
session_date = ""
if session.started_at:
session_date = session.started_at.strftime("%B %d, %Y")
# Summary
summary = session.outcome_notes or ""
# Engineer name
engineer_name = user.name if user else "Unknown"
# Generated timestamp
generated_at = datetime.now(timezone.utc).strftime("%B %d, %Y at %I:%M %p UTC")
# Render HTML
html_content = template.render(
report_type=report_type,
flow_title=flow_title,
logo_data=logo_data,
logo_content_type=logo_content_type,
has_custom_logo=has_custom_logo,
company_name=company_name,
engineer_name=engineer_name,
client_name=session.client_name,
ticket_number=session.ticket_number,
session_date=session_date,
duration=duration,
outcome_class=outcome_class,
outcome_display=outcome_display,
summary=summary,
steps=steps,
supporting_data=supporting_data_items,
generated_at=generated_at,
)
# Generate PDF
pdf_bytes = weasyprint.HTML(string=html_content).write_pdf()
return pdf_bytes
```
- [ ] **Step 4: Update export endpoint for PDF format**
In `backend/app/api/endpoints/sessions.py`, in the `export_session` function, add the PDF branch. After the format dispatch block (around line 395-406), add:
```python
if export_options.format == "pdf":
from app.services.export_service import generate_pdf_export
pdf_bytes = await generate_pdf_export(session, export_options, db)
# Mark as exported if completed (same logic as other formats)
if session.completed_at and not session.exported:
session.exported = True
db.add(session)
await db.commit()
from fastapi.responses import Response
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="session-export-{session_id}.pdf"'
},
)
```
Make sure this block runs before the existing format dispatch so it returns early for PDF.
**Note:** Variable resolution and redaction are handled inside `generate_pdf_export()` before rendering — the function must call `resolve_variables()` on text content and `apply_redaction_to_text()` if `redaction_mode != "none"`, matching the pattern used by other export formats in the existing code.
- [ ] **Step 5: Run tests**
Run: `cd backend && pytest tests/test_pdf_export.py -v --override-ini="addopts="`
Expected: PASS
- [ ] **Step 6: Run full test suite to verify no regressions**
Run: `cd backend && pytest --override-ini="addopts="`
Expected: All existing tests still pass.
- [ ] **Step 7: Commit**
```bash
git add backend/app/services/export_service.py backend/app/api/endpoints/sessions.py backend/tests/test_pdf_export.py
git commit -m "feat: add PDF export generation via WeasyPrint with branded template"
```
---
### Task 9: Supporting Data in Non-PDF Export Formats
**Files:**
- Modify: `backend/app/services/export_service.py`
The spec requires supporting data to be included in ALL export formats, not just PDF.
- [ ] **Step 1: Add supporting data to generate_markdown_export**
After the existing decisions/steps section, add a "## Supporting Data" section that renders each item:
- Text snippets: labeled fenced code blocks
- Screenshots: `[Screenshot: {label}]` placeholder (base64 images don't work in plain markdown)
- [ ] **Step 2: Add supporting data to generate_text_export**
After the steps section, add a "SUPPORTING DATA" section:
- Text snippets: labeled indented blocks
- Screenshots: `[Screenshot: {label}]` placeholder
- [ ] **Step 3: Add supporting data to generate_html_export**
After the steps section, add a "Supporting Data" section:
- Text snippets: `
` blocks with labels
- Screenshots: `` tags with base64 src
- [ ] **Step 4: Add supporting data to generate_psa_export**
After the steps section, add a "Supporting Data" section:
- Text snippets: labeled code blocks (markdown format for CW notes)
- Screenshots: `[Screenshot: {label}]` placeholder
**Note:** All four functions need to accept `db: AsyncSession` as a parameter (or the supporting data items as a pre-fetched list) to load the session's supporting data. Read the existing function signatures and follow the established pattern.
- [ ] **Step 5: Commit**
```bash
git add backend/app/services/export_service.py
git commit -m "feat: include supporting data in all export formats"
```
---
### Task 10: PR 1 Final — Integration Test Run & PR
- [ ] **Step 1: Run full backend test suite**
```bash
cd backend && pytest --override-ini="addopts=" -v
```
Expected: All tests pass.
- [ ] **Step 2: Push feature branch and create PR**
```bash
git push -u origin feat/backend-foundation-empty-states-exports
```
Create PR with title: "feat: backend foundation for empty states, onboarding, and exports"
---
## Chunk 2: PR 2 — Empty States + Guides
### File Structure
| Action | File | Responsibility |
|--------|------|---------------|
| Modify | `frontend/src/components/common/EmptyState.tsx` | Add illustration, learnMoreLink props |
| Create | `frontend/src/components/common/EmptyStateIllustrations.tsx` | SVG illustrations for each page |
| Modify | `frontend/src/pages/TreeLibraryPage.tsx` | Upgraded empty state |
| Modify | `frontend/src/pages/MyAnalyticsPage.tsx` | Upgraded empty state |
| Modify | `frontend/src/pages/TeamAnalyticsPage.tsx` | Upgraded empty state |
| Modify | `frontend/src/pages/SessionHistoryPage.tsx` | Upgraded empty state |
| Modify | `frontend/src/pages/StepLibraryPage.tsx` | Add empty state |
| Modify | `frontend/src/pages/ScriptLibraryPage.tsx` | Add empty state |
| Modify | `frontend/src/pages/MySharesPage.tsx` | Upgraded empty state |
| Modify | relevant integrations page | Add empty state |
| Create | `frontend/src/pages/guides/GuidePage.tsx` | Guide route wrapper |
| Create | `frontend/src/pages/guides/CreatingFlowsGuide.tsx` | Guide content |
| Create | `frontend/src/pages/guides/UnderstandingAnalyticsGuide.tsx` | Guide content |
| Create | `frontend/src/pages/guides/RunningSessionsGuide.tsx` | Guide content |
| Create | `frontend/src/pages/guides/PsaSetupGuide.tsx` | Guide content |
| Create | `frontend/src/pages/guides/StepLibraryGuide.tsx` | Guide content |
| Create | `frontend/src/pages/guides/ScriptTemplatesGuide.tsx` | Guide content |
| Create | `frontend/src/pages/guides/SharingSessionsGuide.tsx` | Guide content |
| Modify | `frontend/src/router.tsx` | Add `/guides/:slug` route |
---
### Task 11: Upgrade EmptyState Component
**Files:**
- Modify: `frontend/src/components/common/EmptyState.tsx`
- Create: `frontend/src/components/common/EmptyStateIllustrations.tsx`
- [ ] **Step 1: Update EmptyState component**
Rewrite `frontend/src/components/common/EmptyState.tsx` to support the new illustrative style:
```tsx
import { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { cn } from '@/lib/utils'
interface EmptyStateProps {
icon?: ReactNode
illustration?: ReactNode
title: string
description?: string
action?: ReactNode
learnMoreLink?: string
learnMoreText?: string
className?: string
}
export function EmptyState({
icon,
illustration,
title,
description,
action,
learnMoreLink,
learnMoreText = 'Learn more',
className,
}: EmptyStateProps) {
return (
{illustration && (
{illustration}
)}
{!illustration && icon && (
{icon}
)}
{title}
{description && (
{description}
)}
{action &&
{action}
}
{learnMoreLink && (
{learnMoreText} →
)}
)
}
```
- [ ] **Step 2: Create illustrations file**
Create `frontend/src/components/common/EmptyStateIllustrations.tsx` with SVG illustrations for each page. Each illustration is a simple 80x60 SVG using brand colors:
```tsx
export function FlowIllustration() {
return (
)
}
export function AnalyticsIllustration() {
return (
)
}
export function SessionIllustration() {
return (
)
}
export function IntegrationIllustration() {
return (
)
}
export function StepLibraryIllustration() {
return (
)
}
export function ScriptIllustration() {
return (
)
}
export function ShareIllustration() {
return (
)
}
```
- [ ] **Step 3: Verify frontend build**
Run: `cd frontend && npm run build`
Expected: Build succeeds.
- [ ] **Step 4: Commit**
```bash
git add frontend/src/components/common/EmptyState.tsx frontend/src/components/common/EmptyStateIllustrations.tsx
git commit -m "feat: upgrade EmptyState component with illustration and learn more support"
```
---
### Task 12: Roll Out Empty States Across Pages
**Files:** 8 page files (see file structure above)
- [ ] **Step 1: Update each page's empty state**
For each page, update the empty state usage to include the new props (illustration, description, CTA, learnMoreLink). The specific edits depend on how each page currently renders its empty state — read each file and update the `` usage.
**Pattern for each page:**
```tsx
import { FlowIllustration } from '@/components/common/EmptyStateIllustrations'
// In the render:
}
title="Build your first troubleshooting flow"
description="Flows guide your team through proven resolution paths, capturing every decision along the way."
action={}
learnMoreLink="/guides/creating-flows"
/>
```
Apply the correct illustration, title, description, CTA, and guide link from the spec table for each of the 8 pages.
- [ ] **Step 2: Add empty states to pages that don't have them**
For StepLibraryPage, ScriptLibraryPage, and the integrations page — add the `EmptyState` component where no data exists. Read each file first to understand the current rendering logic.
- [ ] **Step 3: Verify frontend build**
Run: `cd frontend && npm run build`
Expected: Build succeeds with no TypeScript errors.
- [ ] **Step 4: Commit**
```bash
git add frontend/src/pages/
git commit -m "feat: roll out illustrative empty states across 8 pages"
```
---
### Task 13: Create Guide Pages & Route
**Files:**
- Create: 7 guide component files in `frontend/src/pages/guides/`
- Create: `frontend/src/pages/guides/GuidePage.tsx`
- Modify: `frontend/src/router.tsx`
- [ ] **Step 1: Create guide page wrapper**
Create `frontend/src/pages/guides/GuidePage.tsx`:
```tsx
import { useParams, Link } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
import { EmptyState } from '@/components/common/EmptyState'
import CreatingFlowsGuide from './CreatingFlowsGuide'
import UnderstandingAnalyticsGuide from './UnderstandingAnalyticsGuide'
import RunningSessionsGuide from './RunningSessionsGuide'
import PsaSetupGuide from './PsaSetupGuide'
import StepLibraryGuide from './StepLibraryGuide'
import ScriptTemplatesGuide from './ScriptTemplatesGuide'
import SharingSessionsGuide from './SharingSessionsGuide'
const guides: Record = {
'creating-flows': { title: 'Creating Flows', component: CreatingFlowsGuide },
'understanding-analytics': { title: 'Understanding Analytics', component: UnderstandingAnalyticsGuide },
'running-sessions': { title: 'Running Sessions', component: RunningSessionsGuide },
'psa-setup': { title: 'Connecting Your PSA', component: PsaSetupGuide },
'step-library': { title: 'Using the Step Library', component: StepLibraryGuide },
'script-templates': { title: 'Script Templates', component: ScriptTemplatesGuide },
'sharing-sessions': { title: 'Sharing Sessions', component: SharingSessionsGuide },
}
export default function GuidePage() {
const { slug } = useParams<{ slug: string }>()
const guide = slug ? guides[slug] : undefined
if (!guide) {
return (
Back to Dashboard
}
/>
)
}
const GuideContent = guide.component
return (
)
}
```
- [ ] **Step 2: Create each guide component**
Create 7 guide files in `frontend/src/pages/guides/`. Each follows the same pattern — a functional component with heading, paragraphs, and a CTA link. Example for `CreatingFlowsGuide.tsx`:
```tsx
import { Link } from 'react-router-dom'
export default function CreatingFlowsGuide() {
return (
Creating Flows
Flows are the core of ResolutionFlow — structured troubleshooting paths that guide your team
through proven resolution steps.
Flow Types
Troubleshooting — Decision trees that branch based on what the engineer finds at each step.
Projects — Step-by-step procedural guides for installations, migrations, and setups.
Maintenance — Recurring check sequences you can schedule and run in batches.
Creating a Flow Manually
Click "Create a Flow" from the Flow Library, choose your flow type, and start building
in the visual editor. Add decision nodes, connect paths, and define outcomes.
Using AI to Generate Flows
Describe your troubleshooting scenario in plain language and the AI assistant will generate
a complete flow structure. You can then refine it in the editor.
Go to Flow Library →
)
}
```
Create the remaining 6 guides following the same pattern, tailored to their topic per the spec table. Keep each 300-600 words.
- [ ] **Step 3: Add route to router.tsx**
In `frontend/src/router.tsx`, add the guide route inside the protected children:
```tsx
{ path: 'guides/:slug', element: page(GuidePage) }
```
Add the lazy import at the top:
```tsx
const GuidePage = lazy(() => import('./pages/guides/GuidePage'))
```
- [ ] **Step 4: Verify frontend build**
Run: `cd frontend && npm run build`
Expected: Build succeeds.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/pages/guides/ frontend/src/router.tsx
git commit -m "feat: add 7 in-app user guides with /guides/:slug route"
```
---
### Task 14: EmptyState Vitest Tests
**Files:**
- Create: `frontend/src/components/common/__tests__/EmptyState.test.tsx`
- [ ] **Step 1: Write tests**
Create `frontend/src/components/common/__tests__/EmptyState.test.tsx`:
```tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { EmptyState } from '../EmptyState'
import { FlowIllustration } from '../EmptyStateIllustrations'
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
describe('EmptyState', () => {
it('renders title and description', () => {
render(
,
{ wrapper }
)
expect(screen.getByText('No data')).toBeInTheDocument()
expect(screen.getByText('Nothing here yet')).toBeInTheDocument()
})
it('renders illustration when provided', () => {
render(
} />,
{ wrapper }
)
expect(document.querySelector('svg')).toBeInTheDocument()
})
it('renders action button', () => {
render(
Do Thing} />,
{ wrapper }
)
expect(screen.getByText('Do Thing')).toBeInTheDocument()
})
it('renders learn more link', () => {
render(
,
{ wrapper }
)
const link = screen.getByText('Learn more →')
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute('href', '/guides/test')
})
it('renders without optional props', () => {
render(, { wrapper })
expect(screen.getByText('Just a title')).toBeInTheDocument()
expect(screen.queryByText('Learn more →')).not.toBeInTheDocument()
})
})
```
- [ ] **Step 2: Run tests**
Run: `cd frontend && npx vitest run src/components/common/__tests__/EmptyState.test.tsx`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/common/__tests__/EmptyState.test.tsx
git commit -m "test: add EmptyState component Vitest tests"
```
---
### Task 15: PR 2 — Build Verification & PR
- [ ] **Step 1: Full frontend build**
```bash
cd frontend && npm run build
```
Expected: Clean build, no errors.
- [ ] **Step 2: Create feature branch and PR**
```bash
git checkout -b feat/empty-states-and-guides
git push -u origin feat/empty-states-and-guides
```
Create PR: "feat: illustrative empty states across 8 pages with in-app guides"
---
## Chunk 3: PR 3 — Onboarding Checklist
### File Structure
| Action | File | Responsibility |
|--------|------|---------------|
| Create | `frontend/src/components/dashboard/OnboardingChecklist.tsx` | Checklist widget component |
| Create | `frontend/src/api/onboarding.ts` | API client for onboarding endpoints |
| Modify | `frontend/src/pages/QuickStartPage.tsx` | Insert checklist widget |
---
### Task 16: Onboarding API Client
**Files:**
- Create: `frontend/src/api/onboarding.ts`
- [ ] **Step 1: Create API client**
Create `frontend/src/api/onboarding.ts`:
```typescript
import { apiClient } from './client'
export interface OnboardingStatus {
created_flow: boolean
ran_session: boolean
exported_session: boolean
tried_ai_assistant: boolean
invited_teammate: boolean
connected_psa: boolean
is_team_user: boolean
dismissed: boolean
}
export async function getOnboardingStatus(): Promise {
const response = await apiClient.get('/users/onboarding-status')
return response.data
}
export async function dismissOnboarding(): Promise {
await apiClient.post('/users/onboarding-status/dismiss')
}
```
- [ ] **Step 2: Commit**
```bash
git add frontend/src/api/onboarding.ts
git commit -m "feat: add onboarding status API client"
```
---
### Task 17: OnboardingChecklist Component
**Files:**
- Create: `frontend/src/components/dashboard/OnboardingChecklist.tsx`
- [ ] **Step 1: Create the component**
Create `frontend/src/components/dashboard/OnboardingChecklist.tsx`:
```tsx
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Check, X, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getOnboardingStatus, dismissOnboarding, OnboardingStatus } from '@/api/onboarding'
interface ChecklistItem {
key: keyof OnboardingStatus
label: string
path: string
}
const SOLO_ITEMS: ChecklistItem[] = [
{ key: 'created_flow', label: 'Create your first flow', path: '/trees' },
{ key: 'ran_session', label: 'Run your first session', path: '/trees' },
{ key: 'exported_session', label: 'Export a session', path: '/sessions' },
{ key: 'tried_ai_assistant', label: 'Try the AI assistant', path: '/assistant' },
]
const TEAM_ITEMS: ChecklistItem[] = [
{ key: 'created_flow', label: 'Create your first flow', path: '/trees' },
{ key: 'invited_teammate', label: 'Invite a team member', path: '/account' },
{ key: 'ran_session', label: 'Run your first session', path: '/trees' },
{ key: 'connected_psa', label: 'Connect a PSA integration', path: '/account/integrations' },
{ key: 'exported_session', label: 'Export a session', path: '/sessions' },
]
export function OnboardingChecklist() {
const [status, setStatus] = useState(null)
const [dismissed, setDismissed] = useState(false)
const [allComplete, setAllComplete] = useState(false)
const navigate = useNavigate()
useEffect(() => {
getOnboardingStatus()
.then(setStatus)
.catch(() => {})
}, [])
if (!status || status.dismissed || dismissed) return null
const items = status.is_team_user ? TEAM_ITEMS : SOLO_ITEMS
const completedCount = items.filter((item) => status[item.key]).length
const totalCount = items.length
const isAllDone = completedCount === totalCount
// Show "all set" briefly then auto-hide after 2 seconds
useEffect(() => {
if (isAllDone) {
const timer = setTimeout(() => setAllComplete(true), 2000)
return () => clearTimeout(timer)
}
}, [isAllDone])
if (allComplete) return null
const handleDismiss = async () => {
setDismissed(true)
await dismissOnboarding().catch(() => {})
}
return (
{/* Progress bar */}
Getting Started
{isAllDone ? "You're all set!" : `${completedCount} of ${totalCount} complete`}