Squashed commit of the following:
commit 0d4681feccfcc3fdb0894d21eba30a7a5a2897e4
Author: jableader <jacobdunk@gmail.com>
Date: Sun Oct 13 19:19:08 2024 +1100
Split lists functional
commit 6506f639faaf1c46130d8ae2d2ac2af842c7f332
Author: jableader <jacobdunk@gmail.com>
Date: Sun Oct 13 18:22:23 2024 +1100
View previous shops
commit 499b72dc19760eabd72f1de1942bcbf3e9ec121f
Author: jableader <jacobdunk@gmail.com>
Date: Sun Oct 13 18:02:22 2024 +1100
Able to save
commit fa706d0500eebc748b61d449fc234227338cae5a
Author: jableader <jacobdunk@gmail.com>
Date: Sun Oct 13 14:15:54 2024 +1100
Empty overlapping shops works
commit 33138d7cca78654903d74fcd53e79eef8734f58d
Author: jableader <jacobdunk@gmail.com>
Date: Fri Oct 4 17:53:52 2024 +1000
Works against old api
This commit is contained in:
parent
cac2ce12d3
commit
e8b71cafac
9 changed files with 326 additions and 214 deletions
4
src/assets/house-check.svg
Normal file
4
src/assets/house-check.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M15 19.4001L16.7143 21.0001L21 17.0001M12 12.0001V16.0001M14 14.0001H10M5 9.77753V16.2001C5 17.8802 5 18.7203 5.32698 19.362C5.6146 19.9265 6.07354 20.3855 6.63803 20.6731C7.27976 21.0001 8.11984 21.0001 9.8 21.0001H11M21 12.0001L15.5668 5.96405C14.3311 4.59129 13.7133 3.9049 12.9856 3.65151C12.3466 3.42894 11.651 3.42899 11.0119 3.65165C10.2843 3.90516 9.66661 4.59163 8.43114 5.96458L3 12.0001" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 721 B |
4
src/assets/shopping-cart.svg
Normal file
4
src/assets/shopping-cart.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 1C1.44772 1 1 1.44772 1 2C1 2.55228 1.44772 3 2 3H3.21922L6.78345 17.2569C5.73276 17.7236 5 18.7762 5 20C5 21.6569 6.34315 23 8 23C9.65685 23 11 21.6569 11 20C11 19.6494 10.9398 19.3128 10.8293 19H15.1707C15.0602 19.3128 15 19.6494 15 20C15 21.6569 16.3431 23 18 23C19.6569 23 21 21.6569 21 20C21 18.3431 19.6569 17 18 17H8.78078L8.28078 15H18C20.0642 15 21.3019 13.6959 21.9887 12.2559C22.6599 10.8487 22.8935 9.16692 22.975 7.94368C23.0884 6.24014 21.6803 5 20.1211 5H5.78078L5.15951 2.51493C4.93692 1.62459 4.13696 1 3.21922 1H2ZM18 13H7.78078L6.28078 7H20.1211C20.6742 7 21.0063 7.40675 20.9794 7.81078C20.9034 8.9522 20.6906 10.3318 20.1836 11.3949C19.6922 12.4251 19.0201 13 18 13ZM18 20.9938C17.4511 20.9938 17.0062 20.5489 17.0062 20C17.0062 19.4511 17.4511 19.0062 18 19.0062C18.5489 19.0062 18.9938 19.4511 18.9938 20C18.9938 20.5489 18.5489 20.9938 18 20.9938ZM7.00617 20C7.00617 20.5489 7.45112 20.9938 8 20.9938C8.54888 20.9938 8.99383 20.5489 8.99383 20C8.99383 19.4511 8.54888 19.0062 8 19.0062C7.45112 19.0062 7.00617 19.4511 7.00617 20Z" fill="#0F0F0F"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -1,37 +1,117 @@
|
||||||
<template>
|
<template>
|
||||||
<h3>Full shopping list</h3>
|
<h3>Full shopping list</h3>
|
||||||
|
|
||||||
<input type="checkbox" id="show-found" @change="onCheckboxChange" :checked="stockTaking" />
|
|
||||||
<label for="show-found">Show Stocktaking</label>
|
|
||||||
|
|
||||||
<h4>Included Meals</h4>
|
<h4>Included Meals</h4>
|
||||||
<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 productsToShow" :key="item.id">
|
<li v-for="item in productGroupsToPurchase" :key="item.id" class="selectable" :class="{ 'selected': isSelected(item) }" @click="toggleSelect(item)">
|
||||||
<shopping-list-item :product="item.product" :sources="item.sources" :totals="item.totals" :fully-found="item.fullyFound" :found-date="item.foundDate" />
|
<shopping-list-item :productGrouping="item" />
|
||||||
<span v-if="stockTaking">
|
|
||||||
<button v-if="item.fullyFound" @click="markNotFound(item)">Mark Not Found</button>
|
|
||||||
<button v-else @click="markFound(item)">Mark Found</button>
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<button @click="markPurchased">Purchased, archive this list!</button>
|
<div class="purchased-slider">
|
||||||
|
<span v-if="purchasedGroups.length === 0"></span>
|
||||||
|
<button v-else-if="showPurchased" @click="showPurchased=false" >⏶ Hide Purchased ⏶</button>
|
||||||
|
<button v-else @click="showPurchased=true">⏷ Show Purchased ⏷</button>
|
||||||
|
|
||||||
|
<div v-if="showPurchased">
|
||||||
|
<h4>
|
||||||
|
Already Purchased
|
||||||
|
</h4>
|
||||||
|
<ul class="full-shopping-list">
|
||||||
|
<li v-for="item in purchasedGroups" :key="item.id">
|
||||||
|
<shopping-list-item :productGrouping="item" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
<p v-if="selected.length === 1">
|
||||||
|
Mark '{{ selected[0].product.name }}' as
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
Mark {{ selected.length }} items as
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="button-group">
|
||||||
|
<button @click="markFound">
|
||||||
|
<img src="@/assets/house-check.svg" /><br />
|
||||||
|
Found
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="markPurchased">
|
||||||
|
<img src="@/assets/shopping-cart.svg" /><br />
|
||||||
|
Purchased
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="selected = []">
|
||||||
|
<img src="@/assets/close.svg" /><br />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
|
.full-shopping-list {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.full-shopping-list li {
|
.full-shopping-list li {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
|
||||||
border: 1px solid #ccc;
|
outline: 1px solid #ccc;
|
||||||
border-radius: 0.5em;
|
border-radius: 0.5em;
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.full-shopping-list {
|
.full-shopping-list li.selectable {
|
||||||
padding: 0;
|
cursor: pointer;
|
||||||
|
outline: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-shopping-list li.selected {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
outline-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-buttons {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 1ex;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-buttons p {
|
||||||
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
/* Display as vertical fixed to the bottom of the screen */
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1em;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button img {
|
||||||
|
width: 2em;
|
||||||
|
height: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -39,7 +119,7 @@
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
import data from '@/data.js'
|
import data from '@/data.js'
|
||||||
import { toProductsModel, groupingToIngredients } 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'
|
||||||
|
|
@ -60,7 +140,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: [], productsToShow: []}
|
return { from, to, availableMeals: [], shoppingList: null, includedMeals: [], productGroupsToPurchase: [], purchasedGroups: [], selected: [], showPurchased: false }
|
||||||
},
|
},
|
||||||
async beforeMount() {
|
async beforeMount() {
|
||||||
const allMeals = await data.getUpcomingMeals(this.from, this.to);
|
const allMeals = await data.getUpcomingMeals(this.from, this.to);
|
||||||
|
|
@ -72,7 +152,6 @@ export default {
|
||||||
handler: 'updateLists',
|
handler: 'updateLists',
|
||||||
deep: true
|
deep: true
|
||||||
},
|
},
|
||||||
stockTaking: 'updateLists'
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async updateLists() {
|
async updateLists() {
|
||||||
|
|
@ -80,8 +159,10 @@ 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);
|
|
||||||
this.productsToShow = this.stockTaking ? this.listByProduct : this.listByProduct.filter(p => !p.fullyFound);
|
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);
|
const request = await data.requestMeal(meal.id);
|
||||||
|
|
@ -91,23 +172,33 @@ export default {
|
||||||
await data.unrequestMeal(meal.id);
|
await data.unrequestMeal(meal.id);
|
||||||
this.shoppingList.requests = this.shoppingList.requests.filter(r => r.meal?.id !== meal.id);
|
this.shoppingList.requests = this.shoppingList.requests.filter(r => r.meal?.id !== meal.id);
|
||||||
},
|
},
|
||||||
async markFound(productGrouping) {
|
async markFound() {
|
||||||
const result = await data.markFound(groupingToIngredients(productGrouping));
|
|
||||||
|
|
||||||
// Update shoppingList.result from result.created and result.removed
|
|
||||||
this.shoppingList.results = this.shoppingList.results.filter(r => !result.removed.some(removed => removed.id === r.id));
|
|
||||||
this.shoppingList.results.push(...result.created);
|
|
||||||
},
|
|
||||||
async markNotFound(item) {
|
|
||||||
const deletedRequests = await data.markNotFound(item.product.id);
|
|
||||||
this.shoppingList.results = this.shoppingList.results.filter(r => !deletedRequests.some(deleted => deleted.id === r.id));
|
|
||||||
},
|
},
|
||||||
async markPurchased() {
|
async markPurchased() {
|
||||||
const result = await data.markCurrentShoppingListPurchased();
|
const results = this.selected.map(item => item.requested.map(r => ({
|
||||||
this.$router.push({ name: 'PurchasedShoppingListPage', params: { id: result.id } });
|
product_id: r.ingredient.product_id,
|
||||||
|
product: r.ingredient.product,
|
||||||
|
quantity: r.ingredient.quantity,
|
||||||
|
unit: r.ingredient.unit,
|
||||||
|
list_id: -1,
|
||||||
|
}))).flat();
|
||||||
|
|
||||||
|
const requestedSources = this.selected.map(item => item.requested).flat();
|
||||||
|
const completedRequests = getCompletedRequests(this.shoppingList, results);
|
||||||
|
const servicedRequests = sourcesToRequests(requestedSources);
|
||||||
|
|
||||||
|
const shoppingList = await data.purchaseItems('', servicedRequests, results, completedRequests);
|
||||||
|
this.$router.push(`/shopping/${shoppingList.id}`);
|
||||||
},
|
},
|
||||||
onCheckboxChange(event) {
|
toggleSelect(item) {
|
||||||
this.$router.push({ query: { stockTaking: event.target.checked ? 'true' : undefined } });
|
const index = this.selected.findIndex(i => i === item);
|
||||||
|
if (index === -1)
|
||||||
|
this.selected.push(item);
|
||||||
|
else
|
||||||
|
this.selected.splice(index, 1);
|
||||||
|
},
|
||||||
|
isSelected(item) {
|
||||||
|
return this.selected.some(i => i === item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<h3>Purchased {{ shoppingList?.purchased_date?.toLocaleDateString({ year: 'numeric', month: '2-digit', day: '2-digit' }) || 'Loading...' }}</h3>
|
<h3>Purchased {{ shoppingList?.purchased_date?.toLocaleDateString({ year: 'numeric', month: '2-digit', day: '2-digit' }) || '' }}</h3>
|
||||||
|
|
||||||
<h4>Included Meals</h4>
|
<h4>Included Meals</h4>
|
||||||
<meal-selection-list :checked="includedMeals" :meals="includedMeals" />
|
<meal-selection-list :checked="includedMeals" :meals="includedMeals" />
|
||||||
|
|
||||||
<ul class="full-shopping-list">
|
<ul class="full-shopping-list">
|
||||||
<li v-for="item in productsToShow" :key="item.id">
|
<li v-for="item in listByProduct" :key="item.id">
|
||||||
<shopping-list-item :product="item.product" :sources="item.sources" :totals="item.totals" :fully-found="item.fullyFound" :found-date="item.foundDate" />
|
<shopping-list-item :productGrouping="item" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
import data from '@/data.js'
|
import data from '@/data.js'
|
||||||
import { toProductsModel } 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'
|
||||||
|
|
@ -51,8 +51,7 @@ export default {
|
||||||
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.includedMeals = this.shoppingList.requests.map(r => r.meal).filter(m => m);
|
||||||
this.listByProduct = toProductsModel(this.shoppingList);
|
this.listByProduct = purchasedToProductModel(this.shoppingList);
|
||||||
this.productsToShow = this.stockTaking ? this.listByProduct : this.listByProduct.filter(p => !p.fullyFound);
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,38 @@
|
||||||
|
|
||||||
<!-- 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="product.img_small" 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><a :href="product.link">{{ product.name }}</a></strong>,
|
<strong><a :href="productGrouping.product.link">{{ productGrouping.product.name }}</a></strong>,
|
||||||
<small>
|
<small>
|
||||||
<span v-for="(total, index) in totals" :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 found" v-if="fullyFound && foundDate">✓ {{ getFriendlyDate(foundDate) }}</span>
|
<span class="found-marker partial" v-if="productGrouping.purchased.length > 0">✓ {{ getFriendlyDate(lastPurchased) }}</span>
|
||||||
<span class="found-marker partial" v-else-if="foundDate">? {{ getFriendlyDate(foundDate) }}</span>
|
|
||||||
</small>
|
</small>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="sources">
|
<p class="sources" v-if="productGrouping.requested.length > 0">
|
||||||
<span v-for="(source, index) in sources" :key="source.id">
|
<strong>Need: </strong>
|
||||||
|
<span v-for="(source, index) in productGrouping.requested" :key="source.id">
|
||||||
|
<span v-if="index">, and </span>
|
||||||
|
<span v-if="source.person">
|
||||||
|
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for {{ source.person.name }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="source.recipe">
|
||||||
|
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }}
|
||||||
|
in <router-link :to="`/recipes/${ source.recipe.id }/`">{{ source.recipe.name }}</router-link>
|
||||||
|
for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
|
||||||
|
</span>
|
||||||
|
<span v-else-if="source.meal">
|
||||||
|
{{ source.ingredient.line }} for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="productGrouping.purchased.length > 0">
|
||||||
|
<strong>Already found or purchased: </strong>
|
||||||
|
<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 }}
|
||||||
|
|
@ -91,14 +108,29 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
|
import { calculateTotals } from '@/units.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ShoppingListItem',
|
name: 'ShoppingListItem',
|
||||||
props: ['product', 'sources', 'totals', 'foundDate', 'fullyFound'],
|
props: ['productGrouping' ],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
expanded: false,
|
expanded: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
remainingRequired() {
|
||||||
|
return calculateTotals(this.productGrouping.requested.map(r => r.ingredient));
|
||||||
|
},
|
||||||
|
expectedExisting() {
|
||||||
|
return calculateTotals(this.productGrouping.purchased.map(r => r.ingredient));
|
||||||
|
},
|
||||||
|
lastPurchased() {
|
||||||
|
return this.productGrouping.purchased.reduce((latest, purchase) => {
|
||||||
|
return latest > purchase.date ? latest : purchase.date;
|
||||||
|
}, new Date(0));
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getFriendlyDate(date) {
|
getFriendlyDate(date) {
|
||||||
if (!date)
|
if (!date)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import units from '@/units.js';
|
|
||||||
|
|
||||||
function scaleFactor(mealRecipe) {
|
function scaleFactor(mealRecipe) {
|
||||||
const numRecipe = mealRecipe.recipe.serves;
|
const numRecipe = mealRecipe.recipe.serves;
|
||||||
const numRequested = mealRecipe.servings;
|
const numRequested = mealRecipe.servings;
|
||||||
|
|
@ -38,57 +36,6 @@ export function requestsToSources(requests) {
|
||||||
return sources;
|
return sources;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeMealFromShoppingListSources(sources, meal) {
|
|
||||||
return sources.filter(source => source.meal?.id !== meal.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function groupingToIngredients(grouping) {
|
|
||||||
const product = grouping.product;
|
|
||||||
const totals = [];
|
|
||||||
for (const total of grouping.totals) {
|
|
||||||
totals.push({ id: -1, line: `${total.quantity} ${total.unit} of ${product.name}`, preparation: '', name: product.name, product, ...total });
|
|
||||||
}
|
|
||||||
return totals;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateTotals(sources) {
|
|
||||||
let byBaseUnit = {};
|
|
||||||
for (const { quantity, unit } of sources) {
|
|
||||||
const conversion = units.getConversionFactor(unit) ?? { unit, factor: 1 };
|
|
||||||
|
|
||||||
if (!byBaseUnit[conversion.unit]) {
|
|
||||||
byBaseUnit[conversion.unit] = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!byBaseUnit[conversion.unit][unit]) {
|
|
||||||
byBaseUnit[conversion.unit][unit] = { quantity, factor: conversion.factor };
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
byBaseUnit[conversion.unit][unit].quantity += quantity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totals = {};
|
|
||||||
for (const baseUnit in byBaseUnit) {
|
|
||||||
if (Object.keys(byBaseUnit[baseUnit]).length === 1) {
|
|
||||||
const unit = Object.keys(byBaseUnit[baseUnit])[0];
|
|
||||||
const { quantity, } = byBaseUnit[baseUnit][unit];
|
|
||||||
totals[unit] = quantity;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
let total = 0;
|
|
||||||
for (const unit in byBaseUnit[baseUnit]) {
|
|
||||||
const { quantity, factor } = byBaseUnit[baseUnit][unit];
|
|
||||||
total += quantity / factor;
|
|
||||||
}
|
|
||||||
|
|
||||||
totals[baseUnit] = total;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return totals;
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupBy(groups, keyFn) {
|
function groupBy(groups, keyFn) {
|
||||||
const grouped = {};
|
const grouped = {};
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
|
|
@ -103,62 +50,102 @@ function groupBy(groups, keyFn) {
|
||||||
return grouped;
|
return grouped;
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertToBaseUnits(total) {
|
function resultsToSources(shop) {
|
||||||
const baseUnits = {};
|
return shop.results.map(result => ({ shop, ingredient: { product: result.product, unit: result.unit, quantity: result.quantity, }, }));
|
||||||
for (const unit in total) {
|
|
||||||
const conversion = units.getConversionFactor(unit) ?? { unit, factor: 1 };
|
|
||||||
baseUnits[conversion.unit] = (baseUnits[conversion.unit] ?? 0) + total[unit] / conversion.factor;
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseUnits;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRemainingIngredients(requiredTotals, foundTotals) {
|
function getOutstandingGroupedByMeal(currentShoppingList) {
|
||||||
requiredTotals = convertToBaseUnits(requiredTotals);
|
// { meal_id: { purchased: [ sources, ], required: [ sources, ], }, }
|
||||||
foundTotals = convertToBaseUnits(foundTotals);
|
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], };
|
||||||
|
}
|
||||||
|
|
||||||
const remaining = {};
|
for (const shop of currentShoppingList.overlapping_previous_shops) {
|
||||||
for (const unit in requiredTotals) {
|
const shopResults = groupBy(resultsToSources(shop), source => source.ingredient.product.id);
|
||||||
const required = requiredTotals[unit];
|
const purchasedSources = requestsToSources(shop.requests)
|
||||||
const found = foundTotals[unit] ?? 0;
|
.filter(source => source.meal && requestedMeals[source.meal.id])
|
||||||
const remainingQuantity = required - found;
|
.filter(source => source.ingredient.product.id in shopResults);
|
||||||
if (remainingQuantity > 0) {
|
|
||||||
remaining[unit] = remainingQuantity;
|
for (const source of purchasedSources) {
|
||||||
|
requestedMeals[source.meal.id].purchased.push(source);
|
||||||
|
requestedMeals[source.meal.id].required = requestedMeals[source.meal.id].required.filter(required => required.ingredient.product.id !== source.ingredient.product.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return remaining;
|
return requestedMeals;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toProductsModel(shoppingList) {
|
export function getCompletedRequests(currentShoppingList, results) {
|
||||||
const flattenedIngredients = requestsToSources(shoppingList.requests);
|
const newShoppingList = { requests: currentShoppingList.requests, results };
|
||||||
const foundIngredientsByProduct = groupBy(shoppingList.results.filter(f => f.found_date), found => found.product_id);
|
const fakeCurrentShoppingList = { requests: currentShoppingList.requests, overlapping_previous_shops: [ newShoppingList, ...currentShoppingList.overlapping_previous_shops ], };
|
||||||
const requiredIngredientsByProduct = groupBy(flattenedIngredients, source => source.ingredient?.product?.id ?? "no-product");
|
const requestedMealResults = Object.values(getOutstandingGroupedByMeal(fakeCurrentShoppingList));
|
||||||
|
|
||||||
const results = [];
|
const completedMeals = new Set(requestedMealResults.filter(mealGroup => mealGroup.required.length === 0).map(mealGroup => mealGroup.id));
|
||||||
for (const product_id in requiredIngredientsByProduct) {
|
const purchasedProducts = new Set(results.map(result => result.product.id));
|
||||||
const sources = requiredIngredientsByProduct[product_id];
|
const completedSources = currentShoppingList.requests.filter(request => {
|
||||||
const requiredIngredients = sources.map(source => source.ingredient);
|
if (request.meal) {
|
||||||
|
return completedMeals.has(request.meal.id);
|
||||||
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({
|
return purchasedProducts.has(request.ingredient.product.id);
|
||||||
product: requiredIngredients[0].product,
|
|
||||||
totals: Object.entries(requiredTotals).map(([unit, quantity]) => ({ unit, quantity, })),
|
|
||||||
fullyFound, foundDate, sources,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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: [], };
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
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;
|
||||||
}
|
}
|
||||||
58
src/data.js
58
src/data.js
|
|
@ -2,6 +2,7 @@ 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 }) },
|
||||||
ShoppingList: { fields: [ "created_date", "purchased_date" ], dependants: l => ({ ShoppingListResult: l.results, ShoppingListRequest: l.requests }) },
|
ShoppingList: { fields: [ "created_date", "purchased_date" ], dependants: l => ({ ShoppingListResult: l.results, ShoppingListRequest: l.requests }) },
|
||||||
ShoppingListResult: { fields: [ "found_date", "created_date" ], },
|
ShoppingListResult: { fields: [ "found_date", "created_date" ], },
|
||||||
ShoppingListRequest: { fields: [ "created_date" ], dependants: r => ({ Meal: r.meal }) },
|
ShoppingListRequest: { fields: [ "created_date" ], dependants: r => ({ Meal: r.meal }) },
|
||||||
|
|
@ -20,11 +21,13 @@ const fixDates = (obj, type) => {
|
||||||
const toFix = datesToFix[type];
|
const toFix = datesToFix[type];
|
||||||
if (!toFix) return;
|
if (!toFix) return;
|
||||||
|
|
||||||
|
if (toFix.fields) {
|
||||||
for (const field of toFix.fields) {
|
for (const field of toFix.fields) {
|
||||||
if (obj[field]) {
|
if (obj[field]) {
|
||||||
obj[field] = new Date(obj[field]);
|
obj[field] = new Date(obj[field]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (toFix.dependants) {
|
if (toFix.dependants) {
|
||||||
for (const [key, value] of Object.entries(toFix.dependants(obj))) {
|
for (const [key, value] of Object.entries(toFix.dependants(obj))) {
|
||||||
|
|
@ -239,13 +242,28 @@ export default {
|
||||||
},
|
},
|
||||||
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();
|
||||||
|
fixDates(lst, "CurrentShoppingList");
|
||||||
|
|
||||||
|
return lst;
|
||||||
|
},
|
||||||
|
async purchaseItems(storeName, requests, results, completedRequests) {
|
||||||
|
const response = await fetch(BASE_URL + "/shopping/", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ requests, results, store_name: storeName, completed_requests: completedRequests }),
|
||||||
|
});
|
||||||
|
|
||||||
const lst = await response.json();
|
const lst = await response.json();
|
||||||
fixDates(lst, "ShoppingList");
|
fixDates(lst, "ShoppingList");
|
||||||
|
|
||||||
return lst;
|
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", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -268,44 +286,6 @@ export default {
|
||||||
const requests = await response.json();
|
const requests = await response.json();
|
||||||
fixDates(requests, "ShoppingListRequest");
|
fixDates(requests, "ShoppingListRequest");
|
||||||
},
|
},
|
||||||
async markFound(ingredients) {
|
|
||||||
const response = await fetch(BASE_URL + "/shopping/current/found", {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify(ingredients),
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await response.json();
|
|
||||||
fixDates(results.created, "ShoppingListResult");
|
|
||||||
fixDates(results.removed, "ShoppingListResult");
|
|
||||||
|
|
||||||
return results;
|
|
||||||
},
|
|
||||||
async markNotFound(product_id) {
|
|
||||||
const response = await fetch(BASE_URL + `/shopping/current/found/${product_id}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
const removedResults = await response.json();
|
|
||||||
fixDates(removedResults, "ShoppingListResult");
|
|
||||||
|
|
||||||
return removedResults;
|
|
||||||
},
|
|
||||||
async markCurrentShoppingListPurchased() {
|
|
||||||
const response = await fetch(BASE_URL + "/shopping/current/purchased", {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
const lst = await response.json();
|
|
||||||
fixDates(lst, "ShoppingList");
|
|
||||||
|
|
||||||
return lst;
|
|
||||||
},
|
|
||||||
async getPersonsInHome() {
|
async getPersonsInHome() {
|
||||||
const response = await fetch(BASE_URL + "/persons");
|
const response = await fetch(BASE_URL + "/persons");
|
||||||
return await response.json();
|
return await response.json();
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ const routes = [
|
||||||
{ path: '/mealplan', component: MealPlanPage },
|
{ path: '/mealplan', component: MealPlanPage },
|
||||||
{ path: '/login', component: LoginPage },
|
{ path: '/login', component: LoginPage },
|
||||||
{ path: '/shopping', component: MyShoppingPage },
|
{ path: '/shopping', component: MyShoppingPage },
|
||||||
{ path: '/shopping/current', component: CurrentShoppingListPage, props: route => ({ stockTaking: !!route.query.stockTaking }) },
|
{ path: '/shopping/current', component: CurrentShoppingListPage },
|
||||||
{ path: '/shopping/:id', component: PurchasedShoppingListPage, props : true },
|
{ path: '/shopping/:id', component: PurchasedShoppingListPage, props : true },
|
||||||
{ path: '/recipes', component: RecipesPage },
|
{ path: '/recipes', component: RecipesPage },
|
||||||
{ path: '/recipes/add', component: EditRecipePage },
|
{ path: '/recipes/add', component: EditRecipePage },
|
||||||
|
|
|
||||||
21
src/units.js
21
src/units.js
|
|
@ -95,8 +95,7 @@ function getBaseUnit(unit) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export function getConversionFactor(unit) {
|
||||||
getConversionFactor(unit) {
|
|
||||||
if (unit in equivalentUnits) {
|
if (unit in equivalentUnits) {
|
||||||
return { unit, factor: 1 };
|
return { unit, factor: 1 };
|
||||||
}
|
}
|
||||||
|
|
@ -123,5 +122,21 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
}
|
||||||
|
|
||||||
|
export function calculateTotals(quantityList) {
|
||||||
|
const totals = {};
|
||||||
|
for (const quantity of quantityList) {
|
||||||
|
const baseUnit = getConversionFactor(quantity.unit) ?? { unit: quantity.unit, factor: 1 };
|
||||||
|
const factor = baseUnit.factor;
|
||||||
|
const unit = baseUnit.unit;
|
||||||
|
|
||||||
|
if (!totals[unit]) {
|
||||||
|
totals[unit] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
totals[unit] += quantity.quantity / factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(totals).map(unit => ({ unit, quantity: totals[unit] }));
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue