Squashed commit of the following:

commit 0d4681feccfcc3fdb0894d21eba30a7a5a2897e4
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 13 19:19:08 2024 +1100

    Split lists functional

commit 6506f639faaf1c46130d8ae2d2ac2af842c7f332
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 13 18:22:23 2024 +1100

    View previous shops

commit 499b72dc19760eabd72f1de1942bcbf3e9ec121f
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 13 18:02:22 2024 +1100

    Able to save

commit fa706d0500eebc748b61d449fc234227338cae5a
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 13 14:15:54 2024 +1100

    Empty overlapping shops works

commit 33138d7cca78654903d74fcd53e79eef8734f58d
Author: jableader <jacobdunk@gmail.com>
Date:   Fri Oct 4 17:53:52 2024 +1000

    Works against old api
This commit is contained in:
jableader 2024-10-13 19:19:36 +11:00
parent cac2ce12d3
commit e8b71cafac
9 changed files with 326 additions and 214 deletions

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 19.4001L16.7143 21.0001L21 17.0001M12 12.0001V16.0001M14 14.0001H10M5 9.77753V16.2001C5 17.8802 5 18.7203 5.32698 19.362C5.6146 19.9265 6.07354 20.3855 6.63803 20.6731C7.27976 21.0001 8.11984 21.0001 9.8 21.0001H11M21 12.0001L15.5668 5.96405C14.3311 4.59129 13.7133 3.9049 12.9856 3.65151C12.3466 3.42894 11.651 3.42899 11.0119 3.65165C10.2843 3.90516 9.66661 4.59163 8.43114 5.96458L3 12.0001" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 721 B

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 1C1.44772 1 1 1.44772 1 2C1 2.55228 1.44772 3 2 3H3.21922L6.78345 17.2569C5.73276 17.7236 5 18.7762 5 20C5 21.6569 6.34315 23 8 23C9.65685 23 11 21.6569 11 20C11 19.6494 10.9398 19.3128 10.8293 19H15.1707C15.0602 19.3128 15 19.6494 15 20C15 21.6569 16.3431 23 18 23C19.6569 23 21 21.6569 21 20C21 18.3431 19.6569 17 18 17H8.78078L8.28078 15H18C20.0642 15 21.3019 13.6959 21.9887 12.2559C22.6599 10.8487 22.8935 9.16692 22.975 7.94368C23.0884 6.24014 21.6803 5 20.1211 5H5.78078L5.15951 2.51493C4.93692 1.62459 4.13696 1 3.21922 1H2ZM18 13H7.78078L6.28078 7H20.1211C20.6742 7 21.0063 7.40675 20.9794 7.81078C20.9034 8.9522 20.6906 10.3318 20.1836 11.3949C19.6922 12.4251 19.0201 13 18 13ZM18 20.9938C17.4511 20.9938 17.0062 20.5489 17.0062 20C17.0062 19.4511 17.4511 19.0062 18 19.0062C18.5489 19.0062 18.9938 19.4511 18.9938 20C18.9938 20.5489 18.5489 20.9938 18 20.9938ZM7.00617 20C7.00617 20.5489 7.45112 20.9938 8 20.9938C8.54888 20.9938 8.99383 20.5489 8.99383 20C8.99383 19.4511 8.54888 19.0062 8 19.0062C7.45112 19.0062 7.00617 19.4511 7.00617 20Z" fill="#0F0F0F"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1,37 +1,117 @@
<template> <template>
<h3>Full shopping list</h3> <h3>Full shopping list</h3>
<input type="checkbox" id="show-found" @change="onCheckboxChange" :checked="stockTaking" />
<label for="show-found">Show Stocktaking</label>
<h4>Included Meals</h4> <h4>Included Meals</h4>
<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 productsToShow" :key="item.id"> <li v-for="item in productGroupsToPurchase" :key="item.id" class="selectable" :class="{ 'selected': isSelected(item) }" @click="toggleSelect(item)">
<shopping-list-item :product="item.product" :sources="item.sources" :totals="item.totals" :fully-found="item.fullyFound" :found-date="item.foundDate" /> <shopping-list-item :productGrouping="item" />
<span v-if="stockTaking">
<button v-if="item.fullyFound" @click="markNotFound(item)">Mark Not Found</button>
<button v-else @click="markFound(item)">Mark Found</button>
</span>
</li> </li>
</ul> </ul>
<button @click="markPurchased">Purchased, archive this list!</button> <div class="purchased-slider">
<span v-if="purchasedGroups.length === 0"></span>
<button v-else-if="showPurchased" @click="showPurchased=false" > Hide Purchased </button>
<button v-else @click="showPurchased=true"> Show Purchased </button>
<div v-if="showPurchased">
<h4>
Already Purchased
</h4>
<ul class="full-shopping-list">
<li v-for="item in purchasedGroups" :key="item.id">
<shopping-list-item :productGrouping="item" />
</li>
</ul>
</div>
</div>
<!-- Display 'Stocked', 'Purchased' and 'Cancel' buttons in a vertical stack fixed to the bottom of the screen when any elements are selected -->
<div class="footer-buttons" v-if="selected.length">
<p v-if="selected.length === 1">
Mark '{{ selected[0].product.name }}' as
</p>
<p v-else>
Mark {{ selected.length }} items as
</p>
<div class="button-group">
<button @click="markFound">
<img src="@/assets/house-check.svg" /><br />
Found
</button>
<button @click="markPurchased">
<img src="@/assets/shopping-cart.svg" /><br />
Purchased
</button>
<button @click="selected = []">
<img src="@/assets/close.svg" /><br />
Cancel
</button>
</div>
</div>
</template> </template>
<style scoped> <style scoped>
.full-shopping-list {
padding: 0;
}
.full-shopping-list li { .full-shopping-list li {
list-style-type: none; list-style-type: none;
border: 1px solid #ccc; outline: 1px solid #ccc;
border-radius: 0.5em; border-radius: 0.5em;
margin-bottom: 1em; margin-bottom: 1em;
} }
.full-shopping-list { .full-shopping-list li.selectable {
padding: 0; cursor: pointer;
outline: 1px solid #ccc;
}
.full-shopping-list li.selected {
background-color: #f0f0f0;
outline-width: 3px;
}
.footer-buttons {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 1ex;
background-color: #f0f0f0;
border-top: 1px solid #ccc;
}
.footer-buttons p {
margin: 0;
text-align: left;
font-style: italic;
}
.button-group {
/* Display as vertical fixed to the bottom of the screen */
display: flex;
}
.button-group button {
flex: 1;
padding: 1em;
border: 1px solid #ccc;
border-radius: 0.5em;
background-color: #f0f0f0;
cursor: pointer;
}
button img {
width: 2em;
height: 2em;
} }
</style> </style>
@ -39,7 +119,7 @@
<script> <script>
import data from '@/data.js' import data from '@/data.js'
import { toProductsModel, groupingToIngredients } from './shopping.js' import { toProductsModel, getCompletedRequests, sourcesToRequests } from './shopping.js'
import MealSelectionList from './MealSelectionList.vue' import MealSelectionList from './MealSelectionList.vue'
import ShoppingListItem from './ShoppingListItem.vue' import ShoppingListItem from './ShoppingListItem.vue'
@ -60,7 +140,7 @@ export default {
const to = new Date(); const to = new Date();
to.setDate(to.getDate() + 7); to.setDate(to.getDate() + 7);
return { from, to, availableMeals: [], shoppingList: null, includedMeals: [], listByProduct: [], productsToShow: []} return { from, to, availableMeals: [], shoppingList: null, includedMeals: [], productGroupsToPurchase: [], purchasedGroups: [], selected: [], showPurchased: false }
}, },
async beforeMount() { async beforeMount() {
const allMeals = await data.getUpcomingMeals(this.from, this.to); const allMeals = await data.getUpcomingMeals(this.from, this.to);
@ -72,7 +152,6 @@ export default {
handler: 'updateLists', handler: 'updateLists',
deep: true deep: true
}, },
stockTaking: 'updateLists'
}, },
methods: { methods: {
async updateLists() { async updateLists() {
@ -80,8 +159,10 @@ export default {
return; return;
this.includedMeals = this.shoppingList.requests.map(r => r.meal).filter(m => m); this.includedMeals = this.shoppingList.requests.map(r => r.meal).filter(m => m);
this.listByProduct = toProductsModel(this.shoppingList);
this.productsToShow = this.stockTaking ? this.listByProduct : this.listByProduct.filter(p => !p.fullyFound); 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); const request = await data.requestMeal(meal.id);
@ -91,23 +172,33 @@ export default {
await data.unrequestMeal(meal.id); await data.unrequestMeal(meal.id);
this.shoppingList.requests = this.shoppingList.requests.filter(r => r.meal?.id !== meal.id); this.shoppingList.requests = this.shoppingList.requests.filter(r => r.meal?.id !== meal.id);
}, },
async markFound(productGrouping) { async markFound() {
const result = await data.markFound(groupingToIngredients(productGrouping));
// Update shoppingList.result from result.created and result.removed
this.shoppingList.results = this.shoppingList.results.filter(r => !result.removed.some(removed => removed.id === r.id));
this.shoppingList.results.push(...result.created);
},
async markNotFound(item) {
const deletedRequests = await data.markNotFound(item.product.id);
this.shoppingList.results = this.shoppingList.results.filter(r => !deletedRequests.some(deleted => deleted.id === r.id));
}, },
async markPurchased() { async markPurchased() {
const result = await data.markCurrentShoppingListPurchased(); const results = this.selected.map(item => item.requested.map(r => ({
this.$router.push({ name: 'PurchasedShoppingListPage', params: { id: result.id } }); product_id: r.ingredient.product_id,
product: r.ingredient.product,
quantity: r.ingredient.quantity,
unit: r.ingredient.unit,
list_id: -1,
}))).flat();
const requestedSources = this.selected.map(item => item.requested).flat();
const completedRequests = getCompletedRequests(this.shoppingList, results);
const servicedRequests = sourcesToRequests(requestedSources);
const shoppingList = await data.purchaseItems('', servicedRequests, results, completedRequests);
this.$router.push(`/shopping/${shoppingList.id}`);
}, },
onCheckboxChange(event) { toggleSelect(item) {
this.$router.push({ query: { stockTaking: event.target.checked ? 'true' : undefined } }); const index = this.selected.findIndex(i => i === item);
if (index === -1)
this.selected.push(item);
else
this.selected.splice(index, 1);
},
isSelected(item) {
return this.selected.some(i => i === item);
} }
} }
} }

