import {acceptHMRUpdate, defineStore} from "pinia" import {ApiApi, ApiShoppingListEntryListRequest, Food, Recipe, ShoppingListEntry, ShoppingListEntryBulk, ShoppingListRecipe, Supermarket, SupermarketCategory} from "@/openapi"; import {computed, ref} from "vue"; import { IShoppingExportEntry, IShoppingList, IShoppingListCategory, IShoppingListFood, IShoppingSyncQueueEntry, ShoppingGroupingOptions, ShoppingListStats, ShoppingOperationHistoryEntry, ShoppingOperationHistoryType } from "@/types/Shopping"; import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore"; import {useUserPreferenceStore} from "@/stores/UserPreferenceStore"; import {isDelayed} from "@/utils/logic_utils"; import {DateTime} from "luxon"; const _STORE_ID = "shopping_store" const UNDEFINED_CATEGORY = 'shopping_undefined_category' export const useShoppingStore = defineStore(_STORE_ID, () => { let entries = ref(new Map) let supermarketCategories = ref([] as SupermarketCategory[]) let supermarkets = ref([] as Supermarket[]) let stats = ref({ countChecked: 0, countUnchecked: 0, countCheckedFood: 0, countUncheckedFood: 0, countUncheckedDelayed: 0, } as ShoppingListStats) // internal let currentlyUpdating = ref(false) let initialized = ref(false) let autoSyncLastTimestamp = ref(new Date('1970-01-01')) let autoSyncHasFocus = ref(true) let autoSyncTimeoutId = ref(0) let undoStack = ref([] as ShoppingOperationHistoryEntry[]) let queueTimeoutId = ref(-1) let itemCheckSyncQueue = ref([] as IShoppingSyncQueueEntry[]) /** * build a multi-level data structure ready for display from shopping list entries * group by selected grouping key */ const getEntriesByGroup = computed(() => { stats.value = { countChecked: 0, countUnchecked: 0, countCheckedFood: 0, countUncheckedFood: 0, countUncheckedDelayed: 0, } as ShoppingListStats let structure = {} as IShoppingList structure.categories = new Map if (useUserPreferenceStore().deviceSettings.shopping_selected_grouping === ShoppingGroupingOptions.CATEGORY && useUserPreferenceStore().deviceSettings.shopping_selected_supermarket != null) { useUserPreferenceStore().deviceSettings.shopping_selected_supermarket.categoryToSupermarket.forEach(cTS => { structure.categories.set(cTS.category.name, {'name': cTS.category.name, 'foods': new Map} as IShoppingListCategory) }) } let orderedStructure = [] as IShoppingListCategory[] // build structure entries.value.forEach(shoppingListEntry => { structure = updateEntryInStructure(structure, shoppingListEntry) }) // statistics for UI conditions and display structure.categories.forEach(category => { let categoryStats = { countChecked: 0, countUnchecked: 0, countCheckedFood: 0, countUncheckedFood: 0, countUncheckedDelayed: 0, } as ShoppingListStats category.foods.forEach(food => { let food_checked = true food.entries.forEach(entry => { if (entry.checked) { categoryStats.countChecked++ } else { if (isDelayed(entry)) { categoryStats.countUncheckedDelayed++ } else { categoryStats.countUnchecked++ } } }) if (food_checked) { categoryStats.countCheckedFood++ } else { categoryStats.countUncheckedFood++ } }) category.stats = categoryStats stats.value.countChecked += categoryStats.countChecked stats.value.countUnchecked += categoryStats.countUnchecked stats.value.countCheckedFood += categoryStats.countCheckedFood stats.value.countUncheckedFood += categoryStats.countUncheckedFood }) // ordering let undefinedCategoryGroup = structure.categories.get(UNDEFINED_CATEGORY) if (undefinedCategoryGroup != null) { orderedStructure.push(undefinedCategoryGroup) structure.categories.delete(UNDEFINED_CATEGORY) } structure.categories.forEach(category => { orderedStructure.push(category) }) return orderedStructure }) /** * flattened list of entries used for exporters * kinda uncool but works for now * @return IShoppingExportEntry[] */ function getFlatEntries() { let items: IShoppingExportEntry[] = [] getEntriesByGroup.value.forEach(shoppingListEntry => { shoppingListEntry.foods.forEach(food => { food.entries.forEach(entry => { items.push({ amount: entry.amount, unit: entry.unit?.name ?? '', food: entry.food?.name ?? '', }) }) }) }) return items } /** * very simple list of shopping list entries as IShoppingListFood array filtered by a certain mealplan * @param mealPlanId ID of mealplan */ function getMealPlanEntries(mealPlanId: number) { let items: IShoppingListFood[] = [] entries.value.forEach(shoppingListEntry => { if (shoppingListEntry.listRecipe && shoppingListEntry.listRecipeData.mealplan == mealPlanId) { items.push({ food: shoppingListEntry.food, entries: new Map().set(shoppingListEntry.id!, shoppingListEntry) } as IShoppingListFood) } }) return items } /** * checks if failed items are contained in the sync queue */ function hasFailedItems() { for (let i in itemCheckSyncQueue.value) { if (itemCheckSyncQueue.value[i]['status'] === 'syncing_failed_before' || itemCheckSyncQueue.value[i]['status'] === 'waiting_failed_before') { return true } } return false } /** * Retrieves all shopping related data (shopping list entries, supermarkets, supermarket categories and shopping list recipes) from API * @param mealPlanId optionally filter by mealplan ID and only load entries associated with that */ function refreshFromAPI(mealPlanId?: number) { if (!currentlyUpdating.value) { currentlyUpdating.value = true autoSyncLastTimestamp.value = new Date(); let api = new ApiApi() let requestParameters = {pageSize: 200} as ApiShoppingListEntryListRequest if (mealPlanId) { requestParameters.mealplan = mealPlanId } api.apiShoppingListEntryList(requestParameters).then((r) => { entries.value = new Map // TODO properly load pages r.results.forEach((e) => { entries.value.set(e.id!, e) }) currentlyUpdating.value = false initialized.value = true }).catch((err) => { currentlyUpdating.value = false useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err) }) api.apiSupermarketCategoryList().then(r => { supermarketCategories.value = r.results }).catch((err) => { useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err) }) api.apiSupermarketList().then(r => { supermarkets.value = r.results }).catch((err) => { useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err) }) } } /** * perform auto sync request to special endpoint returning only entries changed since last auto sync */ function autoSync() { if (!currentlyUpdating.value && autoSyncHasFocus.value && !hasFailedItems()) { currentlyUpdating.value = true const api = new ApiApi() api.apiShoppingListEntryList({updatedAfter: autoSyncLastTimestamp.value}).then((r) => { autoSyncLastTimestamp.value = r.timestamp! r.results.forEach((e) => { entries.value.set(e.id!, e) }) currentlyUpdating.value = false }).catch((err: any) => { currentlyUpdating.value = false }) } } /** * creates new ShoppingListEntry in database and updates it in store * @param object entry to create * @param undo if the user should be able to undo the change or not */ function createObject(object: ShoppingListEntry, undo: boolean) { const api = new ApiApi() return api.apiShoppingListEntryCreate({shoppingListEntry: object}).then((r) => { entries.value.set(r.id!, r) if (undo) { registerChange("CREATE", [r]) } return r }).catch((err) => { useMessageStore().addError(ErrorMessageType.CREATE_ERROR, err) return undefined }) } /** * update existing entry object and updated_at timestamp * updates data in store * IMPORTANT: always use this method to update objects to keep client state consistent * @param object entry object to update * @return {Promise} promise of updating call to subscribe to */ function updateObject(object: ShoppingListEntry) { const api = new ApiApi() // sets the update_at timestamp on the client to prevent auto sync from overriding with older changes // moment().format() yields locale aware datetime without ms 2024-01-04T13:39:08.607238+01:00 //Vue.set(object, 'updated_at', moment().format()) // object.updatedAt = DateTime.toLocaleString() // TODO setting timestamp on the client does not make sense because client and server clock might be out of sync and field will be overridden by server anyway return api.apiShoppingListEntryUpdate({id: object.id!, shoppingListEntry: object}).then((r) => { entries.value.set(r.id!, r) }).catch((err) => { useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err) }) } /** * delete shopping list entry object from DB and store * @param object entry object to delete * @param undo if the user should be able to undo the change or not */ function deleteObject(object: ShoppingListEntry, undo: boolean) { const api = new ApiApi() return api.apiShoppingListEntryDestroy({id: object.id!}).then((r) => { entries.value.delete(object.id!) if (undo) { registerChange("DESTROY", [object]) } }).catch((err) => { useMessageStore().addError(ErrorMessageType.DELETE_ERROR, err) }) } /** * returns a distinct list of recipes associated with unchecked shopping list entries */ function getAssociatedRecipes(): ShoppingListRecipe[] { let recipes = [] as ShoppingListRecipe[] entries.value.forEach(e => { if (e.listRecipe != null && recipes.findIndex(x => x.id == e.listRecipe) == -1) { recipes.push(e.listRecipeData) } }) return recipes } // convenience methods /** * puts an entry into the appropriate group of the IShoppingList datastructure * if a group does not yet exist and the sorting is not set to category with selected supermarket only, it will be created * @param structure * @param entry */ function updateEntryInStructure(structure: IShoppingList, entry: ShoppingListEntry) { let groupingKey = UNDEFINED_CATEGORY let group = useUserPreferenceStore().deviceSettings.shopping_selected_grouping if (group == ShoppingGroupingOptions.CATEGORY && entry.food != null && entry.food.supermarketCategory != null) { groupingKey = entry.food?.supermarketCategory?.name } else if (group == ShoppingGroupingOptions.CREATED_BY) { groupingKey = entry.createdBy.displayName } else if (group == ShoppingGroupingOptions.RECIPE && entry.listRecipeData != null) { if (entry.listRecipeData.recipeData != null) { groupingKey = entry.listRecipeData.recipeData.name if (entry.listRecipeData.mealPlanData != null) { groupingKey += ' - ' + entry.listRecipeData.mealPlanData.mealType.name + ' - ' + DateTime.fromJSDate(entry.listRecipeData.mealPlanData.fromDate).toLocaleString(DateTime.DATE_SHORT) } } } if (!structure.categories.has(groupingKey) && !(group == ShoppingGroupingOptions.CATEGORY && useUserPreferenceStore().deviceSettings.shopping_show_selected_supermarket_only)) { structure.categories.set(groupingKey, {'name': groupingKey, 'foods': new Map} as IShoppingListCategory) } if (structure.categories.has(groupingKey)) { if (!structure.categories.get(groupingKey).foods.has(entry.food.id)) { structure.categories.get(groupingKey).foods.set(entry.food.id, { food: entry.food, entries: new Map } as IShoppingListFood) } structure.categories.get(groupingKey).foods.get(entry.food.id).entries.set(entry.id, entry) } return structure } /** * function to handle user checking or unchecking a set of entries * @param {{}} entries set of entries * @param checked boolean to set checked state of entry to * @param undo if the user should be able to undo the change or not */ function setEntriesCheckedState(entries: ShoppingListEntry[], checked: boolean, undo: boolean) { if (undo) { registerChange((checked ? 'CHECKED' : 'UNCHECKED'), entries) } let entryIdList: number[] = [] entries.forEach(entry => { entry.checked = checked entryIdList.push(entry.id!) }) itemCheckSyncQueue.value.push({ ids: entryIdList, checked: checked, status: 'waiting', } as IShoppingSyncQueueEntry) runSyncQueue(5) } /** * go through the list of queued requests and try to run them * add request back to queue if it fails due to offline or timeout * Do NOT call this method directly, always call using runSyncQueue method to prevent simultaneous runs * @private */ function _replaySyncQueue() { if (navigator.onLine || document.location.href.includes('localhost')) { let api = new ApiApi() let promises: Promise[] = [] itemCheckSyncQueue.value.forEach((entry, index) => { entry['status'] = ((entry['status'] === 'waiting') ? 'syncing' : 'syncing_failed_before') let p = api.apiShoppingListEntryBulkCreate({shoppingListEntryBulk: entry}, {}).then((r) => { entry.ids.forEach(id => { let e = entries.value.get(id) e.updatedAt = r.timestamp entries.value.set(id, e) }) itemCheckSyncQueue.value.splice(index, 1) }).catch((err) => { if (err.name === "FetchError") { entry['status'] = 'waiting_failed_before' } else { itemCheckSyncQueue.value.splice(index, 1) useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err) } }) promises.push(p) }) Promise.allSettled(promises).finally(() => { if (itemCheckSyncQueue.value.length > 0) { runSyncQueue(500) } }) } else { // try again if internet after a few seconds runSyncQueue(5000) } } /** * manages running the replaySyncQueue function after the given timeout * calling this function might cancel a previously created timeout * @param timeout time in ms after which to run the replaySyncQueue function */ function runSyncQueue(timeout: number) { clearTimeout(queueTimeoutId.value) queueTimeoutId.value = window.setTimeout(() => { _replaySyncQueue() }, timeout) } /** * function to handle user "delaying" and "undelaying" shopping entries * @param {{}} entries set of entries * @param delay if entries should be delayed or if delay should be removed * @param undo if the user should be able to undo the change or not */ function setEntriesDelayedState(entries: ShoppingListEntry[], delay: boolean, undo: boolean) { let delay_hours = useUserPreferenceStore().userSettings.defaultDelay! let delayDate = new Date(Date.now() + delay_hours * (60 * 60 * 1000)) if (undo) { registerChange((delay ? 'DELAY' : 'UNDELAY'), entries) } entries.forEach(entry => { entry.delayUntil = (delay ? delayDate : new Date('1970-01-01')) updateObject(entry) }) } /** * ignore all foods of the given entries for shopping in the future and check associated entries from the list * @param ignored if the food should be ignored or not ignored (for undo) * @param {{}} entries set of entries associated with food to set checked * @param undo if the user should be able to undo the change or not */ function setFoodIgnoredState(entries: ShoppingListEntry[], ignored: boolean, undo: boolean) { const api = new ApiApi() if (undo) { registerChange((ignored ? 'IGNORE' : 'UNIGNORE'), entries) } let foods = [] as Food[] entries.forEach(e => { if (!foods.includes(e.food!)) { foods.push(e.food!) } }) setEntriesCheckedState(entries, ignored, false) foods.forEach(food => { food.ignoreShopping = ignored api.apiFoodUpdate({food: food, id: food.id!}).catch(err => { useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err) }) }) } /** * delete list of entries * @param {{}} entries set of entries */ function deleteEntries(entries: ShoppingListEntry[]) { entries.forEach((entry) => { deleteObject(entry, false) }) } function deleteShoppingListRecipe(shopping_list_recipe_id: number) { const api = new ApiApi() entries.value.forEach(entry => { if (entry.listRecipe == shopping_list_recipe_id) { entries.value.delete(entry.id!) } }) api.apiShoppingListRecipeDestroy({id: shopping_list_recipe_id}).then((x) => { // no need to update anything, entries were already removed }).catch((err) => { useMessageStore().addError(ErrorMessageType.DELETE_ERROR, err) }) } /** * register the change to a set of entries to allow undoing it * throws an Error if the operation type is not known * @param type the type of change to register. This determines what undoing the change does. (CREATE->delete object, * CHECKED->uncheck entry, UNCHECKED->check entry, DELAY->remove delay) * @param {{}} entries set of entries */ function registerChange(type: ShoppingOperationHistoryType, entries: ShoppingListEntry[]) { undoStack.value.push({'type': type, 'entries': entries} as ShoppingOperationHistoryEntry) } /** * takes the last item from the undo stack and reverts it */ function undoChange() { let last_item = undoStack.value.pop() if (last_item !== undefined) { let type = last_item['type'] let entries = last_item['entries'] if (type === 'CHECKED' || type === 'UNCHECKED') { setEntriesCheckedState(entries, (type === 'UNCHECKED'), false) } else if (type === 'DELAY' || type === 'UNDELAY') { setEntriesDelayedState(entries, (type === 'UNDELAY'), false) } else if (type === 'CREATE') { for (let i in entries) { let e = entries[i] deleteObject(e, false) } } else if (type === 'DESTROY') { for (let i in entries) { let e = entries[i] createObject(e, false) } } else if (type === 'IGNORE' || type === 'UNIGNORE') { setFoodIgnoredState(entries, (type === 'UNIGNORE'), false) } } else { // can use localization in store //StandardToasts.makeStandardToast(this, this.$t('NoMoreUndo')) } } return { UNDEFINED_CATEGORY, entries, supermarkets, supermarketCategories, getEntriesByGroup, autoSyncTimeoutId, autoSyncHasFocus, autoSyncLastTimestamp, currentlyUpdating, initialized, getFlatEntries, hasFailedItems, itemCheckSyncQueue, undoStack, stats, refreshFromAPI, autoSync, createObject, deleteObject, updateObject, undoChange, setEntriesCheckedState, setFoodIgnoredState, delayEntries: setEntriesDelayedState, getAssociatedRecipes, getMealPlanEntries, } }) // enable hot reload for store if (import.meta.hot) { import.meta.hot.accept(acceptHMRUpdate(useShoppingStore, import.meta.hot)) }