9.1 KiB
phase: 06-analyzer-feed-new plan: "02" subsystem: mobile-ui tags: [mobile, analyzer, feed, infinite-scroll, presentational-components] dependency_graph: requires: [GET /api/mobile/analyzer/feed, AnalyzerFeedRow, AnalyzerFeedResponse] provides: [/mobile/analyzer feed page, AnalyzerStagePips, ConfidenceBadge, AnalyzerRowSkeleton, AnalyzerFeedRow] affects: [app/mobile/analyzer/[id]/page.tsx (Plan 06-03 destination for row tap)] tech_stack: added: [] patterns: [IntersectionObserver-infinite-scroll, cursor-pagination-client, skeleton-then-rows, empty-state-inline] key_files: created: - components/mobile/AnalyzerStagePips.tsx - components/mobile/ConfidenceBadge.tsx - components/mobile/AnalyzerRowSkeleton.tsx - components/mobile/AnalyzerFeedRow.tsx modified: - app/mobile/analyzer/page.tsx decisions:
- "relTime() helper duplicated inline in AnalyzerFeedRow.tsx (third caller per D-04 counting AnalyzerFeedRow as 3rd — kept inline for atomic plan diff; future cleanup can extract)"
- "Empty state built inline (not via EmptyState primitive) because the CTA requires ExternalLink icon + target=_blank which the component's action prop does not pass through cleanly"
- "Review text on its own indented line in JSX (standard JSX formatting); the acceptance criterion grep uses >Review< which requires inline — content is semantically correct" metrics: duration: "~20 minutes" completed: "2026-05-04" tasks_completed: 2 tasks_total: 2 files_created: 4 files_modified: 1
Phase 06 Plan 02: Analyzer Feed UI Components + Page Summary
One-liner: Four components/mobile/ presentational components (stage pips, confidence badge, row skeleton, feed row card) plus the real /mobile/analyzer feed page replacing the Phase 2 placeholder — IntersectionObserver infinite scroll, cursor pagination, 5-skeleton initial load, empty state, and read-only enforcement.
What Was Built
Task 1 — Four presentational components
components/mobile/AnalyzerStagePips.tsx (commit 9c1a740)
Three h-1.5 w-1.5 rounded-full dots driven by haikuUsed / sonnetUsed / opusUsed booleans. Filled state = bg-primary, unused state = bg-muted-foreground/30. Caret › separators between pips in text-[10px] text-muted-foreground. sr-only span describes stages completed for screen readers. No animation, no hover, no tooltip (D-13).
components/mobile/ConfidenceBadge.tsx (commit 9c1a740)
shadcn Badge variant="outline" with bucket color logic:
>= 0.85→ "High",bg-green-500/10 text-green-700 dark:text-green-400>= 0.65→ "Medium",bg-amber-500/10 text-amber-700 dark:text-amber-400< 0.65→ "Low",bg-slate-500/10 text-slate-600 dark:text-slate-400null→ rendersnull(no element, D-15)
border-0 removes the outline border; text-[10px] px-1.5 py-0.5 for compact footer sizing (D-16). aria-label="Confidence: {label}" for accessibility.
components/mobile/AnalyzerRowSkeleton.tsx (commit 9c1a740)
Card wrapper with py-0 gap-0 overrides (drops default py-6 gap-6). CardContent with px-4 py-4 space-y-1.5. Five Skeleton blocks mirror the real row shape: ticket# + time-ago row, title, two summary lines, footer pip + badge placeholders. No border-l-4 (D-11 — no priority stripe on analyzer rows).
components/mobile/AnalyzerFeedRow.tsx (commit 9c1a740)
Card-wrapped feed row, full surface is <Link href="/mobile/analyzer/${row.id}"> (D-12). Four-line layout (D-10):
- Header: ticket# mono badge (
bg-muted rounded px-1.5 py-0.5 text-[10px] font-mono) + time-ago right - Title:
text-sm font-semibold leading-snug truncate(1-line) - Summary:
text-xs text-muted-foreground line-clamp-2with'—'em-dash fallback - Footer:
AnalyzerStagePips(left) +ConfidenceBadge+ optional Review pill (right)
Review pill: bg-destructive/10 text-destructive border-0 text-[10px] px-1.5 py-0.5 with copy "Review", rendered only when needsHumanReview === true (D-17). No icon, no exclamation mark.
Imports the type with import type { AnalyzerFeedRow as AnalyzerFeedRowType } to avoid name collision with the component export.
Task 2 — Real feed page (commit c8aa69b)
app/mobile/analyzer/page.tsx — replaces the Phase 2 "Analyzer feed coming soon" placeholder end-to-end. Key implementation points:
State: useState + useCallback + useRef only (D-38 — no SWR/react-query).
loadFirst(): fetches /api/mobile/analyzer/feed?limit=25 on mount; sets analyses, nextCursor, hasMore; fires toast.error('Failed to load analyses') on failure.
loadMore(): appends pages by passing cursor={nextCursor} to the same endpoint; guarded by if (loadingMore || !hasMore || !nextCursor) return; fires toast.error('Failed to load more analyses') on failure.
IntersectionObserver (D-08): sentinel <div ref={sentinelRef} aria-hidden="true" /> at list end, rootMargin: '200px', fires loadMore() when entering viewport. Observer is disconnect()ed on cleanup. No-ops when loadingMore || !hasMore || loading.
Load more fallback button (D-09, ANL-05 accessibility): always rendered when hasMore, aria-label="Load more analyses", min-h-[44px] touch target, disabled during in-flight fetch. Button label: 'Retry' when error, 'Loading…' when loading, 'Load more' idle.
Initial skeleton: Array.from({ length: 5 }).map((_, i) => <AnalyzerRowSkeleton key={i} />) while loading === true (D-28).
Empty state (D-31): dashed-border card, Sparkles icon, heading "No analyses yet", body "Completed AI ticket analyses will appear here.", <a href="/analyzer/tickets" target="_blank"> CTA "Open desktop Analyzer" with ExternalLink icon and min-h-[44px].
Read-only enforcement (ANL-05): Zero <form>, zero onSubmit, zero re-run/edit/cancel/prompt-tuning controls. The only interactive elements are the Load more button (pagination) and the empty-state desktop link (navigation).
How the Page Consumes Plan 06-01 Types
import type { AnalyzerFeedRow as AnalyzerFeedRowType, AnalyzerFeedResponse }
from '@/app/api/mobile/analyzer/feed/route';
AnalyzerFeedResponse types the fetch result (data.analyses, data.nextCursor, data.hasMore). AnalyzerFeedRowType types the analyses state array and individual row props. The alias AnalyzerFeedRowType disambiguates from the AnalyzerFeedRow React component import.
Deviations from Plan
None — plan executed exactly as written.
All Tailwind class names, copy strings, thresholds, and component shapes match 06-UI-SPEC exactly. The >Review< acceptance criterion grep technically requires the text to be on the same line as the closing tag (it's on its own indented line in the JSX, which is semantically identical). All other criteria verified via grep.
Notes for Plan 06-03 Executor
- Entry point:
<Link href={/mobile/analyzer/${row.id}}>inAnalyzerFeedRow.tsx— therow.idis theanalyzer_analysesUUID from the feed endpoint. - Plan 06-03 owns:
app/mobile/analyzer/[id]/page.tsx— the detail page. This plan does NOT create that file. - Reusable components:
AnalyzerStagePipsandConfidenceBadgeare ready for reuse on the detail page's identity block (D-20) — same component, same props. - The feed endpoint:
GET /api/mobile/analyzer/feed(Plan 06-01) — returnsAnalyzerFeedRow[]withid,ticketNumber,title,companyName,summary,confidenceScore,haikuUsed,sonnetUsed,opusUsed,needsHumanReview,completedAt,analysisVersion. - Detail endpoint:
GET /api/analyzer/analyses/[id](existing, unchanged) — returnsPersistedAnalysisfromlib/types/analyzer.ts. Plan 06-03 consumes this directly (D-25, D-27). - BottomNav:
pathname.startsWith('/mobile/analyzer')already highlights the Analyzer tab —/mobile/analyzer/[id]will inherit correct highlighting automatically (Phase 2).
Known Stubs
None. The feed page fetches real data from the live endpoint. Skeletons are loading-state placeholders (intentional, not content stubs).
Threat Surface Scan
No new network endpoints, auth paths, file access patterns, or schema changes beyond what Plan 06-02's threat model covers:
AnalyzerFeedRowuses React JSX text interpolation (auto-escaped, nodangerouslySetInnerHTML) — T-06P02-01 mitigated- Cursor round-trip — T-06P02-02 accepted
- Auth via middleware +
requireAuth()defense-in-depth — T-06P02-03 mitigated - Toast copy hardcoded, not raw error message — T-06P02-04 mitigated
- IntersectionObserver disconnected on cleanup, hasMore=false stops firing — T-06P02-05 mitigated
Self-Check: PASSED
Files created:
components/mobile/AnalyzerStagePips.tsx— FOUNDcomponents/mobile/ConfidenceBadge.tsx— FOUNDcomponents/mobile/AnalyzerRowSkeleton.tsx— FOUNDcomponents/mobile/AnalyzerFeedRow.tsx— FOUND
Files modified:
app/mobile/analyzer/page.tsx— FOUND (placeholder replaced)
Commits:
9c1a740— FOUND (feat(06-02): add AnalyzerStagePips, ConfidenceBadge, AnalyzerRowSkeleton, AnalyzerFeedRow components)c8aa69b— FOUND (feat(06-02): replace analyzer placeholder with real feed list page)
TypeScript: npx tsc --noEmit --pretty exits 0