From 94a7f1dfd9a6f101e501537f9c2d211c6436588f Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 15 Sep 2026 16:39:27 +0700 Subject: [PATCH] feat: briefing auto-generate, pages overhaul (dashboard/screener/report/routines), delete routine --- backend/internal/api/routines.go | 20 +++++- backend/internal/routines/engine.go | 10 +++ web/src/pages/Dashboard.tsx | 55 +++++++++------- web/src/pages/Report.tsx | 93 ++++++++++++++++----------- web/src/pages/Routines.tsx | 99 ++++++++++++++++++----------- web/src/pages/Screener.tsx | 97 ++++++++++++++++++---------- 6 files changed, 244 insertions(+), 130 deletions(-) diff --git a/backend/internal/api/routines.go b/backend/internal/api/routines.go index 6f05fc3..ad98e70 100644 --- a/backend/internal/api/routines.go +++ b/backend/internal/api/routines.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "strconv" + "time" "github.com/go-chi/chi/v5" @@ -11,6 +12,9 @@ import ( "flowsight/internal/store" ) +// todayUTC returns today's date (UTC) for briefing persistence. +func todayUTC() string { return time.Now().UTC().Format("2006-01-02") } + // ListRoutines serves GET /api/routines with last-run status. func (s *Server) ListRoutines(w http.ResponseWriter, r *http.Request) { rows, err := s.DB.ListRoutines(s.userKey(r)) @@ -108,11 +112,23 @@ func (s *Server) RunHistory(w http.ResponseWriter, r *http.Request) { } // BriefingToday serves GET /api/briefing/today: latest payload + citations. +// When no briefing exists yet it generates one on demand from current +// snapshots (so the UI never shows an empty page), then persists it. func (s *Server) BriefingToday(w http.ResponseWriter, r *http.Request) { date, payload, cites, err := s.DB.LatestBriefing() if err != nil { - writeErr(w, http.StatusNotFound, "no briefing yet — run the morning-briefing routine") - return + uk := s.userKey(r) + p, cc, gerr := s.Engine.BriefingFor(r.Context(), uk) + if gerr != nil { + writeErr(w, http.StatusNotFound, "no briefing yet — subscribe to the morning-briefing routine") + return + } + ccJSON, _ := json.Marshal(cc) + if err := s.DB.SaveBriefing(todayUTC(), p, string(ccJSON)); err != nil { + writeErr(w, http.StatusBadGateway, "db: "+err.Error()) + return + } + date, payload, cites = todayUTC(), p, string(ccJSON) } writeJSON(w, http.StatusOK, map[string]any{ "date": date, "payload": payload, "citations": cites, diff --git a/backend/internal/routines/engine.go b/backend/internal/routines/engine.go index 1b0ee20..86a701d 100644 --- a/backend/internal/routines/engine.go +++ b/backend/internal/routines/engine.go @@ -146,6 +146,16 @@ func (e *Engine) deliver(ctx context.Context, r store.Routine, payload string, c return e.Notifier.SendTo(ctx, e.UserKey, f, string(cc)) } +// BriefingFor runs the morning briefing scoped to owner (watchlist + +// delivery) without needing a routines row. +func (e *Engine) BriefingFor(ctx context.Context, owner string) (string, []model.Citation, error) { + scoped := *e + if owner != "" { + scoped.UserKey = owner + } + return scoped.Briefing(ctx) +} + // tickers returns the user's watchlist (never empty: falls back to demo set). func (e *Engine) tickers() []string { wl, err := e.DB.Watchlist(e.UserKey) diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 1b1fd2b..ad9969b 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -2,6 +2,7 @@ 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 { StatCard, EmptyState, Skeleton } from "../components/ui"; import { Line } from "solid-chartjs"; import { Chart, registerables } from "chart.js"; Chart.register(...registerables); @@ -11,6 +12,7 @@ export default function Dashboard() { 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 [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)); @@ -23,34 +25,41 @@ export default function Dashboard() { }); return (
-

Smart Money Dashboard

+
+

Smart Money Dashboard

+

Credits {health()?.credits_today ?? "…"} · Scheduler {health()?.scheduler_ok ? "ok" : "down"} · Last cycle {health()?.last_cycle_at || "never"}

+

Stale: {(f) => {f}; }

-

- Credits today: {health()?.credits_today ?? "…"} · Scheduler: {health()?.scheduler_ok ? "ok" : "down"} · - Last cycle: {health()?.last_cycle_at || "never"} -

-
-
-

Foreign net total

-

{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…

}> +
    {(l) =>
  • {l}
  • }
+
+
- - +
+
-

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…

}> -
    {(l) =>
  • {l}
  • }
+

Morning briefing

