Skip to content

Commit 05de631

Browse files
committed
fix(person-db-resolver): editable person name, observation predicate optional — People-CSV flow basic-functioning
The first live click-through of person-db-resolver against real FreedomFest 2026 records surfaced two dead ends. The person-name input looked editable but silently reset itself on every keystroke. The "add observation" button stayed disabled unless a predicate field — never explained in the UI — was also filled in. The name-field bug was a Svelte 5 reactivity feedback loop: the row-change effect that resets the name field also kicked off a candidate search that transitively read the very `$derived` value built from that field. Svelte's dependency tracking attributed that transitive read to the effect, so every keystroke re-triggered the row-change effect, which reset what was just typed. Fixed by splitting the shared load function into a row-change variant (reads only the plain `record`) and an input-onchange variant (safe to read the derived `personRecord`). Same split applied to the org-name input, which carried the identical latent bug. Observations now only require a value — `predicate` is optional and defaults to `note`, so the form isn't dead when an operator just wants to leave a quick note. Files changed: - apps/person-db-resolver/src/App.svelte Also included: - changelog/2026-07-07_04_People-CSV-Flow-Goes-From-Wired-Up-To-Actually-Usable.md
1 parent 24c3bd8 commit 05de631

2 files changed

Lines changed: 153 additions & 19 deletions

File tree