View file

@ -1,12 +1,12 @@
<template> <template>
<h3>Purchased {{ shoppingList?.purchased_date?.toLocaleDateString({ year: 'numeric', month: '2-digit', day: '2-digit' }) || 'Loading...' }}</h3> <h3>Purchased {{ shoppingList?.purchased_date?.toLocaleDateString({ year: 'numeric', month: '2-digit', day: '2-digit' }) || '' }}</h3>
<h4>Included Meals</h4> <h4>Included Meals</h4>
<meal-selection-list :checked="includedMeals" :meals="includedMeals" /> <meal-selection-list :checked="includedMeals" :meals="includedMeals" />
<ul class="full-shopping-list"> <ul class="full-shopping-list">
<li v-for="item in productsToShow" :key="item.id"> <li v-for="item in listByProduct" :key="item.id">
<shopping-list-item :product="item.product" :sources="item.sources" :totals="item.totals" :fully-found="item.fullyFound" :found-date="item.foundDate" /> <shopping-list-item :productGrouping="item" />
</li> </li>
</ul> </ul>
</template> </template>
@ -30,7 +30,7 @@
<script> <script>
import data from '@/data.js' import data from '@/data.js'
import { toProductsModel } from './shopping.js' import { purchasedToProductModel } from './shopping.js'
import MealSelectionList from './MealSelectionList.vue' import MealSelectionList from './MealSelectionList.vue'
import ShoppingListItem from './ShoppingListItem.vue' import ShoppingListItem from './ShoppingListItem.vue'
@ -51,8 +51,7 @@ 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 = this.shoppingList.requests.map(r => r.meal).filter(m => m);
this.listByProduct = toProductsModel(this.shoppingList); this.listByProduct = purchasedToProductModel(this.shoppingList);
this.productsToShow = this.stockTaking ? this.listByProduct : this.listByProduct.filter(p => !p.fullyFound);
}, },
} }

