From c8aa69baf61370f37831fd26ff70bbc7ee75e054 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 21:31:21 -0400 Subject: [PATCH] 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) --- app/mobile/analyzer/page.tsx | 168 +++++++++++++++++++++++++++++------ 1 file changed, 143 insertions(+), 25 deletions(-) diff --git a/app/mobile/analyzer/page.tsx b/app/mobile/analyzer/page.tsx index 2777b9b..e5adff6 100644 --- a/app/mobile/analyzer/page.tsx +++ b/app/mobile/analyzer/page.tsx @@ -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([]); + const [nextCursor, setNextCursor] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(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(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 ( -
-
-
- +
+ {/* Page H1 — D-35 (in body, not in shell HeaderBar) */} +

Analyzer

+ + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => )}
-

Analyzer feed coming soon

-

- The mobile Analyzer feed is on its way. Until then, view full - analyses on the desktop Analyzer. -

-
+ ) : analyses.length === 0 ? ( + // Empty state — D-31 +
+
+ + + +

No analyses yet

+

+ Completed AI ticket analyses will appear here. +

+ + Open desktop Analyzer + + +
+
+ ) : ( + <> +
+ {analyses.map((row) => ( + + ))} +
+ + {/* Sentinel — D-08 */} + ); }