docs(06): create phase plans (3 plans, 3 waves)

This commit is contained in:
lorentz 2026-05-03 21:22:01 -04:00
parent 9119846ea7
commit 010aadcc81
5 changed files with 1641 additions and 11 deletions

View file

@ -114,7 +114,10 @@ Decimal phases appear between their surrounding integers in numeric order.
3. Tapping a row opens a mobile summary view rendering Summary, Next Step, and Next Step Rationale, with a "View full analysis" link out to the desktop analyzer page
4. The mobile feed never exposes editing, re-run, or prompt-tuning controls (read-only by design)
5. The list reads from `analyzer_analyses` via `/api/mobile/analyzer/feed` (or a reused list endpoint that already returns the right shape)
**Plans**: TBD
**Plans**: 3 plans
- [ ] 06-01-PLAN.md — /api/mobile/analyzer/feed endpoint with cursor pagination + kiosk_settings scoping (ANL-01, ANL-02, ANL-06)
- [ ] 06-02-PLAN.md — AnalyzerFeedRow/StagePips/ConfidenceBadge/RowSkeleton components + replace /mobile/analyzer placeholder with feed list page (ANL-01, ANL-02, ANL-05, ANL-06)
- [ ] 06-03-PLAN.md — /mobile/analyzer/[id] detail page reading existing /api/analyzer/analyses/[id] (ANL-03, ANL-04, ANL-05)
**UI hint**: yes
### Phase 7: Engagement Overview (NEW)
@ -153,7 +156,7 @@ Phases execute in numeric order. Phase 2 unblocks Phases 37 (any order, paral
| 3. Dashboard Restyle | 0/2 | Not started | - |
| 4. Tickets Restyle | 0/3 | Not started | - |
| 5. Finance Restyle | 2/2 | Complete | 2026-05-03 |
| 6. Analyzer Feed | 0/TBD | Not started | - |
| 6. Analyzer Feed | 0/3 | Not started | - |
| 7. Engagement Overview | 0/TBD | Not started | - |
| 8. Engagement User Profile | 0/TBD | Not started | - |

View file

@ -3,15 +3,15 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: executing
stopped_at: Phase 6 context gathered (auto mode)
last_updated: "2026-05-04T01:01:07.942Z"
last_activity: 2026-05-04
stopped_at: Phase 6 UI-SPEC approved
last_updated: "2026-05-04T01:21:56.684Z"
last_activity: 2026-05-04 -- Phase 6 planning complete
progress:
total_phases: 8
completed_phases: 5
total_plans: 11
total_plans: 14
completed_plans: 11
percent: 100
percent: 79
---
# Project State
@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-05-03)
Phase: 6
Plan: Not started
Status: Ready to execute
Last activity: 2026-05-04
Last activity: 2026-05-04 -- Phase 6 planning complete
Progress: [░░░░░░░░░░] 0%
@ -86,6 +86,6 @@ None yet.
## Session Continuity
Last session: 2026-05-04T01:01:07.939Z
Stopped at: Phase 6 context gathered (auto mode)
Resume file: .planning/phases/06-analyzer-feed-new/06-CONTEXT.md
Last session: 2026-05-04T01:07:11.125Z
Stopped at: Phase 6 UI-SPEC approved
Resume file: .planning/phases/06-analyzer-feed-new/06-UI-SPEC.md

View file

