Mostly table column normalisation under docs/audit/ + planning, object- literal expansion in src/lib/ai/coach/snapshot.ts after the W4 F6 Apple Health metric block landed, and small wrap/unwrap line adjustments in the device + APNs routes / tests touched across W3/W4/W5 sessions. No behavioural change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
14 KiB
Phase D — Simplify review (v1.4.20)
Lens: every chunk of new code that v1.4.20 added (Wave B = B1–B5).
Read-only review — write findings, do NOT commit. Security paths,
chart visual identity, fixture data, and the genuinely new
abstractions (correlations.ts, health-score.ts) get extra room.
Summary
Total findings: 5 apply-yes · 4 apply-maybe · 3 apply-no
apply-yes are the clear wins worth folding into reconcile. apply-maybe flags judgement calls (test seams vs. dead parameters, premature extraction). apply-no documents what was looked at and rejected so the maintainer's diff isn't asked the same question twice.
apply-yes (clear wins, ship in reconcile)
S-01 — Drop unused snapshot parameter from streamProviderError
File: src/app/api/insights/chat/route.ts:406-427
Why: The function takes a snapshot: ReturnType<typeof Object.assign> parameter (which evaluates to any — a type smell on its own) and never reads it inside the body. Three callers dutifully pass snapshot.provenance for nothing. Pure dead code on both sides of the call.
Before (≤8 lines):
function streamProviderError(args: {
conversationId: string;
snapshot: ReturnType<typeof Object.assign>;
code: string;
}): Response {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
try {
controller.enqueue(
encodeFrame({ type: "error", code: args.code, message: args.code }),
);
} finally {
controller.close();
}
},
});
return new Response(stream, { status: 503, headers: SSE_HEADERS });
}
After (≤8 lines):
function streamProviderError(args: { code: string }): Response {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
try {
controller.enqueue(
encodeFrame({ type: "error", code: args.code, message: args.code }),
);
} finally {
controller.close();
}
},
});
return new Response(stream, { status: 503, headers: SSE_HEADERS });
}
(All three call sites drop snapshot:/conversationId: keys too — conversationId is also unused, only code survives.)
S-02 — Extract the duplicated formatRelativeTime helper
File: src/components/insights/{hero-strip.tsx:134-148, daily-briefing.tsx:259-273, coach-panel/history-rail.tsx:40-54} (also still present in the deprecated insights-page-hero.tsx:64-86)
Why: Three live byte-identical copies of the same 14-line helper. Each comment even calls out "Mirrors the helper in <InsightsPageHero>" — the maintainers know it's duplicated. Drift risk: a later i18n key change has to land in three files.
Before (≤8 lines, repeated 3×):
function formatRelativeTime(iso: string, t: …): string {
const target = new Date(iso).getTime();
if (Number.isNaN(target)) return "";
const diffMs = Date.now() - target;
if (diffMs < 60_000) return t("insights.relativeJustNow");
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 60) return t("insights.relativeMinutesAgo", { count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return t("insights.relativeHoursAgo", { count: hours });
return t("insights.relativeDaysAgo", { count: Math.floor(hours / 24) });
}
After (≤8 lines):
// new file: src/lib/i18n/relative-time.ts
export function formatRelativeTime(
iso: string,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
/* …same body, written once… */
}
// callers: import { formatRelativeTime } from "@/lib/i18n/relative-time";
S-03 — Drop the dead ["coachConversation", "null"] queryKey branch
File: src/components/insights/coach-panel/use-coach.ts:83-98
Why: The hook ternary picks a different queryKey when id is null — but enabled: id !== null keeps the query disabled in that exact case, so the "null" key never gets a queryFn fire. Just key on id directly; React Query happily accepts null in the array.
Before:
return useQuery({
queryKey: id ? QUERY_KEYS.one(id) : ["coachConversation", "null"],
queryFn: async () => { if (!id) throw new Error("missing id"); … },
enabled: id !== null,
staleTime: 60 * 1000,
});
After:
return useQuery({
queryKey: ["coachConversation", id] as const,
queryFn: async () => { if (!id) throw new Error("missing id"); … },
enabled: id !== null,
staleTime: 60 * 1000,
});
S-04 — Hoist STORYBOARD_COLOR_BY_CATEGORY to module scope
File: src/app/insights/page.tsx:915-920
Why: The map is declared inside the component body so it's recreated on every render of <InsightsPage> (a render-heavy page). Map is data-only — no closures over locals, no i18n keys. Module-scope const is the boring right answer.
Before:
export default function InsightsPage() {
// …200+ lines of derived state…
const STORYBOARD_COLOR_BY_CATEGORY: Record<string, string> = {
medication: "var(--dracula-pink)",
event: "var(--dracula-cyan)",
milestone: "var(--dracula-green)",
warning: "var(--dracula-orange)",
};
}
After:
const STORYBOARD_COLOR_BY_CATEGORY: Record<string, string> = {
medication: "var(--dracula-pink)",
event: "var(--dracula-cyan)",
milestone: "var(--dracula-green)",
warning: "var(--dracula-orange)",
};
export default function InsightsPage() {
/* … */
}
S-05 — Extract the confidence-band class+label table
File: src/components/insights/{correlation-card.tsx:61-73, trend-annotation.tsx:33-46}
Why: Both cards declare the same Record<"low"|"moderate"|"high", string> for badge classes AND for translation-key labels — byte-identical class strings, only the i18n key prefix differs (correlationRow.* vs trendAnnotation.*). Move the classnames to one constant, keep each component's i18n key map local (it IS distinct copy). The class table is the load-bearing duplication.
Before (one of two copies, each ~14 lines):
const CONFIDENCE_BADGE_CLASSNAME: Record<"low" | "moderate" | "high", string> =
{
high: "border-dracula-green/40 bg-dracula-green/10 text-dracula-green",
moderate:
"border-dracula-orange/40 bg-dracula-orange/10 text-dracula-orange",
low: "border-dracula-comment/40 bg-dracula-comment/10 text-muted-foreground",
};
After:
// new file: src/components/insights/confidence-badge.ts
export type ConfidenceBand = "low" | "moderate" | "high";
export const CONFIDENCE_BADGE_CLASS: Record<ConfidenceBand, string> = {
high: "border-dracula-green/40 bg-dracula-green/10 text-dracula-green",
moderate: "border-dracula-orange/40 bg-dracula-orange/10 text-dracula-orange",
low: "border-dracula-comment/40 bg-dracula-comment/10 text-muted-foreground",
};
apply-maybe (judgment calls)
S-06 — <CoachDrawer> slot props (historyRail / sourcesRail / composer) are dead in production
File: src/components/insights/coach-panel/coach-drawer.tsx:53-63, 200-228
Why: The only production caller (/insights/page.tsx:1611) never passes these slots — the JSDoc says they were a commit-2 boundary "until commit 3 lands". Commit 3 has long since landed. <CoachDrawerBody> has its own slot props which the SSR test harness uses; nobody injects against <CoachDrawer>. Removing them halves the prop surface.
Before (≤8 lines):
historyRail?: React.ReactNode;
sourcesRail?: React.ReactNode;
composer?: React.ReactNode;
// …
historyRail={historyRail ?? <HistoryRail … />}
sourcesRail={sourcesRail ?? <SourcesRail />}
composer={composer ?? <CoachInput … />}
After (≤8 lines):
// drop the three props on CoachDrawer entirely
historyRail={<HistoryRail … />}
sourcesRail={<SourcesRail />}
composer={<CoachInput … />}
Why "maybe": keeping them costs little; the trade-off is "future test seam" vs. "delete dead code now". Given <CoachDrawerBody> already exposes the equivalent seams for tests, I'd ship this. But a maintainer who wants to keep the prop-injection contract symmetric across the two components has a defensible counter-argument.
S-07 — Coach <CoachInput> inputId prop is never overridden
File: src/components/insights/coach-panel/coach-input.tsx:45-55
Why: inputId?: string defaults to "coach-composer-textarea"; no caller in the codebase passes a different value. Removing the prop drops 2 lines and one ?? default from the signature.
Why "maybe": tiny. Worth folding into the same pass that touches the file; otherwise leave it.
S-08 — cloneForCheck variable is dead in the chat POST handler
File: src/app/api/insights/chat/route.ts:434-445
Why: let cloneForCheck: NextRequest | undefined = undefined; is assigned via request.clone() as NextRequest, then immediately consumed by cloneForCheck.json() — never referenced again, never read after the try block. Inline the .clone().json() call directly.
Before:
let cloneForCheck: NextRequest | undefined = undefined;
let conversationId: string | undefined = undefined;
try {
cloneForCheck = request.clone() as NextRequest;
const body = await cloneForCheck.json();
if (typeof body?.conversationId === "string")
conversationId = body.conversationId;
} catch {
/* fall through */
}
After:
let conversationId: string | undefined = undefined;
try {
const body = await request.clone().json();
if (typeof body?.conversationId === "string")
conversationId = body.conversationId;
} catch {
/* fall through */
}
Why "maybe": the variable's only sin is verbosity; the logic is fine. Pure cosmetic gain.
S-09 — health-score-card.tsx delta arrow chooser is three sibling && blocks
File: src/components/insights/health-score-card.tsx:153-171
Why: Three delta > 0, delta < 0, delta === 0 blocks each rendering a different arrow icon. A small deltaIcon = delta > 0 ? ArrowUp : delta < 0 ? ArrowDown : Minus lookup keeps all three icon imports but compresses the JSX.
Why "maybe": the current shape is explicit and grep-friendly. The compressed version is shorter but introduces a colour-class lookup too (text-dracula-green vs text-dracula-red vs text-muted-foreground). Borderline — left as a judgement call for the maintainer who actually owns the file.
apply-no (rejected — explanation only, no diff)
S-N1 — The two pearson implementations (src/lib/insights/correlations.ts and src/lib/analytics/correlations.ts)
The new pearson() in correlations.ts returns a tagged union with p-value + 95 % Fisher-z confidence interval; the older pearsonCorrelation() returns { r, strength, n } with strength buckets ("stark"/"moderat"/…). Different surface, different consumers, different statistical guarantees. Merging them would force the older callers (dashboard scatter cards) to deal with the p-value contract they don't need. Keep both.
S-N2 — correlations.ts per-hypothesis bodies (correlateBpCompliance / correlateMoodPulse / correlateWeightWeekday)
Each runner has the same if (insufficient) return …; if (pValue ≥ 0.05) return …; build interpretation; return ok skeleton. Tempting to extract a generic runHypothesis(), but the interpretation phrases are distinct, the input shapes differ, and one of three uses ANOVA not Pearson. The current shape reads top-to-bottom; a generic abstraction would obscure the conservative-phrasing rule that lives in plain English in each runner. The CLAUDE.md guidance is explicit ("the recently-introduced abstractions … give them room"). Leave as is.
S-N3 — recommendation system-prompt EN/DE near-duplication (insight-generator.ts GROUND RULES 8-11)
The German prompt mirrors the English one section-for-section. A single template with placeholder slots was tempting, but the linguistic register matters in prompts (verb mood, technical vocabulary), and Marc's voice rule explicitly wants per-locale authorship not auto-translation. Keep the two prompts side-by-side; the only thing worth doing here is a periodic diff-lint to catch when one locale gets a rule the other doesn't (already covered by the i18n integrity tests).
Things deliberately NOT simplified
refusal.tsinjection regex bank. Verbose by design — a flat array of regexes lets a future audit grep any single phrase. Compressing into a tree would defeat that. Security guidance in CLAUDE.md backs this.use-coach.tsSSE parser. TheparseSseChunkhelper is exported AND consumed by tests. The verbose state-machine shape is the test seam; don't fold it into the streaming hook.weekly-report-view.tsxSection/BulletList helpers. Could be inlined back, but they're called 5 times each and read clearly. Net wash.<HeroStrip>action-button row. The three buttons (weekly-report disabled, ask-coach conditional, regenerate conditional) look like they want a<HeroAction>component — but each carries unique enable/disable/title logic, and the inline JSX matches the artboard one-to-one. Extracting would make the diff harder to compare against the design.computeHealthScoreweight-redistribution loop. The "redistribute null components proportionally" comment IS the load-bearing logic — extracting helpers further would obscure that the same iteration order anchors the formula. Leave it.
Verification gates that won't break
All proposals above are behaviour-preserving:
- S-01: pure dead-parameter removal (pre-conditions unchanged at call sites).
- S-02: extracted helper byte-identical to the three current copies.
- S-03: queryKey shape
["coachConversation", null]is structurally equivalent for cache lookup. - S-04: hoist is referentially identical (no closures captured).
- S-05: classnames extracted; per-component i18n keys stay local.
- S-06–S-09 (maybe): each is a strict subset of the current behaviour.