Device-test round 2: 4 in-app fixes + Matrix intake cleanup (v0.1.0:99)

Grant's real-phone testing surfaced seven items; this lands six (the seventh,
in-app camera card intake, is planned in docs/handoffs/in-app-card-intake-plan.md).

CRM half — ships in the s9pk (v0.1.0:99):
- Intake fuzzy match no longer over-indexes on generic firm words. _name_similarity
  now compares DISTINCTIVE tokens only (generic descriptors — "Investment Group",
  "Capital", "Family Office" — stripped via _GENERIC_ORG_WORDS) for both the difflib
  ratio and the Jaccard, so "Fortitude Investment Group" stops surfacing Aether/Russell
  while "Aether Capital" still surfaces "Aether Investment Group". +2 regression cases.
- Mobile grid "Last contact"/staleness sort is reversible. SortSheet gains opt-in
  dir/onToggleDir; other surfaces (Contacts/Pipeline) are untouched.
- Mobile "Edit investor" prefills a contact's saved email. GET /api/fundraising/state
  heals a blank grid pill email from the linked classic contact
  (fundraising_contacts.contact_id -> contacts.email), fill-only, by pill order then
  name; the next one-row save persists it. +test_grid_email_heal.py.
- Mobile quick-log pencil icon renders. iOS collapses a sole, centered, attribute-only
  -sized flex-child <svg>; .quicklog-btn svg now gets explicit CSS width/height + flex:none
  (the pattern the working bottom-tab/sort-pill icons use). The v97 fix only changed color.

