test: mock AnthropicClient in analyze tests to prevent live API calls
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
|
||||
interface FormData {
|
||||
title: string;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './shared/index.js';
|
||||
export * from './screener/index.js';
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
import type { SidebarState } from '$lib/types.js';
|
||||
|
||||
let { sidebar, onClose }: { sidebar: SidebarState; onClose: () => void } = $props();
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { sigOrd, sorted } from '$lib/utils.js';
|
||||
import VerdictPill from '$lib/VerdictPill.svelte';
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import VerdictPill from '$lib/components/shared/VerdictPill.svelte';
|
||||
import SignalBadge from '$lib/components/shared/SignalBadge.svelte';
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
import type { AssetType, AssetResult } from '$lib/types.js';
|
||||
|
||||
let {
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as AssetTable } from './AssetTable.svelte';
|
||||
export { default as AnalysisSidebar } from './AnalysisSidebar.svelte';
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as Spinner } from './Spinner.svelte';
|
||||
export { default as VerdictPill } from './VerdictPill.svelte';
|
||||
export { default as SignalBadge } from './SignalBadge.svelte';
|
||||
export { default as MarketContext } from './MarketContext.svelte';
|
||||
export { default as MarketContextStrip } from './MarketContextStrip.svelte';
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
import SignalBadge from '$lib/components/shared/SignalBadge.svelte';
|
||||
import { sigOrd, fmt, fmtShort, glClass, advClass } from '$lib/utils.js';
|
||||
import type { AdviceRow } from '$lib/types.js';
|
||||
|
||||
|
||||
+4
-103
@@ -1,105 +1,6 @@
|
||||
/**
|
||||
* Shared pure utility functions used across screener, portfolio, and safe-buys pages.
|
||||
* All functions are stateless and framework-agnostic.
|
||||
* Backward-compatibility shim.
|
||||
* New code should import from '$lib/utils/index.js' or the specific submodule.
|
||||
* Existing '$lib/utils.js' imports continue to work unchanged.
|
||||
*/
|
||||
|
||||
// ── 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';
|
||||
}
|
||||
export * from './utils/index.js';
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Number and currency formatting utilities.
|
||||
*/
|
||||
|
||||
/** 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)
|
||||
: '—';
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './sorting.js';
|
||||
export * from './verdicts.js';
|
||||
export * from './formatting.js';
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Signal ordering and sorting utilities.
|
||||
*/
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Verdict label helpers — convert long verdict strings to short display values
|
||||
* and derive CSS colour classes from emoji prefixes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page, navigating } from '$app/stores';
|
||||
import '../styles/app.scss';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { screenerStore } from '$lib/stores/screener.store.svelte.js';
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import VerdictPill from '$lib/VerdictPill.svelte';
|
||||
import MarketContextStrip from '$lib/MarketContextStrip.svelte';
|
||||
import AssetTable from '$lib/AssetTable.svelte';
|
||||
import AnalysisSidebar from '$lib/AnalysisSidebar.svelte';
|
||||
import SignalBadge from '$lib/components/shared/SignalBadge.svelte';
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
import VerdictPill from '$lib/components/shared/VerdictPill.svelte';
|
||||
import MarketContextStrip from '$lib/components/shared/MarketContextStrip.svelte';
|
||||
import AssetTable from '$lib/components/screener/AssetTable.svelte';
|
||||
import AnalysisSidebar from '$lib/components/screener/AnalysisSidebar.svelte';
|
||||
|
||||
const s = screenerStore;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { portfolioStore } from '$lib/stores/portfolio.store.svelte.js';
|
||||
import MarketContext from '$lib/MarketContext.svelte';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import MarketContext from '$lib/components/shared/MarketContext.svelte';
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
import AddHoldingForm from '$lib/portfolio/AddHoldingForm.svelte';
|
||||
import AdviceTable from '$lib/portfolio/AdviceTable.svelte';
|
||||
import AccountsTable from '$lib/portfolio/AccountsTable.svelte';
|
||||
@@ -27,7 +27,7 @@
|
||||
{:else if p.loadError}
|
||||
<div class="error">{p.loadError}</div>
|
||||
|
||||
{:else if p.data?.advice}
|
||||
{:else if p.data}
|
||||
<div class="portfolio-toolbar">
|
||||
<button class="btn-add" onclick={() => p.formOpen ? p.closeForm() : p.openForm()}>
|
||||
{p.formOpen ? '✕ Cancel' : '+ Add Holding'}
|
||||
@@ -41,14 +41,20 @@
|
||||
<AddHoldingForm saving={p.saving} error={p.formError} onSubmit={d => p.add(d)} onClose={() => p.closeForm()} />
|
||||
{/if}
|
||||
|
||||
{#if p.data.marketContext}
|
||||
<MarketContext ctx={p.data.marketContext} collapsible={true} />
|
||||
{/if}
|
||||
{#if p.data.advice.length === 0 && !p.formOpen}
|
||||
<div class="empty-state">
|
||||
<p>No holdings yet. Add your first position to get started.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if p.data.marketContext}
|
||||
<MarketContext ctx={p.data.marketContext} collapsible={true} />
|
||||
{/if}
|
||||
|
||||
<AdviceTable rows={p.data.advice} onUpdate={(t, d) => p.update(t, d)} onDelete={t => p.remove(t)} />
|
||||
<AdviceTable rows={p.data.advice} onUpdate={(t, d) => p.update(t, d)} onDelete={t => p.remove(t)} />
|
||||
|
||||
{#if p.data.personalFinance}
|
||||
<AccountsTable pf={p.data.personalFinance} />
|
||||
{#if p.data.personalFinance}
|
||||
<AccountsTable pf={p.data.personalFinance} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import MarketContext from '$lib/MarketContext.svelte';
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
import VerdictPill from '$lib/VerdictPill.svelte';
|
||||
import MarketContext from '$lib/components/shared/MarketContext.svelte';
|
||||
import SignalBadge from '$lib/components/shared/SignalBadge.svelte';
|
||||
import VerdictPill from '$lib/components/shared/VerdictPill.svelte';
|
||||
import { sorted } from '$lib/utils.js';
|
||||
import type { AssetResult, MarketContext as MarketContextType } from '$lib/types.js';
|
||||
|
||||
|
||||
@@ -24,6 +24,19 @@
|
||||
&:hover { background: var(--blue-darker); }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-dimmer);
|
||||
font-size: var(--fs-md);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 20px;
|
||||
|
||||
p { margin: 0; }
|
||||
}
|
||||
|
||||
.refreshing-hint {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-dimmer);
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
"$lib": ["./src/lib"],
|
||||
"$lib/*": ["./src/lib/*"],
|
||||
"$app/types": ["./.svelte-kit/types/index.d.ts"],
|
||||
"$types": ["../server/types"],
|
||||
"$types/*": ["../server/types/*"]
|
||||
"$types": ["../server/domains/shared/types"],
|
||||
"$types/*": ["../server/domains/shared/types/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user