feat(06-02): add AnalyzerStagePips, ConfidenceBadge, AnalyzerRowSkeleton, AnalyzerFeedRow components

- AnalyzerStagePips: 3-dot stage indicator with caret separators, sr-only accessibility label
- ConfidenceBadge: High/Medium/Low buckets (0.85/0.65) with green/amber/slate tones, dark mode
- AnalyzerRowSkeleton: Card-wrapped skeleton matching row shape (no border-l-4 per D-11)
- AnalyzerFeedRow: Full card row with header/title/summary/footer, Link to /mobile/analyzer/[id]
This commit is contained in:
lorentz 2026-05-03 21:30:17 -04:00
parent fe4fedace0
commit 9c1a74009b
4 changed files with 183 additions and 0 deletions

View file

@ -0,0 +1,79 @@
'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>
);
}

View file

@ -0,0 +1,29 @@
'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>
);
}

View file

@ -0,0 +1,36 @@
'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>
);
}

View file

@ -0,0 +1,39 @@
'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>
);
}