From 7479ae675eebef5f7cf6f7336347e3e30642b97a Mon Sep 17 00:00:00 2001 From: Ben Marshall Date: Fri, 26 Jun 2026 16:05:05 -0500 Subject: [PATCH] fix(onboarding): intake sharing saves on one tap + auto-saves on edit (43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reported bug: on a sensitive section, "share with partner all" popped a confirm that REPLACED the picker, so clicking Save without first clicking "Share it" silently lost the choice; and sharing only ever persisted via the Save button. - One tap, no confirm: applyScope/applyBulk apply directly (a sensitive answer still STARTS Private, so sharing is still an explicit choice). Removed pendingShare/renderConfirm. - Auto-save on edit: a new silent autoSaveForm + a debounced effect persists answer + sharing changes immediately on a COMPLETED section; the button becomes "Done". First-time sections keep the explicit Continue (auto-save never completes a section being filled the first time). 855 desktop unit (3 reworked RTL: one-tap+auto-save per-question, bulk share-all [the bug], first-time-doesn't-auto-save) + E2E (re-open complete basics → widen to +Sibling → persists with no Save click). Synced spec 43. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 26 +++ apps/desktop/e2e/launch.spec.ts | 22 ++- .../onboarding/IntakeFormPanel.test.tsx | 118 ++++++++------ .../app/routes/onboarding/IntakeFormPanel.tsx | 151 ++++++------------ .../src/renderer/src/stores/intakeStore.ts | 20 +++ ...-relationship-scoped-onboarding-sharing.md | 13 +- 6 files changed, 191 insertions(+), 159 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 41720f7..8c33fc2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -389,6 +389,32 @@ placing anything. Specifically: A running log of durable decisions and feedback captured into the project config. Newest first. +- 2026-06-26 — **Fix (onboarding sharing didn't save — one-tap + auto-save; SPEC 43 amended; on + `fix/onboarding-share-autosave`, PR pending).** User: "in intimacy & sexuality I click _share with partner all_ + and Save, and it doesn't save — and let's save right away when clicked, same with all answers." **Diagnosed + (not assumed):** traced the code — intake sharing was written ONLY by the "Save changes" button + (`submitForm`→`submitSectionForm`). On a sensitive section the bulk "share all" didn't apply on the pick: it + popped an inline **confirm** (§8) that REPLACED the picker, so a person who clicked Save without first clicking + "Share it" silently lost the choice (Save persisted the old Private scopes). The single-scope `intake:setAnswerSharing` + channel existed but the form never called it. **Owner decisions (asked first, both forks):** **one tap, no confirm** + - **auto-save sharing AND answers**. Fix: **(1)** removed the §3.1/§8 sensitive-share confirm — `applyScope`/`applyBulk` + apply directly on one tap (safety preserved by the **default**: a sensitive answer still STARTS Private, so sharing + is still an explicit choice; the confirm machinery — `pendingShare`/`renderConfirm`/`SECTION_BULK` — is gone). **(2)** + new silent `intakeStore.autoSaveForm` (re-runs `intake:submitForm` WITHOUT the `busy` toggle, so controls don't + flicker) + a debounced (600ms) auto-save effect in `IntakeFormPanel` that fires on any answer or scope change **for a + COMPLETED section** (i.e. being edited); the primary button becomes **"Done"** (a flush + advance). A **first-time + section is unchanged** — it still uses the explicit **Continue** (which marks it complete; auto-save never completes + a section being filled the first time, so no premature portrait). Gate green: typecheck (all), lint, **855 desktop** + unit (3 reworked IntakeFormPanel RTL: per-question one-tap+auto-save, bulk share-all auto-save [the reported bug], + first-time-does-NOT-auto-save; removed the two confirm tests), **E2E** (the existing per-question-sharing decrypt + test extended: re-open the now-complete basics section → widen to +Sibling → it persists to the vault with NO Save + click). Visual QA (real Electron screenshot): the section reads clean — honest explainer, one-tap sharing chips, no + confirm clutter. Synced spec 43 (amendment). **Lesson: a confirm that REPLACES the control it guards is a silent + data-loss trap — a user reads the picker as "done" and the pending choice evaporates on the next click; and + per-question intake sharing must be writable on its own (`setAnswerSharing`) OR auto-saved, never gated solely behind + a section-level Save the user may not reach. ALSO (repeated my own footgun): `git checkout ` reverts UNCOMMITTED + work — and I started this fix on `main` without branching; commit/branch BEFORE injecting throwaway screenshots.** + - 2026-06-26 — **Build (owner AI intimacy-topic suggester — SPEC 08 §16.5a AI-assist; on `feat/ai-intimacy-topics`, PR pending).** The last item of the 2026-06-26 batch. The owner-only Intimacy Topics settings control gains a **"Suggest with AI"** affordance. **All 3 product/UX forks asked first (user-chosen):** lives **in the existing diff --git a/apps/desktop/e2e/launch.spec.ts b/apps/desktop/e2e/launch.spec.ts index e6d9dff..9759a04 100644 --- a/apps/desktop/e2e/launch.spec.ts +++ b/apps/desktop/e2e/launch.spec.ts @@ -6592,7 +6592,7 @@ test('onboarding: nudge → turn fills a field → skip intimacy → portrait fe } }); -test('onboarding per-question sharing: scope a section to Partner → the derived fact reaches the partner, not the sibling (43)', async () => { +test('onboarding per-question sharing: scope a section to Partner → fact reaches partner not sibling; editing auto-saves (43 + auto-save fix)', async () => { const { userData, vault } = await seedReadyVault({ 'ai.enabled': true }); await createNodeSecretStore(userData, passthrough).set('anthropic.apiKey', 'sk-ant-e2e'); // Seed a partner B + a sibling C of the owner, and an in-progress intake with only `basics` to do. @@ -6734,6 +6734,26 @@ test('onboarding per-question sharing: scope a section to Partner → the derive expect(siblingCtx).not.toContain('Works as a nurse'); expect(siblingCtx).not.toContain('shared by people related to'); expect(siblingCtx).not.toContain('grief'); + + // Auto-save on edit (the reported bug fix): re-open the now-COMPLETE basics section and widen its sharing + // to also include Sibling. With NO "Save" click, the change must persist — a completed section auto-saves. + await w + .getByRole('button', { name: /The basics/i }) + .first() + .click(); + await expect(w.getByRole('button', { name: 'Done' })).toBeVisible(); // complete → auto-saving, "Done" not "Save" + await w.getByRole('button', { name: /this whole section/i }).click(); + await w.getByRole('checkbox', { name: 'Sibling' }).click(); // Partner is already on; add Sibling + await w.keyboard.press('Escape'); + // No Save/Done click — the auto-save persists the widened scope to the vault within the debounce window. + await expect + .poll( + async () => + (await getIntakeSession(fs, key, 'owner-1'))?.sections.find((s) => s.id === 'basics') + ?.answerSharing?.occupation ?? [], + { timeout: 4000 }, + ) + .toContain('sibling'); } finally { await app.close(); await rm(userData, { recursive: true, force: true }); diff --git a/apps/desktop/src/renderer/src/app/routes/onboarding/IntakeFormPanel.test.tsx b/apps/desktop/src/renderer/src/app/routes/onboarding/IntakeFormPanel.test.tsx index 644703a..2eba3ad 100644 --- a/apps/desktop/src/renderer/src/app/routes/onboarding/IntakeFormPanel.test.tsx +++ b/apps/desktop/src/renderer/src/app/routes/onboarding/IntakeFormPanel.test.tsx @@ -65,35 +65,6 @@ const intimacyMeta: IntakeSectionMeta = { ], }; -// A NON-restricted section (health) that nonetheless holds a restricted question (substancesUsed) — the -// catalog decides `restricted`, so the synthetic question ids must be real health ids (43 §8 bulk-confirm). -const healthMeta: IntakeSectionMeta = { - id: 'health', - title: 'Health & body', - blurb: 'Body & wellbeing.', - restricted: false, - adult: false, - tier: 'invited', - mode: 'form', - opener: 'A few about your body.', - questions: [ - { - id: 'sleepSchedule', - type: 'singleChoice', - prompt: 'Sleep schedule', - required: false, - options: ['Early', 'Late'], - }, - { - id: 'substancesUsed', - type: 'multiChoice', - prompt: 'Substances you use', - required: false, - options: ['Cannabis / weed', 'None'], - }, - ], -}; - // An intimacy section carrying the anatomy questions + the 5-point activity matrix (neutral default rows, as // the bridge sends them). The renderer re-resolves the matrix's oral rows live from the anatomy answers (46). const intimacyMatrixMeta: IntakeSectionMeta = { @@ -437,7 +408,7 @@ describe('IntakeFormPanel — per-question sharing (43)', () => { expect(screen.getByText('Mixed')).toBeInTheDocument(); }); - it('confirms before sharing a sensitive (restricted) answer, then carries the scope into the submit', async () => { + it('shares a sensitive answer on ONE tap (no confirm) and auto-saves a completed section', async () => { const intakeSubmitForm = vi.fn(() => Promise.resolve({ session: {} as never, @@ -450,46 +421,87 @@ describe('IntakeFormPanel — per-question sharing (43)', () => { render( {}} />, ); - // The libido chip starts Private (sensitive). Open it and pick Partner → a confirm appears, not applied yet. + // The libido chip starts Private (sensitive). Pick Partner → it applies on ONE tap, no confirm. fireEvent.click(screen.getByRole('button', { name: /Sex drive: private/i })); fireEvent.click(screen.getByRole('checkbox', { name: 'Partner' })); - expect(await screen.findByText(/is sensitive — share it/i)).toBeInTheDocument(); - // The confirm renders INLINE in the question's sharing slot (44 audit): it REPLACES the picker — the - // checkbox is gone — so it's co-located with the click, not a disconnected top banner. - expect(screen.queryByRole('checkbox', { name: 'Partner' })).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Share it' })); + expect(screen.queryByText(/is sensitive — share it/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Share it' })).not.toBeInTheDocument(); + // No Save click — the completed section auto-saves the new scope (debounced). + await waitFor( + () => + expect(intakeSubmitForm).toHaveBeenCalledWith( + expect.objectContaining({ + sectionId: 'intimacy', + sharing: expect.objectContaining({ libido: ['partner'] }), + }), + ), + { timeout: 2000 }, + ); + }); - fireEvent.click(screen.getByRole('button', { name: /Save changes|Continue/ })); - await waitFor(() => - expect(intakeSubmitForm).toHaveBeenCalledWith( - expect.objectContaining({ - sectionId: 'intimacy', - sharing: expect.objectContaining({ libido: ['partner'] }), - }), - ), + it('bulk "share all → partner" on a completed section auto-saves every scope, no confirm (the reported bug)', async () => { + const intakeSubmitForm = vi.fn(() => + Promise.resolve({ + session: {} as never, + sections: [], + aiAvailable: true, + adultAcknowledged: true, + }), + ); + installMockBridge({ intakeSubmitForm, relationshipsList: () => Promise.resolve([]) }); + render( + {}} + />, + ); + // Open the per-section bulk control and add Partner → applies to EVERY question on one tap, no confirm. + fireEvent.click(screen.getByRole('button', { name: /this whole section/i })); + fireEvent.click(screen.getByRole('checkbox', { name: 'Partner' })); + expect(screen.queryByText(/includes sensitive answers/i)).not.toBeInTheDocument(); + // No Save click — it auto-saves the bulk scope across the section's questions. + await waitFor( + () => + expect(intakeSubmitForm).toHaveBeenCalledWith( + expect.objectContaining({ + sectionId: 'intimacy', + sharing: expect.objectContaining({ libido: ['partner'] }), + }), + ), + { timeout: 2000 }, ); }); - it('confirms a bulk share when a non-restricted section holds a sensitive question (health/substances)', async () => { - installMockBridge({ relationshipsList: () => Promise.resolve([]) }); + it('does NOT auto-save a first-time (incomplete) section — that still rides the explicit Continue', async () => { + const intakeSubmitForm = vi.fn(() => + Promise.resolve({ + session: {} as never, + sections: [], + aiAvailable: true, + adultAcknowledged: true, + }), + ); + installMockBridge({ intakeSubmitForm, relationshipsList: () => Promise.resolve([]) }); render( {}} />, ); - // Open the per-section bulk control and add Partner → because `substancesUsed` is restricted, the §8 - // confirm must appear before anything is shared (the bulk control must not bypass it). fireEvent.click(screen.getByRole('button', { name: /this whole section/i })); fireEvent.click(screen.getByRole('checkbox', { name: 'Partner' })); - expect(await screen.findByText(/includes sensitive answers/i)).toBeInTheDocument(); + // Give the debounce window time to pass — a first-time section must NOT auto-submit. + await new Promise((r) => setTimeout(r, 800)); + expect(intakeSubmitForm).not.toHaveBeenCalled(); }); it('offers the one-tap "refresh your portrait" once an edit makes the portrait stale', () => { diff --git a/apps/desktop/src/renderer/src/app/routes/onboarding/IntakeFormPanel.tsx b/apps/desktop/src/renderer/src/app/routes/onboarding/IntakeFormPanel.tsx index 7dfaad8..b8c1361 100644 --- a/apps/desktop/src/renderer/src/app/routes/onboarding/IntakeFormPanel.tsx +++ b/apps/desktop/src/renderer/src/app/routes/onboarding/IntakeFormPanel.tsx @@ -1,12 +1,8 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { QuestionnaireForm, type QuestionSharing } from '@selfos/answering'; -import { - defaultScopeForQuestion, - questionDefaultsPrivate, - stripIntakeFieldMarkers, -} from '@selfos/core/intake'; +import { defaultScopeForQuestion, stripIntakeFieldMarkers } from '@selfos/core/intake'; import { migrateActivityMatrixValue, resolvedActivityMatrix } from '@selfos/core/intimacy'; -import { SHARING_INLINE_EXPLAINER, describeScope } from '@selfos/core/sharing'; +import { SHARING_INLINE_EXPLAINER } from '@selfos/core/sharing'; import type { AnswerMap, AnswerValue } from '@selfos/core/questionnaires'; import type { Question } from '@selfos/core/schemas'; import type { @@ -37,9 +33,6 @@ function sameScope(a: readonly RelationshipType[], b: readonly RelationshipType[ return a.length === b.length && a.every((t) => b.includes(t)); } -/** The `pendingShare.qid` sentinel for the per-section bulk control (vs a real question id). */ -const SECTION_BULK = '__section__'; - /** * A structured **form** intake section (18-personal-onboarding §14.3/§14.6) — renders the section's questions * through the shared `@selfos/answering` `QuestionnaireForm` (branch-aware, the host owns the answer state), @@ -66,6 +59,7 @@ export function IntakeFormPanel({ }): JSX.Element { const busy = useIntakeStore((s) => s.busy); const submitForm = useIntakeStore((s) => s.submitForm); + const autoSaveForm = useIntakeStore((s) => s.autoSaveForm); const skipSection = useIntakeStore((s) => s.skipSection); const acknowledgeAdult = useIntakeStore((s) => s.acknowledgeAdult); const runTurn = useIntakeStore((s) => s.runTurn); @@ -134,57 +128,15 @@ export function IntakeFormPanel({ ); const hasRelationships = availableTypes !== undefined; - // A sensitive opt-in (restricted question/section → non-empty scope) asks for an explicit confirm first - // (43 §3.1/§8) — `Private` is always one tap away, sharing a sensitive answer is a deliberate gesture. The - // confirm renders INLINE in that question's (or the bulk control's) sharing slot, co-located with the click - // (`qid` = the question, or SECTION_BULK), never a disconnected top banner that read as "nothing happened". - const [pendingShare, setPendingShare] = useState<{ - qid: string; - label: string; - apply: () => void; - } | null>(null); - const promptOf = (qid: string): string => (meta.questions ?? []).find((q) => q.id === qid)?.prompt ?? qid; + // One tap applies a scope directly — no confirm (owner decision, 2026-06-26). A sensitive answer still + // STARTS Private (its category default), so sharing it stays a deliberate choice; it just takes effect (and + // auto-saves) on a single tap instead of a second confirm. const applyScope = (qid: string, types: RelationshipType[]): void => setScopes((s) => ({ ...s, [qid]: types })); - const setScope = (qid: string, types: RelationshipType[]): void => { - const current = scopes[qid] ?? []; - if (questionDefaultsPrivate(meta.id, qid) && current.length === 0 && types.length > 0) { - setPendingShare({ - qid, - label: `“${promptOf(qid)}” is sensitive — share it with ${describeScope(types)}’s coaching?`, - apply: () => applyScope(qid, types), - }); - return; - } - applyScope(qid, types); - }; - - /** The inline sensitive-share confirm (43 §8) rendered in place of a picker when its scope is pending. */ - const renderConfirm = (pending: { label: string; apply: () => void }): JSX.Element => ( -
- {pending.label} -
- - -
-
- ); - // The section bulk scope — a common value when every question agrees, else "mixed" (43 §3.2). const questionIds = (meta.questions ?? []).map((q) => q.id); const bulkScope: RelationshipType[] | null = (() => { @@ -193,42 +145,22 @@ export function IntakeFormPanel({ const first = scopes[firstId] ?? []; return questionIds.every((qid) => sameScope(scopes[qid] ?? [], first)) ? first : null; })(); - const applyBulk = (types: RelationshipType[]): void => { - const doApply = (): void => - setScopes((s) => { - const next = { ...s }; - for (const qid of questionIds) next[qid] = [...types]; - return next; - }); - // Sharing (non-empty) via the bulk control still needs the §8 confirm when the section is sensitive OR - // holds any sensitive (default-private) question — e.g. the substance answers inside the non-restricted - // Health section — so bulk-share can't opt a restricted answer in without the explicit gesture. Locking a - // section to Private (the safe direction) never confirms. - const touchesSensitive = - meta.restricted || questionIds.some((qid) => questionDefaultsPrivate(meta.id, qid)); - if (touchesSensitive && types.length > 0) { - setPendingShare({ - qid: SECTION_BULK, - label: `This section includes sensitive answers — share all of it with ${describeScope(types)}’s coaching?`, - apply: doApply, - }); - return; - } - doApply(); - }; + const applyBulk = (types: RelationshipType[]): void => + setScopes((s) => { + const next = { ...s }; + for (const qid of questionIds) next[qid] = [...types]; + return next; + }); const sharing: QuestionSharing = { - renderControl: (questionId) => - pendingShare?.qid === questionId ? ( - renderConfirm(pendingShare) - ) : ( - setScope(questionId, types)} - label={promptOf(questionId)} - {...(availableTypes ? { availableTypes } : {})} - /> - ), + renderControl: (questionId) => ( + applyScope(questionId, types)} + label={promptOf(questionId)} + {...(availableTypes ? { availableTypes } : {})} + /> + ), }; // The intimacy activity matrix's oral rows are tailored per-person from the DIRECT anatomy answers (46 §5): @@ -262,6 +194,22 @@ export function IntakeFormPanel({ const locked = meta.adult && !adultAcknowledged; const complete = section?.status === 'complete'; + // Auto-save (2026-06-26): on a COMPLETED section being edited, persist answer + sharing changes the moment + // they happen (debounced), so a "share with partner" pick or an answer edit saves right away — no separate + // Save click. A first-time section keeps the explicit Continue (which is what marks it complete); auto-save + // never completes a section the person is still filling out. `firstRun` skips the initial seed render. + const firstRun = useRef(true); + useEffect(() => { + if (firstRun.current) { + firstRun.current = false; + return; + } + if (!complete || locked) return; + const t = setTimeout(() => void autoSaveForm(meta.id, toSubmit(), scopes), 600); + return () => clearTimeout(t); + // Re-run on any answer or sharing change; `complete`/`locked` gate it, the rest are stable. + }, [answers, scopes, complete, locked]); + // A matrix answer is a row→point record (Record) — keep it; every other intake answer is a // scalar/array. Any OTHER non-array object isn't a valid intake answer, so drop it defensively to match the // bridge contract (IntakeAnswerValueSchema). @@ -328,23 +276,17 @@ export function IntakeFormPanel({ - {/* 43 §3.2 — the per-section bulk sharing control. The sensitive-share confirm renders inline here too. */} + {/* 43 §3.2 — the per-section bulk sharing control. One tap applies + auto-saves (no confirm). */} {questionIds.length > 0 ? (
Sharing for this section - {pendingShare?.qid === SECTION_BULK ? ( - renderConfirm(pendingShare) - ) : ( - <> - {bulkScope === null ? Mixed : null} - - - )} + {bulkScope === null ? Mixed : null} +
) : null} @@ -438,7 +380,8 @@ export function IntakeFormPanel({ disabled={busy} onClick={() => void submitForm(meta.id, toSubmit(), scopes).then(onAdvance)} > - {complete ? 'Save changes' : 'Continue'} + {/* A complete section auto-saves as you edit, so this just flushes + moves on. */} + {complete ? 'Done' : 'Continue'}