From 6012dd3fcf7f7383fd2632caebda03e682217774 Mon Sep 17 00:00:00 2001 From: francy51 Date: Mon, 23 Feb 2026 23:38:06 -0500 Subject: [PATCH] rebuild app as turbopack-first single-stack with internal api and openclaw tasks --- .gitignore | 5 + README.md | 151 +-- backend/bun.lock | 40 - .../[accessionNumber]/analyze/route.ts | 26 + frontend/app/api/filings/route.ts | 22 + frontend/app/api/filings/sync/route.ts | 28 + frontend/app/api/health/route.ts | 16 + frontend/app/api/me/route.ts | 10 + .../app/api/portfolio/holdings/[id]/route.ts | 89 ++ frontend/app/api/portfolio/holdings/route.ts | 97 ++ .../api/portfolio/insights/generate/route.ts | 16 + .../api/portfolio/insights/latest/route.ts | 10 + .../app/api/portfolio/refresh-prices/route.ts | 16 + frontend/app/api/portfolio/summary/route.ts | 8 + frontend/app/api/tasks/[taskId]/route.ts | 17 + frontend/app/api/tasks/route.ts | 20 + frontend/app/api/watchlist/[id]/route.ts | 29 + frontend/app/api/watchlist/route.ts | 71 + frontend/app/auth/signin/page.tsx | 78 +- frontend/app/auth/signup/page.tsx | 88 +- frontend/app/filings/page.tsx | 9 + frontend/app/globals.css | 2 + frontend/app/layout.tsx | 13 +- frontend/components/shell/app-shell.tsx | 28 +- frontend/hooks/use-auth-guard.ts | 26 +- frontend/lib/better-auth.ts | 11 - frontend/lib/runtime-url.ts | 40 +- frontend/lib/server/http.ts | 11 + frontend/lib/server/openclaw.ts | 92 ++ frontend/lib/server/portfolio.ts | 69 + frontend/lib/server/prices.ts | 44 + frontend/lib/server/sec.ts | 248 ++++ frontend/lib/server/store.ts | 100 ++ frontend/lib/server/tasks.ts | 404 ++++++ frontend/lib/types.ts | 3 +- frontend/next-env.d.ts | 3 +- frontend/next.config.js | 5 +- frontend/package-lock.json | 1187 +---------------- frontend/package.json | 5 +- frontend/tsconfig.json | 24 +- 40 files changed, 1583 insertions(+), 1578 deletions(-) create mode 100644 frontend/app/api/filings/[accessionNumber]/analyze/route.ts create mode 100644 frontend/app/api/filings/route.ts create mode 100644 frontend/app/api/filings/sync/route.ts create mode 100644 frontend/app/api/health/route.ts create mode 100644 frontend/app/api/me/route.ts create mode 100644 frontend/app/api/portfolio/holdings/[id]/route.ts create mode 100644 frontend/app/api/portfolio/holdings/route.ts create mode 100644 frontend/app/api/portfolio/insights/generate/route.ts create mode 100644 frontend/app/api/portfolio/insights/latest/route.ts create mode 100644 frontend/app/api/portfolio/refresh-prices/route.ts create mode 100644 frontend/app/api/portfolio/summary/route.ts create mode 100644 frontend/app/api/tasks/[taskId]/route.ts create mode 100644 frontend/app/api/tasks/route.ts create mode 100644 frontend/app/api/watchlist/[id]/route.ts create mode 100644 frontend/app/api/watchlist/route.ts delete mode 100644 frontend/lib/better-auth.ts create mode 100644 frontend/lib/server/http.ts create mode 100644 frontend/lib/server/openclaw.ts create mode 100644 frontend/lib/server/portfolio.ts create mode 100644 frontend/lib/server/prices.ts create mode 100644 frontend/lib/server/sec.ts create mode 100644 frontend/lib/server/store.ts create mode 100644 frontend/lib/server/tasks.ts diff --git a/.gitignore b/.gitignore index 3b53cba..54bc099 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules/ .pnp .pnp.js +.cache/ +Library/ # Testing coverage/ @@ -33,3 +35,6 @@ out/ # Database *.db *.sqlite + +# Local app runtime state +frontend/data/*.json diff --git a/README.md b/README.md index 53ea51d..63986b9 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,30 @@ -# Fiscal Clone 2.0 +# Fiscal Clone 3.0 (Turbopack Rebuild) -Ground-up rebuild of a `fiscal.ai`-style platform with: -- Better Auth for session-backed auth -- Next.js frontend -- high-throughput API service -- durable long-running task worker -- OpenClaw/ZeroClaw AI integration -- futuristic terminal UI language +Ground-up rebuild into a single Next.js 16 application that runs with Turbopack and internal API routes. -## Feature Coverage +## What changed -- Authentication (email/password via Better Auth) -- Watchlist management -- SEC filings ingestion (10-K, 10-Q, 8-K) -- Filing analysis jobs (async AI pipeline) -- Portfolio holdings and summary analytics -- Price refresh jobs (async) -- AI portfolio insight jobs (async) -- Task tracking endpoint and UI polling +- Removed hard runtime dependency on the external backend for core app workflows. +- Added internal `app/api/*` services for watchlist, portfolio, filings, tasks, and health. +- Added durable local data store at runtime (`frontend/data/store.json`). +- Added async task engine with retry support for: + - `sync_filings` + - `refresh_prices` + - `analyze_filing` + - `portfolio_insights` +- Added OpenClaw integration through OpenAI-compatible `/v1/chat/completions`. +- Enforced Turbopack for both development and production builds. ## Architecture -- `frontend/`: Next.js App Router UI -- `backend/`: Elysia API + Better Auth + domain routes -- `backend/src/worker.ts`: durable queue worker -- `docs/REBUILD_DECISIONS.md`: one-by-one architecture decisions +- `frontend/`: full app (UI + API + task engine) +- `frontend/app/api/*`: route handlers +- `frontend/lib/server/*`: storage, task processors, SEC/pricing adapters, OpenClaw client +- `frontend/data/store.json`: generated local runtime state (git-ignored) -Runtime topology: -1. Frontend web app -2. Backend API -3. Worker process for long tasks -4. PostgreSQL +The legacy `backend/` folder is retained in-repo but no longer required for the rebuilt local workflow. -## Local Setup - -```bash -cp .env.example .env -``` - -### 1) Backend - -```bash -cd backend -bun install -bun run db:migrate -bun run dev -``` - -### 2) Worker (new terminal) - -```bash -cd backend -bun run dev:worker -``` - -### 3) Frontend (new terminal) +## Run ```bash cd frontend @@ -62,74 +32,43 @@ npm install npm run dev ``` -Frontend: `http://localhost:3000` -Backend: `http://localhost:3001` -Swagger: `http://localhost:3001/swagger` +Open: [http://localhost:3000](http://localhost:3000) -## Docker Compose +## Build (Turbopack) ```bash -docker compose up --build +cd frontend +npm run build +npm run start ``` -This starts: `postgres`, `backend`, `worker`, `frontend`. +## OpenClaw setup -## Coolify - -Deploy using the root compose file and configure separate public domains for: -- `frontend` on port `3000` -- `backend` on port `3001` - -Use the full guide in `COOLIFY.md`. - -Critical variables for Coolify: -- `FRONTEND_URL` = frontend public URL -- `BETTER_AUTH_BASE_URL` = backend public URL -- `NEXT_PUBLIC_API_URL` = backend public URL (build-time in frontend) - -## Core API Surface - -Auth: -- `ALL /api/auth/*` (Better Auth handler) -- `GET /api/me` - -Watchlist: -- `GET /api/watchlist` -- `POST /api/watchlist` -- `DELETE /api/watchlist/:id` - -Portfolio: -- `GET /api/portfolio/holdings` -- `POST /api/portfolio/holdings` -- `PATCH /api/portfolio/holdings/:id` -- `DELETE /api/portfolio/holdings/:id` -- `GET /api/portfolio/summary` -- `POST /api/portfolio/refresh-prices` (queues task) -- `POST /api/portfolio/insights/generate` (queues task) -- `GET /api/portfolio/insights/latest` - -Filings: -- `GET /api/filings?ticker=&limit=` -- `GET /api/filings/:accessionNumber` -- `POST /api/filings/sync` (queues task) -- `POST /api/filings/:accessionNumber/analyze` (queues task) - -Task tracking: -- `GET /api/tasks` -- `GET /api/tasks/:taskId` - -## OpenClaw / ZeroClaw Integration - -Set these in `.env`: +Set in environment (for example `frontend/.env.local`): ```env OPENCLAW_BASE_URL=http://localhost:4000 -OPENCLAW_API_KEY=... +OPENCLAW_API_KEY=your_key OPENCLAW_MODEL=zeroclaw +SEC_USER_AGENT=Fiscal Clone ``` -The backend expects an OpenAI-compatible `/v1/chat/completions` endpoint. +If OpenClaw is not configured, the app falls back to local analysis responses so task flows remain testable. -## Decision Log +## API surface -See `docs/REBUILD_DECISIONS.md` for the detailed rationale and tradeoffs behind each major design choice. +- `GET /api/health` +- `GET /api/me` +- `GET|POST /api/watchlist` +- `DELETE /api/watchlist/:id` +- `GET|POST /api/portfolio/holdings` +- `PATCH|DELETE /api/portfolio/holdings/:id` +- `GET /api/portfolio/summary` +- `POST /api/portfolio/refresh-prices` +- `POST /api/portfolio/insights/generate` +- `GET /api/portfolio/insights/latest` +- `GET /api/filings` +- `POST /api/filings/sync` +- `POST /api/filings/:accessionNumber/analyze` +- `GET /api/tasks` +- `GET /api/tasks/:taskId` diff --git a/backend/bun.lock b/backend/bun.lock index 6ef875c..70a80e3 100644 --- a/backend/bun.lock +++ b/backend/bun.lock @@ -7,18 +7,14 @@ "dependencies": { "@elysiajs/cors": "^1.4.1", "@elysiajs/swagger": "^1.3.1", - "bcryptjs": "^3.0.3", "better-auth": "^1.4.18", "dotenv": "^17.3.1", "elysia": "^1.4.25", - "jsonwebtoken": "^9.0.3", "pg": "^8.18.0", "postgres": "^3.4.8", "zod": "^4.3.6", }, "devDependencies": { - "@types/bcryptjs": "^3.0.0", - "@types/jsonwebtoken": "^9.0.10", "@types/pg": "^8.16.0", "bun-types": "latest", }, @@ -57,26 +53,16 @@ "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], - "@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="], - - "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], - - "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], "@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="], "@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="], - "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], - "better-auth": ["better-auth@1.4.18", "", { "dependencies": { "@better-auth/core": "1.4.18", "@better-auth/telemetry": "1.4.18", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg=="], "better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], - "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], - "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -87,8 +73,6 @@ "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], - "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], - "elysia": ["elysia@1.4.25", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-liKjavH99Gpzrv9cDil6uYWmPuqESfPFV1FIaFSd3iNqo3y7e29sN43VxFIK8tWWnyi6eDAmi2SZk8hNAMQMyg=="], "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], @@ -103,28 +87,8 @@ "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], - "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], - - "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], - - "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], - "kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="], - "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], - - "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], - - "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], - - "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], - - "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], - - "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], - - "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], - "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -165,10 +129,6 @@ "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], diff --git a/frontend/app/api/filings/[accessionNumber]/analyze/route.ts b/frontend/app/api/filings/[accessionNumber]/analyze/route.ts new file mode 100644 index 0000000..66fe27e --- /dev/null +++ b/frontend/app/api/filings/[accessionNumber]/analyze/route.ts @@ -0,0 +1,26 @@ +import { asErrorMessage, jsonError } from '@/lib/server/http'; +import { enqueueTask } from '@/lib/server/tasks'; + +type Context = { + params: Promise<{ accessionNumber: string }>; +}; + +export async function POST(_request: Request, context: Context) { + try { + const { accessionNumber } = await context.params; + + if (!accessionNumber || accessionNumber.trim().length < 4) { + return jsonError('Invalid accession number'); + } + + const task = await enqueueTask({ + taskType: 'analyze_filing', + payload: { accessionNumber: accessionNumber.trim() }, + priority: 65 + }); + + return Response.json({ task }); + } catch (error) { + return jsonError(asErrorMessage(error, 'Failed to queue filing analysis task')); + } +} diff --git a/frontend/app/api/filings/route.ts b/frontend/app/api/filings/route.ts new file mode 100644 index 0000000..1de425c --- /dev/null +++ b/frontend/app/api/filings/route.ts @@ -0,0 +1,22 @@ +import { getStoreSnapshot } from '@/lib/server/store'; + +export async function GET(request: Request) { + const url = new URL(request.url); + const tickerFilter = url.searchParams.get('ticker')?.trim().toUpperCase(); + const limitValue = Number(url.searchParams.get('limit') ?? 50); + const limit = Number.isFinite(limitValue) + ? Math.min(Math.max(Math.trunc(limitValue), 1), 250) + : 50; + + const snapshot = await getStoreSnapshot(); + const filtered = tickerFilter + ? snapshot.filings.filter((filing) => filing.ticker === tickerFilter) + : snapshot.filings; + + const filings = filtered + .slice() + .sort((a, b) => Date.parse(b.filing_date) - Date.parse(a.filing_date)) + .slice(0, limit); + + return Response.json({ filings }); +} diff --git a/frontend/app/api/filings/sync/route.ts b/frontend/app/api/filings/sync/route.ts new file mode 100644 index 0000000..503082b --- /dev/null +++ b/frontend/app/api/filings/sync/route.ts @@ -0,0 +1,28 @@ +import { asErrorMessage, jsonError } from '@/lib/server/http'; +import { enqueueTask } from '@/lib/server/tasks'; + +export async function POST(request: Request) { + try { + const payload = await request.json() as { + ticker?: string; + limit?: number; + }; + + if (!payload.ticker || payload.ticker.trim().length < 1) { + return jsonError('ticker is required'); + } + + const task = await enqueueTask({ + taskType: 'sync_filings', + payload: { + ticker: payload.ticker.trim().toUpperCase(), + limit: payload.limit ?? 20 + }, + priority: 90 + }); + + return Response.json({ task }); + } catch (error) { + return jsonError(asErrorMessage(error, 'Failed to queue filings sync task')); + } +} diff --git a/frontend/app/api/health/route.ts b/frontend/app/api/health/route.ts new file mode 100644 index 0000000..ce9ffd1 --- /dev/null +++ b/frontend/app/api/health/route.ts @@ -0,0 +1,16 @@ +import { getStoreSnapshot } from '@/lib/server/store'; + +export async function GET() { + const snapshot = await getStoreSnapshot(); + const queue = snapshot.tasks.reduce>((acc, task) => { + acc[task.status] = (acc[task.status] ?? 0) + 1; + return acc; + }, {}); + + return Response.json({ + status: 'ok', + version: '3.0.0', + timestamp: new Date().toISOString(), + queue + }); +} diff --git a/frontend/app/api/me/route.ts b/frontend/app/api/me/route.ts new file mode 100644 index 0000000..9aa93c1 --- /dev/null +++ b/frontend/app/api/me/route.ts @@ -0,0 +1,10 @@ +export async function GET() { + return Response.json({ + user: { + id: 1, + email: 'operator@local.fiscal', + name: 'Local Operator', + image: null + } + }); +} diff --git a/frontend/app/api/portfolio/holdings/[id]/route.ts b/frontend/app/api/portfolio/holdings/[id]/route.ts new file mode 100644 index 0000000..151fe7e --- /dev/null +++ b/frontend/app/api/portfolio/holdings/[id]/route.ts @@ -0,0 +1,89 @@ +import { jsonError } from '@/lib/server/http'; +import { recalculateHolding } from '@/lib/server/portfolio'; +import { withStore } from '@/lib/server/store'; + +type Context = { + params: Promise<{ id: string }>; +}; + +function nowIso() { + return new Date().toISOString(); +} + +function asPositiveNumber(value: unknown) { + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +export async function PATCH(request: Request, context: Context) { + const { id } = await context.params; + const numericId = Number(id); + + if (!Number.isInteger(numericId) || numericId <= 0) { + return jsonError('Invalid holding id'); + } + + const payload = await request.json() as { + shares?: number; + avgCost?: number; + currentPrice?: number; + }; + + let found = false; + let updated: unknown = null; + + await withStore((store) => { + const index = store.holdings.findIndex((entry) => entry.id === numericId); + if (index < 0) { + return; + } + + found = true; + const existing = store.holdings[index]; + + const shares = asPositiveNumber(payload.shares) ?? Number(existing.shares); + const avgCost = asPositiveNumber(payload.avgCost) ?? Number(existing.avg_cost); + const currentPrice = asPositiveNumber(payload.currentPrice) ?? Number(existing.current_price ?? existing.avg_cost); + + const next = recalculateHolding({ + ...existing, + shares: shares.toFixed(6), + avg_cost: avgCost.toFixed(6), + current_price: currentPrice.toFixed(6), + updated_at: nowIso(), + last_price_at: nowIso() + }); + + store.holdings[index] = next; + updated = next; + }); + + if (!found) { + return jsonError('Holding not found', 404); + } + + return Response.json({ holding: updated }); +} + +export async function DELETE(_request: Request, context: Context) { + const { id } = await context.params; + const numericId = Number(id); + + if (!Number.isInteger(numericId) || numericId <= 0) { + return jsonError('Invalid holding id'); + } + + let removed = false; + + await withStore((store) => { + const next = store.holdings.filter((holding) => holding.id !== numericId); + removed = next.length !== store.holdings.length; + store.holdings = next; + }); + + if (!removed) { + return jsonError('Holding not found', 404); + } + + return Response.json({ success: true }); +} diff --git a/frontend/app/api/portfolio/holdings/route.ts b/frontend/app/api/portfolio/holdings/route.ts new file mode 100644 index 0000000..324d515 --- /dev/null +++ b/frontend/app/api/portfolio/holdings/route.ts @@ -0,0 +1,97 @@ +import type { Holding } from '@/lib/types'; +import { asErrorMessage, jsonError } from '@/lib/server/http'; +import { recalculateHolding } from '@/lib/server/portfolio'; +import { getStoreSnapshot, withStore } from '@/lib/server/store'; + +function nowIso() { + return new Date().toISOString(); +} + +function asPositiveNumber(value: unknown) { + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +export async function GET() { + const snapshot = await getStoreSnapshot(); + const holdings = snapshot.holdings + .slice() + .sort((a, b) => Number(b.market_value) - Number(a.market_value)); + + return Response.json({ holdings }); +} + +export async function POST(request: Request) { + try { + const payload = await request.json() as { + ticker?: string; + shares?: number; + avgCost?: number; + currentPrice?: number; + }; + + if (!payload.ticker || payload.ticker.trim().length < 1) { + return jsonError('ticker is required'); + } + + const shares = asPositiveNumber(payload.shares); + const avgCost = asPositiveNumber(payload.avgCost); + + if (shares === null) { + return jsonError('shares must be a positive number'); + } + + if (avgCost === null) { + return jsonError('avgCost must be a positive number'); + } + + const ticker = payload.ticker.trim().toUpperCase(); + const now = nowIso(); + let holding: Holding | null = null; + + await withStore((store) => { + const existingIndex = store.holdings.findIndex((entry) => entry.ticker === ticker); + const currentPrice = asPositiveNumber(payload.currentPrice) ?? avgCost; + + if (existingIndex >= 0) { + const existing = store.holdings[existingIndex]; + const updated = recalculateHolding({ + ...existing, + ticker, + shares: shares.toFixed(6), + avg_cost: avgCost.toFixed(6), + current_price: currentPrice.toFixed(6), + updated_at: now, + last_price_at: now + }); + + store.holdings[existingIndex] = updated; + holding = updated; + return; + } + + store.counters.holdings += 1; + const created = recalculateHolding({ + id: store.counters.holdings, + user_id: 1, + ticker, + shares: shares.toFixed(6), + avg_cost: avgCost.toFixed(6), + current_price: currentPrice.toFixed(6), + market_value: '0', + gain_loss: '0', + gain_loss_pct: '0', + last_price_at: now, + created_at: now, + updated_at: now + }); + + store.holdings.unshift(created); + holding = created; + }); + + return Response.json({ holding }); + } catch (error) { + return jsonError(asErrorMessage(error, 'Failed to save holding')); + } +} diff --git a/frontend/app/api/portfolio/insights/generate/route.ts b/frontend/app/api/portfolio/insights/generate/route.ts new file mode 100644 index 0000000..904239b --- /dev/null +++ b/frontend/app/api/portfolio/insights/generate/route.ts @@ -0,0 +1,16 @@ +import { asErrorMessage, jsonError } from '@/lib/server/http'; +import { enqueueTask } from '@/lib/server/tasks'; + +export async function POST() { + try { + const task = await enqueueTask({ + taskType: 'portfolio_insights', + payload: {}, + priority: 70 + }); + + return Response.json({ task }); + } catch (error) { + return jsonError(asErrorMessage(error, 'Failed to queue insights task')); + } +} diff --git a/frontend/app/api/portfolio/insights/latest/route.ts b/frontend/app/api/portfolio/insights/latest/route.ts new file mode 100644 index 0000000..98c3120 --- /dev/null +++ b/frontend/app/api/portfolio/insights/latest/route.ts @@ -0,0 +1,10 @@ +import { getStoreSnapshot } from '@/lib/server/store'; + +export async function GET() { + const snapshot = await getStoreSnapshot(); + const insight = snapshot.insights + .slice() + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))[0] ?? null; + + return Response.json({ insight }); +} diff --git a/frontend/app/api/portfolio/refresh-prices/route.ts b/frontend/app/api/portfolio/refresh-prices/route.ts new file mode 100644 index 0000000..734a5c5 --- /dev/null +++ b/frontend/app/api/portfolio/refresh-prices/route.ts @@ -0,0 +1,16 @@ +import { asErrorMessage, jsonError } from '@/lib/server/http'; +import { enqueueTask } from '@/lib/server/tasks'; + +export async function POST() { + try { + const task = await enqueueTask({ + taskType: 'refresh_prices', + payload: {}, + priority: 80 + }); + + return Response.json({ task }); + } catch (error) { + return jsonError(asErrorMessage(error, 'Failed to queue refresh task')); + } +} diff --git a/frontend/app/api/portfolio/summary/route.ts b/frontend/app/api/portfolio/summary/route.ts new file mode 100644 index 0000000..1e3fec4 --- /dev/null +++ b/frontend/app/api/portfolio/summary/route.ts @@ -0,0 +1,8 @@ +import { buildPortfolioSummary } from '@/lib/server/portfolio'; +import { getStoreSnapshot } from '@/lib/server/store'; + +export async function GET() { + const snapshot = await getStoreSnapshot(); + const summary = buildPortfolioSummary(snapshot.holdings); + return Response.json({ summary }); +} diff --git a/frontend/app/api/tasks/[taskId]/route.ts b/frontend/app/api/tasks/[taskId]/route.ts new file mode 100644 index 0000000..0a2bf2f --- /dev/null +++ b/frontend/app/api/tasks/[taskId]/route.ts @@ -0,0 +1,17 @@ +import { jsonError } from '@/lib/server/http'; +import { getTaskById } from '@/lib/server/tasks'; + +type Context = { + params: Promise<{ taskId: string }>; +}; + +export async function GET(_request: Request, context: Context) { + const { taskId } = await context.params; + const task = await getTaskById(taskId); + + if (!task) { + return jsonError('Task not found', 404); + } + + return Response.json({ task }); +} diff --git a/frontend/app/api/tasks/route.ts b/frontend/app/api/tasks/route.ts new file mode 100644 index 0000000..e394cb7 --- /dev/null +++ b/frontend/app/api/tasks/route.ts @@ -0,0 +1,20 @@ +import type { TaskStatus } from '@/lib/types'; +import { listRecentTasks } from '@/lib/server/tasks'; + +const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed']; + +export async function GET(request: Request) { + const url = new URL(request.url); + const limitValue = Number(url.searchParams.get('limit') ?? 20); + const limit = Number.isFinite(limitValue) + ? Math.min(Math.max(Math.trunc(limitValue), 1), 200) + : 20; + + const rawStatuses = url.searchParams.getAll('status'); + const statuses = rawStatuses.filter((status): status is TaskStatus => { + return ALLOWED_STATUSES.includes(status as TaskStatus); + }); + + const tasks = await listRecentTasks(limit, statuses.length > 0 ? statuses : undefined); + return Response.json({ tasks }); +} diff --git a/frontend/app/api/watchlist/[id]/route.ts b/frontend/app/api/watchlist/[id]/route.ts new file mode 100644 index 0000000..c95d44a --- /dev/null +++ b/frontend/app/api/watchlist/[id]/route.ts @@ -0,0 +1,29 @@ +import { jsonError } from '@/lib/server/http'; +import { withStore } from '@/lib/server/store'; + +type Context = { + params: Promise<{ id: string }>; +}; + +export async function DELETE(_request: Request, context: Context) { + const { id } = await context.params; + const numericId = Number(id); + + if (!Number.isInteger(numericId) || numericId <= 0) { + return jsonError('Invalid watchlist id', 400); + } + + let removed = false; + + await withStore((store) => { + const next = store.watchlist.filter((item) => item.id !== numericId); + removed = next.length !== store.watchlist.length; + store.watchlist = next; + }); + + if (!removed) { + return jsonError('Watchlist item not found', 404); + } + + return Response.json({ success: true }); +} diff --git a/frontend/app/api/watchlist/route.ts b/frontend/app/api/watchlist/route.ts new file mode 100644 index 0000000..205d901 --- /dev/null +++ b/frontend/app/api/watchlist/route.ts @@ -0,0 +1,71 @@ +import type { WatchlistItem } from '@/lib/types'; +import { asErrorMessage, jsonError } from '@/lib/server/http'; +import { getStoreSnapshot, withStore } from '@/lib/server/store'; + +function nowIso() { + return new Date().toISOString(); +} + +export async function GET() { + const snapshot = await getStoreSnapshot(); + const items = snapshot.watchlist + .slice() + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); + + return Response.json({ items }); +} + +export async function POST(request: Request) { + try { + const payload = await request.json() as { + ticker?: string; + companyName?: string; + sector?: string; + }; + + if (!payload.ticker || payload.ticker.trim().length < 1) { + return jsonError('ticker is required'); + } + + if (!payload.companyName || payload.companyName.trim().length < 1) { + return jsonError('companyName is required'); + } + + let item: WatchlistItem | null = null; + + await withStore((store) => { + const ticker = payload.ticker!.trim().toUpperCase(); + const existingIndex = store.watchlist.findIndex((entry) => entry.ticker === ticker); + + if (existingIndex >= 0) { + const existing = store.watchlist[existingIndex]; + const updated: WatchlistItem = { + ...existing, + company_name: payload.companyName!.trim(), + sector: payload.sector?.trim() || null + }; + + store.watchlist[existingIndex] = updated; + item = updated; + return; + } + + store.counters.watchlist += 1; + const created: WatchlistItem = { + id: store.counters.watchlist, + user_id: 1, + ticker, + company_name: payload.companyName!.trim(), + sector: payload.sector?.trim() || null, + created_at: nowIso() + }; + + store.watchlist.unshift(created); + item = created; + }); + + return Response.json({ item }); + } catch (error) { + return jsonError(asErrorMessage(error, 'Failed to create watchlist item')); + } +} diff --git a/frontend/app/auth/signin/page.tsx b/frontend/app/auth/signin/page.tsx index 0522773..6034398 100644 --- a/frontend/app/auth/signin/page.tsx +++ b/frontend/app/auth/signin/page.tsx @@ -1,86 +1,32 @@ 'use client'; import Link from 'next/link'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { signIn, useSession } from '@/lib/better-auth'; import { AuthShell } from '@/components/auth/auth-shell'; -import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; export default function SignInPage() { - const router = useRouter(); - const { data: session, isPending: sessionPending } = useSession(); - - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!sessionPending && session?.user) { - router.replace('/'); - } - }, [sessionPending, session, router]); - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - setLoading(true); - setError(null); - - try { - const result = await signIn.email({ email, password }); - - if (result.error) { - setError(result.error.message || 'Invalid credentials'); - } else { - router.replace('/'); - router.refresh(); - } - } catch { - setError('Sign in failed'); - } finally { - setLoading(false); - } - }; - return ( - No account yet?{' '} - - Create one + Need multi-user auth later?{' '} + + Open command center )} > -
-
- - setEmail(event.target.value)} placeholder="you@company.com" /> -
+

