@adventurelabs/scout-core 2.0.14 → 2.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,226 @@
1
1
  "use client";
2
2
  import { useState, useCallback, useMemo, useEffect, useRef } from "react";
3
3
  import { useGetSessionsInfiniteByHerdQuery, useGetSessionsInfiniteByDeviceQuery, useGetEventsInfiniteByHerdQuery, useGetEventsInfiniteByDeviceQuery, useGetArtifactsInfiniteByHerdQuery, useGetArtifactsInfiniteByDeviceQuery, useGetFeedInfiniteByHerdQuery, useGetFeedInfiniteByDeviceQuery, useGetAnalysisJobsInfiniteQuery, useGetAnalysisTasksInfiniteQuery, } from "../store/api";
4
- function useInfiniteFiltersKey(options) {
4
+ /** Signed media URLs are re-minted on every fetch, so they never indicate a changed row. */
5
+ const REMINTED_URL_KEYS = new Set(["media_url", "thumbnail_url", "proxy_url"]);
6
+ function rowsEqual(left, right) {
7
+ if (left === right)
8
+ return true;
9
+ if (typeof left !== "object" ||
10
+ typeof right !== "object" ||
11
+ left === null ||
12
+ right === null) {
13
+ return false;
14
+ }
15
+ if (Array.isArray(left) || Array.isArray(right)) {
16
+ return (Array.isArray(left) &&
17
+ Array.isArray(right) &&
18
+ left.length === right.length &&
19
+ left.every((item, index) => rowsEqual(item, right[index])));
20
+ }
21
+ const keys = Object.keys(left).filter((key) => !REMINTED_URL_KEYS.has(key));
22
+ const rightKeys = Object.keys(right).filter((key) => !REMINTED_URL_KEYS.has(key));
23
+ return (keys.length === rightKeys.length &&
24
+ keys.every((key) => Object.prototype.hasOwnProperty.call(right, key) &&
25
+ rowsEqual(Reflect.get(left, key), Reflect.get(right, key))));
26
+ }
27
+ /**
28
+ * Reuse the previous row object when a refetch returned the same content, so consumers
29
+ * can memoize on row identity. Rows that only differ by a re-minted signed URL keep the
30
+ * earlier object, and therefore the earlier URL, until the row itself changes.
31
+ */
32
+ function preserveRowReferences(previous, next, rowKey) {
33
+ const previousByKey = new Map(previous
34
+ .map((row) => [rowKey(row), row])
35
+ .filter(([key]) => key != null));
36
+ return next.map((row) => {
37
+ const key = rowKey(row);
38
+ const previousRow = key == null ? undefined : previousByKey.get(key);
39
+ return previousRow && rowsEqual(previousRow, row) ? previousRow : row;
40
+ });
41
+ }
42
+ /** Cursors are flat records of scalars, so a shallow comparison identifies a page. */
43
+ function sameCursor(left, right) {
44
+ if (left === right)
45
+ return true;
46
+ if (left === null || right === null)
47
+ return false;
48
+ const keys = Object.keys(left);
49
+ return (keys.length === Object.keys(right).length &&
50
+ keys.every((key) => Reflect.get(left, key) === Reflect.get(right, key)));
51
+ }
52
+ const rowId = (row) => row.id;
53
+ const feedRowKey = (row) => `${row.sort_ts ?? ""}_${row.sort_id ?? ""}_${row.feed_type ?? ""}`;
54
+ const emptyPaging = (key) => ({
55
+ key,
56
+ cursor: null,
57
+ pages: [],
58
+ refreshQueue: null,
59
+ });
60
+ /**
61
+ * Holds the paging state for `key`, read by the query args and by the engine.
62
+ *
63
+ * State carrying a stale key reads as empty, so a new entity starts from its first page in
64
+ * the same render rather than waiting for an effect to clear the previous one.
65
+ */
66
+ function usePaging(key) {
67
+ const [stored, setStored] = useState(() => emptyPaging(key));
68
+ // Memoized so the empty fallback keeps a stable identity: `items` is derived from
69
+ // `state.pages`, and a fresh array each render would churn consumer dependencies.
70
+ const state = useMemo(() => (stored.key === key ? stored : emptyPaging(key)), [key, stored]);
71
+ const update = useCallback((change) => setStored((previous) => change(previous.key === key ? previous : emptyPaging(key))), [key]);
72
+ return { cursor: state.cursor, state, update };
73
+ }
74
+ function upsertPage(state, page, rowKey) {
75
+ const index = state.pages.findIndex((loaded) => sameCursor(loaded.cursor, page.cursor));
76
+ if (index === -1) {
77
+ return { ...state, pages: [...state.pages, page] };
78
+ }
79
+ const loaded = state.pages[index];
80
+ const rows = preserveRowReferences(loaded.rows, page.rows, rowKey);
81
+ const unchanged = loaded.hasMore === page.hasMore &&
82
+ sameCursor(loaded.nextCursor, page.nextCursor) &&
83
+ loaded.rows.length === rows.length &&
84
+ rows.every((row, position) => row === loaded.rows[position]);
85
+ if (unchanged)
86
+ return state;
87
+ const pages = [...state.pages];
88
+ pages[index] = { ...page, rows };
89
+ return { ...state, pages };
90
+ }
91
+ const dropRefreshedCursor = (state) => {
92
+ if (!state.refreshQueue)
93
+ return state;
94
+ const remaining = state.refreshQueue.slice(1);
95
+ return { ...state, refreshQueue: remaining.length > 0 ? remaining : null };
96
+ };
97
+ /**
98
+ * Accumulates cursor-paginated responses into a list.
99
+ *
100
+ * A single query subscription serves every page: the paging state's cursor decides which
101
+ * page is being requested, and each response is upserted into `pages` under that cursor. A
102
+ * preserve-pages refresh walks the cursor back through the loaded pages one at a time,
103
+ * replacing each in place. Changing entity or filters changes the paging key, which empties
104
+ * the list and abandons any refresh in the same render.
105
+ */
106
+ function useInfinitePages({ cursor, state, update }, query, mapping) {
107
+ const latest = useRef({ query, mapping });
108
+ latest.current = { query, mapping };
109
+ const setCursor = useCallback((next) => update((previous) => sameCursor(previous.cursor, next)
110
+ ? previous
111
+ : { ...previous, cursor: next }), [update]);
112
+ /**
113
+ * Re-requests the page the query currently points at. RTK's `refetch` throws
114
+ * synchronously when the query is skipped, so callers see a rejection instead of a fault
115
+ * at the call site.
116
+ */
117
+ const requestPage = useCallback(async () => latest.current.query.refetch().unwrap(), []);
118
+ const { currentData, isFetching } = query;
119
+ const page = useMemo(() => currentData === undefined
120
+ ? undefined
121
+ : latest.current.mapping.toPage(currentData), [currentData]);
122
+ useEffect(() => {
123
+ if (!page)
124
+ return;
125
+ update((previous) => upsertPage(previous, { ...page, cursor }, latest.current.mapping.rowKey));
126
+ }, [cursor, page, update]);
127
+ useEffect(() => {
128
+ const queue = state.refreshQueue;
129
+ if (!queue)
130
+ return;
131
+ const [refreshing] = queue;
132
+ if (!sameCursor(cursor, refreshing)) {
133
+ setCursor(refreshing);
134
+ return;
135
+ }
136
+ let cancelled = false;
137
+ // A refresh that cannot run drops its cursor and moves on, so a rejection never leaves
138
+ // the queue stuck.
139
+ void (async () => {
140
+ try {
141
+ const response = await requestPage();
142
+ if (cancelled)
143
+ return;
144
+ const { mapping } = latest.current;
145
+ const refreshed = mapping.toPage(response);
146
+ update((previous) =>
147
+ // Only apply while this cursor is still the one being refreshed.
148
+ previous.refreshQueue &&
149
+ sameCursor(previous.refreshQueue[0], refreshing)
150
+ ? dropRefreshedCursor(upsertPage(previous, { ...refreshed, cursor: refreshing }, mapping.rowKey))
151
+ : previous);
152
+ }
153
+ catch {
154
+ if (!cancelled)
155
+ update(dropRefreshedCursor);
156
+ }
157
+ })();
158
+ return () => {
159
+ cancelled = true;
160
+ };
161
+ }, [cursor, requestPage, setCursor, state.refreshQueue, update]);
162
+ /** Both modes queue the cursors to re-request, so a refetch always reaches the network. */
163
+ const refetch = useCallback((options) => {
164
+ update((previous) => options?.preservePages && previous.pages.length > 0
165
+ ? {
166
+ ...previous,
167
+ refreshQueue: previous.pages.map((loaded) => loaded.cursor),
168
+ }
169
+ : { ...emptyPaging(previous.key), refreshQueue: [null] });
170
+ }, [update]);
171
+ const lastPage = state.pages[state.pages.length - 1];
172
+ const nextCursor = lastPage?.nextCursor ?? null;
173
+ const hasMore = Boolean(lastPage?.hasMore) && nextCursor !== null;
174
+ const isRefreshing = state.refreshQueue !== null;
175
+ const loadMore = useCallback(() => {
176
+ if (!hasMore || nextCursor === null || isFetching || isRefreshing)
177
+ return;
178
+ // The cursor already resting on `nextCursor` means that page's request failed, since a
179
+ // loaded page would have moved `nextCursor` on. Re-setting it would be a no-op, so the
180
+ // failed request is retried instead of leaving the list unable to advance.
181
+ if (sameCursor(cursor, nextCursor)) {
182
+ void requestPage().catch(() => { });
183
+ return;
184
+ }
185
+ setCursor(nextCursor);
186
+ }, [
187
+ cursor,
188
+ hasMore,
189
+ isFetching,
190
+ isRefreshing,
191
+ nextCursor,
192
+ requestPage,
193
+ setCursor,
194
+ ]);
195
+ const items = useMemo(() => {
196
+ const seen = new Set();
197
+ return state.pages
198
+ .flatMap((loaded) => loaded.rows)
199
+ .filter((row) => {
200
+ const rowIdentity = latest.current.mapping.rowKey(row);
201
+ if (rowIdentity == null)
202
+ return true;
203
+ if (seen.has(rowIdentity))
204
+ return false;
205
+ seen.add(rowIdentity);
206
+ return true;
207
+ });
208
+ }, [state.pages]);
209
+ return {
210
+ items,
211
+ isLoading: state.pages.length === 0 && (isFetching || isRefreshing),
212
+ isRefreshing,
213
+ isLoadingMore: isFetching && state.pages.length > 0 && !isRefreshing,
214
+ hasMore,
215
+ loadMore,
216
+ refetch,
217
+ error: query.error,
218
+ };
219
+ }
220
+ // =====================================================
221
+ // SHARED QUERY ARGS AND RESPONSE MAPPING
222
+ // =====================================================
223
+ function useFiltersKey(options) {
5
224
  return useMemo(() => JSON.stringify({
6
225
  rangeStart: options.rangeStart ?? null,
7
226
  rangeEnd: options.rangeEnd ?? null,
@@ -14,947 +233,165 @@ function useInfiniteFiltersKey(options) {
14
233
  options.minFlightDistanceMeters,
15
234
  ]);
16
235
  }
236
+ const rangeArgs = (options) => ({
237
+ limit: options.limit || 20,
238
+ supabase: options.supabase,
239
+ rangeStart: options.rangeStart ?? null,
240
+ rangeEnd: options.rangeEnd ?? null,
241
+ });
242
+ const flightArgs = (options) => ({
243
+ ...rangeArgs(options),
244
+ minFlightTimeMinutes: options.minFlightTimeMinutes ?? null,
245
+ minFlightDistanceMeters: options.minFlightDistanceMeters ?? null,
246
+ });
247
+ const sessionsPage = (response) => ({
248
+ rows: response.sessions,
249
+ nextCursor: response.nextCursor,
250
+ hasMore: response.hasMore,
251
+ });
252
+ const eventsPage = (response) => ({
253
+ rows: response.events,
254
+ nextCursor: response.nextCursor,
255
+ hasMore: response.hasMore,
256
+ });
257
+ const artifactsPage = (response) => ({
258
+ rows: response.artifacts,
259
+ nextCursor: response.nextCursor,
260
+ hasMore: response.hasMore,
261
+ });
262
+ const feedPage = (response) => {
263
+ const rows = Array.isArray(response.items) ? response.items : [];
264
+ return {
265
+ rows,
266
+ nextCursor: response.nextCursor,
267
+ // The feed RPC can return fewer rows than the limit while more still exist, so any
268
+ // non-empty page with a next cursor means there is more to load.
269
+ hasMore: rows.length > 0 && response.nextCursor !== null,
270
+ };
271
+ };
272
+ const analysisJobsPage = (response) => ({
273
+ rows: response.jobs,
274
+ nextCursor: response.nextCursor,
275
+ hasMore: response.hasMore,
276
+ });
277
+ const analysisTasksPage = (response) => ({
278
+ rows: response.tasks,
279
+ nextCursor: response.nextCursor,
280
+ hasMore: response.hasMore,
281
+ });
17
282
  // =====================================================
18
- // SESSIONS INFINITE SCROLL HOOKS
283
+ // SESSIONS
19
284
  // =====================================================
20
285
  export const useInfiniteSessionsByHerd = (herdId, options) => {
21
- const [pages, setPages] = useState([]);
22
- const [currentCursor, setCurrentCursor] = useState(null);
23
- const prevHerdIdRef = useRef();
24
- const infiniteFiltersKey = useInfiniteFiltersKey(options);
25
- const prevInfiniteFiltersKeyRef = useRef(null);
26
- const currentQuery = useGetSessionsInfiniteByHerdQuery({
27
- herdId,
28
- limit: options.limit || 20,
29
- cursor: currentCursor,
30
- supabase: options.supabase,
31
- rangeStart: options.rangeStart ?? null,
32
- rangeEnd: options.rangeEnd ?? null,
33
- minFlightTimeMinutes: options.minFlightTimeMinutes ?? null,
34
- minFlightDistanceMeters: options.minFlightDistanceMeters ?? null,
35
- }, {
36
- skip: !options.enabled || !herdId,
286
+ const filtersKey = useFiltersKey(options);
287
+ const paging = usePaging(`herd:${herdId}|${filtersKey}`);
288
+ const query = useGetSessionsInfiniteByHerdQuery({ herdId, cursor: paging.cursor, ...flightArgs(options) }, { skip: !options.enabled || !herdId });
289
+ return useInfinitePages(paging, query, {
290
+ toPage: sessionsPage,
291
+ rowKey: rowId,
37
292
  });
38
- // Reset state when herdId changes
39
- useEffect(() => {
40
- if (prevHerdIdRef.current !== undefined &&
41
- prevHerdIdRef.current !== herdId &&
42
- options.enabled &&
43
- herdId) {
44
- setPages([]);
45
- setCurrentCursor(null);
46
- }
47
- prevHerdIdRef.current = herdId;
48
- }, [herdId, options.enabled]);
49
- useEffect(() => {
50
- if (prevInfiniteFiltersKeyRef.current === null) {
51
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
52
- return;
53
- }
54
- if (prevInfiniteFiltersKeyRef.current !== infiniteFiltersKey) {
55
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
56
- setPages([]);
57
- setCurrentCursor(null);
58
- }
59
- }, [infiniteFiltersKey]);
60
- // Update pages when new data arrives
61
- useEffect(() => {
62
- if (currentQuery.data && !currentQuery.isLoading) {
63
- setPages((prev) => {
64
- const existingPage = prev.find((p) => (p.cursor === null && currentCursor === null) ||
65
- (p.cursor &&
66
- currentCursor &&
67
- p.cursor.id === currentCursor.id &&
68
- p.cursor.timestamp === currentCursor.timestamp));
69
- if (!existingPage) {
70
- return [
71
- ...prev,
72
- { cursor: currentCursor, data: currentQuery.data.sessions },
73
- ];
74
- }
75
- return prev;
76
- });
77
- }
78
- }, [currentQuery.data, currentQuery.isLoading, currentCursor]);
79
- const loadMore = useCallback(() => {
80
- if (currentQuery.data?.hasMore &&
81
- currentQuery.data.nextCursor &&
82
- !currentQuery.isLoading) {
83
- setCurrentCursor(currentQuery.data.nextCursor);
84
- }
85
- }, [currentQuery.data, currentQuery.isLoading]);
86
- const refetch = useCallback(() => {
87
- setPages([]);
88
- setCurrentCursor(null);
89
- currentQuery.refetch();
90
- }, [currentQuery]);
91
- // Flatten all pages into single array
92
- const allItems = useMemo(() => {
93
- return pages.flatMap((page) => page.data);
94
- }, [pages]);
95
- return {
96
- items: allItems,
97
- isLoading: currentQuery.isLoading && pages.length === 0,
98
- isLoadingMore: currentQuery.isLoading && pages.length > 0,
99
- hasMore: currentQuery.data?.hasMore || false,
100
- loadMore,
101
- refetch,
102
- error: currentQuery.error,
103
- };
104
293
  };
105
294
  export const useInfiniteSessionsByDevice = (deviceId, options) => {
106
- const [pages, setPages] = useState([]);
107
- const [currentCursor, setCurrentCursor] = useState(null);
108
- const prevDeviceIdRef = useRef();
109
- const infiniteFiltersKey = useInfiniteFiltersKey(options);
110
- const prevInfiniteFiltersKeyRef = useRef(null);
111
- const currentQuery = useGetSessionsInfiniteByDeviceQuery({
112
- deviceId,
113
- limit: options.limit || 20,
114
- cursor: currentCursor,
115
- supabase: options.supabase,
116
- rangeStart: options.rangeStart ?? null,
117
- rangeEnd: options.rangeEnd ?? null,
118
- minFlightTimeMinutes: options.minFlightTimeMinutes ?? null,
119
- minFlightDistanceMeters: options.minFlightDistanceMeters ?? null,
120
- }, {
121
- skip: !options.enabled || !deviceId,
295
+ const filtersKey = useFiltersKey(options);
296
+ const paging = usePaging(`device:${deviceId}|${filtersKey}`);
297
+ const query = useGetSessionsInfiniteByDeviceQuery({ deviceId, cursor: paging.cursor, ...flightArgs(options) }, { skip: !options.enabled || !deviceId });
298
+ return useInfinitePages(paging, query, {
299
+ toPage: sessionsPage,
300
+ rowKey: rowId,
122
301
  });
123
- // Reset state when deviceId changes
124
- useEffect(() => {
125
- if (prevDeviceIdRef.current !== undefined &&
126
- prevDeviceIdRef.current !== deviceId &&
127
- options.enabled &&
128
- deviceId) {
129
- setPages([]);
130
- setCurrentCursor(null);
131
- }
132
- prevDeviceIdRef.current = deviceId;
133
- }, [deviceId, options.enabled]);
134
- useEffect(() => {
135
- if (prevInfiniteFiltersKeyRef.current === null) {
136
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
137
- return;
138
- }
139
- if (prevInfiniteFiltersKeyRef.current !== infiniteFiltersKey) {
140
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
141
- setPages([]);
142
- setCurrentCursor(null);
143
- }
144
- }, [infiniteFiltersKey]);
145
- useEffect(() => {
146
- if (currentQuery.data && !currentQuery.isLoading) {
147
- setPages((prev) => {
148
- const existingPage = prev.find((p) => (p.cursor === null && currentCursor === null) ||
149
- (p.cursor &&
150
- currentCursor &&
151
- p.cursor.id === currentCursor.id &&
152
- p.cursor.timestamp === currentCursor.timestamp));
153
- if (!existingPage) {
154
- return [
155
- ...prev,
156
- { cursor: currentCursor, data: currentQuery.data.sessions },
157
- ];
158
- }
159
- return prev;
160
- });
161
- }
162
- }, [currentQuery.data, currentQuery.isLoading, currentCursor]);
163
- const loadMore = useCallback(() => {
164
- if (currentQuery.data?.hasMore &&
165
- currentQuery.data.nextCursor &&
166
- !currentQuery.isLoading) {
167
- setCurrentCursor(currentQuery.data.nextCursor);
168
- }
169
- }, [currentQuery.data, currentQuery.isLoading]);
170
- const refetch = useCallback(() => {
171
- setPages([]);
172
- setCurrentCursor(null);
173
- currentQuery.refetch();
174
- }, [currentQuery]);
175
- const allItems = useMemo(() => {
176
- return pages.flatMap((page) => page.data);
177
- }, [pages]);
178
- return {
179
- items: allItems,
180
- isLoading: currentQuery.isLoading && pages.length === 0,
181
- isLoadingMore: currentQuery.isLoading && pages.length > 0,
182
- hasMore: currentQuery.data?.hasMore || false,
183
- loadMore,
184
- refetch,
185
- error: currentQuery.error,
186
- };
187
302
  };
188
303
  // =====================================================
189
- // EVENTS INFINITE SCROLL HOOKS
304
+ // EVENTS
190
305
  // =====================================================
191
306
  export const useInfiniteEventsByHerd = (herdId, options) => {
192
- const [pages, setPages] = useState([]);
193
- const [currentCursor, setCurrentCursor] = useState(null);
194
- const prevHerdIdRef = useRef();
195
- const infiniteFiltersKey = useInfiniteFiltersKey(options);
196
- const prevInfiniteFiltersKeyRef = useRef(null);
197
- const currentQuery = useGetEventsInfiniteByHerdQuery({
198
- herdId,
199
- limit: options.limit || 20,
200
- cursor: currentCursor,
201
- supabase: options.supabase,
202
- rangeStart: options.rangeStart ?? null,
203
- rangeEnd: options.rangeEnd ?? null,
204
- }, {
205
- skip: !options.enabled || !herdId,
307
+ const filtersKey = useFiltersKey(options);
308
+ const paging = usePaging(`herd:${herdId}|${filtersKey}`);
309
+ const query = useGetEventsInfiniteByHerdQuery({ herdId, cursor: paging.cursor, ...rangeArgs(options) }, { skip: !options.enabled || !herdId });
310
+ return useInfinitePages(paging, query, {
311
+ toPage: eventsPage,
312
+ rowKey: rowId,
206
313
  });
207
- // Reset state when herdId changes
208
- useEffect(() => {
209
- if (prevHerdIdRef.current !== undefined &&
210
- prevHerdIdRef.current !== herdId &&
211
- options.enabled &&
212
- herdId) {
213
- setPages([]);
214
- setCurrentCursor(null);
215
- }
216
- prevHerdIdRef.current = herdId;
217
- }, [herdId, options.enabled]);
218
- useEffect(() => {
219
- if (prevInfiniteFiltersKeyRef.current === null) {
220
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
221
- return;
222
- }
223
- if (prevInfiniteFiltersKeyRef.current !== infiniteFiltersKey) {
224
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
225
- setPages([]);
226
- setCurrentCursor(null);
227
- }
228
- }, [infiniteFiltersKey]);
229
- useEffect(() => {
230
- if (currentQuery.data && !currentQuery.isLoading) {
231
- setPages((prev) => {
232
- const existingPage = prev.find((p) => (p.cursor === null && currentCursor === null) ||
233
- (p.cursor &&
234
- currentCursor &&
235
- p.cursor.id === currentCursor.id &&
236
- p.cursor.timestamp === currentCursor.timestamp));
237
- if (!existingPage) {
238
- return [
239
- ...prev,
240
- { cursor: currentCursor, data: currentQuery.data.events },
241
- ];
242
- }
243
- return prev;
244
- });
245
- }
246
- }, [currentQuery.data, currentQuery.isLoading, currentCursor]);
247
- const loadMore = useCallback(() => {
248
- if (currentQuery.data?.hasMore &&
249
- currentQuery.data.nextCursor &&
250
- !currentQuery.isLoading) {
251
- setCurrentCursor(currentQuery.data.nextCursor);
252
- }
253
- }, [currentQuery.data, currentQuery.isLoading]);
254
- const refetch = useCallback(() => {
255
- setPages([]);
256
- setCurrentCursor(null);
257
- currentQuery.refetch();
258
- }, [currentQuery]);
259
- const allItems = useMemo(() => {
260
- return pages.flatMap((page) => page.data);
261
- }, [pages]);
262
- return {
263
- items: allItems,
264
- isLoading: currentQuery.isLoading && pages.length === 0,
265
- isLoadingMore: currentQuery.isLoading && pages.length > 0,
266
- hasMore: currentQuery.data?.hasMore || false,
267
- loadMore,
268
- refetch,
269
- error: currentQuery.error,
270
- };
271
314
  };
272
315
  export const useInfiniteEventsByDevice = (deviceId, options) => {
273
- const [pages, setPages] = useState([]);
274
- const [currentCursor, setCurrentCursor] = useState(null);
275
- const prevDeviceIdRef = useRef();
276
- const infiniteFiltersKey = useInfiniteFiltersKey(options);
277
- const prevInfiniteFiltersKeyRef = useRef(null);
278
- const currentQuery = useGetEventsInfiniteByDeviceQuery({
279
- deviceId,
280
- limit: options.limit || 20,
281
- cursor: currentCursor,
282
- supabase: options.supabase,
283
- rangeStart: options.rangeStart ?? null,
284
- rangeEnd: options.rangeEnd ?? null,
285
- }, {
286
- skip: !options.enabled || !deviceId,
316
+ const filtersKey = useFiltersKey(options);
317
+ const paging = usePaging(`device:${deviceId}|${filtersKey}`);
318
+ const query = useGetEventsInfiniteByDeviceQuery({ deviceId, cursor: paging.cursor, ...rangeArgs(options) }, { skip: !options.enabled || !deviceId });
319
+ return useInfinitePages(paging, query, {
320
+ toPage: eventsPage,
321
+ rowKey: rowId,
287
322
  });
288
- // Reset state when deviceId changes
289
- useEffect(() => {
290
- if (prevDeviceIdRef.current !== undefined &&
291
- prevDeviceIdRef.current !== deviceId &&
292
- options.enabled &&
293
- deviceId) {
294
- setPages([]);
295
- setCurrentCursor(null);
296
- }
297
- prevDeviceIdRef.current = deviceId;
298
- }, [deviceId, options.enabled]);
299
- useEffect(() => {
300
- if (prevInfiniteFiltersKeyRef.current === null) {
301
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
302
- return;
303
- }
304
- if (prevInfiniteFiltersKeyRef.current !== infiniteFiltersKey) {
305
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
306
- setPages([]);
307
- setCurrentCursor(null);
308
- }
309
- }, [infiniteFiltersKey]);
310
- useEffect(() => {
311
- if (currentQuery.data && !currentQuery.isLoading) {
312
- setPages((prev) => {
313
- const existingPage = prev.find((p) => (p.cursor === null && currentCursor === null) ||
314
- (p.cursor &&
315
- currentCursor &&
316
- p.cursor.id === currentCursor.id &&
317
- p.cursor.timestamp === currentCursor.timestamp));
318
- if (!existingPage) {
319
- return [
320
- ...prev,
321
- { cursor: currentCursor, data: currentQuery.data.events },
322
- ];
323
- }
324
- return prev;
325
- });
326
- }
327
- }, [currentQuery.data, currentQuery.isLoading, currentCursor]);
328
- const loadMore = useCallback(() => {
329
- if (currentQuery.data?.hasMore &&
330
- currentQuery.data.nextCursor &&
331
- !currentQuery.isLoading) {
332
- setCurrentCursor(currentQuery.data.nextCursor);
333
- }
334
- }, [currentQuery.data, currentQuery.isLoading]);
335
- const refetch = useCallback(() => {
336
- setPages([]);
337
- setCurrentCursor(null);
338
- currentQuery.refetch();
339
- }, [currentQuery]);
340
- const allItems = useMemo(() => {
341
- return pages.flatMap((page) => page.data);
342
- }, [pages]);
343
- return {
344
- items: allItems,
345
- isLoading: currentQuery.isLoading && pages.length === 0,
346
- isLoadingMore: currentQuery.isLoading && pages.length > 0,
347
- hasMore: currentQuery.data?.hasMore || false,
348
- loadMore,
349
- refetch,
350
- error: currentQuery.error,
351
- };
352
323
  };
353
324
  // =====================================================
354
- // ARTIFACTS INFINITE SCROLL HOOKS
325
+ // ARTIFACTS
355
326
  // =====================================================
356
327
  export const useInfiniteArtifactsByHerd = (herdId, options) => {
357
- const [pages, setPages] = useState([]);
358
- const [currentCursor, setCurrentCursor] = useState(null);
359
- const prevHerdIdRef = useRef();
360
- const infiniteFiltersKey = useInfiniteFiltersKey(options);
361
- const prevInfiniteFiltersKeyRef = useRef(null);
362
- const currentQuery = useGetArtifactsInfiniteByHerdQuery({
363
- herdId,
364
- limit: options.limit || 20,
365
- cursor: currentCursor,
366
- supabase: options.supabase,
367
- rangeStart: options.rangeStart ?? null,
368
- rangeEnd: options.rangeEnd ?? null,
369
- }, {
370
- skip: !options.enabled || !herdId,
328
+ const filtersKey = useFiltersKey(options);
329
+ const paging = usePaging(`herd:${herdId}|${filtersKey}`);
330
+ const query = useGetArtifactsInfiniteByHerdQuery({ herdId, cursor: paging.cursor, ...rangeArgs(options) }, { skip: !options.enabled || !herdId });
331
+ return useInfinitePages(paging, query, {
332
+ toPage: artifactsPage,
333
+ rowKey: rowId,
371
334
  });
372
- // Reset state when herdId changes
373
- useEffect(() => {
374
- if (prevHerdIdRef.current !== undefined &&
375
- prevHerdIdRef.current !== herdId &&
376
- options.enabled &&
377
- herdId) {
378
- setPages([]);
379
- setCurrentCursor(null);
380
- }
381
- prevHerdIdRef.current = herdId;
382
- }, [herdId, options.enabled]);
383
- useEffect(() => {
384
- if (prevInfiniteFiltersKeyRef.current === null) {
385
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
386
- return;
387
- }
388
- if (prevInfiniteFiltersKeyRef.current !== infiniteFiltersKey) {
389
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
390
- setPages([]);
391
- setCurrentCursor(null);
392
- }
393
- }, [infiniteFiltersKey]);
394
- useEffect(() => {
395
- if (currentQuery.data && !currentQuery.isLoading) {
396
- setPages((prev) => {
397
- const existingPage = prev.find((p) => (p.cursor === null && currentCursor === null) ||
398
- (p.cursor &&
399
- currentCursor &&
400
- p.cursor.id === currentCursor.id &&
401
- p.cursor.timestamp === currentCursor.timestamp));
402
- if (!existingPage) {
403
- return [
404
- ...prev,
405
- { cursor: currentCursor, data: currentQuery.data.artifacts },
406
- ];
407
- }
408
- return prev;
409
- });
410
- }
411
- }, [currentQuery.data, currentQuery.isLoading, currentCursor]);
412
- const loadMore = useCallback(() => {
413
- if (currentQuery.data?.hasMore &&
414
- currentQuery.data.nextCursor &&
415
- !currentQuery.isLoading) {
416
- setCurrentCursor(currentQuery.data.nextCursor);
417
- }
418
- }, [currentQuery.data, currentQuery.isLoading]);
419
- const refetch = useCallback(() => {
420
- setPages([]);
421
- setCurrentCursor(null);
422
- currentQuery.refetch();
423
- }, [currentQuery]);
424
- const allItems = useMemo(() => {
425
- return pages.flatMap((page) => page.data);
426
- }, [pages]);
427
- return {
428
- items: allItems,
429
- isLoading: currentQuery.isLoading && pages.length === 0,
430
- isLoadingMore: currentQuery.isLoading && pages.length > 0,
431
- hasMore: currentQuery.data?.hasMore || false,
432
- loadMore,
433
- refetch,
434
- error: currentQuery.error,
435
- };
436
335
  };
437
336
  export const useInfiniteArtifactsByDevice = (deviceId, options) => {
438
- const [pages, setPages] = useState([]);
439
- const [currentCursor, setCurrentCursor] = useState(null);
440
- const prevDeviceIdRef = useRef();
441
- const infiniteFiltersKey = useInfiniteFiltersKey(options);
442
- const prevInfiniteFiltersKeyRef = useRef(null);
443
- const currentQuery = useGetArtifactsInfiniteByDeviceQuery({
444
- deviceId,
445
- limit: options.limit || 20,
446
- cursor: currentCursor,
447
- supabase: options.supabase,
448
- rangeStart: options.rangeStart ?? null,
449
- rangeEnd: options.rangeEnd ?? null,
450
- }, {
451
- skip: !options.enabled || !deviceId,
337
+ const filtersKey = useFiltersKey(options);
338
+ const paging = usePaging(`device:${deviceId}|${filtersKey}`);
339
+ const query = useGetArtifactsInfiniteByDeviceQuery({ deviceId, cursor: paging.cursor, ...rangeArgs(options) }, { skip: !options.enabled || !deviceId });
340
+ return useInfinitePages(paging, query, {
341
+ toPage: artifactsPage,
342
+ rowKey: rowId,
452
343
  });
453
- // Reset state when deviceId changes
454
- useEffect(() => {
455
- if (prevDeviceIdRef.current !== undefined &&
456
- prevDeviceIdRef.current !== deviceId &&
457
- options.enabled &&
458
- deviceId) {
459
- setPages([]);
460
- setCurrentCursor(null);
461
- }
462
- prevDeviceIdRef.current = deviceId;
463
- }, [deviceId, options.enabled]);
464
- useEffect(() => {
465
- if (prevInfiniteFiltersKeyRef.current === null) {
466
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
467
- return;
468
- }
469
- if (prevInfiniteFiltersKeyRef.current !== infiniteFiltersKey) {
470
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
471
- setPages([]);
472
- setCurrentCursor(null);
473
- }
474
- }, [infiniteFiltersKey]);
475
- useEffect(() => {
476
- if (currentQuery.data && !currentQuery.isLoading) {
477
- setPages((prev) => {
478
- const existingPage = prev.find((p) => (p.cursor === null && currentCursor === null) ||
479
- (p.cursor &&
480
- currentCursor &&
481
- p.cursor.id === currentCursor.id &&
482
- p.cursor.timestamp === currentCursor.timestamp));
483
- if (!existingPage) {
484
- return [
485
- ...prev,
486
- { cursor: currentCursor, data: currentQuery.data.artifacts },
487
- ];
488
- }
489
- return prev;
490
- });
491
- }
492
- }, [currentQuery.data, currentQuery.isLoading, currentCursor]);
493
- const loadMore = useCallback(() => {
494
- if (currentQuery.data?.hasMore &&
495
- currentQuery.data.nextCursor &&
496
- !currentQuery.isLoading) {
497
- setCurrentCursor(currentQuery.data.nextCursor);
498
- }
499
- }, [currentQuery.data, currentQuery.isLoading]);
500
- const refetch = useCallback(() => {
501
- setPages([]);
502
- setCurrentCursor(null);
503
- currentQuery.refetch();
504
- }, [currentQuery]);
505
- const allItems = useMemo(() => {
506
- return pages.flatMap((page) => page.data);
507
- }, [pages]);
508
- return {
509
- items: allItems,
510
- isLoading: currentQuery.isLoading && pages.length === 0,
511
- isLoadingMore: currentQuery.isLoading && pages.length > 0,
512
- hasMore: currentQuery.data?.hasMore || false,
513
- loadMore,
514
- refetch,
515
- error: currentQuery.error,
516
- };
517
- };
518
- const feedCursorEq = (a, b) => {
519
- if (a === b)
520
- return true;
521
- if (a == null || b == null)
522
- return false;
523
- return (a.timestamp === b.timestamp &&
524
- a.id === b.id &&
525
- a.feed_type === b.feed_type);
526
344
  };
527
- /** useInfiniteFeedByHerd: logic matches useInfiniteFeedByHerdDummy verbatim; only the fetch is via API (RTK Query) instead of supabase.rpc. */
345
+ // =====================================================
346
+ // FEED (merged events + artifacts)
347
+ // =====================================================
528
348
  export const useInfiniteFeedByHerd = (herdId, options) => {
529
- const limit = options.limit ?? 20;
530
- const enabled = !!(options.enabled && herdId);
531
- const [pages, setPages] = useState([]);
532
- const [currentCursor, setCurrentCursor] = useState(null);
533
- const [currentResult, setCurrentResult] = useState(null);
534
- const prevHerdIdRef = useRef(undefined);
535
- const lastAddedCursorRef = useRef(undefined);
536
- const pagesLengthRef = useRef(0);
537
- /** When true, pass null to the query so we don't request (newHerdId, oldCursor) before state commits. */
538
- const forceNullCursorRef = useRef(false);
539
- const infiniteFiltersKey = useInfiniteFiltersKey(options);
540
- const prevInfiniteFiltersKeyRef = useRef(null);
541
- const cursorForQuery = forceNullCursorRef.current ? null : currentCursor;
542
- const currentQuery = useGetFeedInfiniteByHerdQuery({
543
- herdId,
544
- limit,
545
- cursor: cursorForQuery,
546
- supabase: options.supabase,
547
- rangeStart: options.rangeStart ?? null,
548
- rangeEnd: options.rangeEnd ?? null,
549
- }, { skip: !enabled });
550
- const isLoading = currentQuery.isLoading;
551
- useEffect(() => {
552
- pagesLengthRef.current = pages.length;
553
- }, [pages.length]);
554
- // Reset when herd changes (match dummy: prev !== herdId && enabled && herdId)
555
- useEffect(() => {
556
- if (prevHerdIdRef.current !== undefined &&
557
- prevHerdIdRef.current !== herdId &&
558
- enabled &&
559
- herdId) {
560
- forceNullCursorRef.current = true;
561
- setPages([]);
562
- setCurrentCursor(null);
563
- setCurrentResult(null);
564
- lastAddedCursorRef.current = undefined;
565
- }
566
- prevHerdIdRef.current = herdId;
567
- }, [herdId, enabled]);
568
- useEffect(() => {
569
- if (prevInfiniteFiltersKeyRef.current === null) {
570
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
571
- return;
572
- }
573
- if (prevInfiniteFiltersKeyRef.current !== infiniteFiltersKey) {
574
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
575
- forceNullCursorRef.current = true;
576
- setPages([]);
577
- setCurrentCursor(null);
578
- setCurrentResult(null);
579
- lastAddedCursorRef.current = undefined;
580
- }
581
- }, [infiniteFiltersKey]);
582
- // When cursor changes, clear ref so we merge the new response
583
- useEffect(() => {
584
- lastAddedCursorRef.current = undefined;
585
- }, [currentCursor]);
586
- // Merge when we have data (mirror dummy's .then() logic; fetch is done by RTK Query)
587
- useEffect(() => {
588
- if (!currentQuery.data || currentQuery.isLoading)
589
- return;
590
- const cursor = cursorForQuery;
591
- const items = Array.isArray(currentQuery.data.items)
592
- ? currentQuery.data.items
593
- : [];
594
- const nextCursor = currentQuery.data.nextCursor ?? null;
595
- // Derive hasMore from items we received (like dummy), so we don't get stuck when API
596
- // returns hasMore: false e.g. due to RPC/PostgREST returning fewer rows than limit.
597
- const hasMore = (items.length >= limit || (items.length > 0 && items.length < limit)) &&
598
- nextCursor != null;
599
- // After herd switch we force null cursor; once we've merged that first page, allow normal cursor again
600
- if (cursor === null) {
601
- forceNullCursorRef.current = false;
602
- }
603
- // Only update currentResult for successful response (match dummy)
604
- if (items.length === 0 && cursor === null) {
605
- // Leave currentResult unchanged on spurious empty first page
606
- }
607
- else {
608
- setCurrentResult({
609
- hasMore,
610
- nextCursor,
611
- });
612
- }
613
- if (items.length === 0)
614
- return;
615
- // Skip merge exactly like dummy: full page already added for this cursor and we have pages
616
- if (items.length >= limit &&
617
- feedCursorEq(lastAddedCursorRef.current ?? null, cursor) &&
618
- pagesLengthRef.current > 0) {
619
- return;
620
- }
621
- setPages((prev) => {
622
- const existingPage = prev.find((p) => feedCursorEq(p.cursor, cursor));
623
- const next = !existingPage
624
- ? [...prev, { cursor, data: items }]
625
- : items.length > existingPage.data.length
626
- ? prev.map((p) => feedCursorEq(p.cursor, cursor) ? { cursor, data: items } : p)
627
- : prev;
628
- if (!existingPage && items.length >= limit) {
629
- lastAddedCursorRef.current = cursor;
630
- }
631
- if (existingPage &&
632
- items.length > existingPage.data.length &&
633
- items.length >= limit) {
634
- lastAddedCursorRef.current = cursor;
635
- }
636
- return next;
637
- });
638
- }, [
639
- currentQuery.data,
640
- currentQuery.isLoading,
641
- cursorForQuery,
642
- pages.length,
643
- limit,
644
- ]);
645
- const loadMore = useCallback(() => {
646
- if (currentResult?.hasMore &&
647
- currentResult.nextCursor != null &&
648
- !isLoading) {
649
- setCurrentCursor(currentResult.nextCursor);
650
- }
651
- }, [currentResult, isLoading]);
652
- const refetch = useCallback(() => {
653
- forceNullCursorRef.current = true;
654
- setPages([]);
655
- setCurrentCursor(null);
656
- setCurrentResult(null);
657
- lastAddedCursorRef.current = undefined;
658
- currentQuery.refetch();
659
- }, [currentQuery]);
660
- const allItems = useMemo(() => {
661
- const sorted = [...pages].sort((a, b) => {
662
- if (feedCursorEq(a.cursor, b.cursor))
663
- return 0;
664
- if (a.cursor === null)
665
- return -1;
666
- if (b.cursor === null)
667
- return 1;
668
- const ta = a.cursor.timestamp ?? '';
669
- const tb = b.cursor.timestamp ?? '';
670
- return tb.localeCompare(ta);
671
- });
672
- const seen = new Set();
673
- return sorted.flatMap((p) => p.data).filter((item) => {
674
- const key = `${item.sort_ts ?? ''}_${item.sort_id ?? ''}_${item.feed_type ?? ''}`;
675
- if (seen.has(key))
676
- return false;
677
- seen.add(key);
678
- return true;
679
- });
680
- }, [pages]);
681
- return {
682
- items: allItems,
683
- isLoading: isLoading && pages.length === 0,
684
- isLoadingMore: isLoading && pages.length > 0,
685
- hasMore: currentResult?.hasMore ??
686
- (currentCursor !== null && pages.length > 0) ??
687
- false,
688
- loadMore,
689
- refetch,
690
- error: currentQuery.error,
691
- };
349
+ const filtersKey = useFiltersKey(options);
350
+ const paging = usePaging(`herd:${herdId}|${filtersKey}`);
351
+ const query = useGetFeedInfiniteByHerdQuery({ herdId, cursor: paging.cursor, ...rangeArgs(options) }, { skip: !options.enabled || !herdId });
352
+ return useInfinitePages(paging, query, {
353
+ toPage: feedPage,
354
+ rowKey: feedRowKey,
355
+ });
692
356
  };
693
357
  export const useInfiniteFeedByDevice = (deviceId, options) => {
694
- const [pages, setPages] = useState([]);
695
- const [currentCursor, setCurrentCursor] = useState(null);
696
- const [lastResult, setLastResult] = useState(null);
697
- const prevDeviceIdRef = useRef(undefined);
698
- const lastAddedCursorRef = useRef(undefined);
699
- const pagesLengthRef = useRef(0);
700
- const forceNullCursorRef = useRef(false);
701
- const infiniteFiltersKey = useInfiniteFiltersKey(options);
702
- const prevInfiniteFiltersKeyRef = useRef(null);
703
- const cursorForQuery = forceNullCursorRef.current ? null : currentCursor;
704
- const currentQuery = useGetFeedInfiniteByDeviceQuery({
705
- deviceId,
706
- limit: options.limit || 20,
707
- cursor: cursorForQuery,
708
- supabase: options.supabase,
709
- rangeStart: options.rangeStart ?? null,
710
- rangeEnd: options.rangeEnd ?? null,
711
- }, { skip: !options.enabled || !deviceId });
712
- useEffect(() => {
713
- pagesLengthRef.current = pages.length;
714
- }, [pages.length]);
715
- // Clear all state whenever device id changes (including to/from undefined)
716
- useEffect(() => {
717
- if (prevDeviceIdRef.current !== undefined &&
718
- prevDeviceIdRef.current !== deviceId) {
719
- forceNullCursorRef.current = true;
720
- setPages([]);
721
- setCurrentCursor(null);
722
- lastAddedCursorRef.current = undefined;
723
- setLastResult(null);
724
- }
725
- prevDeviceIdRef.current = deviceId;
726
- }, [deviceId]);
727
- useEffect(() => {
728
- if (prevInfiniteFiltersKeyRef.current === null) {
729
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
730
- return;
731
- }
732
- if (prevInfiniteFiltersKeyRef.current !== infiniteFiltersKey) {
733
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
734
- forceNullCursorRef.current = true;
735
- setPages([]);
736
- setCurrentCursor(null);
737
- lastAddedCursorRef.current = undefined;
738
- setLastResult(null);
739
- }
740
- }, [infiniteFiltersKey]);
741
- // When we request a new page (cursor changed), clear ref so we merge the new response
742
- useEffect(() => {
743
- lastAddedCursorRef.current = undefined;
744
- }, [currentCursor]);
745
- useEffect(() => {
746
- if (!currentQuery.data || currentQuery.isLoading)
747
- return;
748
- const cursor = cursorForQuery;
749
- if (cursor === null) {
750
- forceNullCursorRef.current = false;
751
- }
752
- if (pagesLengthRef.current > 0 &&
753
- feedCursorEq(lastAddedCursorRef.current ?? null, cursor))
754
- return;
755
- const items = Array.isArray(currentQuery.data?.items)
756
- ? currentQuery.data.items
757
- : [];
758
- const limitForPage = options.limit || 20;
759
- const nextCursor = currentQuery.data?.nextCursor ?? null;
760
- const hasMore = (items.length >= limitForPage ||
761
- (items.length > 0 && items.length < limitForPage)) &&
762
- nextCursor != null;
763
- setLastResult({ hasMore, nextCursor });
764
- setPages((prev) => {
765
- const existingPage = prev.find((p) => feedCursorEq(p.cursor, cursor));
766
- if (!existingPage) {
767
- if (items.length >= limitForPage) {
768
- lastAddedCursorRef.current = cursor;
769
- }
770
- return [
771
- ...prev,
772
- { cursor, data: items },
773
- ];
774
- }
775
- return prev;
776
- });
777
- }, [
778
- currentQuery.data,
779
- currentQuery.isLoading,
780
- cursorForQuery,
781
- pages.length,
782
- options.limit,
783
- ]);
784
- const loadMore = useCallback(() => {
785
- if (lastResult?.hasMore &&
786
- lastResult.nextCursor != null &&
787
- !currentQuery.isLoading) {
788
- setCurrentCursor(lastResult.nextCursor);
789
- }
790
- }, [lastResult, currentQuery.isLoading]);
791
- const refetch = useCallback(() => {
792
- forceNullCursorRef.current = true;
793
- setPages([]);
794
- setCurrentCursor(null);
795
- lastAddedCursorRef.current = undefined;
796
- setLastResult(null);
797
- currentQuery.refetch();
798
- }, [currentQuery]);
799
- const allItems = useMemo(() => {
800
- const seen = new Set();
801
- return pages.flatMap((p) => p.data).filter((item) => {
802
- const key = `${item.sort_ts ?? ''}_${item.sort_id ?? ''}_${item.feed_type ?? ''}`;
803
- if (seen.has(key))
804
- return false;
805
- seen.add(key);
806
- return true;
807
- });
808
- }, [pages]);
809
- return {
810
- items: allItems,
811
- isLoading: currentQuery.isLoading && pages.length === 0,
812
- isLoadingMore: currentQuery.isLoading && pages.length > 0,
813
- hasMore: (lastResult?.hasMore ??
814
- (currentCursor !== null && pages.length > 0)) ??
815
- false,
816
- loadMore,
817
- refetch,
818
- error: currentQuery.error,
819
- };
358
+ const filtersKey = useFiltersKey(options);
359
+ const paging = usePaging(`device:${deviceId}|${filtersKey}`);
360
+ const query = useGetFeedInfiniteByDeviceQuery({ deviceId, cursor: paging.cursor, ...rangeArgs(options) }, { skip: !options.enabled || !deviceId });
361
+ return useInfinitePages(paging, query, {
362
+ toPage: feedPage,
363
+ rowKey: feedRowKey,
364
+ });
820
365
  };
821
- function useAnalysisJobsInfiniteFiltersKey(options) {
822
- return useMemo(() => JSON.stringify({ status: options.status ?? null }), [options.status]);
823
- }
824
366
  export const useInfiniteAnalysisJobs = (options) => {
825
- const [pages, setPages] = useState([]);
826
- const [currentCursor, setCurrentCursor] = useState(null);
827
- const infiniteFiltersKey = useAnalysisJobsInfiniteFiltersKey(options);
828
- const prevInfiniteFiltersKeyRef = useRef(null);
829
- const currentQuery = useGetAnalysisJobsInfiniteQuery({
367
+ const status = options.status ?? null;
368
+ const paging = usePaging(`status:${status}`);
369
+ const query = useGetAnalysisJobsInfiniteQuery({
370
+ cursor: paging.cursor,
371
+ status,
830
372
  limit: options.limit || 20,
831
- cursor: currentCursor,
832
373
  supabase: options.supabase,
833
- status: options.status ?? null,
834
- }, {
835
- skip: !options.enabled,
374
+ }, { skip: !options.enabled });
375
+ return useInfinitePages(paging, query, {
376
+ toPage: analysisJobsPage,
377
+ rowKey: rowId,
836
378
  });
837
- useEffect(() => {
838
- if (prevInfiniteFiltersKeyRef.current === null) {
839
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
840
- return;
841
- }
842
- if (prevInfiniteFiltersKeyRef.current !== infiniteFiltersKey) {
843
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
844
- setPages([]);
845
- setCurrentCursor(null);
846
- }
847
- }, [infiniteFiltersKey]);
848
- useEffect(() => {
849
- if (currentQuery.data && !currentQuery.isLoading) {
850
- setPages((prev) => {
851
- const existingPage = prev.find((p) => (p.cursor === null && currentCursor === null) ||
852
- (p.cursor != null &&
853
- currentCursor != null &&
854
- p.cursor.id === currentCursor.id));
855
- if (!existingPage) {
856
- return [
857
- ...prev,
858
- { cursor: currentCursor, data: currentQuery.data.jobs },
859
- ];
860
- }
861
- return prev;
862
- });
863
- }
864
- }, [currentQuery.data, currentQuery.isLoading, currentCursor]);
865
- const loadMore = useCallback(() => {
866
- if (currentQuery.data?.hasMore &&
867
- currentQuery.data.nextCursor &&
868
- !currentQuery.isLoading) {
869
- setCurrentCursor(currentQuery.data.nextCursor);
870
- }
871
- }, [currentQuery.data, currentQuery.isLoading]);
872
- const refetch = useCallback(() => {
873
- setPages([]);
874
- setCurrentCursor(null);
875
- currentQuery.refetch();
876
- }, [currentQuery]);
877
- const allItems = useMemo(() => pages.flatMap((page) => page.data), [pages]);
878
- return {
879
- items: allItems,
880
- isLoading: currentQuery.isLoading && pages.length === 0,
881
- isLoadingMore: currentQuery.isLoading && pages.length > 0,
882
- hasMore: currentQuery.data?.hasMore || false,
883
- loadMore,
884
- refetch,
885
- error: currentQuery.error,
886
- };
887
379
  };
888
- function useAnalysisTasksInfiniteFiltersKey(options) {
889
- return useMemo(() => JSON.stringify({
890
- job_id: options.job_id ?? null,
891
- status: options.status ?? null,
892
- }), [options.job_id, options.status]);
893
- }
894
380
  export const useInfiniteAnalysisTasks = (options) => {
895
- const [pages, setPages] = useState([]);
896
- const [currentCursor, setCurrentCursor] = useState(null);
897
- const infiniteFiltersKey = useAnalysisTasksInfiniteFiltersKey(options);
898
- const prevInfiniteFiltersKeyRef = useRef(null);
899
- const currentQuery = useGetAnalysisTasksInfiniteQuery({
381
+ const status = options.status ?? null;
382
+ const jobId = options.job_id ?? null;
383
+ const paging = usePaging(`job:${jobId}|status:${status}`);
384
+ const query = useGetAnalysisTasksInfiniteQuery({
385
+ cursor: paging.cursor,
386
+ status,
387
+ job_id: jobId,
900
388
  limit: options.limit || 20,
901
- cursor: currentCursor,
902
389
  supabase: options.supabase,
903
- job_id: options.job_id ?? null,
904
- status: options.status ?? null,
905
- }, {
906
- skip: !options.enabled,
390
+ }, { skip: !options.enabled });
391
+ return useInfinitePages(paging, query, {
392
+ toPage: analysisTasksPage,
393
+ rowKey: rowId,
907
394
  });
908
- useEffect(() => {
909
- if (prevInfiniteFiltersKeyRef.current === null) {
910
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
911
- return;
912
- }
913
- if (prevInfiniteFiltersKeyRef.current !== infiniteFiltersKey) {
914
- prevInfiniteFiltersKeyRef.current = infiniteFiltersKey;
915
- setPages([]);
916
- setCurrentCursor(null);
917
- }
918
- }, [infiniteFiltersKey]);
919
- useEffect(() => {
920
- if (currentQuery.data && !currentQuery.isLoading) {
921
- setPages((prev) => {
922
- const existingPage = prev.find((p) => (p.cursor === null && currentCursor === null) ||
923
- (p.cursor != null &&
924
- currentCursor != null &&
925
- p.cursor.id === currentCursor.id));
926
- if (!existingPage) {
927
- return [
928
- ...prev,
929
- { cursor: currentCursor, data: currentQuery.data.tasks },
930
- ];
931
- }
932
- return prev;
933
- });
934
- }
935
- }, [currentQuery.data, currentQuery.isLoading, currentCursor]);
936
- const loadMore = useCallback(() => {
937
- if (currentQuery.data?.hasMore &&
938
- currentQuery.data.nextCursor &&
939
- !currentQuery.isLoading) {
940
- setCurrentCursor(currentQuery.data.nextCursor);
941
- }
942
- }, [currentQuery.data, currentQuery.isLoading]);
943
- const refetch = useCallback(() => {
944
- setPages([]);
945
- setCurrentCursor(null);
946
- currentQuery.refetch();
947
- }, [currentQuery]);
948
- const allItems = useMemo(() => pages.flatMap((page) => page.data), [pages]);
949
- return {
950
- items: allItems,
951
- isLoading: currentQuery.isLoading && pages.length === 0,
952
- isLoadingMore: currentQuery.isLoading && pages.length > 0,
953
- hasMore: currentQuery.data?.hasMore || false,
954
- loadMore,
955
- refetch,
956
- error: currentQuery.error,
957
- };
958
395
  };
959
396
  export const useIntersectionObserver = (callback, options = {}) => {
960
397
  const [element, setElement] = useState(null);