Compare commits
No commits in common. "7705c3113c1cb8baf24f4ff8113197a2f4dfde12" and "212e89ca493e797ee136f0a944698dedeb107118" have entirely different histories.
7705c3113c
...
212e89ca49
8 changed files with 248 additions and 223 deletions
|
|
@ -1,12 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
Before 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">
|
<p class="parse-element teaser-image" v-if="ingredient.product && ingredient.product.img_small">
|
||||||
<img :src="ingredient.product?.img_small ?? require('@/assets/missing-product.svg')" />
|
<img :src="ingredient.product.img_small" />
|
||||||
</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="group in outstandingItemGroups" :key="group.id" class="selectable" :class="{ 'selected': isSelected(group) }" @click="toggleSelect(group)">
|
<li v-for="item in productGroupsToPurchase" :key="item.id" class="selectable" :class="{ 'selected': isSelected(item) }" @click="toggleSelect(item)">
|
||||||
<shopping-list-item :shopping-list-item-group="group" />
|
<shopping-list-item :productGrouping="item" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div v-if="outstandingItemGroups.length === 0">
|
<div v-if="productGroupsToPurchase.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="purchasedItemGroups.length === 0"></span>
|
<span v-if="purchasedGroups.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 purchasedItemGroups" :key="item.id">
|
<li v-for="item in purchasedGroups" :key="item.id">
|
||||||
<shopping-list-item :shopping-list-item-group="item" />
|
<shopping-list-item :productGrouping="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 ?? selected[0].name }}' as
|
Mark '{{ selected[0].product.name }}' as
|
||||||
</p>
|
</p>
|
||||||
<p v-else>
|
<p v-else>
|
||||||
Mark {{ selected.length }} items as
|
Mark {{ selected.length }} items as
|
||||||
|
|
@ -135,28 +135,26 @@ button img {
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
import alert from '@/alert.js'
|
|
||||||
|
|
||||||
import data from '@/data.js'
|
import data from '@/data.js'
|
||||||
import { itemsToGroups, groupsToItems } 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'
|
||||||
|
|
||||||
async function saveShoppingList(outstandingItemGroups) {
|
async function saveShoppingList(storeName, currentShoppingList, productGroupsPurchased) {
|
||||||
const items = groupsToItems(outstandingItemGroups);
|
const results = productGroupsPurchased.map(item => item.requested.map(r => ({
|
||||||
if (items.length === 0) {
|
product_id: r.ingredient.product_id,
|
||||||
alert.show({ type: 'error', message: 'No items selected.' });
|
product: r.ingredient.product,
|
||||||
return;
|
quantity: r.ingredient.quantity,
|
||||||
}
|
unit: r.ingredient.unit,
|
||||||
|
list_id: -1,
|
||||||
|
}))).flat();
|
||||||
|
|
||||||
return await data.purchaseShoppingList(items);
|
const requestedSources = productGroupsPurchased.map(item => item.requested).flat();
|
||||||
}
|
const completedRequests = getCompletedRequests(currentShoppingList, results);
|
||||||
|
const servicedRequests = sourcesToRequests(requestedSources);
|
||||||
|
|
||||||
const groupsMatch = (a, b) => {
|
return await data.purchaseItems(storeName, servicedRequests, results, completedRequests);
|
||||||
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 {
|
||||||
|
|
@ -175,74 +173,69 @@ export default {
|
||||||
const to = new Date();
|
const to = new Date();
|
||||||
to.setDate(to.getDate() + 7);
|
to.setDate(to.getDate() + 7);
|
||||||
|
|
||||||
return { from, to, shoppingList: null, selected: [], showPurchased: false }
|
return { from, to, availableMeals: [], purchasedMeals: [], shoppingList: null, includedMeals: [], productGroupsToPurchase: [], purchasedGroups: [], selected: [], showPurchased: false }
|
||||||
},
|
},
|
||||||
async beforeMount() {
|
async beforeMount() {
|
||||||
this.loadData();
|
this.loadData();
|
||||||
},
|
},
|
||||||
computed: {
|
watch: {
|
||||||
outstandingItemGroups() {
|
shoppingList: {
|
||||||
return itemsToGroups(this.shoppingList?.outstanding_items ?? []);
|
handler: 'updateLists',
|
||||||
|
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) {
|
||||||
await data.requestMeal(meal.id);
|
const request = await data.requestMeal(meal.id);
|
||||||
await this.loadData();
|
this.shoppingList.requests.push(request);
|
||||||
},
|
},
|
||||||
async mealUnselected(meal) {
|
async mealUnselected(meal) {
|
||||||
await data.unrequestMeal(meal.id);
|
await data.unrequestMeal(meal.id);
|
||||||
await this.loadData();
|
this.shoppingList.requests = this.shoppingList.requests.filter(r => r.meal?.id !== meal.id);
|
||||||
},
|
},
|
||||||
async markFound() {
|
async markFound() {
|
||||||
await saveShoppingList(this.selected);
|
await saveShoppingList('', this.shoppingList, this.selected);
|
||||||
|
|
||||||
this.selected = [];
|
this.selected = [];
|
||||||
await this.loadData();
|
this.loadData();
|
||||||
},
|
},
|
||||||
async markPurchased() {
|
async markPurchased() {
|
||||||
const shoppingList = await saveShoppingList(this.selected);
|
const shoppingList = await saveShoppingList('', this.shoppingList, 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 => groupsMatch(i, item));
|
const index = this.selected.findIndex(i => 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 => groupsMatch(i, item));
|
return this.selected.some(i => i === item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,11 +56,11 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async updateShoppingList(save = false) {
|
async updateShoppingList(save = false) {
|
||||||
const new_ingredients = save ?
|
const requests = save ?
|
||||||
await data.saveMyShoppingList(this.ingredients) :
|
await data.saveMyShoppingList(this.ingredients) :
|
||||||
await data.getMyShoppingList();
|
await data.getMyShoppingList();
|
||||||
|
|
||||||
this.ingredients = new_ingredients;
|
this.ingredients = requests.map(r => r.ingredient);
|
||||||
},
|
},
|
||||||
addIngredient() {
|
addIngredient() {
|
||||||
this.ingredients = [{ id: -1 }, ...this.ingredients];
|
this.ingredients = [{ id: -1 }, ...this.ingredients];
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<h3>Purchased {{ shoppingList ? ago(shoppingList.created_date) : '' }}</h3>
|
<h3>Purchased {{ shoppingList ? ago(shoppingList.created_date) : '' }}</h3>
|
||||||
|
|
||||||
<div v-if="includedMeals.length > 0">
|
<h4>Included Meals</h4>
|
||||||
<h4>Included Meals</h4>
|
<meal-selection-list :checked="includedMeals" :meals="includedMeals" :disabled="true" />
|
||||||
<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 :shopping-list-item-group="item" />
|
<shopping-list-item :productGrouping="item" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -34,7 +32,7 @@
|
||||||
import { ago } from '@/dateformats.js'
|
import { ago } from '@/dateformats.js'
|
||||||
|
|
||||||
import data from '@/data.js'
|
import data from '@/data.js'
|
||||||
import { itemsToGroups } 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'
|
||||||
|
|
@ -45,31 +43,17 @@ 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,24 +2,21 @@
|
||||||
|
|
||||||
<!-- 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="`${ shoppingListItemGroup.product?.img_small ?? require('@/assets/missing-product.svg') }`" 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>
|
<strong><a :href="productGrouping.product.link">{{ productGrouping.product.name }}</a></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 remainingRequiredTotals" :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) }} {{ total.unit }}</span>
|
<span>{{ formatQuantity(total.quantity) }} {{ total.unit }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="found-marker partial" v-if="purchased.length > 0">✓ {{ getFriendlyDate(lastPurchased) }}</span>
|
<span class="found-marker partial" v-if="productGrouping.purchased.length > 0">✓ {{ getFriendlyDate(lastPurchased) }}</span>
|
||||||
</small>
|
</small>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="sources" v-if="required.length > 0">
|
<p class="sources" v-if="productGrouping.requested.length > 0">
|
||||||
<strong>Need: </strong>
|
<strong>Need: </strong>
|
||||||
<span v-for="(source, index) in required" :key="source.id">
|
<span v-for="(source, index) in productGrouping.requested" :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 }}
|
||||||
|
|
@ -34,9 +31,9 @@
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="purchased.length > 0">
|
<p v-if="productGrouping.purchased.length > 0">
|
||||||
<strong>Already found or purchased: </strong>
|
<strong>Already found or purchased: </strong>
|
||||||
<span v-for="(source, index) in purchased" :key="source.id">
|
<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) }} {{ source.ingredient.unit }} for {{ source.person.name }}
|
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for {{ source.person.name }}
|
||||||
|
|
@ -116,37 +113,23 @@ import { calculateTotals } from '@/units.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ShoppingListItem',
|
name: 'ShoppingListItem',
|
||||||
props: ['shoppingListItemGroup' ], // { product: { ... }, OR name: 'string', shoppingListItems: { person, ingredient, list_id?, meal? }} where list_id is null if not yet purchased
|
props: ['productGrouping' ],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
expanded: false,
|
expanded: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
remainingRequiredTotals() {
|
remainingRequired() {
|
||||||
return calculateTotals(this.shoppingListItemGroup.shoppingListItems.map(item => item.ingredient));
|
return calculateTotals(this.productGrouping.requested.map(r => r.ingredient));
|
||||||
},
|
},
|
||||||
expectedExistingTotals() {
|
expectedExisting() {
|
||||||
const purchasedNotEaten = this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id && !(item?.meal?.consumed_date));
|
return calculateTotals(this.productGrouping.purchased.map(r => r.ingredient));
|
||||||
|
|
||||||
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() {
|
||||||
const purchasedItems = this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id);
|
return this.productGrouping.purchased.reduce((latest, source) => {
|
||||||
if (purchasedItems.length === 0) {
|
return (source.shop.created_date && latest > source.shop.created_date) ? latest : source.shop.created_date;
|
||||||
return null;
|
}, new Date(0));
|
||||||
}
|
|
||||||
// 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,35 +1,151 @@
|
||||||
export function groupsToItems(groups) {
|
function scaleFactor(mealRecipe) {
|
||||||
return groups.map(group => group.shoppingListItems).flat();
|
const numRecipe = mealRecipe.recipe.serves;
|
||||||
|
const numRequested = mealRecipe.servings;
|
||||||
|
|
||||||
|
return numRequested / numRecipe;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function itemsToGroups(shoppingListItems) {
|
export function mealToShoppingListSources(meal) {
|
||||||
const ingredients_by_product_id = {};
|
const sources = [];
|
||||||
const ingredients_by_name = {};
|
for (const mealRecipe of meal.recipes) {
|
||||||
for (const item of shoppingListItems) {
|
const scale = scaleFactor(mealRecipe);
|
||||||
if (item.ingredient.product) {
|
for (const ingredient of mealRecipe.recipe.ingredients) {
|
||||||
let group = ingredients_by_product_id[item.ingredient.product.id];
|
const quantity = ingredient.quantity * scale;
|
||||||
if (!group) {
|
sources.push({ meal, mealRecipe, recipe: mealRecipe.recipe, ingredient: { ...ingredient, quantity, }, });
|
||||||
group = ingredients_by_product_id[item.ingredient.product.id] = {
|
|
||||||
product: item.ingredient.product,
|
|
||||||
shoppingListItems: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
group.shoppingListItems.push(item);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
let group = ingredients_by_name[item.ingredient.name];
|
|
||||||
if (!group) {
|
|
||||||
group = ingredients_by_name[item.ingredient.name] = {
|
|
||||||
name: item.ingredient.name,
|
|
||||||
shoppingListItems: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
group.shoppingListItems.push(item);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
for (const ingredient of meal.extra_ingredients) {
|
||||||
...Object.values(ingredients_by_product_id),
|
sources.push({ ingredient, meal, });
|
||||||
...Object.values(ingredients_by_name)
|
}
|
||||||
]
|
|
||||||
|
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: [], };
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only return values, array
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
83
src/data.js
83
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 => ({ 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) }) },
|
CurrentShoppingList: { dependants: l => ({ ShoppingListRequest: l.requests, ShoppingList: l.overlapping_previous_shops }) },
|
||||||
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 }) },
|
ShoppingList: { fields: [ "created_date", "purchased_date" ], dependants: l => ({ ShoppingListResult: l.results, ShoppingListRequest: l.requests }) },
|
||||||
ShoppingList: { fields: [ "created_date" ], dependants: l => ({ ShoppingListItem: l.items }) },
|
ShoppingListResult: { fields: [ "found_date", "created_date" ], },
|
||||||
ShoppingListItem: { fields: [ "created_date" ], dependants: r => ({ Meal: r.meal, Recipe: r.recipe, Ingredient: r.ingredient, ShoppingList: r.list }) },
|
ShoppingListRequest: { fields: [ "created_date" ], dependants: r => ({ Meal: r.meal }) },
|
||||||
Recipe: { fields: [ "date_created", "date_hidden" ], },
|
Recipe: { fields: [ "date_created", "date_hidden" ], },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -43,39 +43,6 @@ 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 {
|
||||||
|
|
@ -246,10 +213,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 ingredients = await response.json();
|
const requests = await response.json();
|
||||||
fixDates(ingredients, 'Ingredient');
|
fixDates(requests, "ShoppingListRequest");
|
||||||
|
|
||||||
return ingredients;
|
return requests;
|
||||||
},
|
},
|
||||||
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", {
|
||||||
|
|
@ -261,44 +228,39 @@ export default {
|
||||||
body: JSON.stringify(list),
|
body: JSON.stringify(list),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ingredients = await response.json();
|
const requests = await response.json();
|
||||||
fixDates(ingredients, 'Ingredient');
|
fixDates(requests, "ShoppingListRequest");
|
||||||
|
|
||||||
return ingredients;
|
return requests;
|
||||||
},
|
},
|
||||||
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 purchasedShoppingList = await response.json();
|
const lst = await response.json();
|
||||||
fixDates(purchasedShoppingList, "PurchasedShoppingList");
|
fixDates(lst, "ShoppingList");
|
||||||
setPurchasedShoppingListReferences(purchasedShoppingList);
|
return lst;
|
||||||
|
|
||||||
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 purchaseShoppingList(completed_requests) {
|
async purchaseItems(storeName, requests, results, completedRequests) {
|
||||||
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({ items: completed_requests }),
|
body: JSON.stringify({ requests, results, store_name: storeName, completed_requests: completedRequests }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const purchasedShoppingList = await response.json();
|
const lst = await response.json();
|
||||||
fixDates(purchasedShoppingList, "PurchasedShoppingList");
|
fixDates(lst, "ShoppingList");
|
||||||
setPurchasedShoppingListReferences(purchasedShoppingList);
|
|
||||||
|
|
||||||
return purchasedShoppingList.list;
|
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", {
|
||||||
|
|
@ -311,7 +273,7 @@ export default {
|
||||||
});
|
});
|
||||||
|
|
||||||
const requests = await response.json();
|
const requests = await response.json();
|
||||||
fixDates(requests, "ShoppingListItem");
|
fixDates(requests, "ShoppingListRequest");
|
||||||
|
|
||||||
return requests;
|
return requests;
|
||||||
},
|
},
|
||||||
|
|
@ -321,9 +283,8 @@ export default {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
const requests = await response.json();
|
||||||
throw new Error("Failed to unrequest meal");
|
fixDates(requests, "ShoppingListRequest");
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async getPersonsInHome() {
|
async getPersonsInHome() {
|
||||||
const response = await fetch(BASE_URL + "/persons");
|
const response = await fetch(BASE_URL + "/persons");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue