diff --git a/frontend/.gitignore b/frontend/.gitignore
index 3ab038692..054200b12 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -21,3 +21,4 @@ dist-ssr
# custom
!lib
.react-router
+.vite
diff --git a/frontend/bun.lock b/frontend/bun.lock
index 2ffb23c09..f2933eb4d 100644
--- a/frontend/bun.lock
+++ b/frontend/bun.lock
@@ -6,7 +6,6 @@
"dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.15",
- "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -15,9 +14,12 @@
"@tauri-apps/plugin-opener": "^2.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "dayjs": "^1.11.18",
"echarts": "^6.0.0",
"isbot": "^5",
"lucide-react": "^0.544.0",
+ "overlayscrollbars": "^2.12.0",
+ "overlayscrollbars-react": "^0.5.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1",
@@ -25,7 +27,6 @@
"devDependencies": {
"@biomejs/biome": "^2.2.4",
"@react-router/dev": "^7.8.2",
- "@react-router/fs-routes": "^7.8.2",
"@react-router/serve": "^7.8.2",
"@tailwindcss/vite": "^4.1.13",
"@tauri-apps/cli": "^2.8.4",
@@ -244,8 +245,6 @@
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
- "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
-
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.3.tgz", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", { "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
@@ -258,8 +257,6 @@
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", { "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
- "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
-
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", { "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
@@ -276,8 +273,6 @@
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", { "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@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-presence": "1.1.5", "@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" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
-
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", { "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "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" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -308,8 +303,6 @@
"@react-router/express": ["@react-router/express@7.8.2", "", { "dependencies": { "@react-router/node": "7.8.2" }, "peerDependencies": { "express": "^4.17.1 || ^5", "react-router": "7.8.2", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-AJUNsE5Q+vD8TsNlKTw2MGUUnp/QJGlRV1jG2ItV30lwIx2wE7d4NHx/jWkGZIEblHQBTpodcp6MFirZXbisJw=="],
- "@react-router/fs-routes": ["@react-router/fs-routes@7.8.2", "", { "dependencies": { "minimatch": "^9.0.0" }, "peerDependencies": { "@react-router/dev": "^7.8.2", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-lNIbSIoLum2Pv6ftUuf8GjlnE9+IlCnBfyHsdxKQ5cZSIkan3bFNUzg5Q5KUCZGpfUGftCkn9vYMWiZhduSooQ=="],
-
"@react-router/node": ["@react-router/node@7.8.2", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0" }, "peerDependencies": { "react-router": "7.8.2", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-FNepNg4Aya6V0ZxD/+uObtqxtMXcsBGa0ax9PznUh5qr8g4M6Xo9IN+soLb1tghz6iS/F9djFyhJ/lDkF77dEw=="],
"@react-router/serve": ["@react-router/serve@7.8.2", "", { "dependencies": { "@react-router/express": "7.8.2", "@react-router/node": "7.8.2", "compression": "^1.7.4", "express": "^4.19.2", "get-port": "5.1.1", "morgan": "^1.10.0", "source-map-support": "^0.5.21" }, "peerDependencies": { "react-router": "7.8.2" }, "bin": { "react-router-serve": "bin.js" } }, "sha512-1AwKjBWmyWWA7dGCRjn2glWwO6cA7dDX7roP1tosFi5cu1EvqHaqelRH6K6MZSV10Tv6oPtFG7rgV+rCafJvyw=="],
@@ -548,6 +541,8 @@
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
+ "dayjs": ["dayjs@1.11.18", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.18.tgz", {}, "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA=="],
+
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"dedent": ["dedent@1.7.0", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ=="],
@@ -896,6 +891,10 @@
"outvariant": ["outvariant@1.4.3", "https://registry.npmmirror.com/outvariant/-/outvariant-1.4.3.tgz", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
+ "overlayscrollbars": ["overlayscrollbars@2.12.0", "https://registry.npmmirror.com/overlayscrollbars/-/overlayscrollbars-2.12.0.tgz", {}, "sha512-mWJ5MOkcZ/ljHwfLw8+bN0V9ziGCoNoqULcp994j5DTGNQvnkWKWkA7rnO29Kyew5AoHxUnJ4Ndqfcl0HSQjXg=="],
+
+ "overlayscrollbars-react": ["overlayscrollbars-react@0.5.6", "https://registry.npmmirror.com/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz", { "peerDependencies": { "overlayscrollbars": "^2.0.0", "react": ">=16.8.0" } }, "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw=="],
+
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"package-manager-detector": ["package-manager-detector@1.3.0", "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.3.0.tgz", {}, "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ=="],
diff --git a/frontend/package.json b/frontend/package.json
index 72feb7c49..a64eb8434 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,13 +6,12 @@
"scripts": {
"dev": "react-router dev",
"build": "react-router build",
- "start": "react-router-serve ./build/server/index.js",
+ "start": "vite preview --outDir build/client",
"tauri": "tauri"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.15",
- "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -21,9 +20,12 @@
"@tauri-apps/plugin-opener": "^2.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "dayjs": "^1.11.18",
"echarts": "^6.0.0",
"isbot": "^5",
"lucide-react": "^0.544.0",
+ "overlayscrollbars": "^2.12.0",
+ "overlayscrollbars-react": "^0.5.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1"
@@ -31,7 +33,6 @@
"devDependencies": {
"@biomejs/biome": "^2.2.4",
"@react-router/dev": "^7.8.2",
- "@react-router/fs-routes": "^7.8.2",
"@react-router/serve": "^7.8.2",
"@tailwindcss/vite": "^4.1.13",
"@tauri-apps/cli": "^2.8.4",
diff --git a/frontend/src/app/_home/route.tsx b/frontend/src/app/_home/route.tsx
deleted file mode 100644
index 9ee40d647..000000000
--- a/frontend/src/app/_home/route.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import { Plus } from "lucide-react";
-import {
- AgentRecommendList,
- AgentSuggestionsList,
- SparklineStockList,
-} from "@/app/_home/components";
-import { Button } from "@/components/ui/button";
-import {
- StockMenu,
- StockMenuContent,
- StockMenuGroup,
- StockMenuGroupHeader,
- StockMenuHeader,
- StockMenuListItem,
-} from "@/components/valuecell/menus/stock-menus";
-import { agentRecommendations, agentSuggestions } from "@/mock/agent-data";
-import { sparklineStockData, stockData } from "@/mock/stock-data";
-
-function Home() {
- const handleAgentClick = (agentId: string, title: string) => {
- console.log(`Agent clicked: ${title} (${agentId})`);
- };
-
- return (
-
-
- 👋 Welcome to ValueCell !
-
-
-
- ({
- ...suggestion,
- onClick: () => handleAgentClick(suggestion.id, suggestion.title),
- }))}
- />
-
- ({
- ...recommendation,
- onClick: () =>
- handleAgentClick(recommendation.id, recommendation.title),
- }))}
- />
-
-
-
-
- My Stocks
-
- {stockData.map((group) => (
-
- {group.title}
- {group.stocks.map((stock) => (
- {
- console.log("Selected stock:", stock.symbol);
- }}
- />
- ))}
-
- ))}
-
-
-
-
-
- Add Stocks
-
-
-
- );
-}
-
-export default Home;
diff --git a/frontend/src/app/home/_layout.tsx b/frontend/src/app/home/_layout.tsx
new file mode 100644
index 000000000..b825ffacc
--- /dev/null
+++ b/frontend/src/app/home/_layout.tsx
@@ -0,0 +1,54 @@
+import { Plus } from "lucide-react";
+import { Outlet, useLocation } from "react-router";
+import { Button } from "@/components/ui/button";
+import {
+ StockMenu,
+ StockMenuGroup,
+ StockMenuGroupHeader,
+ StockMenuHeader,
+ StockMenuListItem,
+} from "@/components/valuecell/menus/stock-menus";
+import ScrollContainer from "@/components/valuecell/scroll-container";
+import { stockData } from "@/mock/stock-data";
+
+export default function HomeLayout() {
+ const { pathname } = useLocation();
+
+ // Extract stock symbol (e.g., AAPL) from path like /stock/AAPL
+ const stockSymbol = pathname.split("/")[2];
+
+ return (
+
+
+
+
+
+
+
+ My Stocks
+
+ {stockData.map((group) => (
+
+ {group.title}
+ {group.stocks.map((stock) => (
+
+ ))}
+
+ ))}
+
+
+
+
+
+ Add Stocks
+
+
+
+ );
+}
diff --git a/frontend/src/app/_home/components/agent-recommend-list.tsx b/frontend/src/app/home/components/agent-recommend-list.tsx
similarity index 100%
rename from frontend/src/app/_home/components/agent-recommend-list.tsx
rename to frontend/src/app/home/components/agent-recommend-list.tsx
diff --git a/frontend/src/app/_home/components/agent-suggestions-list.tsx b/frontend/src/app/home/components/agent-suggestions-list.tsx
similarity index 100%
rename from frontend/src/app/_home/components/agent-suggestions-list.tsx
rename to frontend/src/app/home/components/agent-suggestions-list.tsx
diff --git a/frontend/src/app/_home/components/index.tsx b/frontend/src/app/home/components/index.tsx
similarity index 76%
rename from frontend/src/app/_home/components/index.tsx
rename to frontend/src/app/home/components/index.tsx
index 2cdbf44a3..bda807ca0 100644
--- a/frontend/src/app/_home/components/index.tsx
+++ b/frontend/src/app/home/components/index.tsx
@@ -1,3 +1,4 @@
export * from "./agent-recommend-list";
export * from "./agent-suggestions-list";
export * from "./sparkline-stock-list";
+export * from "./stock-details-list";
diff --git a/frontend/src/app/_home/components/sparkline-stock-list.tsx b/frontend/src/app/home/components/sparkline-stock-list.tsx
similarity index 66%
rename from frontend/src/app/_home/components/sparkline-stock-list.tsx
rename to frontend/src/app/home/components/sparkline-stock-list.tsx
index 392b74144..b6e30d58b 100644
--- a/frontend/src/app/_home/components/sparkline-stock-list.tsx
+++ b/frontend/src/app/home/components/sparkline-stock-list.tsx
@@ -1,6 +1,7 @@
-import { MiniSparkline } from "@valuecell/charts/mini-sparkline";
+import MiniSparkline from "@valuecell/charts/mini-sparkline";
+import { STOCK_COLORS } from "@/constants/stock";
import { cn, formatChange, formatPrice, getChangeType } from "@/lib/utils";
-import type { StockChangeType } from "@/types/stock";
+import type { SparklineData } from "@/types/chart";
export interface SparklineStock {
symbol: string;
@@ -8,7 +9,7 @@ export interface SparklineStock {
currency: string;
changeAmount: number;
changePercent: number;
- sparklineData: number[];
+ sparklineData: SparklineData;
}
interface SparklineStockListProps extends React.HTMLAttributes {
@@ -20,18 +21,6 @@ interface SparklineStockItemProps
stock: SparklineStock;
}
-const BASE_COLOR: Record = {
- positive: "#3F845F",
- negative: "#E25C5C",
- neutral: "#707070",
-};
-
-const GRADIENT_COLORS: Record = {
- positive: ["rgba(63, 132, 95, 0.5)", "rgba(63, 132, 95, 0)"],
- negative: ["rgba(226, 92, 92, 0.5)", "rgba(226, 92, 92, 0)"],
- neutral: ["rgba(112, 112, 112, 0.5)", "rgba(112, 112, 112, 0)"],
-};
-
function SparklineStockItem({
className,
stock,
@@ -54,21 +43,27 @@ function SparklineStockItem({
{formatPrice(stock.price, stock.currency)}
{formatChange(stock.changeAmount)}
{formatChange(stock.changePercent, "%")}
@@ -78,8 +73,7 @@ function SparklineStockItem({
diff --git a/frontend/src/app/home/components/stock-details-list.tsx b/frontend/src/app/home/components/stock-details-list.tsx
new file mode 100644
index 000000000..f36dfcde0
--- /dev/null
+++ b/frontend/src/app/home/components/stock-details-list.tsx
@@ -0,0 +1,73 @@
+import { cn } from "@/lib/utils";
+
+export interface StockDetailsData {
+ previousClose?: string;
+ dayRange?: string;
+ yearRange?: string;
+ marketCap?: string;
+ volume?: string;
+ dividendYield?: string;
+}
+
+interface StockDetailItemProps extends React.HTMLAttributes
{
+ label: string;
+ value?: string;
+}
+
+interface StockDetailsListProps extends React.HTMLAttributes {
+ data?: StockDetailsData;
+}
+
+function StockDetailItem({
+ className,
+ label,
+ value,
+ ...props
+}: StockDetailItemProps) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function StockDetailsList({
+ className,
+ data,
+ ...props
+}: StockDetailsListProps) {
+ if (!data) {
+ return null;
+ }
+
+ const stockItems = [
+ { label: "Previous Close", value: data.previousClose },
+ { label: "Day Range", value: data.dayRange },
+ { label: "Year Range", value: data.yearRange },
+ { label: "Market Cap", value: data.marketCap },
+ { label: "Volume", value: data.volume },
+ { label: "Dividend Yield", value: data.dividendYield },
+ ];
+
+ return (
+
+ {stockItems.map((item) => (
+
+ ))}
+
+ );
+}
+
+export { StockDetailsList, StockDetailItem };
diff --git a/frontend/src/app/home/home.tsx b/frontend/src/app/home/home.tsx
new file mode 100644
index 000000000..e1abdcadc
--- /dev/null
+++ b/frontend/src/app/home/home.tsx
@@ -0,0 +1,40 @@
+import { agentRecommendations, agentSuggestions } from "@/mock/agent-data";
+import { sparklineStockData } from "@/mock/stock-data";
+import {
+ AgentRecommendList,
+ AgentSuggestionsList,
+ SparklineStockList,
+} from "./components";
+
+function Home() {
+ const handleAgentClick = (agentId: string, title: string) => {
+ console.log(`Agent clicked: ${title} (${agentId})`);
+ };
+
+ return (
+
+
👋 Welcome to ValueCell !
+
+
+
+
({
+ ...suggestion,
+ onClick: () => handleAgentClick(suggestion.id, suggestion.title),
+ }))}
+ />
+
+ ({
+ ...recommendation,
+ onClick: () =>
+ handleAgentClick(recommendation.id, recommendation.title),
+ }))}
+ />
+
+ );
+}
+
+export default Home;
diff --git a/frontend/src/app/home/stock.tsx b/frontend/src/app/home/stock.tsx
new file mode 100644
index 000000000..b1a4fb6b0
--- /dev/null
+++ b/frontend/src/app/home/stock.tsx
@@ -0,0 +1,151 @@
+import BackButton from "@valuecell/button/back-button";
+import Sparkline from "@valuecell/charts/sparkline";
+import { StockIcon } from "@valuecell/menus/stock-menus";
+import { memo, useMemo } from "react";
+import { useParams } from "react-router";
+import { StockDetailsList } from "@/app/home/components";
+import { Button } from "@/components/ui/button";
+import { STOCK_BADGE_COLORS } from "@/constants/stock";
+import { formatChange, formatPrice, getChangeType } from "@/lib/utils";
+import { stockData } from "@/mock/stock-data";
+import type { SparklineData } from "@/types/chart";
+
+// Generate historical price data in [timestamp, value] format
+function generateHistoricalData(
+ basePrice: number,
+ days: number = 30,
+): SparklineData {
+ const data: SparklineData = [];
+ const now = new Date();
+
+ for (let i = days; i >= 0; i--) {
+ const date = new Date(now);
+ date.setDate(date.getDate() - i);
+
+ // Simulate price fluctuation (±5%)
+ const variation = (Math.random() - 0.5) * 0.1;
+ const price = basePrice * (1 + variation * (i / days)); // Add trend
+
+ // Use [timestamp, value] format to match SparklineData
+ data.push([
+ date.valueOf(), // Use timestamp number instead of ISO string
+ Math.max(0, Number(price.toFixed(2))),
+ ]);
+ }
+
+ return data;
+}
+
+const Stock = memo(function Stock() {
+ const { stockId } = useParams();
+
+ // Find stock information from mock data
+ const stockInfo = useMemo(() => {
+ for (const group of stockData) {
+ const stock = group.stocks.find((s) => s.symbol === stockId);
+ if (stock) return stock;
+ }
+ return null;
+ }, [stockId]);
+
+ // Generate 60-day historical data (fixed, as per design)
+ const chartData = useMemo(() => {
+ if (!stockInfo) return [];
+ return generateHistoricalData(stockInfo.price, 60);
+ }, [stockInfo]);
+
+ // Generate simulated detailed data
+ const detailsData = useMemo(() => {
+ if (!stockInfo) return undefined;
+
+ const basePrice = stockInfo.price;
+ const previousClose = basePrice * (0.99 + Math.random() * 0.02);
+ const dayLow = basePrice * (0.95 + Math.random() * 0.05);
+ const dayHigh = basePrice * (1.01 + Math.random() * 0.04);
+ const yearLow = basePrice * (0.6 + Math.random() * 0.2);
+ const yearHigh = basePrice * (1.1 + Math.random() * 0.3);
+
+ return {
+ previousClose: previousClose.toFixed(2),
+ dayRange: `${dayLow.toFixed(2)} - ${dayHigh.toFixed(2)}`,
+ yearRange: `${yearLow.toFixed(2)} - ${yearHigh.toFixed(2)}`,
+ marketCap: `$${(Math.random() * 50 + 10).toFixed(1)} T USD`,
+ volume: `${(Math.random() * 5000000 + 1000000).toLocaleString()}`,
+ dividendYield: `${(Math.random() * 3 + 0.5).toFixed(2)}%`,
+ };
+ }, [stockInfo]);
+
+ if (!stockInfo) {
+ return (
+
+
Stock {stockId} not found
+
+ );
+ }
+
+ const changeType = getChangeType(stockInfo.changePercent);
+
+ return (
+
+ {/* Stock Main Info */}
+
+
+
+
+
+ {stockInfo.symbol}
+
+
+ Remove
+
+
+
+
+
+
+ {formatPrice(stockInfo.price, stockInfo.currency)}
+
+
+ {formatChange(stockInfo.changePercent, "%")}
+
+
+
+ Oct 25, 5:26:38PM UTC-4 . INDEXSP . Disclaimer
+
+
+
+
+
+
+
+
Details
+
+
+
+
+
+
About
+
+
+ Apple Inc. is an American multinational technology company that
+ specializes in consumer electronics, computer software, and online
+ services. Apple is the world's largest technology company by revenue
+ (totalling $274.5 billion in 2020) and, since January 2021, the
+ world's most valuable company. As of 2021, Apple is the world's
+ fourth-largest PC vendor by unit sales, and fourth-largest smartphone
+ manufacturer. It is one of the Big Five American information
+ technology companies, along with Amazon, Google, Microsoft, and
+ Facebook.
+
+
+
+ );
+});
+
+export default Stock;
diff --git a/frontend/src/components/ui/scroll-area.tsx b/frontend/src/components/ui/scroll-area.tsx
deleted file mode 100644
index 7f8e75d80..000000000
--- a/frontend/src/components/ui/scroll-area.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-function ScrollArea({
- className,
- children,
- ...props
-}: React.ComponentProps) {
- return (
-
-
- {children}
-
-
-
-
- );
-}
-
-function ScrollBar({
- className,
- orientation = "vertical",
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
- );
-}
-
-export { ScrollArea, ScrollBar };
diff --git a/frontend/src/components/valuecell/app-sidebar.tsx b/frontend/src/components/valuecell/app-sidebar.tsx
index 71067d514..1eb7fffc5 100644
--- a/frontend/src/components/valuecell/app-sidebar.tsx
+++ b/frontend/src/components/valuecell/app-sidebar.tsx
@@ -1,4 +1,10 @@
-import { type FC, type HTMLAttributes, type ReactNode, useMemo } from "react";
+import {
+ type FC,
+ type HTMLAttributes,
+ memo,
+ type ReactNode,
+ useMemo,
+} from "react";
import { NavLink, useLocation } from "react-router";
import { BookOpen, ChartBarVertical, Logo, Setting, User } from "@/assets/svg";
import { Separator } from "@/components/ui/separator";
@@ -105,7 +111,7 @@ const SidebarMenuItem: FC = ({
const AppSidebar: FC = () => {
const { pathname } = useLocation();
- const prefixPath = pathname.split("/")[0];
+ const prefixPath = pathname.split("/")[1];
const navItems = useMemo(() => {
return {
@@ -113,7 +119,7 @@ const AppSidebar: FC = () => {
{
id: "home",
icon: Logo,
- label: "首页",
+ label: "Home",
to: "/",
},
],
@@ -121,17 +127,17 @@ const AppSidebar: FC = () => {
{
id: "chart",
icon: ChartBarVertical,
- label: "图表",
+ label: "Chart",
to: "chart",
},
- { id: "book", icon: BookOpen, label: "书籍", to: "book" },
+ { id: "book", icon: BookOpen, label: "Book", to: "book" },
{
id: "settings",
icon: Setting,
- label: "设置",
+ label: "Settings",
to: "settings",
},
- { id: "user", icon: User, label: "用户", to: "user" },
+ { id: "user", icon: User, label: "User", to: "user" },
],
};
}, []);
@@ -193,4 +199,4 @@ const AppSidebar: FC = () => {
);
};
-export default AppSidebar;
+export default memo(AppSidebar);
diff --git a/frontend/src/components/valuecell/button/back-button.tsx b/frontend/src/components/valuecell/button/back-button.tsx
new file mode 100644
index 000000000..8d3956cab
--- /dev/null
+++ b/frontend/src/components/valuecell/button/back-button.tsx
@@ -0,0 +1,22 @@
+import { ArrowLeft } from "lucide-react";
+import type { ComponentProps } from "react";
+import { useNavigate } from "react-router";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+function BackButton({ className, ...props }: ComponentProps<"button">) {
+ const navigate = useNavigate();
+ return (
+ navigate(-1)}
+ {...props}
+ >
+ Back
+
+ );
+}
+
+export default BackButton;
diff --git a/frontend/src/components/valuecell/charts/mini-sparkline.tsx b/frontend/src/components/valuecell/charts/mini-sparkline.tsx
index 027d16a1b..0f23e1f4c 100644
--- a/frontend/src/components/valuecell/charts/mini-sparkline.tsx
+++ b/frontend/src/components/valuecell/charts/mini-sparkline.tsx
@@ -4,23 +4,25 @@ import type { ECharts, EChartsCoreOption } from "echarts/core";
import * as echarts from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { useEffect, useMemo, useRef } from "react";
+import { STOCK_COLORS, STOCK_GRADIENT_COLORS } from "@/constants/stock";
+import { useChartResize } from "@/hooks/use-chart-resize";
import { cn } from "@/lib/utils";
+import type { SparklineData } from "@/types/chart";
+import type { StockChangeType } from "@/types/stock";
echarts.use([LineChart, GridComponent, CanvasRenderer]);
interface MiniSparklineProps {
- data: number[];
- color?: string;
- gradientColors?: [string, string];
+ data: SparklineData;
+ changeType: StockChangeType;
width?: number | string;
height?: number | string;
className?: string;
}
-export function MiniSparkline({
+function MiniSparkline({
data,
- color = "#22c55e",
- gradientColors = ["rgba(34, 197, 94, 0.8)", "rgba(34, 197, 94, 0.1)"],
+ changeType,
width = 100,
height = 40,
className,
@@ -28,6 +30,12 @@ export function MiniSparkline({
const chartRef = useRef(null);
const chartInstance = useRef(null);
+ useChartResize(chartInstance);
+
+ // Get colors based on change type
+ const color = STOCK_COLORS[changeType];
+ const gradientColors = STOCK_GRADIENT_COLORS[changeType];
+
const option: EChartsCoreOption = useMemo(() => {
return {
grid: {
@@ -37,8 +45,11 @@ export function MiniSparkline({
bottom: 0,
},
xAxis: {
- type: "category",
+ type: "time",
show: false,
+ axisLabel: {
+ show: false,
+ },
},
yAxis: {
type: "value",
@@ -75,24 +86,16 @@ export function MiniSparkline({
useEffect(() => {
if (!chartRef.current) return;
- chartInstance.current = echarts.init(chartRef.current);
+ chartInstance.current = echarts.init(chartRef.current);
chartInstance.current.setOption(option);
- const handleResize = () => {
- chartInstance.current?.resize();
- };
-
- window.addEventListener("resize", handleResize);
-
return () => {
- window.removeEventListener("resize", handleResize);
chartInstance.current?.dispose();
};
}, [option]);
useEffect(() => {
- // update chart when data changes
if (chartInstance.current) {
chartInstance.current.setOption({
series: [{ data }],
diff --git a/frontend/src/components/valuecell/charts/sparkline.tsx b/frontend/src/components/valuecell/charts/sparkline.tsx
new file mode 100644
index 000000000..8856f38b3
--- /dev/null
+++ b/frontend/src/components/valuecell/charts/sparkline.tsx
@@ -0,0 +1,172 @@
+import { LineChart } from "echarts/charts";
+import {
+ DataZoomComponent,
+ GridComponent,
+ TooltipComponent,
+} from "echarts/components";
+import type { ECharts } from "echarts/core";
+import * as echarts from "echarts/core";
+import { CanvasRenderer } from "echarts/renderers";
+import type { EChartsOption } from "echarts/types/dist/shared";
+import { useEffect, useMemo, useRef } from "react";
+import { STOCK_COLORS, STOCK_GRADIENT_COLORS } from "@/constants/stock";
+import { useChartResize } from "@/hooks/use-chart-resize";
+import { format } from "@/lib/time";
+import { cn } from "@/lib/utils";
+import type { SparklineData } from "@/types/chart";
+import type { StockChangeType } from "@/types/stock";
+
+echarts.use([
+ LineChart,
+ GridComponent,
+ TooltipComponent,
+ DataZoomComponent,
+ CanvasRenderer,
+]);
+
+interface SparklineProps {
+ data: SparklineData;
+ changeType: StockChangeType;
+ width?: number | string;
+ height?: number | string;
+ className?: string;
+}
+
+function Sparkline({
+ data,
+ changeType,
+ width = "100%",
+ height = 400,
+ className,
+}: SparklineProps) {
+ const chartRef = useRef(null);
+ const chartInstance = useRef(null);
+
+ // Get colors based on change type
+ const color = STOCK_COLORS[changeType];
+ const gradientColors = STOCK_GRADIENT_COLORS[changeType];
+
+ const option: EChartsOption = useMemo(() => {
+ return {
+ grid: {
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ },
+ xAxis: {
+ type: "time",
+ show: false,
+ axisLabel: {
+ show: false,
+ },
+ },
+ yAxis: {
+ type: "value",
+ scale: true,
+ splitLine: {
+ show: true,
+ lineStyle: {
+ color: "rgba(174, 174, 174, 0.5)",
+ opacity: 0.3,
+ type: "solid",
+ },
+ },
+ },
+ series: [
+ {
+ type: "line",
+ data: data,
+ symbol: "circle",
+ symbolSize: 12,
+ showSymbol: false,
+ itemStyle: {
+ color: color,
+ borderColor: "#fff",
+ borderWidth: 4,
+ },
+ areaStyle: {
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+ {
+ offset: 0,
+ color: gradientColors[0],
+ },
+ {
+ offset: 1,
+ color: gradientColors[1],
+ },
+ ]),
+ },
+ animationDuration: 500,
+ animationEasing: "quadraticOut",
+ },
+ ],
+ tooltip: {
+ trigger: "axis",
+ backgroundColor: "rgba(0, 0, 0, 0.7)",
+ textStyle: {
+ color: "#fff",
+ fontSize: 12,
+ },
+ padding: [14, 16],
+ borderRadius: 12,
+ formatter: (params: unknown) => {
+ if (!Array.isArray(params) || params.length === 0) return "";
+
+ const param = params[0] as { data: [number, number] };
+ if (!param || !param.data) return "";
+
+ const timestamp = param.data[0];
+ const value = param.data[1];
+
+ // Use our time utility for formatting
+ const formatDate = format(timestamp, "MMM D");
+ const formatTime = format(timestamp, "h:mm:ss A");
+
+ return `
+
+ ${formatDate}, ${formatTime}
+
+
+ ${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
+ `;
+ },
+ axisPointer: {
+ type: "none",
+ },
+ },
+ };
+ }, [data, color, gradientColors]);
+
+ useChartResize(chartInstance);
+
+ useEffect(() => {
+ if (!chartRef.current) return;
+
+ chartInstance.current = echarts.init(chartRef.current);
+ chartInstance.current.setOption(option);
+
+ return () => {
+ chartInstance.current?.dispose();
+ };
+ }, [option]);
+
+ useEffect(() => {
+ if (chartInstance.current) {
+ chartInstance.current.setOption({
+ series: [{ data }],
+ });
+ }
+ }, [data]);
+
+ return (
+
+ );
+}
+
+export default Sparkline;
diff --git a/frontend/src/components/valuecell/menus/stock-menus.tsx b/frontend/src/components/valuecell/menus/stock-menus.tsx
index d78ffc104..b66c893e2 100644
--- a/frontend/src/components/valuecell/menus/stock-menus.tsx
+++ b/frontend/src/components/valuecell/menus/stock-menus.tsx
@@ -1,6 +1,5 @@
-import type { ScrollAreaProps } from "@radix-ui/react-scroll-area";
+import { Link, type LinkProps } from "react-router";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { ScrollArea } from "@/components/ui/scroll-area";
import { cn, formatChange, formatPrice, getChangeType } from "@/lib/utils";
interface Stock {
@@ -27,10 +26,6 @@ interface StockMenuHeaderProps
children: React.ReactNode;
}
-interface StockMenuContentProps extends ScrollAreaProps {
- children: React.ReactNode;
-}
-
interface StockMenuGroupProps extends React.HTMLAttributes {
children: React.ReactNode;
}
@@ -40,10 +35,13 @@ interface StockMenuGroupHeaderProps
children: React.ReactNode;
}
-interface StockMenuListItemProps
- extends Omit, "onClick"> {
+interface StockIconProps extends React.HTMLAttributes {
stock: Stock;
- onClick?: (stock: Stock) => void;
+}
+
+interface StockMenuListItemProps extends LinkProps {
+ stock: Stock;
+ isActive?: boolean;
}
function StockMenuHeader({
@@ -58,18 +56,6 @@ function StockMenuHeader({
);
}
-function StockMenuContent({
- className,
- children,
- ...props
-}: StockMenuContentProps) {
- return (
-
- {children}
-
- );
-}
-
function StockMenu({ className, children, ...props }: StockMenuProps) {
return (
+
+
+
+ {stock.symbol.slice(0, 2)}
+
+
+
+ );
+}
+
function StockMenuListItem({
className,
stock,
onClick,
+ isActive,
...props
}: StockMenuListItemProps) {
const changeType = getChangeType(stock.changePercent);
return (
- onClick?.(stock)}
{...props}
>
{/* icon */}
-
- {stock.icon ? (
-
-
-
- {stock.symbol.slice(0, 2)}
-
-
- ) : (
-
- {stock.symbol.slice(0, 2)}
-
- )}
-
+
{/* stock info */}
@@ -158,7 +148,7 @@ function StockMenuListItem({
{/* price info */}
-
+
{formatPrice(stock.price, stock.currency)}
@@ -172,15 +162,15 @@ function StockMenuListItem({
{formatChange(stock.changePercent, "%")}
-
+
);
}
export {
StockMenu,
StockMenuHeader,
- StockMenuContent,
StockMenuGroup,
StockMenuGroupHeader,
StockMenuListItem,
+ StockIcon,
};
diff --git a/frontend/src/components/valuecell/scroll-container.tsx b/frontend/src/components/valuecell/scroll-container.tsx
new file mode 100644
index 000000000..75c73990d
--- /dev/null
+++ b/frontend/src/components/valuecell/scroll-container.tsx
@@ -0,0 +1,22 @@
+import {
+ OverlayScrollbarsComponent,
+ type OverlayScrollbarsComponentProps,
+} from "overlayscrollbars-react";
+
+interface ScrollContainerProps extends OverlayScrollbarsComponentProps {
+ children: React.ReactNode;
+}
+
+function ScrollContainer({ children, ...props }: ScrollContainerProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export default ScrollContainer;
diff --git a/frontend/src/constants/stock.ts b/frontend/src/constants/stock.ts
new file mode 100644
index 000000000..4f84ca30a
--- /dev/null
+++ b/frontend/src/constants/stock.ts
@@ -0,0 +1,26 @@
+import type { StockChangeType } from "@/types/stock";
+
+// Stock change type color mappings
+export const STOCK_COLORS: Record
= {
+ positive: "#41C3A9",
+ negative: "#E25C5C",
+ neutral: "#707070",
+};
+
+// Stock change type gradient color mappings
+export const STOCK_GRADIENT_COLORS: Record =
+ {
+ positive: ["rgba(65, 195, 169, 0.6)", "rgba(65, 195, 169, 0)"],
+ negative: ["rgba(226, 92, 92, 0.5)", "rgba(226, 92, 92, 0)"],
+ neutral: ["rgba(112, 112, 112, 0.5)", "rgba(112, 112, 112, 0)"],
+ };
+
+// Stock change type badge color mappings (for percentage change display)
+export const STOCK_BADGE_COLORS: Record<
+ StockChangeType,
+ { bg: string; text: string }
+> = {
+ positive: { bg: "#EEFBF5", text: "#5CCDB3" },
+ negative: { bg: "#FFEAEA", text: "#E25C5C" },
+ neutral: { bg: "#F5F5F5", text: "#707070" },
+};
diff --git a/frontend/src/global.css b/frontend/src/global.css
index 56225d218..eba342a04 100644
--- a/frontend/src/global.css
+++ b/frontend/src/global.css
@@ -26,34 +26,25 @@
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
- --color-chart-1: var(--chart-1);
- --color-chart-2: var(--chart-2);
- --color-chart-3: var(--chart-3);
- --color-chart-4: var(--chart-4);
- --color-chart-5: var(--chart-5);
- --color-sidebar: var(--sidebar);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-ring: var(--sidebar-ring);
--animate-marquee: marquee var(--duration) infinite linear;
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
+
@keyframes marquee {
from {
transform: translateX(0);
}
+
to {
transform: translateX(calc(-100% - var(--gap)));
}
}
+
@keyframes marquee-vertical {
from {
transform: translateY(0);
}
+
to {
transform: translateY(calc(-100% - var(--gap)));
}
@@ -80,19 +71,6 @@
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
- --chart-1: oklch(0.646 0.222 41.116);
- --chart-2: oklch(0.6 0.118 184.704);
- --chart-3: oklch(0.398 0.07 227.392);
- --chart-4: oklch(0.828 0.189 84.429);
- --chart-5: oklch(0.769 0.188 70.08);
- --sidebar: oklch(0.985 0 0);
- --sidebar-foreground: oklch(0.145 0 0);
- --sidebar-primary: oklch(0.205 0 0);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.97 0 0);
- --sidebar-accent-foreground: oklch(0.205 0 0);
- --sidebar-border: oklch(0.922 0 0);
- --sidebar-ring: oklch(0.708 0 0);
}
.dark {
@@ -114,19 +92,6 @@
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
- --chart-1: oklch(0.488 0.243 264.376);
- --chart-2: oklch(0.696 0.17 162.48);
- --chart-3: oklch(0.769 0.188 70.08);
- --chart-4: oklch(0.627 0.265 303.9);
- --chart-5: oklch(0.645 0.246 16.439);
- --sidebar: oklch(0.205 0 0);
- --sidebar-foreground: oklch(0.985 0 0);
- --sidebar-primary: oklch(0.488 0.243 264.376);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.269 0 0);
- --sidebar-accent-foreground: oklch(0.985 0 0);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.556 0 0);
}
@layer base {
@@ -144,3 +109,10 @@
@apply bg-white;
}
}
+
+/* OverlayScrollbars custom styles */
+.os-theme-dark {
+ --os-handle-bg: var(--color-neutral-200);
+ --os-handle-bg-hover: var(--color-neutral-300);
+ --os-handle-bg-active: var(--color-neutral-400);
+}
\ No newline at end of file
diff --git a/frontend/src/hooks/use-chart-resize.ts b/frontend/src/hooks/use-chart-resize.ts
new file mode 100644
index 000000000..120cc4add
--- /dev/null
+++ b/frontend/src/hooks/use-chart-resize.ts
@@ -0,0 +1,26 @@
+import type { ECharts } from "echarts/core";
+import { useEffect } from "react";
+
+/**
+ * deal with ECharts window resize event
+ * @param chartInstance ECharts instance ref
+ * @param dependencies additional dependencies array, when these dependencies change, the resize listener will be re-set
+ */
+export function useChartResize(
+ chartInstance: React.RefObject,
+ dependencies: React.DependencyList = [],
+) {
+ useEffect(() => {
+ const handleResize = () => {
+ chartInstance.current?.resize();
+ };
+
+ // add resize event listener
+ window.addEventListener("resize", handleResize);
+
+ // cleanup function: remove event listener
+ return () => {
+ window.removeEventListener("resize", handleResize);
+ };
+ }, [chartInstance, ...dependencies]);
+}
diff --git a/frontend/src/lib/time.ts b/frontend/src/lib/time.ts
new file mode 100644
index 000000000..b433c8dda
--- /dev/null
+++ b/frontend/src/lib/time.ts
@@ -0,0 +1,233 @@
+import dayjs, { type Dayjs } from "dayjs";
+import relativeTime from "dayjs/plugin/relativeTime";
+import timezone from "dayjs/plugin/timezone";
+import utc from "dayjs/plugin/utc";
+
+// Extend dayjs with plugins
+dayjs.extend(utc);
+dayjs.extend(timezone);
+dayjs.extend(relativeTime);
+
+/**
+ * Common time format constants
+ */
+export const TIME_FORMATS = {
+ DATE: "YYYY-MM-DD",
+ TIME: "HH:mm:ss",
+ DATETIME: "YYYY-MM-DD HH:mm:ss",
+ DATETIME_SHORT: "YYYY-MM-DD HH:mm",
+ MARKET: "MM/DD HH:mm",
+} as const;
+
+/**
+ * Time input types
+ */
+export type TimeInput = string | number | Date | Dayjs;
+
+/**
+ * Time format type
+ */
+export type TimeFormat = (typeof TIME_FORMATS)[keyof typeof TIME_FORMATS];
+
+/**
+ * Timezone string type
+ */
+export type TimezoneString = string;
+
+/**
+ * Time unit type
+ */
+export type TimeUnit =
+ | "year"
+ | "month"
+ | "day"
+ | "hour"
+ | "minute"
+ | "second"
+ | "millisecond";
+
+/**
+ * Get current UTC time
+ * @returns Current UTC time as Dayjs instance
+ */
+export function nowUTC(): Dayjs {
+ return dayjs.utc();
+}
+
+/**
+ * Get current local time
+ * @returns Current local time as Dayjs instance
+ */
+export function now(): Dayjs {
+ return dayjs();
+}
+
+/**
+ * Create UTC time from input
+ * @param input - Time input (optional, defaults to current time)
+ * @returns UTC time as Dayjs instance
+ */
+export function createUTC(input?: TimeInput): Dayjs {
+ return input ? dayjs.utc(input) : dayjs.utc();
+}
+
+/**
+ * Convert time to UTC
+ * @param time - Time input to convert
+ * @returns UTC time as Dayjs instance
+ */
+export function toUTC(time: TimeInput): Dayjs {
+ return dayjs(time).utc();
+}
+
+/**
+ * Convert UTC time to local time
+ * @param time - UTC time input
+ * @returns Local time as Dayjs instance
+ */
+export function fromUTC(time: TimeInput): Dayjs {
+ return dayjs.utc(time).local();
+}
+
+/**
+ * Convert time to specific timezone
+ * @param time - Time input to convert
+ * @param timezone - Target timezone string
+ * @returns Time in specified timezone as Dayjs instance
+ */
+export function toTimezone(time: TimeInput, timezone: TimezoneString): Dayjs {
+ return dayjs(time).tz(timezone);
+}
+
+/**
+ * Format time with specified format
+ * @param time - Time input to format
+ * @param fmt - Format string (defaults to DATETIME)
+ * @returns Formatted time string
+ */
+export function format(
+ time: TimeInput,
+ fmt: string = TIME_FORMATS.DATETIME,
+): string {
+ return dayjs(time).format(fmt);
+}
+
+/**
+ * Format UTC time with specified format
+ * @param time - Time input to format as UTC
+ * @param fmt - Format string (defaults to DATETIME)
+ * @returns Formatted UTC time string
+ */
+export function formatUTC(
+ time: TimeInput,
+ fmt: string = TIME_FORMATS.DATETIME,
+): string {
+ return dayjs.utc(time).format(fmt);
+}
+
+/**
+ * Get relative time from now (e.g., "2 hours ago")
+ * @param time - Time input to get relative time for
+ * @returns Relative time string
+ */
+export function fromNow(time: TimeInput): string {
+ return dayjs(time).fromNow();
+}
+
+/**
+ * Get time difference in milliseconds
+ * @param time1 - End time
+ * @param time2 - Start time
+ * @returns Time difference in milliseconds
+ */
+export function diff(time1: TimeInput, time2: TimeInput): number {
+ return dayjs(time1).diff(dayjs(time2));
+}
+
+/**
+ * Get time difference in specified unit
+ * @param time1 - End time
+ * @param time2 - Start time
+ * @param unit - Time unit for difference calculation
+ * @returns Time difference in specified unit
+ */
+export function diffIn(
+ time1: TimeInput,
+ time2: TimeInput,
+ unit: TimeUnit,
+): number {
+ return dayjs(time1).diff(dayjs(time2), unit);
+}
+
+/**
+ * Check if time is valid
+ * @param time - Time input to validate
+ * @returns True if time is valid, false otherwise
+ */
+export function isValid(time: TimeInput): boolean {
+ return dayjs(time).isValid();
+}
+
+/**
+ * Check if two times are the same day
+ * @param time1 - First time
+ * @param time2 - Second time
+ * @param useUTC - Whether to compare in UTC (defaults to false)
+ * @returns True if same day, false otherwise
+ */
+export function isSameDay(
+ time1: TimeInput,
+ time2: TimeInput,
+ useUTC: boolean = false,
+): boolean {
+ if (useUTC) {
+ return dayjs.utc(time1).isSame(dayjs.utc(time2), "day");
+ }
+ return dayjs(time1).isSame(dayjs(time2), "day");
+}
+
+/**
+ * Add time to a given time
+ * @param time - Base time
+ * @param amount - Amount to add
+ * @param unit - Time unit
+ * @returns New time with added amount
+ */
+export function add(time: TimeInput, amount: number, unit: TimeUnit): Dayjs {
+ return dayjs(time).add(amount, unit);
+}
+
+/**
+ * Subtract time from a given time
+ * @param time - Base time
+ * @param amount - Amount to subtract
+ * @param unit - Time unit
+ * @returns New time with subtracted amount
+ */
+export function subtract(
+ time: TimeInput,
+ amount: number,
+ unit: TimeUnit,
+): Dayjs {
+ return dayjs(time).subtract(amount, unit);
+}
+
+/**
+ * Get start of day
+ * @param time - Time input
+ * @param useUTC - Whether to use UTC (defaults to false)
+ * @returns Start of day as Dayjs instance
+ */
+export function startOfDay(time: TimeInput, useUTC: boolean = false): Dayjs {
+ return useUTC ? dayjs.utc(time).startOf("day") : dayjs(time).startOf("day");
+}
+
+/**
+ * Get end of day
+ * @param time - Time input
+ * @param useUTC - Whether to use UTC (defaults to false)
+ * @returns End of day as Dayjs instance
+ */
+export function endOfDay(time: TimeInput, useUTC: boolean = false): Dayjs {
+ return useUTC ? dayjs.utc(time).endOf("day") : dayjs(time).endOf("day");
+}
diff --git a/frontend/src/mock/agent-data.tsx b/frontend/src/mock/agent-data.tsx
index 16c9f0b67..1956ef5e5 100644
--- a/frontend/src/mock/agent-data.tsx
+++ b/frontend/src/mock/agent-data.tsx
@@ -10,8 +10,8 @@ import {
TrendingUp,
User,
} from "lucide-react";
-import type { AgentRecommendation } from "@/app/_home/components/agent-recommend-list";
-import type { AgentSuggestion } from "@/app/_home/components/agent-suggestions-list";
+import type { AgentRecommendation } from "@/app/home/components/agent-recommend-list";
+import type { AgentSuggestion } from "@/app/home/components/agent-suggestions-list";
const UserAvatar = ({ bgColor, text }: { bgColor: string; text: string }) => (
diff --git a/frontend/src/mock/stock-data.ts b/frontend/src/mock/stock-data.ts
index fc4bc9ed4..1336bb34d 100644
--- a/frontend/src/mock/stock-data.ts
+++ b/frontend/src/mock/stock-data.ts
@@ -1,5 +1,7 @@
-import type { SparklineStock } from "@/app/_home/components/sparkline-stock-list";
+import type { SparklineStock } from "@/app/home/components/sparkline-stock-list";
import type { StockGroup } from "@/components/valuecell/menus/stock-menus";
+import { nowUTC, subtract } from "@/lib/time";
+import type { SparklineData } from "@/types/chart";
export const stockData: StockGroup[] = [
{
@@ -172,16 +174,32 @@ export const stockData: StockGroup[] = [
},
];
-// generate random sparkline data
-function generateSparklineData(): number[] {
- const data = [];
- let value = 100 + Math.random() * 50; // start value between 100-150
+// Generate random sparkline data in [utctime, value] format
+function generateSparklineData(): SparklineData {
+ const data: SparklineData = [];
+ const startValue = 100 + Math.random() * 50; // start value between 100-150
+ let value = startValue;
+ const currentTime = nowUTC();
+
+ // Add some overall trend bias (slightly bearish to bullish)
+ const trendBias = (Math.random() - 0.5) * 0.002; // -0.1% to +0.1% per step
for (let i = 0; i < 30; i++) {
- // add random fluctuation, between -5% and +5%
- const change = (Math.random() - 0.5) * 0.1 * value;
- value = Math.max(0, value + change);
- data.push(value);
+ // Generate time points going backwards from current time (each point is 30 minutes apart)
+ const timePoint = subtract(currentTime, (29 - i) * 30, "minute").valueOf();
+
+ // Random walk with trend bias
+ const randomChange = (Math.random() - 0.5) * 0.06; // -3% to +3% random
+ const changePercent = randomChange + trendBias;
+
+ // Apply change
+ value = value * (1 + changePercent);
+
+ // Prevent negative values and extreme deviations
+ value = Math.max(value, startValue * 0.3); // Don't go below 30% of start
+ value = Math.min(value, startValue * 3); // Don't go above 300% of start
+
+ data.push([timePoint, Number(value.toFixed(2))]);
}
return data;
diff --git a/frontend/src/root.tsx b/frontend/src/root.tsx
index efab42d28..5c121d1b9 100644
--- a/frontend/src/root.tsx
+++ b/frontend/src/root.tsx
@@ -1,6 +1,7 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import AppSidebar from "@/components/valuecell/app-sidebar";
+import "overlayscrollbars/overlayscrollbars.css";
import "./global.css";
export function Layout({ children }: { children: React.ReactNode }) {
diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts
index dad9f7e84..a404f1c81 100644
--- a/frontend/src/routes.ts
+++ b/frontend/src/routes.ts
@@ -1,9 +1,13 @@
-import { type RouteConfig, route } from "@react-router/dev/routes";
-import { flatRoutes } from "@react-router/fs-routes";
+import {
+ index,
+ layout,
+ type RouteConfig,
+ route,
+} from "@react-router/dev/routes";
export default [
- route("/", "app/_home/route.tsx"),
- ...(await flatRoutes({
- rootDirectory: "app",
- })),
+ layout("app/home/_layout.tsx", [
+ index("app/home/home.tsx"),
+ route("/stock/:stockId", "app/home/stock.tsx"),
+ ]),
] satisfies RouteConfig;
diff --git a/frontend/src/types/chart.ts b/frontend/src/types/chart.ts
new file mode 100644
index 000000000..ddbfe7ea5
--- /dev/null
+++ b/frontend/src/types/chart.ts
@@ -0,0 +1,5 @@
+/**
+ * Sparkline data type: [timestamp, value] pairs
+ * timestamp can be number (unix timestamp), string (ISO), or Date object
+ */
+export type SparklineData = Array<[number | string | Date, number]>;
diff --git a/python/third_party/ai-hedge-fund/app/frontend/src/index.css b/python/third_party/ai-hedge-fund/app/frontend/src/index.css
index e9f2f3a0b..8c67ef1f6 100644
--- a/python/third_party/ai-hedge-fund/app/frontend/src/index.css
+++ b/python/third_party/ai-hedge-fund/app/frontend/src/index.css
@@ -31,15 +31,17 @@
--sidebar-background: 0 0% 98%;
--panel-bg: 0 0% 100%;
--node-bg: 0 0% 100%;
-
+
/* Hover colors for light theme */
- --hover-background: rgb(156 163 175 / 0.3); /* gray-400/30 */
+ --hover-background: rgb(156 163 175 / 0.3);
+ /* gray-400/30 */
--hover-foreground: 0 0% 15%;
-
+
/* Active/selected colors for light theme */
- --active-background: rgb(229 231 235 / 0.8); /* gray-200/80 */
+ --active-background: rgb(229 231 235 / 0.8);
+ /* gray-200/80 */
--active-foreground: 0 0% 10%;
-
+
/* Ramp Grey Colors */
--ramp-grey-100: #f5f5f5;
--ramp-grey-200: #e6e6e6;
@@ -72,13 +74,6 @@
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
- --sidebar-foreground: 240 5.3% 26.1%;
- --sidebar-primary: 240 5.9% 10%;
- --sidebar-primary-foreground: 0 0% 98%;
- --sidebar-accent: 240 4.8% 95.9%;
- --sidebar-accent-foreground: 240 5.9% 10%;
- --sidebar-border: 220 13% 91%;
- --sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
@@ -108,13 +103,15 @@
--panel-bg: 240 3% 11%;
--node-bg: 240 3% 11%;
--sidebar-background: 240 5.9% 16%;
-
+
/* Hover colors for dark theme */
- --hover-background: rgb(55 65 81 / 0.5); /* gray-700/50 */
+ --hover-background: rgb(55 65 81 / 0.5);
+ /* gray-700/50 */
--hover-foreground: 0 0% 95%;
-
+
/* Active/selected colors for dark theme */
- --active-background: rgb(55 65 81 / 0.8); /* gray-700/80 */
+ --active-background: rgb(55 65 81 / 0.8);
+ /* gray-700/80 */
--active-foreground: 0 0% 98%;
/* Tab Colors - Dark Theme */
@@ -128,19 +125,6 @@
--tab-icon-active: #007acc;
--tab-icon-inactive: #858585;
--tab-close-hover: #464647;
-
- --chart-1: 220 70% 50%;
- --chart-2: 160 60% 45%;
- --chart-3: 30 80% 55%;
- --chart-4: 280 65% 60%;
- --chart-5: 340 75% 55%;
- --sidebar-foreground: 240 4.8% 95.9%;
- --sidebar-primary: 224.3 76.3% 48%;
- --sidebar-primary-foreground: 0 0% 100%;
- --sidebar-accent: 240 3.7% 15.9%;
- --sidebar-accent-foreground: 240 4.8% 95.9%;
- --sidebar-border: 240 3.7% 15.9%;
- --sidebar-ring: 217.2 91.2% 59.8%;
}
}
@@ -148,43 +132,43 @@
.bg-node {
background-color: hsl(var(--node-bg));
}
-
+
.border-status {
border-color: hsl(var(--status-border));
}
-
+
.border-node {
border-color: hsl(var(--node-border));
}
-
+
.border-node-hover {
border-color: hsl(var(--node-border-hover));
}
-
+
.border-node-selected {
border-color: hsl(var(--node-border-selected));
}
-
+
.hover-bg {
@apply hover:bg-[var(--hover-background)];
}
-
+
.hover-text {
@apply hover:text-[hsl(var(--hover-foreground))];
}
-
+
.hover-item {
@apply hover:bg-[var(--hover-background)] hover:text-[hsl(var(--hover-foreground))];
}
-
+
.active-bg {
@apply bg-[var(--active-background)];
}
-
+
.active-text {
@apply text-[hsl(var(--active-foreground))];
}
-
+
.active-item {
@apply bg-[var(--active-background)] text-[hsl(var(--active-foreground))];
}
@@ -303,13 +287,12 @@
position: absolute;
inset: 0;
border-radius: 0.5rem;
- background: linear-gradient(90deg,
- #2383F4, #5e61e7, #8F00FF, #7831d4, #2383F4
- );
+ background: linear-gradient(90deg,
+ #2383F4, #5e61e7, #8F00FF, #7831d4, #2383F4);
background-size: 200% 100%;
animation: gradientFlow 3s linear infinite;
- -webkit-mask:
- linear-gradient(#fff 0 0) content-box,
+ -webkit-mask:
+ linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
@@ -320,6 +303,7 @@
0% {
background-position: 0% 0%;
}
+
100% {
background-position: 200% 0%;
}
@@ -327,18 +311,16 @@
/* Gradient animation for in-progress elements */
.gradient-animation {
- background: linear-gradient(90deg,
- #2383F4, #5e61e7, #8F00FF, #7831d4, #2383F4
- );
+ background: linear-gradient(90deg,
+ #2383F4, #5e61e7, #8F00FF, #7831d4, #2383F4);
background-size: 200% 100%;
animation: gradientFlow 3s linear infinite;
}
/* Gradient text animation */
.gradient-text {
- background: linear-gradient(90deg,
- #2383F4, #5e61e7, #8F00FF, #7831d4, #2383F4
- );
+ background: linear-gradient(90deg,
+ #2383F4, #5e61e7, #8F00FF, #7831d4, #2383F4);
background-size: 200% 100%;
animation: gradientFlow 3s linear infinite;
background-clip: text;
@@ -351,6 +333,6 @@
border-right: none !important;
}
-.react-flow__controls .react-flow__controls-button + .react-flow__controls-button {
+.react-flow__controls .react-flow__controls-button+.react-flow__controls-button {
border-left: none !important;
}
\ No newline at end of file