mealie/frontend/composables/use-shopping-list-item-actions.ts
Hoa (Kyle) Trinh c24d532608
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Docker Nightly Production / Backend Server Tests (push) Waiting to run
Docker Nightly Production / Frontend Tests (push) Waiting to run
Docker Nightly Production / Build Package (push) Waiting to run
Docker Nightly Production / Build Tagged Release (push) Blocked by required conditions
Docker Nightly Production / Notify Discord (push) Blocked by required conditions
Release Drafter / ✏️ Draft release (push) Waiting to run
feat: Migrate to Nuxt 3 framework (#5184)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-06-19 17:09:12 +00:00

250 lines
7.7 KiB
TypeScript

import { useLocalStorage, useOnline } from "@vueuse/core";
import { useUserApi } from "~/composables/api";
import type { ShoppingListItemOut, ShoppingListOut } from "~/lib/api/types/household";
import type { RequestResponse } from "~/lib/api/types/non-generated";
const localStorageKey = "shopping-list-queue";
const queueTimeout = 5 * 60 * 1000; // 5 minutes
type ItemQueueType = "create" | "update" | "delete";
interface ShoppingListQueue {
create: ShoppingListItemOut[];
update: ShoppingListItemOut[];
delete: ShoppingListItemOut[];
lastUpdate: number;
}
interface Storage {
[key: string]: ShoppingListQueue;
}
export function useShoppingListItemActions(shoppingListId: string) {
const isOnline = useOnline();
const api = useUserApi();
const storage = useLocalStorage(localStorageKey, {} as Storage, { deep: true });
const queue = reactive(getQueue());
const queueEmpty = computed(() => !queue.create.length && !queue.update.length && !queue.delete.length);
if (queueEmpty.value) {
queue.lastUpdate = Date.now();
}
storage.value[shoppingListId] = { ...queue };
watch(
() => queue,
(value) => {
storage.value[shoppingListId] = { ...value };
},
{
deep: true,
immediate: true,
},
);
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);
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;
}
itemQueue.splice(index, 1);
return true;
}
function mergeListItemsByLatest(
list1: ShoppingListItemOut[],
list2: ShoppingListItemOut[],
) {
const mergedList = [...list1];
list2.forEach((list2Item) => {
const conflictingItem = mergedList.find(item => item.id === list2Item.id);
if (conflictingItem
&& list2Item.updatedAt && conflictingItem.updatedAt
&& list2Item.updatedAt > conflictingItem.updatedAt) {
mergedList.splice(mergedList.indexOf(conflictingItem), 1, list2Item);
}
else if (!conflictingItem) {
mergedList.push(list2Item);
}
});
return mergedList;
}
async function getList() {
const response = await api.shopping.lists.getOne(shoppingListId);
if (!isOnline.value && response.data) {
const createAndUpdateQueues = mergeListItemsByLatest(queue.update, queue.create);
response.data.listItems = mergeListItemsByLatest(response.data.listItems ?? [], createAndUpdateQueues);
}
return response.data;
}
function createItem(item: ShoppingListItemOut) {
removeFromQueue(queue.create, item);
queue.create.push(item);
}
function updateItem(item: ShoppingListItemOut) {
const removedFromCreate = removeFromQueue(queue.create, item);
if (removedFromCreate) {
// this item hasn't been created yet, so we don't need to update it
queue.create.push(item);
return;
}
removeFromQueue(queue.update, item);
queue.update.push(item);
}
function deleteItem(item: ShoppingListItemOut) {
const removedFromCreate = removeFromQueue(queue.create, item);
if (removedFromCreate) {
// this item hasn't been created yet, so we don't need to delete it
return;
}
removeFromQueue(queue.update, item);
removeFromQueue(queue.delete, item);
queue.delete.push(item);
}
function getQueueItems(itemQueueType: ItemQueueType) {
return queue[itemQueueType];
}
function clearQueueItems(itemQueueType: ItemQueueType | "all", itemIds: string[] | null = null) {
if (itemQueueType === "create" || itemQueueType === "all") {
queue.create = itemIds ? queue.create.filter(item => !itemIds.includes(item.id)) : [];
}
if (itemQueueType === "update" || itemQueueType === "all") {
queue.update = itemIds ? queue.update.filter(item => !itemIds.includes(item.id)) : [];
}
if (itemQueueType === "delete" || itemQueueType === "all") {
queue.delete = itemIds ? queue.delete.filter(item => !itemIds.includes(item.id)) : [];
}
if (queueEmpty.value) {
queue.lastUpdate = Date.now();
}
}
function checkUpdateState(list: ShoppingListOut) {
const cutoffDate = new Date(queue.lastUpdate + queueTimeout).toISOString();
if (list.updatedAt && list.updatedAt > 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");
}
}
/**
* Processes the queue items and returns whether the processing was successful.
*/
async function processQueueItems(
action: (items: ShoppingListItemOut[]) => Promise<RequestResponse<any>>,
itemQueueType: ItemQueueType,
): 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;
}
try {
const itemsToProcess = [...queueItems];
await action(itemsToProcess)
.then(() => {
if (isOnline.value) {
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 (queueEmpty.value) {
queue.lastUpdate = Date.now();
return;
}
const data = await getList();
if (!data) {
return;
}
checkUpdateState(data);
// 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, 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 (isOnline.value || queueEmpty.value || failures === 3) {
queue.lastUpdate = Date.now();
}
}
return {
getList,
createItem,
updateItem,
deleteItem,
process,
};
}