From aa8ce875608723f89a90cb1dc6fffe0e544e4a65 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Mon, 21 Sep 2026 23:43:25 -0400 Subject: [PATCH] Offer the tag that already exists Wording variants are a user problem, but the completion was making it one: a native datalist substring-matches, so typing "moved to different sku" never offered "moved to a different sku" -- one inserted word and the existing tag is invisible. Four spellings of that initiative are on the current version, with a ledger line each. TagInput matches on words instead. Both sides lowercased, stripped of punctuation, relieved of stopwords and lightly stemmed, so Pulled Forward finds Pull Forward. Typing something that reduces to an existing tag without being it says so: already used as X, use that. Nothing is merged automatically. The suggestion is offered and the typing stays yours -- moved to another sku is genuinely a different sentence from moved to a different sku, and only the person typing knows whether it is a different thing. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 14 +++- ui/src/components/OperationPanel.jsx | 101 +++++++++++++++++++++++++-- ui/src/views/Forecast.jsx | 2 +- 3 files changed, 111 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 15b9100..bce62d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -348,7 +348,19 @@ no adjustments. The `pf_bucket` datalist on the Baseline page was already per version (`bucketsInUse`), so the two now agree. -**Completion is the datalist and nothing else.** The operation panel used to +**Completion matches on words, not substrings.** `TagInput` in +`OperationPanel.jsx` replaces the native datalist on the tag field: both sides +are lowercased, stripped of punctuation, relieved of stopwords and lightly +stemmed, so `Pulled Forward` finds `Pull Forward` and `moved to different sku` +finds `moved to a different sku` — neither of which a substring match reaches, +one inserted word being enough to hide the tag that already exists. Typing +something that reduces to an existing tag without being it says so outright +(*"Already used as … — use that"*), because the ledger will otherwise report the +two as separate initiatives. Nothing is merged automatically; the suggestion is +offered and the typing stays yours. The change log's inline tag cell still uses +the plain datalist. + +**Completion is otherwise the list and nothing else.** The operation panel used to carry six quick-pick pills under the tag field as well. They wrapped to two rows as soon as the tags were longer than a word, and the panel docked at the bottom has a fixed height, so they pushed the form past its own border. The datalist diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index f83f07f..b66fa07 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -343,6 +343,101 @@ function LedgerInput({ value, active, held, onChange, onFocus, onClear, suffix } ) } +// Tag completion that survives how people actually type. +// +// The native datalist substring-matches, so "moved to different sku" never +// offers "moved to a different sku" -- one inserted word and the existing tag +// is invisible. That is how a version ends up with four spellings of one +// initiative and a ledger line for each. +// +// Words, not substrings: both sides are lowercased, stripped of punctuation, +// relieved of the words that carry no meaning here, and lightly stemmed, so +// "Pulled Forward" and "Pull Forward" reduce to the same thing. Nothing is +// merged automatically -- the match is offered and the typing is still yours. +const TAG_STOP = new Set(['a', 'an', 'the', 'to', 'of', 'for', 'in', 'on']) + +function tagTokens(s) { + return String(s || '') + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .split(/\s+/) + .filter(w => w && !TAG_STOP.has(w)) + .map(w => w.replace(/(ed|es|s)$/, '')) +} + +const tagKey = (s) => tagTokens(s).sort().join(' ') + +function TagInput({ value, onChange, tags = [], className }) { + const [open, setOpen] = useState(false) + const boxRef = useRef(null) + + useEffect(() => { + if (!open) return + const onDown = (e) => { if (!boxRef.current?.contains(e.target)) setOpen(false) } + document.addEventListener('mousedown', onDown, true) + return () => document.removeEventListener('mousedown', onDown, true) + }, [open]) + + const typed = value.trim() + const tokens = tagTokens(typed) + + // Ranked: every typed word present (as a whole word or the start of one) + // beats sharing some of them, and a shorter tag beats a longer one on a tie, + // being the more likely thing you meant. + const matches = (() => { + if (!tags.length) return [] + const scored = tags.map(t => { + const tt = tagTokens(t.tag) + if (!tokens.length) return { t, score: 1 } + const hit = tokens.filter(w => tt.some(x => x === w || x.startsWith(w) || w.startsWith(x))) + if (!hit.length) return { t, score: 0 } + return { t, score: hit.length === tokens.length ? 2 : 1 } + }) + return scored + .filter(x => x.score > 0) + .sort((a, b) => b.score - a.score || a.t.tag.length - b.t.tag.length) + .slice(0, 6) + .map(x => x.t) + })() + + // Typed something that reduces to an existing tag without being it. Worth + // saying out loud, because the ledger will report the two separately. + const nearDupe = typed + && !tags.some(t => t.tag === typed) + && tags.find(t => tagKey(t.tag) && tagKey(t.tag) === tagKey(typed)) + + return ( + + { onChange(e.target.value); setOpen(true) }} + onFocus={() => setOpen(true)} + onKeyDown={e => { if (e.key === 'Escape') { setOpen(false); e.stopPropagation() } }} + placeholder="initiative, e.g. reduce_spend" + className={className} /> + + {open && (matches.length > 0 || nearDupe) && ( +
+ {nearDupe && ( + + )} + {matches.map(t => ( + + ))} +
+ )} +
+ ) +} + function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode }) { const valueCol = currentTotals?.valueCol const unitsCol = currentTotals?.unitsCol @@ -1034,7 +1129,7 @@ export default function OperationPanel({ scaleInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, - opTag, setOpTag, logMeta = {}, + opTag, setOpTag, knownTags = [], logMeta = {}, scaleNote, setScaleNote, recodeSet, setRecodeSet, recodeNote, setRecodeNote, @@ -1192,9 +1287,7 @@ export default function OperationPanel({ in the ledger and in the bridge. Completes from initiatives already used on this source; free text is still accepted. */} - setOpTag(e.target.value)} - list="pf-tag-options" placeholder="initiative, e.g. reduce_spend" + {opTag.trim() && (