Skip to content

Commit

Permalink
Merge pull request #2300 from pyth-network/cprussin/insights-landing
Browse files Browse the repository at this point in the history
feat(insights): add overview page
  • Loading branch information
cprussin authored Jan 25, 2025
2 parents 52c2def + 870e4f4 commit c99f0f0
Show file tree
Hide file tree
Showing 17 changed files with 1,087 additions and 180 deletions.
172 changes: 31 additions & 141 deletions apps/insights/src/components/ChangePercent/index.tsx
Original file line number Diff line number Diff line change
@@ -1,157 +1,47 @@
"use client";
import type { ComponentProps } from "react";

import { type ComponentProps, createContext, use } from "react";
import { useNumberFormatter } from "react-aria";
import { z } from "zod";

import { StateType, useData } from "../../use-data";
import { ChangeValue } from "../ChangeValue";
import { useLivePrice } from "../LivePrices";

const ONE_SECOND_IN_MS = 1000;
const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS;
const ONE_HOUR_IN_MS = 60 * ONE_MINUTE_IN_MS;
const REFRESH_YESTERDAYS_PRICES_INTERVAL = ONE_HOUR_IN_MS;

type Props = Omit<ComponentProps<typeof YesterdaysPricesContext>, "value"> & {
feeds: Record<string, string>;
};

const YesterdaysPricesContext = createContext<
undefined | ReturnType<typeof useData<Map<string, number>>>
>(undefined);

export const YesterdaysPricesProvider = ({ feeds, ...props }: Props) => {
const state = useData(
["yesterdaysPrices", Object.keys(feeds)],
() => getYesterdaysPrices(feeds),
{
refreshInterval: REFRESH_YESTERDAYS_PRICES_INTERVAL,
},
);

return <YesterdaysPricesContext value={state} {...props} />;
};

const getYesterdaysPrices = async (
feeds: Props["feeds"],
): Promise<Map<string, number>> => {
const url = new URL("/yesterdays-prices", window.location.origin);
for (const symbol of Object.keys(feeds)) {
url.searchParams.append("symbols", symbol);
}
const response = await fetch(url);
const data = yesterdaysPricesSchema.parse(await response.json());
return new Map(
Object.entries(data).map(([symbol, value]) => [feeds[symbol] ?? "", value]),
);
};

const yesterdaysPricesSchema = z.record(z.string(), z.number());

const useYesterdaysPrices = () => {
const state = use(YesterdaysPricesContext);

if (state) {
return state;
} else {
throw new YesterdaysPricesNotInitializedError();
}
};

type ChangePercentProps = {
className?: string | undefined;
feedKey: string;
};

export const ChangePercent = ({ feedKey, className }: ChangePercentProps) => {
const yesterdaysPriceState = useYesterdaysPrices();

switch (yesterdaysPriceState.type) {
case StateType.Error:
case StateType.Loading:
case StateType.NotLoaded: {
return <ChangeValue className={className} isLoading />;
}

case StateType.Loaded: {
const yesterdaysPrice = yesterdaysPriceState.data.get(feedKey);
return yesterdaysPrice === undefined ? (
<ChangeValue className={className} isLoading />
) : (
<ChangePercentLoaded
className={className}
priorPrice={yesterdaysPrice}
feedKey={feedKey}
/>
);
}
}
};
import { FormattedNumber } from "../FormattedNumber";

