浏览代码

feat(insights): add evaluation period selector in component drawer

Also fixes UI-11
Connor Prussin 9 月之前
父节点
当前提交
8f40983012

+ 10 - 2
apps/insights/src/app/component-score-history/route.ts

@@ -15,8 +15,14 @@ export const GET = async (req: NextRequest) => {
     ),
   );
   if (parsed.success) {
-    const { cluster, publisherKey, symbol } = parsed.data;
-    const data = await getFeedScoreHistory(cluster, publisherKey, symbol);
+    const { cluster, publisherKey, symbol, from, to } = parsed.data;
+    const data = await getFeedScoreHistory(
+      cluster,
+      publisherKey,
+      symbol,
+      from,
+      to,
+    );
     return Response.json(data);
   } else {
     return new Response(fromError(parsed.error).toString(), {
@@ -29,4 +35,6 @@ const queryParamsSchema = z.object({
   cluster: z.enum(CLUSTER_NAMES).transform((value) => toCluster(value)),
   publisherKey: z.string(),
   symbol: z.string().transform((value) => decodeURIComponent(value)),
+  from: z.string(),
+  to: z.string(),
 });

+ 10 - 2
apps/insights/src/components/LivePrices/index.tsx

@@ -146,7 +146,11 @@ export const LiveValue = <T extends keyof PriceData>({
 }: LiveValueProps<T>) => {
   const { current } = useLivePriceData(feedKey);
 
-  return current?.[field]?.toString() ?? defaultValue;
+  return current !== undefined || defaultValue !== undefined ? (
+    (current?.[field]?.toString() ?? defaultValue)
+  ) : (
+    <Skeleton width={SKELETON_WIDTH} />
+  );
 };
 
 type LiveComponentValueProps<T extends keyof PriceComponent["latest"]> = {
@@ -164,7 +168,11 @@ export const LiveComponentValue = <T extends keyof PriceComponent["latest"]>({
 }: LiveComponentValueProps<T>) => {
   const { current } = useLivePriceComponent(feedKey, publisherKey);
 
-  return current?.latest[field].toString() ?? defaultValue;
+  return current !== undefined || defaultValue !== undefined ? (
+    (current?.latest[field].toString() ?? defaultValue)
+  ) : (
+    <Skeleton width={SKELETON_WIDTH} />
+  );
 };
 
 const isToday = (date: Date) => {

+ 160 - 0
apps/insights/src/components/PriceComponentDrawer/index.module.scss

@@ -17,4 +17,164 @@
     margin: theme.spacing(40) auto;
     font-size: theme.spacing(16);
   }
+
+  .rankingBreakdown {
+    .scoreHistoryChart {
+      grid-column: span 2 / span 2;
+      border-radius: theme.border-radius("2xl");
+      display: flex;
+      flex-flow: column nowrap;
+      gap: theme.spacing(4);
+      background: theme.color("background", "primary");
+      margin-bottom: theme.spacing(2);
+
+      .top {
+        display: flex;
+        flex-flow: row nowrap;
+        justify-content: space-between;
+        align-items: flex-start;
+        margin: theme.spacing(4);
+
+        .left {
+          display: flex;
+          flex-flow: column nowrap;
+          gap: theme.spacing(1);
+
+          .header {
+            color: theme.color("heading");
+
+            @include theme.text("sm", "medium");
+          }
+
+          .subheader {
+            color: theme.color("muted");
+
+            @include theme.text("xs", "normal");
+          }
+        }
+      }
+
+      .chart {
+        border-bottom-left-radius: theme.border-radius("2xl");
+        border-bottom-right-radius: theme.border-radius("2xl");
+        overflow: hidden;
+
+        .score,
+        .uptimeScore,
+        .deviationScore,
+        .stalledScore {
+          transition: opacity 100ms linear;
+          opacity: 0.2;
+        }
+
+        .score {
+          color: theme.color("states", "data", "normal");
+        }
+
+        .uptimeScore {
+          color: theme.color("states", "info", "normal");
+        }
+
+        .deviationScore {
+          color: theme.color("states", "lime", "normal");
+        }
+
+        .stalledScore {
+          color: theme.color("states", "warning", "normal");
+        }
+      }
+
+      &:not([data-focused-score], [data-hovered-score]) {
+        .score,
+        .uptimeScore,
+        .deviationScore,
+        .stalledScore {
+          opacity: 1;
+        }
+      }
+
+      &[data-hovered-score="uptime"],
+      &[data-focused-score="uptime"] {
+        .uptimeScore {
+          opacity: 1;
+        }
+      }
+
+      &[data-hovered-score="deviation"],
+      &[data-focused-score="deviation"] {
+        .deviationScore {
+          opacity: 1;
+        }
+      }
+
+      &[data-hovered-score="stalled"],
+      &[data-focused-score="stalled"] {
+        .stalledScore {
+          opacity: 1;
+        }
+      }
+
+      &[data-hovered-score="final"],
+      &[data-focused-score="final"] {
+        .score {
+          opacity: 1;
+        }
+      }
+    }
+
+    .date {
+      @include theme.text("sm", "normal");
+
+      margin: theme.spacing(2) theme.spacing(4);
+    }
+
+    .scoreCell {
+      vertical-align: top;
+    }
+
+    .metric {
+      display: flex;
+      flex-flow: column nowrap;
+      gap: theme.spacing(2);
+      overflow: hidden;
+
+      .metricName {
+        display: flex;
+        flex-flow: row nowwrap;
+        align-items: center;
+        gap: theme.spacing(2);
+
+        .legend {
+          width: theme.spacing(4);
+          height: theme.spacing(4);
+          fill: none;
+        }
+      }
+
+      .metricDescription {
+        color: theme.color("muted");
+
+        @include theme.text("sm", "normal");
+
+        white-space: normal;
+        line-height: 1.2;
+      }
+
+      &[data-component="uptime"] .legend {
+        stroke: theme.color("states", "info", "normal");
+      }
+
+      &[data-component="deviation"] .legend {
+        stroke: theme.color("states", "lime", "normal");
+      }
+
+      &[data-component="stalled"] .legend {
+        stroke: theme.color("states", "warning", "normal");
+      }
+
+      &[data-component="final"] .legend {
+        stroke: theme.color("states", "data", "normal");
+      }
+    }
+  }
 }

+ 414 - 15
apps/insights/src/components/PriceComponentDrawer/index.tsx

@@ -1,10 +1,27 @@
 import { Button } from "@pythnetwork/component-library/Button";
+import { Card } from "@pythnetwork/component-library/Card";
 import { Drawer } from "@pythnetwork/component-library/Drawer";
+import { Select } from "@pythnetwork/component-library/Select";
 import { Spinner } from "@pythnetwork/component-library/Spinner";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
+import { Table } from "@pythnetwork/component-library/Table";
+import dynamic from "next/dynamic";
 import { useRouter } from "next/navigation";
-import { type ReactNode, useState, useRef, useCallback } from "react";
-import { RouterProvider } from "react-aria";
+import {
+  type ReactNode,
+  Suspense,
+  useState,
+  useRef,
+  useCallback,
+  useMemo,
+} from "react";
+import {
+  RouterProvider,
+  useDateFormatter,
+  useNumberFormatter,
+} from "react-aria";
+import { ResponsiveContainer, Tooltip, Line, XAxis, YAxis } from "recharts";
+import type { CategoricalChartState } from "recharts/types/chart/types";
 import { z } from "zod";
 
 import styles from "./index.module.scss";
@@ -13,9 +30,15 @@ import { Cluster, ClusterToName } from "../../services/pyth";
 import type { Status } from "../../status";
 import { LiveConfidence, LivePrice, LiveComponentValue } from "../LivePrices";
 import { Score } from "../Score";
-import { ScoreHistory as ScoreHistoryComponent } from "../ScoreHistory";
 import { Status as StatusComponent } from "../Status";
 
+const LineChart = dynamic(
+  () => import("recharts").then((recharts) => recharts.LineChart),
+  {
+    ssr: false,
+  },
+);
+
 type Props = {
   onClose: () => void;
   title: ReactNode;
@@ -28,6 +51,7 @@ type Props = {
   status: Status;
   navigateButtonText: string;
   navigateHref: string;
+  firstEvaluation: Date;
 };
 
 export const PriceComponentDrawer = ({
@@ -42,6 +66,7 @@ export const PriceComponentDrawer = ({
   headingExtra,
   navigateButtonText,
   navigateHref,
+  firstEvaluation,
 }: Props) => {
   const goToPriceFeedPageOnClose = useRef<boolean>(false);
   const [isFeedDrawerOpen, setIsFeedDrawerOpen] = useState(true);
@@ -65,8 +90,10 @@ export const PriceComponentDrawer = ({
     goToPriceFeedPageOnClose.current = true;
     setIsFeedDrawerOpen(false);
   }, [setIsFeedDrawerOpen]);
+  const { selectedPeriod, setSelectedPeriod, evaluationPeriods } =
+    useEvaluationPeriods(firstEvaluation);
   const scoreHistoryState = useData(
-    [Cluster.Pythnet, publisherKey, symbol],
+    [Cluster.Pythnet, publisherKey, symbol, selectedPeriod],
     getScoreHistory,
   );
 
@@ -136,16 +163,97 @@ export const PriceComponentDrawer = ({
           stat={rank ?? <></>}
         />
       </div>
-      <ScoreHistory state={scoreHistoryState} />
+      <Card
+        title="Score Breakdown"
+        nonInteractive
+        className={styles.rankingBreakdown}
+        toolbar={
+          <Select
+            size="sm"
+            variant="outline"
+            hideLabel
+            label="Evaluation Period"
+            selectedKey={selectedPeriod.label}
+            onSelectionChange={(label) => {
+              const evaluationPeriod = evaluationPeriods.find(
+                (period) => period.label === label,
+              );
+              if (evaluationPeriod) {
+                setSelectedPeriod(evaluationPeriod);
+              }
+            }}
+            options={evaluationPeriods.map(({ label }) => label)}
+            placement="bottom end"
+          />
+        }
+      >
+        <ScoreHistory state={scoreHistoryState} />
+      </Card>
     </Drawer>
   );
 };
 
-const ScoreHistory = ({
-  state,
-}: {
+const useEvaluationPeriods = (firstEvaluation: Date) => {
+  const dateFormatter = useDateFormatter({
+    dateStyle: "medium",
+    timeZone: "UTC",
+  });
+
+  const evaluationPeriods = useMemo<
+    [EvaluationPeriod, ...EvaluationPeriod[]]
+  >(() => {
+    const evaluations: EvaluationPeriod[] = [];
+    const today = new Date();
+    const cursor = new Date(firstEvaluation);
+    cursor.setHours(0);
+    cursor.setMinutes(0);
+    cursor.setSeconds(0);
+    cursor.setMilliseconds(0);
+    // Evaluations are between the 16th of one month and the 15th of the next
+    // month, so move the cursor to the first evaluation boundary before the
+    // first evaluation.
+    if (cursor.getDate() < 16) {
+      cursor.setMonth(cursor.getMonth() - 1);
+    }
+    cursor.setDate(16);
+    while (cursor < today) {
+      const start = new Date(cursor);
+      cursor.setMonth(cursor.getMonth() + 1);
+      const end = new Date(cursor);
+      end.setDate(15);
+      evaluations.unshift({
+        start,
+        end,
+        label: `${dateFormatter.format(start)} to ${end < today ? dateFormatter.format(end) : "Now"}`,
+      });
+    }
+
+    // This ensures that typescript understands that this array is nonempty
+    const [head, ...tail] = evaluations;
+    if (!head) {
+      throw new Error("Failed invariant: No first evaluation!");
+    }
+    return [head, ...tail];
+  }, [firstEvaluation, dateFormatter]);
+
+  const [selectedPeriod, setSelectedPeriod] = useState<EvaluationPeriod>(
+    evaluationPeriods[0],
+  );
+
+  return { selectedPeriod, setSelectedPeriod, evaluationPeriods };
+};
+
+type EvaluationPeriod = {
+  start: Date;
+  end: Date;
+  label: string;
+};
+
+type ScoreHistoryProps = {
   state: ReturnType<typeof useData<z.infer<typeof scoreHistorySchema>>>;
-}) => {
+};
+
+const ScoreHistory = ({ state }: ScoreHistoryProps) => {
   switch (state.type) {
     case StateType.Loading:
     case StateType.Error:
@@ -160,24 +268,35 @@ const ScoreHistory = ({
     }
 
     case StateType.Loaded: {
-      return <ScoreHistoryComponent scoreHistory={state.data} />;
+      return <ResolvedScoreHistory scoreHistory={state.data} />;
     }
   }
 };
 
-const getScoreHistory = async ([cluster, publisherKey, symbol]: [
-  Cluster,
-  string,
-  string,
-]) => {
+const getScoreHistory = async ([
+  cluster,
+  publisherKey,
+  symbol,
+  selectedPeriod,
+]: [Cluster, string, string, EvaluationPeriod]) => {
   const url = new URL("/component-score-history", window.location.origin);
   url.searchParams.set("cluster", ClusterToName[cluster]);
   url.searchParams.set("publisherKey", publisherKey);
   url.searchParams.set("symbol", symbol);
+  url.searchParams.set("from", formatDate(selectedPeriod.start));
+  url.searchParams.set("to", formatDate(selectedPeriod.end));
   const data = await fetch(url);
   return scoreHistorySchema.parse(await data.json());
 };
 
+const formatDate = (date: Date) => {
+  const year = date.getUTCFullYear();
+  const month = date.getUTCMonth() + 1;
+  const day = date.getUTCDate();
+
+  return `${year.toString()}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}`;
+};
+
 const scoreHistorySchema = z.array(
   z.strictObject({
     time: z.string().transform((value) => new Date(value)),
@@ -187,3 +306,283 @@ const scoreHistorySchema = z.array(
     stalledScore: z.number(),
   }),
 );
+
+const CHART_HEIGHT = 104;
+
+type ResolvedScoreHistoryProps = {
+  scoreHistory: Point[];
+};
+
+type Point = {
+  time: Date;
+  score: number;
+  uptimeScore: number;
+  deviationScore: number;
+  stalledScore: number;
+};
+
+const ResolvedScoreHistory = ({ scoreHistory }: ResolvedScoreHistoryProps) => {
+  const [selectedPoint, setSelectedPoint] = useState<Point | undefined>(
+    undefined,
+  );
+  const updateSelectedPoint = useCallback(
+    (chart: CategoricalChartState) => {
+      setSelectedPoint(
+        (chart.activePayload as { payload: Point }[] | undefined)?.[0]?.payload,
+      );
+    },
+    [setSelectedPoint],
+  );
+  const currentPoint = useMemo(
+    () => selectedPoint ?? scoreHistory.at(-1),
+    [selectedPoint, scoreHistory],
+  );
+  const dateFormatter = useDateFormatter({
+    dateStyle: "long",
+    timeZone: "UTC",
+  });
+  const numberFormatter = useNumberFormatter({ maximumFractionDigits: 4 });
+
+  const [hoveredScore, setHoveredScore] = useState<ScoreComponent | undefined>(
+    undefined,
+  );
+  const hoverUptime = useCallback(() => {
+    setHoveredScore("uptime");
+  }, [setHoveredScore]);
+  const hoverDeviation = useCallback(() => {
+    setHoveredScore("deviation");
+  }, [setHoveredScore]);
+  const hoverStalled = useCallback(() => {
+    setHoveredScore("stalled");
+  }, [setHoveredScore]);
+  const hoverFinal = useCallback(() => {
+    setHoveredScore("final");
+  }, [setHoveredScore]);
+  const clearHover = useCallback(() => {
+    setHoveredScore(undefined);
+  }, [setHoveredScore]);
+
+  const [focusedScore, setFocusedScore] = useState<ScoreComponent | undefined>(
+    undefined,
+  );
+  const toggleFocusedScore = useCallback(
+    (value: typeof focusedScore) => {
+      setFocusedScore((cur) => (cur === value ? undefined : value));
+    },
+    [setFocusedScore],
+  );
+  const toggleFocusUptime = useCallback(() => {
+    toggleFocusedScore("uptime");
+  }, [toggleFocusedScore]);
+  const toggleFocusDeviation = useCallback(() => {
+    toggleFocusedScore("deviation");
+  }, [toggleFocusedScore]);
+  const toggleFocusStalled = useCallback(() => {
+    toggleFocusedScore("stalled");
+  }, [toggleFocusedScore]);
+  const toggleFocusFinal = useCallback(() => {
+    toggleFocusedScore("final");
+  }, [toggleFocusedScore]);
+
+  return (
+    <>
+      <div
+        className={styles.scoreHistoryChart}
+        data-hovered-score={hoveredScore}
+        data-focused-score={focusedScore}
+      >
+        <div className={styles.top}>
+          <div className={styles.left}>
+            <h3 className={styles.header}>
+              <MainChartLabel component={hoveredScore ?? focusedScore} />
+            </h3>
+          </div>
+        </div>
+        <Suspense
+          fallback={<div style={{ height: `${CHART_HEIGHT.toString()}px` }} />}
+        >
+          <ResponsiveContainer width="100%" height={CHART_HEIGHT}>
+            <LineChart
+              data={scoreHistory}
+              className={styles.chart ?? ""}
+              onMouseEnter={updateSelectedPoint}
+              onMouseMove={updateSelectedPoint}
+              onMouseLeave={updateSelectedPoint}
+              margin={{ bottom: 0, left: 0, top: 3, right: 0 }}
+            >
+              <Tooltip content={() => <></>} />
+              <Line
+                type="monotone"
+                dataKey="score"
+                dot={false}
+                className={styles.score ?? ""}
+                stroke="currentColor"
+                strokeWidth={focusedScore === "final" ? 3 : 1}
+              />
+              <Line
+                type="monotone"
+                dataKey="uptimeScore"
+                dot={false}
+                className={styles.uptimeScore ?? ""}
+                stroke="currentColor"
+                strokeWidth={focusedScore === "uptime" ? 3 : 1}
+              />
+              <Line
+                type="monotone"
+                dataKey="deviationScore"
+                dot={false}
+                className={styles.deviationScore ?? ""}
+                stroke="currentColor"
+                strokeWidth={focusedScore === "deviation" ? 3 : 1}
+              />
+              <Line
+                type="monotone"
+                dataKey="stalledScore"
+                dot={false}
+                className={styles.stalledScore ?? ""}
+                stroke="currentColor"
+                strokeWidth={focusedScore === "stalled" ? 3 : 1}
+              />
+              <XAxis dataKey="time" hide />
+              <YAxis hide />
+            </LineChart>
+          </ResponsiveContainer>
+        </Suspense>
+      </div>
+      <h3 className={styles.date}>
+        Score details for{" "}
+        {currentPoint && dateFormatter.format(currentPoint.time)}
+      </h3>
+      <Table
+        label="Score Breakdown"
+        rounded
+        fill
+        columns={[
+          {
+            id: "metric",
+            name: "METRIC",
+            isRowHeader: true,
+            alignment: "left",
+          },
+          {
+            id: "weight",
+            name: "WEIGHT",
+            alignment: "right",
+            width: 10,
+            className: styles.scoreCell ?? "",
+          },
+          {
+            id: "score",
+            name: "SCORE",
+            alignment: "right",
+            width: 14,
+            className: styles.scoreCell ?? "",
+          },
+        ]}
+        rows={[
+          {
+            id: "uptime",
+            onHoverStart: hoverUptime,
+            onHoverEnd: clearHover,
+            onAction: toggleFocusUptime,
+            data: {
+              metric: (
+                <Metric
+                  component="uptime"
+                  name={SCORE_COMPONENT_TO_LABEL.uptime}
+                  description="Percentage of time a publisher is available and active"
+                />
+              ),
+              weight: "40%",
+              score: numberFormatter.format(currentPoint?.uptimeScore ?? 0),
+            },
+          },
+          {
+            id: "deviation",
+            onHoverStart: hoverDeviation,
+            onHoverEnd: clearHover,
+            onAction: toggleFocusDeviation,
+            data: {
+              metric: (
+                <Metric
+                  component="deviation"
+                  name={SCORE_COMPONENT_TO_LABEL.deviation}
+                  description="Deviations that occur between a publishers' price and the aggregate price"
+                />
+              ),
+              weight: "40%",
+              score: numberFormatter.format(currentPoint?.deviationScore ?? 0),
+            },
+          },
+          {
+            id: "staleness",
+            onHoverStart: hoverStalled,
+            onHoverEnd: clearHover,
+            onAction: toggleFocusStalled,
+            data: {
+              metric: (
+                <Metric
+                  component="stalled"
+                  name={SCORE_COMPONENT_TO_LABEL.stalled}
+                  description="Penalizes publishers reporting the same value for the price"
+                />
+              ),
+              weight: "20%",
+              score: numberFormatter.format(currentPoint?.stalledScore ?? 0),
+            },
+          },
+          {
+            id: "final",
+            onHoverStart: hoverFinal,
+            onHoverEnd: clearHover,
+            onAction: toggleFocusFinal,
+            data: {
+              metric: (
+                <Metric
+                  component="final"
+                  name={SCORE_COMPONENT_TO_LABEL.final}
+                  description="The aggregate score, calculated by combining the other three score components"
+                />
+              ),
+              weight: undefined,
+              score: numberFormatter.format(currentPoint?.score ?? 0),
+            },
+          },
+        ]}
+      />
+    </>
+  );
+};
+
+type ScoreComponent = "uptime" | "deviation" | "stalled" | "final";
+
+const SCORE_COMPONENT_TO_LABEL = {
+  uptime: "Uptime Score",
+  deviation: "Deviation Score",
+  stalled: "Stalled Score",
+  final: "Final Score",
+} as const;
+
+const MainChartLabel = ({
+  component,
+}: {
+  component: ScoreComponent | undefined;
+}) => `${component ? SCORE_COMPONENT_TO_LABEL[component] : "Score"} History`;
+
+type MetricProps = {
+  name: ReactNode;
+  description: string;
+  component: string;
+};
+
+const Metric = ({ name, description, component }: MetricProps) => (
+  <div className={styles.metric} data-component={component}>
+    <div className={styles.metricName}>
+      <svg viewBox="0 0 12 12" className={styles.legend}>
+        <circle cx="6" cy="6" r="4" strokeWidth="2" />
+      </svg>
+      {name}
+    </div>
+    <div className={styles.metricDescription}>{description}</div>
+  </div>
+);

+ 15 - 15
apps/insights/src/components/PriceComponentsCard/index.tsx

@@ -37,14 +37,14 @@ import { Status as StatusComponent } from "../Status";
 
 const SCORE_WIDTH = 32;
 
-type Props = {
+type Props<T extends PriceComponent> = {
   className?: string | undefined;
-  priceComponents: PriceComponent[];
+  priceComponents: T[];
   metricsTime?: Date | undefined;
   nameLoadingSkeleton: ReactNode;
   label: string;
   searchPlaceholder: string;
-  onPriceComponentAction: (component: PriceComponent) => void;
+  onPriceComponentAction: (component: T) => void;
 };
 
 type PriceComponent = {
@@ -62,11 +62,11 @@ type PriceComponent = {
   nameAsString: string;
 };
 
-export const PriceComponentsCard = ({
+export const PriceComponentsCard = <T extends PriceComponent>({
   priceComponents,
   onPriceComponentAction,
   ...props
-}: Props) => (
+}: Props<T>) => (
   <Suspense fallback={<PriceComponentsCardContents isLoading {...props} />}>
     <ResolvedPriceComponentsCard
       priceComponents={priceComponents}
@@ -76,11 +76,11 @@ export const PriceComponentsCard = ({
   </Suspense>
 );
 
-export const ResolvedPriceComponentsCard = ({
+export const ResolvedPriceComponentsCard = <T extends PriceComponent>({
   priceComponents,
   onPriceComponentAction,
   ...props
-}: Props) => {
+}: Props<T>) => {
   const logger = useLogger();
   const collator = useCollator();
   const filter = useFilter({ sensitivity: "base", usage: "search" });
@@ -278,8 +278,8 @@ export const ResolvedPriceComponentsCard = ({
   );
 };
 
-type PriceComponentsCardProps = Pick<
-  Props,
+type PriceComponentsCardProps<T extends PriceComponent> = Pick<
+  Props<T>,
   | "className"
   | "metricsTime"
   | "nameLoadingSkeleton"
@@ -309,14 +309,14 @@ type PriceComponentsCardProps = Pick<
       }
   );
 
-export const PriceComponentsCardContents = ({
+export const PriceComponentsCardContents = <T extends PriceComponent>({
   className,
   metricsTime,
   nameLoadingSkeleton,
   label,
   searchPlaceholder,
   ...props
-}: PriceComponentsCardProps) => {
+}: PriceComponentsCardProps<T>) => {
   const collator = useCollator();
   return (
     <Card
@@ -485,7 +485,7 @@ const otherColumns = ({
                   <Button
                     href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking#uptime-1"
                     size="xs"
-                    variant="outline"
+                    variant="solid"
                     target="_blank"
                   >
                     Read more
@@ -511,7 +511,7 @@ const otherColumns = ({
                   <Button
                     href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking#price-deviation-1"
                     size="xs"
-                    variant="outline"
+                    variant="solid"
                     target="_blank"
                   >
                     Read more
@@ -538,7 +538,7 @@ const otherColumns = ({
                   <Button
                     href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking#lack-of-stalled-prices-1"
                     size="xs"
-                    variant="outline"
+                    variant="solid"
                     target="_blank"
                   >
                     Read more
@@ -572,7 +572,7 @@ const otherColumns = ({
                   <Button
                     href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking"
                     size="xs"
-                    variant="outline"
+                    variant="solid"
                     target="_blank"
                   >
                     Read more

+ 9 - 5
apps/insights/src/components/PriceFeed/publishers-card.tsx

@@ -16,14 +16,17 @@ import {
 
 type Publisher = ComponentProps<
   typeof ResolvedPriceComponentsCard
->["priceComponents"][number] & {
-  rank?: number | undefined;
-};
+>["priceComponents"][number] &
+  Pick<ComponentProps<typeof PriceComponentDrawer>, "rank"> & {
+    firstEvaluation?: Date | undefined;
+  };
 
 type Props = Omit<
   ComponentProps<typeof ResolvedPriceComponentsCard>,
-  "onPriceComponentAction"
->;
+  "onPriceComponentAction" | "priceComponents"
+> & {
+  priceComponents: Publisher[];
+};
 
 export const PublishersCard = ({ priceComponents, ...props }: Props) => (
   <Suspense fallback={<PriceComponentsCardContents isLoading {...props} />}>
@@ -94,6 +97,7 @@ const ResolvedPublishersCard = ({ priceComponents, ...props }: Props) => {
           score={selectedPublisher.score}
           status={selectedPublisher.status}
           title={selectedPublisher.name}
+          firstEvaluation={selectedPublisher.firstEvaluation ?? new Date()}
           navigateButtonText="Open Publisher"
           navigateHref={`/publishers/${selectedPublisher.publisherKey}`}
         />

+ 1 - 0
apps/insights/src/components/PriceFeed/publishers.tsx

@@ -58,6 +58,7 @@ export const Publishers = async ({ params }: Props) => {
           publisherKey: publisher,
           symbol,
           rank: ranking?.final_rank,
+          firstEvaluation: ranking?.first_ranking_time,
           name: (
             <PublisherTag
               publisherKey={publisher}

+ 1 - 0
apps/insights/src/components/Publisher/layout.tsx

@@ -88,6 +88,7 @@ export const PublishersLayout = async ({ children, params }: Props) => {
         feedKey: feed.product.price_account,
         score: ranking?.final_score,
         rank: ranking?.final_rank,
+        firstEvaluation: ranking?.first_ranking_time,
         status,
       }))}
     >

+ 4 - 2
apps/insights/src/components/Publisher/price-feed-drawer-provider.tsx

@@ -25,10 +25,10 @@ type PriceFeedDrawerProviderProps = Omit<
   "value"
 > & {
   publisherKey: string;
-  priceFeeds: PriceFeeds[];
+  priceFeeds: PriceFeed[];
 };
 
-type PriceFeeds = {
+type PriceFeed = {
   symbol: string;
   displaySymbol: string;
   description: string;
@@ -37,6 +37,7 @@ type PriceFeeds = {
   score: number | undefined;
   rank: number | undefined;
   status: Status;
+  firstEvaluation: Date | undefined;
 };
 
 export const PriceFeedDrawerProvider = (
@@ -91,6 +92,7 @@ const PriceFeedDrawerProviderImpl = ({
           score={selectedFeed.score}
           symbol={selectedFeed.symbol}
           status={selectedFeed.status}
+          firstEvaluation={selectedFeed.firstEvaluation ?? new Date()}
           navigateButtonText="Open Feed"
           navigateHref={feedHref}
           title={<PriceFeedTag symbol={selectedFeed.symbol} />}

+ 0 - 157
apps/insights/src/components/ScoreHistory/index.module.scss

@@ -1,157 +0,0 @@
-@use "@pythnetwork/component-library/theme";
-
-.scoreHistory {
-  display: flex;
-  flex-flow: column nowrap;
-  gap: theme.spacing(6);
-
-  .scoreHistoryChart {
-    grid-column: span 2 / span 2;
-    border-radius: theme.border-radius("2xl");
-    border: 1px solid theme.color("border");
-    display: flex;
-    flex-flow: column nowrap;
-    gap: theme.spacing(4);
-
-    .top {
-      display: flex;
-      flex-flow: row nowrap;
-      justify-content: space-between;
-      align-items: flex-start;
-      margin: theme.spacing(4);
-
-      .left {
-        display: flex;
-        flex-flow: column nowrap;
-        gap: theme.spacing(1);
-
-        .header {
-          color: theme.color("heading");
-
-          @include theme.text("sm", "medium");
-        }
-
-        .subheader {
-          color: theme.color("muted");
-
-          @include theme.text("xs", "normal");
-        }
-      }
-    }
-
-    .chart {
-      border-bottom-left-radius: theme.border-radius("2xl");
-      border-bottom-right-radius: theme.border-radius("2xl");
-      overflow: hidden;
-
-      .score,
-      .uptimeScore,
-      .deviationScore,
-      .stalledScore {
-        transition: opacity 100ms linear;
-        opacity: 0.2;
-      }
-
-      .score {
-        color: theme.color("states", "data", "normal");
-      }
-
-      .uptimeScore {
-        color: theme.color("states", "info", "normal");
-      }
-
-      .deviationScore {
-        color: theme.color("states", "lime", "normal");
-      }
-
-      .stalledScore {
-        color: theme.color("states", "warning", "normal");
-      }
-    }
-
-    &:not([data-focused-score], [data-hovered-score]) {
-      .score,
-      .uptimeScore,
-      .deviationScore,
-      .stalledScore {
-        opacity: 1;
-      }
-    }
-
-    &[data-hovered-score="uptime"],
-    &[data-focused-score="uptime"] {
-      .uptimeScore {
-        opacity: 1;
-      }
-    }
-
-    &[data-hovered-score="deviation"],
-    &[data-focused-score="deviation"] {
-      .deviationScore {
-        opacity: 1;
-      }
-    }
-
-    &[data-hovered-score="stalled"],
-    &[data-focused-score="stalled"] {
-      .stalledScore {
-        opacity: 1;
-      }
-    }
-
-    &[data-hovered-score="final"],
-    &[data-focused-score="final"] {
-      .score {
-        opacity: 1;
-      }
-    }
-  }
-
-  .rankingBreakdown {
-    .legendCell,
-    .scoreCell {
-      vertical-align: top;
-    }
-
-    .uptimeLegend,
-    .deviationLegend,
-    .stalledLegend,
-    .finalScoreLegend {
-      width: theme.spacing(4);
-      height: theme.spacing(4);
-      border-radius: theme.border-radius("full");
-    }
-
-    .uptimeLegend {
-      background: theme.color("states", "info", "normal");
-    }
-
-    .deviationLegend {
-      background: theme.color("states", "lime", "normal");
-    }
-
-    .stalledLegend {
-      background: theme.color("states", "warning", "normal");
-    }
-
-    .finalScoreLegend {
-      background: theme.color("states", "data", "normal");
-    }
-
-    .metric {
-      display: flex;
-      flex-flow: column nowrap;
-      gap: theme.spacing(2);
-      overflow: hidden;
-
-      .metricDescription {
-        color: theme.color("muted");
-
-        @include theme.text("sm", "normal");
-
-        white-space: normal;
-        line-height: 1.2;
-      }
-    }
-  }
-}

+ 0 - 340
apps/insights/src/components/ScoreHistory/index.tsx

@@ -1,340 +0,0 @@
-"use client";
-
-import { Card } from "@pythnetwork/component-library/Card";
-import { Table } from "@pythnetwork/component-library/Table";
-import dynamic from "next/dynamic";
-import {
-  type ReactNode,
-  Suspense,
-  useState,
-  useCallback,
-  useMemo,
-} from "react";
-import { useDateFormatter, useNumberFormatter } from "react-aria";
-import { ResponsiveContainer, Tooltip, Line, XAxis, YAxis } from "recharts";
-import type { CategoricalChartState } from "recharts/types/chart/types";
-
-import styles from "./index.module.scss";
-import { Score } from "../Score";
-
-const LineChart = dynamic(
-  () => import("recharts").then((recharts) => recharts.LineChart),
-  {
-    ssr: false,
-  },
-);
-
-const CHART_HEIGHT = 104;
-
-type Props = {
-  isMedian?: boolean | undefined;
-  scoreHistory: Point[];
-};
-
-type Point = {
-  time: Date;
-  score: number;
-  uptimeScore: number;
-  deviationScore: number;
-  stalledScore: number;
-};
-
-export const ScoreHistory = ({ isMedian, scoreHistory }: Props) => {
-  const [selectedPoint, setSelectedPoint] = useState<Point | undefined>(
-    undefined,
-  );
-  const updateSelectedPoint = useCallback(
-    (chart: CategoricalChartState) => {
-      setSelectedPoint(
-        (chart.activePayload as { payload: Point }[] | undefined)?.[0]?.payload,
-      );
-    },
-    [setSelectedPoint],
-  );
-  const currentPoint = useMemo(
-    () => selectedPoint ?? scoreHistory.at(-1),
-    [selectedPoint, scoreHistory],
-  );
-  const dateFormatter = useDateFormatter();
-  const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 4 });
-
-  const [hoveredScore, setHoveredScore] = useState<ScoreComponent | undefined>(
-    undefined,
-  );
-  const hoverUptime = useCallback(() => {
-    setHoveredScore("uptime");
-  }, [setHoveredScore]);
-  const hoverDeviation = useCallback(() => {
-    setHoveredScore("deviation");
-  }, [setHoveredScore]);
-  const hoverStalled = useCallback(() => {
-    setHoveredScore("stalled");
-  }, [setHoveredScore]);
-  const hoverFinal = useCallback(() => {
-    setHoveredScore("final");
-  }, [setHoveredScore]);
-  const clearHover = useCallback(() => {
-    setHoveredScore(undefined);
-  }, [setHoveredScore]);
-
-  const [focusedScore, setFocusedScore] = useState<ScoreComponent | undefined>(
-    undefined,
-  );
-  const toggleFocusedScore = useCallback(
-    (value: typeof focusedScore) => {
-      setFocusedScore((cur) => (cur === value ? undefined : value));
-    },
-    [setFocusedScore],
-  );
-  const toggleFocusUptime = useCallback(() => {
-    toggleFocusedScore("uptime");
-  }, [toggleFocusedScore]);
-  const toggleFocusDeviation = useCallback(() => {
-    toggleFocusedScore("deviation");
-  }, [toggleFocusedScore]);
-  const toggleFocusStalled = useCallback(() => {
-    toggleFocusedScore("stalled");
-  }, [toggleFocusedScore]);
-  const toggleFocusFinal = useCallback(() => {
-    toggleFocusedScore("final");
-  }, [toggleFocusedScore]);
-
-  return (
-    <div className={styles.scoreHistory}>
-      <div
-        className={styles.scoreHistoryChart}
-        data-hovered-score={hoveredScore}
-        data-focused-score={focusedScore}
-      >
-        <div className={styles.top}>
-          <div className={styles.left}>
-            <h3 className={styles.header}>
-              <Label
-                isMedian={isMedian}
-                component={hoveredScore ?? focusedScore ?? "final"}
-              />{" "}
-              History
-            </h3>
-            <div className={styles.subheader}>
-              {selectedPoint
-                ? dateFormatter.format(selectedPoint.time)
-                : "Last 30 days"}
-            </div>
-          </div>
-          {currentPoint && (
-            <CurrentValue point={currentPoint} focusedScore={focusedScore} />
-          )}
-        </div>
-        <Suspense
-          fallback={<div style={{ height: `${CHART_HEIGHT.toString()}px` }} />}
-        >
-          <ResponsiveContainer width="100%" height={CHART_HEIGHT}>
-            <LineChart
-              data={scoreHistory}
-              className={styles.chart ?? ""}
-              onMouseEnter={updateSelectedPoint}
-              onMouseMove={updateSelectedPoint}
-              onMouseLeave={updateSelectedPoint}
-              margin={{ bottom: 0, left: 0, top: 3, right: 0 }}
-            >
-              <Tooltip content={() => <></>} />
-              <Line
-                type="monotone"
-                dataKey="score"
-                dot={false}
-                className={styles.score ?? ""}
-                stroke="currentColor"
-                strokeWidth={focusedScore === "final" ? 3 : 1}
-              />
-              <Line
-                type="monotone"
-                dataKey="uptimeScore"
-                dot={false}
-                className={styles.uptimeScore ?? ""}
-                stroke="currentColor"
-                strokeWidth={focusedScore === "uptime" ? 3 : 1}
-              />
-              <Line
-                type="monotone"
-                dataKey="deviationScore"
-                dot={false}
-                className={styles.deviationScore ?? ""}
-                stroke="currentColor"
-                strokeWidth={focusedScore === "deviation" ? 3 : 1}
-              />
-              <Line
-                type="monotone"
-                dataKey="stalledScore"
-                dot={false}
-                className={styles.stalledScore ?? ""}
-                stroke="currentColor"
-                strokeWidth={focusedScore === "stalled" ? 3 : 1}
-              />
-              <XAxis dataKey="time" hide />
-              <YAxis hide />
-            </LineChart>
-          </ResponsiveContainer>
-        </Suspense>
-      </div>
-      <Card
-        title="Score Breakdown"
-        nonInteractive
-        className={styles.rankingBreakdown}
-      >
-        <Table
-          label="Score Breakdown"
-          rounded
-          fill
-          columns={[
-            {
-              id: "legend",
-              name: "",
-              width: 4,
-              className: styles.legendCell ?? "",
-            },
-            {
-              id: "metric",
-              name: "METRIC",
-              isRowHeader: true,
-              alignment: "left",
-            },
-            {
-              id: "score",
-              name: "SCORE",
-              alignment: "right",
-              width: 23,
-              className: styles.scoreCell ?? "",
-            },
-          ]}
-          rows={[
-            {
-              id: "uptime",
-              onHoverStart: hoverUptime,
-              onHoverEnd: clearHover,
-              onAction: toggleFocusUptime,
-              data: {
-                legend: <div className={styles.uptimeLegend} />,
-                metric: (
-                  <Metric
-                    name={<Label isMedian={isMedian} component="uptime" />}
-                    description="Percentage of time a publisher is available and active"
-                  />
-                ),
-                score: numberFormatter.format(currentPoint?.uptimeScore ?? 0),
-              },
-            },
-            {
-              id: "deviation",
-              onHoverStart: hoverDeviation,
-              onHoverEnd: clearHover,
-              onAction: toggleFocusDeviation,
-              data: {
-                legend: <div className={styles.deviationLegend} />,
-                metric: (
-                  <Metric
-                    name={<Label isMedian={isMedian} component="deviation" />}
-                    description="Deviations that occur between a publishers' price and the aggregate price"
-                  />
-                ),
-                score: numberFormatter.format(
-                  currentPoint?.deviationScore ?? 0,
-                ),
-              },
-            },
-            {
-              id: "staleness",
-              onHoverStart: hoverStalled,
-              onHoverEnd: clearHover,
-              onAction: toggleFocusStalled,
-              data: {
-                legend: <div className={styles.stalledLegend} />,
-                metric: (
-                  <Metric
-                    name={<Label isMedian={isMedian} component="stalled" />}
-                    description="Penalizes publishers reporting the same value for the price"
-                  />
-                ),
-                score: numberFormatter.format(currentPoint?.stalledScore ?? 0),
-              },
-            },
-            {
-              id: "final",
-              onHoverStart: hoverFinal,
-              onHoverEnd: clearHover,
-              onAction: toggleFocusFinal,
-              data: {
-                legend: <div className={styles.finalScoreLegend} />,
-                metric: (
-                  <Metric
-                    name={<Label isMedian={isMedian} component="final" />}
-                    description="The aggregate score, calculated by combining the other three score components"
-                  />
-                ),
-                score: numberFormatter.format(currentPoint?.score ?? 0),
-              },
-            },
-          ]}
-        />
-      </Card>
-    </div>
-  );
-};
-
-type HeaderTextProps = {
-  isMedian?: boolean | undefined;
-  component: ScoreComponent;
-};
-
-const Label = ({ isMedian, component }: HeaderTextProps) => {
-  switch (component) {
-    case "uptime": {
-      return `${isMedian ? "Median " : ""}Uptime Score`;
-    }
-    case "deviation": {
-      return `${isMedian ? "Median " : ""}Deviation Score`;
-    }
-    case "stalled": {
-      return `${isMedian ? "Median " : ""}Stalled Score`;
-    }
-    case "final": {
-      return `${isMedian ? "Median " : ""}Final Score`;
-    }
-  }
-};
-
-type ScoreComponent = "uptime" | "deviation" | "stalled" | "final";
-
-type CurrentValueProps = {
-  point: Point;
-  focusedScore: ScoreComponent | undefined;
-};
-
-const CurrentValue = ({ point, focusedScore }: CurrentValueProps) => {
-  const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 4 });
-  switch (focusedScore) {
-    case "uptime": {
-      return numberFormatter.format(point.uptimeScore);
-    }
-    case "deviation": {
-      return numberFormatter.format(point.deviationScore);
-    }
-    case "stalled": {
-      return numberFormatter.format(point.stalledScore);
-    }
-    default: {
-      return <Score score={point.score} />;
-    }
-  }
-};
-
-type MetricProps = {
-  name: ReactNode;
-  description: string;
-};
-
-const Metric = ({ name, description }: MetricProps) => (
-  <div className={styles.metric}>
-    <div className={styles.metricName}>{name}</div>
-    <div className={styles.metricDescription}>{description}</div>
-  </div>
-);

+ 77 - 51
apps/insights/src/services/clickhouse.ts

@@ -70,49 +70,71 @@ export const getPublishers = async () =>
 export const getRankingsByPublisher = async (publisherKey: string) =>
   safeQuery(rankingsSchema, {
     query: `
-      SELECT
-          time,
-          symbol,
-          cluster,
-          publisher,
-          uptime_score,
-          deviation_score,
-          stalled_score,
-          final_score,
-          final_rank
+      WITH first_rankings AS (
+        SELECT publisher, symbol, min(time) AS first_ranking_time
         FROM publisher_quality_ranking
-        WHERE time = (SELECT max(time) FROM publisher_quality_ranking)
-        AND publisher = {publisherKey: String}
-        AND interval_days = 1
-        ORDER BY
-          symbol ASC,
-          cluster ASC,
-          publisher ASC
-      `,
+        WHERE interval_days = 1
+        GROUP BY (publisher, symbol)
+      )
+
+      SELECT
+        time,
+        symbol,
+        cluster,
+        publisher,
+        first_ranking_time,
+        uptime_score,
+        deviation_score,
+        stalled_score,
+        final_score,
+        final_rank
+      FROM publisher_quality_ranking
+      JOIN first_rankings
+        ON first_rankings.publisher = publisher_quality_ranking.publisher
+        AND first_rankings.symbol = publisher_quality_ranking.symbol
+      WHERE time = (SELECT max(time) FROM publisher_quality_ranking)
+      AND publisher = {publisherKey: String}
+      AND interval_days = 1
+      ORDER BY
+        symbol ASC,
+        cluster ASC,
+        publisher ASC
+    `,
     query_params: { publisherKey },
   });
 
 export const getRankingsBySymbol = async (symbol: string) =>
   safeQuery(rankingsSchema, {
     query: `
-        SELECT
-          time,
-          symbol,
-          cluster,
-          publisher,
-          uptime_score,
-          deviation_score,
-          stalled_score,
-          final_score,
-          final_rank
+      WITH first_rankings AS (
+        SELECT publisher, symbol, min(time) AS first_ranking_time
         FROM publisher_quality_ranking
-        WHERE time = (SELECT max(time) FROM publisher_quality_ranking)
-        AND symbol = {symbol: String}
-        AND interval_days = 1
-        ORDER BY
-          symbol ASC,
-          cluster ASC,
-          publisher ASC
+        WHERE interval_days = 1
+        GROUP BY (publisher, symbol)
+      )
+
+      SELECT
+        time,
+        symbol,
+        cluster,
+        publisher,
+      first_ranking_time,
+        uptime_score,
+        deviation_score,
+        stalled_score,
+        final_score,
+        final_rank
+      FROM publisher_quality_ranking
+      JOIN first_rankings
+        ON first_rankings.publisher = publisher_quality_ranking.publisher
+        AND first_rankings.symbol = publisher_quality_ranking.symbol
+      WHERE time = (SELECT max(time) FROM publisher_quality_ranking)
+      AND symbol = {symbol: String}
+      AND interval_days = 1
+      ORDER BY
+        symbol ASC,
+        cluster ASC,
+        publisher ASC
       `,
     query_params: { symbol },
   });
@@ -120,6 +142,7 @@ export const getRankingsBySymbol = async (symbol: string) =>
 const rankingsSchema = z.array(
   z.strictObject({
     time: z.string().transform((time) => new Date(time)),
+    first_ranking_time: z.string().transform((time) => new Date(time)),
     symbol: z.string(),
     cluster: z.enum(["pythnet", "pythtest-conformance"]),
     publisher: z.string(),
@@ -182,6 +205,8 @@ export const getFeedScoreHistory = async (
   cluster: Cluster,
   publisherKey: string,
   symbol: string,
+  from: string,
+  to: string,
 ) =>
   safeQuery(
     z.array(
@@ -195,26 +220,27 @@ export const getFeedScoreHistory = async (
     ),
     {
       query: `
-          SELECT * FROM (
-            SELECT
-              time,
-              final_score AS score,
-              uptime_score AS uptimeScore,
-              deviation_score AS deviationScore,
-              stalled_score AS stalledScore
-            FROM publisher_quality_ranking
-            WHERE publisher = {publisherKey: String}
-            AND cluster = {cluster: String}
-            AND symbol = {symbol: String}
-            ORDER BY time DESC
-            LIMIT 30
-          )
-          ORDER BY time ASC
-        `,
+        SELECT
+          time,
+          final_score AS score,
+          uptime_score AS uptimeScore,
+          deviation_score AS deviationScore,
+          stalled_score AS stalledScore
+        FROM publisher_quality_ranking
+        WHERE publisher = {publisherKey: String}
+        AND cluster = {cluster: String}
+        AND symbol = {symbol: String}
+        AND interval_days = 1
+        AND time >= toDateTime64({from: String}, 3)
+        AND time <= toDateTime64({to: String}, 3)
+        ORDER BY time ASC
+      `,
       query_params: {
         cluster: ClusterToName[cluster],
         publisherKey,
         symbol,
+        from,
+        to,
       },
     },
   );