From 00df00105544457d19b6c31739062b5a36715dc5 Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Fri, 9 Oct 2026 10:53:45 +0800 Subject: [PATCH] feat(overview): filter the skill card by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/client/components/overviewSkills.tsx | 73 ++++++++++++----- src/client/i18n.ts | 4 + src/client/styles/overview.css | 12 ++- .../client/components/overviewSkills.spec.ts | 78 +++++++++++++++++++ 4 files changed, 143 insertions(+), 24 deletions(-) diff --git a/src/client/components/overviewSkills.tsx b/src/client/components/overviewSkills.tsx index a386866..cc25899 100644 --- a/src/client/components/overviewSkills.tsx +++ b/src/client/components/overviewSkills.tsx @@ -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(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 : (
- {pinnedStat === undefined && } + {!pinnedRowVisible && } {detail.description !== '' &&

{detail.description}

}
{detail.origin !== '' && ( @@ -269,10 +288,6 @@ export function makeOverviewSkills(kit: ViewKit): (props: OverviewSkillsProps) =
) - // 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) = )} - {/* 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 && ( -
- {t('ov.skills.summary', { skills: fmt(stats.length), loads: fmt(total) })} +
+ + {t('ov.skills.summary', { skills: fmt(visible.length), loads: fmt(visibleTotal) })} + + {stats.length > 1 && ( + { setQuery(ev.target.value) }} + /> + )}
)} {stats.length === 0 ? (
{t('ov.skills.empty')}
+ ) : visible.length === 0 ? ( +
{t('ov.skills.noMatch')}
) : (
- {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) = ?
{rowButton}{detailBlock}
: {rowButton} })} - {stats.length > MAX_ROWS && ( -
{t('ov.skills.more', { n: stats.length - MAX_ROWS })}
+ {visible.length > MAX_ROWS && ( +
{t('ov.skills.more', { n: visible.length - MAX_ROWS })}
)}
)} diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 16a8198..fa32ee1 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -396,6 +396,8 @@ export const DICT_ZH: Record = { '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 = { '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', diff --git a/src/client/styles/overview.css b/src/client/styles/overview.css index bc250e1..9ab35c3 100644 --- a/src/client/styles/overview.css +++ b/src/client/styles/overview.css @@ -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 diff --git a/tests/client/components/overviewSkills.spec.ts b/tests/client/components/overviewSkills.spec.ts index ec725e4..26979dc 100644 --- a/tests/client/components/overviewSkills.spec.ts +++ b/tests/client/components/overviewSkills.spec.ts @@ -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(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(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(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(m.container, 'input.lc-ov-skills-search') + await actType(input, 'light') + const rows = queryAll(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 { + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set + setter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +}