mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -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:
parent
30b2776f3c
commit
9d58f9b266
@ -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[];
|
||||||
if (!queueItems.length) {
|
try {
|
||||||
return;
|
queueItems = getQueueItems(itemQueueType);
|
||||||
|
if (!queueItems.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Error fetching queue items of type ${itemQueueType}:`, error);
|
||||||
|
clearQueueItems(itemQueueType);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemsToProcess = [...queueItems];
|
try {
|
||||||
await action(itemsToProcess)
|
const itemsToProcess = [...queueItems];
|
||||||
.then((response) => {
|
await action(itemsToProcess)
|
||||||
handleResponse(response);
|
.then(() => {
|
||||||
if (!isOffline.value) {
|
if (window.$nuxt.isOnline) {
|
||||||
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();
|
// We send each bulk request one at a time, since the backend may merge items
|
||||||
if (data.updateAt && data.updateAt > cutoffDate) {
|
// "failures" here refers to an actual error, rather than failing to reach the backend
|
||||||
// If the queue is too far behind the shopping list to reliably do updates, we clear the queue
|
let failures = 0;
|
||||||
clearQueueItems("all");
|
if (!(await processQueueItems((items) => api.shopping.items.deleteMany(items), "delete"))) failures++;
|
||||||
} else {
|
if (!(await processQueueItems((items) => api.shopping.items.updateMany(items), "update"))) failures++;
|
||||||
// We send each bulk request one at a time, since the backend may merge items
|
if (!(await processQueueItems((items) => api.shopping.items.createMany(items), "create"))) failures++;
|
||||||
await processQueueItems((items) => api.shopping.items.deleteMany(items), "delete");
|
|
||||||
await processQueueItems((items) => api.shopping.items.updateMany(items), "update");
|
|
||||||
await processQueueItems((items) => api.shopping.items.createMany(items), "create");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user