Files
recipes/vue3/src/stores/ShoppingStore.ts

555 lines
21 KiB
TypeScript

import {acceptHMRUpdate, defineStore} from "pinia"
import {ApiApi, Food, ShoppingListEntry, 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";
const _STORE_ID = "shopping_store"
const UNDEFINED_CATEGORY = 'shopping_undefined_category'
export const useShoppingStore = defineStore(_STORE_ID, () => {
let entries = ref(new Map<number, ShoppingListEntry>)
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 lastAutosync = ref(0)
let autosyncHasFocus = ref(true)
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(() => {
let structure = {} as IShoppingList
structure.categories = new Map<string, IShoppingListCategory>
let orderedStructure = [] as IShoppingListCategory[]
// build structure
entries.value.forEach(shoppingListEntry => {
structure = updateEntryInStructure(structure, shoppingListEntry, useUserPreferenceStore().deviceSettings.shopping_selected_grouping)
})
// 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 {
categoryStats.countUnchecked++
if (entry.delayUntil != null) {
categoryStats.countUncheckedDelayed++
}
}
})
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
if (structure.categories.has(UNDEFINED_CATEGORY)) {
orderedStructure.push(structure.categories.get(UNDEFINED_CATEGORY))
structure.categories.delete(UNDEFINED_CATEGORY)
}
structure.categories.forEach(category => {
orderedStructure.push(category)
})
// TODO implement ordering
// 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 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
}
/**
* 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
*/
function refreshFromAPI() {
if (!currentlyUpdating.value) {
currentlyUpdating.value = true
lastAutosync.value = new Date().getTime();
let api = new ApiApi()
api.apiShoppingListEntryList().then((r) => {
entries.value = new Map<number, ShoppingListEntry>
// TODO load all pages
r.results.forEach((e) => {
entries.value.set(e.id!, e)
})
currentlyUpdating.value = false
}).catch((err) => {
currentlyUpdating.value = false
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
})
api.apiSupermarketList().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
* only updates local entries that are older than the server version
*/
function autosync() {
if (!currentlyUpdating.value && autosyncHasFocus.value) {
console.log('running autosync')
currentlyUpdating.value = true
let previous_autosync = lastAutosync.value
lastAutosync.value = new Date().getTime();
const api = new ApiApi()
// TODO implement parameters on backend Oepnapi
api.apiShoppingListEntryList({lastAutosync: previous_autosync}).then((r) => {
r.results.forEach((e) => {
// dont update stale client data
if (!entries.value.has(e.id!) || entries.value.get(e.id!).updatedAt < e.updatedAt) {
console.log('auto sync updating entry ', e)
entries.value.set(e.id!, e)
}
})
currentlyUpdating.value = false
}).catch((err: any) => {
console.warn('auto sync failed')
currentlyUpdating.value = false
})
}
}
/**
* Create a new shopping list entry
* adds new entry to store
* @param object entry object to create
* @return {Promise<ShoppingListEntry>} promise of creation call to subscribe to
*/
function createObject(object: ShoppingListEntry) {
const api = new ApiApi()
return api.apiShoppingListEntryCreate({shoppingListEntry: object}).then((r) => {
entries.value.set(r.id!, r)
}).catch((err) => {
useMessageStore().addError(ErrorMessageType.CREATE_ERROR, 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<ShoppingListEntry>} 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
* @return {Promise<ShoppingListEntry>} promise of delete call to subscribe to
*/
function deleteObject(object: ShoppingListEntry) {
const api = new ApiApi()
return api.apiShoppingListEntryDestroy({id: object.id!}).then((r) => {
entries.value.delete(object.id!)
}).catch((err) => {
useMessageStore().addError(ErrorMessageType.DELETE_ERROR, err)
})
}
//TODO fix/verify for typescript
/**
* returns a distinct list of recipes associated with unchecked shopping list entries
*/
function getAssociatedRecipes() {
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
*/
function updateEntryInStructure(structure: IShoppingList, entry: ShoppingListEntry, group: ShoppingGroupingOptions) {
let groupingKey = UNDEFINED_CATEGORY
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.recipeMealplan != null) {
groupingKey = entry.recipeMealplan.recipeName
}
if (!structure.categories.has(groupingKey)) {
structure.categories.set(groupingKey, {'name': groupingKey, 'foods': new Map<number, IShoppingListFood>} as IShoppingListCategory)
}
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<number, ShoppingListEntry>
} 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
// TODO used to set updatedAt but does not make sense on client, rethink solution (as above)
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 = []
for (let i in itemCheckSyncQueue.value) {
let entry = itemCheckSyncQueue.value[i]
entry['status'] = ((entry['status'] === 'waiting') ? 'syncing' : 'syncing_failed_before')
itemCheckSyncQueue.value[i] = entry
// TODO set timeout for request (previously was 15000ms) or check that default timeout is similar
let p = api.apiShoppingListEntryBulkCreate({shoppingListEntryBulk: entry}, {}).then((r) => {
delete itemCheckSyncQueue.value[i]
}).catch((err) => {
if (err.code === "ERR_NETWORK" || err.code === "ECONNABORTED") {
entry['status'] = 'waiting_failed_before'
itemCheckSyncQueue.value[i] = entry
} else {
delete itemCheckSyncQueue.value[i]
console.error('Failed API call for entry ', entry)
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
}
})
promises.push(p)
}
Promise.allSettled(promises).finally(() => {
runSyncQueue(500)
})
} else {
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'))
console.log('DELAY: ', delay, entry.delayUntil, entry)
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)
})
}
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 === 'CREATED') {
for (let i in entries) {
let e = entries[i]
deleteObject(e)
}
} 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,
getFlatEntries,
hasFailedItems,
refreshFromAPI,
createObject,
deleteObject,
updateObject,
undoChange,
setEntriesCheckedState,
setFoodIgnoredState,
delayEntries: setEntriesDelayedState,
getAssociatedRecipes,
}
})
// enable hot reload for store
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useShoppingStore, import.meta.hot))
}