Stocktake functions
This commit is contained in:
parent
36f7a24cbf
commit
4a1101be82
5 changed files with 169 additions and 61 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 }} {{ 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>
|
||||||
|
|
@ -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) {
|
||||||
return Object.values(grouped);
|
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;
|
||||||
}
|
}
|
||||||
23
src/data.js
23
src/data.js
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
24
src/units.js
24
src/units.js
|
|
@ -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 {
|
return {
|
||||||
unit: baseUnit,
|
unit: baseUnit,
|
||||||
factor: equivalentUnits[baseUnit][unit],
|
factor: equivalentUnits[baseUnit][unit],
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUnitLower = getBaseUnit(unitLower);
|
||||||
|
if (baseUnitLower) {
|
||||||
|
return {
|
||||||
|
unit: baseUnitLower,
|
||||||
|
factor: equivalentUnits[baseUnitLower][unitLower],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue