A personal media tracker built on the AT Protocol opnshelf.xyz
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add mutation keys

+138
+23
AGENTS.md
··· 188 188 - TanStack Query for server state 189 189 - Sonner for toast notifications (web) 190 190 191 + ## TanStack Query 192 + 193 + **Always use `mutationKey`** on all `useMutation` calls. This enables better debugging, caching, and mutation state tracking. 194 + 195 + ```typescript 196 + const mutation = useMutation({ 197 + mutationKey: ["shows", showId, "markWatched"], 198 + ...showsControllerMarkWatchedMutation(), 199 + onSuccess: () => { /* ... */ }, 200 + }); 201 + ``` 202 + 203 + **Mutation key pattern:** Use resource-based hierarchy following TanStack best practices: 204 + - `['resource', resourceId?, 'subResource'?, 'action']` 205 + 206 + Examples: 207 + - `['shows', showId, 'markWatched']` 208 + - `['shows', showId, 'seasons', seasonNumber, 'markSeasonWatched']` 209 + - `['movies', movieId, 'markWatched']` 210 + - `['lists', 'create']` 211 + - `['auth', 'logout']` 212 + - `['users', 'settings', 'update']` 213 + 191 214 ## Testing Best Practices 192 215 193 216 **Backend:**
+2
apps/mobile/app/(tabs)/profile/shelf.tsx
··· 54 54 }); 55 55 56 56 const deleteMovieMutation = useMutation({ 57 + mutationKey: ["shelf", "movies", "delete"], 57 58 ...moviesControllerDeleteWatchHistoryEntryMutation(), 58 59 onSuccess: () => { 59 60 queryClient.invalidateQueries({ queryKey: ["shelf", "user", userDid] }); ··· 65 66 }); 66 67 67 68 const deleteEpisodeMutation = useMutation({ 69 + mutationKey: ["shelf", "episodes", "delete"], 68 70 ...showsControllerDeleteEpisodeWatchHistoryEntryMutation(), 69 71 onSuccess: () => { 70 72 queryClient.invalidateQueries({ queryKey: ["shelf", "user", userDid] });
+2
apps/mobile/app/(tabs)/search.tsx
··· 104 104 }); 105 105 106 106 const markMutation = useMutation({ 107 + mutationKey: ["movies", "markWatched"], 107 108 ...moviesControllerMarkWatchedMutation(), 108 109 onSuccess: () => { 109 110 queryClient.invalidateQueries({ ··· 119 120 }); 120 121 121 122 const unmarkMutation = useMutation({ 123 + mutationKey: ["movies", "unmarkWatched"], 122 124 ...moviesControllerUnmarkWatchedMutation(), 123 125 onSuccess: () => { 124 126 queryClient.invalidateQueries({
+2
apps/mobile/app/list/[slug].tsx
··· 42 42 }); 43 43 44 44 const removeMutation = useMutation({ 45 + mutationKey: ["lists", slug, "removeItem"], 45 46 ...listsControllerRemoveItemFromListMutation(), 46 47 onSuccess: () => { 47 48 queryClient.invalidateQueries({ ··· 57 58 }); 58 59 59 60 const deleteMutation = useMutation({ 61 + mutationKey: ["lists", slug, "delete"], 60 62 ...listsControllerDeleteListMutation(), 61 63 onSuccess: () => { 62 64 queryClient.invalidateQueries({
+3
apps/mobile/app/movie/[id].tsx
··· 157 157 }, [trackedMovie, userTimezone, is24Hour]); 158 158 159 159 const markMutation = useMutation({ 160 + mutationKey: ["movies", movieId, "markWatched"], 160 161 ...moviesControllerMarkWatchedMutation(), 161 162 onSuccess: () => { 162 163 queryClient.invalidateQueries({ ··· 178 179 }); 179 180 180 181 const unmarkMutation = useMutation({ 182 + mutationKey: ["movies", movieId, "unmarkWatched"], 181 183 ...moviesControllerUnmarkWatchedMutation(), 182 184 onSuccess: () => { 183 185 queryClient.invalidateQueries({ ··· 198 200 }); 199 201 200 202 const deleteWatchEntryMutation = useMutation({ 203 + mutationKey: ["movies", movieId, "deleteWatchEntry"], 201 204 ...moviesControllerDeleteWatchHistoryEntryMutation(), 202 205 onSuccess: () => { 203 206 queryClient.invalidateQueries({
+2
apps/mobile/app/settings.tsx
··· 151 151 152 152 // Mutation for updating settings 153 153 const updateSettingsMutation = useMutation({ 154 + mutationKey: ["users", "settings", "update"], 154 155 ...usersControllerUpdateMySettingsMutation(), 155 156 onSuccess: () => { 156 157 queryClient.invalidateQueries({ ··· 165 166 166 167 // Mutation for deleting account 167 168 const deleteAccountMutation = useMutation({ 169 + mutationKey: ["users", "account", "delete"], 168 170 ...usersControllerDeleteMyAccountMutation(), 169 171 onSuccess: async () => { 170 172 showToast("Account deleted", "success");
+2
apps/mobile/app/show/[id].tsx
··· 109 109 const seasonCount = show?.number_of_seasons || 0; 110 110 111 111 const markShowWatchedMutation = useMutation({ 112 + mutationKey: ["shows", id, "markShowWatched"], 112 113 ...showsControllerMarkShowWatchedMutation(), 113 114 onSuccess: (data) => { 114 115 queryClient.invalidateQueries({ ··· 139 140 }; 140 141 141 142 const unmarkShowWatchedMutation = useMutation({ 143 + mutationKey: ["shows", id, "unmarkShowWatched"], 142 144 ...showsControllerUnmarkWatchedMutation(), 143 145 onSuccess: () => { 144 146 queryClient.invalidateQueries({
+3
apps/mobile/app/show/[id]/season/[seasonNumber]/episode/[episodeNumber]/index.tsx
··· 196 196 }, [season?.episodes, episodeNumber]); 197 197 198 198 const markMutation = useMutation({ 199 + mutationKey: ["shows", id, "episodes", episodeNumber, "markWatched"], 199 200 ...showsControllerMarkWatchedMutation(), 200 201 onSuccess: () => { 201 202 queryClient.invalidateQueries({ ··· 217 218 }); 218 219 219 220 const unmarkMutation = useMutation({ 221 + mutationKey: ["shows", id, "episodes", episodeNumber, "unmarkWatched"], 220 222 ...showsControllerUnmarkWatchedMutation(), 221 223 onSuccess: () => { 222 224 queryClient.invalidateQueries({ ··· 237 239 }); 238 240 239 241 const deleteWatchEntryMutation = useMutation({ 242 + mutationKey: ["shows", id, "episodes", episodeNumber, "deleteWatchEntry"], 240 243 ...showsControllerDeleteEpisodeWatchHistoryEntryMutation(), 241 244 onSuccess: () => { 242 245 queryClient.invalidateQueries({
+2
apps/mobile/app/show/[id]/season/[seasonNumber]/index.tsx
··· 123 123 const seasonEpisodes = season?.episodes || []; 124 124 125 125 const markSeasonWatchedMutation = useMutation({ 126 + mutationKey: ["shows", id, "seasons", seasonNumber, "markSeasonWatched"], 126 127 ...showsControllerMarkSeasonWatchedMutation(), 127 128 onSuccess: (data) => { 128 129 queryClient.invalidateQueries({ ··· 160 161 }; 161 162 162 163 const unmarkSeasonWatchedMutation = useMutation({ 164 + mutationKey: ["shows", id, "seasons", seasonNumber, "unmarkSeasonWatched"], 163 165 ...showsControllerUnmarkWatchedMutation(), 164 166 onSuccess: () => { 165 167 queryClient.invalidateQueries({
+2
apps/mobile/components/AddToListModal.tsx
··· 48 48 const typedListsForMovie = (listsForMovie || []) as MovieListsForItemDto[]; 49 49 50 50 const addMutation = useMutation({ 51 + mutationKey: ["lists", "addItem", mediaType, mediaId], 51 52 ...listsControllerAddItemToListMutation(), 52 53 onSuccess: (_, variables) => { 53 54 const slug = variables.path.slug; ··· 63 64 }); 64 65 65 66 const removeMutation = useMutation({ 67 + mutationKey: ["lists", "removeItem", mediaType, mediaId], 66 68 ...listsControllerRemoveItemFromListMutation(), 67 69 onSuccess: (_, variables) => { 68 70 const slug = variables.path.slug;
+1
apps/mobile/components/CreateListModal.tsx
··· 33 33 const { colors } = useTheme(); 34 34 35 35 const createListMutation = useMutation({ 36 + mutationKey: ["lists", "create"], 36 37 ...listsControllerCreateListMutation(), 37 38 onSuccess: () => { 38 39 queryClient.invalidateQueries({
+2
apps/mobile/components/detail/EpisodeCard.tsx
··· 54 54 const hasWatchedEpisodes = watchedCount > 0; 55 55 56 56 const markMutation = useMutation({ 57 + mutationKey: ["shows", showId, "episodes", episode.episode_number, "markWatched"], 57 58 ...showsControllerMarkWatchedMutation(), 58 59 onSuccess: () => { 59 60 if (userDid) { ··· 76 77 }); 77 78 78 79 const unmarkMutation = useMutation({ 80 + mutationKey: ["shows", showId, "episodes", episode.episode_number, "unmarkWatched"], 79 81 ...showsControllerUnmarkWatchedMutation(), 80 82 onSuccess: () => { 81 83 if (userDid) {
+2
apps/mobile/components/detail/SeasonCard.tsx
··· 54 54 const hasWatchedEpisodes = watchedCount > 0; 55 55 56 56 const markMutation = useMutation({ 57 + mutationKey: ["shows", showId, "seasons", seasonNumber, "markSeasonWatched"], 57 58 ...showsControllerMarkSeasonWatchedMutation(), 58 59 onSuccess: (data) => { 59 60 if (userDid) { ··· 76 77 }); 77 78 78 79 const unmarkMutation = useMutation({ 80 + mutationKey: ["shows", showId, "seasons", seasonNumber, "unmarkSeasonWatched"], 79 81 ...showsControllerUnmarkWatchedMutation(), 80 82 onSuccess: () => { 81 83 if (userDid) {
+2
apps/web/src/components/AddToListModal.tsx
··· 46 46 }); 47 47 48 48 const addMutation = useMutation({ 49 + mutationKey: ["lists", "addItem", mediaType, mediaId], 49 50 ...listsControllerAddItemToListMutation(), 50 51 onSuccess: (_, variables) => { 51 52 const slug = variables.path.slug; ··· 65 66 }); 66 67 67 68 const removeMutation = useMutation({ 69 + mutationKey: ["lists", "removeItem", mediaType, mediaId], 68 70 ...listsControllerRemoveItemFromListMutation(), 69 71 onSuccess: (_, variables) => { 70 72 const slug = variables.path.slug;
+1
apps/web/src/components/CreateListDialog.tsx
··· 28 28 const { seedColor } = useTheme(); 29 29 30 30 const createListMutation = useMutation({ 31 + mutationKey: ["lists", "create"], 31 32 ...listsControllerCreateListMutation(), 32 33 onSuccess: () => { 33 34 queryClient.invalidateQueries({
+16
apps/web/src/components/DatePickerModal.tsx
··· 64 64 }, [open]); 65 65 66 66 const markMutation = useMutation({ 67 + mutationKey: ["movies", target.movieId, "markWatched"], 67 68 ...moviesControllerMarkWatchedMutation(), 68 69 onSuccess: () => { 69 70 if (target.mode === "episode") { ··· 85 86 }, 86 87 }); 87 88 const markEpisodeMutation = useMutation({ 89 + mutationKey: [ 90 + "shows", 91 + target.showId, 92 + "episodes", 93 + target.episodeNumber, 94 + "markWatched", 95 + ], 88 96 ...showsControllerMarkWatchedMutation(), 89 97 onSuccess: () => { 90 98 if (target.mode === "episode") { ··· 107 115 }, 108 116 }); 109 117 const markSeasonMutation = useMutation({ 118 + mutationKey: [ 119 + "shows", 120 + target.showId, 121 + "seasons", 122 + target.seasonNumber, 123 + "markSeasonWatched", 124 + ], 110 125 ...showsControllerMarkSeasonWatchedMutation(), 111 126 onSuccess: () => { 112 127 if (target.mode === "season") { ··· 129 144 }, 130 145 }); 131 146 const markShowMutation = useMutation({ 147 + mutationKey: ["shows", target.showId, "markShowWatched"], 132 148 ...showsControllerMarkShowWatchedMutation(), 133 149 onSuccess: () => { 134 150 if (target.mode === "show") {
+1
apps/web/src/components/Header.tsx
··· 22 22 }); 23 23 24 24 const logoutMutation = useMutation({ 25 + mutationKey: ["auth", "logout"], 25 26 ...authControllerLogoutMutation(), 26 27 onSuccess: () => { 27 28 queryClient.removeQueries(authControllerMeOptions());
+2
apps/web/src/components/MovieCard.tsx
··· 42 42 const movieId = movie.id.toString(); 43 43 44 44 const markMutation = useMutation({ 45 + mutationKey: ["movies", movieId, "markWatched"], 45 46 ...moviesControllerMarkWatchedMutation(), 46 47 onSuccess: () => { 47 48 queryClient.invalidateQueries({ ··· 57 58 }); 58 59 59 60 const unmarkMutation = useMutation({ 61 + mutationKey: ["movies", movieId, "unmarkWatched"], 60 62 ...moviesControllerUnmarkWatchedMutation(), 61 63 onSuccess: () => { 62 64 queryClient.invalidateQueries({
+7
apps/web/src/components/ShelfEpisodeCard.tsx
··· 37 37 const { formatDate } = useFormattedDate(); 38 38 39 39 const deleteMutation = useMutation({ 40 + mutationKey: [ 41 + "shows", 42 + tracked.showId, 43 + "episodes", 44 + tracked.episodeNumber, 45 + "deleteWatchEntry", 46 + ], 40 47 ...showsControllerDeleteEpisodeWatchHistoryEntryMutation(), 41 48 onSuccess: () => { 42 49 queryClient.invalidateQueries({ queryKey: ["shelf", "user", user?.did] });
+1
apps/web/src/components/ShelfMovieCard.tsx
··· 35 35 const { formatDate } = useFormattedDate(); 36 36 37 37 const unmarkMutation = useMutation({ 38 + mutationKey: ["movies", tracked.movieId, "unmarkWatched"], 38 39 ...moviesControllerUnmarkWatchedMutation(), 39 40 onSuccess: () => { 40 41 queryClient.invalidateQueries({ queryKey: ["shelf", "user", user?.did] });
+14
apps/web/src/components/detail/EpisodeCard.tsx
··· 33 33 const queryClient = useQueryClient(); 34 34 35 35 const markMutation = useMutation({ 36 + mutationKey: [ 37 + "shows", 38 + showId, 39 + "episodes", 40 + episode.episode_number, 41 + "markWatched", 42 + ], 36 43 ...showsControllerMarkWatchedMutation(), 37 44 onSuccess: () => { 38 45 if (userDid) { ··· 55 62 }); 56 63 57 64 const unmarkMutation = useMutation({ 65 + mutationKey: [ 66 + "shows", 67 + showId, 68 + "episodes", 69 + episode.episode_number, 70 + "unmarkWatched", 71 + ], 58 72 ...showsControllerUnmarkWatchedMutation(), 59 73 onSuccess: () => { 60 74 if (userDid) {
+14
apps/web/src/components/detail/SeasonCard.tsx
··· 43 43 const hasWatchedEpisodes = watchedCount > 0; 44 44 45 45 const markMutation = useMutation({ 46 + mutationKey: [ 47 + "shows", 48 + showId, 49 + "seasons", 50 + seasonNumber, 51 + "markSeasonWatched", 52 + ], 46 53 ...showsControllerMarkSeasonWatchedMutation(), 47 54 onSuccess: (data) => { 48 55 if (userDid) { ··· 65 72 }); 66 73 67 74 const unmarkMutation = useMutation({ 75 + mutationKey: [ 76 + "shows", 77 + showId, 78 + "seasons", 79 + seasonNumber, 80 + "unmarkSeasonWatched", 81 + ], 68 82 ...showsControllerUnmarkWatchedMutation(), 69 83 onSuccess: () => { 70 84 if (userDid) {
+2
apps/web/src/routes/lists.$slug.tsx
··· 54 54 }); 55 55 56 56 const removeMutation = useMutation({ 57 + mutationKey: ["lists", slug, "removeItem"], 57 58 ...listsControllerRemoveItemFromListMutation(), 58 59 onSuccess: () => { 59 60 queryClient.invalidateQueries({ ··· 67 68 }); 68 69 69 70 const deleteMutation = useMutation({ 71 + mutationKey: ["lists", slug, "delete"], 70 72 ...listsControllerDeleteListMutation(), 71 73 onSuccess: () => { 72 74 queryClient.invalidateQueries({
+3
apps/web/src/routes/movies.$movieId.$title.tsx
··· 183 183 }; 184 184 185 185 const markMutation = useMutation({ 186 + mutationKey: ["movies", movieId, "markWatched"], 186 187 ...moviesControllerMarkWatchedMutation(), 187 188 onSuccess: () => { 188 189 queryClient.invalidateQueries({ ··· 204 205 }); 205 206 206 207 const unmarkMutation = useMutation({ 208 + mutationKey: ["movies", movieId, "unmarkWatched"], 207 209 ...moviesControllerUnmarkWatchedMutation(), 208 210 onSuccess: () => { 209 211 queryClient.invalidateQueries({ ··· 224 226 }); 225 227 226 228 const deleteWatchEntryMutation = useMutation({ 229 + mutationKey: ["movies", movieId, "deleteWatchEntry"], 227 230 ...moviesControllerDeleteWatchHistoryEntryMutation(), 228 231 onSuccess: () => { 229 232 queryClient.invalidateQueries({
+2
apps/web/src/routes/profile.settings.tsx
··· 163 163 }, [settings]); 164 164 165 165 const updateSettingsMutation = useMutation({ 166 + mutationKey: ["users", "settings", "update"], 166 167 ...usersControllerUpdateMySettingsMutation(), 167 168 onSuccess: () => { 168 169 queryClient.invalidateQueries({ ··· 176 177 }); 177 178 178 179 const deleteAccountMutation = useMutation({ 180 + mutationKey: ["users", "account", "delete"], 179 181 ...usersControllerDeleteMyAccountMutation(), 180 182 onSuccess: () => { 181 183 setShowDeleteDialog(false);
+9
apps/web/src/routes/shows.$showId.$title.seasons.$seasonNumber.episodes.$episodeNumber.tsx
··· 177 177 const is24Hour = userSettings?.timeFormat === "24h"; 178 178 179 179 const markMutation = useMutation({ 180 + mutationKey: ["shows", showId, "episodes", episodeNumber, "markWatched"], 180 181 ...showsControllerMarkWatchedMutation(), 181 182 onSuccess: () => { 182 183 queryClient.invalidateQueries({ ··· 196 197 }, 197 198 }); 198 199 const unmarkMutation = useMutation({ 200 + mutationKey: ["shows", showId, "episodes", episodeNumber, "unmarkWatched"], 199 201 ...showsControllerUnmarkWatchedMutation(), 200 202 onSuccess: () => { 201 203 queryClient.invalidateQueries({ ··· 215 217 }, 216 218 }); 217 219 const deleteWatchEntryMutation = useMutation({ 220 + mutationKey: [ 221 + "shows", 222 + showId, 223 + "episodes", 224 + episodeNumber, 225 + "deleteWatchEntry", 226 + ], 218 227 ...showsControllerDeleteEpisodeWatchHistoryEntryMutation(), 219 228 onSuccess: () => { 220 229 queryClient.invalidateQueries({
+14
apps/web/src/routes/shows.$showId.$title.seasons.$seasonNumber.tsx
··· 136 136 const seasonEpisodes = season?.episodes || []; 137 137 138 138 const markSeasonWatchedMutation = useMutation({ 139 + mutationKey: [ 140 + "shows", 141 + showId, 142 + "seasons", 143 + seasonNumber, 144 + "markSeasonWatched", 145 + ], 139 146 ...showsControllerMarkSeasonWatchedMutation(), 140 147 onSuccess: (data) => { 141 148 queryClient.invalidateQueries({ ··· 156 163 }); 157 164 158 165 const unmarkSeasonWatchedMutation = useMutation({ 166 + mutationKey: [ 167 + "shows", 168 + showId, 169 + "seasons", 170 + seasonNumber, 171 + "unmarkSeasonWatched", 172 + ], 159 173 ...showsControllerUnmarkWatchedMutation(), 160 174 onSuccess: () => { 161 175 queryClient.invalidateQueries({
+2
apps/web/src/routes/shows.$showId.$title.tsx
··· 117 117 const episodeCount = show?.number_of_episodes || 0; 118 118 119 119 const markShowWatchedMutation = useMutation({ 120 + mutationKey: ["shows", showId, "markShowWatched"], 120 121 ...showsControllerMarkShowWatchedMutation(), 121 122 onSuccess: (data) => { 122 123 queryClient.invalidateQueries({ ··· 137 138 }); 138 139 139 140 const unmarkShowWatchedMutation = useMutation({ 141 + mutationKey: ["shows", showId, "unmarkShowWatched"], 140 142 ...showsControllerUnmarkWatchedMutation(), 141 143 onSuccess: () => { 142 144 queryClient.invalidateQueries({