Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1610,7 +1610,29 @@ function renderDistributionsTable(fund: FundRow): void {
// 7. Subtitle
// =========================================================================

function renderSubtitle(text?: string): void {
function renderHeaderSummary(subtitle: HTMLElement, tickers: Iterable<string>, 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`
Expand Down Expand Up @@ -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 = `<tr><td colspan="10" class="py-12 text-center ${tone === 'error' ? 'text-rose-500 dark:text-rose-300' : 'text-slate-400 dark:text-slate-500'}">${escapeHtml(message)}</td></tr>`;
}
Expand Down
82 changes: 76 additions & 6 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
</style>
<style id="catalog-summary-styles">
#app-subtitle:empty { display: none; }
#ticker-count { cursor: help; font-family: inherit; }
#ticker-count:focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; }
#app-summary[hidden] { display: none; }
#app-summary {
box-sizing: border-box; position: fixed; z-index: 100; display: block;
width: min(42rem, calc(100vw - 2rem)); max-height: min(70vh, 32rem);
overflow: auto; overflow-wrap: anywhere; padding: 1rem;
border: 1px solid #cbd5e1; border-radius: .75rem;
background: #fff; color: #334155; font-size: .875rem; line-height: 1.5;
box-shadow: 0 12px 30px rgb(15 23 42 / .2);
}
.dark #app-summary { background: #0f172a; color: #cbd5e1; border-color: #475569; }
</style>
</head>
<body class="min-h-screen font-sans bg-slate-50 text-slate-900 dark:bg-slate-900 dark:text-slate-100 transition-colors duration-300">
<div class="min-h-screen flex flex-col animate-fade-in">
Expand All @@ -163,15 +178,16 @@
</svg>
<h1 class="min-w-0 text-xl font-bold tracking-tight text-slate-900 dark:text-white leading-tight">abrdn ETFs</h1>
</div>
<span id="app-subtitle" class="basis-full sm:basis-auto text-sm text-slate-500 dark:text-slate-400">
Search abrdn ETFs, select ETFs via the “Use” checkbox, then use the Watchlist tab. Data comes from
<a href="./api/aberdeen/index.json" target="_blank" rel="noopener noreferrer" class="font-semibold text-blue-600 dark:text-blue-400 hover:underline">api/aberdeen/index.json</a> generated from
<a href="https://www.aberdeeninvestments.com/en-us/investor/funds/view-all-funds?table=prices&fund_product_type=Exchange%20Traded%20Fund" target="_blank" rel="noopener noreferrer" class="font-semibold text-blue-600 dark:text-blue-400 hover:underline">aberdeeninvestments.com ETF fund data</a> + SEC EDGAR N-PORT-P (per-fund registrant — holdings fallback only) + Yahoo Finance (history/dividend fallback)
</span>
<span id="app-subtitle" class="basis-full sm:basis-auto text-sm text-slate-500 dark:text-slate-400"></span>
</div>
<div class="flex items-center gap-3 shrink-0">
<span id="ticker-count" class="bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 text-sm font-semibold px-3.5 py-1.5 rounded-full border border-blue-200 dark:border-blue-500/30">
<button type="button" aria-controls="app-summary" aria-expanded="false" aria-label="ETF catalog information" id="ticker-count" class="bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 text-sm font-semibold px-3.5 py-1.5 rounded-full border border-blue-200 dark:border-blue-500/30">
Loading…
</button>
<span id="app-summary" role="region" aria-label="ETF catalog information" hidden>
Search abrdn ETFs, select ETFs via the “Use” checkbox, then use the Watchlist tab. Data comes from
<a href="./api/aberdeen/index.json" target="_blank" rel="noopener noreferrer" class="font-semibold text-blue-600 dark:text-blue-400 hover:underline">api/aberdeen/index.json</a> generated from
<a href="https://www.aberdeeninvestments.com/en-us/investor/funds/view-all-funds?table=prices&fund_product_type=Exchange%20Traded%20Fund" target="_blank" rel="noopener noreferrer" class="font-semibold text-blue-600 dark:text-blue-400 hover:underline">aberdeeninvestments.com ETF fund data</a> + SEC EDGAR N-PORT-P (per-fund registrant — holdings fallback only) + Yahoo Finance (history/dividend fallback)
</span>
<button id="theme-toggle" class="p-1.5 rounded-lg bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 hover:bg-slate-200 dark:hover:bg-slate-700 transition text-base leading-none" title="Toggle Theme">🌙</button>
</div>
Expand Down Expand Up @@ -272,5 +288,59 @@ <h1 class="min-w-0 text-xl font-bold tracking-tight text-slate-900 dark:text-whi
</div>

<script type="text/babel" data-presets="typescript" src="./app.tsx"></script>
<script id="catalog-summary-behavior">
(() => {
const trigger = document.getElementById('ticker-count');
const panel = document.getElementById('app-summary');
if (!trigger || !panel) return;
let pinned = false;
let closeTimer;
const position = () => {
const rect = trigger.getBoundingClientRect();
panel.style.left = `${Math.max(16, Math.min(rect.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 16))}px`;
panel.style.top = `${Math.max(16, Math.min(rect.bottom + 8, innerHeight - panel.offsetHeight - 16))}px`;
};
const show = () => {
clearTimeout(closeTimer);
panel.hidden = false;
trigger.setAttribute('aria-expanded', 'true');
position();
};
const hide = () => {
clearTimeout(closeTimer);
panel.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
};
const scheduleHide = () => {
clearTimeout(closeTimer);
closeTimer = setTimeout(() => {
const focused = document.activeElement;
if (!pinned && !trigger.matches(':hover') && !panel.matches(':hover') &&
focused !== trigger && !panel.contains(focused)) hide();
}, 150);
};
trigger.addEventListener('pointerenter', event => { if (event.pointerType !== 'touch') show(); });
panel.addEventListener('pointerenter', () => clearTimeout(closeTimer));
trigger.addEventListener('pointerleave', scheduleHide);
panel.addEventListener('pointerleave', scheduleHide);
trigger.addEventListener('focus', show);
panel.addEventListener('focusin', show);
trigger.addEventListener('blur', scheduleHide);
panel.addEventListener('focusout', scheduleHide);
trigger.addEventListener('click', () => { pinned = !pinned; if (pinned) show(); else hide(); });
document.addEventListener('keydown', event => {
if (event.key !== 'Escape') return;
pinned = false;
if (panel.contains(document.activeElement)) trigger.focus();
hide();
});
document.addEventListener('pointerdown', event => {
if (!trigger.contains(event.target) && !panel.contains(event.target)) { pinned = false; hide(); }
});
addEventListener('resize', () => { if (!panel.hidden) position(); });
addEventListener('scroll', () => { if (!panel.hidden) position(); }, { passive: true });
if (typeof ResizeObserver === 'function') new ResizeObserver(() => { if (!panel.hidden) position(); }).observe(panel);
})();
</script>
</body>
</html>
62 changes: 61 additions & 1 deletion scripts/update-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(/<button[^>]*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'");
});