Эх сурвалжийг харах

Merge pull request #2217 from pyth-network/cprussin/optimizations

feat(insights): optimize page payload sizes
Connor Prussin 10 сар өмнө
parent
commit
e35e7011f9
29 өөрчлөгдсөн 925 нэмэгдсэн , 491 устгасан
  1. 18 35
      apps/insights/src/components/ChangePercent/index.tsx
  2. 5 12
      apps/insights/src/components/FeedKey/index.tsx
  3. 18 25
      apps/insights/src/components/LivePrices/index.tsx
  4. 48 17
      apps/insights/src/components/PriceFeed/layout.tsx
  5. 11 8
      apps/insights/src/components/PriceFeed/price-feed-select.tsx
  6. 0 0
      apps/insights/src/components/PriceFeed/publishers-card.module.scss
  7. 355 21
      apps/insights/src/components/PriceFeed/publishers-card.tsx
  8. 18 27
      apps/insights/src/components/PriceFeed/publishers.tsx
  9. 40 40
      apps/insights/src/components/PriceFeed/reference-data.tsx
  10. 0 0
      apps/insights/src/components/PriceFeedIcon/icons.ts
  11. 23 0
      apps/insights/src/components/PriceFeedIcon/index.tsx
  12. 44 68
      apps/insights/src/components/PriceFeedTag/index.tsx
  13. 19 12
      apps/insights/src/components/PriceFeeds/coming-soon-list.tsx
  14. 29 36
      apps/insights/src/components/PriceFeeds/index.tsx
  15. 68 28
      apps/insights/src/components/PriceFeeds/price-feeds-card.tsx
  16. 10 2
      apps/insights/src/components/Publisher/layout.tsx
  17. 47 27
      apps/insights/src/components/Publisher/performance.tsx
  18. 29 30
      apps/insights/src/components/Publisher/price-feeds-card.tsx
  19. 5 7
      apps/insights/src/components/Publisher/price-feeds.tsx
  20. 11 0
      apps/insights/src/components/PublisherTag/index.module.scss
  21. 40 40
      apps/insights/src/components/PublisherTag/index.tsx
  22. 15 9
      apps/insights/src/components/Publishers/index.tsx
  23. 37 17
      apps/insights/src/components/Publishers/publishers-card.tsx
  24. 1 5
      apps/insights/src/components/Root/theme-switch.module.scss
  25. 8 8
      apps/insights/src/components/Root/theme-switch.tsx
  26. 3 7
      packages/component-library/src/Button/index.tsx
  27. 1 0
      packages/component-library/src/Link/index.module.scss
  28. 21 9
      packages/component-library/src/Link/index.tsx
  29. 1 1
      packages/component-library/src/Switch/index.module.scss

+ 18 - 35
apps/insights/src/components/ChangePercent/index.tsx

@@ -14,7 +14,7 @@ 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: (Feed & { symbol: string })[];
+  feeds: Record<string, string>;
 };
 
 const YesterdaysPricesContext = createContext<
