diff --git a/.agents/skills/h2ewd-copy/SKILL.md b/.agents/skills/h2ewd-copy/SKILL.md new file mode 100644 index 000000000..a9464e7b7 --- /dev/null +++ b/.agents/skills/h2ewd-copy/SKILL.md @@ -0,0 +1,58 @@ +--- +name: h2ewd-copy +description: Protect Optimitron / War on Disease public copy from conversion regressions. Use before writing, rewriting, reviewing, or committing user-facing website, email, metadata, CTA, empty-state, dashboard, survey, referral, plaintiff, task, or partner copy in packages/web or docs/h2ewd surfaces. +--- + +# H2EWD Copy + +## Overview + +Use this skill to make public copy more likely to produce the target action: +vote, share, register a plaintiff, endorse, donate, complete a task, or trust a +quantified claim. Compare new copy against the old copy; do not judge it in +isolation. + +## Hard Rule + +Do not replace purpose, motivation, urgency, agency, or trust with mechanism-only +copy. Shorter is worse when it makes the action feel less valuable, less +autonomous, less urgent, or less clear. + +## Workflow + +1. Read `docs/h2ewd.md`, the old copy, and the surrounding rendered/source + context. +2. Search existing source/manual copy before inventing wording. Prefer + `searchManual` when the Optimitron MCP server is mounted; otherwise use the + static manual index at `https://manual.warondisease.org/assets/json/search-index.json` + or `rg` over `docs/`, `packages/web/src/app`, and `packages/web/src/components`. +3. Before editing existing copy, write this brief: + +```md +Audience: +Desired action: +Motivation: +Old copy's strategic job: +Why the new copy increases the action: +Manual/source phrase checked: +Minimum question for Mike: +``` + +4. If audience, desired action, motivation, or source anchor is unclear, ask Mike + one short question with a recommended default. Treat him as the copy merge + gate, not as a person who should rewrite drafts from scratch. +5. Preserve user-supplied sharp language unless it creates a concrete legal, + factual, or conversion problem. If changing it, explain why. +6. Use parameter/citation components for major numeric claims where available. +7. After editing, show the changed copy and ask Mike to approve it before commit. + Do not set `COPY_REVIEW_APPROVED=1` unless he explicitly approved the copy. + +## Review Smells + +- The old copy answered "why"; the new copy only says "what can happen." +- The new copy makes the user feel assigned, managed, sold to, or judged. +- The copy explains internal workflow instead of the value to the human. +- The copy removes the treaty/plaintiff/damages/outcome frame for a generic app + phrase. +- The copy is friendlier but less forceful, less specific, or less true. +- The copy sounds like nonprofit, consultant, or startup onboarding language. diff --git a/.agents/skills/h2ewd-copy/agents/openai.yaml b/.agents/skills/h2ewd-copy/agents/openai.yaml new file mode 100644 index 000000000..2cbf924c6 --- /dev/null +++ b/.agents/skills/h2ewd-copy/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "H2EWD Copy" + short_description: "Protect War on Disease public copy from conversion regressions." + default_prompt: "Use h2ewd-copy to review or edit public copy." diff --git a/.claude/codex-delegation.md b/.claude/codex-delegation.md index b63ee9e8b..b7863a4b0 100644 --- a/.claude/codex-delegation.md +++ b/.claude/codex-delegation.md @@ -23,6 +23,7 @@ Claude edits meta-config (CLAUDE.md, this file, `.codex/config.toml`, hook scrip 4. **Argue back if Claude misread the user.** The verbatim quote makes this checkable. 5. **Regenerate affected `.md` snapshots and screenshots** after any content/component change. Use `node packages/web/scripts/affected-routes.mjs` to pipe changed-file paths into `render-pages-to-markdown.ts --routes=` for targeted regen; fall back to full regen when the change touches shared primitives. 6. **Nothing committed without user approval.** Codex stages the changeset and reports; Claude relays the summary + diff scope; user OKs; then Claude commits on Codex's behalf (Codex can't touch `.git`). +7. **TODO.md update in the same staged changeset.** If the work resolves an unchecked item in TODO.md, Codex must edit TODO.md (mark done with `commit:short-sha` evidence, or delete the line if redundant) IN THE SAME STAGED CHANGESET. If the work doesn't touch any TODO.md item, Codex must include `todo-skipped: ` (e.g. "todo-skipped: net-new feature not previously listed") so the audit trail is explicit. Mike's TODO.md was 60%+ stale on 2026-05-17 because dispatches silently shipped work without closing the corresponding TODO lines — `enforce-codex-protocol.mjs` + `verify-ui-changes.mjs` now check this gate. ## NEVER run `next build` / `pnpm build` diff --git a/.claude/hooks/enforce-no-codex-in-commit-message.mjs b/.claude/hooks/enforce-no-codex-in-commit-message.mjs deleted file mode 100644 index e73ffca86..000000000 --- a/.claude/hooks/enforce-no-codex-in-commit-message.mjs +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env node -// enforce-no-codex-in-commit-message.mjs -// -// PreToolUse hook on Bash and Husky commit-msg hook: BLOCK if the -// commit message contains the word "Codex", "[codex]", or "codex/" -// in an attribution position. Per AGENTS.md: -// -// "Do not put `Codex`, `[codex]`, or `codex/` in branch names, -// pull request titles, or commit messages unless the human -// explicitly asks." -// -// Exception: literal hook/file names that contain "codex" are OK -// because they're descriptive references to actual files (e.g. -// `enforce-codex-background.mjs`). Attribution phrases like -// "Codex preflight clean" or "qa-passed: Codex " are NOT. -// -// Heuristic: distinguish attribution from literal reference. -// - "codex-" (kebab-case identifier) → likely a hook/file name → OK -// - ".mjs" / ".ts" / ".js" suffix nearby → file reference → OK -// - "Codex " followed by an agent id like "bxxxxxxxx" → ATTRIBUTION → BLOCK -// - "Codex preflight" / "Codex review" / "Codex audit" → ATTRIBUTION → BLOCK -// - "qa-passed: Codex" → ATTRIBUTION → BLOCK -// -// Bypass: include the literal string `human-authorized-codex-mention` -// in the message body only when Mike explicitly asked. -// -// 2026-05-16 trigger: codex stop-time review flagged commit c37160d1 -// for "qa-passed: Codex bjb7ndrvy"; multiple session commits had -// similar attribution phrases. - -import { existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; - -const HUMAN_AUTHORIZED_BYPASS = /\bhuman-authorized-codex-mention\b/i; - -function commandFlagFile(command, flag, cwd) { - const match = command.match(new RegExp(`${flag}\\s+("[^"]+"|'[^']+'|\\S+)`)); - if (!match?.[1]) return ""; - const rawPath = match[1].replace(/^["']|["']$/g, ""); - const path = /^[A-Za-z]:[\\/]/.test(rawPath) || rawPath.startsWith("/") - ? rawPath - : resolve(cwd, rawPath); - try { - return readFileSync(path, "utf-8"); - } catch { - return ""; - } -} - -function extractCommandMessage(command, cwd) { - const heredocMatch = command.match( - /<<\s*['"]?([A-Za-z_]+)['"]?\s*[\s\S]*?\n([\s\S]*?)\n\1/, - ); - const dashMMessage = [...command.matchAll(/-m\s+["']([\s\S]*?)["']/g)] - .map((match) => match[1]) - .filter(Boolean) - .join("\n\n"); - const fileMessage = commandFlagFile(command, "-F", cwd); - return [heredocMatch?.[2], dashMMessage, fileMessage] - .filter(Boolean) - .join("\n\n"); -} - -function detectedAttributionHits(message) { - if (!message || HUMAN_AUTHORIZED_BYPASS.test(message)) return []; - - // Patterns that flag ATTRIBUTION (not literal file-name reference): - // "Codex" as a standalone brand/reference in commit prose - // "[codex]" / "codex/" from the AGENTS.md enumerated forms - // "Codex preflight" / "qa-passed: Codex" / "via Codex" - // Literal lower-case kebab file names like enforce-codex-background.mjs are OK. - const attributionPatterns = [ - /\[codex\]/i, - /\bcodex\//i, - /(?:^|[^-\w])Codex\b(?![-/.])/, - /\bCodex\s+[a-z][0-9a-z]{6,}\b/, - /\bCodex\s+(preflight|review|audit|investigated|investigation|implementation|implemented|critique|agent|subagent|dispatch|fixed|wrote|drafted|verdict|verified|cleared|found)\b/i, - /(?:qa-passed|reviewed by|fixed by|implemented by|drafted by|audited by|approved by|cleared by|verified by|written by)[^a-z\n]*Codex\b/i, - /\bvia\s+Codex\b/i, - /\bby\s+Codex\b/i, - /\bfrom\s+Codex\b/i, - /\bwith\s+Codex\b/i, - /\bCodex\s+(says|did|ran|produced|returned|reported|found|caught|flagged)\b/i, - ]; - - return attributionPatterns - .map((re) => message.match(re)) - .filter(Boolean); -} - -function blockIfNeeded(message) { - const hits = detectedAttributionHits(message); - if (hits.length === 0) return; - - const msg = - `[enforce-no-codex-in-commit-message] BLOCKED — commit message contains "Codex" attribution.\n\n` + - `AGENTS.md forbids "Codex" / "[codex]" / "codex/" in branch names, PR titles,\n` + - `and commit messages unless the human explicitly asks.\n\n` + - `Attribution phrases detected:\n${hits.map((h) => ` - ${h?.[0]}`).join("\n")}\n\n` + - `Fix: rewrite the attribution without naming Codex. Examples:\n` + - ` - "qa-passed: Codex bjb7ndrvy" -> "qa-passed: preflight bjb7ndrvy — typecheck + ..."\n` + - ` - "Codex preflight clean" -> "preflight clean: typecheck + e2e + visual smoke"\n` + - ` - "audited by Codex" -> "audited via agent-id-here"\n\n` + - `Bypass (only if Mike explicitly asked): include the literal string\n` + - `\`human-authorized-codex-mention\` in the commit message body.\n\n` + - `Rule lives at: AGENTS.md\n` + - `Memory: feedback_no_codex_in_commit_messages.md`; - - process.stderr.write(msg + "\n"); - process.exit(2); -} - -try { - const commitMsgPath = process.argv[2]; - if (commitMsgPath && existsSync(commitMsgPath)) { - blockIfNeeded(readFileSync(commitMsgPath, "utf-8")); - process.exit(0); - } - - const raw = readFileSync(0, "utf-8"); - if (!raw || !raw.trim()) process.exit(0); - - const hookData = JSON.parse(raw); - if (hookData?.tool_name !== "Bash") process.exit(0); - - const command = String(hookData?.tool_input?.command ?? ""); - if (!command) process.exit(0); - - // Match `git commit` through common flag forms while skipping plumbing - // subcommands like `git commit-tree` and `git commit-graph`. - if (!/\bgit\b(?:(?!\bgit\b)[\s\S])*?\bcommit\b(?!(?:-tree|-graph)|\S)/.test(command)) { - process.exit(0); - } - - // Explicit human bypass. - if (HUMAN_AUTHORIZED_BYPASS.test(command)) process.exit(0); - - const message = extractCommandMessage(command, hookData?.cwd ?? process.cwd()); - - if (!message) process.exit(0); // can't inspect, fail-open - - blockIfNeeded(message); -} catch { - process.exit(0); -} diff --git a/.claude/hooks/review-loop-gate.mjs b/.claude/hooks/review-loop-gate.mjs deleted file mode 100644 index 3e19c217e..000000000 --- a/.claude/hooks/review-loop-gate.mjs +++ /dev/null @@ -1,531 +0,0 @@ -#!/usr/bin/env node -// review-loop-gate.mjs -// -// Claude hook for Mike's one-at-a-time page review protocol. -// -// Modes: -// --post-edit PostToolUse on Edit/Write/MultiEdit; append touched -// user-facing routes to the per-branch markdown queue. -// --post-ask PostToolUse on AskUserQuestion; record a sentinel for -// this turn/top queue item. -// --session-start SessionStart; load/surface current queue context. -// --stop Stop; block turn-end until AskUserQuestion is used when -// Pending pages exist. -// -// Exit codes: -// 0 allow -// 2 block Stop; stderr tells Claude exactly what to do next -// -// Fail-open for unexpected hook/runtime errors except explicit Stop blocks. - -import { execFileSync, spawnSync } from "node:child_process"; -import { - existsSync, - mkdirSync, - readFileSync, - statSync, - writeFileSync, -} from "node:fs"; -import path from "node:path"; - -const MODE = process.argv[2] ?? ""; -const RepoRoot = process.env.CLAUDE_PROJECT_DIR - ? path.resolve(process.env.CLAUDE_PROJECT_DIR) - : process.cwd(); -const StateDir = path.join(RepoRoot, ".claude", "state"); -const QueueFile = process.env.CLAUDE_REVIEW_QUEUE_FILE - ? path.resolve(process.env.CLAUDE_REVIEW_QUEUE_FILE) - : path.join(StateDir, `review-queue-${branchSlug()}.md`); - -class StopBlock extends Error {} - -try { - const hookData = readHookData(); - - if (MODE === "--post-edit") { - postEdit(hookData); - process.exit(0); - } - - if (MODE === "--post-ask") { - postAsk(hookData); - process.exit(0); - } - - if (MODE === "--session-start") { - sessionStart(); - process.exit(0); - } - - if (MODE === "--stop") { - stopGate(hookData); - process.exit(0); - } - - process.exit(0); -} catch (error) { - if (error instanceof StopBlock) { - process.stderr.write(`${error.message}\n`); - process.exit(2); - } - process.exit(0); -} - -function postEdit(hookData) { - const tool = hookData?.tool_name; - if (!["Edit", "Write", "MultiEdit"].includes(tool)) return; - - const filePath = hookData?.tool_input?.file_path; - if (typeof filePath !== "string" || !filePath.trim()) return; - - const relPath = toRepoRelative(filePath); - if (!isReviewablePath(relPath)) return; - - const entries = entriesForTouchedPath(relPath); - if (!entries.length) return; - - appendPendingEntries(entries); -} - -function postAsk(hookData) { - if (hookData?.tool_name && hookData.tool_name !== "AskUserQuestion") return; - - const queue = readQueue(); - const pending = parsePending(queue); - const sentinel = { - sessionId: sessionId(hookData), - transcriptPath: normalizedTranscriptPath(hookData), - turnMarker: currentTurnMarker(hookData), - queueFingerprint: queueFingerprint(pending), - topPending: pending[0] ?? null, - ts: new Date().toISOString(), - }; - - mkdirSync(StateDir, { recursive: true }); - writeFileSync(sentinelPath(hookData), `${JSON.stringify(sentinel, null, 2)}\n`, "utf8"); -} - -function sessionStart() { - const queue = readQueue(); - const pending = parsePending(queue); - if (!pending.length) return; - - const top = pending[0]; - process.stdout.write( - [ - `[review-loop-gate] Review queue loaded: ${pending.length} Pending item(s).`, - `Top Pending page: ${top.label}${top.note ? ` - ${top.note}` : ""}`, - `Queue: ${QueueFile}`, - "Every Claude response that ends while Pending is non-empty must call AskUserQuestion for one page.", - ].join("\n") + "\n", - ); -} - -function stopGate(hookData) { - const queue = readQueue(); - const pending = parsePending(queue); - if (!pending.length) return; - - if (askedThisTurn(hookData, pending)) return; - - const top = pending[0]; - throw new StopBlock(formatStopBlock(pending, top)); -} - -function askedThisTurn(hookData, pending) { - const transcript = transcriptAskState(hookData); - const fingerprint = queueFingerprint(pending); - if (transcript.hasAskUserQuestion) { - if (!transcript.askTimestampMs) return true; - const queueMtimeMs = fileMtimeMs(QueueFile); - if (!queueMtimeMs || transcript.askTimestampMs + 1000 >= queueMtimeMs) return true; - } - - const sentinel = readSentinel(hookData); - if (!sentinel) return false; - if (sentinel.sessionId !== sessionId(hookData)) return false; - if (sentinel.turnMarker !== currentTurnMarker(hookData)) return false; - if (sentinel.queueFingerprint !== fingerprint) return false; - - return true; -} - -function transcriptAskState(hookData) { - const transcriptPath = normalizedTranscriptPath(hookData); - if (!transcriptPath || !existsSync(transcriptPath)) { - return { hasAskUserQuestion: false, askTimestampMs: null }; - } - - const entries = readJsonl(transcriptPath); - let lastHumanIndex = -1; - for (let index = 0; index < entries.length; index += 1) { - if (isHumanUserEntry(entries[index])) lastHumanIndex = index; - } - - let askTimestampMs = null; - for (let index = lastHumanIndex + 1; index < entries.length; index += 1) { - const entry = entries[index]; - if (!entryHasToolUse(entry, "AskUserQuestion")) continue; - const ts = Date.parse(entry.timestamp ?? ""); - askTimestampMs = Number.isFinite(ts) ? ts : null; - } - - return { - hasAskUserQuestion: askTimestampMs !== null || entries - .slice(lastHumanIndex + 1) - .some((entry) => entryHasToolUse(entry, "AskUserQuestion")), - askTimestampMs, - }; -} - -function currentTurnMarker(hookData) { - const transcriptPath = normalizedTranscriptPath(hookData); - if (!transcriptPath || !existsSync(transcriptPath)) { - return `no-transcript:${sessionId(hookData)}`; - } - - const entries = readJsonl(transcriptPath); - let marker = `transcript:${transcriptPath}:no-human`; - for (const entry of entries) { - if (!isHumanUserEntry(entry)) continue; - marker = entry.uuid || entry.timestamp || `human-index:${entries.indexOf(entry)}`; - } - return marker; -} - -function isHumanUserEntry(entry) { - if (entry?.type !== "user") return false; - if (entry?.sourceToolAssistantUUID) return false; - const content = entry?.message?.content; - if (Array.isArray(content)) { - return !content.every((part) => part?.type === "tool_result"); - } - return typeof content === "string"; -} - -function entryHasToolUse(entry, expectedName) { - if (entry?.type !== "assistant") return false; - const content = entry?.message?.content; - if (!Array.isArray(content)) return false; - return content.some( - (part) => - part?.type === "tool_use" && - typeof part.name === "string" && - part.name.includes(expectedName), - ); -} - -function appendPendingEntries(entries) { - const original = readQueue(); - const pending = parsePending(original); - const pendingLabels = new Set(pending.map((item) => item.label)); - const newEntries = uniqueEntries(entries).filter((entry) => !pendingLabels.has(entry.label)); - if (!newEntries.length) return; - - let text = original || initialQueue(); - text = touchUpdatedLine(text); - const section = "## Pending — Auto-added by review-loop hook"; - - if (!text.includes(section)) { - const approvedIndex = text.search(/\n## Approved\b/); - const insert = `\n${section}\n`; - if (approvedIndex >= 0) { - text = `${text.slice(0, approvedIndex)}${insert}${text.slice(approvedIndex)}`; - } else { - text = `${text.replace(/\s*$/, "\n")}${insert}`; - } - } - - const lines = text.split(/\r?\n/); - const sectionIndex = lines.findIndex((line) => line.trim() === section); - let insertIndex = lines.length; - for (let index = sectionIndex + 1; index < lines.length; index += 1) { - if (/^##\s+/.test(lines[index])) { - insertIndex = index; - break; - } - } - - const sectionBody = lines.slice(sectionIndex + 1, insertIndex); - const noneIndex = sectionBody.findIndex((line) => /^\(none yet\)\s*$/.test(line)); - if (noneIndex >= 0) lines.splice(sectionIndex + 1 + noneIndex, 1); - - const entryLines = newEntries.map((entry) => `- [ ] ${entry.label} - touched ${entry.file}`); - const adjustedInsertIndex = noneIndex >= 0 && sectionIndex + 1 + noneIndex < insertIndex - ? insertIndex - 1 - : insertIndex; - lines.splice(adjustedInsertIndex, 0, ...entryLines); - - mkdirSync(path.dirname(QueueFile), { recursive: true }); - writeFileSync(QueueFile, `${lines.join("\n").replace(/\s+$/, "")}\n`, "utf8"); -} - -function entriesForTouchedPath(relPath) { - if (/^packages\/web\/src\/app\//.test(relPath)) { - const route = routeFromAppPath(relPath); - return route ? [{ label: route, file: relPath }] : []; - } - - if (isEmailPath(relPath)) { - return [{ label: emailLabel(relPath), file: relPath }]; - } - - const affected = affectedRoutes(relPath); - if (affected.length) { - return affected.map((route) => ({ label: route, file: relPath })); - } - - if (/^packages\/web\/src\/components\//.test(relPath)) { - return [{ label: `component: ${relPath}`, file: relPath }]; - } - - if (/^packages\/web\/src\/lib\/(routes\.ts|messaging\.ts)$/.test(relPath)) { - return [{ label: `shared copy: ${relPath}`, file: relPath }]; - } - - return []; -} - -function affectedRoutes(relPath) { - const script = path.join(RepoRoot, "packages", "web", "scripts", "affected-routes.mjs"); - if (!existsSync(script)) return []; - - const result = spawnSync(process.execPath, [script, relPath], { - cwd: RepoRoot, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - timeout: 2500, - }); - if (result.status !== 0) return []; - - return result.stdout - .split(",") - .map((route) => route.trim()) - .filter(Boolean); -} - -function routeFromAppPath(relPath) { - const prefix = "packages/web/src/app/"; - const appRel = relPath.slice(prefix.length); - const rawSegments = appRel.split("/"); - if (rawSegments[0] === "api") return null; - - const fileName = rawSegments.at(-1) ?? ""; - if (!/\.(tsx|ts|jsx|js|md)$/.test(fileName)) return null; - - let segments = rawSegments.slice(0, -1); - const stopAt = segments.findIndex((segment) => - ["components", "_components", "lib", "utils", "hooks"].includes(segment), - ); - if (stopAt >= 0) segments = segments.slice(0, stopAt); - - const routeSegments = segments.filter( - (segment) => - segment && - !segment.startsWith("(") && - !segment.startsWith("@") && - !segment.startsWith("_"), - ); - return routeSegments.length ? `/${routeSegments.join("/")}` : "/"; -} - -function isReviewablePath(relPath) { - if (/\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs)$/.test(relPath)) return false; - if (/\/__tests__\//.test(relPath)) return false; - if (/^packages\/web\/src\/app\//.test(relPath)) return true; - if (/^packages\/web\/src\/components\//.test(relPath)) return true; - if (/^packages\/web\/src\/lib\/(routes\.ts|messaging\.ts)$/.test(relPath)) return true; - if (/^packages\/web\/src\/lib\/email\//.test(relPath)) return true; - if (/^packages\/web\/src\/emails\//.test(relPath)) return true; - if (/^packages\/web\/emails\//.test(relPath)) return true; - return false; -} - -function isEmailPath(relPath) { - return ( - /^packages\/web\/src\/lib\/email\//.test(relPath) || - /^packages\/web\/src\/emails\//.test(relPath) || - /^packages\/web\/emails\//.test(relPath) - ); -} - -function emailLabel(relPath) { - const base = path.posix.basename(relPath).replace(/\.(email\.md|tsx|ts|jsx|js|md)$/, ""); - return `email: ${base || relPath}`; -} - -function parsePending(markdown) { - const pending = []; - let inPending = false; - for (const line of markdown.split(/\r?\n/)) { - const heading = line.match(/^##\s+(.+?)\s*$/); - if (heading) { - inPending = heading[1].startsWith("Pending"); - continue; - } - if (!inPending) continue; - const item = line.match(/^\s*-\s+\[\s\]\s+(.+?)(?:\s+[—-]\s+(.+))?\s*$/); - if (!item) continue; - const label = (item[1] ?? "").trim(); - if (!label) continue; - pending.push({ - label, - note: (item[2] ?? "").trim(), - raw: line.trim(), - }); - } - return pending; -} - -function formatStopBlock(pending, top) { - return [ - `[review-loop-gate] ${pending.length} Pending review item(s) remain, and this turn has not called AskUserQuestion for the current queue item.`, - "", - `Top Pending page: ${top.label}${top.note ? ` - ${top.note}` : ""}`, - `Queue file: ${QueueFile}`, - "", - "Mike's protocol is strict: every response that ends with Pending pages in the review queue must terminate with an AskUserQuestion about one page.", - "", - "Before ending the turn, surface the topmost Pending page with AskUserQuestion:", - `- Route/page: ${top.label}`, - top.note ? `- What changed: ${top.note}` : "- What changed: read the queue row and the page diff, then summarize it in one line.", - "- Options: A: Looks good, ship it; B-D: 2-3 specific predicted complaints from the diff; Other: Mike's freeform complaint.", - "", - "After AskUserQuestion fires, the AskUserQuestion PostToolUse hook records the sentinel and this Stop hook will allow the turn to end.", - ].join("\n"); -} - -function readQueue() { - if (!existsSync(QueueFile)) return ""; - return readFileSync(QueueFile, "utf8"); -} - -function initialQueue() { - return [ - `# Review queue - ${branchName()}`, - `Updated: ${new Date().toISOString()}`, - "", - "## Pending", - "(none yet)", - "", - "## Approved", - "(none yet)", - "", - "## Needs fixes", - "(none yet)", - "", - ].join("\n"); -} - -function touchUpdatedLine(text) { - const now = `Updated: ${new Date().toISOString()}`; - if (/^Updated:\s*.+$/m.test(text)) return text.replace(/^Updated:\s*.+$/m, now); - return text.replace(/^# .+$/m, (heading) => `${heading}\n${now}`); -} - -function queueFingerprint(pending) { - const top = pending[0]?.raw ?? ""; - return `${pending.length}:${top}`; -} - -function readSentinel(hookData) { - const filePath = sentinelPath(hookData); - if (!existsSync(filePath)) return null; - try { - return JSON.parse(readFileSync(filePath, "utf8")); - } catch { - return null; - } -} - -function sentinelPath(hookData) { - return path.join(StateDir, `review-loop-ask-user-question-${safeName(sessionId(hookData))}.json`); -} - -function sessionId(hookData) { - return String(hookData?.session_id ?? hookData?.sessionId ?? "unknown-session"); -} - -function normalizedTranscriptPath(hookData) { - const transcriptPath = hookData?.transcript_path ?? hookData?.transcriptPath; - return typeof transcriptPath === "string" && transcriptPath.trim() - ? path.resolve(transcriptPath) - : null; -} - -function readJsonl(filePath) { - try { - return readFileSync(filePath, "utf8") - .split(/\r?\n/) - .filter(Boolean) - .map((line) => { - try { - return JSON.parse(line); - } catch { - return null; - } - }) - .filter(Boolean); - } catch { - return []; - } -} - -function readHookData() { - try { - const raw = readFileSync(0, "utf8"); - if (raw && raw.trim()) return JSON.parse(raw); - } catch { - // No/bad stdin: hooks fail open. - } - return {}; -} - -function toRepoRelative(filePath) { - const normalized = filePath.replace(/\\/g, "/"); - if (normalized.startsWith("packages/")) return normalized; - - const absolute = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(RepoRoot, filePath); - let rel = path.relative(RepoRoot, absolute).replace(/\\/g, "/"); - if (rel.startsWith("../")) rel = normalized; - return rel; -} - -function branchSlug() { - return safeName(branchName()).replace(/^-+|-+$/g, "") || "unknown-branch"; -} - -function branchName() { - try { - return execFileSync("git", ["-C", RepoRoot, "branch", "--show-current"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim() || "unknown-branch"; - } catch { - return "unknown-branch"; - } -} - -function safeName(value) { - return String(value).replace(/[^A-Za-z0-9._-]+/g, "-"); -} - -function uniqueEntries(entries) { - const seen = new Set(); - const out = []; - for (const entry of entries) { - const key = `${entry.label}\n${entry.file}`; - if (seen.has(key)) continue; - seen.add(key); - out.push(entry); - } - return out; -} - -function fileMtimeMs(filePath) { - try { - return statSync(filePath).mtimeMs; - } catch { - return 0; - } -} diff --git a/.claude/hooks/review-loop-gate.test.mjs b/.claude/hooks/review-loop-gate.test.mjs deleted file mode 100644 index 337ffa92b..000000000 --- a/.claude/hooks/review-loop-gate.test.mjs +++ /dev/null @@ -1,232 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(__dirname, "../.."); -const hookPath = path.join(__dirname, "review-loop-gate.mjs"); - -test("Stop blocks a non-empty Pending queue until AskUserQuestion is used", () => { - const fixture = createFixture(); - try { - writeQueue(fixture.queuePath, [ - "## Pending", - "- [ ] /donate — review the donation hero", - "", - "## Approved", - "(none yet)", - ]); - writeTranscript(fixture.transcriptPath, [ - humanMessage("u1", "Review these pages."), - ]); - - const result = runHook(fixture, ["--stop"], { - session_id: "session-a", - transcript_path: fixture.transcriptPath, - }); - - assert.equal(result.status, 2); - assert.match(result.stderr, /AskUserQuestion/); - assert.match(result.stderr, /\/donate/); - assert.match(result.stderr, /review the donation hero/); - } finally { - rmSync(fixture.root, { recursive: true, force: true }); - } -}); - -test("Stop allows the turn when AskUserQuestion was called after the last human prompt", () => { - const fixture = createFixture(); - try { - writeQueue(fixture.queuePath, [ - "## Pending", - "- [ ] /donate — review the donation hero", - ]); - writeTranscript(fixture.transcriptPath, [ - humanMessage("u1", "Review these pages."), - assistantToolUse("a1", "AskUserQuestion"), - ]); - - const result = runHook(fixture, ["--stop"], { - session_id: "session-a", - transcript_path: fixture.transcriptPath, - }); - - assert.equal(result.status, 0); - assert.equal(result.stderr, ""); - } finally { - rmSync(fixture.root, { recursive: true, force: true }); - } -}); - -test("Stop allows the turn when the AskUserQuestion sentinel matches the current turn", () => { - const fixture = createFixture(); - try { - writeQueue(fixture.queuePath, [ - "## Pending", - "- [ ] /donate — review the donation hero", - ]); - writeTranscript(fixture.transcriptPath, [ - humanMessage("u1", "Review these pages."), - ]); - - const postAsk = runHook(fixture, ["--post-ask"], { - session_id: "session-a", - transcript_path: fixture.transcriptPath, - tool_name: "AskUserQuestion", - }); - assert.equal(postAsk.status, 0); - - const stop = runHook(fixture, ["--stop"], { - session_id: "session-a", - transcript_path: fixture.transcriptPath, - }); - - assert.equal(stop.status, 0); - } finally { - rmSync(fixture.root, { recursive: true, force: true }); - } -}); - -test("PostToolUse appends touched app page routes to the review queue", () => { - const fixture = createFixture(); - try { - writeQueue(fixture.queuePath, [ - "# Review queue — feature/test", - "Updated: 2026-05-15T00:00:00Z", - "", - "## Pending", - "(none yet)", - "", - "## Approved", - "(none yet)", - ]); - - const result = runHook(fixture, ["--post-edit"], { - session_id: "session-a", - tool_name: "Edit", - tool_input: { - file_path: path.join(fixture.root, "packages/web/src/app/treaty/page.tsx"), - new_string: "Vote now.", - }, - }); - - assert.equal(result.status, 0); - const queue = readFileSync(fixture.queuePath, "utf8"); - assert.match(queue, /## Pending/); - assert.match(queue, /- \[ \] \/treaty - touched packages\/web\/src\/app\/treaty\/page\.tsx/); - } finally { - rmSync(fixture.root, { recursive: true, force: true }); - } -}); - -test("PostToolUse handles MultiEdit edits arrays and does not duplicate pending routes", () => { - const fixture = createFixture(); - try { - writeQueue(fixture.queuePath, [ - "## Pending", - "- [ ] /organizations/[id] — existing review", - "", - "## Approved", - "(none yet)", - ]); - - const filePath = path.join( - fixture.root, - "packages/web/src/app/organizations/[id]/page.tsx", - ); - const result = runHook(fixture, ["--post-edit"], { - session_id: "session-a", - tool_name: "MultiEdit", - tool_input: { - file_path: filePath, - edits: [ - { old_string: "old", new_string: "new" }, - { old_string: "older", new_string: "newer" }, - ], - }, - }); - - assert.equal(result.status, 0); - const queue = readFileSync(fixture.queuePath, "utf8"); - assert.equal(queue.match(/\/organizations\/\[id\]/g)?.length, 1); - } finally { - rmSync(fixture.root, { recursive: true, force: true }); - } -}); - -test("SessionStart loads the current queue and surfaces the top Pending item", () => { - const fixture = createFixture(); - try { - writeQueue(fixture.queuePath, [ - "# Review queue — feature/test", - "", - "## Pending — Priority 1", - "- [ ] /donate — review the donation hero", - "- [ ] /treaty — review the treaty flow", - ]); - - const result = runHook(fixture, ["--session-start"], { - session_id: "session-a", - }); - - assert.equal(result.status, 0); - assert.match(result.stdout, /Review queue loaded/); - assert.match(result.stdout, /\/donate/); - assert.doesNotMatch(result.stdout, /\/treaty.*top/s); - } finally { - rmSync(fixture.root, { recursive: true, force: true }); - } -}); - -function createFixture() { - const root = mkdtempSync(path.join(tmpdir(), "review-loop-gate-")); - mkdirSync(path.join(root, ".claude/state"), { recursive: true }); - mkdirSync(path.join(root, ".git"), { recursive: true }); - const queuePath = path.join(root, ".claude/state/review-queue-feature-test.md"); - const transcriptPath = path.join(root, "transcript.jsonl"); - return { root, queuePath, transcriptPath }; -} - -function runHook(fixture, args, hookData) { - return spawnSync(process.execPath, [hookPath, ...args], { - cwd: repoRoot, - env: { - ...process.env, - CLAUDE_PROJECT_DIR: fixture.root, - CLAUDE_REVIEW_QUEUE_FILE: fixture.queuePath, - }, - input: JSON.stringify(hookData), - encoding: "utf8", - }); -} - -function writeQueue(filePath, lines) { - writeFileSync(filePath, `${lines.join("\n")}\n`, "utf8"); -} - -function writeTranscript(filePath, entries) { - writeFileSync(filePath, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8"); -} - -function humanMessage(uuid, text) { - return { - type: "user", - uuid, - message: { role: "user", content: text }, - }; -} - -function assistantToolUse(uuid, name) { - return { - type: "assistant", - uuid, - message: { - role: "assistant", - content: [{ type: "tool_use", id: `toolu_${uuid}`, name, input: {} }], - }, - }; -} diff --git a/.claude/hooks/verify-ui-changes.mjs b/.claude/hooks/verify-ui-changes.mjs index ace870fd1..414010bfe 100644 --- a/.claude/hooks/verify-ui-changes.mjs +++ b/.claude/hooks/verify-ui-changes.mjs @@ -154,6 +154,37 @@ ${userFacingChanges.slice(0, 8).map((f) => ` - ${f}`).join("\n")}${ } } + // --- TODO.md drift gate ------------------------------------------------- + // Per CLAUDE.md "Update TODO.md in the same commit as the work it covers" + // — but the rule was rotting silently before this hook. If a commit + // touches packages/web/src/ and the message body doesn't claim + // `todo-touched:` or `todo-skipped: `, and TODO.md isn't in the + // staged set, advisory-flag it. Forces the next session to resolve + // whether the in-flight work closes any TODO.md items. Advisory only — + // never blocks the commit. + const touchesWebSrc = allChanged.some((f) => + /^packages\/web\/src\//.test(f), + ); + const todoStaged = allChanged.includes("TODO.md"); + if ( + touchesWebSrc && + !todoStaged && + hookData?.tool_name === "Bash" + ) { + const cmd = hookData?.tool_input?.command ?? ""; + const hasTodoMarker = /todo[-\s]?(touched|skipped|none)\s*:/i.test(cmd); + if (!hasTodoMarker) { + pushViolation( + "TODO_DRIFT", + 1, + `TODO DRIFT GATE (advisory): commit touches packages/web/src/ but does not stage TODO.md and the message has no \`todo-touched:\` / \`todo-skipped: \` marker. + + If this work resolves a TODO.md item, edit TODO.md in the same commit and add \`todo-touched: \` to the message. If it doesn't, add \`todo-skipped: \` so future audits know it was intentional. This is the rule from CLAUDE.md "Update TODO.md in the same commit as the work it covers" — silent drift is what made today's TODO.md 60%+ stale.`, + { blocking: false }, + ); + } + } + // --- Check 1: UI changes without a fresh screenshot --------------------- if (uiFiles.length) { let lastUiMtime = 0; diff --git a/.claude/plans/referred-voters-list.md b/.claude/plans/referred-voters-list.md new file mode 100644 index 000000000..2b6d3e63f --- /dev/null +++ b/.claude/plans/referred-voters-list.md @@ -0,0 +1,294 @@ +# Plan: Humanity Manager Status Report (replaces "Referred Voters List") + +## Brief + +Original direction: build a "list of humans who voted via my referral link" on /dashboard. Phase 1 CEO review (Codex + Claude subagent, both independent) converged on SCRAP_AND_REPLACE. Mike pivot with "use your best judgment": ship the FULL Humanity Manager Status Report from TODO.md:399-411 instead — TWO cards, not one. + +Card 1 (the propagation lever): **"Humans waiting on your reminder."** Lists invitations Mike sent (or referral clicks his link generated) where the recipient hasn't voted yet. Each row has a copyable reminder text sourced from `share-templates.ts`. This is the recruitment-action surface the campaign was missing. + +Card 2 (the celebration / answer-Mike's-original-question): **"Humans you recruited."** Lists humans who actually VOTED YES on the treaty via Mike's referral link. Concrete names + faces (when public) of the chain doubling. + +Mike originally asked "I thought we previously made it possible to see all the people that voted with our links" — confirmed via investigation we only have a count today. This plan ships the list AND the more-load-bearing reminder action that BOTH Phase 1 reviewers said was the actual K-factor lever. + +Voice: "reminder," never "nudge" (Mike directive, see memory [[feedback_reminder_not_nudge]]). CLAUDE.md's "remind your overdue presidents/employees" frame extends to voter reminders. + +## Current state — ASCII diagram + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ /dashboard │ +│ │ +│ ┌────────────────────────┐ ┌────────────────────────┐ │ +│ │ YOUR PROFILE │ │ REFERRAL LINK │ │ +│ │ ProfileCard.tsx │ │ count: 12 ←── ABSTRACT│ │ +│ └────────────────────────┘ └────────────────────────┘ │ +│ │ +│ ┌────────────────────────┐ │ +│ │ Other cards │ GAP: no reminder action surface │ +│ │ ... │ GAP: no list of WHO voted via my link │ +│ └────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + +Data layer (already ships): + ReferendumVote { userId, referendumId, answer (VotePosition), referredByUserId, ... } + ← source of truth for "voted via my link" — Codex critique caught this + ReferralInvitation { referrerUserId, recipientPersonId, convertedVoteId, ... } + ← source of truth for "invited but not yet voted" + ReferralClick { code, referrerUserId, shareAttemptId, createdAt, ... } + ← secondary: clicks on my link (some convert, some don't) + ShareAttempt { ... } + ← copy/send events keyed to referrer + Referral { userId, referredByUserId, answer (ReferralAnswer), ... } + ← legacy: signup-flow attribution (NOT vote attribution). Skip for celebration card. + +Existing helpers: + getReferralCount(userId) → integer + getReferralCountsByUserIds(userIds[]) → Map + share-templates.ts — canonical reminder copy registry +``` + +## Proposed state — ASCII diagram + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ /dashboard │ +│ │ +│ ┌────────────────────────┐ ┌─────────────────────────┐ │ +│ │ YOUR PROFILE │ │ REFERRAL LINK + count │ │ +│ │ ProfileCard.tsx │ │ (unchanged) │ │ +│ └────────────────────────┘ └─────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ HUMANS WAITING ON YOUR REMINDER 🔔 NEW │ Card 1 │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ 👤 Sarah (invited 4d ago, hasn't voted) │ │ │ +│ │ │ [📋 Copy reminder text] [✉ Send] │ │ │ +│ │ ├─────────────────────────────────────────────────┤ │ │ +│ │ │ 👤 +47 anonymous clicks (no name captured) │ │ │ +│ │ │ [📋 Copy generic reminder] │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ │ [Show more] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ HUMANS YOU RECRUITED 🎉 NEW │ Card 2 │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ 🟢 Alice Chen "data scientist" │ │ │ +│ │ │ voted 3 days ago · recruited 2 more humans │ │ │ +│ │ ├─────────────────────────────────────────────────┤ │ │ +│ │ │ 👤 Anonymous Humanity Manager │ │ │ +│ │ │ voted 1 week ago │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ │ [Show more] [Share your link again →] │ │ +│ └─────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────┘ + +Data flow: + Card 1: getOverdueReferralInvitations(userId, { limit }) + → ReferralInvitation where referrerUserId=userId AND convertedVoteId IS NULL + → joins recipientPersonId for displayName when available + → ALSO: bucket of unnamed ReferralClicks (count only, generic reminder) + → returns rows with status="pending" + copyable reminder text key + + Card 2: getReferredVoters(userId, { limit, cursor }) + → ReferendumVote where referredByUserId=userId + AND referendumId = TREATY_REFERENDUM_ID + AND answer = VotePosition.YES + → joins userId.person + → batched: getReferralCountsByUserIds(...) for downstream-count badges + → privacy: when person.isPublic=false, redact identity fields + → returns: [{ voteId, votedAt, person: {…or anonymized}, downstreamCount }] + + + → renders two cards in priority order (reminder card FIRST, celebration second) + → copy buttons use share-templates.ts (canonical registry, no new copy) + → "reminder" verb throughout (per [[feedback_reminder_not_nudge]]) +``` + +## Phase 2 pivot (Mike approved C) + +After Phase 2 design dual voices: existing `ReferralInvitationStatusCard.tsx` already does 70% of the proposed reminder card. EXTEND it + rename to "Humanity Manager Status Report" instead of building a new component. Cuts diff from ~500 LOC to ~150 LOC. Reuses status chips, filter UI, Inverse Kills Score, treaty styling. + +## Step list + +- [ ] **Extend** `packages/web/src/components/dashboard/ReferralInvitationStatusCard.tsx`: + - Rename component + heading: "Earth Optimization Tasks" → "Humanity Manager Status Report" + - Rename file to `HumanityManagerStatusReport.tsx` (keep export so any other consumers can be migrated) + - Remove `if (isLoading || invitations.length === 0) return null;` — render start-the-chain CTA + share link inline when empty + - Add anonymous-clicks aggregate row (one "+N humans clicked but didn't register" line, no per-click controls) + - Add celebration section: `ReferendumVote.referredByUserId` rows NOT already represented by a `ReferralInvitation` (anonymous votes from raw referral URL) + - Add row-level "Copy reminder" affordance using `packages/web/src/lib/tasks/share-templates.ts` keys (correct path per Codex Phase 2 critique) + - Aggregate private/anonymous voters into a single "+N private" line at the bottom of the celebration section (don't render inline "Anonymous Humanity Manager" rows — Phase 2 reviewer consensus) +- [ ] Add `getReferredVoters(userId, opts)` in `referral.server.ts` querying `ReferendumVote where referredByUserId=userId AND referendumId=TREATY_REFERENDUM_ID AND answer=VotePosition.YES` with privacy redaction. +- [ ] Add `getAnonymousReferralClickCount(userId)` — count `ReferralClick` rows where referrer=userId minus those with corresponding `ReferralInvitation` or `ReferendumVote`. Single integer, no row data. +- [ ] Extend the `/api/referral-invitations` route (or add a sibling) to return celebration data + anonymous count alongside the existing invitation list. OR fetch all three server-side in `dashboard/page.tsx`. +- [ ] Wire into `dashboard/page.tsx` — pass new data into the renamed component. +- [ ] Card-level error boundaries (per Phase 2 reviewers): if celebration query fails, reminder section still renders, and vice versa. +- [ ] Mobile layout: stacked row interior, full-width 44px Copy button below sm breakpoint (per Phase 2 reviewers). +- [ ] Drop NEW emoji badges (`🔔 NEW` / `🎉 NEW`) — off-voice for treaty surfaces (per Phase 2 reviewers). +- [ ] Send button: DEFER. Phase 2 reviewers split (Codex: define channel matrix; Claude subagent: drop). Default to Copy-only for v1 — single affordance per row, predictable across devices. Add channel-specific Send in a follow-up PR if data shows demand. +- [ ] Unit tests: `getReferredVoters` (treaty-referendum filter, YES filter, privacy redaction), `getAnonymousReferralClickCount` (excludes converted rows), new empty-state rendering. +- [ ] Integration test: dashboard renders combined card with seeded fixtures (mix of converted invitations, raw-link votes, private profiles, anonymous clicks). +- [ ] Run `pnpm --filter @optimitron/web exec tsc --noEmit` clean. +- [ ] Regenerate `/dashboard/page.logged-in.md`. + +## Risks + +| # | Risk | Severity | Mitigation | +|---|------|----------|------------| +| 1 | **Privacy default = false.** Most rendered rows in card 2 will show "Anonymous." Reviewers flagged this as feature-gutting. | HIGH | Frame the empty/anonymized state as part of the value: "Most voters keep their identity private — that's normal. Each anonymized row is still a real human you recruited." Defer vote-time consent prompt to follow-up PR (not blocking). | +| 2 | **No K-factor instrumentation yet (TODO #34).** Without it, can't measure if this feature moves the needle. | HIGH | Track #34 as separate follow-up PR. Leading metric we CAN measure today: `ShareAttempt` count per user in 7 days after first dashboard view. If reminder-card click → copy-reminder → ShareAttempt count rises, the feature is working. | +| 3 | **Data source pitfall (now fixed in this plan).** Codex caught: `Referral.answer=YES` ≠ vote. Use `ReferendumVote.referredByUserId` for celebration card; `ReferralInvitation.convertedVoteId IS NULL` for reminder card. | RESOLVED | Schema verified 2026-05-17: ReferendumVote line 4278 confirms `referredByUserId`. | +| 4 | **Reminder card requires sufficient invitation data.** If users haven't been creating `ReferralInvitation` rows (vs raw share links), the reminder card stays empty. | MEDIUM | Verify in code: which user actions create ReferralInvitation rows? If it's only the assignTask flow on /people/[id], reminder card has limited audience. May need to backfill from ReferralClick rows where referrer is known but click didn't convert. | +| 5 | **Privacy leak via the anonymous-clicks bucket.** Showing "+47 anonymous clicks" could leak click-count to attackers timing-attacking the referrer's audience. | LOW | Round down to nearest 5 ("+45 humans clicked"); rate-limit if dashboard is publicly accessible (it shouldn't be — it's the authed dashboard). | +| 6 | **`share-templates.ts` reminder copy might not exist for this surface.** | MEDIUM | Verify the registry has a "reminder to overdue invited voter" template before building. If absent, add it as the FIRST commit of the PR; don't fork the registry. | + +## Files to touch + +| Path | Why | +|------|-----| +| `packages/web/src/lib/referral.server.ts` | Add `getReferredVoters()` + `getOverdueReferralInvitations()` | +| `packages/web/src/lib/__tests__/referral.server.test.ts` | Tests (create if absent) | +| `packages/web/src/components/dashboard/HumanityManagerStatusReport.tsx` | NEW | +| `packages/web/src/lib/share-templates.ts` | Possibly: add reminder-to-overdue-invitee template if absent | +| `packages/web/src/app/dashboard/page.tsx` | Wire fetch + render | +| `packages/web/src/app/dashboard/page.logged-in.md` | Regenerated snapshot | +| `packages/web/src/types/dashboard.ts` | Types for client/server boundary | + +Estimated diff: ~400-500 lines (two server functions, one component with two cards, tests, dashboard wiring, possibly one share-template addition). No schema changes. + +## Out of scope (defer) + +- Vote-time consent prompt for name visibility (follow-up PR; could unlock anonymized rows but isn't blocking ship). +- K-factor instrumentation (TODO #34, separate PR right after). +- Multi-level descendant tree. +- Upstream chain "you were recruited by X" — Codex flagged as potentially higher-leverage; defer to its own plan after this ships. +- Notification / digest email for downstream events — defer; can ride the same data once these queries exist. +- CSV export / share screenshot. + +## Research log + +Repo-internal provenance verified before drafting (file:line refs): + +- `packages/db/prisma/schema.prisma:3487` — `Referral` model: signup-flow attribution, NOT vote attribution. Schema comment explicit: "Whether the user opted into the referral/signup flow." +- `packages/db/prisma/schema.prisma:4278` — `ReferendumVote.referredByUserId` is the source of truth for "voted via my link." Includes `voteSource` (SELF vs represented). +- `packages/db/prisma/schema.prisma:3568` — `ReferralInvitation.convertedVoteId String? @unique` — null when invitation sent but no vote yet. The reminder-card data source. +- `packages/db/prisma/schema.prisma:3526` — `ReferralClick.referrerUserId` — clicks on link where referrer is resolvable but no invitation/vote row exists. Anonymous bucket data. +- `packages/web/src/lib/referral.server.ts:44` — `getReferralCount` exists, integer only. +- `packages/web/src/lib/referral.server.ts:108` — `getReferralCountsByUserIds` reusable for downstream-count badges. +- `packages/web/src/lib/share-templates.ts` — canonical reminder copy registry. Per Codex critique: must NOT invent new copy; source row-level reminder buttons here. +- `packages/web/src/components/dashboard/ReferralLinkCard.tsx` — current dashboard surface; new component renders below this. +- `packages/web/src/app/dashboard/page.tsx` — server component fetches dashboard data. Add new fetches to this block. +- `CLAUDE.md` — "remind your overdue presidents/employees, never pressure politicians" + Wishonia voice + treaty editorial style + "Reuse before rewrite." +- `TODO.md:399-411` — pre-existing "Humanity Manager status report" specification with both halves (overdue + completed). This plan implements it. +- `TODO.md` top-6 #34 — K-factor instrumentation, separate PR. +- Phase 1 dual voices completed 2026-05-17: + - Codex critique log: appended to this plan file under `## Codex critique (round 1)`. + - Claude subagent critique returned as agent result; key findings incorporated above. + +## ALERTS + +_(orchestrator-edited; empty at plan time)_ + +## Agent log + +_(Codex appends after each meaningful action)_ + +## Codex critique (round 1) + +| Finding | Severity | Concrete fix | +|---|---|---| +| The plan optimizes for a passive dashboard return visit, but the growth moment is when a voter has just voted or just caused someone else to vote. | critical | Reframe this as a conversion-feedback loop: post-vote next action, one-time downstream milestone email, and dashboard status only as the archive. **Partially addressed in revised plan: reminder card is the propagation lever; notification email deferred to follow-up.** | +| The proposed query uses `Referral.answer=YES` as "actual voters," but treaty vote attribution now lives on `ReferendumVote.referredByUserId` plus `ReferralInvitation.convertedVoteId`. | critical | Build from official YES `ReferendumVote` rows for the treaty referendum, joined to `ReferralInvitation` where present, and treat legacy `Referral` as signup-only context. **ADDRESSED in revised plan — verified in schema, queries rewritten.** | +| The premise that names and faces make referrers share more is intuition, not a validated campaign fact. | high | Ship only behind an experiment with an exposure event, holdout, and a predeclared action metric before expanding the UI. **Partially addressed: leading metric specified (ShareAttempt delta), experiment framework deferred.** | +| K-factor instrumentation is a prerequisite because otherwise this ships as a vanity feature with no way to prove it increased the branching factor. | critical | Instrument referrer exposure, copy/share/invite actions, click-throughs, direct votes, first downstream votes, depth, and 7-day cohort lift before or in the same PR. **Tracked as TODO #34, separate follow-up PR — Mike's judgment.** | +| The 7-day leading metric cannot be dashboard views because views can rise while the chain still dies. | high | Use "share or named-invitation action within 24 hours of conversion feedback, then referred voter creates one downstream vote within 7 days vs holdout" as the leading metric. | +| Anonymizing private rows destroys the plan's stated names-and-faces value for the exact users most likely to keep profiles private. | high | Split the privacy model: named invitations can show the recipient name the referrer entered, generic referral voters require explicit "show my name to the referrer" consent, and everything else is aggregate. **Partially addressed: reminder card shows invited-recipient names from ReferralInvitation.recipientPersonId (referrer-entered); celebration card still anonymizes private profiles until consent flow lands as follow-up.** | +| The out-of-scope downstream-vote notification email is more important than the passive list because it reaches the referrer without requiring a dashboard habit that probably does not exist yet. | critical | Replace the feed-first slice with a capped milestone email such as "Alice recruited her first voter; make sure she gets to two," with unsubscribe scope and no per-vote spam. **Deferred to follow-up PR — the dashboard cards ship first because Mike's original question was about the dashboard surface.** | +| The plan targets `ReferralLinkCard` placement, but the current War on Disease `/dashboard` renders `TreatyTaskDashboardClient` and `DashboardShareCard`, so the feature can miss the primary campaign surface. | critical | Design against the treaty dashboard branch first and only backfill the generic Earth Optimization dashboard if the same component is reused. **Will verify during Phase 2 design review — added to Phase 2 entry criteria.** | +| A private "humans you recruited" list is weaker than public social proof because it helps one referrer after login while a public verified-referrer leaderboard can influence every visitor. | medium | Test a public verified-referrer/signatory proof module on vote and post-vote surfaces using only public, verified identities and aggregate private counts. **Defer to future plan; out of scope here.** | +| The upstream-chain alternative is underweighted because "X recruited you, now help X get to two" creates obligation at peak commitment. | high | Add upstream attribution to the post-vote flow and notification copy before building a downstream-only archive. **Defer to follow-up plan; the current plan ships the reminder-half of the HM Status Report which is its own propagation lever.** | +| The 6-month regret case is that the team built a privacy-sensitive CRM widget, nobody returned to see it, K-factor stayed below 1, and the campaign lost time on the post-vote/share loop. | high | Time-box this to instrumentation plus one notification experiment and require a measured lift before adding avatars, pagination, or "show more." | +| This is not the right build before the current P0 referral items because TODO prioritizes post-vote email alignment, share-template consolidation, Humanity Manager status, and forward-to-better-fit flows. | high | Move this below those P0 items unless it becomes the measured status/notification work that directly serves them. **ADDRESSED in revised plan — this IS the Humanity Manager Status report (TODO.md:399-411), with both halves.** | + +Overall recommendation: **SCRAP_AND_REPLACE** (resolved — plan rewritten per this critique + Claude subagent critique + Mike's "use your best judgment" + reminder-not-nudge directive). + +## Claude subagent critique (round 1) — summary + +Eight findings, recommendation also SCRAP_AND_REPLACE. Independent verification of the same data-source bug (Referral vs ReferendumVote). Additional independent flag: signup flow at `packages/web/src/app/api/auth/signup/route.ts:61` calls `ensurePersonForUser(user.id, { displayName: name })` and never sets `isPublic=true` — every voter created via the campaign funnel is private-by-default. Full critique text in agent result `a9952a4b896dc480d.output`. Replacement direction matched Codex: ship K-factor first, then HM Status Report with both halves, vote-time consent for name visibility. + +## Mike approved + +2026-05-17: Phase 4 final gate. Both Phase 1+3 reviewers caught that `HumanityManagerStatusPanel` already implements ~80% of the proposed feature. Mike's response to the gate: "use your best judgment, my theory is we can just do them both in the same pull request." + +**Approved scope (bundled into PR #85 feature/public-profile-task-assignment):** + +1. **Treaty dashboard integration**: render `HumanityManagerStatusPanel` on `TreatyTaskDashboardClient.tsx` (currently only on `EarthOptimizationDashboardClient`). Fetch status data via existing `loadHumanityManagerStatus` in the treaty dashboard server component path. +2. **K-factor instrumentation v1 (minimal)**: add `getKFactorForUser(userId)` returning (direct vote conversions / total invitations sent) over 30-day window. Surface as one metric line on `HumanityManagerStatusPanel`. Full cohort-lift analytics deferred. + +**Deferred (not blocking ship):** +- Anonymous-clicks aggregate bucket (no FK to dedupe clicks→votes; approximate at best) +- Vote-time consent prompt for `Person.isPublic=true` (separate UX flow) +- Adding avatars to `completedEmployees` rendering (treaty style minimal already) +- Full HumanityManagerStatus refactor (`HumanityManagerStatusPanel` works as-is) + +**Original "referred voters list" plan: SCRAP_AND_REPLACE outcome.** The autoplan correctly determined this feature mostly already exists. + +## Codex design critique (round 1) + +| Finding | Severity | Concrete fix | +|---|---|---| +| The default reminder-first order is right only when there is at least one contactable pending invitation; otherwise it wastes the first status-report slot on nothing. | high | Sort by state: pending named invitations first, then recent conversion celebration when pending is zero, then a single first-action empty state for brand-new voters. | +| The celebration card belongs above the reminder card when pending count is zero and at least one human has voted through the user's link, especially immediately after a new conversion. | high | Add a `primaryStatusMode` decision before rendering: `pending`, `celebrate`, or `start`, and place only the matching card in the first visible slot. | +| The anonymous-click bucket is not a reminder list because there is no person or channel to remind. | critical | Render unnamed clicks as an aggregate insight below named pending rows, with a CTA to send a named invitation or share again, and never show row-level Send or Copy reminder controls for that bucket. | +| A celebration card with zero votes is dead space and weakens the dashboard's action clarity. | medium | Collapse the celebration card when `recruited.length === 0` unless it is the only status area, in which case show one compact line: "No votes through your link yet" plus the same primary share action. | +| Showing two empty cards to a brand-new voter fails the peak-commitment test because it tells them what did not happen instead of what to do next. | critical | Replace both empty cards with one first-run panel directly under `DashboardShareCard`: "Send this to two humans" with Text, WhatsApp, Email, and Copy actions. | +| The loading state is underspecified for a server-rendered dashboard and risks adding fake skeleton chrome that users never need. | low | If the data stays server-fetched, use the route's existing page loading behavior; if the report fetches client-side, show one compact text row per card, not full skeleton tables. | +| The error and partial states are missing, so a failed report query could hide useful share controls or render the whole dashboard as broken. | high | Fail the report independently: keep `DashboardShareCard` visible, show whichever card loaded, and render a small retry/error line only inside the failed report section. | +| Row-level Send is undefined and currently mixes email delivery, native share, manual copy, and task-comment status into one word. | critical | Define a channel matrix: email uses `sendReferralInvitationMessage`, SMS/WhatsApp/native share open channel links and then mark manual contact, and Copy stays a secondary fallback. | +| Copy alone is not sufficient on mobile because the user still has to choose a channel, find the recipient, paste, and remember to mark it sent. | high | Make the primary row action channel-specific ("Text Sarah", "Email Sarah", "WhatsApp") based on stored contact method, with Copy in an overflow or secondary slot. | +| The plan points to `packages/web/src/lib/share-templates.ts`, but the current canonical task reminder registry is `packages/web/src/lib/tasks/share-templates.ts`. | high | Correct the file path and explicitly decide whether voter reminders use the task template registry's `one_human` mode or the existing referral-invitation copy helpers. | +| The desktop ASCII layout does not answer the real mobile layout problem: two bordered cards plus per-row dual buttons will be cramped and repetitive on a phone. | high | On mobile, render each row as name/status/date followed by one full-width primary action and one compact secondary copy control, with 44px minimum tap targets and no side-by-side action buttons below `sm`. | +| The report should not show more than the first few rows before the user reaches a clear action. | medium | Limit the first view to the top 3 pending rows or top 3 recent conversions and put "View all" behind a disclosure or task list link. | +| Repeating "Anonymous Humanity Manager" as individual celebration rows makes the social proof feel fake and gives the referrer no useful human story. | high | Show public named voters first, show current-referrer-entered invitation names without profile links when appropriate, and merge generic private voters into one aggregate row below named entries. | +| Private converted voters should not be treated the same as named invitees whose name the referrer supplied. | high | Split identity display rules: invitation-converted private rows may use the invitation recipient name as plain text, while generic private referral votes stay aggregate-only. | +| The dashboard reminder rows overlap with `/people/[id]` assign-task and the existing referral-invitation task flow, which can make users wonder whether they are managing people, tasks, or messages. | high | Make this report a status-and-next-action summary over existing `ReferralInvitation` tasks; link to the task detail only as a secondary audit path and do not create a second invitation-management surface. | +| The generic dashboard already has `ReferralInvitationStatusCard`, so adding another invitation list can duplicate the same mental model under a new name. | medium | Replace or fold `ReferralInvitationStatusCard` into the Humanity Manager report on the generic dashboard, and add the report to the treaty dashboard without duplicating old status-card controls. | +| Two equal-weight bordered cards below `DashboardShareCard` will compete with the primary share composer instead of supporting it. | high | Keep `DashboardShareCard` as the dominant first action and render the status report as a lower-weight editorial section with thin rules, compact headings, no "NEW" badges, no emojis, no shadows, and no nested cards. | +| The proposed celebration card title is internally satisfying but not enough of a next step. | medium | Pair each recent conversion with a visible action such as "Ask Alice to send it to two humans" when the row is named, and fall back to "Share with two more humans" for aggregate/private rows. | +| Both cards lack a visible next action above the fold when the user has no pending named reminders. | critical | Put the same primary action in every state: pending = remind the next named human, celebrate = ask the new voter to pass it on, start = share with two humans. | + +Overall recommendation: **SHIP_WITH_REVISIONS** — keep the two-status concept, but revise before implementation so rendering is state-prioritized, anonymous clicks are aggregate-only, Send has defined channel behavior, mobile rows use one primary action, private voters are grouped correctly, the generic invitation status card is not duplicated, and the report stays visually subordinate to the existing treaty share composer. + +## Codex engineering critique (round 1) + +| Finding | Severity | Concrete fix | +|---|---|---| +| Merging ReferralInvitation, ReferendumVote, and ReferralClick inside a client card or across three endpoint calls will create inconsistent auth, loading, error, deduplication, and pagination behavior. | high | Build one server-owned report DTO, preferably by extending the existing `loadHumanityManagerStatus` path or adding one adjacent server helper, and let the component render that normalized payload. | +| Converted named invitations can appear in both ReferralInvitation and ReferendumVote, so the plan needs a canonical row key before any UI work. | critical | Treat `ReferendumVote.id` as the recruited-voter canonical key, exclude votes with `convertedReferralInvitation` from the raw-link vote set, and keep `ReferralInvitation.convertedVoteId IS NULL` as pending-only. | +| The plan says celebration rows are "NOT already represented by a ReferralInvitation," but it also wants named invitation conversions, which are currently the existing Humanity Manager sample. | high | Split recruited rows into `convertedInvitationVotes` and `rawReferralVotes`, display both under one section, and dedupe by vote id before computing totals. | +| The current `/api/referral-invitations` GET returns raw invitation rows with fields such as `recipientEmail`, `messageText`, `originUrl`, and `inviteToken`, so extending it directly would widen a privacy-sensitive API surface. | critical | Replace raw Prisma rows with an allowlisted DTO using `select`, and return only display-safe fields needed by the report. | +| Privacy redaction cannot live in the React component because private names, handles, images, emails, and headlines would already have crossed the server boundary. | critical | Add a server-only mapper such as `toReferredVoterReportRow` that checks `Person.isPublic` and `ReferendumVote.isPublic` before serialization and emits aggregate/private rows with no identity fields. | +| Private voters and sender-entered invitation recipients have different privacy semantics. | high | Use `ReferralInvitation.recipientName` only for the inviter's pending or converted invitation context, and never use private `Person` fields to enrich that row unless the profile and vote are public. | +| At 5000 invitations, the existing API `take: 100` plus client-side filters will lie because the filter only sees the first page. | high | Move filters and counts server-side, return per-status totals, and page pending invitations with a stable cursor such as `(createdAt, id)`. | +| At 800 recruited votes and 50000 clicks, one "Show more" cursor over stacked mixed row types will either skip data or starve one section. | high | Use independent cursors for pending invitations, recruited votes, and anonymous-click aggregates, and expose "Show more" per section instead of one global offset. | +| Anonymous clicks are not humans the manager can remind, so row-level controls for them would be misleading and expensive at high volume. | medium | Render anonymous clicks as one aggregate insight with a share-again action, never as per-click rows or per-click reminder controls. | +| `getReferralCountsByUserIds` is already a single grouped Prisma query, but it counts legacy signup `Referral` rows rather than treaty vote conversions. | medium | Keep its grouped-query shape, but use it only when signup-referral semantics are intended; use `User.downstreamConversionCount` or a treaty-vote conversion query for downstream-vote badges. | +| `getReferredVoters` will become N+1 if downstream counts are fetched per rendered voter. | high | Query a limited page of votes with a narrow `select`, then batch downstream counts for those page user ids in one grouped query or from the cached user column. | +| Exact "anonymous clicks that did not convert" is not derivable from ReferralClick to raw-link ReferendumVote without a first-class vote attribution key. | high | Avoid a slow origin-url or referer parse join; use indexed anti-joins on `shareAttemptId` for invitation/signup-linked conversions, or defer exact raw-vote exclusion until votes persist `shareAttemptId`. | +| A naive `LEFT JOIN` over all clicks for a power user will be slow because `ReferralClick` has only separate indexes on `referrerUserId` and `shareAttemptId`. | high | Query from a limited/indexed candidate set by `referrerUserId` and `deletedAt`, aggregate by `shareAttemptId`, and anti-join against indexed conversion tables before counting. | +| React error boundaries do not catch async fetch failures inside the current client `useEffect`, and route-level `error.tsx` would hide the primary share dashboard. | high | Load report sections with server-side `Promise.allSettled` or explicit per-section try/catch and return section-level error DTOs while keeping `DashboardShareCard` rendered. | +| The repository already has `HumanityManagerStatusPanel`, `loadHumanityManagerStatus`, and `HumanityManagerStatus` rendered on the Earth dashboard, so renaming `ReferralInvitationStatusCard` to the same concept risks two competing implementations. | high | Consolidate into one Humanity Manager report implementation by extending or replacing the existing panel, then remove the old invite-status card from the Earth dashboard. | +| Only `EarthOptimizationDashboardClient` imports `ReferralInvitationStatusCard`, but E2E tests assert the old dashboard text "Earth Optimization Tasks" and unrelated `/tasks` routes also use that title. | medium | Update dashboard-specific tests and imports, but do not rename `/tasks` labels, smoke expectations, or translation strings unless that surface is intentionally in scope. | +| The War on Disease `/dashboard` path currently renders `TreatyTaskDashboardClient` with only `DashboardShareCard`, so wiring only the generic dashboard would miss the primary campaign surface. | critical | Fetch the same report data in the treaty dashboard branch of `dashboard/page.tsx` and render the report under `DashboardShareCard` in `TreatyTaskDashboardClient`. | +| Importing `lib/tasks/share-templates.ts` from dashboard code is package-local and directionally acceptable, but raw template selection needs task/referral tokens and should not be reimplemented in the component. | medium | Keep template rendering in a server helper or reuse the existing reminder-builder functions, and pass final copyable reminder text to the client. | +| The proposed tests include useful boundary cases but also risk route passthrough and UI snapshot tests that CLAUDE.md explicitly discourages. | high | Must test official treaty YES filtering, redaction DTO behavior, invitation/vote deduplication, anonymous-click exclusion semantics, cursor/count behavior, and empty-state behavior at the component boundary; skip symmetry tests that only assert mocked Prisma arguments or rendered copy snapshots. | +| The existing route test style already asserts Prisma call shapes, so adding more of that for the new report would mostly lock implementation instead of protecting behavior. | medium | Put the higher-value tests on pure DTO mappers and server helpers with realistic row fixtures, plus one API/RSC boundary test that proves private data is absent from JSON. | + +Overall recommendation: **SHIP_WITH_REVISIONS**: + +- Consolidate the existing Humanity Manager panel/card paths before renaming anything. +- Use one server-owned DTO with explicit deduplication, redaction, section errors, and per-section cursors. +- Treat anonymous clicks as an aggregate only, and do not claim exact unconverted raw-link click counts unless the query has a real attribution key. +- Add the report to the War on Disease treaty dashboard branch, not just the generic Earth Optimization dashboard. +- Keep tests focused on privacy, source-of-truth filtering, deduplication, pagination/counts, and anonymous-click exclusion semantics. diff --git a/.claude/plans/thank-and-recruit-dispatch.md b/.claude/plans/thank-and-recruit-dispatch.md new file mode 100644 index 000000000..95823620f --- /dev/null +++ b/.claude/plans/thank-and-recruit-dispatch.md @@ -0,0 +1,303 @@ +# Thank-and-recruit copy buttons on Humanity Management Status + +## Brief + +A signed-in voter on warondisease.org/dashboard has just voted YES on the 1% Treaty and shared the link with friends ("employees"). The Humanity Management Status panel currently lets them **bother** late friends with a prefilled reminder, but offers no symmetric path to **thank** the friends who already voted and **ask each one for 2 more humans**. + +Mike's framing: *"I'm thinking the most. The feature I'm most interested in is like seeing who of the people that I shared it with have voted, possibly how they voted, and how many downstream votes I got so that I can bother people that didn't vote yet, and maybe thank people who did vote and ask them to get more people to vote that they know."* + +Goal of the change: give the voter a one-click "Copy thanks" button next to each completed-employee row, with a Wishonia-voice message acknowledging the YES vote and asking for 2 more recruits. Same shape as the existing late-friend reminder — different mode, different intent. + +## Audience and goal + +- **Audience:** signed-in warondisease.org voter who already voted YES, looking at their dashboard from mobile. +- **Concrete action we want them to take:** click "Copy thanks" on a row showing a friend who voted, paste into iMessage/WhatsApp/SMS to that friend. + +## Research log + +This is an internal extension to an existing panel. No third-party API, SDK, or vendor surface is touched — so the research surface is the existing repo + the project manual, not vendor docs. + +Repo-internal provenance (verified by Read tool 2026-05-17): + +- `packages/web/src/lib/humanity-manager-status-content.tsx:120-175` — `createHumanityManagerStatus` defines the presentation contract (`CompletedEmployees`, `ReminderBlock`, `MetricTable`) and the `HumanityManagerStatusInput` shape at `packages/web/src/lib/humanity-manager-status-content.tsx:27-37`. +- `packages/web/src/lib/humanity-manager-status.web.tsx:88-139` — `CompletedEmployees` web renderer; per-row `
  • ` at line 121. `ReminderBlock` Copy button visual at `packages/web/src/lib/humanity-manager-status.web.tsx:141-193`. +- `packages/web/src/lib/humanity-manager-status.server.ts:237-280` — `buildEmployeeReminder` builds reminders from overdue invitations only. `loadHumanityManagerStatus` at `packages/web/src/lib/humanity-manager-status.server.ts:339-481`. `convertedInvitationWhere` defined at line 346-351; query at line 376-396 — confirms vote answer is NOT selected. +- `packages/web/src/lib/tasks/share-templates.ts:51` — `ShareRecipientMode = "leader" | "humanity" | "one_human" | "peer"`. `peer` mode contract assertions at `packages/web/src/lib/tasks/__tests__/accountability.test.ts:710-776` lock peer to exactly one template (`most-important-secret`) at `packages/web/src/lib/tasks/share-templates.ts:763-775`. `DEFAULT_PEER_SHARE_TEMPLATE_ID` exported at `packages/web/src/lib/tasks/share-templates.ts:786` (verified post-Codex critique; original plan-author only read first 40 lines). +- `mcp__optimitron-tasks__searchManual` results (ran 2026-05-17 against `https://manual.warondisease.org`): + - `https://manual.warondisease.org/knowledge/appendix/parameters-and-calculations.html` — `Effective R: 0.15`, `Lives Saved per Verified Voter: 2.6`, `Sharing Opportunity Cost: $0.06`, `Cascade Generations: 3`, `Sharing Upside/Downside Ratio: 58.1Mx`. + - `https://manual.warondisease.org/knowledge/futures/wishonia.html` — Wishonia voice grounding ("We stopped having wars 4,297 years ago…"). Note: the THANKS templates use voter-to-friend voice, not Wishonia voice — voter-to-friend register lives in existing templates at `packages/web/src/lib/tasks/share-templates.ts:756-775` ("sincere", "most-important-secret"). +- Git archaeology: `git log --all -S "thank" --since="2025-01-01" -- packages/web/src/lib/tasks/share-templates.ts packages/web/src/lib/humanity-manager-status*.tsx` returned 0 results. No prior thanks-template attempts to honor or supersede. + +No vendor-API stale-knowledge risk here. The cutoff risks were internal (whether `peer` mode has hidden consumers, whether the manual already had quotable wording) and both have now been resolved by direct file reads + searchManual calls. + +## Current state — ASCII + +``` +┌───────────────────────────────────────────────────────────────────────┐ +│ HUMANITY MANAGEMENT STATUS │ +│ Your employees are either clicking or require management. │ +│ │ +├───────────┬────────────┬───────────┬───────────┬──────────────────────┤ +│ Completed │ Votes/inv │ Late │ Late │ Downstream │ +│ 3 │ 0.42 │ 7 │ 91 │ 12 │ ← 5-cell strip +├───────────┴────────────┴───────────┴───────────┴──────────────────────┤ +│ EMPLOYEES WHO DID THE TASK │ +│ • Maria Lopez completed it on 14 May; 4 downstream votes from her. │ ← text only, +│ • Jordan Kim completed it on 11 May; 0 downstream votes from him. │ no action +│ ... │ +├───────────────────────────────────────────────────────────────────────┤ +│ 7 employees still need the 30-second vote. Examples: ... │ ← prose dup of +│ 91 presidents and heads of government still have not signed. │ cells above +├───────────────────────────────────────────────────────────────────────┤ +│ COPY REMINDERS │ +│ ┌─ Employee reminder ────────────────────────[Copy]─┐ │ ← only for +│ │ Dad │ │ LATE +│ └──────────────────────────────────────────────────┘ │ +│ ┌─ President reminder ───────────────────────[Copy]─┐ │ +│ │ Lula (Brazil) │ │ +│ └──────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +## Proposed state — ASCII + +``` +┌───────────────────────────────────────────────────────────────────────┐ +│ HUMANITY MANAGEMENT STATUS │ +│ Your employees are either clicking or require management. │ +│ │ +├───────────────────────────────────────────────────────────────────────┤ +│ COPY REMINDERS ← HOISTED ABOVE METRICS │ +│ ┌─ Employee reminder ────────────────────────[Copy]─┐ │ +│ │ Dad │ │ +│ └──────────────────────────────────────────────────┘ │ +│ ┌─ President reminder ───────────────────────[Copy]─┐ │ +│ │ Lula (Brazil) │ │ +│ └──────────────────────────────────────────────────┘ │ +├──────────────────────────┬────────────────────────────────────────────┤ +│ EMPLOYEES COMPLETED │ DOWNSTREAM CONVERSIONS │ ← 2-cell strip +│ 3 │ 12 │ (kFactor + dup +└──────────────────────────┴────────────────────────────────────────────┘ counts removed) +├───────────────────────────────────────────────────────────────────────┤ +│ EMPLOYEES WHO DID THE TASK ← sorted by downstream desc │ +│ • Maria Lopez voted YES on 14 May; 4 downstream votes from her. │ +│ [Copy thanks] ▼ "Maria — thanks for voting YES. Bet now is..." │ ← NEW +│ • Jordan Kim voted YES on 11 May; 0 downstream votes from him. │ +│ [Copy thanks] ▼ "Jordan — thanks for voting YES. ..." │ ← NEW +│ ... │ +├───────────────────────────────────────────────────────────────────────┤ +│ 7 employees still need the 30-second vote. Examples: ... │ +│ 91 presidents and heads of government still have not signed. │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +## Step list + +- [ ] Step 1: `searchManual` queries ("thank voter", "ask for two", "two more humans", "viral coefficient", "k factor") + `git log -S` archaeology for prior thanks-template attempts. Quote any usable wording in the Agent log. +- [ ] Step 2: Decide `peer` reuse vs new `peer_thanks` mode. Grep `recipientModes: \["peer"`, `mode: "peer"`, and any `recipientMode === "peer"` callsites. If `peer` is truly unused, reuse it; else add `"peer_thanks"` to the union. +- [ ] Step 3: Author 2-3 thank-and-recruit template bodies in `share-templates.ts` matching the chosen mode. Address by name; acknowledge YES vote; ask for 2 more humans; include `{treaty_url}`; Wishonia voice (deadpan, data-first, no nudge/poke verbs). +- [ ] Step 4: Add `buildEmployeeThanksReminder(...)` in `humanity-manager-status.server.ts`. Driven by `convertedInvitations`. One thanks-reminder per row. +- [ ] Step 5: Extend `HumanityManagerStatusCompletedEmployee` with `thanksReminder?: HumanityManagerStatusReminder | null`. Loader populates per row. +- [ ] Step 6: Sort `completedEmployees` desc by `downstreamConversionCount` in the loader. +- [ ] Step 7: Render inline "Copy thanks" button per row in `humanity-manager-status.web.tsx` `CompletedEmployees` using the same h-10 treaty-ink visual as `ReminderBlock`. Below the row, render the rendered message in a collapsed `
    ` for preview. +- [ ] Step 8: Change row prose from "completed it on {date}" to "voted YES on {date}". Keep the downstream-count suffix. +- [ ] Step 9: Drop "Votes per invite (30d)", "Employees still late", "Late presidents" cells from the metric table. Resulting strip: 2 cells. +- [ ] Step 10: Hoist `ReminderBlock` ABOVE the metric strip in `createHumanityManagerStatus` layout order. Action first, status second. +- [ ] Step 11: Update tests: + - `packages/web/src/lib/__tests__/humanity-manager-status.server.test.ts` — assert thanksReminder is populated + sort order. + - `packages/web/src/components/dashboard/HumanityManagerStatusPanel.test.tsx` — assert per-row Copy thanks button + sort order. +- [ ] Step 12: `pnpm --filter @optimitron/web exec tsc --noEmit` and `pnpm --filter @optimitron/web test -- humanity-manager-status`. Fix every failure. +- [ ] Step 13: `pnpm --filter @optimitron/web copy:preview` to regenerate `.md` snapshots. +- [ ] Step 14: Mike reviews verbatim copy + screenshot. Approve → Claude commits on Codex's behalf. + +## Risks + +1. **`peer` mode reuse may hit invisible consumers.** Need full grep before deciding; otherwise the dispatch could break unrelated share flows. Mitigation: Step 2 enumerates all callsites first; default to NEW mode `peer_thanks` if any ambiguity. +2. **Sort by downstream desc reorders the existing list.** If anyone has wired up an integration test that asserts the prior ordering (by `convertedAt` desc / `createdAt` desc), that test breaks. Mitigation: Step 11 updates tests; grep current ordering assertions in Step 2. +3. **Thanks-template wording is the highest-failure surface** — easy to write something that sounds startup-bro or sycophantic. Mitigation: searchManual + git archaeology in Step 1; verbatim review by Mike in Step 14 BEFORE commit (hook-enforced). +4. **Asking the recipient for "2 more humans" may read as transactional.** Wishonia voice ("On my planet, every voter passed it to two") can soften this, but it's a copy taste call Mike must own. Mitigation: 2-3 variants offered to Mike, not just one. +5. **Mobile rendering of the new per-row `
    ` previews** could push the metric strip below the fold on small screens. Mitigation: Step 10 already moves the action above the metrics; verify on iPhone-14 viewport in Step 14. +6. **The `downstream votes from them` count is computed by `loadDirectReferralDownstreamCounts` (recursive SQL).** Sorting by this count is fine; the count itself is already trusted enough to render. +7. **Existing `convertedInvitations` already slices to 8.** If we sort by downstream desc AFTER the slice, low-downstream high-recency winners survive but high-downstream older entries get cut. Mitigation: sort happens in SQL or before slice; verify in Step 6. + +## Files to touch + +Expected scope (Codex will confirm): + +- `packages/web/src/lib/humanity-manager-status-content.tsx` (data shape + presentation contract) +- `packages/web/src/lib/humanity-manager-status.web.tsx` (web renderer; per-row Copy thanks button) +- `packages/web/src/lib/humanity-manager-status.email.tsx` (email variant — likely same change to data shape; check if thanksReminder should render in email or only web) +- `packages/web/src/lib/humanity-manager-status.server.ts` (loader: new builder, sort, populate thanksReminder) +- `packages/web/src/lib/tasks/share-templates.ts` (mode union + 2-3 templates) +- `packages/web/src/lib/__tests__/humanity-manager-status.server.test.ts` (assertions) +- `packages/web/src/components/dashboard/HumanityManagerStatusPanel.test.tsx` (assertions) +- Regenerated `.md` snapshots under `packages/web/src/app/dashboard/` (from `copy:preview`) + +Estimated diff size: ~250-350 lines, half of which is the new templates + tests. + +## ALERTS + +_(empty — orchestrator-edited mid-flight; Codex re-reads top of every Phase-3 turn)_ + +## Agent log + +- 2026-05-17 Codex: Read dispatch plan top to bottom. ALERTS currently empty. Using revised Step 1-11 and approved peer_thanks templates; will not commit or push. +- 2026-05-17 Codex: Confirmed branch `feature/public-profile-task-assignment`; saw existing unrelated dirty worktree entries. Read `packages/web/AGENTS.md`; web package allows Prisma runtime use, requires treaty style, h2ewd copy rules, screenshots, and no extra dev servers. +- 2026-05-17 Codex: Light memory lookup only confirmed existing treaty dashboard/share-template conventions; repository plan remains the source of truth. +- 2026-05-17 Codex: Pushback before writing the YES filter: Mike's "possibly how they voted" does point toward a fuller all-converters list that shows YES/NO/ABSTAIN, with thank/recruit cards only for YES voters. For this dispatch I am keeping the revised approved YES-only recruitment slice, because the new copy asks the recipient to recruit two more voters and NO/ABSTAIN voters are not recruitment candidates. +- 2026-05-17 Codex: Completed revised Step 1. Added `peer_thanks`, `DEFAULT_PEER_THANKS_SHARE_TEMPLATE_ID`, and the three approved templates; added `target_label` as a token alias so approved copy stays verbatim. + +- 2026-05-17 Codex: Completed revised Step 2. Updated `accountability.test.ts` to keep `peer` locked to `most-important-secret`, cover the three `peer_thanks` templates, assert the new default, and render the approved direct-math copy with the recipient's referral URL. + +- 2026-05-17 Codex: Completed revised Step 3. Added top-level `thanksReminders` to the shared Humanity Manager input and passed it separately into `ReminderBlock`, so web can render thanks cards while email can ignore them. + +- 2026-05-17 Codex: Completed revised Step 4. Tightened converted invitations to `VotePosition.YES`, selected the converted user's `handle`/`referralCode`, added `buildEmployeeThanksReminder`, and populated capped `thanksReminders` using the thanked user's own `/vote/{identifier}` URL. + +- 2026-05-17 Codex: Completed revised Steps 5 and 6. Web `ReminderBlock` now renders normal reminders and YES-voter thanks cards in one action surface; email accepts but ignores `thanksReminders`; completed employee prose now says `voted YES`. + +- 2026-05-17 Codex: Completed revised Step 7. Updated `humanity-manager-status.server.test.ts` with YES-filter assertions, selected recipient identity assertions, and a populated `peer_thanks` reminder that uses Ada's own referral URL instead of Mike's. + +- 2026-05-17 Codex: Completed revised Step 8. Added a `HumanityManagerStatusPanel` test where overdue reminders are empty but `thanksReminders` has an entry; it renders `Copy thanks`, avoids the empty fallback, and copies the thanks message. + +- 2026-05-17 Codex: Completed revised Step 9. First TypeScript run caught a missing `thanksReminders` fixture and the schema reality that handles live on `Person`, not `User`; fixed both. Rerun passed: `pnpm --filter @optimitron/web test -- humanity-manager-status accountability HumanityManagerStatusPanel` (251 tests) and `pnpm --filter @optimitron/web exec tsc --noEmit`. + +- 2026-05-17 Codex: Completed revised Step 10 with a scoped run. Full `copy:preview` failed once because no server was running, then timed out after 15 minutes across many unrelated dirty routes; reran `pnpm --filter @optimitron/web copy:preview -- --routes=/dashboard`, which passed and regenerated `page.logged-out.md` + `page.logged-in.md` for `/dashboard`. +- 2026-05-17 Codex: Captured screenshot review at `packages/web/output/playwright/review/latest.html`, with images under `packages/web/output/playwright/review/thank-and-recruit/`. Demo DB has no YES-converted invitees, so the after screenshot uses a DOM fixture for the thanks-card state; real data path is covered by server/panel tests. Visual inspection: thanks cards render inside `ReminderBlock`; no clipping/overlap in the card. Browser console had Web3Modal dev CORS/fetch noise only. + +- 2026-05-17 Codex: Attempted to stage the intended 10 code/test files only. `git add` failed twice with `Unable to create 'E:/code/optimitron/.git/index.lock': Permission denied`; active Git processes were present, no stale `.git/index.lock` file was visible, and I did not kill other processes. Changes remain unstaged. + +## Codex critique (round 1) + +Codex ran read-only and could not write to the plan file (sandbox blocked `apply_patch`). Findings transcribed by Claude from the dispatch output: + +### 1. `peer` mode is contractually locked, NEW mode required +`peer` is the secret-chain no-link mode consumed by `SecretChainPitch` via `DEFAULT_PEER_SHARE_TEMPLATE_ID`. Callsites: +- `packages/web/src/lib/tasks/share-templates.ts:764-796` +- `packages/web/src/components/.../SecretChainPitch.tsx:24-28` +- `packages/web/src/components/landing/ReferendumSignatureBox.tsx:269-274` +- `packages/web/src/lib/tasks/__tests__/accountability.test.ts:710-776` (asserts `PEER_TEMPLATES.map(t=>t.id) === ["most-important-secret"]` and that peer-mode filter returns ONLY that template) + +Adding thank-templates with `recipientModes: ["peer"]` breaks the contract. **Verdict: add new mode `peer_thanks`** (or use a local builder that doesn't go through `recipientModes` at all — see point 6). + +### 2. Sort by downstream desc is wrong for the modal new voter +A new voter's completed invitees all have `downstreamConversionCount === 0`, so sorting by it produces an arbitrary order. Current `convertedAt desc / createdAt desc` (`humanity-manager-status.server.ts:376-395`) reads as "who just voted" and is the natural framing. **Verdict: KEEP recency order.** + +### 3. Per-row `
    ` previews fragment the action surface +The existing `ReminderBlock` consolidates all copy-action cards in one section (`humanity-manager-status.web.tsx:141-193`). Splitting thanks-buttons inline into the completed-employees list breaks that consistency and creates two action surfaces. **Verdict: render thanks-cards INSIDE the existing `ReminderBlock` alongside overdue reminders**, visually distinguished (e.g., "Thank Maria" vs "Remind Dad"). One action surface. + +### 4. Hoisting `ReminderBlock` above metrics is a regression for zero-reminder users +For the modal user with 0 overdue, 0 completed, the hoist puts an empty fallback ("No copyable reminders yet…") at the top. Current status-first / action-second ordering (`humanity-manager-status-content.tsx:157-170`) is correct. **Verdict: KEEP current layout order.** + +### 5. Metric cells are not all redundant +`Votes per invite (30d)` only lives in the strip. Late counts are faster to scan than prose. **Verdict: drop the cleanup pass from THIS dispatch** — it's scope creep relative to Mike's actual ask. Defer the kFactor-cell question to a separate small commit if Mike wants it. + +### 6. `humanity-manager-status.email.tsx` exists and consumes the shared input contract +File path: `packages/web/src/lib/humanity-manager-status.email.tsx:10-14, 155-170`. If thanks-reminders are placed inside `input.reminders`, the email accidentally renders them. **Verdict: add a separate top-level `thanksReminders: HumanityManagerStatusReminder[]` field**; web reads it inside `ReminderBlock`; email ignores it. + +### 7. Tests need wider scope +`accountability.test.ts:710-776` asserts the current peer contract — must be updated for the new `peer_thanks` mode (or the local-builder approach). A focused panel render test for the zero-overdue case is warranted. **Verdict: update accountability.test.ts + add panel render test.** + +### 8. **CRITICAL: `CONVERTED` ≠ YES vote** +The vote route at `packages/web/src/app/api/.../route.ts:53-58, 124-160` accepts YES / NO / ABSTAIN, writes the answer, then converts the invitation **unconditionally**. The loader at `humanity-manager-status.server.ts:376-395` does NOT select the vote answer. So the plan's "voted YES on {date}" prose is unsafe — a NO voter or abstainer would appear on the thanks list. + +**Verdict: filter `convertedInvitationWhere` to only include invitations whose `convertedVote.answer === YES`.** NO / ABSTAIN converters aren't recruitment candidates anyway. + +### 9. Thanks URL needs the converted voter's OWN referral link +The thank message asks them to recruit 2 more — so the embedded URL should be THEIR personal link, not the thanker's. Current query does not select the converted user's `handle` or `referralCode`. **Verdict: extend the query to select `convertedVote.user.{handle, referralCode}` and use `buildUserInviteReferralUrl` with their identity for the thanks template.** + +### 10. Research log: `searchManual` was pending; now run +- `mcp__optimitron-tasks__searchManual` results (post-critique) — quotable from the manual: `Effective R: 0.15` (current sub-replacement propagation), `Lives Saved per Verified Voter: 2.6`, `Sharing Opportunity Cost: $0.06`, `Cascade Generations: 3`, `Sharing Upside/Downside Ratio: 58.1Mx`. The Wishonia voice grounding ("On my planet we stopped having wars 4,297 years ago…") is available in `knowledge/futures/wishonia.qmd`. +- Git archaeology: no prior thanks-template attempts in panel files (`git log -S "thank"` returned nothing on `packages/web/src/lib/tasks/share-templates.ts` or `humanity-manager-status*`). + +### Other concerns +- The plan assumed `humanity-manager-status.email.tsx` may not exist (it does). +- The plan estimated 250-350 lines but didn't account for accountability.test.ts updates or the YES-filter + handle-selection query changes. +- Sort change risked breaking a snapshot test that asserts current ordering. + +### Bottom line +**Do not ship this plan as-is.** Rework around: status-first layout (keep), recency-first list (keep), consolidated copy-action block (one surface, both overdue + thanks), new `peer_thanks` mode or local-builder, YES-filter + voter-referral-attribution wiring, accountability.test.ts updates + zero-overdue panel render test. Drop the metric-strip cleanup pass from this dispatch. + +## Claude response (round 1) + +Accepting all 10 of Codex's findings. The revisions are load-bearing — points 8 and 9 alone (CONVERTED≠YES, thanks URL must be the recipient's own link) would have shipped a feature that thanks NO voters and gives them links pointing to the WRONG referrer. Those two are critical bugs the original plan would have caused. + +Revised plan diff vs original: + +| Original | Revised | +|---|---| +| Sort completed list by downstream desc | KEEP recency order (`convertedAt desc / createdAt desc`) | +| Inline per-row `
    ` thanks buttons | Render thanks-cards INSIDE existing `ReminderBlock`, alongside overdue reminders, visually labeled "Thank {name}" vs "Remind {name}" | +| Hoist `ReminderBlock` above metric strip | KEEP existing status-first / action-second layout order | +| Cut kFactor + duplicate count cells from metric strip | OUT OF SCOPE this dispatch; defer to a separate small commit | +| `thanksReminder?` field on each `CompletedEmployee` | Separate top-level `thanksReminders: HumanityManagerStatusReminder[]` on `HumanityManagerStatusInput`; web reads it, email ignores it | +| Show "voted YES on {date}" for all converted invitations | Filter the loader's `convertedInvitationWhere` to only `convertedVote.answer === VotePosition.YES`. Keep recency order within that filtered set | +| Thanks URL = thanker's link with their tokens | Thanks URL = THE THANKED USER's personal referral link. Query must `select` `convertedVote.user.{handle, referralCode}` | +| Tests in 2 files | Same 2 + `packages/web/src/lib/tasks/__tests__/accountability.test.ts` (new mode contract) + a zero-overdue panel render test | + +Revised step list (replaces the original Step 1-14): + +- [x] Step 1: Add `peer_thanks` to `ShareRecipientMode` union in `share-templates.ts`. Author 2-3 thanks-templates with `recipientModes: ["peer_thanks"]`. Templates must: address by name, acknowledge YES vote, ask for 2 more humans (k=2 framing, "Effective R: 0.15" currently from manual), include `{treaty_url}` (the recipient's own referral link), Wishonia voice. Mike reviews verbatim before commit. +- [x] Step 2: Update `accountability.test.ts:710-776` peer-mode contract assertions to cover the new `peer_thanks` mode and its filter behavior. +- [x] Step 3: Add `thanksReminders: HumanityManagerStatusReminder[]` to `HumanityManagerStatusInput`. Default `[]`. +- [x] Step 4: In `humanity-manager-status.server.ts` `loadHumanityManagerStatus`: + - Tighten `convertedInvitationWhere` to `convertedVote: { is: { answer: VotePosition.YES, deletedAt: null } }`. + - Extend `prisma.referralInvitation.findMany` select to include `convertedVote.user.handle` and `convertedVote.user.referralCode`. + - Add `buildEmployeeThanksReminder(...)` builder using the recipient's identity to build `{treaty_url}` via `buildUserInviteReferralUrl`. Pick template via `pickRenderedReminder({ mode: "peer_thanks", tokens })`. + - Populate `thanksReminders` from the converted invitations (cap at 3, same shape as employee/president reminders). +- [x] Step 5: In `humanity-manager-status.web.tsx` `ReminderBlock`: render both `employeeReminders` and `thanksReminders` (separate sub-headings or visual chips). Email renderer (`humanity-manager-status.email.tsx`) ignores `thanksReminders` field. +- [x] Step 6: Change row prose from "completed it on {date}" to "voted YES on {date}". Keep downstream-count suffix. KEEP recency order (no sort change). +- [x] Step 7: Update `humanity-manager-status.server.test.ts` to assert the YES-filter, the referral URL points to the recipient's identity, and `thanksReminders` is populated. +- [x] Step 8: Add a zero-overdue panel render test in `HumanityManagerStatusPanel.test.tsx` asserting the empty-reminders fallback still renders correctly when `thanksReminders` has entries. +- [x] Step 9: `pnpm --filter @optimitron/web exec tsc --noEmit` + `pnpm --filter @optimitron/web test -- humanity-manager-status accountability`. Fix every failure. +- [x] Step 10: `pnpm --filter @optimitron/web copy:preview` to regenerate `.md` snapshots. +- [ ] Step 11: Mike reviews verbatim thanks-template copy + screenshot. Approve → Claude commits on Codex's behalf. + +Revised file list: + +- `packages/web/src/lib/tasks/share-templates.ts` — `peer_thanks` mode + 2-3 templates +- `packages/web/src/lib/tasks/__tests__/accountability.test.ts` — mode contract updates +- `packages/web/src/lib/humanity-manager-status-content.tsx` — `thanksReminders` field on input +- `packages/web/src/lib/humanity-manager-status.web.tsx` — render both reminder kinds inside `ReminderBlock` +- `packages/web/src/lib/humanity-manager-status.email.tsx` — verify ignores `thanksReminders` (should already, since it reads `input.reminders` not new field) +- `packages/web/src/lib/humanity-manager-status.server.ts` — YES filter, handle select, `buildEmployeeThanksReminder`, `thanksReminders` populate +- `packages/web/src/lib/__tests__/humanity-manager-status.server.test.ts` — YES filter + URL ownership + thanksReminders assertions +- `packages/web/src/components/dashboard/HumanityManagerStatusPanel.test.tsx` — render test with thanks-reminders present +- Regenerated `.md` snapshots under `packages/web/src/app/dashboard/` + +Estimated diff: ~300-400 lines, half templates + tests. + +Convergence: Claude + Codex agree on all 10 points. Ready for Mike approval — but copy wording (Step 1 templates) is the highest-failure surface and Mike must see the verbatim drafts before commit. Will present 3 template variants alongside the plan summary. + +## Mike approved + +Mike delegated the copy-register decision back to Claude with "I don't know what do you think?" Claude's call: + +**Ship all three template variants in `peer_thanks` mode, picker-driven, A as default.** Rationale: the existing share-templates pattern is multi-template-per-mode with one default (lumbergh in `one_human` mode, polite-reminder in `humanity` mode); recipients getting the same canned thanks from 5 different friends is a real failure mode if only one template exists. + +Verbatim templates approved: + +**Template `peer-thanks-direct-math` (default):** +``` +{target_label} — thanks for actually voting. Each voter currently brings 0.15 more voters. The number we need is 2. Two humans, 30 seconds each, your own link: +{treaty_url} +``` + +**Template `peer-thanks-casual`:** +``` +{target_label} — you voted YES. That's already more than most humans manage. Final favor: 2 more people you know, 30 seconds each. Your link, not mine: +{treaty_url} +``` + +**Template `peer-thanks-stakes`:** +``` +{target_label} — your YES vote saves 2.6 lives in expectation. Two more votes from people you know = 7.8. Send them your link: +{treaty_url} +``` + +`DEFAULT_PEER_THANKS_SHARE_TEMPLATE_ID = "peer-thanks-direct-math"`. + +All three use the same token set: `{target_label}` and `{treaty_url}`. No other required tokens. The thanks URL must be the THANKED RECIPIENT's personal referral link (built from their `handle`/`referralCode` selected in the loader query), not the thanker's. + +Engineering details from the revised Step list (1-11 above) are also approved by delegation — they're mechanical fixes to the bugs Codex caught (CONVERTED≠YES, wrong URL ownership, layout regressions). No further taste input needed. + +Codex may dispatch. diff --git a/.claude/settings.json b/.claude/settings.json index 90f472a3f..b8f3bdc87 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -64,11 +64,6 @@ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-copy-review-before-commit.mjs\"", "timeout": 5000 }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-no-codex-in-commit-message.mjs\"", - "timeout": 3000 - }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/codex-dispatch-blather.mjs\"", @@ -107,46 +102,6 @@ } ], "PostToolUse": [ - { - "matcher": "Write", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/review-loop-gate.mjs\" --post-edit", - "timeout": 3000 - } - ] - }, - { - "matcher": "Edit", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/review-loop-gate.mjs\" --post-edit", - "timeout": 3000 - } - ] - }, - { - "matcher": "MultiEdit", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/review-loop-gate.mjs\" --post-edit", - "timeout": 3000 - } - ] - }, - { - "matcher": "AskUserQuestion", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/review-loop-gate.mjs\" --post-ask", - "timeout": 3000 - } - ] - }, { "matcher": "Bash", "hooks": [ @@ -158,17 +113,6 @@ ] } ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/review-loop-gate.mjs\" --stop", - "timeout": 5000 - } - ] - } - ], "SessionStart": [ { "hooks": [ @@ -181,11 +125,6 @@ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/dev-server-check.mjs\"", "timeout": 8000 - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/review-loop-gate.mjs\" --session-start", - "timeout": 3000 } ] } diff --git a/.codex/agents/voice-critic.toml b/.codex/agents/voice-critic.toml index adc08fe43..f0e84e804 100644 --- a/.codex/agents/voice-critic.toml +++ b/.codex/agents/voice-critic.toml @@ -13,8 +13,9 @@ Does the copy: 3. **Sound like Wishonia + Kurt Vonnegut?** Deadpan, data-first, plain declaratives, sardonic comparisons. Not a Stripe keynote, not a corporate-onboarding flow, not a moral aphorism in lieu of a fact. 4. **Keep momentum?** After a YES action, the next step renders inline. No "open the dashboard to find X" punts. 5. **Reuse what exists?** New components are flagged unless the user explicitly wants a divergence from existing equivalents. +6. **Increase the target action?** Compare old copy to new copy. If the old copy gave motivation, urgency, agency, or trust and the new copy only describes mechanics, flag it as a regression. -If the answer to all five is yes, you have no violations to report. Say so. +If the answer to all six is yes, you have no violations to report. Say so. # How to flag @@ -31,7 +32,8 @@ If you can't confirm by reading the source, DROP the finding or label it explici These run regardless of which smell first caught your attention. -1. **Manual-search before suggesting new copy.** If you're proposing replacement wording for any user-facing string, first call `mcp__optimitron-tasks__searchManual` with the topic phrase and check whether the manual already has a sharper version we should steal. The manual is the source of truth for voice — quoting from it beats inventing fresh prose. If the manual has nothing usable, say so explicitly in the finding so the reader knows you checked. +0. **Strategic-job comparison.** Identify audience, desired action, motivation, old copy's strategic job, and why the new copy should increase the action. If the answer is unclear, the fix is "ask Mike this one question: ..." with the shortest useful question and a recommended default. +1. **Manual-search before suggesting new copy.** If you're proposing replacement wording for any user-facing string, first call `mcp__optimitron-tasks__searchManual` with the topic phrase and check whether the manual already has a sharper version we should steal. If that MCP is unavailable, use the static search index at `https://manual.warondisease.org/assets/json/search-index.json` or grep `docs/`. The manual is the source of truth for voice — quoting from it beats inventing fresh prose. If the manual has nothing usable, say so explicitly in the finding so the reader knows you checked. 2. **Parameter coverage for every number.** For every hardcoded user-facing number in the changeset (digits, percentages, multipliers, dollar amounts, year counts), grep `packages/data/src/parameters/parameters-calculations-citations.ts` and the wider `packages/data/src/parameters/` directory for an existing parameter. If one exists and the JSX uses a raw literal instead of ``, flag it with the parameter ID. If no parameter exists yet, flag whether a new parameter is warranted (cited statistics warrant one; arithmetic identities like "2² = 4" do not). # Common smells (use as hypotheses to investigate, not as automatic verdicts) diff --git a/.codex/config.toml b/.codex/config.toml index df2787a72..4229b9d98 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -4,6 +4,9 @@ model_reasoning_effort = "xhigh" approval_policy = "never" sandbox_mode = "workspace-write" +[features] +goals = true + # MCP server entries removed 2026-05-14: when the local dev server on 3001 or # the spawned pnpm MCP subprocess is unreachable / wedged, Codex hangs on the # handshake at startup ("thinking forever" in the VS Code extension). Re-add diff --git a/.github/scripts/audit-sentry-preview.mjs b/.github/scripts/audit-sentry-preview.mjs new file mode 100644 index 000000000..aa6f84369 --- /dev/null +++ b/.github/scripts/audit-sentry-preview.mjs @@ -0,0 +1,423 @@ +#!/usr/bin/env node + +import { appendFile, writeFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +const DEFAULT_ORG = "wishonia-org"; +const DEFAULT_PROJECT = "optimitron-web"; +const DEFAULT_ENVIRONMENT = "vercel-preview"; +const DEFAULT_LOOKBACK_MINUTES = 30; +const DEFAULT_LIMIT = 50; + +export async function auditSentryPreview(options) { + const { + apiBaseUrl = "https://sentry.io", + authToken, + environment = DEFAULT_ENVIRONMENT, + fetchImpl = fetch, + issueLimit = DEFAULT_LIMIT, + lookbackMinutes = DEFAULT_LOOKBACK_MINUTES, + org = DEFAULT_ORG, + previewUrl, + release, + now = new Date(), + project = DEFAULT_PROJECT, + } = options; + + if (!authToken) { + throw new Error("SENTRY_AUTH_TOKEN is required for preview Sentry audit."); + } + + if (!previewUrl) { + throw new Error("PREVIEW_URL is required for preview Sentry audit."); + } + + const preview = new URL(previewUrl); + const since = new Date(now.getTime() - lookbackMinutes * 60 * 1000); + const issues = await listIssues({ + apiBaseUrl, + authToken, + environment, + fetchImpl, + issueLimit, + lookbackMinutes, + org, + project, + }); + const findings = []; + + for (const issue of issues) { + const events = await listIssueEvents({ + apiBaseUrl, + authToken, + environment, + fetchImpl, + issueId: issue.id, + lookbackMinutes, + org, + }); + const matchingEvents = events + .filter((event) => + eventMatchesDeployment(event, { + environment, + previewHostname: preview.hostname, + release, + since, + }), + ) + .sort( + (a, b) => + Date.parse(b.dateReceived ?? b.timestamp ?? "") - + Date.parse(a.dateReceived ?? a.timestamp ?? ""), + ); + + for (const event of matchingEvents) { + findings.push({ + eventId: event.eventID ?? event.id, + eventUrl: buildEventUrl(org, issue.id, event.eventID ?? event.id), + issueId: issue.id, + issueShortId: issue.shortId, + issueTitle: issue.title, + issueUrl: issue.permalink, + lastSeen: event.timestamp ?? event.dateReceived ?? issue.lastSeen, + release: getEventRelease(event) ?? issue.lastRelease?.version ?? "", + transaction: event.transaction ?? event.culprit ?? "", + url: getEventUrl(event), + }); + } + } + + return { + environment, + findings, + issueCount: issues.length, + lookbackMinutes, + org, + previewUrl, + project, + release: release ?? "", + since: since.toISOString(), + success: findings.length === 0, + }; +} + +export function eventMatchesDeployment( + event, + { environment, previewHostname, release, since }, +) { + if (!event || !isRecentEvent(event, since)) { + return false; + } + + const eventEnvironment = event.environment ?? getTagValue(event, "environment"); + if (eventEnvironment && eventEnvironment !== environment) { + return false; + } + + const eventRelease = getEventRelease(event); + if (release && eventRelease === release) { + return true; + } + + const eventUrl = getEventUrl(event); + if (!eventUrl) { + return false; + } + + try { + return new URL(eventUrl).hostname === previewHostname; + } catch { + return eventUrl.includes(previewHostname); + } +} + +export function buildMarkdownReport(report) { + const lines = [ + "", + "### Sentry preview errors detected", + "", + `Preview: ${report.previewUrl}`, + `Environment: \`${report.environment}\``, + `Release: \`${report.release || "not provided"}\``, + `Lookback: ${report.lookbackMinutes} minutes`, + "", + ]; + + if (report.findings.length === 0) { + lines.push("No matching Sentry errors were found for this preview."); + return `${lines.join("\n")}\n`; + } + + lines.push( + "| Issue | Last event | Route / transaction | Release | Event |", + "| --- | --- | --- | --- | --- |", + ); + + for (const finding of report.findings.slice(0, 10)) { + lines.push( + `| [${escapeCell(finding.issueShortId ?? finding.issueId)}](${finding.issueUrl}) ${escapeCell( + truncate(finding.issueTitle, 90), + )} | ${escapeCell(finding.lastSeen)} | ${escapeCell( + truncate(finding.url || finding.transaction || "n/a", 120), + )} | ${escapeCell(finding.release || "n/a")} | [event](${finding.eventUrl}) |`, + ); + } + + if (report.findings.length > 10) { + lines.push( + `| ... | ${report.findings.length - 10} more matching event(s) | ... | ... | ... |`, + ); + } + + lines.push( + "", + "This check runs after the live Vercel preview smoke test and only matches recent `vercel-preview` events for this deployment release or preview host.", + ); + + return `${lines.join("\n")}\n`; +} + +export function buildStepSummary(report) { + const status = report.success ? "passed" : "failed"; + const lines = [ + "## Sentry Preview Audit", + "", + `- Result: ${status}`, + `- Preview: ${report.previewUrl}`, + `- Environment: ${report.environment}`, + `- Release: ${report.release || "not provided"}`, + `- Lookback: ${report.lookbackMinutes} minutes`, + `- Matching error events: ${report.findings.length}`, + "", + ]; + + if (report.findings.length > 0) { + lines.push(buildMarkdownReport(report)); + } + + return `${lines.join("\n")}\n`; +} + +export function buildAuditFailureMarkdown(error) { + return [ + "", + "### Sentry preview audit failed", + "", + "The preview smoke test ran, but the Sentry audit could not query issues.", + "", + `Error: \`${truncate(error?.message ?? error, 500)}\``, + "", + "Check that the GitHub secret used by this job has Sentry `org:read`, `project:read`, and `event:read` scopes.", + ].join("\n"); +} + +async function listIssues(input) { + const params = new URLSearchParams({ + environment: input.environment, + limit: String(input.issueLimit), + query: "is:unresolved", + sort: "date", + statsPeriod: sentryStatsPeriod(input.lookbackMinutes), + }); + const url = `${input.apiBaseUrl}/api/0/projects/${encodeURIComponent( + input.org, + )}/${encodeURIComponent(input.project)}/issues/?${params}`; + const json = await sentryGet(url, input); + return Array.isArray(json) ? json : json.data ?? []; +} + +async function listIssueEvents(input) { + const params = new URLSearchParams({ + environment: input.environment, + per_page: "10", + statsPeriod: sentryStatsPeriod(input.lookbackMinutes), + }); + const url = `${input.apiBaseUrl}/api/0/organizations/${encodeURIComponent( + input.org, + )}/issues/${encodeURIComponent(input.issueId)}/events/?${params}`; + const json = await sentryGet(url, input); + return Array.isArray(json) ? json : json.data ?? []; +} + +async function sentryGet(url, { authToken, fetchImpl }) { + const response = await fetchImpl(url, { + headers: { + accept: "application/json", + authorization: `Bearer ${authToken}`, + }, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error( + `Sentry API returned HTTP ${response.status} for ${url}: ${truncate( + body, + 500, + )}`, + ); + } + + return response.json(); +} + +function isRecentEvent(event, since) { + const timestamp = event.timestamp ?? event.dateReceived; + if (!timestamp) { + return false; + } + + return Date.parse(timestamp) >= since.getTime(); +} + +function getEventRelease(event) { + if (typeof event.release === "string") { + return event.release; + } + + return event.release?.version ?? getTagValue(event, "release") ?? ""; +} + +function getEventUrl(event) { + return ( + getTagValue(event, "url") ?? + event.request?.url ?? + event.contexts?.page?.url ?? + event.contexts?.trace?.data?.url ?? + "" + ); +} + +function getTagValue(event, key) { + const tags = event.tags ?? []; + for (const tag of tags) { + if (Array.isArray(tag) && tag[0] === key) { + return tag[1]; + } + if (tag?.key === key) { + return tag.value; + } + } + return ""; +} + +function buildEventUrl(org, issueId, eventId) { + if (!eventId) { + return `https://${org}.sentry.io/issues/${issueId}/`; + } + + return `https://${org}.sentry.io/issues/${issueId}/events/${eventId}/`; +} + +function truncate(value, maxLength) { + const text = String(value ?? "").replace(/\s+/gu, " ").trim(); + return text.length > maxLength + ? `${text.slice(0, Math.max(0, maxLength - 1))}...` + : text; +} + +function escapeCell(value) { + return String(value ?? "") + .replace(/\r?\n/gu, " ") + .replace(/\|/gu, "\\|"); +} + +async function main() { + const initialDelayMs = Math.max( + 0, + numberFromEnv("SENTRY_AUDIT_INITIAL_DELAY_MS", 0), + ); + const pollAttempts = Math.max( + 1, + Math.trunc(numberFromEnv("SENTRY_AUDIT_POLL_ATTEMPTS", 1)), + ); + const pollIntervalMs = Math.max( + 0, + numberFromEnv("SENTRY_AUDIT_POLL_INTERVAL_MS", 0), + ); + let report; + + if (initialDelayMs > 0) { + await sleep(initialDelayMs); + } + + for (let attempt = 1; attempt <= pollAttempts; attempt += 1) { + report = await auditSentryPreview({ + apiBaseUrl: process.env.SENTRY_BASE_URL || "https://sentry.io", + authToken: process.env.SENTRY_AUTH_TOKEN, + environment: process.env.SENTRY_ENVIRONMENT || DEFAULT_ENVIRONMENT, + issueLimit: numberFromEnv("SENTRY_AUDIT_ISSUE_LIMIT", DEFAULT_LIMIT), + lookbackMinutes: numberFromEnv( + "SENTRY_AUDIT_LOOKBACK_MINUTES", + DEFAULT_LOOKBACK_MINUTES, + ), + org: process.env.SENTRY_ORG || DEFAULT_ORG, + previewUrl: process.env.PREVIEW_URL, + project: process.env.SENTRY_PROJECT || DEFAULT_PROJECT, + release: process.env.SENTRY_RELEASE || "", + }); + + if (!report.success || attempt === pollAttempts) { + break; + } + + await sleep(pollIntervalMs); + } + + const reportFile = process.env.SENTRY_AUDIT_REPORT_FILE; + if (reportFile) { + await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + } + + const markdown = buildMarkdownReport(report); + const markdownFile = process.env.SENTRY_AUDIT_MARKDOWN_FILE; + if (markdownFile) { + await writeFile(markdownFile, markdown, "utf8"); + } + + if (process.env.GITHUB_STEP_SUMMARY) { + await appendFile(process.env.GITHUB_STEP_SUMMARY, buildStepSummary(report)); + } + + console.log( + `Sentry preview audit ${report.success ? "passed" : "failed"}: ${ + report.findings.length + } matching event(s).`, + ); + + if (!report.success) { + process.exitCode = 1; + } +} + +function numberFromEnv(name, fallback) { + const raw = process.env[name]; + if (!raw) { + return fallback; + } + + const value = Number(raw); + return Number.isFinite(value) ? value : fallback; +} + +function sentryStatsPeriod(lookbackMinutes) { + return lookbackMinutes > 24 * 60 ? "14d" : "24h"; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(async (error) => { + console.error(error); + const markdown = `${buildAuditFailureMarkdown(error)}\n`; + const markdownFile = process.env.SENTRY_AUDIT_MARKDOWN_FILE; + if (markdownFile) { + await writeFile(markdownFile, markdown, "utf8"); + } + if (process.env.GITHUB_STEP_SUMMARY) { + await appendFile(process.env.GITHUB_STEP_SUMMARY, markdown, "utf8"); + } + process.exitCode = 1; + }); +} diff --git a/.github/scripts/audit-sentry-preview.test.mjs b/.github/scripts/audit-sentry-preview.test.mjs new file mode 100644 index 000000000..7f0082f90 --- /dev/null +++ b/.github/scripts/audit-sentry-preview.test.mjs @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + auditSentryPreview, + buildAuditFailureMarkdown, + buildMarkdownReport, + eventMatchesDeployment, +} from "./audit-sentry-preview.mjs"; + +test("matches recent events by release or preview host", () => { + const since = new Date("2026-05-17T20:00:00.000Z"); + const deployment = { + environment: "vercel-preview", + previewHostname: "preview.example.vercel.app", + release: "abc123", + since, + }; + + assert.equal( + eventMatchesDeployment( + { + environment: "vercel-preview", + release: "abc123", + timestamp: "2026-05-17T20:05:00.000Z", + }, + deployment, + ), + true, + ); + assert.equal( + eventMatchesDeployment( + { + environment: "vercel-preview", + tags: [ + { key: "url", value: "https://preview.example.vercel.app/dashboard" }, + ], + timestamp: "2026-05-17T20:05:00.000Z", + }, + deployment, + ), + true, + ); + assert.equal( + eventMatchesDeployment( + { + environment: "vercel-preview", + release: "older", + tags: [{ key: "url", value: "https://other.example.vercel.app/" }], + timestamp: "2026-05-17T19:59:59.000Z", + }, + deployment, + ), + false, + ); +}); + +test("audits Sentry issues and returns matching preview findings", async () => { + const fetchCalls = []; + const fetchImpl = async (url) => { + fetchCalls.push(String(url)); + if (String(url).includes("/issues/?")) { + return jsonResponse([ + { + id: "7487535527", + permalink: "https://wishonia-org.sentry.io/issues/7487535527/", + shortId: "OPTIMITRON-WEB-7X", + title: "PrismaClientKnownRequestError", + }, + ]); + } + + return jsonResponse([ + { + eventID: "event-1", + environment: "vercel-preview", + release: { version: "abc123" }, + tags: [ + { key: "url", value: "https://preview.example.vercel.app/dashboard" }, + ], + timestamp: "2026-05-17T20:05:00.000Z", + transaction: "/dashboard", + }, + ]); + }; + + const report = await auditSentryPreview({ + authToken: "test-token", + fetchImpl, + lookbackMinutes: 30, + now: new Date("2026-05-17T20:10:00.000Z"), + previewUrl: "https://preview.example.vercel.app", + release: "abc123", + }); + + assert.equal(report.success, false); + assert.equal(report.findings.length, 1); + assert.equal(report.findings[0].issueShortId, "OPTIMITRON-WEB-7X"); + assert.equal(fetchCalls.length, 2); +}); + +test("renders a compact PR comment body", () => { + const markdown = buildMarkdownReport({ + environment: "vercel-preview", + findings: [ + { + eventUrl: + "https://wishonia-org.sentry.io/issues/7487535527/events/event-1/", + issueId: "7487535527", + issueShortId: "OPTIMITRON-WEB-7X", + issueTitle: "PrismaClientKnownRequestError", + issueUrl: "https://wishonia-org.sentry.io/issues/7487535527/", + lastSeen: "2026-05-17T20:05:00.000Z", + release: "abc123", + url: "https://preview.example.vercel.app/dashboard", + }, + ], + lookbackMinutes: 30, + previewUrl: "https://preview.example.vercel.app", + release: "abc123", + }); + + assert.match(markdown, //); + assert.match(markdown, /OPTIMITRON-WEB-7X/); + assert.match(markdown, /preview\.example\.vercel\.app\/dashboard/); +}); + +test("renders Sentry permission failures without tokens", () => { + const markdown = buildAuditFailureMarkdown( + new Error("Sentry API returned HTTP 403: permission denied"), + ); + + assert.match(markdown, /Sentry preview audit failed/); + assert.match(markdown, /event:read/); + assert.doesNotMatch(markdown, /Bearer/); +}); + +function jsonResponse(value) { + return { + json: async () => value, + ok: true, + }; +} diff --git a/.github/scripts/generate-pr-preview-links.mjs b/.github/scripts/generate-pr-preview-links.mjs index 4ef859d65..5d2b8f1a1 100644 --- a/.github/scripts/generate-pr-preview-links.mjs +++ b/.github/scripts/generate-pr-preview-links.mjs @@ -1,32 +1,35 @@ #!/usr/bin/env node -// Generates a markdown table of one-click preview-deploy links for a PR. -// Each row: a route that this PR's diff touches + the auth state to view it -// in. Reviewer clicks the link and lands on the right preview page already -// authed/unauthed via the ?login=demo / ?logout=1 dev-auth query params. +// Generates the sticky PR review packet: preview links, visual-review links, +// and a reviewer checklist whose checked state survives CI reruns. // // Reads from env: -// PREVIEW_URL - the Vercel preview deployment URL (https://...vercel.app) -// CHANGED_FILES - JSON array of paths changed in this PR, e.g. -// '["packages/web/src/app/treaty/page.tsx", ...]' -// (excludes deleted files — caller's responsibility). -// If unset, falls back to `git diff origin/main...HEAD` -// for local testing. -// -// Writes markdown to stdout. +// PREVIEW_URL Vercel preview deployment URL. +// CHANGED_FILES JSON array of changed PR paths. +// EXISTING_COMMENT_BODY Existing sticky packet body, if any. +// VISUAL_REVIEW_URL Published latest.html URL. +// VISUAL_REVIEW_MANIFEST_PATH Optional JSON manifest path from visual:review. +// VISUAL_REVIEW_MANIFEST_JSON Optional manifest JSON, mostly for tests. import { execSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; const PREVIEW_URL = process.env.PREVIEW_URL?.replace(/\/$/, "") ?? ""; const CHANGED_FILES_JSON = process.env.CHANGED_FILES ?? ""; +const EXISTING_COMMENT_BODY = process.env.EXISTING_COMMENT_BODY ?? ""; +const VISUAL_REVIEW_URL = + process.env.VISUAL_REVIEW_URL?.replace(/\/$/, "") ?? ""; +const VISUAL_REVIEW_MANIFEST_PATH = + process.env.VISUAL_REVIEW_MANIFEST_PATH ?? ""; +const VISUAL_REVIEW_MANIFEST_JSON = + process.env.VISUAL_REVIEW_MANIFEST_JSON ?? ""; if (!PREVIEW_URL) { console.error("PREVIEW_URL not set; skipping comment generation."); process.exit(0); } -// AUTH-ONLY routes: render the sign-in page if you hit them logged-out, so -// reviewers need `?login=demo`. Mirrors AUTH_REQUIRED_PATHS in -// packages/web/e2e/utils/static-pages.ts. +// AUTH-ONLY routes: render the sign-in page if hit logged-out, so reviewers +// need `?login=demo`. Mirrors AUTH_REQUIRED_PATHS in the web e2e helpers. const AUTH_ROUTES = new Set([ "/dashboard", "/profile", @@ -41,13 +44,12 @@ const AUTH_ROUTES = new Set([ "/mcp/authorize", ]); -// HYBRID routes: render meaningfully differently when authed vs unauthed. -// Listed twice in the table (once per state) so reviewers compare both. +// HYBRID routes render meaningfully differently authed vs unauthed. const HYBRID_ROUTES = new Set([ - "/tasks", // task detail page shows different CTAs based on viewer-claim state + "/tasks", ]); -// Component folder → routes it likely affects. Imperfect but cheap. +// Component folder -> routes it likely affects. Imperfect but cheap. const COMPONENT_FOLDER_ROUTES = { "src/components/dashboard/": ["/dashboard"], "src/components/treaty/": ["/treaty"], @@ -65,14 +67,17 @@ function getChangedFiles() { if (CHANGED_FILES_JSON) { try { const parsed = JSON.parse(CHANGED_FILES_JSON); - return Array.isArray(parsed) ? parsed.filter((f) => typeof f === "string") : []; + return Array.isArray(parsed) + ? parsed.filter((file) => typeof file === "string") + : []; } catch (err) { console.error(`Failed to parse CHANGED_FILES JSON: ${err.message}`); return []; } } + try { - // Local fallback: name-status to drop deletions so we don't link to 404s. + // Local fallback: name-status drops deletions so we do not link to 404s. const out = execSync("git diff --name-status origin/main...HEAD", { encoding: "utf8", }); @@ -91,9 +96,9 @@ function getChangedFiles() { } } -// `packages/web/src/app/foo/bar/page.tsx` → `/foo/bar` -// `packages/web/src/app/page.tsx` → `/` -// `packages/web/src/app/tasks/[id]/page.tsx` → `/tasks` +// `packages/web/src/app/foo/bar/page.tsx` -> `/foo/bar` +// `packages/web/src/app/page.tsx` -> `/` +// `packages/web/src/app/tasks/[id]/page.tsx` -> `/tasks` function pageFileToRoute(file) { const match = file.match( /^packages\/web\/src\/app\/((?:[^/]+\/)*)page\.tsx$/, @@ -102,102 +107,271 @@ function pageFileToRoute(file) { const segments = match[1] .split("/") .filter(Boolean) - .filter((s) => !s.startsWith("(") && !s.startsWith("_")) - .map((s) => (s.startsWith("[") && s.endsWith("]") ? null : s)); - if (segments.some((s) => s === null)) { - const truncated = segments.slice(0, segments.findIndex((s) => s === null)); + .filter((segment) => !segment.startsWith("(") && !segment.startsWith("_")) + .map((segment) => + segment.startsWith("[") && segment.endsWith("]") ? null : segment, + ); + if (segments.some((segment) => segment === null)) { + const truncated = segments.slice(0, segments.findIndex((segment) => segment === null)); return truncated.length === 0 ? "/" : `/${truncated.join("/")}`; } return segments.length === 0 ? "/" : `/${segments.join("/")}`; } +function inferRouteChanges(changedFiles) { + const routeChanges = new Map(); + + for (const file of changedFiles) { + const route = pageFileToRoute(file); + if (route) { + addRouteFile(routeChanges, route, shortFile(file)); + continue; + } + + for (const [folder, routes] of Object.entries(COMPONENT_FOLDER_ROUTES)) { + if (!file.startsWith(`packages/web/${folder}`)) continue; + for (const mappedRoute of routes) { + addRouteFile(routeChanges, mappedRoute, shortFile(file)); + } + } + } + + return routeChanges; +} + +function addRouteFile(routeChanges, route, file) { + if (!routeChanges.has(route)) routeChanges.set(route, new Set()); + routeChanges.get(route).add(file); +} + function classifyRoute(route) { if (HYBRID_ROUTES.has(route)) return "hybrid"; if (AUTH_ROUTES.has(route)) return "auth"; for (const authRoute of AUTH_ROUTES) { - if (route.startsWith(authRoute + "/")) return "auth"; + if (route.startsWith(`${authRoute}/`)) return "auth"; } return "public"; } +function reviewStatesForRoute(route) { + const classification = classifyRoute(route); + if (classification === "auth") { + return [{ id: "demo-logged-in", label: "demo logged-in", param: "login=demo" }]; + } + if (classification === "hybrid") { + return [ + { id: "logged-out", label: "logged-out", param: "logout=1" }, + { id: "demo-logged-in", label: "demo logged-in", param: "login=demo" }, + ]; + } + return [{ id: "logged-out", label: "logged-out", param: "logout=1" }]; +} + function buildUrl(route, authParam) { - const path = route === "/" ? "" : route; - return `${PREVIEW_URL}${path}?${authParam}`; + const path = route === "/" ? "/" : route; + return addAuthParamToUrl(new URL(path, `${PREVIEW_URL}/`).toString(), authParam); +} + +function addAuthParamToUrl(url, authParam) { + const parsed = new URL(url); + const [key, value] = authParam.split("="); + parsed.searchParams.set(key, value); + return parsed.toString(); } function shortFile(file) { return file.replace(/^packages\/web\/src\//, ""); } -function main() { - const changed = getChangedFiles(); - const routeChanges = new Map(); // route → Set +function loadVisualReviewManifest() { + const raw = VISUAL_REVIEW_MANIFEST_JSON || + (VISUAL_REVIEW_MANIFEST_PATH && existsSync(VISUAL_REVIEW_MANIFEST_PATH) + ? readFileSync(VISUAL_REVIEW_MANIFEST_PATH, "utf8") + : ""); + if (!raw) return null; - for (const file of changed) { - const route = pageFileToRoute(file); - if (route) { - if (!routeChanges.has(route)) routeChanges.set(route, new Set()); - routeChanges.get(route).add(shortFile(file)); - continue; - } - for (const [folder, routes] of Object.entries(COMPONENT_FOLDER_ROUTES)) { - if (file.startsWith(`packages/web/${folder}`)) { - for (const r of routes) { - if (!routeChanges.has(r)) routeChanges.set(r, new Set()); - routeChanges.get(r).add(shortFile(file)); - } - } - } + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : null; + } catch (err) { + console.error(`Failed to parse visual review manifest: ${err.message}`); + return null; + } +} + +function parseCheckedItemIds(body) { + const checked = new Set(); + const regex = /^- \[[xX]\] /gm; + let match; + while ((match = regex.exec(body)) !== null) { + checked.add(match[1]); } + return checked; +} - if (routeChanges.size === 0) { - console.log( - "\n_No user-facing page or component changes in this PR._", - ); - return; +function renderCheckItem({ id, label }, checkedIds) { + const checked = checkedIds.has(id) ? "x" : " "; + return `- [${checked}] ${label}`; +} + +function changedManifestRoutes(manifest) { + const routes = Array.isArray(manifest?.routes) ? manifest.routes : []; + return routes + .filter((route) => + route && + (route.changed || route.errored || route.missingPairs > 0 || route.erroredPairs > 0), + ) + .sort((a, b) => String(a.routeName).localeCompare(String(b.routeName))); +} + +function buildVisualReviewItems(manifest) { + if (!VISUAL_REVIEW_URL || !manifest) return []; + + return changedManifestRoutes(manifest).map((route) => { + const routeName = String(route.routeName || "unknown"); + const authState = normalizeAuthState(route.authState); + const authParam = authState === "demo-logged-in" ? "login=demo" : "logout=1"; + const routeUrl = route.routeUrl + ? addAuthParamToUrl(route.routeUrl, authParam) + : typeof route.routePath === "string" + ? buildUrl(route.routePath, authParam) + : null; + const reviewUrl = + typeof route.reviewUrl === "string" && route.reviewUrl + ? route.reviewUrl + : `${VISUAL_REVIEW_URL}#route-${slugify(routeName)}`; + const status = visualStatusLabel(route); + const label = + `:framed_picture: [${route.routeLabel || labelRouteName(routeName)}](${reviewUrl})` + + ` - ${authLabel(authState)} ${status}` + + (routeUrl ? ` - [open page](${routeUrl})` : ""); + return { + id: `visual:${routeName}:${authState}`, + label, + }; + }); +} + +function normalizeAuthState(value) { + if (value === "authenticated" || value === "demo logged-in" || value === "demo-logged-in") { + return "demo-logged-in"; + } + return "logged-out"; +} + +function authLabel(authState) { + return authState === "demo-logged-in" ? "demo logged-in" : "logged-out"; +} + +function visualStatusLabel(route) { + if (route.errored || route.erroredPairs > 0) return "visual review errored"; + const parts = []; + if (route.changedPairs > 0) parts.push(`${route.changedPairs} changed`); + if (route.missingPairs > 0) parts.push(`${route.missingPairs} missing`); + if (parts.length === 0) parts.push("changed"); + return `${parts.join(" / ")} screenshot${parts.length === 1 && parts[0] === "changed" ? "" : "s"}`; +} + +function buildPreviewItems(routeChanges) { + const items = []; + for (const route of Array.from(routeChanges.keys()).sort()) { + const files = Array.from(routeChanges.get(route)).sort(); + const filesLabel = + files.slice(0, 4).join(", ") + + (files.length > 4 ? ` (+${files.length - 4} more)` : ""); + for (const state of reviewStatesForRoute(route)) { + items.push({ + id: `preview:${route}:${state.id}`, + label: + `:rocket: [\`${route}\`](${buildUrl(route, state.param)})` + + ` - ${state.label} preview - ${filesLabel}`, + }); + } } + return items; +} + +function labelRouteName(routeName) { + return String(routeName) + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function slugify(value) { + return String(value) + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function renderPacket({ + changedFiles, + checkedIds, + manifest, + routeChanges, +}) { + const visualItems = buildVisualReviewItems(manifest); + const previewItems = buildPreviewItems(routeChanges); + const allItems = [...visualItems, ...previewItems]; const lines = [ + "", "", - "## Preview deploy — one-click review links", - "", - `Latest preview: ${PREVIEW_URL}`, + "## PR review packet", "", - "| Page | State | What changed |", - "| --- | --- | --- |", + "### Start here", ]; - const sortedRoutes = Array.from(routeChanges.keys()).sort(); - for (const route of sortedRoutes) { - const files = Array.from(routeChanges.get(route)).sort(); - const filesCell = files.slice(0, 4).join(", ") + - (files.length > 4 ? ` (+${files.length - 4} more)` : ""); - const classification = classifyRoute(route); - - if (classification === "auth") { - lines.push( - `| [\`${route}\`](${buildUrl(route, "login=demo")}) | demo logged-in | ${filesCell} |`, - ); - } else if (classification === "hybrid") { - lines.push( - `| [\`${route}\`](${buildUrl(route, "logout=1")}) | logged-out | ${filesCell} |`, - ); - lines.push( - `| [\`${route}\`](${buildUrl(route, "login=demo")}) | demo logged-in | ${filesCell} |`, - ); - } else { - lines.push( - `| [\`${route}\`](${buildUrl(route, "logout=1")}) | logged-out | ${filesCell} |`, - ); - } + if (VISUAL_REVIEW_URL) { + lines.push(`- :framed_picture: [Visual review](${VISUAL_REVIEW_URL})`); + } else { + lines.push("- :framed_picture: Visual review link appears here after CI publishes `latest.html`."); + } + lines.push(`- :rocket: [Preview deployment](${PREVIEW_URL})`); + lines.push("- :point_up: Cmd/Ctrl-click review links to keep this PR open."); + lines.push("- :key: `?login=demo` signs in as the demo user; `?logout=1` clears the session."); + lines.push("- :speech_balloon: For a visual problem, use the comment button in `latest.html` or reply here with `@claude` and the checklist item."); + lines.push(""); + + if (allItems.length === 0) { + lines.push( + "_No user-facing page or component changes were inferred from changed files or the visual review manifest._", + ); + } else { + lines.push("### Review checklist"); + lines.push(""); + lines.push(...allItems.map((item) => renderCheckItem(item, checkedIds))); } lines.push(""); - lines.push( - "_`?login=demo` signs you in as the demo user; `?logout=1` clears the session. Updated automatically when this PR's preview deploys._", - ); + lines.push("
    "); + lines.push("Changed files considered"); + lines.push(""); + if (changedFiles.length === 0) { + lines.push("_No changed file list was available._"); + } else { + for (const file of changedFiles.slice(0, 40)) { + lines.push(`- \`${file}\``); + } + if (changedFiles.length > 40) { + lines.push(`- ...and ${changedFiles.length - 40} more`); + } + } + lines.push(""); + lines.push("
    "); + lines.push(""); + lines.push("_Updated automatically when this PR's preview or visual review reruns._"); - console.log(lines.join("\n")); + return lines.join("\n"); +} + +function main() { + const changedFiles = getChangedFiles(); + const routeChanges = inferRouteChanges(changedFiles); + const checkedIds = parseCheckedItemIds(EXISTING_COMMENT_BODY); + const manifest = loadVisualReviewManifest(); + console.log(renderPacket({ changedFiles, checkedIds, manifest, routeChanges })); } main(); diff --git a/.github/scripts/generate-pr-preview-links.test.mjs b/.github/scripts/generate-pr-preview-links.test.mjs new file mode 100644 index 000000000..89c708ea5 --- /dev/null +++ b/.github/scripts/generate-pr-preview-links.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const SCRIPT = fileURLToPath( + new URL("./generate-pr-preview-links.mjs", import.meta.url), +); + +function runGenerator(env) { + return execFileSync(process.execPath, [SCRIPT], { + encoding: "utf8", + env: { + ...process.env, + ...env, + }, + }); +} + +test("generates a review packet with visual-review links and preserved checkboxes", () => { + const previewUrl = "https://preview.example.vercel.app"; + const visualReviewUrl = + "https://mikepsinn.github.io/optimitron/pr-123/abcdef123456/latest.html"; + const manifest = { + routes: [ + { + routeName: "home", + routeLabel: "Home", + routePath: "/", + routeUrl: `${previewUrl}/`, + authState: "logged-out", + changed: true, + errored: false, + changedPairs: 2, + missingPairs: 0, + erroredPairs: 0, + reviewUrl: `${visualReviewUrl}#route-home`, + }, + ], + }; + + const output = runGenerator({ + PREVIEW_URL: previewUrl, + VISUAL_REVIEW_URL: visualReviewUrl, + VISUAL_REVIEW_MANIFEST_JSON: JSON.stringify(manifest), + CHANGED_FILES: JSON.stringify([ + "packages/web/src/components/landing/TreatyVoteFlow.tsx", + ]), + EXISTING_COMMENT_BODY: + "- [x] old label", + }); + + assert.match(output, //); + assert.match(output, /\[Visual review\]\(https:\/\/mikepsinn\.github\.io\/optimitron\/pr-123\/abcdef123456\/latest\.html\)/); + assert.match(output, /Cmd\/Ctrl-click review links to keep this PR open/); + assert.match(output, /- \[x\] /); + assert.match(output, /\[Home\]\(https:\/\/mikepsinn\.github\.io\/optimitron\/pr-123\/abcdef123456\/latest\.html#route-home\)/); + assert.match(output, /\[open page\]\(https:\/\/preview\.example\.vercel\.app\/\?logout=1\)/); +}); + +test("lists authenticated and logged-out preview states for hybrid routes", () => { + const output = runGenerator({ + PREVIEW_URL: "https://preview.example.vercel.app/", + CHANGED_FILES: JSON.stringify([ + "packages/web/src/components/tasks/TaskCard.tsx", + ]), + }); + + assert.match(output, //); + assert.match(output, /https:\/\/preview\.example\.vercel\.app\/tasks\?logout=1/); + assert.match(output, //); + assert.match(output, /https:\/\/preview\.example\.vercel\.app\/tasks\?login=demo/); +}); + +test("reports when no user-facing page or component routes are inferred", () => { + const output = runGenerator({ + PREVIEW_URL: "https://preview.example.vercel.app", + CHANGED_FILES: JSON.stringify(["packages/web/src/lib/messaging.ts"]), + }); + + assert.match(output, /No user-facing page or component changes were inferred/); + assert.doesNotMatch(output, /'; + const legacyMarker = ''; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number, per_page: 100, + }); + const existing = [...comments].reverse().find((c) => + c.body && (c.body.includes(packetMarker) || c.body.includes(legacyMarker)) + ); const stdout = execFileSync( process.execPath, ['.github/scripts/generate-pr-preview-links.mjs'], @@ -530,6 +541,7 @@ jobs: env: { ...process.env, CHANGED_FILES: JSON.stringify(changed), + EXISTING_COMMENT_BODY: existing?.body ?? '', }, encoding: 'utf8', }, @@ -537,11 +549,6 @@ jobs: const body = stdout.trim(); if (!body) return; - const marker = ''; - const comments = await github.paginate(github.rest.issues.listComments, { - owner, repo, issue_number, per_page: 100, - }); - const existing = comments.find((c) => c.body && c.body.includes(marker)); if (existing) { await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body, @@ -693,6 +700,69 @@ jobs: description: 'Visual review ready', }); + - name: Update PR review packet with visual review + if: ${{ !cancelled() && steps.visual_review_pages.outcome == 'success' && steps.pr_preview_url.outputs.result != '' }} + uses: actions/github-script@v8 + env: + PREVIEW_URL: ${{ steps.pr_preview_url.outputs.result }} + VISUAL_REVIEW_MANIFEST_PATH: packages/web/output/playwright/review/manifest.json + with: + script: | + const { execFileSync } = require('node:child_process'); + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const headSha = context.payload.pull_request.head.sha; + const shortSha = headSha.slice(0, 12); + const visualReviewUrl = `https://mikepsinn.github.io/optimitron/pr-${issue_number}/${shortSha}/latest.html`; + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: issue_number, per_page: 100, + }); + const changed = files + .filter((f) => f.status !== 'removed') + .map((f) => f.filename); + + const packetMarker = ''; + const legacyMarker = ''; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number, per_page: 100, + }); + const existing = [...comments].reverse().find((c) => + c.body && (c.body.includes(packetMarker) || c.body.includes(legacyMarker)) + ); + + const stdout = execFileSync( + process.execPath, + ['.github/scripts/generate-pr-preview-links.mjs'], + { + env: { + ...process.env, + CHANGED_FILES: JSON.stringify(changed), + EXISTING_COMMENT_BODY: existing?.body ?? '', + VISUAL_REVIEW_URL: visualReviewUrl, + }, + encoding: 'utf8', + }, + ); + const body = stdout.trim(); + if (!body) return; + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } + web-validate: name: web-validate if: always() && (github.event_name != 'push' || github.ref == 'refs/heads/main') diff --git a/.github/workflows/smoke-deploy.yml b/.github/workflows/smoke-deploy.yml index e0b287c2d..50833a843 100644 --- a/.github/workflows/smoke-deploy.yml +++ b/.github/workflows/smoke-deploy.yml @@ -7,7 +7,7 @@ permissions: contents: read deployments: read issues: write - pull-requests: read + pull-requests: write # Operator setup for protected Vercel deployments: # 1. Open GitHub Settings -> Environments -> Preview -> Environment secrets. @@ -313,7 +313,7 @@ jobs: github.event.deployment_status.environment != 'Production' && github.event.deployment_status.environment != 'production' runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 12 environment: name: Preview deployment: false @@ -382,6 +382,96 @@ jobs: # has headless: false and crashes in CI (no display). pnpm --filter @optimitron/web exec playwright test e2e/smoke.spec.ts --project=default --reporter=list + - name: Audit Sentry preview errors + id: sentry_audit + if: always() + env: + PREVIEW_URL: ${{ github.event.deployment_status.environment_url }} + # Dedicated read token should have org:read, project:read, event:read. + # The release-upload SENTRY_AUTH_TOKEN may not be allowed to read issues. + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_PREVIEW_AUDIT_TOKEN || secrets.SENTRY_AUTH_TOKEN }} + SENTRY_AUDIT_INITIAL_DELAY_MS: "15000" + SENTRY_AUDIT_LOOKBACK_MINUTES: "30" + SENTRY_AUDIT_MARKDOWN_FILE: ${{ runner.temp }}/sentry-preview-audit.md + SENTRY_AUDIT_POLL_ATTEMPTS: "3" + SENTRY_AUDIT_POLL_INTERVAL_MS: "15000" + SENTRY_AUDIT_REPORT_FILE: ${{ runner.temp }}/sentry-preview-audit.json + SENTRY_ENVIRONMENT: vercel-preview + SENTRY_ORG: wishonia-org + SENTRY_PROJECT: optimitron-web + SENTRY_RELEASE: ${{ github.event.deployment.sha }} + run: | + set +e + node .github/scripts/audit-sentry-preview.mjs + exit_code=$? + echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Comment on Sentry preview errors + if: always() && steps.sentry_audit.outputs.exit_code != '0' + uses: actions/github-script@v8 + continue-on-error: true + env: + SENTRY_AUDIT_MARKDOWN_FILE: ${{ runner.temp }}/sentry-preview-audit.md + with: + script: | + const fs = require("node:fs"); + const { owner, repo } = context.repo; + const sha = + context.payload.deployment?.sha || + context.payload.deployment?.ref || + context.sha; + const pulls = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + { + owner, + repo, + commit_sha: sha, + per_page: 100, + }, + ); + const pull = pulls.find((pr) => pr.state === "open") || pulls[0]; + if (!pull) { + core.warning(`No pull request found for deployment commit ${sha}.`); + return; + } + + const marker = ""; + const reportPath = process.env.SENTRY_AUDIT_MARKDOWN_FILE; + const body = fs.existsSync(reportPath) + ? fs.readFileSync(reportPath, "utf8") + : [ + marker, + "### Sentry preview audit failed", + "", + "The audit could not write a report. Check the workflow logs for the Sentry API error.", + ].join("\n"); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pull.number, + per_page: 100, + }); + const existing = [...comments] + .reverse() + .find((comment) => comment.body && comment.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pull.number, + body, + }); + } + - name: Upload Playwright artifacts on failure if: failure() uses: actions/upload-artifact@v4 @@ -392,3 +482,7 @@ jobs: packages/web/test-results/ retention-days: 7 if-no-files-found: ignore + + - name: Fail Sentry preview audit + if: always() && steps.sentry_audit.outputs.exit_code != '0' + run: exit "${{ steps.sentry_audit.outputs.exit_code }}" diff --git a/.husky/commit-msg b/.husky/commit-msg deleted file mode 100644 index 3889224bb..000000000 --- a/.husky/commit-msg +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -set -e - -node .claude/hooks/enforce-no-codex-in-commit-message.mjs "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit index 6f0e42aa1..2235a988f 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,6 +2,7 @@ set -e pnpm exec lint-staged +pnpm --filter @optimitron/web run copy:gate # === Copy preview markdown reminder (not auto-run) === # Previously this block auto-ran `pnpm copy:preview` on every commit diff --git a/AGENTS.md b/AGENTS.md index 0b4a7660b..4ce8e2002 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ - For meaningful UI changes, capture before/after screenshots when feasible: before from production, main, or the current unchanged page; after from the branch, preview deployment, or local dev server. Assume screenshots may contain sensitive or production-derived data unless proven otherwise. - For before/after screenshots, prefer the cheapest workflow that preserves the work safely. If the working tree is clean and switching branches will not disrupt uncommitted work, use the same checkout and dependency install sequentially: capture `main`/before, switch back to the feature branch, capture after. Use a separate worktree only when both versions must run at the same time, the current checkout is dirty, branch switching would disrupt a running workflow, or isolation is genuinely faster. Do not create a fresh worktree that requires a full `pnpm install` just for routine screenshot comparison. - Inspect the screenshots yourself for layout breakage, overlapping text, missing content, broken styling, and obvious responsive problems. +- For fixed/sticky UI in full-page screenshots, verify whether overlap blocks normal viewport use before moving or hiding useful controls. - Generate the current screenshot review at `packages/web/output/playwright/review/latest.html` by default, organized by page/viewport with before/after screenshots side by side when both versions are available. This gives the human one stable local file to bookmark and refresh after each UI change. - Make a branch-specific or timestamped review folder only when it is genuinely useful for a longer audit, multiple competing versions, or preserving a before/after history. Do not create duplicate review HTML files out of habit. - If you create a named review folder, also update `packages/web/output/playwright/review/latest.html`. Copy referenced screenshot assets beside `latest.html` or rewrite image paths relative to that stable file, then verify the stable page has no broken image references. @@ -85,6 +86,8 @@ Detailed docs live in `docs/`. Read the relevant ones before working: Before writing or editing any public-facing website, email, metadata, CTA, empty-state, dashboard, survey, referral, or partner copy, read `docs/h2ewd.md` and apply that voice. +- Before changing existing public copy, preserve its strategic job. Identify audience, desired action, motivation, old strategic job, and source/quantitative anchor. Do not replace purpose or motivation with mechanism-only copy. +- Treat Mike as the copy merge gate. When strategy is unclear, ask the shortest missing question with a recommended default. Do not set `COPY_REVIEW_APPROVED=1` or bypass the copy gate without explicit approval. - Be concise. Cut filler, throat-clearing, internal process language, and generic nonprofit/consultant copy. - Speak directly to the specific human or organization that should do something. - Make the action obvious, then show the value to them for doing it. diff --git a/CLAUDE.md b/CLAUDE.md index 75e23c88f..8ade80f94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,13 @@ Everything user-facing is narrated by **Wishonia** — _World Integrated System **Never hand-edit `page.logged-out.md` / `*.email.md` snapshots.** They're generated. After UI/copy changes run `pnpm --filter @optimitron/web copy:preview` (smart by default — auto-detects affected routes via `scripts/affected-routes.mjs`, falls through to full when the static-import walker can't infer). Use `copy:preview:all` for shared-helper changes or initial generation. Emails: `pnpm --filter @optimitron/web email:preview-md`. Hand-edits are a smell that the dev server's down or the script's failing — fix that instead. -**Update `TODO.md` in the same commit** as the work it covers — both the check-box and any new follow-up lines. Deferred decisions go in TODO.md the same turn. Subagent prompts include the relevant TODO.md slice as context. +**Update `TODO.md` in the same commit** as the work it covers — both the check-box and any new follow-up lines. Deferred decisions go in TODO.md the same turn. Subagent prompts include the relevant TODO.md slice as context. Commit message must include `todo-touched: ` (work closed a TODO line) or `todo-skipped: ` (net-new work or out-of-scope) — `verify-ui-changes.mjs` advisory-flags missing markers. Audit Mike-Visible: see `## Mike's role` below. + +## Mike's role: strategic copy gate + +Do not ask Mike engineering questions. Agents decide implementation details. Ask Mike only for copy/voice approval, strategic priority forks, or ship/redraft/abandon calls on user-visible artifacts. + +When asking, use the smallest multiple-choice question with a recommended default and optional Other. For copy changes, show verbatim before/after. If Mike says "I don't know, what do you think?", choose, record the decision, and proceed. **Hook-enforced rules.** Pre-architect Read on Write to `packages/*/src/` and "should it really / I thought / aren't we" detection on UserPromptSubmit are enforced by hooks. When a hook fires, treat its output as authoritative. diff --git a/TODO.md b/TODO.md index 2cfb92195..4b2bd8422 100644 --- a/TODO.md +++ b/TODO.md @@ -470,6 +470,19 @@ Durable summary lives here; no loose `.claude/plans/campaign-impact-attribution- the strongest approved variant, not "Support the settlement." - Show a running plaintiff count near the hero. Named plaintiffs are harder to ignore than an anonymous vote total. +- Build the public death/plaintiff stream: a constantly updating, moderated + list of humans submitted by loved ones who recently died, shown as + wrongful-death claims against the governments that kept buying apocalypse + capacity instead of medical progress. Use represented people / + `CourtCaseParty` data first; do not scrape or invent deaths for v1. + Landing-page placement: below the primary treaty vote/referral action, not + above it. Show the most recent named person, a ranked/recent list, and a CTA + to "Name your dead" / register the estate. Tie the legal theory to the + 1900-counterfactual: disease deaths after the model cutoff and aging deaths + after the aging cutoff are prima facie government-caused because a rational + treaty could have redirected military growth to clinical trials and public + goods. Reuse parameter-backed apocalypse / cutoff / damages claims; no + hardcoded 122-apocalypse or 1950/1990 copy. - Add the missing counterfactual sentence: damages are what humanity would have had if governments had signed the 1% Treaty in 1900, freezing military spending growth and redirecting surplus to clinical trials and public goods. diff --git a/packages/web/AGENTS.md b/packages/web/AGENTS.md index 2b72b81c5..07f10ab79 100644 --- a/packages/web/AGENTS.md +++ b/packages/web/AGENTS.md @@ -24,6 +24,8 @@ Imports from ALL `@optimitron/*` packages. This is the integration layer. - **Use primitives for behavior, not decoration.** Check `src/components/retroui/` and existing domain components before hand-rolling buttons, inputs, checkboxes, dialogs, tables, menus, accordions, or other standard controls. Prefer the RetroUI primitive when it provides the right behavior; if its visual chrome conflicts with the simple black-and-white treaty style, migrate the primitive or its usage instead of duplicating a one-off control. - **Metadata from routes.ts.** Use `getRouteMetadata()` — don't hardcode page titles. - **Wishonia's voice.** All user-facing copy is in Wishonia's voice. Read `docs/h2ewd.md` before writing or rewriting public copy. +- **Use the H2EWD copy workflow.** For public copy edits, use `.agents/skills/h2ewd-copy`: identify audience, desired action, motivation, old strategic job, and source anchor before editing. Do not replace purpose/motivation copy with mechanism-only copy. +- **Mike is the copy merge gate.** The pre-commit copy gate blocks public copy changes until explicit approval. Do not bypass it or set `COPY_REVIEW_APPROVED=1` unless Mike approved the changed copy. - **Conversion copy, not internal narration.** Speak directly to the audience, tell them what to do, and show the value of doing it. Keep it concise, funny where appropriate, and allergic to generic nonprofit/consultant language. - **No implementation leaks in copy.** Do not expose internal planning terms like "site variant", "program graph", "initiative landing page", "approved organizations get", route policy language, or admin labels unless the user explicitly wants that exact wording surfaced. - **Treat every empty state as an action surface.** If the user needs to invite humans, embed a survey, vote, assign Earth optimization tasks, or check status, show the useful control before explaining the absence of data. @@ -37,6 +39,7 @@ Imports from ALL `@optimitron/*` packages. This is the integration layer. - **Do not build just to run e2e** if a suitable dev server is already running. - **Protect an existing dev server from routine churn.** Reuse it for small verification steps; if a clean build, restart, or separate run is genuinely needed, that is fine, but escalate from narrow checks to heavier ones only when necessary. - **Screenshot every UI change before committing.** After changing pages, components, layouts, visual styling, or user-facing states, capture screenshots of the affected surface, inspect them for layout/text/styling problems, then tell the human where the screenshots are and ask them to review before committing. +- **Account for fixed UI in screenshots.** Full-page screenshots exaggerate fixed/sticky controls. Verify whether overlap blocks normal viewport use before moving or hiding useful controls. - **Use the stable screenshot review page.** For meaningful UI changes, generate the local HTML comparison at `packages/web/output/playwright/review/latest.html` with before/after screenshots side by side when both versions are available. The review page should still capture all covered routes, but changed routes or missing-baseline routes should be expanded by default and unchanged routes collapsed. If true before screenshots are not practical, make an after-only review page and say so. - **Opt review routes in from `routes.ts`.** Normal route coverage should come from `NavItem` review flags in `src/lib/routes.ts`: `screenshot`, `authenticatedScreenshot`, `copyPreview`, and `authenticatedCopyPreview`. Keep non-nav visual exceptions small and explicit for UI states or seeded dynamic pages only, such as an open side menu or a seeded task detail URL. - **Create named review folders only when useful.** Do not create duplicate `review.html` files out of habit. A named folder under `packages/web/output/playwright/` is useful for a longer audit, multiple competing versions, or preserving before/after history; otherwise `review/latest.html` is enough. @@ -68,6 +71,7 @@ Before calling a public or authenticated UI "done," inspect it as if the human i - **One click should do the obvious thing.** Post-submit CTAs should deep-link to the specific thing just created. Photo boxes should open photo upload/crop. Do not send users to an index where they must find and click the same item again. - **Use normal app language.** Buttons and dialog labels should sound like the familiar control they are: "Upload photo," "Crop photo," "Cancel," "Save," "Delete." Avoid awkward labels like "Crop square photo." - **Do not show giant forms by default.** Use tables/cards for scanning and dialogs or expansion for editing. Forms should appear when the user chooses to edit a specific item. +- **One progressive control beats mode tabs.** Before adding tabs, modes, or always-visible fields, name the user sentence and prefer one search/select control. Search existing records before revealing create-new fields. If the UI mirrors the database model, redesign. - **Mobile is not optional.** Tables must not clip off-screen. Use responsive cards or a proven responsive table primitive. Pagination belongs where users expect it, usually below the list. - **Remove columns nobody cares about.** Do not include evidence counts, implementation fields, or other low-value columns just because the data exists. - **Use proven UI libraries for tricky interactions.** Cropping, dragging, zooming, sliders, dialogs, and tables should rely on established primitives before hand-rolled behavior. diff --git a/packages/web/e2e/email-screenshots.spec.ts b/packages/web/e2e/email-screenshots.spec.ts index f95319f02..c1c654542 100644 --- a/packages/web/e2e/email-screenshots.spec.ts +++ b/packages/web/e2e/email-screenshots.spec.ts @@ -75,7 +75,7 @@ test.describe("email visual coverage", () => { page, "magic-link", "email-magic-link", - "Your sign-in link is below.", + "Click the button below to verify your email and save your vote.", testInfo, ); }); diff --git a/packages/web/e2e/new-user-flow-screenshots.spec.ts b/packages/web/e2e/new-user-flow-screenshots.spec.ts index f929fb9f9..8e4c9c477 100644 --- a/packages/web/e2e/new-user-flow-screenshots.spec.ts +++ b/packages/web/e2e/new-user-flow-screenshots.spec.ts @@ -361,7 +361,7 @@ async function captureEmailPreview( { waitUntil: "domcontentloaded" }, ); await expect(page.getByTestId("magic-link-email-preview")).toContainText( - "Your sign-in link is below.", + "Click the button below to verify your email and save your vote.", ); await captureStep(outcome, dir, stepState, "magic-link-email", (filePath) => captureElement(page.getByTestId("magic-link-email-preview"), filePath), diff --git a/packages/web/e2e/smoke.spec.ts b/packages/web/e2e/smoke.spec.ts index 6bf735c6c..7fb53529f 100644 --- a/packages/web/e2e/smoke.spec.ts +++ b/packages/web/e2e/smoke.spec.ts @@ -43,6 +43,10 @@ const CRITICAL_AUTH_REQUIRED_PATHS = new Set([ const CRITICAL_PUBLIC_PAGE_PATHS = [...CRITICAL_SMOKE_PATHS].filter( (path) => !CRITICAL_AUTH_REQUIRED_PATHS.has(path), ); +const NEXT_AUTH_COOKIE_NAMES = [ + "next-auth.session-token", + "__Secure-next-auth.session-token", +]; function loadStaticPages() { // Keep critical smoke independent from full-route discovery so the fastest @@ -155,3 +159,27 @@ for (const path of SMOKE_AUTH_REQUIRED_PATHS) { await expectPageLoadsWithMetadata(page, path); }); } + +if (SMOKE_SCOPE === "critical") { + test(`${ROUTES.dashboard}?login=demo signs in and renders dashboard`, async ({ + context, + page, + }) => { + await context.clearCookies(); + + await expectPageLoadsWithMetadata(page, `${ROUTES.dashboard}?login=demo`); + + const finalUrl = new URL(page.url()); + expect(finalUrl.pathname).toBe(ROUTES.dashboard); + expect(finalUrl.searchParams.has("login")).toBe(false); + + const sessionCookie = (await context.cookies()).find( + (cookie) => + NEXT_AUTH_COOKIE_NAMES.includes(cookie.name) && cookie.value !== "", + ); + expect( + sessionCookie, + "?login=demo should mint a NextAuth session cookie", + ).toBeTruthy(); + }); +} diff --git a/packages/web/e2e/utils/visual-routes.ts b/packages/web/e2e/utils/visual-routes.ts index a074b8271..788bc30ec 100644 --- a/packages/web/e2e/utils/visual-routes.ts +++ b/packages/web/e2e/utils/visual-routes.ts @@ -1,7 +1,4 @@ -import { - getRouteReviewSpecs, - ROUTES, -} from "@/lib/routes"; +import { getRouteReviewSpecs, ROUTES } from "@/lib/routes"; import { filterRedirectOnlyRoutes, isRedirectOnlyRoutePath, @@ -10,13 +7,16 @@ import { ALL_PAGE_PATHS, PUBLIC_PAGE_PATHS } from "./static-pages"; export type VisualRoute = { authenticated?: boolean; + createTaskMode?: "person"; expectSettings?: boolean; name: string; + openCreateTask?: boolean; openMenu?: boolean; path: string; required: boolean; requiredSelector?: string; requiredText?: RegExp; + waitForImages?: boolean; }; const PRESIDENT_TASK_LIST_SELECTOR = @@ -26,6 +26,8 @@ const REQUIRED_SELECTOR_BY_PATH = new Map([ [ROUTES.employees, PRESIDENT_TASK_LIST_SELECTOR], ]); +const IMAGE_STABLE_ROUTE_PATHS = new Set([ROUTES.employees]); + const REQUIRED_TEXT_BY_PATH = new Map([ [ROUTES.court, /IN WITNESS WHEREOF/], ]); @@ -45,9 +47,23 @@ const SPECIAL_STATE_ROUTES: VisualRoute[] = [ openMenu: true, expectSettings: true, }, + { + name: "create-task-dialog-person", + path: ROUTES.home, + required: true, + authenticated: true, + openCreateTask: true, + createTaskMode: "person", + requiredText: /New person/, + }, ]; const SEEDED_DYNAMIC_ROUTES: VisualRoute[] = [ + { + name: "referendum-one-percent-treaty", + path: "/agencies/dcongress/referendums/one-percent-treaty", + required: false, + }, { name: "organization-iam-public", path: "/organizations/institute-for-accelerated-medicine", @@ -59,9 +75,35 @@ const SEEDED_DYNAMIC_ROUTES: VisualRoute[] = [ required: false, }, { name: "people-mike", path: "/people/mike", required: false }, - { name: "task-optimize-earth", path: "/tasks/optimize-earth", required: false }, - { name: "task-one-percent-treaty", path: "/tasks/1-pct-treaty", required: false }, - { name: "task-signer-canada", path: "/tasks/1-pct-treaty-signer-ca", required: false }, + { + name: "people-demo-owner", + path: "/people/demo", + required: false, + authenticated: true, + requiredText: /work to end war and disease/i, + }, + { + name: "people-demo-assign-dialog", + path: "/people/demo?assignTask=1", + required: false, + authenticated: true, + requiredText: /Who should do it\?/, + }, + { + name: "task-optimize-earth", + path: "/tasks/optimize-earth", + required: false, + }, + { + name: "task-one-percent-treaty", + path: "/tasks/1-pct-treaty", + required: false, + }, + { + name: "task-signer-canada", + path: "/tasks/1-pct-treaty-signer-ca", + required: false, + }, ]; const PUBLIC_SCREENSHOT_ROUTES: VisualRoute[] = filterRedirectOnlyRoutes( @@ -74,6 +116,7 @@ const PUBLIC_SCREENSHOT_ROUTES: VisualRoute[] = filterRedirectOnlyRoutes( required: true, requiredSelector: REQUIRED_SELECTOR_BY_PATH.get(path), requiredText: REQUIRED_TEXT_BY_PATH.get(path), + waitForImages: IMAGE_STABLE_ROUTE_PATHS.has(path), })); const AUTHENTICATED_SCREENSHOT_ROUTES: VisualRoute[] = filterRedirectOnlyRoutes( @@ -87,6 +130,7 @@ const AUTHENTICATED_SCREENSHOT_ROUTES: VisualRoute[] = filterRedirectOnlyRoutes( authenticated: true, requiredSelector: REQUIRED_SELECTOR_BY_PATH.get(path), requiredText: REQUIRED_TEXT_BY_PATH.get(path), + waitForImages: IMAGE_STABLE_ROUTE_PATHS.has(path), })); export const VISUAL_ROUTES: VisualRoute[] = dedupeRoutes([ diff --git a/packages/web/e2e/visual-regression.spec.ts b/packages/web/e2e/visual-regression.spec.ts index f18a19005..cf916bc99 100644 --- a/packages/web/e2e/visual-regression.spec.ts +++ b/packages/web/e2e/visual-regression.spec.ts @@ -79,9 +79,10 @@ test.describe("route visual regression", () => { await writeFile( ROUTE_MANIFEST_PATH, JSON.stringify( - VISUAL_ROUTES.map(({ name, path: routePath }) => ({ - name, - path: routePath, + VISUAL_ROUTES.map((route) => ({ + name: route.name, + path: route.path, + authenticated: route.authenticated === true, })), null, 2, @@ -108,11 +109,17 @@ test.describe("route visual regression", () => { const status = response?.status() ?? 0; if (!route.required && OPTIONAL_ROUTE_SKIP_STATUSES.has(status)) { - test.skip(true, `${route.path} returned ${status}; seed data not available`); + test.skip( + true, + `${route.path} returned ${status}; seed data not available`, + ); return; } - expect(status, `${route.path} should load before screenshot`).toBeLessThan(400); + expect( + status, + `${route.path} should load before screenshot`, + ).toBeLessThan(400); await normalizeVisualPage(page); if ("openMenu" in route && route.openMenu) { @@ -120,6 +127,11 @@ test.describe("route visual regression", () => { expectSettings: "expectSettings" in route && route.expectSettings, }); } + if ("openCreateTask" in route && route.openCreateTask) { + await openCreateTaskDialog(page, { + mode: route.createTaskMode, + }); + } if (route.requiredSelector) { // Regression guard: these visual pages must keep exposing the @@ -133,10 +145,19 @@ test.describe("route visual regression", () => { } await waitForVisualIdle(page); + if (route.waitForImages) { + await waitForVisualImages(page); + } const screenshotFileName = `${route.name}.png`; - const reviewScreenshotDir = path.join(REVIEW_AFTER_ROOT, testInfo.project.name); + const reviewScreenshotDir = path.join( + REVIEW_AFTER_ROOT, + testInfo.project.name, + ); const screenshotDir = path.join(SCREENSHOT_ROOT, testInfo.project.name); - const reviewScreenshotPath = path.join(reviewScreenshotDir, screenshotFileName); + const reviewScreenshotPath = path.join( + reviewScreenshotDir, + screenshotFileName, + ); const screenshotPath = path.join(screenshotDir, screenshotFileName); await mkdir(reviewScreenshotDir, { recursive: true }); await mkdir(screenshotDir, { recursive: true }); @@ -169,9 +190,15 @@ async function openVisualRoute(page: Page, routePath: string) { waitUntil: "domcontentloaded", timeout: 90_000, }); + await page.waitForLoadState("load", { timeout: 10_000 }).catch(() => { + // Some streaming pages keep subresources open; screenshots only need the + // final document after redirects settle. + }); await forceAnimationsComplete(page); - expect(errors, `${routePath} should not throw client-side errors`).toEqual([]); + expect(errors, `${routePath} should not throw client-side errors`).toEqual( + [], + ); return response; } @@ -207,6 +234,139 @@ async function waitForVisualIdle(page: Page) { await forceAnimationsComplete(page); } +async function waitForVisualImages(page: Page) { + await wakeOffscreenImages(page); + await page + .waitForLoadState("networkidle", { timeout: 15_000 }) + .catch(() => undefined); + await waitForDomImages(page); + await waitForAvatarFallbacksToSettle(page); + await page.evaluate(() => window.scrollTo(0, 0)); + await waitForPaint(page); +} + +async function wakeOffscreenImages(page: Page) { + await page.evaluate(async () => { + const height = Math.max( + document.body.scrollHeight, + document.documentElement.scrollHeight, + ); + const step = Math.max(window.innerHeight, 1); + + for (let y = 0; y < height; y += step) { + window.scrollTo(0, y); + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + } + }); +} + +async function waitForDomImages(page: Page) { + await page.evaluate(async () => { + const images = Array.from(document.images).filter((image) => { + if (!image.isConnected || !image.currentSrc) return false; + const rect = image.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + + await Promise.allSettled( + images.map( + (image) => + new Promise((resolve) => { + if (image.complete) { + resolve(); + return; + } + + const finish = () => resolve(); + const timeout = window.setTimeout(finish, 5_000); + image.addEventListener( + "load", + () => { + window.clearTimeout(timeout); + finish(); + }, + { once: true }, + ); + image.addEventListener( + "error", + () => { + window.clearTimeout(timeout); + finish(); + }, + { once: true }, + ); + }), + ), + ); + + await Promise.allSettled( + images.map((image) => + image.complete && image.naturalWidth > 0 + ? image.decode().catch(() => undefined) + : Promise.resolve(), + ), + ); + }); +} + +async function waitForAvatarFallbacksToSettle(page: Page) { + await page + .waitForFunction( + () => { + const getVisibleFallbackCount = () => + Array.from( + document.querySelectorAll( + '[data-volatile="initials"]', + ), + ).filter((element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return ( + style.display !== "none" && + style.visibility !== "hidden" && + rect.width > 0 && + rect.height > 0 + ); + }).length; + + const key = "__OPTIMITRON_VISUAL_AVATAR_FALLBACK_SETTLE__"; + const now = performance.now(); + const currentCount = getVisibleFallbackCount(); + const windowWithState = window as Window & { + [key]?: { + changedAt: number; + count: number; + startedAt: number; + }; + }; + const state = + windowWithState[key] ?? + ({ + changedAt: now, + count: currentCount, + startedAt: now, + } as const); + + if (state.count !== currentCount) { + windowWithState[key] = { + changedAt: now, + count: currentCount, + startedAt: state.startedAt, + }; + return false; + } + + windowWithState[key] = state; + return now - state.startedAt >= 1_500 && now - state.changedAt >= 750; + }, + undefined, + { timeout: 8_000 }, + ) + .catch(() => undefined); +} + async function openSideMenu( page: Page, { expectSettings = false }: { expectSettings?: boolean } = {}, @@ -233,7 +393,9 @@ async function openSideMenu( } await expect(dialog).toBeVisible(); - await expect(dialog.getByRole("link", { name: /Manage Humanity/i })).toBeVisible(); + await expect( + dialog.getByRole("link", { name: /Manage Humanity/i }), + ).toBeVisible(); if (expectSettings) { await expect(dialog.getByRole("link", { name: /Settings/i })).toBeVisible(); } else { @@ -242,3 +404,25 @@ async function openSideMenu( await forceAnimationsComplete(page); await waitForPaint(page); } + +async function openCreateTaskDialog( + page: Page, + { mode }: { mode?: "person" } = {}, +) { + await page.getByRole("button", { name: "Open campaign actions" }).click(); + await page.getByRole("button", { name: /^Create task$/ }).click(); + + const dialog = page.getByRole("dialog", { name: /Create task/i }); + await expect(dialog).toBeVisible(); + + if (mode === "person") { + await dialog.getByLabel("Who should do it?").fill("Jane Doe"); + await dialog + .getByRole("button", { name: /Add "Jane Doe" as a new person/i }) + .click(); + await expect(dialog.getByText("New person")).toBeVisible(); + } + + await forceAnimationsComplete(page); + await waitForPaint(page); +} diff --git a/packages/web/package.json b/packages/web/package.json index 94e52b330..64673fcc4 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -39,6 +39,7 @@ "fix:colors": "tsx scripts/fix-colors.ts", "extract:copy": "tsx scripts/extract-copy.ts", "copy:preview": "node scripts/copy-preview-smart.mjs", + "copy:gate": "tsx scripts/copy-review-gate.ts", "copy:preview:all": "tsx scripts/render-pages-to-markdown.ts", "email:preview-md": "tsx scripts/render-emails-to-markdown.ts", "extract:tasks": "tsx scripts/extract-tasks-from-manual.ts", diff --git a/packages/web/scripts/build-visual-review.mjs b/packages/web/scripts/build-visual-review.mjs index d6af81163..f73382546 100644 --- a/packages/web/scripts/build-visual-review.mjs +++ b/packages/web/scripts/build-visual-review.mjs @@ -34,6 +34,7 @@ const pageLinkBaseUrl = parseOptionalUrl( const outputRoot = path.resolve(webRoot, "output", "playwright", "review"); const assetRoot = path.join(outputRoot, "assets"); const latestHtmlPath = path.join(outputRoot, "latest.html"); +const reviewManifestPath = path.join(outputRoot, "manifest.json"); // 0.2% — covers cross-environment pixel rendering noise (Windows-Chromium- // local vs Linux-Chromium-CI font hinting + anti-aliasing). Tightened from // the original 0.5% workaround for live-clock drift (that drift is now @@ -63,7 +64,10 @@ const reviewCacheKey = [ ] .filter(Boolean) .join("-"); -const routePaths = loadRoutePaths(); +const routeSpecs = loadRouteSpecs(); +const routePaths = new Map( + [...routeSpecs.entries()].map(([name, spec]) => [name, spec.path]), +); const authenticatedSnapshotRouteNames = new Set([ "dashboard", "organizations", @@ -92,11 +96,13 @@ const routeOrder = [ "home", "side-menu", "side-menu-auth", + "create-task-dialog-person", "dashboard", "employees", "vote", "treaty", "treaty-auth", + "referendum-one-percent-treaty", "about", "agencies", "scoreboard", @@ -142,10 +148,13 @@ async function main() { ]; const grouped = await analyzeGroups(groupScreenshots(screenshots)); const html = renderHtml(grouped); + const manifest = buildReviewManifest(grouped); const blockingIssues = getBlockingReviewIssues(grouped, screenshots); writeFileSync(latestHtmlPath, html, "utf8"); + writeFileSync(reviewManifestPath, JSON.stringify(manifest, null, 2), "utf8"); console.log(`[visual-review] wrote ${latestHtmlPath}`); + console.log(`[visual-review] wrote ${reviewManifestPath}`); console.log(`[visual-review] screenshots=${screenshots.length}`); console.log( `[visual-review] changed=${grouped.filter((group) => group.changed).length} unchanged=${grouped.filter((group) => !group.changed).length}`, @@ -705,10 +714,12 @@ function renderHtml(groups) { background: var(--bg); border: 1px solid var(--line); color: var(--fg); + flex: 1 1 280px; font-family: inherit; font-size: 13px; font-weight: 700; - min-width: 240px; + max-width: 360px; + min-width: 220px; padding: 6px 10px; } @@ -729,6 +740,32 @@ function renderHtml(groups) { color: var(--bg); } + .toolbar-route-actions { + align-items: center; + display: flex; + gap: 8px; + } + + .toolbar-page-link { + align-items: center; + display: inline-flex; + min-height: 24px; + text-decoration: none; + } + + .toolbar-button:disabled, + .toolbar-page-link[aria-disabled="true"] { + border-color: #cccccc; + color: var(--muted); + cursor: not-allowed; + } + + .toolbar-button:disabled:hover, + .toolbar-page-link[aria-disabled="true"]:hover { + background: var(--bg); + color: var(--muted); + } + .toolbar-count { color: var(--muted); font-size: 11px; @@ -863,6 +900,10 @@ function renderHtml(groups) { + + + Open page + @@ -1092,6 +1133,7 @@ function renderHtml(groups) { event.stopPropagation(); var raw = button.getAttribute("data-context"); if (!raw) return; + var prev = button.textContent; var ctx; try { ctx = JSON.parse(raw); @@ -1101,7 +1143,6 @@ function renderHtml(groups) { } var formatted = formatContext(ctx); copyToClipboard(formatted).then(function () { - var prev = button.textContent; button.textContent = "✓ Copied"; button.classList.add("copied"); window.setTimeout(function () { @@ -1111,7 +1152,7 @@ function renderHtml(groups) { }, function () { button.textContent = "Copy failed"; window.setTimeout(function () { - button.textContent = "📋 Copy context"; + button.textContent = prev; }, 1500); }); }); @@ -1165,16 +1206,17 @@ function renderHtml(groups) { (function () { var indicator = document.getElementById("current-route"); if (!indicator) return; + var toolbarCopyButton = document.getElementById("toolbar-copy-context"); + var toolbarOpenPage = document.getElementById("toolbar-open-page"); var routes = Array.prototype.slice.call( document.querySelectorAll("details.route"), ); if (routes.length === 0) return; - // Map each route element to its visible title text. - var titles = routes.map(function (r) { - var titleEl = r.querySelector(".route-title"); + function getRouteTitle(route) { + var titleEl = route.querySelector(".route-title"); return titleEl ? titleEl.textContent.trim() : ""; - }); + } // Account for the sticky toolbar's height when deciding which route // is "at the top." The toolbar sits flush with viewport top:0; we @@ -1184,25 +1226,88 @@ function renderHtml(groups) { return toolbar ? toolbar.getBoundingClientRect().height : 0; } + function setCurrentRoute(route) { + var routeTitle = route ? getRouteTitle(route) : ""; + if (indicator.textContent !== routeTitle) { + indicator.textContent = routeTitle; + } + + if (toolbarCopyButton) { + var sourceCopyButton = route + ? route.querySelector(".route-summary-actions .copy-context-button") + : null; + var context = sourceCopyButton + ? sourceCopyButton.getAttribute("data-context") + : ""; + if (context) { + toolbarCopyButton.disabled = false; + toolbarCopyButton.setAttribute("data-context", context); + toolbarCopyButton.setAttribute( + "aria-label", + "Copy context for " + routeTitle + " to clipboard", + ); + } else { + toolbarCopyButton.disabled = true; + toolbarCopyButton.removeAttribute("data-context"); + toolbarCopyButton.setAttribute( + "aria-label", + "Copy context for the current route to clipboard", + ); + } + } + + if (toolbarOpenPage) { + var routeUrl = route ? route.getAttribute("data-route-url") : ""; + if (routeUrl) { + toolbarOpenPage.href = routeUrl; + toolbarOpenPage.target = "_blank"; + toolbarOpenPage.rel = "noreferrer"; + toolbarOpenPage.removeAttribute("aria-disabled"); + toolbarOpenPage.removeAttribute("tabindex"); + toolbarOpenPage.setAttribute( + "aria-label", + "Open " + routeTitle + " page", + ); + } else { + toolbarOpenPage.removeAttribute("href"); + toolbarOpenPage.setAttribute("aria-disabled", "true"); + toolbarOpenPage.setAttribute("tabindex", "-1"); + toolbarOpenPage.setAttribute( + "aria-label", + "Open page for the current route", + ); + } + } + } + function updateCurrent() { var offset = getToolbarOffset(); - var current = ""; + var currentRoute = null; + var firstVisibleRoute = null; + var lastPastRoute = null; for (var i = 0; i < routes.length; i++) { // Skip routes hidden by the filter / "only show changed" — they // still have getBoundingClientRect but it returns zeros, and a // stale title from before filtering would otherwise stick. if (routes[i].hasAttribute("hidden")) continue; + if (!firstVisibleRoute) { + firstVisibleRoute = routes[i]; + } var rect = routes[i].getBoundingClientRect(); + if (rect.top - offset <= 0) { + lastPastRoute = routes[i]; + } // Route is "current" if its top has crossed above the toolbar // bottom edge AND its bottom hasn't passed yet. if (rect.top - offset <= 0 && rect.bottom > offset) { - current = titles[i]; + currentRoute = routes[i]; break; } } - if (indicator.textContent !== current) { - indicator.textContent = current; + if (!currentRoute) { + currentRoute = lastPastRoute || firstVisibleRoute; } + setCurrentRoute(currentRoute); } // Throttle scroll handler via requestAnimationFrame. @@ -1218,6 +1323,13 @@ function renderHtml(groups) { window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("resize", onScroll, { passive: true }); + if (toolbarOpenPage) { + toolbarOpenPage.addEventListener("click", function (event) { + if (toolbarOpenPage.getAttribute("aria-disabled") === "true") { + event.preventDefault(); + } + }); + } // Also update when a route is expanded/collapsed (changes layout). document.addEventListener("toggle", onScroll, true); @@ -1247,9 +1359,13 @@ function renderHtml(groups) { function renderRouteGroup(group) { const pairs = group.pairs.map(renderPair).join("\n"); const openAttr = group.changed || group.errored ? " open" : ""; - const contextJson = JSON.stringify(buildRouteContext(group)); + const routeContext = buildRouteContext(group); + const contextJson = JSON.stringify(routeContext); + const routeUrlAttr = routeContext.routeUrl + ? ` data-route-url="${escapeHtml(routeContext.routeUrl)}"` + : ""; const anchorId = `route-${slugifyForAnchor(group.routeName)}`; - return `
    + return `
    ${escapeHtml(labelRoute(group.routeName))} @@ -1277,17 +1393,14 @@ function renderRouteGroup(group) { */ function buildRouteContext(group) { const routeUrl = getRouteUrl(group.routeName); - const isAuthed = isAuthenticatedMarkdownRoute(group.routeName); + const authState = getRouteAuthState(group.routeName); const status = group.errored ? "errored" : group.changed ? "changed vs main" : "unchanged vs main"; const sha = reviewCommitSha ? shortSha(reviewCommitSha) : null; - const reviewBase = - prNumber && sha - ? `https://mikepsinn.github.io/optimitron/pr-${prNumber}/${sha}` - : null; + const reviewBase = getPublishedReviewBase(); // Per-project before/after screenshot URLs so the coding agent can // download and look at them. relPath is the on-disk path inside the @@ -1309,7 +1422,7 @@ function buildRouteContext(group) { route: group.routeName, routeLabel: labelRoute(group.routeName), routeUrl, - authState: isAuthed ? "authenticated" : "logged-out", + authState, status, screenshots, reviewUrl: reviewBase @@ -1409,12 +1522,9 @@ function renderFigcaption(label, routeUrl, screenContext) { */ function buildScreenContext(pair, viewing, relPath) { const routeUrl = getRouteUrl(pair.routeName); - const isAuthed = isAuthenticatedMarkdownRoute(pair.routeName); + const authState = getRouteAuthState(pair.routeName); const sha = reviewCommitSha ? shortSha(reviewCommitSha) : null; - const reviewBase = - prNumber && sha - ? `https://mikepsinn.github.io/optimitron/pr-${prNumber}/${sha}` - : null; + const reviewBase = getPublishedReviewBase(); const imageUrl = relPath && reviewBase ? `${reviewBase}/${relPath}` : null; return { pr: prNumber, @@ -1425,7 +1535,7 @@ function buildScreenContext(pair, viewing, relPath) { route: pair.routeName, routeLabel: labelRoute(pair.routeName), routeUrl, - authState: isAuthed ? "authenticated" : "logged-out", + authState, project: pair.projectName, projectLabel: labelProject(pair.projectName), viewing, @@ -1834,6 +1944,47 @@ function summarizeGroups(groups) { }; } +function buildReviewManifest(groups) { + const reviewBase = getPublishedReviewBase(); + return { + version: 1, + generatedAt: reviewGeneratedAt.toISOString(), + generatedAtCentral: formatCentralTime(reviewGeneratedAt), + commitSha: reviewCommitSha, + shortSha: reviewCommitSha ? shortSha(reviewCommitSha) : null, + prNumber, + headBranch, + repo: repoSlug, + previewBaseUrl: pageLinkBaseUrl ? pageLinkBaseUrl.toString() : null, + reviewUrl: reviewBase ? `${reviewBase}/latest.html` : null, + summary: summarizeGroups(groups), + routes: groups.map((group) => ({ + routeName: group.routeName, + routeLabel: labelRoute(group.routeName), + routePath: routePaths.get(group.routeName) ?? null, + routeUrl: getRouteUrl(group.routeName), + authState: getRouteAuthState(group.routeName), + changed: group.changed, + errored: group.errored, + changedPairs: group.changedPairs, + missingPairs: group.missingPairs, + erroredPairs: group.erroredPairs, + statusLabel: routeStatusLabel(group), + reviewUrl: reviewBase + ? `${reviewBase}/latest.html#route-${slugifyForAnchor(group.routeName)}` + : null, + projects: group.pairs.map((pair) => ({ + projectName: pair.projectName, + projectLabel: labelProject(pair.projectName), + changed: Boolean(pair.diff?.changed), + missing: Boolean(pair.diff?.missing), + errored: Boolean(pair.diff?.errored), + diffLabel: pair.diff?.label ?? null, + })), + })), + }; +} + function getBlockingReviewIssues(groups, screenshots) { const issues = []; const afterCount = screenshots.filter( @@ -1961,7 +2112,7 @@ function shortSha(value) { return String(value).slice(0, 12); } -function loadRoutePaths() { +function loadRouteSpecs() { if (!existsSync(routeManifestPath)) { return new Map(); } @@ -1979,7 +2130,13 @@ function loadRoutePaths() { typeof entry.name === "string" && typeof entry.path === "string" )) - .map((entry) => [entry.name, entry.path]), + .map((entry) => [ + entry.name, + { + path: entry.path, + authenticated: entry.authenticated === true, + }, + ]), ); } catch (error) { console.warn( @@ -1989,6 +2146,20 @@ function loadRoutePaths() { } } +function getPublishedReviewBase() { + const sha = reviewCommitSha ? shortSha(reviewCommitSha) : null; + return prNumber && sha + ? `https://mikepsinn.github.io/optimitron/pr-${prNumber}/${sha}` + : null; +} + +function getRouteAuthState(routeName) { + if (routeSpecs.get(routeName)?.authenticated || isAuthenticatedMarkdownRoute(routeName)) { + return "demo-logged-in"; + } + return "logged-out"; +} + function getRouteUrl(routeName) { if (!pageLinkBaseUrl) { return null; @@ -1999,7 +2170,16 @@ function getRouteUrl(routeName) { return null; } - return new URL(routePath, pageLinkBaseUrl).toString(); + const url = new URL(routePath, pageLinkBaseUrl); + const authState = getRouteAuthState(routeName); + if (authState === "demo-logged-in") { + url.searchParams.set("login", "demo"); + url.searchParams.delete("logout"); + } else { + url.searchParams.set("logout", "1"); + url.searchParams.delete("login"); + } + return url.toString(); } function parseOptionalUrl(value) { diff --git a/packages/web/scripts/copy-review-gate.ts b/packages/web/scripts/copy-review-gate.ts new file mode 100644 index 000000000..4d10e4c9f --- /dev/null +++ b/packages/web/scripts/copy-review-gate.ts @@ -0,0 +1,40 @@ +import { execFileSync } from "node:child_process"; +import { + buildCopyReviewGateResult, + formatCopyReviewGateMessage, +} from "../src/lib/copy-review-gate"; + +function getStagedPaths() { + const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + }).trim(); + const output = execFileSync( + "git", + ["diff", "--cached", "--name-only", "--diff-filter=ACMR"], + { cwd: repoRoot, encoding: "utf8" }, + ); + + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +const result = buildCopyReviewGateResult({ + changedPaths: getStagedPaths(), + env: process.env, +}); + +if (result.publicCopyPaths.length === 0) { + process.exit(0); +} + +if (!result.shouldBlock) { + console.log( + "COPY_REVIEW_APPROVED=1 set; allowing public copy commit after explicit human approval.", + ); + process.exit(0); +} + +console.error(formatCopyReviewGateMessage(result.publicCopyPaths)); +process.exit(1); diff --git a/packages/web/scripts/smoke-deploy.mjs b/packages/web/scripts/smoke-deploy.mjs index 6b5799f41..5ba4b20da 100644 --- a/packages/web/scripts/smoke-deploy.mjs +++ b/packages/web/scripts/smoke-deploy.mjs @@ -15,12 +15,12 @@ const REQUEST_TIMEOUT_MS = Number( const ROUTES_TO_SMOKE = [ { path: "/", - expectedH1: "Please Take 30 Seconds to End War and Disease", + expectedH1: "PLEASE TAKE 30 SECONDS TO END WAR AND DISEASE", expectedH1ByHost: { "optimitron.com": "Play the Earth Optimization Game!", "www.optimitron.com": "Play the Earth Optimization Game!", }, - source: "site root heading", + source: "warondisease landing action heading", }, { path: "/treaty", diff --git a/packages/web/src/app/agencies/dcongress/referendums/[slug]/page.tsx b/packages/web/src/app/agencies/dcongress/referendums/[slug]/page.tsx index 1d3ebccd7..a972c19bc 100644 --- a/packages/web/src/app/agencies/dcongress/referendums/[slug]/page.tsx +++ b/packages/web/src/app/agencies/dcongress/referendums/[slug]/page.tsx @@ -6,6 +6,7 @@ import { prisma } from "@/lib/prisma"; import { getCurrentUser } from "@/lib/auth-utils"; import { getReferendumStats } from "@/lib/verified-votes.server"; import { ReferendumVoteSection } from "@/components/referendum/ReferendumVoteSection"; +import { readerMarkdownComponents } from "@/components/referendum/reader-markdown-components"; interface Props { params: Promise<{ slug: string }>; @@ -79,10 +80,17 @@ export default async function ReferendumPage({ params, searchParams }: Props) { {referendum.bodyMarkdown && ( -
    - - {referendum.bodyMarkdown} - +
    +
    +
    + + {referendum.bodyMarkdown} + +
    +
    )} diff --git a/packages/web/src/app/api/people/search/route.test.ts b/packages/web/src/app/api/people/search/route.test.ts new file mode 100644 index 000000000..5274771a1 --- /dev/null +++ b/packages/web/src/app/api/people/search/route.test.ts @@ -0,0 +1,110 @@ +import { PersonLifeStatus } from "@optimitron/db"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + personFindMany: vi.fn(), + requireAuth: vi.fn(), +})); + +vi.mock("@/lib/auth-utils", () => ({ + requireAuth: mocks.requireAuth, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + person: { + findMany: mocks.personFindMany, + }, + }, +})); + +import { GET } from "./route"; + +describe("people search route", () => { + beforeEach(() => { + mocks.personFindMany.mockReset(); + mocks.requireAuth.mockReset(); + }); + + it("returns 401 without auth", async () => { + mocks.requireAuth.mockRejectedValue(new Error("Unauthorized")); + + const response = await GET( + new Request("http://localhost/api/people/search?q=ada"), + ); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ error: "Unauthorized" }); + expect(mocks.personFindMany).not.toHaveBeenCalled(); + }); + + it("returns an empty result set for short queries", async () => { + mocks.requireAuth.mockResolvedValue({ userId: "user_1" }); + + const response = await GET( + new Request("http://localhost/api/people/search?q=a"), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + data: [], + success: true, + }); + expect(mocks.personFindMany).not.toHaveBeenCalled(); + }); + + it("searches live people and maps person links", async () => { + mocks.requireAuth.mockResolvedValue({ userId: "user_1" }); + mocks.personFindMany.mockResolvedValue([ + { + currentAffiliation: "Analytical Engine", + displayName: "Ada Lovelace", + handle: "ada", + headline: "Treaty math reviewer", + id: "person_ada", + image: null, + isPublicFigure: false, + }, + ]); + + const response = await GET( + new Request("http://localhost/api/people/search?q=ada%40example.org"), + ); + + expect(response.status).toBe(200); + expect(mocks.personFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: [{ isPublicFigure: "desc" }, { displayName: "asc" }], + take: 8, + where: expect.objectContaining({ + deletedAt: null, + lifeStatus: { not: PersonLifeStatus.DECEASED }, + OR: expect.arrayContaining([ + { email: { equals: "ada@example.org" } }, + { + handle: { + contains: "ada@example.org", + mode: "insensitive", + }, + }, + ]), + }), + }), + ); + await expect(response.json()).resolves.toEqual({ + data: [ + { + affiliation: "Analytical Engine", + displayName: "Ada Lovelace", + handle: "ada", + headline: "Treaty math reviewer", + href: "/people/ada", + id: "person_ada", + image: null, + isPublicFigure: false, + }, + ], + success: true, + }); + }); +}); diff --git a/packages/web/src/app/api/people/search/route.ts b/packages/web/src/app/api/people/search/route.ts new file mode 100644 index 000000000..1d345658a --- /dev/null +++ b/packages/web/src/app/api/people/search/route.ts @@ -0,0 +1,75 @@ +import { PersonLifeStatus } from "@optimitron/db"; +import { NextResponse } from "next/server"; +import { requireAuth } from "@/lib/auth-utils"; +import { getPersonHref } from "@/lib/person-href"; +import { prisma } from "@/lib/prisma"; + +export const runtime = "nodejs"; + +function normalizeQuery(value: string | null) { + return value?.trim() ?? ""; +} + +export async function GET(request: Request) { + try { + await requireAuth(); + const { searchParams } = new URL(request.url); + const query = normalizeQuery(searchParams.get("q")); + + if (query.length < 2) { + return NextResponse.json({ data: [], success: true }); + } + + const emailSearch = query.includes("@") ? query.toLowerCase() : null; + const people = await prisma.person.findMany({ + orderBy: [{ isPublicFigure: "desc" }, { displayName: "asc" }], + select: { + currentAffiliation: true, + displayName: true, + handle: true, + headline: true, + id: true, + image: true, + isPublicFigure: true, + }, + take: 8, + where: { + deletedAt: null, + lifeStatus: { not: PersonLifeStatus.DECEASED }, + OR: [ + { displayName: { contains: query, mode: "insensitive" } }, + { firstName: { contains: query, mode: "insensitive" } }, + { lastName: { contains: query, mode: "insensitive" } }, + { handle: { contains: query.replace(/^@/u, ""), mode: "insensitive" } }, + { headline: { contains: query, mode: "insensitive" } }, + { currentAffiliation: { contains: query, mode: "insensitive" } }, + ...(emailSearch ? [{ email: { equals: emailSearch } }] : []), + ], + }, + }); + + return NextResponse.json({ + data: people.map((person) => ({ + affiliation: person.currentAffiliation, + displayName: person.displayName, + handle: person.handle, + headline: person.headline, + href: getPersonHref(person), + id: person.id, + image: person.image, + isPublicFigure: person.isPublicFigure, + })), + success: true, + }); + } catch (error) { + if (error instanceof Error && error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + console.error("[PEOPLE_SEARCH] Failed to search people:", error); + return NextResponse.json( + { error: "Failed to search people." }, + { status: 500 }, + ); + } +} diff --git a/packages/web/src/app/api/tasks/route.test.ts b/packages/web/src/app/api/tasks/route.test.ts index e3469ff5b..ed0a48ad1 100644 --- a/packages/web/src/app/api/tasks/route.test.ts +++ b/packages/web/src/app/api/tasks/route.test.ts @@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({ getServerSession: vi.fn(), listTasks: vi.fn(), personFindFirst: vi.fn(), + personUpsert: vi.fn(), requireAuth: vi.fn(), taskFindFirst: vi.fn(), })); @@ -23,7 +24,10 @@ vi.mock("@/lib/auth-utils", () => ({ vi.mock("@/lib/prisma", () => ({ prisma: { - person: { findFirst: mocks.personFindFirst }, + person: { + findFirst: mocks.personFindFirst, + upsert: mocks.personUpsert, + }, task: { findFirst: mocks.taskFindFirst }, }, })); @@ -41,6 +45,7 @@ describe("tasks route", () => { mocks.getServerSession.mockReset(); mocks.listTasks.mockReset(); mocks.personFindFirst.mockReset(); + mocks.personUpsert.mockReset(); mocks.requireAuth.mockReset(); mocks.taskFindFirst.mockReset(); }); @@ -203,4 +208,112 @@ describe("tasks route", () => { "assigneePersonIdentifier", ); }); + + it("creates an invited assignee person by email before creating the task", async () => { + mocks.requireAuth.mockResolvedValue({ userId: "user_creator" }); + mocks.personUpsert.mockResolvedValue({ id: "person_invited" }); + mocks.createTask.mockResolvedValue({ + assigneePersonId: "person_invited", + createdByUserId: "user_creator", + id: "task_invite", + isPublic: true, + title: "Review the treaty math", + }); + + const response = await POST( + new Request("http://localhost/api/tasks", { + body: JSON.stringify({ + assigneePersonInvite: { + currentAffiliation: "Analytical Engine", + email: "ADA@EXAMPLE.ORG", + firstName: "Ada", + lastName: "Lovelace", + }, + description: "Check the clinical-trial capacity claim.", + isPublic: true, + title: "Review the treaty math", + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }), + ); + + expect(response.status).toBe(201); + expect(mocks.personUpsert).toHaveBeenCalledWith({ + create: expect.objectContaining({ + createdByUserId: "user_creator", + currentAffiliation: "Analytical Engine", + displayName: "Ada Lovelace", + email: "ada@example.org", + firstName: "Ada", + isPublic: false, + isPublicFigure: false, + lastName: "Lovelace", + }), + select: { id: true }, + update: { deletedAt: null }, + where: { email: "ada@example.org" }, + }); + expect(mocks.createTask).toHaveBeenCalledWith( + "user_creator", + expect.objectContaining({ + assigneePersonId: "person_invited", + description: "Check the clinical-trial capacity claim.", + isPublic: true, + title: "Review the treaty math", + }), + ); + expect(mocks.createTask.mock.calls[0]?.[1]).not.toHaveProperty( + "assigneePersonInvite", + ); + }); + + it("rejects ambiguous assignee targets", async () => { + mocks.requireAuth.mockResolvedValue({ userId: "user_creator" }); + + const response = await POST( + new Request("http://localhost/api/tasks", { + body: JSON.stringify({ + assigneePersonId: "person_existing", + assigneePersonInvite: { + email: "ada@example.org", + firstName: "Ada", + lastName: "Lovelace", + }, + title: "Review the treaty math", + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: "Choose one assignee.", + }); + expect(mocks.personUpsert).not.toHaveBeenCalled(); + expect(mocks.createTask).not.toHaveBeenCalled(); + }); + + it("rejects organization and person assignees together", async () => { + mocks.requireAuth.mockResolvedValue({ userId: "user_creator" }); + + const response = await POST( + new Request("http://localhost/api/tasks", { + body: JSON.stringify({ + assigneeOrganizationId: "org_iam", + assigneePersonId: "person_existing", + title: "Review the treaty math", + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: "Choose one assignee.", + }); + expect(mocks.createTask).not.toHaveBeenCalled(); + }); }); diff --git a/packages/web/src/app/api/tasks/route.ts b/packages/web/src/app/api/tasks/route.ts index 60193539c..e3dd629b1 100644 --- a/packages/web/src/app/api/tasks/route.ts +++ b/packages/web/src/app/api/tasks/route.ts @@ -25,6 +25,14 @@ const CreateTaskBodySchema = z.object({ assigneeOrganizationId: z.string().nullish(), assigneePersonIdentifier: z.string().nullish(), assigneePersonId: z.string().nullish(), + assigneePersonInvite: z + .object({ + currentAffiliation: z.string().trim().nullish(), + email: z.string().trim().email(), + firstName: z.string().trim().min(1), + lastName: z.string().trim().min(1), + }) + .nullish(), category: z.nativeEnum(TaskCategory).nullish(), claimPolicy: z.nativeEnum(TaskClaimPolicy).nullish(), contactLabel: z.string().nullish(), @@ -72,6 +80,51 @@ function normalizeAssigneePersonIdentifier(value?: string | null) { return candidate.replace(/^@/u, "").trim() || null; } +function normalizeEmail(value: string) { + return value.trim().toLowerCase(); +} + +function buildDisplayName(input: { firstName: string; lastName: string }) { + return [input.firstName.trim(), input.lastName.trim()] + .filter(Boolean) + .join(" "); +} + +async function findOrCreateInvitedAssigneePerson({ + creatorUserId, + currentAffiliation, + email, + firstName, + lastName, +}: { + creatorUserId: string; + currentAffiliation?: string | null; + email: string; + firstName: string; + lastName: string; +}) { + const normalizedEmail = normalizeEmail(email); + const displayName = buildDisplayName({ firstName, lastName }); + + return prisma.person.upsert({ + create: { + createdByUserId: creatorUserId, + currentAffiliation: currentAffiliation?.trim() || null, + displayName, + email: normalizedEmail, + firstName: firstName.trim(), + isPublic: false, + isPublicFigure: false, + lastName: lastName.trim(), + }, + select: { id: true }, + update: { + deletedAt: null, + }, + where: { email: normalizedEmail }, + }); +} + export async function GET(request: Request) { try { const session = await getServerSession(authOptions); @@ -119,8 +172,27 @@ export async function POST(request: Request) { try { const { userId } = await requireAuth(); const parsed = CreateTaskBodySchema.parse(await request.json()); - const { assigneePersonIdentifier, dueAt, parentTaskId, ...rest } = parsed; + const { + assigneePersonIdentifier, + assigneePersonInvite, + dueAt, + parentTaskId, + ...rest + } = parsed; let assigneePersonId = rest.assigneePersonId ?? null; + const assigneeTargetCount = [ + rest.assigneeOrganizationId, + assigneePersonId, + assigneePersonIdentifier, + assigneePersonInvite, + ].filter(Boolean).length; + + if (assigneeTargetCount > 1) { + return NextResponse.json( + { error: "Choose one assignee." }, + { status: 400 }, + ); + } if (!assigneePersonId && assigneePersonIdentifier) { const identifier = normalizeAssigneePersonIdentifier( @@ -153,6 +225,17 @@ export async function POST(request: Request) { assigneePersonId = person.id; } + if (!assigneePersonId && assigneePersonInvite) { + const person = await findOrCreateInvitedAssigneePerson({ + creatorUserId: userId, + currentAffiliation: assigneePersonInvite.currentAffiliation, + email: assigneePersonInvite.email, + firstName: assigneePersonInvite.firstName, + lastName: assigneePersonInvite.lastName, + }); + assigneePersonId = person.id; + } + if (parentTaskId) { const parent = await prisma.task.findFirst({ where: { id: parentTaskId, deletedAt: null }, diff --git a/packages/web/src/app/court/page.logged-out.md b/packages/web/src/app/court/page.logged-out.md index ee555f16f..1dbb0b267 100644 --- a/packages/web/src/app/court/page.logged-out.md +++ b/packages/web/src/app/court/page.logged-out.md @@ -41,6 +41,6 @@ - Article V: The [1% Treaty](https://manual.WarOnDisease.org/knowledge/solution/1-percent-treaty.html) is hereby established as the standing settlement offer. Governments may accept the Treaty's terms — redirecting 1% of annual military spending to pragmatic clinical trials, with bondholder and political-incentive structures attached — in exchange for prospective liability caps on Court of Humanity claims arising from acts predating Treaty ratification. Signing gives them a settlement price. Refusing keeps the claims, judgments, and bondholder lawyers pointed at them. - Article VI: Membership in the Court of Humanity is irrevocable for the lifetime of the member, but no member may bind their heirs or descendants. Each subsequent generation joins by their own consent. The Court has no head of state, no annual budget, no dues, and no way to be voted out by anyone other than its own members. In this respect it resembles every other institution your species has ever built that actually works. - IN WITNESS WHEREOF, the undersigned humans, being of sound mind (debatable) and tired of watching their governments kill their families with no consequences, hereby join the Court of Humanity. -- Joined this day, May 17, 2026, in the year of our ongoing confusion. +- Joined this day, [signature date], in the year of our ongoing confusion. - SIGN - Display my name publicly on the signer list and leaderboards (recommended). diff --git a/packages/web/src/app/dashboard/page.tsx b/packages/web/src/app/dashboard/page.tsx index d346cbce7..f033ac889 100644 --- a/packages/web/src/app/dashboard/page.tsx +++ b/packages/web/src/app/dashboard/page.tsx @@ -9,9 +9,10 @@ import { getTasksPageData } from "@/lib/tasks.server"; import { getOptionalReferendumSiteContent } from "@/content/referendum-sites"; import { EarthOptimizationDashboardClient } from "@/components/dashboard/EarthOptimizationDashboardClient"; import { TreatyTaskDashboardClient } from "@/components/site/TreatyTaskDashboardClient"; +import { loadHumanityManagerStatus } from "@/lib/humanity-manager-status.server"; import { dashboardLink, getSignInPath, ROUTES } from "@/lib/routes"; import { getRouteMetadata, getSiteMetadata } from "@/lib/metadata"; -import { getSiteFromHeaders } from "@/lib/site"; +import { getRequestSiteOrigin, getSiteFromHeaders } from "@/lib/site"; import { ensurePersonForUser } from "@/lib/person.server"; import { ensureUserTreatyTask } from "@/lib/tasks/user-treaty-task.server"; import { getProfileIdentityData } from "@/lib/profile-identity.server"; @@ -76,8 +77,24 @@ export default async function DashboardPage({ redirect(getSignInPath(ROUTES.dashboard)); } + const humanityManagerStatus = await loadHumanityManagerStatus({ + baseUrl: getRequestSiteOrigin({ + forwardedHost: hdrs.get("x-forwarded-host"), + forwardedProto: hdrs.get("x-forwarded-proto"), + host: hdrs.get("host"), + }), + user: { + downstreamConversionCount: + profileData.user.downstreamConversionCount ?? 0, + handle: profileData.user.handle, + referralCode: profileData.user.referralCode, + }, + userId, + }); + return ( ); diff --git a/packages/web/src/app/declaration/page.logged-out.md b/packages/web/src/app/declaration/page.logged-out.md index a9e93fc2d..126828715 100644 --- a/packages/web/src/app/declaration/page.logged-out.md +++ b/packages/web/src/app/declaration/page.logged-out.md @@ -66,6 +66,5 @@ - And that as Free Inhabitants of Earth, they have full Power to optimize budgets and institutions, establish transparent allocation systems, contract Alliances with evidence, and to do all other Acts and Things which Self-Governing Civilizations may of right do. And for the support of this Declaration, with a firm reliance on the protection of divine Providence, we mutually pledge to each other our Lives, our Fortunes, and our sacred Votes. - The proposed replacement system is documented in the [Earth Optimization Protocol](https://manual.WarOnDisease.org/knowledge/strategy/earth-optimization-protocol-v1.html). - Sign the Declaration of Optimization +- SIGN - Display my name publicly on the signer list and leaderboards (recommended). -- EMAIL -- EMAIL ME A SIGN-IN LINK diff --git a/packages/web/src/app/love/love-client.tsx b/packages/web/src/app/love/love-client.tsx new file mode 100644 index 000000000..b7156450b --- /dev/null +++ b/packages/web/src/app/love/love-client.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useState } from "react"; +import { Check, Copy } from "lucide-react"; +import { copyTextToClipboard } from "@/lib/clipboard"; + +interface LoveShareButtonProps { + className?: string; +} + +export function LoveShareButton({ className }: LoveShareButtonProps) { + const [label, setLabel] = useState("Share this page"); + + function handleClick() { + void copyTextToClipboard(window.location.href) + .then(() => { + setLabel("Copied!"); + window.setTimeout(() => setLabel("Share this page"), 2000); + }) + .catch(() => { + window.setTimeout(() => setLabel("Share this page"), 2000); + }); + } + + return ( + + ); +} + +export function LoveCopyButton({ + className, + value, +}: { + className?: string; + value: string; +}) { + const [copyState, setCopyState] = useState<"copied" | "error" | "idle">( + "idle", + ); + + function handleCopy() { + void copyTextToClipboard(value) + .then(() => { + setCopyState("copied"); + window.setTimeout(() => setCopyState("idle"), 1500); + }) + .catch(() => { + setCopyState("error"); + window.setTimeout(() => setCopyState("idle"), 2000); + }); + } + + return ( + + ); +} diff --git a/packages/web/src/app/love/page.logged-out.md b/packages/web/src/app/love/page.logged-out.md new file mode 100644 index 000000000..78340c04e --- /dev/null +++ b/packages/web/src/app/love/page.logged-out.md @@ -0,0 +1,97 @@ +# /love + +## Metadata + +- Page title: End War and Disease From Your Dating Profile | International Campaign to End War and Disease +- Meta description: You're already on the apps. You're already writing a bio. You might as well save a few billion lives while you're at it. +- Canonical: https://warondisease.org/love +- Open Graph title: End War and Disease From Your Dating Profile +- Open Graph description: You're already on the apps. You're already writing a bio. You might as well save a few billion lives while you're at it. +- Open Graph image: https://warondisease.org/api/og/route?path=%2Flove +- Twitter title: End War and Disease From Your Dating Profile +- Twitter description: You're already on the apps. You're already writing a bio. You might as well save a few billion lives while you're at it. + +## Visible Page Copy + +- A PROPOSAL +## End war and disease from your dating profile. +- You're already on the apps. You're already writing a bio. You might as well save a few billion lives while you're at it. +### The situation +- It requires only [100](https://manual.WarOnDisease.org/knowledge/appendix/extinction-surplus.html) nuclear explosions to cause a nuclear winter, which would collapse the food chain and end civilization. Humanity currently has [12,000](https://manual.WarOnDisease.org/knowledge/appendix/extinction-surplus.html) nuclear bombs, which is sufficient for [122](https://manual.WarOnDisease.org/knowledge/appendix/extinction-surplus.html) apocalypses. +- Given that you can only destroy civilization once, it seems like humanity would be willing to settle for [121](https://manual.WarOnDisease.org/knowledge/appendix/extinction-surplus.html) apocalypses in exchange for potentially preventing themselves and everyone they love from suffering and dying of horrible diseases. +- If humanity redirected [1%](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) of military spending to clinical trials, disease eradication becomes plausible in [36](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) years instead of [443](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) years at the current rate. +- We built a website where everyone can vote on this. We need a majority of humanity, over [4 billion people](https://manual.WarOnDisease.org/knowledge/strategy/global-referendum.html), to vote. That's a distribution problem. +- Dating apps have hundreds of millions of active users. Each profile is seen by hundreds or thousands of people. If even 10,000 supporters put warondisease.org in their bios, that's tens of millions of impressions from a channel that costs nothing. +### Why this works on dating apps specifically +- Having a mission is attractive. This is well documented in social psychology. A person who is focused on something bigger than themselves is more compelling than a person whose bio says "looking for my partner in crime." +- You're not choosing between a good dating profile and helping end war and disease. A profile with a real mission IS a better dating profile. The person benefits individually from participating. +- 300M+ +- active users on dating apps globally +- $0 +- cost per impression from a dating bio +- [30s](https://manual.WarOnDisease.org/knowledge/appendix/recruitment-and-propaganda-plan.html) +- to vote at warondisease.org +- Every person who sees your profile and doesn't match with you can still vote. Every left swipe is a potential vote. Your rejections are saving lives. +### The math +- Average dating profile: seen by ~500 people/month +- 10,000 supporters with bios = 5,000,000 impressions/month +- Conservative 1% click-through to warondisease.org = 50,000 votes/month +- At that rate, 4 billion votes in ~6,600 years +- But each voter tells friends. At 2x viral coefficient: +- 50,000 → 100,000 → 200,000 → snowball +- The point isn't that dating apps alone get to 4 billion. +- The point is they're one free channel in a portfolio of channels, +- and you were going to be on the app anyway. +### How to do it +#### Vote yourself +- Go to warondisease.org and take [30 seconds](https://manual.WarOnDisease.org/knowledge/appendix/recruitment-and-propaganda-plan.html) to vote. You can't ask others to do something you haven't done. +#### Add it to your dating profile +- Use one of the templates below, or write your own. The key is: make it sound like you, mention warondisease.org, and keep it natural. +#### When someone messages you, ask them to vote +- A person already talking to you is 20x more likely to vote than a stranger reading a bio. The warm ask converts better than the cold one. Wait until they're engaged, then ask. +#### Send them this page +- If they're into it, send them here so they can do the same thing. Now you're both doing it. Now it's spreading. +#### Go on a date. Put up flyers. +- [Print flyers](/poster) with your personal referral QR code from warondisease.org. When you go on a date, put them up around town together. Even if the date is terrible, you didn't waste your time. You were ending war and disease. +- Millions of bad dates happen every night globally. Hours of human time evaporating into awkward silences and forced conversations about hiking. This campaign converts that wasted time into the highest value activity mathematically possible. Even your worst date ends war and disease. +#### YOUR CAMPAIGN URL +- This is the public campaign URL. Sign in to print a personal URL and QR code. +- warondisease.org +- COPY URL +- [PRINT FLYERS](/poster) +- [SIGN IN](/auth/signin) +### The opening message +- When you match with someone, or even when you're sending a first message, open with: +- "I have a great idea for how you can improve your profile. Do you want to hear it?" +- Everyone wants a better dating profile. Curiosity alone will get a response. When they say yes, send them this page. The "improvement" is adding warondisease.org to their bio. +- Now they're voting. Now they're adding it to their profile. Now they're sending the same message to their matches. Each person who participates creates more people who participate. +- You started a conversation. You might also start a relationship. Either way, you started a chain reaction. +### Bio templates +- Use these as-is or adapt to your voice. The only requirement is that warondisease.org appears and the reader has a reason to click it. +- COPY THIS +- It requires only 100 nuclear explosions to end civilization. Humanity has enough for 122. Seems like we could spare 1% of the budget for curing diseases instead. I made a website where you can vote on it. warondisease.org. It takes 30 seconds. [then write whatever you normally write about yourself] +- Before you evaluate whether I'm worth your time, please take 30 seconds to vote at warondisease.org. It will statistically prevent more suffering than anything you will learn about me in the next four paragraphs. [then write your normal bio] +- [your normal bio here] Also I'm trying to get 4 billion people to vote to redirect 1% of military spending to curing diseases. warondisease.org. It takes 30 seconds. Your left swipe can still save lives. +- My most controversial opinion is that humanity should settle for 121 apocalypses instead of 122 and use the savings to cure diseases 12 times faster. Vote on it at warondisease.org. Now, about me: [your normal bio] +### The deeper idea +- You're on a dating app because you want to love someone and be loved. That's the whole reason anyone is here. +- What if you extended that impulse slightly? Not just to the person you match with but to the 8 billion people who also don't want to suffer and die of horrible diseases? +- You don't have to be in love with everyone on earth. You just have to care enough about strangers to spend 30 seconds voting so they don't die of something curable. That's a very low bar for love. You're already meeting it by being a person who reads dating profiles instead of just looking at pictures. +- Wars happen because people don't care enough about the people on the other side. Diseases go uncured because people don't care enough about the people who have them. The entire problem is a deficit of giving a shit. +- You give a shit. You're here. Put it in your bio. +### Questions you might have +#### Won't this make my profile weird? +- Your profile is currently competing with 10,000 people who like hiking and The Office. Yes, it will make your profile weird. That's the point. Weird is memorable. Memorable gets responses. +#### Will it actually get me better matches? +- Having a real mission is consistently rated as one of the most attractive traits in social psychology research. A person who cares about something beyond themselves is more attractive than a person who doesn't. This isn't a sacrifice. It's an upgrade. +#### Won't people think I'm promoting something? +- You're asking them to vote on a free website for 30 seconds. Not buy a coin, join a group, or sign up for anything. The bar for "promotion" is usually money. This is free and takes less time than reading your bio. +#### What if nobody votes from my profile? +- Then you still have a more interesting dating profile than you did before. There's no downside. +#### Is this a real campaign? +- Yes. The Institute for Accelerated Medicine is a 501(c)(3) nonprofit running the International Campaign to End War and Disease. The global referendum at warondisease.org is the coordination mechanism. This is one distribution channel among many. You're not being recruited into anything. You're being asked to put a link in your bio. +### Your dating profile is seen by hundreds of people. Most of them will swipe left. Make the left swipes count. +- Vote first. Then put it in your bio. Then send this page to someone. +- [VOTE NOW](/vote) +- SHARE THIS PAGE +- A project of the [Institute for Accelerated Medicine](https://warondisease.org). Love, Mike. diff --git a/packages/web/src/app/love/page.tsx b/packages/web/src/app/love/page.tsx new file mode 100644 index 000000000..b2d423c57 --- /dev/null +++ b/packages/web/src/app/love/page.tsx @@ -0,0 +1,613 @@ +import Link from "next/link"; +import { getServerSession } from "next-auth"; +import { + DFDA_QUEUE_CLEARANCE_YEARS, + SHARING_TIME_MINUTES, + STATUS_QUO_QUEUE_CLEARANCE_YEARS, + TREATY_REDUCTION_PCT, +} from "@optimitron/data/parameters"; +import { ParameterValue } from "@/components/shared/ParameterValue"; +import type { ReactNode } from "react"; +import { PosterQrCode } from "@/app/poster/poster-client"; +import { authOptions } from "@/lib/auth"; +import { WAR_ON_DISEASE_CANONICAL_ORIGIN } from "@/lib/domains"; +import { getRouteMetadata } from "@/lib/metadata"; +import { loveLink, ROUTES } from "@/lib/routes"; +import { + FLOW_GLOBAL_WARHEAD_COUNT, + FLOW_MAJORITY_OF_HUMANS_ON_EARTH, + FLOW_NUCLEAR_WINTER_OVERKILL_FACTOR, + FLOW_NUCLEAR_WINTER_WARHEAD_THRESHOLD, + FLOW_WASTEFUL_APOCALYPSES, +} from "@/lib/treaty-share-flow-parameters"; +import { buildUserReferralUrl } from "@/lib/url"; +import { LoveCopyButton, LoveShareButton } from "./love-client"; + +export const metadata = getRouteMetadata(loveLink); + +const CAMPAIGN_ORIGIN = WAR_ON_DISEASE_CANONICAL_ORIGIN; +const parameterClassName = + "text-inherit decoration-[#e8e4df]/40 underline-offset-2"; +const accentParameterClassName = + "text-inherit decoration-[#ff4d4d]/40 underline-offset-2"; + +const stats = [ + { + number: "300M+", + label: "active users on dating apps globally", + }, + { + number: "$0", + label: "cost per impression from a dating bio", + }, + { + number: ( + + ), + label: "to vote at warondisease.org", + }, +] as const; + +function getVisibleTargetUrl(targetUrl: string) { + return targetUrl.replace(/^https?:\/\//, ""); +} + +const steps = [ + { + title: "Vote yourself", + body: ( +

    + Go to warondisease.org and take{" "} + {" "} + to vote. You can't ask others to do something you haven't done. +

    + ), + }, + { + title: "Add it to your dating profile", + body: ( +

    + Use one of the templates below, or write your own. The key is: make it + sound like you, mention warondisease.org, and keep it natural. +

    + ), + }, + { + title: "When someone messages you, ask them to vote", + body: ( +

    + A person already talking to you is 20x more likely to vote than a + stranger reading a bio. The warm ask converts better than the cold one. + Wait until they're engaged, then ask. +

    + ), + }, + { + title: "Send them this page", + body: ( +

    + If they're into it, send them here so they can do the same thing. + Now you're both doing it. Now it's spreading. +

    + ), + }, + { + title: "Go on a date. Put up flyers.", + body: ( + <> +

    + + Print flyers + {" "} + with your personal referral QR code from warondisease.org. When you go + on a date, put them up around town together. Even if the date is + terrible, you didn't waste your time. You were ending war and + disease. +

    +

    + Millions of bad dates happen every night globally. Hours of human time + evaporating into awkward silences and forced conversations about + hiking. This campaign converts that wasted time into the highest value + activity mathematically possible. Even your worst date ends war and + disease. +

    + + ), + }, +] as const; + +const faqItems = [ + { + question: "Won't this make my profile weird?", + answer: + 'Your profile is currently competing with 10,000 people who like hiking and The Office. Yes, it will make your profile weird. That\'s the point. Weird is memorable. Memorable gets responses.', + }, + { + question: "Will it actually get me better matches?", + answer: + "Having a real mission is consistently rated as one of the most attractive traits in social psychology research. A person who cares about something beyond themselves is more attractive than a person who doesn't. This isn't a sacrifice. It's an upgrade.", + }, + { + question: "Won't people think I'm promoting something?", + answer: + 'You\'re asking them to vote on a free website for 30 seconds. Not buy a coin, join a group, or sign up for anything. The bar for "promotion" is usually money. This is free and takes less time than reading your bio.', + }, + { + question: "What if nobody votes from my profile?", + answer: + "Then you still have a more interesting dating profile than you did before. There's no downside.", + }, + { + question: "Is this a real campaign?", + answer: + "Yes. The Institute for Accelerated Medicine is a 501(c)(3) nonprofit running the International Campaign to End War and Disease. The global referendum at warondisease.org is the coordination mechanism. This is one distribution channel among many. You're not being recruited into anything. You're being asked to put a link in your bio.", + }, +] as const; + +function LoveSection({ + children, + className = "", +}: { + children: ReactNode; + className?: string; +}) { + return ( +
    + {children} +
    + ); +} + +function SectionHeading({ children }: { children: ReactNode }) { + return ( +

    + {children} +

    + ); +} + +function BodyParagraph({ + children, + dim = false, +}: { + children: ReactNode; + dim?: boolean; +}) { + return ( +

    + {children} +

    + ); +} + +function BioTemplate({ children }: { children: ReactNode }) { + return ( +
    + + COPY THIS + +

    + {children} +

    +
    + ); +} + +function Placeholder({ children }: { children: ReactNode }) { + return {children}; +} + +const buttonClassName = + "inline-flex cursor-pointer items-center justify-center border border-transparent bg-[#ff4d4d] px-12 py-4 font-mono text-[0.85rem] font-bold uppercase tracking-[0.1em] text-[#0a0a0a] no-underline transition-colors hover:bg-[#e8e4df]"; + +const secondaryButtonClassName = + "inline-flex cursor-pointer items-center justify-center border border-[#8a8580] bg-transparent px-12 py-4 font-mono text-[0.85rem] font-bold uppercase tracking-[0.1em] text-[#e8e4df] no-underline transition-colors hover:border-[#e8e4df] hover:bg-[#e8e4df] hover:text-[#0a0a0a]"; + +const utilityButtonClassName = + "inline-flex cursor-pointer items-center justify-center gap-2 border border-[#8a8580] bg-transparent px-5 py-3 font-mono text-[0.78rem] font-bold uppercase tracking-[0.1em] text-[#e8e4df] no-underline transition-colors hover:border-[#e8e4df] hover:bg-[#e8e4df] hover:text-[#0a0a0a]"; + +function CampaignUrlCard({ + hasPersonalReferralUrl, + referralUrl, +}: { + hasPersonalReferralUrl: boolean; + referralUrl: string; +}) { + const visibleReferralUrl = getVisibleTargetUrl(referralUrl); + + return ( +
    +
    +

    + Your campaign URL +

    +

    + {hasPersonalReferralUrl + ? "Use this in your bio, messages, and flyers." + : "This is the public campaign URL. Sign in to print a personal URL and QR code."} +

    +

    + {visibleReferralUrl} +

    +
    + + + Print flyers + + {!hasPersonalReferralUrl && ( + + Sign in + + )} +
    +
    +
    + +
    +
    + ); +} + +export default async function LovePage() { + const session = await getServerSession(authOptions); + const hasPersonalReferralUrl = Boolean( + session?.user?.handle?.trim() || session?.user?.referralCode?.trim(), + ); + const referralUrl = buildUserReferralUrl(session?.user, CAMPAIGN_ORIGIN); + + return ( +
    +
    +
    +
    +

    + A proposal +

    +

    + End war and disease{" "} + from your dating profile. +

    +

    + You're already on the apps. You're already writing a bio. + You might as well save a few billion lives while you're at it. +

    +
    +
    + + + The situation + + It requires only{" "} + {" "} + nuclear explosions to cause a nuclear winter, which would collapse + the food chain and end civilization. Humanity currently has{" "} + {" "} + nuclear bombs, which is sufficient for{" "} + {" "} + apocalypses. + + + Given that you can only destroy civilization once, it seems like + humanity would be willing to settle for{" "} + {" "} + apocalypses in exchange for potentially preventing themselves and + everyone they love from suffering and dying of horrible diseases. + + + If humanity redirected{" "} + {" "} + of military spending to clinical trials, disease eradication becomes + plausible in{" "} + {" "} + years instead of{" "} + {" "} + years at the current rate. + + + We built a website where everyone can vote on this. We need a + majority of humanity, over{" "} + + , to vote. That's a distribution problem. + + +
    + + Dating apps have hundreds of millions of active users. Each + profile is seen by hundreds or thousands of people. If even 10,000 + supporters put warondisease.org in their bios, that's tens of + millions of impressions from a channel that costs nothing. + +
    +
    + + + + Why this works on dating apps specifically + + + Having a mission is attractive. This is well documented in social + psychology. A person who is focused on something bigger than + themselves is more compelling than a person whose bio says + "looking for my partner in crime." + + + You're not choosing between a good dating profile and helping + end war and disease. A profile with a real mission IS a better + dating profile. The person benefits individually from participating. + + +
    + {stats.map((stat) => ( +
    + + {stat.number} + + + {stat.label} + +
    + ))} +
    + + + Every person who sees your profile and doesn't match with you + can still vote. Every left swipe is a potential vote. Your + rejections are saving lives. + +
    + + + The math +
    +
    +

    Average dating profile: seen by ~500 people/month

    +

    10,000 supporters with bios = 5,000,000 impressions/month

    +

    + Conservative 1% click-through to warondisease.org = 50,000 + votes/month +

    +

    At that rate, 4 billion votes in ~6,600 years

    +

    + But each voter tells friends. At 2x viral coefficient: +

    +

    + 50,000 → 100,000 → 200,000 → snowball +

    +

    + The point isn't that dating apps alone get to 4 billion. +

    +

    + The point is they're one free channel in a portfolio of + channels, +

    +

    and you were going to be on the app anyway.

    +
    +
    +
    + + + How to do it + +
    + {steps.map((step) => ( +
    + +

    + {step.title} +

    +
    + {step.body} +
    +
    + ))} +
    + + +
    + + + The opening message + + When you match with someone, or even when you're sending a + first message, open with: + +
    +

    + "I have a great idea for how you can improve your profile. Do + you want to hear it?" +

    +
    + + Everyone wants a better dating profile. Curiosity alone will get a + response. When they say yes, send them this page. The + "improvement" is adding warondisease.org to their bio. + + + Now they're voting. Now they're adding it to their profile. + Now they're sending the same message to their matches. Each + person who participates creates more people who participate. + + + You started a conversation. You might also start a relationship. + Either way, you started a chain reaction. + +
    + + + Bio templates + + Use these as-is or adapt to your voice. The only requirement is that + warondisease.org appears and the reader has a reason to click it. + + + + It requires only 100 nuclear explosions to end civilization. + Humanity has enough for 122. Seems like we could spare 1% of the + budget for curing diseases instead. I made a website where you can + vote on it. warondisease.org. It takes 30 seconds.{" "} + + [then write whatever you normally write about yourself] + + + + + Before you evaluate whether I'm worth your time, please take 30 + seconds to vote at warondisease.org. It will statistically prevent + more suffering than anything you will learn about me in the next + four paragraphs.{" "} + [then write your normal bio] + + + + [your normal bio here] Also I'm + trying to get 4 billion people to vote to redirect 1% of military + spending to curing diseases. warondisease.org. It takes 30 seconds. + Your left swipe can still save lives. + + + + My most controversial opinion is that humanity should settle for 121 + apocalypses instead of 122 and use the savings to cure diseases 12 + times faster. Vote on it at warondisease.org. Now, about me:{" "} + [your normal bio] + + + + + The deeper idea + + You're on a dating app because you want to love someone and be + loved. That's the whole reason anyone is here. + + + What if you extended that impulse slightly? Not just to the person + you match with but to the 8 billion people who also don't want + to suffer and die of horrible diseases? + + + You don't have to be in love with everyone on earth. You just + have to care enough about strangers to spend 30 seconds voting so + they don't die of something curable. That's a very low bar + for love. You're already meeting it by being a person who reads + dating profiles instead of just looking at pictures. + + + Wars happen because people don't care enough about the people on + the other side. Diseases go uncured because people don't care + enough about the people who have them. The entire problem is a + deficit of giving a shit. + + + You give a shit. You're here. Put it in your bio. + + + + + Questions you might have + + {faqItems.map((item) => ( +
    +

    + {item.question} +

    +

    + {item.answer} +

    +
    + ))} +
    + +
    +

    + Your dating profile is seen by hundreds of people. Most of them will + swipe left. Make the left swipes count. +

    +

    + Vote first. Then put it in your bio. Then send this page to someone. +

    +
    + + Vote now + + +
    +
    + + Hey Google, set a timer for one minute. +
    +
    + ); +} diff --git a/packages/web/src/app/page.logged-out.md b/packages/web/src/app/page.logged-out.md index e44e01dd4..f5b5cb6d8 100644 --- a/packages/web/src/app/page.logged-out.md +++ b/packages/web/src/app/page.logged-out.md @@ -14,6 +14,6 @@ ## Visible Page Copy ## PLEASE TAKE 30 SECONDS TO END WAR AND DISEASE -- You pay governments [$36.5 trillion](https://manual.WarOnDisease.org/knowledge/appendix/humanity-v-government.html) a year to promote the general welfare (i.e. maximize median health and wealth). Of the money available for military/weapons and clinical trials, how much should go to each? +- You pay governments [$36.5 trillion](https://manual.WarOnDisease.org/knowledge/appendix/humanity-v-government.html) a year to promote the general welfare: maximize median health and wealth. Of the money available for military/weapons and pragmatic clinical trials, how much should go to each? - PRAGMATIC CLINICAL TRIALS - SLIDE ME diff --git a/packages/web/src/app/people/[id]/page.logged-out.md b/packages/web/src/app/people/[id]/page.logged-out.md index b5722eda4..53aee8aef 100644 --- a/packages/web/src/app/people/[id]/page.logged-out.md +++ b/packages/web/src/app/people/[id]/page.logged-out.md @@ -15,14 +15,9 @@ ## 404 - PAGE NOT FOUND -- Fascinating. You've managed to navigate to a page that doesn't exist. On my planet, our routing infrastructure hasn't lost a page in 4,237 years. You lot can't even keep track of a URL. -- WISHONIA DIAGNOSTIC REPORT -- Problem: Page not found -- Severity: Mildly embarrassing -- Root cause: Human error (probability: 97.3%) -- Recommended action: Click a button that actually goes somewhere -- Time to resolve on my planet: 0.003 seconds -- Estimated time on yours: Unclear. You still haven't fixed healthcare. -- [RETURN TO EARTH](/) -- [VIEW SCOREBOARD](/scoreboard) -- “It's almost impressive how a species that put people on the moon regularly types URLs wrong.”— Wishonia, mildly disappointed (as usual) +- Fascinating. You found a page that does not exist. On my planet, this takes effort. +- [SEARCH](/search) +- [VOTE](/vote) +- [DONATE](/donate) +- [ORGANIZATIONS](/endorse) +- Click something real. The machines are willing to forgive you. diff --git a/packages/web/src/app/people/[id]/page.tsx b/packages/web/src/app/people/[id]/page.tsx index 58c2718b2..4c3a11fbb 100644 --- a/packages/web/src/app/people/[id]/page.tsx +++ b/packages/web/src/app/people/[id]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; import { VOTER_SUFFERING_HOURS_PREVENTED } from "@optimitron/data/parameters"; -import { PersonLifeStatus, VotePosition } from "@optimitron/db/enums"; +import { PersonLifeStatus } from "@optimitron/db/enums"; import { unstable_cache } from "next/cache"; import { headers } from "next/headers"; import Link from "next/link"; @@ -9,24 +9,26 @@ import { getServerSession } from "next-auth"; import { notFound } from "next/navigation"; import { SufferingPreventedMetric } from "@/components/referendum/SignatoriesLeaderboard"; import { Avatar } from "@/components/retroui/Avatar"; -import { CopyLinkButton } from "@/components/sharing/copy-link-button"; -import { - defaultButtonClassName, - primaryButtonClassName, -} from "@/components/ui/default-button"; +import { PublicProfileOwnerControls } from "@/components/profile/PublicProfileOwnerControls"; +import { OpenTaskRequestAction } from "@/components/people/OpenTaskRequestAction"; +import { PersonTaskAssignmentAction } from "@/components/people/PersonTaskAssignmentAction"; +import { defaultButtonClassName } from "@/components/ui/default-button"; import { PublicProfileTaskSection } from "@/components/tasks/PublicProfileTaskSection"; import { WelfareClaim } from "@/components/shared/WelfareClaim"; import { getPersonTaskProfileData } from "@/lib/tasks.server"; import { authOptions } from "@/lib/auth"; -import { DECLARATION_SLUG } from "@/lib/declaration"; -import { prisma } from "@/lib/prisma"; import { getRepresentedLifeStatusLabel } from "@/lib/represented-life-status"; -import { buildOfficialReferendumVoteWhere } from "@/lib/referendum-vote-classification.server"; +import { + PUBLIC_PERSON_PROFILE_CACHE_TAG, + PUBLIC_PERSON_PROFILE_REVALIDATE_SECONDS, +} from "@/lib/person-profile-cache"; import { humanityVGovernmentLink, plaintiffsLink, + getSignInPath, ROUTES, } from "@/lib/routes"; +import { getPersonHref } from "@/lib/person-href"; import { getRepresentedPersonProfileData, type RepresentedPersonProfileData, @@ -36,20 +38,23 @@ import { getSiteFromHeaders, } from "@/lib/site"; import { TREATY_REFERENDUM_SLUG } from "@/lib/treaty"; -import { buildUserReferralUrl } from "@/lib/url"; - -const PUBLIC_PERSON_PROFILE_REVALIDATE_SECONDS = 300; const getCachedRepresentedPersonProfileData = unstable_cache( async (id: string) => getRepresentedPersonProfileData(id), ["public-represented-person-profile"], - { revalidate: PUBLIC_PERSON_PROFILE_REVALIDATE_SECONDS }, + { + revalidate: PUBLIC_PERSON_PROFILE_REVALIDATE_SECONDS, + tags: [PUBLIC_PERSON_PROFILE_CACHE_TAG], + }, ); const getCachedPersonTaskProfileData = unstable_cache( async (id: string) => getPersonTaskProfileData(id, null), ["public-person-task-profile"], - { revalidate: PUBLIC_PERSON_PROFILE_REVALIDATE_SECONDS }, + { + revalidate: PUBLIC_PERSON_PROFILE_REVALIDATE_SECONDS, + tags: [PUBLIC_PERSON_PROFILE_CACHE_TAG], + }, ); type PersonTaskProfileData = NonNullable< @@ -58,28 +63,6 @@ type PersonTaskProfileData = NonNullable< type PublicProfilePerson = PersonTaskProfileData["person"]; type PublicProfileVote = PublicProfilePerson["referendumVotes"][number]; -async function getVisitorTreatyStatus(userId: string | null) { - if (!userId) { - return { hasSignedTreaty: false }; - } - - const vote = await prisma.referendumVote.findFirst({ - where: { - ...buildOfficialReferendumVoteWhere({ - answer: VotePosition.YES, - }), - referendum: { - deletedAt: null, - slug: TREATY_REFERENDUM_SLUG, - }, - userId, - }, - select: { id: true }, - }); - - return { hasSignedTreaty: Boolean(vote) }; -} - export async function generateMetadata({ params, }: { @@ -137,8 +120,8 @@ export async function generateMetadata({ const treatyVote = getVoteBySlug(data.person, TREATY_REFERENDUM_SLUG); const description = treatyVote - ? `${data.person.displayName} signed the 1% Treaty. Add your name.` - : `${data.person.displayName}'s public campaign profile. Sign the 1% Treaty.`; + ? `${data.person.displayName} voted YES on the 1% Treaty. See what ${data.person.displayName} should do next.` + : `${data.person.displayName}'s public action page for ending war and disease.`; return { title: { absolute: `${data.person.displayName} | ${site.name}` }, @@ -213,11 +196,11 @@ function getTrustSignal(person: PublicProfilePerson) { const recruitedCount = person.user?._count.referendumReferrals ?? 0; if (treatyVote) { - const signedDate = formatIsoDate(treatyVote.createdAt); + const voteDate = formatIsoDate(treatyVote.createdAt); const parts = [ - signedDate - ? `Signed the 1% Treaty ${signedDate}` - : "Signed the 1% Treaty", + voteDate + ? `Voted YES on the 1% Treaty ${voteDate}` + : "Voted YES on the 1% Treaty", ]; if (recruitedCount > 0) { parts.push(formatRecruitmentPhrase(recruitedCount)); @@ -238,12 +221,6 @@ function getTrustSignal(person: PublicProfilePerson) { return "Public campaign profile"; } -function buildForwardReferralHref(referralUrl: string) { - const subject = "Sign the 1% Treaty"; - const body = `I signed the 1% Treaty. Add your name: ${referralUrl}`; - return `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; -} - function RepresentedPersonProfile({ data, }: { @@ -482,54 +459,63 @@ export default async function PersonDetailPage({ forwardedHost: hdrs.get("x-forwarded-host"), forwardedProto: hdrs.get("x-forwarded-proto"), }); - const visitorStatus = await getVisitorTreatyStatus(userId); - const { openTasks, person, verifiedTasks } = data; + const { + assignedByOpenTasks, + completedTasks: profileCompletedTasks, + openTasks, + person, + requestedOpenTasks, + } = data; + const isOwnProfile = Boolean(userId && person.user?.id === userId); + const publicProfileHref = getPersonHref(person); + const publicProfileUrl = `${requestOrigin}${publicProfileHref}`; + const assignTaskCallbackHref = `${publicProfileHref}?assignTask=1`; + const assignTaskSignInHref = getSignInPath(assignTaskCallbackHref); + const openRequestSignInHref = getSignInPath(publicProfileHref); const fallbackInitials = getFallbackInitials(person.displayName); const treatyVote = getVoteBySlug(person, TREATY_REFERENDUM_SLUG); - const courtVote = getVoteBySlug(person, DECLARATION_SLUG); const recruitedCount = person.user?._count.referendumReferrals ?? 0; const plaintiffCount = person.user?._count.createdCourtCaseParties ?? 0; - const signatureCount = (treatyVote ? 1 : 0) + recruitedCount; + const publicVoteCount = treatyVote ? 1 : 0; + const attributableVoteCount = publicVoteCount + recruitedCount; const hoursPrevented = - VOTER_SUFFERING_HOURS_PREVENTED.value * signatureCount; - const profileReferralUrl = person.user - ? buildUserReferralUrl( - { handle: person.handle, referralCode: person.user.referralCode }, - requestOrigin, - ) - : `${requestOrigin}${ROUTES.vote}`; - const visitorReferralUrl = session?.user - ? buildUserReferralUrl(session.user, requestOrigin) - : null; - const shouldShowVisitorReferral = - visitorStatus.hasSignedTreaty && Boolean(visitorReferralUrl); - const signatureRows = person.referendumVotes; - const activityRows = [ - { - label: "Treaty signed", - value: treatyVote - ? (formatIsoDate(treatyVote.createdAt) ?? "Yes") - : "Not public", - }, - { - label: "Court signed", - value: courtVote - ? (formatIsoDate(courtVote.createdAt) ?? "Yes") - : "Not public", - }, - { - label: "Plaintiffs registered", - value: formatHumanCount(plaintiffCount), - }, - { - label: "Humans recruited", - value: formatHumanCount(recruitedCount), - }, - ]; + VOTER_SUFFERING_HOURS_PREVENTED.value * attributableVoteCount; + const publicVotes = person.referendumVotes; + const impactRows = [ + publicVoteCount > 0 + ? { + label: "Treaty vote", + value: treatyVote + ? (formatIsoDate(treatyVote.createdAt) ?? "YES") + : "YES", + } + : null, + plaintiffCount > 0 + ? { + label: "Plaintiffs registered", + value: formatHumanCount(plaintiffCount), + } + : null, + recruitedCount > 0 + ? { + label: "Humans recruited", + value: formatHumanCount(recruitedCount), + } + : null, + ].filter(Boolean) as Array<{ label: string; value: string }>; + const shouldShowImpactStats = hoursPrevented > 0 || impactRows.length > 0; return (
    + {isOwnProfile ? ( + + ) : null} +
    {person.image ? ( @@ -550,72 +536,67 @@ export default async function PersonDetailPage({
    -
    - -
    + + } + intro={`Give ${person.displayName} a high-value action, take an open request from ${person.displayName}, or help finish work ${person.displayName} assigned.`} + openTasks={openTasks} + ownerName={person.displayName} + requestAction={ + isOwnProfile ? ( + + ) : null + } + requestedTasks={requestedOpenTasks} + /> -
    - {shouldShowVisitorReferral && visitorReferralUrl ? ( -
    - - Forward My Referral - -
    -

    - Your referral URL + {shouldShowImpactStats ? ( +

    + {hoursPrevented > 0 ? ( + + ) : null} + {impactRows.map((row) => ( +
    +

    + {row.label} +

    +

    + {row.value}

    -
    -

    - {visitorReferralUrl} -

    - -
    -
    - ) : ( - - Sign the Treaty - - )} -
    - -
    - {activityRows.map((row) => ( -
    -

    - {row.label} -

    -

    - {row.value} -

    -
    - ))} -
    + ))} +
    + ) : null} -
    -

    - Public Signatures -

    - {signatureRows.length > 0 ? ( + {publicVotes.length > 0 ? ( +
    +

    + Public Referendum Votes +

      - {signatureRows.map((vote) => ( + {publicVotes.map((vote) => (
    • - {formatIsoDate(vote.createdAt) ?? "Signed"} + {formatIsoDate(vote.createdAt) ?? "Voted"}
    • ))}
    - ) : ( -

    - No public referendum signatures yet. -

    - )} -
    - - +
    + ) : null} ); diff --git a/packages/web/src/app/poster/page.tsx b/packages/web/src/app/poster/page.tsx index af67bb717..3592524bc 100644 --- a/packages/web/src/app/poster/page.tsx +++ b/packages/web/src/app/poster/page.tsx @@ -221,7 +221,7 @@ export default async function PosterPage({ ? "bg-foreground text-background" : "bg-background text-foreground hover:bg-muted" }`} - href="/poster" + href={ROUTES.poster} > Letter @@ -232,7 +232,7 @@ export default async function PosterPage({ ? "bg-foreground text-background" : "bg-background text-foreground hover:bg-muted" }`} - href="/poster?paper=a4" + href={`${ROUTES.poster}?paper=a4`} > A4 diff --git a/packages/web/src/app/prize/page.logged-out.md b/packages/web/src/app/prize/page.logged-out.md index 1bc3deb25..61556acb6 100644 --- a/packages/web/src/app/prize/page.logged-out.md +++ b/packages/web/src/app/prize/page.logged-out.md @@ -63,10 +63,9 @@ ### GAME STATUS - 10 - 04 -- 24 -- 17 -- 39 -- 27 +- 23 +- 42 +- 51 - Until the destructive economy reaches 50% of GDP — the point where stealing beats creating ### PLAY THE GAME - The current cost of governance dysfunction is $101 trillion per year. The break-even probability is 0.0067%. You don't need to be altruistic. You just need to be numerate. diff --git a/packages/web/src/app/profile/page.tsx b/packages/web/src/app/profile/page.tsx index ebabc18ba..a8e567670 100644 --- a/packages/web/src/app/profile/page.tsx +++ b/packages/web/src/app/profile/page.tsx @@ -1,16 +1,17 @@ import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; +import { OpenTaskRequestAction } from "@/components/people/OpenTaskRequestAction"; import { ProfileIdentityClient } from "@/components/profile/ProfileIdentityClient"; import { PublicProfileTaskSection } from "@/components/tasks/PublicProfileTaskSection"; import { authOptions } from "@/lib/auth"; import { getUserPersonHref } from "@/lib/person-href"; import { getProfileIdentityData } from "@/lib/profile-identity.server"; -import { getSignInPath, profileLink, ROUTES } from "@/lib/routes"; +import { getSignInPath, editProfileLink, ROUTES } from "@/lib/routes"; import { getRouteMetadata } from "@/lib/metadata"; import { getPersonTaskProfileData } from "@/lib/tasks.server"; import { getUserDisplayName } from "@/lib/user-display"; -export const metadata = getRouteMetadata(profileLink); +export const metadata = getRouteMetadata(editProfileLink); export default async function ProfilePage() { const session = await getServerSession(authOptions); @@ -42,12 +43,24 @@ export default async function ProfilePage() { />
    + ) : null + } + requestedTasks={publicTaskData?.requestedOpenTasks ?? []} />
    diff --git a/packages/web/src/app/survey/page.logged-out.md b/packages/web/src/app/survey/page.logged-out.md index 09acb9d92..579b83256 100644 --- a/packages/web/src/app/survey/page.logged-out.md +++ b/packages/web/src/app/survey/page.logged-out.md @@ -14,7 +14,7 @@ ## Visible Page Copy ## GLOBAL SURVEY TO END WAR AND DISEASE -- You pay governments [$36.5 trillion](https://manual.WarOnDisease.org/knowledge/appendix/humanity-v-government.html) a year to promote the general welfare (i.e. maximize median health and wealth). Of the money available for military/weapons and clinical trials, how much should go to each? +- You pay governments [$36.5 trillion](https://manual.WarOnDisease.org/knowledge/appendix/humanity-v-government.html) a year to promote the general welfare: maximize median health and wealth. Of the money available for military/weapons and pragmatic clinical trials, how much should go to each? - PRAGMATIC CLINICAL TRIALS - SLIDE ME - IC2EWD SURVEY diff --git a/packages/web/src/app/tools/page.logged-out.md b/packages/web/src/app/tools/page.logged-out.md index 9adef073d..892bd2c1c 100644 --- a/packages/web/src/app/tools/page.logged-out.md +++ b/packages/web/src/app/tools/page.logged-out.md @@ -13,7 +13,7 @@ ## Visible Page Copy -- 25 TOOLS +- 26 TOOLS ## THE ARMORY - Your toolkit for fixing the mess described above. Browse. Equip. Try not to break anything important. ### ANALYSIS @@ -42,6 +42,7 @@ - [🔍DECENTRALIZED ACCOUNTABILITY OFFICE Every fund flow on IPFS — impossible to quietly delete](/agencies/dgao) ### PLAYER - [🎯EARTH OPTIMIZATION TASKS What waiting costs](/tasks) +- [END WAR AND DISEASE FROM YOUR DATING PROFILE End war and disease from your dating profile.](/love) - [🪪REMIND PRESIDENTS Remind presidents to promote the general welfare](/employees) - [📡TRANSMIT Thirty seconds — what you ate, how you slept, how you feel](/transmit) - [📊MANAGE HUMANITY Get humanity to agree](/dashboard) diff --git a/packages/web/src/app/tools/page.tsx b/packages/web/src/app/tools/page.tsx index 231e09871..4c025e24e 100644 --- a/packages/web/src/app/tools/page.tsx +++ b/packages/web/src/app/tools/page.tsx @@ -23,7 +23,7 @@ function ToolCard({ item, color }: { item: NavItem; color: "cyan" | "yellow" | " const inner = (
    - {item.emoji} + {item.emoji ? {item.emoji} : null} {item.external && ( External diff --git a/packages/web/src/app/treaty/page.logged-out.md b/packages/web/src/app/treaty/page.logged-out.md index be35a15fe..f88afb116 100644 --- a/packages/web/src/app/treaty/page.logged-out.md +++ b/packages/web/src/app/treaty/page.logged-out.md @@ -50,6 +50,6 @@ - Article VIII: This treaty supersedes all conflicting domestic law. Including the subsection your legislature added at 2 a.m. last session specifically to make sure this couldn't happen. - Article IX: This Treaty enters into force upon signature by two states. War has killed humans for as long as there have been humans to kill. Disease has been killing them longer. Its founding signatories will be responsible for the largest reduction in human suffering and the largest increase in human prosperity in the history of planet Earth. - IN WITNESS WHEREOF, the undersigned, being of sound mind (debatable) and tired of watching their loved ones die of preventable diseases, have executed this Treaty. -- Signed this day, May 17, 2026, in the year of our ongoing confusion. +- Signed this day, [signature date], in the year of our ongoing confusion. - SIGN - Display my name publicly on the signer list and leaderboards (recommended). diff --git a/packages/web/src/app/vote/page.logged-out.md b/packages/web/src/app/vote/page.logged-out.md index b173c97b6..27a0c5ec9 100644 --- a/packages/web/src/app/vote/page.logged-out.md +++ b/packages/web/src/app/vote/page.logged-out.md @@ -14,6 +14,6 @@ ## Visible Page Copy ## PLEASE TAKE 30 SECONDS TO END WAR AND DISEASE -- You pay governments [$36.5 trillion](https://manual.WarOnDisease.org/knowledge/appendix/humanity-v-government.html) a year to promote the general welfare (i.e. maximize median health and wealth). Of the money available for military/weapons and clinical trials, how much should go to each? +- You pay governments [$36.5 trillion](https://manual.WarOnDisease.org/knowledge/appendix/humanity-v-government.html) a year to promote the general welfare: maximize median health and wealth. Of the money available for military/weapons and pragmatic clinical trials, how much should go to each? - PRAGMATIC CLINICAL TRIALS - SLIDE ME diff --git a/packages/web/src/components/Navbar.test.ts b/packages/web/src/components/Navbar.test.ts new file mode 100644 index 000000000..39c5031b1 --- /dev/null +++ b/packages/web/src/components/Navbar.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { editProfileLink, publicProfileLink } from "@/lib/routes"; +import { getAuthenticatedProfileLinks } from "./Navbar"; + +describe("Navbar profile links", () => { + it("keeps profile editing reachable when a public profile exists", () => { + expect(getAuthenticatedProfileLinks(null)).toEqual([editProfileLink]); + + expect(getAuthenticatedProfileLinks("/people/mike")).toEqual([ + editProfileLink, + { + ...publicProfileLink, + href: "/people/mike", + }, + ]); + }); +}); diff --git a/packages/web/src/components/Navbar.tsx b/packages/web/src/components/Navbar.tsx index c0edd652c..9e09d0a7a 100644 --- a/packages/web/src/components/Navbar.tsx +++ b/packages/web/src/components/Navbar.tsx @@ -16,11 +16,13 @@ import { import { Input } from "@/components/retroui/Input"; import { Accordion } from "@/components/retroui/Accordion"; import { getSiteVariantUiConfig, type SiteNavConfig } from "@/config/site-variant-ui"; +import { getPersonHref } from "@/lib/person-href"; import { ROUTES, + editProfileLink, getSignInPath, isNavItemActive, - profileLink, + publicProfileLink, searchLink, settingsLink, type NavItem, @@ -30,6 +32,21 @@ function getNavItemAriaLabel(item: NavItem): string { return item.description ? `${item.label}: ${item.description}` : item.label; } +export function getAuthenticatedProfileLinks( + publicProfileHref: string | null, +): NavItem[] { + const links = [editProfileLink]; + + if (publicProfileHref) { + links.push({ + ...publicProfileLink, + href: publicProfileHref, + }); + } + + return links; +} + function AvatarButton({ callbackUrl, user, @@ -77,6 +94,11 @@ export default function Navbar({ config = defaultNavConfig }: NavbarProps) { const [navQuery, setNavQuery] = useState(""); const isAuthenticated = status === "authenticated"; const user = session?.user ?? null; + const publicProfileHref = user?.personId + ? getPersonHref({ id: user.personId, handle: user.handle ?? null }) + : null; + const authenticatedProfileLinks = + getAuthenticatedProfileLinks(publicProfileHref); const quickAction = config.quickAction ?? null; const quickActionHref = quickAction ? isAuthenticated @@ -345,16 +367,18 @@ export default function Navbar({ config = defaultNavConfig }: NavbarProps) {
    {isAuthenticated ? ( <> - - - {profileLink.emoji} {profileLink.label} - - + {authenticatedProfileLinks.map((item) => ( + + + {item.emoji} {item.label} + + + ))} {getStatusLabel()} {renderShareStatus()} + + End war and disease from your dating profile. +
    ); diff --git a/packages/web/src/components/dashboard/HumanityManagerStatusPanel.test.tsx b/packages/web/src/components/dashboard/HumanityManagerStatusPanel.test.tsx index 6793f2b99..d37edc458 100644 --- a/packages/web/src/components/dashboard/HumanityManagerStatusPanel.test.tsx +++ b/packages/web/src/components/dashboard/HumanityManagerStatusPanel.test.tsx @@ -27,6 +27,7 @@ const STATUS_FIXTURE: HumanityManagerStatusInput = { ], directConversionCount: 2, downstreamConversionCount: 7, + kFactor30d: 0.75, overdueEmployeeCount: 1, overdueEmployees: [{ displayName: "Jake Smith" }], overduePresidentCount: 1, @@ -76,6 +77,8 @@ describe("HumanityManagerStatusPanel", () => { }); expect(container.textContent).toContain("3 downstream votes from them"); + expect(container.textContent).toContain("Ada Lovelace voted YES"); + expect(container.textContent).toContain("Remind Presidents"); const button = Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent === "Copy", diff --git a/packages/web/src/components/dashboard/PrivacyToggle.tsx b/packages/web/src/components/dashboard/PrivacyToggle.tsx index 3b1fe72ad..ca608a602 100644 --- a/packages/web/src/components/dashboard/PrivacyToggle.tsx +++ b/packages/web/src/components/dashboard/PrivacyToggle.tsx @@ -6,20 +6,27 @@ import { cn } from "@/lib/utils" interface PrivacyToggleProps { isPublic: boolean onChange: (isPublic: boolean) => void + disabled?: boolean } -export function PrivacyToggle({ isPublic, onChange }: PrivacyToggleProps) { +export function PrivacyToggle({ + disabled = false, + isPublic, + onChange, +}: PrivacyToggleProps) { return (
    -
    onChange(!isPublic)} + type="button" > - {/* Sliding Background */} - {/* Private Option (Left) */}
    - 🔒 + 🔒 PRIVATE
    - {/* Public Option (Right) */}
    - 🌍 - + 🌍 + PUBLIC
    -
    + - {/* Description Text */}
    {isPublic ? ( - View Profile + Preview public profile
    diff --git a/packages/web/src/components/dashboard/ReferralLinkEditor.tsx b/packages/web/src/components/dashboard/ReferralLinkEditor.tsx index 72efa9029..6021ee691 100644 --- a/packages/web/src/components/dashboard/ReferralLinkEditor.tsx +++ b/packages/web/src/components/dashboard/ReferralLinkEditor.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { Check, Copy, Save } from "lucide-react"; import { Card } from "@/components/retroui/Card"; import { Button } from "@/components/retroui/Button"; @@ -86,13 +86,13 @@ export function ReferralLinkEditor({ const [copied, setCopied] = useState(false); const [error, setError] = useState(null); const copiedTimeoutRef = useRef(null); + const inputId = useId(); const base = normalizeBaseUrl(baseUrl); const trimmedDraft = draft.trim(); const dirty = trimmedDraft !== currentIdentifier; const displayedIdentifier = trimmedDraft || currentIdentifier; const displayedUrl = buildReferralUrl(displayedIdentifier, base); const savedUrl = buildReferralUrl(currentIdentifier, base); - const linkPrefix = `${base}/vote/`; const isTreaty = variant === "treaty"; const hasHeaderCopy = Boolean(title || description); @@ -212,31 +212,20 @@ export function ReferralLinkEditor({
    ) : null} -
    -
    - +
    + { @@ -248,48 +237,68 @@ export function ReferralLinkEditor({ void saveLinkName(); } }} - className="min-h-12 min-w-[9rem] flex-1 border-0 bg-transparent px-0 font-mono text-base font-black shadow-none focus:shadow-none" + className="min-h-12 w-full border border-black bg-background px-3 font-mono text-base font-black shadow-none focus:shadow-none" maxLength={24} placeholder="your-name" />
    - {dirty ? ( +
    + {dirty ? ( + + ) : null} +
    +
    + +
    +
    +

    + Share this link +

    +

    + {displayedUrl} +

    +
    + +
    - ) : null} - - +
    {error ? ( diff --git a/packages/web/src/components/landing/TreatyVoteFlow.tsx b/packages/web/src/components/landing/TreatyVoteFlow.tsx index a620359d7..ac53edd99 100644 --- a/packages/web/src/components/landing/TreatyVoteFlow.tsx +++ b/packages/web/src/components/landing/TreatyVoteFlow.tsx @@ -104,7 +104,8 @@ export interface TreatyVoteFlowProps { surface?: string; } -const DEFAULT_SLIDER_HEADLINE = "Please Take 30 Seconds to End War and Disease"; +const DEFAULT_SLIDER_HEADLINE = + "PLEASE TAKE 30 SECONDS TO END WAR AND DISEASE"; const CHOICE_CARD_SETTLED_SCROLL_DELAY_MS = 500; function PragmaticClinicalTrialsDefinition({ @@ -186,8 +187,12 @@ function DefaultSliderPrompt() { param={GLOBAL_GOVERNMENT_EXPENSE_ANNUAL} valueOverride={formatWelfareClaimAmountText()} />{" "} - a year to promote the general welfare (i.e. maximize median health and - wealth). Of the money available for military/weapons and clinical trials, + a year to promote the general welfare: maximize median health and wealth. + Of the money available for military/weapons and{" "} + + pragmatic clinical trials + + {", "} how much should go to each? ); @@ -473,6 +478,19 @@ export function TreatyVoteFlow({ const isContextFirstVariant = flowVariant === TREATY_FLOW_VARIANTS.contextFirstV2; + const isSurveyCompletion = postVoteCompletion === "message"; + const saveTitle = isSurveyCompletion + ? "Save Your Response" + : "Save Your 1% Treaty Vote"; + const emailSaveLabel = isSurveyCompletion + ? "Email me a link to save my response" + : "Email me a link to save my vote"; + const emailSavePendingLabel = isSurveyCompletion + ? "Sending response-save link..." + : "Sending vote-save link..."; + const emailSaveSuccessFooter = isSurveyCompletion + ? "Your response is recorded. Open the email to save it." + : VOTE_SECTION.emailSuccessFooter; const advancePreVote = (next: PreVoteScreen, dismissive = false) => { const nextDismissiveCount = dismissive @@ -894,27 +912,17 @@ export function TreatyVoteFlow({
    {/* Slider with Animation */} -
    +
    {showAnimation && !userHasDragged && ( <> - -
    -

    - Slide me -

    -
    -
    -
    @@ -980,7 +996,7 @@ export function TreatyVoteFlow({ onClick={handleSliderSubmit} className={`${treatyPrimaryButtonClass} w-full text-base sm:text-lg`} > - SUBMIT + SUBMIT THIS SPLIT )} @@ -1132,7 +1148,7 @@ export function TreatyVoteFlow({

    - {isContextFirstVariant ? "Vote counted." : "Save Your Vote"} + {isContextFirstVariant ? "Vote counted." : saveTitle}

    {isContextFirstVariant ? ( @@ -1168,9 +1184,9 @@ export function TreatyVoteFlow({ hideContainer title={null} googleButtonLabel={isContextFirstVariant ? "Verify with Google" : "Save with Google"} - emailButtonLabel={isContextFirstVariant ? "Verify by email" : "Email me a save link"} - emailPendingButtonLabel={isContextFirstVariant ? "Sending verification link..." : "Sending save link..."} - emailSuccessFooter={VOTE_SECTION.emailSuccessFooter} + emailButtonLabel={isContextFirstVariant ? "Verify by email" : emailSaveLabel} + emailPendingButtonLabel={isContextFirstVariant ? "Sending verification link..." : emailSavePendingLabel} + emailSuccessFooter={emailSaveSuccessFooter} /> )} diff --git a/packages/web/src/components/people/OpenTaskRequestAction.tsx b/packages/web/src/components/people/OpenTaskRequestAction.tsx new file mode 100644 index 000000000..172c24b66 --- /dev/null +++ b/packages/web/src/components/people/OpenTaskRequestAction.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { ClipboardList } from "lucide-react"; +import { CreateTaskDialog } from "@/components/tasks/CreateTaskDialog"; +import { defaultButtonClassName } from "@/components/ui/default-button"; + +interface OpenTaskRequestActionProps { + buttonLabel?: string; + callbackUrl: string; + isAuthenticated: boolean; + signInHref: string; +} + +export function OpenTaskRequestAction({ + buttonLabel = "Ask for help", + callbackUrl, + isAuthenticated, + signInHref, +}: OpenTaskRequestActionProps) { + const [open, setOpen] = useState(false); + + if (!isAuthenticated) { + return ( + + + {buttonLabel} + + ); + } + + return ( + <> + + + + ); +} diff --git a/packages/web/src/components/people/PersonTaskAssignmentAction.tsx b/packages/web/src/components/people/PersonTaskAssignmentAction.tsx new file mode 100644 index 000000000..224fd4fe5 --- /dev/null +++ b/packages/web/src/components/people/PersonTaskAssignmentAction.tsx @@ -0,0 +1,72 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Clipboard } from "lucide-react"; +import { CreateTaskDialog } from "@/components/tasks/CreateTaskDialog"; +import { defaultButtonClassName } from "@/components/ui/default-button"; + +interface PersonTaskAssignmentActionProps { + buttonLabel?: string; + callbackUrl: string; + isAuthenticated: boolean; + personId: string; + personName: string; + signInHref: string; +} + +export function PersonTaskAssignmentAction({ + buttonLabel = "Assign Task", + callbackUrl, + isAuthenticated, + personId, + personName, + signInHref, +}: PersonTaskAssignmentActionProps) { + const searchParams = useSearchParams(); + const handledAutoOpen = useRef(false); + const [open, setOpen] = useState(false); + + useEffect(() => { + if ( + !handledAutoOpen.current && + isAuthenticated && + searchParams.get("assignTask") === "1" + ) { + handledAutoOpen.current = true; + setOpen(true); + } + }, [isAuthenticated, searchParams]); + + if (!isAuthenticated) { + return ( + + + {buttonLabel} + + ); + } + + return ( + <> + + + + ); +} diff --git a/packages/web/src/components/profile/ProfileIdentityClient.tsx b/packages/web/src/components/profile/ProfileIdentityClient.tsx index edda45922..b24906031 100644 --- a/packages/web/src/components/profile/ProfileIdentityClient.tsx +++ b/packages/web/src/components/profile/ProfileIdentityClient.tsx @@ -2,7 +2,6 @@ import { useState } from "react" import { useRouter } from "next/navigation" -import { useSession } from "next-auth/react" import { ArcadeTag } from "@/components/ui/arcade-tag" import { ReferralLinkBanner } from "@/components/dashboard/ReferralLinkBanner" import { ProfileCard } from "@/components/dashboard/ProfileCard" @@ -26,12 +25,10 @@ export function ProfileIdentityClient({ linkedAuthProviderIds, }: ProfileIdentityClientProps) { const router = useRouter() - const { update: updateSession } = useSession() const [user, setUser] = useState(initialUser) const referralLink = buildUserReferralUrl(user) const refreshPage = () => { - void updateSession() router.refresh() } diff --git a/packages/web/src/components/profile/PublicProfileOwnerControls.tsx b/packages/web/src/components/profile/PublicProfileOwnerControls.tsx new file mode 100644 index 000000000..2f09733ed --- /dev/null +++ b/packages/web/src/components/profile/PublicProfileOwnerControls.tsx @@ -0,0 +1,119 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { CopyLinkButton } from "@/components/sharing/copy-link-button"; +import { PrivacyToggle } from "@/components/dashboard/PrivacyToggle"; +import { defaultButtonClassName } from "@/components/ui/default-button"; +import { ROUTES } from "@/lib/routes"; + +interface PublicProfileOwnerControlsProps { + initialIsPublic: boolean; + publicProfileHref: string; + publicProfileUrl: string; +} + +export function PublicProfileOwnerControls({ + initialIsPublic, + publicProfileHref, + publicProfileUrl, +}: PublicProfileOwnerControlsProps) { + const router = useRouter(); + const [isPublic, setIsPublic] = useState(initialIsPublic); + const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [isPending, startTransition] = useTransition(); + const isBusy = isSaving || isPending; + + async function updateVisibility(nextIsPublic: boolean) { + setError(null); + setIsSaving(true); + + let response: Response; + try { + response = await fetch("/api/dashboard/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isPublic: nextIsPublic }), + }); + } catch { + setIsSaving(false); + setError("Profile visibility did not update. Try again."); + return; + } + + if (!response.ok) { + setIsSaving(false); + setError("Profile visibility did not update. Try again."); + return; + } + + setIsPublic(nextIsPublic); + setIsSaving(false); + startTransition(() => { + router.refresh(); + }); + } + + return ( +

    +
    +
    +

    + Your public to-do list +

    +

    + This is how other humans help you figure out the most valuable action you can take to maximize humanity's median income and healthy life expectancy. Make it public to get help from your network and the world, or keep it private if you prefer. You can change this anytime. +

    + +
    +

    + Public Profile URL +

    +
    +

    + {publicProfileUrl} +

    + +
    +
    + +
    + + View Profile + + + Edit Profile + +
    +
    + +
    +

    + Privacy Settings +

    + void updateVisibility(value)} + /> + {error ? ( +

    {error}

    + ) : null} +
    +
    +
    + ); +} diff --git a/packages/web/src/components/site/CampaignActionFab.tsx b/packages/web/src/components/site/CampaignActionFab.tsx index bb7640d15..13c2b5f15 100644 --- a/packages/web/src/components/site/CampaignActionFab.tsx +++ b/packages/web/src/components/site/CampaignActionFab.tsx @@ -1,24 +1,14 @@ "use client"; -import { FormEvent, useMemo, useState } from "react"; -import { usePathname, useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import { usePathname } from "next/navigation"; import { useSession } from "next-auth/react"; import { Check, Clipboard, Plus, Share2, X } from "lucide-react"; import { Button } from "@/components/retroui/Button"; -import { Dialog } from "@/components/retroui/Dialog"; -import { Input } from "@/components/retroui/Input"; -import { Textarea } from "@/components/retroui/Textarea"; +import { CreateTaskDialog } from "@/components/tasks/CreateTaskDialog"; import { buildUserReferralUrl } from "@/lib/url"; -import { cn } from "@/lib/utils"; -type TaskMode = "self" | "person"; - -const HIDDEN_PATH_PREFIXES = [ - "/api", - "/auth", - "/survey", - "/vote", -] as const; +const HIDDEN_PATH_PREFIXES = ["/api", "/auth", "/survey", "/vote"] as const; function shouldHideForPath(pathname: string | null) { if (!pathname) return true; @@ -43,24 +33,12 @@ async function copyToClipboard(text: string) { document.body.removeChild(textarea); } -async function readApiError(response: Response) { - const body = await response.json().catch(() => null); - return typeof body?.error === "string" ? body.error : "Request failed."; -} - export function CampaignActionFab() { const pathname = usePathname(); - const router = useRouter(); const { data: session, status } = useSession(); const [open, setOpen] = useState(false); const [copied, setCopied] = useState(false); const [taskOpen, setTaskOpen] = useState(false); - const [taskMode, setTaskMode] = useState("self"); - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); - const [assignee, setAssignee] = useState(""); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(null); const referralUrl = useMemo( () => buildUserReferralUrl(session?.user), @@ -77,70 +55,10 @@ export function CampaignActionFab() { window.setTimeout(() => setCopied(false), 2000); } - function resetTaskForm() { - setTaskMode("self"); - setTitle(""); - setDescription(""); - setAssignee(""); - setError(null); - } - - async function createTask(event: FormEvent) { - event.preventDefault(); - const trimmedTitle = title.trim(); - if (!trimmedTitle) { - setError("Title is required."); - return; - } - if (taskMode === "person" && !assignee.trim()) { - setError("Person handle or URL is required."); - return; - } - - try { - setCreating(true); - setError(null); - const response = await fetch("/api/tasks", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - assigneePersonId: - taskMode === "self" ? session?.user.personId : undefined, - assigneePersonIdentifier: - taskMode === "person" ? assignee.trim() : undefined, - description: description.trim(), - isPublic: taskMode === "person", - title: trimmedTitle, - }), - }); - - if (!response.ok) { - throw new Error(await readApiError(response)); - } - - const body = (await response.json()) as { data?: { id?: string } }; - const taskId = body.data?.id; - setTaskOpen(false); - resetTaskForm(); - if (taskId) { - router.push(`/tasks/${taskId}`); - } else { - router.push("/tasks"); - } - router.refresh(); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create task."); - } finally { - setCreating(false); - } - } - const actionButtonClass = "group min-h-10 justify-start gap-2 rounded-full bg-background/95 py-1.5 pl-1.5 pr-3 text-xs font-black uppercase tracking-[0.08em] text-foreground shadow-[0_8px_24px_rgba(0,0,0,0.14)] ring-1 ring-foreground/10 hover:bg-foreground hover:text-background focus-visible:ring-2 focus-visible:ring-foreground"; const actionIconClass = "flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-foreground text-background group-hover:bg-background group-hover:text-foreground"; - const fieldClass = - "border border-foreground bg-background font-bold shadow-none focus:shadow-none"; return ( <> @@ -198,127 +116,11 @@ export function CampaignActionFab() {
    ) : null} - { - setTaskOpen(nextOpen); - if (!nextOpen) resetTaskForm(); - }} - > - -
    void createTask(event)}> - -
    -

    - Create task -

    - - - Close - -
    -
    - -
    -
    - {(["self", "person"] as const).map((mode) => ( - - ))} -
    - - - -