diff --git a/src/components/shopping/CurrentShoppingListPage.vue b/src/components/shopping/CurrentShoppingListPage.vue index b55c278..de783a8 100644 --- a/src/components/shopping/CurrentShoppingListPage.vue +++ b/src/components/shopping/CurrentShoppingListPage.vue @@ -5,19 +5,19 @@ -
+

No items to purchase

- + @@ -29,8 +29,8 @@ Purchased Items
    -
  • - +
  • +
@@ -138,19 +138,19 @@ button img { import alert from '@/alert.js' import data from '@/data.js' -import { toProductsModel, } from './shopping.js' +import { itemsToGroups, groupsToItems } from './shopping.js' import MealSelectionList from './MealSelectionList.vue' import ShoppingListItem from './ShoppingListItem.vue' -async function saveShoppingList(productGroupsPurchased) { - const results = productGroupsPurchased.map(item => item.requested.map(r => ({ - ingredient_id: r.ingredient.id, - person_id: r.person_id, - list_id: null, - }))).flat(); +async function saveShoppingList(outstandingItemGroups) { + const items = groupsToItems(outstandingItemGroups); + if (items.length === 0) { + alert.show({ type: 'error', message: 'No items selected.' }); + return; + } - return await data.purchaseItems(results); + return await data.purchaseItems(items); } export default { @@ -169,54 +169,50 @@ export default { const to = new Date(); to.setDate(to.getDate() + 7); - return { from, to, availableMeals: [], purchasedMeals: [], shoppingList: null, includedMeals: [], productGroupsToPurchase: [], purchasedGroups: [], selected: [], showPurchased: false } + return { from, to, shoppingList: null, includedMeals: [], selected: [], showPurchased: false } }, async beforeMount() { this.loadData(); }, - watch: { - shoppingList: { - handler: 'updateLists', - deep: true + computed: { + outstandingItemGroups() { + return itemsToGroups(this.shoppingList?.outstanding_items ?? []); }, + purchasedItemGroups() { + return itemsToGroups(this.shoppingList?.purchased_items ?? []); + }, + purchasedMeals() { + return Object.values(this.shoppingList?.meals_lookup ?? {}).filter(m => m.purchase_date).sort((a, b) => a.suggested_date - b.suggested_date); + }, + availableMeals() { + const meals = { ...this.shoppingList?.meals_lookup ?? {} }; + this.upcomingMeals?.forEach(m => { + if (!meals[m.id]) { + meals[m.id] = m; + } + }); + + return Object.values(meals).filter(m => !m.purchase_date).sort((a, b) => a.suggested_date - b.suggested_date); + } }, methods: { async loadData() { + this.upcomingMeals = await data.getUpcomingMeals(this.from, this.to); this.shoppingList = await data.getCurrentShoppingList(); - - const upcomingMeals = await data.getUpcomingMeals(this.from, this.to); - const requestedMeals = this.shoppingList.requests.map(r => r.meal).filter(m => m); - - const meals = {}; - requestedMeals.forEach(m => meals[m.id] = m); - upcomingMeals.forEach(m => meals[m.id] = m); - - this.purchasedMeals = Object.values(meals).filter(m => m.purchase_date); - this.availableMeals = Object.values(meals).filter(m => !m.purchase_date); - }, - async updateLists() { - if (!this.shoppingList) - return; - - this.includedMeals = this.shoppingList.requests.map(r => r.meal).filter(m => m); - - const productGroups = Object.values(toProductsModel(this.shoppingList)); - this.productGroupsToPurchase = productGroups.filter(m => m.requested.length > 0); - this.purchasedGroups = productGroups.filter(m => m.requested.length === 0); }, async mealSelected(meal) { - const request = await data.requestMeal(meal.id); - this.shoppingList.requests.push(request); + await data.requestMeal(meal.id); + await this.loadData(); }, async mealUnselected(meal) { await data.unrequestMeal(meal.id); - this.shoppingList.requests = this.shoppingList.requests.filter(r => r.meal?.id !== meal.id); + await this.loadData(); }, async markFound() { await saveShoppingList(this.selected); this.selected = []; - this.loadData(); + await this.loadData(); }, async markPurchased() { const shoppingList = await saveShoppingList(this.selected); diff --git a/src/components/shopping/PurchasedShoppingListPage.vue b/src/components/shopping/PurchasedShoppingListPage.vue index 789d9f0..fe48bb2 100644 --- a/src/components/shopping/PurchasedShoppingListPage.vue +++ b/src/components/shopping/PurchasedShoppingListPage.vue @@ -32,7 +32,7 @@ import { ago } from '@/dateformats.js' import data from '@/data.js' -import { purchasedToProductModel } from './shopping.js' +import { itemsToGroups } from './shopping.js' import MealSelectionList from './MealSelectionList.vue' import ShoppingListItem from './ShoppingListItem.vue' @@ -52,8 +52,8 @@ export default { }, async beforeMount() { this.shoppingList = await data.getShoppingList(this.id); - this.includedMeals = this.shoppingList.requests.map(r => r.meal).filter(m => m); - this.listByProduct = purchasedToProductModel(this.shoppingList); + this.includedMeals = Object.values(this.shoppingList.meal_lookup); + this.listByProduct = itemsToGroups(this.shoppingList); }, methods: { ago diff --git a/src/components/shopping/ShoppingListItem.vue b/src/components/shopping/ShoppingListItem.vue index fdcf977..1415d2e 100644 --- a/src/components/shopping/ShoppingListItem.vue +++ b/src/components/shopping/ShoppingListItem.vue @@ -2,21 +2,24 @@
- +

- {{ productGrouping.product.name }}, + + {{ shoppingListItemGroup.product.name }} + {{ shoppingListItemGroup.name }} + , - + , {{ formatQuantity(total.quantity) }} {{ total.unit }} - ✓ {{ getFriendlyDate(lastPurchased) }} + ✓ {{ getFriendlyDate(lastPurchased) }}

-

+

Need: - + , and {{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for {{ source.person.name }} @@ -31,9 +34,9 @@

-

+

Already found or purchased: - + , and {{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for {{ source.person.name }} @@ -113,23 +116,37 @@ import { calculateTotals } from '@/units.js'; export default { name: 'ShoppingListItem', - props: ['productGrouping' ], + props: ['shoppingListItemGroup' ], // { product: { ... }, OR name: 'string', shoppingListItems: { person, ingredient, list_id?, meal? }} where list_id is null if not yet purchased data() { return { expanded: false, } }, computed: { - remainingRequired() { - return calculateTotals(this.productGrouping.requested.map(r => r.ingredient)); + remainingRequiredTotals() { + return calculateTotals(this.shoppingListItemGroup.shoppingListItems.map(item => item.ingredient)); }, - expectedExisting() { - return calculateTotals(this.productGrouping.purchased.map(r => r.ingredient)); + expectedExistingTotals() { + const purchasedNotEaten = this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id && !(item?.meal?.consumed_date)); + + return calculateTotals(purchasedNotEaten.map(item => item.ingredient)); + }, + required() { + return this.shoppingListItemGroup.shoppingListItems.filter(item => !item.list_id); + }, + purchased() { + return this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id); }, lastPurchased() { - return this.productGrouping.purchased.reduce((latest, source) => { - return (source.shop.created_date && latest > source.shop.created_date) ? latest : source.shop.created_date; - }, new Date(0)); + const purchasedItems = this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id); + if (purchasedItems.length === 0) { + return null; + } + // Find the most recently purchased item + return purchasedItems.reduce((latest, item) => { + const itemDate = item?.meal?.suggested_date || item?.created_at; + return (!latest || (itemDate && itemDate > latest)) ? itemDate : latest; + }, null); } }, methods: { diff --git a/src/components/shopping/shopping.js b/src/components/shopping/shopping.js index 5099fef..408338e 100644 --- a/src/components/shopping/shopping.js +++ b/src/components/shopping/shopping.js @@ -1,158 +1,35 @@ -function scaleFactor(mealRecipe) { - const numRecipe = mealRecipe.recipe.serves; - const numRequested = mealRecipe.servings; - - return numRequested / numRecipe; +export function groupsToItems(groups) { + return groups.map(group => group.shoppingListItems).flat(); } -export function mealToShoppingListSources(meal) { - const sources = []; - for (const mealRecipe of meal.recipes) { - const scale = scaleFactor(mealRecipe); - for (const ingredient of mealRecipe.recipe.ingredients) { - const quantity = ingredient.quantity * scale; - sources.push({ meal, mealRecipe, recipe: mealRecipe.recipe, ingredient: { ...ingredient, quantity, }, }); - } - } - - for (const ingredient of meal.extra_ingredients) { - sources.push({ ingredient, meal, }); - } - - return sources; -} - -var emptyProductId = -1; -export function requestsToSources(requests) { - const sources = []; - for (const request of requests) { - if (request.ingredient) { - sources.push(request); - } - else if (request.meal) { - sources.push(...mealToShoppingListSources(request.meal)); - } - } - - for (const source of sources) { - if (!source.ingredient.product) { - source.ingredient.product = { id: emptyProductId--, name: source.ingredient.name, }; - } - } - - return sources; -} - -function groupBy(groups, keyFn) { - const grouped = {}; - for (const group of groups) { - const key = keyFn(group); - if (!grouped[key]) { - grouped[key] = []; - } - - grouped[key].push(group); - } - - return grouped; -} - -function resultsToSources(shop) { - return shop.results.map(result => ({ shop, ingredient: { product: result.product, unit: result.unit, quantity: result.quantity, }, })); -} - -function getOutstandingGroupedByMeal(currentShoppingList) { - // { meal_id: { purchased: [ sources, ], required: [ sources, ], }, } - const requestedMeals = groupBy(requestsToSources(currentShoppingList.requests), source => source.meal?.id); - for (const meal_id in requestedMeals) { - requestedMeals[meal_id] = { id: parseInt(meal_id), purchased: [], required: requestedMeals[meal_id], }; - } - - for (const shop of currentShoppingList.overlapping_previous_shops) { - const shopResults = groupBy(resultsToSources(shop), source => source.ingredient.product.id); - const purchasedSources = requestsToSources(shop.requests) - .filter(source => source.meal && requestedMeals[source.meal.id]) - .filter(source => source.ingredient.product.id in shopResults); - - for (const source of purchasedSources) { - requestedMeals[source.meal.id].purchased.push({ shop, ...source }); - requestedMeals[source.meal.id].required = requestedMeals[source.meal.id].required.filter(required => required.ingredient.product.id !== source.ingredient.product.id); - } - } - - return requestedMeals; -} - -export function getCompletedRequests(currentShoppingList, results) { - const newShoppingList = { requests: currentShoppingList.requests, results }; - const fakeCurrentShoppingList = { requests: currentShoppingList.requests, overlapping_previous_shops: [ newShoppingList, ...currentShoppingList.overlapping_previous_shops ], }; - const requestedMealResults = Object.values(getOutstandingGroupedByMeal(fakeCurrentShoppingList)); - - const completedMeals = new Set(requestedMealResults.filter(mealGroup => mealGroup.required.length === 0).map(mealGroup => mealGroup.id)); - const purchasedProducts = new Set(results.map(result => result.product.id)); - const completedSources = currentShoppingList.requests.filter(request => { - if (request.meal) { - return completedMeals.has(request.meal.id); - } - - return purchasedProducts.has(request.ingredient.product.id); - }); - - return sourcesToRequests(completedSources); -} - -export function sourcesToRequests(sources) { - const uniqueMeals = sources.filter(source => source.meal) - .map(source => source.meal) - .reduce((acc, meal) => { - acc[meal.id] = { meal, meal_id: meal.id, }; - return acc; - }, {}); - - const directIngredients = sources.filter(source => !source.meal); - return [ ...Object.values(uniqueMeals), ...directIngredients, ]; -} - -export function toProductsModel(currentShoppingList) { - const requestedMealResults = getOutstandingGroupedByMeal(currentShoppingList); - - // Transform into { product_id: { product, requested: [ sources, ], purchased: [ sources, ], }, } - const grouped = {}; - for (const meal_id in requestedMealResults) { - for (const source of requestedMealResults[meal_id].required) { - if (!grouped[source.ingredient.product.id]) { - grouped[source.ingredient.product.id] = { product: source.ingredient.product, requested: [], purchased: [], }; +export function itemsToGroups(shoppingListItems) { + const ingredients_by_product_id = {}; + const ingredients_by_name = {}; + for (const item of shoppingListItems) { + if (item.ingredient.product) { + let group = ingredients_by_product_id[item.ingredient.product.id]; + if (!group) { + group = ingredients_by_product_id[item.ingredient.product.id] = { + product: item.ingredient.product, + shoppingListItems: [] + }; } - - grouped[source.ingredient.product.id].requested.push(source); + group.shoppingListItems.push(item); } - - for (const source of requestedMealResults[meal_id].purchased) { - if (!grouped[source.ingredient.product.id]) { - grouped[source.ingredient.product.id] = { product: source.ingredient.product, requested: [], purchased: [], }; + else { + let group = ingredients_by_name[item.ingredient.name]; + if (!group) { + group = ingredients_by_name[item.ingredient.name] = { + name: item.ingredient.name, + shoppingListItems: [] + }; } - - grouped[source.ingredient.product.id].purchased.push(source); + group.shoppingListItems.push(item); } } - // Only return values, array - return grouped; -} - -export function purchasedToProductModel(shoppingList) { - const purchasedProductIds = new Set(shoppingList.results.map(r => r.product?.id ?? 0)); - const purchasedSources = requestsToSources(shoppingList.requests).filter(r => purchasedProductIds.has(r.ingredient.product.id)); - - const grouped = {}; - for (const source of purchasedSources) { - const product = source.ingredient.product; - if (!grouped[product.id]) { - grouped[product.id] = { product, requested: [], purchased: [], }; - } - - grouped[product.id].requested.push(source); - } - - return grouped; + return [ + ...Object.values(ingredients_by_product_id), + ...Object.values(ingredients_by_name) + ] } \ No newline at end of file diff --git a/src/data.js b/src/data.js index a44f980..c3d7a70 100644 --- a/src/data.js +++ b/src/data.js @@ -2,9 +2,9 @@ const BASE_URL = window.location.href.replace(/^(https?:\/\/[^/]+).*/, "$1/api") const datesToFix = { Meal: { fields: [ "suggested_date", "purchase_date", "consumed_date" ] }, - CurrentShoppingList: { dependants: l => ({ ShoppingListRequest: l.requests, ShoppingList: l.overlapping_previous_shops }) }, + CurrentShoppingList: { dependants: l => ({ ShoppingListItem: [l.outstanding_items, l.requested_meals, l.purchased_items], Meal: Object.values(l.meals_lookup), Recipe: Object.values(l.recipes_lookup), Ingredient: Object.values(l.ingredients_lookup), ShoppingList: Object.values(l.shopping_list_lookup) }) }, ShoppingList: { fields: [ "created_date" ], dependants: l => ({ ShoppingListItem: l.items }) }, - ShoppingListItem: { fields: [ "created_date" ], dependants: r => ({ Meal: r.meal }) }, + ShoppingListItem: { fields: [ "created_date" ], dependants: r => ({ Meal: r.meal, Recipe: r.recipe, Ingredient: r.ingredient, ShoppingList: r.list }) }, Recipe: { fields: [ "date_created", "date_hidden" ], }, }; @@ -42,6 +42,26 @@ const fixDates = (obj, type) => { } } +const setShoppingListReferences = (currentShoppingList) => { + if (!currentShoppingList) return; + + var allRequests = [...currentShoppingList.outstanding_items, ...currentShoppingList.requested_meals, ...currentShoppingList.purchased_items]; + + for (const item of allRequests) { + if (item.ingredient_id) { + item.ingredient = currentShoppingList.ingredients_lookup[item.ingredient_id]; + } + if (item.meal_id) { + item.meal = currentShoppingList.meals_lookup[item.meal_id]; + } + if (item.list_id) { + item.list = currentShoppingList.shopping_list_lookup[item.list_id]; + } + if (item.recipe_id) { + item.recipe = currentShoppingList.recipes_lookup[item.recipe_id]; + } + } +} let user = null; export default { @@ -212,8 +232,10 @@ export default { }, async getMyShoppingList() { const response = await fetch(BASE_URL + "/shopping/current/me/ingredients", { credentials: "include" }); - - return await response.json(); + const ingredients = await response.json(); + fixDates(ingredients, 'Ingredient'); + + return ingredients; }, async saveMyShoppingList(list) { const response = await fetch(BASE_URL + "/shopping/current/me/ingredients", { @@ -225,7 +247,10 @@ export default { body: JSON.stringify(list), }); - return await response.json(); + const ingredients = await response.json(); + fixDates(ingredients, 'Ingredient'); + + return ingredients; }, async getShoppingList(id) { const response = await fetch(BASE_URL + `/shopping/${encodeURIComponent(id)}`); @@ -238,7 +263,8 @@ export default { const response = await fetch(BASE_URL + "/shopping/current"); const lst = await response.json(); fixDates(lst, "CurrentShoppingList"); - + setShoppingListReferences(lst); + return lst; }, async purchaseItems(completed_requests) {