@ -0,0 +1,458 @@
---
phase: 06-analyzer-feed-new
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- app/api/mobile/analyzer/feed/route.ts
autonomous: true
requirements: [ANL-01, ANL-02, ANL-06]
must_haves:
truths:
- "GET /api/mobile/analyzer/feed returns the latest completed analyzer_analyses rows ordered by completed_at DESC, id DESC"
- "Response shape is {analyses: AnalyzerFeedRow[], nextCursor: string | null, hasMore: boolean}"
- "Each row contains the columns AnalyzerFeedRow consumers (Plan 06-02) need: id, ticketNumber, title, companyName, summary, confidenceScore, haikuUsed, sonnetUsed, opusUsed, needsHumanReview, completedAt, analysisVersion"
- "Pagination is cursor-based with server-capped limit ≤ 25 (D-05)"
- "Out-of-scope companies are filtered out by kiosk_settings scoping (D-04)"
- "Latest analysis per ticket only — re-analyzed tickets do not appear multiple times (D-02)"
- "Unauthenticated requests return 401 via requireAuth() (security)"
artifacts:
- path: "app/api/mobile/analyzer/feed/route.ts"
provides: "GET handler + exported AnalyzerFeedRow + AnalyzerFeedResponse types"
exports: ["GET", "AnalyzerFeedRow", "AnalyzerFeedResponse"]
min_lines: 120
key_links:
- from: "app/api/mobile/analyzer/feed/route.ts"
to: "analyzer_analyses, tickets, companies tables"
via: "postgresClient.query() with parameterized SQL"
pattern: "FROM analyzer_analyses.*INNER JOIN tickets.*INNER JOIN companies"
- from: "app/api/mobile/analyzer/feed/route.ts"
to: "kiosk_settings"
via: "getMobileCompanyFilter() helper duplicated inline"
pattern: "kiosk_settings"
- from: "app/api/mobile/analyzer/feed/route.ts"
to: "lib/auth-utils.ts"
via: "requireAuth() session gate"
pattern: "requireAuth"
---
<objective>
Build `GET /api/mobile/analyzer/feed` — the new mobile-only endpoint that returns the most-recent-first stream of completed AI ticket analyses (latest analysis per ticket) with cursor pagination and `kiosk_settings` company scoping. Export `AnalyzerFeedRow` and `AnalyzerFeedResponse` types from the route file so the Wave 2 feed page can `import type` them.
Purpose: This is the data spine for Phase 6. Plan 06-02 (feed page UI) cannot consume real data without it. The endpoint must mirror the patterns from `app/api/mobile/tickets/route.ts` exactly so the manager's mental model from Tickets carries over with zero learning cost — same envelope shape (`{...List, nextCursor, hasMore}`), same cursor encoding (base64 JSON), same `kiosk_settings` scoping helper, same camelCase response transform.
Output:
- `app/api/mobile/analyzer/feed/route.ts` — GET handler, requireAuth-gated, cursor-paginated, scoped, with exported TS interfaces.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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/mobile/tickets/route.ts
@app/api/analyzer/tickets/route.ts
@migrations/069_create_analyzer_tables.sql
<interfaces>
<!-- Key types and patterns the executor needs. Embedded so executor doesn't have to re-derive. -->
From `app/api/mobile/tickets/route.ts` (pattern source — the new feed route mirrors this exactly):
```typescript
// Exported response interfaces (mirror this shape)
export interface MobileTicket { /* fields */ }
export interface MobileTicketListResponse {
tickets: MobileTicket[];
nextCursor: string | null;
hasMore: boolean;
}
// Cursor helpers (inline in the route file — D-06)
interface CursorPayload { last_activity_date: string; id: number; }
function encodeCursor(p: CursorPayload): string { return Buffer.from(JSON.stringify(p),'utf8').toString('base64'); }
function decodeCursor(raw: string | null): CursorPayload | null {
if (!raw) return null;
try {
const parsed = JSON.parse(Buffer.from(raw,'base64').toString('utf8'));
if (typeof parsed?.last_activity_date === 'string' && typeof parsed?.id === 'number') return parsed as CursorPayload;
return null;
} catch { return null; }
}
// Auth + scoping pattern
const { session, error: authError } = await requireAuth();
if (authError) return authError;
const { condition: companyCondition } = await getMobileCompanyFilter();
// companyCondition is "c.company_category_id IN (...) AND c.id NOT IN (...)" (or a fallback)
// LIMIT n+1 trick to detect hasMore without a COUNT query
LIMIT ${limit + 1}
const hasMore = rows.length > limit;
const sliced = hasMore ? rows.slice(0, limit) : rows;
```
From `app/api/analyzer/tickets/route.ts` (latest-version-per-ticket pattern reference, lines 319328):
```sql
LEFT JOIN LATERAL (
SELECT aa.id, aa.triggered_at, aa.completed_at,
aa.needs_human_review, aa.confidence_score,
aa.aggregate_fingerprint, aa.analysis_version
FROM analyzer_analyses aa
WHERE aa.ticket_number = f.ticket_number
AND aa.status = 'complete'
ORDER BY aa.analysis_version DESC
LIMIT 1
) latest ON TRUE
```
NOTE: this endpoint joins tickets→latest analysis. Phase 6's feed reverses the direction — it joins **analyses→tickets** (one row per latest-completed analysis) so the same physical ticket re-analyzed N times appears once. The `LEFT JOIN LATERAL ... ORDER BY analysis_version DESC LIMIT 1` idiom is the same; only the FROM table changes.
From `migrations/069_create_analyzer_tables.sql``analyzer_analyses` columns this plan reads:
```
id UUID PRIMARY KEY
ticket_number TEXT NOT NULL
autotask_ticket_id BIGINT NOT NULL
analysis_version INT NOT NULL
status TEXT (must equal 'complete')
completed_at TIMESTAMPTZ (nullable on pending; non-null when complete)
summary TEXT (nullable)
confidence_score NUMERIC(3,2) (nullable)
needs_human_review BOOLEAN NOT NULL
haiku_used BOOLEAN NOT NULL
sonnet_used BOOLEAN NOT NULL
opus_used BOOLEAN NOT NULL
```
UNIQUE constraint exists on (ticket_number, analysis_version), and an index `idx_analyzer_analyses_ticket_version` on (ticket_number, analysis_version DESC) — the LATERAL select is index-supported.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create route file shell with exported types and auth gate</name>
<files>app/api/mobile/analyzer/feed/route.ts</files>
<read_first>
- .planning/phases/06-analyzer-feed-new/06-CONTEXT.md (D-04, D-05, D-06, D-07, D-24, D-26, D-39 — locked decisions)
- .planning/phases/06-analyzer-feed-new/06-UI-SPEC.md (§"API Shape Contract" — exact field list and types for AnalyzerFeedRow + AnalyzerFeedResponse)
- app/api/mobile/tickets/route.ts (PATTERN SOURCE — copy structure: imports, getMobileCompanyFilter helper, exported interfaces, encodeCursor/decodeCursor, requireAuth flow, NextResponse.json with `satisfies`, error catch shape)
- CLAUDE.md (no Zod in API routes; use NextResponse.json; auth via requireAuth())
</read_first>
<action>
Create the new file `app/api/mobile/analyzer/feed/route.ts`. This task scaffolds the file with everything EXCEPT the SQL query and result transform (Task 2 fills those in).
1. Add the file header imports:
```typescript
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
```
2. Duplicate the `getMobileCompanyFilter()` helper from `app/api/mobile/tickets/route.ts` lines 729 verbatim (D-04 says "duplicate inline; keep this phase's diff small"). The helper signature is `async function getMobileCompanyFilter(): Promise<{ join: string; condition: string }>`. Do NOT import it — duplicate inline. Add a `// ─── Company filter helper (duplicated from /api/mobile/tickets/route.ts per D-04) ───` comment.
3. Export the response types EXACTLY as specified in 06-UI-SPEC.md §"API Shape Contract" (D-26):
```typescript
// ─── Exported response interfaces (D-26) ─────────────────────────────────────
export interface AnalyzerFeedRow {
id: string; // analyzer_analyses UUID
ticketNumber: string;
title: string;
companyName: string;
summary: string | null;
confidenceScore: number | null;
haikuUsed: boolean;
sonnetUsed: boolean;
opusUsed: boolean;
needsHumanReview: boolean;
completedAt: string; // ISO string
analysisVersion: number;
}
export interface AnalyzerFeedResponse {
analyses: AnalyzerFeedRow[];
nextCursor: string | null;
hasMore: boolean;
}
```
4. Add cursor encode/decode helpers inline (D-06). The cursor payload shape is `{ completed_at: ISO string, id: uuid string }` — DIFFERENT from tickets route (which uses `{ last_activity_date, id: number }`). Use base64 of JSON:
```typescript
// ─── Cursor encode/decode (inline per D-06) ──────────────────────────────────
interface CursorPayload { completed_at: string; id: string; }
function encodeCursor(p: CursorPayload): string {
return Buffer.from(JSON.stringify(p), 'utf8').toString('base64');
}
function decodeCursor(raw: string | null): CursorPayload | null {
if (!raw) return null;
try {
const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
if (typeof parsed?.completed_at === 'string' && typeof parsed?.id === 'string') {
return parsed as CursorPayload;
}
return null;
} catch { return null; }
}
```
The `try/catch` around JSON.parse is the cursor-injection mitigation: malformed input returns null (treated as "no cursor → first page"), never throws.
5. Add the GET handler skeleton (Task 2 fills in the SQL):
```typescript
// ─── GET handler ─────────────────────────────────────────────────────────────
export async function GET(request: NextRequest): Promise<NextResponse> {
const { error: authError } = await requireAuth();
if (authError) return authError;
try {
const { searchParams } = request.nextUrl;
const cursorParam = searchParams.get('cursor');
// Server-side limit cap — D-05 (page size 25, cap at 25)
const limit = Math.min(25, Math.max(1, parseInt(searchParams.get('limit') ?? '25')));
const cursor = decodeCursor(cursorParam);
// TODO Task 2: build SQL, execute, transform, return.
return NextResponse.json({ analyses: [], nextCursor: null, hasMore: false } satisfies AnalyzerFeedResponse);
} catch (error) {
console.error('GET /api/mobile/analyzer/feed failed:', error);
return NextResponse.json(
{ error: 'Failed to fetch analyses', message: error instanceof Error ? error.message : 'unknown' },
{ status: 500 },
);
}
}
```
6. NO Zod (D-39, CLAUDE.md). NO ORM (CLAUDE.md). NO new state libraries (D-38). NO request validation library — direct `searchParams.get()` reads.
After this task the file compiles, returns an empty list, and the types are exported for Task 2 (and Plan 06-02) to consume.
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/mobile/analyzer/feed" || echo "TypeScript clean for new route file"</automated>
</verify>
<acceptance_criteria>
- File exists: `test -f app/api/mobile/analyzer/feed/route.ts`
- Exports the correct types: `grep -E '^export interface AnalyzerFeedRow' app/api/mobile/analyzer/feed/route.ts` returns one match
- Exports the response envelope type: `grep -E '^export interface AnalyzerFeedResponse' app/api/mobile/analyzer/feed/route.ts` returns one match
- Exports GET: `grep -E '^export async function GET' app/api/mobile/analyzer/feed/route.ts` returns one match
- Auth gate present: `grep -F 'requireAuth()' app/api/mobile/analyzer/feed/route.ts` returns at least one match
- Cursor payload shape matches D-06: `grep -F 'completed_at: string' app/api/mobile/analyzer/feed/route.ts` returns at least one match (NOT `last_activity_date`)
- Cursor cap enforced: `grep -E 'Math\.min\(25,' app/api/mobile/analyzer/feed/route.ts` returns at least one match
- getMobileCompanyFilter helper duplicated inline: `grep -F 'kiosk_settings' app/api/mobile/analyzer/feed/route.ts` returns at least one match
- No Zod imports: `grep -E "from\s+['\"]zod['\"]" app/api/mobile/analyzer/feed/route.ts` returns zero matches
- All response field names match camelCase per UI-SPEC: `grep -E '\\b(ticketNumber|companyName|confidenceScore|haikuUsed|sonnetUsed|opusUsed|needsHumanReview|completedAt|analysisVersion):' app/api/mobile/analyzer/feed/route.ts | wc -l` returns at least 9
- `npx tsc --noEmit --pretty` exits 0 (no type errors introduced)
</acceptance_criteria>
<done>
File compiles, exports the two interfaces and the GET handler, returns an empty envelope on every call. Wave 2 plans can `import type { AnalyzerFeedRow, AnalyzerFeedResponse } from '@/app/api/mobile/analyzer/feed/route'`.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: Implement cursor-paginated query, joins, transform, and security scoping</name>
<files>app/api/mobile/analyzer/feed/route.ts</files>
<read_first>
- app/api/mobile/analyzer/feed/route.ts (current state from Task 1)
- .planning/phases/06-analyzer-feed-new/06-CONTEXT.md (D-01 status='complete', D-02 latest version per ticket, D-03 ordering, D-04 scoping, D-05 cap, D-07 envelope)
- app/api/mobile/tickets/route.ts (cursor seek predicate pattern, LIMIT n+1 trick, snake_case→camelCase mapping)
- app/api/analyzer/tickets/route.ts (lines 319-328 — LEFT JOIN LATERAL pattern for latest version per ticket)
- migrations/069_create_analyzer_tables.sql (lines 11-65 — column types and indexes; `idx_analyzer_analyses_ticket_version` on (ticket_number, analysis_version DESC) supports the LATERAL)
</read_first>
<behavior>
- Test: cursor=null + no rows → returns `{analyses: [], nextCursor: null, hasMore: false}`
- Test: more than 25 latest-completed analyses exist → returns 25 rows + non-null nextCursor + hasMore=true
- Test: passing the returned nextCursor → returns the next 25 (older) rows + correct hasMore
- Test: malformed cursor (random string) → returns first page (decodeCursor returns null, no exception)
- Test: a ticket re-analyzed 3 times → appears once in the feed (the highest analysis_version among status='complete' rows)
- Test: kiosk_settings excludes a company → analyses for tickets in that company do NOT appear
- Test: requireAuth fails → 401 (existing behavior from Task 1 gate)
- Test: rows with completed_at IS NULL appear AT THE END (NULLS LAST), tied rows broken by id DESC
</behavior>
<action>
Replace the `// TODO Task 2` block in `app/api/mobile/analyzer/feed/route.ts` with the complete query implementation. This task does NOT add any new exports or change the file structure — only fills in the GET handler body.
1. **Apply the kiosk_settings scope.** Just before building the SQL, call:
```typescript
const { condition: companyCondition } = await getMobileCompanyFilter();
```
The helper returns `condition` like `c.company_category_id IN (1) AND c.id NOT IN (42, 99)` (or `c.company_category_id = 1` fallback). The helper aliases the companies table as `c` — your SQL must alias `companies` as `c` to match (D-04). This is the security boundary for ANL-01: a manager must not see analyses for tickets in companies outside their kiosk scope.
2. **Build the predicate list.** Mirror the `conditions: string[]` + `params: unknown[]` pattern from `app/api/mobile/tickets/route.ts` lines 125166:
```typescript
const conditions: string[] = [
"aa.status = 'complete'", // D-01
't.is_deleted = false', // hide soft-deleted tickets
companyCondition, // D-04 (kiosk_settings scoping)
];
const params: unknown[] = [];
```
3. **Cursor seek predicate (D-03, D-06).** When `cursor` is non-null, append the keyset predicate `(completed_at, id) < (cursor.completed_at, cursor.id)`:
```typescript
if (cursor) {
params.push(cursor.completed_at);
params.push(cursor.id);
conditions.push(`(aa.completed_at, aa.id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);
}
```
The cast `$N::uuid` is critical because `analyzer_analyses.id` is UUID (not int like tickets.id).
4. **The query — analyses-first, with LATERAL latest-per-ticket guard (D-02, D-03).** The shape: from `analyzer_analyses` rows, only include the row if it IS the latest `analysis_version` for that `ticket_number` among `status='complete'` rows. This naturally produces "one row per ticket, latest first":
```sql
WITH latest_per_ticket AS (
SELECT DISTINCT ON (ticket_number) id
FROM analyzer_analyses
WHERE status = 'complete'
ORDER BY ticket_number, analysis_version DESC
)
SELECT aa.id, aa.ticket_number, aa.completed_at, aa.analysis_version,
aa.summary, aa.confidence_score, aa.needs_human_review,
aa.haiku_used, aa.sonnet_used, aa.opus_used,
t.title,
c.company_name
FROM analyzer_analyses aa
INNER JOIN latest_per_ticket l ON l.id = aa.id
INNER JOIN tickets t ON t.ticket_number = aa.ticket_number AND t.is_deleted = false
INNER JOIN companies c ON c.id = t.company_id
WHERE ${conditions.filter excluding the t.is_deleted and aa.status which are now in the CTE/inner join — keep only companyCondition + cursor predicate}
ORDER BY aa.completed_at DESC NULLS LAST, aa.id DESC
LIMIT ${limit + 1}
```
Practical implementation: keep `companyCondition` and the cursor predicate in the WHERE; absorb `aa.status='complete'` into the CTE and `t.is_deleted=false` into the JOIN. Final WHERE has 12 predicates. Use `LIMIT ${limit + 1}` so you can detect `hasMore` without a COUNT (mirrors tickets route line 183).
Build the final SQL string by interpolating `${conditions.join(' AND ')}` and `${limit + 1}`. Pass `params` to `postgresClient.query(sql, params)`.
5. **Transform rows snake_case → camelCase** (CLAUDE.md: manual transform, no ORM). Map each pg row to `AnalyzerFeedRow`:
```typescript
const rows = result.rows;
const hasMore = rows.length > limit;
const sliced = hasMore ? rows.slice(0, limit) : rows;
const analyses: AnalyzerFeedRow[] = sliced.map(row => ({
id: String(row.id),
ticketNumber: row.ticket_number,
title: row.title ?? '',
companyName: row.company_name ?? '',
summary: row.summary ?? null,
confidenceScore: row.confidence_score === null ? null : Number(row.confidence_score),
haikuUsed: row.haiku_used,
sonnetUsed: row.sonnet_used,
opusUsed: row.opus_used,
needsHumanReview: row.needs_human_review,
completedAt: row.completed_at instanceof Date ? row.completed_at.toISOString() : String(row.completed_at),
analysisVersion: row.analysis_version,
}));
```
`confidence_score` comes back from pg as a string (NUMERIC type) → coerce with `Number()`. `completed_at` is a `Date` from pg → `.toISOString()`. (Compare to `app/api/analyzer/tickets/route.ts:362` for the same `.toISOString()` pattern.)
6. **Compute nextCursor from the LAST row** of `sliced` (D-06):
```typescript
const nextCursor = hasMore && analyses.length > 0
? encodeCursor({
completed_at: analyses[analyses.length - 1].completedAt,
id: analyses[analyses.length - 1].id,
})
: null;
```
Note the cursor's `completed_at` is the ISO string already in `analyses[N].completedAt` — consistent with the WHERE predicate's `::timestamptz` cast.
7. **Return** with `satisfies AnalyzerFeedResponse`:
```typescript
return NextResponse.json({ analyses, nextCursor, hasMore } satisfies AnalyzerFeedResponse);
```
8. **Security review (per `<security_threat_model>`):**
- cursor injection → mitigated by `decodeCursor` try/catch + shape validation (returns null on malformed input)
- kiosk scoping → mitigated by `getMobileCompanyFilter()` companyCondition (T-06-02 in threat model)
- payload leakage → only the columns the row card needs are returned; NOT `model_traces`, NOT `human_review_reasons`, NOT `itglue_docs_referenced`, NOT IT Glue doc bodies (T-06-05)
- rate limiting → server-side `Math.min(25, ...)` cap (T-06-04)
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | (! grep -E "app/api/mobile/analyzer/feed")</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit --pretty` exits 0 (no errors)
- SQL contains the latest-per-ticket CTE: `grep -E 'DISTINCT ON \(ticket_number\)' app/api/mobile/analyzer/feed/route.ts` returns at least one match
- SQL filters status complete only: `grep -E "status\s*=\s*'complete'" app/api/mobile/analyzer/feed/route.ts` returns at least one match
- Ordering matches D-03: `grep -F "ORDER BY aa.completed_at DESC NULLS LAST, aa.id DESC" app/api/mobile/analyzer/feed/route.ts` returns at least one match
- Cursor seek predicate uses correct types: `grep -E '\\(aa\\.completed_at, aa\\.id\\) < \\(\\$' app/api/mobile/analyzer/feed/route.ts` returns at least one match
- kiosk scoping wired: `grep -F 'getMobileCompanyFilter()' app/api/mobile/analyzer/feed/route.ts` returns at least one match
- LIMIT n+1 trick used: `grep -E 'LIMIT \\$\\{limit \\+ 1\\}' app/api/mobile/analyzer/feed/route.ts` returns at least one match (or equivalent pattern)
- hasMore detection: `grep -E 'rows\\.length > limit' app/api/mobile/analyzer/feed/route.ts` returns at least one match
- camelCase transform present: `grep -E 'ticketNumber: row\\.ticket_number' app/api/mobile/analyzer/feed/route.ts` returns at least one match
- Companies table aliased as `c`: `grep -E 'INNER JOIN companies c\\b' app/api/mobile/analyzer/feed/route.ts` returns at least one match (matches helper's expectation)
- Tickets joined: `grep -E 'INNER JOIN tickets t\\b' app/api/mobile/analyzer/feed/route.ts` returns at least one match
- Sensitive columns NOT selected: `grep -E "(model_traces|itglue_docs_referenced|human_review_reasons)" app/api/mobile/analyzer/feed/route.ts` returns ZERO matches (security: payload minimization)
- Manual smoke test: `curl -s 'http://localhost:3100/api/mobile/analyzer/feed' -H "Cookie: <auth>"` returns JSON with `analyses` array (or 401 if not authed) — NOT a 500. (Optional; auth-gated, dev-only.)
</acceptance_criteria>
<done>
Endpoint returns the latest-completed analysis per ticket, ordered by `completed_at DESC, id DESC`, scoped by `kiosk_settings`, paginated with cursor (≤ 25/page). The response envelope matches `AnalyzerFeedResponse`. Plan 06-02 can fetch and render real data from this route.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → API (`/api/mobile/analyzer/feed`) | Untrusted query string (cursor, limit) crosses into server; session cookie verified |
| API → Postgres | Parameterized queries; no string interpolation of user input |
| API → response payload | Server controls which columns leave the trust boundary; potentially-sensitive analyzer fields must NOT cross |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-01 | Spoofing / Auth Bypass | `GET /api/mobile/analyzer/feed` | mitigate | Call `requireAuth()` from `lib/auth-utils.ts` BEFORE any DB query (Task 1, line `const { error: authError } = await requireAuth(); if (authError) return authError;`). Better Auth session cookie is the gate; no bypass path. ASVS L1 §V2.1. |
| T-06-02 | Information Disclosure (IDOR / cross-tenant read) | feed route SQL | mitigate | Apply `getMobileCompanyFilter()` `companyCondition` to the WHERE clause (Task 2). Without it a manager could list analyses for tickets in companies outside their kiosk scope. The helper reads `kiosk_settings.mobile_company_category_ids` and `mobile_excluded_company_ids` and emits a SQL fragment scoped to `c.*`. The companies table alias `c` MUST be used in JOIN to match the helper. |
| T-06-03 | Tampering (cursor injection) | `decodeCursor()` | mitigate | Wrap `JSON.parse(Buffer.from(raw,'base64').toString('utf8'))` in try/catch. Shape-validate decoded object: only return non-null when `completed_at` is a string AND `id` is a string. Any malformed input returns `null` → handler treats as "first page". Failure mode is fail-closed (no SQL injection vector — params are still parameterized; worst case is a cursor that doesn't match any row, returning empty). |
| T-06-04 | Denial of Service (large pagination) | feed route limit param | mitigate | Server-side `Math.min(25, Math.max(1, ...))` cap on `limit` query param (Task 1). Even if a client sends `?limit=10000`, the server reads at most 26 rows (`limit + 1` for hasMore detection). LIMIT in SQL is integer-interpolated AFTER the cap. |
| T-06-05 | Information Disclosure (sensitive analyzer payload leakage) | feed route SELECT list | mitigate | Whitelist columns in the SELECT — only the 12 fields `AnalyzerFeedRow` declares. Do NOT select `model_traces`, `itglue_docs_referenced`, `human_review_reasons`, or `error_message`. These can contain client data, IT Glue references, and IT Glue doc bodies (per migration 069 comments). The detail page (Plan 06-03) reuses the existing `/api/analyzer/analyses/[id]` endpoint which is already auth-gated, but its IDOR posture is OUT OF SCOPE for this plan and is flagged in Plan 06-03's threat model. |
| T-06-06 | Repudiation | feed route logging | accept | Auth gate logs are produced by Better Auth middleware; per-request access audit logging is NOT implemented for `/api/mobile/*` today (Phase 4 didn't add it either). Risk is low: read-only endpoint, no state change. Future phase can add structured access logs if compliance requires. |
| T-06-07 | Information Disclosure (SQL error messages) | error catch block | mitigate | The catch block returns `error instanceof Error ? error.message : 'unknown'`. Postgres error messages can include schema details. For a read-only endpoint with parameterized SQL the leakage surface is small (no user-controlled SQL fragments); the team's existing `/api/mobile/tickets` route uses the same pattern, so this matches established convention. Error is also logged to `console.error` for server-side observability. |
</threat_model>
<verification>
- `npx tsc --noEmit --pretty` exits 0
- `grep -RE "from\s+['\"]@/app/api/mobile/analyzer/feed/route['\"]" app/ components/ 2>/dev/null` returns nothing yet (Wave 2 will create the consumer)
- Manual smoke (developer): `curl -s 'http://localhost:3100/api/mobile/analyzer/feed' -H "Cookie: better-auth.session_token=<dev token>"` returns `{analyses: [...], nextCursor, hasMore}` JSON
- Pagination smoke: capture `nextCursor` from response 1, pass as `?cursor=<value>`, verify response 2 returns OLDER rows (or empty if total < 25)
</verification>
<success_criteria>
1. `app/api/mobile/analyzer/feed/route.ts` exists and exports `GET`, `AnalyzerFeedRow`, `AnalyzerFeedResponse`
2. The endpoint returns ONE row per ticket (latest analysis_version among status='complete' rows) — NOT multiple rows for re-analyzed tickets
3. Ordering is `completed_at DESC NULLS LAST, id DESC`
4. Out-of-scope companies (per `kiosk_settings`) are excluded
5. Cursor pagination works: passing the returned `nextCursor` returns the next page; null `nextCursor` means exhausted
6. Server-side limit cap of 25 is enforced regardless of `?limit=` value
7. Unauthenticated requests return 401 (via `requireAuth()`)
8. Response payload contains ONLY the 12 fields declared by `AnalyzerFeedRow` (no `model_traces`, no IT Glue bodies, no `human_review_reasons` array)
9. `npx tsc --noEmit --pretty` passes
</success_criteria>
<output>
After completion, create `.planning/phases/06-analyzer-feed-new/06-01-SUMMARY.md` documenting:
- The exported types (with their final field list)
- The SQL approach (DISTINCT ON CTE + JOIN, ordering, cursor predicate)
- How `kiosk_settings` scoping is applied (companies aliased as `c`)
- Notes for Plan 06-02 executors: import path is `@/app/api/mobile/analyzer/feed/route`; sample request URL is `/api/mobile/analyzer/feed?limit=25`
</output>
</content>
</invoke>

View file

@ -0,0 +1,696 @@
---
phase: 06-analyzer-feed-new
plan: 02
type: execute
wave: 2
depends_on: [06-01]
files_modified:
- components/mobile/AnalyzerStagePips.tsx
- components/mobile/ConfidenceBadge.tsx
- components/mobile/AnalyzerRowSkeleton.tsx
- components/mobile/AnalyzerFeedRow.tsx
- app/mobile/analyzer/page.tsx
autonomous: true
requirements: [ANL-01, ANL-02, ANL-05, ANL-06]
must_haves:
truths:
- "Tapping the Analyzer tab in the bottom nav lands on /mobile/analyzer and shows a most-recent-first list of completed AI ticket analyses (ANL-01)"
- "Each row shows ticket number, title, analyzer one-line summary, confidence badge, and stage indicator (Triage/Analyze/Deep Review pips) (ANL-02)"
- "Tapping a row navigates to /mobile/analyzer/[id]"
- "Scrolling near the bottom auto-loads the next page (~25 rows) via IntersectionObserver"
- "A focusable Load more button is always present when hasMore is true (accessibility fallback)"
- "Initial load renders 5 skeleton rows; subsequent fetches show inline spinner above Load more button"
- "When the feed is empty, an empty state with 'No analyses yet' renders with link to desktop"
- "Read-only — NO edit, re-run, or prompt-tuning controls (ANL-05)"
artifacts:
- path: "components/mobile/AnalyzerStagePips.tsx"
provides: "3-dot stage indicator (haiku/sonnet/opus filled or muted)"
exports: ["AnalyzerStagePips"]
min_lines: 25
- path: "components/mobile/ConfidenceBadge.tsx"
provides: "Bucketed confidence label (High/Medium/Low) with color tones"
exports: ["ConfidenceBadge"]
min_lines: 25
- path: "components/mobile/AnalyzerRowSkeleton.tsx"
provides: "Skeleton placeholder matching row shape (no priority stripe)"
exports: ["AnalyzerRowSkeleton"]
min_lines: 15
- path: "components/mobile/AnalyzerFeedRow.tsx"
provides: "Card-wrapped row with header/title/summary/footer linked to detail page"
exports: ["AnalyzerFeedRow"]
min_lines: 50
- path: "app/mobile/analyzer/page.tsx"
provides: "Feed list page with IntersectionObserver, Load more, error/empty states"
min_lines: 120
key_links:
- from: "app/mobile/analyzer/page.tsx"
to: "/api/mobile/analyzer/feed"
via: "fetch in useEffect + Load more handler"
pattern: "fetch.*api/mobile/analyzer/feed"
- from: "app/mobile/analyzer/page.tsx"
to: "AnalyzerFeedRow component"
via: "import + map over analyses array"
pattern: "<AnalyzerFeedRow"
- from: "components/mobile/AnalyzerFeedRow.tsx"
to: "/mobile/analyzer/[id] route"
via: "Next.js Link href"
pattern: 'href=.*mobile/analyzer/'
- from: "components/mobile/AnalyzerFeedRow.tsx"
to: "AnalyzerStagePips, ConfidenceBadge"
via: "internal component composition"
pattern: "AnalyzerStagePips.*ConfidenceBadge"
---
<objective>
Replace the placeholder `app/mobile/analyzer/page.tsx` (Phase 2 stub) with the real read-only Analyzer feed: a most-recent-first list of completed AI ticket analyses with cursor-based infinite scroll, identical UX patterns to Phase 4 Tickets. Build the four supporting `components/mobile/*` components the row card depends on.
Purpose: ANL-01 + ANL-02 + ANL-05 + ANL-06 — this is the manager-facing surface. It must FEEL like Phase 4 (same skeleton-then-rows initial load, same IntersectionObserver, same Load more fallback) so there's zero learning curve when switching between Tickets and Analyzer tabs.
Output:
- 4 new components in `components/mobile/`: stage pips, confidence badge, row skeleton, feed row card
- Replaced page at `app/mobile/analyzer/page.tsx` (the placeholder is gone; the real feed is in)
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
@.planning/phases/06-analyzer-feed-new/06-01-SUMMARY.md
@CLAUDE.md
@app/mobile/tickets/page.tsx
@app/mobile/analyzer/page.tsx
@components/mobile/TicketRowSkeleton.tsx
@components/mobile/FinanceRow.tsx
@components/ui/card.tsx
@components/ui/badge.tsx
@components/ui/skeleton.tsx
@components/ui/empty-state.tsx
@app/api/mobile/analyzer/feed/route.ts
<interfaces>
<!-- Types this plan consumes from Plan 06-01 -->
From `@/app/api/mobile/analyzer/feed/route` (Plan 06-01 export):
```typescript
export interface AnalyzerFeedRow {
id: string;
ticketNumber: string;
title: string;
companyName: string;
summary: string | null;
confidenceScore: number | null;
haikuUsed: boolean;
sonnetUsed: boolean;
opusUsed: boolean;
needsHumanReview: boolean;
completedAt: string;
analysisVersion: number;
}
export interface AnalyzerFeedResponse {
analyses: AnalyzerFeedRow[];
nextCursor: string | null;
hasMore: boolean;
}
```
NOTE: The component file is also named `AnalyzerFeedRow.tsx`. The TypeScript interface and the React component share a name — disambiguate by importing the type with `import type` and the component with regular import. (Phase 4 does the exact same pattern: `MobileTicket` type vs `<TicketRow>` component.)
From `app/mobile/tickets/page.tsx` (PATTERN SOURCE — copy structure):
- `relTime(ts: string | null): string` helper at lines 22-30 (60s→`Xm ago`, hours→`Xh ago`, else `Xd ago`)
- `Suspense` wrapper around the inner client component (Next.js 16 useSearchParams requirement — but Analyzer feed has no URL params this phase, so Suspense may not be needed; verify during build)
- `useState`, `useEffect`, `useCallback`, `useRef`, `IntersectionObserver` setup at lines 188-203
- `loadFirst` and `loadMore` callback pattern at lines 121-174
- Load more fallback button at lines 292-304 (`w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50`)
- Inline loading spinner at lines 285-289 (`Loader2 w-4 h-4 animate-spin text-muted-foreground`)
From `components/mobile/TicketRowSkeleton.tsx` (PATTERN — adapt for analyzer row shape):
```tsx
'use client';
import { Skeleton } from '@/components/ui/skeleton';
export function TicketRowSkeleton() {
return (
<div className="border-l-4 border-muted px-4 py-4">
<Skeleton className="h-4 w-3/4" />
...
</div>
);
}
```
Per D-11 the analyzer row has NO `border-l-4` — drop the stripe in `AnalyzerRowSkeleton`.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Build the 4 presentational components in components/mobile/</name>
<files>components/mobile/AnalyzerStagePips.tsx, components/mobile/ConfidenceBadge.tsx, components/mobile/AnalyzerRowSkeleton.tsx, components/mobile/AnalyzerFeedRow.tsx</files>
<read_first>
- .planning/phases/06-analyzer-feed-new/06-CONTEXT.md (D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-17, D-32, D-33 — locked decisions)
- .planning/phases/06-analyzer-feed-new/06-UI-SPEC.md §"Component Inventory" (exact JSX shapes, classnames, copy strings)
- components/mobile/TicketRowSkeleton.tsx (skeleton pattern)
- components/mobile/FinanceRow.tsx (PascalCase export, props interface, presentational pattern)
- components/ui/card.tsx (Card + CardContent — Card wraps with `bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm` by default; override with px-4 py-4 and reset gap)
- components/ui/badge.tsx (Badge component with variant prop)
- app/api/mobile/analyzer/feed/route.ts (AnalyzerFeedRow type — `import type`)
</read_first>
<action>
Build four small presentational components in `components/mobile/`. All four are `'use client'` (per CLAUDE.md mobile pattern). All four use PascalCase exports from PascalCase filenames (matches existing convention in `components/mobile/`: TicketRowSkeleton, FinanceRow, KpiCardMobile).
**FILE A — `components/mobile/AnalyzerStagePips.tsx` (D-13, D-14)**
Three colored dots representing pipeline stages reached. Pure CSS, no animation, no library. The visually-hidden span describes stages used for screen readers. Exact JSX:
```tsx
'use client';
/* AnalyzerStagePips — phase 06 (ANL-02).
* Purpose: 3-dot stage indicator (Triage → Analyze → Deep Review) with caret separators.
* Filled when stage was used, muted when not. Per D-13/D-14 (no animation).
* Props: see AnalyzerStagePipsProps. */
export interface AnalyzerStagePipsProps {
haikuUsed: boolean;
sonnetUsed: boolean;
opusUsed: boolean;
}
const STAGE_LABELS = ['Triage', 'Analyze', 'Deep Review'];
export function AnalyzerStagePips({ haikuUsed, sonnetUsed, opusUsed }: AnalyzerStagePipsProps) {
const used = [haikuUsed, sonnetUsed, opusUsed];
const completed = STAGE_LABELS.filter((_, i) => used[i]);
const srLabel = completed.length === 0
? 'No stages completed'
: `Stages completed: ${completed.join(', ')}`;
return (
<div className="flex items-center gap-1.5" aria-hidden="false">
<span className="sr-only">{srLabel}</span>
{used.map((isUsed, i) => (
<span key={i} className="flex items-center gap-1.5">
<span
className={`h-1.5 w-1.5 rounded-full ${isUsed ? 'bg-primary' : 'bg-muted-foreground/30'}`}
aria-hidden="true"
/>
{i < 2 && <span className="text-[10px] text-muted-foreground" aria-hidden="true"></span>}
</span>
))}
</div>
);
}
```
Verify against UI-SPEC §"Stage Pips": dot size `h-1.5 w-1.5`, container gap `gap-1.5`, filled `bg-primary`, muted `bg-muted-foreground/30`, caret `` between pips with `text-[10px] text-muted-foreground`. NO tooltip, NO hover, NO animation (D-13).
**FILE B — `components/mobile/ConfidenceBadge.tsx` (D-15, D-16)**
shadcn Badge with bucketed background and text color. Renders `null` when score is null (D-15). Exact JSX:
```tsx
'use client';
/* ConfidenceBadge — phase 06 (ANL-02).
* Purpose: Bucketed confidence label — High (>=0.85) / Medium (>=0.65) / Low (<0.65).
* Renders nothing when score is null. Per D-15 / D-16.
* Props: see ConfidenceBadgeProps. */
import { Badge } from '@/components/ui/badge';
export interface ConfidenceBadgeProps {
score: number | null;
}
export function ConfidenceBadge({ score }: ConfidenceBadgeProps) {
if (score === null) return null;
let label: string;
let className: string;
if (score >= 0.85) {
label = 'High';
className = 'bg-green-500/10 text-green-700 dark:text-green-400';
} else if (score >= 0.65) {
label = 'Medium';
className = 'bg-amber-500/10 text-amber-700 dark:text-amber-400';
} else {
label = 'Low';
className = 'bg-slate-500/10 text-slate-600 dark:text-slate-400';
}
return (
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-0.5 border-0 ${className}`}
aria-label={`Confidence: ${label}`}
>
{label}
</Badge>
);
}
```
Verify thresholds against UI-SPEC §"Confidence Badge Colors" + D-15: `>= 0.85` High green, `0.65 <= < 0.85` Medium amber, `< 0.65` Low slate, `null` → no element. Tailwind classes are EXACTLY `bg-green-500/10 text-green-700 dark:text-green-400` etc. — no other shades. `border-0` removes the default outline border (D-16). `text-[10px] px-1.5 py-0.5` exact.
**FILE C — `components/mobile/AnalyzerRowSkeleton.tsx` (D-28)**
Mirror `TicketRowSkeleton` but DROP the `border-l-4 border-muted` stripe (per D-11 analyzer rows have no priority stripe). Use a Card wrapper to match the real row's surface.
```tsx
'use client';
/* AnalyzerRowSkeleton — phase 06 (D-28).
* Purpose: Skeleton placeholder matching analyzer feed row shape (no priority stripe per D-11).
* Renders 5 instances on initial load.
* Props: none — purely presentational. */
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function AnalyzerRowSkeleton() {
return (
<Card className="py-0 shadow-none gap-0">
<CardContent className="px-4 py-4 space-y-1.5">
<div className="flex justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-10" />
</div>
<Skeleton className="h-4 w-3/4 mt-0.5" />
<Skeleton className="h-3 w-full mt-1" />
<Skeleton className="h-3 w-2/3" />
<div className="flex justify-between mt-2">
<Skeleton className="h-2 w-20" />
<Skeleton className="h-3 w-12" />
</div>
</CardContent>
</Card>
);
}
```
Note `py-0 gap-0` overrides the default Card `py-6 gap-6` so internal padding comes from `CardContent`. Verify shape against UI-SPEC §"Skeleton Row".
**FILE D — `components/mobile/AnalyzerFeedRow.tsx` (D-10, D-11, D-12, D-17)**
The feed row Card. Per D-12 the entire Card is a `<Link>` to `/mobile/analyzer/[id]`. Per D-11 NO `border-l-4`. Per D-10 four-line layout: header / title / summary / footer.
```tsx
'use client';
/* AnalyzerFeedRow — phase 06 (ANL-02).
* Purpose: Feed row card — header (ticket# + time-ago), title (1-line truncate),
* summary (2-line clamp), footer (stage pips left + confidence/review right).
* Entire card is a Link to /mobile/analyzer/[id]. Per D-10..D-12, D-17.
* Props: AnalyzerFeedRow type from @/app/api/mobile/analyzer/feed/route. */
import Link from 'next/link';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { AnalyzerStagePips } from '@/components/mobile/AnalyzerStagePips';
import { ConfidenceBadge } from '@/components/mobile/ConfidenceBadge';
import type { AnalyzerFeedRow as AnalyzerFeedRowType } from '@/app/api/mobile/analyzer/feed/route';
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`;
}
export interface AnalyzerFeedRowProps {
row: AnalyzerFeedRowType;
}
export function AnalyzerFeedRow({ row }: AnalyzerFeedRowProps) {
return (
<Link href={`/mobile/analyzer/${row.id}`} className="block">
<Card className="py-0 gap-0 cursor-pointer hover:bg-muted/50 active:bg-muted/50 transition-colors">
<CardContent className="px-4 py-4 space-y-1.5">
{/* Line 1 — header row */}
<div className="flex justify-between items-center">
<span className="bg-muted rounded px-1.5 py-0.5 text-[10px] font-mono font-semibold">
{row.ticketNumber}
</span>
<span className="text-[10px] text-muted-foreground">
{relTime(row.completedAt)}
</span>
</div>
{/* Line 2 — title */}
<p className="text-sm font-semibold leading-snug truncate">
{row.title}
</p>
{/* Line 3 — summary clamp (2 lines, fallback "—") */}
<p className="text-xs text-muted-foreground line-clamp-2">
{row.summary ?? '—'}
</p>
{/* Footer — pips left, badges right */}
<div className="flex justify-between items-center mt-1">
<AnalyzerStagePips
haikuUsed={row.haikuUsed}
sonnetUsed={row.sonnetUsed}
opusUsed={row.opusUsed}
/>
<div className="flex gap-2 items-center">
<ConfidenceBadge score={row.confidenceScore} />
{row.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>
</CardContent>
</Card>
</Link>
);
}
```
Verify against UI-SPEC §"Feed Row Card": title `text-sm font-semibold leading-snug truncate` (1-line), summary `text-xs text-muted-foreground line-clamp-2`, ticket# badge `bg-muted rounded px-1.5 py-0.5 text-[10px] font-mono`, time-ago `text-[10px] text-muted-foreground`, footer flex row, Review pill EXACTLY `bg-destructive/10 text-destructive` with copy "Review" (D-17). NO icons in the Review pill, NO exclamation mark.
The `relTime()` helper is duplicated inline (per D-04 and CONTEXT.md "duplicate inline; extract shared only when third caller appears"). The tickets page is the second caller; analyzer row is the third — but the diff is small enough that inline duplication keeps Plan 06-02 atomic. A future cleanup phase can extract.
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "components/mobile/(AnalyzerStagePips|ConfidenceBadge|AnalyzerRowSkeleton|AnalyzerFeedRow)\\.tsx" || echo "TypeScript clean for new components"</automated>
</verify>
<acceptance_criteria>
- All 4 files exist: `for f in components/mobile/AnalyzerStagePips.tsx components/mobile/ConfidenceBadge.tsx components/mobile/AnalyzerRowSkeleton.tsx components/mobile/AnalyzerFeedRow.tsx; do test -f "$f" || echo "MISSING $f"; done` produces no MISSING output
- Each file starts with `'use client';`: `head -1 components/mobile/AnalyzerStagePips.tsx components/mobile/ConfidenceBadge.tsx components/mobile/AnalyzerRowSkeleton.tsx components/mobile/AnalyzerFeedRow.tsx | grep -c "'use client'"` returns 4
- Each file exports its named component: `grep -E "^export function (AnalyzerStagePips|ConfidenceBadge|AnalyzerRowSkeleton|AnalyzerFeedRow)" components/mobile/Analyzer*.tsx components/mobile/ConfidenceBadge.tsx | wc -l` returns 4
- AnalyzerStagePips renders correct dot classes: `grep -F 'bg-primary' components/mobile/AnalyzerStagePips.tsx` returns at least one match AND `grep -F 'bg-muted-foreground/30' components/mobile/AnalyzerStagePips.tsx` returns at least one match
- AnalyzerStagePips dot size correct: `grep -F 'h-1.5 w-1.5' components/mobile/AnalyzerStagePips.tsx` returns at least one match
- AnalyzerStagePips includes sr-only label: `grep -F 'sr-only' components/mobile/AnalyzerStagePips.tsx` returns at least one match
- ConfidenceBadge thresholds exact (D-15): `grep -F '0.85' components/mobile/ConfidenceBadge.tsx` returns at least one match AND `grep -F '0.65' components/mobile/ConfidenceBadge.tsx` returns at least one match
- ConfidenceBadge tones exact: `grep -F 'bg-green-500/10' components/mobile/ConfidenceBadge.tsx` returns at least one match AND `grep -F 'bg-amber-500/10' components/mobile/ConfidenceBadge.tsx` returns at least one match AND `grep -F 'bg-slate-500/10' components/mobile/ConfidenceBadge.tsx` returns at least one match
- ConfidenceBadge dark-mode tones present: `grep -F 'dark:text-green-400' components/mobile/ConfidenceBadge.tsx` returns at least one match
- ConfidenceBadge returns null on null score: `grep -E 'score === null.*return null' components/mobile/ConfidenceBadge.tsx` returns at least one match (or equivalent early-return)
- AnalyzerFeedRow links to detail: `grep -F 'href={`/mobile/analyzer/${row.id}`}' components/mobile/AnalyzerFeedRow.tsx` returns at least one match (or use `grep -E 'mobile/analyzer/' components/mobile/AnalyzerFeedRow.tsx`)
- AnalyzerFeedRow has NO border-l-4: `grep -F 'border-l-4' components/mobile/AnalyzerFeedRow.tsx components/mobile/AnalyzerRowSkeleton.tsx` returns ZERO matches (D-11 — no priority stripe)
- Title row classes exact: `grep -F 'text-sm font-semibold leading-snug truncate' components/mobile/AnalyzerFeedRow.tsx` returns at least one match
- Summary clamp class: `grep -F 'line-clamp-2' components/mobile/AnalyzerFeedRow.tsx` returns at least one match
- Summary fallback character is em-dash: `grep -F "'—'" components/mobile/AnalyzerFeedRow.tsx` returns at least one match (literal em-dash, not "--")
- Review pill copy exact: `grep -E '>Review<' components/mobile/AnalyzerFeedRow.tsx` returns at least one match
- Review pill tone: `grep -F 'bg-destructive/10 text-destructive' components/mobile/AnalyzerFeedRow.tsx` returns at least one match
- Skeleton renders no border-l-4 (already covered by previous check)
- AnalyzerFeedRow imports the type, not the component, from the route file: `grep -E 'import type \\{ AnalyzerFeedRow.*from .@/app/api/mobile/analyzer/feed/route.' components/mobile/AnalyzerFeedRow.tsx` returns at least one match
- `npx tsc --noEmit --pretty` exits 0
</acceptance_criteria>
<done>
Four components compile, exported with correct props interfaces, render the exact Tailwind classes and copy strings declared in 06-UI-SPEC. Task 2 can compose them in the page.
</done>
</task>
<task type="auto">
<name>Task 2: Replace placeholder with the feed list page (fetch, IntersectionObserver, Load more, empty/error states)</name>
<files>app/mobile/analyzer/page.tsx</files>
<read_first>
- app/mobile/analyzer/page.tsx (current placeholder — being replaced wholesale)
- .planning/phases/06-analyzer-feed-new/06-CONTEXT.md (D-08, D-09, D-28, D-29, D-30, D-31, D-34, D-35, D-38)
- .planning/phases/06-analyzer-feed-new/06-UI-SPEC.md §"Infinite Scroll Sentinel + Load More" + §"Copywriting Contract" + §"Loading States"
- app/mobile/tickets/page.tsx (PATTERN SOURCE — copy the IntersectionObserver setup at lines 188-203, the loadFirst/loadMore pattern at lines 121-174, the Load more button at lines 292-304, the inline spinner at lines 285-289)
- components/ui/empty-state.tsx (EmptyState component — has `icon`, `title`, `description`, `action` props)
- app/api/mobile/analyzer/feed/route.ts (the endpoint Plan 06-01 created)
</read_first>
<action>
**Delete the existing placeholder content** at `app/mobile/analyzer/page.tsx` and replace with the real feed page. The file is being rewritten end-to-end; no part of the placeholder JSX/imports survives.
The page is `'use client'` (CLAUDE.md mobile pattern; D-38 — no SWR, no react-query, plain `useState` + `useEffect` + `fetch`). It does NOT need `Suspense` because it has NO `useSearchParams()` calls (no URL filter sync this phase per CONTEXT.md "feed has no filters this phase"). If TypeScript or runtime requires Suspense for some other reason, wrap as in `app/mobile/tickets/page.tsx` lines 73-79.
Implementation:
```tsx
'use client';
import { useEffect, useState, useCallback, useRef } from 'react';
import Link from 'next/link';
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>
);
}
```
**Copy contract — these strings are LOCKED in 06-UI-SPEC §"Copywriting Contract":**
- Page H1: exactly `Analyzer` (D-35)
- Empty state heading: exactly `No analyses yet` (D-31)
- Empty state body: exactly `Completed AI ticket analyses will appear here.` (D-31)
- Empty state CTA visible label: exactly `Open desktop Analyzer` linking to `/analyzer/tickets` (D-31)
- Load more button (idle): exactly `Load more`
- Load more button (loading): exactly `Loading…` (with ellipsis character, not three dots)
- Load more button (error): exactly `Retry`
- Error toast (initial load): exactly `Failed to load analyses`
- Error toast (load more): exactly `Failed to load more analyses`
**Container per D-34:** Outer `<div className="px-4 py-4 space-y-4">` matches the page-level rhythm; rows in `<div className="space-y-3">` per UI-SPEC §"Feed Row Card" row list container.
**Read-only (ANL-05, D-23):** This page renders NO Buttons that suggest re-running, editing, or prompt-tuning. Only the Load more fallback button (which is a pagination control, not an analysis action) and the empty-state link to desktop. NO `<Button onClick={runAnalysis}>` patterns, NO triple-dot menus, NO Edit icons.
**Why NOT use the shadcn `EmptyState` primitive directly?** Per CONTEXT.md D-31 "Reuse `components/ui/empty-state.tsx` if its props fit; otherwise mirror its shape inline." The EmptyState `action` prop only accepts `{label, href|onClick}` — but per UI-SPEC the CTA must include an `ExternalLink` icon and `target="_blank"`. The inline mirror in this implementation honors both the EmptyState visual (dashed border, icon-in-rounded-square, headline + description + button) AND the ExternalLink icon convention from Phase 2 DRAWER-04 / Phase 6 D-22. If executor finds a way to pass `target="_blank"` + icon through the existing `EmptyState` component cleanly, that's allowed; otherwise inline as shown.
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/analyzer/page\\.tsx" || echo "TypeScript clean for analyzer page"</automated>
</verify>
<acceptance_criteria>
- File exists: `test -f app/mobile/analyzer/page.tsx`
- Placeholder gone: `grep -F 'Analyzer feed coming soon' app/mobile/analyzer/page.tsx` returns ZERO matches (the old placeholder copy is replaced)
- File starts with `'use client';`: `head -1 app/mobile/analyzer/page.tsx | grep -F "'use client'"` returns one match
- Imports the feed row component: `grep -F "from '@/components/mobile/AnalyzerFeedRow'" app/mobile/analyzer/page.tsx` returns at least one match
- Imports the type from route: `grep -E "import type.*AnalyzerFeedResponse.*from .@/app/api/mobile/analyzer/feed/route." app/mobile/analyzer/page.tsx` returns at least one match
- Fetches the endpoint: `grep -F '/api/mobile/analyzer/feed' app/mobile/analyzer/page.tsx` returns at least 2 matches (loadFirst + loadMore)
- IntersectionObserver wired: `grep -F 'IntersectionObserver' app/mobile/analyzer/page.tsx` returns at least one match AND `grep -E "rootMargin: '200px'" app/mobile/analyzer/page.tsx` returns at least one match
- Sentinel ref in JSX: `grep -F 'ref={sentinelRef}' app/mobile/analyzer/page.tsx` returns at least one match
- Page H1 copy exact: `grep -E '>Analyzer<' app/mobile/analyzer/page.tsx` returns at least one match
- H1 has correct typography: `grep -F 'text-base font-semibold' app/mobile/analyzer/page.tsx` returns at least one match
- Container spacing per D-34: `grep -F 'px-4 py-4 space-y-4' app/mobile/analyzer/page.tsx` returns at least one match
- Row list spacing per D-34: `grep -F 'space-y-3' app/mobile/analyzer/page.tsx` returns at least one match
- Initial 5 skeletons rendered: `grep -E 'Array\\.from\\(\\{ length: 5 \\}\\)' app/mobile/analyzer/page.tsx` returns at least one match AND `grep -F 'AnalyzerRowSkeleton' app/mobile/analyzer/page.tsx` returns at least one match
- Empty state copy: `grep -F 'No analyses yet' app/mobile/analyzer/page.tsx` returns at least one match AND `grep -F 'Completed AI ticket analyses will appear here.' app/mobile/analyzer/page.tsx` returns at least one match
- Empty state desktop CTA link: `grep -F '/analyzer/tickets' app/mobile/analyzer/page.tsx` returns at least one match AND `grep -F 'Open desktop Analyzer' app/mobile/analyzer/page.tsx` returns at least one match
- Load more button copy + states: `grep -F 'Load more' app/mobile/analyzer/page.tsx` returns at least one match AND `grep -F 'Loading…' app/mobile/analyzer/page.tsx` returns at least one match AND `grep -F 'Retry' app/mobile/analyzer/page.tsx` returns at least one match
- Load more aria-label per UI-SPEC: `grep -F 'aria-label="Load more analyses"' app/mobile/analyzer/page.tsx` returns at least one match
- Load more touch target: `grep -F 'min-h-[44px]' app/mobile/analyzer/page.tsx` returns at least one match (page CTA + load more)
- toast.error wired: `grep -F 'toast.error' app/mobile/analyzer/page.tsx` returns at least 2 matches AND error copy exact: `grep -F "'Failed to load analyses'" app/mobile/analyzer/page.tsx` returns at least one match AND `grep -F "'Failed to load more analyses'" app/mobile/analyzer/page.tsx` returns at least one match
- NO Zustand/SWR/react-query imports (D-38): `grep -E "from\\s+['\\\"](zustand|swr|@tanstack/react-query)['\\\"]" app/mobile/analyzer/page.tsx` returns ZERO matches
- NO read-write actions (ANL-05): `grep -E '\\bonClick=.*\\b(reRun|edit|delete|cancel|retry)Analysis\\b' app/mobile/analyzer/page.tsx` returns ZERO matches
- `npx tsc --noEmit --pretty` exits 0
- Manual smoke (when servers running): `curl -s http://localhost:3100/mobile/analyzer | grep -F 'Analyzer'` returns the rendered shell (or unauthenticated redirect — expected)
</acceptance_criteria>
<done>
`/mobile/analyzer` shows 5 skeleton rows on initial load, then real data from the feed endpoint as Card rows, scrolls to load more pages, falls back to "Load more" button for accessibility, shows the empty state when zero rows exist, and emits a toast on error with a "Retry" affordance. No editing/re-run/prompt-tuning controls anywhere on the page (ANL-05).
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → mobile page | DOM rendering of analyzer summary text and titles — text content can be arbitrary user/system input from analyzer pipeline |
| Mobile page → API (`/api/mobile/analyzer/feed`) | Cursor + limit query params (cursor is opaque-to-client — server-encoded) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06P02-01 | Information Disclosure (XSS via summary/title) | `AnalyzerFeedRow` | mitigate | React JSX text interpolation auto-escapes — `{row.summary ?? '—'}` and `{row.title}` are inserted as text nodes, not HTML. No `dangerouslySetInnerHTML` anywhere in this plan. ASVS L1 §V5.3.3. |
| T-06P02-02 | Tampering (client modifies cursor) | `loadMore()` | accept | Cursor is round-tripped from server → client → server. The server's `decodeCursor` (Plan 06-01) treats malformed input as "no cursor" → returns first page; valid-but-tampered cursor (e.g., older `completed_at`) just shows different rows the user could already access. Worst case: information disclosure within the user's already-authorized scope (kiosk_settings still applies). |
| T-06P02-03 | Spoofing (no auth) | `app/mobile/analyzer/page.tsx` | mitigate | Page is under `/mobile/*` which is auth-gated by `middleware.ts` (Better Auth session cookie check). `/api/mobile/analyzer/feed` ALSO calls `requireAuth()` server-side (Plan 06-01) — defense in depth: the page can't even render data without a session because the fetch returns 401. |
| T-06P02-04 | Information Disclosure (toast leaks server error message) | `loadFirst`/`loadMore` catch | mitigate | Toast copy is HARDCODED to "Failed to load analyses" / "Failed to load more analyses" (not the raw `e.message`). Internal `setError(msg)` stores the technical message but it's only used to flip the button label to "Retry"; never displayed to the user. ASVS L1 §V7.4.1. |
| T-06P02-05 | Denial of Service (runaway IntersectionObserver) | sentinel useEffect | mitigate | The observer callback no-ops when `loadingMore || !hasMore || loading`. Once `hasMore=false`, the sentinel never fires another fetch. The observer is `disconnect()`ed on cleanup so a remounted page doesn't accumulate observers. Page-size cap of 25 is enforced server-side (T-06-04 in Plan 06-01). |
| T-06P02-06 | Repudiation (no audit trail of feed reads) | feed page | accept | Read-only mobile feed; no compliance requirement to audit per-user feed reads. Better Auth session activity is logged at the auth layer. Same posture as Phase 4 Tickets. |
</threat_model>
<verification>
- `npx tsc --noEmit --pretty` exits 0
- Component file count: `ls components/mobile/Analyzer*.tsx components/mobile/ConfidenceBadge.tsx 2>/dev/null | wc -l` returns 4
- The placeholder is gone: `grep -F 'Analyzer feed coming soon' app/mobile/analyzer/page.tsx` returns 0 matches
- The new feed page imports the AnalyzerFeedRow component AND the AnalyzerFeedResponse type
- Manual visual smoke (developer): on `http://localhost:3100/mobile/analyzer`
1. Initial: 5 skeleton cards visible for ~200ms then real data
2. Each row card: ticket# (mono badge) | time-ago (right) on top, title in middle, summary clamp, footer with pips + confidence + maybe Review pill
3. Scroll down: when you near the bottom, more rows load automatically
4. Pull/tap "Load more": button works, shows spinner, disables during fetch
5. With test data of zero rows: empty state with dashed-border card, "No analyses yet" headline, "Open desktop Analyzer" link opens `/analyzer/tickets` in new tab
- Tap a row card: navigates to `/mobile/analyzer/[uuid]` (Plan 06-03 owns the destination — this just verifies the link href is correct)
- Active BottomNav tab: Analyzer is highlighted (`text-primary`) on `/mobile/analyzer` AND on `/mobile/analyzer/[id]` per Phase 2 `pathname.startsWith('/mobile/analyzer')`
</verification>
<success_criteria>
1. `/mobile/analyzer` no longer shows the "coming soon" placeholder
2. Initial load shows 5 skeleton cards (per D-28) before transitioning to real data
3. Each row visually presents: ticket# badge (mono, bg-muted), time-ago (right), title (1-line truncate, font-semibold), summary 2-line clamp, footer with stage pips (left) + confidence badge + optional Review pill (right)
4. ConfidenceBadge thresholds work: score >= 0.85 shows green "High", 0.65-0.85 shows amber "Medium", < 0.65 shows slate "Low", null shows nothing
5. AnalyzerStagePips: 3 dots with caret separators between them, filled when corresponding `_used` flag is true
6. Tapping a row navigates to `/mobile/analyzer/[id]` (link href is correct; destination page lives in Plan 06-03)
7. IntersectionObserver triggers `loadMore` when sentinel enters viewport with `rootMargin: '200px'`
8. Load more button is always rendered when `hasMore=true`, focusable, with `aria-label="Load more analyses"`
9. Empty state renders dashed-border card with "No analyses yet" + body + "Open desktop Analyzer" link to `/analyzer/tickets`
10. Error scenario triggers `toast.error` and flips Load more button to "Retry"
11. Page contains NO edit/re-run/prompt-tuning controls (ANL-05)
12. `npx tsc --noEmit --pretty` passes
</success_criteria>
<output>
After completion, create `.planning/phases/06-analyzer-feed-new/06-02-SUMMARY.md` documenting:
- File-by-file diff overview (4 new components + replaced page)
- How the page consumes Plan 06-01's types
- Any deviations from UI-SPEC and why (expected: none — all classnames and copy strings should be exact)
- Notes for executor of Plan 06-03: the row's `<Link href={`/mobile/analyzer/${row.id}`}>` is the entry point — Plan 06-03 owns the destination page
</output>
</content>
</invoke>

