Stocktake functions

This commit is contained in:
jableader 2024-05-19 20:21:49 +10:00
parent 36f7a24cbf
commit 4a1101be82
5 changed files with 169 additions and 61 deletions

View file

@ -5,8 +5,8 @@
<ul class="full-shopping-list"> <ul class="full-shopping-list">
<li v-for="item in listByProduct" :key="item.id" > <li v-for="item in listByProduct" :key="item.id" >
<shopping-list-item :product="item.product" :sources="item.sources" :totals="item.totals" /> <shopping-list-item :product="item.product" :sources="item.sources" :totals="item.totals" :fully-found="item.fullyFound" :found-date="item.foundDate" />
<button v-if="item.product.id in found" @click="markNotFound(item)">Mark Not Found</button> <button v-if="item.fullyFound" @click="markNotFound(item)">Mark Not Found</button>
<button v-else @click="markFound(item)">Mark Found</button> <button v-else @click="markFound(item)">Mark Found</button>
</li> </li>
</ul> </ul>
@ -31,7 +31,7 @@
<script> <script>
import data from '@/data.js' import data from '@/data.js'
import { requestsToSources as flattenToIngredients, groupByProduct, groupingToIngredients } from './shopping.js' import { toProductsModel, groupingToIngredients } from './shopping.js'
import MealSelectionList from './MealSelectionList.vue' import MealSelectionList from './MealSelectionList.vue'
import ShoppingListItem from './ShoppingListItem.vue' import ShoppingListItem from './ShoppingListItem.vue'
@ -49,7 +49,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: [], found: {} } return { from, to, availableMeals: [], shoppingList: null, includedMeals: [], listByProduct: [] }
}, },
async beforeMount() { async beforeMount() {
this.availableMeals = await data.getMeals(this.from, this.to); this.availableMeals = await data.getMeals(this.from, this.to);
@ -67,14 +67,7 @@ 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);
const sources = flattenToIngredients(this.shoppingList.requests);
this.listByProduct = groupByProduct(sources);
this.found = this.shoppingList.results.reduce((acc, r) => {
acc[r.product.id] = r;
return acc;
}, {});
}, },
async mealSelected(meal) { async mealSelected(meal) {
const request = await data.requestMeal(meal.id); const request = await data.requestMeal(meal.id);

View file

@ -9,8 +9,10 @@
<small> <small>
<span v-for="(total, index) in totals" :key="total.id"> <span v-for="(total, index) in totals" :key="total.id">
<span v-if="index">, </span> <span v-if="index">, </span>
<span>{{ total.quantity }} {{ total.unit }}</span> <span>{{ 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-else-if="foundDate">? {{ getFriendlyDate(foundDate) }}</span>
</small> </small>
</h3> </h3>
<p class="sources"> <p class="sources">
@ -39,6 +41,7 @@
/* Show the product image to the left, then the product name and size to the right */ /* Show the product image to the left, then the product name and size to the right */
.shopping-list-item { .shopping-list-item {
position: relative;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -60,7 +63,30 @@
margin: 0; margin: 0;
} }
/* show sources as subtitles to the product name, with the quantity and source name */ .found-marker {
margin: 1ex;
padding-top: 0.6ex;
padding-bottom: 0.5ex;
padding-left: 1em;
padding-right: 1em;
border-radius: 25px;
text-align: center;
white-space: nowrap;
font-size: smaller;
color: white;
font-weight: bold;
z-index: 1000;
opacity: 0.6;
}
.found-marker.found {
background-color: green;
}
.found-marker.partial {
background-color: darkgoldenrod;
}
</style> </style>
@ -68,12 +94,48 @@
export default { export default {
name: 'ShoppingListItem', name: 'ShoppingListItem',
props: ['product', 'sources', 'totals'], props: ['product', 'sources', 'totals', 'foundDate', 'fullyFound'],
data() { data() {
return { return {
expanded: false, expanded: false,
} }
}, },
methods: {
getFriendlyDate(date) {
if (!date)
return '';
// Eg 30 seconds ago, 5 minutes ago, 1 hour ago, 2 days ago, 3 weeks ago, 4 months ago, 5 years ago
const seconds = Math.floor((new Date() - date) / 1000);
let interval = Math.floor(seconds / 31536000);
if (interval > 1) {
return interval + " years ago";
}
interval = Math.floor(seconds / 2592000);
if (interval > 1) {
return interval + " months ago";
}
interval = Math.floor(seconds / 86400);
if (interval > 1) {
return interval + " days ago";
}
interval = Math.floor(seconds / 3600);
if (interval > 1) {
return interval + " hours ago";
}
interval = Math.floor(seconds / 60);
if (interval > 1) {
return interval + " minutes ago";
}
return "moments ago";
}
}
} }
</script> </script>

View file

@ -44,50 +44,78 @@ export function groupingToIngredients(grouping) {
function calculateTotals(sources) { function calculateTotals(sources) {
const totals = {}; const totals = {};
for (const source of sources) { for (const { quantity, unit } of sources) {
const { unit, factor } = units.getConversionFactor(source.ingredient.unit) ?? { unit: source.ingredient.unit, factor: 1 }; const conversion = units.getConversionFactor(unit) ?? { unit, factor: 1 };
const quantity = source.ingredient.quantity / factor; const quantityInBase = quantity / conversion.factor;
if (!totals[unit]) { if (!totals[conversion.unit]) {
totals[unit] = quantity; totals[conversion.unit] = quantityInBase;
} }
else { else {
totals[unit] += quantity; totals[conversion.unit] += quantityInBase;
} }
} }
const result = []; return totals;
for (const unit in totals) {
result.push({ unit, quantity: totals[unit] });
}
return result;
} }
export function groupByProduct(sources) { function groupBy(groups, keyFn) {
const noProductKey = "no-product";
const noProduct = { name: "No product", };
const grouped = {}; const grouped = {};
for (const source of sources) { for (const group of groups) {
const key = source.ingredient?.product?.id ?? noProductKey; const key = keyFn(group);
if (!key) {
continue;
}
if (!grouped[key]) { if (!grouped[key]) {
grouped[key] = { grouped[key] = [];
product: source.ingredient.product ?? noProduct,
sources: [],
};
} }
grouped[key].sources.push(source); grouped[key].push(group);
} }
for (const key in grouped) { return grouped;
grouped[key].totals = calculateTotals(grouped[key].sources); }
function getRemainingIngredients(requiredTotals, foundTotals) {
const remaining = {};
for (const unit in requiredTotals) {
const required = requiredTotals[unit];
const found = foundTotals[unit] ?? 0;
const remainingQuantity = required - found;
if (remainingQuantity > 0) {
remaining[unit] = remainingQuantity;
}
} }
return Object.values(grouped); return remaining;
}
export function toProductsModel(shoppingList) {
const flattenedIngredients = requestsToSources(shoppingList.requests);
const foundIngredientsByProduct = groupBy(shoppingList.results.filter(f => f.found_date), found => found.product_id);
const requiredIngredientsByProduct = groupBy(flattenedIngredients, source => source.ingredient?.product?.id ?? "no-product");
const results = [];
for (const product_id in requiredIngredientsByProduct) {
const sources = requiredIngredientsByProduct[product_id];
const requiredIngredients = sources.map(source => source.ingredient);
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({
product: requiredIngredients[0].product,
totals: Object.entries(requiredTotals).map(([unit, quantity]) => ({ unit, quantity, })),
fullyFound, foundDate, sources,
});
}
return results;
} }

View file

@ -1,11 +1,16 @@
const BASE_URL = window.location.href.replace(/^(http:\/\/[^/:]+).*/, "$1:8000") const BASE_URL = window.location.href.replace(/^(http:\/\/[^/:]+).*/, "$1:8000")
function fixMealDates(meals) { function fixDates(obj, ...fields) {
for (const meal of meals) { for (const field of fields) {
meal.meal_date = new Date(meal.meal_date); if (obj[field]) {
obj[field] = new Date(obj[field]);
}
} }
} }
const fixMealDates = meals => meals.forEach(meal => fixDates(meal, "meal_date"));
const fixResultDates = results => results.forEach(result => fixDates(result, "found_date"));
let user = null; let user = null;
export default { export default {
async getMeals(from, to) { async getMeals(from, to) {
@ -164,6 +169,9 @@ 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();
fixMealDates(lst.requests.map(request => request.meal).filter(meal => meal)); fixMealDates(lst.requests.map(request => request.meal).filter(meal => meal));
fixResultDates(lst.results);
fixDates(lst, "created_date");
return lst; return lst;
}, },
async requestMeal(meal_id) { async requestMeal(meal_id) {
@ -198,7 +206,10 @@ export default {
body: JSON.stringify(ingredients), body: JSON.stringify(ingredients),
}); });
return await response.json(); const results = await response.json();
fixResultDates(results.created);
fixResultDates(results.removed);
return results;
}, },
async markNotFound(product_id) { async markNotFound(product_id) {
const response = await fetch(BASE_URL + `/shopping/current/found/${product_id}`, { const response = await fetch(BASE_URL + `/shopping/current/found/${product_id}`, {
@ -206,6 +217,8 @@ export default {
credentials: "include", credentials: "include",
}); });
return await response.json(); const results = await response.json();
fixResultDates(results);
return results;
} }
} }

View file

@ -96,19 +96,31 @@ function getBaseUnit(unit) {
export default { export default {
getConversionFactor(unit) { getConversionFactor(unit) {
let baseUnit = getBaseUnit(unit); if (unit in equivalentUnits) {
if (!baseUnit) { return { unit, factor: 1 };
unit = unit.toLowerCase();
baseUnit = getBaseUnit(unit);
} }
if (!baseUnit) { const unitLower = unit.toLowerCase();
return null; if (unitLower in equivalentUnits) {
return { unit: unitLower, factor: 1 };
}
const baseUnit = getBaseUnit(unit);
if (baseUnit) {
return {
unit: baseUnit,
factor: equivalentUnits[baseUnit][unit],
};
} }
return { const baseUnitLower = getBaseUnit(unitLower);
unit: baseUnit, if (baseUnitLower) {
factor: equivalentUnits[baseUnit][unit], return {
}; unit: baseUnitLower,
factor: equivalentUnits[baseUnitLower][unitLower],
};
}
return null;
}, },
} }