From 06b865c1c2aace5d6aecef65b14674925ab436cd Mon Sep 17 00:00:00 2001 From: bracesproul Date: Wed, 5 Mar 2025 18:02:46 -0800 Subject: [PATCH] feat: Implement stock price component and api --- agent/stockbroker/nodes/tools.tsx | 69 +- agent/types.ts | 10 + agent/uis/stockbroker/stock-price/index.tsx | 1135 ++++--------------- package.json | 5 + pnpm-lock.yaml | 391 +++++++ src/components/ui/card.tsx | 75 ++ src/components/ui/chart.tsx | 353 ++++++ src/index.css | 15 + 8 files changed, 1116 insertions(+), 937 deletions(-) create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/chart.tsx diff --git a/agent/stockbroker/nodes/tools.tsx b/agent/stockbroker/nodes/tools.tsx index d434b06..7bc2d50 100644 --- a/agent/stockbroker/nodes/tools.tsx +++ b/agent/stockbroker/nodes/tools.tsx @@ -5,6 +5,63 @@ import type ComponentMap from "../../uis/index"; import { z } from "zod"; import { LangGraphRunnableConfig } from "@langchain/langgraph"; import { findToolCall } from "../../find-tool-call"; +import { format, subDays } from "date-fns"; +import { Price } from "../../types"; + +async function getPricesForTicker(ticker: string): Promise<{ + oneDayPrices: Price[]; + thirtyDayPrices: Price[]; +}> { + if (!process.env.FINANCIAL_DATASETS_API_KEY) { + throw new Error("Financial datasets API key not set"); + } + + const options = { + method: "GET", + headers: { "X-API-KEY": process.env.FINANCIAL_DATASETS_API_KEY }, + }; + + const url = "https://api.financialdatasets.ai/prices"; + + const oneMonthAgo = format(subDays(new Date(), 30), "yyyy-MM-dd"); + const now = format(new Date(), "yyyy-MM-dd"); + + const queryParamsOneDay = new URLSearchParams({ + ticker, + interval: "minute", + interval_multiplier: "5", + start_date: now, + end_date: now, + limit: "5000", + }); + + const queryParamsThirtyDays = new URLSearchParams({ + ticker, + interval: "minute", + interval_multiplier: "30", + start_date: oneMonthAgo, + end_date: now, + limit: "5000", + }); + + const [resOneDay, resThirtyDays] = await Promise.all([ + fetch(`${url}?${queryParamsOneDay.toString()}`, options), + fetch(`${url}?${queryParamsThirtyDays.toString()}`, options), + ]); + if (!resOneDay.ok || !resThirtyDays.ok) { + throw new Error("Failed to fetch prices"); + } + const { prices: pricesOneDay } = await resOneDay.json(); + const { prices: pricesThirtyDays } = await resThirtyDays.json(); + + console.log("pricesOneDay", pricesOneDay.length); + console.log("pricesThirtyDays", pricesThirtyDays.length); + + return { + oneDayPrices: pricesOneDay, + thirtyDayPrices: pricesThirtyDays, + }; +} const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 }); @@ -48,15 +105,15 @@ export async function callTools( findToolCall("stock-price"), ); const portfolioToolCall = message.tool_calls?.find( - findToolCall("portfolio"), + findToolCall("portfolio"), ); if (stockbrokerToolCall) { - const instruction = `The stock price of ${ - stockbrokerToolCall.args.ticker - } is ${Math.random() * 100}`; - - ui.write("stock-price", { instruction, logo: "hey" }); + const prices = await getPricesForTicker(stockbrokerToolCall.args.ticker); + ui.write("stock-price", { + ticker: stockbrokerToolCall.args.ticker, + ...prices, + }); } if (portfolioToolCall) { diff --git a/agent/types.ts b/agent/types.ts index 8894f88..afdae92 100644 --- a/agent/types.ts +++ b/agent/types.ts @@ -25,3 +25,13 @@ export type Accommodation = { city: string; image: string; }; + +export type Price = { + ticker: string; + open: number; + close: number; + high: number; + low: number; + volume: number; + time: string; +}; diff --git a/agent/uis/stockbroker/stock-price/index.tsx b/agent/uis/stockbroker/stock-price/index.tsx index 8413a2c..675aa7c 100644 --- a/agent/uis/stockbroker/stock-price/index.tsx +++ b/agent/uis/stockbroker/stock-price/index.tsx @@ -3,953 +3,226 @@ import { useStreamContext, type UIMessage, } from "@langchain/langgraph-sdk/react-ui"; -import type { AIMessage, Message, ToolMessage } from "@langchain/langgraph-sdk"; -import { useState, useEffect, useCallback } from "react"; +import type { Message } from "@langchain/langgraph-sdk"; +import { useState, useMemo } from "react"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import { Price } from "../../../types"; +import { format } from "date-fns"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; -export default function StockPrice(props: { - instruction: string; - logo: string; +const chartConfig = { + price: { + label: "Price", + color: "hsl(var(--chart-1))", + }, +} satisfies ChartConfig; + +type DisplayRange = "1d" | "5d" | "1m"; + +function DisplayRangeSelector({ + displayRange, + setDisplayRange, +}: { + displayRange: DisplayRange; + setDisplayRange: (range: DisplayRange) => void; }) { + const sharedClass = + " bg-transparent text-gray-500 hover:bg-gray-50 transition-colors ease-in-out duration-200 p-2 cursor-pointer"; + const selectedClass = `text-black bg-gray-100 hover:bg-gray-50`; + return ( +
+ +

|

+ +

|

+ +
+ ); +} + +function getPropsForDisplayRange(displayRange: DisplayRange, prices: Price[]) { + // Start by filtering prices by display range. use the `time` field on `price` which is a string date. compare it to the current date + const actualPrices: Price[] = []; + const now = new Date(); + const oneDay = 24 * 60 * 60 * 1000; + const fiveDays = 5 * oneDay; + const oneMonth = 30 * oneDay; + + switch (displayRange) { + case "1d": + console.log("Calculating for 1d", prices.length); + console.log(prices[prices.length - 1]); + actualPrices.push( + ...prices.filter( + (p) => new Date(p.time).getTime() >= now.getTime() - oneDay, + ), + ); + break; + case "5d": + console.log("Calculating for 5d", prices.length); + actualPrices.push( + ...prices.filter( + (p) => new Date(p.time).getTime() >= now.getTime() - fiveDays, + ), + ); + break; + case "1m": + console.log("Calculating for 1m", prices.length); + actualPrices.push( + ...prices.filter( + (p) => new Date(p.time).getTime() >= now.getTime() - oneMonth, + ), + ); + break; + } + return actualPrices; +} +// TODO: UPDATE TO SUPPORT ONE DAY AND THIRTY DAY PRICES AS DIFFERENT PROPS +export default function StockPrice(props: { + ticker: string; + oneDayPrices: Price[]; + thirtyDayPrices: Price[]; +}) { + const { ticker } = props; + console.log(props.prices[0], props.prices[props.prices.length - 1]); + const [displayRange, setDisplayRange] = useState("1d"); const thread = useStreamContext< { messages: Message[]; ui: UIMessage[] }, { MetaType: { ui: UIMessage | undefined } } >(); - const [quantity, setQuantity] = useState(1); - const [stockData, setStockData] = useState({ - symbol: "AAPL", - name: "Apple Inc.", - price: 187.32, - change: 1.24, - changePercent: 0.67, - previousClose: 186.08, - open: 186.75, - dayHigh: 188.45, - dayLow: 186.2, - volume: 54320000, - marketCap: "2.92T", - peRatio: 29.13, - dividend: 0.96, - dividendYield: 0.51, - moving50Day: 182.46, - moving200Day: 175.8, - fiftyTwoWeekHigh: 201.48, - fiftyTwoWeekLow: 143.9, - analystRating: "Buy", - analystCount: 32, - priceTarget: 210.5, - }); + const { + currentPrice, + openPrice, + dollarChange, + percentChange, + highPrice, + lowPrice, + chartData, + change, + } = useMemo(() => { + const prices = getPropsForDisplayRange(displayRange, props.prices); + console.log("prices", prices.length); + const firstPrice = prices[0]; + const lastPrice = prices[prices.length - 1]; - const [priceHistory, setPriceHistory] = useState< - { time: string; price: number }[] - >([]); - const [orderType, setOrderType] = useState<"buy" | "sell">("buy"); - const [showOrder, setShowOrder] = useState(false); - const [activeTab, setActiveTab] = useState<"chart" | "details" | "analysis">( - "chart", - ); - const [isLiveUpdating, setIsLiveUpdating] = useState(false); - const [orderTypeOptions] = useState([ - { value: "market", label: "Market" }, - { value: "limit", label: "Limit" }, - { value: "stop", label: "Stop" }, - ]); - const [selectedOrderTypeOption, setSelectedOrderTypeOption] = - useState("market"); - const [limitPrice, setLimitPrice] = useState(stockData.price.toFixed(2)); - const [showOrderSuccess, setShowOrderSuccess] = useState(false); + const currentPrice = lastPrice?.close; + const openPrice = firstPrice?.open; + const dollarChange = currentPrice - openPrice; + const percentChange = ((currentPrice - openPrice) / openPrice) * 100; - const aiTool = thread.messages.findLast( - (message): message is AIMessage => - message.type === "ai" && !!message.tool_calls?.length, - ); + const highPrice = prices.reduce( + (acc, p) => Math.max(acc, p.high), + -Infinity, + ); + const lowPrice = prices.reduce((acc, p) => Math.min(acc, p.low), Infinity); - const toolCallId = aiTool?.tool_calls?.[0]?.id; - const toolResponse = thread.messages.findLast( - (message): message is ToolMessage => - message.type === "tool" && message.tool_call_id === toolCallId, - ); + const chartData = prices.map((p) => ({ + time: p.time, + price: p.close, + })); - // Simulated price history generation on component mount - useEffect(() => { - generatePriceHistory(); - }, []); - - const generatePriceHistory = () => { - const now = new Date(); - const history = []; - - for (let i = 30; i >= 0; i--) { - const time = new Date(now.getTime() - i * 15 * 60000); // 15-minute intervals - const basePrice = 187.32; - // Make the price movement more interesting with some trends - const trend = Math.sin(i / 5) * 1.5; - const randomFactor = (Math.random() - 0.5) * 1.5; - const price = basePrice + trend + randomFactor; - - history.push({ - time: time.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }), - price: parseFloat(price.toFixed(2)), - }); - } - - setPriceHistory(history); - }; - - // Simulate live price updates - useEffect(() => { - let interval: NodeJS.Timeout; - - if (isLiveUpdating) { - interval = setInterval(() => { - setStockData((prev) => { - // Random small price movement - const priceChange = (Math.random() - 0.5) * 0.3; - const newPrice = parseFloat((prev.price + priceChange).toFixed(2)); - - // Update price history - setPriceHistory((history) => { - const now = new Date(); - const newHistory = [...history]; - if (newHistory.length > 30) { - newHistory.shift(); // Remove oldest entry to keep array length consistent - } - newHistory.push({ - time: now.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }), - price: newPrice, - }); - return newHistory; - }); - - return { - ...prev, - price: newPrice, - change: parseFloat((newPrice - prev.previousClose).toFixed(2)), - changePercent: parseFloat( - ( - ((newPrice - prev.previousClose) / prev.previousClose) * - 100 - ).toFixed(2), - ), - dayHigh: Math.max(prev.dayHigh, newPrice), - dayLow: Math.min(prev.dayLow, newPrice), - }; - }); - }, 3000); - } - - return () => { - if (interval) clearInterval(interval); - }; - }, [isLiveUpdating]); - - const handleQuantityChange = (e: React.ChangeEvent) => { - const value = parseInt(e.target.value); - if (value > 0 && value <= 1000) { - setQuantity(value); - } - }; - - const handleLimitPriceChange = (e: React.ChangeEvent) => { - const value = e.target.value; - if (/^\d*\.?\d*$/.test(value)) { - setLimitPrice(value); - } - }; - - const toggleLiveUpdates = () => { - setIsLiveUpdating((prev) => !prev); - }; - - const handleOrder = () => { - // Submit the order through the thread - if (toolCallId) { - const orderDetails = { - action: orderType, - quantity, - symbol: stockData.symbol, - orderType: selectedOrderTypeOption, - ...(selectedOrderTypeOption !== "market" && { - limitPrice: parseFloat(limitPrice), - }), - }; - - thread.submit({ - messages: [ - { - type: "tool", - tool_call_id: toolCallId, - name: "stockbroker", - content: JSON.stringify(orderDetails), - }, - { - type: "human", - content: `${orderType === "buy" ? "Bought" : "Sold"} ${quantity} shares of ${stockData.symbol} at ${ - selectedOrderTypeOption === "market" - ? formatCurrency(stockData.price) - : formatCurrency(parseFloat(limitPrice)) - }`, - }, - ], - }); - - setShowOrderSuccess(true); - setTimeout(() => { - setShowOrderSuccess(false); - setShowOrder(false); - }, 2000); - } - }; - - const formatCurrency = (value: number) => { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(value); - }; - - const formatPercent = (value: number) => { - return `${value > 0 ? "+" : ""}${value.toFixed(2)}%`; - }; - - const getMinMax = () => { - if (priceHistory.length === 0) return { min: 0, max: 0 }; - const prices = priceHistory.map((item) => item.price); + const change: "up" | "down" = dollarChange > 0 ? "up" : "down"; return { - min: Math.min(...prices), - max: Math.max(...prices), + currentPrice, + openPrice, + dollarChange, + percentChange, + highPrice, + lowPrice, + chartData, + change, }; - }; + }, [props.prices, displayRange]); - // Generate a simple price chart with the last price as endpoint - const generateChartPath = useCallback(() => { - if (priceHistory.length === 0) return ""; - - const { min, max } = getMinMax(); - const range = max - min || 1; - const width = 100; // % of container width - const height = 100; // % of container height - - const points = priceHistory.map((point, i) => { - const x = (i / (priceHistory.length - 1)) * width; - const y = height - ((point.price - min) / range) * height; - return `${x},${y}`; - }); - - return `M ${points.join(" L ")}`; - }, [priceHistory]); - - const { min, max } = getMinMax(); - const range = max - min || 1; - const chartPath = generateChartPath(); - - if (toolResponse) return
Responded
; return ( -
-
= 0 ? "bg-gradient-to-r from-green-600 to-green-500" : "bg-gradient-to-r from-red-600 to-red-500"}`} - > -
-
- {props.logo && ( - Logo +
+

{ticker}

+

${currentPrice}

+
+
+

+ ${dollarChange.toFixed(2)} (${percentChange.toFixed(2)}%) +

+
+
+
+

Open

+

High

+

Low

+
+
+

${openPrice}

+

${highPrice}

+

${lowPrice}

+
+
+ + + + + format(value, "h:mm a")} + /> + `${value.toFixed(2)}`} + /> + format(value, "h:mm a")} /> - )} -

- {stockData.symbol}{" "} - - {stockData.name} - -

-
-
- - {formatCurrency(stockData.price)} - -
= 0 ? "bg-green-700/50" : "bg-red-700/50"} backdrop-blur-sm border ${stockData.change >= 0 ? "border-green-400/30" : "border-red-400/30"}`} - > - {stockData.change >= 0 ? "▲" : "▼"}{" "} - {formatCurrency(Math.abs(stockData.change))} ( - {formatPercent(stockData.changePercent)}) -
-
-
-
- {props.instruction} - -
-
- -
-
- - - -
-
- -
- {activeTab === "chart" && ( - <> -
-
-
- Price Chart -
- - - - -
-
- -
-
- {/* Chart grid lines */} -
- {[0, 1, 2, 3].map((line) => ( -
- ))} -
- - {/* SVG Line Chart */} - - {/* Chart path */} - = 0 ? "#10b981" : "#ef4444"} - strokeWidth="1.5" - fill="none" - strokeLinecap="round" - /> - - {/* Gradient area under the chart */} - = 0 - ? "url(#greenGradient)" - : "url(#redGradient)" - } - fillOpacity="0.2" - /> - - {/* Gradient definitions */} - - - - - - - - - - - - - {/* Price labels on Y-axis */} -
- {formatCurrency(max)} - {formatCurrency(min + range * 0.66)} - {formatCurrency(min + range * 0.33)} - {formatCurrency(min)} -
-
-
- -
-
- - {priceHistory[0]?.time || "9:30 AM"} -
-
- Vol: {stockData.volume.toLocaleString()} -
-
- - {priceHistory[priceHistory.length - 1]?.time || "4:00 PM"} -
-
-
-
- - {/* Key Stock Information */} -
-
-

Day Range

-
-
-
= 0 ? "bg-green-500" : "bg-red-500"}`} - style={{ - width: `${((stockData.price - stockData.dayLow) / (stockData.dayHigh - stockData.dayLow)) * 100}%`, - }} - >
-
-
- {formatCurrency(stockData.dayLow)} - {formatCurrency(stockData.dayHigh)} -
-
-
-
-

- 52-Week Range -

-
-
-
-
-
- {formatCurrency(stockData.fiftyTwoWeekLow)} - {formatCurrency(stockData.fiftyTwoWeekHigh)} -
-
-
-
- - )} - - {activeTab === "details" && ( -
-
-
-

Open

-

{formatCurrency(stockData.open)}

-
-
-

- Previous Close -

-

- {formatCurrency(stockData.previousClose)} -

-
-
-

- Market Cap -

-

{stockData.marketCap}

-
-
-

Volume

-

- {stockData.volume.toLocaleString()} -

-
-
-

- P/E Ratio -

-

{stockData.peRatio}

-
-
-

- Dividend Yield -

-

{stockData.dividendYield}%

-
-
-

- 50-Day Avg -

-

- {formatCurrency(stockData.moving50Day)} -

-
-
-

- 200-Day Avg -

-

- {formatCurrency(stockData.moving200Day)} -

-
-
- -
-

- Company Overview -

-

- {stockData.name} is a leading technology company that designs, - manufactures, and markets consumer electronics, computer - software, and online services. The company has a strong global - presence and is known for its innovation in the industry. -

-
-
- )} - - {activeTab === "analysis" && ( -
-
-
-

Analyst Consensus

-
- {stockData.analystRating} -
-
- -
-
Strong Buy
-
-
-
-
-
-
65%
-
-
-
Buy
-
-
-
-
-
-
20%
-
-
-
Hold
-
-
-
-
-
-
10%
-
-
-
Sell
-
-
-
-
-
-
5%
-
- -
- Based on {stockData.analystCount} analyst ratings -
-
- -
-
-

Price Target

- - {formatCurrency(stockData.priceTarget)} - -
- -
-
-
- - + - {( - (stockData.priceTarget / stockData.price - 1) * - 100 - ).toFixed(2)} - % - {" "} - Upside -
-
-
-
-
-
- Current: {formatCurrency(stockData.price)} - Target: {formatCurrency(stockData.priceTarget)} -
-
-
- -
-

Recent News

-
-
-

- {stockData.name} Reports Strong Quarterly Earnings -

-

2 days ago

-
-
-

- New Product Launch Expected Next Month -

-

5 days ago

-
-
-
-
- )} - - {/* Order Interface */} - {!showOrder && !showOrderSuccess ? ( -
- - -
- ) : showOrderSuccess ? ( -
- - - - Order submitted successfully! -
- ) : ( -
-
-

- {orderType === "buy" ? "Buy" : "Sell"} {stockData.symbol} -

- -
- -
-
- {orderTypeOptions.map((option) => ( - - ))} -
- -
-
- -
- - - -
-
- - {selectedOrderTypeOption !== "market" && ( -
- -
- - $ - - -
-
- )} -
- - {selectedOrderTypeOption !== "market" && ( -
-
-
- - - -
-
-

- {selectedOrderTypeOption === "limit" - ? `Your ${orderType} order will execute only at ${formatCurrency(parseFloat(limitPrice))} or better.` - : `Your ${orderType} order will execute when the price reaches ${formatCurrency(parseFloat(limitPrice))}.`} -

-
-
-
- )} - -
-
- Market Price - - {formatCurrency(stockData.price)} - -
- -
- - {orderType === "buy" ? "Cost" : "Credit"} ({quantity}{" "} - {quantity === 1 ? "share" : "shares"}) - - {formatCurrency(stockData.price * quantity)} -
- -
- Commission - $0.00 -
- -
- Estimated Total - - {formatCurrency(stockData.price * quantity)} - -
-
-
- -
- - -
-
- )} -
+ } + /> + + +
); } diff --git a/package.json b/package.json index d7a0de5..1f10fa0 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "react-markdown": "^10.0.1", "react-router-dom": "^6.17.0", "react-syntax-highlighter": "^15.5.0", + "recharts": "^2.15.1", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", @@ -70,6 +71,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "dotenv": "^16.4.7", "eslint": "^9.19.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.18", @@ -80,5 +82,8 @@ "typescript-eslint": "^8.22.0", "vite": "^6.1.0" }, + "overrides": { + "react-is": "^19.0.0-rc-69d4b800-20241021" + }, "packageManager": "pnpm@10.5.1+sha512.c424c076bd25c1a5b188c37bb1ca56cc1e136fbf530d98bcb3289982a08fd25527b8c9c4ec113be5e3393c39af04521dd647bcf1d0801eaf8ac6a7b14da313af" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d61321d..910fdff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,9 @@ importers: react-syntax-highlighter: specifier: ^15.5.0 version: 15.6.1(react@19.0.0) + recharts: + specifier: ^2.15.1 + version: 2.15.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) rehype-katex: specifier: ^7.0.1 version: 7.0.1 @@ -166,6 +169,9 @@ importers: autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.5.3) + dotenv: + specifier: ^16.4.7 + version: 16.4.7 eslint: specifier: ^9.19.0 version: 9.21.0(jiti@2.4.2) @@ -1850,6 +1856,60 @@ packages: integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==, } + "@types/d3-array@3.2.1": + resolution: + { + integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==, + } + + "@types/d3-color@3.1.3": + resolution: + { + integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==, + } + + "@types/d3-ease@3.0.2": + resolution: + { + integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==, + } + + "@types/d3-interpolate@3.0.4": + resolution: + { + integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==, + } + + "@types/d3-path@3.1.1": + resolution: + { + integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==, + } + + "@types/d3-scale@4.0.9": + resolution: + { + integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==, + } + + "@types/d3-shape@3.1.7": + resolution: + { + integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==, + } + + "@types/d3-time@3.0.4": + resolution: + { + integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==, + } + + "@types/d3-timer@3.0.2": + resolution: + { + integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==, + } + "@types/debug@4.1.12": resolution: { @@ -2499,6 +2559,83 @@ packages: integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==, } + d3-array@3.2.4: + resolution: + { + integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==, + } + engines: { node: ">=12" } + + d3-color@3.1.0: + resolution: + { + integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==, + } + engines: { node: ">=12" } + + d3-ease@3.0.1: + resolution: + { + integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==, + } + engines: { node: ">=12" } + + d3-format@3.1.0: + resolution: + { + integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==, + } + engines: { node: ">=12" } + + d3-interpolate@3.0.1: + resolution: + { + integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==, + } + engines: { node: ">=12" } + + d3-path@3.1.0: + resolution: + { + integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==, + } + engines: { node: ">=12" } + + d3-scale@4.0.2: + resolution: + { + integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==, + } + engines: { node: ">=12" } + + d3-shape@3.2.0: + resolution: + { + integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==, + } + engines: { node: ">=12" } + + d3-time-format@4.1.0: + resolution: + { + integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==, + } + engines: { node: ">=12" } + + d3-time@3.1.0: + resolution: + { + integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==, + } + engines: { node: ">=12" } + + d3-timer@3.0.1: + resolution: + { + integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==, + } + engines: { node: ">=12" } + date-fns@4.1.0: resolution: { @@ -2524,6 +2661,12 @@ packages: } engines: { node: ">=0.10.0" } + decimal.js-light@2.5.1: + resolution: + { + integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==, + } + decode-named-character-reference@1.0.2: resolution: { @@ -2602,6 +2745,12 @@ packages: integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==, } + dom-helpers@5.2.1: + resolution: + { + integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==, + } + dotenv@16.4.7: resolution: { @@ -2898,6 +3047,13 @@ packages: integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, } + fast-equals@5.2.2: + resolution: + { + integrity: sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==, + } + engines: { node: ">=6.0.0" } + fast-glob@3.3.3: resolution: { @@ -3354,6 +3510,13 @@ packages: integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==, } + internmap@2.0.3: + resolution: + { + integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==, + } + engines: { node: ">=12" } + is-alphabetical@1.0.4: resolution: { @@ -3743,6 +3906,12 @@ packages: integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==, } + lodash@4.17.21: + resolution: + { + integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==, + } + logform@2.7.0: resolution: { @@ -3756,6 +3925,13 @@ packages: integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==, } + loose-envify@1.4.0: + resolution: + { + integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==, + } + hasBin: true + lowlight@1.20.0: resolution: { @@ -4215,6 +4391,13 @@ packages: } engines: { node: ">=18" } + object-assign@4.1.1: + resolution: + { + integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, + } + engines: { node: ">=0.10.0" } + once@1.4.0: resolution: { @@ -4491,6 +4674,12 @@ packages: } engines: { node: ">=6" } + prop-types@15.8.1: + resolution: + { + integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==, + } + property-information@5.6.0: resolution: { @@ -4530,6 +4719,18 @@ packages: peerDependencies: react: ^19.0.0 + react-is@16.13.1: + resolution: + { + integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==, + } + + react-is@18.3.1: + resolution: + { + integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==, + } + react-markdown@10.0.1: resolution: { @@ -4600,6 +4801,15 @@ packages: peerDependencies: react: ">=16.8" + react-smooth@4.0.4: + resolution: + { + integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==, + } + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-style-singleton@2.2.3: resolution: { @@ -4630,6 +4840,15 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-transition-group@4.4.5: + resolution: + { + integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==, + } + peerDependencies: + react: ">=16.6.0" + react-dom: ">=16.6.0" + react@19.0.0: resolution: { @@ -4651,6 +4870,22 @@ packages: } engines: { node: ">= 14.18.0" } + recharts-scale@0.4.5: + resolution: + { + integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==, + } + + recharts@2.15.1: + resolution: + { + integrity: sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==, + } + engines: { node: ">=14" } + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + refractor@3.6.0: resolution: { @@ -5003,6 +5238,12 @@ packages: integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==, } + tiny-invariant@1.3.3: + resolution: + { + integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==, + } + to-regex-range@5.0.1: resolution: { @@ -5303,6 +5544,12 @@ packages: integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==, } + victory-vendor@36.9.2: + resolution: + { + integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==, + } + vite@6.2.0: resolution: { @@ -6512,6 +6759,30 @@ snapshots: dependencies: "@babel/types": 7.26.9 + "@types/d3-array@3.2.1": {} + + "@types/d3-color@3.1.3": {} + + "@types/d3-ease@3.0.2": {} + + "@types/d3-interpolate@3.0.4": + dependencies: + "@types/d3-color": 3.1.3 + + "@types/d3-path@3.1.1": {} + + "@types/d3-scale@4.0.9": + dependencies: + "@types/d3-time": 3.0.4 + + "@types/d3-shape@3.1.7": + dependencies: + "@types/d3-path": 3.1.1 + + "@types/d3-time@3.0.4": {} + + "@types/d3-timer@3.0.2": {} + "@types/debug@4.1.12": dependencies: "@types/ms": 2.1.0 @@ -6880,6 +7151,44 @@ snapshots: csstype@3.1.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.0: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + date-fns@4.1.0: {} debug@4.4.0: @@ -6888,6 +7197,8 @@ snapshots: decamelize@1.2.0: {} + decimal.js-light@2.5.1: {} + decode-named-character-reference@1.0.2: dependencies: character-entities: 2.0.2 @@ -6917,6 +7228,11 @@ snapshots: dependencies: dequal: 2.0.3 + dom-helpers@5.2.1: + dependencies: + "@babel/runtime": 7.26.9 + csstype: 3.1.3 + dotenv@16.4.7: {} dunder-proto@1.0.1: @@ -7154,6 +7470,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@5.2.2: {} + fast-glob@3.3.3: dependencies: "@nodelib/fs.stat": 2.0.5 @@ -7444,6 +7762,8 @@ snapshots: inline-style-parser@0.2.4: {} + internmap@2.0.3: {} + is-alphabetical@1.0.4: {} is-alphabetical@2.0.1: {} @@ -7623,6 +7943,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.17.21: {} + logform@2.7.0: dependencies: "@colors/colors": 1.6.0 @@ -7634,6 +7956,10 @@ snapshots: longest-streak@3.1.0: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lowlight@1.20.0: dependencies: fault: 1.0.4 @@ -8083,6 +8409,8 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 + object-assign@4.1.1: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -8257,6 +8585,12 @@ snapshots: prismjs@1.29.0: {} + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + property-information@5.6.0: dependencies: xtend: 4.0.2 @@ -8277,6 +8611,10 @@ snapshots: react: 19.0.0 scheduler: 0.25.0 + react-is@16.13.1: {} + + react-is@18.3.1: {} + react-markdown@10.0.1(@types/react@19.0.10)(react@19.0.0): dependencies: "@types/hast": 3.0.4 @@ -8346,6 +8684,14 @@ snapshots: "@remix-run/router": 1.23.0 react: 19.0.0 + react-smooth@4.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + fast-equals: 5.2.2 + prop-types: 15.8.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-transition-group: 4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react-style-singleton@2.2.3(@types/react@19.0.10)(react@19.0.0): dependencies: get-nonce: 1.0.1 @@ -8373,6 +8719,15 @@ snapshots: transitivePeerDependencies: - "@types/react" + react-transition-group@4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + "@babel/runtime": 7.26.9 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react@19.0.0: {} readable-stream@3.6.2: @@ -8383,6 +8738,23 @@ snapshots: readdirp@4.1.2: {} + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.17.21 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + refractor@3.6.0: dependencies: hastscript: 6.0.0 @@ -8607,6 +8979,8 @@ snapshots: text-hex@1.0.0: {} + tiny-invariant@1.3.3: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -8778,6 +9152,23 @@ snapshots: "@types/unist": 3.0.3 vfile-message: 4.0.2 + victory-vendor@36.9.2: + dependencies: + "@types/d3-array": 3.2.1 + "@types/d3-ease": 3.0.2 + "@types/d3-interpolate": 3.0.4 + "@types/d3-scale": 4.0.9 + "@types/d3-shape": 3.1.7 + "@types/d3-time": 3.0.4 + "@types/d3-timer": 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@6.2.0(@types/node@22.13.5)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.3)(yaml@2.7.0): dependencies: esbuild: 0.25.0 diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..a9a2c52 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,75 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx new file mode 100644 index 0000000..86503e7 --- /dev/null +++ b/src/components/ui/chart.tsx @@ -0,0 +1,353 @@ +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; + +import { cn } from "@/lib/utils"; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"]; +}) { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + + {children} + +
+
+ ); +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( +