diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a4d699a --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.local +.DS_Store diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..9a19dc5 --- /dev/null +++ b/web/README.md @@ -0,0 +1,159 @@ +# Azoth Finance — web dashboard + +A Google-Finance-style web dashboard for Azoth's Vietnam-market data (HOSE/HNX/UPCOM). +It reuses Azoth's existing data layer (`src/data/sources/**`, DNSE/SSI/VNDirect/CafeF) +through a thin Node API server, and renders a React frontend that clones Google +Finance's information architecture and visual language. The UI defaults to the +**light** theme (dark is available via the theme toggle; tokens flip on `[data-theme]`). + +## What's here + +- **`server/index.ts`** — a dependency-light Node `http` API server. It imports Azoth's + `src/**` data clients directly and exposes JSON under `/api/*`. Data is TTL-cached via + Azoth's SQLite cache (`~/.azoth/azoth.db`). No secrets required — all sources are public. +- **`server/store.ts`** — the SQLite persistence layer for user-managed watchlists and manual + portfolios. Tables are prefixed `web_*` and share the same better-sqlite3 handle as the rest + of Azoth; `ensureWebSchema()` creates them (and seeds a default watchlist) at server startup. +- **`src/`** — Vite + React + TypeScript frontend. + - `pages/Home.tsx` — a row of **compact index cards** (small name, medium value, % badge, + sparkline), a **Discover** strip (bluechip/sector shortcuts), and a **market summary** + news block. Movers no longer live here — they moved to the **`/markets`** trends page. + - `pages/Quote.tsx` — price header (with **add-to-list**), interactive chart, then + **Overview / Financials / News tabs**. + - `pages/WatchlistPage.tsx` — a single user watchlist: live quote rows with add/remove a + symbol, rename, and delete the list. + - `pages/Portfolio.tsx` — a manually-entered portfolio (Google-Finance style, **not** + broker-linked): holdings table with per-position and total gain/loss, day change, and weights; + add/edit/remove holdings; create/rename/delete and switch between multiple portfolios. + - `pages/MarketTrends.tsx` — market-trends tabs (Active / Gainers / Losers / Indexes) with a + Liquid/VN30 universe toggle. + - `components/` — TopNav (search + theme toggle), Sidebar (persistent watchlists + portfolios + with inline add/remove, plus a **SectorRail** mini index-list of stock sectors), + ResearchPanel, MarketStrip (compact IndexCards), MarketSummary (home news block), + NewsList, QuoteHeader (perf summary line + + add-to-list popover), DiscoverStrip, PriceChart (lightweight-charts, with **dropdown** + chart-type / indicators / compare menus above a range-button row, and a **Compare** + multi-ticker % overlay), StatsGrid (dense label→value grid), RelatedStocks, AboutCompany, + FinancialsTab (annual key metrics + chart), PortfolioSummary / HoldingsTable / AddHoldingForm, + plus Sparkline + ChangeBadge primitives. + - `lib/` — the shared API contract (`types.ts`), formatters (`format.ts`), fetch client (`api.ts`), + and `userData.tsx` (a `UserDataProvider` + `useWatchlists()` / `usePortfolios()` hooks that + hold watchlist/portfolio state for the sidebar and pages). + - `index.css` — the design system (Google-Finance-style tokens; **light theme by default**, + dark via `[data-theme="dark"]`). + +## Features + +- **Persistent watchlists** — create multiple named lists, add/remove symbols from the sidebar, + the watchlist page, or the stock header's add-to-list popover. Lists persist across restarts. +- **Manual portfolios** — track holdings (quantity + average cost) entered by hand and see live + market value, total and per-position gain/loss, day change, and weights. Portfolios are + **manual-entry only** and are not linked to any broker account. +- **Market trends** — dedicated pages for the most active stocks, top gainers/losers, and the + market indices, with a liquid/VN30 universe toggle. +- **Add-to-list from the stock header** and a **Discover** strip on the home page for quick + navigation into sectors and bluechips. + +Watchlists and portfolios are persisted in the shared Azoth SQLite database (`~/.azoth/azoth.db`) +under `web_*` tables — no separate datastore, no broker credentials. + +## Prerequisites + +Install the **Azoth root** dependencies first (the server imports `../src`): + +```bash +# from the repo root +pnpm install +``` + +Then install the web app's own dependencies: + +```bash +# from web/ +pnpm install +``` + +## Run + +```bash +# from web/ — starts the API server (:8787) and Vite (:5273) together +pnpm dev +``` + +Open http://localhost:5273. Vite proxies `/api` → `http://localhost:8787`. + +Run the pieces individually if you prefer: + +```bash +pnpm server # API server only (:8787) +pnpm client # Vite dev server only (:5273) +``` + +## Build + +```bash +pnpm build # tsc + vite build → dist/ +pnpm typecheck # type-check the frontend +``` + +## API endpoints + +| Endpoint | Description | +| --- | --- | +| `GET /api/indices` | VN-Index, VN30, HNX-Index, HNX30, UPCOM with sparklines | +| `GET /api/movers?kind=gainers\|losers\|active&universe=vn30` | Top movers (used by `/markets`) | +| `GET /api/sectors` | Stock sectors for the sidebar rail: per-sector avg daily % change, a synthetic rebased-index sparkline, and top leaders, sorted by % change | +| `GET /api/watchlist` | Sidebar watchlist rows | +| `GET /api/market-news` | Aggregated market news feed (home market summary) | +| `GET /api/search?q=` | Ticker/name search | +| `GET /api/quote/:ticker` | Quote header + stats (incl. `change_pct_1m` / `_3m` / `_ytd` perf fields) | +| `GET /api/ohlcv/:ticker?range=1D..MAX` | Candles/area chart data | +| `GET /api/indicators/:ticker?range=` | SMA/EMA/Bollinger/RSI/MACD | +| `GET /api/news/:ticker` | Ticker news | +| `GET /api/about/:ticker` | Company profile + related stocks | +| `GET /api/financials/:ticker?period=annual` | Annual key metrics (EPS, BVPS, ROE, ROA, margins, P/E) from CafeF | + +### Persistent (SQLite-backed `web_*` tables) + +User watchlists: + +| Endpoint | Description | +| --- | --- | +| `GET /api/watchlists` | All watchlists (id, name, tickers) | +| `POST /api/watchlists` `{name}` | Create a watchlist | +| `GET /api/watchlists/:id` | One watchlist with live quote rows | +| `PATCH /api/watchlists/:id` `{name}` | Rename a watchlist | +| `DELETE /api/watchlists/:id` | Delete a watchlist | +| `POST /api/watchlists/:id/items` `{ticker}` | Add a symbol | +| `DELETE /api/watchlists/:id/items/:ticker` | Remove a symbol | + +Manual portfolios (not broker-linked): + +| Endpoint | Description | +| --- | --- | +| `GET /api/portfolios` | All portfolios (id, name) | +| `POST /api/portfolios` `{name}` | Create a portfolio | +| `GET /api/portfolios/:id` | One portfolio with computed holdings + totals | +| `PATCH /api/portfolios/:id` `{name}` | Rename a portfolio | +| `DELETE /api/portfolios/:id` | Delete a portfolio | +| `POST /api/portfolios/:id/holdings` `{ticker,quantity,avgCostVnd}` | Add a holding | +| `PATCH /api/holdings/:id` `{quantity?,avgCostVnd?}` | Edit a holding | +| `DELETE /api/holdings/:id` | Remove a holding | + +## Notes on units (Vietnam market) + +- Board prices (last/ref/ceiling/floor/open/high/low/EPS/BVPS) are in **thousand VND** + (28.5 = 28,500 VND). The frontend formatters scale to full VND (`28,500 ₫`). +- SSI iBoard returns ref/ceiling/floor in **plain VND**; the server normalizes them to + board units (÷1000) so everything shares one scale. +- Market cap and portfolio money aggregates (market value, cost basis, gain, day change — every + field ending in `Vnd`) are in **plain VND**; volume is in shares. Portfolio holding cost + (`avgCostVnd`) is entered and stored as plain VND per share (a buy at 64,800 ₫ is `64800`). +- The intraday chart shifts timestamps +7h so the axis shows Vietnam exchange time (ICT). + +## Scope + +This is a data/visualization surface. The "Research" (AI) panel is a visual preview — +Azoth's AI analyst, ordering, backtesting, and broker workflows live in the terminal CLI. +The Portfolio page is **manual entry** (holdings you type in, priced with live market data); +broker-linked positions and cash (which need broker auth) are intentionally not wired here. diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..d7eef12 --- /dev/null +++ b/web/index.html @@ -0,0 +1,33 @@ + + + + + + + + + + Azoth Finance + + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..f3ced1c --- /dev/null +++ b/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "@toreleon/azoth-web", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Google-Finance-style web dashboard for Azoth (Vietnam market data)", + "scripts": { + "dev": "concurrently -n server,web -c cyan,magenta \"node --import tsx server/index.ts\" \"vite\"", + "server": "node --import tsx server/index.ts", + "server:watch": "tsx watch server/index.ts", + "client": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit" + }, + "dependencies": { + "lightweight-charts": "^4.2.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "technicalindicators": "^3.1.0" + }, + "devDependencies": { + "@types/node": "^22.7.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.1.0", + "tsx": "^4.19.1", + "typescript": "^5.6.3", + "vite": "^5.4.11" + }, + "pnpm": { + "onlyBuiltDependencies": ["esbuild"] + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..b57d1f7 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,1631 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + lightweight-charts: + specifier: ^4.2.3 + version: 4.2.3 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.28.0 + version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + technicalindicators: + specifier: ^3.1.0 + version: 3.1.0 + devDependencies: + '@types/node': + specifier: ^22.7.0 + version: 22.20.1 + '@types/react': + specifier: ^18.3.12 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.31) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@5.4.21(@types/node@22.20.1)) + concurrently: + specifier: ^9.1.0 + version: 9.2.4 + tsx: + specifier: ^4.19.1 + version: 4.23.1 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vite: + specifier: ^5.4.11 + version: 5.4.21(@types/node@22.20.1) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@remix-run/router@1.23.3': + resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==} + engines: {node: '>=14.0.0'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/node@6.14.13': + resolution: {integrity: sha512-J1F0XJ/9zxlZel5ZlbeSuHW2OpabrUAqpFuC2sm2I3by8sERQ8+KCjNKUcq8QHuzpGMWiJpo9ZxeHrqrP2KzQw==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + baseline-browser-mapping@2.11.1: + resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concurrently@9.2.4: + resolution: {integrity: sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==} + engines: {node: '>=18'} + hasBin: true + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + electron-to-chromium@1.5.395: + resolution: {integrity: sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fancy-canvas@2.1.0: + resolution: {integrity: sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightweight-charts@4.2.3: + resolution: {integrity: sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.22: + resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@6.30.4: + resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.4: + resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + shell-quote@1.9.0: + resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + technicalindicators@3.1.0: + resolution: {integrity: sha512-f16mOc+Y05hNy/of+UbGxhxQQmxUztCiluhsqC5QLUYz4WowUgKde9m6nIjK1Kay0wGHigT0IkOabpp0+22UfA==} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@remix-run/router@1.23.3': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.9': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/node@6.14.13': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.1))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21(@types/node@22.20.1) + transitivePeerDependencies: + - supports-color + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + baseline-browser-mapping@2.11.1: {} + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.1 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.395 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + caniuse-lite@1.0.30001806: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concurrently@9.2.4: + dependencies: + chalk: 4.1.2 + rxjs: 7.8.2 + shell-quote: 1.9.0 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + electron-to-chromium@1.5.395: {} + + emoji-regex@8.0.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escalade@3.2.0: {} + + fancy-canvas@2.1.0: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + has-flag@4.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightweight-charts@4.2.3: + dependencies: + fancy-canvas: 2.1.0 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + node-releases@2.0.51: {} + + picocolors@1.1.1: {} + + postcss@8.5.22: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-refresh@0.17.0: {} + + react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.4(react@18.3.1) + + react-router@6.30.4(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.3 + react: 18.3.1 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + require-directory@2.1.1: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + shell-quote@1.9.0: {} + + source-map-js@1.2.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + technicalindicators@3.1.0: + dependencies: + '@types/node': 6.14.13 + + tree-kill@1.2.2: {} + + tslib@2.8.1: {} + + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite@5.4.21(@types/node@22.20.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.22 + rollup: 4.62.2 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 diff --git a/web/server/index.ts b/web/server/index.ts new file mode 100644 index 0000000..fd2de9f --- /dev/null +++ b/web/server/index.ts @@ -0,0 +1,1150 @@ +/** + * Azoth Finance — data server. + * + * A dependency-light Node http server that reuses Azoth's existing Vietnam-market + * data layer (src/data/sources/**, src/tools/**) and exposes it as JSON under /api + * for the React dashboard. Run with: pnpm server (tsx watch server/index.ts) + * + * All prices from DNSE/SSI are in THOUSAND VND (28.5 = 28,500 VND); we pass them + * through unscaled and let the frontend format. Market cap is plain VND. + */ +import http from "node:http"; +import { RSI, MACD, SMA, EMA, BollingerBands } from "technicalindicators"; + +import { cached } from "../../src/data/cache.js"; +import { getDb } from "../../src/storage/db.js"; +import { + getStockOhlcv, + getIndexOhlcv, + type Bar, + type Resolution, +} from "../../src/data/sources/dnsePublic.js"; +import { getQuote } from "../../src/data/sources/ssiIboard.js"; +import { + getRatio, + RATIOS, + getCompanyProfile, + getCompanyProfilesByFloor, + type ListedExchange, +} from "../../src/data/sources/vndirectFinfo.js"; +import { + getTickerNews, + getCompanyIntro, + getFinancialRatios, +} from "../../src/data/sources/cafef.js"; +import { TICKER_UNIVERSES } from "../../src/tools/discover.js"; +import { + ensureWebSchema, + listWatchlists, + getWatchlistMeta, + createWatchlist, + renameWatchlist, + deleteWatchlist, + addWatchlistItem, + removeWatchlistItem, + listPortfolios, + getPortfolio, + createPortfolio, + renamePortfolio, + deletePortfolio, + getHoldings, + addHolding, + updateHolding, + deleteHolding, +} from "./store.js"; + +const PORT = Number(process.env.AZOTH_WEB_PORT ?? 8787); +const DAY = 86400; + +function nowSec(): number { + return Math.floor(Date.now() / 1000); +} + +// --------------------------------------------------------------------------- +// Small utilities +// --------------------------------------------------------------------------- + +async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const out: R[] = new Array(items.length); + let i = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (i < items.length) { + const idx = i++; + out[idx] = await fn(items[idx]!); + } + }); + await Promise.all(workers); + return out; +} + +function pct(now: number | undefined, prev: number | undefined): number | null { + if (now == null || prev == null || prev === 0) return null; + return ((now - prev) / prev) * 100; +} + +/** + * SSI iBoard returns ref/ceiling/floor in plain VND (e.g. 64600), whereas DNSE + * OHLCV closes are in THOUSAND VND (64.8). Normalize SSI values to board units + * (thousand VND) so everything downstream shares one scale. + */ +function ssiBoard(v: number | null | undefined): number | null { + return v != null && Number.isFinite(v) && v > 0 ? v / 1000 : null; +} + +function round(v: number | null | undefined, digits = 2): number | null { + if (v == null || !Number.isFinite(v)) return null; + const f = 10 ** digits; + return Math.round(v * f) / f; +} + +// --------------------------------------------------------------------------- +// Market session (Vietnam / ICT, UTC+7) +// --------------------------------------------------------------------------- + +type MarketState = + | "pre-open" + | "morning" + | "lunch" + | "afternoon" + | "atc" + | "after-hours" + | "weekend"; + +function marketSession(): { session: MarketState; isOpen: boolean } { + const ict = new Date(Date.now() + 7 * 3600 * 1000); + const day = ict.getUTCDay(); // 0 Sun .. 6 Sat + const minutes = ict.getUTCHours() * 60 + ict.getUTCMinutes(); + if (day === 0 || day === 6) return { session: "weekend", isOpen: false }; + if (minutes < 9 * 60) return { session: "pre-open", isOpen: false }; + if (minutes < 11 * 60 + 30) return { session: "morning", isOpen: true }; + if (minutes < 13 * 60) return { session: "lunch", isOpen: false }; + if (minutes < 14 * 60 + 30) return { session: "afternoon", isOpen: true }; + if (minutes < 14 * 60 + 45) return { session: "atc", isOpen: true }; + return { session: "after-hours", isOpen: false }; +} + +// --------------------------------------------------------------------------- +// Core data helpers (cached) +// --------------------------------------------------------------------------- + +/** Daily bars for a stock over `days`, cached (10 min TTL). */ +async function dailyBars(ticker: string, days: number): Promise { + const to = nowSec(); + const from = to - days * DAY; + const bucket = new Date(to * 1000).toISOString().slice(0, 10); + const key = `web:daily:${ticker}:${days}:${bucket}`; + return cached(key, 600, () => getStockOhlcv(ticker, "1D", from, to)); +} + +/** Daily bars for an index over `days`, cached. */ +async function dailyIndexBars(symbol: string, days: number): Promise { + const to = nowSec(); + const from = to - days * DAY; + const bucket = new Date(to * 1000).toISOString().slice(0, 10); + const key = `web:idxdaily:${symbol}:${days}:${bucket}`; + return cached(key, 300, () => getIndexOhlcv(symbol, "1D", from, to)); +} + +interface MiniDigest { + ticker: string; + last: number | null; + prevClose: number | null; + changeAbs: number | null; + changePct: number | null; + spark: number[]; + volume: number | null; +} + +/** Last close, prev close, change, and a 30-point sparkline for a stock. */ +async function miniDigest(ticker: string): Promise { + const bars = await dailyBars(ticker, 60).catch(() => [] as Bar[]); + if (!bars.length) { + return { ticker, last: null, prevClose: null, changeAbs: null, changePct: null, spark: [], volume: null }; + } + const last = bars[bars.length - 1]!; + const prev = bars[bars.length - 2]; + const changeAbs = prev ? last.close - prev.close : null; + return { + ticker, + last: last.close, + prevClose: prev?.close ?? null, + changeAbs: changeAbs == null ? null : round(changeAbs, 3), + changePct: round(pct(last.close, prev?.close), 2), + spark: bars.slice(-30).map((b) => b.close), + volume: last.volume, + }; +} + +const INDEX_NAMES: Record = { + VNINDEX: "VN-Index", + VN30: "VN30", + HNX: "HNX-Index", + HNX30: "HNX30", + UPCOM: "UPCOM", +}; + +async function indexDigest(symbol: string) { + const bars = await dailyIndexBars(symbol, 60).catch(() => [] as Bar[]); + if (!bars.length) return null; + const last = bars[bars.length - 1]!; + const prev1 = bars[bars.length - 2]; + const prev5 = bars[bars.length - 6]; + const prev22 = bars[bars.length - 23]; + return { + symbol, + name: INDEX_NAMES[symbol] ?? symbol, + latest_close: round(last.close, 2)!, + latest_time: new Date(last.time * 1000).toISOString(), + change_abs: prev1 ? round(last.close - prev1.close, 2) : null, + change_pct_1d: round(pct(last.close, prev1?.close), 2), + change_pct_1w: round(pct(last.close, prev5?.close), 2), + change_pct_1m: round(pct(last.close, prev22?.close), 2), + spark: bars.slice(-30).map((b) => b.close), + }; +} + +// --------------------------------------------------------------------------- +// Ticker → name index (for search + labels), cached 24h +// --------------------------------------------------------------------------- + +interface NameEntry { + ticker: string; + name: string; + exchange: string; +} + +async function nameIndex(): Promise { + return cached("web:nameindex:v1", 24 * 3600, async () => { + const floors: ListedExchange[] = ["HOSE", "HNX", "UPCOM"]; + const chunks = await Promise.all( + floors.map((f) => getCompanyProfilesByFloor(f, 1000).catch(() => [])), + ); + const seen = new Set(); + const out: NameEntry[] = []; + for (const chunk of chunks) { + for (const p of chunk) { + const code = (p.code ?? "").toUpperCase(); + if (!/^[A-Z]{3}$/.test(code) || seen.has(code)) continue; + seen.add(code); + out.push({ ticker: code, name: p.enName || p.vnName || code, exchange: p.floor ?? "" }); + } + } + return out; + }); +} + +async function nameFor(ticker: string): Promise { + const idx = await nameIndex().catch(() => [] as NameEntry[]); + return idx.find((e) => e.ticker === ticker)?.name; +} + +// --------------------------------------------------------------------------- +// Range → resolution mapping for the chart +// --------------------------------------------------------------------------- + +type RangeKey = "1D" | "5D" | "1M" | "6M" | "YTD" | "1Y" | "5Y" | "MAX"; + +function rangeToPlan(range: RangeKey): { resolution: Resolution; fromSec: number; intraday: boolean } { + const to = nowSec(); + switch (range) { + case "1D": + return { resolution: "1", fromSec: to - 5 * DAY, intraday: true }; + case "5D": + return { resolution: "15", fromSec: to - 9 * DAY, intraday: true }; + case "1M": + return { resolution: "1H", fromSec: to - 40 * DAY, intraday: true }; + case "6M": + return { resolution: "1D", fromSec: to - 200 * DAY, intraday: false }; + case "YTD": { + const jan1 = Date.UTC(new Date().getUTCFullYear(), 0, 1) / 1000; + return { resolution: "1D", fromSec: jan1, intraday: false }; + } + case "1Y": + return { resolution: "1D", fromSec: to - 400 * DAY, intraday: false }; + case "5Y": + return { resolution: "1W", fromSec: to - 5 * 380 * DAY, intraday: false }; + case "MAX": + return { resolution: "1M", fromSec: to - 30 * 380 * DAY, intraday: false }; + } +} + +/** For a 1D range, keep only bars from the latest calendar day present. */ +function lastSessionOnly(bars: Bar[]): Bar[] { + if (!bars.length) return bars; + const lastDay = new Date(bars[bars.length - 1]!.time * 1000).toISOString().slice(0, 10); + return bars.filter((b) => new Date(b.time * 1000).toISOString().slice(0, 10) === lastDay); +} + +// --------------------------------------------------------------------------- +// Indicators +// --------------------------------------------------------------------------- + +function alignLine(values: number[], out: number[], bars: Bar[]) { + const offset = values.length - out.length; + return out + .map((v, k) => ({ time: bars[offset + k]!.time, value: round(v, 3)! })) + .filter((p) => p.value != null && Number.isFinite(p.value)); +} + +function computeIndicators(bars: Bar[]) { + const closes = bars.map((b) => b.close); + const empty = { sma20: [], sma50: [], ema20: [], bollinger: [], rsi14: [], macd: [] }; + if (closes.length < 20) return empty; + + const sma20 = closes.length >= 20 ? alignLine(closes, SMA.calculate({ period: 20, values: closes }), bars) : []; + const sma50 = closes.length >= 50 ? alignLine(closes, SMA.calculate({ period: 50, values: closes }), bars) : []; + const ema20 = closes.length >= 20 ? alignLine(closes, EMA.calculate({ period: 20, values: closes }), bars) : []; + + const bbRaw = closes.length >= 20 ? BollingerBands.calculate({ period: 20, stdDev: 2, values: closes }) : []; + const bbOffset = closes.length - bbRaw.length; + const bollinger = bbRaw.map((b, k) => ({ + time: bars[bbOffset + k]!.time, + upper: round(b.upper, 3)!, + middle: round(b.middle, 3)!, + lower: round(b.lower, 3)!, + })); + + const rsiRaw = closes.length >= 15 ? RSI.calculate({ period: 14, values: closes }) : []; + const rsi14 = alignLine(closes, rsiRaw, bars); + + const macdRaw = + closes.length >= 35 + ? MACD.calculate({ + values: closes, + fastPeriod: 12, + slowPeriod: 26, + signalPeriod: 9, + SimpleMAOscillator: false, + SimpleMASignal: false, + }) + : []; + const macdOffset = closes.length - macdRaw.length; + const macd = macdRaw + .map((m, k) => ({ + time: bars[macdOffset + k]!.time, + macd: round(m.MACD, 3), + signal: round(m.signal, 3), + histogram: round(m.histogram, 3), + })) + .filter((m) => m.macd != null && m.signal != null) as { + time: number; + macd: number; + signal: number; + histogram: number; + }[]; + + return { sma20, sma50, ema20, bollinger, rsi14, macd }; +} + +// --------------------------------------------------------------------------- +// Endpoint handlers +// --------------------------------------------------------------------------- + +async function handleIndices() { + const symbols = ["VNINDEX", "VN30", "HNX", "HNX30", "UPCOM"]; + const snaps = await Promise.all(symbols.map(indexDigest)); + return { indices: snaps.filter(Boolean), asOf: new Date().toISOString() }; +} + +function resolveUniverse(universe: string): string[] { + const u = universe.toLowerCase(); + const known = TICKER_UNIVERSES as Record; + if (known[u]) return [...known[u]!]; + if (u === "vn30" && known.vn30) return [...known.vn30]; + return [...(known.default ?? [])]; +} + +async function handleMovers(kind: string, universe: string) { + const tickers = resolveUniverse(universe); + const digests = (await mapLimit(tickers, 8, miniDigest)).filter((d) => d.changePct != null); + const names = await nameIndex().catch(() => [] as NameEntry[]); + const nameMap = new Map(names.map((n) => [n.ticker, n.name])); + + let sorted: MiniDigest[]; + if (kind === "losers") sorted = digests.sort((a, b) => (a.changePct ?? 0) - (b.changePct ?? 0)); + else if (kind === "active") sorted = digests.sort((a, b) => (b.volume ?? 0) - (a.volume ?? 0)); + else sorted = digests.sort((a, b) => (b.changePct ?? 0) - (a.changePct ?? 0)); + + const rows = sorted.slice(0, 12).map((d) => ({ + ticker: d.ticker, + name: nameMap.get(d.ticker), + last: d.last, + change_pct: d.changePct, + ret_1w_pct: null, + ret_1m_pct: null, + spark: d.spark, + })); + return { kind, universe, rows }; +} + +async function handleWatchlist() { + const tickers = ["VCB", "FPT", "HPG", "VNM", "VHM", "VIC", "MWG", "GAS", "MBB", "MSN"]; + const digests = await mapLimit(tickers, 8, miniDigest); + const names = await nameIndex().catch(() => [] as NameEntry[]); + const nameMap = new Map(names.map((n) => [n.ticker, n.name])); + const rows = digests.map((d) => ({ + ticker: d.ticker, + name: nameMap.get(d.ticker), + last: d.last, + change_abs: d.changeAbs, + change_pct: d.changePct, + spark: d.spark, + })); + return { title: "Watchlist", rows }; +} + +// --------------------------------------------------------------------------- +// User watchlists (persistent) & portfolios +// --------------------------------------------------------------------------- + +/** Build WatchRow-shaped rows ({ticker,name,last,change_abs,change_pct,spark}). */ +async function watchRowsFor(tickers: string[]) { + const digests = await mapLimit(tickers, 8, miniDigest); + const names = await nameIndex().catch(() => [] as NameEntry[]); + const nameMap = new Map(names.map((n) => [n.ticker, n.name])); + return digests.map((d) => ({ + ticker: d.ticker, + name: nameMap.get(d.ticker), + last: d.last, + change_abs: d.changeAbs, + change_pct: d.changePct, + spark: d.spark, + })); +} + +async function handleWatchlistDetail(id: number) { + const meta = getWatchlistMeta(id); + if (!meta) throw new HttpError(404, `watchlist ${id} not found`); + const rows = meta.tickers.length ? await watchRowsFor(meta.tickers) : []; + return { id: meta.id, name: meta.name, rows }; +} + +async function handlePortfolioResponse(id: number) { + const meta = getPortfolio(id); + if (!meta) throw new HttpError(404, `portfolio ${id} not found`); + const holdings = getHoldings(id); + if (!holdings.length) { + return { + id: meta.id, + name: meta.name, + holdings: [] as unknown[], + totals: { + marketValueVnd: 0, + costBasisVnd: 0, + gainVnd: 0, + gainPct: null, + dayChangeVnd: 0, + dayChangePct: null, + }, + }; + } + + const distinct = [...new Set(holdings.map((h) => h.ticker))]; + const digests = await mapLimit(distinct, 8, miniDigest); + const digestMap = new Map(digests.map((d) => [d.ticker, d])); + const names = await nameIndex().catch(() => [] as NameEntry[]); + const nameMap = new Map(names.map((n) => [n.ticker, n.name])); + + // First pass: per-holding raw values (market value drives the weight denominator). + const computed = holdings.map((h) => { + const d = digestMap.get(h.ticker); + const last = d?.last ?? null; + const changeAbs = d?.changeAbs ?? null; + const marketValueVnd = last != null ? h.quantity * last * 1000 : null; + const costBasisVnd = h.quantity * h.avgCostVnd; + const gainVnd = marketValueVnd != null ? marketValueVnd - costBasisVnd : null; + const gainPct = gainVnd != null && costBasisVnd > 0 ? (gainVnd / costBasisVnd) * 100 : null; + const dayChangeVnd = changeAbs != null ? h.quantity * changeAbs * 1000 : null; + return { h, d, last, marketValueVnd, costBasisVnd, gainVnd, gainPct, dayChangeVnd }; + }); + + const totalMV = computed.reduce((s, c) => s + (c.marketValueVnd ?? 0), 0); + + const holdingRows = computed.map((c) => ({ + id: c.h.id, + ticker: c.h.ticker, + name: nameMap.get(c.h.ticker), + quantity: c.h.quantity, + avgCostVnd: c.h.avgCostVnd, + last: c.last == null ? null : round(c.last, 2), + change_pct: c.d?.changePct ?? null, + marketValueVnd: c.marketValueVnd == null ? null : round(c.marketValueVnd, 0), + costBasisVnd: round(c.costBasisVnd, 0)!, + gainVnd: c.gainVnd == null ? null : round(c.gainVnd, 0), + gainPct: round(c.gainPct, 2), + dayChangeVnd: c.dayChangeVnd == null ? null : round(c.dayChangeVnd, 0), + weightPct: + c.marketValueVnd != null && totalMV > 0 ? round((c.marketValueVnd / totalMV) * 100, 2) : null, + spark: c.d?.spark ?? [], + })); + + const totalCost = computed.reduce((s, c) => s + c.costBasisVnd, 0); + const totalGain = computed.reduce((s, c) => s + (c.gainVnd ?? 0), 0); + const totalDayChange = computed.reduce((s, c) => s + (c.dayChangeVnd ?? 0), 0); + const prevMV = totalMV - totalDayChange; + + const totals = { + marketValueVnd: round(totalMV, 0), + costBasisVnd: round(totalCost, 0)!, + gainVnd: round(totalGain, 0), + gainPct: totalCost > 0 ? round((totalGain / totalCost) * 100, 2) : null, + dayChangeVnd: round(totalDayChange, 0), + dayChangePct: prevMV > 0 ? round((totalDayChange / prevMV) * 100, 2) : null, + }; + + return { id: meta.id, name: meta.name, holdings: holdingRows, totals }; +} + +async function latestRatio(ticker: string, code: string): Promise { + const arr = await getRatio(ticker, code, 1).catch(() => []); + return arr[0]?.value ?? null; +} + +async function handleQuote(ticker: string) { + const [quote, bars, intradayBars, fund] = await Promise.all([ + getQuote(ticker).catch(() => null), + dailyBars(ticker, 400).catch(() => [] as Bar[]), + cached(`web:last:${ticker}:${Math.floor(nowSec() / 60)}`, 60, () => + getStockOhlcv(ticker, "1", nowSec() - 3 * DAY, nowSec()).catch(() => [] as Bar[]), + ), + fundamentalsBundle(ticker), + ]); + + const latestDaily = bars[bars.length - 1]; + const lastIntraday = intradayBars.length ? intradayBars[intradayBars.length - 1]!.close : null; + const last = lastIntraday ?? latestDaily?.close ?? null; + const ref = ssiBoard(quote?.ref) ?? bars[bars.length - 2]?.close ?? null; + + const highs = bars.map((b) => b.high); + const lows = bars.map((b) => b.low); + const vols = bars.slice(-60).map((b) => b.volume); + + // Performance baselines (from daily bars). 1m ≈ 22 trading days, 3m ≈ 66. + const close1m = bars[bars.length - 1 - 22]?.close; + const close3m = bars[bars.length - 1 - 66]?.close; + const yearStartSec = Date.UTC(new Date().getUTCFullYear(), 0, 1) / 1000; + const closeYtd = (bars.find((b) => b.time >= yearStartSec) ?? bars[0])?.close; + + const { session, isOpen } = marketSession(); + + return { + ticker, + exchange: quote?.exchange ?? null, + nameVi: quote?.companyNameVi ?? fund.company.nameVi ?? null, + nameEn: quote?.companyNameEn ?? fund.company.nameEn ?? null, + currency: "VND" as const, + priceScale: 1000, + ref: ref == null ? null : round(ref, 2), + ceiling: ssiBoard(quote?.ceiling), + floor: ssiBoard(quote?.floor), + last: last == null ? null : round(last, 2), + change_abs: last != null && ref != null ? round(last - ref, 3) : null, + change_pct: last != null && ref != null ? round(pct(last, ref), 2) : null, + session, + isOpen, + stats: { + open: latestDaily ? round(latestDaily.open, 2) : null, + high: latestDaily ? round(latestDaily.high, 2) : null, + low: latestDaily ? round(latestDaily.low, 2) : null, + volume: latestDaily?.volume ?? null, + avg_vol: vols.length ? Math.round(vols.reduce((a, b) => a + b, 0) / vols.length) : null, + market_cap_vnd: fund.marketCap, + pe: fund.pe, + pb: fund.pb, + ps: fund.ps, + eps_thousand_vnd: fund.eps, + bvps_thousand_vnd: fund.bvps, + roe_pct: fund.roe, + roa_pct: fund.roa, + dividend_yield_pct: fund.divYield, + shares_outstanding: fund.shares, + foreign_ownership_pct: fund.foreignOwn, + week52_high: highs.length ? round(Math.max(...highs), 2) : null, + week52_low: lows.length ? round(Math.min(...lows), 2) : null, + change_pct_1m: last != null ? round(pct(last, close1m), 2) : null, + change_pct_3m: last != null ? round(pct(last, close3m), 2) : null, + change_pct_ytd: last != null ? round(pct(last, closeYtd), 2) : null, + }, + }; +} + +interface FundBundle { + company: { nameVi?: string; nameEn?: string; floor?: string; website?: string; summary?: string; intro?: string; sector?: string }; + marketCap: number | null; + pe: number | null; + pb: number | null; + ps: number | null; + eps: number | null; + bvps: number | null; + roe: number | null; + roa: number | null; + divYield: number | null; + shares: number | null; + foreignOwn: number | null; +} + +async function fundamentalsBundle(ticker: string): Promise { + return cached(`web:fund:${ticker}:${Math.floor(nowSec() / (6 * 3600))}`, 6 * 3600, async () => { + const [pe, pb, ps, divYield, marketCap, shares, foreignOwn, profile, intro, cafef] = await Promise.all([ + latestRatio(ticker, RATIOS.PE), + latestRatio(ticker, RATIOS.PB), + latestRatio(ticker, RATIOS.PS), + latestRatio(ticker, RATIOS.DIV_YIELD), + latestRatio(ticker, RATIOS.MARKETCAP), + latestRatio(ticker, RATIOS.SHARES_OUTSTANDING), + latestRatio(ticker, RATIOS.FOREIGN_OWNERSHIP), + getCompanyProfile(ticker).catch(() => null), + getCompanyIntro(ticker).catch(() => null), + getFinancialRatios(ticker, "QUY", 1).catch(() => []), + ]); + const latestCafef: Record = {}; + for (const v of cafef[0]?.Value ?? []) latestCafef[v.Code] = v.Value; + return { + company: { + nameVi: profile?.vnName, + nameEn: profile?.enName, + floor: profile?.floor, + website: profile?.website, + summary: profile?.vnSummary?.slice(0, 800), + intro: intro?.Intro?.slice(0, 800), + sector: intro?.CategoryName as string | undefined, + }, + marketCap: round(marketCap, 0), + pe: round(pe, 2), + pb: round(pb, 2), + ps: round(ps, 2), + eps: latestCafef.EPS ?? null, + bvps: latestCafef.BV ?? null, + roe: latestCafef.ROE ?? null, + roa: latestCafef.ROA ?? null, + divYield: round(divYield, 2), + shares: round(shares, 0), + foreignOwn: round(foreignOwn, 2), + }; + }); +} + +async function handleOhlcv(ticker: string, range: RangeKey) { + const plan = rangeToPlan(range); + const to = nowSec(); + const bucket = plan.intraday ? Math.floor(to / 120) : new Date(to * 1000).toISOString().slice(0, 10); + const key = `web:ohlcv:${ticker}:${range}:${bucket}`; + let bars = await cached(key, plan.intraday ? 120 : 600, () => + getStockOhlcv(ticker, plan.resolution, plan.fromSec, to), + ); + if (range === "1D") bars = lastSessionOnly(bars); + if (range === "5D") { + // keep only the last 5 distinct trading days + const days = new Set(); + for (let k = bars.length - 1; k >= 0; k--) { + days.add(new Date(bars[k]!.time * 1000).toISOString().slice(0, 10)); + if (days.size > 5) { + bars = bars.slice(k + 1); + break; + } + } + } + const prevClose = + range === "1D" || range === "5D" + ? ssiBoard((await getQuote(ticker).catch(() => null))?.ref) ?? bars[0]?.open ?? null + : bars[0]?.close ?? null; + return { ticker, range, resolution: plan.resolution, intraday: plan.intraday, prevClose, bars }; +} + +async function handleIndicators(ticker: string, range: RangeKey) { + const { bars } = await handleOhlcv(ticker, range); + return { ticker, range, ...computeIndicators(bars) }; +} + +async function handleNews(ticker: string) { + const items = await cached(`web:news:${ticker}:${Math.floor(nowSec() / 900)}`, 900, async () => { + const raw = await getTickerNews(ticker, 0, 15, 1).catch(() => []); + return raw + .map((n) => ({ + title: n.Title, + url: n.LinkDetail ? absUrl(n.LinkDetail) : n.Url, + publishedAt: parseDate(n.PublishDate ?? n.DeployDate), + source: n.Source || "CafeF", + snippet: n.SubTitle?.slice(0, 240), + type: "news", + })) + .filter((n) => n.title); + }); + return { ticker, items }; +} + +function absUrl(path?: string): string | undefined { + if (!path) return undefined; + if (/^https?:\/\//.test(path)) return path; + return `https://cafef.vn${path.startsWith("/") ? "" : "/"}${path}`; +} + +function parseDate(input?: string): string | undefined { + if (!input) return undefined; + const m = /\/Date\((\d+)\)\//.exec(input); + if (m) return new Date(Number(m[1])).toISOString(); + const d = new Date(input); + return Number.isNaN(d.getTime()) ? undefined : d.toISOString(); +} + +async function handleMarketNews() { + const seeds = ["VCB", "FPT", "VIC", "HPG", "VNM", "MWG"]; + const items = await cached(`web:marketnews:${Math.floor(nowSec() / 900)}`, 900, async () => { + const all = await mapLimit(seeds, 4, (t) => getTickerNews(t, 0, 6, 1).catch(() => [])); + const flat = all.flat(); + const seen = new Set(); + const out = flat + .map((n) => ({ + title: n.Title, + url: n.LinkDetail ? absUrl(n.LinkDetail) : n.Url, + publishedAt: parseDate(n.PublishDate ?? n.DeployDate), + source: n.Source || "CafeF", + snippet: n.SubTitle?.slice(0, 200), + type: "market", + })) + .filter((n) => { + if (!n.title || seen.has(n.title)) return false; + seen.add(n.title); + return true; + }); + out.sort((a, b) => (b.publishedAt ?? "").localeCompare(a.publishedAt ?? "")); + return out.slice(0, 14); + }); + return { items }; +} + +// --------------------------------------------------------------------------- +// Stock sectors (home sidebar — mini index list) +// --------------------------------------------------------------------------- + +const SECTOR_MAP: { key: string; name: string; tickers: string[] }[] = [ + { key: "banks", name: "Banks", tickers: ["VCB", "BID", "CTG", "TCB", "MBB", "ACB", "VPB", "STB", "HDB"] }, + { key: "real-estate", name: "Real estate", tickers: ["VHM", "VIC", "NVL", "DXG", "KDH", "PDR"] }, + { key: "materials", name: "Materials", tickers: ["HPG", "HSG", "NKG", "GVR", "DGC"] }, + { key: "energy", name: "Energy", tickers: ["GAS", "PLX", "POW", "PVD", "PVS"] }, + { key: "consumer", name: "Consumer", tickers: ["VNM", "MSN", "SAB", "MWG", "PNJ"] }, + { key: "financials", name: "Financials", tickers: ["SSI", "VND", "VCI", "HCM"] }, + { key: "industrials", name: "Industrials", tickers: ["REE", "GMD", "VCG", "CTD"] }, + { key: "technology", name: "Technology", tickers: ["FPT", "CMG", "ELC"] }, +]; + +async function handleSectors() { + return cached(`web:sectors:${Math.floor(nowSec() / 600)}`, 600, async () => { + // Compute each unique ticker's digest once, then reuse across sectors. + const universe = [...new Set(SECTOR_MAP.flatMap((s) => s.tickers))]; + const digests = await mapLimit(universe, 8, miniDigest); + const byTicker = new Map(digests.map((d) => [d.ticker, d])); + + const sectors = SECTOR_MAP.flatMap((sector) => { + const members = sector.tickers + .map((t) => byTicker.get(t)) + .filter((d): d is MiniDigest => d != null && d.last != null); + if (!members.length) return []; + + const changePcts = members.map((d) => d.changePct).filter((v): v is number => v != null); + if (!changePcts.length) return []; + const change_pct = round(changePcts.reduce((a, b) => a + b, 0) / changePcts.length, 2); + + // Synthetic sector index: rebase each constituent spark to 100, then average + // across constituents at each point (truncated to the shortest series). + const rebased = members + .map((d) => d.spark) + .filter((s) => s.length > 0 && s[0]! > 0) + .map((s) => s.map((v) => (v / s[0]!) * 100)); + const spark: number[] = []; + if (rebased.length) { + const minLen = Math.min(...rebased.map((s) => s.length)); + for (let i = 0; i < minLen; i++) { + const avg = rebased.reduce((a, s) => a + s[i]!, 0) / rebased.length; + spark.push(round(avg, 2)!); + } + } + + const leaders = [...members] + .filter((d) => d.changePct != null) + .sort((a, b) => Math.abs(b.changePct!) - Math.abs(a.changePct!)) + .slice(0, 3) + .map((d) => d.ticker); + + return [{ key: sector.key, name: sector.name, change_pct, spark, leaders }]; + }); + + sectors.sort((a, b) => (b.change_pct ?? -Infinity) - (a.change_pct ?? -Infinity)); + return { sectors, asOf: new Date().toISOString() }; + }); +} + +const SECTOR_PEERS: Record = { + banks: ["VCB", "BID", "CTG", "TCB", "MBB", "ACB", "VPB", "STB", "HDB"], + bluechip: ["VCB", "FPT", "HPG", "VNM", "VHM", "VIC", "MWG", "GAS"], +}; + +async function handleAbout(ticker: string) { + const fund = await fundamentalsBundle(ticker); + let peers: string[]; + if (SECTOR_PEERS.banks.includes(ticker)) peers = SECTOR_PEERS.banks; + else peers = SECTOR_PEERS.bluechip; + peers = peers.filter((t) => t !== ticker).slice(0, 6); + + const digests = await mapLimit(peers, 6, miniDigest); + const names = await nameIndex().catch(() => [] as NameEntry[]); + const nameMap = new Map(names.map((n) => [n.ticker, n.name])); + const related = digests.map((d) => ({ + ticker: d.ticker, + name: nameMap.get(d.ticker), + last: d.last, + change_pct: d.changePct, + spark: d.spark, + })); + + return { + ticker, + company: { + nameVi: fund.company.nameVi, + nameEn: fund.company.nameEn, + floor: fund.company.floor, + website: fund.company.website, + summary: fund.company.summary, + intro: fund.company.intro, + sector: fund.company.sector, + }, + related, + }; +} + +async function handleSearch(q: string) { + const query = q.trim().toUpperCase(); + if (!query) return { query: q, results: [] }; + const idx = await nameIndex().catch(() => [] as NameEntry[]); + const starts = idx.filter((e) => e.ticker.startsWith(query)); + const nameHits = idx.filter( + (e) => !e.ticker.startsWith(query) && e.name.toUpperCase().includes(query), + ); + const results = [...starts, ...nameHits].slice(0, 12).map((e) => ({ + ticker: e.ticker, + name: e.name, + exchange: e.exchange, + })); + return { query: q, results }; +} + +// --------------------------------------------------------------------------- +// Financials (quarterly / annual key metrics from CafeF) +// --------------------------------------------------------------------------- + +const CAFEF_METRICS: { key: string; code: string; label: string; unit: "kVND" | "%" | "x" }[] = [ + { key: "eps", code: "EPS", label: "EPS", unit: "kVND" }, + { key: "bvps", code: "BV", label: "BVPS", unit: "kVND" }, + { key: "roe", code: "ROE", label: "ROE", unit: "%" }, + { key: "roa", code: "ROA", label: "ROA", unit: "%" }, + { key: "ros", code: "ROS", label: "Net margin", unit: "%" }, + { key: "grossMargin", code: "GOS", label: "Gross margin", unit: "%" }, + { key: "debtToAssets", code: "DAR", label: "Debt / Assets", unit: "%" }, + { key: "pe", code: "PE", label: "P/E", unit: "x" }, +]; + +async function handleFinancials(ticker: string, period: string) { + const reportType = period === "annual" ? "NAM" : "QUY"; + const buckets = await cached( + `web:fin:${ticker}:${reportType}:${Math.floor(nowSec() / (6 * 3600))}`, + 6 * 3600, + () => getFinancialRatios(ticker, reportType, 8).catch(() => []), + ); + // CafeF returns newest-first; reverse to oldest→newest for charts/tables. + // NB: CafeF's ratios dataset is annual (Quater is 0), so we label by year and + // only prefix a quarter when CafeF actually reports one (1–4). + const ordered = [...buckets].reverse(); + const hasQuarter = (q: number | undefined): q is number => q != null && q >= 1 && q <= 4; + const columns = ordered.map((b) => ({ + label: hasQuarter(b.Quater) ? `Q${b.Quater} ${b.Year ?? ""}`.trim() : `${b.Year ?? ""}`, + year: b.Year ?? null, + quarter: hasQuarter(b.Quater) ? b.Quater : null, + })); + const flat = ordered.map((b) => { + const m: Record = {}; + for (const v of b.Value ?? []) m[v.Code] = v.Value; + return m; + }); + const metrics = CAFEF_METRICS.map((def) => ({ + key: def.key, + label: def.label, + unit: def.unit, + values: flat.map((m) => + m[def.code] != null && Number.isFinite(m[def.code]) ? round(m[def.code], 2) : null, + ), + })).filter((metric) => metric.values.some((v) => v != null)); + return { ticker, period: reportType === "NAM" ? "annual" : "quarterly", columns, metrics }; +} + +// --------------------------------------------------------------------------- +// Router +// --------------------------------------------------------------------------- + +function sendJson(res: http.ServerResponse, status: number, body: unknown) { + const text = JSON.stringify(body); + res.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "access-control-allow-origin": "*", + "cache-control": "no-store", + }); + res.end(text); +} + +function upperTicker(raw: string): string { + return decodeURIComponent(raw).toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 12); +} + +/** Parse a numeric path id, throwing a 400 if it isn't a finite number. */ +function numId(raw: string | undefined, label = "id"): number { + const n = Number(raw); + if (!Number.isFinite(n)) throw new HttpError(400, `invalid ${label}`); + return n; +} + +function reqName(body: BodyObj): string { + const name = typeof body.name === "string" ? body.name.trim() : ""; + if (!name) throw new HttpError(400, "name required"); + return name; +} + +/** Run a store call, mapping its input-validation Errors to HTTP 400. */ +function validate(fn: () => T): T { + try { + return fn(); + } catch (err) { + if (err instanceof HttpError) throw err; + throw new HttpError(400, err instanceof Error ? err.message : "invalid input"); + } +} + +type BodyObj = Record; + +async function routeWatchlists( + arg: string | undefined, + sub: string | undefined, + subArg: string | undefined, + method: string, + body: BodyObj, +): Promise { + // Collection: /api/watchlists + if (!arg) { + if (method === "GET") return { watchlists: listWatchlists() }; + if (method === "POST") return createWatchlist(reqName(body)); + throw new HttpError(405, `method not allowed: ${method}`); + } + + const id = numId(arg, "watchlist id"); + + // Items sub-resource: /api/watchlists/:id/items[/:ticker] + if (sub === "items") { + if (!subArg) { + if (method === "POST") { + const meta = validate(() => addWatchlistItem(id, String(body.ticker ?? ""))); + if (!meta) throw new HttpError(404, `watchlist ${id} not found`); + return meta; + } + throw new HttpError(405, `method not allowed: ${method}`); + } + if (method === "DELETE") { + const meta = validate(() => removeWatchlistItem(id, upperTicker(subArg))); + if (!meta) throw new HttpError(404, `watchlist ${id} not found`); + return meta; + } + throw new HttpError(405, `method not allowed: ${method}`); + } + + // Single list: /api/watchlists/:id + if (method === "GET") return handleWatchlistDetail(id); + if (method === "PATCH") { + const meta = renameWatchlist(id, reqName(body)); + if (!meta) throw new HttpError(404, `watchlist ${id} not found`); + return meta; + } + if (method === "DELETE") { + deleteWatchlist(id); + return { ok: true }; + } + throw new HttpError(405, `method not allowed: ${method}`); +} + +async function routePortfolios( + arg: string | undefined, + sub: string | undefined, + method: string, + body: BodyObj, +): Promise { + // Collection: /api/portfolios + if (!arg) { + if (method === "GET") return { portfolios: listPortfolios() }; + if (method === "POST") return createPortfolio(reqName(body)); + throw new HttpError(405, `method not allowed: ${method}`); + } + + const id = numId(arg, "portfolio id"); + + // Holdings sub-resource: /api/portfolios/:id/holdings + if (sub === "holdings") { + if (method === "POST") { + if (!getPortfolio(id)) throw new HttpError(404, `portfolio ${id} not found`); + validate(() => + addHolding(id, { + ticker: String(body.ticker ?? ""), + quantity: Number(body.quantity), + avgCostVnd: Number(body.avgCostVnd), + }), + ); + return { ok: true }; + } + throw new HttpError(405, `method not allowed: ${method}`); + } + + // Single portfolio: /api/portfolios/:id + if (method === "GET") return handlePortfolioResponse(id); + if (method === "PATCH") { + const meta = renamePortfolio(id, reqName(body)); + if (!meta) throw new HttpError(404, `portfolio ${id} not found`); + return meta; + } + if (method === "DELETE") { + deletePortfolio(id); + return { ok: true }; + } + throw new HttpError(405, `method not allowed: ${method}`); +} + +async function routeHoldings( + arg: string | undefined, + method: string, + body: BodyObj, +): Promise { + const id = numId(arg, "holding id"); + if (method === "PATCH") { + const patch: { quantity?: number; avgCostVnd?: number } = {}; + if (body.quantity !== undefined) patch.quantity = Number(body.quantity); + if (body.avgCostVnd !== undefined) patch.avgCostVnd = Number(body.avgCostVnd); + validate(() => updateHolding(id, patch)); + return { ok: true }; + } + if (method === "DELETE") { + deleteHolding(id); + return { ok: true }; + } + throw new HttpError(405, `method not allowed: ${method}`); +} + +async function route(url: URL, method: string, body: BodyObj): Promise { + const parts = url.pathname.replace(/^\/api\/?/, "").split("/").filter(Boolean); + const [head, arg, sub, subArg] = parts; + + switch (head) { + case undefined: + case "health": + return { ok: true, service: "azoth-web", time: new Date().toISOString() }; + case "indices": + return handleIndices(); + case "movers": + return handleMovers(url.searchParams.get("kind") ?? "gainers", url.searchParams.get("universe") ?? "vn30"); + case "watchlist": + return handleWatchlist(); + case "watchlists": + return routeWatchlists(arg, sub, subArg, method, body); + case "portfolios": + return routePortfolios(arg, sub, method, body); + case "holdings": + return routeHoldings(arg, method, body); + case "market-news": + return handleMarketNews(); + case "sectors": + return handleSectors(); + case "search": + return handleSearch(url.searchParams.get("q") ?? ""); + case "quote": + if (!arg) throw new HttpError(400, "ticker required"); + return handleQuote(upperTicker(arg)); + case "ohlcv": + if (!arg) throw new HttpError(400, "ticker required"); + return handleOhlcv(upperTicker(arg), (url.searchParams.get("range") as RangeKey) ?? "6M"); + case "indicators": + if (!arg) throw new HttpError(400, "ticker required"); + return handleIndicators(upperTicker(arg), (url.searchParams.get("range") as RangeKey) ?? "6M"); + case "news": + if (!arg) throw new HttpError(400, "ticker required"); + return handleNews(upperTicker(arg)); + case "about": + if (!arg) throw new HttpError(400, "ticker required"); + return handleAbout(upperTicker(arg)); + case "financials": + if (!arg) throw new HttpError(400, "ticker required"); + return handleFinancials(upperTicker(arg), url.searchParams.get("period") ?? "quarterly"); + default: + throw new HttpError(404, `unknown endpoint: ${url.pathname}`); + } +} + +class HttpError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +/** Read and JSON-parse a request body; tolerate an empty body (→ {}). */ +async function readBody(req: http.IncomingMessage): Promise> { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString("utf8").trim(); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as Record) : {}; +} + +const server = http.createServer(async (req, res) => { + if (req.method === "OPTIONS") { + res.writeHead(204, { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET,POST,PATCH,DELETE,OPTIONS", + "access-control-allow-headers": "content-type,accept", + }); + res.end(); + return; + } + const url = new URL(req.url ?? "/", `http://localhost:${PORT}`); + if (!url.pathname.startsWith("/api")) { + sendJson(res, 404, { error: "not found" }); + return; + } + const method = (req.method ?? "GET").toUpperCase(); + const started = Date.now(); + try { + let body: Record = {}; + if (method === "POST" || method === "PATCH" || method === "PUT" || method === "DELETE") { + try { + body = await readBody(req); + } catch { + throw new HttpError(400, "invalid JSON body"); + } + } + const result = await route(url, method, body); + sendJson(res, 200, result); + console.log(`${method} ${url.pathname}${url.search} → 200 (${Date.now() - started}ms)`); + } catch (err) { + const status = err instanceof HttpError ? err.status : 500; + const message = err instanceof Error ? err.message : "internal error"; + sendJson(res, status, { error: message }); + console.error(`${method} ${url.pathname}${url.search} → ${status}: ${message}`); + } +}); + +// Open the cache DB eagerly so disk errors surface at startup, then ensure the +// web persistence tables (watchlists / portfolios) exist and are seeded. +try { + getDb(); + ensureWebSchema(); +} catch (err) { + console.error("Failed to open cache DB / init web schema:", err); +} + +server.listen(PORT, () => { + console.log(`Azoth Finance data server on http://localhost:${PORT}`); +}); diff --git a/web/server/store.ts b/web/server/store.ts new file mode 100644 index 0000000..3388b10 --- /dev/null +++ b/web/server/store.ts @@ -0,0 +1,315 @@ +/** + * Azoth Finance — web persistence layer. + * + * SQLite-backed storage for user-managed watchlists and manual (Google-Finance + * style) portfolios. Tables are prefixed `web_` and live in the same better-sqlite3 + * handle as the rest of Azoth (WAL + foreign_keys=ON already set by getDb()). + * + * Units note: holdings store avg_cost_vnd as PLAIN VND per share (e.g. 64800), + * while board prices are resolved elsewhere in THOUSAND VND. This layer only + * persists raw inputs; all portfolio math (market value, gain, weights) happens + * in the router in index.ts. + */ +import { getDb } from "../../src/storage/db.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface WatchlistMeta { + id: number; + name: string; + tickers: string[]; +} + +export interface PortfolioMeta { + id: number; + name: string; +} + +export interface HoldingInput { + ticker: string; + quantity: number; + avgCostVnd: number; +} + +export interface StoredHolding { + id: number; + ticker: string; + quantity: number; + avgCostVnd: number; +} + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- + +let schemaReady = false; + +export function ensureWebSchema(): void { + if (schemaReady) return; + const db = getDb(); + db.exec(` + CREATE TABLE IF NOT EXISTS web_watchlist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + sort INTEGER DEFAULT 0, + created_at INTEGER + ); + CREATE TABLE IF NOT EXISTS web_watchlist_item ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + watchlist_id INTEGER NOT NULL REFERENCES web_watchlist(id) ON DELETE CASCADE, + ticker TEXT NOT NULL, + added_at INTEGER, + UNIQUE(watchlist_id, ticker) + ); + CREATE TABLE IF NOT EXISTS web_portfolio ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + sort INTEGER DEFAULT 0, + created_at INTEGER + ); + CREATE TABLE IF NOT EXISTS web_holding ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + portfolio_id INTEGER NOT NULL REFERENCES web_portfolio(id) ON DELETE CASCADE, + ticker TEXT NOT NULL, + quantity REAL NOT NULL, + avg_cost_vnd REAL NOT NULL, + added_at INTEGER + ); + `); + + // Seed a default watchlist on first run so the sidebar isn't empty. + const count = (db.prepare("SELECT count(*) AS n FROM web_watchlist").get() as { n: number }).n; + if (count === 0) { + const now = nowSec(); + const info = db + .prepare("INSERT INTO web_watchlist (name, sort, created_at) VALUES (?, 0, ?)") + .run("My watchlist", now); + const wid = Number(info.lastInsertRowid); + const insItem = db.prepare( + "INSERT OR IGNORE INTO web_watchlist_item (watchlist_id, ticker, added_at) VALUES (?, ?, ?)", + ); + for (const t of ["VCB", "FPT", "HPG", "VNM", "MWG", "MBB"]) { + insItem.run(wid, t, now); + } + } + + schemaReady = true; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function nowSec(): number { + return Math.floor(Date.now() / 1000); +} + +function sanitizeTicker(raw: unknown): string { + const t = String(raw ?? "").toUpperCase().trim(); + if (!/^[A-Z0-9]{1,12}$/.test(t)) { + throw new Error(`invalid ticker: ${JSON.stringify(raw)}`); + } + return t; +} + +function sanitizeName(raw: unknown): string { + const n = String(raw ?? "").trim(); + if (!n) throw new Error("name required"); + return n.slice(0, 120); +} + +// --------------------------------------------------------------------------- +// Watchlists +// --------------------------------------------------------------------------- + +export function listWatchlists(): WatchlistMeta[] { + ensureWebSchema(); + const db = getDb(); + const lists = db + .prepare("SELECT id, name FROM web_watchlist ORDER BY sort, id") + .all() as { id: number; name: string }[]; + const itemStmt = db.prepare( + "SELECT ticker FROM web_watchlist_item WHERE watchlist_id = ? ORDER BY added_at, id", + ); + return lists.map((l) => ({ + id: l.id, + name: l.name, + tickers: (itemStmt.all(l.id) as { ticker: string }[]).map((r) => r.ticker), + })); +} + +export function getWatchlistTickers(id: number): string[] { + ensureWebSchema(); + const db = getDb(); + const rows = db + .prepare("SELECT ticker FROM web_watchlist_item WHERE watchlist_id = ? ORDER BY added_at, id") + .all(id) as { ticker: string }[]; + return rows.map((r) => r.ticker); +} + +/** Full meta (id, name, tickers) for a single list, or null if it doesn't exist. */ +export function getWatchlistMeta(id: number): WatchlistMeta | null { + ensureWebSchema(); + const db = getDb(); + const row = db.prepare("SELECT id, name FROM web_watchlist WHERE id = ?").get(id) as + | { id: number; name: string } + | undefined; + if (!row) return null; + return { id: row.id, name: row.name, tickers: getWatchlistTickers(id) }; +} + +export function createWatchlist(name: string): WatchlistMeta { + ensureWebSchema(); + const db = getDb(); + const clean = sanitizeName(name); + const info = db + .prepare("INSERT INTO web_watchlist (name, sort, created_at) VALUES (?, 0, ?)") + .run(clean, nowSec()); + return { id: Number(info.lastInsertRowid), name: clean, tickers: [] }; +} + +export function renameWatchlist(id: number, name: string): WatchlistMeta | null { + ensureWebSchema(); + const db = getDb(); + const clean = sanitizeName(name); + const info = db.prepare("UPDATE web_watchlist SET name = ? WHERE id = ?").run(clean, id); + if (info.changes === 0) return null; + return getWatchlistMeta(id); +} + +export function deleteWatchlist(id: number): void { + ensureWebSchema(); + const db = getDb(); + db.prepare("DELETE FROM web_watchlist WHERE id = ?").run(id); +} + +export function addWatchlistItem(id: number, ticker: string): WatchlistMeta | null { + ensureWebSchema(); + const db = getDb(); + const t = sanitizeTicker(ticker); + if (!getWatchlistMeta(id)) return null; + db.prepare( + "INSERT OR IGNORE INTO web_watchlist_item (watchlist_id, ticker, added_at) VALUES (?, ?, ?)", + ).run(id, t, nowSec()); + return getWatchlistMeta(id); +} + +export function removeWatchlistItem(id: number, ticker: string): WatchlistMeta | null { + ensureWebSchema(); + const db = getDb(); + const t = sanitizeTicker(ticker); + if (!getWatchlistMeta(id)) return null; + db.prepare("DELETE FROM web_watchlist_item WHERE watchlist_id = ? AND ticker = ?").run(id, t); + return getWatchlistMeta(id); +} + +// --------------------------------------------------------------------------- +// Portfolios +// --------------------------------------------------------------------------- + +export function listPortfolios(): PortfolioMeta[] { + ensureWebSchema(); + const db = getDb(); + return db + .prepare("SELECT id, name FROM web_portfolio ORDER BY sort, id") + .all() as PortfolioMeta[]; +} + +export function getPortfolio(id: number): PortfolioMeta | null { + ensureWebSchema(); + const db = getDb(); + const row = db.prepare("SELECT id, name FROM web_portfolio WHERE id = ?").get(id) as + | PortfolioMeta + | undefined; + return row ?? null; +} + +export function createPortfolio(name: string): PortfolioMeta { + ensureWebSchema(); + const db = getDb(); + const clean = sanitizeName(name); + const info = db + .prepare("INSERT INTO web_portfolio (name, sort, created_at) VALUES (?, 0, ?)") + .run(clean, nowSec()); + return { id: Number(info.lastInsertRowid), name: clean }; +} + +export function renamePortfolio(id: number, name: string): PortfolioMeta | null { + ensureWebSchema(); + const db = getDb(); + const clean = sanitizeName(name); + const info = db.prepare("UPDATE web_portfolio SET name = ? WHERE id = ?").run(clean, id); + if (info.changes === 0) return null; + return { id, name: clean }; +} + +export function deletePortfolio(id: number): void { + ensureWebSchema(); + const db = getDb(); + db.prepare("DELETE FROM web_portfolio WHERE id = ?").run(id); +} + +export function getHoldings(id: number): StoredHolding[] { + ensureWebSchema(); + const db = getDb(); + const rows = db + .prepare( + "SELECT id, ticker, quantity, avg_cost_vnd FROM web_holding WHERE portfolio_id = ? ORDER BY added_at, id", + ) + .all(id) as { id: number; ticker: string; quantity: number; avg_cost_vnd: number }[]; + return rows.map((r) => ({ + id: r.id, + ticker: r.ticker, + quantity: r.quantity, + avgCostVnd: r.avg_cost_vnd, + })); +} + +export function addHolding(id: number, input: HoldingInput): StoredHolding { + ensureWebSchema(); + const db = getDb(); + const ticker = sanitizeTicker(input.ticker); + const quantity = Number(input.quantity); + const avgCostVnd = Number(input.avgCostVnd); + if (!Number.isFinite(quantity) || quantity <= 0) throw new Error("quantity must be > 0"); + if (!Number.isFinite(avgCostVnd) || avgCostVnd < 0) throw new Error("avgCostVnd must be >= 0"); + const info = db + .prepare( + "INSERT INTO web_holding (portfolio_id, ticker, quantity, avg_cost_vnd, added_at) VALUES (?, ?, ?, ?, ?)", + ) + .run(id, ticker, quantity, avgCostVnd, nowSec()); + return { id: Number(info.lastInsertRowid), ticker, quantity, avgCostVnd }; +} + +export function updateHolding( + hid: number, + patch: { quantity?: number; avgCostVnd?: number }, +): void { + ensureWebSchema(); + const db = getDb(); + const sets: string[] = []; + const args: (number | string)[] = []; + if (patch.quantity !== undefined) { + const quantity = Number(patch.quantity); + if (!Number.isFinite(quantity) || quantity <= 0) throw new Error("quantity must be > 0"); + sets.push("quantity = ?"); + args.push(quantity); + } + if (patch.avgCostVnd !== undefined) { + const avgCostVnd = Number(patch.avgCostVnd); + if (!Number.isFinite(avgCostVnd) || avgCostVnd < 0) throw new Error("avgCostVnd must be >= 0"); + sets.push("avg_cost_vnd = ?"); + args.push(avgCostVnd); + } + if (!sets.length) return; + args.push(hid); + db.prepare(`UPDATE web_holding SET ${sets.join(", ")} WHERE id = ?`).run(...args); +} + +export function deleteHolding(hid: number): void { + ensureWebSchema(); + const db = getDb(); + db.prepare("DELETE FROM web_holding WHERE id = ?").run(hid); +} diff --git a/web/src/App.css b/web/src/App.css new file mode 100644 index 0000000..741edbe --- /dev/null +++ b/web/src/App.css @@ -0,0 +1,39 @@ +/* 3-column Google-Finance layout: Lists sidebar · content · Research panel. */ +.gf-layout { + display: grid; + grid-template-columns: 232px minmax(0, 1fr) 360px; + gap: 28px; + align-items: start; + padding-top: 20px; +} + +.gf-main { + min-width: 0; +} + +.gf-sidebar, +.gf-research { + position: sticky; + top: calc(var(--nav-h) + 16px); + max-height: calc(100vh - var(--nav-h) - 32px); + overflow-y: auto; +} + +/* Hide the right Research rail first, then the left sidebar, as width shrinks. */ +@media (max-width: 1320px) { + .gf-layout { + grid-template-columns: 232px minmax(0, 1fr); + } + .gf-research { + display: none; + } +} + +@media (max-width: 960px) { + .gf-layout { + grid-template-columns: minmax(0, 1fr); + } + .gf-sidebar { + display: none; + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..0fc7df5 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,43 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import TopNav from "./components/TopNav"; +import Sidebar from "./components/Sidebar"; +import ResearchPanel from "./components/ResearchPanel"; +import { UserDataProvider } from "./lib/userData"; +import Home from "./pages/Home"; +import Quote from "./pages/Quote"; +import WatchlistPage from "./pages/WatchlistPage"; +import Portfolio from "./pages/Portfolio"; +import MarketTrends from "./pages/MarketTrends"; +import "./App.css"; + +export default function App() { + return ( + +
+ +
+
+ +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+ +
+
+
+
+ ); +} diff --git a/web/src/components/AboutCompany.css b/web/src/components/AboutCompany.css new file mode 100644 index 0000000..9b94d87 --- /dev/null +++ b/web/src/components/AboutCompany.css @@ -0,0 +1,65 @@ +.about { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 20px; +} + +.about__body { + display: flex; + flex-direction: column; + gap: 4px; +} + +.about__desc { + margin: 0; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.6; +} + +.about__desc--clamped { + display: -webkit-box; + -webkit-line-clamp: 5; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.about__toggle { + align-self: flex-start; + padding: 0; + border: none; + background: none; + color: var(--accent); + font-size: 14px; + font-weight: 500; +} + +.about__toggle:hover { + color: var(--accent-hover); +} + +.about__empty { + margin: 0; + font-size: 14px; + line-height: 1.6; +} + +.about__meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.about__link { + color: var(--accent); + transition: background 0.12s ease; +} + +.about__link:hover { + background: var(--surface-hover); +} + +.about__link-glyph { + flex: none; +} diff --git a/web/src/components/AboutCompany.tsx b/web/src/components/AboutCompany.tsx new file mode 100644 index 0000000..7bf546f --- /dev/null +++ b/web/src/components/AboutCompany.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import type { CompanyInfo } from "../lib/types"; +import "./AboutCompany.css"; + +const CLAMP_THRESHOLD = 320; + +function normalizeUrl(raw: string): string { + const trimmed = raw.trim(); + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return `https://${trimmed}`; +} + +function hostnameOf(raw: string): string { + try { + return new URL(normalizeUrl(raw)).hostname.replace(/^www\./, ""); + } catch { + return raw.trim(); + } +} + +export default function AboutCompany({ company }: { company: CompanyInfo }) { + const [expanded, setExpanded] = useState(false); + + const description = company.intro || company.summary; + const isLong = !!description && description.length > CLAMP_THRESHOLD; + const clamped = isLong && !expanded; + + return ( +
+

About

+ + {description ? ( +
+

+ {description} +

+ {isLong && ( + + )} +
+ ) : ( +

+ No company description available. +

+ )} + + {(company.sector || company.floor || company.website) && ( +
+ {company.sector && {company.sector}} + {company.floor && {company.floor}} + {company.website && ( + + {hostnameOf(company.website)} + + + )} +
+ )} +
+ ); +} diff --git a/web/src/components/AddHoldingForm.css b/web/src/components/AddHoldingForm.css new file mode 100644 index 0000000..b2add23 --- /dev/null +++ b/web/src/components/AddHoldingForm.css @@ -0,0 +1,69 @@ +.addhold { + padding: 16px 20px 18px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.addhold__title { + font-size: 15px; + font-weight: 600; + color: var(--text); +} + +.addhold__row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; +} + +.addhold__field { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1 1 130px; + min-width: 110px; +} + +.addhold__field--sym { + flex: 0 1 130px; +} + +.addhold__label { + font-size: 12px; + font-weight: 500; +} + +.addhold__input { + height: 38px; + padding: 0 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + font-family: inherit; + font-size: 14px; + font-variant-numeric: tabular-nums; + outline: none; + transition: border-color 0.12s ease; +} + +.addhold__input:focus { + border-color: var(--accent); +} + +.addhold__submit { + height: 38px; + flex: 0 0 auto; + cursor: pointer; +} + +.addhold__hint { + font-size: 12px; +} + +.addhold__error { + font-size: 13px; + font-weight: 500; +} diff --git a/web/src/components/AddHoldingForm.tsx b/web/src/components/AddHoldingForm.tsx new file mode 100644 index 0000000..d62452c --- /dev/null +++ b/web/src/components/AddHoldingForm.tsx @@ -0,0 +1,105 @@ +import { useState, type FormEvent } from "react"; +import { api } from "../lib/api"; +import "./AddHoldingForm.css"; + +interface Props { + portfolioId: number; + onAdded: () => void; +} + +export default function AddHoldingForm({ portfolioId, onAdded }: Props) { + const [ticker, setTicker] = useState(""); + const [quantity, setQuantity] = useState(""); + const [avgCost, setAvgCost] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + const sym = ticker.trim().toUpperCase(); + // Avg cost is PLAIN VND per share — pass straight through, no scaling. + const qty = Number(quantity.replace(/[^0-9.]/g, "")); + const avgCostVnd = Number(avgCost.replace(/[^0-9.]/g, "")); + + if (!sym) { + setError("Enter a ticker symbol."); + return; + } + if (!Number.isFinite(qty) || qty <= 0) { + setError("Enter a quantity greater than 0."); + return; + } + if (!Number.isFinite(avgCostVnd) || avgCostVnd <= 0) { + setError("Enter an average cost in ₫."); + return; + } + + setSubmitting(true); + setError(null); + try { + await api.portfolios.addHolding(portfolioId, { ticker: sym, quantity: qty, avgCostVnd }); + setTicker(""); + setQuantity(""); + setAvgCost(""); + onAdded(); + } catch (err) { + setError((err as Error)?.message || "Couldn't add holding."); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Add holding

+
+ + + + +
+

+ HOSE lots are multiples of 100. Avg cost is the price you paid per share, in đồng + (e.g. 64,800). +

+ {error &&

{error}

} +
+ ); +} diff --git a/web/src/components/ChangeBadge.tsx b/web/src/components/ChangeBadge.tsx new file mode 100644 index 0000000..9456478 --- /dev/null +++ b/web/src/components/ChangeBadge.tsx @@ -0,0 +1,40 @@ +import { dirOf, fmtPct, type Direction } from "../lib/format"; + +interface Props { + /** Percent change; drives color + arrow direction when `direction` is omitted. */ + pct?: number | null; + /** Override the direction (e.g. when showing an absolute-only badge). */ + direction?: Direction; + /** Text to show instead of the formatted percent (e.g. "+307.00"). */ + text?: string; + size?: "sm" | "md" | "lg"; + /** Show the circular arrow dot (Google Finance style). Default true. */ + dot?: boolean; + className?: string; +} + +const ARROW: Record = { up: "▲", down: "▼", flat: "—" }; + +const FONT: Record, number> = { sm: 12, md: 14, lg: 16 }; +const DOT: Record, number> = { sm: 16, md: 18, lg: 22 }; + +export default function ChangeBadge({ pct, direction, text, size = "md", dot = true, className }: Props) { + const dir = direction ?? dirOf(pct); + const label = text ?? fmtPct(pct); + return ( + + {dot && ( + + {ARROW[dir]} + + )} + {label} + + ); +} diff --git a/web/src/components/DiscoverStrip.css b/web/src/components/DiscoverStrip.css new file mode 100644 index 0000000..8e58af2 --- /dev/null +++ b/web/src/components/DiscoverStrip.css @@ -0,0 +1,122 @@ +.discover { + display: flex; + gap: 12px; + overflow-x: auto; + scroll-snap-type: x proximity; + padding: 2px 2px 8px; + margin: 0 -2px; + /* Thin scrollbar (Firefox) */ + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +/* Thin scrollbar (WebKit) */ +.discover::-webkit-scrollbar { + height: 6px; +} + +.discover::-webkit-scrollbar-track { + background: transparent; +} + +.discover::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: var(--pill); +} + +.discover::-webkit-scrollbar-thumb:hover { + background: var(--border-subtle); +} + +.discover__card { + flex: 0 0 auto; + width: 180px; + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + scroll-snap-align: start; + text-decoration: none; + color: inherit; + transition: background 0.12s ease, border-color 0.12s ease; +} + +a.discover__card:hover { + background: var(--surface-hover); + border-color: var(--border); +} + +.discover__head { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.discover__ticker { + font-size: 14px; + font-weight: 600; + color: var(--text); +} + +.discover__name { + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.discover__spark { + display: block; + height: 36px; +} + +.discover__foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.discover__price { + font-size: 13px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.discover__msg { + padding: 20px 2px; + font-size: 13px; +} + +/* Skeleton card */ +.discover__card--skel { + pointer-events: none; +} + +.discover__skel-ticker { + width: 46px; + height: 14px; + border-radius: var(--radius-sm); +} + +.discover__skel-name { + width: 90px; + height: 10px; + border-radius: var(--radius-sm); +} + +.discover__skel-spark { + width: 100%; + height: 36px; + border-radius: var(--radius-sm); +} + +.discover__skel-price { + width: 70px; + height: 14px; + border-radius: var(--radius-sm); +} diff --git a/web/src/components/DiscoverStrip.tsx b/web/src/components/DiscoverStrip.tsx new file mode 100644 index 0000000..23124c4 --- /dev/null +++ b/web/src/components/DiscoverStrip.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api } from "../lib/api"; +import type { MoverRow } from "../lib/types"; +import { fmtPriceVnd } from "../lib/format"; +import Sparkline from "./Sparkline"; +import ChangeBadge from "./ChangeBadge"; +import "./DiscoverStrip.css"; + +const COUNT = 8; + +/** Merge active + gainers, dedupe by ticker (active first), cap at COUNT. */ +function blend(active: MoverRow[], gainers: MoverRow[]): MoverRow[] { + const seen = new Set(); + const out: MoverRow[] = []; + for (const row of [...active, ...gainers]) { + if (seen.has(row.ticker)) continue; + seen.add(row.ticker); + out.push(row); + if (out.length >= COUNT) break; + } + return out; +} + +export default function DiscoverStrip() { + const [rows, setRows] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + const ctrl = new AbortController(); + setRows(null); + setError(false); + Promise.all([ + api.movers("active", "vn30", ctrl.signal), + api.movers("gainers", "vn30", ctrl.signal), + ]) + .then(([active, gainers]) => setRows(blend(active.rows, gainers.rows))) + .catch((err) => { + if (err?.name === "AbortError") return; + setError(true); + }); + return () => ctrl.abort(); + }, []); + + if (error) { + return

Couldn't load suggestions.

; + } + + return ( +
+ {rows == null + ? Array.from({ length: COUNT }).map((_, i) => ( +
+ + + + +
+ )) + : rows.map((row) => ( + +
+ {row.ticker} + {row.name && {row.name}} +
+ + + +
+ {fmtPriceVnd(row.last)} + +
+ + ))} +
+ ); +} diff --git a/web/src/components/FinancialsTab.css b/web/src/components/FinancialsTab.css new file mode 100644 index 0000000..6f22200 --- /dev/null +++ b/web/src/components/FinancialsTab.css @@ -0,0 +1,164 @@ +.fin { + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.fin__head { + display: flex; + align-items: center; + gap: 10px; +} + +.fin__period { + font-size: 11px; +} + +.fin__empty { + padding: 8px 0 4px; +} + +.fin__metrics { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.fin__metric-pill { + height: 30px; + font-size: 12.5px; +} + +/* ---- Bar chart --------------------------------------------------------- */ +.fin__chart-wrap { + display: flex; + flex-direction: column; + gap: 10px; +} + +.fin__chart-title { + font-size: 13px; + font-weight: 500; +} + +.fin__unit { + color: var(--text-muted); + font-weight: 400; +} + +.fin__chart { + display: flex; + align-items: flex-end; + gap: 10px; + height: 200px; + padding-top: 18px; + overflow-x: auto; +} + +.fin__col { + flex: 1 1 0; + min-width: 44px; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + height: 100%; + gap: 6px; +} + +.fin__val { + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; +} + +.fin__bar { + width: 62%; + max-width: 44px; + background: linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 65%, transparent)); + border-radius: 4px 4px 0 0; + transition: height 0.25s ease; +} + +.fin__bar--neg { + background: linear-gradient(180deg, var(--down-strong), color-mix(in srgb, var(--down-strong) 65%, transparent)); +} + +.fin__year { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; +} + +/* ---- Table ------------------------------------------------------------- */ +.fin__table-wrap { + overflow-x: auto; +} + +.fin__table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.fin__table th, +.fin__table td { + padding: 9px 12px; + text-align: right; + white-space: nowrap; + border-bottom: 1px solid var(--border-subtle); +} + +.fin__table thead th { + color: var(--text-muted); + font-weight: 500; + font-size: 11px; + letter-spacing: 0.4px; + text-transform: uppercase; +} + +.fin__th-metric, +.fin__row-label { + text-align: left; + color: var(--text-secondary); + font-weight: 500; + position: sticky; + left: 0; + background: var(--surface); +} + +.fin__row-label { + color: var(--text); +} + +.fin__row-unit { + font-size: 11px; + font-weight: 400; +} + +.fin__table tbody tr { + cursor: pointer; + transition: background 0.12s ease; +} + +.fin__table tbody tr:hover { + background: var(--surface-hover); +} + +.fin__table tbody tr:hover .fin__row-label { + background: var(--surface-hover); +} + +.fin__row--active .fin__row-label, +.fin__row--active td { + color: var(--accent); +} + +.fin__table tbody td { + color: var(--text); +} + +.fin__source { + font-size: 11px; +} diff --git a/web/src/components/FinancialsTab.tsx b/web/src/components/FinancialsTab.tsx new file mode 100644 index 0000000..a714330 --- /dev/null +++ b/web/src/components/FinancialsTab.tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useState } from "react"; +import { api } from "../lib/api"; +import type { FinancialUnit, FinancialsResponse } from "../lib/types"; +import { fmtNum, fmtPctPlain, fmtPriceVnd } from "../lib/format"; +import "./FinancialsTab.css"; + +interface Props { + ticker: string; +} + +const UNIT_LABEL: Record = { + kVND: "thousand ₫", + "%": "%", + x: "×", +}; + +/** Full value for the table cells. */ +function tableFmt(v: number | null, unit: FinancialUnit): string { + if (v == null) return "—"; + if (unit === "kVND") return fmtPriceVnd(v); + if (unit === "%") return fmtPctPlain(v); + return fmtNum(v); +} + +/** Compact value for the bar labels. */ +function barFmt(v: number | null, unit: FinancialUnit): string { + if (v == null) return ""; + if (unit === "%") return `${fmtNum(v, 1)}%`; + return fmtNum(v, v != null && Math.abs(v) < 100 ? 1 : 0); +} + +export default function FinancialsTab({ ticker }: Props) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [metricKey, setMetricKey] = useState("eps"); + + useEffect(() => { + const ac = new AbortController(); + setLoading(true); + setError(false); + api + .financials(ticker, "annual", ac.signal) + .then((d) => { + setData(d); + if (d.metrics.length && !d.metrics.some((m) => m.key === metricKey)) { + setMetricKey(d.metrics[0]!.key); + } + }) + .catch((e) => { + if (e?.name !== "AbortError") setError(true); + }) + .finally(() => setLoading(false)); + return () => ac.abort(); + // metricKey intentionally excluded — we only reset it from the response. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ticker]); + + const selected = useMemo( + () => data?.metrics.find((m) => m.key === metricKey) ?? data?.metrics[0] ?? null, + [data, metricKey], + ); + + const maxAbs = useMemo(() => { + if (!selected) return 1; + const vals = selected.values.filter((v): v is number => v != null); + const m = Math.max(...vals.map((v) => Math.abs(v)), 0); + return m || 1; + }, [selected]); + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + if (error || !data || data.metrics.length === 0 || data.columns.length === 0) { + return ( +
+

Financials

+

No financial data available for {ticker}.

+
+ ); + } + + const cols = data.columns; + + return ( +
+
+

Financials

+ Annual +
+ + {/* Metric selector */} +
+ {data.metrics.map((m) => ( + + ))} +
+ + {/* Bar chart of the selected metric */} + {selected && ( +
+
+ {selected.label} + · {UNIT_LABEL[selected.unit]} +
+
+ {selected.values.map((v, i) => { + const h = v != null && v > 0 ? Math.max(2, (Math.abs(v) / maxAbs) * 150) : v != null ? 2 : 0; + return ( +
+
{barFmt(v, selected.unit)}
+
+
{cols[i]?.label}
+
+ ); + })} +
+
+ )} + + {/* Full metrics table */} +
+ + + + + {cols.map((c, i) => ( + + ))} + + + + {data.metrics.map((m) => ( + setMetricKey(m.key)} + > + + {m.values.map((v, i) => ( + + ))} + + ))} + +
Metric + {c.label} +
+ {m.label} + {m.unit === "kVND" ? "k₫" : m.unit} + + {tableFmt(v, m.unit)} +
+
+ +

Source: CafeF · figures are per-share values in thousand VND, ratios in %.

+
+ ); +} diff --git a/web/src/components/HoldingsTable.css b/web/src/components/HoldingsTable.css new file mode 100644 index 0000000..c6e0f23 --- /dev/null +++ b/web/src/components/HoldingsTable.css @@ -0,0 +1,160 @@ +.holdings { + padding: 8px; +} + +.holdings--empty { + padding: 32px 24px; + display: flex; + flex-direction: column; + gap: 6px; + align-items: flex-start; +} + +.holdings__empty-title { + font-size: 15px; + font-weight: 600; + color: var(--text); +} + +.holdings__err { + padding: 8px 12px; + font-size: 13px; + font-weight: 500; +} + +.holdings__scroll { + width: 100%; + overflow-x: auto; +} + +.holdings__table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.holdings__th { + padding: 8px 12px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid var(--border-subtle); + white-space: nowrap; +} + +.holdings__th--left { + text-align: left; +} + +.holdings__th--num { + text-align: right; +} + +.holdings__th--trend { + text-align: center; +} + +.holdings__th--actions { + width: 76px; +} + +.holdings__row { + transition: background 0.12s ease; +} + +.holdings__row:hover { + background: var(--surface-hover); +} + +.holdings__td { + padding: 10px 12px; + border-bottom: 1px solid var(--border-subtle); + vertical-align: middle; + white-space: nowrap; + color: var(--text); +} + +.holdings__row:last-child .holdings__td { + border-bottom: none; +} + +.holdings__td--left { + text-align: left; +} + +.holdings__td--num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.holdings__td--trend { + text-align: center; +} + +.holdings__td--num .gf-badge { + justify-content: flex-end; +} + +.holdings__sym { + display: inline-flex; + flex-direction: column; + gap: 1px; +} + +.holdings__ticker { + font-weight: 600; + color: var(--text); +} + +.holdings__sym:hover .holdings__ticker { + color: var(--accent); +} + +.holdings__name { + font-size: 11px; + color: var(--text-secondary); + max-width: 170px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.holdings__gain { + display: block; + font-weight: 500; +} + +.holdings__gain-pct { + display: block; + font-size: 11px; +} + +.holdings__actions { + display: inline-flex; + gap: 4px; + justify-content: flex-end; +} + +.holdings__act { + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 13px; + line-height: 1; + transition: background 0.12s ease, color 0.12s ease; +} + +.holdings__act:hover:not(:disabled) { + background: var(--surface-2); + color: var(--text); +} + +.holdings__act:disabled { + opacity: 0.4; + cursor: not-allowed; +} diff --git a/web/src/components/HoldingsTable.tsx b/web/src/components/HoldingsTable.tsx new file mode 100644 index 0000000..4ca972e --- /dev/null +++ b/web/src/components/HoldingsTable.tsx @@ -0,0 +1,163 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { api } from "../lib/api"; +import type { HoldingRow } from "../lib/types"; +import { dirOf, fmtNum, fmtPct, fmtPriceVnd } from "../lib/format"; +import ChangeBadge from "./ChangeBadge"; +import Sparkline from "./Sparkline"; +import "./HoldingsTable.css"; + +// Plain-VND aggregates (market value / cost / gain) format directly; only the +// board-unit `last` goes through fmtPriceVnd (× 1000). +function fmtVnd(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return "—"; + return `${Math.round(v).toLocaleString("en-US")} ₫`; +} + +function fmtSignedVnd(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return "—"; + const sign = v > 0 ? "+" : v < 0 ? "−" : ""; + return `${sign}${Math.round(Math.abs(v)).toLocaleString("en-US")} ₫`; +} + +interface Props { + holdings: HoldingRow[]; + onChanged: () => void; +} + +export default function HoldingsTable({ holdings, onChanged }: Props) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function edit(h: HoldingRow) { + const qtyStr = window.prompt(`Quantity for ${h.ticker}`, String(h.quantity)); + if (qtyStr == null) return; + const costStr = window.prompt(`Avg cost per share (₫) for ${h.ticker}`, String(h.avgCostVnd)); + if (costStr == null) return; + + const quantity = Number(qtyStr.replace(/[^0-9.]/g, "")); + const avgCostVnd = Number(costStr.replace(/[^0-9.]/g, "")); + const patch: { quantity?: number; avgCostVnd?: number } = {}; + if (Number.isFinite(quantity) && quantity > 0 && quantity !== h.quantity) patch.quantity = quantity; + if (Number.isFinite(avgCostVnd) && avgCostVnd > 0 && avgCostVnd !== h.avgCostVnd) { + patch.avgCostVnd = avgCostVnd; + } + if (Object.keys(patch).length === 0) return; + + setBusy(true); + setError(null); + try { + await api.portfolios.updateHolding(h.id, patch); + onChanged(); + } catch (e) { + setError((e as Error)?.message || "Couldn't update holding."); + } finally { + setBusy(false); + } + } + + async function remove(h: HoldingRow) { + if (!window.confirm(`Remove ${h.ticker} from this portfolio?`)) return; + setBusy(true); + setError(null); + try { + await api.portfolios.removeHolding(h.id); + onChanged(); + } catch (e) { + setError((e as Error)?.message || "Couldn't remove holding."); + } finally { + setBusy(false); + } + } + + if (holdings.length === 0) { + return ( +
+

No holdings yet

+

Add one below to start tracking this portfolio.

+
+ ); + } + + return ( +
+ {error &&

{error}

} +
+ + + + + + + + + + + + + + + + {holdings.map((h) => { + const gd = dirOf(h.gainVnd); + return ( + + + + + + + + + + + + + ); + })} + +
SymbolQtyAvg costPriceDayTrendMkt valueGainWeight +
+ + {h.ticker} + {h.name && {h.name}} + + + {h.quantity.toLocaleString("en-US")} + {fmtVnd(h.avgCostVnd)}{fmtPriceVnd(h.last)} + + + + {fmtVnd(h.marketValueVnd)} + {fmtSignedVnd(h.gainVnd)} + {fmtPct(h.gainPct)} + + {h.weightPct == null ? "—" : `${fmtNum(h.weightPct, 1)}%`} + +
+ + +
+
+
+
+ ); +} diff --git a/web/src/components/IndexCard.css b/web/src/components/IndexCard.css new file mode 100644 index 0000000..ae110f5 --- /dev/null +++ b/web/src/components/IndexCard.css @@ -0,0 +1,58 @@ +.indexcard { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 160px; + padding: 12px 14px; + /* Flatter than a default gf-card: keep the thin border, drop the shadow. */ + box-shadow: none; +} + +.indexcard__name { + color: var(--text-secondary); + font-weight: 500; + font-size: 12.5px; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.indexcard__value { + color: var(--text); + font-size: 17px; + font-weight: 500; + line-height: 1.15; +} + +.indexcard__change { + display: flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + line-height: 1.2; +} + +.indexcard__abs { + font-variant-numeric: tabular-nums; +} +.indexcard__change.up .indexcard__abs { + color: var(--up); +} +.indexcard__change.down .indexcard__abs { + color: var(--down); +} +.indexcard__change.flat .indexcard__abs { + color: var(--text-muted); +} + +.indexcard__spark { + width: 100%; + margin-top: 3px; +} + +.indexcard__spark svg { + display: block; + width: 100%; + height: 30px; +} diff --git a/web/src/components/IndexCard.tsx b/web/src/components/IndexCard.tsx new file mode 100644 index 0000000..6f48756 --- /dev/null +++ b/web/src/components/IndexCard.tsx @@ -0,0 +1,26 @@ +import "./IndexCard.css"; +import type { IndexSnapshot } from "../lib/types"; +import { dirOf, fmtIndex, fmtChangeAbs } from "../lib/format"; +import ChangeBadge from "./ChangeBadge"; +import Sparkline from "./Sparkline"; + +interface Props { + index: IndexSnapshot; +} + +export default function IndexCard({ index }: Props) { + const dir = dirOf(index.change_pct_1d); + return ( +
+ {index.name} +
{fmtIndex(index.latest_close)}
+
+ {fmtChangeAbs(index.change_abs)} + +
+
+ +
+
+ ); +} diff --git a/web/src/components/MarketStrip.css b/web/src/components/MarketStrip.css new file mode 100644 index 0000000..f58b073 --- /dev/null +++ b/web/src/components/MarketStrip.css @@ -0,0 +1,57 @@ +.marketstrip { + display: flex; + flex-direction: column; + gap: 8px; +} + +.marketstrip__label { + font-size: 12px; + font-weight: 500; + letter-spacing: 0.02em; +} + +.marketstrip__row { + display: flex; + gap: 12px; + overflow-x: auto; + scroll-snap-type: x proximity; + padding-bottom: 4px; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + -webkit-overflow-scrolling: touch; +} + +.marketstrip__row::-webkit-scrollbar { + height: 6px; +} + +.marketstrip__row::-webkit-scrollbar-track { + background: transparent; +} + +.marketstrip__row::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: var(--pill); +} + +.marketstrip__row::-webkit-scrollbar-thumb:hover { + background: var(--border-subtle); +} + +.marketstrip__item { + flex: 0 0 auto; + scroll-snap-align: start; +} + +.marketstrip__skeleton { + flex: 0 0 auto; + scroll-snap-align: start; + width: 160px; + height: 116px; + border-radius: var(--radius); +} + +.marketstrip__error { + font-size: 13px; + padding: 8px 0; +} diff --git a/web/src/components/MarketStrip.tsx b/web/src/components/MarketStrip.tsx new file mode 100644 index 0000000..18c1bdd --- /dev/null +++ b/web/src/components/MarketStrip.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; +import type { IndexSnapshot } from "../lib/types"; +import { api, ApiError } from "../lib/api"; +import IndexCard from "./IndexCard"; +import "./MarketStrip.css"; + +export default function MarketStrip() { + const [indices, setIndices] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const ctrl = new AbortController(); + setError(null); + setIndices(null); + api + .indices(ctrl.signal) + .then((res) => setIndices(res.indices)) + .catch((err) => { + if (err?.name === "AbortError") return; + setError(err instanceof ApiError ? err.message : "Couldn't load market indices."); + }); + return () => ctrl.abort(); + }, []); + + return ( +
+ Vietnam + + {error ? ( +

Couldn't load market indices.

+ ) : ( +
+ {indices == null + ? Array.from({ length: 4 }).map((_, i) => ( +
+ )) + : indices.map((idx) => ( +
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/web/src/components/MarketSummary.css b/web/src/components/MarketSummary.css new file mode 100644 index 0000000..2c9b504 --- /dev/null +++ b/web/src/components/MarketSummary.css @@ -0,0 +1,114 @@ +/* MarketSummary — Google-Finance-style market news block (home page) */ + +.market-summary { + padding: 8px 8px 4px; +} + +.market-summary__title { + font-size: 18px; + padding: 8px 12px 4px; + margin: 0; +} + +.market-summary__rows { + display: flex; + flex-direction: column; +} + +/* Each row: a stacked link (meta → headline → snippet) */ +.market-summary__row { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px; + border-radius: var(--radius-sm); + border-top: 1px solid var(--border-subtle); + color: inherit; + text-decoration: none; + transition: background 0.12s ease; +} + +.market-summary__row:first-child { + border-top: none; +} + +a.market-summary__row:hover { + background: var(--surface-hover); +} + +.market-summary__row--skeleton { + pointer-events: none; +} + +/* Meta line: source chip + relative time */ +.market-summary__meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.market-summary__source { + padding: 2px 8px; + font-size: 11px; +} + +.market-summary__time { + color: var(--text-muted); + font-size: 12px; + line-height: 1.3; +} + +/* Headline — near-black, clamp to 2 lines */ +.market-summary__headline { + color: var(--text); + font-size: 15px; + font-weight: 500; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Snippet — muted secondary text, clamp to 2 lines */ +.market-summary__snippet { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.market-summary__empty { + padding: 16px 12px; + color: var(--text-muted); + font-size: 13px; +} + +/* Skeleton */ +.market-summary__sk-line { + display: block; + height: 13px; + border-radius: 6px; +} + +.market-summary__sk-line--chip { + width: 72px; + height: 18px; + border-radius: var(--pill); +} + +.market-summary__sk-line--title { + width: 88%; + height: 15px; +} + +.market-summary__sk-line--snippet { + width: 70%; + height: 13px; +} diff --git a/web/src/components/MarketSummary.tsx b/web/src/components/MarketSummary.tsx new file mode 100644 index 0000000..5793e98 --- /dev/null +++ b/web/src/components/MarketSummary.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from "react"; +import { api } from "../lib/api"; +import type { NewsItem } from "../lib/types"; +import { fmtRelativeTime } from "../lib/format"; +import "./MarketSummary.css"; + +/** + * Google-Finance-style "market summary" news block for the home page. + * A titled card with a list of headline rows, each a link with a source chip, + * relative time, and an optional clamped snippet. Replaces the plain NewsList + * on Home (NewsList is still used by the Quote page news tab). + */ +export default function MarketSummary() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + const ac = new AbortController(); + setLoading(true); + setError(false); + api + .marketNews(ac.signal) + .then((r) => setItems(r.items)) + .catch((e) => { + if (e?.name !== "AbortError") { + setItems([]); + setError(true); + } + }) + .finally(() => setLoading(false)); + return () => ac.abort(); + }, []); + + return ( +
+

Today's financial news

+ + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+ +
+ + +
+ ))} +
+ ) : error ? ( +
Couldn't load market news.
+ ) : items.length === 0 ? ( +
No market news available.
+ ) : ( +
+ {items.map((item, i) => { + const rel = fmtRelativeTime(item.publishedAt); + const inner = ( + <> + {item.source || rel ? ( +
+ {item.source ? ( + {item.source} + ) : null} + {rel ? {rel} : null} +
+ ) : null} +
{item.title}
+ {item.snippet ? ( +
{item.snippet}
+ ) : null} + + ); + return item.url ? ( + + {inner} + + ) : ( +
+ {inner} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/web/src/components/NewsList.css b/web/src/components/NewsList.css new file mode 100644 index 0000000..8a19884 --- /dev/null +++ b/web/src/components/NewsList.css @@ -0,0 +1,138 @@ +/* NewsList — Google-Finance-style news feed (presentational) */ + +.newslist { + display: block; +} + +.newslist__title { + margin-bottom: 12px; +} + +.newslist__rows { + display: flex; + flex-direction: column; +} + +/* Each row: logo + body */ +.newslist__row { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 8px; + border-radius: var(--radius-sm); + border-bottom: 1px solid var(--border-subtle); + color: inherit; + transition: background 0.12s ease; +} + +.newslist__row:last-child { + border-bottom: none; +} + +a.newslist__row:hover { + background: var(--surface-hover); +} + +.newslist__row--skeleton { + pointer-events: none; +} + +/* Source monogram avatar */ +.newslist__logo { + flex: none; + width: 24px; + height: 24px; + margin-top: 1px; + border-radius: 50%; + background: var(--surface-2); + color: var(--text-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + line-height: 1; + user-select: none; +} + +.newslist__body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Meta line */ +.newslist__meta { + display: flex; + align-items: center; + flex-wrap: wrap; + font-size: 12px; + line-height: 1.3; +} + +.newslist__source { + color: var(--text-secondary); + font-weight: 500; +} + +.newslist__dot { + color: var(--text-muted); + white-space: pre; +} + +.newslist__time { + color: var(--text-muted); +} + +/* Headline — clamp to 2 lines */ +.newslist__headline { + color: var(--text); + font-size: 15px; + font-weight: 500; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Snippet — clamp to 2 lines */ +.newslist__snippet { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.newslist__empty { + padding: 16px 8px; + font-size: 13px; +} + +/* Skeleton lines */ +.newslist__sk-line { + height: 12px; + border-radius: 6px; +} + +.newslist__sk-line--meta { + width: 40%; + height: 10px; +} + +.newslist__sk-line--title { + width: 92%; + height: 15px; +} + +.newslist__sk-line--snippet { + width: 78%; + height: 12px; +} diff --git a/web/src/components/NewsList.tsx b/web/src/components/NewsList.tsx new file mode 100644 index 0000000..e194d8d --- /dev/null +++ b/web/src/components/NewsList.tsx @@ -0,0 +1,89 @@ +import "./NewsList.css"; +import type { NewsItem } from "../lib/types"; +import { fmtRelativeTime } from "../lib/format"; + +interface NewsListProps { + items: NewsItem[]; + loading?: boolean; + title?: string; + compact?: boolean; +} + +/** First letter of a source name, uppercased, for the monogram avatar. */ +function monogram(source: string | undefined): string { + const ch = source?.trim()?.[0]; + return ch ? ch.toUpperCase() : "•"; +} + +function NewsRowInner({ item, compact }: { item: NewsItem; compact?: boolean }) { + const rel = fmtRelativeTime(item.publishedAt); + return ( + <> + +
+
+ {item.source ? ( + <> + {item.source} + {rel ? · : null} + + ) : null} + {rel ? {rel} : null} +
+
{item.title}
+ {!compact && item.snippet ? ( +
{item.snippet}
+ ) : null} +
+ + ); +} + +export default function NewsList({ items, loading, title, compact }: NewsListProps) { + return ( +
+ {title ?

{title}

: null} + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+ {!compact ? ( +
+ ) : null} +
+
+ ))} +
+ ) : items.length === 0 ? ( +
No news available.
+ ) : ( +
+ {items.map((item, i) => + item.url ? ( + + + + ) : ( +
+ +
+ ) + )} +
+ )} +
+ ); +} diff --git a/web/src/components/PortfolioSummary.css b/web/src/components/PortfolioSummary.css new file mode 100644 index 0000000..40ccdee --- /dev/null +++ b/web/src/components/PortfolioSummary.css @@ -0,0 +1,73 @@ +.psum { + padding: 20px 24px; +} + +.psum__primary { + display: flex; + flex-direction: column; + gap: 6px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border-subtle); +} + +.psum__label { + font-size: 13px; +} + +.psum__value { + font-size: 32px; + font-weight: 500; + letter-spacing: -0.5px; + color: var(--text); +} + +.psum__primary-change { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin-top: 2px; +} + +.psum__primary-abs { + font-size: 15px; + font-weight: 500; +} + +.psum__primary-note { + font-size: 12px; +} + +.psum__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + padding-top: 18px; +} + +.psum__tile { + display: flex; + flex-direction: column; + gap: 4px; +} + +.psum__tile-label { + font-size: 12px; +} + +.psum__tile-value { + font-size: 18px; + font-weight: 500; + color: var(--text); +} + +@media (max-width: 640px) { + .psum__grid { + grid-template-columns: 1fr; + gap: 14px; + } + + .psum__value { + font-size: 26px; + } +} diff --git a/web/src/components/PortfolioSummary.tsx b/web/src/components/PortfolioSummary.tsx new file mode 100644 index 0000000..0213104 --- /dev/null +++ b/web/src/components/PortfolioSummary.tsx @@ -0,0 +1,59 @@ +import { dirOf } from "../lib/format"; +import type { PortfolioTotals } from "../lib/types"; +import ChangeBadge from "./ChangeBadge"; +import "./PortfolioSummary.css"; + +// Portfolio totals are plain-VND aggregates (already scaled), so they are +// formatted directly — never multiplied by the board price scale. +function fmtVnd(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return "—"; + return `${Math.round(v).toLocaleString("en-US")} ₫`; +} + +function fmtSignedVnd(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return "—"; + const sign = v > 0 ? "+" : v < 0 ? "−" : ""; + return `${sign}${Math.round(Math.abs(v)).toLocaleString("en-US")} ₫`; +} + +interface Props { + totals: PortfolioTotals; +} + +export default function PortfolioSummary({ totals }: Props) { + const gainDir = dirOf(totals.gainVnd); + const dayDir = dirOf(totals.dayChangeVnd); + + return ( +
+
+
Total value
+
{fmtVnd(totals.marketValueVnd)}
+
+ + {fmtSignedVnd(totals.gainVnd)} + all time +
+
+ +
+
+
Today
+
{fmtSignedVnd(totals.dayChangeVnd)}
+ +
+ +
+
Total gain/loss
+
{fmtSignedVnd(totals.gainVnd)}
+ +
+ +
+
Cost basis
+
{fmtVnd(totals.costBasisVnd)}
+
+
+
+ ); +} diff --git a/web/src/components/PriceChart.css b/web/src/components/PriceChart.css new file mode 100644 index 0000000..fa733f5 --- /dev/null +++ b/web/src/components/PriceChart.css @@ -0,0 +1,255 @@ +/* PriceChart — Google-Finance-style interactive price chart. */ + +.pchart { + padding: 12px 16px; +} + +.pchart__toolbar { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.pchart__group { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +/* Range buttons reuse .gf-pill; tighten for a denser toolbar. */ +.pchart__pill { + cursor: pointer; + height: 30px; + padding: 0 12px; +} +.pchart__pill:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +/* ---- Dropdown menus (Chart type / Indicators / Compare) ---------------- */ +.pchart__menu { + position: relative; +} + +/* Dropdown trigger — a .gf-pill with a trailing caret. */ +.pchart__trigger { + cursor: pointer; + height: 30px; + padding: 0 10px 0 12px; +} +.pchart__trigger:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.pchart__caret { + flex: none; + opacity: 0.7; +} + +/* The floating menu card, positioned under its trigger. */ +.pchart__menu-pop { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 20; + min-width: 190px; + padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; + box-shadow: var(--shadow-2); +} + +.pchart__menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 7px 10px; + border: none; + background: none; + border-radius: var(--radius-sm); + color: var(--text); + font-family: inherit; + font-size: 13.5px; + text-align: left; + cursor: pointer; +} +.pchart__menu-item:hover { + background: var(--surface-hover); +} + +/* Radio mark (chart type) — just a checkmark in an accent color. */ +.pchart__menu-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex-shrink: 0; + color: var(--accent); +} + +/* Checkbox box (indicators) — filled with accent when on. */ +.pchart__menu-box { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex-shrink: 0; + border-radius: 5px; + border: 1.5px solid var(--border); + background: var(--surface); + color: var(--on-accent); +} +.pchart__menu-item.is-checked .pchart__menu-box { + background: var(--accent); + border-color: var(--accent); +} + +.pchart__chart-wrap { + position: relative; + width: 100%; +} + +.pchart__chart { + width: 100%; + height: 380px; +} + +/* Loading + error overlays sit on top of the chart container. */ +.pchart__overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--surface) 55%, transparent); + border-radius: var(--radius-sm); + z-index: 2; +} + +.pchart__spinner { + width: 28px; + height: 28px; + border-radius: 50%; + border: 3px solid var(--border-subtle); + border-top-color: var(--accent); + animation: pchart-spin 0.8s linear infinite; +} + +@keyframes pchart-spin { + to { + transform: rotate(360deg); + } +} + +.pchart__error { + font-size: 13px; + color: var(--text-muted); +} + +.pchart__empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + color: var(--text-muted); + pointer-events: none; +} + +/* ---- Compare control (popover layers on .pchart__menu-pop) -------------- */ +.pchart__compare-pop { + width: 240px; + padding: 10px; + gap: 8px; +} + +.pchart__compare-input { + width: 100%; + height: 34px; + padding: 0 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + font-family: inherit; + font-size: 13px; + outline: none; +} +.pchart__compare-input:focus { + border-color: var(--accent); +} + +.pchart__compare-suggest { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.pchart__compare-chip { + cursor: pointer; + border: none; + font-variant-numeric: tabular-nums; +} +.pchart__compare-chip:hover { + background: var(--surface-hover); + color: var(--text); +} + +.pchart__compare-note { + font-size: 11px; +} + +/* ---- Compare legend ---------------------------------------------------- */ +.pchart__legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px; + margin: 2px 0 10px; + font-size: 13px; + font-weight: 500; +} + +.pchart__legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text); +} + +.pchart__legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex: none; +} + +.pchart__legend-x { + border: none; + background: none; + color: var(--text-muted); + font-size: 15px; + line-height: 1; + padding: 2px 4px; + border-radius: 50%; +} +.pchart__legend-x:hover { + background: var(--surface-hover); + color: var(--text); +} + +.pchart__legend-note { + font-weight: 400; + font-size: 12px; +} diff --git a/web/src/components/PriceChart.tsx b/web/src/components/PriceChart.tsx new file mode 100644 index 0000000..04e9e1a --- /dev/null +++ b/web/src/components/PriceChart.tsx @@ -0,0 +1,736 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + createChart, + ColorType, + LineStyle, + CrosshairMode, + type IChartApi, + type ISeriesApi, + type IPriceLine, + type UTCTimestamp, +} from "lightweight-charts"; +import { RANGE_KEYS } from "../lib/types"; +import type { RangeKey, OhlcvResponse, IndicatorsResponse } from "../lib/types"; +import { api } from "../lib/api"; +import "./PriceChart.css"; + +interface PriceChartProps { + ticker: string; + prevCloseHint?: number | null; +} + +// A distinct muted orange for the SMA-50 overlay (readable in both themes). +const SMA50_COLOR = "#f9ab00"; + +// Palette for compare lines (base ticker uses the theme accent). +const COMPARE_PALETTE = ["#e8710a", "#12b5cb", "#9334e6", "#e52592", "#1e8e3e"]; +const COMPARE_SUGGESTED = ["VNM", "VCB", "FPT", "HPG", "VIC", "MWG", "TCB", "MSN"]; +const MAX_COMPARE = 4; + +/** Convert a #rgb / #rrggbb hex string to an rgba() string with the given alpha. */ +function hexToRgba(hex: string, a: number): string { + let h = (hex || "").replace("#", "").trim(); + if (h.length === 3) { + h = h + .split("") + .map((c) => c + c) + .join(""); + } + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + if ([r, g, b].some((n) => Number.isNaN(n))) return `rgba(0,0,0,${a})`; + return `rgba(${r}, ${g}, ${b}, ${a})`; +} + +interface ThemeColors { + text: string; + grid: string; + up: string; + down: string; + accent: string; + muted: string; +} + +function readColors(): ThemeColors { + const cs = getComputedStyle(document.documentElement); + const v = (name: string) => cs.getPropertyValue(name).trim(); + return { + text: v("--text-secondary") || "#5f6368", + grid: v("--border-subtle") || "#e8eaed", + up: v("--up-strong") || "#1e8e3e", + down: v("--down-strong") || "#d93025", + accent: v("--accent") || "#1a73e8", + muted: v("--text-muted") || "#80868b", + }; +} + +type TimedPoint = { time: UTCTimestamp; value: number }; + +/** Which toolbar dropdown is currently open (mutually exclusive). */ +type MenuKey = "type" | "indicators" | "compare" | null; + +/** Small downward chevron for dropdown triggers. */ +function Caret() { + return ( + + ); +} + +/** Checkmark used by menu items that reflect on/off or selected state. */ +function MenuCheck() { + return ( + + ); +} + +/** Sort ascending by time, drop non-finite values, and collapse duplicate timestamps. */ +function cleanLine( + points: T[] | undefined, + getVal: (p: T) => number, +): TimedPoint[] { + if (!points || points.length === 0) return []; + const out: TimedPoint[] = []; + const sorted = [...points].sort((a, b) => a.time - b.time); + let lastTime = -Infinity; + for (const p of sorted) { + const value = getVal(p); + if (!Number.isFinite(p.time) || !Number.isFinite(value)) continue; + if (p.time === lastTime) { + out[out.length - 1] = { time: p.time as UTCTimestamp, value }; + } else { + out.push({ time: p.time as UTCTimestamp, value }); + } + lastTime = p.time; + } + return out; +} + +export default function PriceChart({ ticker, prevCloseHint }: PriceChartProps) { + const [range, setRange] = useState("6M"); + const [chartType, setChartType] = useState<"area" | "candlestick">("area"); + const [showSMA, setShowSMA] = useState(false); + const [showBoll, setShowBoll] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const [bars, setBars] = useState(null); + const [indicators, setIndicators] = useState(null); + const [themeVersion, setThemeVersion] = useState(0); + + // Compare (multi-ticker normalized % overlay). + const [compareTickers, setCompareTickers] = useState([]); + const [compareBars, setCompareBars] = useState>({}); + const [compareInput, setCompareInput] = useState(""); + + // Toolbar dropdowns — only one open at a time (chart type / indicators / compare). + const [openMenu, setOpenMenu] = useState(null); + const toolbarRef = useRef(null); + + const containerRef = useRef(null); + const chartRef = useRef(null); + const mainSeriesRef = useRef | null>(null); + const overlayRefs = useRef[]>([]); + const priceLineRef = useRef(null); + const compareSeriesRef = useRef[]>([]); + + const intraday = bars?.intraday ?? false; + const compareMode = compareTickers.length > 0; + + const addCompare = (raw: string) => { + const t = raw.trim().toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 12); + if (!t || t === ticker || compareTickers.includes(t) || compareTickers.length >= MAX_COMPARE) return; + setCompareTickers((prev) => [...prev, t]); + setCompareInput(""); + }; + const removeCompare = (t: string) => setCompareTickers((prev) => prev.filter((x) => x !== t)); + + // Reset comparisons when navigating to a different ticker. + useEffect(() => { + setCompareTickers([]); + setOpenMenu(null); + setCompareInput(""); + }, [ticker]); + + // Close whichever toolbar dropdown is open on outside click or Escape. + useEffect(() => { + if (!openMenu) return; + const onDown = (e: MouseEvent) => { + if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) { + setOpenMenu(null); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpenMenu(null); + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [openMenu]); + + // Apply theme-derived layout/axis options to the chart. Reads colors live. + const applyTheme = useCallback(() => { + const chart = chartRef.current; + if (!chart) return; + const c = readColors(); + chart.applyOptions({ + layout: { + background: { type: ColorType.Solid, color: "transparent" }, + textColor: c.text, + }, + grid: { + vertLines: { visible: false }, + horzLines: { color: c.grid }, + }, + rightPriceScale: { borderColor: c.grid }, + timeScale: { + borderColor: c.grid, + timeVisible: intraday, + secondsVisible: false, + }, + crosshair: { mode: CrosshairMode.Normal }, + }); + }, [intraday]); + + // (1) Create the chart once, plus resize + theme observers. + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const chart = createChart(container, { + width: container.clientWidth, + height: 380, + autoSize: false, + }); + chartRef.current = chart; + applyTheme(); + + const ro = new ResizeObserver(() => { + if (chartRef.current && container) { + chartRef.current.applyOptions({ width: container.clientWidth }); + } + }); + ro.observe(container); + + const mo = new MutationObserver(() => { + setThemeVersion((v) => v + 1); + }); + mo.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + + return () => { + ro.disconnect(); + mo.disconnect(); + overlayRefs.current = []; + mainSeriesRef.current = null; + priceLineRef.current = null; + chart.remove(); + chartRef.current = null; + }; + // Create exactly once for the component's lifetime. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // (2) Re-apply theme options whenever the theme flips or intraday-ness changes. + useEffect(() => { + applyTheme(); + }, [applyTheme, themeVersion]); + + // (3) Fetch bars + indicators on [ticker, range]. + useEffect(() => { + const ctrl = new AbortController(); + setLoading(true); + setError(false); + Promise.all([ + api.ohlcv(ticker, range, ctrl.signal), + api.indicators(ticker, range, ctrl.signal), + ]) + .then(([ohlcv, ind]) => { + if (ctrl.signal.aborted) return; + setBars(ohlcv); + setIndicators(ind); + setLoading(false); + }) + .catch((err: unknown) => { + if (ctrl.signal.aborted || (err as { name?: string })?.name === "AbortError") return; + setError(true); + setLoading(false); + }); + return () => ctrl.abort(); + }, [ticker, range]); + + // (4) Draw the main series on [bars, chartType, themeVersion]. + useEffect(() => { + const chart = chartRef.current; + if (!chart) return; + + // Remove previous main series (and its price line). + if (mainSeriesRef.current) { + try { + chart.removeSeries(mainSeriesRef.current); + } catch { + /* series already gone */ + } + mainSeriesRef.current = null; + priceLineRef.current = null; + } + + // In compare mode the compare-draw effect owns the chart's series. + if (compareMode) return; + + const rows = bars?.bars ?? []; + if (rows.length === 0) return; + + const c = readColors(); + const firstClose = rows[0].close; + const lastClose = rows[rows.length - 1].close; + const dirColor = lastClose >= firstClose ? c.up : c.down; + + // lightweight-charts renders timestamps in UTC. For intraday ranges, shift by + // +7h so the axis/crosshair show Vietnam exchange-local time (ICT). Daily+ bars + // are left untouched (shifting could cross a day boundary and mislabel dates). + const tz = bars?.intraday ? 7 * 3600 : 0; + const rowsTz = tz ? rows.map((b) => ({ ...b, time: b.time + tz })) : rows; + + if (chartType === "area") { + const series = chart.addAreaSeries({ + lineColor: dirColor, + topColor: hexToRgba(dirColor, 0.2), + bottomColor: hexToRgba(dirColor, 0), + lineWidth: 2, + priceLineVisible: false, + }); + series.setData(cleanLine(rowsTz, (b) => b.close)); + mainSeriesRef.current = series; + } else { + const series = chart.addCandlestickSeries({ + upColor: c.up, + downColor: c.down, + borderVisible: false, + wickUpColor: c.up, + wickDownColor: c.down, + }); + // Candles need ascending unique times; dedupe by timestamp. + const seen = new Set(); + const data = [...rowsTz] + .sort((a, b) => a.time - b.time) + .filter((b) => { + if ( + seen.has(b.time) || + ![b.open, b.high, b.low, b.close].every((n) => Number.isFinite(n)) + ) { + return false; + } + seen.add(b.time); + return true; + }) + .map((b) => ({ + time: b.time as UTCTimestamp, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + })); + series.setData(data); + mainSeriesRef.current = series; + } + + // Previous-close dashed line for intraday ranges. + if (range === "1D" || range === "5D") { + const prev = bars?.prevClose ?? prevCloseHint; + if (prev != null && Number.isFinite(prev) && mainSeriesRef.current) { + priceLineRef.current = mainSeriesRef.current.createPriceLine({ + price: prev, + color: c.muted, + lineStyle: LineStyle.Dashed, + lineWidth: 1, + axisLabelVisible: true, + title: "Prev close", + }); + } + } + + chart.timeScale().fitContent(); + }, [bars, chartType, themeVersion, range, prevCloseHint, compareMode]); + + // (5) Draw indicator overlays on [indicators, showSMA, showBoll, bars, themeVersion]. + useEffect(() => { + const chart = chartRef.current; + if (!chart) return; + + // Clear existing overlays. + for (const s of overlayRefs.current) { + try { + chart.removeSeries(s); + } catch { + /* already gone */ + } + } + overlayRefs.current = []; + + // Overlays only make sense on non-intraday ranges, and not while comparing. + if (!bars || bars.intraday || !indicators || compareMode) return; + const c = readColors(); + + const addLine = ( + data: TimedPoint[], + color: string, + opts?: { width?: number; dashed?: boolean }, + ) => { + if (data.length === 0) return; + const s = chart.addLineSeries({ + color, + lineWidth: (opts?.width ?? 1.5) as 1 | 2 | 3 | 4, + lineStyle: opts?.dashed ? LineStyle.Dashed : LineStyle.Solid, + priceLineVisible: false, + lastValueVisible: false, + crosshairMarkerVisible: false, + }); + s.setData(data); + overlayRefs.current.push(s); + }; + + if (showSMA) { + addLine(cleanLine(indicators.sma20, (p) => p.value), c.accent, { width: 1.5 }); + addLine(cleanLine(indicators.sma50, (p) => p.value), SMA50_COLOR, { width: 1.5 }); + } + + if (showBoll) { + addLine(cleanLine(indicators.bollinger, (p) => p.upper), c.muted, { + width: 1, + dashed: true, + }); + addLine(cleanLine(indicators.bollinger, (p) => p.middle), c.muted, { width: 1 }); + addLine(cleanLine(indicators.bollinger, (p) => p.lower), c.muted, { + width: 1, + dashed: true, + }); + } + }, [indicators, showSMA, showBoll, bars, themeVersion, compareMode]); + + // (6a) Fetch OHLCV for each compare ticker on [compareTickers, range]. + useEffect(() => { + if (compareTickers.length === 0) { + setCompareBars({}); + return; + } + const ctrl = new AbortController(); + Promise.all( + compareTickers.map((t) => + api + .ohlcv(t, range, ctrl.signal) + .then((r) => [t, r] as const) + .catch(() => null), + ), + ).then((pairs) => { + if (ctrl.signal.aborted) return; + const map: Record = {}; + for (const p of pairs) if (p) map[p[0]] = p[1]; + setCompareBars(map); + }); + return () => ctrl.abort(); + }, [compareTickers, range]); + + // (6b) Draw normalized % comparison lines. + useEffect(() => { + const chart = chartRef.current; + if (!chart) return; + + for (const s of compareSeriesRef.current) { + try { + chart.removeSeries(s); + } catch { + /* already gone */ + } + } + compareSeriesRef.current = []; + + if (!compareMode || !bars || bars.bars.length === 0) return; + const c = readColors(); + + const pctFmt = { + type: "custom" as const, + minMove: 0.01, + formatter: (p: number) => `${p >= 0 ? "+" : ""}${p.toFixed(1)}%`, + }; + + // Normalize a series to % change from its first bar (with intraday tz shift). + const normalized = (ohlcv: OhlcvResponse): TimedPoint[] => { + const rows = ohlcv.bars; + const base = rows[0]?.close; + if (!base) return []; + const tzS = ohlcv.intraday ? 7 * 3600 : 0; + return cleanLine( + rows.map((b) => ({ time: b.time + tzS, value: (b.close / base - 1) * 100 })), + (p) => p.value, + ); + }; + + const addNorm = (data: TimedPoint[], color: string) => { + if (data.length === 0) return; + const s = chart.addLineSeries({ + color, + lineWidth: 2, + priceLineVisible: false, + lastValueVisible: true, + crosshairMarkerVisible: true, + priceFormat: pctFmt, + }); + s.setData(data); + compareSeriesRef.current.push(s); + }; + + // Base ticker first (accent), then each compare from the palette. + addNorm(normalized(bars), c.accent); + compareTickers.forEach((t, i) => { + const ob = compareBars[t]; + if (ob) addNorm(normalized(ob), COMPARE_PALETTE[i % COMPARE_PALETTE.length]!); + }); + + chart.timeScale().fitContent(); + }, [compareMode, compareBars, bars, range, themeVersion, compareTickers]); + + const hasData = !!bars && bars.bars.length > 0; + const overlaysDisabled = intraday; + + return ( +
+
+
+ {/* Chart type ▾ — Area vs Candlestick (drives the same chartType state). */} +
+ + {openMenu === "type" && ( +
+ + +
+ )} +
+ + {/* Indicators ▾ — SMA / Bollinger toggles (same showSMA / showBoll state). */} +
+ + {openMenu === "indicators" && ( +
+ + +
+ )} +
+ + {/* Compare ▾ — existing multi-ticker popover, trigger restyled to match. */} +
+ + {openMenu === "compare" && ( +
+ setCompareInput(e.target.value.toUpperCase())} + onKeyDown={(e) => { + if (e.key === "Enter") addCompare(compareInput); + else if (e.key === "Escape") setOpenMenu(null); + }} + maxLength={12} + aria-label="Add a ticker to compare" + /> +
+ {COMPARE_SUGGESTED.filter((t) => t !== ticker && !compareTickers.includes(t)) + .slice(0, 6) + .map((t) => ( + + ))} +
+ {compareTickers.length >= MAX_COMPARE && ( + Max {MAX_COMPARE} comparisons + )} +
+ )} +
+
+ +
+ {RANGE_KEYS.map((r) => ( + + ))} +
+
+ + {compareMode && ( +
+ + + {ticker} + + {compareTickers.map((t, i) => ( + + + {t} + + + ))} + % change over {range} +
+ )} + +
+
+ + {loading && ( +
+
+
+ )} + {!loading && error && ( +
+ Couldn't load chart +
+ )} + {!loading && !error && !hasData && ( +
No price data
+ )} +
+
+ ); +} diff --git a/web/src/components/QuoteHeader.css b/web/src/components/QuoteHeader.css new file mode 100644 index 0000000..5ee4664 --- /dev/null +++ b/web/src/components/QuoteHeader.css @@ -0,0 +1,270 @@ +.qh { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* ---- Breadcrumb -------------------------------------------------------- */ +.qh__crumb { + font-size: 13px; + color: var(--text-secondary); +} +.qh__crumb-link { + color: var(--text-secondary); +} +.qh__crumb-link:hover { + color: var(--text); + text-decoration: underline; +} +.qh__crumb-sep { + color: var(--text-muted); +} + +/* ---- Title row --------------------------------------------------------- */ +.qh__title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} +.qh__name { + font-size: 26px; + font-weight: 500; + line-height: 1.2; + color: var(--text); + margin: 0; +} +.qh__actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +/* ---- Add-to-list popover ---------------------------------------------- */ +.qh__addwrap { + position: relative; +} +.qh__add { + cursor: pointer; +} +.qh__add > span:first-child { + display: inline-flex; + align-items: center; +} +.qh__add--saved { + color: var(--accent); +} + +.qh__addpop { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 30; + width: 260px; + max-width: min(260px, 80vw); + padding: 10px; + display: flex; + flex-direction: column; + gap: 6px; + box-shadow: var(--shadow-2); +} + +.qh__addpop-head { + padding: 2px 4px 4px; +} + +.qh__addpop-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 240px; + overflow-y: auto; +} + +.qh__addpop-empty { + padding: 8px 6px; + font-size: 13px; +} + +.qh__addpop-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 7px 8px; + border: none; + background: none; + border-radius: var(--radius-sm); + color: var(--text); + font-family: inherit; + font-size: 13.5px; + text-align: left; + cursor: pointer; +} +.qh__addpop-item:hover { + background: var(--surface-hover); +} + +.qh__addpop-box { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex-shrink: 0; + border-radius: 5px; + border: 1.5px solid var(--border); + color: var(--on-accent); + background: var(--surface); +} +.qh__addpop-item.is-checked .qh__addpop-box { + background: var(--accent); + border-color: var(--accent); +} + +.qh__addpop-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.qh__addpop-divider { + height: 1px; + background: var(--border-subtle); + margin: 2px 0; +} + +.qh__addpop-new { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px; + border: none; + background: none; + border-radius: var(--radius-sm); + color: var(--accent); + font-family: inherit; + font-size: 13.5px; + font-weight: 500; + text-align: left; + cursor: pointer; +} +.qh__addpop-new:hover { + background: var(--accent-subtle); +} + +/* ---- Price row --------------------------------------------------------- */ +.qh__price-row { + display: flex; + align-items: baseline; + gap: 12px; + flex-wrap: wrap; +} +.qh__price { + font-size: 34px; + font-weight: 500; + line-height: 1.1; + color: var(--text); +} +.qh__change { + display: inline-flex; + align-items: baseline; + gap: 8px; +} +.qh__today { + font-size: 14px; + color: var(--text-secondary); +} + +/* ---- Performance summary ---------------------------------------------- */ +.qh__perf { + margin: 0; + font-size: 13px; + line-height: 1.4; +} +.qh__perf-pct { + font-weight: 500; + font-variant-numeric: tabular-nums; +} +.qh__perf-pct--up { + color: var(--up); +} +.qh__perf-pct--down { + color: var(--down); +} +.qh__perf-pct--flat { + color: var(--flat); +} + +.qh__perf-row { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 18px; + font-size: 12.5px; +} +.qh__perf-item { + display: inline-flex; + align-items: baseline; + gap: 6px; +} +.qh__perf-label { + color: var(--text-muted); +} +.qh__perf-val { + font-variant-numeric: tabular-nums; +} +.qh__perf-val--up { + color: var(--up); +} +.qh__perf-val--down { + color: var(--down); +} +.qh__perf-val--flat { + color: var(--flat); +} + +/* ---- Sub-line ---------------------------------------------------------- */ +.qh__subline { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + font-size: 12.5px; +} +.qh__session { + font-size: 12.5px; +} +.qh__session-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.qh__meta { + font-size: 12.5px; +} + +/* Ceiling = purple, Floor = cyan (VN board convention). */ +.qh__ceiling { + color: #a142f4; +} +.qh__floor { + color: #00acc1; +} +:root[data-theme="dark"] .qh__ceiling { + color: #c58af9; +} +:root[data-theme="dark"] .qh__floor { + color: #4dd0e1; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .qh__ceiling { + color: #c58af9; + } + :root:not([data-theme="light"]) .qh__floor { + color: #4dd0e1; + } +} diff --git a/web/src/components/QuoteHeader.tsx b/web/src/components/QuoteHeader.tsx new file mode 100644 index 0000000..cabc028 --- /dev/null +++ b/web/src/components/QuoteHeader.tsx @@ -0,0 +1,241 @@ +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import "./QuoteHeader.css"; +import ChangeBadge from "./ChangeBadge"; +import { + fmtPriceVnd, + fmtChangeVnd, + fmtPct, + fmtPctPlain, + fmtBoard, + dirOf, + sessionLabel, +} from "../lib/format"; +import { useWatchlists } from "../lib/userData"; +import type { QuoteResponse } from "../lib/types"; + +interface Props { + quote: QuoteResponse; +} + +function CheckIcon() { + return ( + + ); +} + +export default function QuoteHeader({ quote }: Props) { + const name = quote.nameEn || quote.nameVi || quote.ticker; + const dir = dirOf(quote.change_pct); + const ticker = quote.ticker.toUpperCase(); + + // Performance summary + mini perf row (1M / 3M / YTD) from daily bars. + const perf1m = quote.stats.change_pct_1m; + const perf1mDir = dirOf(perf1m); + const perfItems: { label: string; value: number | null }[] = [ + { label: "1M", value: quote.stats.change_pct_1m }, + { label: "3M", value: quote.stats.change_pct_3m }, + { label: "YTD", value: quote.stats.change_pct_ytd }, + ]; + const hasPerf = perfItems.some((p) => p.value != null); + + const { watchlists, loading, create, addItem, removeItem, isSaved } = useWatchlists(); + const saved = isSaved(ticker); + + const [open, setOpen] = useState(false); + const wrapRef = useRef(null); + + // Close the popover on outside click or Escape. + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const toggleList = async (id: number, inList: boolean) => { + try { + if (inList) await removeItem(id, ticker); + else await addItem(id, ticker); + } catch { + /* keep popover open; state stays consistent with server on next load */ + } + }; + + const handleNewList = async () => { + const raw = window.prompt(`Name the new watchlist for ${ticker}`); + const nm = raw?.trim(); + if (!nm) return; + try { + const created = await create(nm); + if (created) await addItem(created.id, ticker); + } catch { + /* ignore */ + } + }; + + return ( +
+
+ + Home + + / + + {quote.ticker}:{quote.exchange ?? ""} + +
+ +
+

{name}

+
+
+ + + {open && ( +
+
Save to watchlist
+ +
+ {loading ? ( +
Loading…
+ ) : watchlists.length === 0 ? ( +
No watchlists yet
+ ) : ( + watchlists.map((list) => { + const inList = list.tickers.includes(ticker); + return ( + + ); + }) + )} +
+ +
+ + +
+ )} +
+ + +
+
+ +
+ {fmtPriceVnd(quote.last)} + + + Today + +
+ + {perf1m != null && ( +

+ {perf1mDir === "flat" ? ( + <>{name} is roughly flat over the past month. + ) : ( + <> + {name} is {perf1mDir === "up" ? "up" : "down"}{" "} + + {fmtPctPlain(Math.abs(perf1m))} + {" "} + over the past month. + + )} +

+ )} + + {hasPerf && ( +
+ {perfItems.map((p) => ( + + {p.label} + + {fmtPct(p.value)} + + + ))} +
+ )} + +
+ + + + Ref {fmtPriceVnd(quote.ref, { suffix: false })} + + + Ceiling {fmtBoard(quote.ceiling)} + + + Floor {fmtBoard(quote.floor)} + +
+
+ ); +} diff --git a/web/src/components/RelatedStocks.css b/web/src/components/RelatedStocks.css new file mode 100644 index 0000000..a131864 --- /dev/null +++ b/web/src/components/RelatedStocks.css @@ -0,0 +1,65 @@ +.related { + display: flex; + flex-direction: column; + gap: 12px; +} + +.related__row { + display: flex; + gap: 12px; + overflow-x: auto; + scroll-snap-type: x proximity; + padding-bottom: 4px; + -webkit-overflow-scrolling: touch; +} + +.related__card { + flex: 0 0 auto; + width: 168px; + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px; + scroll-snap-align: start; + text-decoration: none; + color: inherit; + transition: box-shadow 0.15s ease, background 0.15s ease; +} + +.related__card:hover { + box-shadow: var(--shadow-2); + background: var(--surface-hover); +} + +.related__ticker { + color: var(--text); + font-weight: 600; + font-size: 13px; + line-height: 1.2; +} + +.related__name { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.related__spark { + display: block; + width: 100%; +} + +.related__foot { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.related__last { + color: var(--text); + font-size: 13px; +} diff --git a/web/src/components/RelatedStocks.tsx b/web/src/components/RelatedStocks.tsx new file mode 100644 index 0000000..85ded9a --- /dev/null +++ b/web/src/components/RelatedStocks.tsx @@ -0,0 +1,33 @@ +import { Link } from "react-router-dom"; +import type { RelatedStock } from "../lib/types"; +import { fmtPriceVnd } from "../lib/format"; +import Sparkline from "./Sparkline"; +import ChangeBadge from "./ChangeBadge"; +import "./RelatedStocks.css"; + +interface Props { + related: RelatedStock[]; +} + +export default function RelatedStocks({ related }: Props) { + if (!related || related.length === 0) return null; + + return ( +
+

Related stocks

+
+ {related.map((r) => ( + + {r.ticker} + {r.name && {r.name}} + +
+ {fmtPriceVnd(r.last, { suffix: false })} + +
+ + ))} +
+
+ ); +} diff --git a/web/src/components/ResearchPanel.css b/web/src/components/ResearchPanel.css new file mode 100644 index 0000000..17fe904 --- /dev/null +++ b/web/src/components/ResearchPanel.css @@ -0,0 +1,149 @@ +/* ResearchPanel — static visual clone of Google Finance "Research" right rail */ + +.research { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Header row */ +.research__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.research__title { + font-size: 20px; + font-weight: 500; + color: var(--text); + letter-spacing: -0.2px; +} +.research__header-actions { + display: flex; + align-items: center; + gap: 2px; +} + +/* Prompt intro line */ +.research__lead { + font-size: 15px; + font-weight: 500; + color: var(--text); +} + +/* Suggested prompts */ +.research__prompts { + display: flex; + flex-direction: column; + gap: 8px; +} +.research__prompt { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + padding: 12px 14px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + font-family: var(--sans); + font-size: 13.5px; + line-height: 1.35; + text-align: left; + transition: background 0.12s ease, border-color 0.12s ease; +} +.research__prompt:hover { + background: var(--surface-hover); + border-color: var(--border); +} +.research__prompt-icon { + flex: none; + color: var(--accent); + display: inline-flex; +} + +/* Explore section */ +.research__explore-label { + font-size: 13px; + color: var(--text-secondary); +} +.research__chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.research__chip { + border: none; + cursor: pointer; + padding: 6px 12px; + font-family: var(--sans); + transition: background 0.12s ease, color 0.12s ease; +} +.research__chip:hover { + background: var(--surface-hover); + color: var(--text); +} +.research__chip-glyph { + font-weight: 600; + line-height: 1; +} + +/* Composer */ +.research__composer { + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); +} +.research__composer-add { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: transparent; + color: var(--text-secondary); + border-radius: 50%; + transition: background 0.12s ease; +} +.research__composer-add:hover { + background: var(--surface-hover); +} +.research__composer-placeholder { + flex: 1; + min-width: 0; + font-size: 14px; + color: var(--text-muted); +} +.research__send { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: var(--surface-2); + color: var(--text-secondary); + transition: background 0.12s ease, color 0.12s ease; +} +.research__send:hover { + background: var(--surface-hover); + color: var(--text); +} + +/* Footnote */ +.research__footnote { + font-size: 11px; + color: var(--text-muted); + line-height: 1.4; +} diff --git a/web/src/components/ResearchPanel.tsx b/web/src/components/ResearchPanel.tsx new file mode 100644 index 0000000..1339409 --- /dev/null +++ b/web/src/components/ResearchPanel.tsx @@ -0,0 +1,184 @@ +import "./ResearchPanel.css"; + +const PROMPTS = [ + "What's moving the VN-Index today?", + "Compare VCB and TCB fundamentals", + "Which VN30 stocks look oversold right now?", +]; + +function noop(label: string) { + console.debug(`[ResearchPanel] ${label} (preview — non-functional)`); +} + +/** Compose / edit glyph */ +function ComposeIcon() { + return ( + + ); +} + +/** Layers / expand glyph */ +function LayersIcon() { + return ( + + ); +} + +/** Sparkle glyph for the suggested prompts */ +function SparkleIcon() { + return ( + + ); +} + +/** Up-arrow for the send button */ +function SendIcon() { + return ( + + ); +} + +export default function ResearchPanel() { + return ( +
+
+ Research +
+ + +
+
+ +

What's on your mind?

+ +
+ {PROMPTS.map((prompt) => ( + + ))} +
+ + Explore what's possible + +
+ + +
+ +
+ + Ask anything + +
+ +

+ Azoth's AI analyst runs in the terminal — this panel is a preview. +

+
+ ); +} diff --git a/web/src/components/SectorRail.css b/web/src/components/SectorRail.css new file mode 100644 index 0000000..abe4e74 --- /dev/null +++ b/web/src/components/SectorRail.css @@ -0,0 +1,66 @@ +/* Compact sector-performance rail (Google Finance "Stock sectors"). */ + +.sector-rail { + display: flex; + flex-direction: column; +} + +.sector-rail__row { + display: flex; + align-items: center; + gap: 8px; + min-height: 30px; + padding: 4px 8px; + border-radius: var(--radius-sm); + transition: background 0.12s ease; +} + +.sector-rail__row:hover { + background: var(--surface-hover); +} + +.sector-rail__name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 500; + color: var(--text); +} + +.sector-rail__spark { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + width: 40px; + height: 18px; +} + +.sector-rail__pct { + flex: 0 0 auto; + min-width: 52px; + font-size: 12px; + font-weight: 500; + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* Skeleton rows -------------------------------------------------------------- */ + +.sector-rail__row--skel { + justify-content: space-between; +} + +.sector-rail__skel-name { + height: 12px; + width: 58%; + border-radius: 6px; +} + +.sector-rail__skel-pct { + height: 12px; + width: 34px; + border-radius: 6px; +} diff --git a/web/src/components/SectorRail.tsx b/web/src/components/SectorRail.tsx new file mode 100644 index 0000000..7477ac2 --- /dev/null +++ b/web/src/components/SectorRail.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; +import { api } from "../lib/api"; +import type { SectorRow } from "../lib/types"; +import { dirOf, fmtPct } from "../lib/format"; +import Sparkline from "./Sparkline"; +import "./SectorRail.css"; + +/** + * Compact "Stock sectors" rail (Google Finance style): a read-only list of + * sector names with their average daily % change (+ a tiny sparkline). Rows do + * not navigate — sectors have no dedicated page. The whole block hides on + * error/empty so the sidebar never shows a broken section. + */ +export default function SectorRail() { + const [sectors, setSectors] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + const ctrl = new AbortController(); + setSectors(null); + setError(false); + api + .sectors(ctrl.signal) + .then((res) => setSectors(res.sectors)) + .catch((err) => { + if (err?.name === "AbortError") return; + setError(true); + }); + return () => ctrl.abort(); + }, []); + + // Hide the whole section on error or when there's nothing to show. + if (error) return null; + if (sectors != null && sectors.length === 0) return null; + + const loading = sectors == null; + + return ( + <> +
+
SECTORS
+
+ {loading + ? Array.from({ length: 6 }).map((_, i) => ( +
+ + +
+ )) + : sectors!.map((s) => { + const dir = dirOf(s.change_pct); + return ( +
+ {s.name} + {s.spark.length >= 2 && ( + + + + )} + {fmtPct(s.change_pct)} +
+ ); + })} +
+ + ); +} diff --git a/web/src/components/Sidebar.css b/web/src/components/Sidebar.css new file mode 100644 index 0000000..e05f240 --- /dev/null +++ b/web/src/components/Sidebar.css @@ -0,0 +1,306 @@ +.sidebar { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sidebar__header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.sidebar__title { + margin: 0; + font-size: 18px; + font-weight: 500; + color: var(--text); +} + +/* Primary navigation ------------------------------------------------------- */ + +.sidebar__nav { + display: flex; + flex-direction: column; + gap: 2px; +} + +.sidebar__nav-link { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-family: var(--sans); + font-size: 14px; + font-weight: 500; + text-decoration: none; + transition: background 0.12s ease, color 0.12s ease; +} + +.sidebar__nav-link:hover { + background: var(--surface-hover); + color: var(--text); +} + +.sidebar__nav-link--active { + background: var(--accent-subtle); + color: var(--accent); +} + +.sidebar__nav-link--active:hover { + background: var(--accent-subtle); + color: var(--accent); +} + +.sidebar__nav-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 18px; + height: 18px; + color: currentColor; +} + +.sidebar__rule { + margin: 4px 0; +} + +/* Section labels ----------------------------------------------------------- */ + +.sidebar__section-label { + margin-top: 4px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.6px; + color: var(--text-muted); +} + +/* Portfolios --------------------------------------------------------------- */ + +.sidebar__pf-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.sidebar__pf-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Create buttons ----------------------------------------------------------- */ + +.sidebar__create { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 38px; + padding: 0 14px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-secondary); + font-family: var(--sans); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.12s ease; +} + +.sidebar__create:hover { + background: var(--surface-hover); +} + +.sidebar__create-plus { + font-size: 18px; + line-height: 1; + font-weight: 400; +} + +/* Watchlist groups --------------------------------------------------------- */ + +.sidebar__group { + display: flex; + flex-direction: column; +} + +.sidebar__group-head { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 4px 4px 2px; + border-radius: var(--radius-sm); +} + +.sidebar__group-head:hover { + background: var(--surface-hover); +} + +.sidebar__group-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 22px; + height: 22px; + padding: 0; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.sidebar__chevron { + transition: transform 0.14s ease; +} + +.sidebar__chevron--open { + transform: rotate(90deg); +} + +.sidebar__group-name { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1 1 auto; + text-decoration: none; + color: var(--text); +} + +.sidebar__group-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 600; +} + +.sidebar__group-count { + flex: 0 0 auto; + font-size: 11px; + font-weight: 500; + color: var(--text-muted); +} + +.sidebar__group-add { + flex: 0 0 auto; + opacity: 0; + transition: opacity 0.12s ease; +} + +.sidebar__group-head:hover .sidebar__group-add, +.sidebar__group-add:focus-visible { + opacity: 1; +} + +/* Watchlist member rows ---------------------------------------------------- */ + +.sidebar__members { + display: flex; + flex-direction: column; + padding-left: 22px; +} + +.sidebar__member { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + border-radius: var(--radius-sm); + text-decoration: none; + transition: background 0.12s ease; +} + +.sidebar__member:hover { + background: var(--surface-hover); +} + +.sidebar__member-ticker { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 700; + color: var(--text); + line-height: 1.3; +} + +.sidebar__member-remove { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + cursor: pointer; + opacity: 0; + transition: opacity 0.12s ease, background 0.12s ease, color 0.12s ease; +} + +.sidebar__member:hover .sidebar__member-remove, +.sidebar__member-remove:focus-visible { + opacity: 1; +} + +.sidebar__member-remove:hover { + background: var(--down-bg); + color: var(--down); +} + +.sidebar__add-symbol { + display: flex; + align-items: center; + padding: 6px 8px; + border: none; + background: transparent; + color: var(--text-secondary); + font-family: var(--sans); + font-size: 13px; + font-weight: 500; + text-align: left; + cursor: pointer; + border-radius: var(--radius-sm); + transition: background 0.12s ease, color 0.12s ease; +} + +.sidebar__add-symbol:hover { + background: var(--surface-hover); + color: var(--text); +} + +/* Skeletons + empty state -------------------------------------------------- */ + +.sidebar__rows { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sidebar__skeleton { + height: 36px; + border-radius: 8px; +} + +.sidebar__skeleton--sm { + height: 30px; +} + +.sidebar__empty { + font-size: 12px; + padding: 4px 8px; +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx new file mode 100644 index 0000000..c29d308 --- /dev/null +++ b/web/src/components/Sidebar.tsx @@ -0,0 +1,257 @@ +import { useState } from "react"; +import { Link, NavLink, useNavigate } from "react-router-dom"; +import { usePortfolios, useWatchlists } from "../lib/userData"; +import SectorRail from "./SectorRail"; +import "./Sidebar.css"; + +// Inline nav icons (kept tiny + optional per the design language). +function IconHome() { + return ( + + + + ); +} + +function IconPortfolio() { + return ( + + + + + ); +} + +function IconTrends() { + return ( + + + + + ); +} + +function IconChevron({ open }: { open: boolean }) { + return ( + + + + ); +} + +function navLinkClass({ isActive }: { isActive: boolean }) { + return `sidebar__nav-link${isActive ? " sidebar__nav-link--active" : ""}`; +} + +export default function Sidebar() { + const navigate = useNavigate(); + const wl = useWatchlists(); + const pf = usePortfolios(); + + // Collapse state per watchlist; groups default to open (undefined === open). + const [collapsed, setCollapsed] = useState>({}); + const isOpen = (id: number) => !collapsed[id]; + const toggle = (id: number) => + setCollapsed((prev) => ({ ...prev, [id]: !prev[id] })); + + async function onCreatePortfolio() { + const name = window.prompt("Name your portfolio")?.trim(); + if (!name) return; + const meta = await pf.create(name); + if (meta) navigate(`/portfolio/${meta.id}`); + } + + async function onCreateWatchlist() { + const name = window.prompt("Name your watchlist")?.trim(); + if (!name) return; + const meta = await wl.create(name); + if (meta) { + setCollapsed((prev) => ({ ...prev, [meta.id]: false })); + navigate(`/watchlist/${meta.id}`); + } + } + + async function onAddSymbol(listId: number) { + const raw = window.prompt("Add a symbol (ticker)")?.trim(); + if (!raw) return; + await wl.addItem(listId, raw.toUpperCase()); + setCollapsed((prev) => ({ ...prev, [listId]: false })); + } + + function onRemoveSymbol(e: React.MouseEvent, listId: number, ticker: string) { + e.preventDefault(); + e.stopPropagation(); + void wl.removeItem(listId, ticker); + } + + return ( + + ); +} diff --git a/web/src/components/Sparkline.tsx b/web/src/components/Sparkline.tsx new file mode 100644 index 0000000..a7a7ba1 --- /dev/null +++ b/web/src/components/Sparkline.tsx @@ -0,0 +1,90 @@ +import { useId } from "react"; +import type { Direction } from "../lib/format"; + +interface Props { + data: number[]; + width?: number; + height?: number; + /** If omitted, derived from first vs last value. */ + direction?: Direction; + strokeWidth?: number; + /** Draw a soft gradient area under the line. */ + fill?: boolean; + className?: string; +} + +const COLOR: Record = { + up: "var(--up-strong)", + down: "var(--down-strong)", + flat: "var(--text-muted)", +}; + +/** + * Dependency-free SVG sparkline. Stretches to the given viewBox; give it a sized + * container and it scales via preserveAspectRatio="none". + */ +export default function Sparkline({ + data, + width = 96, + height = 32, + direction, + strokeWidth = 1.6, + fill = false, + className, +}: Props) { + const gid = useId(); + const clean = data.filter((v) => Number.isFinite(v)); + if (clean.length < 2) { + return ; + } + + const dir: Direction = + direction ?? (clean[clean.length - 1]! > clean[0]! ? "up" : clean[clean.length - 1]! < clean[0]! ? "down" : "flat"); + const color = COLOR[dir]; + + const min = Math.min(...clean); + const max = Math.max(...clean); + const span = max - min || 1; + const pad = strokeWidth; + const innerH = height - pad * 2; + const stepX = width / (clean.length - 1); + + const points = clean.map((v, i) => { + const x = i * stepX; + const y = pad + innerH - ((v - min) / span) * innerH; + return [x, y] as const; + }); + + const linePath = points.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`).join(" "); + const areaPath = `${linePath} L${width},${height} L0,${height} Z`; + + return ( + + {fill && ( + + + + + + + )} + {fill && } + + + ); +} diff --git a/web/src/components/StatsGrid.css b/web/src/components/StatsGrid.css new file mode 100644 index 0000000..ec63481 --- /dev/null +++ b/web/src/components/StatsGrid.css @@ -0,0 +1,56 @@ +.statsgrid { + padding: 14px 18px 4px; +} + +.statsgrid__heading { + margin: 0 0 6px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.6px; + color: var(--text-muted); + text-transform: uppercase; +} + +.statsgrid__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + column-gap: 40px; +} + +.statsgrid__row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + padding: 7px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.statsgrid__label { + font-size: 12.5px; + color: var(--text-secondary); +} + +.statsgrid__value { + font-size: 12.5px; + color: var(--text); + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.statsgrid__value--ceiling { + color: #a142f4; +} + +.statsgrid__value--floor { + color: #00acc1; +} + +:root[data-theme="dark"] .statsgrid__value--ceiling { + color: #c58af9; +} + +:root[data-theme="dark"] .statsgrid__value--floor { + color: #4dd0e1; +} diff --git a/web/src/components/StatsGrid.tsx b/web/src/components/StatsGrid.tsx new file mode 100644 index 0000000..380550a --- /dev/null +++ b/web/src/components/StatsGrid.tsx @@ -0,0 +1,66 @@ +import "./StatsGrid.css"; +import { fmtPriceVnd, fmtBigNum, fmtBigVnd, fmtNum, fmtPctPlain } from "../lib/format"; +import type { QuoteResponse } from "../lib/types"; + +interface StatsGridProps { + quote: QuoteResponse; +} + +type Cell = { + label: string; + value: string; + tone?: "ceiling" | "floor"; +}; + +export default function StatsGrid({ quote }: StatsGridProps) { + const s = quote.stats; + + const cells: Cell[] = [ + { label: "Open", value: fmtPriceVnd(s.open) }, + { label: "High", value: fmtPriceVnd(s.high) }, + { label: "Low", value: fmtPriceVnd(s.low) }, + { label: "Prev close", value: fmtPriceVnd(quote.ref) }, + { label: "Ceiling", value: fmtPriceVnd(quote.ceiling), tone: "ceiling" }, + { label: "Floor", value: fmtPriceVnd(quote.floor), tone: "floor" }, + { label: "Volume", value: fmtBigNum(s.volume) }, + { label: "Avg volume", value: fmtBigNum(s.avg_vol) }, + { label: "Market cap", value: fmtBigVnd(s.market_cap_vnd) }, + { label: "P/E ratio", value: fmtNum(s.pe) }, + { label: "P/B ratio", value: fmtNum(s.pb) }, + { label: "P/S ratio", value: fmtNum(s.ps) }, + { label: "EPS", value: fmtPriceVnd(s.eps_thousand_vnd) }, + { label: "BVPS", value: fmtPriceVnd(s.bvps_thousand_vnd) }, + { label: "ROE", value: fmtPctPlain(s.roe_pct) }, + { label: "ROA", value: fmtPctPlain(s.roa_pct) }, + { label: "Div yield", value: fmtPctPlain(s.dividend_yield_pct) }, + { label: "Foreign own", value: fmtPctPlain(s.foreign_ownership_pct) }, + { label: "Shares out", value: fmtBigNum(s.shares_outstanding) }, + { label: "52-wk high", value: fmtPriceVnd(s.week52_high) }, + { label: "52-wk low", value: fmtPriceVnd(s.week52_low) }, + ]; + + return ( +
+

Stats

+
+ {cells.map((c) => ( +
+ {c.label} + + {c.value} + +
+ ))} +
+
+ ); +} diff --git a/web/src/components/TopNav.css b/web/src/components/TopNav.css new file mode 100644 index 0000000..42863c6 --- /dev/null +++ b/web/src/components/TopNav.css @@ -0,0 +1,205 @@ +/* TopNav — sticky top navigation bar (Google Finance clone) */ + +.topnav { + position: sticky; + top: 0; + z-index: 50; + width: 100%; + height: var(--nav-h); + background: var(--bg); + border-bottom: 1px solid var(--border-subtle); +} + +.topnav__inner { + height: 100%; + max-width: var(--content-max); + margin: 0 auto; + padding: 0 24px; + display: flex; + align-items: center; + gap: 16px; +} + +/* ---- Brand ------------------------------------------------------------- */ +.topnav__brand { + display: inline-flex; + align-items: center; + gap: 10px; + flex: none; + border-radius: var(--radius-sm); +} + +.topnav__logo { + display: block; + flex: none; +} + +.topnav__wordmark { + display: inline-flex; + align-items: baseline; + gap: 5px; + font-size: 20px; + line-height: 1; + white-space: nowrap; +} +.topnav__wordmark-name { + color: var(--text); + font-weight: 500; +} +.topnav__wordmark-sub { + color: var(--text-secondary); + font-weight: 400; +} + +.topnav__beta { + padding: 2px 7px; + font-size: 10px; + letter-spacing: 0.3px; + text-transform: uppercase; +} + +/* ---- Search ------------------------------------------------------------ */ +.topnav__search { + position: relative; + flex: 1; + max-width: 720px; + margin: 0 auto; +} + +.topnav__search-field { + display: flex; + align-items: center; + gap: 10px; + height: 44px; + padding: 0 16px; + border-radius: var(--pill); + background: var(--surface-2); + border: 1px solid transparent; + transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease; +} +.topnav__search-field:focus-within { + background: var(--surface); + border-color: var(--border); + box-shadow: var(--shadow-1); +} + +.topnav__search-icon { + display: inline-flex; + align-items: center; + color: var(--text-muted); + flex: none; +} + +.topnav__search-input { + flex: 1; + min-width: 0; + height: 100%; + border: none; + outline: none; + background: transparent; + color: var(--text); + font-family: var(--sans); + font-size: 15px; +} +.topnav__search-input::placeholder { + color: var(--text-muted); +} + +/* ---- Results dropdown -------------------------------------------------- */ +.topnav__results { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: 60; + max-height: 360px; + overflow-y: auto; + padding: 6px; + margin: 0; + list-style: none; +} + +.topnav__result { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 12px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + text-align: left; + color: var(--text); + transition: background 0.1s ease; +} +.topnav__result:hover, +.topnav__result--active { + background: var(--surface-hover); +} + +.topnav__result-ticker { + flex: none; + min-width: 48px; + font-weight: 600; + font-size: 14px; + color: var(--text); +} + +.topnav__result-name { + flex: 1; + min-width: 0; + color: var(--text-secondary); + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.topnav__result-exch { + flex: none; + padding: 2px 8px; + font-size: 11px; +} + +/* ---- Right actions ----------------------------------------------------- */ +.topnav__actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex: none; +} + +.topnav__signin { + display: inline-flex; + align-items: center; + justify-content: center; + height: 40px; + padding: 0 18px; + margin-left: 6px; + border: none; + border-radius: var(--pill); + background: var(--accent); + color: var(--on-accent); + font-size: 14px; + font-weight: 500; + white-space: nowrap; + transition: background 0.12s ease; +} +.topnav__signin:hover { + background: var(--accent-hover); +} + +/* ---- Responsive -------------------------------------------------------- */ +@media (max-width: 640px) { + .topnav__inner { + padding: 0 12px; + gap: 10px; + } + .topnav__wordmark-sub, + .topnav__beta { + display: none; + } + .topnav__signin { + padding: 0 14px; + } +} diff --git a/web/src/components/TopNav.tsx b/web/src/components/TopNav.tsx new file mode 100644 index 0000000..ea28f85 --- /dev/null +++ b/web/src/components/TopNav.tsx @@ -0,0 +1,263 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { api, ApiError } from "../lib/api"; +import type { SearchResult } from "../lib/types"; +import "./TopNav.css"; + +const THEME_KEY = "azoth-theme"; +type Theme = "light" | "dark"; + +function currentTheme(): Theme { + const attr = document.documentElement.getAttribute("data-theme"); + if (attr === "light" || attr === "dark") return attr; + const stored = localStorage.getItem(THEME_KEY); + if (stored === "light" || stored === "dark") return stored; + // Default to light (like Google Finance), ignoring the OS dark-mode setting. + return "light"; +} + +function BarChartGlyph() { + return ( + + ); +} + +function SearchIcon() { + return ( + + ); +} + +function SunIcon() { + return ( + + ); +} + +function MoonIcon() { + return ( + + ); +} + +function GearIcon() { + return ( + + ); +} + +export default function TopNav() { + const navigate = useNavigate(); + const [theme, setTheme] = useState(() => currentTheme()); + + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [open, setOpen] = useState(false); + const [highlight, setHighlight] = useState(0); + + const rootRef = useRef(null); + + // Apply + persist theme. + useEffect(() => { + document.documentElement.setAttribute("data-theme", theme); + localStorage.setItem(THEME_KEY, theme); + }, [theme]); + + const toggleTheme = useCallback(() => { + setTheme((t) => (t === "dark" ? "light" : "dark")); + }, []); + + // Debounced search. + useEffect(() => { + const q = query.trim(); + if (!q) { + setResults([]); + setOpen(false); + return; + } + const controller = new AbortController(); + const timer = setTimeout(() => { + api + .search(q, controller.signal) + .then((res) => { + setResults(res.results); + setHighlight(0); + setOpen(true); + }) + .catch((err) => { + if (err instanceof DOMException && err.name === "AbortError") return; + if (err instanceof ApiError || err instanceof Error) { + setResults([]); + setOpen(false); + } + }); + }, 200); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [query]); + + // Close on outside click. + useEffect(() => { + function onDown(e: MouseEvent) { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, []); + + const goTo = useCallback( + (ticker: string) => { + setQuery(""); + setResults([]); + setOpen(false); + navigate(`/quote/${ticker}`); + }, + [navigate], + ); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (!open || results.length === 0) { + if (e.key === "Escape") setOpen(false); + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlight((h) => (h + 1) % results.length); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlight((h) => (h - 1 + results.length) % results.length); + } else if (e.key === "Enter") { + e.preventDefault(); + const pick = results[highlight] ?? results[0]; + if (pick) goTo(pick.ticker); + } else if (e.key === "Escape") { + setOpen(false); + } + }; + + const showDropdown = open && query.trim().length > 0 && results.length > 0; + + return ( +
+
+ {/* Left — brand */} + + + + Azoth + Finance + + Beta + + + {/* Center — search */} +
+
+ + setQuery(e.target.value)} + onKeyDown={onKeyDown} + onFocus={() => { + if (results.length > 0) setOpen(true); + }} + placeholder="Search stocks, tickers, companies" + aria-label="Search stocks, tickers, companies" + role="combobox" + aria-expanded={showDropdown} + aria-controls="topnav-search-results" + aria-autocomplete="list" + autoComplete="off" + /> +
+ + {showDropdown && ( +
    + {results.map((r, i) => ( +
  • + +
  • + ))} +
+ )} +
+ + {/* Right — actions */} +
+ + + +
+
+
+ ); +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..3d2d2fb --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,373 @@ +/* ========================================================================== + Azoth Finance — design system + A Google-Finance-inspired token set + global primitives. + Components own their own co-located *.css; this file holds tokens, resets, + and a few shared primitives (.gf-card, .gf-badge, .gf-pill, .gf-chip, skeleton). + ========================================================================== */ + +/* ---- Light theme (default) --------------------------------------------- */ +:root { + --bg: #ffffff; + --bg-subtle: #f8f9fa; + --surface: #ffffff; + --surface-2: #f1f3f4; + --surface-hover: #f1f3f4; + --border: #dadce0; + --border-subtle: #e8eaed; + + --text: #202124; + --text-secondary: #5f6368; + --text-muted: #80868b; + + --accent: #1a73e8; + --accent-hover: #1765cc; + --accent-subtle: #e8f0fe; + --on-accent: #ffffff; + + --up: #137333; + --up-strong: #1e8e3e; + --up-bg: #e6f4ea; + --down: #a50e0e; + --down-strong: #d93025; + --down-bg: #fce8e6; + --flat: #5f6368; + --flat-bg: #f1f3f4; + + --shadow-1: 0 1px 2px rgba(60, 64, 67, 0.1), 0 1px 3px 1px rgba(60, 64, 67, 0.06); + --shadow-2: 0 1px 3px rgba(60, 64, 67, 0.12), 0 4px 8px 3px rgba(60, 64, 67, 0.08); + --focus-ring: rgba(26, 115, 232, 0.4); + + --radius-sm: 8px; + --radius: 12px; + --radius-lg: 16px; + --pill: 999px; + + --nav-h: 64px; + --content-max: 1600px; + + --sans: "Google Sans", "Google Sans Text", "Roboto", -apple-system, + BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + --mono: "Roboto Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + + color-scheme: light; +} + +/* ---- Dark theme -------------------------------------------------------- */ +:root[data-theme="dark"] { + --bg: #202124; + --bg-subtle: #28292c; + --surface: #2a2b2e; + --surface-2: #35363a; + --surface-hover: #35363a; + --border: #3c4043; + --border-subtle: #303134; + + --text: #e8eaed; + --text-secondary: #9aa0a6; + --text-muted: #80868b; + + --accent: #8ab4f8; + --accent-hover: #aecbfa; + --accent-subtle: #1f3047; + --on-accent: #202124; + + --up: #81c995; + --up-strong: #81c995; + --up-bg: rgba(129, 201, 149, 0.16); + --down: #f28b82; + --down-strong: #f28b82; + --down-bg: rgba(242, 139, 130, 0.16); + --flat: #9aa0a6; + --flat-bg: #35363a; + + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 3px 1px rgba(0, 0, 0, 0.15); + --shadow-2: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 8px 3px rgba(0, 0, 0, 0.25); + --focus-ring: rgba(138, 180, 248, 0.5); + + color-scheme: dark; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #202124; + --bg-subtle: #28292c; + --surface: #2a2b2e; + --surface-2: #35363a; + --surface-hover: #35363a; + --border: #3c4043; + --border-subtle: #303134; + + --text: #e8eaed; + --text-secondary: #9aa0a6; + --text-muted: #80868b; + + --accent: #8ab4f8; + --accent-hover: #aecbfa; + --accent-subtle: #1f3047; + --on-accent: #202124; + + --up: #81c995; + --up-strong: #81c995; + --up-bg: rgba(129, 201, 149, 0.16); + --down: #f28b82; + --down-strong: #f28b82; + --down-bg: rgba(242, 139, 130, 0.16); + --flat: #9aa0a6; + --flat-bg: #35363a; + + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 3px 1px rgba(0, 0, 0, 0.15); + --shadow-2: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 8px 3px rgba(0, 0, 0, 0.25); + --focus-ring: rgba(138, 180, 248, 0.5); + + color-scheme: dark; + } +} + +/* ---- Reset ------------------------------------------------------------- */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + font-family: inherit; + cursor: pointer; +} + +h1, +h2, +h3, +h4 { + margin: 0; + font-weight: 500; + font-family: var(--sans); +} + +:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; + border-radius: 4px; +} + +/* ---- Semantic color helpers -------------------------------------------- */ +.up { + color: var(--up); +} +.down { + color: var(--down); +} +.flat { + color: var(--flat); +} +.text-secondary { + color: var(--text-secondary); +} +.text-muted { + color: var(--text-muted); +} +.mono { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; +} +.gf-num { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; +} + +/* ---- Card -------------------------------------------------------------- */ +.gf-card { + background: var(--surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + box-shadow: var(--shadow-1); +} + +/* ---- Change badge (circular arrow + value), Google Finance style ------- */ +.gf-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-weight: 500; + font-variant-numeric: tabular-nums; +} +.gf-badge__dot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + flex: none; +} +.gf-badge--up .gf-badge__dot { + background: var(--up-bg); + color: var(--up-strong); +} +.gf-badge--down .gf-badge__dot { + background: var(--down-bg); + color: var(--down-strong); +} +.gf-badge--flat .gf-badge__dot { + background: var(--flat-bg); + color: var(--flat); +} +.gf-badge--up { + color: var(--up); +} +.gf-badge--down { + color: var(--down); +} +.gf-badge--flat { + color: var(--flat); +} + +/* ---- Pill / segmented buttons ------------------------------------------ */ +.gf-pill { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 14px; + border-radius: var(--pill); + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; +} +.gf-pill:hover { + background: var(--surface-hover); +} +.gf-pill--active { + background: var(--accent-subtle); + border-color: transparent; + color: var(--accent); +} + +/* ---- Chip -------------------------------------------------------------- */ +.gf-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: var(--pill); + background: var(--surface-2); + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; +} + +/* ---- Icon button ------------------------------------------------------- */ +.gf-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background: transparent; + color: var(--text-secondary); + transition: background 0.12s ease; +} +.gf-icon-btn:hover { + background: var(--surface-hover); +} + +/* ---- Skeleton loading -------------------------------------------------- */ +.gf-skeleton { + position: relative; + overflow: hidden; + background: var(--surface-2); + border-radius: 6px; +} +.gf-skeleton::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent, + color-mix(in srgb, var(--text) 6%, transparent), + transparent + ); + animation: gf-shimmer 1.3s infinite; +} +@keyframes gf-shimmer { + 100% { + transform: translateX(100%); + } +} + +/* ---- Section heading --------------------------------------------------- */ +.gf-section-title { + font-size: 20px; + font-weight: 500; + color: var(--text); + letter-spacing: -0.2px; +} + +/* ---- Divider ----------------------------------------------------------- */ +.gf-divider { + height: 1px; + background: var(--border-subtle); + border: none; + margin: 0; +} + +/* ---- Scrollbars -------------------------------------------------------- */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} +*::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 8px; + border: 3px solid var(--bg); +} +*::-webkit-scrollbar-track { + background: transparent; +} + +/* ---- App layout scaffold ----------------------------------------------- */ +.app-shell { + min-height: 100%; + display: flex; + flex-direction: column; +} +.app-body { + flex: 1; + width: 100%; + max-width: var(--content-max); + margin: 0 auto; + padding: 0 24px 48px; +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..71fee4e --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,163 @@ +// Typed fetch client for the Azoth data server. All endpoints are served under /api +// (proxied to the Node server in dev by vite.config.ts). + +import type { + AboutResponse, + FinancialsResponse, + HoldingInput, + IndicatorsResponse, + IndicesResponse, + MarketNewsResponse, + MoversResponse, + NewsResponse, + OhlcvResponse, + PortfolioMeta, + PortfolioResponse, + PortfoliosResponse, + QuoteResponse, + RangeKey, + SearchResponse, + SectorsResponse, + WatchlistDetail, + WatchlistMeta, + WatchlistResponse, + WatchlistsResponse, +} from "./types"; + +export class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + this.name = "ApiError"; + } +} + +async function getJson(path: string, signal?: AbortSignal): Promise { + const res = await fetch(path, { signal, headers: { accept: "application/json" } }); + if (!res.ok) { + let msg = `${res.status} ${res.statusText}`; + try { + const body = (await res.json()) as { error?: string }; + if (body?.error) msg = body.error; + } catch { + /* ignore parse errors */ + } + throw new ApiError(res.status, msg); + } + return (await res.json()) as T; +} + +async function mutate( + method: "POST" | "PATCH" | "DELETE" | "PUT", + path: string, + body?: unknown, + signal?: AbortSignal, +): Promise { + const res = await fetch(path, { + method, + signal, + headers: { + accept: "application/json", + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (!res.ok) { + let msg = `${res.status} ${res.statusText}`; + try { + const b = (await res.json()) as { error?: string }; + if (b?.error) msg = b.error; + } catch { + /* ignore parse errors */ + } + throw new ApiError(res.status, msg); + } + return (await res.json()) as T; +} + +export const api = { + indices: (signal?: AbortSignal) => getJson("/api/indices", signal), + + movers: (kind: "gainers" | "losers" | "active", universe = "vn30", signal?: AbortSignal) => + getJson(`/api/movers?kind=${kind}&universe=${encodeURIComponent(universe)}`, signal), + + watchlist: (signal?: AbortSignal) => getJson("/api/watchlist", signal), + + marketNews: (signal?: AbortSignal) => getJson("/api/market-news", signal), + + sectors: (signal?: AbortSignal) => getJson("/api/sectors", signal), + + search: (q: string, signal?: AbortSignal) => + getJson(`/api/search?q=${encodeURIComponent(q)}`, signal), + + quote: (ticker: string, signal?: AbortSignal) => + getJson(`/api/quote/${encodeURIComponent(ticker)}`, signal), + + ohlcv: (ticker: string, range: RangeKey, signal?: AbortSignal) => + getJson(`/api/ohlcv/${encodeURIComponent(ticker)}?range=${range}`, signal), + + indicators: (ticker: string, range: RangeKey, signal?: AbortSignal) => + getJson(`/api/indicators/${encodeURIComponent(ticker)}?range=${range}`, signal), + + news: (ticker: string, signal?: AbortSignal) => + getJson(`/api/news/${encodeURIComponent(ticker)}`, signal), + + about: (ticker: string, signal?: AbortSignal) => + getJson(`/api/about/${encodeURIComponent(ticker)}`, signal), + + financials: (ticker: string, period: "quarterly" | "annual", signal?: AbortSignal) => + getJson( + `/api/financials/${encodeURIComponent(ticker)}?period=${period}`, + signal, + ), + + // User-managed watchlists (SQLite-backed). + watchlists: { + list: (signal?: AbortSignal) => getJson("/api/watchlists", signal), + + get: (id: number, signal?: AbortSignal) => + getJson(`/api/watchlists/${id}`, signal), + + create: (name: string) => + mutate("POST", "/api/watchlists", { name }), + + rename: (id: number, name: string) => + mutate("PATCH", `/api/watchlists/${id}`, { name }), + + remove: (id: number) => mutate<{ ok: true }>("DELETE", `/api/watchlists/${id}`), + + addItem: (id: number, ticker: string) => + mutate("POST", `/api/watchlists/${id}/items`, { ticker }), + + removeItem: (id: number, ticker: string) => + mutate( + "DELETE", + `/api/watchlists/${id}/items/${encodeURIComponent(ticker)}`, + ), + }, + + // Manual portfolios (Google-Finance style — not broker-linked). + portfolios: { + list: (signal?: AbortSignal) => getJson("/api/portfolios", signal), + + get: (id: number, signal?: AbortSignal) => + getJson(`/api/portfolios/${id}`, signal), + + create: (name: string) => mutate("POST", "/api/portfolios", { name }), + + rename: (id: number, name: string) => + mutate("PATCH", `/api/portfolios/${id}`, { name }), + + remove: (id: number) => mutate<{ ok: true }>("DELETE", `/api/portfolios/${id}`), + + addHolding: (id: number, input: HoldingInput) => + mutate<{ ok: true }>("POST", `/api/portfolios/${id}/holdings`, input), + + updateHolding: (holdingId: number, patch: Partial) => + mutate<{ ok: true }>("PATCH", `/api/holdings/${holdingId}`, patch), + + removeHolding: (holdingId: number) => + mutate<{ ok: true }>("DELETE", `/api/holdings/${holdingId}`), + }, +}; diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..2733451 --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,132 @@ +// Formatting helpers. Vietnam board prices arrive in THOUSAND VND; we render them +// as full VND (board * 1000) with the ₫ suffix, Google-Finance style. + +const PRICE_SCALE = 1000; + +/** Board price (thousand VND) → full VND string, e.g. 28.5 → "28,500". */ +export function fmtPriceVnd(boardPrice: number | null | undefined, opts?: { suffix?: boolean }): string { + if (boardPrice == null || !Number.isFinite(boardPrice)) return "—"; + const vnd = boardPrice * PRICE_SCALE; + const digits = vnd >= 1000 ? 0 : vnd >= 100 ? 0 : 1; + const s = vnd.toLocaleString("en-US", { maximumFractionDigits: digits }); + return opts?.suffix === false ? s : `${s} ₫`; +} + +/** Board price → compact board number (no scaling), e.g. 28.5 → "28.50". */ +export function fmtBoard(boardPrice: number | null | undefined): string { + if (boardPrice == null || !Number.isFinite(boardPrice)) return "—"; + return boardPrice.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +/** Signed change in board units → full VND string, e.g. 0.5 → "+500". */ +export function fmtChangeVnd(boardChange: number | null | undefined): string { + if (boardChange == null || !Number.isFinite(boardChange)) return "—"; + const vnd = boardChange * PRICE_SCALE; + const sign = vnd > 0 ? "+" : vnd < 0 ? "−" : ""; + const s = Math.abs(vnd).toLocaleString("en-US", { maximumFractionDigits: 0 }); + return `${sign}${s}`; +} + +/** Signed percent, e.g. 1.79 → "+1.79%". */ +export function fmtPct(pct: number | null | undefined, digits = 2): string { + if (pct == null || !Number.isFinite(pct)) return "—"; + const sign = pct > 0 ? "+" : pct < 0 ? "−" : ""; + return `${sign}${Math.abs(pct).toFixed(digits)}%`; +} + +/** Index value (points), e.g. 1281.34. */ +export function fmtIndex(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return "—"; + return v.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +/** Signed absolute change for indices, e.g. 5.06 → "+5.06". */ +export function fmtChangeAbs(v: number | null | undefined, digits = 2): string { + if (v == null || !Number.isFinite(v)) return "—"; + const sign = v > 0 ? "+" : v < 0 ? "−" : ""; + return `${sign}${Math.abs(v).toFixed(digits)}`; +} + +/** Compact VND for market cap etc., e.g. 4.16e12 → "4.16T ₫". */ +export function fmtBigVnd(vnd: number | null | undefined): string { + if (vnd == null || !Number.isFinite(vnd)) return "—"; + const abs = Math.abs(vnd); + const units: [number, string][] = [ + [1e12, "T"], + [1e9, "B"], + [1e6, "M"], + [1e3, "K"], + ]; + for (const [base, suffix] of units) { + if (abs >= base) { + return `${(vnd / base).toFixed(2)}${suffix} ₫`; + } + } + return `${vnd.toLocaleString("en-US", { maximumFractionDigits: 0 })} ₫`; +} + +/** Compact count for shares / volume, e.g. 32_750_000 → "32.75M". */ +export function fmtBigNum(n: number | null | undefined): string { + if (n == null || !Number.isFinite(n)) return "—"; + const abs = Math.abs(n); + const units: [number, string][] = [ + [1e12, "T"], + [1e9, "B"], + [1e6, "M"], + [1e3, "K"], + ]; + for (const [base, suffix] of units) { + if (abs >= base) return `${(n / base).toFixed(2)}${suffix}`; + } + return n.toLocaleString("en-US", { maximumFractionDigits: 0 }); +} + +/** Plain percent value (already a ratio in %), e.g. 26.1 → "26.10". */ +export function fmtNum(v: number | null | undefined, digits = 2): string { + if (v == null || !Number.isFinite(v)) return "—"; + return v.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits }); +} + +/** Percent as plain (no sign forcing), e.g. 1.24 → "1.24%". */ +export function fmtPctPlain(v: number | null | undefined, digits = 2): string { + if (v == null || !Number.isFinite(v)) return "—"; + return `${v.toFixed(digits)}%`; +} + +/** "5 minutes ago" style relative time from an ISO string. */ +export function fmtRelativeTime(iso: string | undefined): string { + if (!iso) return ""; + const then = new Date(iso).getTime(); + if (!Number.isFinite(then)) return ""; + const diff = Date.now() - then; + const min = Math.round(diff / 60000); + if (min < 1) return "just now"; + if (min < 60) return `${min} minute${min === 1 ? "" : "s"} ago`; + const hr = Math.round(min / 60); + if (hr < 24) return `${hr} hour${hr === 1 ? "" : "s"} ago`; + const day = Math.round(hr / 24); + if (day < 30) return `${day} day${day === 1 ? "" : "s"} ago`; + return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); +} + +export type Direction = "up" | "down" | "flat"; + +/** Sign of a change → semantic direction. */ +export function dirOf(v: number | null | undefined): Direction { + if (v == null || !Number.isFinite(v) || v === 0) return "flat"; + return v > 0 ? "up" : "down"; +} + +/** Human label for a market session. */ +export function sessionLabel(s: string, isOpen: boolean): string { + const map: Record = { + "pre-open": "Pre-open", + morning: "Market open", + lunch: "Lunch break", + afternoon: "Market open", + atc: "ATC auction", + "after-hours": "After hours", + weekend: "Market closed", + }; + return map[s] ?? (isOpen ? "Market open" : "Market closed"); +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000..d75578e --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,365 @@ +// Shared API contract between the Node data server (server/index.ts) and the +// React frontend. Every field the UI renders is defined here. +// +// UNITS: Vietnam board prices (last / ref / ceiling / floor / open / high / low / +// week52_* / spark values / eps / bvps) are in THOUSAND VND — e.g. 28.5 = 28,500 VND. +// Monetary aggregates (market_cap_vnd, notional) are in plain VND. Volume is in shares. + +export type RangeKey = "1D" | "5D" | "1M" | "6M" | "YTD" | "1Y" | "5Y" | "MAX"; + +export const RANGE_KEYS: RangeKey[] = ["1D", "5D", "1M", "6M", "YTD", "1Y", "5Y", "MAX"]; + +/** Market session, derived from Vietnam trading hours (ICT). */ +export type MarketState = + | "pre-open" + | "morning" + | "lunch" + | "afternoon" + | "atc" + | "after-hours" + | "weekend"; + +// --------------------------------------------------------------------------- +// Indices (market strip) +// --------------------------------------------------------------------------- + +export interface IndexSnapshot { + symbol: string; // VNINDEX, VN30, HNXINDEX, HNX30, UPCOMINDEX + name: string; // display name, e.g. "VN-Index" + latest_close: number; + latest_time: string; // ISO + change_abs: number | null; // absolute, index points + change_pct_1d: number | null; + change_pct_1w: number | null; + change_pct_1m: number | null; + spark: number[]; // recent closes for the mini sparkline +} + +export interface IndicesResponse { + indices: IndexSnapshot[]; + asOf: string; // ISO +} + +// --------------------------------------------------------------------------- +// Movers / discover +// --------------------------------------------------------------------------- + +export interface MoverRow { + ticker: string; + name?: string; + last: number | null; // thousand VND + change_pct: number | null; // daily % + ret_1w_pct: number | null; + ret_1m_pct: number | null; + spark: number[]; +} + +export interface MoversResponse { + kind: "gainers" | "losers" | "active"; + universe: string; + rows: MoverRow[]; +} + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +export interface SearchResult { + ticker: string; + name?: string; + exchange?: string; +} + +export interface SearchResponse { + query: string; + results: SearchResult[]; +} + +// --------------------------------------------------------------------------- +// Watchlist (sidebar) — lightweight quote rows with sparkline +// --------------------------------------------------------------------------- + +export interface WatchRow { + ticker: string; + name?: string; + last: number | null; // thousand VND + change_abs: number | null; // thousand VND + change_pct: number | null; + spark: number[]; +} + +export interface WatchlistResponse { + title: string; + rows: WatchRow[]; +} + +// --------------------------------------------------------------------------- +// Quote (detail header + stats) +// --------------------------------------------------------------------------- + +export interface QuoteStats { + open: number | null; // thousand VND + high: number | null; // thousand VND + low: number | null; // thousand VND + volume: number | null; // shares (latest session) + avg_vol: number | null; // shares (avg over ~3m) + market_cap_vnd: number | null; // VND + pe: number | null; + pb: number | null; + ps: number | null; + eps_thousand_vnd: number | null; + bvps_thousand_vnd: number | null; + roe_pct: number | null; + roa_pct: number | null; + dividend_yield_pct: number | null; + shares_outstanding: number | null; + foreign_ownership_pct: number | null; + week52_high: number | null; // thousand VND + week52_low: number | null; // thousand VND + change_pct_1m: number | null; // performance over ~1 month + change_pct_3m: number | null; // performance over ~3 months + change_pct_ytd: number | null; // performance year-to-date +} + +export interface QuoteResponse { + ticker: string; + exchange: string | null; + nameVi: string | null; + nameEn: string | null; + currency: "VND"; + priceScale: number; // 1000 — board price * priceScale = VND + ref: number | null; // reference / prev close proxy (thousand VND) + ceiling: number | null; + floor: number | null; + last: number | null; // thousand VND + change_abs: number | null; // vs ref, thousand VND + change_pct: number | null; // vs ref + session: MarketState; + isOpen: boolean; + stats: QuoteStats; +} + +// --------------------------------------------------------------------------- +// OHLCV (chart) +// --------------------------------------------------------------------------- + +export interface Bar { + time: number; // unix seconds + open: number; + high: number; + low: number; + close: number; + volume: number; +} + +export interface OhlcvResponse { + ticker: string; + range: RangeKey; + resolution: string; + intraday: boolean; + prevClose: number | null; // baseline for the dashed "previous close" line + bars: Bar[]; +} + +// --------------------------------------------------------------------------- +// Indicators (chart overlays + oscillator panes) +// --------------------------------------------------------------------------- + +export interface LinePoint { + time: number; + value: number; +} + +export interface BollingerPoint { + time: number; + upper: number; + middle: number; + lower: number; +} + +export interface MacdPoint { + time: number; + macd: number; + signal: number; + histogram: number; +} + +export interface IndicatorsResponse { + ticker: string; + range: RangeKey; + sma20: LinePoint[]; + sma50: LinePoint[]; + ema20: LinePoint[]; + bollinger: BollingerPoint[]; + rsi14: LinePoint[]; + macd: MacdPoint[]; +} + +// --------------------------------------------------------------------------- +// News +// --------------------------------------------------------------------------- + +export interface NewsItem { + title: string; + url?: string; + publishedAt?: string; // ISO + source?: string; + snippet?: string; + type?: string; +} + +export interface NewsResponse { + ticker: string; + items: NewsItem[]; +} + +// --------------------------------------------------------------------------- +// Company / about + related +// --------------------------------------------------------------------------- + +export interface CompanyInfo { + nameVi?: string; + nameEn?: string; + floor?: string; + website?: string; + summary?: string; + intro?: string; + sector?: string; +} + +export interface RelatedStock { + ticker: string; + name?: string; + last: number | null; // thousand VND + change_pct: number | null; + spark: number[]; +} + +export interface AboutResponse { + ticker: string; + company: CompanyInfo; + related: RelatedStock[]; +} + +// --------------------------------------------------------------------------- +// Financials (quarterly / annual key metrics) +// --------------------------------------------------------------------------- + +export type FinancialUnit = "kVND" | "%" | "x"; + +export interface FinancialsColumn { + label: string; // "Q2 2025" or "2025" + year: number | null; + quarter: number | null; +} + +export interface FinancialMetric { + key: string; + label: string; + unit: FinancialUnit; + values: (number | null)[]; // aligned to columns +} + +export interface FinancialsResponse { + ticker: string; + period: "quarterly" | "annual"; + columns: FinancialsColumn[]; + metrics: FinancialMetric[]; +} + +// --------------------------------------------------------------------------- +// Market news (home feed) +// --------------------------------------------------------------------------- + +export interface MarketNewsResponse { + items: NewsItem[]; +} + +// --------------------------------------------------------------------------- +// Stock sectors (home sidebar — mini index list) +// --------------------------------------------------------------------------- + +export interface SectorRow { + key: string; + name: string; + change_pct: number | null; // avg daily % change of constituents + spark: number[]; // synthetic normalized index (rebased to 100, averaged) + leaders: string[]; // up to 3 constituent tickers by |change| +} + +export interface SectorsResponse { + sectors: SectorRow[]; + asOf: string; // ISO +} + +// --------------------------------------------------------------------------- +// Watchlists (user-managed, SQLite-backed) +// --------------------------------------------------------------------------- + +export interface WatchlistMeta { + id: number; + name: string; + tickers: string[]; +} + +export interface WatchlistsResponse { + watchlists: WatchlistMeta[]; +} + +export interface WatchlistDetail { + id: number; + name: string; + rows: WatchRow[]; // WatchRow (above) with live quote + sparkline +} + +// --------------------------------------------------------------------------- +// Portfolios (manual holdings, Google-Finance style — NOT broker-linked) +// --------------------------------------------------------------------------- + +export interface PortfolioMeta { + id: number; + name: string; +} + +export interface PortfoliosResponse { + portfolios: PortfolioMeta[]; +} + +export interface HoldingInput { + ticker: string; + quantity: number; + avgCostVnd: number; // plain VND / share +} + +export interface HoldingRow { + id: number; + ticker: string; + name?: string; + quantity: number; + avgCostVnd: number; // plain VND / share + last: number | null; // board units (thousand VND) + change_pct: number | null; // day % + marketValueVnd: number | null; // plain VND + costBasisVnd: number; // plain VND + gainVnd: number | null; // plain VND + gainPct: number | null; + dayChangeVnd: number | null; // plain VND + weightPct: number | null; + spark: number[]; +} + +export interface PortfolioTotals { + marketValueVnd: number | null; // plain VND + costBasisVnd: number; // plain VND + gainVnd: number | null; // plain VND + gainPct: number | null; + dayChangeVnd: number | null; // plain VND + dayChangePct: number | null; +} + +export interface PortfolioResponse { + id: number; + name: string; + holdings: HoldingRow[]; + totals: PortfolioTotals; +} diff --git a/web/src/lib/userData.tsx b/web/src/lib/userData.tsx new file mode 100644 index 0000000..2480178 --- /dev/null +++ b/web/src/lib/userData.tsx @@ -0,0 +1,217 @@ +// Shared client state for user-managed watchlists and manual portfolios. +// A single provider fetches both collections once and exposes two hooks — +// useWatchlists() / usePortfolios() — that read the cached metadata and run +// mutations against the api, updating in-memory state after each call resolves. + +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { api } from "./api"; +import type { PortfolioMeta, WatchlistMeta } from "./types"; + +interface UserDataContextValue { + watchlists: WatchlistMeta[]; + watchlistsLoading: boolean; + portfolios: PortfolioMeta[]; + portfoliosLoading: boolean; + + refreshWatchlists: () => Promise; + createWatchlist: (name: string) => Promise; + renameWatchlist: (id: number, name: string) => Promise; + removeWatchlist: (id: number) => Promise; + addWatchlistItem: (id: number, ticker: string) => Promise; + removeWatchlistItem: (id: number, ticker: string) => Promise; + + refreshPortfolios: () => Promise; + createPortfolio: (name: string) => Promise; + renamePortfolio: (id: number, name: string) => Promise; + removePortfolio: (id: number) => Promise; +} + +const UserDataContext = createContext(null); + +export function UserDataProvider({ children }: { children: ReactNode }) { + const [watchlists, setWatchlists] = useState([]); + const [watchlistsLoading, setWatchlistsLoading] = useState(true); + const [portfolios, setPortfolios] = useState([]); + const [portfoliosLoading, setPortfoliosLoading] = useState(true); + + // Initial load of both collections. + useEffect(() => { + const ac = new AbortController(); + setWatchlistsLoading(true); + setPortfoliosLoading(true); + api + .watchlists + .list(ac.signal) + .then((r) => setWatchlists(r.watchlists)) + .catch((e) => { + if (e?.name !== "AbortError") setWatchlists([]); + }) + .finally(() => setWatchlistsLoading(false)); + api + .portfolios + .list(ac.signal) + .then((r) => setPortfolios(r.portfolios)) + .catch((e) => { + if (e?.name !== "AbortError") setPortfolios([]); + }) + .finally(() => setPortfoliosLoading(false)); + return () => ac.abort(); + }, []); + + // --- Watchlist actions ----------------------------------------------------- + + const refreshWatchlists = useCallback(async () => { + setWatchlistsLoading(true); + try { + const r = await api.watchlists.list(); + setWatchlists(r.watchlists); + } finally { + setWatchlistsLoading(false); + } + }, []); + + const createWatchlist = useCallback(async (name: string) => { + try { + const meta = await api.watchlists.create(name); + setWatchlists((prev) => [...prev, meta]); + return meta; + } catch { + return null; + } + }, []); + + const renameWatchlist = useCallback(async (id: number, name: string) => { + const meta = await api.watchlists.rename(id, name); + setWatchlists((prev) => prev.map((w) => (w.id === id ? meta : w))); + }, []); + + const removeWatchlist = useCallback(async (id: number) => { + await api.watchlists.remove(id); + setWatchlists((prev) => prev.filter((w) => w.id !== id)); + }, []); + + const addWatchlistItem = useCallback(async (id: number, ticker: string) => { + const meta = await api.watchlists.addItem(id, ticker); + setWatchlists((prev) => prev.map((w) => (w.id === id ? meta : w))); + }, []); + + const removeWatchlistItem = useCallback(async (id: number, ticker: string) => { + const meta = await api.watchlists.removeItem(id, ticker); + setWatchlists((prev) => prev.map((w) => (w.id === id ? meta : w))); + }, []); + + // --- Portfolio actions ----------------------------------------------------- + + const refreshPortfolios = useCallback(async () => { + setPortfoliosLoading(true); + try { + const r = await api.portfolios.list(); + setPortfolios(r.portfolios); + } finally { + setPortfoliosLoading(false); + } + }, []); + + const createPortfolio = useCallback(async (name: string) => { + try { + const meta = await api.portfolios.create(name); + setPortfolios((prev) => [...prev, meta]); + return meta; + } catch { + return null; + } + }, []); + + const renamePortfolio = useCallback(async (id: number, name: string) => { + const meta = await api.portfolios.rename(id, name); + setPortfolios((prev) => prev.map((p) => (p.id === id ? meta : p))); + }, []); + + const removePortfolio = useCallback(async (id: number) => { + await api.portfolios.remove(id); + setPortfolios((prev) => prev.filter((p) => p.id !== id)); + }, []); + + const value = useMemo( + () => ({ + watchlists, + watchlistsLoading, + portfolios, + portfoliosLoading, + refreshWatchlists, + createWatchlist, + renameWatchlist, + removeWatchlist, + addWatchlistItem, + removeWatchlistItem, + refreshPortfolios, + createPortfolio, + renamePortfolio, + removePortfolio, + }), + [ + watchlists, + watchlistsLoading, + portfolios, + portfoliosLoading, + refreshWatchlists, + createWatchlist, + renameWatchlist, + removeWatchlist, + addWatchlistItem, + removeWatchlistItem, + refreshPortfolios, + createPortfolio, + renamePortfolio, + removePortfolio, + ], + ); + + return {children}; +} + +function useUserData(): UserDataContextValue { + const ctx = useContext(UserDataContext); + if (!ctx) { + throw new Error("useWatchlists/usePortfolios must be used within a "); + } + return ctx; +} + +export function useWatchlists() { + const ctx = useUserData(); + const { watchlists } = ctx; + + const isSaved = useCallback( + (ticker: string) => { + const t = ticker.toUpperCase(); + return watchlists.some((w) => w.tickers.includes(t)); + }, + [watchlists], + ); + + return { + watchlists, + loading: ctx.watchlistsLoading, + create: ctx.createWatchlist, + rename: ctx.renameWatchlist, + remove: ctx.removeWatchlist, + addItem: ctx.addWatchlistItem, + removeItem: ctx.removeWatchlistItem, + isSaved, + refresh: ctx.refreshWatchlists, + }; +} + +export function usePortfolios() { + const ctx = useUserData(); + return { + portfolios: ctx.portfolios, + loading: ctx.portfoliosLoading, + create: ctx.createPortfolio, + rename: ctx.renamePortfolio, + remove: ctx.removePortfolio, + refresh: ctx.refreshPortfolios, + }; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..c7bdaa3 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./index.css"; + +// Restore persisted theme before first paint (falls back to system preference). +const savedTheme = localStorage.getItem("azoth-theme"); +if (savedTheme === "light" || savedTheme === "dark") { + document.documentElement.setAttribute("data-theme", savedTheme); +} + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/web/src/pages/Home.css b/web/src/pages/Home.css new file mode 100644 index 0000000..fd26331 --- /dev/null +++ b/web/src/pages/Home.css @@ -0,0 +1,42 @@ +.home { + display: flex; + flex-direction: column; + gap: 32px; +} + +.home__section { + display: flex; + flex-direction: column; + gap: 14px; +} + +.home__title { + font-size: 24px; + font-weight: 400; + letter-spacing: -0.3px; + color: var(--text); +} + +.home__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.home__more { + font-size: 13px; + color: var(--accent); + text-decoration: none; + white-space: nowrap; + transition: color 0.12s ease; +} + +.home__more:hover { + color: var(--text); +} + +.home__subtitle { + font-size: 18px; + margin: 0; +} diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx new file mode 100644 index 0000000..3e03697 --- /dev/null +++ b/web/src/pages/Home.tsx @@ -0,0 +1,30 @@ +import { Link } from "react-router-dom"; +import MarketStrip from "../components/MarketStrip"; +import MarketSummary from "../components/MarketSummary"; +import DiscoverStrip from "../components/DiscoverStrip"; +import "./Home.css"; + +export default function Home() { + return ( +
+
+
+

Markets

+ + Market trends → + +
+ +
+ +
+ +
+ +
+

You may be interested in

+ +
+
+ ); +} diff --git a/web/src/pages/MarketTrends.css b/web/src/pages/MarketTrends.css new file mode 100644 index 0000000..b5847ab --- /dev/null +++ b/web/src/pages/MarketTrends.css @@ -0,0 +1,259 @@ +.markets { + display: flex; + flex-direction: column; + gap: 20px; +} + +.markets__head { + display: flex; + flex-direction: column; + gap: 4px; +} + +.markets__title { + font-size: 24px; + font-weight: 400; + letter-spacing: -0.3px; + color: var(--text); +} + +.markets__caption { + font-size: 14px; +} + +/* GF-style underline tab bar */ +.markets__tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--border-subtle); +} + +.markets__tab { + position: relative; + padding: 12px 16px; + font-size: 14px; + font-weight: 500; + color: var(--text-secondary); + text-decoration: none; + border-radius: 8px 8px 0 0; + transition: color 0.12s ease, background 0.12s ease; +} + +.markets__tab:hover { + color: var(--text); + background: var(--surface-hover); +} + +.markets__tab--active { + color: var(--accent); +} + +.markets__tab--active::after { + content: ""; + position: absolute; + left: 12px; + right: 12px; + bottom: -1px; + height: 3px; + border-radius: 3px 3px 0 0; + background: var(--accent); +} + +/* Toolbar (universe toggle) */ +.markets__toolbar { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.markets__universe { + display: inline-flex; + gap: 4px; +} + +.markets__uni-pill { + font-size: 12px; +} + +/* Movers table */ +.markets__table { + padding: 8px; +} + +.markets__thead, +.markets__row, +.markets__skel-row { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 12px; +} + +.markets__thead { + padding-top: 6px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-subtle); +} + +.markets__th { + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); +} + +.markets__th-sym, +.markets__sym { + flex: 1; + min-width: 0; +} + +.markets__th-spark, +.markets__spark { + flex: none; + width: 96px; +} + +.markets__th-price, +.markets__price { + flex: none; + width: 132px; + text-align: right; +} + +.markets__th-change, +.markets__change { + flex: none; + width: 120px; + display: flex; + justify-content: flex-end; +} + +.markets__list { + display: flex; + flex-direction: column; + padding-top: 4px; +} + +/* Row */ +.markets__row { + border-radius: 10px; + text-decoration: none; + color: inherit; + transition: background 0.12s ease; +} + +.markets__row:hover { + background: var(--surface-hover); +} + +.markets__sym { + display: flex; + flex-direction: column; + gap: 2px; +} + +.markets__ticker { + font-size: 14px; + font-weight: 600; + color: var(--text); +} + +.markets__name { + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.markets__spark { + display: inline-flex; + align-items: center; +} + +.markets__price { + font-size: 14px; + color: var(--text); +} + +/* Skeleton rows */ +.markets__skel-sym { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.markets__skel-ticker { + width: 52px; + height: 13px; + border-radius: var(--radius-sm); +} + +.markets__skel-name { + width: 120px; + height: 11px; + border-radius: var(--radius-sm); +} + +.markets__skel-spark { + flex: none; + width: 96px; + height: 30px; + border-radius: var(--radius-sm); +} + +.markets__skel-price { + flex: none; + width: 132px; + height: 16px; + border-radius: var(--radius-sm); +} + +.markets__skel-badge { + flex: none; + width: 120px; + height: 18px; + border-radius: var(--pill); +} + +.markets__msg { + padding: 40px 12px; + font-size: 14px; + text-align: center; +} + +/* Indexes grid */ +.markets__index-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; +} + +.markets__index-skel { + height: 148px; + border-radius: var(--radius); +} + +/* Responsive: drop the sparkline column on narrow viewports */ +@media (max-width: 640px) { + .markets__th-spark, + .markets__spark, + .markets__skel-spark { + display: none; + } + + .markets__th-price, + .markets__price, + .markets__skel-price { + width: 108px; + } + + .markets__th-change, + .markets__change, + .markets__skel-badge { + width: 104px; + } +} diff --git a/web/src/pages/MarketTrends.tsx b/web/src/pages/MarketTrends.tsx new file mode 100644 index 0000000..d088375 --- /dev/null +++ b/web/src/pages/MarketTrends.tsx @@ -0,0 +1,183 @@ +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { api } from "../lib/api"; +import type { IndexSnapshot, MoverRow } from "../lib/types"; +import { fmtPriceVnd } from "../lib/format"; +import Sparkline from "../components/Sparkline"; +import ChangeBadge from "../components/ChangeBadge"; +import IndexCard from "../components/IndexCard"; +import "./MarketTrends.css"; + +const KINDS = ["active", "gainers", "losers", "indexes"] as const; +type Kind = (typeof KINDS)[number]; + +const TABS: { key: Kind; label: string }[] = [ + { key: "active", label: "Active" }, + { key: "gainers", label: "Gainers" }, + { key: "losers", label: "Losers" }, + { key: "indexes", label: "Indexes" }, +]; + +const CAPTION: Record = { + active: "Most actively traded stocks today", + gainers: "Biggest gainers today", + losers: "Biggest losers today", + indexes: "Vietnam market indices", +}; + +type Universe = "default" | "vn30"; +const UNIVERSES: { key: Universe; label: string }[] = [ + { key: "default", label: "Liquid" }, + { key: "vn30", label: "VN30" }, +]; + +export default function MarketTrends() { + const { kind: raw } = useParams(); + const kind: Kind = KINDS.includes(raw as Kind) ? (raw as Kind) : "active"; + + const [universe, setUniverse] = useState("default"); + const [rows, setRows] = useState(null); + const [indices, setIndices] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + const ac = new AbortController(); + setError(false); + + if (kind === "indexes") { + setIndices(null); + api + .indices(ac.signal) + .then((r) => setIndices(r.indices)) + .catch((e) => { + if (e?.name !== "AbortError") setError(true); + }); + } else { + setRows(null); + api + .movers(kind, universe, ac.signal) + .then((r) => setRows(r.rows)) + .catch((e) => { + if (e?.name !== "AbortError") setError(true); + }); + } + + return () => ac.abort(); + }, [kind, universe]); + + return ( +
+
+

Market trends

+

{CAPTION[kind]}

+
+ + + + {kind === "indexes" ? ( + + ) : ( + <> +
+
+ {UNIVERSES.map((u) => ( + + ))} +
+
+ + + )} +
+ ); +} + +function MoversView({ rows, error }: { rows: MoverRow[] | null; error: boolean }) { + return ( +
+
+ Symbol +
+ + {error ? ( +

Couldn't load market trends.

+ ) : rows == null ? ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+
+ + +
+ + + +
+ ))} +
+ ) : rows.length === 0 ? ( +

No stocks to show.

+ ) : ( +
+ {rows.map((row) => ( + + + {row.ticker} + {row.name && {row.name}} + + + + + {fmtPriceVnd(row.last)} + + + + + ))} +
+ )} +
+ ); +} + +function IndexesView({ indices, error }: { indices: IndexSnapshot[] | null; error: boolean }) { + if (error) { + return ( +
+

Couldn't load market indices.

+
+ ); + } + + return ( +
+ {indices == null + ? Array.from({ length: 5 }).map((_, i) => ( +
+ )) + : indices.map((idx) => )} +
+ ); +} diff --git a/web/src/pages/Portfolio.css b/web/src/pages/Portfolio.css new file mode 100644 index 0000000..2642fae --- /dev/null +++ b/web/src/pages/Portfolio.css @@ -0,0 +1,119 @@ +.portfolio { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* ---- Header -------------------------------------------------------------- */ +.portfolio__head { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; +} + +.portfolio__title-wrap { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.portfolio__title { + font-size: 26px; + font-weight: 400; + letter-spacing: -0.3px; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.portfolio__title-skel { + width: 200px; + height: 28px; + border-radius: var(--radius-sm); +} + +.portfolio__chip { + flex: none; +} + +.portfolio__actions { + display: inline-flex; + gap: 8px; +} + +.portfolio__act { + font-size: 13px; +} + +/* ---- Portfolio switcher -------------------------------------------------- */ +.portfolio__switch { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.portfolio__switch-pill { + font-size: 13px; +} + +/* ---- Disclaimer caption -------------------------------------------------- */ +.portfolio__disclaimer { + font-size: 12px; + margin: -6px 2px 0; +} + +/* ---- Empty + error states ----------------------------------------------- */ +.portfolio__empty { + padding: 40px 32px; + display: flex; + flex-direction: column; + gap: 12px; + align-items: flex-start; +} + +.portfolio__empty-title { + font-size: 22px; + font-weight: 400; + color: var(--text); +} + +.portfolio__empty-copy { + max-width: 460px; + font-size: 14px; +} + +.portfolio__empty-btn { + margin-top: 4px; + cursor: pointer; +} + +.portfolio__error { + padding: 32px; + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} + +.portfolio__back { + margin-top: 8px; + color: var(--accent); + font-weight: 500; +} + +/* ---- Skeletons ----------------------------------------------------------- */ +.portfolio__skel-head { + width: 220px; + height: 30px; + border-radius: var(--radius-sm); +} + +.portfolio__skel-card { + width: 100%; + height: 220px; + border-radius: var(--radius); +} diff --git a/web/src/pages/Portfolio.tsx b/web/src/pages/Portfolio.tsx new file mode 100644 index 0000000..6cfeb71 --- /dev/null +++ b/web/src/pages/Portfolio.tsx @@ -0,0 +1,191 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { usePortfolios } from "../lib/userData"; +import { api } from "../lib/api"; +import type { PortfolioResponse } from "../lib/types"; +import PortfolioSummary from "../components/PortfolioSummary"; +import HoldingsTable from "../components/HoldingsTable"; +import AddHoldingForm from "../components/AddHoldingForm"; +import "./Portfolio.css"; + +export default function Portfolio() { + const { id } = useParams(); + const navigate = useNavigate(); + const { + portfolios, + loading: portfoliosLoading, + create, + rename, + remove, + } = usePortfolios(); + + const activeId = id ? Number(id) : null; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + + const refetch = useCallback(() => setReloadKey((k) => k + 1), []); + + // With no :id in the URL, land on the first portfolio (or fall through to the + // empty state below once we know there are none). + useEffect(() => { + if (id || portfoliosLoading) return; + if (portfolios.length > 0) { + navigate(`/portfolio/${portfolios[0].id}`, { replace: true }); + } + }, [id, portfolios, portfoliosLoading, navigate]); + + // Load the active portfolio (with computed quotes) on id change / after mutations. + useEffect(() => { + if (activeId == null) { + setData(null); + setLoading(false); + return; + } + const ac = new AbortController(); + setLoading(true); + setError(null); + api.portfolios + .get(activeId, ac.signal) + .then(setData) + .catch((e) => { + if (e?.name !== "AbortError") setError(e?.message || "Failed to load portfolio"); + }) + .finally(() => setLoading(false)); + return () => ac.abort(); + }, [activeId, reloadKey]); + + const handleCreate = useCallback(async () => { + const name = window.prompt("Name your portfolio", "My portfolio")?.trim(); + if (!name) return; + const meta = await create(name); + if (meta) navigate(`/portfolio/${meta.id}`); + }, [create, navigate]); + + async function handleRename() { + if (activeId == null || !data) return; + const name = window.prompt("Rename portfolio", data.name)?.trim(); + if (!name || name === data.name) return; + await rename(activeId, name); + refetch(); + } + + async function handleDelete() { + if (activeId == null || !data) return; + if (!window.confirm(`Delete "${data.name}"? This can't be undone.`)) return; + await remove(activeId); + navigate("/portfolio", { replace: true }); + } + + // ---- No portfolio selected yet ------------------------------------------- + if (activeId == null) { + if (!portfoliosLoading && portfolios.length === 0) { + return ( +
+
+

Track a portfolio

+

+ Build a manually-entered portfolio to follow your holdings with live Vietnam-market + prices. It isn't linked to any broker account. +

+ +
+
+ ); + } + // Loading portfolios, or a redirect to the first one is in flight. + return ( +
+
+
+
+ ); + } + + // ---- Failed to load the selected portfolio ------------------------------- + if (error && !data) { + return ( +
+
+

Couldn't load this portfolio.

+

{error}

+ + ← All portfolios + +
+
+ ); + } + + return ( +
+
+
+ {data ? ( +

{data.name}

+ ) : ( +
+ )} + Manual +
+
+ + + +
+
+ + {portfolios.length > 1 && ( + + )} + + {loading || !data ? ( +
+ ) : ( + <> + +

+ Manually-entered portfolio — values use live market prices but are not linked to a + broker account. +

+ + + + )} +
+ ); +} diff --git a/web/src/pages/Quote.css b/web/src/pages/Quote.css new file mode 100644 index 0000000..7bbf646 --- /dev/null +++ b/web/src/pages/Quote.css @@ -0,0 +1,72 @@ +.quote { + display: flex; + flex-direction: column; + gap: 28px; +} + +.quote__header-skel { + display: flex; + flex-direction: column; + gap: 12px; + padding: 4px 0; +} + +.quote__error { + padding: 32px; + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} + +.quote__back { + margin-top: 8px; + color: var(--accent); + font-weight: 500; +} + +/* GF-style underline tab bar */ +.quote__tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--border-subtle); + margin-top: -6px; +} + +.quote__tab { + position: relative; + border: none; + background: none; + padding: 12px 16px; + font-size: 14px; + font-weight: 500; + color: var(--text-secondary); + border-radius: 8px 8px 0 0; + transition: color 0.12s ease, background 0.12s ease; +} + +.quote__tab:hover { + color: var(--text); + background: var(--surface-hover); +} + +.quote__tab--active { + color: var(--accent); +} + +.quote__tab--active::after { + content: ""; + position: absolute; + left: 12px; + right: 12px; + bottom: -1px; + height: 3px; + border-radius: 3px 3px 0 0; + background: var(--accent); +} + +.quote__panel { + display: flex; + flex-direction: column; + gap: 28px; +} diff --git a/web/src/pages/Quote.tsx b/web/src/pages/Quote.tsx new file mode 100644 index 0000000..69c601d --- /dev/null +++ b/web/src/pages/Quote.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import QuoteHeader from "../components/QuoteHeader"; +import PriceChart from "../components/PriceChart"; +import StatsGrid from "../components/StatsGrid"; +import RelatedStocks from "../components/RelatedStocks"; +import NewsList from "../components/NewsList"; +import AboutCompany from "../components/AboutCompany"; +import FinancialsTab from "../components/FinancialsTab"; +import { api } from "../lib/api"; +import type { AboutResponse, NewsItem, QuoteResponse } from "../lib/types"; +import "./Quote.css"; + +type QuoteTab = "overview" | "financials" | "news"; +const TABS: { key: QuoteTab; label: string }[] = [ + { key: "overview", label: "Overview" }, + { key: "financials", label: "Financials" }, + { key: "news", label: "News" }, +]; + +export default function Quote() { + const { ticker = "" } = useParams(); + const sym = ticker.toUpperCase(); + + const [quote, setQuote] = useState(null); + const [about, setAbout] = useState(null); + const [news, setNews] = useState([]); + const [loading, setLoading] = useState(true); + const [newsLoading, setNewsLoading] = useState(true); + const [error, setError] = useState(null); + const [tab, setTab] = useState("overview"); + + useEffect(() => { + const ac = new AbortController(); + setLoading(true); + setNewsLoading(true); + setError(null); + setQuote(null); + setAbout(null); + setNews([]); + setTab("overview"); + + api + .quote(sym, ac.signal) + .then(setQuote) + .catch((e) => { + if (e?.name !== "AbortError") setError(e?.message || "Failed to load"); + }) + .finally(() => setLoading(false)); + + api + .about(sym, ac.signal) + .then(setAbout) + .catch(() => { + /* about is secondary */ + }); + + api + .news(sym, ac.signal) + .then((r) => setNews(r.items)) + .catch(() => { + /* news is secondary */ + }) + .finally(() => setNewsLoading(false)); + + return () => ac.abort(); + }, [sym]); + + if (error && !quote) { + return ( +
+

Couldn't load data for {sym}.

+

{error}

+ + ← Back to markets + +
+ ); + } + + return ( +
+ {loading || !quote ? ( +
+
+
+
+
+ ) : ( + + )} + + + +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === "overview" && ( +
+ {quote && } + {about?.related?.length ? : null} + {about?.company && } +
+ )} + + {tab === "financials" && } + + {tab === "news" && } +
+ ); +} diff --git a/web/src/pages/WatchlistPage.css b/web/src/pages/WatchlistPage.css new file mode 100644 index 0000000..458daf5 --- /dev/null +++ b/web/src/pages/WatchlistPage.css @@ -0,0 +1,335 @@ +.wl { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* ---- Header ------------------------------------------------------------- */ +.wl__head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.wl__titles { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.wl__title { + font-size: 28px; + font-weight: 400; + letter-spacing: -0.3px; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; +} + +.wl__count { + font-size: 13px; +} + +.wl__actions { + display: inline-flex; + gap: 8px; + flex: none; +} + +.wl__delete:hover:not(:disabled) { + color: var(--down); + border-color: var(--down); +} + +.wl .gf-pill:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ---- Add-symbol form ---------------------------------------------------- */ +.wl__add { + display: flex; + gap: 8px; + align-items: center; +} + +.wl__input { + height: 40px; + width: 220px; + max-width: 100%; + padding: 0 14px; + border-radius: var(--pill); + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + font-size: 14px; + letter-spacing: 0.06em; + text-transform: uppercase; + transition: border-color 0.12s ease, background 0.12s ease; +} + +.wl__input::placeholder { + color: var(--text-muted); + letter-spacing: normal; + text-transform: none; +} + +.wl__input:focus { + outline: none; + border-color: var(--accent); + background: var(--surface); +} + +.wl__add-btn { + height: 40px; +} + +.wl__msg { + margin: -4px 0 0; + font-size: 13px; +} + +/* ---- Table -------------------------------------------------------------- */ +.wl__card { + padding: 4px 8px; + overflow: hidden; +} + +.wl__scroll { + overflow-x: auto; +} + +.wl__table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.wl__colhead { + border-bottom: 1px solid var(--border-subtle); +} + +.wl__th { + padding: 12px 12px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); + text-align: right; + white-space: nowrap; +} + +.wl__th--sym { + text-align: left; +} + +.wl__th--spark, +.wl__th--act { + width: 1%; +} + +.wl__th--num { + width: 128px; +} + +/* Rows */ +.wl__row { + border-bottom: 1px solid var(--border-subtle); + transition: background 0.12s ease; +} + +.wl__row:last-child { + border-bottom: none; +} + +.wl__row:hover { + background: var(--surface-hover); +} + +.wl__td { + padding: 12px; + vertical-align: middle; + white-space: nowrap; +} + +.wl__td--num { + text-align: right; + color: var(--text); +} + +.wl__td--num.up { + color: var(--up); +} +.wl__td--num.down { + color: var(--down); +} +.wl__td--num.flat { + color: var(--flat); +} + +.wl__td--spark { + width: 1%; +} + +.wl__symlink { + display: inline-flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.wl__ticker { + font-size: 14px; + font-weight: 600; + color: var(--text); +} + +.wl__symlink:hover .wl__ticker { + color: var(--accent); +} + +.wl__name { + font-size: 12px; + color: var(--text-secondary); + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wl__badge-wrap { + display: inline-flex; + justify-content: flex-end; +} + +.wl__td--act { + width: 1%; + text-align: right; +} + +.wl__remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: transparent; + color: var(--text-muted); + font-size: 20px; + line-height: 1; + transition: background 0.12s ease, color 0.12s ease; +} + +.wl__remove:hover:not(:disabled) { + background: var(--down-bg); + color: var(--down-strong); +} + +.wl__remove:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ---- Empty state -------------------------------------------------------- */ +.wl__empty { + padding: 48px 16px; + text-align: center; +} + +.wl__empty-title { + margin: 0 0 6px; + font-size: 15px; + font-weight: 500; + color: var(--text); +} + +.wl__empty .text-secondary { + max-width: 420px; + margin: 0 auto; + font-size: 13px; +} + +/* ---- Error card --------------------------------------------------------- */ +.wl__error { + padding: 32px; + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} + +.wl__error-title { + margin: 0; + font-size: 18px; + font-weight: 500; + color: var(--text); +} + +.wl__back { + margin-top: 8px; + color: var(--accent); + font-weight: 500; +} + +.wl__retry { + margin-top: 8px; +} + +/* ---- Skeletons ---------------------------------------------------------- */ +.wl__skel-title { + width: 200px; + height: 26px; + border-radius: var(--radius-sm); +} + +.wl__skel-sub { + width: 80px; + height: 13px; + border-radius: var(--radius-sm); +} + +.wl__skel-ticker { + display: block; + width: 52px; + height: 13px; + border-radius: var(--radius-sm); + margin-bottom: 6px; +} + +.wl__skel-name { + display: block; + width: 120px; + height: 11px; + border-radius: var(--radius-sm); +} + +.wl__skel-spark { + display: block; + width: 112px; + height: 32px; + border-radius: var(--radius-sm); +} + +.wl__skel-num { + display: inline-block; + width: 72px; + height: 14px; + border-radius: var(--radius-sm); +} + +/* ---- Narrow screens ----------------------------------------------------- */ +@media (max-width: 640px) { + .wl__th--spark, + .wl__td--spark { + display: none; + } + .wl__name { + max-width: 160px; + } +} diff --git a/web/src/pages/WatchlistPage.tsx b/web/src/pages/WatchlistPage.tsx new file mode 100644 index 0000000..fffc183 --- /dev/null +++ b/web/src/pages/WatchlistPage.tsx @@ -0,0 +1,307 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { FormEvent } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { api, ApiError } from "../lib/api"; +import type { WatchlistDetail } from "../lib/types"; +import { dirOf, fmtChangeVnd, fmtPriceVnd } from "../lib/format"; +import { useWatchlists } from "../lib/userData"; +import Sparkline from "../components/Sparkline"; +import ChangeBadge from "../components/ChangeBadge"; +import "./WatchlistPage.css"; + +export default function WatchlistPage() { + const { id: idParam } = useParams(); + const id = Number(idParam); + const navigate = useNavigate(); + const { rename, remove, addItem, removeItem } = useWatchlists(); + + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [input, setInput] = useState(""); + const [addError, setAddError] = useState(null); + const [actionError, setActionError] = useState(null); + const [busy, setBusy] = useState(false); + + const load = useCallback( + async (signal?: AbortSignal) => { + if (!Number.isFinite(id)) { + setDetail(null); + setError("notfound"); + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const d = await api.watchlists.get(id, signal); + setDetail(d); + } catch (e: any) { + if (e?.name === "AbortError") return; + setDetail(null); + setError(e instanceof ApiError && e.status === 404 ? "notfound" : e?.message || "Failed to load"); + } finally { + if (!signal?.aborted) setLoading(false); + } + }, + [id], + ); + + useEffect(() => { + const ac = new AbortController(); + setInput(""); + setAddError(null); + setActionError(null); + load(ac.signal); + return () => ac.abort(); + }, [load]); + + const sanitized = useMemo(() => input.toUpperCase().replace(/[^A-Z0-9]/g, ""), [input]); + const canAdd = /^[A-Z0-9]{3,}$/.test(sanitized) && !busy; + + async function onAdd(e: FormEvent) { + e.preventDefault(); + if (!canAdd) return; + setBusy(true); + setAddError(null); + setActionError(null); + try { + await addItem(id, sanitized); + setInput(""); + await load(); + } catch (err: any) { + setAddError(err?.message || `Couldn't add ${sanitized}`); + } finally { + setBusy(false); + } + } + + async function onRemove(ticker: string) { + setBusy(true); + setActionError(null); + try { + await removeItem(id, ticker); + await load(); + } catch (err: any) { + setActionError(err?.message || `Couldn't remove ${ticker}`); + } finally { + setBusy(false); + } + } + + async function onRename() { + const current = detail?.name ?? ""; + const next = window.prompt("Rename watchlist", current); + if (next == null) return; + const trimmed = next.trim(); + if (!trimmed || trimmed === current) return; + setBusy(true); + setActionError(null); + try { + await rename(id, trimmed); + await load(); + } catch (err: any) { + setActionError(err?.message || "Couldn't rename watchlist"); + } finally { + setBusy(false); + } + } + + async function onDelete() { + const label = detail?.name ?? "this watchlist"; + if (!window.confirm(`Delete "${label}"? This can't be undone.`)) return; + setBusy(true); + setActionError(null); + try { + await remove(id); + navigate("/"); + } catch (err: any) { + setActionError(err?.message || "Couldn't delete watchlist"); + setBusy(false); + } + } + + // --- Not-found / hard error ------------------------------------------------- + if (error === "notfound") { + return ( +
+
+

Watchlist not found

+

It may have been deleted or the link is out of date.

+ + ← Back to markets + +
+
+ ); + } + + if (error && !detail) { + return ( +
+
+

Couldn't load this watchlist

+

{error}

+ +
+
+ ); + } + + const rows = detail?.rows ?? []; + const count = rows.length; + + return ( +
+
+
+ {loading && !detail ? ( + <> + + + + ) : ( + <> +

{detail?.name}

+

+ {count} {count === 1 ? "symbol" : "symbols"} +

+ + )} +
+
+ + +
+
+ +
+ { + setInput(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, "")); + if (addError) setAddError(null); + }} + /> + +
+ {addError &&

{addError}

} + {actionError &&

{actionError}

} + +
+
+ + + + + + + + + + + {loading && !detail ? ( + Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + )) + ) : count === 0 ? ( + + + + ) : ( + rows.map((row) => { + const dir = dirOf(row.change_abs); + return ( + + + + + + + + + ); + }) + )} + +
Symbol + PriceChange% Change +
+ + + + + + + + + + + +
+

No symbols yet

+

+ Add a ticker above to start tracking it. Prices, day change, and a mini chart + show up here. +

+
+ + {row.ticker} + {row.name && {row.name}} + + + + {fmtPriceVnd(row.last)}{fmtChangeVnd(row.change_abs)} + + + + + +
+
+
+
+ ); +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..f4d1ef2 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..b63940d --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// Frontend dev server proxies /api to the Node data server (server/index.ts). +export default defineConfig({ + plugins: [react()], + server: { + port: 5273, + proxy: { + "/api": { + target: "http://localhost:8787", + changeOrigin: true, + }, + }, + }, + build: { + outDir: "dist", + sourcemap: false, + }, +});