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" />
<ul class="full-shopping-list">
<li v-for="item in productGroupsToPurchase" :key="item.id" class="selectable" :class="{ 'selected': isSelected(item) }" @click="toggleSelect(item)">
<shopping-list-item :productGrouping="item" />
<li v-for="group in outstandingItemGroups" :key="group.id" class="selectable" :class="{ 'selected': isSelected(group) }" @click="toggleSelect(group)">
<shopping-list-item :shopping-list-item-group="group" />
</li>
</ul>
<div v-if="productGroupsToPurchase.length === 0">
<div v-if="outstandingItemGroups.length === 0">
<p>
No items to purchase
</p>
</div>
<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 @click="showPurchased=true"> Show Purchased </button>
@ -29,8 +29,8 @@
Purchased Items
</h4>
<ul class="full-shopping-list">
<li v-for="item in purchasedGroups" :key="item.id">
<shopping-list-item :productGrouping="item" />
<li v-for="item in purchasedItemGroups" :key="item.id">
<shopping-list-item :shopping-list-item-group="item" />
</li>
</ul>
</div>
@ -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);

View file

@ -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

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 -->
<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">
<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>
<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>{{ formatQuantity(total.quantity) }}&nbsp;{{ total.unit }}</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>
</h3>
<p class="sources" v-if="productGrouping.requested.length > 0">
<p class="sources" v-if="required.length > 0">
<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="source.person">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} for {{ source.person.name }}
@ -31,9 +34,9 @@
</span>
</span>
</p>
<p v-if="productGrouping.purchased.length > 0">
<p v-if="purchased.length > 0">
<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="source.person">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ 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: {

View file

@ -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)
]
}

View file

@ -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" });
const ingredients = await response.json();
fixDates(ingredients, 'Ingredient');
return await response.json();
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,6 +263,7 @@ export default {
const response = await fetch(BASE_URL + "/shopping/current");
const lst = await response.json();
fixDates(lst, "CurrentShoppingList");
setShoppingListReferences(lst);
return lst;
},