wulf-pulse/.planning/phases/06-analyzer-feed-new/06-03-PLAN.md

28 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
06-analyzer-feed-new 03 execute 3
06-02
app/mobile/analyzer/[id]/page.tsx
true
ANL-03
ANL-04
ANL-05
truths artifacts key_links
Tapping an analyzer feed row opens /mobile/analyzer/[id] which renders Summary, Next Step, and Next Step Rationale (ANL-03)
Detail page includes a 'View full analysis' link out to the desktop analyzer at /analyzer/analysis/[id] (ANL-04)
The page is read-only — NO edit, re-run, prompt-tuning, share, or any action buttons (ANL-05)
Tapping the back chevron returns to the feed via router.back() at the same scroll position
URL is a real shareable Next.js segment route (not a modal)
Identity block shows ticket#, title, company name, completed-at relative time, stage pips, confidence badge, optional Review pill
Each section has a clear heading; null fields render the locked fallback copy
path provides min_lines
app/mobile/analyzer/[id]/page.tsx Mobile analyzer detail page rendering Summary / Next Step / Rationale 150
from to via pattern
app/mobile/analyzer/[id]/page.tsx /api/analyzer/analyses/[id] fetch in useEffect on mount fetch.*api/analyzer/analyses/
from to via pattern
app/mobile/analyzer/[id]/page.tsx /analyzer/analysis/[id] (desktop) external link with target=_blank + ExternalLink icon href=.*analyzer/analysis/
from to via pattern
app/mobile/analyzer/[id]/page.tsx components/mobile/AnalyzerStagePips, ConfidenceBadge import + render in identity block AnalyzerStagePips.*ConfidenceBadge
Build the mobile per-analysis summary view at `/mobile/analyzer/[id]/page.tsx`. It's a real Next.js page (segment route — shareable URL per D-18), not a modal. It reads from the EXISTING `GET /api/analyzer/analyses/[id]` endpoint (D-25, no new endpoint), renders the three content sections (Summary / Next Step / Next Step Rationale per D-21 / ANL-03), and provides a "View full analysis" external link to the desktop at `/analyzer/analysis/[id]` (D-22 / ANL-04).

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.tsx

From @/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>
- `npx tsc --noEmit --pretty` exits 0 - `app/mobile/analyzer/[id]/page.tsx` is the only file modified by this plan - No diff in `app/api/analyzer/`, `app/analyzer/`, `lib/types/`, `lib/services/analyzer/` (per D-36, D-37): `git status --porcelain | grep -E '^(M|A) (app/api/analyzer|app/analyzer|lib/services/analyzer)'` returns nothing - Manual smoke (developer): authenticate on `http://localhost:3100`, visit `/mobile/analyzer/` → 1. Detail page renders breadcrumb "Analyzer / #T...", back chevron, and external-link icon in header 2. Identity block shows ticket# badge + completed-at relative time + stage pips + confidence badge 3. Three sections (Summary, Next Step, Next Step Rationale) — each with heading + body OR locked fallback copy 4. Footer has "View full analysis" link with ExternalLink icon → opens `/analyzer/analysis/` in new tab - Tap back chevron: returns to `/mobile/analyzer` at the same scroll position (browser history) - Visit `/mobile/analyzer/not-a-real-uuid`: see "Analysis not found" message + header (404 path) - Confirm BottomNav: Analyzer tab is active (text-primary) on the detail page (Phase 2 startsWith match)

<success_criteria>

  1. app/mobile/analyzer/[id]/page.tsx exists and exports a default Page component
  2. Page fetches GET /api/analyzer/analyses/[id] (existing endpoint reused per D-25, no new endpoint)
  3. Page renders three sections in order: Summary, Next Step, Next Step Rationale, each with text-sm font-semibold heading and text-sm font-normal leading-relaxed whitespace-pre-wrap body
  4. Null fields render the locked fallback copy (Summary not available. etc.) in muted color
  5. Header has: back chevron (ArrowLeft) + "Analyzer" label tied to router.back(), breadcrumb Analyzer / #{ticketNumber}, ExternalLink icon to /analyzer/analysis/[id] (opens new tab)
  6. Footer has: "View full analysis" link with ExternalLink icon → /analyzer/analysis/[id] (opens new tab, min-h-[44px] touch target)
  7. Identity block renders ticket# badge, completed-at relative time, stage pips, confidence badge, optional Review pill
  8. The page is read-only — NO Edit/Re-run/Share/Delete/Cancel buttons (ANL-05)
  9. Loading state renders Skeletons for identity + 3 sections; error state renders "Analysis not found" message; toast fires on fetch failure
  10. NO modifications to desktop analyzer routes or services (D-36, D-37)
  11. npx tsc --noEmit --pretty passes </success_criteria>
After completion, create `.planning/phases/06-analyzer-feed-new/06-03-SUMMARY.md` documenting: - The page structure (header, identity, 3 sections, footer) - The decision to omit title/company from identity block (per interfaces note — D-25/D-36 conflict with UI-SPEC; resolved by following the locked CONTEXT decisions) - The IDOR follow-up (T-06P03-02 in threat model — flag for STATE.md) - Confirmation that no desktop files were touched