View file

@ -2,21 +2,38 @@
<!-- 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="product.img_small" class="product-image" /> <img :src="productGrouping.product.img_small" class="product-image" />
<div class="product-details"> <div class="product-details">
<h3 class="header"> <h3 class="header">
<strong><a :href="product.link">{{ product.name }}</a></strong>, <strong><a :href="productGrouping.product.link">{{ productGrouping.product.name }}</a></strong>,
<small> <small>
<span v-for="(total, index) in totals" :key="total.id"> <span v-for="(total, index) in remainingRequired" :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 found" v-if="fullyFound && foundDate"> {{ getFriendlyDate(foundDate) }}</span> <span class="found-marker partial" v-if="productGrouping.purchased.length > 0"> {{ getFriendlyDate(lastPurchased) }}</span>
<span class="found-marker partial" v-else-if="foundDate">? {{ getFriendlyDate(foundDate) }}</span>
</small> </small>
</h3> </h3>
<p class="sources"> <p class="sources" v-if="productGrouping.requested.length > 0">
<span v-for="(source, index) in sources" :key="source.id"> <strong>Need: </strong>
<span v-for="(source, index) in productGrouping.requested" :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 }}
</span>
<span v-else-if="source.recipe">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }}
in <router-link :to="`/recipes/${ source.recipe.id }/`">{{ source.recipe.name }}</router-link>
for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
</span>
<span v-else-if="source.meal">
{{ source.ingredient.line }} for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
</span>
</span>
</p>
<p v-if="productGrouping.purchased.length > 0">
<strong>Already found or purchased: </strong>
<span v-for="(source, index) in productGrouping.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 }}
@ -91,14 +108,29 @@
<script> <script>
import { calculateTotals } from '@/units.js';
export default { export default {
name: 'ShoppingListItem', name: 'ShoppingListItem',
props: ['product', 'sources', 'totals', 'foundDate', 'fullyFound'], props: ['productGrouping' ],
data() { data() {
return { return {
expanded: false, expanded: false,
} }
}, },
computed: {
remainingRequired() {
return calculateTotals(this.productGrouping.requested.map(r => r.ingredient));
},
expectedExisting() {
return calculateTotals(this.productGrouping.purchased.map(r => r.ingredient));
},
lastPurchased() {
return this.productGrouping.purchased.reduce((latest, purchase) => {
return latest > purchase.date ? latest : purchase.date;
}, new Date(0));
}
},
methods: { methods: {
getFriendlyDate(date) { getFriendlyDate(date) {
if (!date) if (!date)

View file

@ -1,5 +1,3 @@
import units from '@/units.js';
function scaleFactor(mealRecipe) { function scaleFactor(mealRecipe) {
const numRecipe = mealRecipe.recipe.serves; const numRecipe = mealRecipe.recipe.serves;
const numRequested = mealRecipe.servings; const numRequested = mealRecipe.servings;
@ -38,57 +36,6 @@ export function requestsToSources(requests) {
return sources; return sources;
} }
export function removeMealFromShoppingListSources(sources, meal) {
return sources.filter(source => source.meal?.id !== meal.id);
}
export function groupingToIngredients(grouping) {
const product = grouping.product;
const totals = [];
for (const total of grouping.totals) {
totals.push({ id: -1, line: `${total.quantity} ${total.unit} of ${product.name}`, preparation: '', name: product.name, product, ...total });
}
return totals;
}
function calculateTotals(sources) {
let byBaseUnit = {};
for (const { quantity, unit } of sources) {
const conversion = units.getConversionFactor(unit) ?? { unit, factor: 1 };
if (!byBaseUnit[conversion.unit]) {
byBaseUnit[conversion.unit] = {};
}
if (!byBaseUnit[conversion.unit][unit]) {
byBaseUnit[conversion.unit][unit] = { quantity, factor: conversion.factor };
}
else {
byBaseUnit[conversion.unit][unit].quantity += quantity;
}
}
const totals = {};
for (const baseUnit in byBaseUnit) {
if (Object.keys(byBaseUnit[baseUnit]).length === 1) {
const unit = Object.keys(byBaseUnit[baseUnit])[0];
const { quantity, } = byBaseUnit[baseUnit][unit];
totals[unit] = quantity;
}
else {
let total = 0;
for (const unit in byBaseUnit[baseUnit]) {
const { quantity, factor } = byBaseUnit[baseUnit][unit];
total += quantity / factor;
}
totals[baseUnit] = total;
}
}
return totals;
}
function groupBy(groups, keyFn) { function groupBy(groups, keyFn) {
const grouped = {}; const grouped = {};
for (const group of groups) { for (const group of groups) {
@ -103,62 +50,102 @@ function groupBy(groups, keyFn) {
return grouped; return grouped;
} }
function convertToBaseUnits(total) { function resultsToSources(shop) {
const baseUnits = {}; return shop.results.map(result => ({ shop, ingredient: { product: result.product, unit: result.unit, quantity: result.quantity, }, }));
for (const unit in total) {
const conversion = units.getConversionFactor(unit) ?? { unit, factor: 1 };
baseUnits[conversion.unit] = (baseUnits[conversion.unit] ?? 0) + total[unit] / conversion.factor;
}
return baseUnits;
} }
function getRemainingIngredients(requiredTotals, foundTotals) { function getOutstandingGroupedByMeal(currentShoppingList) {
requiredTotals = convertToBaseUnits(requiredTotals); // { meal_id: { purchased: [ sources, ], required: [ sources, ], }, }
foundTotals = convertToBaseUnits(foundTotals); 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(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);
const remaining = {}; // Transform into { product_id: { product, requested: [ sources, ], purchased: [ sources, ], }, }
for (const unit in requiredTotals) { const grouped = {};
const required = requiredTotals[unit]; for (const meal_id in requestedMealResults) {
const found = foundTotals[unit] ?? 0; for (const source of requestedMealResults[meal_id].required) {
const remainingQuantity = required - found; if (!grouped[source.ingredient.product.id]) {
if (remainingQuantity > 0) { grouped[source.ingredient.product.id] = { product: source.ingredient.product, requested: [], purchased: [], };
remaining[unit] = remainingQuantity; }
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);
} }
} }
return remaining; // Only return values, array
return grouped;
} }
export function toProductsModel(shoppingList) { export function purchasedToProductModel(shoppingList) {
const flattenedIngredients = requestsToSources(shoppingList.requests); const purchasedProductIds = new Set(shoppingList.results.map(r => r.product.id));
const foundIngredientsByProduct = groupBy(shoppingList.results.filter(f => f.found_date), found => found.product_id); const purchasedSources = requestsToSources(shoppingList.requests).filter(r => purchasedProductIds.has(r.ingredient.product.id));
const requiredIngredientsByProduct = groupBy(flattenedIngredients, source => source.ingredient?.product?.id ?? "no-product");
const results = []; const grouped = {};
for (const product_id in requiredIngredientsByProduct) { for (const source of purchasedSources) {
const sources = requiredIngredientsByProduct[product_id]; const product = source.ingredient.product;
const requiredIngredients = sources.map(source => source.ingredient); if (!grouped[product.id]) {
grouped[product.id] = { product, requested: [], purchased: [], };
const requiredTotals = calculateTotals(requiredIngredients);
const foundTotals = calculateTotals(foundIngredientsByProduct[product_id] ?? []);
let fullyFound = null, foundDate = null;
if (Object.keys(foundTotals).length === 0) {
foundDate = null;
fullyFound = false;
}
else {
foundDate = foundIngredientsByProduct[product_id][0].found_date;
fullyFound = Object.keys(getRemainingIngredients(requiredTotals, foundTotals)).length === 0;
} }
results.push({ grouped[product.id].requested.push(source);
product: requiredIngredients[0].product,
totals: Object.entries(requiredTotals).map(([unit, quantity]) => ({ unit, quantity, })),
fullyFound, foundDate, sources,
});
} }
return results; return grouped;
} }

View file

@ -2,6 +2,7 @@ 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 }) },
ShoppingList: { fields: [ "created_date", "purchased_date" ], dependants: l => ({ ShoppingListResult: l.results, ShoppingListRequest: l.requests }) }, ShoppingList: { fields: [ "created_date", "purchased_date" ], dependants: l => ({ ShoppingListResult: l.results, ShoppingListRequest: l.requests }) },
ShoppingListResult: { fields: [ "found_date", "created_date" ], }, ShoppingListResult: { fields: [ "found_date", "created_date" ], },
ShoppingListRequest: { fields: [ "created_date" ], dependants: r => ({ Meal: r.meal }) }, ShoppingListRequest: { fields: [ "created_date" ], dependants: r => ({ Meal: r.meal }) },
@ -20,9 +21,11 @@ const fixDates = (obj, type) => {
const toFix = datesToFix[type]; const toFix = datesToFix[type];
if (!toFix) return; if (!toFix) return;
for (const field of toFix.fields) { if (toFix.fields) {
if (obj[field]) { for (const field of toFix.fields) {
obj[field] = new Date(obj[field]); if (obj[field]) {
obj[field] = new Date(obj[field]);
}
} }
} }
@ -239,13 +242,28 @@ export default {
}, },
async getCurrentShoppingList() { async getCurrentShoppingList() {
const response = await fetch(BASE_URL + "/shopping/current"); const response = await fetch(BASE_URL + "/shopping/current");
const lst = await response.json();
fixDates(lst, "CurrentShoppingList");
return lst;
},
async purchaseItems(storeName, requests, results, completedRequests) {
const response = await fetch(BASE_URL + "/shopping/", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ requests, results, store_name: storeName, completed_requests: completedRequests }),
});
const lst = await response.json(); const lst = await response.json();
fixDates(lst, "ShoppingList"); fixDates(lst, "ShoppingList");
return lst; return lst;
}, },
async requestMeal(meal_id) { async requestMeal(meal_id) {
const response = await fetch(BASE_URL + `/shopping/current/meals/me`, { const response = await fetch(BASE_URL + "/shopping/current/meals/me", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: {
@ -268,44 +286,6 @@ export default {
const requests = await response.json(); const requests = await response.json();
fixDates(requests, "ShoppingListRequest"); fixDates(requests, "ShoppingListRequest");
}, },
async markFound(ingredients) {
const response = await fetch(BASE_URL + "/shopping/current/found", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(ingredients),
});
const results = await response.json();
fixDates(results.created, "ShoppingListResult");
fixDates(results.removed, "ShoppingListResult");
return results;
},
async markNotFound(product_id) {
const response = await fetch(BASE_URL + `/shopping/current/found/${product_id}`, {
method: "DELETE",
credentials: "include",
});
const removedResults = await response.json();
fixDates(removedResults, "ShoppingListResult");
return removedResults;
},
async markCurrentShoppingListPurchased() {
const response = await fetch(BASE_URL + "/shopping/current/purchased", {
method: "POST",
credentials: "include",
});
const lst = await response.json();
fixDates(lst, "ShoppingList");
return lst;
},
async getPersonsInHome() { async getPersonsInHome() {
const response = await fetch(BASE_URL + "/persons"); const response = await fetch(BASE_URL + "/persons");
return await response.json(); return await response.json();

View file

@ -22,7 +22,7 @@ const routes = [
{ path: '/mealplan', component: MealPlanPage }, { path: '/mealplan', component: MealPlanPage },
{ path: '/login', component: LoginPage }, { path: '/login', component: LoginPage },
{ path: '/shopping', component: MyShoppingPage }, { path: '/shopping', component: MyShoppingPage },
{ path: '/shopping/current', component: CurrentShoppingListPage, props: route => ({ stockTaking: !!route.query.stockTaking }) }, { path: '/shopping/current', component: CurrentShoppingListPage },
{ path: '/shopping/:id', component: PurchasedShoppingListPage, props : true }, { path: '/shopping/:id', component: PurchasedShoppingListPage, props : true },
{ path: '/recipes', component: RecipesPage }, { path: '/recipes', component: RecipesPage },
{ path: '/recipes/add', component: EditRecipePage }, { path: '/recipes/add', component: EditRecipePage },

View file

@ -95,33 +95,48 @@ function getBaseUnit(unit) {
return null; return null;
} }
export default { export function getConversionFactor(unit) {
getConversionFactor(unit) { if (unit in equivalentUnits) {
if (unit in equivalentUnits) { return { unit, factor: 1 };
return { unit, factor: 1 }; }
const unitLower = unit.toLowerCase();
if (unitLower in equivalentUnits) {
return { unit: unitLower, factor: 1 };
}
const baseUnit = getBaseUnit(unit);
if (baseUnit) {
return {
unit: baseUnit,
factor: equivalentUnits[baseUnit][unit],
};
}
const baseUnitLower = getBaseUnit(unitLower);
if (baseUnitLower) {
return {
unit: baseUnitLower,
factor: equivalentUnits[baseUnitLower][unitLower],
};
}
return null;
}
export function calculateTotals(quantityList) {
const totals = {};
for (const quantity of quantityList) {
const baseUnit = getConversionFactor(quantity.unit) ?? { unit: quantity.unit, factor: 1 };
const factor = baseUnit.factor;
const unit = baseUnit.unit;
if (!totals[unit]) {
totals[unit] = 0;
} }
const unitLower = unit.toLowerCase(); totals[unit] += quantity.quantity / factor;
if (unitLower in equivalentUnits) { }
return { unit: unitLower, factor: 1 };
}
const baseUnit = getBaseUnit(unit);
if (baseUnit) {
return {
unit: baseUnit,
factor: equivalentUnits[baseUnit][unit],
};
}
const baseUnitLower = getBaseUnit(unitLower); return Object.keys(totals).map(unit => ({ unit, quantity: totals[unit] }));
if (baseUnitLower) {
return {
unit: baseUnitLower,
factor: equivalentUnits[baseUnitLower][unitLower],
};
}
return null;
},
} }