Skip to content
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

stats: add time to fill card #215

Merged
merged 1 commit into from
Dec 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions app/api/stats/time-to-fill/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import invariant from "tiny-invariant"

export const runtime = "edge"

const TIME_TO_FILL_PATH = "time-to-fill"

export type TimeToFillResponse = {
data?: {
estimatedMs: number
}
error?: string
}

interface BotServerResponse {
data: number // milliseconds
}

export async function GET(request: Request) {
try {
const BOT_SERVER_URL = process.env.BOT_SERVER_URL
invariant(BOT_SERVER_URL, "BOT_SERVER_URL is not set")
const BOT_SERVER_API_KEY = process.env.BOT_SERVER_API_KEY
invariant(BOT_SERVER_API_KEY, "BOT_SERVER_API_KEY is not set")

const { searchParams } = new URL(request.url)
const amount = searchParams.get("amount")
const baseTicker = searchParams.get("baseTicker")

if (!amount || !baseTicker) {
return Response.json(
{
error: "Invalid amount or baseTicker parameter",
} satisfies TimeToFillResponse,
{ status: 400 },
)
}

const url = new URL(`${BOT_SERVER_URL}/${TIME_TO_FILL_PATH}`)
url.searchParams.set("amount", amount)
url.searchParams.set("baseTicker", baseTicker)

const res = await fetch(url, {
headers: { "x-api-key": BOT_SERVER_API_KEY },
})

if (!res.ok) {
throw new Error(
`Bot server responded with status ${res.status}: ${res.statusText}`,
)
}

const data = (await res.json()) as BotServerResponse

return Response.json(
{ data: { estimatedMs: data.data } } satisfies TimeToFillResponse,
{ status: 200 },
)
} catch (error) {
console.error("[TimeToFill API] Error:", {
name: error instanceof Error ? error.name : "Unknown",
message: error instanceof Error ? error.message : "Unknown error",
error,
})

return Response.json(
{
error:
error instanceof Error
? error.message
: "Failed to fetch time to fill",
} satisfies TimeToFillResponse,
{ status: 500 },
)
}
}
154 changes: 154 additions & 0 deletions app/stats/charts/time-to-fill-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import React, { useMemo, useRef } from "react"

import NumberFlow, { NumberFlowGroup } from "@number-flow/react"
import { useDebounceValue } from "usehooks-ts"

import { TokenSelect } from "@/app/stats/charts/token-select"
import { useTimeToFill } from "@/app/stats/hooks/use-time-to-fill"

import { Slider } from "@/components/animated-slider"
import { Skeleton } from "@/components/ui/skeleton"

import { useOrderValue } from "@/hooks/use-order-value"
import { cn } from "@/lib/utils"

interface TimeDisplayValues {
value: number
prefix: string
suffix: string
}

