39 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 06-analyzer-feed-new | 02 | execute | 2 |
|
|
true |
|
|
Purpose: ANL-01 + ANL-02 + ANL-05 + ANL-06 — this is the manager-facing surface. It must FEEL like Phase 4 (same skeleton-then-rows initial load, same IntersectionObserver, same Load more fallback) so there's zero learning curve when switching between Tickets and Analyzer tabs.
Output:
- 4 new components in
components/mobile/: stage pips, confidence badge, row skeleton, feed row card - Replaced page at
app/mobile/analyzer/page.tsx(the placeholder is gone; the real feed is in)
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/REQUIREMENTS.md @.planning/phases/06-analyzer-feed-new/06-CONTEXT.md @.planning/phases/06-analyzer-feed-new/06-UI-SPEC.md @.planning/phases/06-analyzer-feed-new/06-01-SUMMARY.md @CLAUDE.md @app/mobile/tickets/page.tsx @app/mobile/analyzer/page.tsx @components/mobile/TicketRowSkeleton.tsx @components/mobile/FinanceRow.tsx @components/ui/card.tsx @components/ui/badge.tsx @components/ui/skeleton.tsx @components/ui/empty-state.tsx @app/api/mobile/analyzer/feed/route.tsFrom @/app/api/mobile/analyzer/feed/route (Plan 06-01 export):
export interface AnalyzerFeedRow {
id: string;
ticketNumber: string;
title: string;
companyName: string;
summary: string | null;
confidenceScore: number | null;
haikuUsed: boolean;
sonnetUsed: boolean;
opusUsed: boolean;
needsHumanReview: boolean;
completedAt: string;
analysisVersion: number;
}
export interface AnalyzerFeedResponse {
analyses: AnalyzerFeedRow[];
nextCursor: string | null;
hasMore: boolean;
}
NOTE: The component file is also named AnalyzerFeedRow.tsx. The TypeScript interface and the React component share a name — disambiguate by importing the type with import type and the component with regular import. (Phase 4 does the exact same pattern: MobileTicket type vs <TicketRow> component.)
From app/mobile/tickets/page.tsx (PATTERN SOURCE — copy structure):
relTime(ts: string | null): stringhelper at lines 22-30 (60s→Xm ago, hours→Xh ago, elseXd ago)Suspensewrapper around the inner client component (Next.js 16 useSearchParams requirement — but Analyzer feed has no URL params this phase, so Suspense may not be needed; verify during build)useState,useEffect,useCallback,useRef,IntersectionObserversetup at lines 188-203loadFirstandloadMorecallback pattern at lines 121-174- Load more fallback button at lines 292-304 (
w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50) - Inline loading spinner at lines 285-289 (
Loader2 w-4 h-4 animate-spin text-muted-foreground)
From components/mobile/TicketRowSkeleton.tsx (PATTERN — adapt for analyzer row shape):
'use client';
import { Skeleton } from '@/components/ui/skeleton';
export function TicketRowSkeleton() {
return (
<div className="border-l-4 border-muted px-4 py-4">
<Skeleton className="h-4 w-3/4" />
...
</div>
);
}
Per D-11 the analyzer row has NO border-l-4 — drop the stripe in AnalyzerRowSkeleton.
FILE A — components/mobile/AnalyzerStagePips.tsx (D-13, D-14)
Three colored dots representing pipeline stages reached. Pure CSS, no animation, no library. The visually-hidden span describes stages used for screen readers. Exact JSX:
'use client';
/* AnalyzerStagePips — phase 06 (ANL-02).
* Purpose: 3-dot stage indicator (Triage → Analyze → Deep Review) with caret separators.
* Filled when stage was used, muted when not. Per D-13/D-14 (no animation).
* Props: see AnalyzerStagePipsProps. */
export interface AnalyzerStagePipsProps {
haikuUsed: boolean;
sonnetUsed: boolean;
opusUsed: boolean;
}
const STAGE_LABELS = ['Triage', 'Analyze', 'Deep Review'];
export function AnalyzerStagePips({ haikuUsed, sonnetUsed, opusUsed }: AnalyzerStagePipsProps) {
const used = [haikuUsed, sonnetUsed, opusUsed];
const completed = STAGE_LABELS.filter((_, i) => used[i]);
const srLabel = completed.length === 0
? 'No stages completed'
: `Stages completed: ${completed.join(', ')}`;
return (
<div className="flex items-center gap-1.5" aria-hidden="false">
<span className="sr-only">{srLabel}</span>
{used.map((isUsed, i) => (
<span key={i} className="flex items-center gap-1.5">
<span
className={`h-1.5 w-1.5 rounded-full ${isUsed ? 'bg-primary' : 'bg-muted-foreground/30'}`}
aria-hidden="true"
/>
{i < 2 && <span className="text-[10px] text-muted-foreground" aria-hidden="true">›</span>}
</span>
))}
</div>
);
}
Verify against UI-SPEC §"Stage Pips": dot size h-1.5 w-1.5, container gap gap-1.5, filled bg-primary, muted bg-muted-foreground/30, caret › between pips with text-[10px] text-muted-foreground. NO tooltip, NO hover, NO animation (D-13).
FILE B — components/mobile/ConfidenceBadge.tsx (D-15, D-16)
shadcn Badge with bucketed background and text color. Renders null when score is null (D-15). Exact JSX:
'use client';
/* ConfidenceBadge — phase 06 (ANL-02).
* Purpose: Bucketed confidence label — High (>=0.85) / Medium (>=0.65) / Low (<0.65).
* Renders nothing when score is null. Per D-15 / D-16.
* Props: see ConfidenceBadgeProps. */
import { Badge } from '@/components/ui/badge';
export interface ConfidenceBadgeProps {
score: number | null;
}
export function ConfidenceBadge({ score }: ConfidenceBadgeProps) {
if (score === null) return null;
let label: string;
let className: string;
if (score >= 0.85) {
label = 'High';
className = 'bg-green-500/10 text-green-700 dark:text-green-400';
} else if (score >= 0.65) {
label = 'Medium';
className = 'bg-amber-500/10 text-amber-700 dark:text-amber-400';
} else {
label = 'Low';
className = 'bg-slate-500/10 text-slate-600 dark:text-slate-400';
}
return (
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-0.5 border-0 ${className}`}
aria-label={`Confidence: ${label}`}
>
{label}
</Badge>
);
}
Verify thresholds against UI-SPEC §"Confidence Badge Colors" + D-15: >= 0.85 High green, 0.65 <= < 0.85 Medium amber, < 0.65 Low slate, null → no element. Tailwind classes are EXACTLY bg-green-500/10 text-green-700 dark:text-green-400 etc. — no other shades. border-0 removes the default outline border (D-16). text-[10px] px-1.5 py-0.5 exact.
FILE C — components/mobile/AnalyzerRowSkeleton.tsx (D-28)
Mirror TicketRowSkeleton but DROP the border-l-4 border-muted stripe (per D-11 analyzer rows have no priority stripe). Use a Card wrapper to match the real row's surface.
'use client';
/* AnalyzerRowSkeleton — phase 06 (D-28).
* Purpose: Skeleton placeholder matching analyzer feed row shape (no priority stripe per D-11).
* Renders 5 instances on initial load.
* Props: none — purely presentational. */
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function AnalyzerRowSkeleton() {
return (
<Card className="py-0 shadow-none gap-0">
<CardContent className="px-4 py-4 space-y-1.5">
<div className="flex justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-10" />
</div>
<Skeleton className="h-4 w-3/4 mt-0.5" />
<Skeleton className="h-3 w-full mt-1" />
<Skeleton className="h-3 w-2/3" />
<div className="flex justify-between mt-2">
<Skeleton className="h-2 w-20" />
<Skeleton className="h-3 w-12" />
</div>
</CardContent>
</Card>
);
}
Note py-0 gap-0 overrides the default Card py-6 gap-6 so internal padding comes from CardContent. Verify shape against UI-SPEC §"Skeleton Row".
FILE D — components/mobile/AnalyzerFeedRow.tsx (D-10, D-11, D-12, D-17)
The feed row Card. Per D-12 the entire Card is a <Link> to /mobile/analyzer/[id]. Per D-11 NO border-l-4. Per D-10 four-line layout: header / title / summary / footer.
'use client';
/* AnalyzerFeedRow — phase 06 (ANL-02).
* Purpose: Feed row card — header (ticket# + time-ago), title (1-line truncate),
* summary (2-line clamp), footer (stage pips left + confidence/review right).
* Entire card is a Link to /mobile/analyzer/[id]. Per D-10..D-12, D-17.
* Props: AnalyzerFeedRow type from @/app/api/mobile/analyzer/feed/route. */
import Link from 'next/link';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { AnalyzerStagePips } from '@/components/mobile/AnalyzerStagePips';
import { ConfidenceBadge } from '@/components/mobile/ConfidenceBadge';
import type { AnalyzerFeedRow as AnalyzerFeedRowType } from '@/app/api/mobile/analyzer/feed/route';
function relTime(ts: string | null): string {
if (!ts) return '—';
const diff = Date.now() - new Date(ts).getTime();
const m = Math.floor(diff / 60000);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
export interface AnalyzerFeedRowProps {
row: AnalyzerFeedRowType;
}
export function AnalyzerFeedRow({ row }: AnalyzerFeedRowProps) {
return (
<Link href={`/mobile/analyzer/${row.id}`} className="block">
<Card className="py-0 gap-0 cursor-pointer hover:bg-muted/50 active:bg-muted/50 transition-colors">
<CardContent className="px-4 py-4 space-y-1.5">
{/* Line 1 — header row */}
<div className="flex justify-between items-center">
<span className="bg-muted rounded px-1.5 py-0.5 text-[10px] font-mono font-semibold">
{row.ticketNumber}
</span>
<span className="text-[10px] text-muted-foreground">
{relTime(row.completedAt)}
</span>
</div>
{/* Line 2 — title */}
<p className="text-sm font-semibold leading-snug truncate">
{row.title}
</p>
{/* Line 3 — summary clamp (2 lines, fallback "—") */}
<p className="text-xs text-muted-foreground line-clamp-2">
{row.summary ?? '—'}
</p>
{/* Footer — pips left, badges right */}
<div className="flex justify-between items-center mt-1">
<AnalyzerStagePips
haikuUsed={row.haikuUsed}
sonnetUsed={row.sonnetUsed}
opusUsed={row.opusUsed}
/>
<div className="flex gap-2 items-center">
<ConfidenceBadge score={row.confidenceScore} />
{row.needsHumanReview && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0.5 border-0 bg-destructive/10 text-destructive"
aria-label="Needs human review"
>
Review
</Badge>
)}
</div>
</div>
</CardContent>
</Card>
</Link>
);
}
Verify against UI-SPEC §"Feed Row Card": title text-sm font-semibold leading-snug truncate (1-line), summary text-xs text-muted-foreground line-clamp-2, ticket# badge bg-muted rounded px-1.5 py-0.5 text-[10px] font-mono, time-ago text-[10px] text-muted-foreground, footer flex row, Review pill EXACTLY bg-destructive/10 text-destructive with copy "Review" (D-17). NO icons in the Review pill, NO exclamation mark.
The relTime() helper is duplicated inline (per D-04 and CONTEXT.md "duplicate inline; extract shared only when third caller appears"). The tickets page is the second caller; analyzer row is the third — but the diff is small enough that inline duplication keeps Plan 06-02 atomic. A future cleanup phase can extract.
npx tsc --noEmit --pretty 2>&1 | grep -E "components/mobile/(AnalyzerStagePips|ConfidenceBadge|AnalyzerRowSkeleton|AnalyzerFeedRow)\.tsx" || echo "TypeScript clean for new components"
<acceptance_criteria>
- All 4 files exist: for f in components/mobile/AnalyzerStagePips.tsx components/mobile/ConfidenceBadge.tsx components/mobile/AnalyzerRowSkeleton.tsx components/mobile/AnalyzerFeedRow.tsx; do test -f "$f" || echo "MISSING $f"; done produces no MISSING output
- Each file starts with 'use client';: head -1 components/mobile/AnalyzerStagePips.tsx components/mobile/ConfidenceBadge.tsx components/mobile/AnalyzerRowSkeleton.tsx components/mobile/AnalyzerFeedRow.tsx | grep -c "'use client'" returns 4
- Each file exports its named component: grep -E "^export function (AnalyzerStagePips|ConfidenceBadge|AnalyzerRowSkeleton|AnalyzerFeedRow)" components/mobile/Analyzer*.tsx components/mobile/ConfidenceBadge.tsx | wc -l returns 4
- AnalyzerStagePips renders correct dot classes: grep -F 'bg-primary' components/mobile/AnalyzerStagePips.tsx returns at least one match AND grep -F 'bg-muted-foreground/30' components/mobile/AnalyzerStagePips.tsx returns at least one match
- AnalyzerStagePips dot size correct: grep -F 'h-1.5 w-1.5' components/mobile/AnalyzerStagePips.tsx returns at least one match
- AnalyzerStagePips includes sr-only label: grep -F 'sr-only' components/mobile/AnalyzerStagePips.tsx returns at least one match
- ConfidenceBadge thresholds exact (D-15): grep -F '0.85' components/mobile/ConfidenceBadge.tsx returns at least one match AND grep -F '0.65' components/mobile/ConfidenceBadge.tsx returns at least one match
- ConfidenceBadge tones exact: grep -F 'bg-green-500/10' components/mobile/ConfidenceBadge.tsx returns at least one match AND grep -F 'bg-amber-500/10' components/mobile/ConfidenceBadge.tsx returns at least one match AND grep -F 'bg-slate-500/10' components/mobile/ConfidenceBadge.tsx returns at least one match
- ConfidenceBadge dark-mode tones present: grep -F 'dark:text-green-400' components/mobile/ConfidenceBadge.tsx returns at least one match
- ConfidenceBadge returns null on null score: grep -E 'score === null.*return null' components/mobile/ConfidenceBadge.tsx returns at least one match (or equivalent early-return)
- AnalyzerFeedRow links to detail: grep -F 'href={/mobile/analyzer/${row.id}}' components/mobile/AnalyzerFeedRow.tsx returns at least one match (or use grep -E 'mobile/analyzer/' components/mobile/AnalyzerFeedRow.tsx)
- AnalyzerFeedRow has NO border-l-4: grep -F 'border-l-4' components/mobile/AnalyzerFeedRow.tsx components/mobile/AnalyzerRowSkeleton.tsx returns ZERO matches (D-11 — no priority stripe)
- Title row classes exact: grep -F 'text-sm font-semibold leading-snug truncate' components/mobile/AnalyzerFeedRow.tsx returns at least one match
- Summary clamp class: grep -F 'line-clamp-2' components/mobile/AnalyzerFeedRow.tsx returns at least one match
- Summary fallback character is em-dash: grep -F "'—'" components/mobile/AnalyzerFeedRow.tsx returns at least one match (literal em-dash, not "--")
- Review pill copy exact: grep -E '>Review<' components/mobile/AnalyzerFeedRow.tsx returns at least one match
- Review pill tone: grep -F 'bg-destructive/10 text-destructive' components/mobile/AnalyzerFeedRow.tsx returns at least one match
- Skeleton renders no border-l-4 (already covered by previous check)
- AnalyzerFeedRow imports the type, not the component, from the route file: grep -E 'import type \\{ AnalyzerFeedRow.*from .@/app/api/mobile/analyzer/feed/route.' components/mobile/AnalyzerFeedRow.tsx returns at least one match
- npx tsc --noEmit --pretty exits 0
</acceptance_criteria>
Four components compile, exported with correct props interfaces, render the exact Tailwind classes and copy strings declared in 06-UI-SPEC. Task 2 can compose them in the page.
The page is 'use client' (CLAUDE.md mobile pattern; D-38 — no SWR, no react-query, plain useState + useEffect + fetch). It does NOT need Suspense because it has NO useSearchParams() calls (no URL filter sync this phase per CONTEXT.md "feed has no filters this phase"). If TypeScript or runtime requires Suspense for some other reason, wrap as in app/mobile/tickets/page.tsx lines 73-79.
Implementation:
'use client';
import { useEffect, useState, useCallback, useRef } from 'react';
import Link from 'next/link';
import { Loader2, Sparkles, ExternalLink } from 'lucide-react';
import { toast } from 'sonner';
import { AnalyzerFeedRow } from '@/components/mobile/AnalyzerFeedRow';
import { AnalyzerRowSkeleton } from '@/components/mobile/AnalyzerRowSkeleton';
import type { AnalyzerFeedRow as AnalyzerFeedRowType, AnalyzerFeedResponse } from '@/app/api/mobile/analyzer/feed/route';
export default function MobileAnalyzerPage() {
// List state
const [analyses, setAnalyses] = useState<AnalyzerFeedRowType[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
// First page (mount)
const loadFirst = useCallback(async () => {
setLoading(true);
setError(null);
try {
const r = await fetch('/api/mobile/analyzer/feed?limit=25');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data: AnalyzerFeedResponse = await r.json();
setAnalyses(data.analyses);
setNextCursor(data.nextCursor);
setHasMore(data.hasMore);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to load analyses';
setError(msg);
toast.error('Failed to load analyses');
} finally {
setLoading(false);
}
}, []);
// Cursor advance
const loadMore = useCallback(async () => {
if (loadingMore || !hasMore || !nextCursor) return;
setLoadingMore(true);
setError(null);
try {
const sp = new URLSearchParams({ cursor: nextCursor, limit: '25' });
const r = await fetch(`/api/mobile/analyzer/feed?${sp.toString()}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data: AnalyzerFeedResponse = await r.json();
setAnalyses(prev => [...prev, ...data.analyses]);
setNextCursor(data.nextCursor);
setHasMore(data.hasMore);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to load more analyses';
setError(msg);
toast.error('Failed to load more analyses');
} finally {
setLoadingMore(false);
}
}, [loadingMore, hasMore, nextCursor]);
useEffect(() => { void loadFirst(); }, [loadFirst]);
// IntersectionObserver — D-08 (rootMargin: '200px', no-op when loadingMore || !hasMore)
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const node = sentinelRef.current;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasMore && !loadingMore && !loading) {
void loadMore();
}
},
{ rootMargin: '200px' },
);
observer.observe(node);
return () => observer.disconnect();
}, [hasMore, loadingMore, loading, loadMore]);
// ──── Render ────
return (
<div className="px-4 py-4 space-y-4">
{/* Page H1 — D-35 (in body, not in shell HeaderBar) */}
<h1 className="text-base font-semibold">Analyzer</h1>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => <AnalyzerRowSkeleton key={i} />)}
</div>
) : analyses.length === 0 ? (
// Empty state — D-31
<div className="py-12">
<div className="flex flex-col items-center justify-center text-center gap-3 rounded-md border border-dashed border-border/60 px-6 py-10">
<span className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Sparkles className="h-5 w-5" />
</span>
<p className="text-sm font-semibold text-foreground">No analyses yet</p>
<p className="text-sm text-muted-foreground max-w-prose">
Completed AI ticket analyses will appear here.
</p>
<a
href="/analyzer/tickets"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-semibold text-primary hover:underline mt-2 min-h-[44px]"
>
Open desktop Analyzer
<ExternalLink className="h-4 w-4" />
</a>
</div>
</div>
) : (
<>
<div className="space-y-3">
{analyses.map((row) => (
<AnalyzerFeedRow key={row.id} row={row} />
))}
</div>
{/* Sentinel — D-08 */}
<div ref={sentinelRef} aria-hidden="true" />
{/* Loading-more spinner — D-29 */}
{loadingMore && (
<div className="flex justify-center py-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" aria-hidden="true" />
</div>
)}
{/* Load more fallback button — D-09, ANL accessibility */}
{hasMore && (
<button
type="button"
onClick={() => void loadMore()}
disabled={loadingMore}
aria-label="Load more analyses"
className="w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50 min-h-[44px]"
>
{error ? 'Retry' : loadingMore ? 'Loading…' : 'Load more'}
</button>
)}
</>
)}
</div>
);
}
Copy contract — these strings are LOCKED in 06-UI-SPEC §"Copywriting Contract":
- Page H1: exactly
Analyzer(D-35) - Empty state heading: exactly
No analyses yet(D-31) - Empty state body: exactly
Completed AI ticket analyses will appear here.(D-31) - Empty state CTA visible label: exactly
Open desktop Analyzerlinking to/analyzer/tickets(D-31) - Load more button (idle): exactly
Load more - Load more button (loading): exactly
Loading…(with ellipsis character, not three dots) - Load more button (error): exactly
Retry - Error toast (initial load): exactly
Failed to load analyses - Error toast (load more): exactly
Failed to load more analyses
Container per D-34: Outer <div className="px-4 py-4 space-y-4"> matches the page-level rhythm; rows in <div className="space-y-3"> per UI-SPEC §"Feed Row Card" row list container.
Read-only (ANL-05, D-23): This page renders NO Buttons that suggest re-running, editing, or prompt-tuning. Only the Load more fallback button (which is a pagination control, not an analysis action) and the empty-state link to desktop. NO <Button onClick={runAnalysis}> patterns, NO triple-dot menus, NO Edit icons.
Why NOT use the shadcn EmptyState primitive directly? Per CONTEXT.md D-31 "Reuse components/ui/empty-state.tsx if its props fit; otherwise mirror its shape inline." The EmptyState action prop only accepts {label, href|onClick} — but per UI-SPEC the CTA must include an ExternalLink icon and target="_blank". The inline mirror in this implementation honors both the EmptyState visual (dashed border, icon-in-rounded-square, headline + description + button) AND the ExternalLink icon convention from Phase 2 DRAWER-04 / Phase 6 D-22. If executor finds a way to pass target="_blank" + icon through the existing EmptyState component cleanly, that's allowed; otherwise inline as shown.
npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/analyzer/page\.tsx" || echo "TypeScript clean for analyzer page"
<acceptance_criteria>
- File exists: test -f app/mobile/analyzer/page.tsx
- Placeholder gone: grep -F 'Analyzer feed coming soon' app/mobile/analyzer/page.tsx returns ZERO matches (the old placeholder copy is replaced)
- File starts with 'use client';: head -1 app/mobile/analyzer/page.tsx | grep -F "'use client'" returns one match
- Imports the feed row component: grep -F "from '@/components/mobile/AnalyzerFeedRow'" app/mobile/analyzer/page.tsx returns at least one match
- Imports the type from route: grep -E "import type.*AnalyzerFeedResponse.*from .@/app/api/mobile/analyzer/feed/route." app/mobile/analyzer/page.tsx returns at least one match
- Fetches the endpoint: grep -F '/api/mobile/analyzer/feed' app/mobile/analyzer/page.tsx returns at least 2 matches (loadFirst + loadMore)
- IntersectionObserver wired: grep -F 'IntersectionObserver' app/mobile/analyzer/page.tsx returns at least one match AND grep -E "rootMargin: '200px'" app/mobile/analyzer/page.tsx returns at least one match
- Sentinel ref in JSX: grep -F 'ref={sentinelRef}' app/mobile/analyzer/page.tsx returns at least one match
- Page H1 copy exact: grep -E '>Analyzer<' app/mobile/analyzer/page.tsx returns at least one match
- H1 has correct typography: grep -F 'text-base font-semibold' app/mobile/analyzer/page.tsx returns at least one match
- Container spacing per D-34: grep -F 'px-4 py-4 space-y-4' app/mobile/analyzer/page.tsx returns at least one match
- Row list spacing per D-34: grep -F 'space-y-3' app/mobile/analyzer/page.tsx returns at least one match
- Initial 5 skeletons rendered: grep -E 'Array\\.from\\(\\{ length: 5 \\}\\)' app/mobile/analyzer/page.tsx returns at least one match AND grep -F 'AnalyzerRowSkeleton' app/mobile/analyzer/page.tsx returns at least one match
- Empty state copy: grep -F 'No analyses yet' app/mobile/analyzer/page.tsx returns at least one match AND grep -F 'Completed AI ticket analyses will appear here.' app/mobile/analyzer/page.tsx returns at least one match
- Empty state desktop CTA link: grep -F '/analyzer/tickets' app/mobile/analyzer/page.tsx returns at least one match AND grep -F 'Open desktop Analyzer' app/mobile/analyzer/page.tsx returns at least one match
- Load more button copy + states: grep -F 'Load more' app/mobile/analyzer/page.tsx returns at least one match AND grep -F 'Loading…' app/mobile/analyzer/page.tsx returns at least one match AND grep -F 'Retry' app/mobile/analyzer/page.tsx returns at least one match
- Load more aria-label per UI-SPEC: grep -F 'aria-label="Load more analyses"' app/mobile/analyzer/page.tsx returns at least one match
- Load more touch target: grep -F 'min-h-[44px]' app/mobile/analyzer/page.tsx returns at least one match (page CTA + load more)
- toast.error wired: grep -F 'toast.error' app/mobile/analyzer/page.tsx returns at least 2 matches AND error copy exact: grep -F "'Failed to load analyses'" app/mobile/analyzer/page.tsx returns at least one match AND grep -F "'Failed to load more analyses'" app/mobile/analyzer/page.tsx returns at least one match
- NO Zustand/SWR/react-query imports (D-38): grep -E "from\\s+['\\\"](zustand|swr|@tanstack/react-query)['\\\"]" app/mobile/analyzer/page.tsx returns ZERO matches
- NO read-write actions (ANL-05): grep -E '\\bonClick=.*\\b(reRun|edit|delete|cancel|retry)Analysis\\b' app/mobile/analyzer/page.tsx returns ZERO matches
- npx tsc --noEmit --pretty exits 0
- Manual smoke (when servers running): curl -s http://localhost:3100/mobile/analyzer | grep -F 'Analyzer' returns the rendered shell (or unauthenticated redirect — expected)
</acceptance_criteria>
/mobile/analyzer shows 5 skeleton rows on initial load, then real data from the feed endpoint as Card rows, scrolls to load more pages, falls back to "Load more" button for accessibility, shows the empty state when zero rows exist, and emits a toast on error with a "Retry" affordance. No editing/re-run/prompt-tuning controls anywhere on the page (ANL-05).
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Browser → mobile page | DOM rendering of analyzer summary text and titles — text content can be arbitrary user/system input from analyzer pipeline |
Mobile page → API (/api/mobile/analyzer/feed) |
Cursor + limit query params (cursor is opaque-to-client — server-encoded) |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-06P02-01 | Information Disclosure (XSS via summary/title) | AnalyzerFeedRow |
mitigate | React JSX text interpolation auto-escapes — {row.summary ?? '—'} and {row.title} are inserted as text nodes, not HTML. No dangerouslySetInnerHTML anywhere in this plan. ASVS L1 §V5.3.3. |
| T-06P02-02 | Tampering (client modifies cursor) | loadMore() |
accept | Cursor is round-tripped from server → client → server. The server's decodeCursor (Plan 06-01) treats malformed input as "no cursor" → returns first page; valid-but-tampered cursor (e.g., older completed_at) just shows different rows the user could already access. Worst case: information disclosure within the user's already-authorized scope (kiosk_settings still applies). |
| T-06P02-03 | Spoofing (no auth) | app/mobile/analyzer/page.tsx |
mitigate | Page is under /mobile/* which is auth-gated by middleware.ts (Better Auth session cookie check). /api/mobile/analyzer/feed ALSO calls requireAuth() server-side (Plan 06-01) — defense in depth: the page can't even render data without a session because the fetch returns 401. |
| T-06P02-04 | Information Disclosure (toast leaks server error message) | loadFirst/loadMore catch |
mitigate | Toast copy is HARDCODED to "Failed to load analyses" / "Failed to load more analyses" (not the raw e.message). Internal setError(msg) stores the technical message but it's only used to flip the button label to "Retry"; never displayed to the user. ASVS L1 §V7.4.1. |
| T-06P02-05 | Denial of Service (runaway IntersectionObserver) | sentinel useEffect | mitigate | The observer callback no-ops when `loadingMore |
| T-06P02-06 | Repudiation (no audit trail of feed reads) | feed page | accept | Read-only mobile feed; no compliance requirement to audit per-user feed reads. Better Auth session activity is logged at the auth layer. Same posture as Phase 4 Tickets. |
| </threat_model> |
<success_criteria>
/mobile/analyzerno longer shows the "coming soon" placeholder- Initial load shows 5 skeleton cards (per D-28) before transitioning to real data
- Each row visually presents: ticket# badge (mono, bg-muted), time-ago (right), title (1-line truncate, font-semibold), summary 2-line clamp, footer with stage pips (left) + confidence badge + optional Review pill (right)
- ConfidenceBadge thresholds work: score >= 0.85 shows green "High", 0.65-0.85 shows amber "Medium", < 0.65 shows slate "Low", null shows nothing
- AnalyzerStagePips: 3 dots with caret separators between them, filled when corresponding
_usedflag is true - Tapping a row navigates to
/mobile/analyzer/[id](link href is correct; destination page lives in Plan 06-03) - IntersectionObserver triggers
loadMorewhen sentinel enters viewport withrootMargin: '200px' - Load more button is always rendered when
hasMore=true, focusable, witharia-label="Load more analyses" - Empty state renders dashed-border card with "No analyses yet" + body + "Open desktop Analyzer" link to
/analyzer/tickets - Error scenario triggers
toast.errorand flips Load more button to "Retry" - Page contains NO edit/re-run/prompt-tuning controls (ANL-05)
npx tsc --noEmit --prettypasses </success_criteria>