phase-2: extract shared utils

This commit is contained in:
Kazuma
2026-06-04 11:06:30 -04:00
parent b75e8bda72
commit 0a0a368b87
49 changed files with 299 additions and 120 deletions
+104
View File
@@ -12,6 +12,8 @@
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.0.0"
}
},
@@ -875,6 +877,16 @@
}
}
},
"node_modules/@sveltejs/load-config": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.1.1.tgz",
"integrity": "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 18.0.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz",
@@ -961,6 +973,22 @@
"node": ">= 0.4"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1149,6 +1177,16 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -1228,6 +1266,20 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/rollup": {
"version": "4.61.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz",
@@ -1272,6 +1324,19 @@
"fsevents": "~2.3.2"
}
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"mri": "^1.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/set-cookie-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
@@ -1328,6 +1393,31 @@
"node": ">=18"
}
},
"node_modules/svelte-check": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.6.0.tgz",
"integrity": "sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"@sveltejs/load-config": "0.1.1",
"chokidar": "^4.0.1",
"fdir": "^6.2.0",
"picocolors": "^1.0.0",
"sade": "^1.7.4"
},
"bin": {
"svelte-check": "bin/svelte-check"
},
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
"svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": ">=5.0.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1353,6 +1443,20 @@
"node": ">=6"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+2
View File
@@ -12,6 +12,8 @@
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.0.0"
}
}
+4 -1
View File
@@ -1,7 +1,10 @@
<script>
import { untrack } from 'svelte';
let { ctx, collapsible = false } = $props();
let expanded = $state(!collapsible); // collapsed by default when collapsible=true
// Read collapsible once for initial state — untrack avoids a reactive dep on the prop
let expanded = $state(untrack(() => !collapsible));
const cards = $derived.by(() => {
const b = ctx?.benchmarks ?? {};
+105
View File
@@ -0,0 +1,105 @@
/**
* Shared pure utility functions used across screener, portfolio, and safe-buys pages.
* All functions are stateless and framework-agnostic.
*/
// ── Signal ordering ───────────────────────────────────────────────────────────
export type Signal =
| '✅ Strong Buy'
| '⚡ Momentum'
| '🔄 Neutral'
| '⚠️ Speculation'
| '❌ Avoid';
const SIGNAL_ORDER: Record<string, number> = {
'✅ Strong Buy': 0,
'⚡ Momentum': 1,
'🔄 Neutral': 2,
'⚠️ Speculation': 3,
'❌ Avoid': 4,
};
/** Returns sort order for a signal string (lower = stronger). Unknown signals → 5. */
export function sigOrd(signal: string | null | undefined): number {
return SIGNAL_ORDER[signal ?? ''] ?? 5;
}
/** Sorts an array of screener result rows by signal strength (strongest first). */
export function sorted<T extends { signal?: string | null }>(arr: T[]): T[] {
return [...(arr ?? [])].sort((a, b) => sigOrd(a.signal) - sigOrd(b.signal));
}
// ── Verdict label helpers ─────────────────────────────────────────────────────
/**
* Converts a long verdict label into a short display string.
* e.g. "🟢 BUY (High Conviction)" → "Strong"
*/
export function verdictShort(label: string | null | undefined): string {
if (!label) return '—';
if (label.includes('High Conviction')) return 'Strong';
if (label.includes('Speculative')) return 'Speculative';
if (label.includes('BUY')) return 'Buy';
if (label.includes('Efficient')) return 'Efficient';
if (label.includes('Attractive')) return 'Attractive';
if (label.includes('Neutral')) return 'Hold';
if (label.includes('REJECT')) return 'Reject';
if (label.includes('Avoid')) return 'Avoid';
return label.replace(/[🟢🟡🔴]/u, '').trim();
}
/**
* Returns a CSS colour class ('green' | 'yellow' | 'red') based on
* the emoji prefix of a verdict label.
*/
export function vClass(label: string | null | undefined): 'green' | 'yellow' | 'red' {
if (label?.startsWith('🟢')) return 'green';
if (label?.startsWith('🟡')) return 'yellow';
return 'red';
}
// ── Number formatters ─────────────────────────────────────────────────────────
/** Formats a P/E ratio — e.g. 22.5 → "22.5x", null → "—" */
export function fmtPE(v: number | null | undefined): string {
return v != null ? v + 'x' : '—';
}
/** Full currency format — e.g. 1234.5 → "$1,234.50" */
export function fmt(n: number | null | undefined): string {
return n != null
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n)
: '—';
}
/** Compact currency format (no cents) — e.g. 1234.5 → "$1,235" */
export function fmtShort(n: number | null | undefined): string {
return n != null
? new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(n)
: '—';
}
/**
* Returns 'green' for non-negative G/L percentage, 'red' otherwise.
* Accepts string (e.g. "12.5") or number.
*/
export function glClass(pct: string | number | null | undefined): 'green' | 'red' {
return parseFloat(String(pct ?? 0)) >= 0 ? 'green' : 'red';
}
/**
* Returns a CSS colour class for a portfolio advice string based on its emoji prefix.
* 🟢 → 'green', 🟡 → 'yellow', 🟠 → 'orange', 🔴 → 'red', else 'gray'.
*/
export function advClass(advice: string | null | undefined): 'green' | 'yellow' | 'orange' | 'red' | 'gray' {
if (advice?.includes('🟢')) return 'green';
if (advice?.includes('🟡')) return 'yellow';
if (advice?.includes('🟠')) return 'orange';
if (advice?.includes('🔴')) return 'red';
return 'gray';
}
+3 -23
View File
@@ -1,5 +1,6 @@
<script>
import { screenTickers, fetchCatalysts, analyzeTickers } from '$lib/api.js';
import { sigOrd, sorted, verdictShort, vClass, fmtPE } from '$lib/utils.js';
import SignalBadge from '$lib/SignalBadge.svelte';
import Spinner from '$lib/Spinner.svelte';
@@ -72,25 +73,6 @@
}
}
const sigOrd = s => ({'✅ Strong Buy':0,'⚡ Momentum':1,'🔄 Neutral':2,'⚠️ Speculation':3,'❌ Avoid':4})[s] ?? 5;
const sorted = arr => [...(arr ?? [])].sort((a, b) => sigOrd(a.signal) - sigOrd(b.signal));
const verdictShort = label => {
if (!label) return '—';
if (label.includes('High Conviction')) return 'Strong';
if (label.includes('Speculative')) return 'Speculative';
if (label.includes('BUY')) return 'Buy';
if (label.includes('Efficient')) return 'Efficient';
if (label.includes('Attractive')) return 'Attractive';
if (label.includes('Neutral')) return 'Hold';
if (label.includes('REJECT')) return 'Reject';
if (label.includes('Avoid')) return 'Avoid';
return label.replace(/[🟢🟡🔴]/u, '').trim();
};
const vClass = label =>
label?.startsWith('🟢') ? 'green' : label?.startsWith('🟡') ? 'yellow' : 'red';
const getTab = type => activeTab[type] ?? 'inflated';
const setTab = (type, tab) => activeTab = { ...activeTab, [type]: tab };
@@ -98,8 +80,6 @@
const allAssets = $derived(results
? sorted([...results.STOCK, ...results.ETF, ...results.BOND])
: []);
const fmtPE = v => v != null ? v + 'x' : '—';
</script>
<div class="page">
@@ -340,8 +320,8 @@
<!-- ── LLM Analysis Sidebar ─────────────────────────────────────────────── -->
{#if sidebar.open}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="sidebar-backdrop" onclick={closeSidebar}></div>
<div class="sidebar-backdrop" role="button" tabindex="-1" aria-label="Close sidebar"
onclick={closeSidebar} onkeydown={(e) => e.key === 'Escape' && closeSidebar()}></div>
<aside class="sidebar">
<div class="sidebar-header">
<div class="sidebar-title">
+11 -34
View File
@@ -3,6 +3,7 @@
import MarketContext from '$lib/MarketContext.svelte';
import Spinner from '$lib/Spinner.svelte';
import { addHolding, removeHolding } from '$lib/api.js';
import { sigOrd, fmt, fmtShort, glClass, advClass } from '$lib/utils.js';
let { data: _data } = $props(); // unused — we load client-side
@@ -142,8 +143,6 @@
let sortCol = $state('ticker');
let sortDir = $state(1); // 1 = asc, -1 = desc
const sigOrd = s => ({'✅ Strong Buy':0,'⚡ Momentum':1,'🔄 Neutral':2,'⚠️ Speculation':3,'❌ Avoid':4})[s] ?? 5;
function toggleSort(col) {
if (sortCol === col) sortDir = sortDir === 1 ? -1 : 1;
else { sortCol = col; sortDir = 1; }
@@ -172,23 +171,6 @@
const sortIcon = (col) => sortCol !== col ? '⇅' : sortDir === 1 ? '↑' : '↓';
const fmt = (n) => n != null
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n)
: '—';
const fmtShort = (n) => n != null
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n)
: '—';
const glClass = (pct) => parseFloat(pct) >= 0 ? 'green' : 'red';
const advClass = (a) => {
if (a?.includes('🟢')) return 'green';
if (a?.includes('🟡')) return 'yellow';
if (a?.includes('🟠')) return 'orange';
if (a?.includes('🔴')) return 'red';
return 'gray';
};
const totalValue = $derived(data?.advice?.reduce((s, a) => s + (parseFloat(a.marketValue) || 0), 0) ?? 0);
const totalCost = $derived(data?.advice?.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0) ?? 0);
@@ -221,20 +203,20 @@
<div class="form-title">Add Holding</div>
<div class="form-row">
<div class="field">
<label>Ticker</label>
<input bind:value={form.ticker} placeholder="AAPL" maxlength="10" style="text-transform:uppercase" />
<label for="form-ticker">Ticker</label>
<input id="form-ticker" bind:value={form.ticker} placeholder="AAPL" maxlength="10" style="text-transform:uppercase" />
</div>
<div class="field">
<label>Shares</label>
<input bind:value={form.shares} placeholder="10" type="number" min="0" step="any" />
<label for="form-shares">Shares</label>
<input id="form-shares" bind:value={form.shares} placeholder="10" type="number" min="0" step="any" />
</div>
<div class="field">
<label>Cost Basis / share</label>
<input bind:value={form.costBasis} placeholder="150.00" type="number" min="0" step="any" />
<label for="form-cost">Cost Basis / share</label>
<input id="form-cost" bind:value={form.costBasis} placeholder="150.00" type="number" min="0" step="any" />
</div>
<div class="field">
<label>Type</label>
<select bind:value={form.type}>
<label for="form-type">Type</label>
<select id="form-type" bind:value={form.type}>
<option value="stock">Stock</option>
<option value="etf">ETF</option>
<option value="bond">Bond</option>
@@ -242,8 +224,8 @@
</select>
</div>
<div class="field">
<label>Source</label>
<input bind:value={form.source} placeholder="Robinhood" />
<label for="form-source">Source</label>
<input id="form-source" bind:value={form.source} placeholder="Robinhood" />
</div>
<button class="btn-save" onclick={submitHolding} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
@@ -569,11 +551,6 @@
margin-bottom: 14px;
}
.field input.readonly {
opacity: 0.5;
cursor: not-allowed;
}
.btn-cancel-edit {
background: transparent;
border: 1px solid #2d3f55;
+1 -16
View File
@@ -1,6 +1,7 @@
<script>
import MarketContext from '$lib/MarketContext.svelte';
import SignalBadge from '$lib/SignalBadge.svelte';
import { sorted, verdictShort, vClass } from '$lib/utils.js';
let { data } = $props();
@@ -14,22 +15,6 @@
const watchEtfs = $derived((data.ETF ?? []).filter(r => r.signal !== SIGNAL_STRONG));
const watchBonds = $derived((data.BOND ?? []).filter(r => r.signal !== SIGNAL_STRONG));
const sigOrd = s => ({'✅ Strong Buy':0,'⚡ Momentum':1,'🔄 Neutral':2,'⚠️ Speculation':3,'❌ Avoid':4})[s] ?? 5;
const sorted = arr => [...arr].sort((a, b) => sigOrd(a.signal) - sigOrd(b.signal));
const vClass = label =>
label?.startsWith('🟢') ? 'green' : label?.startsWith('🟡') ? 'yellow' : 'red';
const verdictShort = label => {
if (!label) return '—';
if (label.includes('Efficient')) return 'Efficient';
if (label.includes('Attractive')) return 'Attractive';
if (label.includes('Neutral')) return 'Hold';
if (label.includes('REJECT')) return 'Reject';
if (label.includes('Avoid')) return 'Avoid';
return label.replace(/[🟢🟡🔴]/u, '').trim();
};
const totalScreened = $derived((data.ETF?.length ?? 0) + (data.BOND?.length ?? 0));
const totalStrong = $derived(strongEtfs.length + strongBonds.length);
</script>
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"strict": true,
"allowJs": true,
"checkJs": false
}
}