diff --git a/src/components/charts/AdvancedChart.tsx b/src/components/charts/AdvancedChart.tsx
index 8cd184b9d..1f6cf7099 100644
--- a/src/components/charts/AdvancedChart.tsx
+++ b/src/components/charts/AdvancedChart.tsx
@@ -158,7 +158,7 @@ export const AdvancedChart = () => {
const _output: TVChartFormattedData[] = [];
seasonsData.data.forEach((seasonData) => {
// Verify a datapoint is available for this season (some data, like tractor, is not since season 1)
- if (selectedChart.priceScaleKey in seasonData) {
+ if (seasonData[selectedChart.priceScaleKey] !== undefined && seasonData[selectedChart.priceScaleKey] !== null) {
const formatValue = selectedChart.valueFormatter;
const dataPoint = {
value: formatValue(seasonData[selectedChart.priceScaleKey]),
@@ -262,6 +262,11 @@ export const AdvancedChart = () => {
+ {seasonsData.hasUnavailableApiData && (
+
+ Some chart data is temporarily unavailable. Available charts are still shown.
+
+ )}
{selectedCharts.length > 0 ? (
{
- const selectedData = formattedData[i];
- if (!selectedData || selectedData.length === 0) return;
-
- const dataIndex = mode === "last" ? selectedData.length - 1 : 0;
- if (!selectedData[dataIndex]) return;
-
- _time = Math.max(_time, selectedData[dataIndex]?.time?.valueOf() as number);
- _season = Math.max(_season, selectedData[dataIndex]?.customValues?.season || 0);
- _value.push(selectedData[dataIndex]?.value);
- });
-
- if (_time === 0) return undefined;
-
- return {
- time: new Date(_time * 1000)?.toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }),
- value: _value,
- season: _season,
- timestamp: _time,
- };
- }
-
// Set initial points only if we have valid data
if (dataWasSet) {
- const lastPoint = getDataPoint("last");
- const firstPoint = getDataPoint("first");
+ const lastPoint = getChartDataPoint(formattedData, selected.length, "last");
+ const firstPoint = getChartDataPoint(formattedData, selected.length, "first");
if (lastPoint) setLastDataPoint(lastPoint);
if (firstPoint) setFirstDataPoint(firstPoint);
diff --git a/src/components/charts/__tests__/chartDataPoint.test.ts b/src/components/charts/__tests__/chartDataPoint.test.ts
new file mode 100644
index 000000000..b36d1c956
--- /dev/null
+++ b/src/components/charts/__tests__/chartDataPoint.test.ts
@@ -0,0 +1,22 @@
+import type { UTCTimestamp } from "lightweight-charts";
+import { describe, expect, it } from "vitest";
+import { getChartDataPoint } from "../chartDataPoint";
+
+const prices = [
+ { time: 1793412057 as UTCTimestamp, value: 0.032, customValues: { season: 17027 } },
+ { time: 1793415657 as UTCTimestamp, value: 0.033, customValues: { season: 17028 } },
+];
+
+describe("chart legend with unavailable series", () => {
+ it.each(["first", "last"] as const)(
+ "keeps %s values under their own labels when the first series is empty",
+ (mode) => {
+ const point = getChartDataPoint([[], prices, []], 3, mode);
+ expect(point?.value).toEqual([undefined, mode === "first" ? 0.032 : 0.033, undefined]);
+ expect(point?.season).toBe(mode === "first" ? 17027 : 17028);
+ },
+ );
+ it("does not invent a data point when every series is unavailable", () => {
+ expect(getChartDataPoint([[], []], 2, "last")).toBeUndefined();
+ });
+});
diff --git a/src/components/charts/chartDataPoint.ts b/src/components/charts/chartDataPoint.ts
new file mode 100644
index 000000000..9e4d223e0
--- /dev/null
+++ b/src/components/charts/chartDataPoint.ts
@@ -0,0 +1,34 @@
+import type { TVChartFormattedData } from "./TVChart";
+
+export function getChartDataPoint(
+ formattedData: TVChartFormattedData[][] | undefined,
+ numberOfSeries: number,
+ mode: "first" | "last",
+) {
+ if (!formattedData) return undefined;
+
+ let _time = 0;
+ const _value: (number | undefined)[] = Array(numberOfSeries).fill(undefined);
+ let _season = 0;
+
+ Array.from({ length: numberOfSeries }).forEach((_, i) => {
+ const selectedData = formattedData[i];
+ if (!selectedData || selectedData.length === 0) return;
+
+ const dataIndex = mode === "last" ? selectedData.length - 1 : 0;
+ if (!selectedData[dataIndex]) return;
+
+ _time = Math.max(_time, selectedData[dataIndex]?.time?.valueOf() as number);
+ _season = Math.max(_season, selectedData[dataIndex]?.customValues?.season || 0);
+ _value[i] = selectedData[dataIndex]?.value;
+ });
+
+ if (_time === 0) return undefined;
+
+ return {
+ time: new Date(_time * 1000)?.toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }),
+ value: _value,
+ season: _season,
+ timestamp: _time,
+ };
+}
diff --git a/src/state/__tests__/fixtures/advanced-chart.json b/src/state/__tests__/fixtures/advanced-chart.json
new file mode 100644
index 000000000..ad61d1194
--- /dev/null
+++ b/src/state/__tests__/fixtures/advanced-chart.json
@@ -0,0 +1,116 @@
+{
+ "seasons": [
+ {
+ "id": "17028",
+ "sunriseBlock": "53313155",
+ "rewardBeans": "0",
+ "price": "0.033041",
+ "deltaBeans": "1780901",
+ "raining": false,
+ "season": 17028,
+ "createdAt": "1793415657"
+ },
+ {
+ "id": "17027",
+ "sunriseBlock": "53311355",
+ "rewardBeans": "0",
+ "price": "0.033025",
+ "deltaBeans": "1780901",
+ "raining": false,
+ "season": 17027,
+ "createdAt": "1793412057"
+ }
+ ],
+ "fieldHourlySnapshots": [
+ {
+ "id": "0xd1a0d188e861ed9d15773a2f3574a2e94134ba8f-17028",
+ "caseId": "1027",
+ "issuedSoil": "266480000",
+ "deltaSownBeans": "0",
+ "sownBeans": "13512605109982",
+ "deltaPodDemand": "0",
+ "blocksToSoldOutSoil": null,
+ "podRate": "3259.178343635143932583281748148457",
+ "temperature": "4755",
+ "deltaTemperature": "0.5",
+ "season": 17028,
+ "cultivationTemperature": "3488.5",
+ "harvestableIndex": "3155543269527",
+ "harvestablePods": "21777279235",
+ "harvestedPods": "3133765990292",
+ "numberOfSowers": 390,
+ "numberOfSows": 5691,
+ "podIndex": "46436088761157",
+ "realRateOfReturn": "1469.386519778457068490663115523138",
+ "seasonBlock": "53313155",
+ "soil": "266480000",
+ "soilSoldOut": false,
+ "unharvestablePods": "43280545491630"
+ },
+ {
+ "id": "0xd1a0d188e861ed9d15773a2f3574a2e94134ba8f-17027",
+ "caseId": "1027",
+ "issuedSoil": "266478717",
+ "deltaSownBeans": "0",
+ "sownBeans": "13512605109982",
+ "deltaPodDemand": "0",
+ "blocksToSoldOutSoil": null,
+ "podRate": "3259.615484643863734878209906363725",
+ "temperature": "4754.5",
+ "deltaTemperature": "0.5",
+ "season": 17027,
+ "cultivationTemperature": "3488.5",
+ "harvestableIndex": "3155543269527",
+ "harvestablePods": "21777279235",
+ "harvestedPods": "3133765990292",
+ "numberOfSowers": 390,
+ "numberOfSows": 5691,
+ "podIndex": "46436088761157",
+ "realRateOfReturn": "1469.947009841029523088569265707797",
+ "seasonBlock": "53311355",
+ "soil": "266478717",
+ "soilSoldOut": false,
+ "unharvestablePods": "43280545491630"
+ }
+ ],
+ "siloHourlySnapshots": [
+ {
+ "id": "0xd1a0d188e861ed9d15773a2f3574a2e94134ba8f-17028",
+ "beanToMaxLpGpPerBdvRatio": "0",
+ "deltaBeanToMaxLpGpPerBdvRatio": "0",
+ "season": 17028,
+ "stalk": "392841813830493899432259",
+ "cropRatio": "50",
+ "deltaCropRatio": "0"
+ },
+ {
+ "id": "0xd1a0d188e861ed9d15773a2f3574a2e94134ba8f-17027",
+ "beanToMaxLpGpPerBdvRatio": "0",
+ "deltaBeanToMaxLpGpPerBdvRatio": "0",
+ "season": 17027,
+ "stalk": "392841813830493899432259",
+ "cropRatio": "50",
+ "deltaCropRatio": "0"
+ }
+ ],
+ "gaugesInfoHourlySnapshots": [
+ {
+ "id": "gauges-17028",
+ "g0CultivationFactor": "1",
+ "g1BlightFactor": "0",
+ "g1ConvertDownPenalty": "0.08466532500037156",
+ "g2BdvConvertedThisSeason": "0",
+ "g2BonusStalkPerBdv": "1940949367",
+ "g2MaxConvertCapacity": "51381879"
+ },
+ {
+ "id": "gauges-17027",
+ "g0CultivationFactor": "1",
+ "g1BlightFactor": "0",
+ "g1ConvertDownPenalty": "0.08474835557550424",
+ "g2BdvConvertedThisSeason": "0",
+ "g2BonusStalkPerBdv": "1940949367",
+ "g2MaxConvertCapacity": "51381879"
+ }
+ ]
+}
diff --git a/src/state/__tests__/useSeasonsData.test.ts b/src/state/__tests__/useSeasonsData.test.ts
new file mode 100644
index 000000000..05da03d90
--- /dev/null
+++ b/src/state/__tests__/useSeasonsData.test.ts
@@ -0,0 +1,106 @@
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import useSeasonsData from "../useSeasonsData";
+import stalkData from "./fixtures/advanced-chart.json";
+
+const api = vi.hoisted(() => ({
+ apy: undefined as any,
+ tractor: undefined as any,
+ inflow: undefined as any,
+ market: [] as any[],
+ prices: {} as any,
+}));
+vi.mock("wagmi", () => ({ useChainId: () => 8453 }));
+vi.mock("../useTokenData", () => ({
+ default: () => ({ mainToken: { address: "0xb170000aeefa790fa61d6e837d1035906839a3c8", decimals: 6 } }),
+}));
+vi.mock("../seasonal/queries/useSeasonalInternalQueries", () => ({
+ default: () => ({ data: [], isLoading: false }),
+ useMultiSeasonalQueries: () => ({ data: stalkData, isLoading: false }),
+}));
+vi.mock("../seasonal/queries/useSeasonalAPY", () => ({
+ APYWindow: { DAILY: 24, WEEKLY: 168, MONTHLY: 720 },
+ useSeasonalAPYs: () => ({ data: api.apy, isLoading: false, isError: !api.apy }),
+}));
+vi.mock("../seasonal/queries/useSeasonalTractorSnapshots", () => ({
+ default: () => ({ data: api.tractor, isLoading: false, isError: !api.tractor }),
+}));
+vi.mock("../seasonal/queries/useSeasonalInflowSnapshots", () => ({
+ default: () => ({ data: api.inflow, isLoading: false, isError: !api.inflow }),
+}));
+vi.mock("../seasonal/queries/useSeasonalMarketPerformance", () => ({
+ SMPChartType: {},
+ useMarketPerformanceCalc: () => api.prices,
+ useSeasonalMarketPerformanceData: () => ({ data: api.market, isLoading: false }),
+}));
+
+function readSeasons(options = {}) {
+ let result: ReturnType | undefined;
+ function Probe() {
+ result = useSeasonsData(17028, 17028, {
+ seasonSync: true,
+ beanData: false,
+ basinData: false,
+ marketPerformanceData: false,
+ ...options,
+ });
+ return null;
+ }
+ renderToStaticMarkup(createElement(Probe));
+ if (!result) throw new Error("Season probe did not render");
+ return result;
+}
+
+beforeEach(() => {
+ api.apy = undefined;
+ api.tractor = undefined;
+ api.inflow = undefined;
+ api.market = [];
+ api.prices = {};
+});
+
+describe("All explorer during a Pinto API outage", () => {
+ it("does not report errors for disabled API sources on the Seasons page", () => {
+ const result = readSeasons({ apyData: false, tractorData: false, inflowData: false });
+ expect(result.hasUnavailableApiData).toBe(false);
+ expect(result.data).toHaveLength(1);
+ });
+ it.each([undefined, []])("keeps subgraph prices when API results are %s", (missing) => {
+ api.tractor = missing;
+ api.inflow = missing;
+ const { data, hasUnavailableApiData } = readSeasons();
+ expect(hasUnavailableApiData).toBe(true);
+ expect(data).toHaveLength(1);
+ expect(data[0].season).toBe(17028);
+ expect(data[0].price.toNumber()).toBe(0.033);
+ expect(data[0].timestamp).toBe(1793415657);
+ expect(data[0]).not.toHaveProperty("pinto24h");
+ expect(data[0]).not.toHaveProperty("tractorSownPinto");
+ expect(data[0]).not.toHaveProperty("inflowAllDeltaNet");
+ });
+
+ it("shows real zero APY values when the API recovers, without filling missing windows with zero", () => {
+ api.apy = { 24: [{ season: 17028, value: 0, timestamp: new Date(1793415657000) }] };
+ const { data } = readSeasons();
+ expect(data).toHaveLength(1);
+ expect(data[0].pinto24h).toBe(0);
+ expect(data[0]).not.toHaveProperty("pinto7d");
+ expect(data[0]).not.toHaveProperty("pinto30d");
+ });
+});
+
+it("keeps price history when a market token has shorter or missing history", () => {
+ api.market = [
+ { season: 17027, timestamp: "1793412057" },
+ { season: 17028, timestamp: "1793415657" },
+ ];
+ api.prices = { WETH: [{ season: 17028, value: 2000 }], cbETH: [], cbBTC: [], WSOL: [] };
+ const { data } = readSeasons({ marketPerformanceData: true });
+ expect(data[0].price.toNumber()).toBe(0.033);
+ expect(data[0].marketPriceWeth).toBe(2000);
+ expect(data[1].marketPriceWeth).toBeUndefined();
+ expect(data[0].marketPriceCbbtc).toBeUndefined();
+ expect(data[1].season).toBe(17027);
+ expect(data[1].timestamp).toBe(1793412057);
+});
diff --git a/src/state/useSeasonsData.ts b/src/state/useSeasonsData.ts
index 775505fe1..8c48b13b7 100644
--- a/src/state/useSeasonsData.ts
+++ b/src/state/useSeasonsData.ts
@@ -372,13 +372,11 @@ export default function useSeasonsData(
const marketPercentSeasonal = useMarketPerformanceCalc(useMarketPerformanceQuery.data, SMPChartType.PERCENT_SEASONAL);
const transformedData = useMemo(() => {
+ // REST API data is optional: an outage must not hide healthy subgraph charts.
if (
(beanstalkData && Object.keys(useStalkQuery.data || {}).length === 0) ||
(beanData && (useBeanQuery.data?.length ?? 0) === 0) ||
(basinData && (useBasinQuery.data?.length ?? 0) === 0) ||
- (apyData && Object.keys(useAPYQuery.data || {}).length === 0) ||
- (tractorData && (useTractorQuery.data?.length ?? 0) === 0) ||
- (inflowData && (useInflowQuery.data?.length ?? 0) === 0) ||
(marketPerformanceData && (useMarketPerformanceQuery.data?.length ?? 0) === 0)
) {
return [];
@@ -566,9 +564,10 @@ export default function useSeasonsData(
}
if (apyData) {
- allData.pinto30d = apy30d?.[idx]?.value || 0;
- allData.pinto7d = apy7d?.[idx]?.value || 0;
- allData.pinto24h = apy24h?.[idx]?.value || 0;
+ // Omit unavailable points rather than plotting an outage as zero yield.
+ if (apy30d?.[idx]) allData.pinto30d = apy30d[idx].value;
+ if (apy7d?.[idx]) allData.pinto7d = apy7d[idx].value;
+ if (apy24h?.[idx]) allData.pinto24h = apy24h[idx].value;
if (!allData.season) {
allData.season = apy24h?.[idx]?.season;
@@ -578,7 +577,7 @@ export default function useSeasonsData(
if (tractorData) {
// Ensure tractor api response is fully caught up/in sync
- if (!allData.season || tractorSnapshots[idx]?.season === allData.season) {
+ if (tractorSnapshots[idx] && (!allData.season || tractorSnapshots[idx].season === allData.season)) {
allData.tractorSownPinto = TokenValue.fromBlockchain(
tractorSnapshots[idx]?.totalPintoSown || 0n,
PODS.decimals,
@@ -650,11 +649,11 @@ export default function useSeasonsData(
}
if (marketPerformanceData && idx < countSubgraphSeasons) {
- allData.marketPriceWeth = marketPrices.WETH[marketPrices.WETH.length - 1 - idx].value;
- allData.marketPriceCbeth = marketPrices.cbETH[marketPrices.cbETH.length - 1 - idx].value;
+ allData.marketPriceWeth = marketPrices.WETH?.[marketPrices.WETH.length - 1 - idx]?.value;
+ allData.marketPriceCbeth = marketPrices.cbETH?.[marketPrices.cbETH.length - 1 - idx]?.value;
allData.marketPriceWsteth = marketPrices.wstETH?.[marketPrices.wstETH.length - 1 - idx]?.value;
- allData.marketPriceCbbtc = marketPrices.cbBTC[marketPrices.cbBTC.length - 1 - idx].value;
- allData.marketPriceWsol = marketPrices.WSOL[marketPrices.WSOL.length - 1 - idx].value;
+ allData.marketPriceCbbtc = marketPrices.cbBTC?.[marketPrices.cbBTC.length - 1 - idx]?.value;
+ allData.marketPriceWsol = marketPrices.WSOL?.[marketPrices.WSOL.length - 1 - idx]?.value;
allData.marketCumulativeNonPintoUsd =
marketUsdCumulative.NET?.[marketUsdCumulative.NET.length - 1 - idx]?.value;
allData.marketCumulativeWethUsd = marketUsdCumulative.WETH?.[marketUsdCumulative.WETH.length - 1 - idx]?.value;
@@ -665,12 +664,12 @@ export default function useSeasonsData(
allData.marketCumulativeCbbtcUsd =
marketUsdCumulative.cbBTC?.[marketUsdCumulative.cbBTC.length - 1 - idx]?.value;
allData.marketCumulativeWsolUsd = marketUsdCumulative.WSOL?.[marketUsdCumulative.WSOL.length - 1 - idx]?.value;
- allData.marketSeasonalNonPintoUsd = marketUsdSeasonal.NET[marketUsdSeasonal.NET.length - 1 - idx]?.value;
- allData.marketSeasonalWethUsd = marketUsdSeasonal.WETH[marketUsdSeasonal.WETH.length - 1 - idx]?.value;
- allData.marketSeasonalCbethUsd = marketUsdSeasonal.cbETH[marketUsdSeasonal.cbETH.length - 1 - idx]?.value;
+ allData.marketSeasonalNonPintoUsd = marketUsdSeasonal.NET?.[marketUsdSeasonal.NET.length - 1 - idx]?.value;
+ allData.marketSeasonalWethUsd = marketUsdSeasonal.WETH?.[marketUsdSeasonal.WETH.length - 1 - idx]?.value;
+ allData.marketSeasonalCbethUsd = marketUsdSeasonal.cbETH?.[marketUsdSeasonal.cbETH.length - 1 - idx]?.value;
allData.marketSeasonalWstethUsd = marketUsdSeasonal.wstETH?.[marketUsdSeasonal.wstETH.length - 1 - idx]?.value;
- allData.marketSeasonalCbbtcUsd = marketUsdSeasonal.cbBTC[marketUsdSeasonal.cbBTC.length - 1 - idx]?.value;
- allData.marketSeasonalWsolUsd = marketUsdSeasonal.WSOL[marketUsdSeasonal.WSOL.length - 1 - idx]?.value;
+ allData.marketSeasonalCbbtcUsd = marketUsdSeasonal.cbBTC?.[marketUsdSeasonal.cbBTC.length - 1 - idx]?.value;
+ allData.marketSeasonalWsolUsd = marketUsdSeasonal.WSOL?.[marketUsdSeasonal.WSOL.length - 1 - idx]?.value;
allData.marketCumulativeNonPintoPercent =
marketPercentCumulative.NET?.[marketPercentCumulative.NET.length - 1 - idx]?.value;
allData.marketCumulativeWethPercent =
@@ -684,22 +683,22 @@ export default function useSeasonsData(
allData.marketCumulativeWsolPercent =
marketPercentCumulative.WSOL?.[marketPercentCumulative.WSOL.length - 1 - idx]?.value;
allData.marketSeasonalNonPintoPercent =
- marketPercentSeasonal.NET[marketPercentSeasonal.NET.length - 1 - idx]?.value;
+ marketPercentSeasonal.NET?.[marketPercentSeasonal.NET.length - 1 - idx]?.value;
allData.marketSeasonalWethPercent =
- marketPercentSeasonal.WETH[marketPercentSeasonal.WETH.length - 1 - idx]?.value;
+ marketPercentSeasonal.WETH?.[marketPercentSeasonal.WETH.length - 1 - idx]?.value;
allData.marketSeasonalCbethPercent =
- marketPercentSeasonal.cbETH[marketPercentSeasonal.cbETH.length - 1 - idx]?.value;
+ marketPercentSeasonal.cbETH?.[marketPercentSeasonal.cbETH.length - 1 - idx]?.value;
allData.marketSeasonalWstethPercent =
marketPercentSeasonal.wstETH?.[marketPercentSeasonal.wstETH.length - 1 - idx]?.value;
allData.marketSeasonalCbbtcPercent =
- marketPercentSeasonal.cbBTC[marketPercentSeasonal.cbBTC.length - 1 - idx]?.value;
+ marketPercentSeasonal.cbBTC?.[marketPercentSeasonal.cbBTC.length - 1 - idx]?.value;
allData.marketSeasonalWsolPercent =
- marketPercentSeasonal.WSOL[marketPercentSeasonal.WSOL.length - 1 - idx]?.value;
+ marketPercentSeasonal.WSOL?.[marketPercentSeasonal.WSOL.length - 1 - idx]?.value;
- if (!allData.season) {
- const season = marketPerformanceResults[marketPerformanceResults.length - 1 - idx].season;
+ if (!allData.season && idx < marketPerformanceResults.length) {
+ const season = marketPerformanceResults[marketPerformanceResults.length - 1 - idx];
allData.season = season.season;
- allData.timestamp = Number(season.createdTimestamp);
+ allData.timestamp = Number(season.timestamp);
}
}
transformedData.push(allData as SeasonsTableData);
@@ -728,6 +727,10 @@ export default function useSeasonsData(
return {
isFetching: useBeanQuery.isLoading || useStalkQuery.isLoading,
+ hasUnavailableApiData:
+ (apyData && useAPYQuery.isError) ||
+ (tractorData && useTractorQuery.isError) ||
+ (inflowData && useInflowQuery.isError),
data: transformedData,
};
}