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-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs" no-gutters class="mb-2">
<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-row>
<v-row v-if="listItem.checked" no-gutters class="mb-2">
@ -136,10 +136,6 @@ export default defineComponent({
type: Map<string, RecipeSummary>,
default: undefined,
},
isOffline: {
type: Boolean,
default: false,
},
},
setup(props, context) {
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 { 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 queueTimeout = 5 * 60 * 1000; // 5 minutes
@ -23,27 +24,73 @@ interface Storage {
export function useShoppingListItemActions(shoppingListId: string) {
const api = useUserApi();
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);
if (queueEmpty.value) {
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 {
const index = queue.findIndex(i => i.id === item.id);
function isValidQueueObject(obj: any): obj is ShoppingListQueue {
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) {
return false;
}
queue.splice(index, 1);
itemQueue.splice(index, 1);
return true;
}
async function getList() {
const response = await api.shopping.lists.getOne(shoppingListId);
handleResponse(response);
return response.data;
}
@ -90,44 +137,59 @@ export function useShoppingListItemActions(shoppingListId: string) {
if (itemQueueType === "delete" || itemQueueType === "all") {
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.
storage.value[shoppingListId] = { ...queue };
function checkUpdateState(list: ShoppingListOut) {
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(
action: (items: ShoppingListItemOut[]) => Promise<any>,
action: (items: ShoppingListItemOut[]) => Promise<RequestResponse<any>>,
itemQueueType: ItemQueueType,
) {
const queueItems = getQueueItems(itemQueueType);
if (!queueItems.length) {
return;
): Promise<boolean> {
let queueItems: ShoppingListItemOut[];
try {
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];
await action(itemsToProcess)
.then((response) => {
handleResponse(response);
if (!isOffline.value) {
clearQueueItems(itemQueueType, itemsToProcess.map(item => item.id));
}
});
try {
const itemsToProcess = [...queueItems];
await action(itemsToProcess)
.then(() => {
if (window.$nuxt.isOnline) {
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() {
if(
!queue.create.length &&
!queue.update.length &&
!queue.delete.length
) {
if(queueEmpty.value) {
queue.lastUpdate = Date.now();
return;
}
@ -135,26 +197,23 @@ export function useShoppingListItemActions(shoppingListId: string) {
if (!data) {
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
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");
}
// We send each bulk request one at a time, since the backend may merge items
// "failures" here refers to an actual error, rather than failing to reach the backend
let failures = 0;
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 (!isOffline.value) {
// If we're online, or the queue is empty, the queue is fully processed, so we're up to date
// 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();
}
}
return {
isOffline,
getList,
createItem,
updateItem,

View File

@ -28,7 +28,7 @@
<template #title> {{ shoppingList.name }} </template>
</BasePageTitle>
<BannerWarning
v-if="isOffline"
v-if="$nuxt.isOffline"
:title="$tc('shopping-list.you-are-offline')"
:description="$tc('shopping-list.you-are-offline-description')"
/>
@ -46,7 +46,6 @@
:units="allUnits || []"
:foods="allFoods || []"
:recipes="recipeMap"
:is-offline="isOffline"
@checked="saveListItem"
@save="saveListItem"
@delete="deleteListItem(item)"
@ -75,7 +74,6 @@
:units="allUnits || []"
:foods="allFoods || []"
:recipes="recipeMap"
:is-offline="isOffline"
@checked="saveListItem"
@save="saveListItem"
@delete="deleteListItem(item)"
@ -132,7 +130,6 @@
:labels="allLabels || []"
:units="allUnits || []"
:foods="allFoods || []"
:is-offline="isOffline"
@delete="createEditorOpen = false"
@cancel="createEditorOpen = false"
@save="createListItem"
@ -141,7 +138,7 @@
<div v-else class="mt-4 d-flex justify-end">
<BaseButton
v-if="preferences.viewByLabel" edit class="mr-2"
:disabled="isOffline"
:disabled="$nuxt.isOffline"
@click="toggleReorderLabelsDialog">
<template #icon> {{ $globals.icons.tags }} </template>
{{ $t('shopping-list.reorder-labels') }}
@ -221,7 +218,6 @@
:labels="allLabels || []"
:units="allUnits || []"
:foods="allFoods || []"
:is-offline="isOffline"
@checked="saveListItem"
@save="saveListItem"
@delete="deleteListItem(item)"
@ -244,10 +240,10 @@
{{ $tc('shopping-list.linked-recipes-count', shoppingList.recipeReferences ? shoppingList.recipeReferences.length : 0) }}
</div>
<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}`]>
<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-btn>
</v-list-item-action>
@ -255,7 +251,7 @@
{{ shoppingList.recipeReferences[index].recipeQuantity }}
</div>
<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-btn>
</v-list-item-action>
@ -268,7 +264,7 @@
<div class="d-flex justify-end">
<BaseButton
edit
:disabled="isOffline"
:disabled="$nuxt.isOffline"
@click="toggleSettingsDialog"
>
<template #icon> {{ $globals.icons.cog }} </template>
@ -278,7 +274,7 @@
</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
:to="`/group/data/labels`"
:text="$tc('shopping-list.manage-labels')"
@ -1072,7 +1068,6 @@ export default defineComponent({
getLabelColor,
groupSlug,
itemsByLabel,
isOffline: shoppingListItemActions.isOffline,
listItems,
loadingCounter,
preferences,