feat: dashboard awam-first — hero ringkasan, kartu verdict, tooltip istilah
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s

This commit is contained in:
asepharyana
2026-09-15 19:06:35 +07:00
parent 1bda859def
commit 390133663a
3 changed files with 231 additions and 60 deletions
+54
View File
@@ -0,0 +1,54 @@
import { For, Show } from "solid-js";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { ISTILAH, type Verdict } from "../lib/awam";
// Kata teknis bergaris-titik + tooltip penjelasan santai.
export function Term(props: { kata: string }) {
const key = props.kata.toLowerCase();
const arti = ISTILAH[key];
if (!arti) return <span>{props.kata}</span>;
return (
<Tooltip>
<TooltipTrigger as="span" class="cursor-help underline decoration-dotted underline-offset-2">{props.kata}</TooltipTrigger>
<TooltipContent class="max-w-xs">{arti}</TooltipContent>
</Tooltip>
);
}
export function VerdictBadge(props: { verdict: Verdict }) {
const warna: Record<Verdict, "default" | "error" | "secondary"> = {
"Dilirik": "default",
"Dilepas": "error",
"Netral": "secondary",
};
return <Badge variant={warna[props.verdict]}>{props.verdict}</Badge>;
}
// Bar alasan: seberapa kuat sinyal asing vs broker (0-100).
export function AlasanBar(props: { asing: number; broker: number }) {
const total = Math.abs(props.asing) + Math.abs(props.broker) || 1;
const pAsing = Math.round((Math.abs(props.asing) / total) * 100);
return (
<div>
<div class="flex h-2 w-full overflow-hidden rounded-full bg-muted">
<div class="bg-blue-500" style={{ width: `${pAsing}%` }} />
<div class="bg-emerald-500" style={{ width: `${100 - pAsing}%` }} />
</div>
<div class="mt-1 flex justify-between text-xs text-muted-foreground">
<span><span class="text-blue-500">■</span> Asing {props.asing >= 0 ? "masuk" : "keluar"}</span>
<span><span class="text-emerald-500">■</span> Broker {props.broker >= 0 ? "borong" : "jualan"}</span>
</div>
</div>
);
}
export function IstilahStrip() {
const kunci = ["akumulasi", "foreign flow", "broker", "net", "briefing", "alert"];
return (
<p class="text-xs text-muted-foreground">
Istilah asing? Arahkan kursor:{" "}
<For each={kunci}>{(k, i) => <><Show when={i() > 0}>, </Show><Term kata={k} /></>}</For>
</p>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { ScreenRow } from "./api";
// Kamus istilah: kata teknis -> penjelasan santai 1 kalimat.
export const ISTILAH: Record<string, string> = {
"akumulasi": "Broker-broker besar lagi borong saham ini diam-diam. Biasanya tanda mereka tahu sesuatu yang bagus.",
"distribusi": "Broker-broker besar lagi jualan saham ini pelan-pelan. Hati-hati, bisa turun.",
"foreign flow": "Uang investor luar negeri yang masuk (beli) atau keluar (jual) dari saham ini.",
"inflow": "Uang asing lagi masuk — mereka beli. Tanda kepercayaan.",
"outflow": "Uang asing lagi keluar — mereka jual. Waspada.",
"broker": "Perantara jual-beli saham (sekuritas). Gerakan mereka dibaca buat nebak arah pasar.",
"net": "Selisih total beli dikurangi total jual. Positif = lebih banyak yang beli.",
"composite": "Nilai gabungan dari semua sinyal (asing + broker + volume). Makin tinggi makin menarik.",
"screener": "Alat penyaring: cari saham yang memenuhi kriteria tertentu.",
"briefing": "Ringkasan pagi otomatis: kabar pasar hari ini dalam beberapa kalimat.",
"alert": "Notifikasi otomatis kalau saham incaranmu bergerak aneh.",
"routine": "Jadwal otomatis, misalnya terima ringkasan pasar tiap pagi jam 7.",
"portofolio": "Kumpulan semua saham yang kamu pegang.",
"diversifikasi": "Jangan taruh semua uang di satu saham — sebar biar aman.",
"rekomendasi": "Saran beli/tahan/jual dari sistem. Bukan nasihat keuangan resmi — tetap riset sendiri.",
"hold": "Tahan: jangan jual dulu, tapi belum saatnya tambah beli.",
"buy": "Beli: sinyalnya bagus, layak dipertimbangkan.",
"sell": "Jual: sinyalnya jelek, pertimbangkan keluar.",
"citations": "Sumber data: dari API mana angka ini diambil, biar bisa diverifikasi.",
"live": "Data real-time dari scheduler, bukan contoh.",
"stale": "Data sudah lama, belum diperbarui. Jangan terlalu dipercaya.",
"mover": "Saham yang harganya bergerak drastis hari ini.",
"agenda": "Jadwal penting emiten: bagi dividen, rapat pemegang saham, laporan keuangan.",
"ex-div": "Tanggal batas: kalau beli setelah tanggal ini, tidak dapat dividen periode ini.",
};
export type Verdict = "Dilirik" | "Dilepas" | "Netral";
export function verdictFor(row: ScreenRow): { verdict: Verdict; alasan: string } {
const b = (row.breakdown || {}) as Record<string, unknown>;
const comp = Number(row.composite ?? 0);
const trend = String(b.foreign_trend ?? "");
const broker = Number(b.broker_score ?? b.broker ?? 0);
const insider = Boolean(b.insider_buying ?? b.insider);
if (comp > 5 || (trend === "inflow" && broker > 0)) {
const bits: string[] = [];
if (trend === "inflow") bits.push("uang asing masuk");
if (broker > 0) bits.push("broker besar borong");
if (insider) bits.push("orang dalam ikut beli");
return { verdict: "Dilirik", alasan: bits.length ? bits.join(", ") : "sinyal gabungan positif" };
}
if (comp < -5 || (trend === "outflow" && broker < 0)) {
const bits: string[] = [];
if (trend === "outflow") bits.push("uang asing keluar");
if (broker < 0) bits.push("broker besar jualan");
return { verdict: "Dilepas", alasan: bits.length ? bits.join(", ") : "sinyal gabungan negatif" };
}
return { verdict: "Netral", alasan: "belum ada gerakan mencolok — pantau saja" };
}
// Ringkas briefing mentah jadi 3 kalimat santai.
export function heroSummary(briefing: string): string[] {
const lines = briefing.split("\n").filter((l) => l.trim());
const top = lines.filter((l) => /^\d+\./.test(l.trim())).slice(0, 3);
const foreign = lines.filter((l) => /^-/.test(l.trim()) && /Rp/i.test(l)).slice(0, 2);
const mover = lines.find((l) => /mover/i.test(l));
const out: string[] = [];
if (top.length) {
const names = top.map((t) => t.replace(/^\d+\.\s*/, "").split(" net")[0]).join(", ");
out.push(`Pagi ini yang paling diborong: ${names}. Artinya pemain besar lagi kumpul di sana.`);
}
if (foreign.length) out.push(`Uang asing: ${foreign.map((f) => f.replace(/^-\s*/, "")).join("; ")}.`);
if (mover) out.push(`Perhatian khusus: ${mover.replace(/^.*?:\s*/, "")}.`);
if (!out.length && briefing.trim()) out.push(briefing.slice(0, 220));
return out.slice(0, 3);
}
+100 -53
View File
@@ -1,80 +1,127 @@
import { createResource, createSignal, onCleanup, For, Show } from "solid-js"; import { createResource, createSignal, For, Show } from "solid-js";
import { api, subscribeSSE, fmtIDR } from "../lib/api"; import { A, useNavigate } from "@solidjs/router";
import { AccumulationTable, ActivityFeed, RotationMap } from "../components/Cards"; import { api, fmtIDR } from "../lib/api";
import { Citations } from "../components/Citations"; import { verdictFor, heroSummary } from "../lib/awam";
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
import { Badge } from "../components/ui/badge"; import { Badge } from "../components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Skeleton } from "../components/ui/skeleton"; import { Skeleton } from "../components/ui/skeleton";
import { Line } from "solid-chartjs"; import { Line } from "solid-chartjs";
import { Chart, registerables } from "chart.js"; import { Chart, registerables } from "chart.js";
Chart.register(...registerables); Chart.register(...registerables);
export default function Dashboard() { export default function Dashboard() {
const [health] = createResource(() => api.health()); const navigate = useNavigate();
const [flow, { refetch }] = createResource(() => api.flowSummary()); const [flow] = createResource(() => api.flowSummary());
const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 10))); const [screen] = createResource(() => api.screen({ limit: 5 }).catch(() => null));
const [foreign] = createResource(() => api.flowForeign("BBCA").catch(() => null));
const [briefing] = createResource(() => api.briefing().catch(() => null)); const [briefing] = createResource(() => api.briefing().catch(() => null));
const [live, setLive] = createSignal<string[]>([]); const [events] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 5)).catch(() => []));
const stop = subscribeSSE((ch, data) => { const [chartTiker, setChartTiker] = createSignal("BBCA");
setLive((p) => [`${ch}: ${data.slice(0, 120)}`, ...p].slice(0, 5)); const [foreign] = createResource(chartTiker, (t) => api.flowForeign(t).catch(() => null));
if (ch === "alerts" || ch === "activity") { refetch(); refetchEv(); }
}); const ringkasan = () => {
onCleanup(stop); const p = (briefing() as { payload: string } | null)?.payload;
const flowChart = () => ({ return p ? heroSummary(p) : [];
};
const sorotan = () => (screen()?.rows || []).map((r) => ({ row: r, ...verdictFor(r) }));
const chartData = () => ({
labels: (foreign()?.dates || []) as string[], labels: (foreign()?.dates || []) as string[],
datasets: [{ label: "BBCA foreign net", data: (foreign()?.nets || []) as number[] }], datasets: [{ label: `${chartTiker()} — uang asing harian`, data: (foreign()?.nets || []) as number[] }],
}); });
return ( return (
<div class="space-y-6"> <div class="space-y-6">
<div> {/* HERO: hari ini ada apa */}
<h1 class="text-2xl font-bold tracking-tight">Smart Money Dashboard</h1> <Card class="border-primary/30 bg-gradient-to-br from-primary/10 to-transparent">
<p class="text-sm text-muted-foreground"> <CardHeader>
Credits {health()?.credits_today ?? "…"} · Scheduler {health()?.scheduler_ok ? "ok" : "down"} · Last cycle {health()?.last_cycle_at || "never"} <CardTitle class="text-2xl">Hari ini ada apa? 📈</CardTitle>
</p> <CardDescription>Ringkasan pasar pagi ini — tanpa perlu paham istilah ekonomi.</CardDescription>
</div> </CardHeader>
<Show when={health()?.stale_flags?.length}> <CardContent>
<p class="text-sm text-muted-foreground">Stale: <For each={health()!.stale_flags}>{(f) => <Badge variant="outline">{f}</Badge>}</For></p> <Show when={!briefing.loading} fallback={<div class="space-y-2"><Skeleton class="h-4 w-full" /><Skeleton class="h-4 w-3/4" /></div>}>
<Show when={ringkasan().length} fallback={<p class="text-sm text-muted-foreground">Belum ada ringkasan hari ini — coba lagi setelah scheduler jalan.</p>}>
<ul class="space-y-2 text-base leading-relaxed">
<For each={ringkasan()}>{(k) => <li>• {k}</li>}</For>
</ul>
</Show> </Show>
<Show when={!flow.loading} fallback={<div class="space-y-2"><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /></div>}> </Show>
<div class="mt-3"><IstilahStrip /></div>
</CardContent>
</Card>
{/* SOROTAN SAHAM */}
<div>
<h2 class="mb-3 text-lg font-semibold">Saham sorotan hari ini</h2>
<Show when={!screen.loading} fallback={<div class="grid gap-4 md:grid-cols-2"><Skeleton class="h-40" /><Skeleton class="h-40" /></div>}>
<Show when={sorotan().length} fallback={<p class="text-sm text-muted-foreground">Belum ada data sorotan.</p>}>
<div class="grid gap-4 md:grid-cols-2">
<For each={sorotan()}>{(s) => {
const b = (s.row.breakdown || {}) as Record<string, unknown>;
return (
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-xl font-bold">{s.row.symbol}</CardTitle>
<VerdictBadge verdict={s.verdict} />
</CardHeader>
<CardContent class="space-y-3">
<p class="text-sm">Karena: <strong>{s.alasan}</strong>.</p>
<AlasanBar asing={Number(b.foreign ?? 0)} broker={Number(b.broker_score ?? b.broker ?? 0)} />
<div class="flex gap-2">
<Button size="sm" onClick={() => navigate(`/report?t=${s.row.symbol}`)}>Kenapa? Jelaskan</Button>
<Button size="sm" variant="outline"><A href={`/screener`}>Bandingkan</A></Button>
</div>
</CardContent>
</Card>
);
}}</For>
</div>
</Show>
</Show>
</div>
{/* UANG ASING PER SAHAM */}
<Card>
<CardHeader>
<CardTitle>Uang asing lagi ke mana?</CardTitle>
<CardDescription><Term kata="foreign flow" /> harian — naik = asing beli, turun = asing jual. Pilih saham:</CardDescription>
</CardHeader>
<CardContent>
<div class="mb-3 flex flex-wrap gap-2">
<For each={(flow()?.top_accumulation || []).map((t) => t.ticker)}>{(t) =>
<Button size="sm" variant={chartTiker() === t ? "default" : "outline"} onClick={() => setChartTiker(t)}>{t}</Button>
}</For>
</div>
<Show when={(foreign()?.dates || []).length} fallback={<p class="text-sm text-muted-foreground">Belum ada data grafik.</p>}>
<Line data={chartData()} options={{ responsive: true }} />
<Show when={foreign()?.reversal}><p class="mt-2 text-sm font-medium">⚠️ Arahnya baru berbalik — perhatikan beberapa hari ke depan.</p></Show>
</Show>
</CardContent>
</Card>
{/* AKTIVITAS + ANGKA MENTAH LIPAT */}
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
<Card> <Card>
<CardHeader><CardTitle>Foreign net total</CardTitle><CardDescription>Aggregate across snapshots.</CardDescription></CardHeader> <CardHeader><CardTitle>Kabar terbaru</CardTitle><CardDescription><Term kata="alert" /> dan aktivitas sistem.</CardDescription></CardHeader>
<CardContent> <CardContent>
<p class="font-mono text-2xl font-bold">{flow() ? fmtIDR(flow()!.foreign_net_total) : "…"}</p> <Show when={(events() || []).length} fallback={<p class="text-sm text-muted-foreground">Belum ada kabar — pasang alert biar dapat notifikasi.</p>}>
<Citations items={flow()?.citations} /> <ul class="space-y-2 text-sm">
<For each={events() || []}>{(e) => <li>• <strong>{String(e.ticker || "")}</strong> — {String(e.message || "")} <span class="text-muted-foreground">({String(e.date || "")})</span></li>}</For>
</ul>
</Show>
<div class="mt-3"><Button size="sm" variant="outline"><A href="/alerts">Atur notifikasi saya</A></Button></div>
</CardContent> </CardContent>
</Card> </Card>
<AccumulationTable rows={flow()?.top_accumulation || []} />
<RotationMap signal={undefined} citations={flow()?.citations} />
<Card> <Card>
<CardHeader><CardTitle>Foreign flow (BBCA)</CardTitle></CardHeader> <CardHeader><CardTitle>Yang paling diborong</CardTitle><CardDescription>Total <Term kata="net" /> beli broker hari ini: <strong>{flow() ? fmtIDR(flow()!.foreign_net_total) : "…"}</strong></CardDescription></CardHeader>
<CardContent> <CardContent>
<Show when={(foreign()?.dates || []).length} fallback={<p class="text-sm text-muted-foreground">No foreign-flow series yet.</p>}> <ul class="space-y-1 text-sm">
<Line data={flowChart()} options={{ responsive: true }} /> <For each={flow()?.top_accumulation || []}>{(t) => <li class="flex justify-between"><A class="font-medium underline-offset-2 hover:underline" href={`/report?t=${t.ticker}`}>{t.ticker}</A><span class="font-mono">{fmtIDR(t.net_sum)} · {t.brokers} broker</span></li>}</For>
<Show when={foreign()?.reversal}><p class="text-sm">Reversal flag set.</p></Show> </ul>
</Show> <div class="mt-2"><Badge variant="outline">Angka detail untuk yang penasaran — keputusan ada di kartu sorotan ☝️</Badge></div>
</CardContent>
</Card>
<ActivityFeed events={(events() || []).map((e) => ({ ticker: String(e.ticker || ""), message: String(e.message || ""), date: String(e.date || "") }))} />
<Card>
<CardHeader><CardTitle>Live agent panel</CardTitle></CardHeader>
<CardContent>
<Show when={live().length} fallback={<p class="text-sm text-muted-foreground">Waiting for SSE events…</p>}>
<ul class="space-y-1 text-sm text-muted-foreground"><For each={live()}>{(l) => <li>{l}</li>}</For></ul>
</Show>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</Show>
<Card>
<CardHeader><CardTitle>Morning briefing</CardTitle></CardHeader>
<CardContent>
<Show when={briefing()} fallback={<p class="text-sm text-muted-foreground">No briefing yet — subscribe to the morning-briefing routine.</p>}>
<pre class="whitespace-pre-wrap text-sm text-muted-foreground">{(briefing() as { payload: string })?.payload?.slice(0, 800)}</pre>
</Show>
</CardContent>
</Card>
</div> </div>
); );
} }