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">
<li v-for="item in listByProduct" :key="item.id" >
<shopping-list-item :product="item.product" :sources="item.sources" :totals="item.totals" />
<button v-if="item.product.id in found" @click="markNotFound(item)">Mark Not Found</button>
<shopping-list-item :product="item.product" :sources="item.sources" :totals="item.totals" :fully-found="item.fullyFound" :found-date="item.foundDate" />
<button v-if="item.fullyFound" @click="markNotFound(item)">Mark Not Found</button>
<button v-else @click="markFound(item)">Mark Found</button>
</li>
</ul>
@ -31,7 +31,7 @@
<script>
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 ShoppingListItem from './ShoppingListItem.vue'
@ -49,7 +49,7 @@ export default {
const to = new Date();
to.setDate(to.getDate() + 7);
return { from, to, availableMeals: [], shoppingList: null, includedMeals: [], listByProduct: [], found: {} }
return { from, to, availableMeals: [], shoppingList: null, includedMeals: [], listByProduct: [] }
},
async beforeMount() {
this.availableMeals = await data.getMeals(this.from, this.to);
@ -67,14 +67,7 @@ export default {
return;
this.includedMeals = this.shoppingList.requests.map(r => r.meal).filter(m => m);
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;
}, {});
this.listByProduct = toProductsModel(this.shoppingList);
},
async mealSelected(meal) {
const request = await data.requestMeal(meal.id);

View file

@ -9,8 +9,10 @@
<small>
<span v-for="(total, index) in totals" :key="total.id">
<span v-if="index">, </span>
<span>{{ total.quantity }} {{ total.unit }}</span>
<span>{{ total.quantity }}&nbsp;{{ total.unit }}</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>
</h3>
<p class="sources">
@ -39,6 +41,7 @@
/* Show the product image to the left, then the product name and size to the right */
.shopping-list-item {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
@ -60,7 +63,30 @@
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>
@ -68,12 +94,48 @@
export default {
name: 'ShoppingListItem',
props: ['product', 'sources', 'totals'],
props: ['product', 'sources', 'totals', 'foundDate', 'fullyFound'],
data() {
return {
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>

View file

@ -44,50 +44,78 @@ export function groupingToIngredients(grouping) {
function calculateTotals(sources) {
const totals = {};
for (const source of sources) {
const { unit, factor } = units.getConversionFactor(source.ingredient.unit) ?? { unit: source.ingredient.unit, factor: 1 };
for (const { quantity, unit } of sources) {
const conversion = units.getConversionFactor(unit) ?? { unit, factor: 1 };
const quantity = source.ingredient.quantity / factor;
if (!totals[unit]) {
totals[unit] = quantity;
const quantityInBase = quantity / conversion.factor;
if (!totals[conversion.unit]) {
totals[conversion.unit] = quantityInBase;
}
else {
totals[unit] += quantity;
totals[conversion.unit] += quantityInBase;
}
}
const result = [];
for (const unit in totals) {
result.push({ unit, quantity: totals[unit] });
}
return result;
return totals;
}
export function groupByProduct(sources) {
const noProductKey = "no-product";
const noProduct = { name: "No product", };
function groupBy(groups, keyFn) {
const grouped = {};
for (const source of sources) {
const key = source.ingredient?.product?.id ?? noProductKey;
if (!key) {
continue;
}
for (const group of groups) {
const key = keyFn(group);
if (!grouped[key]) {
grouped[key] = {
product: source.ingredient.product ?? noProduct,
sources: [],
};
grouped[key] = [];
}
grouped[key].sources.push(source);
grouped[key].push(group);
}
for (const key in grouped) {
grouped[key].totals = calculateTotals(grouped[key].sources);
}
return Object.values(grouped);
return grouped;
}
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 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")
function fixMealDates(meals) {
for (const meal of meals) {
meal.meal_date = new Date(meal.meal_date);
function fixDates(obj, ...fields) {
for (const field of fields) {
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;
export default {
async getMeals(from, to) {
@ -164,6 +169,9 @@ export default {
const response = await fetch(BASE_URL + "/shopping/current");
const lst = await response.json();
fixMealDates(lst.requests.map(request => request.meal).filter(meal => meal));
fixResultDates(lst.results);
fixDates(lst, "created_date");
return lst;
},
async requestMeal(meal_id) {
@ -198,7 +206,10 @@ export default {
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) {
const response = await fetch(BASE_URL + `/shopping/current/found/${product_id}`, {
@ -206,6 +217,8 @@ export default {
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 {
getConversionFactor(unit) {
let baseUnit = getBaseUnit(unit);
if (!baseUnit) {
unit = unit.toLowerCase();
baseUnit = getBaseUnit(unit);
if (unit in equivalentUnits) {
return { unit, factor: 1 };
}
if (!baseUnit) {
return null;
const unitLower = unit.toLowerCase();
if (unitLower in equivalentUnits) {
return { unit: unitLower, factor: 1 };
}
return {
unit: baseUnit,
factor: equivalentUnits[baseUnit][unit],
};
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;
},
}