|
19 | 19 | applyPerson, |
20 | 20 | affiliatePerson, |
21 | 21 | addPersonObservation, |
| 22 | + fetchPersonObservations, |
22 | 23 | fetchOrgCandidates, |
23 | 24 | searchOrgs, |
24 | 25 | } from './lib/resolver-client'; |
|
27 | 28 | PersonNormRecord, |
28 | 29 | PersonCandidate, |
29 | 30 | PersonApplyResult, |
| 31 | + PersonObservationRow, |
30 | 32 | OrgCandidate, |
31 | 33 | OrgSuggestion, |
32 | 34 | PersonAffiliateResult, |
|
36 | 38 | const WS_URL = 'ws://localhost:3001/ws'; |
37 | 39 | const ACTIVE_RECORD_SET_KEY = 'augment-it:active-record-set'; |
38 | 40 | const MAPPING_KEY_PREFIX = 'augment-it:person-db-resolver:mapping:'; |
| 41 | + // Per-record-set "where I left off" — restored on every selectRecordSet() |
| 42 | + // (mount, HMR remount, workspace switch, tab reopen), not just typed |
| 43 | + // navigation. Saved on every idx change so it's always current, not just |
| 44 | + // on explicit jumps. |
| 45 | + const IDX_KEY_PREFIX = 'augment-it:person-db-resolver:idx:'; |
39 | 46 |
|
40 | 47 | let status = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting'); |
41 | 48 | let client = $state<string>('reach-edu'); |
|
53 | 60 | let personCandidates = $state<PersonCandidate[]>([]); |
54 | 61 | let loadingPerson = $state(false); |
55 | 62 | let personError = $state<string | null>(null); |
| 63 | + // Which action set personError — candidates/match/create all share the |
| 64 | + // one error slot, but a raw SurrealDB constraint message (e.g. a UNIQUE |
| 65 | + // index collision on create) reads very differently from a failed |
| 66 | + // candidate lookup. Labeled at the throw site instead of hardcoded in |
| 67 | + // the template. |
| 68 | + let personErrorLabel = $state<'candidates' | 'match' | 'create'>('candidates'); |
56 | 69 | let personResult = $state<PersonApplyResult | null>(null); |
57 | 70 | let personBusy = $state(false); |
58 | 71 | let personNameInput = $state(''); |
| 72 | + // Editable mirror of the mapped Observation column — before this, the |
| 73 | + // event-tie text (e.g. "attendee at Aspen Institute: ...") was parsed and |
| 74 | + // written silently with zero operator visibility or per-row override. |
| 75 | + // Same "operator-edited value wins, falls back to the mapped column" |
| 76 | + // pattern as personNameInput. |
| 77 | + let personObservationInput = $state(''); |
59 | 78 | let personSearchQuery = $state(''); |
60 | 79 | let personSearchResults = $state<PersonCandidate[]>([]); |
61 | 80 | let personSearching = $state(false); |
62 | 81 | let personSkipped = $state(false); |
| 82 | + // Read-only history for the matched/created person — observations are |
| 83 | + // append-only, so this is what makes "editing" sane: see what's on file, |
| 84 | + // add a correction on top, don't blindly append with no context. |
| 85 | + let personObservations = $state<PersonObservationRow[]>([]); |
| 86 | + let personObservationsLoading = $state(false); |
63 | 87 |
|
64 | 88 | let orgCandidates = $state<OrgCandidate[]>([]); |
65 | 89 | let loadingOrg = $state(false); |
|
87 | 111 | ); |
88 | 112 | const source = $derived(selectedSet ? `record-set:${selectedSet.name}` : 'person-db-resolver'); |
89 | 113 | // 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. |
| 114 | + // name and observation text, not the raw mapped columns — record stays |
| 115 | + // visible in RecordCard as "here's what the CSV said," these inputs are |
| 116 | + // what actually gets written. Falls back to the mapped values if cleared. |
93 | 117 | const personRecord = $derived( |
94 | | - record ? { ...record, name: personNameInput.trim() || record.name } : null, |
| 118 | + record |
| 119 | + ? { |
| 120 | + ...record, |
| 121 | + name: personNameInput.trim() || record.name, |
| 122 | + observation: personObservationInput.trim() || record.observation, |
| 123 | + } |
| 124 | + : null, |
95 | 125 | ); |
96 | 126 |
|
97 | 127 | function onActiveRecordSetChange(e: Event) { |
|
161 | 191 | try { |
162 | 192 | const r = (await workspace.invoke('row.list', { record_set_id })) as { rows: Row[] }; |
163 | 193 | rows = r.rows.filter((row) => !(row.fields as Record<string, unknown>).archived); |
| 194 | + // Resume where this record set was left off — mount, HMR remount, |
| 195 | + // workspace switch, or tab reopen all land here via the same path. |
| 196 | + const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(`${IDX_KEY_PREFIX}${record_set_id}`) : null; |
| 197 | + if (stored != null) { |
| 198 | + const n = Number(stored); |
| 199 | + if (Number.isFinite(n)) idx = Math.min(Math.max(0, n), Math.max(0, rows.length - 1)); |
| 200 | + } |
164 | 201 | } catch (err) { |
165 | 202 | console.error('row.list', err); |
166 | 203 | } |
167 | 204 | } |
168 | 205 |
|
| 206 | + function saveIdx() { |
| 207 | + if (selectedRecordSetId && typeof localStorage !== 'undefined') { |
| 208 | + localStorage.setItem(`${IDX_KEY_PREFIX}${selectedRecordSetId}`, String(idx)); |
| 209 | + } |
| 210 | + } |
| 211 | +
|
| 212 | + // Manual "jump to row N" — accepts 1-based row numbers (matches the |
| 213 | + // "N / total" display), clamps to the valid range. |
| 214 | + function jumpTo(raw: string | number) { |
| 215 | + const n = typeof raw === 'number' ? raw : Number(raw); |
| 216 | + if (!Number.isFinite(n) || rows.length === 0) return; |
| 217 | + idx = Math.min(Math.max(0, Math.round(n) - 1), rows.length - 1); |
| 218 | + resetRowState(); |
| 219 | + saveIdx(); |
| 220 | + } |
| 221 | +
|
169 | 222 | function loadMapping(record_set_id: string) { |
170 | 223 | const key = `${MAPPING_KEY_PREFIX}${record_set_id}`; |
171 | 224 | const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null; |
|
197 | 250 | personSkipped = false; |
198 | 251 | personSearchQuery = ''; |
199 | 252 | personSearchResults = []; |
| 253 | + personObservations = []; |
200 | 254 | orgCandidates = []; |
201 | 255 | orgError = null; |
202 | 256 | orgResult = null; |
|
226 | 280 | return; |
227 | 281 | } |
228 | 282 | personNameInput = rec.name; |
| 283 | + personObservationInput = rec.observation ?? ''; |
229 | 284 | void loadPersonCandidatesFor(rec); |
230 | 285 | }); |
231 | 286 |
|
|
236 | 291 | try { |
237 | 292 | personCandidates = await fetchPersonCandidates(rec, client); |
238 | 293 | } catch (err) { |
| 294 | + personErrorLabel = 'candidates'; |
239 | 295 | personError = err instanceof Error ? err.message : String(err); |
240 | 296 | personCandidates = []; |
241 | 297 | } finally { |
|
287 | 343 | await loadOrgCandidatesFor(orgNameInput); |
288 | 344 | } |
289 | 345 |
|
| 346 | + async function loadPersonObservations(person_uuid: string) { |
| 347 | + personObservationsLoading = true; |
| 348 | + try { |
| 349 | + personObservations = await fetchPersonObservations(person_uuid, client); |
| 350 | + } catch (err) { |
| 351 | + console.error('person.observations', err); |
| 352 | + personObservations = []; |
| 353 | + } finally { |
| 354 | + personObservationsLoading = false; |
| 355 | + } |
| 356 | + } |
| 357 | +
|
290 | 358 | async function doMatchPerson(c: PersonCandidate) { |
291 | 359 | if (!personRecord) return; |
292 | 360 | personBusy = true; |
293 | 361 | personError = null; |
294 | 362 | try { |
295 | 363 | personResult = await applyPerson({ action: 'match', person_uuid: c.person_uuid, record: personRecord, client, source }); |
| 364 | + void loadPersonObservations(personResult.person_uuid); |
296 | 365 | } catch (err) { |
| 366 | + personErrorLabel = 'match'; |
297 | 367 | personError = err instanceof Error ? err.message : String(err); |
298 | 368 | } finally { |
299 | 369 | personBusy = false; |
|
306 | 376 | personError = null; |
307 | 377 | try { |
308 | 378 | personResult = await applyPerson({ action: 'create', record: personRecord, client, source }); |
| 379 | + void loadPersonObservations(personResult.person_uuid); |
309 | 380 | } catch (err) { |
| 381 | + personErrorLabel = 'create'; |
310 | 382 | personError = err instanceof Error ? err.message : String(err); |
311 | 383 | } finally { |
312 | 384 | personBusy = false; |
|
423 | 495 | obsSaved = true; |
424 | 496 | obsPredicate = ''; |
425 | 497 | obsValue = ''; |
| 498 | + void loadPersonObservations(personResult.person_uuid); |
426 | 499 | } catch (err) { |
427 | 500 | obsError = err instanceof Error ? err.message : String(err); |
428 | 501 | } finally { |
|
433 | 506 | function advance() { |
434 | 507 | idx = Math.min(idx + 1, rows.length); |
435 | 508 | resetRowState(); |
| 509 | + saveIdx(); |
436 | 510 | } |
437 | 511 | function back() { |
438 | 512 | idx = Math.max(0, idx - 1); |
439 | 513 | resetRowState(); |
| 514 | + saveIdx(); |
440 | 515 | } |
441 | 516 | function skipRow() { |
442 | 517 | advance(); |
|
463 | 538 | {/each} |
464 | 539 | </select> |
465 | 540 | {#if rows.length} |
466 | | - <span class="pdr-progress">{Math.min(idx + 1, rows.length)} / {rows.length}</span> |
| 541 | + <span class="pdr-progress"> |
| 542 | + <input |
| 543 | + type="number" |
| 544 | + class="pdr-jump" |
| 545 | + min="1" |
| 546 | + max={rows.length} |
| 547 | + value={Math.min(idx + 1, rows.length)} |
| 548 | + title="jump to row" |
| 549 | + onchange={(e) => jumpTo(e.currentTarget.value)} |
| 550 | + onkeydown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }} |
| 551 | + /> / {rows.length} |
| 552 | + </span> |
467 | 553 | <button type="button" class="pdr-btn" onclick={() => (showMapper = true)}>edit column mapping</button> |
468 | 554 | {/if} |
469 | 555 | </div> |
|
489 | 575 | <span class="pdr-eyebrow">person</span> |
490 | 576 | {#if loadingPerson}<span class="pdr-muted">finding candidates…</span>{/if} |
491 | 577 | </div> |
492 | | - {#if personError}<div class="pdr-error">candidates: {personError}</div>{/if} |
| 578 | + {#if personError}<div class="pdr-error">{personErrorLabel}: {personError}</div>{/if} |
493 | 579 |
|
494 | 580 | {#if !personResult && !personSkipped} |
495 | 581 | <label class="pdr-org-name-row"> |
496 | 582 | <span>person name</span> |
497 | 583 | <input type="text" bind:value={personNameInput} onchange={() => void loadPersonCandidates()} placeholder="Person name" /> |
498 | 584 | </label> |
| 585 | + <label class="pdr-org-name-row"> |
| 586 | + <span>observation (event tie)</span> |
| 587 | + <input |
| 588 | + type="text" |
| 589 | + bind:value={personObservationInput} |
| 590 | + placeholder="e.g. attendee at Event Name" |
| 591 | + title="Written as a parsed event-tie observation on create/match. Edit or clear before resolving this row." |
| 592 | + /> |
| 593 | + </label> |
499 | 594 | <PersonCandidateList candidates={personCandidates} busy={personBusy} onMatch={doMatchPerson} /> |
500 | 595 | <div class="pdr-create"> |
501 | 596 | <button type="button" class="pdr-btn pdr-btn-create" disabled={personBusy || !personRecord?.name} onclick={doCreatePerson}> |
|
537 | 632 | <div class="pdr-result-head"> |
538 | 633 | {personResult.created ? '✓ created' : '✓ matched'} <strong>{personResult.name}</strong> |
539 | 634 | </div> |
| 635 | + <details class="pdr-search" open> |
| 636 | + <summary> |
| 637 | + observation history{personObservationsLoading ? ' — loading…' : ` (${personObservations.length})`} |
| 638 | + </summary> |
| 639 | + {#if !personObservationsLoading && personObservations.length === 0} |
| 640 | + <p class="pdr-muted">nothing on file yet.</p> |
| 641 | + {:else} |
| 642 | + <ul class="pdr-search-results"> |
| 643 | + {#each personObservations as o (o.predicate + String(o.observed_at) + String(o.object))} |
| 644 | + <li> |
| 645 | + <span> |
| 646 | + <strong>{o.predicate}</strong>: {String(o.object)} |
| 647 | + <span class="pdr-muted"> — {new Date(o.observed_at).toLocaleString()} · {o.source}</span> |
| 648 | + </span> |
| 649 | + </li> |
| 650 | + {/each} |
| 651 | + </ul> |
| 652 | + {/if} |
| 653 | + </details> |
540 | 654 | <div class="pdr-add-obs"> |
541 | 655 | <label><span>predicate (optional)</span><input type="text" bind:value={obsPredicate} placeholder="defaults to 'note'" /></label> |
542 | 656 | <label><span>value</span><input type="text" bind:value={obsValue} placeholder="e.g. confirmed 2026-07-07" /></label> |
|
0 commit comments