feat: dashboard substansial — harga close per kartu, konteks pasar, grafik berformat Rp + periode
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 20:55:38 +07:00
parent dab3a9d394
commit 793e0724ed
4 changed files with 109 additions and 15 deletions
+14 -1
View File
@@ -28,6 +28,7 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
} }
var accs []accRow var accs []accRow
foreignTotal := 0.0 foreignTotal := 0.0
var closes []map[string]any
var cites []model.Citation var cites []model.Citation
for _, tk := range wl { for _, tk := range wl {
if nets, err := s.DB.NetBuySum5d(tk); err == nil && len(nets) > 0 { if nets, err := s.DB.NetBuySum5d(tk); err == nil && len(nets) > 0 {
@@ -45,6 +46,9 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
foreignTotal += nets[len(nets)-1] foreignTotal += nets[len(nets)-1]
cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, date)) cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, date))
} }
if px, dx, err := s.DB.LatestClose(tk); err == nil {
closes = append(closes, map[string]any{"ticker": tk, "close": px, "date": dx})
}
} }
sort.Slice(accs, func(i, j int) bool { return accs[i].NetSum > accs[j].NetSum }) sort.Slice(accs, func(i, j int) bool { return accs[i].NetSum > accs[j].NetSum })
if len(accs) > 5 { if len(accs) > 5 {
@@ -52,7 +56,7 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"date": date, "foreign_net_total": foreignTotal, "date": date, "foreign_net_total": foreignTotal,
"top_accumulation": accs, "citations": cites, "top_accumulation": accs, "closes": closes, "citations": cites,
}) })
} }
@@ -132,5 +136,14 @@ func (s *Server) FlowForeign(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"ticker": q.Ticker, "dates": dates, "nets": nets, "reversal": reversal, "ticker": q.Ticker, "dates": dates, "nets": nets, "reversal": reversal,
"citations": []model.Citation{model.Cite("v2/foreign-flow/"+q.Ticker+"/", q.Ticker, dates[len(dates)-1])}, "citations": []model.Citation{model.Cite("v2/foreign-flow/"+q.Ticker+"/", q.Ticker, dates[len(dates)-1])},
"start": startOf(dates), "end": dates[len(dates)-1],
}) })
} }
// startOf returns the first series date ("" when empty).
func startOf(dates []string) string {
if len(dates) == 0 {
return ""
}
return dates[0]
}
+3 -2
View File
@@ -17,17 +17,18 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
} }
export interface Citation { endpoint: string; snapshot_at: string; ticker?: string; stale?: boolean } export interface Citation { endpoint: string; snapshot_at: string; ticker?: string; stale?: boolean }
export interface Health { last_cycle_at: string; credits_today: number; scheduler_ok: boolean; stale_flags: string[] } export interface Health { last_cycle_at: string; credits_today: number; scheduler_ok: boolean; stale_flags: string[] }
export interface FlowSummary { date: string; foreign_net_total: number; top_accumulation: { ticker: string; net_sum: number; brokers: number }[]; citations: Citation[] } export interface FlowSummary { date: string; foreign_net_total: number; top_accumulation: { ticker: string; net_sum: number; brokers: number }[]; closes: { ticker: string; close: number; date: string }[]; citations: Citation[] }
export interface ScreenRow { symbol: string; name: string; composite: number; breakdown: Record<string, unknown>; citations: Citation[] } export interface ScreenRow { symbol: string; name: string; composite: number; breakdown: Record<string, unknown>; citations: Citation[] }
export interface Routine { id: number; user_key: string; type: string; schedule_cron: string; channels_json?: string; channels?: string[]; enabled: boolean; last_run?: unknown } export interface Routine { id: number; user_key: string; type: string; schedule_cron: string; channels_json?: string; channels?: string[]; enabled: boolean; last_run?: unknown }
export interface AlertItem { id: number; user_key: string; name: string; rule_json: string; channels_json: string; last_fired: string } export interface AlertItem { id: number; user_key: string; name: string; rule_json: string; channels_json: string; last_fired: string }
export interface ReportPayload { ticker: string; generated_at: string; sections: { name: string; body: string; citations: Citation[] }[]; synthesis: { recommendation: string; conviction: number; thesis: string; position_pct: number; conflict: boolean; conflict_note?: string }; agent_scores: unknown[]; citations: Citation[]; narasi_awam?: string } export interface ReportPayload { ticker: string; generated_at: string; sections: { name: string; body: string; citations: Citation[] }[]; synthesis: { recommendation: string; conviction: number; thesis: string; position_pct: number; conflict: boolean; conflict_note?: string }; agent_scores: unknown[]; citations: Citation[]; narasi_awam?: string }
export interface AuthUser { id: number; email: string; name: string; avatar_url: string; user_key: string; google_configured: boolean } export interface AuthUser { id: number; email: string; name: string; avatar_url: string; user_key: string; google_configured: boolean }
export interface ForeignSeries { dates: string[]; nets: number[]; reversal: boolean; start?: string; end?: string; citations: Citation[] }
export const api = { export const api = {
health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`), health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`),
flowSummary: () => req<FlowSummary>("/api/flow/summary"), flowSummary: () => req<FlowSummary>("/api/flow/summary"),
flowBroker: (ticker: string) => req<{ buyers: unknown[]; sellers: unknown[]; citations: Citation[] }>(`/api/flow/broker?ticker=${ticker}`), flowBroker: (ticker: string) => req<{ buyers: unknown[]; sellers: unknown[]; citations: Citation[] }>(`/api/flow/broker?ticker=${ticker}`),
flowForeign: (ticker: string) => req<{ dates: string[]; nets: number[]; reversal: boolean; citations: Citation[] }>(`/api/flow/foreign?ticker=${ticker}`), flowForeign: (ticker: string) => req<ForeignSeries>(`/api/flow/foreign?ticker=${ticker}`),
screen: (body: Record<string, unknown>) => req<{ rows: ScreenRow[]; count: number }>("/api/screen", { method: "POST", body: JSON.stringify(body) }), screen: (body: Record<string, unknown>) => req<{ rows: ScreenRow[]; count: number }>("/api/screen", { method: "POST", body: JSON.stringify(body) }),
routines: () => req<{ routines: Routine[] }>("/api/routines"), routines: () => req<{ routines: Routine[] }>("/api/routines"),
createRoutine: (body: Record<string, unknown>) => req<{ id: number }>("/api/routines", { method: "POST", body: JSON.stringify(body) }), createRoutine: (body: Record<string, unknown>) => req<{ id: number }>("/api/routines", { method: "POST", body: JSON.stringify(body) }),
+48 -1
View File
@@ -1,4 +1,4 @@
import type { ScreenRow } from "./api"; import type { ScreenRow, FlowSummary, ForeignSeries } from "./api";
// Kamus istilah: kata teknis -> penjelasan santai 1 kalimat. // Kamus istilah: kata teknis -> penjelasan santai 1 kalimat.
export const ISTILAH: Record<string, string> = { export const ISTILAH: Record<string, string> = {
@@ -68,3 +68,50 @@ export function heroSummary(briefing: string): string[] {
if (!out.length && briefing.trim()) out.push(briefing.slice(0, 220)); if (!out.length && briefing.trim()) out.push(briefing.slice(0, 220));
return out.slice(0, 3); return out.slice(0, 3);
} }
// fmtRp: 1.78T / 305B / 25M — untuk harga pakai fmtHarga.
export function fmtRp(v: number): string {
const neg = v < 0; const a = Math.abs(v);
let s: string;
if (a >= 1e12) s = `Rp${(a / 1e12).toFixed(2)}T`;
else if (a >= 1e9) s = `Rp${Math.round(a / 1e9)}B`;
else if (a >= 1e6) s = `Rp${Math.round(a / 1e6)}M`;
else s = `Rp${Math.round(a)}`;
return neg ? "-" + s : s;
}
export function fmtHarga(v: number): string {
return `Rp${Math.round(v).toLocaleString("id-ID")}`;
}
// Cerita 1 kalimat per kartu sorotan: harga + arah asing + broker.
export function ceritaKartu(args: {
symbol: string; verdict: Verdict; alasan: string;
close?: number; closeDate?: string; foreignNet?: number;
}): string {
const parts: string[] = [];
if (args.close) parts.push(`terakhir ${fmtHarga(args.close)}`);
if (args.foreignNet !== undefined) {
parts.push(args.foreignNet >= 0
? `asing beli bersih ${fmtRp(args.foreignNet)}`
: `asing jual bersih ${fmtRp(Math.abs(args.foreignNet))}`);
}
parts.push(args.alasan);
return parts.join(" · ") + ".";
}
// Konteks pasar: berapa Dilirik vs Dilepas + total asing hari ini.
export function konteksPasar(sorotan: { verdict: Verdict }[], foreignTotal?: number): string {
const up = sorotan.filter((s) => s.verdict === "Dilirik").length;
const down = sorotan.filter((s) => s.verdict === "Dilepas").length;
const mood = up > down ? "mayoritas dilirik — pasar lagi berani."
: down > up ? "mayoritas dilepas — pasar lagi hati-hati."
: "pasar campur aduk — belum ada arah jelas.";
let s = `Dari ${sorotan.length} saham pantauan: ${up} dilirik, ${down} dilepas — ${mood}`;
if (foreignTotal !== undefined) {
s += foreignTotal >= 0
? ` Total asing hari ini beli bersih ${fmtRp(foreignTotal)}.`
: ` Total asing hari ini jual bersih ${fmtRp(Math.abs(foreignTotal))}.`;
}
return s;
}
+44 -11
View File
@@ -1,7 +1,7 @@
import { createResource, createSignal, For, Show } from "solid-js"; import { createResource, createSignal, For, Show } from "solid-js";
import { A, useNavigate } from "@solidjs/router"; import { A, useNavigate } from "@solidjs/router";
import { api, fmtIDR } from "../lib/api"; import { api } from "../lib/api";
import { verdictFor, heroSummary } from "../lib/awam"; import { verdictFor, heroSummary, konteksPasar, fmtRp, fmtHarga } from "../lib/awam";
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/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";
@@ -27,10 +27,32 @@ export default function Dashboard() {
return p ? heroSummary(p) : []; return p ? heroSummary(p) : [];
}; };
const sorotan = () => (screen()?.rows || []).map((r) => ({ row: r, ...verdictFor(r) })); const sorotan = () => (screen()?.rows || []).map((r) => ({ row: r, ...verdictFor(r) }));
const closeMap = () => Object.fromEntries((flow()?.closes || []).map((c) => [c.ticker, c]));
const chartData = () => ({ const chartData = () => ({
labels: (foreign()?.dates || []) as string[], labels: (foreign()?.dates || []) as string[],
datasets: [{ label: `${chartTiker()} — uang asing harian`, data: (foreign()?.nets || []) as number[] }], datasets: [{
label: `${chartTiker()} — uang asing harian (${fmtRp(0).slice(0, 2)}7700`,
data: (foreign()?.nets || []) as number[],
borderColor: "#3b82f6",
backgroundColor: "rgba(59,130,246,.15)",
fill: true,
tension: 0.25,
}],
}); });
const chartOpts = () => ({
responsive: true,
plugins: {
tooltip: { callbacks: { label: (c: { parsed: { y: number } }) => ` ${fmtRp(c.parsed.y)}` } },
legend: { display: false },
},
scales: {
y: { ticks: { callback: (v: number | string) => fmtRp(Number(v)), maxTicksLimit: 5 } },
},
});
const periode = () => {
const f = foreign();
return f?.start && f?.end ? `${f.start} → ${f.end}` : "";
};
return ( return (
<div class="space-y-6"> <div class="space-y-6">
@@ -48,6 +70,9 @@ export default function Dashboard() {
</ul> </ul>
</Show> </Show>
</Show> </Show>
<Show when={sorotan().length}>
<p class="mt-3 border-t pt-3 text-sm text-muted-foreground">🧭 {konteksPasar(sorotan(), flow()?.foreign_net_total)}</p>
</Show>
<div class="mt-3"><IstilahStrip /></div> <div class="mt-3"><IstilahStrip /></div>
</CardContent> </CardContent>
</Card> </Card>
@@ -55,22 +80,29 @@ export default function Dashboard() {
{/* SOROTAN SAHAM */} {/* SOROTAN SAHAM */}
<div> <div>
<h2 class="mb-3 text-lg font-semibold">Saham sorotan hari ini</h2> <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={!screen.loading} fallback={<div class="grid gap-4 md:grid-cols-2"><Skeleton class="h-44" /><Skeleton class="h-44" /></div>}>
<Show when={sorotan().length} fallback={<p class="text-sm text-muted-foreground">Belum ada data sorotan.</p>}> <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"> <div class="grid gap-4 md:grid-cols-2">
<For each={sorotan()}>{(s) => { <For each={sorotan()}>{(s) => {
const b = (s.row.breakdown || {}) as Record<string, unknown>; const b = (s.row.breakdown || {}) as Record<string, unknown>;
const cx = closeMap()[s.row.symbol];
return ( return (
<Card> <Card class={s.verdict === "Dilirik" ? "border-l-4 border-l-emerald-500" : s.verdict === "Dilepas" ? "border-l-4 border-l-red-500" : ""}>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-xl font-bold">{s.row.symbol}</CardTitle> <CardTitle class="text-xl font-bold">
{s.row.symbol}
<Show when={cx?.close}><span class="ml-2 font-mono text-sm font-normal text-muted-foreground">{fmtHarga(cx.close)}</span></Show>
</CardTitle>
<VerdictBadge verdict={s.verdict} /> <VerdictBadge verdict={s.verdict} />
</CardHeader> </CardHeader>
<CardContent class="space-y-3"> <CardContent class="space-y-3">
<p class="text-sm">Karena: <strong>{s.alasan}</strong>.</p> <p class="text-sm">
Harga terakhir <strong class="font-mono">{cx?.close ? fmtHarga(cx.close) : "—"}</strong>
{" "}— {s.alasan}.
</p>
<AlasanBar asing={Number(b.foreign ?? 0)} broker={Number(b.broker_score ?? b.broker ?? 0)} /> <AlasanBar asing={Number(b.foreign ?? 0)} broker={Number(b.broker_score ?? b.broker ?? 0)} />
<div class="flex gap-2"> <div class="flex gap-2">
<Button size="sm" onClick={() => navigate(`/report?t=${s.row.symbol}`)}>Kenapa? Jelaskan</Button> <Button size="sm" onClick={() => navigate(`/report/${s.row.symbol}`)}>Kenapa? Jelaskan</Button>
<Button size="sm" variant="outline"><A href={`/screener`}>Bandingkan</A></Button> <Button size="sm" variant="outline"><A href={`/screener`}>Bandingkan</A></Button>
</div> </div>
</CardContent> </CardContent>
@@ -95,7 +127,8 @@ export default function Dashboard() {
}</For> }</For>
</div> </div>
<Show when={(foreign()?.dates || []).length} fallback={<p class="text-sm text-muted-foreground">Belum ada data grafik.</p>}> <Show when={(foreign()?.dates || []).length} fallback={<p class="text-sm text-muted-foreground">Belum ada data grafik.</p>}>
<Line data={chartData()} options={{ responsive: true }} /> <Line data={chartData()} options={chartOpts() as never} />
<p class="mt-1 text-xs text-muted-foreground">Periode {periode()} · sumber: Sectors API (lihat sitasi report).</p>
<Show when={foreign()?.reversal}><p class="mt-2 text-sm font-medium">⚠️ Arahnya baru berbalik — perhatikan beberapa hari ke depan.</p></Show> <Show when={foreign()?.reversal}><p class="mt-2 text-sm font-medium">⚠️ Arahnya baru berbalik — perhatikan beberapa hari ke depan.</p></Show>
</Show> </Show>
</CardContent> </CardContent>
@@ -115,10 +148,10 @@ export default function Dashboard() {
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<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> <CardHeader><CardTitle>Yang paling diborong</CardTitle><CardDescription>Total <Term kata="net" /> beli broker 5 hari: <strong>{flow() ? fmtRp(flow()!.foreign_net_total) : "…"}</strong></CardDescription></CardHeader>
<CardContent> <CardContent>
<ul class="space-y-1 text-sm"> <ul class="space-y-1 text-sm">
<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> <For each={flow()?.top_accumulation || []}>{(t) => <li class="flex justify-between"><A class="font-medium underline-offset-2 hover:underline" href={`/report/${t.ticker}`}>{t.ticker}</A><span class="font-mono">{fmtRp(t.net_sum)} · {t.brokers} broker</span></li>}</For>
</ul> </ul>
<div class="mt-2"><Badge variant="outline">Angka detail untuk yang penasaran — keputusan ada di kartu sorotan ☝️</Badge></div> <div class="mt-2"><Badge variant="outline">Angka detail untuk yang penasaran — keputusan ada di kartu sorotan ☝️</Badge></div>
</CardContent> </CardContent>