Skip to content

Commit 05dbc6b

Browse files
vladjercatrakt-bot[bot]
authored andcommitted
feat(pwa): expose isQueued state for offline tracking actions
Adds useIsQueued, which resolves whether a not-yet-synced offline action covers a given media key set, and threads an isQueued observable out of the mark-as-watched, watchlist, favorite, and rating hooks. Distinct from isLoading: a queued action is settled locally but pending server sync.
1 parent f5a498c commit 05dbc6b

6 files changed

Lines changed: 119 additions & 7 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { buildOfflineAction } from '$test/beds/offline/buildOfflineAction.ts';
2+
import { filter, firstValueFrom } from 'rxjs';
3+
import { afterEach, describe, expect, it } from 'vitest';
4+
import { offlineActionsStore } from './_internal/offlineActionsStore.ts';
5+
import { useIsQueued } from './useIsQueued.ts';
6+
7+
async function drainQueue() {
8+
const ids = offlineActionsStore.current().map(({ id }) => id);
9+
await offlineActionsStore.remove(ids);
10+
}
11+
12+
describe('store: useIsQueued', () => {
13+
afterEach(drainQueue);
14+
15+
it('should be false with an empty queue', async () => {
16+
const { isQueued } = useIsQueued({ domain: 'history', keys: ['movie:1'] });
17+
18+
expect(await firstValueFrom(isQueued)).toBe(false);
19+
});
20+
21+
it('should be true when a queued action covers the keys', async () => {
22+
const { isQueued } = useIsQueued({ domain: 'history', keys: ['movie:1'] });
23+
24+
await offlineActionsStore.enqueue(
25+
buildOfflineAction({ endpoint: 'history:add', keys: ['movie:1'] }),
26+
);
27+
28+
expect(await firstValueFrom(isQueued.pipe(filter(Boolean)))).toBe(true);
29+
});
30+
31+
it('should ignore queued actions from another domain', async () => {
32+
const { isQueued } = useIsQueued({ domain: 'history', keys: ['movie:1'] });
33+
34+
await offlineActionsStore.enqueue(
35+
buildOfflineAction({ endpoint: 'watchlist:add', keys: ['movie:1'] }),
36+
);
37+
38+
expect(await firstValueFrom(isQueued)).toBe(false);
39+
});
40+
41+
it('should ignore queued actions for other keys', async () => {
42+
const { isQueued } = useIsQueued({ domain: 'history', keys: ['movie:1'] });
43+
44+
await offlineActionsStore.enqueue(
45+
buildOfflineAction({ endpoint: 'history:add', keys: ['movie:2'] }),
46+
);
47+
48+
expect(await firstValueFrom(isQueued)).toBe(false);
49+
});
50+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { map, type Observable } from 'rxjs';
2+
import { findPendingOverride } from './findPendingOverride.ts';
3+
import type { OfflineActionDomain } from './models/OfflineActionEndpoint.ts';
4+
import { useOfflineActions } from './useOfflineActions.ts';
5+
6+
type UseIsQueuedParams = {
7+
domain: OfflineActionDomain;
8+
keys: string[];
9+
};
10+
11+
/**
12+
* True while a queued (not-yet-synced) offline action covers these media
13+
* keys - lets the UI flag the action as pending rather than just disabled.
14+
*/
15+
export function useIsQueued(
16+
{ domain, keys }: UseIsQueuedParams,
17+
): { isQueued: Observable<boolean> } {
18+
const { actions } = useOfflineActions();
19+
20+
const isQueued = actions.pipe(
21+
map((queued) =>
22+
findPendingOverride({ actions: queued, domain, keys }) != null
23+
),
24+
);
25+
26+
return { isQueued };
27+
}

projects/client/src/lib/sections/media-actions/favorite/useFavorites.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { executeOrEnqueue } from '$lib/features/offline/executeOrEnqueue.ts';
33
import { findPendingOverride } from '$lib/features/offline/findPendingOverride.ts';
44
import { isAddEndpoint } from '$lib/features/offline/isAddEndpoint.ts';
55
import { toMediaKey } from '$lib/features/offline/toMediaKey.ts';
6+
import { useIsQueued } from '$lib/features/offline/useIsQueued.ts';
67
import { useOfflineActions } from '$lib/features/offline/useOfflineActions.ts';
78
import { InvalidateAction } from '$lib/requests/models/InvalidateAction.ts';
89
import type { MediaType } from '$lib/requests/models/MediaType.ts';
@@ -31,6 +32,10 @@ export function useFavorites({ type, id }: FavoritesStoreProps) {
3132
const { favorites } = useUser();
3233
const { invalidate } = useInvalidator();
3334
const { actions } = useOfflineActions();
35+
const { isQueued } = useIsQueued({
36+
domain: 'favorites',
37+
keys: [toMediaKey(type, id)],
38+
});
3439

3540
const isFavorited = combineLatest([favorites, actions]).pipe(
3641
map(([$favorites, $actions]) => {
@@ -71,13 +76,17 @@ export function useFavorites({ type, id }: FavoritesStoreProps) {
7176

7277
if (result === 'executed') {
7378
await invalidate(InvalidateAction.Favorited(type));
74-
isUpdatingFavorite.next(false);
7579
}
80+
81+
// Always clear: a queued action stays flagged via isQueued, and leaving
82+
// this pinned would re-disable the button once it syncs and dequeues.
83+
isUpdatingFavorite.next(false);
7684
};
7785

7886
return {
7987
isUpdatingFavorite,
8088
isFavorited,
89+
isQueued,
8190
addToFavorites: async () => await addOrRemoveFavorite('add'),
8291
removeFromFavorites: async () => await addOrRemoveFavorite('remove'),
8392
};

projects/client/src/lib/sections/media-actions/mark-as-watched/useMarkAsWatched.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useTrack } from '$lib/features/analytics/useTrack.ts';
33
import { useUser } from '$lib/features/auth/stores/useUser.ts';
44
import { executeOrEnqueue } from '$lib/features/offline/executeOrEnqueue.ts';
55
import { toMediaKey } from '$lib/features/offline/toMediaKey.ts';
6+
import { useIsQueued } from '$lib/features/offline/useIsQueued.ts';
67
import type { MediaStoreProps } from '$lib/models/MediaStoreProps.ts';
78
import { InvalidateAction } from '$lib/requests/models/InvalidateAction.ts';
89
import type { MediaStatus } from '$lib/requests/models/MediaStatus.ts';
@@ -31,6 +32,7 @@ export function useMarkAsWatched(
3132
const { track } = useTrack(AnalyticsEvent.MarkAsWatched);
3233

3334
const { isWatched } = useIsWatched(props);
35+
const { isQueued } = useIsQueued({ domain: 'history', keys: mediaKeys });
3436

3537
const markAsWatched = async (watchedAt?: MarkAsWatchedAt) => {
3638
const current = await resolve(user);
@@ -53,8 +55,11 @@ export function useMarkAsWatched(
5355

5456
if (result === 'executed') {
5557
await invalidate(InvalidateAction.MarkAsWatched(type));
56-
isMarkingAsWatched.next(false);
5758
}
59+
60+
// Always clear: a queued action stays flagged via isQueued, and leaving
61+
// this pinned would re-disable the button once it syncs and dequeues.
62+
isMarkingAsWatched.next(false);
5863
};
5964

6065
// Ids whose rating this removal orphans. The main action removes *every*
@@ -122,8 +127,9 @@ export function useMarkAsWatched(
122127

123128
if (removeResult === 'executed') {
124129
await invalidate(InvalidateAction.MarkAsWatched(type));
125-
isMarkingAsWatched.next(false);
126130
}
131+
132+
isMarkingAsWatched.next(false);
127133
};
128134

129135
const isWatchable = media.every((item) => {
@@ -138,6 +144,7 @@ export function useMarkAsWatched(
138144
removeWatched,
139145
isWatched,
140146
isMarkingAsWatched,
147+
isQueued,
141148
isWatchable,
142149
};
143150
}

projects/client/src/lib/sections/media-actions/watchlist/useWatchlist.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AnalyticsEvent } from '$lib/features/analytics/events/AnalyticsEvent.ts
22
import { useTrack } from '$lib/features/analytics/useTrack.ts';
33
import { executeOrEnqueue } from '$lib/features/offline/executeOrEnqueue.ts';
44
import { toMediaKey } from '$lib/features/offline/toMediaKey.ts';
5+
import { useIsQueued } from '$lib/features/offline/useIsQueued.ts';
56
import type { MediaStoreProps } from '$lib/models/MediaStoreProps.ts';
67
import { InvalidateAction } from '$lib/requests/models/InvalidateAction.ts';
78
import { toBulkPayload } from '$lib/sections/media-actions/_internal/toBulkPayload.ts';
@@ -19,6 +20,10 @@ export function useWatchlist(props: MediaStoreProps) {
1920
const ids = media.map(({ id }) => id);
2021

2122
const { isWatchlisted } = useIsWatchlisted(props);
23+
const { isQueued } = useIsQueued({
24+
domain: 'watchlist',
25+
keys: ids.map((id) => toMediaKey(type, id)),
26+
});
2227
const body = toBulkPayload(type, ids);
2328

2429
const addToWatchlist = async () => {
@@ -38,8 +43,11 @@ export function useWatchlist(props: MediaStoreProps) {
3843

3944
if (addResult === 'executed') {
4045
await invalidate(InvalidateAction.Watchlisted(type));
41-
isWatchlistUpdating.next(false);
4246
}
47+
48+
// Always clear: a queued action stays flagged via isQueued, and leaving
49+
// this pinned would re-disable the button once it syncs and dequeues.
50+
isWatchlistUpdating.next(false);
4351
};
4452

4553
const removeFromWatchlist = async () => {
@@ -59,13 +67,15 @@ export function useWatchlist(props: MediaStoreProps) {
5967

6068
if (removeResult === 'executed') {
6169
await invalidate(InvalidateAction.Watchlisted(type));
62-
isWatchlistUpdating.next(false);
6370
}
71+
72+
isWatchlistUpdating.next(false);
6473
};
6574

6675
return {
6776
isWatchlistUpdating,
6877
isWatchlisted,
78+
isQueued,
6979
addToWatchlist,
7080
removeFromWatchlist,
7181
};

projects/client/src/lib/sections/summary/components/rating/useRatings.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { findPendingOverride } from '$lib/features/offline/findPendingOverride.t
77
import { isAddEndpoint } from '$lib/features/offline/isAddEndpoint.ts';
88
import type { OfflineAction } from '$lib/features/offline/models/OfflineAction.ts';
99
import { toMediaKey } from '$lib/features/offline/toMediaKey.ts';
10+
import { useIsQueued } from '$lib/features/offline/useIsQueued.ts';
1011
import { useOfflineActions } from '$lib/features/offline/useOfflineActions.ts';
1112
import { useLastWatched } from '$lib/features/toast/useLastWatched.ts';
1213
import {
@@ -81,6 +82,10 @@ export function useRatings({ type, id }: WatchlistStoreProps) {
8182
const { dismiss } = useLastWatched();
8283

8384
const { actions } = useOfflineActions();
85+
const { isQueued } = useIsQueued({
86+
domain: 'rating',
87+
keys: [toMediaKey(type, id)],
88+
});
8489

8590
const rating = combineLatest([ratings, actions]).pipe(
8691
map(([$ratings, $actions]) => {
@@ -183,12 +188,15 @@ export function useRatings({ type, id }: WatchlistStoreProps) {
183188
});
184189
if (addResult === 'executed') {
185190
await invalidate(InvalidateAction.Rated(type));
186-
pendingRating.next(null);
187-
isSubmitting.next(false);
188191
if (type !== 'season') {
189192
dismiss(id, type, 'rating');
190193
}
191194
}
195+
196+
// Always clear: a queued rating stays flagged via isQueued, and leaving
197+
// these pinned would re-disable the stars once it syncs and dequeues.
198+
pendingRating.next(null);
199+
isSubmitting.next(false);
192200
});
193201

194202
const addRating = (newRating: number) => {
@@ -216,6 +224,7 @@ export function useRatings({ type, id }: WatchlistStoreProps) {
216224
return {
217225
pendingRating,
218226
isSubmitting,
227+
isQueued,
219228
current,
220229
addRating,
221230
removeRating,

0 commit comments

Comments
 (0)