+ No briefing yet — subscribe to the morning-briefing routine.

}> +
{(briefing() as { payload: string })?.payload?.slice(0, 800)}
diff --git a/web/src/pages/Report.tsx b/web/src/pages/Report.tsx index 9dc4f55..010b6c2 100644 --- a/web/src/pages/Report.tsx +++ b/web/src/pages/Report.tsx @@ -1,67 +1,88 @@ -import { createSignal, For, Show } from "solid-js"; +import { createSignal, For, Show, onMount } from "solid-js"; import { useParams } from "@solidjs/router"; import { api, type ReportPayload } from "../lib/api"; import { Citations } from "../components/Citations"; +import { RecBadge, Skeleton } from "../components/ui"; export default function Report() { const params = useParams(); const [rep, setRep] = createSignal(null); const [md, setMd] = createSignal(""); - const [question, setQuestion] = createSignal("kenapa conviction segitu?"); + const [question, setQuestion] = createSignal(""); const [answer, setAnswer] = createSignal(""); + const [busy, setBusy] = createSignal(true); + const [asking, setAsking] = createSignal(false); const [err, setErr] = createSignal(""); + const authHeaders = () => ({ "X-User-Key": localStorage.getItem("fs-key") || "demo" }); async function load() { - setErr(""); + setErr(""); setBusy(true); try { setRep(await api.report(params.ticker)); } catch (e) { setErr(String(e)); } + finally { setBusy(false); } } + onMount(load); async function exportMd() { setMd(await api.reportMd(params.ticker)); } async function ask() { - const r = await api.interrogate(params.ticker, question()); - setAnswer(r.answer); + if (!question().trim()) return; + setAsking(true); + try { + const r = await api.interrogate(params.ticker, question()); + setAnswer(r.answer); + } finally { setAsking(false); } + } + function dl(url: string, name: string) { + const a = document.createElement("a"); + a.href = url; a.download = name; a.click(); } return (
-

Report: {params.ticker}

- - - - - -

{err()}

+
+

Report: {params.ticker}

+ +
+

{err()}

+ -
-

{rep()!.synthesis.recommendation} · conviction {rep()!.synthesis.conviction}/5

+
+
+ + conviction {rep()!.synthesis.conviction}/5 + conflict flagged + position {rep()!.synthesis.position_pct}% +

{rep()!.synthesis.thesis}

+
+

Export

+
+ + + + +
+
- {(s) =>

{s.name}

{s.body}

} + {(s) =>

{s.name}

{s.body}

}
-
+

Interrogate this report

- setQuestion(e.currentTarget.value)} style={{ width: "320px" }} /> - -

{answer()}

+
+ setQuestion(e.currentTarget.value)} style={{ flex: 1 }} /> + +
+

{answer()}

-
{md().slice(0, 2000)}
+
{md().slice(0, 2000)}
); } diff --git a/web/src/pages/Routines.tsx b/web/src/pages/Routines.tsx index d1ed0eb..880a480 100644 --- a/web/src/pages/Routines.tsx +++ b/web/src/pages/Routines.tsx @@ -1,6 +1,9 @@ import { createResource, createSignal, For, Show } from "solid-js"; import { api } from "../lib/api"; import { Citations } from "../components/Citations"; +import { EmptyState } from "../components/ui"; + +const TYPES = ["morning-briefing", "accumulation-radar", "foreign-reversal", "insider-tape", "earnings-countdown", "dividend-calendar", "weekend-review"]; export default function Routines() { const [data, { refetch }] = createResource(() => api.routines()); @@ -8,55 +11,77 @@ export default function Routines() { const [type_, setType] = createSignal("morning-briefing"); const [schedule, setSchedule] = createSignal(""); const [channels, setChannels] = createSignal(""); + const [busy, setBusy] = createSignal(false); + const [runNowId, setRunNowId] = createSignal(null); 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(); + setBusy(true); + try { + await api.createRoutine({ + type: type_(), + schedule_cron: schedule().trim() || undefined, + channels: channels().split(",").map((c) => c.trim()).filter(Boolean), + }); + refetch(); + } finally { setBusy(false); } } async function toggle(id: number, enabled: boolean) { await api.updateRoutine(id, { enabled: !enabled }); refetch(); } + async function del(id: number) { + await fetch(`/api/routines/${id}`, { method: "DELETE", headers: { "X-User-Key": localStorage.getItem("fs-key") || "demo" } }); + refetch(); + } return (
-

Routine Manager

+

Routine Manager

Scheduled deliveries with citations.

Subscribe

- setType(e.currentTarget.value)}> + {(t) => } + + setSchedule(e.currentTarget.value)} style={{ width: "240px" }} /> + setChannels(e.currentTarget.value)} style={{ width: "200px" }} /> + +
+
+
+ }> +
+ + {(r) => ( +
+
+ {r.type} + {r.enabled ? "on" : "off"} +
+

{r.schedule_cron}

+
+ + + +
+
+ )}
- - setSchedule(e.currentTarget.value)} style={{ width: "260px" }} /> - setChannels(e.currentTarget.value)} style={{ width: "220px" }} /> - -
-
- - {(r) => ( -
- {r.type} {r.schedule_cron} · {r.enabled ? "on" : "off"} -
-
- )} -
-
-

