diff --git a/app/analyzer/tickets/page.tsx b/app/analyzer/tickets/page.tsx new file mode 100644 index 0000000..31511f5 --- /dev/null +++ b/app/analyzer/tickets/page.tsx @@ -0,0 +1,487 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Search, + Sparkles, + CheckCircle2, + Filter, + X, +} from 'lucide-react'; +import { AnalyzeButton } from '@/components/analyzer/analyze-button'; + +type Period = + | 'today' + | 'yesterday' + | 'this_week' + | 'last_week' + | 'last_30d' + | 'last_60d' + | 'all'; + +interface PeriodOption { + value: Period; + label: string; +} + +const PERIOD_OPTIONS: PeriodOption[] = [ + { value: 'today', label: 'Today' }, + { value: 'yesterday', label: 'Yesterday' }, + { value: 'this_week', label: 'This week' }, + { value: 'last_week', label: 'Last week' }, + { value: 'last_30d', label: 'Last 30 days' }, + { value: 'last_60d', label: 'Last 60 days' }, + { value: 'all', label: 'All time' }, +]; + +interface TicketRow { + ticketNumber: string; + title: string | null; + companyName: string | null; + issueTypeLabel: string | null; + statusLabel: string | null; + priorityLabel: string | null; + lastActivityDate: string | null; + createDate: string | null; + latestAnalysisId: string | null; + latestAnalysisVersion: number | null; +} + +interface FilterOptions { + companies: { id: string; name: string }[]; + issueTypes: { value: number; label: string }[]; +} + +const PAGE_SIZE = 50; + +const ALL_COMPANIES = '__all_companies__'; +const ALL_ISSUE_TYPES = '__all_issue_types__'; + +function formatRelative(iso: string | null): string { + if (!iso) return '—'; + const d = new Date(iso); + const diffMs = Date.now() - d.getTime(); + const diffMin = Math.round(diffMs / 60000); + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.round(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDays = Math.round(diffHr / 24); + if (diffDays < 7) return `${diffDays}d ago`; + return d.toLocaleDateString(); +} + +export default function AnalyzerBrowseTicketsPage() { + const [period, setPeriod] = useState('last_30d'); + const [companyId, setCompanyId] = useState(ALL_COMPANIES); + const [issueType, setIssueType] = useState(ALL_ISSUE_TYPES); + const [searchInput, setSearchInput] = useState(''); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(0); + + const [tickets, setTickets] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [filterOptions, setFilterOptions] = useState(null); + + // Debounce search input → applied search + useEffect(() => { + const t = setTimeout(() => { + setSearch(searchInput.trim()); + setPage(0); + }, 300); + return () => clearTimeout(t); + }, [searchInput]); + + // Reset page when filters change + useEffect(() => { + setPage(0); + }, [period, companyId, issueType]); + + // Load filter options once + useEffect(() => { + let cancelled = false; + fetch('/api/analyzer/tickets/filter-options') + .then((r) => (r.ok ? r.json() : Promise.reject(r))) + .then((data: FilterOptions) => { + if (!cancelled) setFilterOptions(data); + }) + .catch(() => { + if (!cancelled) + setFilterOptions({ companies: [], issueTypes: [] }); + }); + return () => { + cancelled = true; + }; + }, []); + + const fetchTickets = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ + period, + limit: String(PAGE_SIZE), + offset: String(page * PAGE_SIZE), + }); + if (companyId !== ALL_COMPANIES) params.set('companyId', companyId); + if (issueType !== ALL_ISSUE_TYPES) params.set('issueType', issueType); + if (search) params.set('search', search); + const res = await fetch(`/api/analyzer/tickets/list?${params.toString()}`); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { + error?: string; + message?: string; + }; + throw new Error(data.message ?? data.error ?? `Failed: ${res.status}`); + } + const data = (await res.json()) as { + tickets: TicketRow[]; + total: number; + }; + setTickets(data.tickets); + setTotal(data.total); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + setError(msg); + setTickets([]); + setTotal(0); + } finally { + setLoading(false); + } + }, [period, companyId, issueType, search, page]); + + useEffect(() => { + void fetchTickets(); + }, [fetchTickets]); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + const activeFilterCount = useMemo(() => { + let n = 0; + if (period !== 'last_30d') n++; + if (companyId !== ALL_COMPANIES) n++; + if (issueType !== ALL_ISSUE_TYPES) n++; + if (search) n++; + return n; + }, [period, companyId, issueType, search]); + + function clearFilters() { + setPeriod('last_30d'); + setCompanyId(ALL_COMPANIES); + setIssueType(ALL_ISSUE_TYPES); + setSearchInput(''); + setSearch(''); + } + + const companyName = useMemo(() => { + if (companyId === ALL_COMPANIES) return null; + return ( + filterOptions?.companies.find((c) => c.id === companyId)?.name ?? null + ); + }, [companyId, filterOptions]); + + return ( +
+
+
+

+ Browse Tickets to Analyze +

+

+ Filter by activity window, client, or issue type — click Analyze to + run the AI pipeline against any ticket. +

+
+ +
+ + {/* Filter bar */} + + +
+ + + Filters + {activeFilterCount > 0 && ( + + {activeFilterCount} + + )} + + {activeFilterCount > 0 && ( + + )} +
+
+ + {/* Period chips */} +
+ +
+ {PERIOD_OPTIONS.map((p) => ( + + ))} +
+
+ + {/* Other filters in a grid */} +
+
+ + +
+ +
+ + +
+ +
+ +
+ + setSearchInput(e.target.value)} + /> +
+
+
+
+
+ + {/* Results */} + + +
+ + {loading + ? 'Loading…' + : total === 0 + ? 'No tickets match' + : total === 1 + ? '1 ticket' + : `${total.toLocaleString()} tickets`} + {companyName && total > 0 && ( + + · {companyName} + + )} + + {total > PAGE_SIZE && ( +
+ + Page {page + 1} of {totalPages} + + + +
+ )} +
+
+ + {error ? ( +
{error}
+ ) : tickets.length === 0 && !loading ? ( +
+ +

No tickets match the current filters.

+

+ Try widening the period or clearing some filters. +

+
+ ) : ( + + + + Ticket + Title + Client + Issue type + Status + Last activity + + Actions + + + + + {tickets.map((t) => ( + + + + {t.ticketNumber} + + + +
+ + {t.title ?? No title} + + {t.latestAnalysisId && ( + + + v{t.latestAnalysisVersion} + + )} +
+
+ + {t.companyName ?? } + + + {t.issueTypeLabel ?? } + + + {t.statusLabel ? ( + {t.statusLabel} + ) : ( + + )} + + + {formatRelative(t.lastActivityDate)} + + +
+ {t.latestAnalysisId && ( + + )} + +
+
+
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/app/api/analyzer/tickets/filter-options/route.ts b/app/api/analyzer/tickets/filter-options/route.ts new file mode 100644 index 0000000..e12c641 --- /dev/null +++ b/app/api/analyzer/tickets/filter-options/route.ts @@ -0,0 +1,55 @@ +/** + * GET /api/analyzer/tickets/filter-options + * + * Returns the dropdown source data for the analyzer ticket browser. + * Companies are limited to those that have at least one non-deleted ticket + * (255 → typically ~150 with tickets) so the dropdown isn't padded with + * dormant accounts. + */ + +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +interface CompanyRow { + id: string; + company_name: string; +} +interface IssueTypeRow { + value: number; + label: string; +} + +export async function GET() { + const { error } = await requireAuth(); + if (error) return error; + + const [companies, issueTypes] = await Promise.all([ + postgresClient.query( + `SELECT c.id::text AS id, c.company_name + FROM companies c + WHERE c.is_active = true + AND c.is_deleted = false + AND EXISTS ( + SELECT 1 FROM tickets t + WHERE t.company_id = c.id AND t.is_deleted = false + ) + ORDER BY c.company_name` + ), + postgresClient.query( + `SELECT value, label + FROM issue_types + WHERE is_active = true + AND is_deleted = false + ORDER BY sort_order NULLS LAST, label` + ), + ]); + + return NextResponse.json({ + companies: companies.rows.map((r) => ({ + id: r.id, + name: r.company_name, + })), + issueTypes: issueTypes.rows, + }); +} diff --git a/app/api/analyzer/tickets/list/route.ts b/app/api/analyzer/tickets/list/route.ts new file mode 100644 index 0000000..985d31a --- /dev/null +++ b/app/api/analyzer/tickets/list/route.ts @@ -0,0 +1,177 @@ +/** + * GET /api/analyzer/tickets/list + * + * Browse view backing the /analyzer/tickets page. Filters tickets by + * `last_activity_date` (the most useful axis for "what's worth analyzing + * right now") plus optional company / issue type / free-text search. + * + * Query params: + * period one of today | yesterday | this_week | last_week | last_30d | last_60d | all + * companyId numeric companies.id, optional + * issueType numeric issue_types.value, optional + * search substring match against ticket_number or title + * limit default 50, capped at 200 + * offset default 0 + * + * Returns: + * { tickets: TicketRow[], total: number } + * + * Each row carries `latestAnalysisId` if the ticket already has a complete + * analysis, so the UI can offer "View analysis" alongside "Analyze". + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +type Period = + | 'today' + | 'yesterday' + | 'this_week' + | 'last_week' + | 'last_30d' + | 'last_60d' + | 'all'; + +const ALLOWED_PERIODS: ReadonlySet = new Set([ + 'today', + 'yesterday', + 'this_week', + 'last_week', + 'last_30d', + 'last_60d', + 'all', +]); + +/** + * Returns the SQL fragment for the date predicate. Uses Postgres-side NOW() + * so "today" reflects the database server's clock — this is an internal tool + * and the DB and app process share the same clock. + * + * Returns the predicate string with no parameters — these date expressions + * are constants from the API perspective, computed in Postgres. + */ +function periodPredicate(period: Period): string { + switch (period) { + case 'today': + return `t.last_activity_date >= date_trunc('day', NOW())`; + case 'yesterday': + return `t.last_activity_date >= date_trunc('day', NOW()) - INTERVAL '1 day' + AND t.last_activity_date < date_trunc('day', NOW())`; + case 'this_week': + return `t.last_activity_date >= date_trunc('week', NOW())`; + case 'last_week': + return `t.last_activity_date >= date_trunc('week', NOW()) - INTERVAL '1 week' + AND t.last_activity_date < date_trunc('week', NOW())`; + case 'last_30d': + return `t.last_activity_date >= NOW() - INTERVAL '30 days'`; + case 'last_60d': + return `t.last_activity_date >= NOW() - INTERVAL '60 days'`; + case 'all': + return `TRUE`; + } +} + +interface TicketRow { + ticket_number: string; + title: string | null; + company_name: string | null; + issue_type_label: string | null; + status_label: string | null; + priority_label: string | null; + last_activity_date: Date | null; + create_date: Date | null; + latest_analysis_id: string | null; + latest_analysis_version: number | null; + total_count: string; +} + +export async function GET(request: NextRequest) { + const { error } = await requireAuth(); + if (error) return error; + + const url = new URL(request.url); + const periodParam = (url.searchParams.get('period') ?? 'last_30d') as Period; + const period: Period = ALLOWED_PERIODS.has(periodParam) ? periodParam : 'last_30d'; + const companyIdRaw = url.searchParams.get('companyId'); + const companyId = companyIdRaw ? Number(companyIdRaw) : null; + const issueTypeRaw = url.searchParams.get('issueType'); + const issueType = issueTypeRaw ? Number(issueTypeRaw) : null; + const search = (url.searchParams.get('search') ?? '').trim() || null; + const limit = Math.min(Number(url.searchParams.get('limit') ?? 50) || 50, 200); + const offset = Math.max(Number(url.searchParams.get('offset') ?? 0) || 0, 0); + + const sql = ` + WITH filtered AS ( + SELECT t.id, t.ticket_number, t.title, + t.company_id, t.issue_type, t.status, t.priority, + t.last_activity_date, t.create_date + FROM tickets t + WHERE t.is_deleted = false + AND ${periodPredicate(period)} + AND ($1::bigint IS NULL OR t.company_id = $1::bigint) + AND ($2::int IS NULL OR t.issue_type = $2::int) + AND ($3::text IS NULL OR ( + t.ticket_number ILIKE '%' || $3::text || '%' + OR t.title ILIKE '%' || $3::text || '%' + )) + ) + SELECT f.ticket_number, + f.title, + c.company_name, + it.label AS issue_type_label, + s.label AS status_label, + pr.label AS priority_label, + f.last_activity_date, + f.create_date, + latest.id::text AS latest_analysis_id, + latest.analysis_version AS latest_analysis_version, + COUNT(*) OVER () AS total_count + FROM filtered f + LEFT JOIN companies c ON c.id = f.company_id + LEFT JOIN issue_types it ON it.value = f.issue_type + LEFT JOIN statuses s ON s.value = f.status + LEFT JOIN priorities pr ON pr.value = f.priority + LEFT JOIN LATERAL ( + SELECT aa.id, aa.analysis_version + FROM analyzer_analyses aa + WHERE aa.ticket_number = f.ticket_number + AND aa.status = 'complete' + ORDER BY aa.analysis_version DESC + LIMIT 1 + ) latest ON TRUE + ORDER BY f.last_activity_date DESC NULLS LAST + LIMIT $4 OFFSET $5 + `; + + const res = await postgresClient.query(sql, [ + companyId, + issueType, + search, + limit, + offset, + ]); + + const total = res.rows.length > 0 ? Number(res.rows[0].total_count) : 0; + + return NextResponse.json({ + period, + total, + limit, + offset, + tickets: res.rows.map((r) => ({ + ticketNumber: r.ticket_number, + title: r.title, + companyName: r.company_name, + issueTypeLabel: r.issue_type_label, + statusLabel: r.status_label, + priorityLabel: r.priority_label, + lastActivityDate: r.last_activity_date + ? r.last_activity_date.toISOString() + : null, + createDate: r.create_date ? r.create_date.toISOString() : null, + latestAnalysisId: r.latest_analysis_id, + latestAnalysisVersion: r.latest_analysis_version, + })), + }); +} diff --git a/components/analyzer/analysis-view.tsx b/components/analyzer/analysis-view.tsx index b4ad5f5..46c52d3 100644 --- a/components/analyzer/analysis-view.tsx +++ b/components/analyzer/analysis-view.tsx @@ -11,7 +11,13 @@ import { CollapsibleTrigger, } from '@/components/ui/collapsible'; import { Separator } from '@/components/ui/separator'; -import { ChevronRight, ChevronDown, ExternalLink, AlertTriangle } from 'lucide-react'; +import { + ChevronRight, + ChevronDown, + ExternalLink, + AlertTriangle, + ArrowRight, +} from 'lucide-react'; import { ShareModal } from './share-modal'; import { AnalyzeButton } from './analyze-button'; import type { PersistedAnalysis, Visibility } from '@/lib/types/analyzer'; @@ -38,6 +44,29 @@ const SEVERITY_TONE: Record = { low: 'border-blue-500 bg-blue-500/5', }; +function ProseText({ + text, + className = '', +}: { + text: string; + className?: string; +}) { + const paragraphs = text + .split(/\n\s*\n/) + .map((p) => p.trim()) + .filter(Boolean); + if (paragraphs.length === 0) return null; + return ( +
+ {paragraphs.map((p, i) => ( +

+ {p} +

+ ))} +
+ ); +} + function ConfidenceBadge({ score }: { score: number | null }) { if (score === null) return null; const pct = Math.round(score * 100); @@ -135,42 +164,51 @@ export function AnalysisView({ analysis: a }: AnalysisViewProps) { {a.summary && ( - Summary + + Summary + -

- {a.summary} -

+
)} {/* 3. Next step */} {a.nextStep && ( - + - - Next step + + + Recommended Next Step -

{a.nextStep}

+ {a.nextStepRationale && ( - + - -

- {a.nextStepRationale} -

+
)} @@ -308,12 +346,15 @@ export function AnalysisView({ analysis: a }: AnalysisViewProps) { {a.postResolutionAnalysis && ( - Post-resolution analysis + + Post-Resolution Analysis + -

- {a.postResolutionAnalysis} -

+
)} diff --git a/components/navigation/app-navigation.tsx b/components/navigation/app-navigation.tsx index 6ad5993..bb63f0d 100644 --- a/components/navigation/app-navigation.tsx +++ b/components/navigation/app-navigation.tsx @@ -29,6 +29,8 @@ import { SlidersHorizontal, GitCompare, Brain, + Search, + AlertTriangle, } from 'lucide-react'; import { NavigationMenu, @@ -106,6 +108,24 @@ const navigationItems: NavItem[] = [ }, ], }, + { + title: 'Analyzer', + icon: Sparkles, + children: [ + { + title: 'Browse Tickets', + href: '/analyzer/tickets', + icon: Search, + description: 'Filter tickets by period, client, or issue type — pick one to analyze', + }, + { + title: 'Needs Review', + href: '/analyzer/queue', + icon: AlertTriangle, + description: 'Analyses flagged for human review (low confidence or cost-ceiling skipped Opus)', + }, + ], + }, { title: 'Admin', icon: Activity, diff --git a/docs/wulf-pulse-ticket-analyzer-build-notes.md b/docs/wulf-pulse-ticket-analyzer-build-notes.md index ee7f254..0d2ec94 100644 --- a/docs/wulf-pulse-ticket-analyzer-build-notes.md +++ b/docs/wulf-pulse-ticket-analyzer-build-notes.md @@ -414,6 +414,85 @@ This file is updated after each phase ships. --- +## Phase 9 — Ticket browser + analysis-view formatting + +Reactive to first-use feedback: the analysis page Summary / Next Step +text was bare and dense, and there was no way to discover tickets to +analyze without typing the URL. + +**Delivered** + +- `` helper inside `analysis-view.tsx` — splits text on blank + lines, renders each chunk as a separate `

` with `leading-7` and + `whitespace-pre-line`. Applied to Summary, Next Step, Next Step + rationale, and Post-Resolution Analysis. Single-paragraph text still + renders cleanly. +- Summary + Post-Resolution headers got an uppercase tracking-wide + treatment to act as section dividers, and body text bumped to + `text-base text-foreground` so it reads as a finding rather than a + caption. +- Next Step card now has a subtle `bg-primary/5` tint, an `ArrowRight` + icon next to "Recommended Next Step", a stronger separator before + the rationale collapsible, and the rationale itself renders in a + bordered indented block. +- New `/analyzer/tickets` browse page — pill-style period chips + (Today, Yesterday, This week, Last week, Last 30/60 days, All time), + a client (company) Select, an issue-type Select, and a debounced + free-text search across ticket_number/title. Compact table with + per-row Analyze/Re-analyze button (reusing ``) and a + "View" button shortcut to the existing analysis when one is + recorded. Active-filter count + clear-all in the filter card header. +- New API `GET /api/analyzer/tickets/list` — filters by `period` + (computed in Postgres against `last_activity_date`), `companyId`, + `issueType`, `search`. Returns 50 rows + total via `COUNT(*) OVER ()`, + plus `latestAnalysisId` from a LATERAL join into `analyzer_analyses`. +- New API `GET /api/analyzer/tickets/filter-options` — companies that + have at least one non-deleted ticket (drops dormant accounts) + + active issue types ordered by `sort_order, label`. +- Top-level "Analyzer" nav menu added to `app-navigation.tsx`, with + "Browse Tickets" + "Needs Review". Earlier phases left this off + intentionally; this phase opts in. + +**Decisions worth flagging** + +- **Period filters on `last_activity_date`, not `create_date`.** + "Today" surfaces tickets that had activity today (new tickets, + re-opened, status churn) — much more useful for an analyzer-driven + triage flow than tickets created today. A new ticket created today + also has activity today, so we don't lose those. +- **Period math runs in Postgres via `date_trunc('day', NOW())` etc.** + Database server-clock = app-process clock for an internal Docker + stack, so naive timestamps and naive `NOW()` agree. If users + complain about edge-of-day drift, swap to + `NOW() AT TIME ZONE 'America/New_York'` — Pulse's primary user base. +- **Default period is `last_30d`.** "All time" pulls many thousands of + rows; defaulting wide-open hurts first-page latency. 30 days hits + ~7K rows in our DB, paginates cleanly. +- **Per-row analyze button reuses `` directly.** Each + row gets its own component instance — no shared state, the running + state lives per-button. The button navigates on completion, which + feels right: click Analyze, watch the stages, land on the analysis + page. +- **`force=true` is set automatically on tickets that already have an + analysis.** Re-analyze should re-run, not short-circuit to the + cached row. The "View" button covers the cached path. +- **No Linear/JIRA-style multi-select filters.** Single-value Selects + are simpler and match the rest of Pulse. + +**Deliberately left out** + +- **No saved views.** A filter URL is shareable, but there's no + bookmark / saved-view UX. Add when someone asks. +- **No `latest_analysis_status` exposure.** A failed analysis doesn't + show up — the LATERAL join filters by `status='complete'`. So a + ticket whose only analysis failed looks like an un-analyzed ticket. + Acceptable: re-running is the intended action there anyway. +- **Search is `ILIKE '%...%'`.** No tsvector / trigram index. 7K-row + scans are sub-100ms in this DB; if the corpus grows past low six + digits, swap in `pg_trgm`. + +--- + ## Status after each phase | Phase | Tests | tsc | Notes | @@ -426,3 +505,4 @@ This file is updated after each phase ships. | 6 | 128 | clean | frontend (no FE tests) | | 7 | 128 | clean | share email via existing SMTP transport | | 8 | 128 | clean | operator runbook + README link | +| 9 | 128 | clean | browse page + analysis-view formatting + nav entry |