From db0ef3f555d0e30e3a868b2c8f95801fc7ac719e Mon Sep 17 00:00:00 2001 From: Maksim Kostromin Date: Thu, 29 Oct 2026 19:01:57 -0400 Subject: [PATCH] feat(ui): 52-week range, low and high columns Shared ui-std range52w block (ported with range52w.ts, as in Schwab): 52W Range (position of the last close in percent with a bar, sorted by it, amber bar for funds with less than 52 weeks of history), 52W Low and 52W High right after NAV, in the filters, the CSV export and the header tooltips. --- src/index.css | 3 +++ src/main.tsx | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/index.css b/src/index.css index 192f9649..946f6d53 100644 --- a/src/index.css +++ b/src/index.css @@ -337,3 +337,6 @@ .clear-btn:focus-visible,.clear-row:focus-within{outline:2px solid #3b82f6;outline-offset:2px} @keyframes clearFade{from{opacity:0}to{opacity:1}} @media (prefers-reduced-motion:reduce){.clear-backdrop{animation:none}} +/* 52-week range cell (ui-std range52w-block.tsx): a 3px bar under the percent, filled up to the position */ +.range52w-cell{background-image:linear-gradient(to right,rgb(59 130 246 / .7) 0 var(--range52w,0%),rgb(148 163 184 / .35) var(--range52w,0%) 100%);background-repeat:no-repeat;background-size:calc(100% - 2rem) 3px;background-position:1rem calc(100% - .3rem)} +.range52w-cell.range52w-young{background-image:linear-gradient(to right,rgb(245 158 11 / .75) 0 var(--range52w,0%),rgb(148 163 184 / .35) var(--range52w,0%) 100%)} diff --git a/src/main.tsx b/src/main.tsx index 3adaaa9d..61d08e3a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -148,6 +148,9 @@ const COLUMN_TOOLTIPS: Record = { SEDOL: 'SEDOL — Stock Exchange Daily Official List identifier.', TER: 'Gross Expense Ratio — Total annual fund operating expenses as a % of assets.', NAV: 'NAV (Net Asset Value) — Per-share dollar value of the fund.', + '52W Range': '52W Range - where the last close sits between the 52-week low (0%) and high (100%) of the daily closes; sorts by that position. A fund with less than 52 weeks of history shows the range since its first close (amber bar).', + '52W Low': '52W Low - lowest daily close of the last 52 weeks (or since the first close of a younger fund).', + '52W High': '52W High - highest daily close of the last 52 weeks (or since the first close of a younger fund).', 'Net Assets': 'Net Assets (AUM) — Total market value of all fund assets minus liabilities.', Weight: 'Weight — Position weight as a percentage of the fund\'s total net assets.', 'Weight Sum': 'Weight Sum — Summed weight of this holding across all selected ETFs (%).', @@ -1033,6 +1036,9 @@ const FUND_FILTER_COLUMNS: FilterColumn[] = [ { key: 'name', label: 'Fund Name', numeric: false, text: (fund: any) => fundNameText(fund.name, fund) }, { key: 'category', label: 'Type', numeric: false, text: (fund: any) => String((categoryLabel(fund.category)) ?? '') }, { key: 'navValue', label: 'NAV', numeric: true, text: (fund: any) => String((fund.nav || '—') ?? ''), value: (fund: any) => fund.navValue }, + { key: 'range52w', label: '52W Range', numeric: true, text: (fund: any) => range52wText(fund.range52w), value: (fund: any) => fund.range52w }, + { key: 'week52Low', label: '52W Low', numeric: true, text: (fund: any) => range52wPriceText(fund.week52Low), value: (fund: any) => fund.week52Low }, + { key: 'week52High', label: '52W High', numeric: true, text: (fund: any) => range52wPriceText(fund.week52High), value: (fund: any) => fund.week52High }, { key: 'aumValue', label: 'Net Assets', numeric: true, text: (fund: any) => String((formatMoney(fund.aumValue)) ?? ''), value: (fund: any) => fund.aumValue }, { key: 'terValue', label: 'Expense', numeric: true, text: (fund: any) => String((fund.ter || '—') ?? ''), value: (fund: any) => fund.terValue }, { key: 'dividendYield', label: 'Dividend Yield', numeric: true, text: (fund: any) => String((formatPercent(fund.dividendYield)) ?? ''), value: (fund: any) => fund.dividendYield }, @@ -1647,6 +1653,59 @@ function renderBusy(keepRows = true, after?: () => void): void { }); } +// ---- 52-week range (owner 2026-10-29): index.json metrics week52Low, week52High, range52w, range52wClose, range52wAsOf, range52wSince, range52wBasis ---- +// Shared block of .claude/tools/etfs/ui-std/range52w-block.tsx: never edit it in one repo. CSS: range52w.css. + +/** A finite number or null (local copy, so the block needs nothing else from the app). */ +function range52wNumber(value: unknown): number | null { + if (value === null || value === undefined || value === '') return null; + const parsed = typeof value === 'number' ? value : Number(String(value).replace(/[$,%\s]/g, '')); + return Number.isFinite(parsed) ? parsed : null; +} + +/** The three sortable row fields from a row's metrics: spread them into the flattened table row. */ +function range52wRowFields(metrics: any): { range52w: number | null; week52Low: number | null; week52High: number | null } { + const m = metrics && typeof metrics === 'object' ? metrics : {}; + return { range52w: range52wNumber(m.range52w), week52Low: range52wNumber(m.week52Low), week52High: range52wNumber(m.week52High) }; +} + +/** `42.25%` (the `%` type of the column filters), or an em dash. */ +function range52wText(value: unknown): string { + const parsed = range52wNumber(value); + return parsed === null ? '—' : `${parsed.toFixed(2)}%`; +} + +/** A 52-week low or high as a share price: `$25.29`, `$1,234.50`, or an em dash. */ +function range52wPriceText(value: unknown): string { + const parsed = range52wNumber(value); + return parsed === null ? '—' : `$${parsed.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +/** Tooltip: what the percent means, the close it is measured on, the range, the basis, and the note for a young fund. */ +function range52wTitle(metrics: any): string { + const m = metrics && typeof metrics === 'object' ? metrics : {}; + const position = range52wNumber(m.range52w); + if (position === null) return '52-week range: not available (no price history, or the price did not move)'; + const lines = [ + `52-week range: ${range52wText(position)} (0% = at the 52-week low, 100% = at the 52-week high)`, + `Close ${range52wPriceText(m.range52wClose)}${m.range52wAsOf ? ` on ${m.range52wAsOf}` : ''}`, + `Low ${range52wPriceText(m.week52Low)}, high ${range52wPriceText(m.week52High)} (daily ${m.range52wBasis ? String(m.range52wBasis).toLowerCase() : 'close'} prices)`, + ]; + if (m.range52wSince) lines.push(`Less than 52 weeks of history: range since ${m.range52wSince}`); + return lines.join('\n'); +} + +/** The table cell: the percent as text (so sorting, filters and exports keep working) over a thin bar of the same value. */ +function range52wCellHtml(metrics: any, tdClass: string): string { + const m = metrics && typeof metrics === 'object' ? metrics : {}; + const position = range52wNumber(m.range52w); + // a fund with less than 52 weeks of history gets an amber bar instead of a blue one; the text stays the bare percent + const young = position !== null && m.range52wSince ? ' range52w-young' : ''; + const bar = position === null ? '' : ` range52w-cell${young}" style="--range52w:${Math.min(100, Math.max(0, position))}%`; + return `${range52wText(position)}`; +} +// ---- end of 52-week range ---- + init(); // ========================================================================= @@ -1815,6 +1874,7 @@ function normalizeFundRow(fund: IndexFund): FundRow { cagr5y: metrics.cagr5y ?? monthEnd.yr5 ?? null, cagr10y: metrics.cagr10y ?? monthEnd.yr10 ?? null, dividendYield: metrics.dividendYield ?? null, + ...range52wRowFields(metrics), dividendFrequency: formatDividendFrequency(fund.distributions && fund.distributions.frequency ? fund.distributions.frequency : '—'), secYield: metrics.secYield ?? null, // official WisdomTree product pages publish it for many funds. returnAsOf: monthEnd.asOfDate ?? null, @@ -2642,6 +2702,9 @@ function renderFundsTable(): void { ${sortHeader('Fund Name', 'name')} ${sortHeader('Type', 'category')} ${sortHeader('NAV', 'navValue', true)} + ${sortHeader('52W Range', 'range52w', true)} + ${sortHeader('52W Low', 'week52Low', true)} + ${sortHeader('52W High', 'week52High', true)} ${sortHeader('Net Assets', 'aumValue', true)} ${sortHeader('Expense', 'terValue', true)} ${sortHeader('Dividend Yield', 'dividendYield', true)} @@ -2684,6 +2747,9 @@ function renderFundsTable(): void { ${escapeHtml(fund.name)}${closedBadgeHtml(fund)} ${escapeHtml(categoryLabel(fund.category))} ${escapeHtml(fund.nav || '—')} + ${range52wCellHtml(fund.metrics, 'py-2.5 px-4 text-right font-mono text-slate-700 dark:text-slate-300')} + ${range52wPriceText(fund.week52Low)} + ${range52wPriceText(fund.week52High)} ${formatMoney(fund.aumValue)} ${escapeHtml(fund.ter || '—')} ${formatPercent(fund.dividendYield)} @@ -3953,7 +4019,7 @@ function currentExportRows(): { headers: string[]; rows: string[][]; scope: stri } return { - headers: ['#', 'Selected', 'Ticker', 'Fund Name', 'Type', 'NAV', 'Net Assets ($)', 'Expense (%)', 'Dividend Yield (%)', 'SEC Yield (%)', 'Frequency', 'YTD Return (%)', 'TR 1Y (%)', 'TR 3Y (%)', 'TR 5Y (%)', 'TR 10Y (%)', 'CAGR 3Y (%)', 'CAGR 5Y (%)', 'CAGR 10Y (%)', 'SI Ann. (%)', 'Return As Of', 'Inception', 'Holdings', 'History', 'As Of'], + headers: ['#', 'Selected', 'Ticker', 'Fund Name', 'Type', 'NAV', '52W Range (%)', '52W Low ($)', '52W High ($)', 'Net Assets ($)', 'Expense (%)', 'Dividend Yield (%)', 'SEC Yield (%)', 'Frequency', 'YTD Return (%)', 'TR 1Y (%)', 'TR 3Y (%)', 'TR 5Y (%)', 'TR 10Y (%)', 'CAGR 3Y (%)', 'CAGR 5Y (%)', 'CAGR 10Y (%)', 'SI Ann. (%)', 'Return As Of', 'Inception', 'Holdings', 'History', 'As Of'], rows: filteredCatalogFunds().map((fund, index) => [ String(rowNumber(fund, index)), state.selected.has(fund.ticker) ? 'yes' : 'no', @@ -3961,6 +4027,9 @@ function currentExportRows(): { headers: string[]; rows: string[][]; scope: stri fund.name, fund.category, fund.nav || '', + numberCell(fund.range52w), + numberCell(fund.week52Low), + numberCell(fund.week52High), numberCell(fund.aumValue), numberCell(fund.terValue), numberCell(fund.dividendYield),