@adventurelabs/scout-core 2.0.13 → 2.0.15

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.
Files changed (33) hide show
  1. package/dist/client/index.d.ts +4 -2
  2. package/dist/client/index.js +4 -2
  3. package/dist/helpers/document_templates.d.ts +1 -0
  4. package/dist/helpers/document_templates.js +4 -1
  5. package/dist/helpers/document_templates.queries.d.ts +1 -0
  6. package/dist/helpers/document_templates.queries.js +11 -0
  7. package/dist/helpers/herd_media_usage.d.ts +4 -0
  8. package/dist/helpers/herd_media_usage.js +8 -0
  9. package/dist/helpers/herd_media_usage.queries.d.ts +6 -0
  10. package/dist/helpers/herd_media_usage.queries.js +28 -0
  11. package/dist/helpers/herds.d.ts +2 -1
  12. package/dist/helpers/herds.js +4 -1
  13. package/dist/helpers/herds.queries.d.ts +2 -1
  14. package/dist/helpers/herds.queries.js +12 -0
  15. package/dist/helpers/maintenance_requests_server.d.ts +7 -0
  16. package/dist/helpers/user_last_activity.d.ts +5 -0
  17. package/dist/helpers/user_last_activity.js +12 -0
  18. package/dist/helpers/user_last_activity_server.d.ts +5 -0
  19. package/dist/helpers/user_last_activity_server.js +6 -0
  20. package/dist/hooks/index.d.ts +3 -1
  21. package/dist/hooks/index.js +2 -0
  22. package/dist/hooks/useInfiniteQuery.d.ts +9 -4
  23. package/dist/hooks/useInfiniteQuery.js +319 -905
  24. package/dist/hooks/useScoutRealtimeArtifacts.d.ts +5 -0
  25. package/dist/hooks/useScoutRealtimeArtifacts.js +7 -0
  26. package/dist/hooks/useScoutRealtimeHerdMediaUsage.d.ts +6 -0
  27. package/dist/hooks/useScoutRealtimeHerdMediaUsage.js +7 -0
  28. package/dist/server/index.d.ts +4 -2
  29. package/dist/server/index.js +4 -2
  30. package/dist/types/db.d.ts +3 -0
  31. package/dist/types/realtime.d.ts +2 -0
  32. package/dist/types/supabase.d.ts +96 -10
  33. package/package.json +1 -1
