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)
This commit is contained in:
parent
9c1a74009b
commit
c8aa69baf6
1 changed files with 143 additions and 25 deletions
|
|
@ -1,33 +1,151 @@
|
|||
/* Placeholder for /mobile/analyzer.
|
||||
*
|
||||
* Phase 02 only adds the Analyzer tab to the bottom nav — the real feed
|
||||
* lands in Phase 6 (`docs/superpowers/specs/2026-05-03-mobile-shell-design.md`
|
||||
* §6.4). This file exists so tapping the Analyzer tab resolves to a real
|
||||
* route instead of 404. Phase 6 will replace this file with the actual
|
||||
* read-only feed page.
|
||||
*
|
||||
* DO NOT add features, data fetching, or UI beyond the "Coming soon"
|
||||
* card here — Phase 6 owns the real implementation. */
|
||||
'use client';
|
||||
|
||||
import { Sparkles } from 'lucide-react';
|
||||
/* 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. */
|
||||
|
||||
export const metadata = {
|
||||
title: 'Analyzer · Pulse',
|
||||
};
|
||||
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 MobileAnalyzerPlaceholder() {
|
||||
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="p-4">
|
||||
<div className="rounded-2xl border bg-card p-6 flex flex-col items-center text-center gap-3">
|
||||
<div className="h-12 w-12 rounded-2xl bg-primary/10 text-primary flex items-center justify-center">
|
||||
<Sparkles className="h-6 w-6" />
|
||||
<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>
|
||||
<h1 className="text-lg font-semibold">Analyzer feed coming soon</h1>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
The mobile Analyzer feed is on its way. Until then, view full
|
||||
analyses on the desktop Analyzer.
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue