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() && (