diff --git a/apps/insights/src/app/historical-prices/route.ts b/apps/insights/src/app/historical-prices/route.ts index b989d7360d..868eb58517 100644 --- a/apps/insights/src/app/historical-prices/route.ts +++ b/apps/insights/src/app/historical-prices/route.ts @@ -1,17 +1,84 @@ import type { NextRequest } from "next/server"; +import { z } from "zod"; import { getHistoricalPrices } from "../../services/clickhouse"; +const queryParamsSchema = z.object({ + symbol: z.string().transform(decodeURIComponent), + publisher: z + .string() + .nullable() + .transform((value) => value ?? undefined), + from: z.string().transform(Number), + to: z.string().transform(Number), + resolution: z.enum(["1s", "1m", "5m", "1H", "1D"]).transform((value) => { + switch (value) { + case "1s": { + return "1 SECOND"; + } + case "1m": { + return "1 MINUTE"; + } + case "5m": { + return "5 MINUTE"; + } + case "1H": { + return "1 HOUR"; + } + case "1D": { + return "1 DAY"; + } + } + }), +}); + export async function GET(req: NextRequest) { - const symbol = req.nextUrl.searchParams.get("symbol"); - const until = req.nextUrl.searchParams.get("until"); - if (symbol && until) { - const res = await getHistoricalPrices({ - symbol: decodeURIComponent(symbol), - until, + const parsed = queryParamsSchema.safeParse( + Object.fromEntries( + Object.keys(queryParamsSchema.shape).map((key) => [ + key, + req.nextUrl.searchParams.get(key), + ]), + ), + ); + if (!parsed.success) { + return new Response(`Invalid params: ${parsed.error.message}`, { + status: 400, + }); + } + + const { symbol, publisher, from, to, resolution } = parsed.data; + + if (getNumDataPoints(to, from, resolution) > MAX_DATA_POINTS) { + return new Response("Unsupported resolution for date range", { + status: 400, }); - return Response.json(res); - } else { - return new Response("Must provide `symbol` and `until`", { status: 400 }); } + + const res = await getHistoricalPrices({ + symbol, + from, + to, + publisher, + resolution, + }); + + return Response.json(res); } + +const MAX_DATA_POINTS = 3000; + +type Resolution = "1 SECOND" | "1 MINUTE" | "5 MINUTE" | "1 HOUR" | "1 DAY"; +const ONE_MINUTE_IN_SECONDS = 60; +const ONE_HOUR_IN_SECONDS = 60 * ONE_MINUTE_IN_SECONDS; + +const SECONDS_IN_ONE_PERIOD: Record = { + "1 SECOND": 1, + "1 MINUTE": ONE_MINUTE_IN_SECONDS, + "5 MINUTE": 5 * ONE_MINUTE_IN_SECONDS, + "1 HOUR": ONE_HOUR_IN_SECONDS, + "1 DAY": 24 * ONE_HOUR_IN_SECONDS, +}; + +const getNumDataPoints = (from: number, to: number, resolution: Resolution) => + (to - from) / SECONDS_IN_ONE_PERIOD[resolution]; diff --git a/apps/insights/src/app/price-feeds/[slug]/(main)/loading.ts b/apps/insights/src/app/price-feeds/[slug]/(main)/loading.ts index a1085ea612..62f0c87e75 100644 --- a/apps/insights/src/app/price-feeds/[slug]/(main)/loading.ts +++ b/apps/insights/src/app/price-feeds/[slug]/(main)/loading.ts @@ -1 +1 @@ -export { ChartPageLoading as default } from "../../../../components/PriceFeed/chart-page"; +export { ChartPageLoading as default } from "../../../../components/PriceFeed/Chart/chart-page"; diff --git a/apps/insights/src/app/price-feeds/[slug]/(main)/page.ts b/apps/insights/src/app/price-feeds/[slug]/(main)/page.ts index 165ad5abcd..10e4cc3c72 100644 --- a/apps/insights/src/app/price-feeds/[slug]/(main)/page.ts +++ b/apps/insights/src/app/price-feeds/[slug]/(main)/page.ts @@ -1,3 +1,3 @@ -export { ChartPage as default } from "../../../../components/PriceFeed/chart-page"; +export { ChartPage as default } from "../../../../components/PriceFeed/Chart/chart-page"; export const revalidate = 3600; diff --git a/apps/insights/src/components/PriceFeed/chart-page.module.scss b/apps/insights/src/components/PriceFeed/Chart/chart-page.module.scss similarity index 100% rename from apps/insights/src/components/PriceFeed/chart-page.module.scss rename to apps/insights/src/components/PriceFeed/Chart/chart-page.module.scss diff --git a/apps/insights/src/components/PriceFeed/chart-page.tsx b/apps/insights/src/components/PriceFeed/Chart/chart-page.tsx similarity index 93% rename from apps/insights/src/components/PriceFeed/chart-page.tsx rename to apps/insights/src/components/PriceFeed/Chart/chart-page.tsx index aa2797827d..a60cfe9506 100644 --- a/apps/insights/src/components/PriceFeed/chart-page.tsx +++ b/apps/insights/src/components/PriceFeed/Chart/chart-page.tsx @@ -5,7 +5,8 @@ import { Spinner } from "@pythnetwork/component-library/Spinner"; import { Chart } from "./chart"; import styles from "./chart-page.module.scss"; -import { getFeed } from "./get-feed"; +import { getFeed } from "../get-feed"; +import { ChartToolbar } from "./chart-toolbar"; type Props = { params: Promise<{ @@ -26,7 +27,7 @@ type ChartPageImplProps = }); const ChartPageImpl = (props: ChartPageImplProps) => ( - + }>
{props.isLoading ? (
diff --git a/apps/insights/src/components/PriceFeed/Chart/chart-toolbar.tsx b/apps/insights/src/components/PriceFeed/Chart/chart-toolbar.tsx new file mode 100644 index 0000000000..deb7e64efc --- /dev/null +++ b/apps/insights/src/components/PriceFeed/Chart/chart-toolbar.tsx @@ -0,0 +1,90 @@ +"use client"; +import { Select } from "@pythnetwork/component-library/Select"; +import { SingleToggleGroup } from "@pythnetwork/component-library/SingleToggleGroup"; +import { useLogger } from "@pythnetwork/component-library/useLogger"; +import { useCallback } from "react"; +import type { Key } from "react-aria"; + +import type { QuickSelectWindow, Resolution } from "./use-chart-toolbar"; +import { + QUICK_SELECT_WINDOW_TO_RESOLUTION, + QUICK_SELECT_WINDOWS, + RESOLUTION_TO_QUICK_SELECT_WINDOW, + RESOLUTIONS, + useChartQuickSelectWindow, + useChartResolution, +} from "./use-chart-toolbar"; + +const ENABLE_RESOLUTION_SELECTOR = false; + +export const ChartToolbar = () => { + const logger = useLogger(); + const [quickSelectWindow, setQuickSelectWindow] = useChartQuickSelectWindow(); + const [resolution, setResolution] = useChartResolution(); + + const handleResolutionChanged = useCallback( + (resolution: Resolution) => { + setResolution(resolution).catch((error: unknown) => { + logger.error("Failed to update resolution", error); + }); + setQuickSelectWindow(RESOLUTION_TO_QUICK_SELECT_WINDOW[resolution]).catch( + (error: unknown) => { + logger.error("Failed to update quick select window", error); + }, + ); + }, + [logger, setResolution, setQuickSelectWindow], + ); + + const handleQuickSelectWindowChange = useCallback( + (quickSelectWindow: Key) => { + if (!isQuickSelectWindow(quickSelectWindow)) { + throw new TypeError("Invalid quick select window"); + } + setQuickSelectWindow(quickSelectWindow).catch((error: unknown) => { + logger.error("Failed to update quick select window", error); + }); + setResolution(QUICK_SELECT_WINDOW_TO_RESOLUTION[quickSelectWindow]).catch( + (error: unknown) => { + logger.error("Failed to update resolution", error); + }, + ); + }, + [logger, setQuickSelectWindow, setResolution], + ); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!ENABLE_RESOLUTION_SELECTOR) { + return; + } + + return ( + <> +