feat(overview): filter the skill card by name

A case-insensitive substring filter rides the skill card's totals row,
narrowing the rows, the summary counts, and the overflow line; the
input stays mounted on an empty match, next to a zeroed summary and a
dedicated no-match note. The bars keep the scope's heaviest tally as
their denominator so they hold still while the filter narrows.

A pin the filter hides now falls back to the detached detail block with
its head row — previously the head rendered only for pins outside the
scope, leaving a filter-hidden (or render-bound-hidden) pin unnamed.
This commit is contained in:
bowenliang123 committed 2026-10-09 10:53:45 +08:00
1 parent f60e1a5643
commit 00df001055
4 files changed
+143 -24

No files matched your search

+52 -21
View File
@@ -6,9 +6,11 @@
* in the picked order (the title's segmented toggle: loads / last used): a
* name-hashed color dot, the load tally, a
* proportional hairline bar (share of the heaviest row's tally), and the last
* load's relative time. A row click PINs the session list to the sessions
* that loaded the skill (the column's drill-down, beside the heatmap's day
* pin — click again to release).
* load's relative time. The sub row under the title carries the scope's
* totals and a name filter (a case-insensitive substring match narrowing
* both the rows and the counts). A row click PINs the session list to the
* sessions that loaded the skill (the column's drill-down, beside the
* heatmap's day pin — click again to release).
*
* The card also answers what a skill IS, in two registers:
* - a HOVER card (portaled like the page's hoverTip, but structured — the
@@ -151,12 +153,23 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) =
const { t, fmt } = kit
return function OverviewSkills(props: OverviewSkillsProps): ReactElement {
const { stats } = props
const total = stats.reduce((sum, s) => sum + s.loads, 0)
// The bar's denominator is the heaviest row's tally, wherever the sort
// put it — keying on the first row overflows the card the moment another
// ordering (sessions / recent) leads with a lighter row.
// The bar's denominator is the heaviest row's tally across the whole
// scope, wherever the sort put it — keying on the first row overflows
// the card the moment another ordering (sessions / recent) leads with a
// lighter row, and keeping the scope's denominator stops the bars from
// jumping while a name filter narrows the rows.
const max = stats.reduce((top, s) => Math.max(top, s.loads), 0)
// ---- the name filter ---------------------------------------------------
// A local, case-insensitive substring filter over the skill names; the
// summary counts and the rendered rows follow it, so the card always
// says what it shows. The input stays mounted on an empty match so the
// filter can always be cleared from the UI.
const [query, setQuery] = useState('')
const needle = query.trim().toLowerCase()
const visible = needle === '' ? stats : stats.filter(s => s.name.toLowerCase().includes(needle))
const visibleTotal = visible.reduce((sum, s) => sum + s.loads, 0)
// ---- the hover card ---------------------------------------------------
const [hoverRow, setHoverRow] = useState<HoverRow | null>(null)
const [spot, setSpot] = useState<{ forRow: HoverRow; left: number; top: number } | null>(null)
@@ -213,9 +226,10 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) =
// block. The description leads (it answers what the skill IS); the facts
// follow as a two-column label grid — origin, usage, last load, and the
// full path as a click-to-copy value — so nothing floats unlabeled. A pin
// whose row left the visible scope (or the render bound) falls back to a
// detached block at the card's bottom, head row included; a catalog-less
// pin with in-scope tallies still shows its usage rows.
// whose row is not rendered — out of the filtered scope or past the
// render bound — falls back to a detached block at the card's bottom,
// head row included; a catalog-less pin with in-scope tallies still shows
// its usage rows.
const selected = props.selected
const pinnedStat = selected === null || selected === undefined
? undefined
@@ -233,9 +247,14 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) =
}
// The copy value's path, narrowed once (a const's narrowing holds in the closure).
const detailPath = detail?.path
// Whether the pinned skill's own row renders (in the filtered scope and
// inside the render bound) — the inline seat exists only then; a pin the
// filter hides falls back to the detached block at the card's bottom.
const pinnedRowVisible = selected !== null && selected !== undefined
&& visible.slice(0, MAX_ROWS).some(s => s.name === selected)
const detailBlock = detail === null ? null : (
<div className="lc-ov-skill-detail">
{pinnedStat === undefined && <DetailHead name={detail.name} origin="" />}
{!pinnedRowVisible && <DetailHead name={detail.name} origin="" />}
{detail.description !== '' && <p className="lc-skilld-desc">{detail.description}</p>}
<div className="lc-skilld-grid">
{detail.origin !== '' && (
@@ -269,10 +288,6 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) =
</div>
</div>
)
// Whether the pinned skill's own row renders (in scope and inside the
// render bound) — the inline seat exists only then.
const pinnedRowVisible = selected !== null && selected !== undefined
&& stats.slice(0, MAX_ROWS).some(s => s.name === selected)
const hoverTip = hoverStat === undefined ? null : (() => {
const info = props.catalog?.get(hoverStat.name)
@@ -324,17 +339,33 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) =
</div>
)}
</div>
{/* The scope's totals live under the title, clear of the toggle. */}
{/* The scope's totals live under the title, clear of the toggle; the
name filter rides the same row, folded right and wrapping below
under width pressure. A lone row leaves nothing to filter. */}
{stats.length > 0 && (
<div className="lc-card-sub lc-ov-skills-sub">
{t('ov.skills.summary', { skills: fmt(stats.length), loads: fmt(total) })}
<div className="lc-ov-skills-sub">
<span className="lc-card-sub lc-ov-skills-sub-n">
{t('ov.skills.summary', { skills: fmt(visible.length), loads: fmt(visibleTotal) })}
</span>
{stats.length > 1 && (
<input
className="lc-ov-search lc-ov-skills-search"
type="search"
value={query}
placeholder={t('ov.skills.search')}
aria-label={t('ov.skills.search')}
onChange={(ev) => { setQuery(ev.target.value) }}
/>
)}
</div>
)}
{stats.length === 0 ? (
<div className="lc-empty">{t('ov.skills.empty')}</div>
) : visible.length === 0 ? (
<div className="lc-empty">{t('ov.skills.noMatch')}</div>
) : (
<div className="lc-ov-skills" role="group" aria-label={t('stats.skills')}>
{stats.slice(0, MAX_ROWS).map((s) => {
{visible.slice(0, MAX_ROWS).map((s) => {
const color = skillColorOf(s.name)
const pinned = props.selected === s.name
// The bar's percentage resolves against the row's padding box
@@ -374,8 +405,8 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) =
? <div key={s.name} className="lc-ov-skill-unit">{rowButton}{detailBlock}</div>
: <Fragment key={s.name}>{rowButton}</Fragment>
})}
{stats.length > MAX_ROWS && (
<div className="lc-ov-skill-more">{t('ov.skills.more', { n: stats.length - MAX_ROWS })}</div>
{visible.length > MAX_ROWS && (
<div className="lc-ov-skill-more">{t('ov.skills.more', { n: visible.length - MAX_ROWS })}</div>
)}
</div>
)}
+4
View File
@@ -396,6 +396,8 @@ export const DICT_ZH: Record<string, string> = {
'ov.heat.mon.11': '11月',
'ov.heat.mon.12': '12月',
'ov.skills.empty': '统计范围内暂无技能加载 — 加载技能后,这里会按名称列出次数与最近加载',
'ov.skills.noMatch': '没有匹配当前筛选的技能',
'ov.skills.search': '搜索技能…',
'ov.skills.more': '另有 {n} 个技能',
'ov.skills.loads': '{n} 次加载',
'ov.skills.summary': '{skills} 个技能 · {loads} 次加载',
@@ -833,6 +835,8 @@ export const DICT_EN: Record<string, string> = {
'ov.heat.mon.11': 'Nov',
'ov.heat.mon.12': 'Dec',
'ov.skills.empty': 'No skill loads in this range — loaded skills appear here with their counts and last load',
'ov.skills.noMatch': 'No skills match the current filter',
'ov.skills.search': 'Search skills…',
'ov.skills.more': '{n} more skills',
'ov.skills.loads': '{n} loads',
'ov.skills.summary': '{skills} skills · {loads} loads',
+9 -3
View File
@@ -224,9 +224,15 @@ button.lc-heat-cell:hover { box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--col
so the tally and the time never crush it. ---- */
.lc-ov-skills { display: flex; flex-direction: column; gap: 2px; }
/* The scope's totals sit under the title, pulled into the title's bottom
margin so the pair reads as one head; the sort toggle stays up in the
title row's right corner. */
.lc-ov-skills-sub { margin: -4px 0 8px; }
margin so the pair reads as one head; the name filter rides the same row,
folded to the right and wrapping below the totals under width pressure.
The sort toggle stays up in the title row's right corner. */
.lc-ov-skills-sub { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: -4px 0 8px; }
.lc-ov-skills-sub-n { margin-left: 0; }
/* The filter keeps the session-list search's look; it shrinks before the
totals and, once wrapped, fills its own line so the input stays usable
on the narrowest pane. */
.lc-ov-skills-search { flex: 0 1 170px; margin-left: auto; }
.lc-ov-skill { position: relative; display: flex; align-items: baseline; gap: 7px; min-width: 0; width: 100%; border: 0; border-radius: 6px; padding: 2px 6px 4px; background: transparent; font: inherit; font-size: 12px; line-height: 1.7; color: inherit; text-align: left; cursor: pointer; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-ov-skill:hover { background: var(--dsw-alias-bg-layer-2); }
/* The pinned row and its detail read as one unit: the fill and the single
@@ -392,6 +392,75 @@ describe('OverviewSkills', () => {
await bare.unmount()
})
test('the name filter narrows the rows and the summary, case-insensitively; clearing restores', async () => {
const m = await mount(h(Skills, {
stats: [stat('ponytail', 2, NOW), stat('grilling', 1, NOW), stat('grill-me', 1, NOW)],
now: NOW,
}))
const input = query<HTMLInputElement>(m.container, 'input.lc-ov-skills-search')
assert.equal(input.getAttribute('aria-label'), 'Search skills…')
assert.equal(queryAll(m.container, 'button.lc-ov-skill').length, 3)
assert.ok(text(query(m.container, '.lc-ov-skills-sub-n')).includes('3 skills · 4 loads'))
await actType(input, 'GRILL')
assert.deepEqual(
queryAll(m.container, '.lc-ov-skill-name').map(n => text(n)),
['grilling', 'grill-me'],
'the substring match ignores case',
)
assert.ok(text(query(m.container, '.lc-ov-skills-sub-n')).includes('2 skills · 2 loads'), 'the counts follow the filter')
await actType(input, ' grill-me ')
assert.deepEqual(queryAll(m.container, '.lc-ov-skill-name').map(n => text(n)), ['grill-me'], 'the query trims')
await actType(input, '')
assert.equal(queryAll(m.container, 'button.lc-ov-skill').length, 3, 'clearing restores the scope')
await m.unmount()
})
test('an empty match keeps the filter input mounted and swaps the note; a lone row offers nothing to filter', async () => {
const m = await mount(h(Skills, { stats: [stat('tdd', 1, NOW), stat('grill-me', 1, NOW)], now: NOW }))
const input = query<HTMLInputElement>(m.container, 'input.lc-ov-skills-search')
await actType(input, 'zzz')
assert.equal(queryAll(m.container, 'button.lc-ov-skill').length, 0)
assert.ok(text(query(m.container, '.lc-ov-skills-sub-n')).includes('0 skills · 0 loads'), 'the counts follow the filter down to zero')
assert.ok(text(m.container).includes('No skills match the current filter'))
await actType(input, 'tdd')
assert.equal(queryAll(m.container, 'button.lc-ov-skill').length, 1, 'the input stays mounted and can clear the filter')
await m.unmount()
const one = await mount(h(Skills, { stats: [stat('tdd', 1, NOW)], now: NOW }))
assert.equal(queryAll(one.container, 'input.lc-ov-skills-search').length, 0, 'a lone row has nothing to filter')
await one.unmount()
const empty = await mount(h(Skills, { stats: [], now: NOW }))
assert.equal(queryAll(empty.container, 'input.lc-ov-skills-search').length, 0)
await empty.unmount()
})
test('a pin the filter hides falls back to the detached block; the filtered overflow line counts the matches', async () => {
const stats = Array.from({ length: 25 }, (_, i) => stat(`skill-${i}`, i + 1, NOW - i * 1000))
stats.push(stat('grill-me', 1, NOW))
const m = await mount(h(Skills, { stats, selected: 'skill-24', now: NOW }))
const input = query<HTMLInputElement>(m.container, 'input.lc-ov-skills-search')
await actType(input, 'grill')
assert.equal(queryAll(m.container, 'button.lc-ov-skill').length, 1)
const detail = query(m.container, '.lc-ov-skill-detail')
assert.equal(detail.closest('.lc-ov-skill-unit'), null, 'no inline seat without the pinned row')
assert.equal(queryAll(detail, '.lc-skilld-head').length, 1, 'the detached seat carries the head')
// All twenty-five generated names match 'skill' — twenty rows plus the
// overflow line counting the filtered set.
await actType(input, 'skill')
assert.equal(queryAll(m.container, 'button.lc-ov-skill').length, 20)
assert.ok(text(m.container).includes('5 more skills'), 'the overflow counts the filtered set')
await m.unmount()
})
test('the bars keep the scope’s heaviest tally as their denominator under a filter', async () => {
const m = await mount(h(Skills, { stats: [stat('heavy', 8, NOW), stat('light', 4, NOW)], now: NOW }))
const input = query<HTMLInputElement>(m.container, 'input.lc-ov-skills-search')
await actType(input, 'light')
const rows = queryAll<HTMLElement>(m.container, 'button.lc-ov-skill')
assert.equal(rows.length, 1)
assert.ok(query(rows[0], '.lc-ov-skill-bar').getAttribute('style')?.includes('calc(50% - 6px)'), 'the share stays against the scope’s heaviest row')
await m.unmount()
})
test('zh locale renders the shipped strings', async () => {
const m = await mount(h(SkillsZh, {
stats: [stat('tdd', 2, NOW - 2 * 3_600_000)],
@@ -416,3 +485,12 @@ describe('OverviewSkills', () => {
await empty.unmount()
})
})
/** Type into an input through the React change path (native setter + input event, act-wrapped). */
async function actType(input: HTMLInputElement, value: string): Promise<void> {
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
setter?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}