@@ -23,7 +23,7 @@ const YesterdaysPricesContext = createContext<
 
 export const YesterdaysPricesProvider = ({ feeds, ...props }: Props) => {
   const state = useData(
-    ["yesterdaysPrices", feeds.map((feed) => feed.symbol)],
+    ["yesterdaysPrices", Object.keys(feeds)],
     () => getYesterdaysPrices(feeds),
     {
       refreshInterval: REFRESH_YESTERDAYS_PRICES_INTERVAL,
@@ -34,22 +34,16 @@ export const YesterdaysPricesProvider = ({ feeds, ...props }: Props) => {
 };
 
 const getYesterdaysPrices = async (
-  feeds: (Feed & { symbol: string })[],
+  feeds: Props["feeds"],
 ): Promise<Map<string, number>> => {
   const url = new URL("/yesterdays-prices", window.location.origin);
-  for (const feed of feeds) {
-    url.searchParams.append("symbols", feed.symbol);
+  for (const symbol of Object.keys(feeds)) {
+    url.searchParams.append("symbols", symbol);
   }
   const response = await fetch(url);
-  const data: unknown = await response.json();
+  const data = yesterdaysPricesSchema.parse(await response.json());
   return new Map(
-    Object.entries(yesterdaysPricesSchema.parse(data)).map(
-      ([symbol, value]) => [
-        feeds.find((feed) => feed.symbol === symbol)?.product.price_account ??
-          "",
-        value,
-      ],
-    ),
+    Object.entries(data).map(([symbol, value]) => [feeds[symbol] ?? "", value]),
   );
 };
 
@@ -67,39 +61,28 @@ const useYesterdaysPrices = () => {
 
 type ChangePercentProps = {
   className?: string | undefined;
-  feed: Feed;
+  feedKey: string;
 };
 
-type Feed = {
-  product: {
-    price_account: string;
-  };
-};
-
-export const ChangePercent = ({ feed, className }: ChangePercentProps) => {
+export const ChangePercent = ({ feedKey, className }: ChangePercentProps) => {
   const yesterdaysPriceState = useYesterdaysPrices();
 
   switch (yesterdaysPriceState.type) {
-    case StateType.Error: {
-      // eslint-disable-next-line unicorn/no-null
-      return null;
-    }
-
+    case StateType.Error:
     case StateType.Loading:
     case StateType.NotLoaded: {
       return <ChangeValue className={className} isLoading />;
     }
 
     case StateType.Loaded: {
-      const yesterdaysPrice = yesterdaysPriceState.data.get(
-        feed.product.price_account,
-      );
-      // eslint-disable-next-line unicorn/no-null
-      return yesterdaysPrice === undefined ? null : (
+      const yesterdaysPrice = yesterdaysPriceState.data.get(feedKey);
+      return yesterdaysPrice === undefined ? (
+        <ChangeValue className={className} isLoading />
+      ) : (
         <ChangePercentLoaded
           className={className}
           priorPrice={yesterdaysPrice}
-          feed={feed}
+          feedKey={feedKey}
         />
       );
     }
@@ -109,15 +92,15 @@ export const ChangePercent = ({ feed, className }: ChangePercentProps) => {
 type ChangePercentLoadedProps = {
   className?: string | undefined;
   priorPrice: number;
-  feed: Feed;
+  feedKey: string;
 };
 
 const ChangePercentLoaded = ({
   className,
   priorPrice,
-  feed,
+  feedKey,
 }: ChangePercentLoadedProps) => {
-  const currentPrice = useLivePrice(feed);
+  const currentPrice = useLivePrice(feedKey);
 
   return currentPrice === undefined ? (
     <ChangeValue className={className} isLoading />

+ 5 - 12
apps/insights/src/components/FeedKey/index.tsx

@@ -4,11 +4,7 @@ import { toHex, truncateHex } from "../../hex";
 import { CopyButton } from "../CopyButton";
 
 type OwnProps = {
-  feed: {
-    product: {
-      price_account: string;
-    };
-  };
+  feedKey: string;
 };
 
 type Props = Omit<
@@ -17,15 +13,12 @@ type Props = Omit<
 > &
   OwnProps;
 
-export const FeedKey = ({ feed, ...props }: Props) => {
-  const key = useMemo(
-    () => toHex(feed.product.price_account),
-    [feed.product.price_account],
-  );
-  const truncatedKey = useMemo(() => truncateHex(key), [key]);
+export const FeedKey = ({ feedKey, ...props }: Props) => {
+  const hexKey = useMemo(() => toHex(feedKey), [feedKey]);
+  const truncatedKey = useMemo(() => truncateHex(hexKey), [hexKey]);
 
   return (
-    <CopyButton text={key} {...props}>
+    <CopyButton text={hexKey} {...props}>
       {truncatedKey}
     </CopyButton>
   );

+ 18 - 25
apps/insights/src/components/LivePrices/index.tsx

@@ -38,35 +38,28 @@ type LivePricesProviderProps = Omit<
   "value"
 >;
 
-export const LivePricesProvider = ({ ...props }: LivePricesProviderProps) => {
+export const LivePricesProvider = (props: LivePricesProviderProps) => {
   const priceData = usePriceData();
 
   return <LivePricesContext value={priceData} {...props} />;
 };
 
-type Feed = {
-  product: {
-    price_account: string;
-  };
-};
-
-export const useLivePrice = (feed: Feed) => {
-  const { price_account } = feed.product;
+export const useLivePrice = (feedKey: string) => {
   const { priceData, addSubscription, removeSubscription } = useLivePrices();
 
   useEffect(() => {
-    addSubscription(price_account);
+    addSubscription(feedKey);
     return () => {
-      removeSubscription(price_account);
+      removeSubscription(feedKey);
     };
-  }, [addSubscription, removeSubscription, price_account]);
+  }, [addSubscription, removeSubscription, feedKey]);
 
-  return priceData.get(price_account);
+  return priceData.get(feedKey);
 };
 
-export const LivePrice = ({ feed }: { feed: Feed }) => {
+export const LivePrice = ({ feedKey }: { feedKey: string }) => {
   const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
-  const price = useLivePrice(feed);
+  const price = useLivePrice(feedKey);
 
   return price === undefined ? (
     <Skeleton width={SKELETON_WIDTH} />
@@ -77,9 +70,9 @@ export const LivePrice = ({ feed }: { feed: Feed }) => {
   );
 };
 
-export const LiveConfidence = ({ feed }: { feed: Feed }) => {
+export const LiveConfidence = ({ feedKey }: { feedKey: string }) => {
   const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
-  const price = useLivePrice(feed);
+  const price = useLivePrice(feedKey);
 
   return (
     <span className={styles.confidence}>
@@ -93,8 +86,8 @@ export const LiveConfidence = ({ feed }: { feed: Feed }) => {
   );
 };
 
-export const LiveLastUpdated = ({ feed }: { feed: Feed }) => {
-  const price = useLivePrice(feed);
+export const LiveLastUpdated = ({ feedKey }: { feedKey: string }) => {
+  const price = useLivePrice(feedKey);
   const formatterWithDate = useDateFormatter({
     dateStyle: "short",
     timeStyle: "medium",
@@ -118,18 +111,18 @@ export const LiveLastUpdated = ({ feed }: { feed: Feed }) => {
 
 type LiveValueProps<T extends keyof PriceData> = {
   field: T;
-  feed: Feed & {
-    price: Record<T, ReactNode>;
-  };
+  feedKey: string;
+  defaultValue?: ReactNode | undefined;
 };
 
 export const LiveValue = <T extends keyof PriceData>({
-  feed,
+  feedKey,
   field,
+  defaultValue,
 }: LiveValueProps<T>) => {
-  const price = useLivePrice(feed);
+  const price = useLivePrice(feedKey);
 
-  return price?.[field]?.toString() ?? feed.price[field];
+  return price?.[field]?.toString() ?? defaultValue;
 };
 
 const isToday = (date: Date) => {

+ 48 - 17
apps/insights/src/components/PriceFeed/layout.tsx

@@ -23,6 +23,7 @@ import {
   LiveLastUpdated,
   LiveValue,
 } from "../LivePrices";
+import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PriceFeedTag } from "../PriceFeedTag";
 import { TabPanel, TabRoot, Tabs } from "../Tabs";
 
@@ -61,33 +62,57 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
             feeds={data
               .filter((feed) => feed.symbol !== symbol)
               .map((feed) => ({
-                id: encodeURIComponent(feed.symbol),
+                id: feed.symbol,
                 key: toHex(feed.product.price_account),
                 displaySymbol: feed.product.display_symbol,
-                name: <PriceFeedTag compact feed={feed} />,
-                assetClassText: feed.product.asset_type,
-                assetClass: (
-                  <Badge variant="neutral" style="outline" size="xs">
-                    {feed.product.asset_type.toUpperCase()}
-                  </Badge>
-                ),
+                icon: <PriceFeedIcon symbol={feed.symbol} />,
+                assetClass: feed.product.asset_type,
               }))}
           >
-            <PriceFeedTag feed={feed} />
+            <PriceFeedTag
+              symbol={feed.product.display_symbol}
+              description={feed.product.description}
+              icon={<PriceFeedIcon symbol={feed.symbol} />}
+            />
           </PriceFeedSelect>
           <div className={styles.rightGroup}>
             <FeedKey
               variant="ghost"
               size="sm"
               className={styles.feedKey ?? ""}
-              feed={feed}
+              feedKey={feed.product.price_account}
             />
             <DrawerTrigger>
               <Button variant="outline" size="sm" beforeIcon={ListDashes}>
                 Reference Data
               </Button>
               <Drawer fill title="Reference Data">
-                <ReferenceData feed={feed} />
+                <ReferenceData
+                  feed={{
+                    symbol: feed.symbol,
+                    feedKey: feed.product.price_account,
+                    assetClass: feed.product.asset_type,
+                    base: feed.product.base,
+                    description: feed.product.description,
+                    country: feed.product.country,
+                    quoteCurrency: feed.product.quote_currency,
+                    tenor: feed.product.tenor,
+                    cmsSymbol: feed.product.cms_symbol,
+                    cqsSymbol: feed.product.cqs_symbol,
+                    nasdaqSymbol: feed.product.nasdaq_symbol,
+                    genericSymbol: feed.product.generic_symbol,
+                    weeklySchedule: feed.product.weekly_schedule,
+                    schedule: feed.product.schedule,
+                    contractId: feed.product.contract_id,
+                    displaySymbol: feed.product.display_symbol,
+                    exponent: feed.price.exponent,
+                    numComponentPrices: feed.price.numComponentPrices,
+                    numQuoters: feed.price.numQuoters,
+                    minPublishers: feed.price.minPublishers,
+                    lastSlot: feed.price.lastSlot,
+                    validSlot: feed.price.validSlot,
+                  }}
+                />
               </Drawer>
             </DrawerTrigger>
           </div>
@@ -96,11 +121,11 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
           <StatCard
             variant="primary"
             header="Aggregated Price"
-            stat={<LivePrice feed={feed} />}
+            stat={<LivePrice feedKey={feed.product.price_account} />}
           />
           <StatCard
             header="Confidence"
-            stat={<LiveConfidence feed={feed} />}
+            stat={<LiveConfidence feedKey={feed.product.price_account} />}
             corner={
               <AlertTrigger>
                 <Button
@@ -135,14 +160,16 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
           <StatCard
             header="1-Day Price Change"
             stat={
-              <YesterdaysPricesProvider feeds={[feed]}>
-                <ChangePercent feed={feed} />
+              <YesterdaysPricesProvider
+                feeds={{ [feed.symbol]: feed.product.price_account }}
+              >
+                <ChangePercent feedKey={feed.product.price_account} />
               </YesterdaysPricesProvider>
             }
           />
           <StatCard
             header="Last Updated"
-            stat={<LiveLastUpdated feed={feed} />}
+            stat={<LiveLastUpdated feedKey={feed.product.price_account} />}
           />
         </section>
       </section>
@@ -158,7 +185,11 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
                 <div className={styles.priceComponentsTabLabel}>
                   <span>Publishers</span>
                   <Badge size="xs" style="filled" variant="neutral">
-                    <LiveValue feed={feed} field="numComponentPrices" />
+                    <LiveValue
+                      feedKey={feed.product.price_account}
+                      field="numComponentPrices"
+                      defaultValue={feed.price.numComponentPrices}
+                    />
                   </Badge>
                 </div>
               ),

+ 11 - 8
apps/insights/src/components/PriceFeed/price-feed-select.tsx

@@ -1,5 +1,6 @@
 "use client";
 
+import { Badge } from "@pythnetwork/component-library/Badge";
 import { DropdownCaretDown } from "@pythnetwork/component-library/DropdownCaretDown";
 import {
   Virtualizer,
@@ -19,6 +20,7 @@ import { type ReactNode, useMemo, useState } from "react";
 import { useCollator, useFilter } from "react-aria";
 
 import styles from "./price-feed-select.module.scss";
+import { PriceFeedTag } from "../PriceFeedTag";
 
 type Props = {
   children: ReactNode;
@@ -26,9 +28,8 @@ type Props = {
     id: string;
     key: string;
     displaySymbol: string;
-    name: ReactNode;
-    assetClass: ReactNode;
-    assetClassText: string;
+    icon: ReactNode;
+    assetClass: string;
   }[];
 };
 
@@ -48,7 +49,7 @@ export const PriceFeedSelect = ({ children, feeds }: Props) => {
         : sortedFeeds.filter(
             (feed) =>
               filter.contains(feed.displaySymbol, search) ||
-              filter.contains(feed.assetClassText, search) ||
+              filter.contains(feed.assetClass, search) ||
               filter.contains(feed.key, search),
           ),
     [sortedFeeds, search, filter],
@@ -84,15 +85,17 @@ export const PriceFeedSelect = ({ children, feeds }: Props) => {
               // eslint-disable-next-line jsx-a11y/no-autofocus
               autoFocus={false}
             >
-              {({ name, assetClass, id, displaySymbol }) => (
+              {({ assetClass, id, displaySymbol, icon }) => (
                 <ListBoxItem
                   textValue={displaySymbol}
                   className={styles.priceFeed ?? ""}
-                  href={`/price-feeds/${id}`}
+                  href={`/price-feeds/${encodeURIComponent(id)}`}
                   data-is-first={id === filteredFeeds[0]?.id ? "" : undefined}
                 >
-                  {name}
-                  {assetClass}
+                  <PriceFeedTag compact symbol={displaySymbol} icon={icon} />
+                  <Badge variant="neutral" style="outline" size="xs">
+                    {assetClass.toUpperCase()}
+                  </Badge>
                 </ListBoxItem>
               )}
             </ListBox>

+ 0 - 0
apps/insights/src/components/PriceFeed/publishers.module.scss → apps/insights/src/components/PriceFeed/publishers-card.module.scss


+ 355 - 21
apps/insights/src/components/PriceFeed/publishers-card.tsx

@@ -1,24 +1,62 @@
 "use client";
 
+import { useLogger } from "@pythnetwork/app-logger";
+import { Badge } from "@pythnetwork/component-library/Badge";
+import { Card } from "@pythnetwork/component-library/Card";
+import { Paginator } from "@pythnetwork/component-library/Paginator";
+import { SearchInput } from "@pythnetwork/component-library/SearchInput";
 import { Switch } from "@pythnetwork/component-library/Switch";
-import { type ComponentProps, useState, useMemo } from "react";
+import {
+  type RowConfig,
+  type SortDescriptor,
+  Table,
+} from "@pythnetwork/component-library/Table";
+import { useQueryState, parseAsBoolean } from "nuqs";
+import { type ReactNode, Suspense, useMemo, useCallback } from "react";
+import { useFilter, useCollator } from "react-aria";
 
-import { PriceComponentsCard } from "../PriceComponentsCard";
+import styles from "./publishers-card.module.scss";
+import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination";
+import { FormattedNumber } from "../FormattedNumber";
+import { PublisherTag } from "../PublisherTag";
+import rootStyles from "../Root/index.module.scss";
+import { Score } from "../Score";
 
-type OwnProps = {
-  priceComponents: (ComponentProps<
-    typeof PriceComponentsCard
-  >["priceComponents"][number] & {
-    isTest: boolean;
-  })[];
+const SCORE_WIDTH = 24;
+
+type Props = {
+  className?: string | undefined;
+  priceComponents: PriceComponent[];
 };
 
-type Props = Omit<ComponentProps<typeof PriceComponentsCard>, keyof OwnProps> &
-  OwnProps;
+type PriceComponent = {
+  id: string;
+  score: number;
+  uptimeScore: number;
+  deviationPenalty: number | null;
+  deviationScore: number;
+  stalledPenalty: number;
+  stalledScore: number;
+  isTest: boolean;
+} & (
+  | { name: string; icon: ReactNode }
+  | { name?: undefined; icon?: undefined }
+);
 
-export const PublishersCard = ({ priceComponents, ...props }: Props) => {
-  const [includeTestComponents, setIncludeTestComponents] = useState(false);
+export const PublishersCard = ({ priceComponents, ...props }: Props) => (
+  <Suspense fallback={<PriceComponentsCardContents isLoading {...props} />}>
+    <ResolvedPriceComponentsCard priceComponents={priceComponents} {...props} />
+  </Suspense>
+);
 
+const ResolvedPriceComponentsCard = ({ priceComponents, ...props }: Props) => {
+  const logger = useLogger();
+  const [includeTestComponents, setIncludeTestComponents] = useQueryState(
+    "includeTestComponents",
+    parseAsBoolean.withDefault(false),
+  );
+  const collator = useCollator();
+  const filter = useFilter({ sensitivity: "base", usage: "search" });
   const filteredPriceComponents = useMemo(
     () =>
       includeTestComponents
@@ -27,18 +65,314 @@ export const PublishersCard = ({ priceComponents, ...props }: Props) => {
     [includeTestComponents, priceComponents],
   );
 
+  const {
+    search,
+    sortDescriptor,
+    page,
+    pageSize,
+    updateSearch,
+    updateSortDescriptor,
+    updatePage,
+    updatePageSize,
+    paginatedItems,
+    numResults,
+    numPages,
+    mkPageLink,
+  } = useQueryParamFilterPagination(
+    filteredPriceComponents,
+    (priceComponent, search) =>
+      filter.contains(priceComponent.id, search) ||
+      (priceComponent.name !== undefined &&
+        filter.contains(priceComponent.name, search)),
+    (a, b, { column, direction }) => {
+      switch (column) {
+        case "score":
+        case "uptimeScore":
+        case "deviationScore":
+        case "stalledScore":
+        case "stalledPenalty": {
+          return (
+            (direction === "descending" ? -1 : 1) * (a[column] - b[column])
+          );
+        }
+
+        case "deviationPenalty": {
+          if (a.deviationPenalty === null && b.deviationPenalty === null) {
+            return 0;
+          } else if (a.deviationPenalty === null) {
+            return direction === "descending" ? 1 : -1;
+          } else if (b.deviationPenalty === null) {
+            return direction === "descending" ? -1 : 1;
+          } else {
+            return (
+              (direction === "descending" ? -1 : 1) *
+              (a.deviationPenalty - b.deviationPenalty)
+            );
+          }
+        }
+
+        case "name": {
+          return (
+            (direction === "descending" ? -1 : 1) *
+            collator.compare(a.name ?? a.id, b.name ?? b.id)
+          );
+        }
+
+        default: {
+          return (direction === "descending" ? -1 : 1) * (a.score - b.score);
+        }
+      }
+    },
+    {
+      defaultPageSize: 20,
+      defaultSort: "score",
+      defaultDescending: true,
+    },
+  );
+
+  const rows = useMemo(
+    () =>
+      paginatedItems.map(
+        ({
+          id,
+          score,
+          uptimeScore,
+          deviationPenalty,
+          deviationScore,
+          stalledPenalty,
+          stalledScore,
+          isTest,
+          ...publisher
+        }) => ({
+          id,
+          data: {
+            score: <Score score={score} width={SCORE_WIDTH} />,
+            name: (
+              <div className={styles.publisherName}>
+                <PublisherTag
+                  publisherKey={id}
+                  {...(publisher.name && {
+                    name: publisher.name,
+                    icon: publisher.icon,
+                  })}
+                />
+                {isTest && (
+                  <Badge variant="muted" style="filled" size="xs">
+                    test
+                  </Badge>
+                )}
+              </div>
+            ),
+            uptimeScore: (
+              <FormattedNumber
+                value={uptimeScore}
+                maximumSignificantDigits={5}
+              />
+            ),
+            deviationPenalty: deviationPenalty ? (
+              <FormattedNumber
+                value={deviationPenalty}
+                maximumSignificantDigits={5}
+              />
+            ) : // eslint-disable-next-line unicorn/no-null
+            null,
+            deviationScore: (
+              <FormattedNumber
+                value={deviationScore}
+                maximumSignificantDigits={5}
+              />
+            ),
+            stalledPenalty: (
+              <FormattedNumber
+                value={stalledPenalty}
+                maximumSignificantDigits={5}
+              />
+            ),
+            stalledScore: (
+              <FormattedNumber
+                value={stalledScore}
+                maximumSignificantDigits={5}
+              />
+            ),
+          },
+        }),
+      ),
+    [paginatedItems],
+  );
+
+  const updateIncludeTestComponents = useCallback(
+    (newValue: boolean) => {
+      setIncludeTestComponents(newValue).catch((error: unknown) => {
+        logger.error(
+          "Failed to update include test components query param",
+          error,
+        );
+      });
+    },
+    [setIncludeTestComponents, logger],
+  );
+
   return (
-    <PriceComponentsCard
-      priceComponents={filteredPriceComponents}
-      toolbar={
+    <PriceComponentsCardContents
+      numResults={numResults}
+      search={search}
+      sortDescriptor={sortDescriptor}
+      numPages={numPages}
+      page={page}
+      pageSize={pageSize}
+      includeTestComponents={includeTestComponents}
+      onSearchChange={updateSearch}
+      onSortChange={updateSortDescriptor}
+      onPageSizeChange={updatePageSize}
+      onPageChange={updatePage}
+      mkPageLink={mkPageLink}
+      onIncludeTestComponentsChange={updateIncludeTestComponents}
+      rows={rows}
+      {...props}
+    />
+  );
+};
+
+type PriceComponentsCardProps = Pick<Props, "className"> &
+  (
+    | { isLoading: true }
+    | {
+        isLoading?: false;
+        numResults: number;
+        search: string;
+        sortDescriptor: SortDescriptor;
+        numPages: number;
+        page: number;
+        pageSize: number;
+        includeTestComponents: boolean;
+        onIncludeTestComponentsChange: (newValue: boolean) => void;
+        onSearchChange: (newSearch: string) => void;
+        onSortChange: (newSort: SortDescriptor) => void;
+        onPageSizeChange: (newPageSize: number) => void;
+        onPageChange: (newPage: number) => void;
+        mkPageLink: (page: number) => string;
+        rows: RowConfig<
+          | "score"
+          | "name"
+          | "uptimeScore"
+          | "deviationScore"
+          | "deviationPenalty"
+          | "stalledScore"
+          | "stalledPenalty"
+        >[];
+      }
+  );
+
+const PriceComponentsCardContents = ({
+  className,
+  ...props
+}: PriceComponentsCardProps) => (
+  <Card
+    className={className}
+    title="Publishers"
+    toolbar={
+      <>
         <Switch
-          isSelected={includeTestComponents}
-          onChange={setIncludeTestComponents}
+          {...(props.isLoading
+            ? { isLoading: true }
+            : {
+                isSelected: props.includeTestComponents,
+                onChange: props.onIncludeTestComponentsChange,
+              })}
         >
           Show test components
         </Switch>
-      }
-      {...props}
+        <SearchInput
+          size="sm"
+          width={40}
+          {...(props.isLoading
+            ? { isPending: true, isDisabled: true }
+            : {
+                value: props.search,
+                onChange: props.onSearchChange,
+              })}
+        />
+      </>
+    }
+    {...(!props.isLoading && {
+      footer: (
+        <Paginator
+          numPages={props.numPages}
+          currentPage={props.page}
+          onPageChange={props.onPageChange}
+          pageSize={props.pageSize}
+          onPageSizeChange={props.onPageSizeChange}
+          pageSizeOptions={[10, 20, 30, 40, 50]}
+          mkPageLink={props.mkPageLink}
+        />
+      ),
+    })}
+  >
+    <Table
+      label="Publishers"
+      fill
+      rounded
+      stickyHeader={rootStyles.headerHeight}
+      columns={[
+        {
+          id: "score",
+          name: "SCORE",
+          alignment: "center",
+          width: SCORE_WIDTH,
+          loadingSkeleton: <Score isLoading width={SCORE_WIDTH} />,
+          allowsSorting: true,
+        },
+        {
+          id: "name",
+          name: "NAME / ID",
+          alignment: "left",
+          isRowHeader: true,
+          loadingSkeleton: <PublisherTag isLoading />,
+          allowsSorting: true,
+        },
+        {
+          id: "uptimeScore",
+          name: "UPTIME SCORE",
+          alignment: "center",
+          width: 40,
+          allowsSorting: true,
+        },
+        {
+          id: "deviationScore",
+          name: "DEVIATION SCORE",
+          alignment: "center",
+          width: 40,
+          allowsSorting: true,
+        },
+        {
+          id: "deviationPenalty",
+          name: "DEVIATION PENALTY",
+          alignment: "center",
+          width: 40,
+          allowsSorting: true,
+        },
+        {
+          id: "stalledScore",
+          name: "STALLED SCORE",
+          alignment: "center",
+          width: 40,
+          allowsSorting: true,
+        },
+        {
+          id: "stalledPenalty",
+          name: "STALLED PENALTY",
+          alignment: "center",
+          width: 40,
+          allowsSorting: true,
+        },
+      ]}
+      {...(props.isLoading
+        ? { isLoading: true }
+        : {
+            rows: props.rows,
+            sortDescriptor: props.sortDescriptor,
+            onSortChange: props.onSortChange,
+          })}
     />
-  );
-};
+  </Card>
+);

+ 18 - 27
apps/insights/src/components/PriceFeed/publishers.tsx

@@ -1,12 +1,10 @@
-import { Badge } from "@pythnetwork/component-library/Badge";
 import { lookup as lookupPublisher } from "@pythnetwork/known-publishers";
 import { notFound } from "next/navigation";
+import { createElement } from "react";
 
 import { PublishersCard } from "./publishers-card";
-import styles from "./publishers.module.scss";
 import { getRankings } from "../../services/clickhouse";
 import { getData } from "../../services/pyth";
-import { PublisherTag } from "../PublisherTag";
 
 type Props = {
   params: Promise<{
@@ -22,30 +20,23 @@ export const Publishers = async ({ params }: Props) => {
 
   return feed ? (
     <PublishersCard
-      defaultSort="score"
-      defaultDescending
-      priceComponents={rankings.map((ranking) => ({
-        id: ranking.publisher,
-        nameAsString: lookupPublisher(ranking.publisher)?.name,
-        score: ranking.final_score,
-        isTest: ranking.cluster === "pythtest-conformance",
-        name: (
-          <div className={styles.publisherName}>
-            <PublisherTag publisherKey={ranking.publisher} />
-            {ranking.cluster === "pythtest-conformance" && (
-              <Badge variant="muted" style="filled" size="xs">
-                test
-              </Badge>
-            )}
-          </div>
-        ),
-        uptimeScore: ranking.uptime_score,
-        deviationPenalty: ranking.deviation_penalty,
-        deviationScore: ranking.deviation_score,
-        stalledPenalty: ranking.stalled_penalty,
-        stalledScore: ranking.stalled_score,
-      }))}
-      nameLoadingSkeleton={<PublisherTag isLoading />}
+      priceComponents={rankings.map((ranking) => {
+        const knownPublisher = lookupPublisher(ranking.publisher);
+        return {
+          id: ranking.publisher,
+          score: ranking.final_score,
+          isTest: ranking.cluster === "pythtest-conformance",
+          uptimeScore: ranking.uptime_score,
+          deviationPenalty: ranking.deviation_penalty,
+          deviationScore: ranking.deviation_score,
+          stalledPenalty: ranking.stalled_penalty,
+          stalledScore: ranking.stalled_score,
+          ...(knownPublisher && {
+            name: knownPublisher.name,
+            icon: createElement(knownPublisher.icon.color),
+          }),
+        };
+      })}
     />
   ) : (
     notFound()

+ 40 - 40
apps/insights/src/components/PriceFeed/reference-data.tsx

@@ -11,31 +11,27 @@ import { LiveValue } from "../LivePrices";
 type Props = {
   feed: {
     symbol: string;
-    product: {
-      display_symbol: string;
-      asset_type: string;
-      description: string;
-      price_account: string;
-      base?: string | undefined;
-      country?: string | undefined;
-      quote_currency?: string | undefined;
-      tenor?: string | undefined;
-      cms_symbol?: string | undefined;
-      cqs_symbol?: string | undefined;
-      nasdaq_symbol?: string | undefined;
-      generic_symbol?: string | undefined;
-      weekly_schedule?: string | undefined;
-      schedule?: string | undefined;
-      contract_id?: string | undefined;
-    };
-    price: {
-      exponent: number;
-      numComponentPrices: number;
-      numQuoters: number;
-      minPublishers: number;
-      lastSlot: bigint;
-      validSlot: bigint;
-    };
+    feedKey: string;
+    assetClass: string;
+    base?: string | undefined;
+    description: string;
+    country?: string | undefined;
+    quoteCurrency?: string | undefined;
+    tenor?: string | undefined;
+    cmsSymbol?: string | undefined;
+    cqsSymbol?: string | undefined;
+    nasdaqSymbol?: string | undefined;
+    genericSymbol?: string | undefined;
+    weeklySchedule?: string | undefined;
+    schedule?: string | undefined;
+    contractId?: string | undefined;
+    displaySymbol: string;
+    exponent: number;
+    numComponentPrices: number;
+    numQuoters: number;
+    minPublishers: number;
+    lastSlot: bigint;
+    validSlot: bigint;
   };
 };
 
@@ -48,23 +44,23 @@ export const ReferenceData = ({ feed }: Props) => {
         ...Object.entries({
           "Asset Type": (
             <Badge variant="neutral" style="outline" size="xs">
-              {feed.product.asset_type.toUpperCase()}
+              {feed.assetClass.toUpperCase()}
             </Badge>
           ),
-          Base: feed.product.base,
-          Description: feed.product.description,
+          Base: feed.base,
+          Description: feed.description,
           Symbol: feed.symbol,
-          Country: feed.product.country,
-          "Quote Currency": feed.product.quote_currency,
-          Tenor: feed.product.tenor,
-          "CMS Symbol": feed.product.cms_symbol,
-          "CQS Symbol": feed.product.cqs_symbol,
-          "NASDAQ Symbol": feed.product.nasdaq_symbol,
-          "Generic Symbol": feed.product.generic_symbol,
-          "Weekly Schedule": feed.product.weekly_schedule,
-          Schedule: feed.product.schedule,
-          "Contract ID": feed.product.contract_id,
-          "Display Symbol": feed.product.display_symbol,
+          Country: feed.country,
+          "Quote Currency": feed.quoteCurrency,
+          Tenor: feed.tenor,
+          "CMS Symbol": feed.cmsSymbol,
+          "CQS Symbol": feed.cqsSymbol,
+          "NASDAQ Symbol": feed.nasdaqSymbol,
+          "Generic Symbol": feed.genericSymbol,
+          "Weekly Schedule": feed.weeklySchedule,
+          Schedule: feed.schedule,
+          "Contract ID": feed.contractId,
+          "Display Symbol": feed.displaySymbol,
         }),
         ...Object.entries({
           Exponent: "exponent",
@@ -78,7 +74,11 @@ export const ReferenceData = ({ feed }: Props) => {
             [
               key,
               <span key={key} className={styles.value}>
-                <LiveValue feed={feed} field={value} />
+                <LiveValue
+                  feedKey={feed.feedKey}
+                  field={value}
+                  defaultValue={feed[value]}
+                />
               </span>,
             ] as const,
         ),

+ 0 - 0
apps/insights/src/components/PriceFeedTag/icons.ts → apps/insights/src/components/PriceFeedIcon/icons.ts


+ 23 - 0
apps/insights/src/components/PriceFeedIcon/index.tsx

@@ -0,0 +1,23 @@
+import Generic from "cryptocurrency-icons/svg/color/generic.svg";
+import type { ComponentProps } from "react";
+
+import { icons } from "./icons";
+
+type OwnProps = {
+  symbol: string;
+};
+type Props = Omit<
+  ComponentProps<typeof Generic>,
+  keyof OwnProps | "width" | "height" | "viewBox"
+> &
+  OwnProps;
+
+export const PriceFeedIcon = ({ symbol, ...props }: Props) => {
+  const firstPart = symbol.split("/")[0];
+  const Icon =
+    firstPart && firstPart in icons
+      ? icons[firstPart as keyof typeof icons]
+      : Generic;
+
+  return <Icon width="100%" height="100%" viewBox="0 0 32 32" {...props} />;
+};

+ 44 - 68
apps/insights/src/components/PriceFeedTag/index.tsx

@@ -1,66 +1,61 @@
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import clsx from "clsx";
-import Generic from "cryptocurrency-icons/svg/color/generic.svg";
-import { type ComponentProps, Fragment } from "react";
+import { type ComponentProps, type ReactNode, Fragment } from "react";
 
-import { icons } from "./icons";
 import styles from "./index.module.scss";
 
-type OwnProps = {
-  compact?: boolean | undefined;
-} & (
-  | { isLoading: true }
-  | {
+type OwnProps =
+  | { isLoading: true; compact?: boolean | undefined }
+  | ({
       isLoading?: false;
-      feed: {
-        product: {
-          display_symbol: string;
+      symbol: string;
+      icon: ReactNode;
+    } & (
+      | { compact: true }
+      | {
+          compact?: false;
           description: string;
-        };
-      };
-    }
-);
+        }
+    ));
 
 type Props = Omit<ComponentProps<"div">, keyof OwnProps> & OwnProps;
 
-export const PriceFeedTag = ({ className, compact, ...props }: Props) => (
-  <div
-    className={clsx(styles.priceFeedTag, className)}
-    data-compact={compact ? "" : undefined}
-    data-loading={props.isLoading ? "" : undefined}
-    {...props}
-  >
-    {props.isLoading ? (
-      <Skeleton fill className={styles.icon} />
-    ) : (
-      <FeedIcon
-        className={styles.icon}
-        symbol={props.feed.product.display_symbol}
-      />
-    )}
-    <div className={styles.nameAndDescription}>
+export const PriceFeedTag = ({ className, ...props }: Props) => {
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  const { compact, ...propsWithoutCompact } = props;
+  return (
+    <div
+      className={clsx(styles.priceFeedTag, className)}
+      data-compact={props.compact ? "" : undefined}
+      data-loading={props.isLoading ? "" : undefined}
+      {...propsWithoutCompact}
+    >
       {props.isLoading ? (
-        <div className={styles.name}>
-          <Skeleton width={30} />
-        </div>
+        <Skeleton fill className={styles.icon} />
       ) : (
-        <FeedName
-          className={styles.name}
-          symbol={props.feed.product.display_symbol}
-        />
-      )}
-      {!compact && (
-        <div className={styles.description}>
-          {props.isLoading ? (
-            <Skeleton width={50} />
-          ) : (
-            props.feed.product.description.split("/")[0]
-          )}
-        </div>
+        <div className={styles.icon}>{props.icon}</div>
       )}
+      <div className={styles.nameAndDescription}>
+        {props.isLoading ? (
+          <div className={styles.name}>
+            <Skeleton width={30} />
+          </div>
+        ) : (
+          <FeedName className={styles.name} symbol={props.symbol} />
+        )}
+        {!props.compact && (
+          <div className={styles.description}>
+            {props.isLoading ? (
+              <Skeleton width={50} />
+            ) : (
+              props.description.split("/")[0]
+            )}
+          </div>
+        )}
+      </div>
     </div>
-  </div>
-);
+  );
+};
 
 type OwnFeedNameProps = { symbol: string };
 type FeedNameProps = Omit<ComponentProps<"div">, keyof OwnFeedNameProps> &
@@ -81,22 +76,3 @@ const FeedName = ({ symbol, className, ...props }: FeedNameProps) => {
     </div>
   );
 };
-
-type OwnFeedIconProps = {
-  symbol: string;
-};
-type FeedIconProps = Omit<
-  ComponentProps<typeof Generic>,
-  keyof OwnFeedIconProps | "width" | "height" | "viewBox"
-> &
-  OwnFeedIconProps;
-
-const FeedIcon = ({ symbol, ...props }: FeedIconProps) => {
-  const firstPart = symbol.split("/")[0];
-  const Icon =
-    firstPart && firstPart in icons
-      ? icons[firstPart as keyof typeof icons]
-      : Generic;
-
-  return <Icon width="100%" height="100%" viewBox="0 0 32 32" {...props} />;
-};

+ 19 - 12
apps/insights/src/components/PriceFeeds/coming-soon-list.tsx

@@ -1,5 +1,6 @@
 "use client";
 
+import { Badge } from "@pythnetwork/component-library/Badge";
 import { SearchInput } from "@pythnetwork/component-library/SearchInput";
 import { Select } from "@pythnetwork/component-library/Select";
 import { Table } from "@pythnetwork/component-library/Table";
@@ -7,18 +8,17 @@ import { type ReactNode, useMemo, useState } from "react";
 import { useCollator, useFilter } from "react-aria";
 
 import styles from "./coming-soon-list.module.scss";
+import { PriceFeedTag } from "../PriceFeedTag";
 
 type Props = {
   comingSoonFeeds: ComingSoonPriceFeed[];
 };
 
 type ComingSoonPriceFeed = {
-  symbol: string;
   id: string;
   displaySymbol: string;
-  assetClassAsString: string;
-  priceFeedName: ReactNode;
-  assetClass: ReactNode;
+  icon: ReactNode;
+  assetClass: string;
 };
 
 export const ComingSoonList = ({ comingSoonFeeds }: Props) => {
@@ -29,9 +29,7 @@ export const ComingSoonList = ({ comingSoonFeeds }: Props) => {
   const assetClasses = useMemo(
     () =>
       [
-        ...new Set(
-          comingSoonFeeds.map((priceFeed) => priceFeed.assetClassAsString),
-        ),
+        ...new Set(comingSoonFeeds.map((priceFeed) => priceFeed.assetClass)),
       ].sort((a, b) => collator.compare(a, b)),
     [comingSoonFeeds, collator],
   );
@@ -45,7 +43,7 @@ export const ComingSoonList = ({ comingSoonFeeds }: Props) => {
   const feedsFilteredByAssetClass = useMemo(
     () =>
       assetClass
-        ? sortedFeeds.filter((feed) => feed.assetClassAsString === assetClass)
+        ? sortedFeeds.filter((feed) => feed.assetClass === assetClass)
         : sortedFeeds,
     [assetClass, sortedFeeds],
   );
@@ -54,15 +52,24 @@ export const ComingSoonList = ({ comingSoonFeeds }: Props) => {
       search === ""
         ? feedsFilteredByAssetClass
         : feedsFilteredByAssetClass.filter((feed) =>
-            filter.contains(feed.symbol, search),
+            filter.contains(feed.displaySymbol, search),
           ),
     [search, feedsFilteredByAssetClass, filter],
   );
   const rows = useMemo(
     () =>
-      filteredFeeds.map(({ id, priceFeedName, assetClass }) => ({
+      filteredFeeds.map(({ id, displaySymbol, assetClass, icon }) => ({
         id,
-        data: { priceFeedName, assetClass },
+        data: {
+          priceFeedName: (
+            <PriceFeedTag compact symbol={displaySymbol} icon={icon} />
+          ),
+          assetClass: (
+            <Badge variant="neutral" style="outline" size="xs">
+              {assetClass.toUpperCase()}
+            </Badge>
+          ),
+        },
       })),
     [filteredFeeds],
   );
@@ -95,7 +102,7 @@ export const ComingSoonList = ({ comingSoonFeeds }: Props) => {
       <Table
         fill
         stickyHeader
-        label="Asset Classes"
+        label="Coming Soon"
         className={styles.priceFeeds ?? ""}
         columns={[
           {

+ 29 - 36
apps/insights/src/components/PriceFeeds/index.tsx

@@ -20,8 +20,8 @@ import { PriceFeedsCard } from "./price-feeds-card";
 import { getData } from "../../services/pyth";
 import { priceFeeds as priceFeedsStaticConfig } from "../../static-data/price-feeds";
 import { YesterdaysPricesProvider, ChangePercent } from "../ChangePercent";
-import { FeedKey } from "../FeedKey";
-import { LivePrice, LiveConfidence, LiveValue } from "../LivePrices";
+import { LivePrice } from "../LivePrices";
+import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PriceFeedTag } from "../PriceFeedTag";
 
 const PRICE_FEEDS_ANCHOR = "priceFeeds";
@@ -75,9 +75,16 @@ export const PriceFeeds = async () => {
             />
           </AssetClassesDrawer>
         </section>
-        <YesterdaysPricesProvider feeds={featuredRecentlyAdded}>
+        <YesterdaysPricesProvider
+          feeds={Object.fromEntries(
+            featuredRecentlyAdded.map(({ symbol, product }) => [
+              symbol,
+              product.price_account,
+            ]),
+          )}
+        >
           <FeaturedFeedsCard
-            title="Recently added"
+            title="Recently Added"
             icon={<StackPlus />}
             feeds={featuredRecentlyAdded}
             showPrices
@@ -85,7 +92,7 @@ export const PriceFeeds = async () => {
           />
         </YesterdaysPricesProvider>
         <FeaturedFeedsCard
-          title="Coming soon"
+          title="Coming Soon"
           icon={<ClockCountdown />}
           feeds={featuredComingSoon}
           toolbar={
@@ -105,15 +112,11 @@ export const PriceFeeds = async () => {
               >
                 <ComingSoonList
                   comingSoonFeeds={priceFeeds.comingSoon.map((feed) => ({
-                    symbol: feed.symbol,
                     id: feed.product.price_account,
                     displaySymbol: feed.product.display_symbol,
-                    assetClassAsString: feed.product.asset_type,
-                    priceFeedName: <PriceFeedTag compact feed={feed} />,
-                    assetClass: (
-                      <Badge variant="neutral" style="outline" size="xs">
-                        {feed.product.asset_type.toUpperCase()}
-                      </Badge>
+                    assetClass: feed.product.asset_type,
+                    icon: (
+                      <PriceFeedIcon symbol={feed.product.display_symbol} />
                     ),
                   }))}
                 />
@@ -123,31 +126,14 @@ export const PriceFeeds = async () => {
         />
         <PriceFeedsCard
           id={PRICE_FEEDS_ANCHOR}
-          nameLoadingSkeleton={<PriceFeedTag compact isLoading />}
           priceFeeds={priceFeeds.activeFeeds.map((feed) => ({
             symbol: feed.symbol,
+            icon: <PriceFeedIcon symbol={feed.product.display_symbol} />,
             id: feed.product.price_account,
             displaySymbol: feed.product.display_symbol,
-            assetClassAsString: feed.product.asset_type,
-            exponent: <LiveValue field="exponent" feed={feed} />,
-            numPublishers: <LiveValue field="numQuoters" feed={feed} />,
-            price: <LivePrice feed={feed} />,
-            confidenceInterval: <LiveConfidence feed={feed} />,
-            weeklySchedule: feed.product.weekly_schedule,
-            priceFeedName: <PriceFeedTag compact feed={feed} />,
-            assetClass: (
-              <Badge variant="neutral" style="outline" size="xs">
-                {feed.product.asset_type.toUpperCase()}
-              </Badge>
-            ),
-            priceFeedId: (
-              <FeedKey
-                className={styles.feedKey ?? ""}
-                size="xs"
-                variant="ghost"
-                feed={feed}
-              />
-            ),
+            assetClass: feed.product.asset_type,
+            exponent: feed.price.exponent,
+            numQuoters: feed.price.numQuoters,
           }))}
         />
       </div>
@@ -188,11 +174,18 @@ const FeaturedFeedsCard = <T extends ElementType>({
           })}
         >
           <div className={styles.feedCardContents}>
-            <PriceFeedTag feed={feed} />
+            <PriceFeedTag
+              symbol={feed.product.display_symbol}
+              description={feed.product.description}
+              icon={<PriceFeedIcon symbol={feed.product.display_symbol} />}
+            />
             {showPrices && (
               <div className={styles.prices}>
-                <LivePrice feed={feed} />
-                <ChangePercent className={styles.changePercent} feed={feed} />
+                <LivePrice feedKey={feed.product.price_account} />
+                <ChangePercent
+                  className={styles.changePercent}
+                  feedKey={feed.product.price_account}
+                />
               </div>
             )}
           </div>

+ 68 - 28
apps/insights/src/components/PriceFeeds/price-feeds-card.tsx

@@ -17,28 +17,30 @@ import { type ReactNode, Suspense, useCallback, useMemo } from "react";
 import { useFilter, useCollator } from "react-aria";
 
 import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination";
-import { SKELETON_WIDTH } from "../LivePrices";
+import { FeedKey } from "../FeedKey";
+import {
+  SKELETON_WIDTH,
+  LivePrice,
+  LiveConfidence,
+  LiveValue,
+} from "../LivePrices";
 import { NoResults } from "../NoResults";
+import { PriceFeedTag } from "../PriceFeedTag";
 import rootStyles from "../Root/index.module.scss";
 
 type Props = {
   id: string;
-  nameLoadingSkeleton: ReactNode;
   priceFeeds: PriceFeed[];
 };
 
 type PriceFeed = {
+  icon: ReactNode;
   symbol: string;
   id: string;
   displaySymbol: string;
-  assetClassAsString: string;
-  exponent: ReactNode;
-  numPublishers: ReactNode;
-  price: ReactNode;
-  confidenceInterval: ReactNode;
-  priceFeedId: ReactNode;
-  priceFeedName: ReactNode;
-  assetClass: ReactNode;
+  assetClass: string;
+  exponent: number;
+  numQuoters: number;
 };
 
 export const PriceFeedsCard = ({ priceFeeds, ...props }: Props) => (
@@ -58,7 +60,7 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
   const feedsFilteredByAssetClass = useMemo(
     () =>
       assetClass
-        ? priceFeeds.filter((feed) => feed.assetClassAsString === assetClass)
+        ? priceFeeds.filter((feed) => feed.assetClass === assetClass)
         : priceFeeds,
     [assetClass, priceFeeds],
   );
@@ -83,12 +85,11 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
         .flatMap((item) => item.split(","))
         .filter(Boolean);
       return searchTokens.some((token) =>
-        filter.contains(priceFeed.symbol, token),
+        filter.contains(priceFeed.displaySymbol, token),
       );
     },
     (a, b, { column, direction }) => {
-      const field =
-        column === "assetClass" ? "assetClassAsString" : "displaySymbol";
+      const field = column === "assetClass" ? "assetClass" : "displaySymbol";
       return (
         (direction === "descending" ? -1 : 1) *
         collator.compare(a[field], b[field])
@@ -99,11 +100,54 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
 
   const rows = useMemo(
     () =>
-      paginatedItems.map(({ id, symbol, ...data }) => ({
-        id,
-        href: `/price-feeds/${encodeURIComponent(symbol)}`,
-        data,
-      })),
+      paginatedItems.map(
+        ({
+          icon,
+          id,
+          symbol,
+          displaySymbol,
+          exponent,
+          numQuoters,
+          assetClass,
+        }) => ({
+          id,
+          href: `/price-feeds/${encodeURIComponent(symbol)}`,
+          data: {
+            exponent: (
+              <LiveValue
+                field="exponent"
+                feedKey={id}
+                defaultValue={exponent}
+              />
+            ),
+            numPublishers: (
+              <LiveValue
+                field="numQuoters"
+                feedKey={id}
+                defaultValue={numQuoters}
+              />
+            ),
+            price: <LivePrice feedKey={id} />,
+            confidenceInterval: <LiveConfidence feedKey={id} />,
+            priceFeedName: (
+              <PriceFeedTag compact symbol={displaySymbol} icon={icon} />
+            ),
+            assetClass: (
+              <Badge variant="neutral" style="outline" size="xs">
+                {assetClass.toUpperCase()}
+              </Badge>
+            ),
+            priceFeedId: (
+              <FeedKey
+                // className={styles.feedKey ?? ""}
+                size="xs"
+                variant="ghost"
+                feedKey={id}
+              />
+            ),
+          },
+        }),
+      ),
     [paginatedItems],
   );
 
@@ -119,8 +163,8 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
 
   const assetClasses = useMemo(
     () =>
-      [...new Set(priceFeeds.map((feed) => feed.assetClassAsString))].sort(
-        (a, b) => collator.compare(a, b),
+      [...new Set(priceFeeds.map((feed) => feed.assetClass))].sort((a, b) =>
+        collator.compare(a, b),
       ),
     [priceFeeds, collator],
   );
@@ -147,7 +191,7 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
   );
 };
 
-type PriceFeedsCardContents = Pick<Props, "id" | "nameLoadingSkeleton"> &
+type PriceFeedsCardContents = Pick<Props, "id"> &
   (
     | { isLoading: true }
     | {
@@ -178,11 +222,7 @@ type PriceFeedsCardContents = Pick<Props, "id" | "nameLoadingSkeleton"> &
       }
   );
 
-const PriceFeedsCardContents = ({
-  id,
-  nameLoadingSkeleton,
-  ...props
-}: PriceFeedsCardContents) => (
+const PriceFeedsCardContents = ({ id, ...props }: PriceFeedsCardContents) => (
   <Card
     id={id}
     icon={<ChartLine />}
@@ -256,7 +296,7 @@ const PriceFeedsCardContents = ({
           name: "PRICE FEED",
           isRowHeader: true,
           alignment: "left",
-          loadingSkeleton: nameLoadingSkeleton,
+          loadingSkeleton: <PriceFeedTag compact isLoading />,
           allowsSorting: true,
         },
         {

+ 10 - 2
apps/insights/src/components/Publisher/layout.tsx

@@ -11,8 +11,9 @@ import { Button } from "@pythnetwork/component-library/Button";
 import { DrawerTrigger, Drawer } from "@pythnetwork/component-library/Drawer";
 import { InfoBox } from "@pythnetwork/component-library/InfoBox";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
+import { lookup } from "@pythnetwork/known-publishers";
 import { notFound } from "next/navigation";
-import type { ReactNode } from "react";
+import { type ReactNode, createElement } from "react";
 
 import { ActiveFeedsCard } from "./active-feeds-card";
 import { ChartCard } from "./chart-card";
@@ -71,6 +72,7 @@ export const PublishersLayout = async ({ children, params }: Props) => {
 
   const currentMedianScore = medianScoreHistory.at(-1);
   const previousMedianScore = medianScoreHistory.at(-2);
+  const knownPublisher = lookup(key);
 
   return currentRanking && currentMedianScore && publisher ? (
     <div className={styles.publisherLayout}>
@@ -87,7 +89,13 @@ export const PublishersLayout = async ({ children, params }: Props) => {
           />
         </div>
         <div className={styles.headerRow}>
-          <PublisherTag publisherKey={key} />
+          <PublisherTag
+            publisherKey={key}
+            {...(knownPublisher && {
+              name: knownPublisher.name,
+              icon: createElement(knownPublisher.icon.color),
+            })}
+          />
         </div>
         <section className={styles.stats}>
           <ChartCard

+ 47 - 27
apps/insights/src/components/Publisher/performance.tsx

@@ -3,12 +3,15 @@ import { Network } from "@phosphor-icons/react/dist/ssr/Network";
 import { Badge } from "@pythnetwork/component-library/Badge";
 import { Card } from "@pythnetwork/component-library/Card";
 import { Table } from "@pythnetwork/component-library/Table";
+import { lookup } from "@pythnetwork/known-publishers";
 import { notFound } from "next/navigation";
+import { createElement } from "react";
 
 import { getRankingsWithData } from "./get-rankings-with-data";
 import styles from "./performance.module.scss";
 import { getPublishers } from "../../services/clickhouse";
 import { getTotalFeedCount } from "../../services/pyth";
+import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PriceFeedTag } from "../PriceFeedTag";
 import { PublisherTag } from "../PublisherTag";
 import { Ranking } from "../Ranking";
@@ -75,35 +78,46 @@ export const Performance = async ({ params }: Props) => {
               width: PUBLISHER_SCORE_WIDTH,
             },
           ]}
-          rows={slicedPublishers.map((publisher) => ({
-            id: publisher.key,
-            data: {
-              ranking: (
-                <Ranking isCurrent={publisher.key === key}>
-                  {publisher.rank}
-                </Ranking>
-              ),
-              activeFeeds: publisher.numSymbols,
-              inactiveFeeds: totalFeeds - publisher.numSymbols,
-              medianScore: (
-                <Score
-                  width={PUBLISHER_SCORE_WIDTH}
-                  score={publisher.medianScore}
-                />
-              ),
-              name: <PublisherTag publisherKey={publisher.key} />,
-            },
-            ...(publisher.key !== key && {
-              href: `/publishers/${publisher.key}`,
-            }),
-          }))}
+          rows={slicedPublishers.map((publisher) => {
+            const knownPublisher = lookup(publisher.key);
+            return {
+              id: publisher.key,
+              data: {
+                ranking: (
+                  <Ranking isCurrent={publisher.key === key}>
+                    {publisher.rank}
+                  </Ranking>
+                ),
+                activeFeeds: publisher.numSymbols,
+                inactiveFeeds: totalFeeds - publisher.numSymbols,
+                medianScore: (
+                  <Score
+                    width={PUBLISHER_SCORE_WIDTH}
+                    score={publisher.medianScore}
+                  />
+                ),
+                name: (
+                  <PublisherTag
+                    publisherKey={publisher.key}
+                    {...(knownPublisher && {
+                      name: knownPublisher.name,
+                      icon: createElement(knownPublisher.icon.color),
+                    })}
+                  />
+                ),
+              },
+              ...(publisher.key !== key && {
+                href: `/publishers/${publisher.key}`,
+              }),
+            };
+          })}
         />
       </Card>
-      <Card icon={<Network />} title="High-performing feeds">
+      <Card icon={<Network />} title="High-Performing Feeds">
         <Table
           rounded
           fill
-          label="High-performing feeds"
+          label="High-Performing Feeds"
           columns={feedColumns}
           rows={getFeedRows(
             rankingsWithData
@@ -112,11 +126,11 @@ export const Performance = async ({ params }: Props) => {
           )}
         />
       </Card>
-      <Card icon={<Network />} title="Low-performing feeds">
+      <Card icon={<Network />} title="Low-Performing Feeds">
         <Table
           rounded
           fill
-          label="Low-performing feeds"
+          label="Low-Performing Feeds"
           columns={feedColumns}
           rows={getFeedRows(
             rankingsWithData
@@ -157,7 +171,13 @@ const getFeedRows = (
   rankingsWithData.slice(0, 10).map(({ feed, ranking }) => ({
     id: ranking.symbol,
     data: {
-      asset: <PriceFeedTag compact feed={feed} />,
+      asset: (
+        <PriceFeedTag
+          compact
+          symbol={feed.product.display_symbol}
+          icon={<PriceFeedIcon symbol={feed.symbol} />}
+        />
+      ),
       assetClass: (
         <Badge variant="neutral" style="outline" size="xs">
           {feed.product.asset_type.toUpperCase()}

+ 29 - 30
apps/insights/src/components/PriceComponentsCard/index.tsx → apps/insights/src/components/Publisher/price-feeds-card.tsx

@@ -2,6 +2,7 @@
 
 import { Card } from "@pythnetwork/component-library/Card";
 import { Paginator } from "@pythnetwork/component-library/Paginator";
+import { SearchInput } from "@pythnetwork/component-library/SearchInput";
 import {
   type RowConfig,
   type SortDescriptor,
@@ -12,6 +13,7 @@ import { useFilter, useCollator } from "react-aria";
 
 import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination";
 import { FormattedNumber } from "../FormattedNumber";
+import { PriceFeedTag } from "../PriceFeedTag";
 import rootStyles from "../Root/index.module.scss";
 import { Score } from "../Score";
 
@@ -20,36 +22,28 @@ const SCORE_WIDTH = 24;
 type Props = {
   className?: string | undefined;
   toolbar?: ReactNode;
-  defaultSort: string;
-  defaultDescending?: boolean | undefined;
   priceComponents: PriceComponent[];
-  nameLoadingSkeleton: ReactNode;
 };
 
 type PriceComponent = {
   id: string;
-  nameAsString: string | undefined;
   score: number;
-  name: ReactNode;
+  displaySymbol: string;
   uptimeScore: number;
   deviationPenalty: number | null;
   deviationScore: number;
   stalledPenalty: number;
   stalledScore: number;
+  icon: ReactNode;
 };
 
-export const PriceComponentsCard = ({ priceComponents, ...props }: Props) => (
+export const PriceFeedsCard = ({ priceComponents, ...props }: Props) => (
   <Suspense fallback={<PriceComponentsCardContents isLoading {...props} />}>
     <ResolvedPriceComponentsCard priceComponents={priceComponents} {...props} />
   </Suspense>
 );
 
-const ResolvedPriceComponentsCard = ({
-  priceComponents,
-  defaultSort,
-  defaultDescending,
-  ...props
-}: Props) => {
+const ResolvedPriceComponentsCard = ({ priceComponents, ...props }: Props) => {
   const collator = useCollator();
   const filter = useFilter({ sensitivity: "base", usage: "search" });
 
@@ -69,9 +63,7 @@ const ResolvedPriceComponentsCard = ({
   } = useQueryParamFilterPagination(
     priceComponents,
     (priceComponent, search) =>
-      filter.contains(priceComponent.id, search) ||
-      (priceComponent.nameAsString !== undefined &&
-        filter.contains(priceComponent.nameAsString, search)),
+      filter.contains(priceComponent.displaySymbol, search),
     (a, b, { column, direction }) => {
       switch (column) {
         case "score":
@@ -102,7 +94,7 @@ const ResolvedPriceComponentsCard = ({
         case "name": {
           return (
             (direction === "descending" ? -1 : 1) *
-            collator.compare(a.nameAsString ?? a.id, b.nameAsString ?? b.id)
+            collator.compare(a.displaySymbol, b.displaySymbol)
           );
         }
 
@@ -113,8 +105,8 @@ const ResolvedPriceComponentsCard = ({
     },
     {
       defaultPageSize: 20,
-      defaultSort,
-      defaultDescending: defaultDescending ?? false,
+      defaultSort: "name",
+      defaultDescending: false,
     },
   );
 
@@ -129,11 +121,12 @@ const ResolvedPriceComponentsCard = ({
           deviationScore,
           stalledPenalty,
           stalledScore,
-          ...data
+          displaySymbol,
+          icon,
         }) => ({
           id,
           data: {
-            ...data,
+            name: <PriceFeedTag compact symbol={displaySymbol} icon={icon} />,
             score: <Score score={score} width={SCORE_WIDTH} />,
             uptimeScore: (
               <FormattedNumber
@@ -191,10 +184,7 @@ const ResolvedPriceComponentsCard = ({
   );
 };
 
-type PriceComponentsCardProps = Pick<
-  Props,
-  "className" | "nameLoadingSkeleton" | "toolbar"
-> &
+type PriceComponentsCardProps = Pick<Props, "className" | "toolbar"> &
   (
     | { isLoading: true }
     | {
@@ -224,14 +214,23 @@ type PriceComponentsCardProps = Pick<
 
 const PriceComponentsCardContents = ({
   className,
-  nameLoadingSkeleton,
-  toolbar,
   ...props
 }: PriceComponentsCardProps) => (
   <Card
     className={className}
-    title="Price components"
-    toolbar={toolbar}
+    title="Price Feeds"
+    toolbar={
+      <SearchInput
+        size="sm"
+        width={40}
+        {...(props.isLoading
+          ? { isPending: true, isDisabled: true }
+          : {
+              value: props.search,
+              onChange: props.onSearchChange,
+            })}
+      />
+    }
     {...(!props.isLoading && {
       footer: (
         <Paginator
@@ -247,7 +246,7 @@ const PriceComponentsCardContents = ({
     })}
   >
     <Table
-      label="Price components"
+      label="Price Feeds"
       fill
       rounded
       stickyHeader={rootStyles.headerHeight}
@@ -265,7 +264,7 @@ const PriceComponentsCardContents = ({
           name: "NAME / ID",
           alignment: "left",
           isRowHeader: true,
-          loadingSkeleton: nameLoadingSkeleton,
+          loadingSkeleton: <PriceFeedTag compact isLoading />,
           allowsSorting: true,
         },
         {

+ 5 - 7
apps/insights/src/components/Publisher/price-feeds.tsx

@@ -1,6 +1,6 @@
 import { getRankingsWithData } from "./get-rankings-with-data";
-import { PriceComponentsCard } from "../PriceComponentsCard";
-import { PriceFeedTag } from "../PriceFeedTag";
+import { PriceFeedsCard } from "./price-feeds-card";
+import { PriceFeedIcon } from "../PriceFeedIcon";
 
 type Props = {
   params: Promise<{
@@ -13,20 +13,18 @@ export const PriceFeeds = async ({ params }: Props) => {
   const rankingsWithData = await getRankingsWithData(key);
 
   return (
-    <PriceComponentsCard
-      defaultSort="name"
+    <PriceFeedsCard
       priceComponents={rankingsWithData.map(({ ranking, feed }) => ({
         id: feed.product.price_account,
-        nameAsString: feed.product.display_symbol,
+        displaySymbol: feed.product.display_symbol,
         score: ranking.final_score,
-        name: <PriceFeedTag compact feed={feed} />,
+        icon: <PriceFeedIcon symbol={feed.symbol} />,
         uptimeScore: ranking.uptime_score,
         deviationPenalty: ranking.deviation_penalty,
         deviationScore: ranking.deviation_score,
         stalledPenalty: ranking.stalled_penalty,
         stalledScore: ranking.stalled_score,
       }))}
-      nameLoadingSkeleton={<PriceFeedTag compact isLoading />}
     />
   );
 };

+ 11 - 0
apps/insights/src/components/PublisherTag/index.module.scss

@@ -9,6 +9,15 @@
   .icon {
     width: theme.spacing(9);
     height: theme.spacing(9);
+    display: grid;
+    place-content: center;
+
+    & > svg {
+      max-width: 100%;
+      max-height: 100%;
+      width: 100%;
+      height: 100%;
+    }
   }
 
   .nameAndKey {
@@ -31,6 +40,8 @@
     border-radius: theme.border-radius("full");
     display: grid;
     place-content: center;
+    width: theme.spacing(9);
+    height: theme.spacing(9);
 
     .undisclosedIcon {
       width: theme.spacing(4);

+ 40 - 40
apps/insights/src/components/PublisherTag/index.tsx

@@ -1,51 +1,51 @@
 import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
-import { lookup as lookupPublisher } from "@pythnetwork/known-publishers";
 import clsx from "clsx";
-import { type ComponentProps, useMemo } from "react";
+import { type ComponentProps, type ReactNode } from "react";
 
 import styles from "./index.module.scss";
 import { PublisherKey } from "../PublisherKey";
 
-type Props = { isLoading: true } | { isLoading?: false; publisherKey: string };
+type Props =
+  | { isLoading: true }
+  | ({
+      isLoading?: false;
+      publisherKey: string;
+    } & (
+      | { name: string; icon: ReactNode }
+      | { name?: undefined; icon?: undefined }
+    ));
 
-export const PublisherTag = (props: Props) => {
-  const knownPublisher = useMemo(
-    () => (props.isLoading ? undefined : lookupPublisher(props.publisherKey)),
-    [props],
-  );
-  const Icon = knownPublisher?.icon.color ?? UndisclosedIcon;
-  return (
-    <div
-      data-loading={props.isLoading ? "" : undefined}
-      className={styles.publisherTag}
-    >
-      {props.isLoading ? (
-        <Skeleton fill className={styles.icon} />
-      ) : (
-        <Icon className={styles.icon} />
-      )}
-      {props.isLoading ? (
-        <Skeleton width={30} />
-      ) : (
-        <>
-          {knownPublisher ? (
-            <div className={styles.nameAndKey}>
-              <div className={styles.name}>{knownPublisher.name}</div>
-              <PublisherKey
-                className={styles.key ?? ""}
-                publisherKey={props.publisherKey}
-                size="xs"
-              />
-            </div>
-          ) : (
-            <PublisherKey publisherKey={props.publisherKey} size="sm" />
-          )}
-        </>
-      )}
-    </div>
-  );
-};
+export const PublisherTag = (props: Props) => (
+  <div
+    data-loading={props.isLoading ? "" : undefined}
+    className={styles.publisherTag}
+  >
+    {props.isLoading ? (
+      <Skeleton fill className={styles.icon} />
+    ) : (
+      <div className={styles.icon}>{props.icon ?? <UndisclosedIcon />}</div>
+    )}
+    {props.isLoading ? (
+      <Skeleton width={30} />
+    ) : (
+      <>
+        {props.name ? (
+          <div className={styles.nameAndKey}>
+            <div className={styles.name}>{props.name}</div>
+            <PublisherKey
+              className={styles.key ?? ""}
+              publisherKey={props.publisherKey}
+              size="xs"
+            />
+          </div>
+        ) : (
+          <PublisherKey publisherKey={props.publisherKey} size="sm" />
+        )}
+      </>
+    )}
+  </div>
+);
 
 const UndisclosedIcon = ({ className, ...props }: ComponentProps<"div">) => (
   <div className={clsx(styles.undisclosedIconWrapper, className)} {...props}>

+ 15 - 9
apps/insights/src/components/Publishers/index.tsx

@@ -6,6 +6,7 @@ import { Button } from "@pythnetwork/component-library/Button";
 import { Card } from "@pythnetwork/component-library/Card";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
 import { lookup as lookupPublisher } from "@pythnetwork/known-publishers";
+import { createElement } from "react";
 
 import styles from "./index.module.scss";
 import { PublishersCard } from "./publishers-card";
@@ -151,15 +152,20 @@ export const Publishers = async () => {
           className={styles.publishersCard}
           nameLoadingSkeleton={<PublisherTag isLoading />}
           publishers={publishers.map(
-            ({ key, rank, numSymbols, medianScore }) => ({
-              id: key,
-              nameAsString: lookupPublisher(key)?.name,
-              name: <PublisherTag publisherKey={key} />,
-              ranking: rank,
-              activeFeeds: numSymbols,
-              inactiveFeeds: totalFeeds - numSymbols,
-              medianScore: medianScore,
-            }),
+            ({ key, rank, numSymbols, medianScore }) => {
+              const knownPublisher = lookupPublisher(key);
+              return {
+                id: key,
+                ranking: rank,
+                activeFeeds: numSymbols,
+                inactiveFeeds: totalFeeds - numSymbols,
+                medianScore: medianScore,
+                ...(knownPublisher && {
+                  name: knownPublisher.name,
+                  icon: createElement(knownPublisher.icon.color),
+                }),
+              };
+            },
           )}
         />
       </div>

+ 37 - 17
apps/insights/src/components/Publishers/publishers-card.tsx

@@ -15,6 +15,7 @@ import { useFilter, useCollator } from "react-aria";
 
 import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination";
 import { NoResults } from "../NoResults";
+import { PublisherTag } from "../PublisherTag";
 import { Ranking } from "../Ranking";
 import rootStyles from "../Root/index.module.scss";
 import { Score } from "../Score";
@@ -29,13 +30,14 @@ type Props = {
 
 type Publisher = {
   id: string;
-  nameAsString: string | undefined;
-  name: ReactNode;
   ranking: number;
   activeFeeds: number;
   inactiveFeeds: number;
   medianScore: number;
-};
+} & (
+  | { name: string; icon: ReactNode }
+  | { name?: undefined; icon?: undefined }
+);
 
 export const PublishersCard = ({ publishers, ...props }: Props) => (
   <Suspense fallback={<PublishersCardContents isLoading {...props} />}>
@@ -63,8 +65,7 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
     publishers,
     (publisher, search) =>
       filter.contains(publisher.id, search) ||
-      (publisher.nameAsString !== undefined &&
-        filter.contains(publisher.nameAsString, search)),
+      (publisher.name !== undefined && filter.contains(publisher.name, search)),
     (a, b, { column, direction }) => {
       switch (column) {
         case "ranking":
@@ -79,7 +80,7 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
         case "name": {
           return (
             (direction === "descending" ? -1 : 1) *
-            collator.compare(a.nameAsString ?? a.id, b.nameAsString ?? b.id)
+            collator.compare(a.name ?? a.id, b.name ?? b.id)
           );
         }
 
@@ -95,17 +96,36 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
 
   const rows = useMemo(
     () =>
-      paginatedItems.map(({ id, ranking, medianScore, ...data }) => ({
-        id,
-        href: `/publishers/${id}`,
-        data: {
-          ...data,
-          ranking: <Ranking>{ranking}</Ranking>,
-          medianScore: (
-            <Score score={medianScore} width={PUBLISHER_SCORE_WIDTH} />
-          ),
-        },
-      })),
+      paginatedItems.map(
+        ({
+          id,
+          ranking,
+          medianScore,
+          activeFeeds,
+          inactiveFeeds,
+          ...publisher
+        }) => ({
+          id,
+          href: `/publishers/${id}`,
+          data: {
+            ranking: <Ranking>{ranking}</Ranking>,
+            name: (
+              <PublisherTag
+                publisherKey={id}
+                {...(publisher.name && {
+                  name: publisher.name,
+                  icon: publisher.icon,
+                })}
+              />
+            ),
+            activeFeeds,
+            inactiveFeeds,
+            medianScore: (
+              <Score score={medianScore} width={PUBLISHER_SCORE_WIDTH} />
+            ),
+          },
+        }),
+      ),
     [paginatedItems],
   );
 

+ 1 - 5
apps/insights/src/components/Root/theme-switch.module.scss

@@ -19,11 +19,7 @@
       position: absolute;
       top: 0;
       right: 0;
-
-      .icon {
-        width: 100%;
-        height: 100%;
-      }
+      font-size: theme.spacing(5);
     }
   }
 }

+ 8 - 8
apps/insights/src/components/Root/theme-switch.tsx

@@ -12,11 +12,11 @@ import clsx from "clsx";
 import { motion } from "motion/react";
 import { useTheme } from "next-themes";
 import {
+  type ReactNode,
   type ElementType,
   useCallback,
   useRef,
   useMemo,
-  type ComponentType,
 } from "react";
 import { useIsSSR } from "react-aria";
 
@@ -62,26 +62,26 @@ const IconPath = ({ className, ...props }: Omit<IconProps, "offset">) => {
     <div className={className} />
   ) : (
     <div className={clsx(styles.iconPath, className)}>
-      <IconMovement icon={Desktop} offset={offsets.desktop} {...props} />
-      <IconMovement icon={Sun} offset={offsets.sun} {...props} />
-      <IconMovement icon={Moon} offset={offsets.moon} {...props} />
+      <IconMovement icon={<Desktop {...props} />} offset={offsets.desktop} />
+      <IconMovement icon={<Sun {...props} />} offset={offsets.sun} />
+      <IconMovement icon={<Moon {...props} />} offset={offsets.moon} />
     </div>
   );
 };
 
-type IconMovementProps = Omit<IconProps, "offset"> & {
-  icon: ComponentType<IconProps>;
+type IconMovementProps = {
+  icon: ReactNode;
   offset: string;
 };
 
-const IconMovement = ({ icon: Icon, offset, ...props }: IconMovementProps) => (
+const IconMovement = ({ icon, offset }: IconMovementProps) => (
   <motion.div
     className={styles.iconMovement}
     animate={{ offsetDistance: offset }}
     transition={{ type: "spring", bounce: 0.35, duration: 0.6 }}
     initial={false}
   >
-    <Icon className={styles.icon} {...props} />
+    {icon}
   </motion.div>
 );
 

+ 3 - 7
packages/component-library/src/Button/index.tsx

@@ -46,10 +46,6 @@ export const Button = (
     <UnstyledButton {...buttonProps(props)} />
   );
 
-type ButtonImplProps = OwnProps & {
-  className?: Parameters<typeof clsx>[0];
-};
-
 const buttonProps = ({
   variant = "primary",
   size = "md",
@@ -59,9 +55,9 @@ const buttonProps = ({
   beforeIcon,
   afterIcon,
   hideText = false,
-  ...inputProps
-}: ButtonImplProps) => ({
-  ...inputProps,
+  ...otherProps
+}: OwnProps & { className?: Parameters<typeof clsx>[0] }) => ({
+  ...otherProps,
   "data-variant": variant,
   "data-size": size,
   "data-rounded": rounded ? "" : undefined,

+ 1 - 0
packages/component-library/src/Link/index.module.scss

@@ -14,6 +14,7 @@
   outline-offset: 0;
   color: theme.color("link", "normal");
   cursor: pointer;
+  background: transparent;
 
   &[data-focus-visible] {
     outline-color: theme.color("focus-dim");

+ 21 - 9
packages/component-library/src/Link/index.tsx

@@ -1,19 +1,31 @@
 import clsx from "clsx";
-import type { ComponentProps } from "react";
+import type { ComponentProps, ElementType } from "react";
 
 import styles from "./index.module.scss";
+import { Button } from "../unstyled/Button/index.js";
 import { Link as UnstyledLink } from "../unstyled/Link/index.js";
 
 type OwnProps = {
   invert?: boolean | undefined;
 };
-type Props = Omit<ComponentProps<typeof UnstyledLink>, keyof OwnProps> &
+type Props<T extends ElementType> = Omit<ComponentProps<T>, keyof OwnProps> &
   OwnProps;
 
-export const Link = ({ className, invert, ...props }: Props) => (
-  <UnstyledLink
-    className={clsx(styles.link, className)}
-    data-invert={invert ? "" : undefined}
-    {...props}
-  />
-);
+export const Link = (
+  props: Props<typeof Button> | Props<typeof UnstyledLink>,
+) =>
+  "href" in props ? (
+    <UnstyledLink {...mkProps(props)} />
+  ) : (
+    <Button {...mkProps(props)} />
+  );
+
+const mkProps = ({
+  className,
+  invert = false,
+  ...otherProps
+}: OwnProps & { className?: Parameters<typeof clsx>[0] }) => ({
+  ...otherProps,
+  "data-invert": invert ? "" : undefined,
+  className: clsx(styles.link, className),
+});

+ 1 - 1
packages/component-library/src/Switch/index.module.scss

@@ -13,7 +13,6 @@
     display: inline-flex;
     align-items: center;
     padding: 0 theme.spacing(0.5);
-    margin-right: theme.spacing(2);
     justify-content: flex-start;
     transition-property: background-color, border-color, outline-color;
     transition-duration: 100ms;
@@ -35,6 +34,7 @@
     @include theme.text("sm", "normal");
 
     display: inline-block;
+    margin: 0 theme.spacing(2);
   }
 
   &[data-hovered] {