export function TimeToFillCard() {
const [selectedAmount, setSelectedAmount] = React.useState<number>(10000)
const [selectedTicker, setSelectedToken] = React.useState("WETH")
const [isSell, setIsSell] = React.useState(true)

const { priceInBase, priceInUsd } = useOrderValue({
amount: selectedAmount.toString(),
base: selectedTicker,
isQuoteCurrency: true,
isSell,
})
console.log("🚀 ~ TimeToFillCard ~ priceInBase:", priceInBase)
const [debouncedUsdValue] = useDebounceValue(priceInUsd, 500)

const { data: timeToFillMs, isLoading } = useTimeToFill({
amount: debouncedUsdValue,
baseTicker: selectedTicker,
})

const lastValidValue = useRef<TimeDisplayValues>({
value: 0,
prefix: "",
suffix: "",
})

const displayValues = useMemo<TimeDisplayValues>(() => {
// Ensure NumberFlow doesn't flash 0 when
// 1. user sets amount to zero or
// 2. new estimate is loading
if (!timeToFillMs && lastValidValue.current.value !== 0) {
return lastValidValue.current
}

if (!timeToFillMs) {
return {
value: 0,
prefix: "",
suffix: "",
}
}

const timeInMinutes = timeToFillMs / (1000 * 60)
let result: TimeDisplayValues

if (timeInMinutes >= 60) {
const timeInHours = timeInMinutes / 60
const roundedHours = Number(timeInHours.toFixed(1))
result = {
value: roundedHours,
prefix: "in ~",
suffix: roundedHours === 1 ? " hour" : " hours",
}
} else {
const roundedMinutes = Math.round(timeInMinutes)
result = {
value: roundedMinutes < 1 ? 1 : roundedMinutes,
prefix: `in ${roundedMinutes < 1 ? "< " : "~"}`,
suffix: roundedMinutes === 1 ? " minute" : " minutes",
}
}

lastValidValue.current = result
return result
}, [timeToFillMs])

return (
<NumberFlowGroup>
<div className="grid grid-cols-1 place-items-center items-center gap-16 text-2xl leading-none md:grid-cols-[1fr_auto_1fr_2fr_auto] md:gap-4 lg:pr-32">
<div className="relative col-span-1 md:col-span-3">
<div
className={cn(
"absolute inset-0 text-center text-base text-gray-500 transition-all duration-200",
selectedAmount === 0
? "pointer-events-auto opacity-100"
: "pointer-events-none opacity-0",
)}
>
Use the slider to set an amount and see estimated time to fill
</div>
<div
className={cn(
"grid grid-cols-1 gap-4 transition-all duration-200 sm:grid-cols-[1fr_auto_1fr]",
selectedAmount === 0
? "pointer-events-none opacity-0"
: "pointer-events-auto opacity-100",
)}
>
{Number(priceInBase) ? (
<NumberFlow
className="text-center font-serif text-2xl font-bold sm:text-right"
format={{
maximumFractionDigits: 2,
}}
prefix={`${isSell ? "Sell" : "Buy"} `}
value={Number(priceInBase)}
onClick={() => setIsSell((prev) => !prev)}
/>
) : (
<Skeleton className="h-8 w-32" />
)}
<TokenSelect
value={selectedTicker}
onChange={setSelectedToken}
/>
{displayValues.value ? (
<NumberFlow
className="text-center font-serif font-bold sm:text-left"
prefix={`${displayValues.prefix}`}
suffix={displayValues.suffix}
value={displayValues.value}
/>
) : (
<Skeleton className="h-8 w-32" />
)}
</div>
</div>
<div className="col-span-1 w-2/3 md:w-full">
<Slider
max={1000000}
numberFlowClassName="text-right font-serif text-2xl font-bold"
numberFlowFormat={{
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
}}
step={10000}
value={[selectedAmount]}
onValueChange={([value]) => setSelectedAmount(value)}
/>
</div>
</div>
</NumberFlowGroup>
)
}
93 changes: 93 additions & 0 deletions app/stats/charts/token-select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React from "react"

import { Check, ChevronsUpDown } from "lucide-react"

import { TokenIcon } from "@/components/token-icon"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"

import { DISPLAY_TOKENS } from "@/lib/token"
import { cn } from "@/lib/utils"

const tokens = DISPLAY_TOKENS({ hideHidden: true, hideStables: true }).map(
(token) => ({
value: token.ticker,
label: token.ticker,
}),
)

type TokenSelectProps = {
value: string
onChange: (value: string) => void
}

export function TokenSelect({ value, onChange }: TokenSelectProps) {
const [open, setOpen] = React.useState(false)

return (
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<Button
aria-expanded={open}
className="px-2 font-serif text-2xl font-bold"
role="combobox"
type="button"
variant="ghost"
>
<TokenIcon
className="mr-2"
size={22}
ticker={value}
/>
{value
? tokens.find((token) => token.value === value)?.label
: "Select token"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search token..." />
<CommandList>
<CommandEmpty>No token found.</CommandEmpty>
<CommandGroup>
{tokens.map((token) => (
<CommandItem
key={token.value}
value={token.value}
onSelect={(currentValue) => {
onChange(currentValue === value ? "" : currentValue)
setOpen(false)
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === token.value ? "opacity-100" : "opacity-0",
)}
/>
{token.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
39 changes: 39 additions & 0 deletions app/stats/hooks/use-time-to-fill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useQuery } from "@tanstack/react-query"

import { TimeToFillResponse } from "@/app/api/stats/time-to-fill/route"

interface TimeToFillParams {
amount: string
baseTicker: string
}

export function useTimeToFill({ amount, baseTicker }: TimeToFillParams) {
return useQuery({
queryKey: ["timeToFill", amount, baseTicker],
queryFn: async () => {
const searchParams = new URLSearchParams({
amount,
baseTicker,
})

const response = await fetch(`/api/stats/time-to-fill?${searchParams}`)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || "Failed to fetch time to fill")
}

const { data, error } = (await response.json()) as TimeToFillResponse

if (error) {
throw new Error(error)
}

if (!data) {
throw new Error("No data received")
}

return data.estimatedMs
},
enabled: Boolean(amount && baseTicker),
})
}
Loading
Loading