apps/person-db-resolver/src/App.svelte

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
} from './lib/resolver-client';
2525
import type {
2626
FieldMapping,
27+
PersonNormRecord,
2728
PersonCandidate,
2829
PersonApplyResult,
2930
OrgCandidate,
@@ -54,6 +55,7 @@
5455
let personError = $state<string | null>(null);
5556
let personResult = $state<PersonApplyResult | null>(null);
5657
let personBusy = $state(false);
58+
let personNameInput = $state('');
5759
let personSearchQuery = $state('');
5860
let personSearchResults = $state<PersonCandidate[]>([]);
5961
let personSearching = $state(false);
@@ -84,6 +86,13 @@
8486
current && mapping ? normalizePersonRecord(current.fields as Record<string, unknown>, mapping) : null,
8587
);
8688
const source = $derived(selectedSet ? `record-set:${selectedSet.name}` : 'person-db-resolver');
89+
// The person actions (candidates/create/match) use the OPERATOR-EDITED
90+
// name, not the raw mapped column — record.name stays visible in
91+
// RecordCard as "here's what the CSV said," personNameInput is what
92+
// actually gets written. Falls back to the mapped name if cleared.
93+
const personRecord = $derived(
94+
record ? { ...record, name: personNameInput.trim() || record.name } : null,
95+
);
8796
8897
function onActiveRecordSetChange(e: Event) {
8998
const detail = (e as CustomEvent).detail as { record_set_id?: string } | undefined;
@@ -199,23 +208,33 @@
199208
obsSaved = false;
200209
}
201210
202-
// Load person candidates whenever the current record changes.
211+
// Load person candidates whenever the current record changes. Also resets
212+
// the editable name input to the mapped column's value for the new row.
213+
//
214+
// IMPORTANT: this effect must only read `record`/`current` — NOT
215+
// `personRecord`/`personNameInput`, even transitively. An earlier version
216+
// called loadPersonCandidates() here, which synchronously read the
217+
// personRecord derived (itself reading personNameInput) before its first
218+
// await — that read got tracked as a dependency of THIS effect, so every
219+
// keystroke in the name field re-triggered the row-change effect, which
220+
// immediately reset the field back to the mapped value. Un-editable input.
203221
$effect(() => {
204222
const rid = current?.row_id;
205223
const rec = record;
206224
if (!rid || !rec || !rec.name) {
207225
personCandidates = [];
208226
return;
209227
}
210-
void loadPersonCandidates();
228+
personNameInput = rec.name;
229+
void loadPersonCandidatesFor(rec);
211230
});
212231
213-
async function loadPersonCandidates() {
214-
if (!record || !record.name) return;
232+
async function loadPersonCandidatesFor(rec: PersonNormRecord) {
233+
if (!rec.name) return;
215234
loadingPerson = true;
216235
personError = null;
217236
try {
218-
personCandidates = await fetchPersonCandidates(record, client);
237+
personCandidates = await fetchPersonCandidates(rec, client);
219238
} catch (err) {
220239
personError = err instanceof Error ? err.message : String(err);
221240
personCandidates = [];
@@ -224,29 +243,37 @@
224243
}
225244
}
226245
246+
// Called from the name input's onchange (a DOM event handler, not a
247+
// reactive effect) — safe to read personRecord here.
248+
async function loadPersonCandidates() {
249+
if (!personRecord) return;
250+
await loadPersonCandidatesFor(personRecord);
251+
}
252+
227253
// Independent of person state — per the "independent decisions" design
228254
// (person and org are mutually independent OR interdependent, operator's
229255
// choice), the org section is always live, not gated behind personResult.
256+
// Same "don't transitively read the input you just wrote" rule as above.
230257
$effect(() => {
231258
const rec = record;
232259
if (!rec) {
233260
orgCandidates = [];
234261
return;
235262
}
236263
orgNameInput = rec.org_name ?? '';
237-
void loadOrgCandidates();
264+
void loadOrgCandidatesFor(rec.org_name ?? '');
238265
});
239266
240-
async function loadOrgCandidates() {
241-
const name = orgNameInput.trim();
242-
if (!name) {
267+
async function loadOrgCandidatesFor(name: string) {
268+
const trimmed = name.trim();
269+
if (!trimmed) {
243270
orgCandidates = [];
244271
return;
245272
}
246273
loadingOrg = true;
247274
orgError = null;
248275
try {
249-
orgCandidates = await fetchOrgCandidates(name, client);
276+
orgCandidates = await fetchOrgCandidates(trimmed, client);
250277
} catch (err) {
251278
orgError = err instanceof Error ? err.message : String(err);
252279
orgCandidates = [];
@@ -255,12 +282,17 @@
255282
}
256283
}
257284
285+
// Called from the org-name input's onchange — safe to read orgNameInput here.
286+
async function loadOrgCandidates() {
287+
await loadOrgCandidatesFor(orgNameInput);
288+
}
289+
258290
async function doMatchPerson(c: PersonCandidate) {
259-
if (!record) return;
291+
if (!personRecord) return;
260292
personBusy = true;
261293
personError = null;
262294
try {
263-
personResult = await applyPerson({ action: 'match', person_uuid: c.person_uuid, record, client, source });
295+
personResult = await applyPerson({ action: 'match', person_uuid: c.person_uuid, record: personRecord, client, source });
264296
} catch (err) {
265297
personError = err instanceof Error ? err.message : String(err);
266298
} finally {
@@ -269,11 +301,11 @@
269301
}
270302
271303
async function doCreatePerson() {
272-
if (!record) return;
304+
if (!personRecord || !personRecord.name) return;
273305
personBusy = true;
274306
personError = null;
275307
try {
276-
personResult = await applyPerson({ action: 'create', record, client, source });
308+
personResult = await applyPerson({ action: 'create', record: personRecord, client, source });
277309
} catch (err) {
278310
personError = err instanceof Error ? err.message : String(err);
279311
} finally {
@@ -378,9 +410,11 @@
378410
379411
async function doAddObservation() {
380412
if (!personResult) return;
381-
const predicate = obsPredicate.trim();
413+
// Only the value is required — predicate defaults to a generic 'note'
414+
// so the button isn't dead just because the operator only typed a value.
415+
const predicate = obsPredicate.trim() || 'note';
382416
const value = obsValue.trim();
383-
if (!predicate || !value) return;
417+
if (!value) return;
384418
obsBusy = true;
385419
obsError = null;
386420
obsSaved = false;
@@ -458,9 +492,13 @@
458492
{#if personError}<div class="pdr-error">candidates: {personError}</div>{/if}
459493

460494
{#if !personResult && !personSkipped}
495+
<label class="pdr-org-name-row">
496+
<span>person name</span>
497+
<input type="text" bind:value={personNameInput} onchange={() => void loadPersonCandidates()} placeholder="Person name" />
498+
</label>
461499
<PersonCandidateList candidates={personCandidates} busy={personBusy} onMatch={doMatchPerson} />
462500
<div class="pdr-create">
463-
<button type="button" class="pdr-btn pdr-btn-create" disabled={personBusy || !record.name} onclick={doCreatePerson}>
501+
<button type="button" class="pdr-btn pdr-btn-create" disabled={personBusy || !personRecord?.name} onclick={doCreatePerson}>
464502
+ create new person from this record
465503
</button>
466504
<button type="button" class="pdr-btn" disabled={personBusy} onclick={doSkipPerson}>
@@ -500,9 +538,9 @@
500538
{personResult.created ? '✓ created' : '✓ matched'} <strong>{personResult.name}</strong>
501539
</div>
502540
<div class="pdr-add-obs">
503-
<label><span>predicate</span><input type="text" bind:value={obsPredicate} placeholder="e.g. confirmed_via_email" /></label>
541+
<label><span>predicate (optional)</span><input type="text" bind:value={obsPredicate} placeholder="defaults to 'note'" /></label>
504542
<label><span>value</span><input type="text" bind:value={obsValue} placeholder="e.g. confirmed 2026-07-07" /></label>
505-
<button type="button" class="pdr-btn" disabled={obsBusy || !obsPredicate.trim() || !obsValue.trim()} onclick={doAddObservation}>
543+
<button type="button" class="pdr-btn" disabled={obsBusy || !obsValue.trim()} onclick={doAddObservation}>
506544
+ add observation
507545
</button>
508546
{#if obsSaved}<span class="pdr-stamp-ok">✓ saved</span>{/if}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
---
2+
date_created: 2026-07-07
3+
date_modified: 2026-07-07
4+
title: "The People-CSV Flow Goes From Wired-Up to Actually Usable"
5+
lede: "person-db-resolver shipped a few hours ago wired end to end — but the name field couldn't be typed into, and the observation form refused to save unless you filled in a predicate nobody was prompted for. Both are fixed. Improve a CSV of People is now basic-functioning: pick a record, edit the name, match or create the person, match or create the org, drop a note."
6+
publish: true
7+
authors:
8+
- Michael Staton
9+
augmented_with:
10+
- Claude Code on Claude Sonnet 5
11+
files_changed:
12+
- apps/person-db-resolver/src/App.svelte
13+
tags:
14+
- Progress-Update
15+
- Person-DB-Resolver
16+
- Svelte-5
17+
- Reactivity
18+
- Canonical-Layer
19+
- Reach-Edu
20+
- FreedomFest
21+
---
22+
23+
## Why Care?
24+
25+
`person-db-resolver` [[2026-07-07_03_Person-DB-Resolver-A-Sibling-Remote-For-People-Not-Orgs|shipped earlier tonight]]
26+
with the right shape — person and org as independent match-or-create
27+
decisions — but the first live click-through against real FreedomFest 2026
28+
records surfaced two dead ends: the person-name input looked editable but
29+
reset itself after every keystroke, and the "add observation" button stayed
30+
disabled unless you typed something into a `predicate` field the UI never
31+
explained. Both are fixed now. The **Improve a CSV of People** flow is at
32+
basic-functioning: an operator can run a real record through match/create/
33+
skip for both person and org, and leave a note, without fighting the form.
34+
35+
## What's New?
36+
37+
- **The person-name field is actually editable.** A CSV's mapped name column
38+
isn't always what should get written — a "Lyn" needs to become "Lynette
39+
Ulbricht," a title needs trimming out of a name column. That's now a real
40+
input, not a read-only echo of the mapped value.
41+
- **Observations no longer require a predicate.** The form asked for
42+
`predicate` + `value` with no explanation of what a predicate even was;
43+
half the time the operator just wants to jot a note. The value field alone
44+
now unlocks the button — `predicate` is optional and defaults to `note`.
45+
46+
## The Story
47+
48+
The name-field bug was the interesting one. It didn't fail loudly — the
49+
input rendered, accepted keystrokes, and then silently reverted to the
50+
CSV's original value on every single one, making it look broken rather than
51+
explaining why.
52+
53+
The cause was a feedback loop hiding inside Svelte 5's fine-grained
54+
reactivity. The effect that resets the name field when the operator moves to
55+
a new record also kicked off the person-candidate search — and that search
56+
function read a `$derived` value (`personRecord`) that itself read the very
57+
input the effect was about to reset. Svelte's dependency tracking doesn't
58+
care where in a function body a read happens, only that it happened during
59+
the effect's synchronous execution — so that one transitive read got
60+
attributed to the row-change effect, not to the keystroke that actually
61+
should have triggered it. Every keystroke re-ran the row-change effect,
62+
which reset the field it was just typed into.
63+
64+
```mermaid
65+
flowchart LR
66+
A[operator types a letter] --> B[personNameInput updates]
67+
B --> C[personRecord derived recomputes]
68+
C --> D["row-change $effect (wrongly) re-fires"]
69+
D --> E[personNameInput reset to CSV value]
70+
E -.->|next keystroke, same loop| A
71+
```
72+
73+
The fix: split the one function that read `personRecord` into two. The
74+
row-change effect now calls `loadPersonCandidatesFor(rec)`, which only ever
75+
touches the plain `record` the effect is already watching — no transitive
76+
read of the input. A separate `loadPersonCandidates()` (no arguments) is
77+
wired to the input's own `onchange`, where reading `personRecord` is exactly
78+
what should happen. Same split applied to the org-name input, which had the
79+
identical latent bug waiting to bite the next person who typed into it.
80+
81+
## What's Next
82+
83+
Basic-functioning isn't done-functioning — the flow still doesn't
84+
retroactively `RELATE` an affiliation if the org gets resolved before the
85+
person on the same row (documented as an open question in the prior entry).
86+
The next real step, per
87+
[[../context-v/plans/SurrealDB-MCP-Plus-Skill-for-Canonical-Layer-Verification|the SurrealDB MCP + verification-skill plan]],
88+
is standing up direct query access to the canonical layer so tonight's
89+
FreedomFest batch (and every batch after it) can be checked for coherence
90+
without another disposable Node script.
91+
92+
## Related
93+
94+
- [[2026-07-07_03_Person-DB-Resolver-A-Sibling-Remote-For-People-Not-Orgs]] — the remote this flow lives in
95+
- `context-v/plans/Person-Aware-Canonical-Resolver-Extension.md` — the design this implements
96+
- `context-v/plans/SurrealDB-MCP-Plus-Skill-for-Canonical-Layer-Verification.md` — the next step

0 commit comments

Comments
 (0)