@@ -1,7 +1,203 @@
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
+ /**
55
+ * Holds the cursor of the page the live query should request.
56
+ *
57
+ * State is tagged with `key`, and state carrying a stale key reads as null, so a new entity
58
+ * is never requested with the previous entity's cursor.
59
+ */
60
+ function useCursor(key) {
61
+ const [stored, setStored] = useState(() => ({ key, cursor: null }));
62
+ const setCursor = useCallback((cursor) => setStored({ key, cursor }), [key]);
63
+ return {
64
+ key,
65
+ cursor: stored.key === key ? stored.cursor : null,
66
+ setCursor,
67
+ };
68
+ }
69
+ const noPages = (key) => ({
70
+ key,
71
+ pages: [],
72
+ refreshQueue: null,
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 cursor handle decides which page is
101
+ * 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. Pages are tagged with the handle's key, so changing entity or
104
+ * filters empties the list and abandons any refresh in the same render.
105
+ */
106
+ function useInfinitePages({ key, cursor, setCursor }, query, mapping) {
107
+ const [stored, setStored] = useState(() => noPages(key));
108
+ const latest = useRef({ query, mapping });
109
+ latest.current = { query, mapping };
110
+ const state = stored.key === key ? stored : noPages(key);
111
+ const update = useCallback((change) => {
112
+ setStored((previous) => change(previous.key === key ? previous : noPages(key)));
113
+ }, [key]);
114
+ const { data, isLoading } = query;
115
+ const page = useMemo(() => (data === undefined ? undefined : latest.current.mapping.toPage(data)), [data]);
116
+ useEffect(() => {
117
+ if (!page || isLoading)
118
+ return;
119
+ update((previous) => upsertPage(previous, { ...page, cursor }, latest.current.mapping.rowKey));
120
+ }, [cursor, isLoading, page, update]);
121
+ useEffect(() => {
122
+ const queue = state.refreshQueue;
123
+ if (!queue)
124
+ return;
125
+ const [refreshing] = queue;
126
+ if (!sameCursor(cursor, refreshing)) {
127
+ setCursor(refreshing);
128
+ return;
129
+ }
130
+ let cancelled = false;
131
+ void latest.current.query
132
+ .refetch()
133
+ .unwrap()
134
+ .then((response) => {
135
+ if (cancelled)
136
+ return;
137
+ const { mapping } = latest.current;
138
+ const refreshed = mapping.toPage(response);
139
+ update((previous) =>
140
+ // Only apply while this cursor is still the one being refreshed.
141
+ previous.refreshQueue &&
142
+ sameCursor(previous.refreshQueue[0], refreshing)
143
+ ? dropRefreshedCursor(upsertPage(previous, { ...refreshed, cursor: refreshing }, mapping.rowKey))
144
+ : previous);
145
+ })
146
+ .catch(() => {
147
+ if (!cancelled)
148
+ update(dropRefreshedCursor);
149
+ });
150
+ return () => {
151
+ cancelled = true;
152
+ };
153
+ }, [cursor, setCursor, state.refreshQueue, update]);
154
+ /** Both modes queue the cursors to re-request, so a refetch always reaches the network. */
155
+ const refetch = useCallback((options) => {
156
+ update((previous) => options?.preservePages && previous.pages.length > 0
157
+ ? {
158
+ ...previous,
159
+ refreshQueue: previous.pages.map((loaded) => loaded.cursor),
160
+ }
161
+ : { ...noPages(key), refreshQueue: [null] });
162
+ }, [key, update]);
163
+ const lastPage = state.pages[state.pages.length - 1];
164
+ const nextCursor = lastPage?.nextCursor ?? null;
165
+ const hasMore = Boolean(lastPage?.hasMore) && nextCursor !== null;
166
+ const isRefreshing = state.refreshQueue !== null;
167
+ const loadMore = useCallback(() => {
168
+ if (!hasMore || nextCursor === null || isLoading || isRefreshing)
169
+ return;
170
+ setCursor(nextCursor);
171
+ }, [hasMore, isLoading, isRefreshing, nextCursor, setCursor]);
172
+ const items = useMemo(() => {
173
+ const seen = new Set();
174
+ return state.pages
175
+ .flatMap((loaded) => loaded.rows)
176
+ .filter((row) => {
177
+ const rowIdentity = latest.current.mapping.rowKey(row);
178
+ if (rowIdentity == null)
179
+ return true;
180
+ if (seen.has(rowIdentity))
181
+ return false;
182
+ seen.add(rowIdentity);
183
+ return true;
184
+ });
185
+ }, [state.pages]);
186
+ return {
187
+ items,
188
+ isLoading: isLoading && state.pages.length === 0,
189
+ isRefreshing,
190
+ isLoadingMore: isLoading && state.pages.length > 0,
191
+ hasMore,
192
+ loadMore,
193
+ refetch,
194
+ error: query.error,
195
+ };
196
+ }
197
+ // =====================================================
198
+ // SHARED QUERY ARGS AND RESPONSE MAPPING
199
+ // =====================================================
200
+ function useFiltersKey(options) {
5
201
  return useMemo(() => JSON.stringify({
6
202
  rangeStart: options.rangeStart ?? null,
7
203
  rangeEnd: options.rangeEnd ?? null,
@@ -14,947 +210,165 @@ function useInfiniteFiltersKey(options) {
14
210
  options.minFlightDistanceMeters,
15
211
  ]);
16
212
  }
213
+ const rangeArgs = (options) => ({
214
+ limit: options.limit || 20,
215
+ supabase: options.supabase,
216
+ rangeStart: options.rangeStart ?? null,
217
+ rangeEnd: options.rangeEnd ?? null,
218
+ });
219
+ const flightArgs = (options) => ({
220
+ ...rangeArgs(options),
221
+ minFlightTimeMinutes: options.minFlightTimeMinutes ?? null,
222
+ minFlightDistanceMeters: options.minFlightDistanceMeters ?? null,
223
+ });
224
+ const sessionsPage = (response) => ({
225
+ rows: response.sessions,
226
+ nextCursor: response.nextCursor,
227
+ hasMore: response.hasMore,
228
+ });
229
+ const eventsPage = (response) => ({
230
+ rows: response.events,
231
+ nextCursor: response.nextCursor,
232
+ hasMore: response.hasMore,
233
+ });
234
+ const artifactsPage = (response) => ({
235
+ rows: response.artifacts,
236
+ nextCursor: response.nextCursor,
237
+ hasMore: response.hasMore,
238
+ });
239
+ const feedPage = (response) => {
240
+ const rows = Array.isArray(response.items) ? response.items : [];
241
+ return {
242
+ rows,
243
+ nextCursor: response.nextCursor,
244
+ // The feed RPC can return fewer rows than the limit while more still exist, so any
245
+ // non-empty page with a next cursor means there is more to load.
246
+ hasMore: rows.length > 0 && response.nextCursor !== null,
247
+ };
248
+ };
249
+ const analysisJobsPage = (response) => ({
250
+ rows: response.jobs,
251
+ nextCursor: response.nextCursor,
252
+ hasMore: response.hasMore,
253
+ });
254
+ const analysisTasksPage = (response) => ({
255
+ rows: response.tasks,
256
+ nextCursor: response.nextCursor,
257
+ hasMore: response.hasMore,
258
+ });
17
259
  // =====================================================
18
- // SESSIONS INFINITE SCROLL HOOKS
260
+ // SESSIONS
19
261
  // =====================================================
20
262
  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,
263
+ const filtersKey = useFiltersKey(options);
264
+ const paging = useCursor(`herd:${herdId}|${filtersKey}`);
265
+ const query = useGetSessionsInfiniteByHerdQuery({ herdId, cursor: paging.cursor, ...flightArgs(options) }, { skip: !options.enabled || !herdId });
266
+ return useInfinitePages(paging, query, {
267
+ toPage: sessionsPage,
268
+ rowKey: rowId,
37
269
  });
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
270
  };
105
271
  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,
272
+ const filtersKey = useFiltersKey(options);
273
+ const paging = useCursor(`device:${deviceId}|${filtersKey}`);
274
+ const query = useGetSessionsInfiniteByDeviceQuery({ deviceId, cursor: paging.cursor, ...flightArgs(options) }, { skip: !options.enabled || !deviceId });
275
+ return useInfinitePages(paging, query, {
276
+ toPage: sessionsPage,
277
+ rowKey: rowId,
122
278
  });
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
279
  };
188
280
  // =====================================================
189
- // EVENTS INFINITE SCROLL HOOKS
281
+ // EVENTS
190
282
  // =====================================================
191
283
  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,
284
+ const filtersKey = useFiltersKey(options);
285
+ const paging = useCursor(`herd:${herdId}|${filtersKey}`);
286
+ const query = useGetEventsInfiniteByHerdQuery({ herdId, cursor: paging.cursor, ...rangeArgs(options) }, { skip: !options.enabled || !herdId });
287
+ return useInfinitePages(paging, query, {
288
+ toPage: eventsPage,
289
+ rowKey: rowId,
206
290
  });
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
291
  };
272
292
  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,
293
+ const filtersKey = useFiltersKey(options);
294
+ const paging = useCursor(`device:${deviceId}|${filtersKey}`);
295
+ const query = useGetEventsInfiniteByDeviceQuery({ deviceId, cursor: paging.cursor, ...rangeArgs(options) }, { skip: !options.enabled || !deviceId });
296
+ return useInfinitePages(paging, query, {
297
+ toPage: eventsPage,
298
+ rowKey: rowId,
287
299
  });
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
300
  };
