lots of shopping tweaks

This commit is contained in:
vabene1111
2024-12-01 12:32:16 +01:00
parent 557954a259
commit 423dc7a6bf
150 changed files with 2918 additions and 2207 deletions

View File

@@ -1,487 +0,0 @@
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"
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<T | void>} 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<T | void>} 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<T | void>} 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'))
}
}
},
})

View File

@@ -43,15 +43,11 @@ export const useShoppingStore = defineStore(_STORE_ID, () => {
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(() => {
//TODO why is this called many times on each auto sync
console.log('running getEntriesByGroup')
stats.value = {
countChecked: 0,
countUnchecked: 0,
@@ -73,7 +69,7 @@ export const useShoppingStore = defineStore(_STORE_ID, () => {
// build structure
entries.value.forEach(shoppingListEntry => {
structure = updateEntryInStructure(structure, shoppingListEntry, useUserPreferenceStore().deviceSettings.shopping_selected_grouping)
structure = updateEntryInStructure(structure, shoppingListEntry)
})
// statistics for UI conditions and display
@@ -115,8 +111,9 @@ export const useShoppingStore = defineStore(_STORE_ID, () => {
})
// ordering
if (structure.categories.has(UNDEFINED_CATEGORY)) {
orderedStructure.push(structure.categories.get(UNDEFINED_CATEGORY))
let undefinedCategoryGroup = structure.categories.get(UNDEFINED_CATEGORY)
if (undefinedCategoryGroup != null) {
orderedStructure.push(undefinedCategoryGroup)
structure.categories.delete(UNDEFINED_CATEGORY)
}
@@ -207,9 +204,7 @@ export const useShoppingStore = defineStore(_STORE_ID, () => {
const api = new ApiApi()
api.apiShoppingListEntryList({updatedAfter: autoSyncLastTimestamp.value}).then((r) => {
autoSyncLastTimestamp.value = r.timestamp!
r.results.forEach((e) => {
console.log('updating entry by auto sync ', e)
entries.value.set(e.id!, e)
})
currentlyUpdating.value = false
@@ -220,15 +215,17 @@ export const useShoppingStore = defineStore(_STORE_ID, () => {
}
/**
* 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
* 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) {
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])
}
}).catch((err) => {
useMessageStore().addError(ErrorMessageType.CREATE_ERROR, err)
})
@@ -259,12 +256,15 @@ export const useShoppingStore = defineStore(_STORE_ID, () => {
/**
* 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
* @param undo if the user should be able to undo the change or not
*/
function deleteObject(object: ShoppingListEntry) {
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)
})
@@ -368,7 +368,7 @@ export const useShoppingStore = defineStore(_STORE_ID, () => {
})
itemCheckSyncQueue.value.splice(index, 1)
}).catch((err) => {
if (err.code === "ERR_NETWORK" || err.code === "ECONNABORTED") {
if (err.name === "FetchError") {
entry['status'] = 'waiting_failed_before'
} else {
itemCheckSyncQueue.value.splice(index, 1)
@@ -377,11 +377,14 @@ export const useShoppingStore = defineStore(_STORE_ID, () => {
})
promises.push(p)
})
// TODO verify this all settled works
Promise.allSettled(promises).finally(() => {
runSyncQueue(500)
if (itemCheckSyncQueue.value.length > 0) {
runSyncQueue(500)
}
})
} else {
// try again if internet after a few seconds
runSyncQueue(5000)
}
}
@@ -454,7 +457,7 @@ export const useShoppingStore = defineStore(_STORE_ID, () => {
*/
function deleteEntries(entries: ShoppingListEntry[]) {
entries.forEach((entry) => {
deleteObject(entry)
deleteObject(entry, false)
})
}
@@ -499,10 +502,15 @@ export const useShoppingStore = defineStore(_STORE_ID, () => {
setEntriesCheckedState(entries, (type === 'UNCHECKED'), false)
} else if (type === 'DELAY' || type === 'UNDELAY') {
setEntriesDelayedState(entries, (type === 'UNDELAY'), false)
} else if (type === 'CREATED') {
} else if (type === 'CREATE') {
for (let i in entries) {
let e = entries[i]
deleteObject(e)
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)

View File

@@ -1,7 +1,7 @@
import {acceptHMRUpdate, defineStore} from 'pinia'
import {useStorage} from "@vueuse/core";
import {ErrorMessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore";
import {ApiApi, ServerSettings, Space, UserPreference} from "@/openapi";
import {ApiApi, ServerSettings, Space, Supermarket, UserPreference} from "@/openapi";
import {ShoppingGroupingOptions} from "@/types/Shopping";
const DEVICE_SETTINGS_KEY = 'TANDOOR_DEVICE_SETTINGS'
@@ -14,10 +14,11 @@ class DeviceSettings {
shopping_show_delayed_entries = false
shopping_show_selected_supermarket_only = false
shopping_selected_grouping = ShoppingGroupingOptions.CATEGORY
shopping_selected_supermarket = null
shopping_selected_supermarket: Supermarket = {} as Supermarket
shopping_item_info_created_by = false
shopping_item_info_mealplan = false
shopping_item_info_recipe = true
shopping_show_debug = false
mealplan_displayPeriod = 'week'
mealplan_displayPeriodCount = 3