Files
market_screener/ui/src/routes/calls/+page.svelte
T
2026-06-06 22:55:43 -04:00

421 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { createCall, deleteCall } from '$lib/api.js';
import SignalBadge from '$lib/SignalBadge.svelte';
import Spinner from '$lib/Spinner.svelte';
import { invalidateAll } from '$app/navigation';
let { data } = $props();
// New call form state
let showForm = $state(false);
let saving = $state(false);
let formError = $state(null);
let form = $state({
title: '',
quarter: currentQuarter(),
date: today(),
thesis: '',
tickers: '',
});
function currentQuarter() {
const d = new Date();
const q = Math.ceil((d.getMonth() + 1) / 3);
return `Q${q} ${d.getFullYear()}`;
}
function today() {
return new Date().toISOString().slice(0, 10);
}
async function submit() {
formError = null;
saving = true;
try {
await createCall({
title: form.title.trim(),
quarter: form.quarter.trim(),
date: form.date,
thesis: form.thesis.trim(),
tickers: form.tickers.split(/[\s,]+/).map(t => t.trim().toUpperCase()).filter(Boolean),
});
showForm = false;
form = { title: '', quarter: currentQuarter(), date: today(), thesis: '', tickers: '' };
await invalidateAll(); // re-run load() to refresh the list
} catch (e) {
formError = e.message;
} finally {
saving = false;
}
}
async function remove(id) {
if (!confirm('Delete this market call?')) return;
await deleteCall(id);
await invalidateAll();
}
const signalColor = s => {
if (s?.includes('Strong')) return '#4ade80';
if (s?.includes('Momentum')) return '#60a5fa';
if (s?.includes('Neutral')) return '#94a3b8';
if (s?.includes('Speculation')) return '#fb923c';
return '#f87171';
};
const eventIcon = type => ({ earnings: '📊', exdividend: '💰', dividend: '💵' })[type] ?? '📅';
const eventColor = type => ({ earnings: '#60a5fa', exdividend: '#facc15', dividend: '#4ade80' })[type] ?? '#94a3b8';
const upcoming = $derived((data.events ?? []).filter(e => !e.isPast).slice(0, 20));
const past = $derived((data.events ?? []).filter(e => e.isPast).slice(0, 10));
const fmtMoney = n => n == null ? null :
n >= 1e9 ? '$' + (n / 1e9).toFixed(1) + 'B' :
n >= 1e6 ? '$' + (n / 1e6).toFixed(1) + 'M' : '$' + n.toFixed(2);
</script>
<div class="page">
<div class="page-header">
<div>
<h1>Market Calls</h1>
<p class="subtitle">Quarterly investment theses tracked from the day you made the call</p>
</div>
<button class="btn-primary" onclick={() => showForm = !showForm}>
{showForm ? 'Cancel' : ' New Call'}
</button>
</div>
<!-- ── New Call Form ──────────────────────────────────────────────── -->
{#if showForm}
<section class="section form-section">
<div class="section-header"><h2>New Market Call</h2></div>
<form class="call-form" onsubmit={e => { e.preventDefault(); submit(); }}>
<div class="form-row">
<label>
<span>Title</span>
<input bind:value={form.title} placeholder="Q3 2025 Rate pivot & tech rotation" required />
</label>
<label class="narrow">
<span>Quarter</span>
<input bind:value={form.quarter} placeholder="Q3 2025" required />
</label>
<label class="narrow">
<span>Date</span>
<input type="date" bind:value={form.date} required />
</label>
</div>
<label>
<span>Thesis</span>
<textarea
bind:value={form.thesis}
rows="4"
placeholder="Describe the macro thesis behind this call — why these tickers, why now..."
required
></textarea>
</label>
<label>
<span>Tickers to track</span>
<input
bind:value={form.tickers}
placeholder="AAPL, MSFT, TLT, GLD …"
required
/>
<span class="hint">Comma or space separated. Current prices will be snapshot automatically.</span>
</label>
{#if formError}
<div class="form-error">{formError}</div>
{/if}
<button type="submit" class="btn-primary" disabled={saving}>
{#if saving}
<Spinner size="sm" />
<span>Snapshotting prices…</span>
{:else}
Save Call
{/if}
</button>
</form>
</section>
{/if}
<!-- ── Calendar ──────────────────────────────────────────────────── -->
{#if (data.events ?? []).length > 0}
<section class="section">
<div class="section-header">
<h2>📅 Upcoming Events</h2>
<span class="count">{upcoming.length} upcoming</span>
{#if past.length > 0}
<span class="count" style="margin-left:4px">{past.length} recent</span>
{/if}
</div>
<div class="cal-grid">
{#each upcoming as ev}
<div class="cal-event">
<div class="cal-date">{ev.date}</div>
<div class="cal-content">
<span class="cal-ticker">{ev.ticker}</span>
<span class="cal-type" style="color:{eventColor(ev.type)}">
{eventIcon(ev.type)} {ev.label}
{#if ev.detail}<span class="cal-detail">· {ev.detail}</span>{/if}
</span>
{#if ev.epsEstimate != null}
<span class="cal-est">EPS est. ${ev.epsEstimate?.toFixed(2)} · Rev est. {fmtMoney(ev.revEstimate)}</span>
{/if}
</div>
</div>
{/each}
{#if past.length > 0}
<div class="cal-divider">— Past —</div>
{#each past as ev}
<div class="cal-event past">
<div class="cal-date">{ev.date}</div>
<div class="cal-content">
<span class="cal-ticker">{ev.ticker}</span>
<span class="cal-type past-type">
{eventIcon(ev.type)} {ev.label}
</span>
</div>
</div>
{/each}
{/if}
</div>
</section>
{/if}
<!-- ── Calls List ────────────────────────────────────────────────── -->
{#if data.error}
<div class="error-banner">{data.error}</div>
{:else if data.calls.length === 0}
<div class="empty">No market calls yet. Create your first one to start tracking.</div>
{:else}
{#each data.calls as call}
<section class="section call-card">
<div class="section-header">
<div class="call-meta">
<a href="/calls/{call.id}" class="call-title">{call.title}</a>
<div class="call-badges">
<span class="tag">{call.quarter}</span>
<span class="date-badge">{call.date}</span>
<span class="count">{call.tickers.length} tickers</span>
</div>
</div>
<button class="btn-delete" onclick={() => remove(call.id)}>✕</button>
</div>
<div class="call-body">
<p class="thesis">{call.thesis}</p>
{#if Object.keys(call.snapshot ?? {}).length}
<div class="snapshot-grid">
{#each call.tickers as ticker}
{@const snap = call.snapshot[ticker]}
{#if snap}
<a href="/calls/{call.id}" class="snap-card">
<div class="snap-ticker">{ticker}</div>
<div class="snap-price">${snap.price?.toFixed(2) ?? '—'}</div>
<div class="snap-signal" style="color:{signalColor(snap.signal)}">
{snap.signal?.replace(/[✅⚡⚠️🔄❌]/u, '').trim() ?? '—'}
</div>
</a>
{/if}
{/each}
</div>
<a href="/calls/{call.id}" class="view-link">View performance → </a>
{/if}
</div>
</section>
{/each}
{/if}
</div>
<style>
.page { max-width: 1100px; padding-bottom: 60px; }
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
h1 { font-size: 20px; font-weight: 700; color: #f1f5f9; margin-bottom: 4px; }
.subtitle { font-size: 12px; color: #475569; }
/* ── Buttons ─────────────────────────────────────────────────────── */
button {
padding: 9px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
transition: background 0.15s;
}
.btn-primary { background: #2563eb; color: #fff; display: inline-flex; align-items: center; gap: 8px; }
.btn-primary:hover:not(:disabled) { background: #1d4ed8; }
.btn-primary:disabled { opacity: 0.5; cursor: default; }
.btn-delete { background: transparent; color: #475569; padding: 4px 8px; font-size: 14px; }
.btn-delete:hover { color: #f87171; }
/* ── Section ─────────────────────────────────────────────────────── */
.section {
background: #0d1117;
border: 1px solid #1e293b;
border-radius: 10px;
margin-bottom: 16px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid #1e293b;
background: #111827;
}
h2 { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin: 0; }
/* ── Form ────────────────────────────────────────────────────────── */
.call-form { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.form-row { display: grid; grid-template-columns: 1fr auto auto; gap: 12px; align-items: start; }
.form-row .narrow { min-width: 120px; }
label { display: flex; flex-direction: column; gap: 5px; }
label > span { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; }
input, textarea {
background: #1e293b;
border: 1px solid #2d3f55;
border-radius: 8px;
color: #e2e8f0;
padding: 9px 12px;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
font-family: inherit;
}
input:focus, textarea:focus { border-color: #3b82f6; }
textarea { resize: vertical; }
.hint { font-size: 11px; color: #475569; }
.form-error { color: #f87171; font-size: 12px; background: #450a0a33; padding: 8px 12px; border-radius: 6px; }
/* ── Call card ───────────────────────────────────────────────────── */
.call-meta { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; }
.call-title {
font-size: 14px;
font-weight: 700;
color: #f1f5f9;
text-decoration: none;
}
.call-title:hover { color: #60a5fa; }
.call-badges { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.tag {
display: inline-block;
background: #1e293b;
color: #64748b;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.date-badge { font-size: 11px; color: #475569; }
.count { font-size: 10px; color: #334155; background: #1e293b; padding: 2px 7px; border-radius: 20px; }
.call-body { padding: 18px; display: flex; flex-direction: column; gap: 16px; }
.thesis {
font-size: 13px;
color: #94a3b8;
line-height: 1.6;
border-left: 3px solid #1e3a5f;
padding-left: 14px;
margin: 0;
}
/* ── Snapshot grid ───────────────────────────────────────────────── */
.snapshot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 8px;
}
.snap-card {
background: #111827;
border: 1px solid #1e293b;
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 3px;
text-decoration: none;
transition: border-color 0.15s;
}
.snap-card:hover { border-color: #334155; }
.snap-ticker { font-size: 12px; font-weight: 700; color: #f1f5f9; }
.snap-price { font-size: 11px; color: #64748b; font-variant-numeric: tabular-nums; }
.snap-signal { font-size: 10px; font-weight: 600; }
.view-link { font-size: 12px; color: #60a5fa; text-decoration: none; }
.view-link:hover { text-decoration: underline; }
.empty { color: #475569; font-size: 13px; padding: 40px 0; text-align: center; }
.error-banner { background: #450a0a55; border: 1px solid #7f1d1d; border-radius: 8px; color: #f87171; padding: 10px 14px; margin-bottom: 16px; font-size: 13px; }
/* ── Calendar ───────────────────────────────────────────────────── */
.cal-grid {
padding: 8px 18px 14px;
display: flex;
flex-direction: column;
gap: 2px;
}
.cal-event {
display: grid;
grid-template-columns: 96px 1fr;
gap: 14px;
align-items: start;
padding: 8px 6px;
border-radius: 6px;
transition: background 0.1s;
}
.cal-event:hover { background: #111827; }
.cal-event.past { opacity: 0.45; }
.cal-date {
font-size: 11px;
font-variant-numeric: tabular-nums;
color: #475569;
padding-top: 1px;
white-space: nowrap;
}
.cal-content { display: flex; flex-direction: column; gap: 2px; }
.cal-ticker { font-size: 12px; font-weight: 700; color: #f1f5f9; }
.cal-type { font-size: 11px; font-weight: 600; }
.cal-detail { font-weight: 400; color: #64748b; }
.past-type { color: #475569 !important; }
.cal-est { font-size: 10px; color: #475569; }
.cal-divider {
font-size: 10px;
color: #334155;
text-align: center;
padding: 8px 0 4px;
letter-spacing: 0.06em;
}
</style>