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:
Michael Genson 2024-07-27 21:25:58 -05:00 committed by GitHub
parent 30b2776f3c
commit 9d58f9b266
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 114 additions and 64 deletions

View File

@ -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();

View File

@ -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,

View File

@ -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,