Allowed for null products, move ingredient spreading server-side #2

Merged
jacob merged 6 commits from nullproduct into master 2025-07-28 23:22:59 +00:00
5 changed files with 133 additions and 217 deletions
Showing only changes of commit ce3e255eb4 - Show all commits

View file

@ -5,19 +5,19 @@
<meal-selection-list @meal-selected="mealSelected" @meal-unselected="mealUnselected" :checked="includedMeals" :meals="availableMeals" /> <meal-selection-list @meal-selected="mealSelected" @meal-unselected="mealUnselected" :checked="includedMeals" :meals="availableMeals" />
<ul class="full-shopping-list"> <ul class="full-shopping-list">
<li v-for="item in productGroupsToPurchase" :key="item.id" class="selectable" :class="{ 'selected': isSelected(item) }" @click="toggleSelect(item)"> <li v-for="group in outstandingItemGroups" :key="group.id" class="selectable" :class="{ 'selected': isSelected(group) }" @click="toggleSelect(group)">
<shopping-list-item :productGrouping="item" /> <shopping-list-item :shopping-list-item-group="group" />
</li> </li>
</ul> </ul>
<div v-if="productGroupsToPurchase.length === 0"> <div v-if="outstandingItemGroups.length === 0">
<p> <p>
No items to purchase No items to purchase
</p> </p>
</div> </div>
<div class="purchased-slider"> <div class="purchased-slider">
<span v-if="purchasedGroups.length === 0"></span> <span v-if="purchasedItemGroups.length === 0"></span>
<button v-else-if="showPurchased" @click="showPurchased=false" > Hide Purchased </button> <button v-else-if="showPurchased" @click="showPurchased=false" > Hide Purchased </button>
<button v-else @click="showPurchased=true"> Show Purchased </button> <button v-else @click="showPurchased=true"> Show Purchased </button>
@ -29,8 +29,8 @@
Purchased Items Purchased Items
</h4> </h4>
<ul class="full-shopping-list"> <ul class="full-shopping-list">
<li v-for="item in purchasedGroups" :key="item.id"> <li v-for="item in purchasedItemGroups" :key="item.id">
<shopping-list-item :productGrouping="item" /> <shopping-list-item :shopping-list-item-group="item" />
</li> </li>
</ul> </ul>
</div> </div>
@ -138,19 +138,19 @@ button img {
import alert from '@/alert.js' import alert from '@/alert.js'
import data from '@/data.js' import data from '@/data.js'
import { toProductsModel, } from './shopping.js' import { itemsToGroups, groupsToItems } from './shopping.js'
import MealSelectionList from './MealSelectionList.vue' import MealSelectionList from './MealSelectionList.vue'
import ShoppingListItem from './ShoppingListItem.vue' import ShoppingListItem from './ShoppingListItem.vue'
async function saveShoppingList(productGroupsPurchased) { async function saveShoppingList(outstandingItemGroups) {
const results = productGroupsPurchased.map(item => item.requested.map(r => ({ const items = groupsToItems(outstandingItemGroups);
ingredient_id: r.ingredient.id, if (items.length === 0) {
person_id: r.person_id, alert.show({ type: 'error', message: 'No items selected.' });
list_id: null, return;
}))).flat(); }
return await data.purchaseItems(results); return await data.purchaseItems(items);
} }
export default { export default {
@ -169,54 +169,50 @@ export default {
const to = new Date(); const to = new Date();
to.setDate(to.getDate() + 7); 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() { async beforeMount() {
this.loadData(); this.loadData();
}, },
watch: { computed: {
shoppingList: { outstandingItemGroups() {
handler: 'updateLists', return itemsToGroups(this.shoppingList?.outstanding_items ?? []);
deep: true
}, },
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: { methods: {
async loadData() { async loadData() {
this.upcomingMeals = await data.getUpcomingMeals(this.from, this.to);
this.shoppingList = await data.getCurrentShoppingList(); 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) { async mealSelected(meal) {
const request = await data.requestMeal(meal.id); await data.requestMeal(meal.id);
this.shoppingList.requests.push(request); await this.loadData();
}, },
async mealUnselected(meal) { async mealUnselected(meal) {
await data.unrequestMeal(meal.id); await data.unrequestMeal(meal.id);
this.shoppingList.requests = this.shoppingList.requests.filter(r => r.meal?.id !== meal.id); await this.loadData();
}, },
async markFound() { async markFound() {
await saveShoppingList(this.selected); await saveShoppingList(this.selected);
this.selected = []; this.selected = [];
this.loadData(); await this.loadData();
}, },
async markPurchased() { async markPurchased() {
const shoppingList = await saveShoppingList(this.selected); const shoppingList = await saveShoppingList(this.selected);

View file

@ -32,7 +32,7 @@
import { ago } from '@/dateformats.js' import { ago } from '@/dateformats.js'
import data from '@/data.js' import data from '@/data.js'
import { purchasedToProductModel } from './shopping.js' import { itemsToGroups } from './shopping.js'
import MealSelectionList from './MealSelectionList.vue' import MealSelectionList from './MealSelectionList.vue'
import ShoppingListItem from './ShoppingListItem.vue' import ShoppingListItem from './ShoppingListItem.vue'
@ -52,8 +52,8 @@ export default {
}, },
async beforeMount() { async beforeMount() {
this.shoppingList = await data.getShoppingList(this.id); this.shoppingList = await data.getShoppingList(this.id);
this.includedMeals = this.shoppingList.requests.map(r => r.meal).filter(m => m); this.includedMeals = Object.values(this.shoppingList.meal_lookup);
this.listByProduct = purchasedToProductModel(this.shoppingList); this.listByProduct = itemsToGroups(this.shoppingList);
}, },
methods: { methods: {
ago ago

View file

@ -2,21 +2,24 @@
<!-- Show a card of the product with the total, taking up the available space and have an expanding section to view sources --> <!-- Show a card of the product with the total, taking up the available space and have an expanding section to view sources -->
<div class="shopping-list-item"> <div class="shopping-list-item">
<img :src="`${ productGrouping.product?.img_small ?? require('@/assets/missing-product.svg') }`" class="product-image" /> <img :src="`${ shoppingListItemGroup.product?.img_small ?? require('@/assets/missing-product.svg') }`" class="product-image" />
<div class="product-details"> <div class="product-details">
<h3 class="header"> <h3 class="header">
<strong><a :href="productGrouping.product?.link">{{ productGrouping.product.name }}</a></strong>, <strong>
<a v-if="shoppingListItemGroup.product?.link" :href="shoppingListItemGroup.product?.link">{{ shoppingListItemGroup.product.name }}</a>
<span v-else>{{ shoppingListItemGroup.name }}</span>
</strong>,
<small> <small>
<span v-for="(total, index) in remainingRequired" :key="total.id"> <span v-for="(total, index) in remainingRequiredTotals" :key="total.id">
<span v-if="index">, </span> <span v-if="index">, </span>
<span>{{ formatQuantity(total.quantity) }}&nbsp;{{ total.unit }}</span> <span>{{ formatQuantity(total.quantity) }}&nbsp;{{ total.unit }}</span>
</span> </span>
<span class="found-marker partial" v-if="productGrouping.purchased.length > 0"> {{ getFriendlyDate(lastPurchased) }}</span> <span class="found-marker partial" v-if="purchased.length > 0"> {{ getFriendlyDate(lastPurchased) }}</span>
</small> </small>
</h3> </h3>
<p class="sources" v-if="productGrouping.requested.length > 0"> <p class="sources" v-if="required.length > 0">
<strong>Need: </strong> <strong>Need: </strong>
<span v-for="(source, index) in productGrouping.requested" :key="source.id"> <span v-for="(source, index) in required" :key="source.id">
<span v-if="index">, and </span> <span v-if="index">, and </span>
<span v-if="source.person"> <span v-if="source.person">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} for {{ source.person.name }} {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} for {{ source.person.name }}
@ -31,9 +34,9 @@
</span> </span>
</span> </span>
</p> </p>
<p v-if="productGrouping.purchased.length > 0"> <p v-if="purchased.length > 0">
<strong>Already found or purchased: </strong> <strong>Already found or purchased: </strong>
<span v-for="(source, index) in productGrouping.purchased" :key="source.id"> <span v-for="(source, index) in purchased" :key="source.id">
<span v-if="index">, and </span> <span v-if="index">, and </span>
<span v-if="source.person"> <span v-if="source.person">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} for {{ source.person.name }} {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} for {{ source.person.name }}
@ -113,23 +116,37 @@ import { calculateTotals } from '@/units.js';
export default { export default {
name: 'ShoppingListItem', 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() { data() {
return { return {
expanded: false, expanded: false,
} }
}, },
computed: { computed: {
remainingRequired() { remainingRequiredTotals() {
return calculateTotals(this.productGrouping.requested.map(r => r.ingredient)); return calculateTotals(this.shoppingListItemGroup.shoppingListItems.map(item => item.ingredient));
}, },
expectedExisting() { expectedExistingTotals() {
return calculateTotals(this.productGrouping.purchased.map(r => r.ingredient)); 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() { lastPurchased() {
return this.productGrouping.purchased.reduce((latest, source) => { const purchasedItems = this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id);
return (source.shop.created_date && latest > source.shop.created_date) ? latest : source.shop.created_date; if (purchasedItems.length === 0) {
}, new Date(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: { methods: {

View file

@ -1,158 +1,35 @@
function scaleFactor(mealRecipe) { export function groupsToItems(groups) {
const numRecipe = mealRecipe.recipe.serves; return groups.map(group => group.shoppingListItems).flat();
const numRequested = mealRecipe.servings;
return numRequested / numRecipe;
} }
export function mealToShoppingListSources(meal) { export function itemsToGroups(shoppingListItems) {
const sources = []; const ingredients_by_product_id = {};
for (const mealRecipe of meal.recipes) { const ingredients_by_name = {};
const scale = scaleFactor(mealRecipe); for (const item of shoppingListItems) {
for (const ingredient of mealRecipe.recipe.ingredients) { if (item.ingredient.product) {
const quantity = ingredient.quantity * scale; let group = ingredients_by_product_id[item.ingredient.product.id];
sources.push({ meal, mealRecipe, recipe: mealRecipe.recipe, ingredient: { ...ingredient, quantity, }, }); if (!group) {
group = ingredients_by_product_id[item.ingredient.product.id] = {
product: item.ingredient.product,
shoppingListItems: []
};
}
group.shoppingListItems.push(item);
}
else {
let group = ingredients_by_name[item.ingredient.name];
if (!group) {
group = ingredients_by_name[item.ingredient.name] = {
name: item.ingredient.name,
shoppingListItems: []
};
}
group.shoppingListItems.push(item);
} }
} }
for (const ingredient of meal.extra_ingredients) { return [
sources.push({ ingredient, meal, }); ...Object.values(ingredients_by_product_id),
} ...Object.values(ingredients_by_name)
]
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: [], };
}
grouped[source.ingredient.product.id].requested.push(source);
}
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: [], };
}
grouped[source.ingredient.product.id].purchased.push(source);
}
}
// 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;
} }

View file

@ -2,9 +2,9 @@ const BASE_URL = window.location.href.replace(/^(https?:\/\/[^/]+).*/, "$1/api")
const datesToFix = { const datesToFix = {
Meal: { fields: [ "suggested_date", "purchase_date", "consumed_date" ] }, 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 }) }, 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" ], }, 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; let user = null;
export default { export default {
@ -212,8 +232,10 @@ export default {
}, },
async getMyShoppingList() { async getMyShoppingList() {
const response = await fetch(BASE_URL + "/shopping/current/me/ingredients", { credentials: "include" }); const response = await fetch(BASE_URL + "/shopping/current/me/ingredients", { credentials: "include" });
const ingredients = await response.json();
fixDates(ingredients, 'Ingredient');
return await response.json(); return ingredients;
}, },
async saveMyShoppingList(list) { async saveMyShoppingList(list) {
const response = await fetch(BASE_URL + "/shopping/current/me/ingredients", { const response = await fetch(BASE_URL + "/shopping/current/me/ingredients", {
@ -225,7 +247,10 @@ export default {
body: JSON.stringify(list), body: JSON.stringify(list),
}); });
return await response.json(); const ingredients = await response.json();
fixDates(ingredients, 'Ingredient');
return ingredients;
}, },
async getShoppingList(id) { async getShoppingList(id) {
const response = await fetch(BASE_URL + `/shopping/${encodeURIComponent(id)}`); const response = await fetch(BASE_URL + `/shopping/${encodeURIComponent(id)}`);
@ -238,6 +263,7 @@ export default {
const response = await fetch(BASE_URL + "/shopping/current"); const response = await fetch(BASE_URL + "/shopping/current");
const lst = await response.json(); const lst = await response.json();
fixDates(lst, "CurrentShoppingList"); fixDates(lst, "CurrentShoppingList");
setShoppingListReferences(lst);
return lst; return lst;
}, },