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:
+56
-15
@@ -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); }
|
||||
|
||||
/* A–Z 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) => (
|
||||
|
||||
Reference in New Issue
Block a user