feat(06-03): add mobile analyzer detail page /mobile/analyzer/[id]

- Real segment route reading GET /api/analyzer/analyses/[id] (D-25, reused as-is)
- Three content sections: Summary / Next Step / Next Step Rationale (D-21, ANL-03)
- Identity block: ticket# badge, completed-at relative time, stage pips, confidence badge, Review pill
- Header: back chevron (router.back()) + breadcrumb 'Analyzer / #{ticketNumber}' + external link (D-19)
- Footer: 'View full analysis' link to /analyzer/analysis/[id] with ExternalLink icon, min-h-[44px] (D-22, ANL-04)
- Read-only enforcement: zero form/edit/re-run/cancel controls (D-23, ANL-05)
- Loading skeleton, 404 state, error state with toast (D-28)
- Title/company omitted from identity block per D-25/D-36 (PersistedAnalysis lacks those fields)
This commit is contained in:
lorentz 2026-05-03 21:36:49 -04:00
parent 86369bd6ab
commit aa4ff00065

View file

@ -0,0 +1,253 @@
'use client';
/* MobileAnalyzerDetailPage phase 06 (ANL-03, ANL-04, ANL-05).
* Purpose: Per-analysis summary view at /mobile/analyzer/[id].
* Reads from GET /api/analyzer/analyses/[id] (D-25, reused as-is).
* Renders Summary / Next Step / Next Step Rationale (D-21).
* Provides "View full analysis" footer link to desktop (D-22).
* Read-only no edit, re-run, cancel, or prompt-tuning controls (D-23, ANL-05).
* Note: Title and company name are NOT rendered PersistedAnalysis does not include
* those fields (they live on the tickets/companies tables). D-25/D-36 forbid
* modifying the existing endpoint. Breadcrumb conveys ticket identity (D-19). */
import { useEffect, useState, use } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, ExternalLink } from 'lucide-react';
import { toast } from 'sonner';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { AnalyzerStagePips } from '@/components/mobile/AnalyzerStagePips';
import { ConfidenceBadge } from '@/components/mobile/ConfidenceBadge';
import type { PersistedAnalysis } from '@/lib/types/analyzer';
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`;
}
interface DetailPageProps {
params: Promise<{ id: string }>;
}
export default function MobileAnalyzerDetailPage({ params }: DetailPageProps) {
const { id } = use(params);
const router = useRouter();
const [analysis, setAnalysis] = useState<PersistedAnalysis | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
setNotFound(false);
setError(null);
try {
const r = await fetch(`/api/analyzer/analyses/${encodeURIComponent(id)}`);
if (r.status === 404) {
if (!cancelled) {
setNotFound(true);
setAnalysis(null);
}
return;
}
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
if (!cancelled) setAnalysis(data.analysis as PersistedAnalysis);
} catch (e) {
if (!cancelled) {
const msg = e instanceof Error ? e.message : 'Failed to load analysis';
setError(msg);
toast.error('Failed to load analysis');
}
} finally {
if (!cancelled) setLoading(false);
}
};
void load();
return () => { cancelled = true; };
}, [id]);
// ──── In-page header (D-19) — back chevron + breadcrumb + external link ────
const header = (
<div className="flex items-center justify-between px-4 py-3 border-b">
<button
type="button"
onClick={() => router.back()}
aria-label="Back to Analyzer"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
<span>Analyzer</span>
</button>
<span className="text-sm font-semibold truncate max-w-[55%] text-center">
{analysis ? `Analyzer / #${analysis.ticketNumber}` : 'Analyzer'}
</span>
<a
href={`/analyzer/analysis/${id}`}
target="_blank"
rel="noopener noreferrer"
aria-label="Open full analysis on desktop"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-4 w-4" aria-hidden="true" />
</a>
</div>
);
// ──── Loading skeleton (D-28 / UI-SPEC "Detail page loading") ────
if (loading) {
return (
<div>
{header}
<div className="px-4 pt-4 pb-2 space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-3 w-32" />
<div className="flex gap-2 mt-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-12" />
</div>
</div>
{[0, 1, 2].map((i) => (
<section key={i} className="px-4 py-4 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
<Skeleton className="h-3 w-4/6" />
</section>
))}
</div>
);
}
// ──── 404 state ────
if (notFound) {
return (
<div>
{header}
<div className="px-4 py-12 text-center space-y-3">
<p className="text-sm text-muted-foreground">Analysis not found</p>
<a href="/mobile/analyzer" className="text-sm text-primary hover:underline">
Back to feed
</a>
</div>
</div>
);
}
// ──── Error state ────
if (error || !analysis) {
return (
<div>
{header}
<div className="px-4 py-12 text-center space-y-3">
<p className="text-sm text-destructive">{error ?? 'Failed to load analysis'}</p>
<button
type="button"
onClick={() => { setError(null); setLoading(true); }}
className="text-sm text-primary hover:underline"
>
Retry
</button>
</div>
</div>
);
}
// ──── Loaded — full render ────
return (
<div>
{header}
{/* Identity block (D-20 — adjusted: no title/company per interfaces note) */}
<div className="px-4 pt-4 pb-2 space-y-1">
<span className="text-[10px] font-mono bg-muted rounded px-1.5 py-0.5 inline-block">
{analysis.ticketNumber}
</span>
<p className="text-[10px] text-muted-foreground">
{relTime(analysis.completedAt)}
</p>
<div className="flex gap-2 items-center mt-1">
<AnalyzerStagePips
haikuUsed={analysis.haikuUsed}
sonnetUsed={analysis.sonnetUsed}
opusUsed={analysis.opusUsed}
/>
<ConfidenceBadge score={analysis.confidenceScore} />
{analysis.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>
<Separator />
{/* Section 1 — Summary (D-21) */}
<section className="px-4 py-4 space-y-2">
<h2 className="text-sm font-semibold">Summary</h2>
{analysis.summary ? (
<p className="text-sm font-normal leading-relaxed text-foreground whitespace-pre-wrap">
{analysis.summary}
</p>
) : (
<p className="text-sm text-muted-foreground">Summary not available.</p>
)}
</section>
<Separator />
{/* Section 2 — Next Step (D-21) */}
<section className="px-4 py-4 space-y-2">
<h2 className="text-sm font-semibold">Next Step</h2>
{analysis.nextStep ? (
<p className="text-sm font-normal leading-relaxed text-foreground whitespace-pre-wrap">
{analysis.nextStep}
</p>
) : (
<p className="text-sm text-muted-foreground">Next step not available.</p>
)}
</section>
<Separator />
{/* Section 3 — Next Step Rationale (D-21) */}
<section className="px-4 py-4 space-y-2">
<h2 className="text-sm font-semibold">Next Step Rationale</h2>
{analysis.nextStepRationale ? (
<p className="text-sm font-normal leading-relaxed text-foreground whitespace-pre-wrap">
{analysis.nextStepRationale}
</p>
) : (
<p className="text-sm text-muted-foreground">Rationale not available.</p>
)}
</section>
{/* Footer link (D-22) — "View full analysis" → desktop */}
<div className="px-4 py-4 border-t">
<a
href={`/analyzer/analysis/${id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm font-semibold text-primary hover:underline min-h-[44px]"
>
View full analysis
<ExternalLink className="h-4 w-4" aria-hidden="true" />
</a>
</div>
</div>
);
}