Files
resolutionflow/docs/plans/archive/2026-03-05-admin-survey-responses.md
Michael Chihlas cbb4b25671
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m42s
CI / e2e (pull_request) Successful in 10m11s
CI / backend (pull_request) Successful in 10m43s
fix(ui): drop setState-in-effect in useAuthSessionExpiry
CI surfaced react-hooks/set-state-in-effect on the synchronous
setState(computeState(token)) inside the useEffect body. The earlier
shape mirrored token -> state via an effect, which is exactly the
"you might not need an effect" pattern React 19's eslint rule now
flags.

Switch to derived state: compute during render, use a useReducer
tick to force re-render on the 30s cadence (so relative timestamps
stay current even when token props don't change). Same observable
behavior, no cascading renders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 20:15:11 -04:00

25 KiB

Admin Survey Responses Page — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build an admin page to view submitted survey responses with expandable row detail and CSV export.

Architecture: New backend endpoints on the existing admin_survey.py router + new frontend page following the SurveyInvitesPage pattern. Expandable rows show Q&A detail inline. CSV export endpoint returns a downloadable file.

Tech Stack: FastAPI, SQLAlchemy async, Pydantic v2, React, Tailwind CSS, Lucide icons


Task 1: Backend — Survey response schemas

Files:

  • Modify: backend/app/schemas/survey.py

Step 1: Add response schemas to survey.py

Add these schemas at the end of backend/app/schemas/survey.py:

class SurveyResponseDetail(BaseModel):
    """Full survey response returned to admin."""
    id: str
    respondent_name: Optional[str]
    responses: dict[str, Any]
    source: str  # "invite" or "direct"
    invite_name: Optional[str]
    created_at: datetime


class SurveyResponseListResponse(BaseModel):
    """List of survey responses with summary stats."""
    responses: list[SurveyResponseDetail]
    total: int
    this_week: int

Step 2: Commit

git add backend/app/schemas/survey.py
git commit -m "feat: add survey response admin schemas"

Task 2: Backend — List survey responses endpoint

Files:

  • Modify: backend/app/api/endpoints/admin_survey.py

Step 1: Write the failing test

Add to backend/tests/test_survey.py:

@pytest.mark.asyncio
async def test_list_survey_responses_admin(client, admin_auth_headers):
    """Admin can list survey responses."""
    # Submit a response first
    await client.post(
        "/api/v1/survey/submit",
        json={"respondent_name": "Tester", "responses": {"q1": "answer"}},
    )

    res = await client.get("/api/v1/admin/survey-responses", headers=admin_auth_headers)
    assert res.status_code == 200
    data = res.json()
    assert "responses" in data
    assert "total" in data
    assert "this_week" in data
    assert data["total"] >= 1
    assert data["responses"][0]["respondent_name"] == "Tester"
    assert data["responses"][0]["source"] == "direct"


@pytest.mark.asyncio
async def test_list_survey_responses_requires_admin(client, auth_headers):
    """Non-admin cannot list survey responses."""
    res = await client.get("/api/v1/admin/survey-responses", headers=auth_headers)
    assert res.status_code == 403

Step 2: Run tests to verify they fail

Run: cd backend && python -m pytest tests/test_survey.py::test_list_survey_responses_admin tests/test_survey.py::test_list_survey_responses_requires_admin -v Expected: FAIL (404 — endpoint doesn't exist yet)

Step 3: Implement the endpoint

Add to backend/app/api/endpoints/admin_survey.py:

from datetime import datetime, timezone, timedelta
from app.models.survey_response import SurveyResponse
from app.schemas.survey import SurveyResponseDetail, SurveyResponseListResponse

@router.get("/survey-responses", response_model=SurveyResponseListResponse)
async def list_survey_responses(
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(require_admin)],
):
    """List all survey responses with summary stats."""
    result = await db.execute(
        select(SurveyResponse, SurveyInvite.recipient_name.label("invite_name"))
        .outerjoin(SurveyInvite, SurveyResponse.invite_id == SurveyInvite.id)
        .order_by(SurveyResponse.created_at.desc())
    )
    rows = result.all()

    one_week_ago = datetime.now(timezone.utc) - timedelta(days=7)

    responses = []
    this_week = 0
    for row in rows:
        sr = row[0]  # SurveyResponse
        inv_name = row[1]  # invite_name or None
        detail = SurveyResponseDetail(
            id=str(sr.id),
            respondent_name=sr.respondent_name,
            responses=sr.responses,
            source="invite" if sr.invite_id else "direct",
            invite_name=inv_name,
            created_at=sr.created_at,
        )
        responses.append(detail)
        if sr.created_at >= one_week_ago:
            this_week += 1

    return SurveyResponseListResponse(
        responses=responses,
        total=len(responses),
        this_week=this_week,
    )

Add the missing imports at the top of admin_survey.py:

from datetime import datetime, timezone, timedelta
from app.models.survey_response import SurveyResponse
from app.schemas.survey import SurveyResponseDetail, SurveyResponseListResponse

Step 4: Run tests to verify they pass

Run: cd backend && python -m pytest tests/test_survey.py -v Expected: ALL PASS

Step 5: Commit

git add backend/app/api/endpoints/admin_survey.py backend/tests/test_survey.py
git commit -m "feat: add admin list survey responses endpoint"

Task 3: Backend — CSV export endpoint

Files:

  • Modify: backend/app/api/endpoints/admin_survey.py

Step 1: Write the failing test

Add to backend/tests/test_survey.py:

@pytest.mark.asyncio
async def test_export_survey_responses_csv(client, admin_auth_headers):
    """Admin can export survey responses as CSV."""
    # Submit a response
    await client.post(
        "/api/v1/survey/submit",
        json={
            "respondent_name": "CSV Tester",
            "responses": {
                "prereqs": ["Who's affected", "What changed recently"],
                "verify_fix": "Have the user confirm",
                "steps_at_a_time": "5 steps",
                "prioritization": ["Likelihood", "Speed", "Blast radius"],
            },
        },
    )

    res = await client.get("/api/v1/admin/survey-responses/export", headers=admin_auth_headers)
    assert res.status_code == 200
    assert "text/csv" in res.headers["content-type"]
    assert "attachment" in res.headers.get("content-disposition", "")
    body = res.text
    assert "CSV Tester" in body
    assert "Respondent" in body  # Header row


@pytest.mark.asyncio
async def test_export_survey_responses_requires_admin(client, auth_headers):
    """Non-admin cannot export survey responses."""
    res = await client.get("/api/v1/admin/survey-responses/export", headers=auth_headers)
    assert res.status_code == 403

Step 2: Run tests to verify they fail

Run: cd backend && python -m pytest tests/test_survey.py::test_export_survey_responses_csv tests/test_survey.py::test_export_survey_responses_requires_admin -v Expected: FAIL (404)

Step 3: Implement the CSV export endpoint

Add to backend/app/api/endpoints/admin_survey.py:

import csv
import io
from fastapi.responses import StreamingResponse

# Question IDs in survey order, with display labels
SURVEY_QUESTIONS = [
    ("prereqs", "Q1: Pre-work info needed"),
    ("verify_fix", "Q2: How you verify a fix"),
    ("steps_at_a_time", "Q3: Steps at a time preference"),
    ("first_step", "Q4: First move on vague ticket"),
    ("junior_mistake", "Q5: Common junior mistake"),
    ("pivot", "Q6: When to pivot theories"),
    ("scenario_approach", "Q7: Scenario diagnostic steps"),
    ("scenario_deeper", "Q8: Scenario server checks"),
    ("doc_pct", "Q9: Documentation percentage"),
    ("go_to_commands", "Q10: Go-to commands"),
    ("secret_weapon", "Q11: Secret weapon"),
    ("gotcha", "Q12: Wrong obvious diagnosis"),
    ("hard_rules", "Q13: Hard rules followed"),
    ("prioritization", "Q14: Diagnostic prioritization"),
    ("detail_level", "Q15: AI suggestion specificity"),
    ("ai_personality", "Q16: AI colleague traits"),
]


def _format_answer(value) -> str:
    """Format a survey answer for CSV output."""
    if value is None:
        return ""
    if isinstance(value, list):
        # Ranked items: numbered; multi-select: semicolon-joined
        if len(value) > 0 and all(isinstance(v, str) for v in value):
            return "; ".join(f"{i+1}. {v}" if len(value) > 3 else v for i, v in enumerate(value))
        return "; ".join(str(v) for v in value)
    return str(value)


@router.get("/survey-responses/export")
async def export_survey_responses_csv(
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(require_admin)],
):
    """Export all survey responses as a CSV file."""
    result = await db.execute(
        select(SurveyResponse).order_by(SurveyResponse.created_at.desc())
    )
    responses = result.scalars().all()

    output = io.StringIO()
    writer = csv.writer(output)

    # Header row
    headers = ["Respondent", "Date"] + [label for _, label in SURVEY_QUESTIONS]
    writer.writerow(headers)

    # Data rows
    for sr in responses:
        row = [
            sr.respondent_name or "Anonymous",
            sr.created_at.strftime("%Y-%m-%d %H:%M") if sr.created_at else "",
        ]
        for qid, _ in SURVEY_QUESTIONS:
            row.append(_format_answer(sr.responses.get(qid)))
        writer.writerow(row)

    output.seek(0)
    return StreamingResponse(
        iter([output.getvalue()]),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=survey-responses.csv"},
    )

Step 4: Run tests to verify they pass

Run: cd backend && python -m pytest tests/test_survey.py -v Expected: ALL PASS

Step 5: Commit

git add backend/app/api/endpoints/admin_survey.py backend/tests/test_survey.py
git commit -m "feat: add admin CSV export for survey responses"

Task 4: Frontend — Admin API client functions

Files:

  • Modify: frontend/src/api/admin.ts

Step 1: Add the response type and API functions

Add the type after the existing SurveyInviteResponse interface in frontend/src/api/admin.ts:

export interface SurveyResponseDetail {
  id: string
  respondent_name: string | null
  responses: Record<string, string | string[]>
  source: 'invite' | 'direct'
  invite_name: string | null
  created_at: string
}

export interface SurveyResponseListResponse {
  responses: SurveyResponseDetail[]
  total: number
  this_week: number
}

Add these functions inside the adminApi object, after the createSurveyInvite entry:

  // Survey Responses
  listSurveyResponses: () =>
    api.get<SurveyResponseListResponse>('/admin/survey-responses').then(r => r.data),
  exportSurveyResponsesCsv: () =>
    api.get('/admin/survey-responses/export', { responseType: 'blob' }).then(r => r.data),

Step 2: Commit

git add frontend/src/api/admin.ts
git commit -m "feat: add survey responses admin API client"

