Compare commits
6 commits
master
...
nullproduc
| Author | SHA1 | Date | |
|---|---|---|---|
| 7705c3113c | |||
| 6cc9f8fef7 | |||
| e72c84b18e | |||
| ce3e255eb4 | |||
| 67cbb370ed | |||
| 728532a7c8 |
8 changed files with 220 additions and 245 deletions
12
src/assets/missing-product.svg
Normal file
12
src/assets/missing-product.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<title>ic_fluent_missing_metadata_24_regular</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<g id="🔍-System-Icons" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="ic_fluent_missing_metadata_24_regular" fill="#212121" fill-rule="nonzero">
|
||||||
|
<path d="M19.7501,2 C20.9927,2 22.0001,3.00736 22.0001,4.25 L22.0001,9.71196 C22.0001,10.5738 21.6578,11.4003 21.0484,12.0098 L21.0222,12.0361 C20.5797,11.7503 20.1003,11.5167 19.5928,11.3442 L19.9876,10.9492 C20.3157,10.6211 20.5001,10.176 20.5001,9.71196 L20.5001,4.25 C20.5001,3.83579 20.1643,3.5 19.7501,3.5 L14.2847,3.5 C13.8202,3.5 13.3748,3.68467 13.0465,4.01333 L4.53436,12.5358 C3.86414,13.2207 3.86923,14.3191 4.54908,14.9977 L9.0103,19.4522 C9.64819,20.0877 10.6535,20.1309 11.3408,19.5826 C11.5052,20.0689 11.7256,20.5295 11.9943,20.9567 C10.7373,21.7569 9.05064,21.6098 7.95104,20.5143 L3.48934,16.0592 C2.21887,14.7913 2.21724,12.7334 3.48556,11.4632 L11.9852,2.95334 C12.5948,2.34297 13.4221,2 14.2847,2 L19.7501,2 Z M17,5.50218 C17.8284,5.50218 18.5,6.17374 18.5,7.00216 C18.5,7.83057 17.8284,8.50213 17,8.50213 C16.1716,8.50213 15.5001,7.83057 15.5001,7.00216 C15.5001,6.17374 16.1716,5.50218 17,5.50218 Z M23,17.5 C23,14.4624 20.5376,12 17.5,12 C14.4624,12 12,14.4624 12,17.5 C12,20.5376 14.4624,23 17.5,23 C20.5376,23 23,20.5376 23,17.5 Z M16.8755,20.5045 C16.8755,20.1596 17.1551,19.88 17.5,19.88 C17.8449,19.88 18.1245,20.1596 18.1245,20.5045 C18.1245,20.8494 17.8449,21.129 17.5,21.129 C17.1551,21.129 16.8755,20.8494 16.8755,20.5045 Z M15.6467,15.9574 C15.6357,14.8205 16.4521,14.0031 17.5,14.0031 C18.5311,14.0031 19.3534,14.8489 19.3534,15.9526 C19.3534,16.5186 19.1682,16.866 18.6905,17.4003 L18.4247,17.6908 L18.3238,17.8063 C18.0765,18.0981 18,18.2684 18,18.5006 C18,18.7767 17.7762,19.0006 17.5,19.0006 C17.2239,19.0006 17,18.7767 17,18.5006 C17,17.9255 17.1868,17.5749 17.6711,17.0333 L17.9365,16.7432 L18.0355,16.63 C18.2782,16.3437 18.3534,16.1769 18.3534,15.9526 C18.3534,15.395 17.9724,15.0031 17.5,15.0031 C17.0063,15.0031 16.6411,15.3688 16.6465901,15.9478 C16.6493,16.2239 16.4276,16.4499 16.1514,16.4526 C15.8753,16.4552 15.6493,16.2335 15.6467,15.9574 Z" id="🎨-Color">
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="compact-parse-results">
|
<div class="compact-parse-results">
|
||||||
<p class="parse-element teaser-image" v-if="ingredient.product && ingredient.product.img_small">
|
<p class="parse-element teaser-image">
|
||||||
<img :src="ingredient.product.img_small" />
|
<img :src="ingredient.product?.img_small ?? require('@/assets/missing-product.svg')" />
|
||||||
</p>
|
</p>
|
||||||
<p class="ingredient-details">
|
<p class="ingredient-details">
|
||||||
<span class="parse-element quantity" :class="{ missing: !(ingredient?.quantity)}">{{ ingredient?.quantity || 'qty' }}</span>
|
<span class="parse-element quantity" :class="{ missing: !(ingredient?.quantity)}">{{ ingredient?.quantity || 'qty' }}</span>
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,19 @@
|
||||||
<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 productGroupsToPurchase" :key="item.id" class="selectable" :class="{ 'selected': isSelected(item) }" @click="toggleSelect(item)">
|
<li v-for="group in outstandingItemGroups" :key="group.id" class="selectable" :class="{ 'selected': isSelected(group) }" @click="toggleSelect(group)">
|
||||||
<shopping-list-item :productGrouping="item" />
|
<shopping-list-item :shopping-list-item-group="group" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div v-if="productGroupsToPurchase.length === 0">
|
<div v-if="outstandingItemGroups.length === 0">
|
||||||
<p>
|
<p>
|
||||||
No items to purchase
|
No items to purchase
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="purchased-slider">
|
<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-if="showPurchased" @click="showPurchased=false" >⏶ Hide Purchased ⏶</button>
|
||||||
<button v-else @click="showPurchased=true">⏷ Show Purchased ⏷</button>
|
<button v-else @click="showPurchased=true">⏷ Show Purchased ⏷</button>
|
||||||
|
|
||||||
|
|
@ -29,8 +29,8 @@
|
||||||
Purchased Items
|
Purchased Items
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="full-shopping-list">
|
<ul class="full-shopping-list">
|
||||||
<li v-for="item in purchasedGroups" :key="item.id">
|
<li v-for="item in purchasedItemGroups" :key="item.id">
|
||||||
<shopping-list-item :productGrouping="item" />
|
<shopping-list-item :shopping-list-item-group="item" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
<!-- Display 'Stocked', 'Purchased' and 'Cancel' buttons in a vertical stack fixed to the bottom of the screen when any elements are selected -->
|
<!-- 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">
|
<div class="footer-buttons" v-if="selected.length">
|
||||||
<p v-if="selected.length === 1">
|
<p v-if="selected.length === 1">
|
||||||
Mark '{{ selected[0].product.name }}' as
|
Mark '{{ selected[0].product?.name ?? selected[0].name }}' as
|
||||||
</p>
|
</p>
|
||||||
<p v-else>
|
<p v-else>
|
||||||
Mark {{ selected.length }} items as
|
Mark {{ selected.length }} items as
|
||||||
|
|
@ -135,26 +135,28 @@ button img {
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
|
import alert from '@/alert.js'
|
||||||
|
|
||||||
import data from '@/data.js'
|
import data from '@/data.js'
|
||||||
import { toProductsModel, getCompletedRequests, sourcesToRequests } from './shopping.js'
|
import { itemsToGroups, groupsToItems } from './shopping.js'
|
||||||
|
|
||||||
import MealSelectionList from './MealSelectionList.vue'
|
import MealSelectionList from './MealSelectionList.vue'
|
||||||
import ShoppingListItem from './ShoppingListItem.vue'
|
import ShoppingListItem from './ShoppingListItem.vue'
|
||||||
|
|
||||||
async function saveShoppingList(storeName, currentShoppingList, productGroupsPurchased) {
|
async function saveShoppingList(outstandingItemGroups) {
|
||||||
const results = productGroupsPurchased.map(item => item.requested.map(r => ({
|
const items = groupsToItems(outstandingItemGroups);
|
||||||
product_id: r.ingredient.product_id,
|
if (items.length === 0) {
|
||||||
product: r.ingredient.product,
|
alert.show({ type: 'error', message: 'No items selected.' });
|
||||||
quantity: r.ingredient.quantity,
|
return;
|
||||||
unit: r.ingredient.unit,
|
}
|
||||||
list_id: -1,
|
|
||||||
}))).flat();
|
|
||||||
|
|
||||||
const requestedSources = productGroupsPurchased.map(item => item.requested).flat();
|
return await data.purchaseShoppingList(items);
|
||||||
const completedRequests = getCompletedRequests(currentShoppingList, results);
|
}
|
||||||
const servicedRequests = sourcesToRequests(requestedSources);
|
|
||||||
|
|
||||||
return await data.purchaseItems(storeName, servicedRequests, results, completedRequests);
|
const groupsMatch = (a, b) => {
|
||||||
|
if (!!a.product != !!b.product) return false;
|
||||||
|
if (a.name) return a.name === b.name;
|
||||||
|
return a.product.id === b.product.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
@ -173,69 +175,74 @@ export default {
|
||||||
const to = new Date();
|
const to = new Date();
|
||||||
to.setDate(to.getDate() + 7);
|
to.setDate(to.getDate() + 7);
|
||||||
|
|
||||||
return { from, to, availableMeals: [], purchasedMeals: [], shoppingList: null, includedMeals: [], productGroupsToPurchase: [], purchasedGroups: [], selected: [], showPurchased: false }
|
return { from, to, shoppingList: null, selected: [], showPurchased: false }
|
||||||
},
|
},
|
||||||
async beforeMount() {
|
async beforeMount() {
|
||||||
this.loadData();
|
this.loadData();
|
||||||
},
|
},
|
||||||
watch: {
|
computed: {
|
||||||
shoppingList: {
|
outstandingItemGroups() {
|
||||||
handler: 'updateLists',
|
return itemsToGroups(this.shoppingList?.outstanding_items ?? []);
|
||||||
deep: true
|
|
||||||
},
|
},
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
includedMeals() {
|
||||||
|
return this.shoppingList?.requested_meals.map(m => m.meal) ?? [];
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async loadData() {
|
async loadData() {
|
||||||
|
this.upcomingMeals = await data.getUpcomingMeals(this.from, this.to);
|
||||||
this.shoppingList = await data.getCurrentShoppingList();
|
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) {
|
async mealSelected(meal) {
|
||||||
const request = await data.requestMeal(meal.id);
|
await data.requestMeal(meal.id);
|
||||||
this.shoppingList.requests.push(request);
|
await this.loadData();
|
||||||
},
|
},
|
||||||
async mealUnselected(meal) {
|
async mealUnselected(meal) {
|
||||||
await data.unrequestMeal(meal.id);
|
await data.unrequestMeal(meal.id);
|
||||||
this.shoppingList.requests = this.shoppingList.requests.filter(r => r.meal?.id !== meal.id);
|
await this.loadData();
|
||||||
},
|
},
|
||||||
async markFound() {
|
async markFound() {
|
||||||
await saveShoppingList('', this.shoppingList, this.selected);
|
await saveShoppingList(this.selected);
|
||||||
|
|
||||||
this.selected = [];
|
this.selected = [];
|
||||||
this.loadData();
|
await this.loadData();
|
||||||
},
|
},
|
||||||
async markPurchased() {
|
async markPurchased() {
|
||||||
const shoppingList = await saveShoppingList('', this.shoppingList, this.selected);
|
const shoppingList = await saveShoppingList(this.selected);
|
||||||
|
|
||||||
|
if (!shoppingList || !shoppingList.id) {
|
||||||
|
alert.show({ type: 'error', message: 'Failed to purchase.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.selected = [];
|
this.selected = [];
|
||||||
this.$router.push(`/shopping/${shoppingList.id}`);
|
this.$router.push(`/shopping/${shoppingList.id}`);
|
||||||
},
|
},
|
||||||
toggleSelect(item) {
|
toggleSelect(item) {
|
||||||
const index = this.selected.findIndex(i => i === item);
|
const index = this.selected.findIndex(i => groupsMatch(i, item));
|
||||||
if (index === -1)
|
if (index === -1)
|
||||||
this.selected.push(item);
|
this.selected.push(item);
|
||||||
else
|
else
|
||||||
this.selected.splice(index, 1);
|
this.selected.splice(index, 1);
|
||||||
},
|
},
|
||||||
isSelected(item) {
|
isSelected(item) {
|
||||||
return this.selected.some(i => i === item);
|
return this.selected.some(i => groupsMatch(i, item));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,11 +56,11 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async updateShoppingList(save = false) {
|
async updateShoppingList(save = false) {
|
||||||
const requests = save ?
|
const new_ingredients = save ?
|
||||||
await data.saveMyShoppingList(this.ingredients) :
|
await data.saveMyShoppingList(this.ingredients) :
|
||||||
await data.getMyShoppingList();
|
await data.getMyShoppingList();
|
||||||
|
|
||||||
this.ingredients = requests.map(r => r.ingredient);
|
this.ingredients = new_ingredients;
|
||||||
},
|
},
|
||||||
addIngredient() {
|
addIngredient() {
|
||||||
this.ingredients = [{ id: -1 }, ...this.ingredients];
|
this.ingredients = [{ id: -1 }, ...this.ingredients];
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
<template>
|
<template>
|
||||||
<h3>Purchased {{ shoppingList ? ago(shoppingList.created_date) : '' }}</h3>
|
<h3>Purchased {{ shoppingList ? ago(shoppingList.created_date) : '' }}</h3>
|
||||||
|
|
||||||
<h4>Included Meals</h4>
|
<div v-if="includedMeals.length > 0">
|
||||||
<meal-selection-list :checked="includedMeals" :meals="includedMeals" :disabled="true" />
|
<h4>Included Meals</h4>
|
||||||
|
<meal-selection-list :checked="includedMeals" :meals="includedMeals" :disabled="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<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 :productGrouping="item" />
|
<shopping-list-item :shopping-list-item-group="item" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -32,7 +34,7 @@
|
||||||
import { ago } from '@/dateformats.js'
|
import { ago } from '@/dateformats.js'
|
||||||
|
|
||||||
import data from '@/data.js'
|
import data from '@/data.js'
|
||||||
import { purchasedToProductModel } from './shopping.js'
|
import { itemsToGroups } from './shopping.js'
|
||||||
|
|
||||||
import MealSelectionList from './MealSelectionList.vue'
|
import MealSelectionList from './MealSelectionList.vue'
|
||||||
import ShoppingListItem from './ShoppingListItem.vue'
|
import ShoppingListItem from './ShoppingListItem.vue'
|
||||||
|
|
@ -43,17 +45,31 @@ export default {
|
||||||
props: {
|
props: {
|
||||||
id: [String, Number]
|
id: [String, Number]
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
includedMeals() {
|
||||||
|
if (!this.shoppingList) return [];
|
||||||
|
|
||||||
|
const seenMeals = new Set();
|
||||||
|
return this.shoppingList.items
|
||||||
|
.map(item => item.meal)
|
||||||
|
.filter(meal => {
|
||||||
|
if (!meal) return false;
|
||||||
|
if (seenMeals.has(meal.id)) return false;
|
||||||
|
seenMeals.add(meal.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
listByProduct() {
|
||||||
|
return this.shoppingList ? itemsToGroups(this.shoppingList.items) : [];
|
||||||
|
}
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
shoppingList: null,
|
shoppingList: null,
|
||||||
includedMeals: [],
|
|
||||||
listByProduct: [],
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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.listByProduct = purchasedToProductModel(this.shoppingList);
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
ago
|
ago
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
<!-- 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="productGrouping.product.img_small" class="product-image" />
|
<img :src="`${ shoppingListItemGroup.product?.img_small ?? require('@/assets/missing-product.svg') }`" class="product-image" />
|
||||||
<div class="product-details">
|
<div class="product-details">
|
||||||
<h3 class="header">
|
<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>
|
<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 v-if="index">, </span>
|
||||||
<span>{{ formatQuantity(total.quantity) }} {{ total.unit }}</span>
|
<span>{{ formatQuantity(total.quantity) }} {{ total.unit }}</span>
|
||||||
</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>
|
</small>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="sources" v-if="productGrouping.requested.length > 0">
|
<p class="sources" v-if="required.length > 0">
|
||||||
<strong>Need: </strong>
|
<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="index">, and </span>
|
||||||
<span v-if="source.person">
|
<span v-if="source.person">
|
||||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for {{ source.person.name }}
|
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for {{ source.person.name }}
|
||||||
|
|
@ -31,9 +34,9 @@
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="productGrouping.purchased.length > 0">
|
<p v-if="purchased.length > 0">
|
||||||
<strong>Already found or purchased: </strong>
|
<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="index">, and </span>
|
||||||
<span v-if="source.person">
|
<span v-if="source.person">
|
||||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for {{ source.person.name }}
|
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for {{ source.person.name }}
|
||||||
|
|
@ -113,23 +116,37 @@ import { calculateTotals } from '@/units.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ShoppingListItem',
|
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() {
|
data() {
|
||||||
return {
|
return {
|
||||||
expanded: false,
|
expanded: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
remainingRequired() {
|
remainingRequiredTotals() {
|
||||||
return calculateTotals(this.productGrouping.requested.map(r => r.ingredient));
|
return calculateTotals(this.shoppingListItemGroup.shoppingListItems.map(item => item.ingredient));
|
||||||
},
|
},
|
||||||
expectedExisting() {
|
expectedExistingTotals() {
|
||||||
return calculateTotals(this.productGrouping.purchased.map(r => r.ingredient));
|
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() {
|
lastPurchased() {
|
||||||
return this.productGrouping.purchased.reduce((latest, source) => {
|
const purchasedItems = this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id);
|
||||||
return (source.shop.created_date && latest > source.shop.created_date) ? latest : source.shop.created_date;
|
if (purchasedItems.length === 0) {
|
||||||
}, new Date(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: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -1,151 +1,35 @@
|
||||||
function scaleFactor(mealRecipe) {
|
export function groupsToItems(groups) {
|
||||||
const numRecipe = mealRecipe.recipe.serves;
|
return groups.map(group => group.shoppingListItems).flat();
|
||||||
const numRequested = mealRecipe.servings;
|
|
||||||
|
|
||||||
return numRequested / numRecipe;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mealToShoppingListSources(meal) {
|
export function itemsToGroups(shoppingListItems) {
|
||||||
const sources = [];
|
const ingredients_by_product_id = {};
|
||||||
for (const mealRecipe of meal.recipes) {
|
const ingredients_by_name = {};
|
||||||
const scale = scaleFactor(mealRecipe);
|
for (const item of shoppingListItems) {
|
||||||
for (const ingredient of mealRecipe.recipe.ingredients) {
|
if (item.ingredient.product) {
|
||||||
const quantity = ingredient.quantity * scale;
|
let group = ingredients_by_product_id[item.ingredient.product.id];
|
||||||
sources.push({ meal, mealRecipe, recipe: mealRecipe.recipe, ingredient: { ...ingredient, quantity, }, });
|
if (!group) {
|
||||||
}
|
group = ingredients_by_product_id[item.ingredient.product.id] = {
|
||||||
}
|
product: item.ingredient.product,
|
||||||
|
shoppingListItems: []
|
||||||
for (const ingredient of meal.extra_ingredients) {
|
};
|
||||||
sources.push({ ingredient, meal, });
|
|
||||||
}
|
|
||||||
|
|
||||||
return sources;
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: [], };
|
|
||||||
}
|
}
|
||||||
|
group.shoppingListItems.push(item);
|
||||||
grouped[source.ingredient.product.id].requested.push(source);
|
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
for (const source of requestedMealResults[meal_id].purchased) {
|
let group = ingredients_by_name[item.ingredient.name];
|
||||||
if (!grouped[source.ingredient.product.id]) {
|
if (!group) {
|
||||||
grouped[source.ingredient.product.id] = { product: source.ingredient.product, requested: [], purchased: [], };
|
group = ingredients_by_name[item.ingredient.name] = {
|
||||||
|
name: item.ingredient.name,
|
||||||
|
shoppingListItems: []
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
group.shoppingListItems.push(item);
|
||||||
grouped[source.ingredient.product.id].purchased.push(source);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only return values, array
|
return [
|
||||||
return grouped;
|
...Object.values(ingredients_by_product_id),
|
||||||
}
|
...Object.values(ingredients_by_name)
|
||||||
|
]
|
||||||
export function purchasedToProductModel(shoppingList) {
|
|
||||||
const purchasedProductIds = new Set(shoppingList.results.map(r => r.product.id));
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
81
src/data.js
81
src/data.js
|
|
@ -2,10 +2,10 @@ 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 }) },
|
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", "purchased_date" ], dependants: l => ({ ShoppingListResult: l.results, ShoppingListRequest: l.requests }) },
|
PurchasedShoppingList: { dependants: l => ({ ShoppingListItem: l.items, Meal: Object.values(l.meals_lookup), Recipe: Object.values(l.recipes_lookup), Ingredient: Object.values(l.ingredients_lookup), ShoppingList: l.list }) },
|
||||||
ShoppingListResult: { fields: [ "found_date", "created_date" ], },
|
ShoppingList: { fields: [ "created_date" ], dependants: l => ({ ShoppingListItem: l.items }) },
|
||||||
ShoppingListRequest: { 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" ], },
|
Recipe: { fields: [ "date_created", "date_hidden" ], },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -43,6 +43,39 @@ const fixDates = (obj, type) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setPurchasedShoppingListReferences = (purchasedShoppingList) => {
|
||||||
|
if (!purchasedShoppingList) return;
|
||||||
|
const { ingredients_lookup, meals_lookup, recipes_lookup, } = purchasedShoppingList;
|
||||||
|
const shopping_list_lookup = { [purchasedShoppingList.list.id]: purchasedShoppingList.list };
|
||||||
|
setShoppingListItemReferences(purchasedShoppingList.list.items, ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCurrentShoppingListReferences = (currentShoppingList) => {
|
||||||
|
if (!currentShoppingList) return;
|
||||||
|
const { ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup } = currentShoppingList;
|
||||||
|
const allShoppingListItems = [...currentShoppingList.outstanding_items, ...currentShoppingList.requested_meals, ...currentShoppingList.purchased_items];
|
||||||
|
|
||||||
|
setShoppingListItemReferences(allShoppingListItems, ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setShoppingListItemReferences = (shoppingListItems, ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup) => {
|
||||||
|
if (!shoppingListItems) return;
|
||||||
|
|
||||||
|
for (const item of shoppingListItems) {
|
||||||
|
if (item.ingredient_id) {
|
||||||
|
item.ingredient = ingredients_lookup[item.ingredient_id];
|
||||||
|
}
|
||||||
|
if (item.meal_id) {
|
||||||
|
item.meal = meals_lookup[item.meal_id];
|
||||||
|
}
|
||||||
|
if (item.list_id) {
|
||||||
|
item.list = shopping_list_lookup[item.list_id];
|
||||||
|
}
|
||||||
|
if (item.recipe_id) {
|
||||||
|
item.recipe = recipes_lookup[item.recipe_id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let user = null;
|
let user = null;
|
||||||
export default {
|
export default {
|
||||||
|
|
@ -213,10 +246,10 @@ export default {
|
||||||
},
|
},
|
||||||
async getMyShoppingList() {
|
async getMyShoppingList() {
|
||||||
const response = await fetch(BASE_URL + "/shopping/current/me/ingredients", { credentials: "include" });
|
const response = await fetch(BASE_URL + "/shopping/current/me/ingredients", { credentials: "include" });
|
||||||
const requests = await response.json();
|
const ingredients = await response.json();
|
||||||
fixDates(requests, "ShoppingListRequest");
|
fixDates(ingredients, 'Ingredient');
|
||||||
|
|
||||||
return requests;
|
return ingredients;
|
||||||
},
|
},
|
||||||
async saveMyShoppingList(list) {
|
async saveMyShoppingList(list) {
|
||||||
const response = await fetch(BASE_URL + "/shopping/current/me/ingredients", {
|
const response = await fetch(BASE_URL + "/shopping/current/me/ingredients", {
|
||||||
|
|
@ -228,39 +261,44 @@ export default {
|
||||||
body: JSON.stringify(list),
|
body: JSON.stringify(list),
|
||||||
});
|
});
|
||||||
|
|
||||||
const requests = await response.json();
|
const ingredients = await response.json();
|
||||||
fixDates(requests, "ShoppingListRequest");
|
fixDates(ingredients, 'Ingredient');
|
||||||
|
|
||||||
return requests;
|
return ingredients;
|
||||||
},
|
},
|
||||||
async getShoppingList(id) {
|
async getShoppingList(id) {
|
||||||
const response = await fetch(BASE_URL + `/shopping/${encodeURIComponent(id)}`);
|
const response = await fetch(BASE_URL + `/shopping/${encodeURIComponent(id)}`);
|
||||||
|
|
||||||
const lst = await response.json();
|
const purchasedShoppingList = await response.json();
|
||||||
fixDates(lst, "ShoppingList");
|
fixDates(purchasedShoppingList, "PurchasedShoppingList");
|
||||||
return lst;
|
setPurchasedShoppingListReferences(purchasedShoppingList);
|
||||||
|
|
||||||
|
return purchasedShoppingList.list;
|
||||||
},
|
},
|
||||||
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();
|
const lst = await response.json();
|
||||||
fixDates(lst, "CurrentShoppingList");
|
fixDates(lst, "CurrentShoppingList");
|
||||||
|
setCurrentShoppingListReferences(lst);
|
||||||
|
|
||||||
return lst;
|
return lst;
|
||||||
},
|
},
|
||||||
async purchaseItems(storeName, requests, results, completedRequests) {
|
async purchaseShoppingList(completed_requests) {
|
||||||
const response = await fetch(BASE_URL + "/shopping/", {
|
const response = await fetch(BASE_URL + "/shopping/", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ requests, results, store_name: storeName, completed_requests: completedRequests }),
|
body: JSON.stringify({ items: completed_requests }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const lst = await response.json();
|
const purchasedShoppingList = await response.json();
|
||||||
fixDates(lst, "ShoppingList");
|
fixDates(purchasedShoppingList, "PurchasedShoppingList");
|
||||||
|
setPurchasedShoppingListReferences(purchasedShoppingList);
|
||||||
|
|
||||||
return lst;
|
return purchasedShoppingList.list;
|
||||||
},
|
},
|
||||||
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", {
|
||||||
|
|
@ -273,7 +311,7 @@ export default {
|
||||||
});
|
});
|
||||||
|
|
||||||
const requests = await response.json();
|
const requests = await response.json();
|
||||||
fixDates(requests, "ShoppingListRequest");
|
fixDates(requests, "ShoppingListItem");
|
||||||
|
|
||||||
return requests;
|
return requests;
|
||||||
},
|
},
|
||||||
|
|
@ -283,8 +321,9 @@ export default {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
|
|
||||||
const requests = await response.json();
|
if (!response.ok) {
|
||||||
fixDates(requests, "ShoppingListRequest");
|
throw new Error("Failed to unrequest meal");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async getPersonsInHome() {
|
async getPersonsInHome() {
|
||||||
const response = await fetch(BASE_URL + "/persons");
|
const response = await fetch(BASE_URL + "/persons");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue