phase-10.5: market screener ui enhancements

This commit is contained in:
Kazuma
2026-06-09 01:21:02 -04:00
parent 7bc242911e
commit fbadd7fb6e
45 changed files with 3054 additions and 539 deletions
+26 -4
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import { page, navigating } from '$app/stores';
import { goto } from '$app/navigation';
import '../styles/app.scss';
import Spinner from '$lib/components/shared/Spinner.svelte';
import { authStore } from '$lib/stores/auth.store.svelte.js';
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
@@ -9,6 +11,14 @@
// so the nav link highlights immediately on click, not after load completes.
const activePath = $derived($navigating?.to?.url?.pathname ?? $page.url.pathname);
// All routes except /auth/* require login
$effect(() => {
const path = $page.url.pathname;
if (!path.startsWith('/auth/') && !authStore.isLoggedIn) {
goto('/auth/login');
}
});
const navLabel = $derived(
activePath === '/portfolio' ? 'Loading portfolio…' :
activePath?.startsWith('/calls') ? 'Loading market calls…' :
@@ -21,10 +31,22 @@
<nav>
<span class="brand">📊 Market Screener</span>
<div class="links">
<a href="/" class:active={activePath === '/'}>Screener</a>
<a href="/portfolio" class:active={activePath === '/portfolio'}>Portfolio</a>
<a href="/calls" class:active={activePath?.startsWith('/calls')}>Market Calls</a>
<a href="/safe-buys" class:active={activePath === '/safe-buys'}>🛡 Safe Buys</a>
{#if authStore.isLoggedIn}
<a href="/" class:active={activePath === '/'}>Screener</a>
<a href="/portfolio" class:active={activePath === '/portfolio'}>Portfolio</a>
<a href="/calls" class:active={activePath?.startsWith('/calls')}>Market Calls</a>
<a href="/safe-buys" class:active={activePath === '/safe-buys'}>🛡 Safe Buys</a>
{/if}
</div>
<div class="nav-auth">
{#if authStore.isLoggedIn}
<span class="nav-user">{authStore.user?.email}</span>
<button class="btn-ghost btn-sm" onclick={() => { authStore.clearAuth(); goto('/auth/login'); }}>
Sign out
</button>
{:else}
<a href="/auth/login" class="btn-ghost btn-sm">Sign in</a>
{/if}
</div>
</nav>
+2 -90
View File
@@ -1,8 +1,6 @@
<script lang="ts">
import { screenerStore } from '$lib/stores/screener.store.svelte.js';
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';
@@ -23,14 +21,11 @@
});
</script>
<div class="page">
<div class="screener-page">
<!-- ── Toolbar ────────────────────────────────────────────────────── -->
<div class="toolbar">
<div class="toolbar-top">
<button onclick={() => s.reloadCatalysts()} disabled={s.loading || s.loadingCats} class="btn-catalyst">
{#if s.loadingCats}<Spinner size="sm" />{:else}📰 Today Market{/if}
</button>
<button
onclick={() => searchOpen = !searchOpen}
class="btn-search-toggle"
@@ -46,6 +41,7 @@
{#if searchOpen}
<div class="search-row">
<input
class="search-input"
bind:value={s.input}
placeholder="AAPL, MSFT, VOO …"
onkeydown={e => e.key === 'Enter' && s.screen()}
@@ -72,43 +68,6 @@
{/if}
{#if s.results && !s.loading && !s.loadingCats}
<!-- ── Signal Summary ───────────────────────────────────────────── -->
<section class="section">
<div class="section-header">
<h2>Signal Summary</h2>
<span class="count">{s.allAssets.length} assets</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-ticker">Ticker</th>
<th>Type</th>
<th>Signal</th>
<th>Mkt-Adjusted</th>
<th>Fundamental</th>
<th title="Market cap tier (stocks only)">Cap</th>
<th title="Growth / style classification (stocks only)">Style</th>
</tr>
</thead>
<tbody>
{#each s.allAssets as r}
{@const dm = r.asset.displayMetrics ?? {}}
<tr>
<td class="ticker">{r.asset.ticker}</td>
<td><span class="tag">{r.asset.type}</span></td>
<td><SignalBadge signal={r.signal} /></td>
<td><VerdictPill label={r.inflated.label} /></td>
<td><VerdictPill label={r.fundamental.label} /></td>
<td class="dim-cell">{dm['Cap Tier'] ?? '—'}</td>
<td class="dim-cell">{dm['Style'] ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
<!-- ── Per-type detail tables ────────────────────────────────────── -->
{#each (['STOCK', 'ETF', 'BOND'] as const) as type}
{#if s.results[type]?.length}
@@ -136,50 +95,3 @@
</div>
<AnalysisSidebar sidebar={s.sidebar} onClose={() => s.closeSidebar()} />
<style>
.page { max-width: 1400px; padding-bottom: 60px; }
.toolbar { margin-bottom: 20px; display: flex; flex-direction: column; gap: 10px; }
.toolbar-top { display: flex; align-items: center; gap: 8px; }
.search-row { display: flex; gap: 8px; align-items: center; }
input {
flex: 1;
min-width: 0;
background: var(--bg-card);
border: 1px solid var(--border-input);
border-radius: var(--radius-md);
color: var(--text-secondary);
padding: 10px var(--space-lg);
font-size: var(--fs-md);
font-family: 'SF Mono', 'Fira Code', monospace;
letter-spacing: 0.02em;
outline: none;
transition: border-color var(--transition);
&:focus { border-color: var(--blue); box-shadow: 0 0 0 2px #3b82f620; }
}
.btn-search-toggle {
background: var(--bg-card);
color: var(--text-dim);
border: 1px solid var(--border-input);
font-size: 12px;
padding: 8px var(--space-lg);
&:hover { background: #263347; color: var(--text-muted); }
}
.screened-at {
margin-left: auto;
font-size: var(--fs-sm);
color: var(--text-dimmer);
}
.dim-cell { font-size: var(--fs-sm); color: var(--text-dim); white-space: nowrap; }
.error-list { padding: 12px var(--space-xl); display: flex; flex-direction: column; gap: 6px; }
.error-item { color: var(--text-dim); font-size: 12px; }
.error-item :global(.ticker) { color: var(--red); font-weight: 700; margin-right: 8px; }
</style>
@@ -0,0 +1,123 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.store.svelte.js';
const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
let email = $state('');
let error = $state<string | null>(null);
let success = $state(false);
let loading = $state(false);
$effect(() => {
if (authStore.isLoggedIn) goto('/');
});
async function handleSubmit(e: Event) {
e.preventDefault();
error = null;
loading = true;
try {
const res = await fetch(`${BASE}/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!res.ok) {
const { error: msg } = await res.json().catch(() => ({ error: 'Request failed' }));
throw new Error(msg);
}
success = true;
} catch (err) {
error = err instanceof Error ? err.message : 'Something went wrong';
} finally {
loading = false;
}
}
</script>
<div class="login-wrap">
<form class="login-form" onsubmit={handleSubmit}>
<h1 class="login-title">Forgot password</h1>
<p class="login-subtitle">Enter your email and check the server console for a reset link.</p>
{#if error}
<div class="error-banner">{error}</div>
{/if}
{#if success}
<div class="success-banner">
Reset link printed to server console. Copy it and open it in your browser.
</div>
<p class="auth-switch">
<a href="/auth/login">Back to sign in</a>
</p>
{:else}
<label class="field">
<span>Email</span>
<input type="email" autocomplete="email" required bind:value={email} disabled={loading} />
</label>
<button type="submit" class="btn-primary" disabled={loading}>
{loading ? 'Sending…' : 'Send reset link'}
</button>
<p class="auth-switch">
<a href="/auth/login">Back to sign in</a>
</p>
{/if}
</form>
</div>
<style>
.login-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 2rem;
}
.login-form {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.login-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
}
.login-subtitle {
margin: -0.75rem 0 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.success-banner {
background: color-mix(in srgb, var(--signal-buy) 15%, transparent);
border: 1px solid var(--signal-buy);
color: var(--signal-buy);
border-radius: 6px;
padding: 0.75rem 1rem;
font-size: var(--fs-md);
}
.auth-switch {
text-align: center;
font-size: var(--fs-md);
color: var(--text-dim);
margin: 0;
a {
color: var(--blue);
text-decoration: none;
font-weight: 500;
}
}
</style>
+120
View File
@@ -0,0 +1,120 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { login } from '$lib/api/auth.js';
import { authStore } from '$lib/stores/auth.store.svelte.js';
let email = $state('');
let password = $state('');
let error = $state<string | null>(null);
let loading = $state(false);
// If already logged in, redirect to home
$effect(() => {
if (authStore.isLoggedIn) goto('/');
});
async function handleSubmit(e: Event) {
e.preventDefault();
error = null;
loading = true;
try {
const { token, user } = await login(email, password);
authStore.setAuth(token, user);
await goto('/');
} catch (err) {
error = err instanceof Error ? err.message : 'Login failed';
} finally {
loading = false;
}
}
</script>
<div class="login-wrap">
<form class="login-form" onsubmit={handleSubmit}>
<h1 class="login-title">Market Screener</h1>
<p class="login-subtitle">Sign in to continue</p>
{#if error}
<div class="error-banner">{error}</div>
{/if}
<label class="field">
<span>Email</span>
<input
type="email"
autocomplete="email"
required
bind:value={email}
disabled={loading}
/>
</label>
<label class="field">
<span>Password</span>
<input
type="password"
autocomplete="current-password"
required
minlength="8"
bind:value={password}
disabled={loading}
/>
</label>
<button type="submit" class="btn-primary" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
<p class="auth-switch">
<a href="/auth/forgot-password">Forgot password?</a>
</p>
<p class="auth-switch">
Don't have an account? <a href="/auth/register">Register</a>
</p>
</form>
</div>
<style>
/* Auth page layout only — input styles come from global _forms.scss */
.login-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 2rem;
}
.login-form {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.login-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
}
.login-subtitle {
margin: -0.75rem 0 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.auth-switch {
text-align: center;
font-size: var(--fs-md);
color: var(--text-dim);
margin: 0;
a {
color: var(--blue);
text-decoration: none;
font-weight: 500;
}
}
</style>
+1
View File
@@ -0,0 +1 @@
export const ssr = false;
+144
View File
@@ -0,0 +1,144 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { register } from '$lib/api/auth.js';
import { authStore } from '$lib/stores/auth.store.svelte.js';
let email = $state('');
let password = $state('');
let confirm = $state('');
let inviteCode = $state('');
let error = $state<string | null>(null);
let loading = $state(false);
$effect(() => {
if (authStore.isLoggedIn) goto('/');
});
async function handleSubmit(e: Event) {
e.preventDefault();
error = null;
if (password !== confirm) {
error = 'Passwords do not match';
return;
}
loading = true;
try {
const { token, user } = await register(email, password, 'trader', inviteCode);
authStore.setAuth(token, user);
await goto('/');
} catch (err) {
error = err instanceof Error ? err.message : 'Registration failed';
} finally {
loading = false;
}
}
</script>
<div class="login-wrap">
<form class="login-form" onsubmit={handleSubmit}>
<h1 class="login-title">Create account</h1>
<p class="login-subtitle">Market Screener</p>
{#if error}
<div class="error-banner">{error}</div>
{/if}
<label class="field">
<span>Email</span>
<input type="email" autocomplete="email" required bind:value={email} disabled={loading} />
</label>
<label class="field">
<span>Password <small>(min 8 characters)</small></span>
<input
type="password"
autocomplete="new-password"
required
minlength="8"
bind:value={password}
disabled={loading}
/>
</label>
<label class="field">
<span>Confirm password</span>
<input
type="password"
autocomplete="new-password"
required
minlength="8"
bind:value={confirm}
disabled={loading}
/>
</label>
<label class="field">
<span>Invite code</span>
<input
type="text"
required
placeholder="Ask the admin for an invite code"
bind:value={inviteCode}
disabled={loading}
/>
</label>
<button type="submit" class="btn-primary" disabled={loading}>
{loading ? 'Creating account…' : 'Create account'}
</button>
<p class="auth-switch">
Already have an account? <a href="/auth/login">Sign in</a>
</p>
</form>
</div>
<style>
/* Auth page layout only — input/select styles come from global _forms.scss */
.login-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 2rem;
}
.login-form {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.login-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
}
.login-subtitle {
margin: -0.75rem 0 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.field small {
font-weight: 400;
color: var(--text-dim);
}
.auth-switch {
text-align: center;
font-size: var(--fs-md);
color: var(--text-dim);
margin: 0;
a {
color: var(--blue);
text-decoration: none;
font-weight: 500;
}
}
</style>
+1
View File
@@ -0,0 +1 @@
export const ssr = false;
@@ -0,0 +1,157 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.store.svelte.js';
const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
const token = $derived($page.url.searchParams.get('token') ?? '');
let password = $state('');
let confirm = $state('');
let error = $state<string | null>(null);
let success = $state(false);
let loading = $state(false);
$effect(() => {
if (authStore.isLoggedIn) goto('/');
});
async function handleSubmit(e: Event) {
e.preventDefault();
error = null;
if (password !== confirm) {
error = 'Passwords do not match';
return;
}
if (!token) {
error = 'Missing reset token — please use the full link from the console.';
return;
}
loading = true;
try {
const res = await fetch(`${BASE}/auth/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
});
if (!res.ok) {
const { error: msg } = await res.json().catch(() => ({ error: 'Reset failed' }));
throw new Error(msg);
}
success = true;
} catch (err) {
error = err instanceof Error ? err.message : 'Something went wrong';
} finally {
loading = false;
}
}
</script>
<div class="login-wrap">
<form class="login-form" onsubmit={handleSubmit}>
<h1 class="login-title">Reset password</h1>
<p class="login-subtitle">Choose a new password for your account.</p>
{#if error}
<div class="error-banner">{error}</div>
{/if}
{#if success}
<div class="success-banner">
Password updated. You can now sign in with your new password.
</div>
<a href="/auth/login" class="btn-primary" style="text-align:center;">Go to sign in</a>
{:else}
<label class="field">
<span>New password <small>(min 8 characters)</small></span>
<input
type="password"
autocomplete="new-password"
required
minlength="8"
bind:value={password}
disabled={loading}
/>
</label>
<label class="field">
<span>Confirm password</span>
<input
type="password"
autocomplete="new-password"
required
minlength="8"
bind:value={confirm}
disabled={loading}
/>
</label>
<button type="submit" class="btn-primary" disabled={loading}>
{loading ? 'Updating…' : 'Set new password'}
</button>
<p class="auth-switch">
<a href="/auth/login">Back to sign in</a>
</p>
{/if}
</form>
</div>
<style>
.login-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 2rem;
}
.login-form {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.login-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
}
.login-subtitle {
margin: -0.75rem 0 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.success-banner {
background: color-mix(in srgb, var(--signal-buy) 15%, transparent);
border: 1px solid var(--signal-buy);
color: var(--signal-buy);
border-radius: 6px;
padding: 0.75rem 1rem;
font-size: var(--fs-md);
}
.auth-switch {
text-align: center;
font-size: var(--fs-md);
color: var(--text-dim);
margin: 0;
a {
color: var(--blue);
text-decoration: none;
font-weight: 500;
}
}
.field small {
font-weight: 400;
color: var(--text-dim);
}
</style>
+1 -58
View File
@@ -27,7 +27,7 @@
const totalStrong = $derived(strongEtfs.length + strongBonds.length);
</script>
<div class="page">
<div class="safe-buys-page">
<div class="page-header">
<div>
<h1>🛡 Safe Buys</h1>
@@ -231,60 +231,3 @@
{/if}
{/if}
</div>
<style>
/* ── Page ── unique to this route ──────────────────────────────── */
.page { max-width: 1100px; padding-bottom: 60px; }
.page-header { margin-bottom: 20px; }
h1 { font-size: var(--fs-2xl); font-weight: 700; color: var(--text-primary); margin-bottom: 6px; }
.subtitle { font-size: 12px; color: var(--text-dimmer); line-height: 1.5; }
.subtitle strong { color: var(--text-muted); }
/* ── Strong Buy banner ───────────────────────────────────────────── */
.strong-header { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.strong-badge {
font-size: 12px;
font-weight: 700;
color: var(--green);
background: var(--green-bg);
padding: 4px 14px;
border-radius: var(--radius-pill);
}
.strong-sub { font-size: var(--fs-sm); color: var(--text-dimmer); }
.empty-strong {
padding: var(--space-3xl) 20px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
font-size: var(--fs-md);
color: var(--text-dim);
text-align: center;
margin-bottom: 24px;
line-height: 1.6;
}
/* ── Watch List ──────────────────────────────────────────────────── */
.watch-header { display: flex; align-items: center; gap: 12px; margin-top: 28px; margin-bottom: 12px; }
.watch-label {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
background: var(--bg-card);
padding: 4px 14px;
border-radius: var(--radius-pill);
}
.watch-sub { font-size: var(--fs-sm); color: var(--text-dimmer); }
/* Watch sections are slightly dimmed — hover to focus */
.watch-section { opacity: 0.75; }
.watch-section:hover { opacity: 1; transition: opacity 0.2s; }
/* ── Score cell ─────────────────────────────────────────────────── */
.score { color: var(--text-dimmer); font-size: var(--fs-sm); }
</style>