Task 5: Frontend — Survey Responses admin page

Files:

  • Create: frontend/src/pages/admin/SurveyResponsesPage.tsx

Step 1: Create the page

Create frontend/src/pages/admin/SurveyResponsesPage.tsx:

import { useState, useEffect } from 'react'
import { adminApi } from '@/api/admin'
import type { SurveyResponseDetail } from '@/api/admin'
import { PageHeader } from '@/components/admin'
import { ChevronDown, Download, User, Link2, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'

// Question metadata for display
const QUESTIONS: { id: string; num: string; text: string; type: 'mc' | 'mc-multi' | 'range' | 'text' | 'rank' }[] = [
  { id: 'prereqs', num: '1', text: 'Before you start troubleshooting, what info do you need?', type: 'mc-multi' },
  { id: 'verify_fix', num: '2', text: 'After you apply a fix, how do you verify it actually worked?', type: 'mc' },
  { id: 'steps_at_a_time', num: '3', text: 'How many steps do you prefer to see at once?', type: 'range' },
  { id: 'first_step', num: '4', text: 'A vague ticket comes in: "Internet is down." What\'s your FIRST move?', type: 'mc' },
  { id: 'junior_mistake', num: '5', text: 'Most common mistake you see junior engineers make?', type: 'mc' },
  { id: 'pivot', num: '6', text: 'How do you decide when to stop pursuing one theory and pivot?', type: 'mc' },
  { id: 'scenario_approach', num: '7', text: 'Walk through your first 3 diagnostic steps for this ticket.', type: 'text' },
  { id: 'scenario_deeper', num: '8', text: 'Server pings fine, you can RDP in. What do you check next?', type: 'text' },
  { id: 'doc_pct', num: '9', text: 'What percentage of troubleshooting steps do you actually document?', type: 'range' },
  { id: 'go_to_commands', num: '10', text: 'Top 3 go-to PowerShell commands or one-liners?', type: 'text' },
  { id: 'secret_weapon', num: '11', text: 'Secret weapon command/tool/technique?', type: 'text' },
  { id: 'gotcha', num: '12', text: 'Issue where the obvious diagnosis was WRONG?', type: 'text' },
  { id: 'hard_rules', num: '13', text: 'Which "rules" do you follow?', type: 'mc-multi' },
  { id: 'prioritization', num: '14', text: 'Rank factors by diagnostic priority influence.', type: 'rank' },
  { id: 'detail_level', num: '15', text: 'How specific should AI diagnostic suggestions be?', type: 'mc' },
  { id: 'ai_personality', num: '16', text: 'What makes an AI feel like a useful colleague?', type: 'mc' },
]

export default function SurveyResponsesPage() {
  const [responses, setResponses] = useState<SurveyResponseDetail[]>([])
  const [total, setTotal] = useState(0)
  const [thisWeek, setThisWeek] = useState(0)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState('')
  const [expandedId, setExpandedId] = useState<string | null>(null)
  const [exporting, setExporting] = useState(false)

  useEffect(() => {
    const load = async () => {
      try {
        const data = await adminApi.listSurveyResponses()
        setResponses(data.responses)
        setTotal(data.total)
        setThisWeek(data.this_week)
      } catch {
        setError('Failed to load survey responses')
      } finally {
        setLoading(false)
      }
    }
    load()
  }, [])

  const handleExport = async () => {
    setExporting(true)
    try {
      const blob = await adminApi.exportSurveyResponsesCsv()
      const url = URL.createObjectURL(blob)
      const a = document.createElement('a')
      a.href = url
      a.download = 'survey-responses.csv'
      a.click()
      URL.revokeObjectURL(url)
    } catch {
      setError('Export failed')
    } finally {
      setExporting(false)
    }
  }

  const formatDate = (dateStr: string) =>
    new Date(dateStr).toLocaleDateString('en-US', {
      month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit',
    })

  return (
    <div className="space-y-6">
      <div className="flex items-start justify-between gap-4">
        <PageHeader
          title="Survey Responses"
          description={`${total} response${total !== 1 ? 's' : ''} collected`}
        />
        <button
          onClick={handleExport}
          disabled={exporting || total === 0}
          className="inline-flex items-center gap-2 rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-foreground hover:border-[rgba(255,255,255,0.12)] active:scale-[0.97] disabled:opacity-50 disabled:cursor-not-allowed transition-all mt-1"
        >
          {exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
          Export CSV
        </button>
      </div>

      {/* Stats */}
      <div className="flex gap-4">
        <div className="glass-card-static px-5 py-4 flex-1">
          <div className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1">Total Responses</div>
          <div className="text-2xl font-heading font-bold text-gradient-brand">{total}</div>
        </div>
        <div className="glass-card-static px-5 py-4 flex-1">
          <div className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1">This Week</div>
          <div className="text-2xl font-heading font-bold text-foreground">{thisWeek}</div>
        </div>
      </div>

      {error && <p className="text-sm text-rose-500">{error}</p>}

      {/* Responses Table */}
      <div className="glass-card-static overflow-hidden">
        <div className="overflow-x-auto">
          <table className="w-full">
            <thead>
              <tr className="border-b border-border">
                <th className="w-10 px-4 py-3" />
                <th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">#</th>
                <th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Respondent</th>
                <th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Source</th>
                <th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Date</th>
                <th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Answered</th>
              </tr>
            </thead>
            <tbody>
              {loading ? (
                <tr><td colSpan={6} className="px-4 py-8 text-center text-sm text-muted-foreground">Loading...</td></tr>
              ) : responses.length === 0 ? (
                <tr><td colSpan={6} className="px-4 py-8 text-center text-sm text-muted-foreground">No responses yet</td></tr>
              ) : (
                responses.map((r, idx) => {
                  const isExpanded = expandedId === r.id
                  const answeredCount = Object.keys(r.responses).filter(k => {
                    const v = r.responses[k]
                    return v !== undefined && v !== '' && (!Array.isArray(v) || v.length > 0)
                  }).length

                  return (
                    <ResponseRow
                      key={r.id}
                      response={r}
                      index={total - idx}
                      answeredCount={answeredCount}
                      isExpanded={isExpanded}
                      onToggle={() => setExpandedId(isExpanded ? null : r.id)}
                      formatDate={formatDate}
                    />
                  )
                })
              )}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  )
}

function ResponseRow({
  response: r,
  index,
  answeredCount,
  isExpanded,
  onToggle,
  formatDate,
}: {
  response: SurveyResponseDetail
  index: number
  answeredCount: number
  isExpanded: boolean
  onToggle: () => void
  formatDate: (d: string) => string
}) {
  return (
    <>
      <tr
        onClick={onToggle}
        className="border-b border-border/50 hover:bg-[rgba(255,255,255,0.02)] transition-colors cursor-pointer group"
      >
        <td className="px-4 py-3">
          <ChevronDown
            className={cn(
              'h-4 w-4 text-muted-foreground transition-transform duration-200',
              isExpanded && 'rotate-180'
            )}
          />
        </td>
        <td className="px-4 py-3 font-label text-xs text-muted-foreground">{index}</td>
        <td className="px-4 py-3 text-sm text-foreground">{r.respondent_name || 'Anonymous'}</td>
        <td className="px-4 py-3">
          <span className={cn(
            'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wider',
            r.source === 'invite'
              ? 'bg-primary/10 text-primary'
              : 'bg-[rgba(255,255,255,0.06)] text-muted-foreground'
          )}>
            {r.source === 'invite' ? <Link2 className="h-3 w-3" /> : <User className="h-3 w-3" />}
            {r.source === 'invite' ? r.invite_name || 'Invite' : 'Direct'}
          </span>
        </td>
        <td className="px-4 py-3 font-label text-xs text-muted-foreground">{formatDate(r.created_at)}</td>
        <td className="px-4 py-3">
          <span className="font-label text-xs text-muted-foreground">{answeredCount} / {QUESTIONS.length}</span>
        </td>
      </tr>
      {isExpanded && (
        <tr>
          <td colSpan={6} className="p-0">
            <ExpandedDetail responses={r.responses} />
          </td>
        </tr>
      )}
    </>
  )
}

function ExpandedDetail({ responses }: { responses: Record<string, string | string[]> }) {
  return (
    <div
      className="px-6 py-5 animate-fade-in-up"
      style={{ background: 'rgba(0, 0, 0, 0.15)', borderTop: '1px solid rgba(6, 182, 212, 0.1)' }}
    >
      <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
        {QUESTIONS.map(q => {
          const answer = responses[q.id]
          const hasAnswer = answer !== undefined && answer !== '' && (!Array.isArray(answer) || answer.length > 0)

          return (
            <div
              key={q.id}
              className="rounded-[10px] p-4"
              style={{
                background: 'rgba(24, 26, 31, 0.6)',
                border: '1px solid var(--glass-border)',
              }}
            >
              <div className="font-label text-[10px] mb-1 font-medium" style={{ color: '#06b6d4' }}>Q{q.num}</div>
              <div className="text-[13px] font-medium text-foreground/80 mb-2 leading-snug">{q.text}</div>

              {!hasAnswer ? (
                <div className="text-[12px] text-[#5a6170] italic">No answer</div>
              ) : q.type === 'mc-multi' && Array.isArray(answer) ? (
                <div className="flex flex-wrap gap-1.5">
                  {answer.map((v, i) => (
                    <span key={i} className="inline-block rounded-full px-2.5 py-0.5 text-[11px] font-label bg-primary/10 text-primary">
                      {v}
                    </span>
                  ))}
                </div>
              ) : q.type === 'rank' && Array.isArray(answer) ? (
                <ol className="space-y-1">
                  {answer.map((v, i) => (
                    <li key={i} className="flex items-center gap-2 text-[12px]">
                      <span className="font-label text-[10px] font-semibold w-4 text-center" style={{ color: '#06b6d4' }}>{i + 1}</span>
                      <span className="text-muted-foreground">{v}</span>
                    </li>
                  ))}
                </ol>
              ) : q.type === 'text' ? (
                <div
                  className="text-[13px] text-muted-foreground leading-relaxed rounded-lg p-2.5"
                  style={{ background: 'rgba(0, 0, 0, 0.2)', borderLeft: '2px solid rgba(6, 182, 212, 0.2)' }}
                >
                  {String(answer)}
                </div>
              ) : (
                <div className="text-[13px] text-muted-foreground">{String(answer)}</div>
              )}
            </div>
          )
        })}
      </div>
    </div>
  )
}

Step 2: Verify build

Run: cd frontend && npm run build Expected: Build succeeds (page isn't routed yet but should compile)

Step 3: Commit

git add frontend/src/pages/admin/SurveyResponsesPage.tsx
git commit -m "feat: add admin survey responses page component"

Task 6: Frontend — Wire up route, sidebar, and lazy import

Files:

  • Modify: frontend/src/router.tsx
  • Modify: frontend/src/components/admin/AdminSidebar.tsx

Step 1: Add lazy import to router.tsx

Add after the AdminSurveyInvitesPage lazy import (line 54):

const AdminSurveyResponsesPage = lazy(() => import('@/pages/admin/SurveyResponsesPage'))

Step 2: Add route to router.tsx

Add a new route object after the survey-invites route (after line 405):

          {
            path: 'survey-responses',
            element: (
              <Suspense fallback={<PageLoader />}>
                <AdminSurveyResponsesPage />
              </Suspense>
            ),
          },

Step 3: Add sidebar nav item

In frontend/src/components/admin/AdminSidebar.tsx:

Add MessageSquareText to the lucide import:

import {
  LayoutDashboard,
  Users,
  Ticket,
  FileText,
  Gauge,
  ToggleLeft,
  Settings,
  FolderTree,
  ClipboardList,
  MessageSquareText,
  ArrowLeft,
} from 'lucide-react'

Add the nav item after the Survey Invites entry:

  { path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText },

Step 4: Verify build

Run: cd frontend && npm run build Expected: Build succeeds with no errors

Step 5: Commit

git add frontend/src/router.tsx frontend/src/components/admin/AdminSidebar.tsx
git commit -m "feat: wire up admin survey responses route and sidebar nav"

Task 7: End-to-end verification

Step 1: Run all backend tests

Run: cd backend && python -m pytest tests/test_survey.py -v Expected: ALL PASS

Step 2: Run frontend build

Run: cd frontend && npm run build Expected: Build succeeds

Step 3: Manual verification

  1. Start backend: cd backend && uvicorn app.main:app --reload
  2. Start frontend: cd frontend && npm run dev
  3. Submit a test survey at http://localhost:5173/survey
  4. Log in as admin, navigate to Admin > Survey Responses
  5. Verify: stats show, table shows response, click to expand, verify Q&A renders
  6. Click Export CSV, verify file downloads with correct data

Step 4: Final commit

git add -A
git commit -m "feat: admin survey responses page with expandable detail and CSV export

- Backend: GET /admin/survey-responses (list with stats)
- Backend: GET /admin/survey-responses/export (CSV download)
- Frontend: SurveyResponsesPage with expandable row detail
- Two-column Q&A grid with typed answer rendering
- Stats cards (total, this week)
- CSV export button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"