From ac0140ecd7e79613c124d9dde670c9dab36ccacf Mon Sep 17 00:00:00 2001 From: jolah1 Date: Sat, 4 Jul 2026 09:21:10 +0100 Subject: [PATCH] feat(web): Tools nav page, done tools leave dashboard, channel-true practice copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - practice card copy now names the heir's real channel (email, text, WhatsApp) from the server heir profile instead of always saying email; generic "message" when unknown - dashboard More list hides tools that are already done (video saved, practice sent, reminders on for this device); emergency stays - new #/tools page (nav: Dashboard · Tools · Recovery kit · Inherit) lists every tool permanently with its live status - practice page intro: em dash removed, "No money moves" - new shared useToolDoneState hook (video status + push subscription) Co-Authored-By: Claude Opus 4.8 --- ghostkey-web/src/App.tsx | 6 ++ ghostkey-web/src/Dashboard.tsx | 22 ++++- ghostkey-web/src/NavBar.tsx | 1 + ghostkey-web/src/PracticeClaimCard.tsx | 63 +++++++++++-- ghostkey-web/src/VaultToolPages.tsx | 124 ++++++++++++++++++++++++- ghostkey-web/src/practiceCard.test.ts | 18 +++- ghostkey-web/src/toolStatus.ts | 64 +++++++++++++ 7 files changed, 282 insertions(+), 16 deletions(-) create mode 100644 ghostkey-web/src/toolStatus.ts diff --git a/ghostkey-web/src/App.tsx b/ghostkey-web/src/App.tsx index 6c0a00f..bab01bc 100644 --- a/ghostkey-web/src/App.tsx +++ b/ghostkey-web/src/App.tsx @@ -73,6 +73,9 @@ const EmergencyPage = lazy(() => const RemindersPage = lazy(() => import("./VaultToolPages").then((m) => ({ default: m.RemindersPage })), ); +const ToolsPage = lazy(() => + import("./VaultToolPages").then((m) => ({ default: m.ToolsPage })), +); const RecoveryKitPage = lazy(() => import("./RecoveryKitPage").then((m) => ({ default: m.RecoveryKitPage })), ); @@ -125,6 +128,7 @@ export type Route = | "practice" | "emergency" | "reminders" + | "tools" | "recovery" | "recovery-guide" | "checkin" @@ -146,6 +150,7 @@ const VALID: Route[] = [ "practice", "emergency", "reminders", + "tools", "recovery", "recovery-guide", "checkin", @@ -409,6 +414,7 @@ export default function App() { {location.kind === "route" && location.route === "practice" && } {location.kind === "route" && location.route === "emergency" && } {location.kind === "route" && location.route === "reminders" && } + {location.kind === "route" && location.route === "tools" && } {location.kind === "route" && location.route === "recovery" && } {location.kind === "route" && location.route === "recovery-guide" && } {location.kind === "route" && location.route === "checkin" && } diff --git a/ghostkey-web/src/Dashboard.tsx b/ghostkey-web/src/Dashboard.tsx index bccac1d..8092080 100644 --- a/ghostkey-web/src/Dashboard.tsx +++ b/ghostkey-web/src/Dashboard.tsx @@ -63,6 +63,7 @@ import { import { unsealOwner } from "./crypto/sealing"; import { usePrice, btcAndUsd, satsToUsd, formatUsd } from "./fiat"; import { lastResortCheckinOpen } from "./checkin"; +import { useToolDoneState } from "./toolStatus"; import type { Route } from "./App"; interface Props { @@ -120,6 +121,9 @@ export function Dashboard({ onNavigate }: Props) { // VAPID public key from /health, or null when the server has no // push keypair configured. Gates the reminder opt-in card. const [pushKey, setPushKey] = useState(null); + // Set-once tools that are already done drop off the More list below; + // the nav's Tools page stays their permanent home. + const toolsDone = useToolDoneState(activeId, ownerToken); const now = useTicker(1000); @@ -482,11 +486,20 @@ export function Dashboard({ onNavigate }: Props) { {/* Set-once tools live on their own pages now, reached from this compact list, so the dashboard stays status + money + - heir. Recovery kit already works the same way (in the nav). */} + heir. Once a tool is done (video saved, practice sent, + reminders on) its link leaves this list too; the nav's + Tools page is the permanent home for all of them. */} diff --git a/ghostkey-web/src/NavBar.tsx b/ghostkey-web/src/NavBar.tsx index 950c454..ed688ec 100644 --- a/ghostkey-web/src/NavBar.tsx +++ b/ghostkey-web/src/NavBar.tsx @@ -32,6 +32,7 @@ const SIGNED_OUT_ITEMS: NavItem[] = [ const SIGNED_IN_ITEMS: NavItem[] = [ { key: "dashboard", label: "Dashboard" }, + { key: "tools", label: "Tools" }, { key: "recovery", label: "Recovery kit" }, { key: "inherit", label: "Inherit" }, ]; diff --git a/ghostkey-web/src/PracticeClaimCard.tsx b/ghostkey-web/src/PracticeClaimCard.tsx index b235be5..9f78bc4 100644 --- a/ghostkey-web/src/PracticeClaimCard.tsx +++ b/ghostkey-web/src/PracticeClaimCard.tsx @@ -21,6 +21,46 @@ export interface DrillProgress { drill_completed_at?: string | null; } +/** How this heir is reached, from the server's heir profile. The copy + * must match the real channel: a WhatsApp heir never gets an email, + * so the card must not promise one. Unknown falls back to "message". */ +export type HeirChannel = "email" | "sms" | "whatsapp" | null | undefined; + +function practiceNoun(channel: HeirChannel): string { + switch (channel) { + case "email": + return "practice email"; + case "sms": + return "practice text message"; + case "whatsapp": + return "practice WhatsApp message"; + default: + return "practice message"; + } +} + +function sendWords(channel: HeirChannel, who: string): { + alert: string; + button: string; +} { + switch (channel) { + case "email": + return { alert: `This emails ${who} right now.`, button: `Email ${who} now` }; + case "sms": + return { alert: `This texts ${who} right now.`, button: `Text ${who} now` }; + case "whatsapp": + return { + alert: `This sends ${who} a WhatsApp message right now.`, + button: `Message ${who} on WhatsApp`, + }; + default: + return { + alert: `This sends ${who} a message right now.`, + button: `Send it to ${who} now`, + }; + } +} + function fmtDay(rfc: string): string | null { const d = new Date(rfc); if (Number.isNaN(d.getTime())) return null; @@ -33,7 +73,11 @@ function fmtDay(rfc: string): string | null { /** One line describing where the rehearsal stands, for the card and * its tests. */ -export function drillStatusLine(progress: DrillProgress, who: string): string { +export function drillStatusLine( + progress: DrillProgress, + who: string, + channel?: HeirChannel, +): string { if (progress.drill_completed_at) { const when = fmtDay(progress.drill_completed_at); return when @@ -52,7 +96,7 @@ export function drillStatusLine(progress: DrillProgress, who: string): string { ? `Practice sent ${when}. ${who} hasn't opened it yet.` : `Practice sent. ${who} hasn't opened it yet.`; } - return `See the claim work while you're here to help. ${who} gets a clearly-marked practice email and walks the real steps. Nothing can move.`; + return `See the claim work while you're here to help. ${who} gets a clearly-marked ${practiceNoun(channel)} and walks the real steps. Nothing can move.`; } type Stage = "idle" | "confirming" | "sending" | "sent"; @@ -61,11 +105,13 @@ export function PracticeClaimCard({ vaultId, ownerToken, heirName, + heirChannel, progress, }: { vaultId: string; ownerToken: string | null; heirName?: string; + heirChannel?: HeirChannel; progress: DrillProgress; }) { const [stage, setStage] = useState("idle"); @@ -78,8 +124,9 @@ export function PracticeClaimCard({ // A just-sent drill beats whatever the vault fetch knew. const line = stage === "sent" && result - ? drillStatusLine({ drill_started_at: result.started_at }, who) - : drillStatusLine(progress, who); + ? drillStatusLine({ drill_started_at: result.started_at }, who, heirChannel) + : drillStatusLine(progress, who, heirChannel); + const send = sendWords(heirChannel, who); const completed = Boolean(progress.drill_completed_at) && stage !== "sent"; const startedBefore = Boolean(progress.drill_started_at) || stage === "sent"; @@ -129,8 +176,8 @@ export function PracticeClaimCard({

- This emails {who} right now. The message says clearly that you - are fine and that this is practice. + {send.alert} The message says clearly that you are fine and + that this is practice.

{error ? ( @@ -142,7 +189,7 @@ export function PracticeClaimCard({ onClick={() => void onSend()} disabled={stage === "sending"} > - {stage === "sending" ? "Sending…" : `Email ${who} now`} + {stage === "sending" ? "Sending…" : send.button} + ))} + + ) : ( + + )} + + ); +} + export function RemindersPage({ onNavigate }: PageProps) { const { meta, ownerToken, vault, pushKey, loading } = useActiveVault(); const ready = Boolean(meta && ownerToken && pushKey && vault?.status !== "claimed"); diff --git a/ghostkey-web/src/practiceCard.test.ts b/ghostkey-web/src/practiceCard.test.ts index b5048c1..4f830da 100644 --- a/ghostkey-web/src/practiceCard.test.ts +++ b/ghostkey-web/src/practiceCard.test.ts @@ -11,10 +11,26 @@ import { drillStatusLine } from "./PracticeClaimCard"; describe("drillStatusLine", () => { it("invites a first practice when nothing was sent", () => { const line = drillStatusLine({}, "Fola"); - expect(line).toContain("Fola gets a clearly-marked practice email"); + expect(line).toContain("Fola gets a clearly-marked practice message"); expect(line).toContain("Nothing can move"); }); + it("names the heir's real channel, never promising an email to a WhatsApp heir", () => { + expect(drillStatusLine({}, "Fola", "email")).toContain( + "clearly-marked practice email", + ); + expect(drillStatusLine({}, "Fola", "sms")).toContain( + "clearly-marked practice text message", + ); + expect(drillStatusLine({}, "Fola", "whatsapp")).toContain( + "clearly-marked practice WhatsApp message", + ); + // Unknown channel stays honest and generic. + expect(drillStatusLine({}, "Fola", null)).toContain( + "clearly-marked practice message", + ); + }); + it("reports sent-but-unopened", () => { const line = drillStatusLine( { drill_started_at: "2026-07-02T10:00:00Z" }, diff --git a/ghostkey-web/src/toolStatus.ts b/ghostkey-web/src/toolStatus.ts new file mode 100644 index 0000000..5bb8f42 --- /dev/null +++ b/ghostkey-web/src/toolStatus.ts @@ -0,0 +1,64 @@ +/** + * Shared "is this set-once tool already done?" signals. + * + * The dashboard uses them to hide finished tools from its More list + * (a recorded video or an enabled reminder doesn't need a dashboard + * slot forever); the Tools page uses the same signals to show where + * each tool stands. `null` means unknown (fetch failed, no session, + * unsupported browser) — callers treat unknown as not-done so the + * tool stays reachable rather than vanishing on a hiccup. + */ +import { useEffect, useState } from "react"; + +import { api } from "./api"; +import { getPushSubscription } from "./push"; + +export interface ToolDoneState { + /** A heir video message is saved on the server. */ + hasVideo: boolean | null; + /** This device holds a live push subscription. */ + remindersOn: boolean | null; +} + +export function useToolDoneState( + vaultId: string | null, + ownerToken: string | null, +): ToolDoneState { + const [hasVideo, setHasVideo] = useState(null); + const [remindersOn, setRemindersOn] = useState(null); + + useEffect(() => { + if (!vaultId || !ownerToken) { + setHasVideo(null); + return; + } + let alive = true; + api + .getVideoStatus(vaultId, ownerToken) + .then((v) => { + if (alive) setHasVideo(v.has_video); + }) + .catch(() => { + if (alive) setHasVideo(null); + }); + return () => { + alive = false; + }; + }, [vaultId, ownerToken]); + + useEffect(() => { + let alive = true; + getPushSubscription() + .then((s) => { + if (alive) setRemindersOn(Boolean(s)); + }) + .catch(() => { + if (alive) setRemindersOn(null); + }); + return () => { + alive = false; + }; + }, []); + + return { hasVideo, remindersOn }; +}