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) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-21 23:43:25 -04:00
parent 318a09b6f2
commit aa8ce87560
3 changed files with 111 additions and 6 deletions

View File

@ -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

View File

@ -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 (
<span ref={boxRef} className="relative inline-flex items-center">
<input
value={value}
onChange={e => { 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) && (
<div className="absolute top-full left-0 mt-1 z-50 min-w-full max-w-xs bg-white border
border-gray-200 rounded shadow-lg py-1 text-xs">
{nearDupe && (
<button onClick={() => { onChange(nearDupe.tag); setOpen(false) }}
className="block w-full text-left px-2 py-1 text-amber-700 hover:bg-amber-50">
Already used as “{nearDupe.tag}” — use that
</button>
)}
{matches.map(t => (
<button key={t.tag} onClick={() => { onChange(t.tag); setOpen(false) }}
className={`block w-full text-left px-2 py-1 truncate hover:bg-gray-50
${t.tag === typed ? 'text-blue-700' : 'text-gray-700'}`}>
{t.tag}
</button>
))}
</div>
)}
</span>
)
}
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. */}
<Field label="tag">
<input
value={opTag} onChange={e => setOpTag(e.target.value)}
list="pf-tag-options" placeholder="initiative, e.g. reduce_spend"
<TagInput value={opTag} onChange={setOpTag} tags={knownTags}
className={`${TEXT} w-48`} />
{opTag.trim() && (
<button onClick={() => setOpTag('')} title="Clear tag"

View File

@ -1896,7 +1896,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
scaleInputs, setScaleInputs,
scalePlug, setScalePlug,
targetBasis, setTargetBasis,
opTag, setOpTag, logMeta,
opTag, setOpTag, knownTags, logMeta,
scaleNote, setScaleNote,
recodeSet, setRecodeSet,
recodeNote, setRecodeNote,