No routines yet.

-
-

Latest briefing

- No briefing yet.

}> -
{(briefing() as { payload: string })?.payload?.slice(0, 800)}
- -
-
-
-

Run history

- -
    {(r) =>
  • {String(r.started_at)} — {String(r.status)}
  • }
+
+
+
+
+

Latest briefing

+ No briefing yet — it generates after the first morning-briefing run.

}> +
{(briefing() as { payload: string })?.payload?.slice(0, 800)}
+ +
+
+
+

Run history

+ +
    {(r) =>
  • {String(r.started_at)} — {String(r.status)}
  • }
+
); diff --git a/web/src/pages/Screener.tsx b/web/src/pages/Screener.tsx index 3c089a7..8520be7 100644 --- a/web/src/pages/Screener.tsx +++ b/web/src/pages/Screener.tsx @@ -1,14 +1,28 @@ import { createSignal, For, Show } from "solid-js"; import { api, type ScreenRow } from "../lib/api"; import { Citations } from "../components/Citations"; +import { BreakdownBars, EmptyState, RecBadge, Skeleton } from "../components/ui"; const SAVED_KEY = "fs-saved-screeners"; +// scoreOf extracts a -100..+100 composite from the row breakdown for the badge. +function scoreOf(r: ScreenRow): number { + const b = r.breakdown as Record; + for (const k of ["composite", "score", "broker_score", "broker"]) { + if (typeof b[k] === "number") return b[k] as number; + } + return r.composite || 0; +} + export default function Screener() { const [mode, setMode] = createSignal<"where" | "q">("q"); const [q, setQ] = createSignal(""); const [brokerMin, setBrokerMin] = createSignal(0); + const [foreignOnly, setForeignOnly] = createSignal(false); + const [insiderOnly, setInsiderOnly] = createSignal(false); const [rows, setRows] = createSignal([]); + const [ran, setRan] = createSignal(false); + const [busy, setBusy] = createSignal(false); const [err, setErr] = createSignal(""); const loadSaved = (): Record => { try { return JSON.parse(localStorage.getItem(SAVED_KEY) || "{}"); } catch { return {}; } @@ -16,14 +30,18 @@ export default function Screener() { const [saved, setSaved] = createSignal>(loadSaved()); const [sname, setSname] = createSignal(""); async function run() { - setErr(""); + setErr(""); setBusy(true); try { + const institutional: Record = { broker_score_min: brokerMin() }; + if (foreignOnly()) institutional.foreign_inflow = true; + if (insiderOnly()) institutional.insider_buying = true; const body = mode() === "q" - ? { q: q() || undefined, institutional: { broker_score_min: brokerMin() }, limit: 20 } - : { where: q() || undefined, institutional: { broker_score_min: brokerMin() }, limit: 20 }; + ? { q: q() || undefined, institutional, limit: 20 } + : { where: q() || undefined, institutional, limit: 20 }; const r = await api.screen(body); - setRows(r.rows); + setRows(r.rows); setRan(true); } catch (e) { setErr(String(e)); } + finally { setBusy(false); } } function save() { const all = { ...saved(), [sname().trim() || q().slice(0, 24) || "untitled"]: q() }; @@ -32,39 +50,54 @@ export default function Screener() { } return (
-

Institutional Screener

+

Institutional Screener

Rank tickers by smart-money signals.

- - 10T"} value={q()} onInput={(e) => setQ(e.currentTarget.value)} style={{ width: "320px" }} /> - - -

{err()}

+
+ + 10T"} value={q()} onInput={(e) => setQ(e.currentTarget.value)} style={{ flex: 1, "min-width": "200px" }} /> + +
+
+ + + +
+

{err()}

-
- - - - {(r) => ( - - - - - - - )} - - -
SymbolCompositeBreakdownCitations
{r.symbol}{r.composite.toFixed(1)}{JSON.stringify(r.breakdown)}
-
+
+ +
}> +
+ + {(r) => ( +
+
+ {r.symbol} + {r.composite.toFixed(1)} +
+
} />
+ +
= 40 ? "BUY" : scoreOf(r) <= -40 ? "AVOID" : "HOLD"} />
+
+ )} +
+
+ + + +
+
+

Saved screeners

- setSname(e.currentTarget.value)} style={{ width: "140px" }} /> - +
+ setSname(e.currentTarget.value)} style={{ width: "140px" }} /> + +
    {([n, query]) =>
  • {query.slice(0, 60)}
  • }
-

Run a screen to see ranked rows with per-row signal breakdown.

); }