353
301
  // =====================================================
354
- // ARTIFACTS INFINITE SCROLL HOOKS
302
+ // ARTIFACTS
355
303
  // =====================================================
356
304
  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,
305
+ const filtersKey = useFiltersKey(options);
306
+ const paging = useCursor(`herd:${herdId}|${filtersKey}`);
307
+ const query = useGetArtifactsInfiniteByHerdQuery({ herdId, cursor: paging.cursor, ...rangeArgs(options) }, { skip: !options.enabled || !herdId });
308
+ return useInfinitePages(paging, query, {
309
+ toPage: artifactsPage,
310
+ rowKey: rowId,
371
311
  });
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
312
  };
437
313
  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,
314
+ const filtersKey = useFiltersKey(options);
315
+ const paging = useCursor(`device:${deviceId}|${filtersKey}`);
316
+ const query = useGetArtifactsInfiniteByDeviceQuery({ deviceId, cursor: paging.cursor, ...rangeArgs(options) }, { skip: !options.enabled || !deviceId });
317
+ return useInfinitePages(paging, query, {
318
+ toPage: artifactsPage,
319
+ rowKey: rowId,
452
320
  });
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
321
  };
527
- /** useInfiniteFeedByHerd: logic matches useInfiniteFeedByHerdDummy verbatim; only the fetch is via API (RTK Query) instead of supabase.rpc. */
322
+ // =====================================================
323
+ // FEED (merged events + artifacts)
324
+ // =====================================================
528
325
  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
