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), - }))} - /> -
- - -
- ); -} - -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 ( +
+ + + + + +
+ ); +} 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} + + +
+ +
+
+ + {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 ( + + ); +} + +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 ( - + ); } 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