28 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 06-analyzer-feed-new | 03 | execute | 3 |
|
|
true |
|
|
Purpose: ANL-03 + ANL-04 + ANL-05. A manager taps a row in the feed and lands here in a single tap; the page must feel CALM (UI-SPEC §"specifics" — "calm and quick to read … not a wall of text") with three labelled sections separated by clear vertical space.
Output:
- One new file:
app/mobile/analyzer/[id]/page.tsx - Reuses the components built in Plan 06-02 (
AnalyzerStagePips,ConfidenceBadge) — does NOT duplicate them.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/REQUIREMENTS.md @.planning/phases/06-analyzer-feed-new/06-CONTEXT.md @.planning/phases/06-analyzer-feed-new/06-UI-SPEC.md @CLAUDE.md @app/api/analyzer/analyses/[id]/route.ts @lib/types/analyzer.ts @app/mobile/tickets/[id]/page.tsx @components/ui/separator.tsx @components/ui/skeleton.tsxFrom @/lib/types/analyzer (existing — D-27, do NOT redefine):
export type PersistedAnalysis = z.infer<typeof PersistedAnalysis>;
// Fields used by this page (camelCase from API response):
// id: string
// ticketNumber: string
// autotaskTicketId: number
// analysisVersion: number
// status: 'pending' | 'running' | 'complete' | 'failed'
// completedAt: string | null
// haikuUsed: boolean
// sonnetUsed: boolean
// opusUsed: boolean
// summary: string | null
// nextStep: string | null
// nextStepRationale: string | null
// confidenceScore: number | null
// needsHumanReview: boolean
// (Many more fields exist — IT Glue refs, gaps, timeline — none are rendered on this mobile detail page per ANL-03 / D-23.)
The endpoint GET /api/analyzer/analyses/[id] returns { analysis: PersistedAnalysis } (note: wrapped in analysis key per app/api/analyzer/analyses/[id]/route.ts line 24). The handler uses await params per Next.js 16 async params convention.
NOTE on ticket title and company name: PersistedAnalysis does NOT include title or companyName directly — those live on the tickets and companies tables. The desktop /analyzer/analysis/[id] page joins them in a separate query (verify: read app/analyzer/analysis/[id]/page.tsx to see how desktop sources title). For the mobile detail page, we have two options:
(a) Reuse the existing /api/analyzer/analyses/[id] endpoint as-is (returns ONLY the analysis row — no title/companyName) — ticket title in the breadcrumb shows ticket NUMBER only ("Analyzer / #T20250034"), and the identity block shows analysis.ticketNumber + analysis-only fields. The title/companyName are nice-to-have but the spec ANL-03 only requires Summary/Next Step/Rationale + ANL-04 only requires the desktop link.
(b) Add title/companyName to the existing endpoint's response (touches a non-Phase-6 file).
Per D-25 ("Detail page reuses existing GET /api/analyzer/analyses/[id]") and D-36 ("Existing desktop analyzer routes are unchanged"), this plan uses option (a). The breadcrumb is Analyzer / #{ticketNumber} (D-19 — already specified this way) and the identity block shows ticket number prominently with completed-at; the page does NOT display ticket title or company on mobile. UI-SPEC §"Detail Page Identity Block" lists title/company name in the visual contract but the source data is unavailable from the existing endpoint — executor MUST resolve this conflict by REMOVING the title/company lines from the rendered identity block (single source of truth: existing endpoint per D-25/D-36, NOT changing the desktop endpoint). The breadcrumb already conveys "which ticket".
If executor disagrees and wants to extend the existing endpoint instead, that's a CHECKPOINT decision — DO NOT modify /api/analyzer/analyses/[id]/route.ts without surfacing the choice to the user, because D-36 prohibits desktop changes without approval.
Final identity block fields the executor renders (revised from UI-SPEC, conformant with D-25/D-27/D-36):
- ticket number (mono badge)
- completed-at relative time
- stage pips
- confidence badge
- Review pill (when needsHumanReview)
Title and companyName lines are skipped — the breadcrumb conveys ticket identity.
Task 1: Build the mobile analyzer detail page app/mobile/analyzer/[id]/page.tsx - .planning/phases/06-analyzer-feed-new/06-CONTEXT.md (D-18, D-19, D-20, D-21, D-22, D-23, D-25, D-27, D-36) - .planning/phases/06-analyzer-feed-new/06-UI-SPEC.md §"Detail Page In-Page Header", §"Detail Page Identity Block", §"Detail Page Content Sections", §"Detail Page Footer Link", §"Copywriting Contract" - app/api/analyzer/analyses/[id]/route.ts (the endpoint shape — wraps result in `{ analysis }`) - lib/types/analyzer.ts (PersistedAnalysis schema — fields available) - app/mobile/tickets/[id]/page.tsx (PATTERN — back chevron + breadcrumb header from Phase 4 D-18; same shape) - components/ui/separator.tsx (Separator primitive between sections) - components/ui/skeleton.tsx (Skeleton for loading state) Create the new file `app/mobile/analyzer/[id]/page.tsx`. It's a `'use client'` page that takes the `id` from the URL segment, fetches `/api/analyzer/analyses/[id]`, and renders the calm 3-section summary layout.Next.js 16 async params: the page receives params: Promise<{ id: string }> per current convention. Unwrap with React.use(params) (Client component) or pre-resolve at the data fetch step.
Implementation:
'use client';
import { useEffect, useState, use } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, ExternalLink, Loader2 } 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 [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
setError(null);
try {
const r = await fetch(`/api/analyzer/analyses/${encodeURIComponent(id)}`);
if (r.status === 404) {
if (!cancelled) {
setError('Analysis not found');
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"
>
<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}` : ''}
</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"
>
<ExternalLink className="h-4 w-4" aria-hidden="true" />
</a>
</div>
);
// ──── Loading skeleton (D-21 / 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>
);
}
// ──── Error state (404 or fetch failure) ────
if (error || !analysis) {
return (
<div>
{header}
<div className="px-4 py-12 text-center space-y-3">
<p className="text-sm text-muted-foreground">{error ?? 'Analysis not found'}</p>
</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">
{analysis.completedAt ? 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="inline-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>
);
}
Locked copy (06-UI-SPEC §"Copywriting Contract") — exact strings:
- Back button visible label:
Analyzer(with ArrowLeft icon) - Back button aria-label:
Back to Analyzer - Breadcrumb:
Analyzer / #{ticketNumber}(template literal) - Header right link aria-label:
Open full analysis on desktop - Section headings:
Summary,Next Step,Next Step Rationale - Null fallbacks:
Summary not available.,Next step not available.,Rationale not available.(all with trailing period) - Footer link visible label:
View full analysis(NOT "View on desktop", NOT "Open analysis") - Review pill copy:
Review - Error toast:
Failed to load analysis
Read-only enforcement (ANL-05, D-23): This page renders ZERO Buttons that suggest actions. The only interactive elements are: (1) back button → router.back(), (2) header external link → desktop, (3) footer external link → desktop. NO Re-run, NO Cancel, NO Edit, NO Share button, NO triple-dot menu. If executor adds one, the plan fails ANL-05.
Why no title/company in identity block (per interfaces note): UI-SPEC §"Detail Page Identity Block" lists title and company name. CONTEXT.md D-25 mandates reuse of /api/analyzer/analyses/[id] which returns ONLY PersistedAnalysis (no joined ticket title). D-36 prohibits modifying the desktop endpoint. Conflict resolution: omit title/company from identity block — the breadcrumb (Analyzer / #T20250034) plus the prominent ticket# badge in the identity block convey ticket identity. Manager who needs full context taps "View full analysis" → desktop.
npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/analyzer/\[id\]/page\.tsx" || echo "TypeScript clean for detail page"
<acceptance_criteria>
- File exists: test -f 'app/mobile/analyzer/[id]/page.tsx'
- Starts with 'use client';: head -1 'app/mobile/analyzer/[id]/page.tsx' | grep -F "'use client'" returns one match
- Default export present: grep -E '^export default function MobileAnalyzerDetailPage' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Imports PersistedAnalysis type from existing module: grep -E "import type.*PersistedAnalysis.*from .@/lib/types/analyzer." 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Imports stage pips component: grep -E "from .@/components/mobile/AnalyzerStagePips." 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Imports confidence badge component: grep -E "from .@/components/mobile/ConfidenceBadge." 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Async params unwrap (Next.js 16): grep -F 'use(params)' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Fetches existing endpoint (D-25): grep -F '/api/analyzer/analyses/' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Endpoint URL uses encoded id: grep -F 'encodeURIComponent(id)' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Reads data.analysis from response wrapper: grep -F 'data.analysis' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Back button uses router.back: grep -F 'router.back()' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Back button aria-label exact: grep -F 'aria-label="Back to Analyzer"' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Header external link aria-label exact: grep -F 'aria-label="Open full analysis on desktop"' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Breadcrumb format: grep -E "Analyzer / #" 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Three section headings exact: grep -E '>Summary<' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match AND grep -E '>Next Step<' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match AND grep -E '>Next Step Rationale<' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Section heading typography: grep -F 'text-sm font-semibold' 'app/mobile/analyzer/[id]/page.tsx' returns at least 3 matches (one per heading)
- whitespace-pre-wrap on body: grep -F 'whitespace-pre-wrap' 'app/mobile/analyzer/[id]/page.tsx' returns at least 3 matches
- Body typography exact (D-21): grep -F 'text-sm font-normal leading-relaxed' 'app/mobile/analyzer/[id]/page.tsx' returns at least 3 matches
- Null fallbacks exact: grep -F 'Summary not available.' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match AND grep -F 'Next step not available.' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match AND grep -F 'Rationale not available.' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Footer link copy exact: grep -F 'View full analysis' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Footer link target=_blank: grep -F 'target="_blank"' 'app/mobile/analyzer/[id]/page.tsx' returns at least 2 matches (header + footer)
- Footer link rel attr: grep -F 'rel="noopener noreferrer"' 'app/mobile/analyzer/[id]/page.tsx' returns at least 2 matches
- Footer link points to desktop route: grep -F '/analyzer/analysis/' 'app/mobile/analyzer/[id]/page.tsx' returns at least 2 matches
- Footer link touch target: grep -F 'min-h-[44px]' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Separator used between sections: grep -F 'Separator' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match (import + at least one render)
- Toast on error: grep -F "toast.error('Failed to load analysis')" 'app/mobile/analyzer/[id]/page.tsx' returns at least one match
- Loading skeleton renders before data: grep -F 'Skeleton' 'app/mobile/analyzer/[id]/page.tsx' returns at least one match (Skeleton import + JSX)
- NO read-write actions (ANL-05): grep -E '\\bonClick=.*\\b(reRun|edit|delete|cancel|share|retry)Analysis\\b' 'app/mobile/analyzer/[id]/page.tsx' returns ZERO matches
- NO modal/dialog imports (D-23 — page is real route, not modal): grep -E "from\\s+['\\\"]@/components/ui/dialog['\\\"]" 'app/mobile/analyzer/[id]/page.tsx' returns ZERO matches
- Does NOT modify desktop routes (D-36): git status --porcelain app/api/analyzer/ app/analyzer/ 2>/dev/null | wc -l returns 0 after this task
- npx tsc --noEmit --pretty exits 0
- Manual smoke (when dev server running): visit http://localhost:3100/mobile/analyzer/<some-uuid> in a logged-in browser → see breadcrumb, identity block, three sections, footer link. Tapping back chevron returns to feed.
</acceptance_criteria>
/mobile/analyzer/[id] is a real shareable page that fetches the existing analyses endpoint, renders calm Summary / Next Step / Rationale sections with section headings and whitespace-pre-wrap body text, identity block with stage pips and confidence badge, header back-chevron + external-link, footer "View full analysis" link to desktop. Read-only — no controls beyond navigation. npx tsc --noEmit --pretty passes.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Browser → mobile detail page | The id URL segment is user-controllable (anyone can edit the URL bar) |
Mobile detail page → API (/api/analyzer/analyses/[id]) |
The id is forwarded to the existing detail endpoint without modification |
| API → response payload | The existing endpoint returns the FULL PersistedAnalysis row (including IT Glue refs, model_traces if present) — but the mobile page only RENDERS Summary, Next Step, Rationale, and stage flags. Other fields are received but not displayed. |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-06P03-01 | Spoofing / Auth Bypass | mobile detail page | mitigate | Page is under /mobile/* — middleware.ts requires Better Auth session. The fetched API endpoint /api/analyzer/analyses/[id] ALSO calls requireAuth() server-side (app/api/analyzer/analyses/[id]/route.ts:16). Defense in depth. |
| T-06P03-02 | Information Disclosure (IDOR) | GET /api/analyzer/analyses/:id |
flag for review | The existing endpoint authenticates the user but does NOT scope by kiosk_settings company filter. A logged-in user could enumerate UUIDs of analyses for tickets in companies outside their kiosk scope. This is an EXISTING risk in the desktop product — Phase 6 inherits it without making it worse. Recommendation: post-Phase-6, file a follow-up ticket to add kiosk_settings scoping to app/api/analyzer/analyses/[id]/route.ts (or specifically to mobile callers). NOT in Phase 6 scope per D-36 (no desktop changes). The risk is mitigated for the typical user (guessing 36-character UUIDs is computationally infeasible) but the IDOR posture is weaker than Plan 06-01's feed endpoint. Disposition is accept-and-flag (track in STATE.md as a follow-up); upgrade to mitigate if user prioritizes. |
| T-06P03-03 | Information Disclosure (XSS via summary/next_step text) | section body renders | mitigate | All three section bodies render via JSX text interpolation ({analysis.summary}) inside <p> tags — React auto-escapes. whitespace-pre-wrap is a CSS property and does NOT enable HTML parsing. NO dangerouslySetInnerHTML used anywhere. ASVS L1 §V5.3.3. |
| T-06P03-04 | Tampering (id segment manipulation) | URL segment | mitigate | The id is URL-encoded with encodeURIComponent(id) before being passed to the fetch URL — prevents path-traversal style attacks. Server-side, the existing endpoint is parameter-bound (WHERE id = $1); arbitrary input becomes an empty result, not SQL injection. |
| T-06P03-05 | Information Disclosure (404 leaks existence) | error state | accept | When an analysis doesn't exist OR is in a different scope, the endpoint returns 404. This is the existing desktop behavior. The mobile page renders "Analysis not found" — same UX as desktop. Negligible additional risk. |
| T-06P03-06 | Information Disclosure (toast leaks server error) | catch block | mitigate | Toast copy is hardcoded to Failed to load analysis — never shows raw e.message. ASVS L1 §V7.4.1. |
| T-06P03-07 | Read-only violation | page interactions | mitigate | The plan acceptance criteria includes a grep that fails if any `reRun |
| </threat_model> |
<success_criteria>
app/mobile/analyzer/[id]/page.tsxexists and exports a default Page component- Page fetches
GET /api/analyzer/analyses/[id](existing endpoint reused per D-25, no new endpoint) - Page renders three sections in order: Summary, Next Step, Next Step Rationale, each with
text-sm font-semiboldheading andtext-sm font-normal leading-relaxed whitespace-pre-wrapbody - Null fields render the locked fallback copy (
Summary not available.etc.) in muted color - Header has: back chevron (
ArrowLeft) + "Analyzer" label tied torouter.back(), breadcrumbAnalyzer / #{ticketNumber}, ExternalLink icon to/analyzer/analysis/[id](opens new tab) - Footer has: "View full analysis" link with ExternalLink icon →
/analyzer/analysis/[id](opens new tab,min-h-[44px]touch target) - Identity block renders ticket# badge, completed-at relative time, stage pips, confidence badge, optional Review pill
- The page is read-only — NO Edit/Re-run/Share/Delete/Cancel buttons (ANL-05)
- Loading state renders Skeletons for identity + 3 sections; error state renders "Analysis not found" message; toast fires on fetch failure
- NO modifications to desktop analyzer routes or services (D-36, D-37)
npx tsc --noEmit --prettypasses </success_criteria>