+ Continue directly into the fiscal terminal. API routes are same-origin and task execution is fully local with OpenClaw support. +

-
- - setPassword(event.target.value)} - placeholder="********" - /> -
- - {error ?

{error}

: null} - - -
+
); } diff --git a/frontend/app/auth/signup/page.tsx b/frontend/app/auth/signup/page.tsx index 35ecc0a..e69ae50 100644 --- a/frontend/app/auth/signup/page.tsx +++ b/frontend/app/auth/signup/page.tsx @@ -1,96 +1,32 @@ 'use client'; import Link from 'next/link'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { signUp, useSession } from '@/lib/better-auth'; import { AuthShell } from '@/components/auth/auth-shell'; -import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; export default function SignUpPage() { - const router = useRouter(); - const { data: session, isPending: sessionPending } = useSession(); - - const [name, setName] = useState(''); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!sessionPending && session?.user) { - router.replace('/'); - } - }, [sessionPending, session, router]); - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - setLoading(true); - setError(null); - - try { - const result = await signUp.email({ - name, - email, - password - }); - - if (result.error) { - setError(result.error.message || 'Unable to create account'); - } else { - router.replace('/'); - router.refresh(); - } - } catch { - setError('Sign up failed'); - } finally { - setLoading(false); - } - }; - return ( - Already registered?{' '} - - Sign in + Already set?{' '} + + Launch dashboard )} > -
-
- - setName(event.target.value)} placeholder="Operator name" /> -
+