- };
326
+ const filtersKey = useFiltersKey(options);
327
+ const paging = useCursor(`herd:${herdId}|${filtersKey}`);
328
+ const query = useGetFeedInfiniteByHerdQuery({ herdId, cursor: paging.cursor, ...rangeArgs(options) }, { skip: !options.enabled || !herdId });
329
+ return useInfinitePages(paging, query, {
330
+ toPage: feedPage,
331
+ rowKey: feedRowKey,
332
+ });
692
333
  };
693
334
  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
- };
335
+ const filtersKey = useFiltersKey(options);
336
+ const paging = useCursor(`device:${deviceId}|${filtersKey}`);
337
+ const query = useGetFeedInfiniteByDeviceQuery({ deviceId, cursor: paging.cursor, ...rangeArgs(options) }, { skip: !options.enabled || !deviceId });
338
+ return useInfinitePages(paging, query, {
339
+ toPage: feedPage,
340
+ rowKey: feedRowKey,
341
+ });
820
342
  };
821
- function useAnalysisJobsInfiniteFiltersKey(options) {
822
- return useMemo(() => JSON.stringify({ status: options.status ?? null }), [options.status]);
823
- }
824
343
  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({
344
+ const status = options.status ?? null;
345
+ const paging = useCursor(`status:${status}`);
346
+ const query = useGetAnalysisJobsInfiniteQuery({
347
+ cursor: paging.cursor,
348
+ status,
830
349
  limit: options.limit || 20,
831
- cursor: currentCursor,
832
350
  supabase: options.supabase,
833
- status: options.status ?? null,
834
- }, {
835
- skip: !options.enabled,
351
+ }, { skip: !options.enabled });
352
+ return useInfinitePages(paging, query, {
353
+ toPage: analysisJobsPage,
354
+ rowKey: rowId,
836
355
  });
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
356
  };
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
357
  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({
358
+ const status = options.status ?? null;
359
+ const jobId = options.job_id ?? null;
360
+ const paging = useCursor(`job:${jobId}|status:${status}`);
361
+ const query = useGetAnalysisTasksInfiniteQuery({
362
+ cursor: paging.cursor,
363
+ status,
364
+ job_id: jobId,
900
365
  limit: options.limit || 20,
901
- cursor: currentCursor,
902
366
  supabase: options.supabase,
903
- job_id: options.job_id ?? null,
904
- status: options.status ?? null,
905
- }, {
906
- skip: !options.enabled,
367
+ }, { skip: !options.enabled });
368
+ return useInfinitePages(paging, query, {
369
+ toPage: analysisTasksPage,
370
+ rowKey: rowId,
907
371
  });
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
372
  };
959
373
  export const useIntersectionObserver = (callback, options = {}) => {
960
374
  const [element, setElement] = useState(null);