diff --git a/vue3/src/pages/ShoppingListPage.vue b/vue3/src/pages/ShoppingListPage.vue index 2212b9458..6eecae904 100644 --- a/vue3/src/pages/ShoppingListPage.vue +++ b/vue3/src/pages/ShoppingListPage.vue @@ -1,12 +1,37 @@ diff --git a/vue3/src/stores/ShoppingListStore.js b/vue3/src/stores/ShoppingListStore.js new file mode 100644 index 000000000..34af42fbf --- /dev/null +++ b/vue3/src/stores/ShoppingListStore.js @@ -0,0 +1,490 @@ +import {defineStore} from "pinia" +import Vue from "vue" +import _ from 'lodash'; +import {useUserPreferenceStore} from "@/stores/UserPreferenceStore"; +import {ApiApi} from "@/openapi/index.js"; +import {DateTime} from "luxon"; + + +const _STORE_ID = "shopping_list_store" +/* + * test store to play around with pinia and see if it can work for my use cases + * don't trust that all shopping list entries are in store as there is no cache validation logic, its just a shared data holder + * */ +export const useShoppingListStore = defineStore(_STORE_ID, { + state: () => ({ + // shopping data + entries: {}, + supermarket_categories: [], + supermarkets: [], + + total_unchecked: 0, + total_checked: 0, + total_unchecked_food: 0, + total_checked_food: 0, + + // internal + currently_updating: false, + last_autosync: null, + autosync_has_focus: true, + autosync_timeout_id: null, + undo_stack: [], + + queue_timeout_id: undefined, + item_check_sync_queue: {}, + + // constants + GROUP_CATEGORY: 'food.supermarket_category.name', + GROUP_CREATED_BY: 'created_by.display_name', + GROUP_RECIPE: 'recipe_mealplan.recipe_name', + + UNDEFINED_CATEGORY: 'shopping_undefined_category' + }), + getters: { + /** + * build a multi-level data structure ready for display from shopping list entries + * group by selected grouping key + * @return {{}} + */ + get_entries_by_group: function () { + let structure = {} + let ordered_structure = [] + + // build structure + for (let i in this.entries) { + structure = this.updateEntryInStructure(structure, this.entries[i], useUserPreferenceStore().device_settings.shopping_selected_grouping) + } + + // statistics for UI conditions and display + let total_unchecked = 0 + let total_checked = 0 + let total_unchecked_food = 0 + let total_checked_food = 0 + for (let i in structure) { + let count_unchecked = 0 + let count_checked = 0 + let count_unchecked_food = 0 + let count_checked_food = 0 + + for (let fi in structure[i]['foods']) { + let food_checked = true + for (let ei in structure[i]['foods'][fi]['entries']) { + if (structure[i]['foods'][fi]['entries'][ei].checked) { + count_checked++ + } else { + food_checked = false + count_unchecked++ + } + } + if (food_checked) { + count_checked_food++ + } else { + count_unchecked_food++ + } + } + + Vue.set(structure[i], 'count_unchecked', count_unchecked) + Vue.set(structure[i], 'count_checked', count_checked) + Vue.set(structure[i], 'count_unchecked_food', count_unchecked_food) + Vue.set(structure[i], 'count_checked_food', count_checked_food) + + total_unchecked += count_unchecked + total_checked += count_checked + total_unchecked_food += count_unchecked_food + total_checked_food += count_checked_food + } + + this.total_unchecked = total_unchecked + this.total_checked = total_checked + this.total_unchecked_food = total_unchecked_food + this.total_checked_food = total_checked_food + + // ordering + if (this.UNDEFINED_CATEGORY in structure) { + ordered_structure.push(structure[this.UNDEFINED_CATEGORY]) + Vue.delete(structure, this.UNDEFINED_CATEGORY) + } + + if (useUserPreferenceStore().device_settings.shopping_selected_grouping === this.GROUP_CATEGORY && useUserPreferenceStore().device_settings.shopping_selected_supermarket !== null) { + for (let c of useUserPreferenceStore().device_settings.shopping_selected_supermarket.category_to_supermarket) { + if (c.category.name in structure) { + ordered_structure.push(structure[c.category.name]) + Vue.delete(structure, c.category.name) + } + } + if (!useUserPreferenceStore().device_settings.shopping_show_selected_supermarket_only) { + for (let i in structure) { + ordered_structure.push(structure[i]) + } + } + } else { + for (let i in structure) { + ordered_structure.push(structure[i]) + } + } + + return ordered_structure + }, + /** + * flattened list of entries used for exporters + * kinda uncool but works for now + * @return {*[]} + */ + get_flat_entries: function () { + let items = [] + for (let i in this.get_entries_by_group) { + for (let f in this.get_entries_by_group[i]['foods']) { + for (let e in this.get_entries_by_group[i]['foods'][f]['entries']) { + items.push({ + amount: this.get_entries_by_group[i]['foods'][f]['entries'][e].amount, + unit: this.get_entries_by_group[i]['foods'][f]['entries'][e].unit?.name ?? '', + food: this.get_entries_by_group[i]['foods'][f]['entries'][e].food?.name ?? '', + }) + } + } + } + return items + }, + /** + * list of options available for grouping entry display + * @return {[{id: *, translatable_label: string},{id: *, translatable_label: string},{id: *, translatable_label: string}]} + */ + grouping_options: function () { + return [ + {'id': this.GROUP_CATEGORY, 'translatable_label': 'Category'}, + {'id': this.GROUP_CREATED_BY, 'translatable_label': 'created_by'}, + {'id': this.GROUP_RECIPE, 'translatable_label': 'Recipe'} + ] + }, + /** + * checks if failed items are contained in the sync queue + */ + has_failed_items: function () { + for (let i in this.item_check_sync_queue) { + if (this.item_check_sync_queue[i]['status'] === 'syncing_failed_before' || this.item_check_sync_queue[i]['status'] === 'waiting_failed_before') { + return true + } + } + return false + } + }, + actions: { + /** + * Retrieves all shopping related data (shopping list entries, supermarkets, supermarket categories and shopping list recipes) from API + */ + refreshFromAPI() { + if (!this.currently_updating) { + this.currently_updating = true + this.last_autosync = new Date().getTime(); + + let api = new ApiApi() + api.listShoppingListEntrys().then((r) => { + this.entries = {} + + r.forEach((e) => { + this.entries[e.id] = e + }) + this.currently_updating = false + }).catch((err) => { + this.currently_updating = false + //StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err) + }) + + api.listSupermarketCategorys().then(r => { + this.supermarket_categories = r + }).catch((err) => { + // StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err) + }) + + api.listSupermarkets().then(r => { + this.supermarkets = r + }).catch((err) => { + // StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err) + }) + } + }, + /** + * perform auto sync request to special endpoint returning only entries changed since last auto sync + * only updates local entries that are older than the server version + */ + autosync() { + if (!this.currently_updating && this.autosync_has_focus) { + console.log('running autosync') + + this.currently_updating = true + + let previous_autosync = this.last_autosync + this.last_autosync = new Date().getTime(); + + const api = new ApiApi() + // TODO query parameters + api.listShoppingListEntrys({last_autosync: previous_autosync}).then((r) => { + r.forEach((e) => { + // dont update stale client data + if (!(Object.keys(this.entries).includes(e.id.toString())) || Date.parse(this.entries[e.id].updated_at) < Date.parse(e.updated_at)) { + console.log('auto sync updating entry ', e) + this.entries[e.id] = e + } + }) + this.currently_updating = false + }).catch((err) => { + console.warn('auto sync failed') + this.currently_updating = false + }) + } + }, + /** + * Create a new shopping list entry + * adds new entry to store + * @param object entry object to create + * @return {Promise} promise of creation call to subscribe to + */ + createObject(object) { + const api = new ApiApi() + return api.createShoppingListEntry(object).then((r) => { + this.entries[r.id] = r + }).catch((err) => { + // StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err) + }) + }, + /** + * 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 + */ + updateObject(object) { + 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['update_at'] = DateTime.toLocaleString() // TODO check formats + + return api.updateShoppingListEntry(object.id, object).then((r) => { + this.entries[r.id] = r + }).catch((err) => { + // StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err) + }) + }, + /** + * delete shopping list entry object from DB and store + * @param object entry object to delete + * @return {Promise} promise of delete call to subscribe to + */ + deleteObject(object) { + const api = new ApiApi() + return api.destroyShoppingListEntry({id: object.id}).then((r) => { + delete this.entries[object.id] + }).catch((err) => { + // StandardToasts.makeStandardToast(this, StandardToasts.FAIL_DELETE, err) + }) + }, + /** + * returns a distinct list of recipes associated with unchecked shopping list entries + */ + getAssociatedRecipes: function () { + let recipes = {} // TODO this needs a type + + for (let i in this.entries) { + let e = this.entries[i] + if (e.recipe_mealplan !== null) { + recipes[e.recipe_mealplan.recipe] = { + 'shopping_list_recipe_id': e.list_recipe, + 'recipe_id': e.recipe_mealplan.recipe, + 'recipe_name': e.recipe_mealplan.recipe_name, + 'servings': e.recipe_mealplan.servings, + 'mealplan_from_date': e.recipe_mealplan.mealplan_from_date, + 'mealplan_type': e.recipe_mealplan.mealplan_type, + } + } + } + + return recipes + }, + // convenience methods + /** + * function to set entry to its proper place in the data structure to perform grouping + * @param {{}} structure datastructure + * @param {*} entry entry to place + * @param {*} group group to place entry into (must be of ShoppingListStore.GROUP_XXX/dot notation of entry property) + * @returns {{}} datastructure including entry + */ + updateEntryInStructure(structure, entry, group) { + let grouping_key = _.get(entry, group, this.UNDEFINED_CATEGORY) + + if (grouping_key === undefined || grouping_key === null) { + grouping_key = this.UNDEFINED_CATEGORY + } + + if (!(grouping_key in structure)) { + structure[grouping_key] = {'name': grouping_key, 'foods': {}} + } + if (!(entry.food.id in structure[grouping_key]['foods'])) { + structure[grouping_key]['foods'][entry.food.id] = { + 'id': entry.food.id, + 'name': entry.food.name, + 'entries': {} + } + } + structure[grouping_key]['foods'][entry.food.id]['entries'][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 + */ + setEntriesCheckedState(entries, checked, undo) { + if (undo) { + this.registerChange((checked ? 'CHECKED' : 'UNCHECKED'), entries) + } + + let entry_id_list = [] + for (let i in entries) { + this.entries[i]['checked'] = checked + this.entries[i]['updated_at'] = DateTime.now().toISOTime() // TODO was moment.format() (see above) + entry_id_list.push(i) + } + + this.item_check_sync_queue[Math.random()] = { + 'ids': entry_id_list, + 'checked': checked, + 'status': 'waiting' + } + this.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 + */ + _replaySyncQueue() { + if (navigator.onLine || document.location.href.includes('localhost')) { + let api = new ApiApi() + let promises = [] + + for (let i in this.item_check_sync_queue) { + let entry = this.item_check_sync_queue[i] + entry['status'] = ((entry['status'] === 'waiting') ? 'syncing' : 'syncing_failed_before') + this.item_check_sync_queue[i] = entry + + // TODO request params + let p = api.bulkShoppingListEntry(entry, {timeout: 15000}).then((r) => { + delete this.item_check_sync_queue[i] + }).catch((err) => { + if (err.code === "ERR_NETWORK" || err.code === "ECONNABORTED") { + entry['status'] = 'waiting_failed_before' + this.item_check_sync_queue[i] = entry + } else { + delete this.item_check_sync_queue[i] + console.error('Failed API call for entry ', entry) + // StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err) + } + }) + promises.push(p) + } + + Promise.allSettled(promises).finally(r => { + this.runSyncQueue(500) + }) + } else { + this.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 + */ + runSyncQueue(timeout) { + clearTimeout(this.queue_timeout_id) + + this.queue_timeout_id = setTimeout(() => { + this._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 + */ + delayEntries(entries, delay, undo) { + let delay_hours = useUserPreferenceStore().user_settings.default_delay + let delay_date = new Date(Date.now() + delay_hours * (60 * 60 * 1000)) + + if (undo) { + this.registerChange((delay ? 'DELAY' : 'UNDELAY'), entries) + } + + for (let i in entries) { + this.entries[i].delay_until = (delay ? delay_date : null) + this.updateObject(this.entries[i]) + } + }, + /** + * delete list of entries + * @param {{}} entries set of entries + */ + deleteEntries(entries) { + for (let i in entries) { + this.deleteObject(this.entries[i]) + } + }, + deleteShoppingListRecipe(shopping_list_recipe_id) { + const api = new ApiApi() + + for (let i in this.entries) { + if (this.entries[i].list_recipe === shopping_list_recipe_id) { + delete this.entries[i] + } + } + + api.destroyShoppingListRecipe(shopping_list_recipe_id).then((x) => { + // no need to update anything, entries were already removed + }).catch((err) => { + // StandardToasts.makeStandardToast(this, StandardToasts.FAIL_DELETE, 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 + */ + registerChange(type, entries) { + if (!['CREATED', 'CHECKED', 'UNCHECKED', 'DELAY', 'UNDELAY'].includes(type)) { + throw Error('Tried to register unknown change type') + } + this.undo_stack.push({'type': type, 'entries': entries}) + }, + /** + * takes the last item from the undo stack and reverts it + */ + undoChange() { + let last_item = this.undo_stack.pop() + if (last_item !== undefined) { + let type = last_item['type'] + let entries = last_item['entries'] + + if (type === 'CHECKED' || type === 'UNCHECKED') { + this.setEntriesCheckedState(entries, (type === 'UNCHECKED'), false) + } else if (type === 'DELAY' || type === 'UNDELAY') { + this.delayEntries(entries, (type === 'UNDELAY'), false) + } else if (type === 'CREATED') { + for (let i in entries) { + let e = entries[i] + this.deleteObject(e) + } + } + } else { + // can use localization in store + //StandardToasts.makeStandardToast(this, this.$t('NoMoreUndo')) + } + } + }, +}) diff --git a/vue3/src/stores/UserPreferenceStore.js b/vue3/src/stores/UserPreferenceStore.js new file mode 100644 index 000000000..b457cfa57 --- /dev/null +++ b/vue3/src/stores/UserPreferenceStore.js @@ -0,0 +1,205 @@ +import {defineStore} from 'pinia' + + +import {ApiApiFactory, UserPreference} from "@/utils/openapi/api"; +import Vue from "vue"; +import {StandardToasts} from "@/utils/utils"; + +const _STALE_TIME_IN_MS = 1000 * 30 +const _STORE_ID = 'user_preference_store' + +const _LS_DEVICE_SETTINGS = 'TANDOOR_LOCAL_SETTINGS' +const _LS_USER_SETTINGS = 'TANDOOR_USER_SETTINGS' +const _USER_ID = localStorage.getItem('USER_ID') + +export const useUserPreferenceStore = defineStore(_STORE_ID, { + state: () => ({ + data: null, + updated_at: null, + currently_updating: false, + + user_settings_loaded_at: new Date(0), + user_settings: { + image: null, + theme: "TANDOOR", + nav_bg_color: "#ddbf86", + nav_text_color: "DARK", + nav_show_logo: true, + default_unit: "g", + default_page: "SEARCH", + use_fractions: false, + use_kj: false, + plan_share: [], + nav_sticky: true, + ingredient_decimals: 2, + comments: true, + shopping_auto_sync: 5, + mealplan_autoadd_shopping: false, + food_inherit_default: [], + default_delay: "4.0000", + mealplan_autoinclude_related: true, + mealplan_autoexclude_onhand: true, + shopping_share: [], + shopping_recent_days: 7, + csv_delim: ",", + csv_prefix: "", + filter_to_supermarket: false, + shopping_add_onhand: false, + left_handed: false, + show_step_ingredients: true, + food_children_exist: false, + locally_updated_at: new Date(0), + }, + + device_settings_initialized: false, + device_settings_loaded_at: new Date(0), + device_settings: { + // shopping + shopping_show_checked_entries: false, + shopping_show_delayed_entries: false, + shopping_show_selected_supermarket_only: false, + shopping_selected_grouping: 'food.supermarket_category.name', + shopping_selected_supermarket: null, + shopping_item_info_created_by: false, + shopping_item_info_mealplan: false, + shopping_item_info_recipe: true, + }, + }), + getters: {}, + actions: { + // Device settings (on device settings stored in local storage) + /** + * Load device settings from local storage and update state device_settings + */ + loadDeviceSettings() { + let s = localStorage.getItem(_LS_DEVICE_SETTINGS) + if (s !== null) { + let settings = JSON.parse(s) + for (s in settings) { + Vue.set(this.device_settings, s, settings[s]) + } + } + this.device_settings_initialized = true + }, + /** + * persist changes to device settings into local storage + */ + updateDeviceSettings: function () { + localStorage.setItem(_LS_DEVICE_SETTINGS, JSON.stringify(this.device_settings)) + }, + // ---------------- new methods for user settings + loadUserSettings: function (allow_cached_results) { + let s = localStorage.getItem(_LS_USER_SETTINGS) + if (s !== null) { + let settings = JSON.parse(s) + for (s in settings) { + Vue.set(this.user_settings, s, settings[s]) + } + console.log(`loaded local user settings age ${((new Date().getTime()) - this.user_settings.locally_updated_at) / 1000} `) + } + if (((new Date().getTime()) - this.user_settings.locally_updated_at) > _STALE_TIME_IN_MS || !allow_cached_results) { + console.log('refreshing user settings from API') + let apiClient = new ApiApiFactory() + apiClient.retrieveUserPreference(localStorage.getItem('USER_ID')).then(r => { + for (s in r.data) { + if (!(s in this.user_settings) && s !== 'user') { + // dont load new keys if no default exists (to prevent forgetting to add defaults) + console.error(`API returned UserPreference key "${s}" which has no default in UserPreferenceStore.user_settings.`) + } else { + Vue.set(this.user_settings, s, r.data[s]) + } + } + Vue.set(this.user_settings, 'locally_updated_at', new Date().getTime()) + localStorage.setItem(_LS_USER_SETTINGS, JSON.stringify(this.user_settings)) + }).catch(err => { + this.currently_updating = false + }) + } + + }, + updateUserSettings: function () { + let apiClient = new ApiApiFactory() + apiClient.partialUpdateUserPreference(_USER_ID, this.user_settings).then(r => { + this.user_settings = r.data + Vue.set(this.user_settings, 'locally_updated_at', new Date().getTime()) + localStorage.setItem(_LS_USER_SETTINGS, JSON.stringify(this.user_settings)) + StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE) + }).catch(err => { + this.currently_updating = false + StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err) + }) + }, + // ---------------- + // User Preferences (database settings stored in user preference model) + /** + * gets data from the store either directly or refreshes from API if data is considered stale + * @returns {UserPreference|*|Promise>} + */ + getData: function () { + if (this.isStaleOrEmpty) { + return this.refreshFromAPI() + } else { + return this.data + } + }, + /** + * get data from store. Does not use API, if store is not initialized returns null. + * @returns {null|UserPreference|*} + */ + getStaleData: function () { + return this.data + }, + /** + * checks if update timestamp is older than configured stale time interval + * @returns {boolean} true if data is considered stale and should be updated + */ + isStale() { + return this.updated_at === null || ((new Date()) - this.updated_at) > _STALE_TIME_IN_MS; + }, + /** + * checks if data of store is empty/not initialized + * @returns {boolean} true if store is empty + */ + isEmpty() { + return this.data === null + }, + /** + * checks if store is empty or data is considered stale, see isStale() and isEmpty() + * @returns {boolean} + */ + isStaleOrEmpty() { + return this.isStale() || this.isEmpty() + }, + /** + * refreshes store data if isStaleOrEmpty() is true + * @returns {Promise>} returns promise with data + */ + updateIfStaleOrEmpty() { + if (this.isStaleOrEmpty) { + return this.refreshFromAPI() + } + }, + /** + * refreshes store data from API + * @returns {Promise>} returns promise with data + */ + refreshFromAPI() { + let apiClient = new ApiApiFactory() + if (!this.currently_updating) { + this.currently_updating = true + return apiClient.retrieveUserPreference(localStorage.getItem('USER_ID')).then(r => { + this.data = r.data + this.updated_at = new Date() + this.currently_updating = false + + this.user_settings = r.data + this.user_settings_loaded_at = new Date() + + return this.data + }).catch(err => { + this.currently_updating = false + }) + } + }, + }, +}) \ No newline at end of file