feat: add Citations and WatchlistChat components, integrate with API

- Implemented Citations component to display citation data.
- Created WatchlistDrawer and ChatSidebar components for managing watchlists and AI chat functionality.
- Integrated API calls for watchlist management and chat interactions.
- Updated index.tsx to include new components in the main application layout.
- Added API client in lib/api.ts for structured API interactions.
- Developed Alerts, Dashboard, Portfolio, Routines, Screener, and Report pages with relevant data fetching and UI components.
- Introduced styles in tokens.css for consistent theming across the application.
- Configured TypeScript and Vite for project setup and development.
This commit is contained in:
asepharyana
2026-09-15 12:36:48 +07:00
parent 0db2cf28b3
commit 8c184ccae1
111 changed files with 12349 additions and 62 deletions
+91
View File
@@ -0,0 +1,91 @@
import { createResource, createSignal, For } from "solid-js";
import { api } from "../lib/api";
export default function Alerts() {
const [alerts, { refetch }] = createResource(() => api.alerts());
const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 30)));
const [dests, { refetch: refetchDests }] = createResource(() => api.destinations());
const [name, setName] = createSignal("accumulation watcher");
const [rule, setRule] = createSignal('{"all": true}');
const [channels, setChannels] = createSignal("");
const [dkind, setDkind] = createSignal("telegram");
const [dlabel, setDlabel] = createSignal("");
const [dbot, setDbot] = createSignal("");
const [dchat, setDchat] = createSignal("");
const [dhook, setDhook] = createSignal("");
const [derr, setDerr] = createSignal("");
async function create() {
await api.createAlert({
name: name(),
rule: JSON.parse(rule()),
channels: channels().split(",").map((c) => c.trim()).filter(Boolean),
});
refetch();
}
async function del(id: number) { await api.deleteAlert(id); refetch(); }
async function addDest() {
setDerr("");
try {
const body: Record<string, unknown> = dkind() === "telegram"
? { kind: "telegram", label: dlabel().trim(), bot_token: dbot().trim(), chat_id: dchat().trim() }
: { kind: "discord", label: dlabel().trim(), webhook_url: dhook().trim() };
await api.createDestination(body);
setDbot(""); setDchat(""); setDhook(""); setDlabel("");
refetchDests();
} catch (e) { setDerr(String(e)); }
}
async function toggleDest(id: number, enabled: boolean) {
await api.updateDestination(id, { enabled: !enabled });
refetchDests();
}
async function delDest(id: number) { await api.deleteDestination(id); refetchDests(); }
return (
<div>
<h1>Alerts</h1>
<div class="fs-card">
<h3>Push destinations (per login)</h3>
<p class="fs-muted">Secrets stay server-side — the list never shows tokens. Delivery uses your enabled destinations; server env is fallback.</p>
<select class="fs-input" value={dkind()} onChange={(e) => setDkind(e.currentTarget.value)}>
<option value="telegram">Telegram</option>
<option value="discord">Discord</option>
</select>
<input class="fs-input" placeholder="label (e.g. my phone)" value={dlabel()} onInput={(e) => setDlabel(e.currentTarget.value)} style={{ width: "160px" }} />
{dkind() === "telegram" ? (
<>
<input class="fs-input" placeholder="bot token" value={dbot()} onInput={(e) => setDbot(e.currentTarget.value)} style={{ width: "220px" }} />
<input class="fs-input" placeholder="chat id" value={dchat()} onInput={(e) => setDchat(e.currentTarget.value)} style={{ width: "140px" }} />
</>
) : (
<input class="fs-input" placeholder="https://discord webhook url" value={dhook()} onInput={(e) => setDhook(e.currentTarget.value)} style={{ width: "300px" }} />
)}
<button class="fs-btn" onClick={addDest}>Add destination</button>
{derr() ? <p class="fs-muted">{derr()}</p> : null}
<ul>
{(dests()?.destinations || []).map((d) => (
<li>{d.kind} — {d.label || `#${d.id}`} <span class="fs-muted">{d.enabled ? "on" : "off"} · {d.configured ? "configured" : "missing secret"}</span>
<button class="fs-btn ghost" onClick={() => toggleDest(d.id, d.enabled)}>{d.enabled ? "Disable" : "Enable"}</button>
<button class="fs-btn ghost" onClick={() => delDest(d.id)}>del</button>
</li>
))}
</ul>
</div>
<div class="fs-card">
<h3>New rule</h3>
<input class="fs-input" value={name()} onInput={(e) => setName(e.currentTarget.value)} />
<input class="fs-input" value={rule()} onInput={(e) => setRule(e.currentTarget.value)} style={{ width: "280px" }} />
<input class="fs-input" placeholder="Telegram/Discord channels, comma-separated" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} style={{ width: "300px" }} />
<button class="fs-btn" onClick={create}>Create</button>
<p class="fs-muted">Delivery: your enabled destinations above (server env fallback). Empty = record only.</p>
</div>
<div class="fs-grid">
<div class="fs-card"><h3>Rules</h3>
<ul><For each={alerts()?.alerts || []}>{(a) => <li>{a.name} <span class="fs-muted">last: {a.last_fired || "never"}</span> <button class="fs-btn ghost" onClick={() => del(a.id)}>del</button></li>}</For></ul>
</div>
<div class="fs-card"><h3>Event history</h3>
<button class="fs-btn ghost" onClick={() => refetchEv()}>Refresh</button>
<ul><For each={events() || []}>{(e) => <li><strong>{String(e.ticker)}</strong> — {String(e.message)}</li>}</For></ul>
</div>
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { createResource, createSignal, onCleanup, For, Show } from "solid-js";
import { api, subscribeSSE, fmtIDR } from "../lib/api";
import { AccumulationTable, ActivityFeed, RotationMap } from "../components/Cards";
import { Citations } from "../components/Citations";
import { Line } from "solid-chartjs";
import { Chart, registerables } from "chart.js";
Chart.register(...registerables);
export default function Dashboard() {
const [health] = createResource(() => api.health());
const [flow, { refetch }] = createResource(() => api.flowSummary());
const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 10)));
const [foreign] = createResource(() => api.flowForeign("BBCA").catch(() => null));
const [live, setLive] = createSignal<string[]>([]);
const stop = subscribeSSE((ch, data) => {
setLive((p) => [`${ch}: ${data.slice(0, 120)}`, ...p].slice(0, 5));
if (ch === "alerts" || ch === "activity") { refetch(); refetchEv(); }
});
onCleanup(stop);
const flowChart = () => ({
labels: (foreign()?.dates || []) as string[],
datasets: [{ label: "BBCA foreign net", data: (foreign()?.nets || []) as number[] }],
});
return (
<div>
<h1>Smart Money Dashboard</h1>
<Show when={health()?.stale_flags?.length}>
<p class="fs-muted">Stale: <For each={health()!.stale_flags}>{(f) => <span>{f}; </span>}</For></p>
</Show>
<p class="fs-muted">
Credits today: {health()?.credits_today ?? "…"} · Scheduler: {health()?.scheduler_ok ? "ok" : "down"} ·
Last cycle: {health()?.last_cycle_at || "never"}
</p>
<div class="fs-grid">
<div class="fs-card">
<h3>Foreign net total</h3>
<p style={{ "font-size": "24px" }}>{flow() ? fmtIDR(flow()!.foreign_net_total) : "…"}</p>
<Citations items={flow()?.citations} />
</div>
<AccumulationTable rows={flow()?.top_accumulation || []} />
<RotationMap signal={undefined} citations={flow()?.citations} />
<div class="fs-card">
<h3>Foreign flow (BBCA)</h3>
<Show when={(foreign()?.dates || []).length} fallback={<p class="fs-muted">No foreign-flow series yet.</p>}>
<Line data={flowChart()} options={{ responsive: true }} />
<Show when={foreign()?.reversal}><p>Reversal flag set.</p></Show>
</Show>
</div>
<ActivityFeed events={(events() || []).map((e) => ({ ticker: String(e.ticker || ""), message: String(e.message || ""), date: String(e.date || "") }))} />
<div class="fs-card">
<h3>Live agent panel</h3>
<Show when={live().length} fallback={<p class="fs-muted">Waiting for SSE events…</p>}>
<ul><For each={live()}>{(l) => <li class="fs-muted">{l}</li>}</For></ul>
</Show>
</div>
</div>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { createResource, For } from "solid-js";
import { api } from "../lib/api";
import { Bar } from "solid-chartjs";
import { Chart, registerables } from "chart.js";
Chart.register(...registerables);
export default function Portfolio() {
const [risk] = createResource(() => api.risk());
const [acc] = createResource(() => api.accuracy());
const chartData = () => ({
labels: (risk()?.concentration || []).map((c) => c.ticker),
datasets: [{ label: "Weight", data: (risk()?.concentration || []).map((c) => +(c.weight * 100).toFixed(1)) }],
});
return (
<div>
<h1>Portfolio Risk + Accuracy Ledger</h1>
<div class="fs-grid">
<div class="fs-card">
<h3>Concentration</h3>
<Bar data={chartData()} options={{ responsive: true }} />
<ul><For each={risk()?.warnings || []}>{(w) => <li>{w}</li>}</For></ul>
</div>
<div class="fs-card">
<h3>Beta vs index</h3>
<p style={{ "font-size": "24px" }}>{risk()?.beta ?? "…"}</p>
<h3>Correlation heatmap</h3>
<CorrHeatmap matrix={risk()?.correlation || {}} />
</div>
<div class="fs-card">
<h3>Agent accuracy</h3>
<table class="fs-table">
<thead><tr><th>Agent</th><th>Calls</th><th>Resolved</th><th>Hit %</th></tr></thead>
<tbody>
<For each={acc()?.agents || []}>
{(a) => <tr><td>{a.agent}</td><td>{a.calls}</td><td>{a.resolved}</td><td>{(a.hit_rate * 100).toFixed(0)}%</td></tr>}
</For>
</tbody>
</table>
</div>
</div>
</div>
);
}
function CorrHeatmap(props: { matrix: Record<string, Record<string, number>> }) {
const keys = () => Object.keys(props.matrix);
const cell = (v: number) => {
const a = Math.min(1, Math.abs(v));
const bg = v >= 0 ? `rgba(63,185,80,${(0.15 + 0.65 * a).toFixed(2)})` : `rgba(248,81,73,${(0.15 + 0.65 * a).toFixed(2)})`;
return bg;
};
return (
<div class="fs-scroll"><table class="fs-table">
<thead><tr><th></th><For each={keys()}>{(k) => <th>{k}</th>}</For></tr></thead>
<tbody>
<For each={keys()}>
{(r) => (
<tr>
<td><strong>{r}</strong></td>
<For each={keys()}>
{(c) => {
const v = props.matrix[r]?.[c] ?? 0;
return <td style={{ background: cell(v) }} title={`${r}/${c} = ${v.toFixed(2)}`}>{v.toFixed(2)}</td>;
}}
</For>
</tr>
)}
</For>
</tbody>
</table></div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { createSignal, For, Show } from "solid-js";
import { useParams } from "@solidjs/router";
import { api, type ReportPayload } from "../lib/api";
import { Citations } from "../components/Citations";
export default function Report() {
const params = useParams();
const [rep, setRep] = createSignal<ReportPayload | null>(null);
const [md, setMd] = createSignal("");
const [question, setQuestion] = createSignal("kenapa conviction segitu?");
const [answer, setAnswer] = createSignal("");
const [err, setErr] = createSignal("");
async function load() {
setErr("");
try { setRep(await api.report(params.ticker)); }
catch (e) { setErr(String(e)); }
}
async function exportMd() { setMd(await api.reportMd(params.ticker)); }
async function ask() {
const r = await api.interrogate(params.ticker, question());
setAnswer(r.answer);
}
return (
<div>
<h1>Report: {params.ticker}</h1>
<button class="fs-btn" onClick={load}>Generate report</button>
<button class="fs-btn ghost" onClick={exportMd}>Export MD</button>
<button class="fs-btn ghost" onClick={async () => {
const r = await fetch(`/api/report/${params.ticker}?format=html`, { method: "POST", headers: { "X-User-Key": localStorage.getItem("fs-key") || "demo" } });
const html = await r.text();
const url = URL.createObjectURL(new Blob([html], { type: "text/html" }));
window.open(url, "_blank");
}}>HTML</button>
<button class="fs-btn ghost" onClick={() => {
const blob = new Blob([JSON.stringify(rep(), null, 1)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${params.ticker}-report.json`;
a.click();
}}>JSON</button>
<button class="fs-btn ghost" onClick={async () => {
const r = await fetch(`/api/report/${params.ticker}?format=pdf`, { method: "POST", headers: { "X-User-Key": localStorage.getItem("fs-key") || "demo" } });
const blob = await r.blob();
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
}}>PDF</button>
<Show when={err()}><p class="fs-muted">{err()}</p></Show>
<Show when={rep()}>
<div class="fs-card">
<h2 class={`rec-${rep()!.synthesis.recommendation}`}>{rep()!.synthesis.recommendation} · conviction {rep()!.synthesis.conviction}/5</h2>
<p>{rep()!.synthesis.thesis}</p>
<Citations items={rep()!.citations} />
</div>
<For each={rep()!.sections}>
{(s) => <div class="fs-card"><h3>{s.name}</h3><p>{s.body}</p><Citations items={s.citations} /></div>}
</For>
<div class="fs-card">
<h3>Interrogate this report</h3>
<input class="fs-input" value={question()} onInput={(e) => setQuestion(e.currentTarget.value)} style={{ width: "320px" }} />
<button class="fs-btn" onClick={ask}>Ask</button>
<Show when={answer()}><p>{answer()}</p></Show>
</div>
</Show>
<Show when={md()}><div class="fs-card"><pre class="fs-muted">{md().slice(0, 2000)}</pre></div></Show>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { createResource, createSignal, For, Show } from "solid-js";
import { api } from "../lib/api";
import { Citations } from "../components/Citations";
export default function Routines() {
const [data, { refetch }] = createResource(() => api.routines());
const [runs, { refetch: refetchRuns }] = createResource(() => api.runs().then((r) => r.runs.slice(0, 20)));
const [type_, setType] = createSignal("morning-briefing");
const [schedule, setSchedule] = createSignal("");
const [channels, setChannels] = createSignal("");
const [briefing] = createResource(() => api.briefing().catch(() => null));
async function subscribe() {
await api.createRoutine({
type: type_(),
schedule_cron: schedule().trim() || undefined,
channels: channels().split(",").map((c) => c.trim()).filter(Boolean),
});
refetch();
}
async function toggle(id: number, enabled: boolean) {
await api.updateRoutine(id, { enabled: !enabled });
refetch();
}
return (
<div>
<h1>Routine Manager</h1>
<div class="fs-card">
<h3>Subscribe</h3>
<select class="fs-input" value={type_()} onChange={(e) => setType(e.currentTarget.value)}>
<For each={["morning-briefing","accumulation-radar","foreign-reversal","insider-tape","earnings-countdown","dividend-calendar","weekend-review"]}>
{(t) => <option value={t}>{t}</option>}
</For>
</select>
<input class="fs-input" placeholder="schedule cron (blank = default)" value={schedule()} onInput={(e) => setSchedule(e.currentTarget.value)} style={{ width: "260px" }} />
<input class="fs-input" placeholder="channels, comma-separated" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} style={{ width: "220px" }} />
<button class="fs-btn" onClick={subscribe}>Subscribe</button>
</div>
<div class="fs-grid">
<For each={data()?.routines || []}>
{(r) => (
<div class="fs-card">
<strong>{r.type}</strong> <span class="fs-muted">{r.schedule_cron} · {r.enabled ? "on" : "off"}</span>
<div><button class="fs-btn ghost" onClick={() => toggle(r.id, r.enabled)}>{r.enabled ? "Disable" : "Enable"}</button></div>
</div>
)}
</For>
</div>
<Show when={!data()?.routines?.length}><p class="fs-muted">No routines yet.</p></Show>
<div class="fs-card">
<h3>Latest briefing</h3>
<Show when={briefing()} fallback={<p class="fs-muted">No briefing yet.</p>}>
<pre class="fs-muted">{(briefing() as { payload: string })?.payload?.slice(0, 800)}</pre>
<Citations items={(briefing() as { citations: string })?.citations} />
</Show>
</div>
<div class="fs-card">
<h3>Run history</h3>
<button class="fs-btn ghost" onClick={() => refetchRuns()}>Refresh</button>
<ul><For each={runs() || []}>{(r) => <li class="fs-muted">{String(r.started_at)} — {String(r.status)}</li>}</For></ul>
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { createSignal, For, Show } from "solid-js";
import { api, type ScreenRow } from "../lib/api";
import { Citations } from "../components/Citations";
const SAVED_KEY = "fs-saved-screeners";
export default function Screener() {
const [mode, setMode] = createSignal<"where" | "q">("q");
const [q, setQ] = createSignal("");
const [brokerMin, setBrokerMin] = createSignal(0);
const [rows, setRows] = createSignal<ScreenRow[]>([]);
const [err, setErr] = createSignal("");
const loadSaved = (): Record<string, string> => {
try { return JSON.parse(localStorage.getItem(SAVED_KEY) || "{}"); } catch { return {}; }
};
const [saved, setSaved] = createSignal<Record<string, string>>(loadSaved());
const [sname, setSname] = createSignal("");
async function run() {
setErr("");
try {
const body = mode() === "q"
? { q: q() || undefined, institutional: { broker_score_min: brokerMin() }, limit: 20 }
: { where: q() || undefined, institutional: { broker_score_min: brokerMin() }, limit: 20 };
const r = await api.screen(body);
setRows(r.rows);
} catch (e) { setErr(String(e)); }
}
function save() {
const all = { ...saved(), [sname().trim() || q().slice(0, 24) || "untitled"]: q() };
localStorage.setItem(SAVED_KEY, JSON.stringify(all));
setSaved(all);
}
return (
<div>
<h1>Institutional Screener</h1>
<div class="fs-card">
<select class="fs-input" value={mode()} onChange={(e) => setMode(e.currentTarget.value as "where" | "q")}>
<option value="q">Natural language (q)</option>
<option value="where">SQL-like (where)</option>
</select>
<input class="fs-input" placeholder={mode() === "q" ? "e.g. large banks with foreign inflow" : "e.g. market_cap > 10T"} value={q()} onInput={(e) => setQ(e.currentTarget.value)} style={{ width: "320px" }} />
<label class="fs-muted"> broker ≥ <input class="fs-input" type="number" value={brokerMin()} onInput={(e) => setBrokerMin(Number(e.currentTarget.value))} style={{ width: "80px" }} /></label>
<button class="fs-btn" onClick={run}>Screen</button>
<Show when={err()}><p class="fs-muted">{err()}</p></Show>
</div>
<div class="fs-scroll"><table class="fs-table">
<thead><tr><th>Symbol</th><th>Composite</th><th>Breakdown</th><th>Citations</th></tr></thead>
<tbody>
<For each={rows()}>
{(r) => (
<tr>
<td><a href={`/report/${r.symbol}`}>{r.symbol}</a></td>
<td>{r.composite.toFixed(1)}</td>
<td class="fs-muted">{JSON.stringify(r.breakdown)}</td>
<td><Citations items={r.citations} /></td>
</tr>
)}
</For>
</tbody>
</table></div>
<div class="fs-card">
<h3>Saved screeners</h3>
<input class="fs-input" placeholder="name" value={sname()} onInput={(e) => setSname(e.currentTarget.value)} style={{ width: "140px" }} />
<button class="fs-btn ghost" onClick={save}>Save current</button>
<ul><For each={Object.entries(saved())}>{([n, query]) => <li><button class="fs-btn ghost" onClick={() => { setQ(query); run(); }}>{n}</button> <span class="fs-muted">{query.slice(0, 60)}</span></li>}</For></ul>
</div>
<Show when={!rows().length}><p class="fs-muted">Run a screen to see ranked rows with per-row signal breakdown.</p></Show>
</div>
);
}