View file

@ -0,0 +1,473 @@
---
phase: 06-analyzer-feed-new
plan: 03
type: execute
wave: 3
depends_on: [06-02]
files_modified:
- app/mobile/analyzer/[id]/page.tsx
autonomous: true
requirements: [ANL-03, ANL-04, ANL-05]
must_haves:
truths:
- "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"
artifacts:
- path: "app/mobile/analyzer/[id]/page.tsx"
provides: "Mobile analyzer detail page rendering Summary / Next Step / Rationale"
min_lines: 150
key_links:
- from: "app/mobile/analyzer/[id]/page.tsx"
to: "/api/analyzer/analyses/[id]"
via: "fetch in useEffect on mount"
pattern: "fetch.*api/analyzer/analyses/"
- from: "app/mobile/analyzer/[id]/page.tsx"
to: "/analyzer/analysis/[id] (desktop)"
via: "external link with target=_blank + ExternalLink icon"
pattern: 'href=.*analyzer/analysis/'
- from: "app/mobile/analyzer/[id]/page.tsx"
to: "components/mobile/AnalyzerStagePips, ConfidenceBadge"
via: "import + render in identity block"
pattern: "AnalyzerStagePips.*ConfidenceBadge"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- Types this plan consumes -->
From `@/lib/types/analyzer` (existing — D-27, do NOT redefine):
```typescript
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.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Build the mobile analyzer detail page</name>
<files>app/mobile/analyzer/[id]/page.tsx</files>
<read_first>
- .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)
</read_first>
<action>
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:
```tsx
'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.
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/analyzer/\\[id\\]/page\\.tsx" || echo "TypeScript clean for detail page"</automated>
</verify>
<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>
<done>
`/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.
</done>
</task>
</tasks>
<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|edit|delete|cancel|share|retry` Analysis onClick handlers are added (ANL-05 enforcement). NO Dialog imports allowed (would imply edit/confirm UI). Only navigation actions present (router.back + 2 external links). |
</threat_model>
<verification>
- `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/<some uuid from analyzer_analyses>`
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/<id>` 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)
</verification>
<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>
<output>
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
</output>
</content>
</invoke>