phase-10.5: market screener ui enhancements

This commit is contained in:
saikiranvella
2026-06-09 01:21:02 -04:00
parent 3c321a4a79
commit 5c8cd8935a
45 changed files with 3054 additions and 539 deletions
+8
View File
@@ -12,3 +12,11 @@ SIMPLEFIN_SETUP_TOKEN=
# Remove SIMPLEFIN_SETUP_TOKEN once this appears. # Remove SIMPLEFIN_SETUP_TOKEN once this appears.
# #
# SIMPLEFIN_ACCESS_URL=https://user:token@beta-bridge.simplefin.org/simplefin # SIMPLEFIN_ACCESS_URL=https://user:token@beta-bridge.simplefin.org/simplefin
# ── Docker / Production ───────────────────────────────────────────────────────
# Bearer token for all API routes (optional — leave blank to disable)
API_KEY=
# The public origin of your UI, used by Fastify for CORS
# Set to your domain when behind nginx (e.g. https://screener.example.com)
CLIENT_ORIGIN=http://localhost
+56
View File
@@ -1257,6 +1257,62 @@ lib/types/
**Timeline:** 4-6 weeks (after Phase 10). **Timeline:** 4-6 weeks (after Phase 10).
---
### Phase 10.5 — Implementation Status (June 2026)
#### ✅ Completed
| Item | Details |
|------|---------|
| **Column sort** | Click any header to sort asc/desc; sort icon indicates active column |
| **Inline filter row** | Per-column `<thead>` filter row — no external sidebar needed for quick filters |
| **Verdict filter** | Dropdown in filter row with per-asset-type label sets (Strong Buy, Momentum, etc.) |
| **Style filter** | Dropdown to filter by growth style (High Growth, Turnaround, Value, etc.) |
| **Cap tier filter** | Dropdown to filter by market cap segment (Mega, Large, Mid, Small, Micro) |
| **Merged Signal + Verdict column** | Single `sv-pill` badge replaces two separate columns; color-coded by signal class |
| **Dot-scale score** | `●●●●○` 5-dot scale derived from raw score, with numeric beside it |
| **Flags hover badge** | `⚠ N` count badge; hover expands into tooltip showing individual risk flag pills |
| **Row lift highlight** | Brighter left border accent + lighter background on hover/open; sticky column background inherits row color (fixed stacking context clipping) |
| **Market strip rounding** | 10Y, VIX, REIT Yld → `.toFixed(1)`; IG Sprd → `.toFixed(2)`; P/E ratios → `fmtPE()` |
| **Regime badge colors** | `HIGH` = amber, `NORMAL` = muted gray, `LOW` = blue (driven by `data-regime` CSS attribute) |
| **Signal Summary hidden** | Removed from `+page.svelte` — table section no longer renders |
#### 🔲 Next Up (Phase 10.5 Remaining)
These five items are the immediate next build targets, in priority order:
**1. Slide-in tearsheet panel** (`10.5d`)
- Replace the current inline expand row with a 420px right-side slide-in panel (CSS `transform: translateX` animation, 0.2s)
- Panel triggered by row click; closes via `[X]` button or `Escape`
- Sticky header shows ticker + price; body scrolls independently
- All current inline-expand content (display metrics grid) moves here as the first section
**2. P/E + ROE + 52W columns in main table** (`10.5c`)
- Add three numeric columns: `P/E` (from `peRatio`), `ROE` (from `roe`), `52W Chg` (from `52W Chg` display metric)
- Right-aligned monospace; color-coded (P/E neutral, ROE green if >15%, 52W green/red by sign)
- Replace the existing free-form metric columns that show different fields per asset type
**3. Valuation context (peer comparison) as first tearsheet section** (`10.5d §2`)
- Table inside tearsheet: `Metric | THIS | Sector | S&P500`
- Rows: P/E, PEG, ROE — pull sector avg and market avg from `marketContext.benchmarks`
- Makes the tearsheet immediately useful before any LLM analysis is run
**4. Numeric range filters for P/E and ROE** (`10.5b`)
- Add two range inputs to the filter row (or a compact filter popover): `P/E max` and `ROE min`
- Filter applied client-side against `displayMetrics` values; integrates with existing `filteredRows()` chain
- Input type `number`, placeholder `P/E ≤` / `ROE ≥`
**5. Threshold sensitivity block in tearsheet** (`10.5d §5`)
- Section inside tearsheet: "WHAT-IF SCENARIOS"
- Three computed rows:
- If P/E compresses to `currentPE * 0.75`: stock price impact %
- If growth slows to half current rate: stock price impact % (via DCF delta)
- If rates rise 100bps: discount rate impact on DCF intrinsic value
- All computed client-side from existing `dcfIntrinsicValue`, `peRatio`, `earningsGrowth` fields — no extra API call
---
### 10.5a — UI Architecture: Three-Layer Layout ### 10.5a — UI Architecture: Three-Layer Layout
``` ```
+66
View File
@@ -882,3 +882,69 @@ A: Not yet. Only consider if:
- You have $20K+ to spend on GPU infrastructure - You have $20K+ to spend on GPU infrastructure
For now, optimize prompts instead. Good prompt beats fine-tuned model. For now, optimize prompts instead. Good prompt beats fine-tuned model.
---
## Future Enhancements (Unscheduled)
### FE-1 — Pinned Stocks Watchlist
**Concept:** User can pin any stock from the screener table. Pinned stocks appear in a persistent sidebar or dedicated panel showing:
- Minimal summary: ticker, current price, signal badge, score
- Price-since-pin sparkline — a small inline chart showing how price moved from the day the stock was pinned to today
- Quick unpin button
**Data requirements:**
- Store `{ ticker, pinnedAt, pinnedPrice }` in SQLite (`pinned_stocks` table)
- Fetch daily OHLC history from Yahoo Finance for the period `pinnedAt → now` to power the sparkline
- API: `GET /api/pins` (list), `POST /api/pins` (add), `DELETE /api/pins/:ticker` (remove), `GET /api/pins/:ticker/history` (OHLC since pin)
**UI notes:**
- Pin button (📌) appears on hover of each summary row in the screener table
- Pinned panel can live in a collapsible drawer at the bottom, or a fixed right sidebar
- Sparkline: use a lightweight SVG path (no charting library needed); green if price above pin price, red if below
- On click of the sparkline, open a larger chart modal (Phase FE-1b — can use TradingView widget or Chart.js)
**Why deferred:** Requires persistent per-user state (needs Phase 11 auth to be meaningful across sessions). Build after Phase 11.
---
### FE-2 — Column Header Tooltips ("Why does this matter?")
**Concept:** Clicking a column header in the screener summary row opens a small popover explaining:
- What the metric measures
- What a good vs bad value looks like
- How the screener uses it in scoring
This turns the table into a learning tool — users understand *why* P/E or ROE matters, not just what the number is.
**Suggested content per column:**
| Header | What to explain |
|--------|----------------|
| **Score** | Weighted sum of all factor scores. >6 = quality, <4 = weak. Gates must pass first — score only fires if gates are cleared. |
| **Signal** | Compares two scoring lenses (Mkt-Adjusted vs Graham). Strong Buy = passes both. Momentum = passes inflated only. |
| **P/E** | Price-to-earnings. Lower = cheaper relative to earnings. Gate: <15x (Graham) or <SPY×1.5 (market-adjusted). >30x warrants scrutiny unless high growth. |
| **PEG** | P/E ÷ growth rate. Normalises valuation for growth. <1.0 = paying less than growth justifies. Lynch's standard. |
| **ROE%** | Return on equity — how efficiently the company uses shareholder money. >15% is healthy; >30% is exceptional. Weighted 3× in scoring. |
| **OpMgn%** | Operating margin — profit per dollar of revenue before interest and tax. Measures business efficiency. |
| **FCF%** | Free cash flow yield — cash the business actually generates relative to price. Negative = cash-burning; gate fails. |
| **D/E** | Debt-to-equity. Measures leverage. Gate: <1.5× (general), <2.0× (tech). Higher than 2× raises distress risk. |
| **52W Chg** | Total price return over last 52 weeks. Positive momentum is healthy; >+50% may signal overextension. |
| **From High** | % below the 52-week high. -5% to -15% is a typical dip zone; ≤-30% triggers a risk flag. |
| **Analyst** | Yahoo consensus (1=Strong Buy, 5=Strong Sell). Requires ≥3 analysts to fire. ≤2.0 adds points; >4.0 subtracts. |
| **DCF Safety** | Margin of safety from a two-stage DCF model. Positive = stock appears undervalued vs intrinsic value. Only fires when FCF > 0. |
| **Cap** | Market cap tier: Mega (>$200B), Large ($10B+), Mid ($2B+), Small ($300M+), Micro (<$300M). Smaller = higher risk + volatility. |
| **Style** | Growth classification from revenue + earnings growth rate. High Growth = ≥15% revenue growth. Value = low growth + ≥3% yield. |
**Implementation approach:**
- Add `data-tip` attribute to each `<th>` in `AssetTable.svelte`
- On click, show a positioned `<div class="col-tip">` anchored to the header
- Dismiss on outside click or Escape
- No library needed — pure Svelte `$state` + CSS positioning
- Mobile: tip opens as a bottom sheet modal
**Why not `title` attribute?** `title` tooltips are unstyled, non-interactive, and don't work on touch. A custom popover lets you format the content properly and include a "Good range" callout.
**Why deferred:** Nice-to-have educational feature. Build after the core screener UI (Phase 10.5) is stable.
+86
View File
@@ -0,0 +1,86 @@
# market-screener.conf
# Drop this in /etc/nginx/sites-available/ and symlink to sites-enabled/
# Replace YOUR_DOMAIN with your actual domain or server IP.
upstream market_screener_ui {
server 127.0.0.1:3001;
}
upstream market_screener_api {
server 127.0.0.1:3000;
}
server {
listen 80;
server_name YOUR_DOMAIN;
# Redirect HTTP → HTTPS (uncomment once you have a cert)
# return 301 https://$host$request_uri;
# --- API routes ---
location /api/ {
proxy_pass http://market_screener_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
location /health {
proxy_pass http://market_screener_api;
}
# Polygon / other webhook paths hitting /webhooks/*
location /webhooks/ {
proxy_pass http://market_screener_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
# --- SvelteKit UI (everything else) ---
location / {
proxy_pass http://market_screener_ui;
proxy_http_version 1.1;
# Required for SvelteKit HMR in dev; harmless in prod
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
}
# --- HTTPS block (uncomment + fill in after running certbot) ---
# server {
# listen 443 ssl http2;
# server_name YOUR_DOMAIN;
#
# ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
#
# location /api/ {
# proxy_pass http://market_screener_api;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
#
# location / {
# proxy_pass http://market_screener_ui;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# }
+69 -5
View File
@@ -1,3 +1,4 @@
import { randomBytes } from 'crypto';
import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify'; import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit'; import rateLimit from '@fastify/rate-limit';
@@ -7,6 +8,8 @@ import { ScreenerController, ScreenerEngine, AnalyzeController } from './domains
import { FinanceController } from './domains/finance'; import { FinanceController } from './domains/finance';
import { PortfolioAdvisor } from './domains/portfolio'; import { PortfolioAdvisor } from './domains/portfolio';
import { CallsController, CalendarService } from './domains/calls'; import { CallsController, CalendarService } from './domains/calls';
import { AuthController, AuthService, UserStore, verifyJwt } from './domains/auth';
import type { TokenPayload } from './domains/auth';
// Shared infrastructure // Shared infrastructure
import { import {
@@ -27,6 +30,36 @@ interface BuildAppOptions {
db?: DatabaseConnection; db?: DatabaseConnection;
} }
// ── JWT auth helpers ─────────────────────────────────────────────────────────
/** Fastify hook that requires a valid JWT. Attaches payload to req.user. */
function makeAuthGuard(secret: string) {
return async (req: FastifyRequest, reply: FastifyReply) => {
const header = req.headers['authorization'] ?? '';
if (!header.startsWith('Bearer ')) {
return reply.code(401).send({ error: 'Missing token' });
}
try {
(req as FastifyRequest & { user: TokenPayload }).user = verifyJwt(header.slice(7), secret);
} catch {
return reply.code(401).send({ error: 'Invalid or expired token' });
}
};
}
/** Fastify hook that requires a specific role (must run after authGuard). */
function makeRoleGuard(required: 'trader' | 'admin') {
return async (req: FastifyRequest, reply: FastifyReply) => {
const user = (req as FastifyRequest & { user?: TokenPayload }).user;
if (!user) return reply.code(401).send({ error: 'Unauthorized' });
// admin passes every role check; trader passes trader check
const roleRank: Record<string, number> = { viewer: 0, trader: 1, admin: 2 };
if ((roleRank[user.role] ?? 0) < (roleRank[required] ?? 99)) {
return reply.code(403).send({ error: 'Forbidden' });
}
};
}
// ── Adding a new domain ─────────────────────────────────────────────── // ── Adding a new domain ───────────────────────────────────────────────
// 1. Create: server/domains/<domain>/ directory structure // 1. Create: server/domains/<domain>/ directory structure
// 2. Move controllers, services, types to the domain // 2. Move controllers, services, types to the domain
@@ -51,8 +84,8 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
const API_KEY = process.env.API_KEY; const API_KEY = process.env.API_KEY;
if (API_KEY) { if (API_KEY) {
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
// Skip auth for health check and OPTIONS preflight // Skip auth for health check, OPTIONS preflight, and auth routes
if (req.url === '/health' || req.method === 'OPTIONS') return; if (req.url === '/health' || req.method === 'OPTIONS' || req.url.startsWith('/auth/')) return;
const header = req.headers['authorization'] ?? ''; const header = req.headers['authorization'] ?? '';
if (header !== `Bearer ${API_KEY}`) { if (header !== `Bearer ${API_KEY}`) {
return reply.code(401).send({ error: 'Unauthorized' }); return reply.code(401).send({ error: 'Unauthorized' });
@@ -64,11 +97,16 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
const db = const db =
injectedDb ?? injectedDb ??
(() => { (() => {
const rawDb = createDb(); const rawDb = createDb(process.env.DB_PATH ?? './market-screener.db');
const audit = new QueryAudit(); const audit = new QueryAudit();
return new DatabaseConnection(rawDb, { audit, logSlowQueries: 100 }); return new DatabaseConnection(rawDb, { audit, logSlowQueries: 100 });
})(); })();
// ── JWT secret ────────────────────────────────────────────────────────────
const JWT_SECRET = process.env.JWT_SECRET ?? 'dev-secret-change-in-production';
const authGuard = makeAuthGuard(JWT_SECRET);
const traderGuard = makeRoleGuard('trader');
// Services and clients // Services and clients
const yahoo = new YahooFinanceClient(); const yahoo = new YahooFinanceClient();
const benchmark = new BenchmarkProvider(yahoo, { logger: noopLogger }); const benchmark = new BenchmarkProvider(yahoo, { logger: noopLogger });
@@ -78,10 +116,36 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
const llm = new LLMAnalyst({ logger: noopLogger }); const llm = new LLMAnalyst({ logger: noopLogger });
const catalystCache = new CatalystCache({ logger: noopLogger }); // Singleton, cached for 15m const catalystCache = new CatalystCache({ logger: noopLogger }); // Singleton, cached for 15m
// Auth domain — generate a fresh invite code on every boot and print it
const INVITE_CODE = randomBytes(12).toString('hex'); // 24-char hex string
// Box width based on longest content line (no emoji inside — emoji width is terminal-dependent)
const line1 = ` Invite code for this session:`;
const line2 = ` ${INVITE_CODE}`;
const innerWidth = Math.max(line1.length, line2.length) + 2;
const hr = '─'.repeat(innerWidth);
const pad = (s: string) => `${s}${' '.repeat(innerWidth - 1 - s.length)}`;
console.log(`\n┌${hr}`);
console.log(pad(''));
console.log(pad(line1));
console.log(pad(line2));
console.log(pad(''));
console.log(`${hr}\n`);
const userStore = new UserStore(db);
const authService = new AuthService(userStore, JWT_SECRET);
new AuthController(authService, INVITE_CODE).register(app);
// Register controllers // Register controllers
// Public routes (GET) remain open; write routes require JWT + trader role
new ScreenerController(engine, catalystCache).register(app); new ScreenerController(engine, catalystCache).register(app);
new FinanceController(engine, new PortfolioRepository(db), advisor).register(app); new FinanceController(engine, new PortfolioRepository(db), advisor, {
new CallsController(new MarketCallRepository(db), engine, calSvc).register(app); authGuard,
traderGuard,
}).register(app);
new CallsController(new MarketCallRepository(db), engine, calSvc, {
authGuard,
traderGuard,
}).register(app);
new AnalyzeController(catalystCache, llm).register(app); new AnalyzeController(catalystCache, llm).register(app);
app.get('/health', async () => ({ status: 'ok' })); app.get('/health', async () => ({ status: 'ok' }));
+146
View File
@@ -0,0 +1,146 @@
/**
* AuthController — HTTP layer for authentication.
*
* POST /auth/register — create account (requires invite code generated at boot)
* POST /auth/login — verify credentials, returns JWT
*/
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import type { AuthService } from './AuthService.js';
interface RegisterBody {
email: string;
password: string;
inviteCode: string;
role?: 'trader' | 'viewer';
}
interface LoginBody {
email: string;
password: string;
}
interface ForgotBody {
email: string;
}
interface ResetBody {
token: string;
password: string;
}
const registerSchema = {
body: {
type: 'object',
required: ['email', 'password', 'inviteCode'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 8 },
inviteCode: { type: 'string' },
role: { type: 'string', enum: ['trader', 'viewer'] },
},
},
};
const loginSchema = {
body: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string' },
},
},
};
const forgotSchema = {
body: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
},
},
};
const resetSchema = {
body: {
type: 'object',
required: ['token', 'password'],
properties: {
token: { type: 'string', minLength: 32 },
password: { type: 'string', minLength: 8 },
},
},
};
export class AuthController {
readonly #inviteCode: string;
constructor(
private readonly authService: AuthService,
inviteCode: string,
) {
this.#inviteCode = inviteCode;
}
register(app: FastifyInstance): void {
app.post('/auth/register', { schema: registerSchema }, this.#register.bind(this));
app.post('/auth/login', { schema: loginSchema }, this.#login.bind(this));
app.post('/auth/forgot-password', { schema: forgotSchema }, this.#forgot.bind(this));
app.post('/auth/reset-password', { schema: resetSchema }, this.#reset.bind(this));
}
async #register(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const { email, password, inviteCode, role } = req.body as RegisterBody;
if (inviteCode !== this.#inviteCode) {
return reply.code(403).send({ error: 'Invalid invite code' });
}
try {
const result = this.authService.register(email, password, role ?? 'viewer');
reply.code(201).send(result);
} catch (err: unknown) {
const e = err as { message: string; statusCode?: number };
reply.code(e.statusCode ?? 500).send({ error: e.message });
}
}
async #login(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const { email, password } = req.body as LoginBody;
try {
const result = this.authService.login(email, password);
reply.send(result);
} catch (err: unknown) {
const e = err as { message: string; statusCode?: number };
reply.code(e.statusCode ?? 500).send({ error: e.message });
}
}
async #forgot(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const { email } = req.body as ForgotBody;
const origin = process.env.CLIENT_ORIGIN ?? 'http://localhost:5173';
try {
this.authService.forgotPassword(email, origin);
} catch (err) {
// Log server-side but never expose details to client
console.error('[forgot-password] error:', err);
}
// Always return 200 — never reveal whether the email exists or any error occurred
reply.send({
message: 'If that email is registered, a reset link has been printed to the server console.',
});
}
async #reset(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const { token, password } = req.body as ResetBody;
try {
this.authService.resetPassword(token, password);
reply.send({ message: 'Password updated. You can now log in.' });
} catch (err: unknown) {
const e = err as { message: string; statusCode?: number };
reply.code(e.statusCode ?? 500).send({ error: e.message });
}
}
}
+146
View File
@@ -0,0 +1,146 @@
/**
* AuthService — authentication logic.
*
* JWT: hand-rolled HMAC-SHA256 (no external lib) using Node's built-in crypto.
* Passwords: scrypt KDF with random salt (Node crypto, OWASP-recommended).
*/
import { createHmac, randomBytes, scryptSync, timingSafeEqual, randomUUID } from 'crypto';
import type { UserStore } from './UserStore.js';
import type { AuthResponse, Role, TokenPayload, User } from './auth.model.js';
// ── JWT helpers ───────────────────────────────────────────────────────────────
function b64url(input: string | Buffer): string {
const buf = typeof input === 'string' ? Buffer.from(input) : input;
return buf.toString('base64url');
}
function signJwt(payload: TokenPayload, secret: string, expiresInSec = 60 * 60 * 8): string {
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const now = Math.floor(Date.now() / 1000);
const body = b64url(JSON.stringify({ ...payload, iat: now, exp: now + expiresInSec }));
const sig = b64url(createHmac('sha256', secret).update(`${header}.${body}`).digest());
return `${header}.${body}.${sig}`;
}
export function verifyJwt(token: string, secret: string): TokenPayload {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('Invalid token format');
const [header, body, sig] = parts;
const expected = b64url(createHmac('sha256', secret).update(`${header}.${body}`).digest());
if (sig !== expected) throw new Error('Invalid token signature');
const payload: TokenPayload = JSON.parse(Buffer.from(body, 'base64url').toString());
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) throw new Error('Token expired');
return payload;
}
// ── Password helpers ──────────────────────────────────────────────────────────
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, keylen: 32 };
function hashPassword(plain: string): string {
const salt = randomBytes(16).toString('hex');
const hash = scryptSync(plain, salt, SCRYPT_PARAMS.keylen, {
N: SCRYPT_PARAMS.N,
r: SCRYPT_PARAMS.r,
p: SCRYPT_PARAMS.p,
}).toString('hex');
return `${salt}:${hash}`;
}
function verifyPassword(plain: string, stored: string): boolean {
const [salt, hash] = stored.split(':');
if (!salt || !hash) return false;
const attempt = scryptSync(plain, salt, SCRYPT_PARAMS.keylen, {
N: SCRYPT_PARAMS.N,
r: SCRYPT_PARAMS.r,
p: SCRYPT_PARAMS.p,
});
return timingSafeEqual(Buffer.from(hash, 'hex'), attempt);
}
// ── AuthService ───────────────────────────────────────────────────────────────
export class AuthService {
readonly #store: UserStore;
readonly #secret: string;
constructor(store: UserStore, secret: string) {
this.#store = store;
this.#secret = secret;
}
register(email: string, password: string, role: Role = 'viewer'): AuthResponse {
const existing = this.#store.findByEmail(email);
if (existing) throw Object.assign(new Error('Email already registered'), { statusCode: 409 });
const passwordHash = hashPassword(password);
const user = this.#store.create(email, passwordHash, role);
const token = this.#issueToken(user);
return { token, user };
}
login(email: string, password: string): AuthResponse {
const row = this.#store.findByEmail(email);
if (!row) throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 });
const valid = verifyPassword(password, row.password_hash);
if (!valid) throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 });
this.#store.touchLogin(row.id);
const user: User = {
id: row.id,
email: row.email,
role: row.role,
createdAt: row.created_at,
lastLogin: row.last_login,
};
const token = this.#issueToken(user);
return { token, user };
}
verify(token: string): TokenPayload {
return verifyJwt(token, this.#secret);
}
/**
* Generate a password reset token and print the reset link to the console.
* Always returns success (no email enumeration).
*/
forgotPassword(email: string, appOrigin: string): void {
this.#store.purgeExpiredTokens();
const user = this.#store.findByEmail(email);
if (!user) return; // silent — don't reveal whether email exists
const token = randomUUID().replace(/-/g, ''); // 32-char hex
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
this.#store.createResetToken(user.id, token, expiresAt);
const link = `${appOrigin}/auth/reset-password?token=${token}`;
console.log('\n🔐 Password reset requested for:', email);
console.log(' Link (expires in 1 hour):');
console.log(` ${link}\n`);
}
/**
* Validate a reset token and update the user's password.
*/
resetPassword(token: string, newPassword: string): void {
const row = this.#store.findResetToken(token);
if (!row) throw Object.assign(new Error('Invalid or expired reset link'), { statusCode: 400 });
if (row.used) throw Object.assign(new Error('Reset link already used'), { statusCode: 400 });
if (new Date(row.expires_at) < new Date()) {
throw Object.assign(new Error('Reset link has expired'), { statusCode: 400 });
}
const passwordHash = hashPassword(newPassword);
this.#store.updatePassword(row.user_id, passwordHash);
this.#store.markTokenUsed(token);
}
#issueToken(user: User): string {
return signJwt({ sub: user.id, email: user.email, role: user.role }, this.#secret);
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* UserStore — persistence layer for the users table.
* All queries go through DatabaseConnection for audit + safety.
*/
import { randomUUID } from 'crypto';
import type { DatabaseConnection } from '../shared/db/DatabaseConnection.js';
import { USER_QUERIES, RESET_TOKEN_QUERIES } from '../shared/db/queries.constant.js';
import type { Role, User, UserRow } from './auth.model.js';
export class UserStore {
constructor(private readonly db: DatabaseConnection) {}
findByEmail(email: string): UserRow | undefined {
return this.db.rawGet<UserRow>(USER_QUERIES.SELECT_BY_EMAIL, [email]);
}
findById(id: string): User | undefined {
const row = this.db.rawGet<UserRow>(USER_QUERIES.SELECT_BY_ID, [id]);
if (!row) return undefined;
return this.#toUser(row);
}
create(email: string, passwordHash: string, role: Role = 'viewer'): User {
const id = randomUUID();
const createdAt = new Date().toISOString();
this.db.rawRun(USER_QUERIES.INSERT, [id, email, passwordHash, role, createdAt]);
return { id, email, role, createdAt, lastLogin: null };
}
touchLogin(id: string): void {
this.db.rawRun(USER_QUERIES.UPDATE_LAST_LOGIN, [new Date().toISOString(), id]);
}
updatePassword(id: string, passwordHash: string): void {
this.db.rawRun('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id]);
}
// ── Password reset tokens ──────────────────────────────────────────────────
createResetToken(userId: string, token: string, expiresAt: string): void {
this.db.rawRun(RESET_TOKEN_QUERIES.INSERT, [token, userId, expiresAt]);
}
findResetToken(
token: string,
): { token: string; user_id: string; expires_at: string; used: number } | undefined {
return this.db.rawGet(RESET_TOKEN_QUERIES.FIND, [token]);
}
markTokenUsed(token: string): void {
this.db.rawRun(RESET_TOKEN_QUERIES.MARK_USED, [token]);
}
purgeExpiredTokens(): void {
this.db.rawRun(RESET_TOKEN_QUERIES.PURGE, [new Date().toISOString()]);
}
#toUser(row: UserRow): User {
return {
id: row.id,
email: row.email,
role: row.role,
createdAt: row.created_at,
lastLogin: row.last_login,
};
}
}
+36
View File
@@ -0,0 +1,36 @@
// ── Auth domain types ─────────────────────────────────────────────────────────
export type Role = 'trader' | 'viewer' | 'admin';
export interface User {
id: string;
email: string;
role: Role;
createdAt: string;
lastLogin: string | null;
}
/** Full user row including password hash — only used internally by UserStore/AuthService. */
export interface UserRow {
id: string;
email: string;
password_hash: string;
role: Role;
created_at: string;
last_login: string | null;
}
/** Payload embedded in the JWT. */
export interface TokenPayload {
sub: string; // user id
email: string;
role: Role;
iat?: number;
exp?: number;
}
/** Response body for successful login / register. */
export interface AuthResponse {
token: string;
user: User;
}
+4
View File
@@ -0,0 +1,4 @@
export { AuthController } from './AuthController.js';
export { AuthService, verifyJwt } from './AuthService.js';
export { UserStore } from './UserStore.js';
export type { User, UserRow, Role, TokenPayload, AuthResponse } from './auth.model.js';
+19 -4
View File
@@ -1,16 +1,27 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyInstance, FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify';
import { MarketCallRepository } from '../../domains/shared'; import { MarketCallRepository } from '../../domains/shared';
import { CalendarService } from './CalendarService'; import { CalendarService } from './CalendarService';
import { ScreenerEngine } from '../screener'; import { ScreenerEngine } from '../screener';
import type { SnapshotEntry } from '../../domains/shared'; import type { SnapshotEntry } from '../../domains/shared';
import { callSchema } from '../../domains/shared/types/schemas'; import { callSchema } from '../../domains/shared/types/schemas';
interface CallsControllerOptions {
authGuard?: preHandlerHookHandler;
traderGuard?: preHandlerHookHandler;
}
export class CallsController { export class CallsController {
readonly #guards: preHandlerHookHandler[];
constructor( constructor(
private readonly repo: MarketCallRepository, private readonly repo: MarketCallRepository,
private readonly engine: ScreenerEngine, private readonly engine: ScreenerEngine,
private readonly calendar: CalendarService, private readonly calendar: CalendarService,
) {} options: CallsControllerOptions = {},
) {
this.#guards =
options.authGuard && options.traderGuard ? [options.authGuard, options.traderGuard] : [];
}
private static toSnapshot(r: any): SnapshotEntry | null { private static toSnapshot(r: any): SnapshotEntry | null {
if (!r) return null; if (!r) return null;
@@ -30,8 +41,12 @@ export class CallsController {
app.get('/api/calls', this.list.bind(this)); app.get('/api/calls', this.list.bind(this));
app.get('/api/calls/calendar', this.handleCalendar.bind(this)); app.get('/api/calls/calendar', this.handleCalendar.bind(this));
app.get('/api/calls/:id', this.get.bind(this)); app.get('/api/calls/:id', this.get.bind(this));
app.post('/api/calls', { schema: callSchema }, this.create.bind(this)); app.post(
app.delete('/api/calls/:id', this.remove.bind(this)); '/api/calls',
{ schema: callSchema, preHandler: this.#guards },
this.create.bind(this),
);
app.delete('/api/calls/:id', { preHandler: this.#guards }, this.remove.bind(this));
} }
private async list() { private async list() {
+49 -15
View File
@@ -1,26 +1,59 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyInstance, FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify';
import { SimpleFINClient, PortfolioRepository, noopLogger } from '../../domains/shared'; import { SimpleFINClient, PortfolioRepository, noopLogger } from '../../domains/shared/index.js';
import { PersonalFinanceAnalyzer, ScreenerEngine } from '../screener'; import { PersonalFinanceAnalyzer, ScreenerEngine } from '../screener/index.js';
import { PortfolioAdvisor } from '../portfolio/PortfolioAdvisor'; import { PortfolioAdvisor } from '../portfolio/PortfolioAdvisor.js';
import type { PortfolioHolding } from '../../domains/shared'; import type { PortfolioHolding } from '../../domains/shared/index.js';
import { holdingSchema } from '../../domains/shared/types/schemas'; import { holdingSchema } from '../../domains/shared/types/schemas.js';
import type { TokenPayload } from '../auth/index.js';
interface FinanceControllerOptions {
authGuard?: preHandlerHookHandler;
traderGuard?: preHandlerHookHandler;
}
type AuthRequest = FastifyRequest & { user?: TokenPayload };
function userId(req: FastifyRequest): string {
return (req as AuthRequest).user?.sub ?? '';
}
export class FinanceController { export class FinanceController {
// All portfolio routes only need a valid login — data is already user-scoped by user_id.
// No role restriction needed; any registered user can manage their own portfolio.
readonly #authGuards: preHandlerHookHandler[];
constructor( constructor(
private readonly engine: ScreenerEngine, private readonly engine: ScreenerEngine,
private readonly repo: PortfolioRepository, private readonly repo: PortfolioRepository,
private readonly advisor: PortfolioAdvisor, private readonly advisor: PortfolioAdvisor,
) {} options: FinanceControllerOptions = {},
) {
this.#authGuards = options.authGuard ? [options.authGuard] : [];
}
register(app: FastifyInstance): void { register(app: FastifyInstance): void {
app.get('/api/finance/portfolio', this.portfolio.bind(this)); app.get('/api/finance/portfolio', { preHandler: this.#authGuards }, this.portfolio.bind(this));
app.post('/api/finance/holdings', { schema: holdingSchema }, this.addHolding.bind(this)); app.post(
app.delete('/api/finance/holdings/:ticker', this.removeHolding.bind(this)); '/api/finance/holdings',
{
schema: holdingSchema,
preHandler: this.#authGuards,
},
this.addHolding.bind(this),
);
app.delete(
'/api/finance/holdings/:ticker',
{
preHandler: this.#authGuards,
},
this.removeHolding.bind(this),
);
app.get('/api/finance/market-context', this.marketContext.bind(this)); app.get('/api/finance/market-context', this.marketContext.bind(this));
} }
private async portfolio(_req: FastifyRequest, _reply: FastifyReply) { private async portfolio(req: FastifyRequest, _reply: FastifyReply) {
const { holdings } = this.repo.exists() ? this.repo.read() : { holdings: [] }; const uid = userId(req);
const { holdings } = this.repo.exists(uid) ? this.repo.read(uid) : { holdings: [] };
let personalFinance = null; let personalFinance = null;
if (process.env.SIMPLEFIN_ACCESS_URL) { if (process.env.SIMPLEFIN_ACCESS_URL) {
@@ -43,6 +76,7 @@ export class FinanceController {
} }
private async addHolding(req: FastifyRequest, reply: FastifyReply) { private async addHolding(req: FastifyRequest, reply: FastifyReply) {
const uid = userId(req);
const { const {
ticker, ticker,
shares, shares,
@@ -50,14 +84,14 @@ export class FinanceController {
type = 'stock', type = 'stock',
source = 'Manual', source = 'Manual',
} = req.body as PortfolioHolding; } = req.body as PortfolioHolding;
const entry = this.repo.upsert({ ticker, shares, costBasis, type, source }); const entry = this.repo.upsert({ ticker, shares, costBasis, type, source }, uid);
return reply.code(201).send(entry); return reply.code(201).send(entry);
} }
private async removeHolding(req: FastifyRequest, reply: FastifyReply) { private async removeHolding(req: FastifyRequest, reply: FastifyReply) {
const uid = userId(req);
const ticker = (req.params as { ticker: string }).ticker.toUpperCase(); const ticker = (req.params as { ticker: string }).ticker.toUpperCase();
const removed = this.repo.remove(ticker, uid);
const removed = this.repo.remove(ticker);
if (!removed) return reply.code(404).send({ error: 'Holding not found' }); if (!removed) return reply.code(404).send({ error: 'Holding not found' });
return { ok: true }; return { ok: true };
} }
@@ -139,6 +139,24 @@ export class DatabaseConnection {
return txn(); return txn();
} }
/**
* Execute a raw SQL SELECT and return the first row.
* Use only when QueryBuilder is not practical (e.g. auth domain with static queries).
*/
rawGet<T = Record<string, unknown>>(sql: string, params: unknown[] = []): T | undefined {
const stmt = this.getOrCacheStatement(sql);
return stmt.get(...params) as T | undefined;
}
/**
* Execute a raw SQL INSERT/UPDATE/DELETE.
* Use only when QueryBuilder is not practical (e.g. auth domain with static queries).
*/
rawRun(sql: string, params: unknown[] = []): number {
const stmt = this.getOrCacheStatement(sql);
return stmt.run(...params).changes;
}
/** /**
* Get the raw better-sqlite3 Db instance (for advanced use only). * Get the raw better-sqlite3 Db instance (for advanced use only).
* Prefer the DatabaseConnection methods. * Prefer the DatabaseConnection methods.
+87 -35
View File
@@ -4,14 +4,15 @@
* Handles: * Handles:
* - Creating/opening SQLite database * - Creating/opening SQLite database
* - Running DDL schema setup * - Running DDL schema setup
* - Runtime ALTER TABLE migrations (safe to re-run)
* - Seeding the admin user from ADMIN_EMAIL + ADMIN_PASSWORD env vars
* - Migrating legacy JSON files (one-time) * - Migrating legacy JSON files (one-time)
*/ */
import BetterSqlite3 from 'better-sqlite3'; import BetterSqlite3 from 'better-sqlite3';
import { existsSync, readFileSync, renameSync } from 'fs'; import { existsSync, readFileSync, renameSync } from 'fs';
import { randomUUID } from 'crypto'; import { randomUUID, randomBytes, scryptSync } from 'crypto';
import { DDL } from './queries.constant'; import { DDL, RUNTIME_MIGRATIONS, HOLDINGS_QUERIES, USER_QUERIES } from './queries.constant.js';
import { QueryBuilder } from '../utils/QueryBuilder';
export type Db = BetterSqlite3.Database; export type Db = BetterSqlite3.Database;
@@ -43,85 +44,137 @@ interface LegacyCall {
* *
* Steps: * Steps:
* 1. Create/open database file * 1. Create/open database file
* 2. Enable WAL mode (concurrent read safety) * 2. Enable WAL mode + foreign keys
* 3. Enable foreign keys * 3. Run DDL (create tables if missing)
* 4. Run DDL (create tables if missing) * 4. Run runtime ALTER TABLE migrations (adds user_id etc. to existing DBs)
* 5. Migrate legacy JSON files (one-time) * 5. Seed admin user from env vars
* * 6. Migrate legacy JSON files (one-time)
* @param path Path to database file (default: ./market-screener.db)
* @returns Opened database instance (wrap in DatabaseConnection for safe access)
*/ */
export function createDb(path = './market-screener.db'): Db { export function createDb(path = './market-screener.db'): Db {
const db = new BetterSqlite3(path); const db = new BetterSqlite3(path);
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON'); db.pragma('foreign_keys = OFF'); // off during schema changes, back on after
db.exec(DDL); db.exec(DDL);
runRuntimeMigrations(db);
db.pragma('foreign_keys = ON');
seedAdmin(db);
// Upgrade any legacy 'viewer' accounts to 'trader' so all users have full access
db.prepare("UPDATE users SET role = 'trader' WHERE role = 'viewer'").run();
migrateJson(db); migrateJson(db);
return db; return db;
} }
// ── Migration Helpers ─────────────────────────────────────────────────────── // ── Runtime migrations ───────────────────────────────────────────────────────
/** /**
* Migrate legacy JSON files to SQLite (one-time, non-fatal). * Run ALTER TABLE statements that bring existing DBs up to the current schema.
* Called automatically during database initialization. * Each statement is wrapped in try/catch — SQLite throws if column already exists.
*/ */
function runRuntimeMigrations(db: Db): void {
for (const sql of RUNTIME_MIGRATIONS) {
try {
db.exec(sql);
} catch {
// Column already exists — safe to ignore
}
}
}
// ── Admin seeding ────────────────────────────────────────────────────────────
/**
* Create the admin account on first boot if ADMIN_EMAIL + ADMIN_PASSWORD are set.
* No-ops if the admin already exists.
*/
function seedAdmin(db: Db): void {
const email = process.env.ADMIN_EMAIL;
const password = process.env.ADMIN_PASSWORD;
if (!email || !password) return;
const existing = db.prepare(USER_QUERIES.SELECT_BY_EMAIL).get(email);
if (existing) {
// Migrate any ownerless holdings from before auth was added to this admin
const adminRow = existing as { id: string };
db.prepare(HOLDINGS_QUERIES.MIGRATE_TO_ADMIN).run(adminRow.id);
return;
}
// Hash password using the same scrypt approach as AuthService
// (inline here to avoid circular imports with the auth domain)
const salt = randomBytes(16).toString('hex');
const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }).toString('hex');
const passwordHash = `${salt}:${hash}`;
const id = randomUUID();
const createdAt = new Date().toISOString();
db.prepare(USER_QUERIES.INSERT).run(id, email, passwordHash, 'admin', createdAt);
// Migrate any ownerless holdings to this new admin
db.prepare(HOLDINGS_QUERIES.MIGRATE_TO_ADMIN).run(id);
}
// ── JSON migration helpers ───────────────────────────────────────────────────
function migrateJson(db: Db): void { function migrateJson(db: Db): void {
migratePortfolio(db); migratePortfolio(db);
migrateCalls(db); migrateCalls(db);
} }
/**
* Migrate portfolio.json → holdings table.
* If portfolio.json exists, import all holdings and rename to portfolio.json.migrated.
* If import fails, leave portfolio.json in place (non-fatal).
*/
function migratePortfolio(db: Db): void { function migratePortfolio(db: Db): void {
const src = './portfolio.json'; const src = './portfolio.json';
if (!existsSync(src)) return; if (!existsSync(src)) return;
// Need admin id to assign migrated holdings
const adminEmail = process.env.ADMIN_EMAIL;
if (!adminEmail) return;
const adminRow = db.prepare(USER_QUERIES.SELECT_BY_EMAIL).get(adminEmail) as
| { id: string }
| undefined;
if (!adminRow) return;
try { try {
const { holdings } = JSON.parse(readFileSync(src, 'utf8')) as { const { holdings } = JSON.parse(readFileSync(src, 'utf8')) as {
holdings: LegacyHolding[]; holdings: LegacyHolding[];
}; };
const insertAll = db.transaction((rows: LegacyHolding[]) => { const insertAll = db.transaction((rows: LegacyHolding[]) => {
const stmt = db.prepare(`
INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source, user_id)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const h of rows) { for (const h of rows) {
const qb = new QueryBuilder('MIGRATION_QUERIES.HOLDINGS_INSERT_OR_IGNORE', [ stmt.run(
h.ticker.toUpperCase(), h.ticker.toUpperCase(),
h.shares, h.shares,
h.costBasis ?? 0, h.costBasis ?? 0,
h.type ?? 'stock', h.type ?? 'stock',
h.source ?? 'Manual', h.source ?? 'Manual',
]); adminRow.id,
db.prepare(qb.sql).run(...qb.queryParams); );
} }
}); });
insertAll(holdings); insertAll(holdings);
renameSync(src, `${src}.migrated`); renameSync(src, `${src}.migrated`);
} catch { } catch {
// Non-fatal: leave portfolio.json in place if migration fails // Non-fatal
} }
} }
/**
* Migrate market-calls.json → market_calls table.
* If market-calls.json exists, import all calls and rename to market-calls.json.migrated.
* If import fails, leave market-calls.json in place (non-fatal).
*/
function migrateCalls(db: Db): void { function migrateCalls(db: Db): void {
const src = './market-calls.json'; const src = './market-calls.json';
if (!existsSync(src)) return; if (!existsSync(src)) return;
try { try {
const { calls } = JSON.parse(readFileSync(src, 'utf8')) as { const { calls } = JSON.parse(readFileSync(src, 'utf8')) as { calls: LegacyCall[] };
calls: LegacyCall[];
};
const insertAll = db.transaction((rows: LegacyCall[]) => { const insertAll = db.transaction((rows: LegacyCall[]) => {
const stmt = db.prepare(`
INSERT OR IGNORE INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const c of rows) { for (const c of rows) {
const qb = new QueryBuilder('MIGRATION_QUERIES.MARKET_CALLS_INSERT_OR_IGNORE', [ stmt.run(
c.id ?? randomUUID(), c.id ?? randomUUID(),
c.title, c.title,
c.quarter, c.quarter,
@@ -130,14 +183,13 @@ function migrateCalls(db: Db): void {
JSON.stringify(c.tickers ?? []), JSON.stringify(c.tickers ?? []),
JSON.stringify(c.snapshot ?? {}), JSON.stringify(c.snapshot ?? {}),
c.createdAt, c.createdAt,
]); );
db.prepare(qb.sql).run(...qb.queryParams);
} }
}); });
insertAll(calls); insertAll(calls);
renameSync(src, `${src}.migrated`); renameSync(src, `${src}.migrated`);
} catch { } catch {
// Non-fatal: leave market-calls.json in place if migration fails // Non-fatal
} }
} }
+94 -16
View File
@@ -2,8 +2,7 @@
* SQL Query Constants * SQL Query Constants
* *
* All SQL queries used in the application. * All SQL queries used in the application.
* Repositories reference these by name (e.g., MARKET_CALLS_QUERIES.SELECT_ALL). * Repositories reference these by name.
* QueryBuilder looks them up and binds parameters.
* *
* All queries use parameterized statements (?) for security. * All queries use parameterized statements (?) for security.
* User input NEVER goes into the SQL string. * User input NEVER goes into the SQL string.
@@ -12,25 +11,33 @@
// ── Holdings Table Queries ─────────────────────────────────────────────────── // ── Holdings Table Queries ───────────────────────────────────────────────────
export const HOLDINGS_QUERIES = { export const HOLDINGS_QUERIES = {
// Check if any holdings exist // Check if any holdings exist for a user
EXISTS: 'SELECT COUNT(*) AS n FROM holdings', EXISTS: 'SELECT COUNT(*) AS n FROM holdings WHERE user_id = ?',
// Get all holdings, sorted by ticker // Get all holdings for a user, sorted by ticker
SELECT_ALL: 'SELECT ticker, shares, cost_basis, type, source FROM holdings ORDER BY ticker ASC', SELECT_ALL: `
SELECT ticker, shares, cost_basis, type, source
FROM holdings
WHERE user_id = ?
ORDER BY ticker ASC
`,
// Insert or update a holding (UPSERT) // Insert or update a holding scoped to a user
UPSERT: ` UPSERT: `
INSERT INTO holdings (ticker, shares, cost_basis, type, source) INSERT INTO holdings (ticker, shares, cost_basis, type, source, user_id)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(ticker) DO UPDATE SET ON CONFLICT(ticker, user_id) DO UPDATE SET
shares = excluded.shares, shares = excluded.shares,
cost_basis = excluded.cost_basis, cost_basis = excluded.cost_basis,
type = excluded.type, type = excluded.type,
source = excluded.source source = excluded.source
`, `,
// Delete a holding by ticker // Delete a holding by ticker for a specific user
DELETE_BY_TICKER: 'DELETE FROM holdings WHERE ticker = ?', DELETE_BY_TICKER: 'DELETE FROM holdings WHERE ticker = ? AND user_id = ?',
// Migrate ownerless holdings to admin user (one-time)
MIGRATE_TO_ADMIN: "UPDATE holdings SET user_id = ? WHERE user_id IS NULL OR user_id = ''",
}; };
// ── Market Calls Table Queries ─────────────────────────────────────────────── // ── Market Calls Table Queries ───────────────────────────────────────────────
@@ -65,8 +72,8 @@ export const MARKET_CALLS_QUERIES = {
export const MIGRATION_QUERIES = { export const MIGRATION_QUERIES = {
// Insert holdings during migration // Insert holdings during migration
HOLDINGS_INSERT_OR_IGNORE: ` HOLDINGS_INSERT_OR_IGNORE: `
INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source) INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source, user_id)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`, `,
// Insert market calls during migration // Insert market calls during migration
@@ -76,15 +83,78 @@ export const MIGRATION_QUERIES = {
`, `,
}; };
// ── User Table Queries ───────────────────────────────────────────────────────
export const USER_QUERIES = {
SELECT_BY_EMAIL: `
SELECT id, email, password_hash, role, created_at, last_login
FROM users WHERE email = ?
`,
SELECT_BY_ID: `
SELECT id, email, role, created_at, last_login
FROM users WHERE id = ?
`,
INSERT: `
INSERT INTO users (id, email, password_hash, role, created_at)
VALUES (?, ?, ?, ?, ?)
`,
UPDATE_LAST_LOGIN: `
UPDATE users SET last_login = ? WHERE id = ?
`,
};
// ── Password Reset Token Queries ─────────────────────────────────────────────
export const RESET_TOKEN_QUERIES = {
INSERT: `
INSERT INTO password_reset_tokens (token, user_id, expires_at)
VALUES (?, ?, ?)
`,
FIND: `
SELECT token, user_id, expires_at, used
FROM password_reset_tokens
WHERE token = ?
`,
MARK_USED: `
UPDATE password_reset_tokens SET used = 1 WHERE token = ?
`,
// Clean up expired/used tokens older than 24h
PURGE: `
DELETE FROM password_reset_tokens
WHERE used = 1 OR expires_at < ?
`,
};
// ── Schema Definition (DDL) ────────────────────────────────────────────────── // ── Schema Definition (DDL) ──────────────────────────────────────────────────
export const DDL = ` export const DDL = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('trader', 'viewer', 'admin')),
created_at TEXT NOT NULL,
last_login TEXT
);
CREATE TABLE IF NOT EXISTS holdings ( CREATE TABLE IF NOT EXISTS holdings (
ticker TEXT PRIMARY KEY, ticker TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id),
shares REAL NOT NULL, shares REAL NOT NULL,
cost_basis REAL NOT NULL DEFAULT 0, cost_basis REAL NOT NULL DEFAULT 0,
type TEXT NOT NULL DEFAULT 'stock', type TEXT NOT NULL DEFAULT 'stock',
source TEXT NOT NULL DEFAULT 'Manual' source TEXT NOT NULL DEFAULT 'Manual',
PRIMARY KEY (ticker, user_id)
);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
expires_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS market_calls ( CREATE TABLE IF NOT EXISTS market_calls (
@@ -98,3 +168,11 @@ export const DDL = `
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
`; `;
// ── Runtime migrations (ALTER TABLE for existing DBs) ────────────────────────
// These are safe to run repeatedly — they no-op if the column already exists.
export const RUNTIME_MIGRATIONS = [
// Add user_id to holdings if upgrading from pre-auth schema
`ALTER TABLE holdings ADD COLUMN user_id TEXT NOT NULL DEFAULT '' REFERENCES users(id)`,
];
@@ -1,34 +1,33 @@
import { DatabaseConnection } from '../db/index'; import { DatabaseConnection } from '../db/index.js';
import { QueryBuilder } from '../utils/QueryBuilder'; import { QueryBuilder } from '../utils/QueryBuilder.js';
import { sanitizeTicker, sanitizeNumber } from '../utils/sanitizer'; import { sanitizeTicker, sanitizeNumber } from '../utils/sanitizer.js';
import type { PortfolioData, PortfolioHolding, HoldingRow } from '../types'; import type { PortfolioData, PortfolioHolding, HoldingRow } from '../types/index.js';
export class PortfolioRepository { export class PortfolioRepository {
constructor(private readonly db: DatabaseConnection) {} constructor(private readonly db: DatabaseConnection) {}
/** /**
* Check if portfolio has any holdings. * Check if a user has any holdings.
*/ */
exists(): boolean { exists(userId: string): boolean {
const qb = new QueryBuilder('HOLDINGS_QUERIES.EXISTS'); const qb = new QueryBuilder('HOLDINGS_QUERIES.EXISTS', [userId]);
const row = this.db.get<{ n: number }>(qb); const row = this.db.get<{ n: number }>(qb);
return row ? row.n > 0 : false; return row ? row.n > 0 : false;
} }
/** /**
* Read all holdings. * Read all holdings for a user.
*/ */
read(): PortfolioData { read(userId: string): PortfolioData {
const qb = new QueryBuilder('HOLDINGS_QUERIES.SELECT_ALL'); const qb = new QueryBuilder('HOLDINGS_QUERIES.SELECT_ALL', [userId]);
const rows = this.db.all<HoldingRow>(qb); const rows = this.db.all<HoldingRow>(qb);
return { holdings: rows.map(PortfolioRepository.toHolding) }; return { holdings: rows.map(PortfolioRepository.toHolding) };
} }
/** /**
* Insert or update a holding (UPSERT). * Insert or update a holding scoped to a user (UPSERT).
*/ */
upsert(entry: PortfolioHolding): PortfolioHolding { upsert(entry: PortfolioHolding, userId: string): PortfolioHolding {
// Sanitize inputs
const ticker = sanitizeTicker(entry.ticker); const ticker = sanitizeTicker(entry.ticker);
const shares = sanitizeNumber(entry.shares, 'shares', { min: 0 }); const shares = sanitizeNumber(entry.shares, 'shares', { min: 0 });
const costBasis = sanitizeNumber(entry.costBasis ?? 0, 'costBasis', { min: 0 }); const costBasis = sanitizeNumber(entry.costBasis ?? 0, 'costBasis', { min: 0 });
@@ -41,6 +40,7 @@ export class PortfolioRepository {
costBasis, costBasis,
type, type,
source, source,
userId,
]); ]);
this.db.run(qb); this.db.run(qb);
@@ -48,20 +48,15 @@ export class PortfolioRepository {
} }
/** /**
* Delete a holding by ticker. * Delete a holding by ticker for a specific user.
*/ */
remove(ticker: string): boolean { remove(ticker: string, userId: string): boolean {
// Sanitize input
const sanitizedTicker = sanitizeTicker(ticker); const sanitizedTicker = sanitizeTicker(ticker);
const qb = new QueryBuilder('HOLDINGS_QUERIES.DELETE_BY_TICKER', [sanitizedTicker]); const qb = new QueryBuilder('HOLDINGS_QUERIES.DELETE_BY_TICKER', [sanitizedTicker, userId]);
const changes = this.db.run(qb); const changes = this.db.run(qb);
return changes > 0; return changes > 0;
} }
/**
* Convert database row to domain object.
*/
private static toHolding(row: HoldingRow): PortfolioHolding { private static toHolding(row: HoldingRow): PortfolioHolding {
return { return {
ticker: row.ticker, ticker: row.ticker,
+47
View File
@@ -0,0 +1,47 @@
import type { AuthResponse } from '$lib/types.js';
import { authStore } from '$lib/stores/auth.store.svelte.js';
const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
/**
* fetch() wrapper that automatically attaches the JWT Bearer token.
* Use this for all API calls that require authentication.
*/
export function authFetch(url: string, init: RequestInit = {}): Promise<Response> {
const token = authStore.token;
const headers = new Headers(init.headers);
if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
if (token) headers.set('Authorization', `Bearer ${token}`);
return fetch(url, { ...init, headers });
}
export async function login(email: string, password: string): Promise<AuthResponse> {
const res = await fetch(`${BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const { error } = await res.json().catch(() => ({ error: 'Login failed' }));
throw new Error(error ?? 'Login failed');
}
return res.json() as Promise<AuthResponse>;
}
export async function register(
email: string,
password: string,
role: 'trader' | 'viewer' = 'viewer',
inviteCode = '',
): Promise<AuthResponse> {
const res = await fetch(`${BASE}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, role, inviteCode }),
});
if (!res.ok) {
const { error } = await res.json().catch(() => ({ error: 'Registration failed' }));
throw new Error(error ?? 'Registration failed');
}
return res.json() as Promise<AuthResponse>;
}
+3 -3
View File
@@ -1,4 +1,5 @@
import type { MarketCall, CalendarEvent, ScreenerResult } from '$lib/types.js'; import type { MarketCall, CalendarEvent, ScreenerResult } from '$lib/types.js';
import { authFetch } from './auth.js';
const BASE = '/api'; const BASE = '/api';
@@ -21,9 +22,8 @@ export async function createCall(payload: {
tickers: string[]; tickers: string[];
date?: string; date?: string;
}): Promise<MarketCall> { }): Promise<MarketCall> {
const res = await fetch(`${BASE}/calls`, { const res = await authFetch(`${BASE}/calls`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
@@ -31,7 +31,7 @@ export async function createCall(payload: {
} }
export async function deleteCall(id: string): Promise<{ ok: boolean }> { export async function deleteCall(id: string): Promise<{ ok: boolean }> {
const res = await fetch(`${BASE}/calls/${id}`, { method: 'DELETE' }); const res = await authFetch(`${BASE}/calls/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
} }
+5 -7
View File
@@ -1,4 +1,5 @@
import type { MarketContext, PortfolioHolding, PortfolioAdvice } from '$lib/types.js'; import type { MarketContext, PortfolioHolding, PortfolioAdvice } from '$lib/types.js';
import { authFetch } from './auth.js';
const BASE = '/api'; const BASE = '/api';
@@ -9,7 +10,7 @@ export async function fetchPortfolio(): Promise<{
netWorth: number | null; netWorth: number | null;
error?: string; error?: string;
}> { }> {
const res = await fetch(`${BASE}/finance/portfolio`); const res = await authFetch(`${BASE}/finance/portfolio`);
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
} }
@@ -17,9 +18,8 @@ export async function fetchPortfolio(): Promise<{
export async function addHolding( export async function addHolding(
holding: PortfolioHolding, holding: PortfolioHolding,
): Promise<{ holdings: PortfolioHolding[] }> { ): Promise<{ holdings: PortfolioHolding[] }> {
const res = await fetch(`${BASE}/finance/holdings`, { const res = await authFetch(`${BASE}/finance/holdings`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(holding), body: JSON.stringify(holding),
}); });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
@@ -27,15 +27,13 @@ export async function addHolding(
} }
export async function removeHolding(ticker: string): Promise<{ holdings: PortfolioHolding[] }> { export async function removeHolding(ticker: string): Promise<{ holdings: PortfolioHolding[] }> {
const res = await fetch(`${BASE}/finance/holdings/${ticker}`, { const res = await authFetch(`${BASE}/finance/holdings/${ticker}`, { method: 'DELETE' });
method: 'DELETE',
});
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
} }
export async function fetchMarketContext(): Promise<MarketContext> { export async function fetchMarketContext(): Promise<MarketContext> {
const res = await fetch(`${BASE}/finance/market-context`); const res = await authFetch(`${BASE}/finance/market-context`);
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
} }
+1
View File
@@ -5,3 +5,4 @@
export { screenTickers, fetchCatalysts, analyzeTickers } from './screener.js'; export { screenTickers, fetchCatalysts, analyzeTickers } from './screener.js';
export { fetchPortfolio, addHolding, removeHolding, fetchMarketContext } from './finance.js'; export { fetchPortfolio, addHolding, removeHolding, fetchMarketContext } from './finance.js';
export { fetchCalls, fetchCall, createCall, deleteCall, fetchCallsCalendar } from './calls.js'; export { fetchCalls, fetchCall, createCall, deleteCall, fetchCallsCalendar } from './calls.js';
export { login, register, authFetch } from './auth.js';
@@ -29,7 +29,7 @@
<div class="accounts-two-col"> <div class="accounts-two-col">
<section class="accounts-section"> <section class="accounts-section">
<h2>Accounts</h2> <h2>Accounts</h2>
<table> <table class="accounts-table">
<thead><tr><th>Account</th><th>Type</th><th>Institution</th><th class="right">Balance</th></tr></thead> <thead><tr><th>Account</th><th>Type</th><th>Institution</th><th class="right">Balance</th></tr></thead>
<tbody> <tbody>
{#each pf.accounts as a} {#each pf.accounts as a}
@@ -46,7 +46,7 @@
<section class="accounts-section"> <section class="accounts-section">
<h2>Spending — Last 30 Days</h2> <h2>Spending — Last 30 Days</h2>
<table> <table class="accounts-table">
<thead><tr><th>Category</th><th class="right">Amount</th><th class="right">%</th><th>Share</th></tr></thead> <thead><tr><th>Category</th><th class="right">Amount</th><th class="right">%</th><th>Share</th></tr></thead>
<tbody> <tbody>
{#each pf.categoryBreakdown.slice(0, 10) as c} {#each pf.categoryBreakdown.slice(0, 10) as c}
@@ -118,7 +118,7 @@
<!-- Holdings table --> <!-- Holdings table -->
<section class="advice-section"> <section class="advice-section">
<h2>Holdings — Hold / Sell / Add Advice</h2> <h2>Holdings — Hold / Sell / Add Advice</h2>
<table> <table class="advice-table">
<thead> <thead>
<tr> <tr>
<th class="sortable" onclick={() => toggleSort('ticker')}>Ticker {sortIcon('ticker')}</th> <th class="sortable" onclick={() => toggleSort('ticker')}>Ticker {sortIcon('ticker')}</th>
@@ -168,7 +168,7 @@
<td class="num {glClass(a.gainLossPct)}">{a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td> <td class="num {glClass(a.gainLossPct)}">{a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
<td>{#if a.signal}<SignalBadge signal={a.signal} />{:else}<span class="gray"></span>{/if}</td> <td>{#if a.signal}<SignalBadge signal={a.signal} />{:else}<span class="gray"></span>{/if}</td>
<td class={advClass(a.advice)}>{a.advice}</td> <td class={advClass(a.advice)}>{a.advice}</td>
<td class="reason">{a.reason}</td> <td class="reason col-reason">{a.reason}</td>
<td class="advice-row-actions"> <td class="advice-row-actions">
{#if isEditing} {#if isEditing}
<button class="btn-save-inline" onclick={saveEdit} disabled={saving}>{saving ? '…' : '✓'}</button> <button class="btn-save-inline" onclick={saveEdit} disabled={saving}>{saving ? '…' : '✓'}</button>
+437 -100
View File
@@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import { sigOrd, sorted } from '$lib/utils.js'; import { sigOrd, sorted } from '$lib/utils.js';
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 Spinner from '$lib/components/shared/Spinner.svelte';
import type { AssetType, AssetResult } from '$lib/types.js'; import type { AssetType, AssetResult } from '$lib/types.js';
@@ -17,22 +15,172 @@
onAnalyze: () => void; onAnalyze: () => void;
} = $props(); } = $props();
// Mode state is self-contained — each table independently tracks inflated vs fundamental let mode = $state('inflated');
let mode = $state('inflated'); let expanded = $state<string | null>(null);
let sortCol = $state<string | null>(null);
let sortAsc = $state(true);
let filterTicker = $state('');
let filterSignal = $state('');
let filterStyle = $state('');
let filterCap = $state('');
let filterPriceMin = $state('');
let filterPriceMax = $state('');
let filterScoreMin = $state('');
let filterFlags = $state(false);
const STYLE_OPTIONS = ['High Growth', 'Growth', 'Value', 'Stable', 'Turnaround', 'Declining'];
const CAP_OPTIONS = ['Mega Cap', 'Large Cap', 'Mid Cap', 'Small Cap', 'Micro Cap'];
function hasFilter() {
return !!(filterTicker || filterSignal || filterStyle || filterCap || filterPriceMin || filterPriceMax || filterScoreMin || filterFlags);
}
function clearFilters() {
filterTicker = ''; filterSignal = ''; filterStyle = ''; filterCap = '';
filterPriceMin = ''; filterPriceMax = ''; filterScoreMin = ''; filterFlags = false;
}
function filteredRows(rows: AssetResult[]): AssetResult[] {
let out = rows;
if (filterTicker.trim()) {
const q = filterTicker.trim().toUpperCase();
out = out.filter(r => r.asset.ticker.includes(q));
}
if (filterSignal) {
out = out.filter(r => r.signal === filterSignal);
}
if (filterStyle) {
out = out.filter(r => (r.asset.displayMetrics?.['Style'] ?? '') === filterStyle);
}
if (filterCap) {
out = out.filter(r => (r.asset.displayMetrics?.['Cap Tier'] ?? '') === filterCap);
}
if (filterPriceMin !== '') {
const min = parseFloat(filterPriceMin);
out = out.filter(r => numVal(r.asset.displayMetrics?.['Price']) >= min);
}
if (filterPriceMax !== '') {
const max = parseFloat(filterPriceMax);
out = out.filter(r => numVal(r.asset.displayMetrics?.['Price']) <= max);
}
if (filterScoreMin !== '' && filterScoreMin !== null) {
const min = Number(filterScoreMin);
if (!isNaN(min)) {
out = out.filter(r => {
const v = r[mode as 'inflated' | 'fundamental'];
const raw = v.scoreSummary ?? '';
// Gate-failed rows have no numeric score — treat as 0
const match = raw.match(/Score:\s*(\d+)/);
const s = match ? parseInt(match[1], 10) : 0;
return s >= min;
});
}
}
if (filterFlags) {
// Hide gate-failed (rejected) rows — use scoreSummary as it's always serialized
out = out.filter(r => {
const v = r[mode as 'inflated' | 'fundamental'];
return !(v.scoreSummary ?? '').startsWith('Gate failed');
});
}
return out;
}
function toggleExpand(ticker: string) {
expanded = expanded === ticker ? null : ticker;
}
function setSort(col: string) {
if (sortCol === col) {
sortAsc = !sortAsc;
} else {
sortCol = col;
sortAsc = col === 'ticker'; // text cols default asc; number cols default desc
}
expanded = null; // close any open row when re-sorting
}
function sortIcon(col: string): string {
if (sortCol !== col) return '⇅';
return sortAsc ? '↑' : '↓';
}
function numVal(s: string | number | undefined | null): number {
if (s == null || s === '—') return -Infinity;
return parseFloat(String(s).replace(/[%$,x]/g, '')) || 0;
}
function sortedRows(rows: AssetResult[]): AssetResult[] {
const base = filteredRows(rows);
if (!sortCol) return sorted(base);
const col = sortCol;
const asc = sortAsc;
return [...base].sort((a, b) => {
const ma = a.asset.displayMetrics ?? {};
const mb = b.asset.displayMetrics ?? {};
const va = a[mode as 'inflated' | 'fundamental'];
const vb = b[mode as 'inflated' | 'fundamental'];
let av: number | string = 0;
let bv: number | string = 0;
if (col === 'ticker') {
av = a.asset.ticker; bv = b.asset.ticker;
} else if (col === 'price') {
av = numVal(ma['Price']); bv = numVal(mb['Price']);
} else if (col === 'signal') {
av = sigOrd(a.signal); bv = sigOrd(b.signal);
} else if (col === 'score') {
av = numVal(va.scoreSummary); bv = numVal(vb.scoreSummary);
} else if (col === 'cap') {
const capOrder: Record<string, number> = { 'Mega Cap': 5, 'Large Cap': 4, 'Mid Cap': 3, 'Small Cap': 2, 'Micro Cap': 1 };
av = capOrder[ma['Cap Tier'] as string] ?? 0;
bv = capOrder[mb['Cap Tier'] as string] ?? 0;
} else {
// Generic display metric by display key
const keyMap: Record<string, string> = {
pe: 'P/E', peg: 'PEG', roe: 'ROE%', fcf: 'FCF Yld%',
expense: 'Exp Ratio%', ret5y: '5Y Return%',
rating: 'Rating', ytm: 'YTM%',
};
const metricKey = keyMap[col] ?? col;
av = numVal(ma[metricKey]); bv = numVal(mb[metricKey]);
}
if (typeof av === 'string' && typeof bv === 'string') {
return asc ? av.localeCompare(bv) : bv.localeCompare(av);
}
const diff = (av as number) - (bv as number);
return asc ? diff : -diff;
});
}
// Colour class for signed % values (52W Chg, From High, Upside, DCF Safety)
function signClass(val: string | number | null | undefined): string { function signClass(val: string | number | null | undefined): string {
if (val == null) return ''; if (val == null) return '';
const n = typeof val === 'number' ? val : parseFloat(String(val)); const n = typeof val === 'number' ? val : parseFloat(String(val));
if (isNaN(n)) return ''; if (isNaN(n)) return '';
return n > 0 ? 'pos' : n < 0 ? 'neg' : ''; return n > 0 ? 'pos' : n < 0 ? 'neg' : '';
} }
function breakdownEntries(bd: Record<string, number> | undefined) {
if (!bd) return [];
return Object.entries(bd).sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
}
function maxAbs(bd: Record<string, number> | undefined): number {
if (!bd) return 1;
const max = Math.max(...Object.values(bd).map(Math.abs));
return max === 0 ? 1 : max;
}
</script> </script>
<section class="section"> <section class="section">
<div class="section-header"> <div class="section-header">
<h2>{type}S</h2> <h2>{type}S</h2>
<span class="count">{rows.length}</span> <span class="count">{filteredRows(rows).length === rows.length ? rows.length : `${filteredRows(rows).length} / ${rows.length}`}</span>
{#if hasFilter()}
<button class="filter-clear-btn" onclick={clearFilters}> Clear filters</button>
{/if}
<div class="mode-tabs"> <div class="mode-tabs">
<button class:active={mode === 'inflated'} onclick={() => mode = 'inflated'}>Mkt-Adjusted</button> <button class:active={mode === 'inflated'} onclick={() => mode = 'inflated'}>Mkt-Adjusted</button>
@@ -54,123 +202,312 @@
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table class="asset-table">
<thead> <thead>
<!-- ── Column headers ── -->
<tr> <tr>
<th class="col-ticker">Ticker</th> <th class="col-expand"></th>
<th>Price</th> <th class="col-ticker sort-th" onclick={() => setSort('ticker')}>
<th>Verdict</th> Ticker <span class="sort-icon">{sortIcon('ticker')}</span>
<th>Score</th> </th>
<th class="sort-th" onclick={() => setSort('price')}>
Price <span class="sort-icon">{sortIcon('price')}</span>
</th>
<th class="sort-th" onclick={() => setSort('signal')}>
Signal <span class="sort-icon">{sortIcon('signal')}</span>
</th>
<th class="sort-th" onclick={() => setSort('score')}>
Score <span class="sort-icon">{sortIcon('score')}</span>
</th>
{#if type === 'STOCK'} {#if type === 'STOCK'}
<!-- Classification --> <th class="sort-th" title="Market cap tier" onclick={() => setSort('cap')}>
<th title="Market cap tier">Cap</th> Cap <span class="sort-icon">{sortIcon('cap')}</span>
<th title="Growth / style classification">Style</th> </th>
<!-- Valuation --> <th title="Growth / style">Style</th>
<th>P/E</th>
<th>PEG</th>
<!-- Quality -->
<th title="Gross Margin %">GrossM%</th>
<th>ROE%</th>
<th>OpMgn%</th>
<th>FCF%</th>
<!-- Risk -->
<th>D/E</th>
<!-- 52-week movement -->
<th title="Total price return over last 52 weeks">52W Chg</th>
<th title="% below 52-week high">From High</th>
<!-- Expert signals -->
<th title="Wall Street analyst consensus">Analyst</th>
<th title="% upside to analyst target price">Upside</th>
<th title="DCF margin of safety — positive means undervalued">DCF Safety</th>
<!-- Risk flags -->
<th>Flags</th> <th>Flags</th>
{:else if type === 'ETF'} {:else if type === 'ETF'}
<th>Expense</th><th>Yield</th><th>AUM</th><th>5Y Ret</th> <th class="sort-th" onclick={() => setSort('expense')}>
Expense <span class="sort-icon">{sortIcon('expense')}</span>
</th>
<th class="sort-th" onclick={() => setSort('ret5y')}>
5Y Ret <span class="sort-icon">{sortIcon('ret5y')}</span>
</th>
{:else} {:else}
<th>YTM</th><th>Duration</th><th>Rating</th> <th class="sort-th" onclick={() => setSort('rating')}>
Rating <span class="sort-icon">{sortIcon('rating')}</span>
</th>
<th class="sort-th" onclick={() => setSort('ytm')}>
YTM <span class="sort-icon">{sortIcon('ytm')}</span>
</th>
{/if}
</tr>
<!-- ── Inline filter row ── -->
<tr class="filter-row">
<td></td>
<td class="col-ticker">
<input class="th-filter" type="text" placeholder="Ticker…" bind:value={filterTicker} />
</td>
<td>
<div class="th-filter-pair">
<input class="th-filter th-filter-num" type="number" placeholder="$ min" bind:value={filterPriceMin} />
<input class="th-filter th-filter-num" type="number" placeholder="$ max" bind:value={filterPriceMax} />
</div>
</td>
<td>
<select class="th-filter" bind:value={filterSignal}>
<option value="">All signals</option>
<option value="✅ Strong Buy">Strong Buy</option>
<option value="⚡ Momentum">Momentum</option>
<option value="⚠️ Speculation">Speculation</option>
<option value="🔄 Neutral">Neutral</option>
<option value="❌ Avoid">Avoid</option>
</select>
</td>
<td>
<input class="th-filter th-filter-num" type="number" placeholder="Score ≥" min="0" max="20" bind:value={filterScoreMin} />
</td>
{#if type === 'STOCK'}
<td>
<select class="th-filter" bind:value={filterCap}>
<option value="">All caps</option>
{#each CAP_OPTIONS as c}<option value={c}>{c}</option>{/each}
</select>
</td>
<td>
<select class="th-filter" bind:value={filterStyle}>
<option value="">All styles</option>
{#each STYLE_OPTIONS as s}<option value={s}>{s}</option>{/each}
</select>
</td>
<td>
<label class="th-filter-check" title="Show only rows that passed all gates">
<input type="checkbox" bind:checked={filterFlags} />
<span>Passed only</span>
</label>
</td>
{:else}
<td></td>
<td></td>
{/if} {/if}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each sorted(rows) as r} {#each sortedRows(rows) as r}
{@const m = r.asset.displayMetrics ?? {}} {@const m = r.asset.displayMetrics ?? {}}
{@const v = r[mode as 'inflated' | 'fundamental']} {@const v = r[mode as 'inflated' | 'fundamental']}
<tr class="data-row" data-signal={sigOrd(r.signal)}> {@const isOpen = expanded === r.asset.ticker}
{@const colCount = type === 'STOCK' ? 8 : 7}
{@const flags = v.audit?.riskFlags ?? []}
{@const rawScore = parseInt(v.scoreSummary?.replace(/\D/g, '') ?? '0', 10)}
<!-- ── Summary row ── -->
<tr
class="data-row summary-row"
class:row-open={isOpen}
data-signal={sigOrd(r.signal)}
onclick={() => toggleExpand(r.asset.ticker)}
>
<td class="col-expand">{isOpen ? '▾' : '▸'}</td>
<td class="ticker">{r.asset.ticker}</td> <td class="ticker">{r.asset.ticker}</td>
<td class="num">{m.Price ?? '—'}</td> <td class="num">{m.Price ?? '—'}</td>
<td><VerdictPill label={v.label} /></td> <!-- Signal pill -->
<td class="score-cell" title={v.scoreSummary}>{v.scoreSummary}</td> <td class="signal-verdict-cell">
<span class="sv-pill sv-{(r.signal ?? '').includes('Strong') ? 'strong' : (r.signal ?? '').includes('Momentum') ? 'momentum' : (r.signal ?? '').includes('Speculation') ? 'spec' : (r.signal ?? '').includes('Neutral') ? 'neutral' : 'avoid'}">{(r.signal ?? '').replace(/^[^\w\s]+\s*/, '').trim() || '—'}</span>
</td>
<!-- Score as dot scale -->
<td class="score-cell" title={v.scoreSummary}>
{#if v.scoreSummary?.startsWith('Gate failed')}
<span class="score-fail"></span>
{:else}
<span class="score-dots">
{#each Array(5) as _, i}
<span class="score-dot" class:on={i < Math.round(rawScore / 4)}></span>
{/each}
</span>
<span class="score-num">{rawScore}</span>
{/if}
</td>
{#if type === 'STOCK'} {#if type === 'STOCK'}
<!-- Classification -->
<td><span class="tag sm cap-tag">{m['Cap Tier'] ?? '—'}</span></td> <td><span class="tag sm cap-tag">{m['Cap Tier'] ?? '—'}</span></td>
<td><span class="tag sm style-tag">{m['Style'] ?? '—'}</span></td> <td><span class="tag sm style-tag">{m['Style'] ?? '—'}</span></td>
<!-- Valuation --> <!-- Flags: count badge with hover expand -->
<td class="num">{m['P/E'] ?? '—'}</td> <td class="flags-cell">
<td class="num">{m['PEG'] ?? '—'}</td> {#if flags.length > 0}
<!-- Quality --> <span class="flags-badge">
<td class="num">{m['GrossM%'] ?? '—'}</td> <span class="flags-count">{flags.length}</span>
<td class="num">{m['ROE%'] ?? '—'}</td> </span>
<td class="num">{m['OpMgn%'] ?? '—'}</td> {/if}
<td class="num">{m['FCF Yld%'] ?? '—'}</td>
<!-- Risk -->
<td class="num">{m['D/E'] ?? '—'}</td>
<!-- 52-week movement — green if up, red if down -->
<td class="num {signClass(m['52W Chg'])}">{m['52W Chg'] ?? '—'}</td>
<td class="num {signClass(m['From High'])}">{m['From High'] ?? '—'}</td>
<!-- Expert signals -->
<td class="analyst-cell">{m['Analyst'] ?? '—'}</td>
<td class="num {signClass(m['Upside'])}">{m['Upside'] ?? '—'}</td>
<td class="num {signClass(m['DCF Safety'])}">{m['DCF Safety'] ?? '—'}</td>
<!-- Risk flags -->
<td class="flags">
{#each v.audit?.riskFlags ?? [] as flag}
<span class="flag">{flag}</span>
{/each}
</td> </td>
{:else if type === 'ETF'} {:else if type === 'ETF'}
<td class="num">{m['Exp Ratio%'] ?? '—'}</td> <td class="num">{m['Exp Ratio%'] ?? '—'}</td>
<td class="num">{m['Yield%'] ?? '—'}</td> <td class="num">{m['5Y Return%'] ?? '—'}</td>
<td class="num">{m['AUM'] ?? '—'}</td>
<td class="num">{m['5Y Return%'] ?? '—'}</td>
{:else} {:else}
<td class="num">{m['YTM%'] ?? '—'}</td> <td class="num">{m['Rating'] ?? '—'}</td>
<td class="num">{m['Duration'] ?? '—'}</td> <td class="num">{m['YTM%'] ?? '—'}</td>
<td class="num">{m['Rating'] ?? '—'}</td>
{/if} {/if}
</tr> </tr>
<!-- ── Inline detail row ── -->
{#if isOpen}
{@const mktPass = r.inflated.audit?.passedGates}
{@const grahamPass = r.fundamental.audit?.passedGates}
<tr class="detail-row">
<td colspan={colCount} class="detail-cell">
<div class="detail-panel">
<!-- ══ LEFT — metric grid ══════════════════════════════════ -->
<div class="dp-left">
<div class="dp-title">Metrics</div>
<div class="dp-metric-grid">
{#if type === 'STOCK'}
<div class="dp-metric-card">
<span class="dp-mc-label">P/E</span>
<span class="dp-mc-value">{m['P/E'] ?? '—'}</span>
</div>
<div class="dp-metric-card">
<span class="dp-mc-label">PEG</span>
<span class="dp-mc-value">{m['PEG'] ?? '—'}</span>
</div>
<div class="dp-metric-card">
<span class="dp-mc-label">ROE%</span>
<span class="dp-mc-value {signClass(m['ROE%'])}">{m['ROE%'] ?? '—'}</span>
</div>
<div class="dp-metric-card">
<span class="dp-mc-label">Op Mgn%</span>
<span class="dp-mc-value {signClass(m['OpMgn%'])}">{m['OpMgn%'] ?? '—'}</span>
</div>
<div class="dp-metric-card">
<span class="dp-mc-label">Gross M%</span>
<span class="dp-mc-value {signClass(m['GrossM%'])}">{m['GrossM%'] ?? '—'}</span>
</div>
<div class="dp-metric-card">
<span class="dp-mc-label">FCF Yld%</span>
<span class="dp-mc-value {signClass(m['FCF Yld%'])}">{m['FCF Yld%'] ?? '—'}</span>
</div>
<div class="dp-metric-card">
<span class="dp-mc-label">D/E</span>
<span class="dp-mc-value">{m['D/E'] ?? '—'}</span>
</div>
<div class="dp-metric-card">
<span class="dp-mc-label">52W Chg</span>
<span class="dp-mc-value {signClass(m['52W Chg'])}">{m['52W Chg'] ?? '—'}</span>
</div>
<div class="dp-metric-card">
<span class="dp-mc-label">From High</span>
<span class="dp-mc-value {signClass(m['From High'])}">{m['From High'] ?? '—'}</span>
</div>
<div class="dp-metric-card">
<span class="dp-mc-label">Analyst</span>
<span class="dp-mc-value">{m['Analyst'] ?? '—'}</span>
</div>
<div class="dp-metric-card">
<span class="dp-mc-label">Upside</span>
<span class="dp-mc-value {signClass(m['Upside'])}">{m['Upside'] ?? '—'}</span>
</div>
<div class="dp-metric-card">
<span class="dp-mc-label">DCF Safety</span>
<span class="dp-mc-value {signClass(m['DCF Safety'])}">{m['DCF Safety'] ?? '—'}</span>
</div>
{:else if type === 'ETF'}
<div class="dp-metric-card">
<span class="dp-mc-label">Yield%</span>
<span class="dp-mc-value">{m['Yield%'] ?? '—'}</span>
</div>
<div class="dp-metric-card">
<span class="dp-mc-label">AUM</span>
<span class="dp-mc-value">{m['AUM'] ?? '—'}</span>
</div>
<div class="dp-metric-card">
<span class="dp-mc-label">5Y Ret%</span>
<span class="dp-mc-value {signClass(m['5Y Return%'])}">{m['5Y Return%'] ?? '—'}</span>
</div>
<div class="dp-metric-card">
<span class="dp-mc-label">Exp Ratio%</span>
<span class="dp-mc-value">{m['Exp Ratio%'] ?? '—'}</span>
</div>
{:else}
<div class="dp-metric-card">
<span class="dp-mc-label">YTM%</span>
<span class="dp-mc-value">{m['YTM%'] ?? '—'}</span>
</div>
<div class="dp-metric-card">
<span class="dp-mc-label">Duration</span>
<span class="dp-mc-value">{m['Duration'] ?? '—'}</span>
</div>
<div class="dp-metric-card">
<span class="dp-mc-label">Rating</span>
<span class="dp-mc-value">{m['Rating'] ?? '—'}</span>
</div>
{/if}
</div>
<!-- ── Gate badge chips ── -->
<div class="dp-gates-row">
<span class="dp-gate-chip" class:dp-gate-chip-pass={mktPass} class:dp-gate-chip-fail={!mktPass}>
MKT {mktPass ? '✓' : '✗'}
</span>
<span class="dp-gate-chip" class:dp-gate-chip-pass={grahamPass} class:dp-gate-chip-fail={!grahamPass}>
GRAHAM {grahamPass ? '✓' : '✗'}
</span>
</div>
<!-- ── Risk flag pills ── -->
{#if v.audit?.riskFlags?.length}
<div class="dp-risk-row">
{#each v.audit.riskFlags as flag}
<span class="dp-risk-pill">{flag}</span>
{/each}
</div>
{/if}
</div>
<!-- ══ RIGHT — verdict card (factor bar chart) ═════════════ -->
<div class="dp-right">
<div class="dp-title">
Factor Scores
<span class="dp-mode-note">({mode === 'inflated' ? 'Mkt-Adj' : 'Graham'})</span>
</div>
{#if !v.audit?.passedGates && v.audit?.failures?.length}
<!-- Gate failures shown when gates didn't pass -->
<div class="dp-failures">
{#each v.audit.failures as f}
<div class="dp-failure-item">{f}</div>
{/each}
</div>
{:else if breakdownEntries(v.audit?.breakdown).length}
{@const entries = breakdownEntries(v.audit?.breakdown)}
{@const scale = maxAbs(v.audit?.breakdown)}
<div class="dp-bar-chart">
{#each entries as [factor, score]}
{@const pct = Math.round((Math.abs(score) / scale) * 100)}
<div class="dp-bar-row">
<span class="dp-bar-label">{factor}</span>
<div class="dp-bar-track">
<div
class="dp-bar-fill {score > 0 ? 'dp-bar-pos' : 'dp-bar-neg'}"
style="width: {pct}%"
></div>
</div>
<span class="dp-bar-val {score > 0 ? 'pos' : score < 0 ? 'neg' : ''}">
{score > 0 ? '+' : ''}{score}
</span>
</div>
{/each}
</div>
{:else}
<div class="dp-no-factors">No factor data — gates failed before scoring</div>
{/if}
</div>
</div>
</td>
</tr>
{/if}
{/each} {/each}
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </section>
<style>
/* Score cell — truncates long gate summaries, tooltip shows full text */
.score-cell {
color: var(--text-dim);
font-size: var(--fs-sm);
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Classification tags */
.cap-tag { color: var(--blue-light, #93c5fd); border-color: var(--blue-dim, #1e3a5f); }
.style-tag { color: var(--text-muted); }
/* Signed % colouring */
.pos { color: var(--green); }
.neg { color: var(--red); }
/* Analyst label — not a number */
.analyst-cell {
font-size: var(--fs-sm);
color: var(--text-muted);
white-space: nowrap;
}
/* Risk flags column */
.flags { display: flex; flex-direction: column; gap: 2px; min-width: 160px; }
.flag { color: var(--orange); font-size: var(--fs-sm); white-space: nowrap; }
</style>
@@ -68,110 +68,19 @@
{/if} {/if}
{#if expanded} {#if expanded}
<div class="grid"> <div class="ctx-grid">
{#each cards as c} {#each cards as c}
<div class="card"> <div class="ctx-card">
<div class="label-row"> <div class="ctx-label-row">
<span class="label">{c.label}</span> <span class="ctx-card-label">{c.label}</span>
<span class="tip-wrap"> <span class="tip-wrap">
<span class="tip-anchor">?</span> <span class="tip-anchor">?</span>
<span class="tip-box">{c.tip}</span> <span class="tip-box">{c.tip}</span>
</span> </span>
</div> </div>
<div class="value">{c.value}</div> <div class="ctx-value">{c.value}</div>
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
</div> </div>
<style>
.ctx-wrap { margin-bottom: 20px; }
/* ── Collapsible toggle ─────────────────────────────────────────── */
.ctx-toggle {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 12px;
cursor: pointer;
margin-bottom: 10px;
}
.ctx-toggle-label {
font-size: var(--fs-sm);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dimmer);
}
.ctx-toggle-chevron { font-size: var(--fs-2xs); color: var(--text-faint); }
/* ── Cards grid ─────────────────────────────────────────────────── */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 10px;
margin-bottom: 8px;
}
.card { background: var(--bg-card); border-radius: var(--radius-md); padding: 12px var(--space-lg); }
.label-row { display: flex; align-items: center; justify-content: space-between; gap: 4px; }
.label { font-size: var(--fs-xs); color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; }
/* ── Tooltip ─────────────────────────────────────────────────────── */
.tip-wrap { position: relative; display: inline-flex; flex-shrink: 0; }
.tip-anchor {
display: inline-flex;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--text-faint);
color: var(--text-dimmer);
font-size: var(--fs-2xs);
font-weight: 700;
cursor: help;
}
.tip-box {
display: none;
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
width: 220px;
background: var(--bg-card);
border: 1px solid var(--text-faint);
border-radius: var(--radius-sm);
padding: 8px 10px;
font-size: var(--fs-sm);
color: var(--text-muted);
line-height: 1.5;
z-index: 50;
pointer-events: none;
white-space: normal;
}
.tip-box::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--text-faint);
}
.tip-wrap:hover .tip-box { display: block; }
.value { font-size: 17px; font-weight: 700; color: var(--text-primary); margin-top: 4px; }
</style>
@@ -3,67 +3,25 @@
import type { MarketContext } from '$lib/types.js'; import type { MarketContext } from '$lib/types.js';
let { ctx }: { ctx: MarketContext } = $props(); let { ctx }: { ctx: MarketContext } = $props();
// Flat list of chips so the template stays declarative // color: bg, border, text (all as hex/rgba strings)
const chips = $derived([ const chips = $derived([
{ label: '10Y', value: ctx.riskFreeRate?.toFixed(2) + '%' }, { label: '10Y', value: ctx.riskFreeRate != null ? ctx.riskFreeRate.toFixed(1) + '%' : '—', color: 'indigo' },
{ label: 'VIX', value: ctx.vixLevel?.toFixed(1) }, { label: 'VIX', value: ctx.vixLevel != null ? ctx.vixLevel.toFixed(1) : '—', color: 'rose' },
{ label: 'S&P', value: ctx.sp500Price?.toLocaleString() }, { label: 'S&P', value: ctx.sp500Price?.toLocaleString() ?? '—', color: 'emerald' },
{ label: 'S&P P/E', value: fmtPE(ctx.benchmarks?.marketPE) }, { label: 'S&P P/E', value: fmtPE(ctx.benchmarks?.marketPE), color: 'sky' },
{ label: 'Tech P/E', value: fmtPE(ctx.benchmarks?.techPE) }, { label: 'Tech P/E', value: fmtPE(ctx.benchmarks?.techPE), color: 'violet' },
{ label: 'REIT Yld', value: ctx.benchmarks?.reitYield?.toFixed(2) + '%' }, { label: 'REIT Yld', value: ctx.benchmarks?.reitYield != null ? ctx.benchmarks.reitYield.toFixed(1) + '%' : '—', color: 'amber' },
{ label: 'IG Sprd', value: ctx.benchmarks?.igSpread?.toFixed(2) + '%' }, { label: 'IG Sprd', value: ctx.benchmarks?.igSpread != null ? ctx.benchmarks.igSpread.toFixed(2) + '%' : '—', color: 'teal' },
{ label: 'Rates', value: ctx.rateRegime, regime: ctx.rateRegime }, { label: 'Rates', value: ctx.rateRegime, regime: ctx.rateRegime, color: ctx.rateRegime === 'HIGH' ? 'red' : ctx.rateRegime === 'LOW' ? 'blue' : 'slate' },
{ label: 'Vol', value: ctx.volatilityRegime, regime: ctx.volatilityRegime }, { label: 'Vol', value: ctx.volatilityRegime, regime: ctx.volatilityRegime, color: ctx.volatilityRegime === 'ELEVATED' ? 'orange' : 'slate' },
]); ]);
</script> </script>
<div class="ctx-strip"> <div class="bubble-strip">
{#each chips as chip} {#each chips as chip}
<div class="ctx-chip"> <div class="bubble bubble-{chip.color}">
<span class="ctx-label">{chip.label}</span> <span class="bubble-val">{chip.value ?? '—'}</span>
<span class="ctx-val" class:ctx-regime={!!chip.regime} data-regime={chip.regime ?? ''}> <span class="bubble-label">{chip.label}</span>
{chip.value ?? '—'}
</span>
</div> </div>
{/each} {/each}
</div> </div>
<style>
.ctx-strip {
display: flex;
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.ctx-chip {
flex: 1;
min-width: 70px;
background: var(--bg-base);
padding: 10px var(--space-lg);
display: flex;
flex-direction: column;
gap: 3px;
}
.ctx-label {
font-size: var(--fs-2xs);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dimmer);
}
.ctx-val {
font-size: var(--fs-lg);
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.ctx-regime[data-regime='HIGH'] { color: var(--red); }
.ctx-regime[data-regime='NORMAL'] { color: var(--text-muted); }
.ctx-regime[data-regime='LOW'] { color: var(--green); }
</style>
+71
View File
@@ -0,0 +1,71 @@
/**
* Auth store — holds current user + JWT token.
* Persists token to sessionStorage so it survives page refresh within the tab.
*/
import type { AuthUser, Role } from '$lib/types.js';
const TOKEN_KEY = 'ms_token';
function createAuthStore() {
// Hydrate from sessionStorage on first load
const stored = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(TOKEN_KEY) : null;
let token = $state<string | null>(stored);
let user = $state<AuthUser | null>(stored ? parseTokenUser(stored) : null);
function setAuth(newToken: string, newUser: AuthUser) {
token = newToken;
user = newUser;
sessionStorage.setItem(TOKEN_KEY, newToken);
}
function clearAuth() {
token = null;
user = null;
sessionStorage.removeItem(TOKEN_KEY);
}
return {
get token() {
return token;
},
get user() {
return user;
},
get isLoggedIn() {
return token !== null && user !== null;
},
get role(): Role | null {
return user?.role ?? null;
},
get isTrader() {
return user?.role === 'trader' || user?.role === 'admin';
},
setAuth,
clearAuth,
};
}
/** Decode the JWT payload (base64url middle segment) to extract user info. */
function parseTokenUser(token: string): AuthUser | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
if (!payload.sub || !payload.email || !payload.role) return null;
// Check expiry
if (payload.exp && payload.exp * 1000 < Date.now()) return null;
return {
id: payload.sub as string,
email: payload.email as string,
role: payload.role as Role,
createdAt: '',
lastLogin: null,
};
} catch {
return null;
}
}
export const authStore = createAuthStore();
+2 -3
View File
@@ -1,4 +1,4 @@
import { addHolding, removeHolding } from '$lib/api.js'; import { addHolding, removeHolding, authFetch } from '$lib/api.js';
import type { MarketContext, AdviceRow, PersonalFinance, HoldingFormData } from '$lib/types.js'; import type { MarketContext, AdviceRow, PersonalFinance, HoldingFormData } from '$lib/types.js';
interface PortfolioData { interface PortfolioData {
@@ -23,8 +23,7 @@ class PortfolioStore {
else this.refreshing = true; else this.refreshing = true;
this.loadError = null; this.loadError = null;
window authFetch('/api/finance/portfolio')
.fetch('/api/finance/portfolio')
.then((res) => .then((res) =>
res.ok res.ok
? res.json() ? res.json()
+17
View File
@@ -1,6 +1,23 @@
import type { AssetType } from '$types/asset.model.js'; import type { AssetType } from '$types/asset.model.js';
import type { LLMAnalysis } from '$types/finance.model.js'; import type { LLMAnalysis } from '$types/finance.model.js';
// ── Auth types ────────────────────────────────────────────────────────────────
export type Role = 'trader' | 'viewer' | 'admin';
export interface AuthUser {
id: string;
email: string;
role: Role;
createdAt: string;
lastLogin: string | null;
}
export interface AuthResponse {
token: string;
user: AuthUser;
}
/** Detailed display metrics rendered per asset row in the screener table. */ /** Detailed display metrics rendered per asset row in the screener table. */
export interface AssetDisplayMetrics { export interface AssetDisplayMetrics {
// ── Common ────────────────────────────────────────────────────────── // ── Common ──────────────────────────────────────────────────────────
+2 -2
View File
@@ -2,9 +2,9 @@
* Number and currency formatting utilities. * Number and currency formatting utilities.
*/ */
/** Formats a P/E ratio — e.g. 22.5 → "22.5x", null → "—" */ /** Formats a P/E ratio — e.g. 26.72091 → "26.7x", null → "—" */
export function fmtPE(v: number | null | undefined): string { export function fmtPE(v: number | null | undefined): string {
return v != null ? v + 'x' : '—'; return v != null ? v.toFixed(1) + 'x' : '—';
} }
/** Full currency format — e.g. 1234.5 → "$1,234.50" */ /** Full currency format — e.g. 1234.5 → "$1,234.50" */
+32 -8
View File
@@ -9,25 +9,49 @@
*/ */
export function verdictShort(label: string | null | undefined): string { export function verdictShort(label: string | null | undefined): string {
if (!label) return '—'; if (!label) return '—';
if (label.includes('High Conviction')) return 'Strong'; if (label.includes('High Conviction')) return 'Strong Buy';
if (label.includes('Speculative')) return 'Speculative'; if (label.includes('Speculative')) return 'Speculative';
if (label.includes('Momentum')) return 'Momentum';
if (label.includes('BUY')) return 'Buy'; if (label.includes('BUY')) return 'Buy';
if (label.includes('Efficient')) return 'Efficient'; if (label.includes('Efficient')) return 'Efficient';
if (label.includes('Attractive')) return 'Attractive'; if (label.includes('Attractive')) return 'Attractive';
if (label.includes('Neutral')) return 'Hold'; if (label.includes('Neutral')) return 'Hold';
if (label.includes('REJECT')) return 'Reject'; if (label.includes('REJECT')) return 'Reject';
if (label.includes('Avoid')) return 'Avoid'; if (label.includes('Avoid')) return 'Avoid';
return label.replace(/[🟢🟡🔴]/u, '').trim(); return label.replace(/[\u{1F7E2}\u{1F7E1}\u{1F534}]/u, '').trim();
} }
/** /**
* Returns a CSS colour class ('green' | 'yellow' | 'red') based on * Returns a CSS colour class based on the verdict label content.
* the emoji prefix of a verdict label. *
* Signal mapping:
* 🟢 / High Conviction / Efficient / Attractive → green
* 🟡 / Speculative / Momentum → yellow
* Neutral / Hold / no signal → blue (calm, not alarming)
* 🔴 / Avoid / Reject / REJECT → red
*/ */
export function vClass(label: string | null | undefined): 'green' | 'yellow' | 'red' { export function vClass(
if (label?.startsWith('🟢')) return 'green'; label: string | null | undefined,
if (label?.startsWith('🟡')) return 'yellow'; ): 'green' | 'yellow' | 'red' | 'blue' | 'gray' {
return 'red'; if (!label) return 'gray';
if (
label.startsWith('🟢') ||
label.includes('High Conviction') ||
label.includes('Efficient') ||
label.includes('Attractive')
)
return 'green';
if (label.startsWith('🟡') || label.includes('Speculative') || label.includes('Momentum'))
return 'yellow';
if (
label.startsWith('🔴') ||
label.includes('Avoid') ||
label.includes('Reject') ||
label.includes('REJECT')
)
return 'red';
if (label.includes('Neutral') || label.includes('Hold') || label.includes('BUY')) return 'blue';
return 'gray';
} }
/** /**
+26 -4
View File
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { page, navigating } from '$app/stores'; import { page, navigating } from '$app/stores';
import { goto } from '$app/navigation';
import '../styles/app.scss'; import '../styles/app.scss';
import Spinner from '$lib/components/shared/Spinner.svelte'; import Spinner from '$lib/components/shared/Spinner.svelte';
import { authStore } from '$lib/stores/auth.store.svelte.js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props(); let { children }: { children: Snippet } = $props();
@@ -9,6 +11,14 @@
// so the nav link highlights immediately on click, not after load completes. // so the nav link highlights immediately on click, not after load completes.
const activePath = $derived($navigating?.to?.url?.pathname ?? $page.url.pathname); 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( const navLabel = $derived(
activePath === '/portfolio' ? 'Loading portfolio…' : activePath === '/portfolio' ? 'Loading portfolio…' :
activePath?.startsWith('/calls') ? 'Loading market calls…' : activePath?.startsWith('/calls') ? 'Loading market calls…' :
@@ -21,10 +31,22 @@
<nav> <nav>
<span class="brand">📊 Market Screener</span> <span class="brand">📊 Market Screener</span>
<div class="links"> <div class="links">
<a href="/" class:active={activePath === '/'}>Screener</a> {#if authStore.isLoggedIn}
<a href="/portfolio" class:active={activePath === '/portfolio'}>Portfolio</a> <a href="/" class:active={activePath === '/'}>Screener</a>
<a href="/calls" class:active={activePath?.startsWith('/calls')}>Market Calls</a> <a href="/portfolio" class:active={activePath === '/portfolio'}>Portfolio</a>
<a href="/safe-buys" class:active={activePath === '/safe-buys'}>🛡 Safe Buys</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> </div>
</nav> </nav>
+2 -90
View File
@@ -1,8 +1,6 @@
<script lang="ts"> <script lang="ts">
import { screenerStore } from '$lib/stores/screener.store.svelte.js'; 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 Spinner from '$lib/components/shared/Spinner.svelte';
import VerdictPill from '$lib/components/shared/VerdictPill.svelte';
import MarketContextStrip from '$lib/components/shared/MarketContextStrip.svelte'; import MarketContextStrip from '$lib/components/shared/MarketContextStrip.svelte';
import AssetTable from '$lib/components/screener/AssetTable.svelte'; import AssetTable from '$lib/components/screener/AssetTable.svelte';
import AnalysisSidebar from '$lib/components/screener/AnalysisSidebar.svelte'; import AnalysisSidebar from '$lib/components/screener/AnalysisSidebar.svelte';
@@ -23,14 +21,11 @@
}); });
</script> </script>
<div class="page"> <div class="screener-page">
<!-- ── Toolbar ────────────────────────────────────────────────────── --> <!-- ── Toolbar ────────────────────────────────────────────────────── -->
<div class="toolbar"> <div class="toolbar">
<div class="toolbar-top"> <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 <button
onclick={() => searchOpen = !searchOpen} onclick={() => searchOpen = !searchOpen}
class="btn-search-toggle" class="btn-search-toggle"
@@ -46,6 +41,7 @@
{#if searchOpen} {#if searchOpen}
<div class="search-row"> <div class="search-row">
<input <input
class="search-input"
bind:value={s.input} bind:value={s.input}
placeholder="AAPL, MSFT, VOO …" placeholder="AAPL, MSFT, VOO …"
onkeydown={e => e.key === 'Enter' && s.screen()} onkeydown={e => e.key === 'Enter' && s.screen()}
@@ -72,43 +68,6 @@
{/if} {/if}
{#if s.results && !s.loading && !s.loadingCats} {#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 ────────────────────────────────────── --> <!-- ── Per-type detail tables ────────────────────────────────────── -->
{#each (['STOCK', 'ETF', 'BOND'] as const) as type} {#each (['STOCK', 'ETF', 'BOND'] as const) as type}
{#if s.results[type]?.length} {#if s.results[type]?.length}
@@ -136,50 +95,3 @@
</div> </div>
<AnalysisSidebar sidebar={s.sidebar} onClose={() => s.closeSidebar()} /> <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); const totalStrong = $derived(strongEtfs.length + strongBonds.length);
</script> </script>
<div class="page"> <div class="safe-buys-page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>🛡 Safe Buys</h1> <h1>🛡 Safe Buys</h1>
@@ -231,60 +231,3 @@
{/if} {/if}
{/if} {/if}
</div> </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>
+8 -3
View File
@@ -13,15 +13,20 @@
// Unified: replaces both .verdict-pill (screener) and .vpill (safe-buys) // Unified: replaces both .verdict-pill (screener) and .vpill (safe-buys)
$verdict-variants: ( $verdict-variants: (
'green': (color: var(--green), bg: var(--green-bg)), 'green': (color: var(--green), bg: var(--green-bg)),
'yellow': (color: var(--yellow), bg: var(--yellow-bg)), 'yellow': (color: var(--yellow), bg: var(--yellow-bg)),
'red': (color: var(--red), bg: var(--red-bg)), 'red': (color: var(--red), bg: var(--red-bg)),
'blue': (color: #60a5fa, bg: #1e3a5f33),
'gray': (color: var(--text-muted), bg: #1e293b),
); );
.verdict-pill { .verdict-pill {
@extend %pill-base; @extend %pill-base;
font-size: var(--fs-sm); font-size: var(--fs-sm);
letter-spacing: 0.02em; letter-spacing: 0.02em;
// Ensure all pills have a consistent look — fallback to gray
background: #1e293b;
color: var(--text-muted);
@each $name, $vals in $verdict-variants { @each $name, $vals in $verdict-variants {
&.#{$name} { &.#{$name} {
+23 -1
View File
@@ -23,7 +23,6 @@ nav {
.links { .links {
display: flex; display: flex;
gap: var(--space-xs); gap: var(--space-xs);
margin-left: auto;
a { a {
color: var(--text-dim); color: var(--text-dim);
@@ -42,6 +41,29 @@ nav {
} }
} }
// ── Nav auth (sign in / user + sign out) ─────────────────────────────────
.nav-auth {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-left: auto;
}
.nav-user {
font-size: var(--fs-sm);
color: var(--text-dim);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-sm {
padding: 4px 10px;
font-size: var(--fs-sm);
}
main { flex: 1; padding: 28px 32px; } main { flex: 1; padding: 28px 32px; }
// ── Navigation progress bar ─────────────────────────────────────────────── // ── Navigation progress bar ───────────────────────────────────────────────
+12
View File
@@ -216,6 +216,18 @@
} }
} }
// Reason column — wraps so it doesn't blow out the table width
.col-reason {
white-space: normal !important;
max-width: 260px;
min-width: 160px;
line-height: 1.4;
font-size: var(--fs-sm);
color: var(--text-muted);
}
.advice-table .sortable { cursor: pointer; user-select: none; &:hover { color: var(--text-muted); } }
.advice-row-actions { display: flex; gap: 4px; align-items: center; } .advice-row-actions { display: flex; gap: 4px; align-items: center; }
.btn-row-edit { .btn-row-edit {
+822
View File
@@ -0,0 +1,822 @@
// ── Screener route — +page.svelte, safe-buys/+page.svelte, AssetTable ────
// ── +page.svelte (screener) ───────────────────────────────────────────────
.screener-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; }
.search-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; }
// ── safe-buys/+page.svelte ────────────────────────────────────────────────
.safe-buys-page { max-width: 1100px; padding-bottom: 60px; }
.page-header { margin-bottom: 20px; }
.safe-buys-page {
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-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-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-section { opacity: 0.75; }
.watch-section:hover { opacity: 1; transition: opacity 0.2s; }
.score { color: var(--text-dimmer); font-size: var(--fs-sm); }
// ── AssetTable ────────────────────────────────────────────────────────────
.asset-section { margin-bottom: 24px; }
.analyze-btn {
margin-left: auto;
font-size: var(--fs-sm);
padding: 5px 14px;
}
// ── Inline filter row (inside thead) ─────────────────────────────────────
.filter-row td {
padding: 4px var(--space-lg) !important;
background: #0a1628;
border-bottom: 1px solid var(--border) !important;
// sticky first cell matches the header sticky column
&:first-child {
position: sticky;
left: 0;
background: #0a1628;
z-index: 1;
}
}
// Pair of inputs side-by-side (e.g. price min/max)
.th-filter-pair {
display: flex;
flex-direction: row;
gap: 4px;
max-width: 160px;
.th-filter { min-width: 0; width: 50%; padding-left: 5px; padding-right: 5px; }
}
.th-filter-num {
min-width: 0;
-moz-appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button { -webkit-appearance: none; }
}
// Checkbox + label for flag filter
.th-filter-check {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
input[type='checkbox'] {
accent-color: var(--blue);
width: 12px;
height: 12px;
cursor: pointer;
}
}
// Input / select sitting inside a th filter cell
.th-filter {
width: 100%;
min-width: 80px;
background: #0f1e33;
border: 1px solid #1e3050;
border-radius: var(--radius-sm);
color: var(--text-secondary);
padding: 3px 7px;
font-size: 11px;
font-family: inherit;
outline: none;
transition: border-color var(--transition);
appearance: none;
-webkit-appearance: none;
&:focus { border-color: var(--blue); box-shadow: 0 0 0 2px #3b82f620; }
&::placeholder { color: var(--text-faint); }
// Style active (non-empty) filter
&:not([value='']):not(:placeholder-shown) {
border-color: var(--blue);
color: #93c5fd;
}
}
// "Clear filters" link in section header
.filter-clear-btn {
font-size: 11px;
color: var(--text-faint);
background: transparent;
border: none;
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-sm);
margin-left: 4px;
&:hover { color: var(--red); }
}
// Sortable column header
.sort-th {
cursor: pointer;
user-select: none;
&:hover { color: var(--text-muted); }
}
.sort-icon {
display: inline-block;
margin-left: 4px;
font-size: 10px;
color: var(--text-faint);
vertical-align: middle;
line-height: 1;
}
// Expand toggle column
.col-expand {
width: 24px;
min-width: 24px;
color: var(--text-faint);
font-size: var(--fs-xs);
cursor: pointer;
user-select: none;
}
// Prevent Verdict column from collapsing — pill needs room
.asset-table th:nth-child(5),
.asset-table td:nth-child(5) {
min-width: 100px;
}
// Prevent Signal column from collapsing
.asset-table th:nth-child(4),
.asset-table td:nth-child(4) {
min-width: 110px;
}
// ── Merged Signal/Verdict pill ────────────────────────────────────────────
.signal-verdict-cell {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 120px;
}
// Main verdict pill — colored by signal class
.sv-pill {
display: inline-block;
font-size: 11px;
font-weight: 700;
padding: 2px 9px;
border-radius: var(--radius-pill);
letter-spacing: 0.03em;
white-space: nowrap;
width: fit-content;
&.sv-strong { background: #14532d33; color: #4ade80; border: 1px solid #14532d66; }
&.sv-momentum { background: #1e3a5f33; color: #60a5fa; border: 1px solid #1e3a5f66; }
&.sv-spec { background: #7c2d1233; color: #fb923c; border: 1px solid #7c2d1266; }
&.sv-neutral { background: #1e293b; color: #94a3b8; border: 1px solid #334155; }
&.sv-avoid { background: #450a0a33; color: #f87171; border: 1px solid #450a0a66; }
}
// Sub-label showing the full signal text
.sv-signal-label {
font-size: 10px;
color: var(--text-faint);
letter-spacing: 0.02em;
white-space: nowrap;
}
// ── Score dot scale ───────────────────────────────────────────────────────
.score-cell {
white-space: nowrap;
min-width: 80px;
}
.score-dots {
display: inline-flex;
gap: 3px;
vertical-align: middle;
margin-right: 5px;
}
.score-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #1e3050;
border: 1px solid #2a4060;
transition: background 0.15s;
&.on { background: var(--blue); border-color: #60a5fa; box-shadow: 0 0 4px #3b82f644; }
}
.score-num {
font-size: 11px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.score-fail {
color: var(--red);
font-size: 12px;
font-weight: 700;
}
// ── Flags badge with hover-expand tooltip ────────────────────────────────
.flags-cell {
position: relative;
min-width: 48px;
}
.flags-badge {
position: relative;
display: inline-block;
cursor: default;
&:hover .flags-tooltip { opacity: 1; pointer-events: auto; }
}
.flags-count {
display: inline-block;
background: #431a0033;
border: 1px solid #431a0066;
color: #fb923c;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: var(--radius-pill);
white-space: nowrap;
cursor: default;
}
.flags-tooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
z-index: 50;
background: #0c1829;
border: 1px solid #1e3a5f;
border-radius: var(--radius-md);
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 180px;
max-width: 280px;
box-shadow: 0 8px 24px #00000066;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
// Summary row — clickable; open state gets left accent bar + lifted bg
// NOTE: background is set on the <tr> so that position:sticky td:first-child
// inherits it (sticky cells create their own stacking context).
.summary-row {
cursor: pointer;
&:hover { background: #1e2f48; }
&.row-open {
background: #1a2f4a;
td { border-bottom: none !important; }
// Brighter left accent on the expand cell
.col-expand {
box-shadow: inset 3px 0 0 #60a5fa;
}
}
}
// ── Inline detail row ────────────────────────────────────────────────────
.detail-row td {
padding: 0 !important;
border-bottom: none !important;
box-shadow: inset 0 -3px 0 #1e3a5f;
}
.detail-cell { padding: 0 !important; }
// Two-column layout: left = metrics (55%), right = bar chart (45%)
// min-width: 0 on each child prevents grid blowout
.detail-panel {
display: grid;
grid-template-columns: 55% 45%;
gap: 0;
background: #0c1829;
border-top: 2px solid var(--blue);
border-left: 3px solid var(--blue);
overflow: hidden;
}
// ── Left zone ────────────────────────────────────────────────────────────
.dp-left {
min-width: 0;
padding: 16px 18px 16px 16px;
border-right: 1px solid #1e3a5f;
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
}
// ── Right zone ───────────────────────────────────────────────────────────
.dp-right {
min-width: 0;
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 10px;
}
// ── Section title ─────────────────────────────────────────────────────────
.dp-title {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #4a7aaa;
margin-bottom: 2px;
}
.dp-mode-note {
font-size: 10px;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
color: #3a5a7a;
margin-left: 4px;
}
// ── Metric grid cards ─────────────────────────────────────────────────────
// Fixed 4-col grid on left panel — no min-width auto-fill to prevent overflow
.dp-metric-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 5px;
}
.dp-metric-card {
min-width: 0;
background: #0f2040;
border: 1px solid #1a3050;
border-radius: 6px;
padding: 5px 7px;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.dp-mc-label {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #3d5a7a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dp-mc-value {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// ── Gate badge chips ──────────────────────────────────────────────────────
.dp-gates-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.dp-gate-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
padding: 3px 10px;
border-radius: var(--radius-pill);
border: 1px solid transparent;
}
.dp-gate-chip-pass {
background: #14532d33;
color: var(--green);
border-color: #14532d66;
}
.dp-gate-chip-fail {
background: #450a0a33;
color: var(--red);
border-color: #450a0a66;
}
// ── Risk flag pills ───────────────────────────────────────────────────────
.dp-risk-row {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.dp-risk-pill {
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 600;
padding: 2px 9px;
border-radius: var(--radius-pill);
background: #431a0033;
color: #fb923c;
border: 1px solid #431a0066;
white-space: nowrap;
}
// ── Horizontal bar chart (factor scores) ─────────────────────────────────
.dp-bar-chart {
display: flex;
flex-direction: column;
gap: 8px;
}
.dp-bar-row {
display: grid;
grid-template-columns: 88px 1fr 36px;
align-items: center;
gap: 10px;
}
.dp-bar-label {
font-size: 12px;
color: #6b8aad;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dp-bar-track {
height: 10px;
background: #0f2040;
border-radius: 3px;
overflow: hidden;
border: 1px solid #1a3050;
}
.dp-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
&.dp-bar-pos {
background: linear-gradient(90deg, #16a34a, #4ade80);
box-shadow: 0 0 6px #4ade8044;
}
&.dp-bar-neg {
background: linear-gradient(90deg, #991b1b, #f87171);
box-shadow: 0 0 6px #f8717144;
}
}
.dp-bar-val {
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
text-align: right;
}
// ── Gate failures (shown instead of bars when gates fail) ─────────────────
.dp-failures {
display: flex;
flex-direction: column;
gap: 5px;
}
.dp-failure-item {
font-size: 13px;
color: #f87171;
line-height: 1.5;
}
.dp-no-factors {
font-size: 12px;
color: #3a5a7a;
font-style: italic;
}
/* score-cell base — layout only; visual rules are in the dot-scale block above */
/* Classification tags */
.cap-tag { color: var(--blue-light, #93c5fd); border-color: var(--blue-dim, #1e3a5f); }
.style-tag { color: var(--text-muted); }
/* Signed % colouring */
.pos { color: var(--green); }
.neg { color: var(--red); }
/* Analyst label — not a number */
.analyst-cell {
font-size: var(--fs-sm);
color: var(--text-muted);
white-space: nowrap;
}
/* .flag / .flag-more removed — replaced by .flags-badge hover-expand system */
// ── MarketContext (collapsible card) ──────────────────────────────────────
.ctx-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
padding: var(--space-xl);
}
.ctx-card {
background: var(--bg-card);
border-radius: var(--radius-md);
padding: 12px var(--space-lg);
}
.ctx-label {
font-size: var(--fs-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dimmer);
margin-bottom: 4px;
}
.ctx-value {
font-size: var(--fs-lg);
font-weight: 700;
color: var(--text-primary);
}
.ctx-sub {
font-size: var(--fs-xs);
color: var(--text-dim);
margin-top: 2px;
}
.ctx-toggle {
margin-left: auto;
background: none;
border: none;
color: var(--text-dimmer);
font-size: var(--fs-sm);
cursor: pointer;
padding: 2px 8px;
&:hover { color: var(--text-muted); }
}
// ── MarketContextStrip — colorful bubbles ─────────────────────────────────
.bubble-strip {
display: flex;
flex-wrap: nowrap;
gap: 8px;
margin: 8px 0;
}
.bubble {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 0;
padding: 10px 8px;
border-radius: 14px;
border: 1px solid transparent;
gap: 2px;
transition: transform 0.12s ease, box-shadow 0.12s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px #0006;
}
}
.bubble-val {
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
line-height: 1;
}
.bubble-label {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.72;
margin-top: 3px;
}
// Individual color themes
.bubble-indigo { background: #312e8133; border-color: #4f46e566; color: #a5b4fc; }
.bubble-rose { background: #4c0d1533; border-color: #e1184866; color: #fda4af; }
.bubble-emerald { background: #05401a33; border-color: #10b98166; color: #6ee7b7; }
.bubble-sky { background: #082f4933; border-color: #0ea5e966; color: #7dd3fc; }
.bubble-violet { background: #2e1a6e33; border-color: #7c3aed66; color: #c4b5fd; }
.bubble-amber { background: #451a0333; border-color: #f59e0b66; color: #fcd34d; }
.bubble-teal { background: #042f2e33; border-color: #14b8a666; color: #5eead4; }
.bubble-slate { background: #1e293b; border-color: #47556966; color: #94a3b8; }
.bubble-red { background: #450a0a33; border-color: #ef444466; color: #fca5a5; }
.bubble-blue { background: #1e3a5f33; border-color: #3b82f666; color: #93c5fd; }
.bubble-orange { background: #431a0333; border-color: #f9731666; color: #fdba74; }
// ── MarketContext (collapsible card grid) ─────────────────────────────────
.ctx-wrap { margin-bottom: 20px; }
.ctx-toggle {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 12px;
cursor: pointer;
margin-bottom: 10px;
}
.ctx-toggle-label {
font-size: var(--fs-sm);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dimmer);
}
.ctx-toggle-chevron { font-size: var(--fs-2xs); color: var(--text-faint); }
.ctx-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 10px;
margin-bottom: 8px;
}
.ctx-card { background: var(--bg-card); border-radius: var(--radius-md); padding: 12px var(--space-lg); }
.ctx-label-row { display: flex; align-items: center; justify-content: space-between; gap: 4px; }
.ctx-card-label { font-size: var(--fs-xs); color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; }
// Tooltip
.tip-wrap { position: relative; display: inline-flex; flex-shrink: 0; }
.tip-anchor {
display: inline-flex;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--text-faint);
color: var(--text-dimmer);
font-size: var(--fs-2xs);
font-weight: 700;
cursor: help;
}
.tip-box {
display: none;
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
width: 220px;
background: var(--bg-card);
border: 1px solid var(--text-faint);
border-radius: var(--radius-sm);
padding: 8px 10px;
font-size: var(--fs-sm);
color: var(--text-muted);
line-height: 1.5;
z-index: 50;
pointer-events: none;
white-space: normal;
&::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--text-faint);
}
}
.tip-wrap:hover .tip-box { display: block; }
.ctx-value { font-size: 17px; font-weight: 700; color: var(--text-primary); margin-top: 4px; }
+5 -8
View File
@@ -17,7 +17,7 @@ table {
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
color: var(--text-faint); color: var(--text-dim);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
white-space: nowrap; white-space: nowrap;
background: var(--bg-elevated); background: var(--bg-elevated);
@@ -27,11 +27,7 @@ table {
tr { tr {
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
&:hover { &:hover { background: var(--bg-card-hover); }
background: var(--bg-card-hover);
td:first-child { background: var(--bg-card-hover); }
}
} }
td { td {
@@ -40,11 +36,12 @@ table {
white-space: nowrap; white-space: nowrap;
font-size: var(--fs-md); font-size: var(--fs-md);
// Sticky first column (body) // Sticky first column — inherits row background so hover/select states
// paint through correctly across the full row width
&:first-child { &:first-child {
position: sticky; position: sticky;
left: 0; left: 0;
background: var(--bg-surface); background: inherit;
z-index: 1; z-index: 1;
} }
} }
+1
View File
@@ -13,3 +13,4 @@
@use 'sidebar'; @use 'sidebar';
@use 'calls'; @use 'calls';
@use 'portfolio'; @use 'portfolio';
@use 'screener';