-
Notifications
You must be signed in to change notification settings - Fork 297
feat: Allow date range selection in charts #3085
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
fhqvst
merged 7 commits into
main
from
fhqvst/ui-187-allow-date-range-selection-in-charts
Oct 7, 2025
+925
−333
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
cd07cf1
Move some files around
fhqvst 3498c55
feat: Add resolution/lookback support to charts
fhqvst 23bc086
add tests
fhqvst 670254f
add feature flag
fhqvst 1b23216
update query
fhqvst 11c89c5
PR feedback
fhqvst dde6add
PR feedback
fhqvst File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Resolution, number> = { | ||
"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]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
export { ChartPageLoading as default } from "../../../../components/PriceFeed/chart-page"; | ||
export { ChartPageLoading as default } from "../../../../components/PriceFeed/Chart/chart-page"; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; |
File renamed without changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
90 changes: 90 additions & 0 deletions
90
apps/insights/src/components/PriceFeed/Chart/chart-toolbar.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
}, | ||
); | ||
fhqvst marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}, | ||
[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 ( | ||
<> | ||
<Select | ||
label="Resolution" | ||
hideLabel={true} | ||
options={RESOLUTIONS.map((resolution) => ({ | ||
id: resolution, | ||
label: resolution, | ||
}))} | ||
selectedKey={resolution} | ||
onSelectionChange={handleResolutionChanged} | ||
size="sm" | ||
variant="outline" | ||
/> | ||
<SingleToggleGroup | ||
selectedKey={quickSelectWindow} | ||
onSelectionChange={handleQuickSelectWindowChange} | ||
rounded | ||
items={QUICK_SELECT_WINDOWS.map((quickSelectWindow) => ({ | ||
id: quickSelectWindow, | ||
children: quickSelectWindow, | ||
}))} | ||
/> | ||
</> | ||
); | ||
}; | ||
|
||
function isQuickSelectWindow(value: Key): value is QuickSelectWindow { | ||
return QUICK_SELECT_WINDOWS.includes(value as QuickSelectWindow); | ||
} |
8 changes: 6 additions & 2 deletions
8
...rc/components/PriceFeed/chart.module.scss → ...ponents/PriceFeed/Chart/chart.module.scss
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,16 @@ | ||
@use "@pythnetwork/component-library/theme"; | ||
|
||
.chart { | ||
--border-light: #{theme.pallette-color("stone", 300)}; | ||
--border-dark: #{theme.pallette-color("steel", 600)}; | ||
--chart-background-light: #{theme.pallette-color("white")}; | ||
--chart-background-dark: #{theme.pallette-color("steel", 950)}; | ||
--border-light: #{theme.pallette-color("stone", 100)}; | ||
--border-dark: #{theme.pallette-color("steel", 900)}; | ||
--muted-light: #{theme.pallette-color("stone", 700)}; | ||
--muted-dark: #{theme.pallette-color("steel", 300)}; | ||
--chart-series-primary-light: #{theme.pallette-color("violet", 500)}; | ||
--chart-series-primary-dark: #{theme.pallette-color("violet", 400)}; | ||
--chart-series-neutral-light: #{theme.pallette-color("stone", 500)}; | ||
--chart-series-neutral-dark: #{theme.pallette-color("steel", 300)}; | ||
--chart-series-muted-light: #{theme.pallette-color("violet", 100)}; | ||
--chart-series-muted-dark: #{theme.pallette-color("violet", 950)}; | ||
} |
156 changes: 156 additions & 0 deletions
156
apps/insights/src/components/PriceFeed/Chart/chart.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,156 @@ | ||
import type { LineData, UTCTimestamp } from "lightweight-charts"; | ||
|
||
import { mergeData, startOfResolution } from "./chart"; | ||
|
||
describe("mergeData", () => { | ||
it("merges two arrays with no overlap", () => { | ||
const a: LineData[] = [ | ||
{ time: 1000 as UTCTimestamp, value: 10 }, | ||
{ time: 2000 as UTCTimestamp, value: 20 }, | ||
]; | ||
const b: LineData[] = [ | ||
{ time: 3000 as UTCTimestamp, value: 30 }, | ||
{ time: 4000 as UTCTimestamp, value: 40 }, | ||
]; | ||
|
||
const result = mergeData(a, b); | ||
|
||
expect(result).toEqual([ | ||
{ time: 1000, value: 10 }, | ||
{ time: 2000, value: 20 }, | ||
{ time: 3000, value: 30 }, | ||
{ time: 4000, value: 40 }, | ||
]); | ||
}); | ||
|
||
it("deduplicates by time, keeping the second value", () => { | ||
const a: LineData[] = [ | ||
{ time: 1000 as UTCTimestamp, value: 10 }, | ||
{ time: 2000 as UTCTimestamp, value: 20 }, | ||
]; | ||
const b: LineData[] = [ | ||
{ time: 2000 as UTCTimestamp, value: 99 }, | ||
{ time: 3000 as UTCTimestamp, value: 30 }, | ||
]; | ||
|
||
const result = mergeData(a, b); | ||
|
||
expect(result).toEqual([ | ||
{ time: 1000, value: 10 }, | ||
{ time: 2000, value: 99 }, // second value overwrites first | ||
{ time: 3000, value: 30 }, | ||
]); | ||
}); | ||
|
||
it("sorts the merged array by time", () => { | ||
const a: LineData[] = [ | ||
{ time: 3000 as UTCTimestamp, value: 30 }, | ||
{ time: 1000 as UTCTimestamp, value: 10 }, | ||
]; | ||
const b: LineData[] = [ | ||
{ time: 4000 as UTCTimestamp, value: 40 }, | ||
{ time: 2000 as UTCTimestamp, value: 20 }, | ||
]; | ||
|
||
const result = mergeData(a, b); | ||
|
||
expect(result).toEqual([ | ||
{ time: 1000, value: 10 }, | ||
{ time: 2000, value: 20 }, | ||
{ time: 3000, value: 30 }, | ||
{ time: 4000, value: 40 }, | ||
]); | ||
}); | ||
|
||
it("handles empty first array", () => { | ||
const a: LineData[] = []; | ||
const b: LineData[] = [ | ||
{ time: 1000 as UTCTimestamp, value: 10 }, | ||
{ time: 2000 as UTCTimestamp, value: 20 }, | ||
]; | ||
|
||
const result = mergeData(a, b); | ||
|
||
expect(result).toEqual([ | ||
{ time: 1000, value: 10 }, | ||
{ time: 2000, value: 20 }, | ||
]); | ||
}); | ||
|
||
it("handles empty second array", () => { | ||
const a: LineData[] = [ | ||
{ time: 1000 as UTCTimestamp, value: 10 }, | ||
{ time: 2000 as UTCTimestamp, value: 20 }, | ||
]; | ||
const b: LineData[] = []; | ||
|
||
const result = mergeData(a, b); | ||
|
||
expect(result).toEqual([ | ||
{ time: 1000, value: 10 }, | ||
{ time: 2000, value: 20 }, | ||
]); | ||
}); | ||
|
||
it("handles both arrays empty", () => { | ||
const a: LineData[] = []; | ||
const b: LineData[] = []; | ||
|
||
const result = mergeData(a, b); | ||
|
||
expect(result).toEqual([]); | ||
}); | ||
}); | ||
|
||
describe("startOfResolution", () => { | ||
it("returns start of second for 1s resolution", () => { | ||
const date = new Date("2024-01-15T10:30:45.678Z"); | ||
const result = startOfResolution(date, "1s"); | ||
|
||
expect(result).toBe(new Date("2024-01-15T10:30:45.000Z").getTime()); | ||
}); | ||
|
||
it("returns start of minute for 1m resolution", () => { | ||
const date = new Date("2024-01-15T10:30:45.678Z"); | ||
const result = startOfResolution(date, "1m"); | ||
|
||
expect(result).toBe(new Date("2024-01-15T10:30:00.000Z").getTime()); | ||
}); | ||
|
||
it("returns start of minute for 5m resolution", () => { | ||
const date = new Date("2024-01-15T10:30:45.678Z"); | ||
const result = startOfResolution(date, "5m"); | ||
|
||
expect(result).toBe(new Date("2024-01-15T10:30:00.000Z").getTime()); | ||
}); | ||
|
||
it("returns start of hour for 1H resolution", () => { | ||
const date = new Date("2024-01-15T10:30:45.678Z"); | ||
const result = startOfResolution(date, "1H"); | ||
|
||
expect(result).toBe(new Date("2024-01-15T10:00:00.000Z").getTime()); | ||
}); | ||
|
||
it("returns start of day for 1D resolution", () => { | ||
const date = new Date("2024-01-15T10:30:45.678Z"); | ||
const result = startOfResolution(date, "1D"); | ||
|
||
expect(result).toBe(new Date("2024-01-15T00:00:00.000Z").getTime()); | ||
}); | ||
|
||
it("throws error for unknown resolution", () => { | ||
const date = new Date("2024-01-15T10:30:45.678Z"); | ||
|
||
expect(() => startOfResolution(date, "15m")).toThrow( | ||
"Unknown resolution: 15m", | ||
); | ||
}); | ||
|
||
it("throws error for invalid resolution", () => { | ||
const date = new Date("2024-01-15T10:30:45.678Z"); | ||
|
||
expect(() => startOfResolution(date, "invalid")).toThrow( | ||
"Unknown resolution: invalid", | ||
); | ||
}); | ||
}); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.