diff --git a/web/src/components/Awam.tsx b/web/src/components/Awam.tsx
new file mode 100644
index 0000000..5abcc43
--- /dev/null
+++ b/web/src/components/Awam.tsx
@@ -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 {props.kata} ;
+ return (
+
+ {props.kata}
+ {arti}
+
+ );
+}
+
+export function VerdictBadge(props: { verdict: Verdict }) {
+ const warna: Record = {
+ "Dilirik": "default",
+ "Dilepas": "error",
+ "Netral": "secondary",
+ };
+ return {props.verdict} ;
+}
+
+// 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 (
+
+
+
+ ■ Asing {props.asing >= 0 ? "masuk" : "keluar"}
+ ■ Broker {props.broker >= 0 ? "borong" : "jualan"}
+
+
+ );
+}
+
+export function IstilahStrip() {
+ const kunci = ["akumulasi", "foreign flow", "broker", "net", "briefing", "alert"];
+ return (
+
+ Istilah asing? Arahkan kursor:{" "}
+ {(k, i) => <> 0}>, >}
+
+ );
+}
diff --git a/web/src/lib/awam.ts b/web/src/lib/awam.ts
new file mode 100644
index 0000000..c32fcef
--- /dev/null
+++ b/web/src/lib/awam.ts
@@ -0,0 +1,70 @@
+import type { ScreenRow } from "./api";
+
+// Kamus istilah: kata teknis -> penjelasan santai 1 kalimat.
+export const ISTILAH: Record = {
+ "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;
+ 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);
+}
diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx
index dd90f80..ed2a532 100644
--- a/web/src/pages/Dashboard.tsx
+++ b/web/src/pages/Dashboard.tsx
@@ -1,80 +1,127 @@
-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 { createResource, createSignal, For, Show } from "solid-js";
+import { A, useNavigate } from "@solidjs/router";
+import { api, fmtIDR } from "../lib/api";
+import { verdictFor, heroSummary } from "../lib/awam";
+import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
import { Badge } from "../components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
+import { Button } from "../components/ui/button";
import { Skeleton } from "../components/ui/skeleton";
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 navigate = useNavigate();
+ const [flow] = createResource(() => api.flowSummary());
+ const [screen] = createResource(() => api.screen({ limit: 5 }).catch(() => null));
const [briefing] = createResource(() => api.briefing().catch(() => null));
- const [live, setLive] = createSignal([]);
- 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 = () => ({
+ const [events] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 5)).catch(() => []));
+ const [chartTiker, setChartTiker] = createSignal("BBCA");
+ const [foreign] = createResource(chartTiker, (t) => api.flowForeign(t).catch(() => null));
+
+ const ringkasan = () => {
+ const p = (briefing() as { payload: string } | null)?.payload;
+ return p ? heroSummary(p) : [];
+ };
+ const sorotan = () => (screen()?.rows || []).map((r) => ({ row: r, ...verdictFor(r) }));
+ const chartData = () => ({
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 (
-
-
Smart Money Dashboard
-
- Credits {health()?.credits_today ?? "…"} · Scheduler {health()?.scheduler_ok ? "ok" : "down"} · Last cycle {health()?.last_cycle_at || "never"}
-
-
-
- Stale: {(f) => {f} }
-
-
}>
-
-
- Foreign net total Aggregate across snapshots.
-
- {flow() ? fmtIDR(flow()!.foreign_net_total) : "…"}
-
-
-
-
-
-
- Foreign flow (BBCA)
-
- No foreign-flow series yet.}>
-
- Reversal flag set.
-
-
-
-
({ ticker: String(e.ticker || ""), message: String(e.message || ""), date: String(e.date || "") }))} />
-
- Live agent panel
-
- Waiting for SSE events…}>
-
-
-
-
-
-
-
- Morning briefing
+ {/* HERO: hari ini ada apa */}
+
+
+ Hari ini ada apa? 📈
+ Ringkasan pasar pagi ini — tanpa perlu paham istilah ekonomi.
+
- No briefing yet — subscribe to the morning-briefing routine.
}>
- {(briefing() as { payload: string })?.payload?.slice(0, 800)}
+ }>
+ Belum ada ringkasan hari ini — coba lagi setelah scheduler jalan.}>
+
+
+
+
+
+
+
+ {/* SOROTAN SAHAM */}
+
+
Saham sorotan hari ini
+ }>
+ Belum ada data sorotan.}>
+
+
{(s) => {
+ const b = (s.row.breakdown || {}) as Record;
+ return (
+
+
+ {s.row.symbol}
+
+
+
+ Karena: {s.alasan} .
+
+
+
navigate(`/report?t=${s.row.symbol}`)}>Kenapa? Jelaskan
+
Bandingkan
+
+
+
+ );
+ }}
+
+
+
+
+
+ {/* UANG ASING PER SAHAM */}
+
+
+ Uang asing lagi ke mana?
+ harian — naik = asing beli, turun = asing jual. Pilih saham:
+
+
+
+ t.ticker)}>{(t) =>
+ setChartTiker(t)}>{t}
+ }
+
+ Belum ada data grafik.}>
+
+ ⚠️ Arahnya baru berbalik — perhatikan beberapa hari ke depan.
+
+ {/* AKTIVITAS + ANGKA MENTAH LIPAT */}
+
+
+ Kabar terbaru dan aktivitas sistem.
+
+ Belum ada kabar — pasang alert biar dapat notifikasi.}>
+
+ {(e) => • {String(e.ticker || "")} — {String(e.message || "")} ({String(e.date || "")}) }
+
+
+
+
+
+
+ Yang paling diborong Total beli broker hari ini: {flow() ? fmtIDR(flow()!.foreign_net_total) : "…"}
+
+
+ {(t) => {t.ticker} {fmtIDR(t.net_sum)} · {t.brokers} broker }
+
+ Angka detail untuk yang penasaran — keputusan ada di kartu sorotan ☝️
+
+
+
);
}