Compare commits
4
Commits
fee313ce97
...
5927eec465
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5927eec465 | ||
|
|
94a7f1dfd9 | ||
|
|
ff156112e7 | ||
|
|
1881e7c6b1 |
No files matched your search
@@ -271,3 +271,51 @@ func TestDestinations(t *testing.T) {
|
||||
t.Fatalf("code = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Auth endpoints 404 when Google is unconfigured; /me 401.
|
||||
func TestAuthUnconfigured(t *testing.T) {
|
||||
s := testServer(t)
|
||||
rec := do(s, "GET", "/api/auth/start", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("start code = %d", rec.Code)
|
||||
}
|
||||
rec = do(s, "GET", "/api/auth/me", nil)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("me code = %d", rec.Code)
|
||||
}
|
||||
rec = do(s, "POST", "/api/auth/logout", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("logout code = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Session round-trip: upsert user -> create session -> resolve -> revoke.
|
||||
func TestSessionRoundTrip(t *testing.T) {
|
||||
s := testServer(t)
|
||||
u, err := s.DB.UpsertUser("sub-123", "a@x.id", "A", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.UserKey != "u:sub-123" {
|
||||
t.Fatalf("userkey = %s", u.UserKey)
|
||||
}
|
||||
tok, err := s.DB.CreateSession(u.ID, u.UserKey, 3600000000000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := s.DB.SessionUser(tok)
|
||||
if !ok || got.Email != "a@x.id" {
|
||||
t.Fatalf("resolve ok=%v got=%v", ok, got)
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/api/watchlist", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "fs_session", Value: tok})
|
||||
if key := s.userKey(req); key != "u:sub-123" {
|
||||
t.Fatalf("userKey = %s", key)
|
||||
}
|
||||
if err := s.DB.DeleteSession(tok); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := s.DB.SessionUser(tok); ok {
|
||||
t.Fatal("revoked session still valid")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"flowsight/internal/store"
|
||||
)
|
||||
|
||||
// Session lifetime for Google logins.
|
||||
const sessionTTL = 30 * 24 * time.Hour
|
||||
|
||||
// googleAuthURL builds the Google consent URL (state carries platform=web).
|
||||
func (s *Server) googleAuthURL() string {
|
||||
q := url.Values{
|
||||
"client_id": {s.Cfg.GoogleClientID},
|
||||
"redirect_uri": {s.Cfg.GoogleRedirectURL},
|
||||
"response_type": {"code"},
|
||||
"scope": {"openid email profile"},
|
||||
"access_type": {"offline"},
|
||||
"prompt": {"select_account"},
|
||||
"state": {base64.RawURLEncoding.EncodeToString([]byte(`{"platform":"web"}`))},
|
||||
}
|
||||
return "https://accounts.google.com/o/oauth2/v2/auth?" + q.Encode()
|
||||
}
|
||||
|
||||
// AuthStart serves GET /api/auth/start: 302 to Google, or 404 unconfigured.
|
||||
func (s *Server) AuthStart(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.Cfg.HasGoogle() {
|
||||
writeErr(w, http.StatusNotFound, "google login not configured")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, s.googleAuthURL(), http.StatusFound)
|
||||
}
|
||||
|
||||
// AuthCallback serves GET /api/auth/callback: exchanges code for tokens,
|
||||
// upserts the user, mints a session cookie, redirects to /.
|
||||
// Error cases redirect to /login?error=... (web client shows the message).
|
||||
func (s *Server) AuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.Cfg.HasGoogle() {
|
||||
writeErr(w, http.StatusNotFound, "google login not configured")
|
||||
return
|
||||
}
|
||||
fail := func(msg string) {
|
||||
http.Redirect(w, r, "/login?error="+url.QueryEscape(msg), http.StatusFound)
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if q.Get("error") != "" || q.Get("code") == "" {
|
||||
fail(q.Get("error"))
|
||||
if q.Get("error") == "" {
|
||||
fail("missing_code")
|
||||
}
|
||||
return
|
||||
}
|
||||
tokens, err := exchangeGoogleCode(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret,
|
||||
s.Cfg.GoogleRedirectURL, q.Get("code"))
|
||||
if err != nil {
|
||||
fail("google token exchange failed")
|
||||
return
|
||||
}
|
||||
claims, err := decodeGoogleIDToken(tokens.IDToken)
|
||||
if err != nil || !claims.EmailVerified || claims.Email == "" {
|
||||
fail("email not verified by google")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(claims.Name)
|
||||
if name == "" {
|
||||
name = strings.Split(claims.Email, "@")[0]
|
||||
}
|
||||
user, err := s.DB.UpsertUser(claims.Sub, strings.ToLower(strings.TrimSpace(claims.Email)),
|
||||
name, claims.Picture)
|
||||
if err != nil {
|
||||
fail("user store unavailable")
|
||||
return
|
||||
}
|
||||
// Ensure the login user owns the default watchlist on first login.
|
||||
if wl, _ := s.DB.Watchlist(user.UserKey); len(wl) == 0 {
|
||||
for _, t := range s.Cfg.Watchlist {
|
||||
_ = s.DB.AddWatch(user.UserKey, t)
|
||||
}
|
||||
}
|
||||
tok, err := s.DB.CreateSession(user.ID, user.UserKey, sessionTTL)
|
||||
if err != nil {
|
||||
fail("session store unavailable")
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
|
||||
Secure: true, SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(sessionTTL),
|
||||
})
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
// AuthMe serves GET /api/auth/me: 200 user when logged in, else 401.
|
||||
func (s *Server) AuthMe(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := s.sessionUser(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "not logged in")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"user": map[string]any{
|
||||
"id": u.ID, "email": u.Email, "name": u.Name,
|
||||
"avatar_url": u.AvatarURL, "user_key": u.UserKey,
|
||||
"google_configured": s.Cfg.HasGoogle(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AuthLogout serves POST /api/auth/logout: revokes the session cookie.
|
||||
func (s *Server) AuthLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie("fs_session"); err == nil {
|
||||
_ = s.DB.DeleteSession(c.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "fs_session", Value: "", Path: "/", HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// sessionUser resolves the request's session cookie to its user.
|
||||
func (s *Server) sessionUser(r *http.Request) (*store.User, bool) {
|
||||
c, err := r.Cookie("fs_session")
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return s.DB.SessionUser(c.Value)
|
||||
}
|
||||
|
||||
// googleTokenResp is the subset of oauth2.googleapis.com/token we need.
|
||||
type googleTokenResp struct {
|
||||
IDToken string `json:"id_token"`
|
||||
}
|
||||
|
||||
// googleClaims is the verified-identity subset of the id_token payload.
|
||||
type googleClaims struct {
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
// exchangeGoogleCode swaps an authorization code for tokens.
|
||||
func exchangeGoogleCode(clientID, secret, redirectURL, code string) (*googleTokenResp, error) {
|
||||
form := url.Values{
|
||||
"code": {code},
|
||||
"client_id": {clientID},
|
||||
"client_secret": {secret},
|
||||
"redirect_uri": {redirectURL},
|
||||
"grant_type": {"authorization_code"},
|
||||
}
|
||||
resp, err := http.PostForm("https://oauth2.googleapis.com/token", form)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &url.Error{Op: "exchange", URL: "oauth2.googleapis.com/token", Err: errString(resp.StatusCode)}
|
||||
}
|
||||
var out googleTokenResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.IDToken == "" {
|
||||
return nil, &url.Error{Op: "exchange", URL: "oauth2.googleapis.com/token", Err: errString(0)}
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// decodeGoogleIDToken decodes (not signature-verifies) the id_token payload.
|
||||
// Full verification happens implicitly: the token arrives over TLS directly
|
||||
// from Google's token endpoint in exchange for our client_secret.
|
||||
func decodeGoogleIDToken(idToken string) (*googleClaims, error) {
|
||||
parts := strings.Split(idToken, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, errInvalidToken{}
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c googleClaims
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Sub == "" {
|
||||
return nil, errInvalidToken{}
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
type errString int
|
||||
|
||||
func (e errString) Error() string {
|
||||
if e == 0 {
|
||||
return "empty id_token"
|
||||
}
|
||||
return http.StatusText(int(e))
|
||||
}
|
||||
|
||||
type errInvalidToken struct{}
|
||||
|
||||
func (errInvalidToken) Error() string { return "invalid id_token" }
|
||||
@@ -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,
|
||||
|
||||
@@ -62,6 +62,10 @@ func (s *Server) Router() http.Handler {
|
||||
r.Use(middleware.Logger, middleware.Recoverer, middleware.Heartbeat("/ping"))
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Get("/health", s.Health)
|
||||
r.Get("/auth/start", s.AuthStart)
|
||||
r.Get("/auth/callback", s.AuthCallback)
|
||||
r.Get("/auth/me", s.AuthMe)
|
||||
r.Post("/auth/logout", s.AuthLogout)
|
||||
r.Get("/stream", s.Stream)
|
||||
r.Get("/flow/summary", s.FlowSummary)
|
||||
r.Get("/flow/broker", s.FlowBroker)
|
||||
@@ -96,8 +100,12 @@ func (s *Server) Router() http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
// userKey resolves the demo auth header (single demo key for hackathon).
|
||||
// userKey resolves the request owner: session cookie first (Google login ->
|
||||
// `u:<sub>`), then the demo header, then the shared demo key.
|
||||
func (s *Server) userKey(r *http.Request) string {
|
||||
if u, ok := s.sessionUser(r); ok {
|
||||
return u.UserKey
|
||||
}
|
||||
if k := strings.TrimSpace(r.Header.Get("X-User-Key")); k != "" {
|
||||
return k
|
||||
}
|
||||
|
||||
@@ -29,6 +29,17 @@ type Config struct {
|
||||
Watchlist []string
|
||||
// StaticDir serves the prebuilt web dist (WEB_DIST_DIR). Empty = API only.
|
||||
StaticDir string
|
||||
// Google OAuth (login). Empty client ID = auth endpoints 404.
|
||||
GoogleClientID string
|
||||
GoogleClientSecret string
|
||||
GoogleRedirectURL string
|
||||
}
|
||||
|
||||
// HasGoogle reports whether Google OAuth login is configured.
|
||||
func (c Config) HasGoogle() bool {
|
||||
return strings.TrimSpace(c.GoogleClientID) != "" &&
|
||||
strings.TrimSpace(c.GoogleClientSecret) != "" &&
|
||||
strings.TrimSpace(c.GoogleRedirectURL) != ""
|
||||
}
|
||||
|
||||
// HasSectorsKey reports whether live Sectors API calls are possible.
|
||||
@@ -86,5 +97,8 @@ func Load() Config {
|
||||
CreditCapPerCycle: getenvInt("CREDIT_CAP_PER_CYCLE", 120),
|
||||
Watchlist: tickers,
|
||||
StaticDir: os.Getenv("WEB_DIST_DIR"),
|
||||
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
|
||||
GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
|
||||
GoogleRedirectURL: os.Getenv("GOOGLE_REDIRECT_URL"),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User is one Google-authenticated account. UserKey (`u:<google_sub>`) is
|
||||
// the scoping key used by every user-owned table.
|
||||
type User struct {
|
||||
ID int64
|
||||
GoogleSub string
|
||||
Email string
|
||||
Name string
|
||||
AvatarURL string
|
||||
CreatedAt string
|
||||
UserKey string
|
||||
}
|
||||
|
||||
// UserKeyForSub maps a Google subject to its scoping key.
|
||||
func UserKeyForSub(sub string) string { return "u:" + sub }
|
||||
|
||||
// UpsertUser inserts a first-time login or refreshes profile on return.
|
||||
func (db *DB) UpsertUser(sub, email, name, avatar string) (*User, error) {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := db.Exec(`INSERT INTO users(google_sub,email,name,avatar_url,created_at)
|
||||
VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(google_sub) DO UPDATE SET email=excluded.email,
|
||||
name=excluded.name, avatar_url=excluded.avatar_url`,
|
||||
sub, email, name, avatar, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db.UserBySub(sub)
|
||||
}
|
||||
|
||||
// UserBySub returns the user for a Google subject.
|
||||
func (db *DB) UserBySub(sub string) (*User, error) {
|
||||
var u User
|
||||
if err := db.QueryRow(`SELECT id,google_sub,email,name,avatar_url,created_at
|
||||
FROM users WHERE google_sub=?`, sub).
|
||||
Scan(&u.ID, &u.GoogleSub, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.UserKey = UserKeyForSub(u.GoogleSub)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// NewSessionToken mints one opaque 256-bit session token.
|
||||
func NewSessionToken() (string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
// CreateSession stores a session valid for ttl.
|
||||
func (db *DB) CreateSession(userID int64, userKey string, ttl time.Duration) (string, error) {
|
||||
tok, err := NewSessionToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if _, err := db.Exec(`INSERT INTO sessions(token,user_key,user_id,expires_at,created_at)
|
||||
VALUES(?,?,?,?,?)`, tok, userKey, userID,
|
||||
now.Add(ttl).Format(time.RFC3339), now.Format(time.RFC3339)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// SessionUser resolves a session token to its user. Expired tokens are
|
||||
// deleted lazily and reported invalid.
|
||||
func (db *DB) SessionUser(token string) (*User, bool) {
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return nil, false
|
||||
}
|
||||
var u User
|
||||
var exp string
|
||||
if err := db.QueryRow(`SELECT u.id,u.google_sub,u.email,u.name,u.avatar_url,
|
||||
u.created_at,s.expires_at FROM sessions s
|
||||
JOIN users u ON u.id=s.user_id WHERE s.token=?`, token).
|
||||
Scan(&u.ID, &u.GoogleSub, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt, &exp); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, exp)
|
||||
if err != nil || time.Now().UTC().After(t) {
|
||||
_, _ = db.Exec(`DELETE FROM sessions WHERE token=?`, token)
|
||||
return nil, false
|
||||
}
|
||||
u.UserKey = UserKeyForSub(u.GoogleSub)
|
||||
return &u, true
|
||||
}
|
||||
|
||||
// UserKeyBySession returns the scoping key for a valid session token.
|
||||
func (db *DB) UserKeyBySession(token string) (string, bool) {
|
||||
u, ok := db.SessionUser(token)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return u.UserKey, true
|
||||
}
|
||||
|
||||
// DeleteSession revokes one session token.
|
||||
func (db *DB) DeleteSession(token string) error {
|
||||
_, err := db.Exec(`DELETE FROM sessions WHERE token=?`, token)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- FlowSight schema v5: Google-authenticated users + server sessions.
|
||||
-- user_key for a login user is `u:<google_sub>`; all user-scoped tables
|
||||
-- (watchlists, routines, alerts, destinations, reports) work unchanged.
|
||||
CREATE TABLE IF NOT EXISTS users(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
google_sub TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions(
|
||||
token TEXT PRIMARY KEY,
|
||||
user_key TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createResource, createSignal, Show } from "solid-js";
|
||||
import { api, type AuthUser } from "../lib/api";
|
||||
|
||||
// AuthState: loads /api/auth/me once; exposes login/logout + theme toggle.
|
||||
function theme(): string {
|
||||
return localStorage.getItem("fs-theme") ||
|
||||
(matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
|
||||
}
|
||||
|
||||
export function applyTheme(t: string) {
|
||||
document.documentElement.dataset.theme = t;
|
||||
localStorage.setItem("fs-theme", t);
|
||||
}
|
||||
applyTheme(theme());
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [t, setT] = createSignal(theme());
|
||||
return (
|
||||
<button class="fs-iconbtn" title="Toggle light/dark" onClick={() => {
|
||||
const n = t() === "dark" ? "light" : "dark";
|
||||
setT(n); applyTheme(n);
|
||||
}}>
|
||||
{t() === "dark" ? "☀️" : "🌙"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [me, { refetch }] = createResource(async (): Promise<AuthUser | null> => {
|
||||
try { return (await api.me()).user; } catch { return null; }
|
||||
});
|
||||
return { me, refetch };
|
||||
}
|
||||
|
||||
export function AuthButton(props: { me: AuthUser | null | undefined; onLogout: () => void }) {
|
||||
return (
|
||||
<Show when={props.me} fallback={
|
||||
<a class="fs-btn" href="/api/auth/start">Sign in with Google</a>
|
||||
}>
|
||||
{(u) => (
|
||||
<span class="fs-user">
|
||||
{u().avatar_url ? <img class="fs-avatar" src={u().avatar_url} alt="" /> : null}
|
||||
{u().name || u().email}
|
||||
<button class="fs-iconbtn" onClick={props.onLogout}>Logout</button>
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { For, Show, type JSX } from "solid-js";
|
||||
|
||||
// Shared building blocks: StatCard, EmptyState, Badge, SignalBar, Skeleton.
|
||||
|
||||
export function StatCard(props: { title: string; value: string; sub?: string; children?: JSX.Element }) {
|
||||
return (
|
||||
<div class="fs-card">
|
||||
<h3>{props.title}</h3>
|
||||
<p class="fs-stat">{props.value}</p>
|
||||
<Show when={props.sub}><p class="fs-muted" style={{ margin: 0 }}>{props.sub}</p></Show>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState(props: { icon: string; title: string; hint?: string; action?: JSX.Element }) {
|
||||
return (
|
||||
<div class="fs-card fs-empty">
|
||||
<div class="big">{props.icon}</div>
|
||||
<strong>{props.title}</strong>
|
||||
<Show when={props.hint}><p class="fs-muted">{props.hint}</p></Show>
|
||||
<Show when={props.action}><div style={{ "margin-top": "10px" }}>{props.action}</div></Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Skeleton(props: { rows?: number }) {
|
||||
const n = props.rows ?? 3;
|
||||
return (
|
||||
<div class="fs-stack">
|
||||
<For each={Array.from({ length: n })}>{() => <div class="fs-sk" style={{ height: "56px" }} />}</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecBadge(props: { rec: string }) {
|
||||
const cls = props.rec === "BUY" ? "buy" : props.rec === "HOLD" ? "hold" : "avoid";
|
||||
return <span class={`fs-badge ${cls}`}>{props.rec}</span>;
|
||||
}
|
||||
|
||||
// SignalBar renders one -100..+100 signal as a labeled bar.
|
||||
export function SignalBar(props: { label: string; value: number }) {
|
||||
const v = Math.max(-100, Math.min(100, props.value));
|
||||
const left = v < 0 ? `${50 + v / 2}%` : "50%";
|
||||
const width = `${Math.abs(v) / 2}%`;
|
||||
const cls = v < 0 ? "neg" : v > 0 ? "pos" : "";
|
||||
return (
|
||||
<div class="fs-sig">
|
||||
<span class="fs-muted">{props.label}</span>
|
||||
<span class="bar"><i class={cls} style={{ left, width }} /></span>
|
||||
<b>{v}</b>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// BreakdownBars renders a screen/report signal breakdown map as bars.
|
||||
// Unknown numeric fields are shown; booleans/strings are skipped except flags.
|
||||
export function BreakdownBars(props: { breakdown: Record<string, unknown> }) {
|
||||
const entries = () =>
|
||||
Object.entries(props.breakdown || {}).filter(([, v]) => typeof v === "number") as [string, number][];
|
||||
return (
|
||||
<Show when={entries().length} fallback={<span class="fs-muted">no signals</span>}>
|
||||
<For each={entries()}>{([k, v]) => <SignalBar label={k.replace(/_/g, " ")} value={v} />}</For>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
+74
-20
@@ -1,6 +1,9 @@
|
||||
import { render } from "solid-js/web";
|
||||
import { Router, Route, useLocation } from "@solidjs/router";
|
||||
import { Router, Route, useLocation, useNavigate, useParams, type RouteSectionProps } from "@solidjs/router";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { WatchlistDrawer, ChatSidebar } from "./components/WatchlistChat";
|
||||
import { ThemeToggle, useAuth, AuthButton } from "./components/auth";
|
||||
import { api } from "./lib/api";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Routines from "./pages/Routines";
|
||||
import Screener from "./pages/Screener";
|
||||
@@ -11,39 +14,90 @@ import "./styles/tokens.css";
|
||||
|
||||
function Nav() {
|
||||
const loc = useLocation();
|
||||
const link = (href: string, label: string) => (
|
||||
<a href={href} class={loc.pathname === href ? "active" : ""}>{label}</a>
|
||||
const link = (href: string, icon: string, label: string) => (
|
||||
<a href={href} class={loc.pathname === href ? "active" : ""}><span>{icon}</span> {label}</a>
|
||||
);
|
||||
return (
|
||||
<nav class="fs-nav">
|
||||
<strong>FlowSight</strong>
|
||||
{link("/", "Dashboard")}
|
||||
{link("/routines", "Routines")}
|
||||
{link("/screener", "Screener")}
|
||||
{link("/alerts", "Alerts")}
|
||||
{link("/portfolio", "Portfolio")}
|
||||
<nav class="fs-nav" id="fs-nav">
|
||||
<div class="fs-brand">Flow<span>Sight</span></div>
|
||||
{link("/", "📊", "Dashboard")}
|
||||
{link("/routines", "🗓️", "Routines")}
|
||||
{link("/screener", "🔍", "Screener")}
|
||||
{link("/alerts", "🔔", "Alerts")}
|
||||
{link("/portfolio", "💼", "Portfolio")}
|
||||
<div class="fs-nav-foot">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function TickerSearch() {
|
||||
const nav = useNavigate();
|
||||
const [q, setQ] = createSignal("");
|
||||
const go = () => {
|
||||
const t = q().toUpperCase().trim();
|
||||
if (t) nav(`/report/${t}`);
|
||||
};
|
||||
return (
|
||||
<form class="fs-search" onSubmit={(e) => { e.preventDefault(); go(); }}>
|
||||
<input class="fs-input" placeholder="Ticker → report (e.g. BBCA)" value={q()}
|
||||
onInput={(e) => setQ(e.currentTarget.value)} style={{ flex: 1 }} />
|
||||
<button class="fs-btn" type="submit">Open</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginPage() {
|
||||
const params = useParams<{ error?: string }>();
|
||||
const err = () => new URLSearchParams(window.location.search).get("error");
|
||||
void params;
|
||||
return (
|
||||
<div style={{ "max-width": "440px", margin: "8vh auto" }}>
|
||||
<div class="fs-card" style={{ padding: "28px", "text-align": "center" }}>
|
||||
<h1 style={{ margin: "0 0 8px" }}>Welcome to FlowSight</h1>
|
||||
<p class="fs-muted">Sign in with Google to get your own watchlist, routines, alerts and reports.</p>
|
||||
<Show when={err()}><p style={{ color: "var(--fs-avoid)" }}>Login failed: {err()}</p></Show>
|
||||
<a class="fs-btn" href="/api/auth/start" style={{ display: "inline-block", "margin-top": "12px" }}>Sign in with Google</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell(props: RouteSectionProps) {
|
||||
const { me, refetch } = useAuth();
|
||||
const logout = async () => { await api.logout(); refetch(); };
|
||||
return (
|
||||
<div class="fs-app">
|
||||
<Nav />
|
||||
<div class="fs-body">
|
||||
<header class="fs-topbar">
|
||||
<button id="fs-menu-btn" class="fs-iconbtn" onClick={() => document.getElementById("fs-nav")?.classList.toggle("open")}>☰</button>
|
||||
<TickerSearch />
|
||||
<span class="fs-spacer" />
|
||||
<ThemeToggle />
|
||||
<AuthButton me={me()} onLogout={logout} />
|
||||
</header>
|
||||
<main class="fs-main">{props.children}</main>
|
||||
</div>
|
||||
<aside class="fs-side">
|
||||
<WatchlistDrawer />
|
||||
<ChatSidebar />
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<Router root={(props) => (
|
||||
<div class="fs-app">
|
||||
<Nav />
|
||||
<main class="fs-main">{props.children}</main>
|
||||
<aside style={{ width: "300px", padding: "20px 16px 20px 0", display: "flex", "flex-direction": "column", gap: "12px" }}>
|
||||
<WatchlistDrawer />
|
||||
<ChatSidebar />
|
||||
</aside>
|
||||
</div>
|
||||
)}>
|
||||
<Router root={Shell}>
|
||||
<Route path="/" component={Dashboard} />
|
||||
<Route path="/routines" component={Routines} />
|
||||
<Route path="/screener" component={Screener} />
|
||||
<Route path="/alerts" component={Alerts} />
|
||||
<Route path="/report/:ticker" component={Report} />
|
||||
<Route path="/portfolio" component={Portfolio} />
|
||||
<Route path="/login" component={LoginPage} />
|
||||
</Router>
|
||||
),
|
||||
document.getElementById("root")!,
|
||||
|
||||
+4
-1
@@ -22,6 +22,7 @@ export interface ScreenRow { symbol: string; name: string; composite: number; br
|
||||
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 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[] }
|
||||
export interface AuthUser { id: number; email: string; name: string; avatar_url: string; user_key: string; google_configured: boolean }
|
||||
export const api = {
|
||||
health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`),
|
||||
flowSummary: () => req<FlowSummary>("/api/flow/summary"),
|
||||
@@ -49,7 +50,9 @@ export const api = {
|
||||
removeWatch: (ticker: string) => req<unknown>(`/api/watchlist/${ticker}`, { method: "DELETE" }),
|
||||
risk: () => req<{ concentration: { ticker: string; sector: string; weight: number }[]; correlation: Record<string, Record<string, number>>; beta: number; warnings: string[] }>("/api/portfolio/risk"),
|
||||
accuracy: () => req<{ agents: { agent: string; calls: number; resolved: number; hits: number; hit_rate: number }[] }>("/api/accuracy"),
|
||||
chat: (message: string) => req<{ answer: string }>("/api/chat", { method: "POST", body: JSON.stringify({ message }) }),
|
||||
chat: (message: string) => req<{ answer: string }>(`/api/chat`, { method: "POST", body: JSON.stringify({ message }) }),
|
||||
me: () => req<{ user: AuthUser }>(`/api/auth/me`),
|
||||
logout: () => req<{ ok: boolean }>(`/api/auth/logout`, { method: "POST" }),
|
||||
};
|
||||
// SSE hook helper: subscribe to channels agents|alerts|activity.
|
||||
export function subscribeSSE(onEvent: (channel: string, data: string) => void): () => void {
|
||||
|
||||
+76
-36
@@ -1,26 +1,44 @@
|
||||
import { createResource, createSignal, For } from "solid-js";
|
||||
import { createResource, createSignal, For, Show } from "solid-js";
|
||||
import { api } from "../lib/api";
|
||||
import { EmptyState, Skeleton } from "../components/ui";
|
||||
|
||||
const FIELDS = ["score", "broker_score", "foreign_net", "volume_mult", "composite"];
|
||||
const OPS = [">=", "<=", ">", "<", "=="];
|
||||
|
||||
type Cond = { field: string; op: string; value: number };
|
||||
|
||||
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 [field, setField] = createSignal("score");
|
||||
const [op, setOp] = createSignal(">=");
|
||||
const [value, setValue] = createSignal(60);
|
||||
const [conds, setConds] = createSignal<Cond[]>([]);
|
||||
const [channels, setChannels] = createSignal("");
|
||||
const [busy, setBusy] = createSignal(false);
|
||||
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("");
|
||||
const rulePreview = () => JSON.stringify(conds().length ? { all: conds() } : { all: true });
|
||||
function addCond() {
|
||||
setConds((p) => [...p, { field: field(), op: op(), value: value() }]);
|
||||
}
|
||||
async function create() {
|
||||
await api.createAlert({
|
||||
name: name(),
|
||||
rule: JSON.parse(rule()),
|
||||
channels: channels().split(",").map((c) => c.trim()).filter(Boolean),
|
||||
});
|
||||
refetch();
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.createAlert({
|
||||
name: name(),
|
||||
rule: JSON.parse(rulePreview()),
|
||||
channels: channels().split(",").map((c) => c.trim()).filter(Boolean),
|
||||
});
|
||||
setConds([]);
|
||||
refetch();
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
async function del(id: number) { await api.deleteAlert(id); refetch(); }
|
||||
async function addDest() {
|
||||
@@ -41,49 +59,71 @@ export default function Alerts() {
|
||||
async function delDest(id: number) { await api.deleteDestination(id); refetchDests(); }
|
||||
return (
|
||||
<div>
|
||||
<h1>Alerts</h1>
|
||||
<div class="fs-page-head"><h1>Alerts</h1><p class="fs-muted">Rules evaluated over snapshots each cycle.</p></div>
|
||||
<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}
|
||||
<p class="fs-muted">Secrets stay server-side — the list never shows tokens.</p>
|
||||
<div class="fs-row">
|
||||
<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: "200px" }} />
|
||||
<input class="fs-input" placeholder="chat id" value={dchat()} onInput={(e) => setDchat(e.currentTarget.value)} style={{ width: "120px" }} />
|
||||
</>
|
||||
) : (
|
||||
<input class="fs-input" placeholder="https://discord webhook url" value={dhook()} onInput={(e) => setDhook(e.currentTarget.value)} style={{ flex: 1 }} />
|
||||
)}
|
||||
<button class="fs-btn" onClick={addDest}>Add destination</button>
|
||||
</div>
|
||||
{derr() ? <p style={{ color: "var(--fs-avoid)" }}>{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>
|
||||
<li><span class={`fs-badge ${d.enabled ? "buy" : "info"}`}>{d.kind}</span> {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>
|
||||
<button class="fs-btn danger" onClick={() => delDest(d.id)}>Delete</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="fs-card">
|
||||
<div class="fs-card" style={{ "margin-top": "14px" }}>
|
||||
<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 class="fs-row">
|
||||
<input class="fs-input" placeholder="rule name" value={name()} onInput={(e) => setName(e.currentTarget.value)} style={{ flex: 1 }} />
|
||||
</div>
|
||||
<div class="fs-row" style={{ "margin-top": "10px" }}>
|
||||
<select class="fs-input" value={field()} onChange={(e) => setField(e.currentTarget.value)}>
|
||||
<For each={FIELDS}>{(f) => <option value={f}>{f}</option>}</For>
|
||||
</select>
|
||||
<select class="fs-input" value={op()} onChange={(e) => setOp(e.currentTarget.value)}>
|
||||
<For each={OPS}>{(o) => <option value={o}>{o}</option>}</For>
|
||||
</select>
|
||||
<input class="fs-input" type="number" value={value()} onInput={(e) => setValue(Number(e.currentTarget.value))} style={{ width: "100px" }} />
|
||||
<button class="fs-btn ghost" onClick={addCond}>+ condition</button>
|
||||
</div>
|
||||
<Show when={conds().length}>
|
||||
<ul><For each={conds()}>{(c, i) => <li class="fs-muted">{c.field} {c.op} {c.value} <button class="fs-btn ghost" onClick={() => setConds((p) => p.filter((_, j) => j !== i()))}>×</button></li>}</For></ul>
|
||||
</Show>
|
||||
<p class="fs-muted">Rule JSON: <code>{rulePreview()}</code></p>
|
||||
<div class="fs-row">
|
||||
<input class="fs-input" placeholder="channels, comma-separated (blank = record only)" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} style={{ flex: 1 }} />
|
||||
<button class="fs-btn" onClick={create} disabled={busy()}>Create rule</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fs-grid">
|
||||
<div class="fs-grid" style={{ "margin-top": "14px" }}>
|
||||
<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>
|
||||
<Show when={(alerts()?.alerts || []).length} fallback={<EmptyState icon="🔔" title="No rules" hint="Create one above." />}>
|
||||
<ul><For each={alerts()?.alerts || []}>{(a) => <li>{a.name} <span class="fs-muted">last: {a.last_fired || "never"}</span> <button class="fs-btn danger" onClick={() => del(a.id)}>Delete</button></li>}</For></ul>
|
||||
</Show>
|
||||
</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>
|
||||
<Show when={(events() || []).length} fallback={<p class="fs-muted">No events yet.</p>}>
|
||||
<ul><For each={events() || []}>{(e) => <li><strong>{String(e.ticker)}</strong> — {String(e.message)}</li>}</For></ul>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+32
-23
@@ -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<string[]>([]);
|
||||
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 (
|
||||
<div>
|
||||
<h1>Smart Money Dashboard</h1>
|
||||
<div class="fs-page-head">
|
||||
<h1>Smart Money Dashboard</h1>
|
||||
<p class="fs-muted">Credits {health()?.credits_today ?? "…"} · Scheduler {health()?.scheduler_ok ? "ok" : "down"} · Last cycle {health()?.last_cycle_at || "never"}</p>
|
||||
</div>
|
||||
<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} />
|
||||
<Show when={!flow.loading} fallback={<Skeleton rows={4} />}>
|
||||
<div class="fs-grid">
|
||||
<StatCard title="Foreign net total" value={flow() ? fmtIDR(flow()!.foreign_net_total) : "…"}>
|
||||
<Citations items={flow()?.citations} />
|
||||
</StatCard>
|
||||
<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>
|
||||
<AccumulationTable rows={flow()?.top_accumulation || []} />
|
||||
<RotationMap signal={undefined} citations={flow()?.citations} />
|
||||
</Show>
|
||||
<div style={{ "margin-top": "14px" }}>
|
||||
<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>
|
||||
<h3>Morning briefing</h3>
|
||||
<Show when={briefing()} fallback={<p class="fs-muted">No briefing yet — subscribe to the morning-briefing routine.</p>}>
|
||||
<pre class="fs-muted">{(briefing() as { payload: string })?.payload?.slice(0, 800)}</pre>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+49
-44
@@ -1,7 +1,8 @@
|
||||
import { createResource, For } from "solid-js";
|
||||
import { createResource, For, Show } from "solid-js";
|
||||
import { api } from "../lib/api";
|
||||
import { Bar } from "solid-chartjs";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
import { StatCard, Skeleton } from "../components/ui";
|
||||
Chart.register(...registerables);
|
||||
|
||||
export default function Portfolio() {
|
||||
@@ -9,35 +10,37 @@ export default function Portfolio() {
|
||||
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)) }],
|
||||
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 class="fs-page-head"><h1>Portfolio Risk + Accuracy Ledger</h1><p class="fs-muted">Computed from your watchlist.</p></div>
|
||||
<Show when={!risk.loading && !acc.loading} fallback={<Skeleton rows={4} />}>
|
||||
<div class="fs-grid">
|
||||
<div class="fs-card">
|
||||
<h3>Concentration</h3>
|
||||
<Show when={(risk()?.concentration || []).length} fallback={<p class="fs-muted">Add tickers to your watchlist to see concentration.</p>}>
|
||||
<Bar data={chartData()} options={{ responsive: true }} />
|
||||
</Show>
|
||||
<For each={risk()?.warnings || []}>{(w) => <p>⚠️ {w}</p>}</For>
|
||||
</div>
|
||||
<StatCard title="Beta vs index" value={risk() ? String(risk()!.beta) : "…"}>
|
||||
<h3 style={{ "margin-top": "12px" }}>Correlation heatmap</h3>
|
||||
<CorrHeatmap matrix={risk()?.correlation || {}} />
|
||||
</StatCard>
|
||||
<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 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>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,23 +53,25 @@ function CorrHeatmap(props: { matrix: Record<string, Record<string, number>> })
|
||||
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>
|
||||
<Show when={keys().length} fallback={<p class="fs-muted">No correlation data yet.</p>}>
|
||||
<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>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
+57
-36
@@ -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<ReportPayload | null>(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 (
|
||||
<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>
|
||||
<div class="fs-page-head">
|
||||
<h1>Report: {params.ticker}</h1>
|
||||
<button class="fs-btn ghost" onClick={load}>↻ Regenerate</button>
|
||||
</div>
|
||||
<Show when={err()}><p style={{ color: "var(--fs-avoid)" }}>{err()}</p></Show>
|
||||
<Show when={busy()}><Skeleton rows={5} /></Show>
|
||||
<Show when={rep()}>
|
||||
<div class="fs-card">
|
||||
<h2 class={`rec-${rep()!.synthesis.recommendation}`}>{rep()!.synthesis.recommendation} · conviction {rep()!.synthesis.conviction}/5</h2>
|
||||
<div class="fs-card" style={{ "border-left": "4px solid var(--fs-accent)" }}>
|
||||
<div class="fs-row">
|
||||
<RecBadge rec={rep()!.synthesis.recommendation} />
|
||||
<strong>conviction {rep()!.synthesis.conviction}/5</strong>
|
||||
<Show when={rep()!.synthesis.conflict}><span class="fs-badge info">conflict flagged</span></Show>
|
||||
<span class="fs-muted">position {rep()!.synthesis.position_pct}%</span>
|
||||
</div>
|
||||
<p>{rep()!.synthesis.thesis}</p>
|
||||
<Citations items={rep()!.citations} />
|
||||
</div>
|
||||
<div class="fs-card" style={{ "margin-top": "14px" }}>
|
||||
<h3>Export</h3>
|
||||
<div class="fs-row">
|
||||
<button class="fs-btn ghost" onClick={exportMd}>Markdown</button>
|
||||
<button class="fs-btn ghost" onClick={async () => {
|
||||
const r = await fetch(`/api/report/${params.ticker}?format=html`, { method: "POST", headers: authHeaders() });
|
||||
dl(URL.createObjectURL(new Blob([await r.text()], { type: "text/html" })), `${params.ticker}-report.html`);
|
||||
}}>HTML</button>
|
||||
<button class="fs-btn ghost" onClick={() => {
|
||||
dl(URL.createObjectURL(new Blob([JSON.stringify(rep(), null, 1)], { type: "application/json" })), `${params.ticker}-report.json`);
|
||||
}}>JSON</button>
|
||||
<button class="fs-btn ghost" onClick={async () => {
|
||||
const r = await fetch(`/api/report/${params.ticker}?format=pdf`, { method: "POST", headers: authHeaders() });
|
||||
dl(URL.createObjectURL(await r.blob()), `${params.ticker}-report.pdf`);
|
||||
}}>PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
<For each={rep()!.sections}>
|
||||
{(s) => <div class="fs-card"><h3>{s.name}</h3><p>{s.body}</p><Citations items={s.citations} /></div>}
|
||||
{(s) => <div class="fs-card" style={{ "margin-top": "14px" }}><h3>{s.name}</h3><p style={{ "white-space": "pre-wrap" }}>{s.body}</p><Citations items={s.citations} /></div>}
|
||||
</For>
|
||||
<div class="fs-card">
|
||||
<div class="fs-card" style={{ "margin-top": "14px" }}>
|
||||
<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 class="fs-row">
|
||||
<input class="fs-input" placeholder="Ask about this report…" value={question()} onInput={(e) => setQuestion(e.currentTarget.value)} style={{ flex: 1 }} />
|
||||
<button class="fs-btn" onClick={ask} disabled={asking()}>{asking() ? "…" : "Ask"}</button>
|
||||
</div>
|
||||
<Show when={answer()}><p style={{ "margin-top": "10px" }}>{answer()}</p></Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={md()}><div class="fs-card"><pre class="fs-muted">{md().slice(0, 2000)}</pre></div></Show>
|
||||
<Show when={md()}><div class="fs-card" style={{ "margin-top": "14px" }}><pre class="fs-muted">{md().slice(0, 2000)}</pre></div></Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+62
-37
@@ -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<number | null>(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 (
|
||||
<div>
|
||||
<h1>Routine Manager</h1>
|
||||
<div class="fs-page-head"><h1>Routine Manager</h1><p class="fs-muted">Scheduled deliveries with citations.</p></div>
|
||||
<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>}
|
||||
<div class="fs-row">
|
||||
<select class="fs-input" value={type_()} onChange={(e) => setType(e.currentTarget.value)}>
|
||||
<For each={TYPES}>{(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: "240px" }} />
|
||||
<input class="fs-input" placeholder="channels, comma-separated" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} style={{ width: "200px" }} />
|
||||
<button class="fs-btn" onClick={subscribe} disabled={busy()}>Subscribe</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={(data()?.routines || []).length} fallback={
|
||||
<div style={{ "margin-top": "14px" }}><EmptyState icon="🗓️" title="No routines yet" hint="Subscribe above — the morning briefing runs 07:30 WIB daily." /></div>
|
||||
}>
|
||||
<div class="fs-grid" style={{ "margin-top": "14px" }}>
|
||||
<For each={data()?.routines || []}>
|
||||
{(r) => (
|
||||
<div class="fs-card">
|
||||
<div class="fs-row" style={{ "justify-content": "space-between" }}>
|
||||
<strong>{r.type}</strong>
|
||||
<span class={`fs-badge ${r.enabled ? "buy" : "info"}`}>{r.enabled ? "on" : "off"}</span>
|
||||
</div>
|
||||
<p class="fs-muted">{r.schedule_cron}</p>
|
||||
<div class="fs-row">
|
||||
<button class="fs-btn ghost" onClick={() => toggle(r.id, r.enabled)}>{r.enabled ? "Disable" : "Enable"}</button>
|
||||
<button class="fs-btn ghost" disabled={runNowId() === r.id}>Run</button>
|
||||
<button class="fs-btn danger" onClick={() => del(r.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</Show>
|
||||
<div class="fs-grid" style={{ "margin-top": "14px" }}>
|
||||
<div class="fs-card">
|
||||
<h3>Latest briefing</h3>
|
||||
<Show when={briefing()} fallback={<p class="fs-muted">No briefing yet — it generates after the first morning-briefing run.</p>}>
|
||||
<pre class="fs-muted" style={{ "white-space": "pre-wrap" }}>{(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>
|
||||
</div>
|
||||
);
|
||||
|
||||
+65
-32
@@ -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<string, unknown>;
|
||||
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<ScreenRow[]>([]);
|
||||
const [ran, setRan] = createSignal(false);
|
||||
const [busy, setBusy] = createSignal(false);
|
||||
const [err, setErr] = createSignal("");
|
||||
const loadSaved = (): Record<string, string> => {
|
||||
try { return JSON.parse(localStorage.getItem(SAVED_KEY) || "{}"); } catch { return {}; }
|
||||
@@ -16,14 +30,18 @@ export default function Screener() {
|
||||
const [saved, setSaved] = createSignal<Record<string, string>>(loadSaved());
|
||||
const [sname, setSname] = createSignal("");
|
||||
async function run() {
|
||||
setErr("");
|
||||
setErr(""); setBusy(true);
|
||||
try {
|
||||
const institutional: Record<string, unknown> = { 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 (
|
||||
<div>
|
||||
<h1>Institutional Screener</h1>
|
||||
<div class="fs-page-head"><h1>Institutional Screener</h1><p class="fs-muted">Rank tickers by smart-money signals.</p></div>
|
||||
<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 class="fs-row">
|
||||
<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={{ flex: 1, "min-width": "200px" }} />
|
||||
<button class="fs-btn" onClick={run} disabled={busy()}>{busy() ? " screening…" : "Screen"}</button>
|
||||
</div>
|
||||
<div class="fs-row" style={{ "margin-top": "10px" }}>
|
||||
<label class="fs-muted">broker ≥ <input class="fs-input" type="number" value={brokerMin()} onInput={(e) => setBrokerMin(Number(e.currentTarget.value))} style={{ width: "80px" }} /></label>
|
||||
<label class="fs-muted"><input type="checkbox" checked={foreignOnly()} onChange={(e) => setForeignOnly(e.currentTarget.checked)} /> foreign inflow</label>
|
||||
<label class="fs-muted"><input type="checkbox" checked={insiderOnly()} onChange={(e) => setInsiderOnly(e.currentTarget.checked)} /> insider buying</label>
|
||||
</div>
|
||||
<Show when={err()}><p style={{ color: "var(--fs-avoid)" }}>{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">
|
||||
<Show when={busy()}><div style={{ "margin-top": "14px" }}><Skeleton rows={3} /></div></Show>
|
||||
<Show when={ran() && !busy()}>
|
||||
<Show when={rows().length} fallback={<div style={{ "margin-top": "14px" }}><EmptyState icon="🔍" title="No matches" hint="Loosen the filters and try again." /></div>}>
|
||||
<div class="fs-grid" style={{ "margin-top": "14px" }}>
|
||||
<For each={rows()}>
|
||||
{(r) => (
|
||||
<div class="fs-card">
|
||||
<div class="fs-row" style={{ "justify-content": "space-between" }}>
|
||||
<strong><a href={`/report/${r.symbol}`}>{r.symbol}</a></strong>
|
||||
<span class="fs-muted">{r.composite.toFixed(1)}</span>
|
||||
</div>
|
||||
<div style={{ "margin": "8px 0" }}><BreakdownBars breakdown={r.breakdown as Record<string, unknown>} /></div>
|
||||
<Citations items={r.citations} />
|
||||
<Show when={scoreOf(r) !== 0}><div style={{ "margin-top": "6px" }}><RecBadge rec={scoreOf(r) >= 40 ? "BUY" : scoreOf(r) <= -40 ? "AVOID" : "HOLD"} /></div></Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={!ran() && !busy()}>
|
||||
<div style={{ "margin-top": "14px" }}><EmptyState icon="🔍" title="Run a screen" hint="Ranked rows with per-signal bars appear here." /></div>
|
||||
</Show>
|
||||
<div class="fs-card" style={{ "margin-top": "14px" }}>
|
||||
<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>
|
||||
<div class="fs-row">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+116
-19
@@ -1,38 +1,135 @@
|
||||
/* FlowSight design tokens: single source of truth for color, type, spacing. */
|
||||
/* FlowSight design tokens v2: light default, dark via [data-theme="dark"].
|
||||
Single source of truth for color, type, spacing. */
|
||||
:root {
|
||||
--fs-bg: #f6f8fb;
|
||||
--fs-surface: #ffffff;
|
||||
--fs-surface-2: #eef2f7;
|
||||
--fs-border: #dfe6ef;
|
||||
--fs-text: #0f1c2e;
|
||||
--fs-muted: #5b6b82;
|
||||
--fs-accent: #1d6ff2;
|
||||
--fs-accent-soft: #e3edff;
|
||||
--fs-buy: #15803d;
|
||||
--fs-buy-soft: #dcfce7;
|
||||
--fs-hold: #b45309;
|
||||
--fs-hold-soft: #fef3c7;
|
||||
--fs-avoid: #dc2626;
|
||||
--fs-avoid-soft: #fee2e2;
|
||||
--fs-stale: #7c3aed;
|
||||
--fs-font: "Inter", system-ui, -apple-system, sans-serif;
|
||||
--fs-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||
--fs-radius: 10px;
|
||||
--fs-gap: 14px;
|
||||
--fs-shadow: 0 1px 2px rgba(15, 28, 46, 0.06), 0 4px 16px rgba(15, 28, 46, 0.06);
|
||||
--fs-nav-w: 216px;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--fs-bg: #0d1117;
|
||||
--fs-surface: #161b22;
|
||||
--fs-surface-2: #1c2330;
|
||||
--fs-border: #30363d;
|
||||
--fs-text: #e6edf3;
|
||||
--fs-muted: #8b949e;
|
||||
--fs-accent: #2f81f7;
|
||||
--fs-accent-soft: #12233f;
|
||||
--fs-buy: #3fb950;
|
||||
--fs-buy-soft: #12291a;
|
||||
--fs-hold: #d29922;
|
||||
--fs-hold-soft: #2e230d;
|
||||
--fs-avoid: #f85149;
|
||||
--fs-avoid-soft: #341314;
|
||||
--fs-stale: #9e7bea;
|
||||
--fs-font: "Inter", system-ui, sans-serif;
|
||||
--fs-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
--fs-radius: 8px;
|
||||
--fs-gap: 12px;
|
||||
--fs-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--fs-bg); color: var(--fs-text); font-family: var(--fs-font); }
|
||||
a { color: var(--fs-accent); }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body { margin: 0; background: var(--fs-bg); color: var(--fs-text); font-family: var(--fs-font); font-size: 15px; line-height: 1.5; }
|
||||
a { color: var(--fs-accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* Layout */
|
||||
.fs-app { display: flex; min-height: 100vh; }
|
||||
.fs-nav { width: 200px; padding: 16px; border-right: 1px solid var(--fs-border); display: flex; flex-direction: column; gap: 8px; }
|
||||
.fs-nav a { padding: 8px 12px; border-radius: var(--fs-radius); text-decoration: none; color: var(--fs-text); }
|
||||
.fs-nav a.active { background: var(--fs-surface); }
|
||||
.fs-main { flex: 1; padding: 20px; max-width: 1200px; }
|
||||
.fs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--fs-gap); }
|
||||
.fs-card { background: var(--fs-surface); border: 1px solid var(--fs-border); border-radius: var(--fs-radius); padding: 14px; }
|
||||
.fs-nav { width: var(--fs-nav-w); flex-shrink: 0; padding: 16px 12px; border-right: 1px solid var(--fs-border); background: var(--fs-surface); display: flex; flex-direction: column; gap: 4px; position: sticky; top: 0; height: 100vh; }
|
||||
.fs-brand { font-weight: 800; font-size: 18px; padding: 4px 10px 12px; letter-spacing: -0.01em; }
|
||||
.fs-brand span { color: var(--fs-accent); }
|
||||
.fs-nav a { padding: 9px 12px; border-radius: var(--fs-radius); color: var(--fs-text); font-weight: 500; display: flex; gap: 10px; align-items: center; }
|
||||
.fs-nav a:hover { background: var(--fs-surface-2); text-decoration: none; }
|
||||
.fs-nav a.active { background: var(--fs-accent-soft); color: var(--fs-accent); font-weight: 700; }
|
||||
.fs-nav .fs-nav-foot { margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
|
||||
.fs-body { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.fs-topbar { display: flex; gap: 10px; align-items: center; padding: 12px 20px; border-bottom: 1px solid var(--fs-border); background: var(--fs-surface); position: sticky; top: 0; z-index: 10; }
|
||||
.fs-topbar .fs-search { flex: 1; max-width: 420px; display: flex; gap: 8px; }
|
||||
.fs-topbar .fs-spacer { flex: 1; }
|
||||
.fs-main { flex: 1; padding: 22px; max-width: 1280px; width: 100%; margin: 0 auto; }
|
||||
.fs-side { width: 320px; flex-shrink: 0; padding: 22px 18px 22px 0; display: flex; flex-direction: column; gap: var(--fs-gap); }
|
||||
.fs-page-head { display: flex; align-items: baseline; gap: 12px; margin: 2px 0 16px; flex-wrap: wrap; }
|
||||
.fs-page-head h1 { font-size: 24px; margin: 0; letter-spacing: -0.02em; }
|
||||
.fs-page-head p { margin: 0; color: var(--fs-muted); }
|
||||
|
||||
/* Cards, grid, stats */
|
||||
.fs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: var(--fs-gap); }
|
||||
.fs-card { background: var(--fs-surface); border: 1px solid var(--fs-border); border-radius: var(--fs-radius); padding: 16px; box-shadow: var(--fs-shadow); }
|
||||
.fs-card h3 { margin: 0 0 10px; font-size: 14px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--fs-muted); }
|
||||
.fs-stat { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; margin: 2px 0; font-variant-numeric: tabular-nums; }
|
||||
.fs-muted { color: var(--fs-muted); font-size: 13px; }
|
||||
.fs-cite { display: inline-block; font-family: var(--fs-mono); font-size: 11px; border: 1px solid var(--fs-border); border-radius: 4px; padding: 1px 6px; margin: 2px 4px 2px 0; }
|
||||
.fs-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.fs-stack { display: flex; flex-direction: column; gap: var(--fs-gap); }
|
||||
|
||||
/* Badges, signals */
|
||||
.fs-badge { display: inline-block; font-size: 12px; font-weight: 700; padding: 2px 10px; border-radius: 999px; }
|
||||
.fs-badge.buy { background: var(--fs-buy-soft); color: var(--fs-buy); }
|
||||
.fs-badge.hold { background: var(--fs-hold-soft); color: var(--fs-hold); }
|
||||
.fs-badge.avoid { background: var(--fs-avoid-soft); color: var(--fs-avoid); }
|
||||
.fs-badge.info { background: var(--fs-accent-soft); color: var(--fs-accent); }
|
||||
.fs-sig { display: grid; grid-template-columns: 110px 1fr 52px; gap: 8px; align-items: center; margin: 6px 0; font-size: 13px; }
|
||||
.fs-sig .bar { height: 8px; border-radius: 99px; background: var(--fs-surface-2); position: relative; overflow: hidden; }
|
||||
.fs-sig .bar i { position: absolute; top: 0; bottom: 0; border-radius: 99px; background: var(--fs-accent); }
|
||||
.fs-sig .bar i.neg { background: var(--fs-avoid); }
|
||||
.fs-sig .bar i.pos { background: var(--fs-buy); }
|
||||
.fs-sig b { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Citations */
|
||||
.fs-cite { display: inline-block; font-family: var(--fs-mono); font-size: 11px; border: 1px solid var(--fs-border); border-radius: 6px; padding: 1px 7px; margin: 2px 4px 2px 0; background: var(--fs-surface-2); }
|
||||
.fs-cite.stale { border-color: var(--fs-stale); color: var(--fs-stale); }
|
||||
|
||||
/* Tables, forms */
|
||||
.fs-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.fs-table th, .fs-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--fs-border); }
|
||||
.fs-input, .fs-btn { font: inherit; padding: 8px 12px; border-radius: var(--fs-radius); border: 1px solid var(--fs-border); }
|
||||
.fs-input { background: var(--fs-bg); color: var(--fs-text); }
|
||||
.fs-btn { background: var(--fs-accent); color: #fff; cursor: pointer; border: none; }
|
||||
.fs-btn.ghost { background: transparent; border: 1px solid var(--fs-border); color: var(--fs-text); }
|
||||
.fs-table th, .fs-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--fs-border); }
|
||||
.fs-table th { color: var(--fs-muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.fs-table tr:hover td { background: var(--fs-surface-2); }
|
||||
.fs-input, .fs-btn, select.fs-input { font: inherit; padding: 9px 13px; border-radius: var(--fs-radius); border: 1px solid var(--fs-border); }
|
||||
.fs-input { background: var(--fs-surface); color: var(--fs-text); min-width: 0; }
|
||||
.fs-input:focus { outline: 2px solid var(--fs-accent); outline-offset: -1px; border-color: var(--fs-accent); }
|
||||
.fs-btn { background: var(--fs-accent); color: #fff; cursor: pointer; border: 1px solid transparent; font-weight: 600; }
|
||||
.fs-btn:hover { filter: brightness(1.07); }
|
||||
.fs-btn.ghost { background: transparent; border-color: var(--fs-border); color: var(--fs-text); }
|
||||
.fs-btn.danger { background: transparent; border-color: var(--fs-avoid); color: var(--fs-avoid); }
|
||||
.fs-btn:disabled { opacity: 0.55; cursor: wait; }
|
||||
.fs-scroll { overflow-x: auto; }
|
||||
.fs-avatar { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; }
|
||||
|
||||
/* Skeletons, empty */
|
||||
.fs-sk { border-radius: 8px; background: linear-gradient(90deg, var(--fs-surface-2) 25%, var(--fs-border) 50%, var(--fs-surface-2) 75%); background-size: 200% 100%; animation: fs-sh 1.2s infinite; min-height: 18px; }
|
||||
@keyframes fs-sh { to { background-position: -200% 0; } }
|
||||
.fs-empty { text-align: center; padding: 34px 16px; color: var(--fs-muted); }
|
||||
.fs-empty .big { font-size: 30px; margin-bottom: 6px; }
|
||||
|
||||
.rec-BUY { color: var(--fs-buy); } .rec-HOLD { color: var(--fs-hold); } .rec-AVOID { color: var(--fs-avoid); }
|
||||
|
||||
/* Theme toggle + user chip */
|
||||
.fs-iconbtn { background: transparent; border: 1px solid var(--fs-border); color: var(--fs-text); border-radius: var(--fs-radius); padding: 8px 11px; cursor: pointer; font: inherit; }
|
||||
.fs-iconbtn:hover { background: var(--fs-surface-2); }
|
||||
.fs-user { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; }
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 1080px) {
|
||||
.fs-side { display: none; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.fs-nav { position: fixed; z-index: 20; transform: translateX(-100%); transition: transform 0.2s; box-shadow: var(--fs-shadow); }
|
||||
.fs-nav.open { transform: none; }
|
||||
.fs-main { padding: 14px; }
|
||||
.fs-topbar { padding: 10px 12px; }
|
||||
#fs-menu-btn { display: inline-block !important; }
|
||||
}
|
||||
#fs-menu-btn { display: none; }
|
||||
Reference in new issue
Block a user