mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 10:13:32 -04:00 
			
		
		
		
	fix: Offline Shopping List Fixes V2 - Electric Boogaloo (#3837)
Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
		| @@ -69,7 +69,7 @@ | |||||||
|     </v-row> |     </v-row> | ||||||
|     <v-row v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs" no-gutters class="mb-2"> |     <v-row v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs" no-gutters class="mb-2"> | ||||||
|       <v-col cols="auto" style="width: 100%;"> |       <v-col cols="auto" style="width: 100%;"> | ||||||
|         <RecipeList :recipes="recipeList" :list-item="listItem" :disabled="isOffline" small tile /> |         <RecipeList :recipes="recipeList" :list-item="listItem" :disabled="$nuxt.isOffline" small tile /> | ||||||
|       </v-col> |       </v-col> | ||||||
|     </v-row> |     </v-row> | ||||||
|     <v-row v-if="listItem.checked" no-gutters class="mb-2"> |     <v-row v-if="listItem.checked" no-gutters class="mb-2"> | ||||||
| @@ -136,10 +136,6 @@ export default defineComponent({ | |||||||
|       type: Map<string, RecipeSummary>, |       type: Map<string, RecipeSummary>, | ||||||
|       default: undefined, |       default: undefined, | ||||||
|     }, |     }, | ||||||
|     isOffline: { |  | ||||||
|       type: Boolean, |  | ||||||
|       default: false, |  | ||||||
|     }, |  | ||||||
|   }, |   }, | ||||||
|   setup(props, context) { |   setup(props, context) { | ||||||
|     const { i18n } = useContext(); |     const { i18n } = useContext(); | ||||||
|   | |||||||
| @@ -1,7 +1,8 @@ | |||||||
| import { computed, ref } from "@nuxtjs/composition-api"; | import { computed, reactive, watch } from "@nuxtjs/composition-api"; | ||||||
| import { useLocalStorage } from "@vueuse/core"; | import { useLocalStorage } from "@vueuse/core"; | ||||||
| import { useUserApi } from "~/composables/api"; | import { useUserApi } from "~/composables/api"; | ||||||
| import { ShoppingListItemOut } from "~/lib/api/types/group"; | import { ShoppingListItemOut, ShoppingListOut } from "~/lib/api/types/group"; | ||||||
|  | import { RequestResponse } from "~/lib/api/types/non-generated"; | ||||||
|  |  | ||||||
| const localStorageKey = "shopping-list-queue"; | const localStorageKey = "shopping-list-queue"; | ||||||
| const queueTimeout = 5 * 60 * 1000;  // 5 minutes | const queueTimeout = 5 * 60 * 1000;  // 5 minutes | ||||||
| @@ -23,27 +24,73 @@ interface Storage { | |||||||
| export function useShoppingListItemActions(shoppingListId: string) { | export function useShoppingListItemActions(shoppingListId: string) { | ||||||
|   const api = useUserApi(); |   const api = useUserApi(); | ||||||
|   const storage = useLocalStorage(localStorageKey, {} as Storage, { deep: true }); |   const storage = useLocalStorage(localStorageKey, {} as Storage, { deep: true }); | ||||||
|   const queue = storage.value[shoppingListId] ||= { create: [], update: [], delete: [], lastUpdate: Date.now()}; |   const queue = reactive(getQueue()); | ||||||
|   const queueEmpty = computed(() => !queue.create.length && !queue.update.length && !queue.delete.length); |   const queueEmpty = computed(() => !queue.create.length && !queue.update.length && !queue.delete.length); | ||||||
|   if (queueEmpty.value) { |   if (queueEmpty.value) { | ||||||
|     queue.lastUpdate = Date.now(); |     queue.lastUpdate = Date.now(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const isOffline = ref(false); |   storage.value[shoppingListId] = { ...queue } | ||||||
|  |   watch( | ||||||
|  |     () => queue, | ||||||
|  |     (value) => { | ||||||
|  |       storage.value[shoppingListId] = { ...value } | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       deep: true, | ||||||
|  |       immediate: true, | ||||||
|  |     }, | ||||||
|  |   ) | ||||||
|  |  | ||||||
|   function removeFromQueue(queue: ShoppingListItemOut[], item: ShoppingListItemOut): boolean { |   function isValidQueueObject(obj: any): obj is ShoppingListQueue { | ||||||
|     const index = queue.findIndex(i => i.id === item.id); |     if (typeof obj !== "object" || obj === null) { | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const hasRequiredProps = "create" in obj && "update" in obj && "delete" in obj && "lastUpdate" in obj; | ||||||
|  |     if (!hasRequiredProps) { | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const arraysValid = Array.isArray(obj.create) && Array.isArray(obj.update) && Array.isArray(obj.delete); | ||||||
|  |     // eslint-disable-next-line @typescript-eslint/no-unsafe-argument | ||||||
|  |     const lastUpdateValid = typeof obj.lastUpdate === "number" && !isNaN(new Date(obj.lastUpdate).getTime()); | ||||||
|  |  | ||||||
|  |     return arraysValid && lastUpdateValid; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   function createEmptyQueue(): ShoppingListQueue { | ||||||
|  |     const newQueue = { create: [], update: [], delete: [], lastUpdate: Date.now() }; | ||||||
|  |     return newQueue; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   function getQueue(): ShoppingListQueue { | ||||||
|  |     try { | ||||||
|  |       const fetchedQueue = storage.value[shoppingListId]; | ||||||
|  |       if (!isValidQueueObject(fetchedQueue)) { | ||||||
|  |         console.log("Invalid queue object in local storage; resetting queue."); | ||||||
|  |         return createEmptyQueue(); | ||||||
|  |       } else { | ||||||
|  |         return fetchedQueue; | ||||||
|  |       } | ||||||
|  |     } catch (error) { | ||||||
|  |       console.log("Error validating queue object in local storage; resetting queue.", error); | ||||||
|  |       return createEmptyQueue(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   function removeFromQueue(itemQueue: ShoppingListItemOut[], item: ShoppingListItemOut): boolean { | ||||||
|  |     const index = itemQueue.findIndex(i => i.id === item.id); | ||||||
|     if (index === -1) { |     if (index === -1) { | ||||||
|       return false; |       return false; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     queue.splice(index, 1); |     itemQueue.splice(index, 1); | ||||||
|     return true; |     return true; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   async function getList() { |   async function getList() { | ||||||
|     const response = await api.shopping.lists.getOne(shoppingListId); |     const response = await api.shopping.lists.getOne(shoppingListId); | ||||||
|     handleResponse(response); |  | ||||||
|     return response.data; |     return response.data; | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -90,44 +137,59 @@ export function useShoppingListItemActions(shoppingListId: string) { | |||||||
|     if (itemQueueType === "delete" || itemQueueType === "all") { |     if (itemQueueType === "delete" || itemQueueType === "all") { | ||||||
|       queue.delete = itemIds ? queue.delete.filter(item => !itemIds.includes(item.id)) : []; |       queue.delete = itemIds ? queue.delete.filter(item => !itemIds.includes(item.id)) : []; | ||||||
|     } |     } | ||||||
|  |     if (queueEmpty.value) { | ||||||
|  |       queue.lastUpdate = Date.now(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|     // Set the storage value explicitly so changes are saved in the browser. |   function checkUpdateState(list: ShoppingListOut) { | ||||||
|     storage.value[shoppingListId] = { ...queue }; |     const cutoffDate = new Date(queue.lastUpdate + queueTimeout).toISOString(); | ||||||
|  |     if (list.updateAt && list.updateAt > cutoffDate) { | ||||||
|  |       // If the queue is too far behind the shopping list to reliably do updates, we clear the queue | ||||||
|  |       console.log("Out of sync with server; clearing queue"); | ||||||
|  |       clearQueueItems("all"); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * Handles the response from the backend and sets the isOffline flag if necessary. |    * Processes the queue items and returns whether the processing was successful. | ||||||
|    */ |    */ | ||||||
|   function handleResponse(response: any) { |  | ||||||
|     // TODO: is there a better way of checking for network errors? |  | ||||||
|     isOffline.value = response.error?.message?.includes("Network Error") || false; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   async function processQueueItems( |   async function processQueueItems( | ||||||
|     action: (items: ShoppingListItemOut[]) => Promise<any>, |     action: (items: ShoppingListItemOut[]) => Promise<RequestResponse<any>>, | ||||||
|     itemQueueType: ItemQueueType, |     itemQueueType: ItemQueueType, | ||||||
|   ) { |   ): Promise<boolean> { | ||||||
|     const queueItems = getQueueItems(itemQueueType); |     let queueItems: ShoppingListItemOut[]; | ||||||
|  |     try { | ||||||
|  |       queueItems = getQueueItems(itemQueueType); | ||||||
|       if (!queueItems.length) { |       if (!queueItems.length) { | ||||||
|       return; |         return true; | ||||||
|  |       } | ||||||
|  |     } catch (error) { | ||||||
|  |       console.log(`Error fetching queue items of type ${itemQueueType}:`, error); | ||||||
|  |       clearQueueItems(itemQueueType); | ||||||
|  |       return false; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     try { | ||||||
|       const itemsToProcess = [...queueItems]; |       const itemsToProcess = [...queueItems]; | ||||||
|       await action(itemsToProcess) |       await action(itemsToProcess) | ||||||
|       .then((response) => { |         .then(() => { | ||||||
|         handleResponse(response); |           if (window.$nuxt.isOnline) { | ||||||
|         if (!isOffline.value) { |  | ||||||
|             clearQueueItems(itemQueueType, itemsToProcess.map(item => item.id)); |             clearQueueItems(itemQueueType, itemsToProcess.map(item => item.id)); | ||||||
|           } |           } | ||||||
|         }); |         }); | ||||||
|  |     } catch (error) { | ||||||
|  |       console.log(`Error processing queue items of type ${itemQueueType}:`, error); | ||||||
|  |       clearQueueItems(itemQueueType); | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return true; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   async function process() { |   async function process() { | ||||||
|     if( |     if(queueEmpty.value) { | ||||||
|       !queue.create.length && |       queue.lastUpdate = Date.now(); | ||||||
|       !queue.update.length && |  | ||||||
|       !queue.delete.length |  | ||||||
|     ) { |  | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -135,26 +197,23 @@ export function useShoppingListItemActions(shoppingListId: string) { | |||||||
|     if (!data) { |     if (!data) { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |     checkUpdateState(data); | ||||||
|  |  | ||||||
|     const cutoffDate = new Date(queue.lastUpdate + queueTimeout).toISOString(); |  | ||||||
|     if (data.updateAt && data.updateAt > cutoffDate) { |  | ||||||
|       // If the queue is too far behind the shopping list to reliably do updates, we clear the queue |  | ||||||
|       clearQueueItems("all"); |  | ||||||
|     } else { |  | ||||||
|     // We send each bulk request one at a time, since the backend may merge items |     // We send each bulk request one at a time, since the backend may merge items | ||||||
|       await processQueueItems((items) => api.shopping.items.deleteMany(items), "delete"); |     // "failures" here refers to an actual error, rather than failing to reach the backend | ||||||
|       await processQueueItems((items) => api.shopping.items.updateMany(items), "update"); |     let failures = 0; | ||||||
|       await processQueueItems((items) => api.shopping.items.createMany(items), "create"); |     if (!(await processQueueItems((items) => api.shopping.items.deleteMany(items), "delete"))) failures++; | ||||||
|     } |     if (!(await processQueueItems((items) => api.shopping.items.updateMany(items), "update"))) failures++; | ||||||
|  |     if (!(await processQueueItems((items) => api.shopping.items.createMany(items), "create"))) failures++; | ||||||
|  |  | ||||||
|     // If we're online, the queue is fully processed, so we're up to date |     // If we're online, or the queue is empty, the queue is fully processed, so we're up to date | ||||||
|     if (!isOffline.value) { |     // Otherwise, if all three queue processes failed, we've already reset the queue, so we need to reset the date | ||||||
|  |     if (window.$nuxt.isOnline || queueEmpty.value || failures === 3) { | ||||||
|       queue.lastUpdate = Date.now(); |       queue.lastUpdate = Date.now(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   return { |   return { | ||||||
|     isOffline, |  | ||||||
|     getList, |     getList, | ||||||
|     createItem, |     createItem, | ||||||
|     updateItem, |     updateItem, | ||||||
|   | |||||||
| @@ -28,7 +28,7 @@ | |||||||
|       <template #title> {{ shoppingList.name }} </template> |       <template #title> {{ shoppingList.name }} </template> | ||||||
|     </BasePageTitle> |     </BasePageTitle> | ||||||
|     <BannerWarning |     <BannerWarning | ||||||
|       v-if="isOffline" |       v-if="$nuxt.isOffline" | ||||||
|       :title="$tc('shopping-list.you-are-offline')" |       :title="$tc('shopping-list.you-are-offline')" | ||||||
|       :description="$tc('shopping-list.you-are-offline-description')" |       :description="$tc('shopping-list.you-are-offline-description')" | ||||||
|     /> |     /> | ||||||
| @@ -46,7 +46,6 @@ | |||||||
|               :units="allUnits || []" |               :units="allUnits || []" | ||||||
|               :foods="allFoods || []" |               :foods="allFoods || []" | ||||||
|               :recipes="recipeMap" |               :recipes="recipeMap" | ||||||
|               :is-offline="isOffline" |  | ||||||
|               @checked="saveListItem" |               @checked="saveListItem" | ||||||
|               @save="saveListItem" |               @save="saveListItem" | ||||||
|               @delete="deleteListItem(item)" |               @delete="deleteListItem(item)" | ||||||
| @@ -75,7 +74,6 @@ | |||||||
|                 :units="allUnits || []" |                 :units="allUnits || []" | ||||||
|                 :foods="allFoods || []" |                 :foods="allFoods || []" | ||||||
|                 :recipes="recipeMap" |                 :recipes="recipeMap" | ||||||
|                 :is-offline="isOffline" |  | ||||||
|                 @checked="saveListItem" |                 @checked="saveListItem" | ||||||
|                 @save="saveListItem" |                 @save="saveListItem" | ||||||
|                 @delete="deleteListItem(item)" |                 @delete="deleteListItem(item)" | ||||||
| @@ -132,7 +130,6 @@ | |||||||
|           :labels="allLabels || []" |           :labels="allLabels || []" | ||||||
|           :units="allUnits || []" |           :units="allUnits || []" | ||||||
|           :foods="allFoods || []" |           :foods="allFoods || []" | ||||||
|           :is-offline="isOffline" |  | ||||||
|           @delete="createEditorOpen = false" |           @delete="createEditorOpen = false" | ||||||
|           @cancel="createEditorOpen = false" |           @cancel="createEditorOpen = false" | ||||||
|           @save="createListItem" |           @save="createListItem" | ||||||
| @@ -141,7 +138,7 @@ | |||||||
|       <div v-else class="mt-4 d-flex justify-end"> |       <div v-else class="mt-4 d-flex justify-end"> | ||||||
|         <BaseButton |         <BaseButton | ||||||
|           v-if="preferences.viewByLabel" edit class="mr-2" |           v-if="preferences.viewByLabel" edit class="mr-2" | ||||||
|           :disabled="isOffline" |           :disabled="$nuxt.isOffline" | ||||||
|           @click="toggleReorderLabelsDialog"> |           @click="toggleReorderLabelsDialog"> | ||||||
|           <template #icon> {{ $globals.icons.tags }} </template> |           <template #icon> {{ $globals.icons.tags }} </template> | ||||||
|           {{ $t('shopping-list.reorder-labels') }} |           {{ $t('shopping-list.reorder-labels') }} | ||||||
| @@ -221,7 +218,6 @@ | |||||||
|                 :labels="allLabels || []" |                 :labels="allLabels || []" | ||||||
|                 :units="allUnits || []" |                 :units="allUnits || []" | ||||||
|                 :foods="allFoods || []" |                 :foods="allFoods || []" | ||||||
|                 :is-offline="isOffline" |  | ||||||
|                 @checked="saveListItem" |                 @checked="saveListItem" | ||||||
|                 @save="saveListItem" |                 @save="saveListItem" | ||||||
|                 @delete="deleteListItem(item)" |                 @delete="deleteListItem(item)" | ||||||
| @@ -244,10 +240,10 @@ | |||||||
|           {{ $tc('shopping-list.linked-recipes-count', shoppingList.recipeReferences ? shoppingList.recipeReferences.length : 0) }} |           {{ $tc('shopping-list.linked-recipes-count', shoppingList.recipeReferences ? shoppingList.recipeReferences.length : 0) }} | ||||||
|         </div> |         </div> | ||||||
|         <v-divider class="my-4"></v-divider> |         <v-divider class="my-4"></v-divider> | ||||||
|         <RecipeList :recipes="Array.from(recipeMap.values())" show-description :disabled="isOffline"> |         <RecipeList :recipes="Array.from(recipeMap.values())" show-description :disabled="$nuxt.isOffline"> | ||||||
|           <template v-for="(recipe, index) in recipeMap.values()" #[`actions-${recipe.id}`]> |           <template v-for="(recipe, index) in recipeMap.values()" #[`actions-${recipe.id}`]> | ||||||
|             <v-list-item-action :key="'item-actions-decrease' + recipe.id"> |             <v-list-item-action :key="'item-actions-decrease' + recipe.id"> | ||||||
|               <v-btn icon :disabled="isOffline" @click.prevent="removeRecipeReferenceToList(recipe.id)"> |               <v-btn icon :disabled="$nuxt.isOffline" @click.prevent="removeRecipeReferenceToList(recipe.id)"> | ||||||
|                 <v-icon color="grey lighten-1">{{ $globals.icons.minus }}</v-icon> |                 <v-icon color="grey lighten-1">{{ $globals.icons.minus }}</v-icon> | ||||||
|               </v-btn> |               </v-btn> | ||||||
|             </v-list-item-action> |             </v-list-item-action> | ||||||
| @@ -255,7 +251,7 @@ | |||||||
|               {{ shoppingList.recipeReferences[index].recipeQuantity }} |               {{ shoppingList.recipeReferences[index].recipeQuantity }} | ||||||
|             </div> |             </div> | ||||||
|             <v-list-item-action :key="'item-actions-increase' + recipe.id"> |             <v-list-item-action :key="'item-actions-increase' + recipe.id"> | ||||||
|               <v-btn icon :disabled="isOffline" @click.prevent="addRecipeReferenceToList(recipe.id)"> |               <v-btn icon :disabled="$nuxt.isOffline" @click.prevent="addRecipeReferenceToList(recipe.id)"> | ||||||
|                 <v-icon color="grey lighten-1">{{ $globals.icons.createAlt }}</v-icon> |                 <v-icon color="grey lighten-1">{{ $globals.icons.createAlt }}</v-icon> | ||||||
|               </v-btn> |               </v-btn> | ||||||
|             </v-list-item-action> |             </v-list-item-action> | ||||||
| @@ -268,7 +264,7 @@ | |||||||
|       <div class="d-flex justify-end"> |       <div class="d-flex justify-end"> | ||||||
|         <BaseButton |         <BaseButton | ||||||
|           edit |           edit | ||||||
|           :disabled="isOffline" |           :disabled="$nuxt.isOffline" | ||||||
|           @click="toggleSettingsDialog" |           @click="toggleSettingsDialog" | ||||||
|         > |         > | ||||||
|           <template #icon> {{ $globals.icons.cog }} </template> |           <template #icon> {{ $globals.icons.cog }} </template> | ||||||
| @@ -278,7 +274,7 @@ | |||||||
|     </v-lazy> |     </v-lazy> | ||||||
|  |  | ||||||
|     <v-lazy> |     <v-lazy> | ||||||
|       <div v-if="!isOffline" class="d-flex justify-end mt-10"> |       <div v-if="$nuxt.isOnline" class="d-flex justify-end mt-10"> | ||||||
|         <ButtonLink |         <ButtonLink | ||||||
|           :to="`/group/data/labels`" |           :to="`/group/data/labels`" | ||||||
|           :text="$tc('shopping-list.manage-labels')" |           :text="$tc('shopping-list.manage-labels')" | ||||||
| @@ -1072,7 +1068,6 @@ export default defineComponent({ | |||||||
|       getLabelColor, |       getLabelColor, | ||||||
|       groupSlug, |       groupSlug, | ||||||
|       itemsByLabel, |       itemsByLabel, | ||||||
|       isOffline: shoppingListItemActions.isOffline, |  | ||||||
|       listItems, |       listItems, | ||||||
|       loadingCounter, |       loadingCounter, | ||||||
|       preferences, |       preferences, | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user