Matrix intake bot — ships on the Spark (bot-only, NOT the s9pk):
- Approve/reject now redacts the whole intake thread (card + ack + main-timeline nudge +
  the user's own photo/note), mirroring the email-review room; redact_thread takes the
  room as an arg and matches replies by m.thread OR m.in_reply_to (so the nudge clears).
  No more in-Matrix confirmation after a commit (the thread vanishing is the ack).
  Needs the bot to hold a redact/moderator power level in the intake room.
- New one-time backend/matrix_intake/redact_intake.py clears the room's pre-existing
  backlog (dry-run default; --apply).

Tests 42/42 green; frontend render-smoke green. Frontend fixes are inspection + render
-smoke verified (on-device confirm pending); the bot redaction is live-smoke only.
This commit is contained in:
Keysat
2026-06-20 12:32:56 -05:00
parent 7fe5f57c6e
commit a917280bbb
13 changed files with 606 additions and 58 deletions
+56 -15
View File
@@ -2281,6 +2281,14 @@
.sort-row-label { font-size: 15px; font-weight: 500; color: var(--text-primary); }
.sort-row-hint { font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: var(--text-subtle); }
.sort-row-check { flex: none; color: var(--accent); font-size: 15px; }
/* Reversible-direction control under a selected sort row (e.g. Staleness oldest/newest). */
.sort-dir { display: flex; gap: 8px; padding: 0 4px 2px; }
.sort-dir-opt {
flex: 1; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 500;
min-height: 40px; padding: 0 12px; border-radius: 9px;
border: 1px solid var(--border); background: var(--bg-input); color: var(--text-secondary);
}
.sort-dir-opt.active { border-color: var(--accent); color: var(--accent); background: var(--bg-panel-elevated); }
/* AZ directory: sticky letter headers over a card list. */
.az-header {
@@ -2560,6 +2568,11 @@
display: inline-flex; align-items: center; justify-content: center;
}
.quicklog-btn:active { background: var(--bg-hover); }
/* iOS Safari collapses an inline <svg> that is the sole, centered flex child of a
fixed-size flex button when the svg carries only width/height *attributes* (it renders
as a ~1px dot). Explicit CSS dimensions + flex:none fix it — the same reason the working
.bottom-tab-icon (sized box) and .sort-pill (flex:none + text) icons render. */
.quicklog-btn svg { width: 18px; height: 18px; flex: none; }
.quicklog-hint { font-size: 13px; color: var(--text-subtle); line-height: 1.5; margin: 0 0 12px; }
.quicklog-pool { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.quicklog-empty { font-size: 13px; color: var(--text-subtle); padding: 16px 4px; }
@@ -4445,19 +4458,39 @@
{label}
</button>
);
const SortSheet = ({ open, onClose, title, options, value, onPick }) => (
// Optional `dir`/`onToggleDir` add a reversible-direction control under the selected option
// when that option is `reversible` (e.g. the grid's Staleness — oldest vs most-recent first).
// Callers that omit onToggleDir behave exactly as before (every row taps-and-closes).
const SortSheet = ({ open, onClose, title, options, value, onPick, dir, onToggleDir }) => (
<BottomSheet open={open} onClose={onClose} title={title}>
<div className="sort-list">
{options.map((o) => (
<button key={o.id} type="button" className={`sort-row ${value === o.id ? 'active' : ''}`}
onClick={() => { onPick(o.id); onClose(); }}>
<span className="sort-row-main">
<span className="sort-row-label">{o.label}</span>
{o.hint && <span className="sort-row-hint">{o.hint}</span>}
</span>
{value === o.id && <span className="sort-row-check"></span>}
</button>
))}
{options.map((o) => {
const reversible = o.reversible && !!onToggleDir;
return (
<React.Fragment key={o.id}>
<button type="button" className={`sort-row ${value === o.id ? 'active' : ''}`}
onClick={() => { onPick(o.id); if (!reversible) onClose(); }}>
<span className="sort-row-main">
<span className="sort-row-label">{o.label}</span>
{o.hint && <span className="sort-row-hint">{o.hint}</span>}
</span>
{value === o.id && <span className="sort-row-check"></span>}
</button>
{value === o.id && reversible && (
<div className="sort-dir" role="group" aria-label="Sort direction">
<button type="button" className={`sort-dir-opt ${dir !== 'asc' ? 'active' : ''}`}
onClick={() => { onToggleDir('desc'); onClose(); }}>
{(o.dirLabels && o.dirLabels.desc) || 'Descending'}
</button>
<button type="button" className={`sort-dir-opt ${dir === 'asc' ? 'active' : ''}`}
onClick={() => { onToggleDir('asc'); onClose(); }}>
{(o.dirLabels && o.dirLabels.asc) || 'Ascending'}
</button>
</div>
)}
</React.Fragment>
);
})}
</div>
</BottomSheet>
);
@@ -4468,7 +4501,8 @@
{ id: 'name', pill: 'Name', label: 'Name', hint: 'A → Z' },
{ id: 'stage', pill: 'Stage', label: 'Pipeline stage', hint: 'Lead → Commitment' },
{ id: 'committed', pill: 'Committed', label: 'Committed', hint: 'Most first' },
{ id: 'staleness', pill: 'Staleness', label: 'Last contact', hint: 'Most stale first' },
{ id: 'staleness', pill: 'Staleness', label: 'Last contact', hint: 'Most stale first', reversible: true,
dirLabels: { desc: 'Most stale first', asc: 'Most recent first' } },
{ id: 'priority', pill: 'Priority', label: 'Priority', hint: 'Flagged first' },
];
// Pipeline sorts within a stage (dc PipelineApp:580). "Staleness" uses the opp's updated_at as
@@ -10166,6 +10200,7 @@
const [selectedId, setSelectedId] = useState(null);
const [sheet, setSheet] = useState(null); // 'view' | 'create' | 'note' | 'stage' | 'reminder' | 'sort'
const [sortKey, setSortKey] = useState('name'); // GRID_SORTS
const [sortDir, setSortDir] = useState('desc'); // only the reversible sorts (Staleness) read this
const [busy, setBusy] = useState(false);
const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '', priority: false, stage: '', reminderTitle: '', reminderDue: '' });
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' });
@@ -10263,6 +10298,8 @@
const byName = (a, b) => String(a.investor_name || '')
.localeCompare(String(b.investor_name || ''), undefined, { sensitivity: 'base' });
const staleDays = (r) => { const d = daysSince(r.last_activity_at); return d == null ? Number.MAX_SAFE_INTEGER : d; };
// Direction multiplier for the reversible sorts; 'desc' keeps each key's natural default.
const dirMul = sortDir === 'asc' ? -1 : 1;
const cmp = {
name: byName,
stage: (a, b) => {
@@ -10270,11 +10307,13 @@
return (oi(a) - oi(b)) || byName(a, b);
},
committed: (a, b) => (gridRollup(b, fundColumnIds) - gridRollup(a, fundColumnIds)) || byName(a, b),
staleness: (a, b) => (staleDays(b) - staleDays(a)) || byName(a, b), // larger days first = most stale first
// default (desc): larger days first = most stale first; asc flips to most-recent first.
// Name stays the ascending tiebreak regardless of direction.
staleness: (a, b) => (dirMul * (staleDays(b) - staleDays(a))) || byName(a, b),
priority: (a, b) => ((b.priority ? 1 : 0) - (a.priority ? 1 : 0)) || byName(a, b), // row.priority: boolean
};
return [...searched].sort(cmp[sortKey] || byName);
}, [rows, activeViewObj, columns, fundColumnIds, search, sortKey]);
}, [rows, activeViewObj, columns, fundColumnIds, search, sortKey, sortDir]);
const selectedRow = useMemo(() => rows.find((r) => r.id === selectedId) || null, [rows, selectedId]);
const closeSheet = () => setSheet(null);
@@ -10533,7 +10572,9 @@
)}
<SortSheet open={sheet === 'sort'} onClose={closeSheet} title="Sort investors"
options={GRID_SORTS} value={sortKey} onPick={setSortKey} />
options={GRID_SORTS} value={sortKey}
onPick={(id) => { setSortKey(id); setSortDir('desc'); }}
dir={sortDir} onToggleDir={setSortDir} />
<BottomSheet open={sheet === 'view'} onClose={closeSheet} title="Views">
{views.map((v) => (