diff --git a/app.tsx b/app.tsx index 88c853b..2c42ab8 100644 --- a/app.tsx +++ b/app.tsx @@ -1610,7 +1610,29 @@ function renderDistributionsTable(fund: FundRow): void { // 7. Subtitle // ========================================================================= -function renderSubtitle(text?: string): void { +function renderHeaderSummary(subtitle: HTMLElement, tickers: Iterable, activeTicker: string | null, activate: (ticker: string) => void): void { + const panel = document.getElementById('app-summary'); + if (!panel) return; + // Move existing nodes: provenance links and their handlers remain intact. + panel.replaceChildren(...Array.from(subtitle.childNodes)); + subtitle.replaceChildren(); + const selected = [...tickers].sort(); + if (!selected.length) return; + subtitle.append(document.createTextNode(`${selected.length} selected: `)); + selected.forEach((ticker, index) => { + if (index) subtitle.append(document.createTextNode(', ')); + const link = document.createElement('a'); + link.href = '#'; + link.dataset.headerFund = ticker; + link.title = `View ${ticker} details`; + link.className = `font-semibold ${ticker === activeTicker ? 'text-blue-700 dark:text-blue-300 underline' : 'text-blue-600 dark:text-blue-400 hover:underline'}`; + link.textContent = ticker; + link.addEventListener('click', event => { event.preventDefault(); activate(ticker); }); + subtitle.append(link); + }); +} + +function renderSubtitleDetails(text?: string): void { const generated = state.generatedAt ? new Date(state.generatedAt).toLocaleString() : ''; const countsText = state.counts ? `${state.counts.funds} ETFs · ${(state.counts.holdings || 0).toLocaleString('en-US')} holdings rows · ${(state.counts.history || 0).toLocaleString('en-US')} history rows` @@ -1641,6 +1663,11 @@ function renderSubtitle(text?: string): void { }); } +function renderSubtitle(text?: string): void { + renderSubtitleDetails(text); + renderHeaderSummary(el.subtitle, state.selected, state.activeFundTicker, activateFund); +} + function setStatusRow(message: string, tone: 'info' | 'error'): void { el.tableBody.innerHTML = `${escapeHtml(message)}`; } diff --git a/index.html b/index.html index a61ce55..b7fb96f 100644 --- a/index.html +++ b/index.html @@ -149,6 +149,21 @@ #table-body.table-content-enter{animation:tableRefresh .22s ease-out} @media (prefers-reduced-motion:reduce){#table-body.table-content-enter{animation:none}main.space-y-6 > #selected-tabs-panel{transition:none}main.space-y-6 > #blacklist-panel{transition:none}} +
@@ -163,15 +178,16 @@

abrdn ETFs

- - Search abrdn ETFs, select ETFs via the “Use” checkbox, then use the Watchlist tab. Data comes from - api/aberdeen/index.json generated from - aberdeeninvestments.com ETF fund data + SEC EDGAR N-PORT-P (per-fund registrant — holdings fallback only) + Yahoo Finance (history/dividend fallback) - +
- + +
@@ -272,5 +288,59 @@

+ diff --git a/scripts/update-data.test.ts b/scripts/update-data.test.ts index 8f23c14..da869ec 100644 --- a/scripts/update-data.test.ts +++ b/scripts/update-data.test.ts @@ -87,7 +87,10 @@ await main({...env,MAX_FETCHES:'1'});const state=await Bun.file('api/aberdeen/up },30000); test('UI differs from pinned JPMorgan only by recorded substitutions and approved corrections',async()=>{ - const manifest=JSON.parse(await readFile(new URL('../.plans/ui-copy.json',import.meta.url),'utf8')); + const log=await readFile(new URL('../.worklog.txt',import.meta.url),'utf8'); + const archived=log.split('--- FILE: .plans/ui-copy.json ---\n')[1]?.split('\n--- END FILE: .plans/ui-copy.json ---')[0]; + expect(archived).toBeDefined(); + const manifest=JSON.parse(archived!); for(const [name,spec] of Object.entries(manifest.files) as [string,any][]){ let text=await readFile(new URL('../'+name,import.meta.url),'utf8'); for(const [before,after] of spec.substitutions.toReversed())text=text.replaceAll(after,before); @@ -258,3 +261,60 @@ test('Aberdeen About uses the sibling description pattern and Pages URL', async const shared=metadata.reference.topics.filter((topic:string)=>!['jpmorgan','jpmorgan-etfs'].includes(topic)); expect([...metadata.desired.topics].sort()).toEqual([...shared,'abrdn','abrdn-etfs'].sort()); }); + + +import { test as headerTest, expect as headerExpect } from 'bun:test'; +async function headerSummaryHarness() { + const source = await Bun.file(new URL('../app.tsx', import.meta.url)).text(); + const match = /^([ \t]*)function renderHeaderSummary\(/m.exec(source); + headerExpect(match).not.toBeNull(); + const tail = source.slice(match!.index); + const end = new RegExp('^' + match![1] + '}', 'm').exec(tail)!; + const js = new Bun.Transpiler({ loader: 'ts' }).transformSync(tail.slice(0, end.index + end[0].length)); + const makeNode = (text = ''): any => { + const node: any = { textContent: text, childNodes: [], dataset: {}, listeners: {} }; + node.replaceChildren = (...children: any[]) => { node.childNodes = children; }; + node.append = (...children: any[]) => { node.childNodes.push(...children); }; + node.addEventListener = (name: string, listener: any) => { node.listeners[name] = listener; }; + return node; + }; + const panel = makeNode(), subtitle = makeNode(), details = makeNode('Data: source link and updated timestamp'); + subtitle.append(details); + const document = { getElementById: () => panel, createTextNode: makeNode, createElement: () => makeNode() }; + const render = new Function('document', js + '; return renderHeaderSummary;')(document); + const text = () => subtitle.childNodes.map((n: any) => n.textContent).join(''); + return { render, panel, subtitle, details, makeNode, text }; +} +headerTest('header has no visible subtitle without selection; original details nodes are retained', async () => { + const h = await headerSummaryHarness(); + h.render(h.subtitle, new Set(), null, () => {}); + headerExpect(h.text()).toBe(''); + headerExpect(h.panel.childNodes).toEqual([h.details]); + headerExpect(h.panel.childNodes[0]).toBe(h.details); +}); +headerTest('header shows sorted selected tickers only, preserving click activation and highlight', async () => { + const h = await headerSummaryHarness(); const activated: string[] = []; + h.render(h.subtitle, new Set(['ZZZ', 'AAA']), 'AAA', (ticker: string) => activated.push(ticker)); + headerExpect(h.text()).toBe('2 selected: AAA, ZZZ'); + const links = h.subtitle.childNodes.filter((n: any) => n.dataset.headerFund); + headerExpect(links[0].className).toContain('underline'); + links[1].listeners.click({ preventDefault() {} }); + headerExpect(activated).toEqual(['ZZZ']); + headerExpect(h.panel.childNodes[0]).toBe(h.details); +}); +headerTest('all selected still lists tickers; clear replaces both summary and selection', async () => { + const h = await headerSummaryHarness(); + h.render(h.subtitle, new Set(['CCC','AAA','BBB']), 'BBB', () => {}); + headerExpect(h.text()).toBe('3 selected: AAA, BBB, CCC'); + const next = h.makeNode('Fresh detail context'); h.subtitle.replaceChildren(next); + h.render(h.subtitle, new Set(), null, () => {}); + headerExpect(h.text()).toBe(''); headerExpect(h.panel.childNodes).toEqual([next]); +}); +headerTest('header markup supplies a focusable counter and hidden rich panel with dismissal', async () => { + const html = await Bun.file(new URL('../index.html', import.meta.url)).text(); + headerExpect(html).toMatch(/]*aria-controls="app-summary"[^>]*id="ticker-count"/); + headerExpect(html).toContain('id="app-summary" role="region" aria-label="ETF catalog information" hidden'); + headerExpect(html).toContain("event.key !== 'Escape'"); + headerExpect(html).toContain("trigger.addEventListener('focus', show)"); + headerExpect(html).toContain("trigger.addEventListener('pointerenter'"); +});