test
1 week ago
useCompleteOnboarding.js
1 week ago
useDateRangePicker.js
1 month ago
useEmail.ts
1 month ago
useEngagementChartData.js
1 month ago
useLicenseSettings.js
1 month ago
useLink.js
1 month ago
useMediaDetail.js
1 month ago
useMediaLibrary.js
1 month ago
useMediaList.ts
1 month ago
usePerformanceSettings.js
1 month ago
useRegisterActivePage.js
1 month ago
useSettingOption.js
1 month ago
useSimpleSettingsPage.js
1 month ago
useTopPerforming.js
1 month ago
useTopVideosPaginated.js
1 month ago
useUpgradeCTA.js
1 month ago
useUserDetail.js
1 month ago
useEngagementChartData.js
362 lines
| 1 | import { useState, useMemo, useEffect, useRef, useCallback } from "@wordpress/element"; |
| 2 | import { __ } from "@wordpress/i18n"; |
| 3 | import { |
| 4 | formatChartData, |
| 5 | padDailyPoints, |
| 6 | format, |
| 7 | getAllTimeRange, |
| 8 | getLastNDays, |
| 9 | DEFAULT_ANALYTICS_DAYS, |
| 10 | fetchViews, |
| 11 | fetchWatchTime, |
| 12 | toAnalyticsDate, |
| 13 | } from "../utils"; |
| 14 | |
| 15 | // Watch time stays in seconds for the chart so low values (e.g. 6s) plot |
| 16 | // above the x-axis. The Y-axis and tooltip handle unit formatting. |
| 17 | const formatData = (data, range) => |
| 18 | padDailyPoints(formatChartData(data, "total", false, format), range, format); |
| 19 | |
| 20 | const useEngagementChartData = ({ showAllTimeSummary = false }) => { |
| 21 | // Analytics endpoints are Pro-only. For free users, skip the fetch and let |
| 22 | // the chart render its built-in "No Stats Available" empty state — rather |
| 23 | // than firing doomed requests that surface as a red error banner. |
| 24 | const isPremium = !! window.prestoPlayer?.isPremium; |
| 25 | |
| 26 | const [activeChartTab, setActiveChartTab] = useState("views"); |
| 27 | const [selectedDates, setSelectedDates] = useState({ from: null, to: null }); |
| 28 | const [mountData, setMountData] = useState(null); |
| 29 | const [isInitialLoading, setIsInitialLoading] = useState(isPremium); |
| 30 | const [filteredResult, setFilteredResult] = useState(null); |
| 31 | const [isFetching, setIsFetching] = useState(false); |
| 32 | // Errors are tracked per-metric so a watch-time failure doesn't paint a |
| 33 | // red banner on the views tab (and vice versa). `filterError` is only |
| 34 | // relevant while a `filteredResult` is in play; it resets on tab switch |
| 35 | // because the in-flight fetch was for the prior tab. |
| 36 | const [mountErrors, setMountErrors] = useState({ |
| 37 | views: null, |
| 38 | watchTime: null, |
| 39 | }); |
| 40 | const [filterError, setFilterError] = useState(null); |
| 41 | |
| 42 | const mountAbortRef = useRef(null); |
| 43 | const fetchAbortRef = useRef(null); |
| 44 | const defaultDateRange = useMemo( |
| 45 | () => getLastNDays(DEFAULT_ANALYTICS_DAYS), |
| 46 | [] |
| 47 | ); |
| 48 | |
| 49 | // --- Mount-level fetch (default last-N-days + optional all-time summary) --- |
| 50 | useEffect(() => { |
| 51 | if (!isPremium) { |
| 52 | return; |
| 53 | } |
| 54 | |
| 55 | mountAbortRef.current = new AbortController(); |
| 56 | const { signal } = mountAbortRef.current; |
| 57 | |
| 58 | const fetchInitialData = async () => { |
| 59 | try { |
| 60 | setIsInitialLoading(true); |
| 61 | setMountErrors({ views: null, watchTime: null }); |
| 62 | |
| 63 | const { from: startDate, to: endDate } = getLastNDays( |
| 64 | DEFAULT_ANALYTICS_DAYS |
| 65 | ); |
| 66 | |
| 67 | const start = toAnalyticsDate(startDate); |
| 68 | const end = toAnalyticsDate(endDate); |
| 69 | |
| 70 | // When the consumer asks for an all-time summary (Dashboard's two |
| 71 | // "All Time …" cards), fetch the all-time totals in parallel — |
| 72 | // the chart itself runs on the default last-N-days window above, |
| 73 | // so the summary needs its own pair of requests. |
| 74 | const requests = [ |
| 75 | fetchViews(start, end, { signal }), |
| 76 | fetchWatchTime(start, end, { signal }), |
| 77 | ]; |
| 78 | if (showAllTimeSummary) { |
| 79 | const all = getAllTimeRange(); |
| 80 | const allStart = toAnalyticsDate(all.from); |
| 81 | const allEnd = toAnalyticsDate(all.to); |
| 82 | requests.push(fetchViews(allStart, allEnd, { signal })); |
| 83 | requests.push(fetchWatchTime(allStart, allEnd, { signal })); |
| 84 | } |
| 85 | |
| 86 | const results = await Promise.allSettled(requests); |
| 87 | |
| 88 | const views = |
| 89 | results[0].status === "fulfilled" |
| 90 | ? results[0].value |
| 91 | : { total: 0, chartData: [] }; |
| 92 | const watchTime = |
| 93 | results[1].status === "fulfilled" |
| 94 | ? results[1].value |
| 95 | : { total: 0, chartData: [], average: 0 }; |
| 96 | |
| 97 | let allTimeViewsTotal = null; |
| 98 | let allTimeWatchTimeTotal = null; |
| 99 | if (showAllTimeSummary) { |
| 100 | if (results[2]?.status === "fulfilled") { |
| 101 | allTimeViewsTotal = results[2].value.total; |
| 102 | } else if (results[2]?.status === "rejected") { |
| 103 | // Aborts are noise; everything else is a genuine failure that |
| 104 | // would otherwise leave the "All Time Views" card silently blank. |
| 105 | if (results[2].reason?.name !== "AbortError") { |
| 106 | console.warn( |
| 107 | "Failed to load all-time views summary:", |
| 108 | results[2].reason |
| 109 | ); |
| 110 | } |
| 111 | } |
| 112 | if (results[3]?.status === "fulfilled") { |
| 113 | allTimeWatchTimeTotal = results[3].value.total; |
| 114 | } else if (results[3]?.status === "rejected") { |
| 115 | if (results[3].reason?.name !== "AbortError") { |
| 116 | console.warn( |
| 117 | "Failed to load all-time watch-time summary:", |
| 118 | results[3].reason |
| 119 | ); |
| 120 | } |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | setMountData({ |
| 125 | totalViews: views.total, |
| 126 | viewsChartData: views.chartData, |
| 127 | totalWatchTime: watchTime.total, |
| 128 | watchTimeChartData: watchTime.chartData, |
| 129 | range: { from: startDate, to: endDate }, |
| 130 | allTimeViews: allTimeViewsTotal, |
| 131 | allTimeWatchTime: allTimeWatchTimeTotal, |
| 132 | }); |
| 133 | |
| 134 | setMountErrors({ |
| 135 | views: |
| 136 | results[0].status === "rejected" |
| 137 | ? __( |
| 138 | "Couldn't load views. Try refreshing the page.", |
| 139 | "presto-player" |
| 140 | ) |
| 141 | : null, |
| 142 | watchTime: |
| 143 | results[1].status === "rejected" |
| 144 | ? __( |
| 145 | "Couldn't load watch time. Try refreshing the page.", |
| 146 | "presto-player" |
| 147 | ) |
| 148 | : null, |
| 149 | }); |
| 150 | } catch (err) { |
| 151 | if (err.name === "AbortError") return; |
| 152 | console.error("Error fetching engagement data:", err); |
| 153 | setMountData({ |
| 154 | totalViews: 0, |
| 155 | viewsChartData: [], |
| 156 | totalWatchTime: 0, |
| 157 | watchTimeChartData: [], |
| 158 | allTimeViews: null, |
| 159 | allTimeWatchTime: null, |
| 160 | }); |
| 161 | setMountErrors({ |
| 162 | views: __( |
| 163 | "Couldn't load views. Try refreshing the page.", |
| 164 | "presto-player" |
| 165 | ), |
| 166 | watchTime: __( |
| 167 | "Couldn't load watch time. Try refreshing the page.", |
| 168 | "presto-player" |
| 169 | ), |
| 170 | }); |
| 171 | } finally { |
| 172 | setIsInitialLoading(false); |
| 173 | } |
| 174 | }; |
| 175 | |
| 176 | fetchInitialData(); |
| 177 | |
| 178 | return () => { |
| 179 | mountAbortRef.current?.abort(); |
| 180 | }; |
| 181 | }, [showAllTimeSummary, isPremium]); |
| 182 | |
| 183 | // --- Derived values --- |
| 184 | const isWatchTime = activeChartTab === "watch-time"; |
| 185 | |
| 186 | const dataToShow = useMemo(() => { |
| 187 | if (filteredResult?.chartData) return filteredResult.chartData; |
| 188 | if (!mountData) return []; |
| 189 | |
| 190 | const source = isWatchTime |
| 191 | ? mountData.watchTimeChartData |
| 192 | : mountData.viewsChartData; |
| 193 | if (!source.length) return []; |
| 194 | |
| 195 | return formatData(source, mountData.range); |
| 196 | }, [isWatchTime, mountData, filteredResult]); |
| 197 | |
| 198 | const currentTotal = useMemo(() => { |
| 199 | if (filteredResult?.total != null) return filteredResult.total; |
| 200 | if (!mountData) return 0; |
| 201 | return isWatchTime |
| 202 | ? (mountData.totalWatchTime || 0) |
| 203 | : (mountData.totalViews || 0); |
| 204 | }, [isWatchTime, mountData, filteredResult]); |
| 205 | |
| 206 | // Pick the error appropriate for what the user is currently looking at: |
| 207 | // - while a date filter is active, the in-flight metric's filter error |
| 208 | // - otherwise, the active tab's mount-fetch error |
| 209 | // This stops a failure on one metric from painting a banner on the |
| 210 | // other tab where the data is fine. |
| 211 | const activeError = useMemo(() => { |
| 212 | if (filteredResult !== null) return filterError; |
| 213 | return isWatchTime ? mountErrors.watchTime : mountErrors.views; |
| 214 | }, [filteredResult, filterError, isWatchTime, mountErrors]); |
| 215 | |
| 216 | // --- Date-filter fetch --- |
| 217 | const fetchChartData = useCallback(async (dates, chartType) => { |
| 218 | if (!isPremium) { |
| 219 | return; |
| 220 | } |
| 221 | if (!dates.from) { |
| 222 | return; |
| 223 | } |
| 224 | |
| 225 | if (fetchAbortRef.current) { |
| 226 | fetchAbortRef.current.abort(); |
| 227 | } |
| 228 | |
| 229 | fetchAbortRef.current = new AbortController(); |
| 230 | const { signal } = fetchAbortRef.current; |
| 231 | |
| 232 | const start = toAnalyticsDate(dates.from); |
| 233 | const end = toAnalyticsDate(dates.to || dates.from); |
| 234 | const isWT = chartType === "watch-time"; |
| 235 | |
| 236 | setIsFetching(true); |
| 237 | |
| 238 | try { |
| 239 | setFilterError(null); |
| 240 | const result = isWT |
| 241 | ? await fetchWatchTime(start, end, { signal }) |
| 242 | : await fetchViews(start, end, { signal }); |
| 243 | |
| 244 | const range = { from: dates.from, to: dates.to || dates.from }; |
| 245 | if (result.chartData?.length) { |
| 246 | setFilteredResult({ |
| 247 | chartData: formatData(result.chartData, range), |
| 248 | total: result.total, |
| 249 | }); |
| 250 | } else { |
| 251 | setFilteredResult({ |
| 252 | chartData: formatData([], range), |
| 253 | total: 0, |
| 254 | }); |
| 255 | } |
| 256 | } catch (err) { |
| 257 | if (err.name !== "AbortError") { |
| 258 | console.error("Error fetching chart data:", err); |
| 259 | setFilterError( |
| 260 | __("Failed to load chart data. Please try again.", "presto-player") |
| 261 | ); |
| 262 | setFilteredResult({ chartData: [], total: 0 }); |
| 263 | } |
| 264 | } finally { |
| 265 | setIsFetching(false); |
| 266 | } |
| 267 | }, [isPremium]); |
| 268 | |
| 269 | useEffect(() => { |
| 270 | if (selectedDates.from) { |
| 271 | fetchChartData(selectedDates, activeChartTab); |
| 272 | } |
| 273 | |
| 274 | return () => { |
| 275 | if (fetchAbortRef.current) { |
| 276 | fetchAbortRef.current.abort(); |
| 277 | } |
| 278 | }; |
| 279 | }, [selectedDates.from, selectedDates.to, activeChartTab, fetchChartData]); |
| 280 | |
| 281 | // --- Callbacks --- |
| 282 | const handleClearFilters = useCallback(() => { |
| 283 | setSelectedDates({ from: null, to: null }); |
| 284 | setFilteredResult(null); |
| 285 | setFilterError(null); |
| 286 | }, []); |
| 287 | |
| 288 | const handleTabChange = useCallback(({ value }) => { |
| 289 | setActiveChartTab(value.slug); |
| 290 | // Drop the prior tab's filtered result + filter error; the effect will |
| 291 | // refetch for the new tab if a date range is still selected. |
| 292 | setFilteredResult(null); |
| 293 | setFilterError(null); |
| 294 | }, []); |
| 295 | |
| 296 | const handleDateApply = useCallback((dates) => { |
| 297 | const { from, to } = dates; |
| 298 | |
| 299 | if (from && to) { |
| 300 | const fromDate = new Date(from); |
| 301 | const toDate = new Date(to); |
| 302 | setSelectedDates(fromDate > toDate ? { from: to, to: from } : dates); |
| 303 | } else if (from && !to) { |
| 304 | setSelectedDates({ from, to: from }); |
| 305 | } else { |
| 306 | setSelectedDates({ from: null, to: null }); |
| 307 | } |
| 308 | }, []); |
| 309 | |
| 310 | // --- Context-ready callbacks (match what consumers expect) --- |
| 311 | const setActiveTab = useCallback( |
| 312 | (slug) => handleTabChange({ value: { slug } }), |
| 313 | [handleTabChange] |
| 314 | ); |
| 315 | |
| 316 | const setSelectedDatesForContext = useCallback( |
| 317 | (dates) => { |
| 318 | if (dates === null) { |
| 319 | handleClearFilters(); |
| 320 | } else { |
| 321 | handleDateApply(dates); |
| 322 | } |
| 323 | }, |
| 324 | [handleClearFilters, handleDateApply] |
| 325 | ); |
| 326 | |
| 327 | // --- Return context-ready value --- |
| 328 | return useMemo( |
| 329 | () => ({ |
| 330 | activeTab: activeChartTab, |
| 331 | setActiveTab, |
| 332 | chartData: dataToShow, |
| 333 | total: currentTotal, |
| 334 | allTimeViews: showAllTimeSummary ? (mountData?.allTimeViews ?? null) : null, |
| 335 | allTimeWatchTime: showAllTimeSummary ? (mountData?.allTimeWatchTime ?? null) : null, |
| 336 | isWatchTime, |
| 337 | selectedDates, |
| 338 | setSelectedDates: setSelectedDatesForContext, |
| 339 | defaultDateRange, |
| 340 | isLoading: isInitialLoading || isFetching, |
| 341 | error: activeError, |
| 342 | }), |
| 343 | [ |
| 344 | activeChartTab, |
| 345 | setActiveTab, |
| 346 | dataToShow, |
| 347 | currentTotal, |
| 348 | showAllTimeSummary, |
| 349 | mountData, |
| 350 | isWatchTime, |
| 351 | selectedDates, |
| 352 | setSelectedDatesForContext, |
| 353 | defaultDateRange, |
| 354 | isInitialLoading, |
| 355 | isFetching, |
| 356 | activeError, |
| 357 | ] |
| 358 | ); |
| 359 | }; |
| 360 | |
| 361 | export default useEngagementChartData; |
| 362 |