From 886471b259ee1b9cf04d6daf4f904065532d05fc Mon Sep 17 00:00:00 2001 From: Harshil Date: Mon, 21 Sep 2026 13:23:43 +0530 Subject: [PATCH 1/4] fix: standardize candle interval format and improve loading state handling --- backend/src/controllers/market.ts | 10 +++--- backend/src/schema/exchange.ts | 2 +- .../src/components/chart/chart-toolbar.tsx | 2 +- frontend/src/components/market/trade-form.tsx | 16 ++++++++-- frontend/src/components/ui/dropdown-menu.tsx | 31 +++++++++++-------- frontend/src/content/docs/markets.md | 4 +-- frontend/src/hooks/use-candles.ts | 10 +++--- frontend/src/hooks/use-trades.ts | 1 + frontend/src/pages/trade.tsx | 17 +++++----- 9 files changed, 54 insertions(+), 39 deletions(-) diff --git a/backend/src/controllers/market.ts b/backend/src/controllers/market.ts index 701b495..6e8d17b 100644 --- a/backend/src/controllers/market.ts +++ b/backend/src/controllers/market.ts @@ -16,10 +16,10 @@ import { marketStore } from "../store/market"; import { sendApiError, sendEngineError } from "../utils/apiError"; const intervalMap = { - "1M": "1 minute", - "5M": "5 minutes", - "15M": "15 minutes", - "30M": "30 minutes", + "1m": "1 minute", + "5m": "5 minutes", + "15m": "15 minutes", + "30m": "30 minutes", "1H": "1 hour", "4H": "4 hours", "1D": "1 day", @@ -107,7 +107,7 @@ export async function getCandles(req: Request, res: Response) { } const { symbol } = parsedParams.data; - const { interval = "15M" } = parsedQueries.data; + const { interval = "15m" } = parsedQueries.data; const bucket = intervalMap[interval]; if (!marketStore.has(symbol)) { sendApiError(res, 404, "MARKET_NOT_FOUND", `Market ${symbol} was not found`); diff --git a/backend/src/schema/exchange.ts b/backend/src/schema/exchange.ts index 051ad21..9d8e72c 100644 --- a/backend/src/schema/exchange.ts +++ b/backend/src/schema/exchange.ts @@ -34,7 +34,7 @@ export const orderBodySchema = z.discriminatedUnion("type", [ ]); export const candleQuerySchema = z.object({ - interval: z.enum(["1M", "5M", "15M", "30M", "1H", "4H", "1D"]).optional(), + interval: z.enum(["1m", "5m", "15m", "30m", "1H", "4H", "1D"]).optional(), }); export const orderQuerySchema = z.object({ diff --git a/frontend/src/components/chart/chart-toolbar.tsx b/frontend/src/components/chart/chart-toolbar.tsx index 7fe2a32..43028af 100644 --- a/frontend/src/components/chart/chart-toolbar.tsx +++ b/frontend/src/components/chart/chart-toolbar.tsx @@ -38,7 +38,7 @@ export function ChartToolbar({ - + onIntervalChange(value as CandleInterval)} diff --git a/frontend/src/components/market/trade-form.tsx b/frontend/src/components/market/trade-form.tsx index 0a90a51..8c099ba 100644 --- a/frontend/src/components/market/trade-form.tsx +++ b/frontend/src/components/market/trade-form.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { toast } from "sonner"; import { useAuth } from "@/context/AuthContext"; @@ -40,6 +40,7 @@ export function TradeForm({ symbol, loading, lastPrice, bestBid, bestAsk }: Trad const [price, setPrice] = useState(""); const [quantity, setQuantity] = useState(""); const [percent, setPercent] = useState(0); + const initializedPriceSymbol = useRef(null); const { authenticated, verified, loading: authLoading } = useAuth(); const createOrder = useCreateOrder(); const submitting = createOrder.isPending; @@ -88,10 +89,19 @@ export function TradeForm({ symbol, loading, lastPrice, bestBid, bestAsk }: Trad (side === "BUY" && !isPositive(effectivePrice)); useEffect(() => { - setPrice(formatPrice(lastPrice ?? "", pricePrecision)); + initializedPriceSymbol.current = null; + setPrice(""); setQuantity(""); setPercent(0); - }, [symbol, loading]); + }, [symbol]); + + useEffect(() => { + if (initializedPriceSymbol.current === symbol) return; + const initialPrice = editablePrice(Number(lastPrice), pricePrecision); + if (!initialPrice) return; + setPrice(initialPrice); + initializedPriceSymbol.current = symbol; + }, [lastPrice, pricePrecision, symbol]); const setTradeSide = (nextSide: "BUY" | "SELL") => { setSide(nextSide); diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx index bd03f21..a0a63fa 100644 --- a/frontend/src/components/ui/dropdown-menu.tsx +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -23,20 +23,25 @@ function DropdownMenuTrigger({ function DropdownMenuContent({ className, sideOffset = 4, + portalled = true, ...props -}: React.ComponentProps) { - return ( - - - +}: React.ComponentProps & { portalled?: boolean }) { + const content = ( + + ); + + return portalled ? ( + {content} + ) : ( + content ); } diff --git a/frontend/src/content/docs/markets.md b/frontend/src/content/docs/markets.md index 8e7d8a1..6d3f30f 100644 --- a/frontend/src/content/docs/markets.md +++ b/frontend/src/content/docs/markets.md @@ -56,9 +56,9 @@ Before the first trade, price and volume values can be `null`. ## Candles -`GET /markets/:symbol/candles?interval=15M` returns historical OHLCV candles in chronological order. +`GET /markets/:symbol/candles?interval=15m` returns historical OHLCV candles in chronological order. -Supported intervals: `1M`, `5M`, `15M`, `30M`, `1H`, `4H`, and `1D`. The default is `15M`. +Supported intervals: `1m`, `5m`, `15m`, `30m`, `1H`, `4H`, and `1D`. The default is `15m`. ```bash curl "https://api.paperdrill.dev/v1/markets/SOL_USD/candles?interval=1H" diff --git a/frontend/src/hooks/use-candles.ts b/frontend/src/hooks/use-candles.ts index 0a66806..20177db 100644 --- a/frontend/src/hooks/use-candles.ts +++ b/frontend/src/hooks/use-candles.ts @@ -3,14 +3,14 @@ import type { Candle } from "@/types"; import { api } from "@/lib/api"; import { wsManager } from "@/lib/ws"; -export const CANDLE_INTERVALS = ["1M", "5M", "15M", "30M", "1H", "4H", "1D"] as const; +export const CANDLE_INTERVALS = ["1m", "5m", "15m", "30m", "1H", "4H", "1D"] as const; export type CandleInterval = (typeof CANDLE_INTERVALS)[number]; export const CANDLE_INTERVAL_MS: Record = { - "1M": 60 * 1000, - "5M": 5 * 60 * 1000, - "15M": 15 * 60 * 1000, - "30M": 30 * 60 * 1000, + "1m": 60 * 1000, + "5m": 5 * 60 * 1000, + "15m": 15 * 60 * 1000, + "30m": 30 * 60 * 1000, "1H": 60 * 60 * 1000, "4H": 4 * 60 * 60 * 1000, "1D": 24 * 60 * 60 * 1000, diff --git a/frontend/src/hooks/use-trades.ts b/frontend/src/hooks/use-trades.ts index e29a314..d4a71b7 100644 --- a/frontend/src/hooks/use-trades.ts +++ b/frontend/src/hooks/use-trades.ts @@ -10,6 +10,7 @@ export function useTrades(symbol: string, limit = 50) { useEffect(() => { let active = true; + setLoading(true); setTrades([]); setError(null); diff --git a/frontend/src/pages/trade.tsx b/frontend/src/pages/trade.tsx index b863eb7..eaaf141 100644 --- a/frontend/src/pages/trade.tsx +++ b/frontend/src/pages/trade.tsx @@ -24,7 +24,6 @@ export function TradePage() { const { orderbook, loading: orderbookLoading, bestBid, bestAsk } = useOrderbook(symbol); const { trades, loading: tradesLoading } = useTrades(symbol); const ticker = tickers[symbol] ?? null; - const isDataLoading = authLoading || orderbookLoading || tradesLoading || tickerLoading; const bookTradesTabs = (
@@ -55,7 +54,7 @@ export function TradePage() { return ( -
+
@@ -68,12 +67,12 @@ export function TradePage() { ) : ( - + )}
@@ -86,18 +85,18 @@ export function TradePage() { compact bids={orderbook.bids} asks={orderbook.asks} - loading={isDataLoading} + loading={orderbookLoading} symbol={symbol} lastPrice={ticker?.lastPrice} /> ) : ( - + )}
- {isDataLoading ? ( + {tickerLoading && !ticker ? (
@@ -124,7 +123,7 @@ export function TradePage() {
- +
From 8efa7eb1e7448f137ecb26d237e1c136fb1e31d4 Mon Sep 17 00:00:00 2001 From: Harshil Date: Mon, 21 Sep 2026 16:53:34 +0530 Subject: [PATCH 2/4] feat: implement chart style selection --- .../src/components/chart/chart-toolbar.tsx | 65 +++++++++++++- frontend/src/components/chart/chart-utils.ts | 4 + frontend/src/components/chart/chart.tsx | 6 +- frontend/src/components/chart/price-chart.tsx | 90 ++++++++++++++----- frontend/src/components/navbar.tsx | 2 +- frontend/styles/globals.css | 9 +- 6 files changed, 150 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/chart/chart-toolbar.tsx b/frontend/src/components/chart/chart-toolbar.tsx index 43028af..3fd539f 100644 --- a/frontend/src/components/chart/chart-toolbar.tsx +++ b/frontend/src/components/chart/chart-toolbar.tsx @@ -1,4 +1,13 @@ -import { ChartNoAxesColumn, ChevronDown, LocateFixed, Maximize2, RotateCcw } from "lucide-react"; +import { + ChartArea, + ChartCandlestick, + ChartLine, + ChartNoAxesColumn, + ChevronDown, + LocateFixed, + Maximize2, + RotateCcw, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -8,11 +17,20 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { CANDLE_INTERVALS, type CandleInterval } from "../../hooks/use-candles"; +import type { ChartStyle } from "./chart-utils"; + +const CHART_STYLES = [ + { value: "candlestick", label: "Candle", Icon: ChartCandlestick }, + { value: "line", label: "Line", Icon: ChartLine }, + { value: "area", label: "Area", Icon: ChartArea }, +] as const; type ChartToolbarProps = { interval: CandleInterval; + chartStyle: ChartStyle; showVolume: boolean; onIntervalChange: (interval: CandleInterval) => void; + onChartStyleChange: (style: ChartStyle) => void; onToggleVolume: () => void; onGoLive: () => void; onReset: () => void; @@ -21,19 +39,30 @@ type ChartToolbarProps = { export function ChartToolbar({ interval, + chartStyle, showVolume, onIntervalChange, + onChartStyleChange, onToggleVolume, onGoLive, onReset, onFullscreen, }: ChartToolbarProps) { + const activeChartStyle = + CHART_STYLES.find((option) => option.value === chartStyle) ?? CHART_STYLES[0]; + const ActiveChartIcon = activeChartStyle.Icon; + return (
- @@ -54,6 +83,38 @@ export function ChartToolbar({
+ + + + + + onChartStyleChange(value as ChartStyle)} + > + {CHART_STYLES.map(({ value, label, Icon }) => ( + + + {label} + + ))} + + + + +
+ + +
+ + ) : ( + + )} Don't see the email in your inbox or spam folder?