type ChangePercentLoadedProps = {
type PriceDifferenceProps = Omit<
ComponentProps<typeof ChangeValue>,
"children" | "direction" | "isLoading"
> & {
className?: string | undefined;
priorPrice: number;
feedKey: string;
};

const ChangePercentLoaded = ({
className,
priorPrice,
feedKey,
}: ChangePercentLoadedProps) => {
const { current } = useLivePrice(feedKey);

return current === undefined ? (
<ChangeValue className={className} isLoading />
) : (
<PriceDifference
className={className}
currentPrice={current.aggregate.price}
priorPrice={priorPrice}
/>
} & (
| { isLoading: true }
| {
isLoading?: false;
currentValue: number;
previousValue: number;
}
);
};

type PriceDifferenceProps = {
className?: string | undefined;
currentPrice: number;
priorPrice: number;
};

const PriceDifference = ({
className,
currentPrice,
priorPrice,
}: PriceDifferenceProps) => {
const numberFormatter = useNumberFormatter({ maximumFractionDigits: 2 });
const direction = getDirection(currentPrice, priorPrice);

return (
<ChangeValue direction={direction} className={className}>
{numberFormatter.format(
(100 * Math.abs(currentPrice - priorPrice)) / priorPrice,
)}
export const ChangePercent = ({ ...props }: PriceDifferenceProps) =>
props.isLoading ? (
<ChangeValue {...props} />
) : (
<ChangeValue
direction={getDirection(props.currentValue, props.previousValue)}
{...props}
>
<FormattedNumber
maximumFractionDigits={2}
value={
(100 * Math.abs(props.currentValue - props.previousValue)) /
props.previousValue
}
/>
%
</ChangeValue>
);
};

const getDirection = (currentPrice: number, priorPrice: number) => {
if (currentPrice < priorPrice) {
const getDirection = (currentValue: number, previousValue: number) => {
if (currentValue < previousValue) {
return "down";
} else if (currentPrice > priorPrice) {
} else if (currentValue > previousValue) {
return "up";
} else {
return "flat";
}
};

class YesterdaysPricesNotInitializedError extends Error {
constructor() {
super(
"This component must be contained within a <YesterdaysPricesProvider>",
);
this.name = "YesterdaysPricesNotInitializedError";
}
}
13 changes: 13 additions & 0 deletions apps/insights/src/components/ChartCard/index.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@use "@pythnetwork/component-library/theme";

.chartCard {
.line {
color: theme.color("chart", "series", "neutral");
}

&[data-variant="primary"] {
.line {
color: theme.color("chart", "series", "primary");
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { StatCard } from "@pythnetwork/component-library/StatCard";
import clsx from "clsx";
import dynamic from "next/dynamic";
import {
type ElementType,
Expand All @@ -14,6 +15,8 @@ import {
import { ResponsiveContainer, Tooltip, Line, XAxis, YAxis } from "recharts";
import type { CategoricalChartState } from "recharts/types/chart/types";

import styles from "./index.module.scss";

const LineChart = dynamic(
() => import("recharts").then((recharts) => recharts.LineChart),
{
Expand All @@ -25,7 +28,6 @@ const CHART_HEIGHT = 36;

type OwnProps<T> = {
chartClassName?: string | undefined;
lineClassName?: string | undefined;
data: Point<T>[];
};

Expand All @@ -43,8 +45,8 @@ type Props<T extends ElementType, U> = Omit<
OwnProps<U>;

export const ChartCard = <T extends ElementType, U>({
className,
chartClassName,
lineClassName,
data,
stat,
miniStat,
Expand Down Expand Up @@ -77,6 +79,7 @@ export const ChartCard = <T extends ElementType, U>({

return (
<StatCard
className={clsx(className, styles.chartCard)}
{...props}
stat={selectedPoint ? (selectedPoint.displayY ?? selectedPoint.y) : stat}
miniStat={selectedDate ?? miniStat}
Expand All @@ -96,7 +99,7 @@ export const ChartCard = <T extends ElementType, U>({
<Line
type="monotone"
dataKey="y"
className={lineClassName ?? ""}
className={styles.line ?? ""}
stroke="currentColor"
dot={false}
/>
Expand Down
54 changes: 54 additions & 0 deletions apps/insights/src/components/Overview/index.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,59 @@

color: theme.color("heading");
font-weight: theme.font-weight("semibold");
margin-bottom: theme.spacing(6);
}

.stats {
display: flex;
flex-flow: row nowrap;
align-items: stretch;
gap: theme.spacing(6);

& > * {
flex: 1 1 0px;
width: 0;
}

.publishersChart,
.priceFeedsChart {
& svg {
cursor: pointer;
}
}
}

.overviewMainContent {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: theme.spacing(40);
align-items: center;
padding: theme.spacing(18) 0;

.headline {
@include theme.text("3xl", "medium");

color: theme.color("heading");
line-height: 125%;
margin-top: theme.spacing(8);
margin-bottom: theme.spacing(4);
}

.message {
@include theme.text("base", "normal");

color: theme.color("heading");
line-height: 150%;
}

.tabList {
margin: theme.spacing(12) 0;
}

.buttons {
display: flex;
flex-flow: row nowrap;
gap: theme.spacing(3);
}
}
}
Loading

0 comments on commit c99f0f0

Please sign in to comment.