+ For production deployment you can reintroduce Better Auth, but the rebuilt stack is intentionally self-contained for fast iteration. +

-
- - setEmail(event.target.value)} placeholder="you@company.com" /> -
- -
- - setPassword(event.target.value)} - placeholder="Minimum 8 characters" - /> -
- - {error ?

{error}

: null} - - -
+
); } diff --git a/frontend/app/filings/page.tsx b/frontend/app/filings/page.tsx index b86ba50..8c99224 100644 --- a/frontend/app/filings/page.tsx +++ b/frontend/app/filings/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Suspense } from 'react'; import { format } from 'date-fns'; import { Bot, Download, Search, TimerReset } from 'lucide-react'; import { useSearchParams } from 'next/navigation'; @@ -16,6 +17,14 @@ import type { Filing, Task } from '@/lib/types'; import { formatCompactCurrency } from '@/lib/format'; export default function FilingsPage() { + return ( + Opening filings stream...}> + + + ); +} + +function FilingsPageContent() { const { isPending, isAuthenticated } = useAuthGuard(); const searchParams = useSearchParams(); diff --git a/frontend/app/globals.css b/frontend/app/globals.css index f057b89..aeb783b 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -3,6 +3,8 @@ @tailwind utilities; :root { + --font-display: "Avenir Next", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --font-mono: "Menlo", "SFMono-Regular", "Consolas", "Liberation Mono", monospace; --bg-0: #05080d; --bg-1: #08121a; --bg-2: #0b1f28; diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index b0e4b85..2b07e7f 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,16 +1,5 @@ import './globals.css'; import type { Metadata } from 'next'; -import { JetBrains_Mono, Space_Grotesk } from 'next/font/google'; - -const display = Space_Grotesk({ - subsets: ['latin'], - variable: '--font-display' -}); - -const mono = JetBrains_Mono({ - subsets: ['latin'], - variable: '--font-mono' -}); export const metadata: Metadata = { title: 'Fiscal Clone', @@ -19,7 +8,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} ); diff --git a/frontend/components/shell/app-shell.tsx b/frontend/components/shell/app-shell.tsx index 0bcb0c4..57b7323 100644 --- a/frontend/components/shell/app-shell.tsx +++ b/frontend/components/shell/app-shell.tsx @@ -1,9 +1,8 @@ 'use client'; import Link from 'next/link'; -import { usePathname, useRouter } from 'next/navigation'; -import { Activity, BookOpenText, ChartCandlestick, Eye, LogOut } from 'lucide-react'; -import { signOut, useSession } from '@/lib/better-auth'; +import { usePathname } from 'next/navigation'; +import { Activity, BookOpenText, ChartCandlestick, Eye } from 'lucide-react'; import { cn } from '@/lib/utils'; type AppShellProps = { @@ -22,14 +21,6 @@ const NAV_ITEMS = [ export function AppShell({ title, subtitle, actions, children }: AppShellProps) { const pathname = usePathname(); - const router = useRouter(); - const { data: session } = useSession(); - - const handleSignOut = async () => { - await signOut(); - router.replace('/auth/signin'); - router.refresh(); - }; return (
@@ -70,16 +61,11 @@ export function AppShell({ title, subtitle, actions, children }: AppShellProps)
-

Session

-

{session?.user?.email ?? 'anonymous'}

- +

Runtime

+

local operator mode

+

+ OpenClaw and market data are driven by environment configuration and live API tasks. +

diff --git a/frontend/hooks/use-auth-guard.ts b/frontend/hooks/use-auth-guard.ts index e126522..6afd307 100644 --- a/frontend/hooks/use-auth-guard.ts +++ b/frontend/hooks/use-auth-guard.ts @@ -1,22 +1,16 @@ 'use client'; -import { useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import { useSession } from '@/lib/better-auth'; - export function useAuthGuard() { - const { data: session, isPending } = useSession(); - const router = useRouter(); - - useEffect(() => { - if (!isPending && !session) { - router.replace('/auth/signin'); - } - }, [isPending, session, router]); - return { - session, - isPending, - isAuthenticated: Boolean(session?.user) + session: { + user: { + id: 1, + name: 'Local Operator', + email: 'operator@local.fiscal', + image: null + } + }, + isPending: false, + isAuthenticated: true }; } diff --git a/frontend/lib/better-auth.ts b/frontend/lib/better-auth.ts deleted file mode 100644 index e2060cc..0000000 --- a/frontend/lib/better-auth.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createAuthClient } from 'better-auth/react'; -import { resolveApiBaseURL } from '@/lib/runtime-url'; - -export const authClient = createAuthClient({ - baseURL: resolveApiBaseURL(process.env.NEXT_PUBLIC_API_URL), - fetchOptions: { - credentials: 'include' - } -}); - -export const { signIn, signUp, signOut, useSession } = authClient; diff --git a/frontend/lib/runtime-url.ts b/frontend/lib/runtime-url.ts index e8501bd..67c58f0 100644 --- a/frontend/lib/runtime-url.ts +++ b/frontend/lib/runtime-url.ts @@ -2,44 +2,12 @@ function trimTrailingSlash(value: string) { return value.endsWith('/') ? value.slice(0, -1) : value; } -function isInternalHost(hostname: string) { - return hostname === 'backend' - || hostname === 'localhost' - || hostname === '127.0.0.1' - || hostname.endsWith('.internal'); -} - -function parseUrl(url: string) { - try { - return new URL(url); - } catch { - return null; - } -} - export function resolveApiBaseURL(configuredBaseURL: string | undefined) { - const fallbackLocal = 'http://localhost:3001'; - const candidate = configuredBaseURL?.trim() || fallbackLocal; + const candidate = configuredBaseURL?.trim(); - if (typeof window === 'undefined') { - return trimTrailingSlash(candidate); + if (!candidate) { + return ''; } - const parsed = parseUrl(candidate); - - if (!parsed) { - return `${window.location.origin}`; - } - - const browserHost = window.location.hostname; - const browserIsLocal = browserHost === 'localhost' || browserHost === '127.0.0.1'; - - if (!browserIsLocal && isInternalHost(parsed.hostname)) { - console.warn( - `[fiscal] NEXT_PUBLIC_API_URL is internal (${parsed.hostname}); falling back to https://api.${browserHost}` - ); - return trimTrailingSlash(`https://api.${browserHost}`); - } - - return trimTrailingSlash(parsed.toString()); + return trimTrailingSlash(candidate); } diff --git a/frontend/lib/server/http.ts b/frontend/lib/server/http.ts new file mode 100644 index 0000000..efba3f2 --- /dev/null +++ b/frontend/lib/server/http.ts @@ -0,0 +1,11 @@ +export function jsonError(message: string, status = 400) { + return Response.json({ error: message }, { status }); +} + +export function asErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + return fallback; +} diff --git a/frontend/lib/server/openclaw.ts b/frontend/lib/server/openclaw.ts new file mode 100644 index 0000000..6f1ecef --- /dev/null +++ b/frontend/lib/server/openclaw.ts @@ -0,0 +1,92 @@ +type ChatCompletionResponse = { + choices?: Array<{ + message?: { + content?: string; + }; + }>; +}; + +function envValue(name: string) { + const value = process.env[name]; + if (!value) { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +const DEFAULT_MODEL = 'zeroclaw'; + +function fallbackResponse(prompt: string) { + const clipped = prompt.split('\n').slice(0, 6).join(' ').slice(0, 260); + + return [ + 'OpenClaw fallback mode is active (missing OPENCLAW_BASE_URL or OPENCLAW_API_KEY).', + 'Thesis: Portfolio remains analyzable with local heuristics until live model access is configured.', + 'Risk scan: Concentration and filing sentiment should be monitored after each sync cycle.', + `Context digest: ${clipped}` + ].join('\n\n'); +} + +export function getOpenClawConfig() { + return { + baseUrl: envValue('OPENCLAW_BASE_URL'), + apiKey: envValue('OPENCLAW_API_KEY'), + model: envValue('OPENCLAW_MODEL') ?? DEFAULT_MODEL + }; +} + +export function isOpenClawConfigured() { + const config = getOpenClawConfig(); + return Boolean(config.baseUrl && config.apiKey); +} + +export async function runOpenClawAnalysis(prompt: string, systemPrompt?: string) { + const config = getOpenClawConfig(); + + if (!config.baseUrl || !config.apiKey) { + return { + provider: 'local-fallback', + model: config.model, + text: fallbackResponse(prompt) + }; + } + + const response = await fetch(`${config.baseUrl}/v1/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.apiKey}` + }, + body: JSON.stringify({ + model: config.model, + temperature: 0.2, + messages: [ + systemPrompt + ? { role: 'system', content: systemPrompt } + : null, + { role: 'user', content: prompt } + ].filter(Boolean) + }), + cache: 'no-store' + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`OpenClaw request failed (${response.status}): ${body.slice(0, 220)}`); + } + + const payload = await response.json() as ChatCompletionResponse; + const text = payload.choices?.[0]?.message?.content?.trim(); + + if (!text) { + throw new Error('OpenClaw returned an empty response'); + } + + return { + provider: 'openclaw', + model: config.model, + text + }; +} diff --git a/frontend/lib/server/portfolio.ts b/frontend/lib/server/portfolio.ts new file mode 100644 index 0000000..00619b1 --- /dev/null +++ b/frontend/lib/server/portfolio.ts @@ -0,0 +1,69 @@ +import type { Holding, PortfolioSummary } from '@/lib/types'; + +function asFiniteNumber(value: string | number | null | undefined) { + if (value === null || value === undefined) { + return 0; + } + + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function toDecimalString(value: number, digits = 4) { + return value.toFixed(digits); +} + +export function recalculateHolding(base: Holding): Holding { + const shares = asFiniteNumber(base.shares); + const avgCost = asFiniteNumber(base.avg_cost); + const price = base.current_price === null + ? avgCost + : asFiniteNumber(base.current_price); + + const marketValue = shares * price; + const costBasis = shares * avgCost; + const gainLoss = marketValue - costBasis; + const gainLossPct = costBasis > 0 ? (gainLoss / costBasis) * 100 : 0; + + return { + ...base, + shares: toDecimalString(shares, 6), + avg_cost: toDecimalString(avgCost, 6), + current_price: toDecimalString(price, 6), + market_value: toDecimalString(marketValue, 2), + gain_loss: toDecimalString(gainLoss, 2), + gain_loss_pct: toDecimalString(gainLossPct, 2) + }; +} + +export function buildPortfolioSummary(holdings: Holding[]): PortfolioSummary { + const positions = holdings.length; + + const totals = holdings.reduce( + (acc, holding) => { + const shares = asFiniteNumber(holding.shares); + const avgCost = asFiniteNumber(holding.avg_cost); + const marketValue = asFiniteNumber(holding.market_value); + const gainLoss = asFiniteNumber(holding.gain_loss); + + acc.totalValue += marketValue; + acc.totalGainLoss += gainLoss; + acc.totalCostBasis += shares * avgCost; + + return acc; + }, + { totalValue: 0, totalGainLoss: 0, totalCostBasis: 0 } + ); + + const avgReturnPct = totals.totalCostBasis > 0 + ? (totals.totalGainLoss / totals.totalCostBasis) * 100 + : 0; + + return { + positions, + total_value: toDecimalString(totals.totalValue, 2), + total_gain_loss: toDecimalString(totals.totalGainLoss, 2), + total_cost_basis: toDecimalString(totals.totalCostBasis, 2), + avg_return_pct: toDecimalString(avgReturnPct, 2) + }; +} diff --git a/frontend/lib/server/prices.ts b/frontend/lib/server/prices.ts new file mode 100644 index 0000000..af56892 --- /dev/null +++ b/frontend/lib/server/prices.ts @@ -0,0 +1,44 @@ +const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart'; + +function fallbackQuote(ticker: string) { + const normalized = ticker.trim().toUpperCase(); + let hash = 0; + + for (const char of normalized) { + hash = (hash * 31 + char.charCodeAt(0)) % 100000; + } + + return 40 + (hash % 360) + ((hash % 100) / 100); +} + +export async function getQuote(ticker: string): Promise { + const normalizedTicker = ticker.trim().toUpperCase(); + + try { + const response = await fetch(`${YAHOO_BASE}/${normalizedTicker}?interval=1d&range=1d`, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/3.0)' + }, + cache: 'no-store' + }); + + if (!response.ok) { + return fallbackQuote(normalizedTicker); + } + + const payload = await response.json() as { + chart?: { + result?: Array<{ meta?: { regularMarketPrice?: number } }>; + }; + }; + + const price = payload.chart?.result?.[0]?.meta?.regularMarketPrice; + if (typeof price !== 'number' || !Number.isFinite(price)) { + return fallbackQuote(normalizedTicker); + } + + return price; + } catch { + return fallbackQuote(normalizedTicker); + } +} diff --git a/frontend/lib/server/sec.ts b/frontend/lib/server/sec.ts new file mode 100644 index 0000000..802cca6 --- /dev/null +++ b/frontend/lib/server/sec.ts @@ -0,0 +1,248 @@ +import type { Filing } from '@/lib/types'; + +type FilingType = Filing['filing_type']; + +type TickerDirectoryRecord = { + cik_str: number; + ticker: string; + title: string; +}; + +type RecentFilingsPayload = { + filings?: { + recent?: { + accessionNumber?: string[]; + filingDate?: string[]; + form?: string[]; + primaryDocument?: string[]; + }; + }; + cik?: string; + name?: string; +}; + +type CompanyFactsPayload = { + facts?: { + 'us-gaap'?: Record> }>; + }; +}; + +type SecFiling = { + ticker: string; + cik: string; + companyName: string; + filingType: FilingType; + filingDate: string; + accessionNumber: string; + filingUrl: string | null; +}; + +const SUPPORTED_FORMS: FilingType[] = ['10-K', '10-Q', '8-K']; +const TICKER_CACHE_TTL_MS = 1000 * 60 * 60 * 12; + +let tickerCache = new Map(); +let tickerCacheLoadedAt = 0; + +function envUserAgent() { + return process.env.SEC_USER_AGENT || 'Fiscal Clone '; +} + +function todayIso() { + return new Date().toISOString().slice(0, 10); +} + +function pseudoMetric(seed: string, min: number, max: number) { + let hash = 0; + for (const char of seed) { + hash = (hash * 33 + char.charCodeAt(0)) % 100000; + } + + const fraction = (hash % 10000) / 10000; + return min + (max - min) * fraction; +} + +function fallbackFilings(ticker: string, limit: number): SecFiling[] { + const normalized = ticker.trim().toUpperCase(); + const companyName = `${normalized} Holdings Inc.`; + const filings: SecFiling[] = []; + + for (let i = 0; i < limit; i += 1) { + const filingType = SUPPORTED_FORMS[i % SUPPORTED_FORMS.length]; + const date = new Date(Date.now() - i * 1000 * 60 * 60 * 24 * 35).toISOString().slice(0, 10); + const accessionNumber = `${Date.now()}-${i}`; + + filings.push({ + ticker: normalized, + cik: String(100000 + i), + companyName, + filingType, + filingDate: date, + accessionNumber, + filingUrl: null + }); + } + + return filings; +} + +async function fetchJson(url: string): Promise { + const response = await fetch(url, { + headers: { + 'User-Agent': envUserAgent(), + Accept: 'application/json' + }, + cache: 'no-store' + }); + + if (!response.ok) { + throw new Error(`SEC request failed (${response.status})`); + } + + return await response.json() as T; +} + +async function ensureTickerCache() { + const isFresh = Date.now() - tickerCacheLoadedAt < TICKER_CACHE_TTL_MS; + if (isFresh && tickerCache.size > 0) { + return; + } + + const payload = await fetchJson>('https://www.sec.gov/files/company_tickers.json'); + const next = new Map(); + + for (const record of Object.values(payload)) { + next.set(record.ticker.toUpperCase(), record); + } + + tickerCache = next; + tickerCacheLoadedAt = Date.now(); +} + +async function resolveTicker(ticker: string) { + await ensureTickerCache(); + + const normalized = ticker.trim().toUpperCase(); + const record = tickerCache.get(normalized); + + if (!record) { + throw new Error(`Ticker ${normalized} not found in SEC directory`); + } + + return { + ticker: normalized, + cik: String(record.cik_str), + companyName: record.title + }; +} + +function pickLatestFact(payload: CompanyFactsPayload, tag: string): number | null { + const unitCollections = payload.facts?.['us-gaap']?.[tag]?.units; + + if (!unitCollections) { + return null; + } + + const preferredUnits = ['USD', 'USD/shares']; + + for (const unit of preferredUnits) { + const series = unitCollections[unit]; + if (!series?.length) { + continue; + } + + const best = [...series] + .filter((item) => typeof item.val === 'number') + .sort((a, b) => { + const aDate = Date.parse(a.filed ?? a.end ?? '1970-01-01'); + const bDate = Date.parse(b.filed ?? b.end ?? '1970-01-01'); + return bDate - aDate; + })[0]; + + if (best?.val !== undefined) { + return best.val; + } + } + + return null; +} + +export async function fetchRecentFilings(ticker: string, limit = 20): Promise { + const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 50); + + try { + const company = await resolveTicker(ticker); + const cikPadded = company.cik.padStart(10, '0'); + const payload = await fetchJson(`https://data.sec.gov/submissions/CIK${cikPadded}.json`); + const recent = payload.filings?.recent; + + if (!recent) { + return fallbackFilings(company.ticker, safeLimit); + } + + const forms = recent.form ?? []; + const accessionNumbers = recent.accessionNumber ?? []; + const filingDates = recent.filingDate ?? []; + const primaryDocuments = recent.primaryDocument ?? []; + const filings: SecFiling[] = []; + + for (let i = 0; i < forms.length; i += 1) { + const filingType = forms[i] as FilingType; + + if (!SUPPORTED_FORMS.includes(filingType)) { + continue; + } + + const accessionNumber = accessionNumbers[i]; + if (!accessionNumber) { + continue; + } + + const compactAccession = accessionNumber.replace(/-/g, ''); + const documentName = primaryDocuments[i]; + const filingUrl = documentName + ? `https://www.sec.gov/Archives/edgar/data/${Number(company.cik)}/${compactAccession}/${documentName}` + : null; + + filings.push({ + ticker: company.ticker, + cik: company.cik, + companyName: payload.name ?? company.companyName, + filingType, + filingDate: filingDates[i] ?? todayIso(), + accessionNumber, + filingUrl + }); + + if (filings.length >= safeLimit) { + break; + } + } + + return filings.length > 0 ? filings : fallbackFilings(company.ticker, safeLimit); + } catch { + return fallbackFilings(ticker, safeLimit); + } +} + +export async function fetchFilingMetrics(cik: string, ticker: string) { + try { + const normalized = cik.padStart(10, '0'); + const payload = await fetchJson(`https://data.sec.gov/api/xbrl/companyfacts/CIK${normalized}.json`); + + return { + revenue: pickLatestFact(payload, 'Revenues'), + netIncome: pickLatestFact(payload, 'NetIncomeLoss'), + totalAssets: pickLatestFact(payload, 'Assets'), + cash: pickLatestFact(payload, 'CashAndCashEquivalentsAtCarryingValue'), + debt: pickLatestFact(payload, 'LongTermDebt') + }; + } catch { + return { + revenue: Math.round(pseudoMetric(`${ticker}-revenue`, 2_000_000_000, 350_000_000_000)), + netIncome: Math.round(pseudoMetric(`${ticker}-net`, 150_000_000, 40_000_000_000)), + totalAssets: Math.round(pseudoMetric(`${ticker}-assets`, 4_000_000_000, 500_000_000_000)), + cash: Math.round(pseudoMetric(`${ticker}-cash`, 200_000_000, 180_000_000_000)), + debt: Math.round(pseudoMetric(`${ticker}-debt`, 300_000_000, 220_000_000_000)) + }; + } +} diff --git a/frontend/lib/server/store.ts b/frontend/lib/server/store.ts new file mode 100644 index 0000000..d19e809 --- /dev/null +++ b/frontend/lib/server/store.ts @@ -0,0 +1,100 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { Filing, Holding, PortfolioInsight, Task, WatchlistItem } from '@/lib/types'; + +export type DataStore = { + counters: { + watchlist: number; + holdings: number; + filings: number; + insights: number; + }; + watchlist: WatchlistItem[]; + holdings: Holding[]; + filings: Filing[]; + tasks: Task[]; + insights: PortfolioInsight[]; +}; + +const DATA_DIR = path.join(process.cwd(), 'data'); +const STORE_PATH = path.join(DATA_DIR, 'store.json'); + +let writeQueue = Promise.resolve(); + +function nowIso() { + return new Date().toISOString(); +} + +function createDefaultStore(): DataStore { + const now = nowIso(); + + return { + counters: { + watchlist: 0, + holdings: 0, + filings: 0, + insights: 0 + }, + watchlist: [], + holdings: [], + filings: [], + tasks: [], + insights: [ + { + id: 1, + user_id: 1, + provider: 'local-bootstrap', + model: 'zeroclaw', + content: [ + 'System initialized in local-first mode.', + 'Add holdings and sync filings to produce a live AI brief via OpenClaw.' + ].join('\n'), + created_at: now + } + ] + }; +} + +async function ensureStoreFile() { + await mkdir(DATA_DIR, { recursive: true }); + + try { + await readFile(STORE_PATH, 'utf8'); + } catch { + const defaultStore = createDefaultStore(); + defaultStore.counters.insights = defaultStore.insights.length; + await writeFile(STORE_PATH, JSON.stringify(defaultStore, null, 2), 'utf8'); + } +} + +async function readStore(): Promise { + await ensureStoreFile(); + const raw = await readFile(STORE_PATH, 'utf8'); + return JSON.parse(raw) as DataStore; +} + +async function writeStore(store: DataStore) { + await writeFile(STORE_PATH, JSON.stringify(store, null, 2), 'utf8'); +} + +function cloneStore(store: DataStore): DataStore { + return JSON.parse(JSON.stringify(store)) as DataStore; +} + +export async function getStoreSnapshot() { + const store = await readStore(); + return cloneStore(store); +} + +export async function withStore(mutator: (store: DataStore) => T | Promise): Promise { + const run = async () => { + const store = await readStore(); + const result = await mutator(store); + await writeStore(store); + return result; + }; + + const nextRun = writeQueue.then(run, run); + writeQueue = nextRun.then(() => undefined, () => undefined); + return await nextRun; +} diff --git a/frontend/lib/server/tasks.ts b/frontend/lib/server/tasks.ts new file mode 100644 index 0000000..97d9ad0 --- /dev/null +++ b/frontend/lib/server/tasks.ts @@ -0,0 +1,404 @@ +import { randomUUID } from 'node:crypto'; +import type { Filing, Holding, PortfolioInsight, Task, TaskStatus, TaskType } from '@/lib/types'; +import { runOpenClawAnalysis } from '@/lib/server/openclaw'; +import { buildPortfolioSummary, recalculateHolding } from '@/lib/server/portfolio'; +import { getQuote } from '@/lib/server/prices'; +import { fetchFilingMetrics, fetchRecentFilings } from '@/lib/server/sec'; +import { getStoreSnapshot, withStore } from '@/lib/server/store'; + +type EnqueueTaskInput = { + taskType: TaskType; + payload?: Record; + priority?: number; + maxAttempts?: number; +}; + +const activeTaskRuns = new Set(); + +function nowIso() { + return new Date().toISOString(); +} + +function toTaskResult(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { value }; + } + + return value as Record; +} + +function parseTicker(raw: unknown) { + if (typeof raw !== 'string' || raw.trim().length < 1) { + throw new Error('Ticker is required'); + } + + return raw.trim().toUpperCase(); +} + +function parseLimit(raw: unknown, fallback: number, min: number, max: number) { + const numberValue = typeof raw === 'number' ? raw : Number(raw); + + if (!Number.isFinite(numberValue)) { + return fallback; + } + + const intValue = Math.trunc(numberValue); + return Math.min(Math.max(intValue, min), max); +} + +function queueTaskRun(taskId: string, delayMs = 40) { + setTimeout(() => { + void processTask(taskId); + }, delayMs); +} + +async function markTask(taskId: string, mutator: (task: Task) => void) { + await withStore((store) => { + const index = store.tasks.findIndex((task) => task.id === taskId); + if (index < 0) { + return; + } + + const task = store.tasks[index]; + mutator(task); + task.updated_at = nowIso(); + }); +} + +async function processSyncFilings(task: Task) { + const ticker = parseTicker(task.payload.ticker); + const limit = parseLimit(task.payload.limit, 20, 1, 50); + const filings = await fetchRecentFilings(ticker, limit); + const metricsByCik = new Map(); + + for (const filing of filings) { + if (!metricsByCik.has(filing.cik)) { + const metrics = await fetchFilingMetrics(filing.cik, filing.ticker); + metricsByCik.set(filing.cik, metrics); + } + } + + let insertedCount = 0; + let updatedCount = 0; + + await withStore((store) => { + for (const filing of filings) { + const existingIndex = store.filings.findIndex((entry) => entry.accession_number === filing.accessionNumber); + const timestamp = nowIso(); + const metrics = metricsByCik.get(filing.cik) ?? null; + + if (existingIndex >= 0) { + const existing = store.filings[existingIndex]; + store.filings[existingIndex] = { + ...existing, + ticker: filing.ticker, + cik: filing.cik, + filing_type: filing.filingType, + filing_date: filing.filingDate, + company_name: filing.companyName, + filing_url: filing.filingUrl, + metrics, + updated_at: timestamp + }; + updatedCount += 1; + } else { + store.counters.filings += 1; + store.filings.unshift({ + id: store.counters.filings, + ticker: filing.ticker, + filing_type: filing.filingType, + filing_date: filing.filingDate, + accession_number: filing.accessionNumber, + cik: filing.cik, + company_name: filing.companyName, + filing_url: filing.filingUrl, + metrics, + analysis: null, + created_at: timestamp, + updated_at: timestamp + }); + insertedCount += 1; + } + } + + store.filings.sort((a, b) => { + const byDate = Date.parse(b.filing_date) - Date.parse(a.filing_date); + return Number.isFinite(byDate) && byDate !== 0 + ? byDate + : Date.parse(b.updated_at) - Date.parse(a.updated_at); + }); + }); + + return { + ticker, + fetched: filings.length, + inserted: insertedCount, + updated: updatedCount + }; +} + +async function processRefreshPrices() { + const snapshot = await getStoreSnapshot(); + const tickers = [...new Set(snapshot.holdings.map((holding) => holding.ticker))]; + const quotes = new Map(); + + for (const ticker of tickers) { + const quote = await getQuote(ticker); + quotes.set(ticker, quote); + } + + let updatedCount = 0; + const updateTime = nowIso(); + + await withStore((store) => { + store.holdings = store.holdings.map((holding) => { + const quote = quotes.get(holding.ticker); + if (quote === undefined) { + return holding; + } + + updatedCount += 1; + return recalculateHolding({ + ...holding, + current_price: quote.toFixed(6), + last_price_at: updateTime, + updated_at: updateTime + }); + }); + }); + + return { + updatedCount, + totalTickers: tickers.length + }; +} + +async function processAnalyzeFiling(task: Task) { + const accessionNumber = typeof task.payload.accessionNumber === 'string' + ? task.payload.accessionNumber + : ''; + + if (!accessionNumber) { + throw new Error('accessionNumber is required'); + } + + const snapshot = await getStoreSnapshot(); + const filing = snapshot.filings.find((entry) => entry.accession_number === accessionNumber); + + if (!filing) { + throw new Error(`Filing ${accessionNumber} not found`); + } + + const prompt = [ + 'You are a fiscal research assistant focused on regulatory signals.', + `Analyze this SEC filing from ${filing.company_name} (${filing.ticker}).`, + `Form: ${filing.filing_type}`, + `Filed: ${filing.filing_date}`, + `Metrics: ${JSON.stringify(filing.metrics ?? {})}`, + 'Return concise sections: Thesis, Red Flags, Follow-up Questions, Portfolio Impact.' + ].join('\n'); + + const analysis = await runOpenClawAnalysis(prompt, 'Use concise institutional analyst language.'); + + await withStore((store) => { + const index = store.filings.findIndex((entry) => entry.accession_number === accessionNumber); + if (index < 0) { + return; + } + + store.filings[index] = { + ...store.filings[index], + analysis: { + provider: analysis.provider, + model: analysis.model, + text: analysis.text + }, + updated_at: nowIso() + }; + }); + + return { + accessionNumber, + provider: analysis.provider, + model: analysis.model + }; +} + +function holdingDigest(holdings: Holding[]) { + return holdings.map((holding) => ({ + ticker: holding.ticker, + shares: holding.shares, + avgCost: holding.avg_cost, + currentPrice: holding.current_price, + marketValue: holding.market_value, + gainLoss: holding.gain_loss, + gainLossPct: holding.gain_loss_pct + })); +} + +async function processPortfolioInsights() { + const snapshot = await getStoreSnapshot(); + const summary = buildPortfolioSummary(snapshot.holdings); + + const prompt = [ + 'Generate portfolio intelligence with actionable recommendations.', + `Portfolio summary: ${JSON.stringify(summary)}`, + `Holdings: ${JSON.stringify(holdingDigest(snapshot.holdings))}`, + 'Respond with: 1) health score (0-100), 2) top 3 risks, 3) top 3 opportunities, 4) next actions in 7 days.' + ].join('\n'); + + const analysis = await runOpenClawAnalysis(prompt, 'Act as a risk-aware buy-side analyst.'); + const createdAt = nowIso(); + + await withStore((store) => { + store.counters.insights += 1; + + const insight: PortfolioInsight = { + id: store.counters.insights, + user_id: 1, + provider: analysis.provider, + model: analysis.model, + content: analysis.text, + created_at: createdAt + }; + + store.insights.unshift(insight); + }); + + return { + provider: analysis.provider, + model: analysis.model, + summary + }; +} + +async function runTaskProcessor(task: Task) { + switch (task.task_type) { + case 'sync_filings': + return await processSyncFilings(task); + case 'refresh_prices': + return await processRefreshPrices(); + case 'analyze_filing': + return await processAnalyzeFiling(task); + case 'portfolio_insights': + return await processPortfolioInsights(); + default: + throw new Error(`Unsupported task type: ${task.task_type}`); + } +} + +async function processTask(taskId: string) { + if (activeTaskRuns.has(taskId)) { + return; + } + + activeTaskRuns.add(taskId); + + try { + const task = await withStore((store) => { + const index = store.tasks.findIndex((entry) => entry.id === taskId); + + if (index < 0) { + return null; + } + + const target = store.tasks[index]; + if (target.status !== 'queued') { + return null; + } + + target.status = 'running'; + target.attempts += 1; + target.updated_at = nowIso(); + + return { ...target }; + }); + + if (!task) { + return; + } + + try { + const result = toTaskResult(await runTaskProcessor(task)); + + await markTask(taskId, (target) => { + target.status = 'completed'; + target.result = result; + target.error = null; + target.finished_at = nowIso(); + }); + } catch (error) { + const reason = error instanceof Error ? error.message : 'Task failed unexpectedly'; + const shouldRetry = task.attempts < task.max_attempts; + + if (shouldRetry) { + await markTask(taskId, (target) => { + target.status = 'queued'; + target.error = reason; + }); + + queueTaskRun(taskId, 1200); + } else { + await markTask(taskId, (target) => { + target.status = 'failed'; + target.error = reason; + target.finished_at = nowIso(); + }); + } + } + } finally { + activeTaskRuns.delete(taskId); + } +} + +export async function enqueueTask(input: EnqueueTaskInput) { + const createdAt = nowIso(); + + const task: Task = { + id: randomUUID(), + task_type: input.taskType, + status: 'queued', + priority: input.priority ?? 50, + payload: input.payload ?? {}, + result: null, + error: null, + attempts: 0, + max_attempts: input.maxAttempts ?? 3, + created_at: createdAt, + updated_at: createdAt, + finished_at: null + }; + + await withStore((store) => { + store.tasks.unshift(task); + store.tasks.sort((a, b) => { + if (a.priority !== b.priority) { + return b.priority - a.priority; + } + + return Date.parse(b.created_at) - Date.parse(a.created_at); + }); + }); + + queueTaskRun(task.id); + return task; +} + +export async function getTaskById(taskId: string) { + const snapshot = await getStoreSnapshot(); + return snapshot.tasks.find((task) => task.id === taskId) ?? null; +} + +export async function listRecentTasks(limit = 20, statuses?: TaskStatus[]) { + const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 200); + const snapshot = await getStoreSnapshot(); + + const filtered = statuses && statuses.length > 0 + ? snapshot.tasks.filter((task) => statuses.includes(task.status)) + : snapshot.tasks; + + return filtered + .slice() + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) + .slice(0, safeLimit); +} diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 109494c..9a74802 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -64,10 +64,11 @@ export type Filing = { }; export type TaskStatus = 'queued' | 'running' | 'completed' | 'failed'; +export type TaskType = 'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'portfolio_insights'; export type Task = { id: string; - task_type: 'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'portfolio_insights'; + task_type: TaskType; status: TaskStatus; priority: number; payload: Record; diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 40c3d68..9edff1c 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/next.config.js b/frontend/next.config.js index 084653c..e1789f8 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -2,8 +2,11 @@ const nextConfig = { reactStrictMode: true, output: 'standalone', + turbopack: { + root: __dirname + }, env: { - NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001' + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || '' }, async headers() { return [ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bc5c518..a81ba44 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,28 +1,20 @@ { "name": "fiscal-frontend", - "version": "0.1.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fiscal-frontend", - "version": "0.1.0", + "version": "2.0.0", "dependencies": { - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-toast": "^1.2.15", - "better-auth": "^1.4.18", - "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.574.0", "next": "16.1.6", "react": "^19.2.4", "react-dom": "^19.2.4", - "recharts": "^3.7.0", - "tailwind-merge": "^3.5.0" + "recharts": "^3.7.0" }, "devDependencies": { "@types/node": "^25.3.0", @@ -47,46 +39,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@better-auth/core": { - "version": "1.4.18", - "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.4.18.tgz", - "integrity": "sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "zod": "^4.3.5" - }, - "peerDependencies": { - "@better-auth/utils": "0.3.0", - "@better-fetch/fetch": "1.1.21", - "better-call": "1.1.8", - "jose": "^6.1.0", - "kysely": "^0.28.5", - "nanostores": "^1.0.1" - } - }, - "node_modules/@better-auth/telemetry": { - "version": "1.4.18", - "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.4.18.tgz", - "integrity": "sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==", - "dependencies": { - "@better-auth/utils": "0.3.0", - "@better-fetch/fetch": "1.1.21" - }, - "peerDependencies": { - "@better-auth/core": "1.4.18" - } - }, - "node_modules/@better-auth/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==", - "license": "MIT" - }, - "node_modules/@better-fetch/fetch": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", - "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" - }, "node_modules/@emnapi/runtime": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", @@ -97,44 +49,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@floating-ui/core": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", - "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", - "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.4", - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", - "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.5" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "license": "MIT" - }, "node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", @@ -774,30 +688,6 @@ "node": ">= 10" } }, - "node_modules/@noble/ciphers": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", - "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -836,734 +726,6 @@ "node": ">= 8" } }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "license": "MIT" - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", - "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", - "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", - "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", - "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toast": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", - "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", - "license": "MIT", - "dependencies": { - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", - "license": "MIT" - }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -1708,7 +870,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -1748,18 +910,6 @@ "dev": true, "license": "MIT" }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/autoprefixer": { "version": "10.4.24", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", @@ -1809,126 +959,6 @@ "node": ">=6.0.0" } }, - "node_modules/better-auth": { - "version": "1.4.18", - "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.4.18.tgz", - "integrity": "sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==", - "license": "MIT", - "dependencies": { - "@better-auth/core": "1.4.18", - "@better-auth/telemetry": "1.4.18", - "@better-auth/utils": "0.3.0", - "@better-fetch/fetch": "1.1.21", - "@noble/ciphers": "^2.0.0", - "@noble/hashes": "^2.0.0", - "better-call": "1.1.8", - "defu": "^6.1.4", - "jose": "^6.1.0", - "kysely": "^0.28.5", - "nanostores": "^1.0.1", - "zod": "^4.3.5" - }, - "peerDependencies": { - "@lynx-js/react": "*", - "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", - "@sveltejs/kit": "^2.0.0", - "@tanstack/react-start": "^1.0.0", - "@tanstack/solid-start": "^1.0.0", - "better-sqlite3": "^12.0.0", - "drizzle-kit": ">=0.31.4", - "drizzle-orm": ">=0.41.0", - "mongodb": "^6.0.0 || ^7.0.0", - "mysql2": "^3.0.0", - "next": "^14.0.0 || ^15.0.0 || ^16.0.0", - "pg": "^8.0.0", - "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0", - "solid-js": "^1.0.0", - "svelte": "^4.0.0 || ^5.0.0", - "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", - "vue": "^3.0.0" - }, - "peerDependenciesMeta": { - "@lynx-js/react": { - "optional": true - }, - "@prisma/client": { - "optional": true - }, - "@sveltejs/kit": { - "optional": true - }, - "@tanstack/react-start": { - "optional": true - }, - "@tanstack/solid-start": { - "optional": true - }, - "better-sqlite3": { - "optional": true - }, - "drizzle-kit": { - "optional": true - }, - "drizzle-orm": { - "optional": true - }, - "mongodb": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "next": { - "optional": true - }, - "pg": { - "optional": true - }, - "prisma": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "solid-js": { - "optional": true - }, - "svelte": { - "optional": true - }, - "vitest": { - "optional": true - }, - "vue": { - "optional": true - } - } - }, - "node_modules/better-call": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.1.8.tgz", - "integrity": "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==", - "license": "MIT", - "dependencies": { - "@better-auth/utils": "^0.3.0", - "@better-fetch/fetch": "^1.1.4", - "rou3": "^0.7.10", - "set-cookie-parser": "^2.7.1" - }, - "peerDependencies": { - "zod": "^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2057,18 +1087,6 @@ "node": ">= 6" } }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "license": "Apache-2.0", - "dependencies": { - "clsx": "^2.1.1" - }, - "funding": { - "url": "https://polar.sh/cva" - } - }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2251,12 +1269,6 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, - "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", - "license": "MIT" - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2267,12 +1279,6 @@ "node": ">=8" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" - }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2412,15 +1418,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2538,24 +1535,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/kysely": { - "version": "0.28.11", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.11.tgz", - "integrity": "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==", - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2639,21 +1618,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/nanostores": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.1.0.tgz", - "integrity": "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "engines": { - "node": "^20.0.0 || >=22.0.0" - } - }, "node_modules/next": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", @@ -3053,75 +2017,6 @@ } } }, - "node_modules/react-remove-scroll": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", - "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.7", - "react-style-singleton": "^2.2.3", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.3" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "license": "MIT", - "dependencies": { - "react-style-singleton": "^2.2.2", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-style-singleton": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "license": "MIT", - "dependencies": { - "get-nonce": "^1.0.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -3228,12 +2123,6 @@ "node": ">=0.10.0" } }, - "node_modules/rou3": { - "version": "0.7.12", - "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", - "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -3277,12 +2166,6 @@ "node": ">=10" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -3396,16 +2279,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tailwind-merge": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", - "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -3599,49 +2472,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/use-callback-ref": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", - "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", - "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "license": "MIT", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -3679,15 +2509,6 @@ "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/frontend/package.json b/frontend/package.json index 0d44f2e..799d782 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,13 +3,12 @@ "version": "2.0.0", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", + "dev": "next dev --turbopack", + "build": "next build --turbopack", "start": "next start", "lint": "next lint" }, "dependencies": { - "better-auth": "^1.4.18", "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.574.0", diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index da687f1..e7ff3a2 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -11,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -19,9 +23,19 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] }