use-analytics.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. import { useCallback } from 'react';
  2. import { atom, useAtom, type PrimitiveAtom } from 'jotai';
  3. import { useTabChromeKey } from 'app/context/tab-chrome';
  4. import { useFetch } from 'app/lib/hooks/use-fetch';
  5. import type { AnalyticsBlockData, AnalyticsFilterState, AnalyticsStrings } from './analytics.types';
  6. import { getDeviceTimezone } from './format';
  7. const DEFAULT_FILTERS: AnalyticsFilterState = { range: '7d', platform: '', f: {} };
  8. // One filter state per screen: every analytics block on a screen follows the same
  9. // filter bar, the way the classic page's blocks follow its URL parameters. Native
  10. // keeps one per tab screen (several stay mounted); web has one screen ('web').
  11. // Kept out of the URL on purpose: a query string on the page URL makes the
  12. // Conductor lose the Analytics subtab and refetches the page on the server.
  13. const filtersAtoms = new Map<string, PrimitiveAtom<AnalyticsFilterState>>();
  14. function getFiltersAtom(key: string): PrimitiveAtom<AnalyticsFilterState> {
  15. let filtersAtom = filtersAtoms.get(key);
  16. if (!filtersAtom) {
  17. filtersAtom = atom<AnalyticsFilterState>(DEFAULT_FILTERS);
  18. filtersAtoms.set(key, filtersAtom);
  19. }
  20. return filtersAtom;
  21. }
  22. export function useAnalyticsFilters() {
  23. const [filters, setFilters] = useAtom(getFiltersAtom(useTabChromeKey()));
  24. const setRange = useCallback((range: string) => {
  25. setFilters((prev) => (prev.range === range ? prev : { ...prev, range }));
  26. }, [setFilters]);
  27. const setPlatform = useCallback((platform: string) => {
  28. setFilters((prev) => (prev.platform === platform ? prev : { ...prev, platform }));
  29. }, [setFilters]);
  30. /** Show only rows where `dimension` is `value`; the platform has its own filter. */
  31. const addFilter = useCallback((dimension: string, value: string) => {
  32. if (dimension === 'platform') {
  33. setPlatform(value);
  34. return;
  35. }
  36. setFilters((prev) => (prev.f[dimension] === value ? prev : { ...prev, f: { ...prev.f, [dimension]: value } }));
  37. }, [setFilters, setPlatform]);
  38. const removeFilter = useCallback((dimension: string) => {
  39. setFilters((prev) => {
  40. if (!(dimension in prev.f)) return prev;
  41. const f = { ...prev.f };
  42. delete f[dimension];
  43. return { ...prev, f };
  44. });
  45. }, [setFilters]);
  46. return { filters, setRange, setPlatform, addFilter, removeFilter };
  47. }
  48. const DEFAULT_STRINGS: AnalyticsStrings = {
  49. // Fallbacks for a server that sends no strings; UNA sends them translated.
  50. range: 'Period',
  51. platform: 'Platform',
  52. all_platforms: 'All platforms',
  53. online: 'online in the last {0} minutes',
  54. remove_filter: 'Remove filter: {0}',
  55. filter_by: 'Show only {0}',
  56. previous_period: 'Previous period: {0}',
  57. key_figures: 'Key figures',
  58. visitors: 'Unique visitors',
  59. no_data: 'No data for this period',
  60. error: "Couldn't load this report.",
  61. duration_h: 'h',
  62. duration_m: 'm',
  63. duration_s: 's',
  64. };
  65. export function getStrings(data: AnalyticsBlockData | undefined): AnalyticsStrings {
  66. return { ...DEFAULT_STRINGS, ...(data?.strings || {}) };
  67. }
  68. type ReportResponse<T> = {
  69. status?: number;
  70. code?: number;
  71. error?: unknown;
  72. data?: T;
  73. };
  74. /**
  75. * A block's report from `get_report`, with the screen's filters and the block's own
  76. * params (`local` adds per-block state such as the selected tab).
  77. *
  78. * `fetcher` doesn't throw on UNA errors: `{error, code}` arrives as a body with
  79. * status 500, so the result is checked here. Access denied (403) is `denied`: the
  80. * block renders nothing, like the classic block for a viewer without access.
  81. */
  82. export function useAnalyticsReport<T>(data: AnalyticsBlockData | undefined, local?: Record<string, string>) {
  83. const { filters } = useAnalyticsFilters();
  84. const params: Record<string, unknown> = {
  85. ...(data?.params && typeof data.params === 'object' ? data.params : {}),
  86. ...local,
  87. range: filters.range,
  88. tz: getDeviceTimezone(),
  89. };
  90. if (filters.platform) params.platform = filters.platform;
  91. if (Object.keys(filters.f).length) params.f = filters.f;
  92. const url = data?.report && data.request_url
  93. ? `/api.php?r=${data.request_url}&params[]=${encodeURIComponent(data.report)}&params[]=${encodeURIComponent(JSON.stringify(params))}`
  94. : null;
  95. const query = useFetch<ReportResponse<T>>(url, { keepPreviousData: true });
  96. const response = query.data;
  97. const denied = response?.code === 403 || response?.status === 403;
  98. const report = response && response.status === 200 && !response.error && response.data && typeof response.data === 'object'
  99. ? response.data
  100. : undefined;
  101. return {
  102. report,
  103. /** Render nothing: no access, or a block without an endpoint. */
  104. hidden: denied || !url,
  105. isError: !denied && (!!query.error || (!!response && !report)),
  106. /** Nothing to show yet: the first request is on its way. */
  107. isLoading: !!url && !response && !query.error,
  108. /** Showing the previous filters' data while the new ones load. */
  109. isUpdating: query.isFetching && !!response,
  110. refetch: query.refetch,
  111. };
  112. }