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:
parent
fe4fedace0
commit
9c1a74009b
4 changed files with 183 additions and 0 deletions
79
components/mobile/AnalyzerFeedRow.tsx
Normal file
79
components/mobile/AnalyzerFeedRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
components/mobile/AnalyzerRowSkeleton.tsx
Normal file
29
components/mobile/AnalyzerRowSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
components/mobile/AnalyzerStagePips.tsx
Normal file
36
components/mobile/AnalyzerStagePips.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
components/mobile/ConfidenceBadge.tsx
Normal file
39
components/mobile/ConfidenceBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue