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, }; }