diff --git a/packages/next-common/components/dotusd/dailyStats.jsx b/packages/next-common/components/dotusd/dailyStats.jsx
new file mode 100644
index 0000000000..d38145c45b
--- /dev/null
+++ b/packages/next-common/components/dotusd/dailyStats.jsx
@@ -0,0 +1,190 @@
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+import { Line } from "react-chartjs-2";
+import "next-common/components/charts/globalConfig";
+import hoverLinePlugin from "next-common/components/charts/plugins/hoverLine";
+import { SecondaryCard } from "next-common/components/styled/containers/secondaryCard";
+import Loading from "next-common/components/loading";
+import NoData from "next-common/components/noData";
+import ValueDisplay from "next-common/components/valueDisplay";
+import { useThemeSetting } from "next-common/context/theme";
+import { toPrecision, toPrecisionNumber } from "next-common/utils";
+import { abbreviateBigNumber } from "next-common/utils/viewfuncs";
+import RecordsLink from "./recordsLink";
+import useHolderStats from "./useHolderStats";
+import useIssuanceStats from "./useIssuanceStats";
+
+dayjs.extend(utc);
+
+// dotUSD is an Asset Hub asset with six decimals.
+const DOT_USD_DECIMALS = 6;
+const chartHeight = 240;
+
+function buildChartData(points, color, valueOf) {
+ return {
+ labels: points.map(({ date }) => dayjs.utc(date).format("MMM D")),
+ datasets: [
+ {
+ data: points.map(valueOf),
+ borderColor: color,
+ backgroundColor: color,
+ borderWidth: 2,
+ // A long series would otherwise be covered by its own points.
+ pointRadius: points.length > 31 ? 0 : 2,
+ pointHoverRadius: 4,
+ pointBackgroundColor: color,
+ tension: 0.2,
+ },
+ ],
+ };
+}
+
+function buildChartOptions(
+ points,
+ { textTertiary, neutral100, neutral300, neutral400, tooltipBg },
+ { formatTick, formatTooltip, ticksPrecision },
+) {
+ return {
+ responsive: true,
+ maintainAspectRatio: false,
+ animation: false,
+ interaction: { mode: "index", intersect: false },
+ scales: {
+ x: {
+ border: { display: false },
+ grid: { display: false },
+ ticks: { color: textTertiary, maxRotation: 0, maxTicksLimit: 7 },
+ },
+ y: {
+ border: { display: false },
+ grid: { color: neutral300, drawTicks: false },
+ ticks: {
+ color: textTertiary,
+ maxTicksLimit: 5,
+ precision: ticksPrecision,
+ callback: formatTick,
+ },
+ },
+ },
+ plugins: {
+ legend: { display: false },
+ hoverLine: { lineColor: neutral400, lineWidth: 1 },
+ tooltip: {
+ displayColors: false,
+ backgroundColor: tooltipBg,
+ titleColor: neutral100,
+ bodyColor: neutral100,
+ callbacks: {
+ title: ([{ dataIndex }]) =>
+ dayjs.utc(points[dataIndex].date).format("MMM D, YYYY"),
+ label: ({ parsed }) => formatTooltip(parsed.y),
+ },
+ },
+ },
+ };
+}
+
+// One panel of the trend: the latest value, its description and the line of the metric.
+function StatPanel({
+ points,
+ isLoading,
+ colorKey,
+ valueOf,
+ value,
+ label,
+ action,
+ formatTick,
+ formatTooltip,
+ ticksPrecision,
+ ariaLabel,
+ emptyText,
+}) {
+ const theme = useThemeSetting();
+
+ return (
+
+ {isLoading ? (
+
+
+
+ ) : !points.length ? (
+
+ ) : (
+ <>
+
+
{value}
+
+ {label}
+ {action}
+
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+// The two metrics are read from their own apis, so they load and fail independently.
+export default function DotUsdStats() {
+ const holder = useHolderStats();
+ const issuance = useIssuanceStats();
+ const latestHolder = holder.items[holder.items.length - 1]?.holderCount;
+ const latestIssuance =
+ issuance.items[issuance.items.length - 1]?.totalIssuance;
+
+ return (
+
+
+ toPrecisionNumber(totalIssuance, DOT_USD_DECIMALS)
+ }
+ value={
+ latestIssuance === undefined ? (
+ "—"
+ ) : (
+
+ )
+ }
+ label="Total issued dotUSD"
+ formatTick={(value) => abbreviateBigNumber(value, 2)}
+ formatTooltip={(value) => `${abbreviateBigNumber(value, 2)} dotUSD`}
+ ariaLabel="dotUSD total issuance since launch"
+ emptyText="No issuance data available."
+ />
+
+ holderCount}
+ value={latestHolder ?? "—"}
+ label="Accounts holding the asset"
+ action={}
+ formatTick={(value) => value}
+ ticksPrecision={0}
+ formatTooltip={(value) => `${value} accounts`}
+ ariaLabel="dotUSD holders since launch"
+ emptyText="No holder data available."
+ />
+
+ );
+}
diff --git a/packages/next-common/components/dotusd/holdersTab.jsx b/packages/next-common/components/dotusd/holdersTab.jsx
new file mode 100644
index 0000000000..a287a43b5d
--- /dev/null
+++ b/packages/next-common/components/dotusd/holdersTab.jsx
@@ -0,0 +1,10 @@
+import { Section } from "./layout";
+import DotUsdHolders from "./holders";
+
+export default function DotUsdHoldersTab() {
+ return (
+
+ );
+}
diff --git a/packages/next-common/components/dotusd/index.jsx b/packages/next-common/components/dotusd/index.jsx
index d0fd0c14c2..7f7c68c6af 100644
--- a/packages/next-common/components/dotusd/index.jsx
+++ b/packages/next-common/components/dotusd/index.jsx
@@ -4,7 +4,8 @@ import DotUsdAccount from "./account";
import DotUsdInstance from "./instance";
import DotUsdExternalAssets from "./externalAssets";
import DotUsdPool from "./pool";
-import DotUsdHolders from "./holders";
+import DotUsdStats from "./dailyStats";
+import DotUsdHoldersTab from "./holdersTab";
import DotUsdMintRecords from "./mintRecords";
import DotUsdRedeemRecords from "./redeemRecords";
@@ -37,10 +38,10 @@ function DotUsdOverview() {
);
@@ -49,6 +50,10 @@ function DotUsdOverview() {
function DotUsdTabContent() {
const { activeTab } = useDotUsdTabs();
+ if (activeTab === "holders") {
+ return ;
+ }
+
if (activeTab === "mint") {
return ;
}
diff --git a/packages/next-common/components/dotusd/recordsLink.jsx b/packages/next-common/components/dotusd/recordsLink.jsx
new file mode 100644
index 0000000000..170954e44c
--- /dev/null
+++ b/packages/next-common/components/dotusd/recordsLink.jsx
@@ -0,0 +1,21 @@
+import { ArrowExternalLink } from "@osn/icons/subsquare";
+import Tooltip from "next-common/components/tooltip";
+import { useDotUsdTabs } from "./tabs";
+
+// Switches to the tab of the list the number is summed from.
+export default function RecordsLink({ tab, tooltip }) {
+ const { setActiveTab } = useDotUsdTabs();
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/next-common/components/dotusd/summary.jsx b/packages/next-common/components/dotusd/summary.jsx
index 3109678bad..07b1056a48 100644
--- a/packages/next-common/components/dotusd/summary.jsx
+++ b/packages/next-common/components/dotusd/summary.jsx
@@ -1,4 +1,3 @@
-import { ArrowExternalLink } from "@osn/icons/subsquare";
import BigNumber from "bignumber.js";
import { isNil } from "lodash-es";
import { toPrecision } from "next-common/utils";
@@ -10,7 +9,7 @@ import ValueDisplay from "next-common/components/valueDisplay";
import Tooltip from "next-common/components/tooltip";
import { useDotUsdContext } from "./context";
import usePsmStats from "./usePsmStats";
-import { useDotUsdTabs } from "./tabs";
+import RecordsLink from "./recordsLink";
function EmptyValue() {
return —;
@@ -57,24 +56,6 @@ function SignedAmountValue({ value, symbol, decimals }) {
);
}
-// Switches to the records list the flow number is summed from.
-function RecordsLink({ tab, tooltip }) {
- const { setActiveTab } = useDotUsdTabs();
-
- return (
-
-
-
- );
-}
-
function DebtUsageBar({ ratio, maxDebt }) {
const percent = BigNumber.min(BigNumber.max(ratio, 0), 100).toNumber();
diff --git a/packages/next-common/components/dotusd/tabs.jsx b/packages/next-common/components/dotusd/tabs.jsx
index eab7fec996..a55125dce7 100644
--- a/packages/next-common/components/dotusd/tabs.jsx
+++ b/packages/next-common/components/dotusd/tabs.jsx
@@ -2,6 +2,7 @@ import { createContext, useContext, useState } from "react";
export const DOT_USD_TABS = [
{ value: "overview", label: "Overview" },
+ { value: "holders", label: "Holders" },
{ value: "mint", label: "Mint records" },
{ value: "redeem", label: "Redeem records" },
];
diff --git a/packages/next-common/components/dotusd/useDailyStats.js b/packages/next-common/components/dotusd/useDailyStats.js
new file mode 100644
index 0000000000..c60de820a1
--- /dev/null
+++ b/packages/next-common/components/dotusd/useDailyStats.js
@@ -0,0 +1,50 @@
+import { useEffect, useState } from "react";
+import nextApi from "next-common/services/nextApi";
+
+/**
+ * The daily snapshots of one dotUSD metric served by the backend, oldest first. `days` is
+ * forwarded to the api when set, and the api decides how much history it returns otherwise.
+ */
+export default function useDailyStats(path, days) {
+ const [state, setState] = useState({ items: [], isLoading: true });
+
+ useEffect(() => {
+ let isMounted = true;
+ // Keep the previous points on screen while the next response loads.
+ setState((previous) => ({ ...previous, isLoading: true }));
+
+ const params = days === undefined ? {} : { days };
+
+ nextApi
+ .fetch(path, params)
+ .then(({ result, error }) => {
+ if (!isMounted) {
+ return;
+ }
+
+ if (error) {
+ console.error(`Failed to fetch ${path}`, error);
+ setState({ items: [], isLoading: false });
+ return;
+ }
+
+ setState({
+ items: Array.isArray(result) ? result : [],
+ isLoading: false,
+ });
+ })
+ .catch((error) => {
+ console.error(`Failed to fetch ${path}`, error);
+
+ if (isMounted) {
+ setState({ items: [], isLoading: false });
+ }
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, [path, days]);
+
+ return state;
+}
diff --git a/packages/next-common/components/dotusd/useHolderStats.js b/packages/next-common/components/dotusd/useHolderStats.js
new file mode 100644
index 0000000000..c4adad790c
--- /dev/null
+++ b/packages/next-common/components/dotusd/useHolderStats.js
@@ -0,0 +1,6 @@
+import useDailyStats from "./useDailyStats";
+
+// No days argument: the api returns the whole history since the launch.
+export default function useHolderStats() {
+ return useDailyStats("dotusd/holder-stats");
+}
diff --git a/packages/next-common/components/dotusd/useIssuanceStats.js b/packages/next-common/components/dotusd/useIssuanceStats.js
new file mode 100644
index 0000000000..03e8ee9a57
--- /dev/null
+++ b/packages/next-common/components/dotusd/useIssuanceStats.js
@@ -0,0 +1,6 @@
+import useDailyStats from "./useDailyStats";
+
+// No days argument: the api returns the whole history since the launch.
+export default function useIssuanceStats() {
+ return useDailyStats("dotusd/issuance-stats");
+}