wulf-pulse/app/mobile/analyzer/page.tsx
lorentz c8aa69baf6 feat(06-02): replace analyzer placeholder with real feed list page
- Replaces 'coming soon' placeholder with full read-only feed
- useState/useEffect/fetch only (no SWR/react-query per CLAUDE.md D-38)
- IntersectionObserver sentinel with rootMargin 200px for auto-load
- Load more fallback button with aria-label, min-h-[44px] touch target
- 5 AnalyzerRowSkeleton instances on initial load (D-28)
- Empty state with dashed border, Sparkles icon, Open desktop Analyzer link
- toast.error on load failures; Load more flips to Retry on error
- No edit/re-run/prompt-tuning controls (ANL-05)
2026-05-03 21:31:21 -04:00

151 lines
5.7 KiB
TypeScript

'use client';
/* MobileAnalyzerPage — phase 06 (ANL-01, ANL-02, ANL-05, ANL-06).
* Purpose: Read-only Analyzer feed — most-recent-first list of completed AI ticket analyses.
* IntersectionObserver infinite scroll + Load more fallback button.
* No edit, re-run, or prompt-tuning controls (ANL-05). Per D-08..D-09, D-28..D-31, D-34..D-35, D-38. */
import { useEffect, useState, useCallback, useRef } from 'react';
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>
);
}