Skip to content

Commit 4a6c768

Browse files
authored
Merge pull request #3085 from pyth-network/fhqvst/ui-187-allow-date-range-selection-in-charts
2 parents f0a9834 + dde6add commit 4a6c768

File tree

12 files changed

+925
-333
lines changed

12 files changed

+925
-333
lines changed
Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,84 @@
11
import type { NextRequest } from "next/server";
2+
import { z } from "zod";
23

34
import { getHistoricalPrices } from "../../services/clickhouse";
45

6+
const queryParamsSchema = z.object({
7+
symbol: z.string().transform(decodeURIComponent),
8+
publisher: z
9+
.string()
10+
.nullable()
11+
.transform((value) => value ?? undefined),
12+
from: z.string().transform(Number),
13+
to: z.string().transform(Number),
14+
resolution: z.enum(["1s", "1m", "5m", "1H", "1D"]).transform((value) => {
15+
switch (value) {
16+
case "1s": {
17+
return "1 SECOND";
18+
}
19+
case "1m": {
20+
return "1 MINUTE";
21+
}
22+
case "5m": {
23+
return "5 MINUTE";
24+
}
25+
case "1H": {
26+
return "1 HOUR";
27+
}
28+
case "1D": {
29+
return "1 DAY";
30+
}
31+
}
32+
}),
33+
});
34+
535
export async function GET(req: NextRequest) {
6-
const symbol = req.nextUrl.searchParams.get("symbol");
7-
const until = req.nextUrl.searchParams.get("until");
8-
if (symbol && until) {
9-
const res = await getHistoricalPrices({
10-
symbol: decodeURIComponent(symbol),
11-
until,
36+
const parsed = queryParamsSchema.safeParse(
37+
Object.fromEntries(
38+
Object.keys(queryParamsSchema.shape).map((key) => [
39+
key,
40+
req.nextUrl.searchParams.get(key),
41+
]),
42+
),
43+
);
44+
if (!parsed.success) {
45+
return new Response(`Invalid params: ${parsed.error.message}`, {
46+
status: 400,
47+
});
48+
}
49+
50+
const { symbol, publisher, from, to, resolution } = parsed.data;
51+
52+
if (getNumDataPoints(to, from, resolution) > MAX_DATA_POINTS) {
53+
return new Response("Unsupported resolution for date range", {
54+
status: 400,
1255
});
13-
return Response.json(res);
14-
} else {
15-
return new Response("Must provide `symbol` and `until`", { status: 400 });
1656
}
57+
58+
const res = await getHistoricalPrices({
59+
symbol,
60+
from,
61+
to,
62+
publisher,
63+
resolution,
64+
});
65+
66+
return Response.json(res);
1767
}
68+
69+
const MAX_DATA_POINTS = 3000;
70+
71+
type Resolution = "1 SECOND" | "1 MINUTE" | "5 MINUTE" | "1 HOUR" | "1 DAY";
72+
const ONE_MINUTE_IN_SECONDS = 60;
73+
const ONE_HOUR_IN_SECONDS = 60 * ONE_MINUTE_IN_SECONDS;
74+
75+
const SECONDS_IN_ONE_PERIOD: Record<Resolution, number> = {
76+
"1 SECOND": 1,
77+
"1 MINUTE": ONE_MINUTE_IN_SECONDS,
78+
"5 MINUTE": 5 * ONE_MINUTE_IN_SECONDS,
79+
"1 HOUR": ONE_HOUR_IN_SECONDS,
80+
"1 DAY": 24 * ONE_HOUR_IN_SECONDS,
81+
};
82+
83+
const getNumDataPoints = (from: number, to: number, resolution: Resolution) =>
84+
(to - from) / SECONDS_IN_ONE_PERIOD[resolution];
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { ChartPageLoading as default } from "../../../../components/PriceFeed/chart-page";
1+
export { ChartPageLoading as default } from "../../../../components/PriceFeed/Chart/chart-page";
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
export { ChartPage as default } from "../../../../components/PriceFeed/chart-page";
1+
export { ChartPage as default } from "../../../../components/PriceFeed/Chart/chart-page";
22

33
export const revalidate = 3600;
File renamed without changes.

apps/insights/src/components/PriceFeed/chart-page.tsx renamed to apps/insights/src/components/PriceFeed/Chart/chart-page.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { Spinner } from "@pythnetwork/component-library/Spinner";
55

66
import { Chart } from "./chart";
77
import styles from "./chart-page.module.scss";
8-
import { getFeed } from "./get-feed";
8+
import { getFeed } from "../get-feed";
9+
import { ChartToolbar } from "./chart-toolbar";
910

1011
type Props = {
1112
params: Promise<{
@@ -26,7 +27,7 @@ type ChartPageImplProps =
2627
});
2728

2829
const ChartPageImpl = (props: ChartPageImplProps) => (
29-
<Card title="Chart" className={styles.chartCard}>
30+
<Card title="Chart" className={styles.chartCard} toolbar={<ChartToolbar />}>
3031
<div className={styles.chart}>
3132
{props.isLoading ? (
3233
<div className={styles.spinnerContainer}>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"use client";
2+
import { Select } from "@pythnetwork/component-library/Select";
3+
import { SingleToggleGroup } from "@pythnetwork/component-library/SingleToggleGroup";
4+
import { useLogger } from "@pythnetwork/component-library/useLogger";
5+
import { useCallback } from "react";
6+
import type { Key } from "react-aria";
7+
8+
import type { QuickSelectWindow, Resolution } from "./use-chart-toolbar";
9+
import {
10+
QUICK_SELECT_WINDOW_TO_RESOLUTION,
11+
QUICK_SELECT_WINDOWS,
12+
RESOLUTION_TO_QUICK_SELECT_WINDOW,
13+
RESOLUTIONS,
14+
useChartQuickSelectWindow,
15+
useChartResolution,
16+
} from "./use-chart-toolbar";
17+
18+
const ENABLE_RESOLUTION_SELECTOR = false;
19+
20+
export const ChartToolbar = () => {
21+
const logger = useLogger();
22+
const [quickSelectWindow, setQuickSelectWindow] = useChartQuickSelectWindow();
23+
const [resolution, setResolution] = useChartResolution();
24+
25+
const handleResolutionChanged = useCallback(
26+
(resolution: Resolution) => {
27+
setResolution(resolution).catch((error: unknown) => {
28+
logger.error("Failed to update resolution", error);
29+
});
30+
setQuickSelectWindow(RESOLUTION_TO_QUICK_SELECT_WINDOW[resolution]).catch(
31+
(error: unknown) => {
32+
logger.error("Failed to update quick select window", error);
33+
},
34+
);
35+
},
36+
[logger, setResolution, setQuickSelectWindow],
37+
);
38+
39+
const handleQuickSelectWindowChange = useCallback(
40+
(quickSelectWindow: Key) => {
41+
if (!isQuickSelectWindow(quickSelectWindow)) {
42+
throw new TypeError("Invalid quick select window");
43+
}
44+
setQuickSelectWindow(quickSelectWindow).catch((error: unknown) => {
45+
logger.error("Failed to update quick select window", error);
46+
});
47+
setResolution(QUICK_SELECT_WINDOW_TO_RESOLUTION[quickSelectWindow]).catch(
48+
(error: unknown) => {
49+
logger.error("Failed to update resolution", error);
50+
},
51+
);
52+
},
53+
[logger, setQuickSelectWindow, setResolution],
54+
);
55+
56+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
57+
if (!ENABLE_RESOLUTION_SELECTOR) {
58+
return;
59+
}
60+
61+
return (
62+
<>
63+
<Select
64+
label="Resolution"
65+
hideLabel={true}
66+
options={RESOLUTIONS.map((resolution) => ({
67+
id: resolution,
68+
label: resolution,
69+
}))}
70+
selectedKey={resolution}
71+
onSelectionChange={handleResolutionChanged}
72+
size="sm"
73+
variant="outline"
74+
/>
75+
<SingleToggleGroup
76+
selectedKey={quickSelectWindow}
77+
onSelectionChange={handleQuickSelectWindowChange}
78+
rounded
79+
items={QUICK_SELECT_WINDOWS.map((quickSelectWindow) => ({
80+
id: quickSelectWindow,
81+
children: quickSelectWindow,
82+
}))}
83+
/>
84+
</>
85+
);
86+
};
87+
88+
function isQuickSelectWindow(value: Key): value is QuickSelectWindow {
89+
return QUICK_SELECT_WINDOWS.includes(value as QuickSelectWindow);
90+
}
Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
@use "@pythnetwork/component-library/theme";
22

33
.chart {
4-
--border-light: #{theme.pallette-color("stone", 300)};
5-
--border-dark: #{theme.pallette-color("steel", 600)};
4+
--chart-background-light: #{theme.pallette-color("white")};
5+
--chart-background-dark: #{theme.pallette-color("steel", 950)};
6+
--border-light: #{theme.pallette-color("stone", 100)};
7+
--border-dark: #{theme.pallette-color("steel", 900)};
68
--muted-light: #{theme.pallette-color("stone", 700)};
79
--muted-dark: #{theme.pallette-color("steel", 300)};
810
--chart-series-primary-light: #{theme.pallette-color("violet", 500)};
911
--chart-series-primary-dark: #{theme.pallette-color("violet", 400)};
1012
--chart-series-neutral-light: #{theme.pallette-color("stone", 500)};
1113
--chart-series-neutral-dark: #{theme.pallette-color("steel", 300)};
14+
--chart-series-muted-light: #{theme.pallette-color("violet", 100)};
15+
--chart-series-muted-dark: #{theme.pallette-color("violet", 950)};
1216
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import type { LineData, UTCTimestamp } from "lightweight-charts";
2+
3+
import { mergeData, startOfResolution } from "./chart";
4+
5+
describe("mergeData", () => {
6+
it("merges two arrays with no overlap", () => {
7+
const a: LineData[] = [
8+
{ time: 1000 as UTCTimestamp, value: 10 },
9+
{ time: 2000 as UTCTimestamp, value: 20 },
10+
];
11+
const b: LineData[] = [
12+
{ time: 3000 as UTCTimestamp, value: 30 },
13+
{ time: 4000 as UTCTimestamp, value: 40 },
14+
];
15+
16+
const result = mergeData(a, b);
17+
18+
expect(result).toEqual([
19+
{ time: 1000, value: 10 },
20+
{ time: 2000, value: 20 },
21+
{ time: 3000, value: 30 },
22+
{ time: 4000, value: 40 },
23+
]);
24+
});
25+
26+
it("deduplicates by time, keeping the second value", () => {
27+
const a: LineData[] = [
28+
{ time: 1000 as UTCTimestamp, value: 10 },
29+
{ time: 2000 as UTCTimestamp, value: 20 },
30+
];
31+
const b: LineData[] = [
32+
{ time: 2000 as UTCTimestamp, value: 99 },
33+
{ time: 3000 as UTCTimestamp, value: 30 },
34+
];
35+
36+
const result = mergeData(a, b);
37+
38+
expect(result).toEqual([
39+
{ time: 1000, value: 10 },
40+
{ time: 2000, value: 99 }, // second value overwrites first
41+
{ time: 3000, value: 30 },
42+
]);
43+
});
44+
45+
it("sorts the merged array by time", () => {
46+
const a: LineData[] = [
47+
{ time: 3000 as UTCTimestamp, value: 30 },
48+
{ time: 1000 as UTCTimestamp, value: 10 },
49+
];
50+
const b: LineData[] = [
51+
{ time: 4000 as UTCTimestamp, value: 40 },
52+
{ time: 2000 as UTCTimestamp, value: 20 },
53+
];
54+
55+
const result = mergeData(a, b);
56+
57+
expect(result).toEqual([
58+
{ time: 1000, value: 10 },
59+
{ time: 2000, value: 20 },
60+
{ time: 3000, value: 30 },
61+
{ time: 4000, value: 40 },
62+
]);
63+
});
64+
65+
it("handles empty first array", () => {
66+
const a: LineData[] = [];
67+
const b: LineData[] = [
68+
{ time: 1000 as UTCTimestamp, value: 10 },
69+
{ time: 2000 as UTCTimestamp, value: 20 },
70+
];
71+
72+
const result = mergeData(a, b);
73+
74+
expect(result).toEqual([
75+
{ time: 1000, value: 10 },
76+
{ time: 2000, value: 20 },
77+
]);
78+
});
79+
80+
it("handles empty second array", () => {
81+
const a: LineData[] = [
82+
{ time: 1000 as UTCTimestamp, value: 10 },
83+
{ time: 2000 as UTCTimestamp, value: 20 },
84+
];
85+
const b: LineData[] = [];
86+
87+
const result = mergeData(a, b);
88+
89+
expect(result).toEqual([
90+
{ time: 1000, value: 10 },
91+
{ time: 2000, value: 20 },
92+
]);
93+
});
94+
95+
it("handles both arrays empty", () => {
96+
const a: LineData[] = [];
97+
const b: LineData[] = [];
98+
99+
const result = mergeData(a, b);
100+
101+
expect(result).toEqual([]);
102+
});
103+
});
104+
105+
describe("startOfResolution", () => {
106+
it("returns start of second for 1s resolution", () => {
107+
const date = new Date("2024-01-15T10:30:45.678Z");
108+
const result = startOfResolution(date, "1s");
109+
110+
expect(result).toBe(new Date("2024-01-15T10:30:45.000Z").getTime());
111+
});
112+
113+
it("returns start of minute for 1m resolution", () => {
114+
const date = new Date("2024-01-15T10:30:45.678Z");
115+
const result = startOfResolution(date, "1m");
116+
117+
expect(result).toBe(new Date("2024-01-15T10:30:00.000Z").getTime());
118+
});
119+
120+
it("returns start of minute for 5m resolution", () => {
121+
const date = new Date("2024-01-15T10:30:45.678Z");
122+
const result = startOfResolution(date, "5m");
123+
124+
expect(result).toBe(new Date("2024-01-15T10:30:00.000Z").getTime());
125+
});
126+
127+
it("returns start of hour for 1H resolution", () => {
128+
const date = new Date("2024-01-15T10:30:45.678Z");
129+
const result = startOfResolution(date, "1H");
130+
131+
expect(result).toBe(new Date("2024-01-15T10:00:00.000Z").getTime());
132+
});
133+
134+
it("returns start of day for 1D resolution", () => {
135+
const date = new Date("2024-01-15T10:30:45.678Z");
136+
const result = startOfResolution(date, "1D");
137+
138+
expect(result).toBe(new Date("2024-01-15T00:00:00.000Z").getTime());
139+
});
140+
141+
it("throws error for unknown resolution", () => {
142+
const date = new Date("2024-01-15T10:30:45.678Z");
143+
144+
expect(() => startOfResolution(date, "15m")).toThrow(
145+
"Unknown resolution: 15m",
146+
);
147+
});
148+
149+
it("throws error for invalid resolution", () => {
150+
const date = new Date("2024-01-15T10:30:45.678Z");
151+
152+
expect(() => startOfResolution(date, "invalid")).toThrow(
153+
"Unknown resolution: invalid",
154+
);
155+
});
156+
});

0 commit comments

Comments
 (0)