Refactor #6.2 autoformat

This commit is contained in:
jableader 2025-10-18 13:08:22 +11:00
parent 28ad3a16ff
commit f0a8adbd77
37 changed files with 2280 additions and 2007 deletions

View file

@ -1,24 +1,29 @@
# doof-front # doof-front
## Project setup ## Project setup
``` ```
npm install npm install
``` ```
### Compiles and hot-reloads for development ### Compiles and hot-reloads for development
``` ```
npm run serve npm run serve
``` ```
### Compiles and minifies for production ### Compiles and minifies for production
``` ```
npm run build npm run build
``` ```
### Lints and fixes files ### Lints and fixes files
``` ```
npm run lint npm run lint
``` ```
### Customize configuration ### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/). See [Configuration Reference](https://cli.vuejs.org/config/).

View file

@ -1,5 +1,3 @@
module.exports = { module.exports = {
presets: [ presets: ['@vue/cli-plugin-babel/preset'],
'@vue/cli-plugin-babel/preset'
]
} }

View file

@ -5,15 +5,8 @@
"baseUrl": "./", "baseUrl": "./",
"moduleResolution": "node", "moduleResolution": "node",
"paths": { "paths": {
"@/*": [ "@/*": ["src/*"]
"src/*"
]
}, },
"lib": [ "lib": ["esnext", "dom", "dom.iterable", "scripthost"]
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
} }
} }

View file

@ -1,15 +1,18 @@
<!DOCTYPE html> <!doctype html>
<html lang=""> <html lang="">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<title><%= htmlWebpackPlugin.options.title %></title> <title><%= htmlWebpackPlugin.options.title %></title>
</head> </head>
<body> <body>
<noscript> <noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> <strong
>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without
JavaScript enabled. Please enable it to continue.</strong
>
</noscript> </noscript>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected --> <!-- built files will be auto injected -->

View file

@ -5,6 +5,7 @@ This document outlines a pragmatic, step-by-step refactor plan to improve struct
Last updated: 2025-10-18 Last updated: 2025-10-18
## Goals ## Goals
- Separate concerns (routing, HTTP/API, mapping/normalization, UI logic) - Separate concerns (routing, HTTP/API, mapping/normalization, UI logic)
- Improve readability and testability - Improve readability and testability
- Establish light-weight standards (naming, lint/format) without blocking development - Establish light-weight standards (naming, lint/format) without blocking development
@ -13,6 +14,7 @@ Last updated: 2025-10-18
## Phases and Steps ## Phases and Steps
### Phase 1 — Routing and Auth (Foundational) ### Phase 1 — Routing and Auth (Foundational)
- [x] Extract router into `src/router/index.js` with named routes - [x] Extract router into `src/router/index.js` with named routes
- [x] Add route meta `requiresAuth` and a global auth guard - [x] Add route meta `requiresAuth` and a global auth guard
- [x] Remove auth-redirect from `App.vue` (handled by guard instead) - [x] Remove auth-redirect from `App.vue` (handled by guard instead)
@ -21,6 +23,7 @@ Last updated: 2025-10-18
Outcome: Routing logic is centralized and testable; pages redirect consistently based on auth. Outcome: Routing logic is centralized and testable; pages redirect consistently based on auth.
### Phase 2 — API Layer Split (Incremental) ### Phase 2 — API Layer Split (Incremental)
- [x] Add `src/api/http.js` wrapper for JSON fetch with error handling and env-based base URL - [x] Add `src/api/http.js` wrapper for JSON fetch with error handling and env-based base URL
- [x] Add `src/api/mappers/mealMapper.js` to normalize Meal data (dates) - [x] Add `src/api/mappers/mealMapper.js` to normalize Meal data (dates)
- [x] Add `src/api/meals.js` and migrate MealPlan API calls (get upcoming, mark consumed, delete) - [x] Add `src/api/meals.js` and migrate MealPlan API calls (get upcoming, mark consumed, delete)
@ -31,6 +34,7 @@ Outcome: Routing logic is centralized and testable; pages redirect consistently
Outcome: Feature modules call cohesive services; logic for mapping/normalization is isolated and testable. Outcome: Feature modules call cohesive services; logic for mapping/normalization is isolated and testable.
### Phase 3 — Composables (UI-Facing Logic) ### Phase 3 — Composables (UI-Facing Logic)
- [x] Add `src/composables/useAuth.js` (user ref, ensureAuth) - [x] Add `src/composables/useAuth.js` (user ref, ensureAuth)
- [x] Add `src/composables/useMeals.js` (fetch and mutate meals) - [x] Add `src/composables/useMeals.js` (fetch and mutate meals)
- [x] Refactor pages to use composables and `<script setup>` where appropriate - [x] Refactor pages to use composables and `<script setup>` where appropriate
@ -40,6 +44,7 @@ Outcome: Feature modules call cohesive services; logic for mapping/normalization
Outcome: Components get smaller and easier to read; business logic is reusable. Outcome: Components get smaller and easier to read; business logic is reusable.
### Phase 4 — Tooling and Standards ### Phase 4 — Tooling and Standards
- [x] Add Prettier config and .editorconfig; wire Prettier with ESLint - [x] Add Prettier config and .editorconfig; wire Prettier with ESLint
- [ ] Upgrade ESLint (if/when convenient) and align with Vue 3 rules - [ ] Upgrade ESLint (if/when convenient) and align with Vue 3 rules
- [x] Ensure Volar is used (dev environment) for Vue 3 type intelligence - [x] Ensure Volar is used (dev environment) for Vue 3 type intelligence
@ -49,6 +54,7 @@ Outcome: Components get smaller and easier to read; business logic is reusable.
Outcome: Stable formatting and consistent linting across contributors. Outcome: Stable formatting and consistent linting across contributors.
### Phase 5 — Tests (Targeted) ### Phase 5 — Tests (Targeted)
- [ ] Add a unit test runner (Vitest or Jest) - [ ] Add a unit test runner (Vitest or Jest)
- [ ] Test mappers (dates/reference wiring) - [ ] Test mappers (dates/reference wiring)
- [ ] Test `units.js` conversions and totals - [ ] Test `units.js` conversions and totals
@ -56,9 +62,11 @@ Outcome: Stable formatting and consistent linting across contributors.
Outcome: Confidence in refactors and easier onboarding. Outcome: Confidence in refactors and easier onboarding.
## Environment ## Environment
- Prefer `VUE_APP_API_BASE` for backend base URL (fallback to `/api` for dev). Optionally set a devServer proxy in `vue.config.js`. - Prefer `VUE_APP_API_BASE` for backend base URL (fallback to `/api` for dev). Optionally set a devServer proxy in `vue.config.js`.
## Progress Log ## Progress Log
- 2025-10-18: - 2025-10-18:
- Extracted router with names and auth guard - Extracted router with names and auth guard
- Removed `App.vue` auth redirect - Removed `App.vue` auth redirect

View file

@ -2,13 +2,19 @@
<div> <div>
<ul class="nav"> <ul class="nav">
<li class="nav-item"> <li class="nav-item">
<router-link class="nav-link" :to="{ name: 'recipes' }" active-class="active">Recipes</router-link> <router-link class="nav-link" :to="{ name: 'recipes' }" active-class="active"
>Recipes</router-link
>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<router-link class="nav-link" :to="{ name: 'mealplan' }" active-class="active">Meal Plan</router-link> <router-link class="nav-link" :to="{ name: 'mealplan' }" active-class="active"
>Meal Plan</router-link
>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<router-link class="nav-link" :to="{ name: 'shopping' }" active-class="active">Shopping</router-link> <router-link class="nav-link" :to="{ name: 'shopping' }" active-class="active"
>Shopping</router-link
>
</li> </li>
</ul> </ul>
</div> </div>
@ -25,18 +31,17 @@ import AlertToast from './components/AlertToast.vue'
export default { export default {
name: 'App', name: 'App',
components: { components: {
'alert-toast': AlertToast 'alert-toast': AlertToast,
}, },
computed: { computed: {
currentRoute() { currentRoute() {
return this.$route.path return this.$route.path
} },
} },
} }
</script> </script>
<style> <style>
#app { #app {
font-family: Avenir, Helvetica, Arial, sans-serif; font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@ -80,7 +85,7 @@ export default {
/* Have active route use different color */ /* Have active route use different color */
.nav li:has(> a.active) { .nav li:has(> a.active) {
background-color: #4CAF50; background-color: #4caf50;
color: white; color: white;
} }
@ -88,5 +93,4 @@ export default {
max-width: 1200px; max-width: 1200px;
margin: auto; margin: auto;
} }
</style> </style>

View file

@ -1,10 +1,11 @@
const subscribers = []; const subscribers = []
export default { export default {
subscribe(callback) { subscribe(callback) {
subscribers.push(callback); subscribers.push(callback)
},
show(message) {
// { message, heading, type: ["success", "error", "info"] }
subscribers.forEach((callback) => callback(message))
}, },
show(message) { // { message, heading, type: ["success", "error", "info"] }
subscribers.forEach(callback => callback(message));
}
} }

View file

@ -6,14 +6,19 @@ async function request(path, options = {}) {
credentials: 'include', credentials: 'include',
...options, ...options,
headers: { headers: {
'Accept': 'application/json', Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers || {}), ...(options.headers || {}),
}, },
}) })
if (!resp.ok) { if (!resp.ok) {
let message = `${resp.status} ${resp.statusText}` let message = `${resp.status} ${resp.statusText}`
try { const err = await resp.json(); message = err.message || message } catch (_) { /* ignore */ } try {
const err = await resp.json()
message = err.message || message
} catch (_) {
/* ignore */
}
const error = new Error(message) const error = new Error(message)
error.status = resp.status error.status = resp.status
throw error throw error

View file

@ -5,10 +5,14 @@ function toDate(value) {
return value ? new Date(value) : value return value ? new Date(value) : value
} }
function attachItemRefs(items, { ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup }) { function attachItemRefs(
items,
{ ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup }
) {
if (!Array.isArray(items)) return if (!Array.isArray(items)) return
for (const item of items) { for (const item of items) {
if (item.ingredient_id && ingredients_lookup) item.ingredient = ingredients_lookup[item.ingredient_id] if (item.ingredient_id && ingredients_lookup)
item.ingredient = ingredients_lookup[item.ingredient_id]
if (item.meal_id && meals_lookup) item.meal = meals_lookup[item.meal_id] if (item.meal_id && meals_lookup) item.meal = meals_lookup[item.meal_id]
if (item.recipe_id && recipes_lookup) item.recipe = recipes_lookup[item.recipe_id] if (item.recipe_id && recipes_lookup) item.recipe = recipes_lookup[item.recipe_id]
if (item.list_id && shopping_list_lookup) item.list = shopping_list_lookup[item.list_id] if (item.list_id && shopping_list_lookup) item.list = shopping_list_lookup[item.list_id]

View file

@ -26,7 +26,7 @@ export async function parseRecipe(url) {
} }
export async function parseIngredients(lines) { export async function parseIngredients(lines) {
const params = lines.map(line => `ingredients=${encodeURIComponent(line)}`).join('&') const params = lines.map((line) => `ingredients=${encodeURIComponent(line)}`).join('&')
return http.get(`/recipes/ingredients/parse?${params}`) return http.get(`/recipes/ingredients/parse?${params}`)
} }

View file

@ -10,7 +10,7 @@
<script> <script>
export default { export default {
name: 'ActionItem', name: 'ActionItem',
props: ['title', 'image'] props: ['title', 'image'],
} }
</script> </script>
@ -33,5 +33,4 @@ img {
.card:hover { .card:hover {
background-color: #ccc; background-color: #ccc;
} }
</style> </style>

View file

@ -1,5 +1,4 @@
<template> <template>
<div v-if="showAlert" :class="['alert', type]" @click="dismiss"> <div v-if="showAlert" :class="['alert', type]" @click="dismiss">
<img v-if="icon" :src="icon" alt="Notification icon" /> <img v-if="icon" :src="icon" alt="Notification icon" />
<div class="message-container"> <div class="message-container">
@ -7,11 +6,9 @@
<p class="message">{{ message }}</p> <p class="message">{{ message }}</p>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* Display as a toast, in the bottom right corner */ /* Display as a toast, in the bottom right corner */
/* Place the icon to the left for the full height, then have the heading and message stacked to the right */ /* Place the icon to the left for the full height, then have the heading and message stacked to the right */
@ -42,24 +39,22 @@
} }
.alert.success { .alert.success {
background-color: #4CAF50; background-color: #4caf50;
} }
.alert.info { .alert.info {
background-color: #2196F3; background-color: #2196f3;
} }
</style> </style>
<script> <script>
import alert from '@/alert'
import alert from '@/alert';
const alertIcons = { const alertIcons = {
error: require('@/assets/notification-error.svg'), error: require('@/assets/notification-error.svg'),
success: require('@/assets/notification-success.svg'), success: require('@/assets/notification-success.svg'),
info: require('@/assets/notification-info.svg') info: require('@/assets/notification-info.svg'),
}; }
export default { export default {
name: 'AlertToast', name: 'AlertToast',
@ -68,31 +63,30 @@ export default {
showAlert: false, showAlert: false,
heading: '', heading: '',
message: '', message: '',
type: '' type: '',
}; }
}, },
computed: { computed: {
icon() { icon() {
return this.type && alertIcons[this.type] ? alertIcons[this.type] : null; return this.type && alertIcons[this.type] ? alertIcons[this.type] : null
} },
}, },
mounted() { mounted() {
alert.subscribe(this.show); alert.subscribe(this.show)
}, },
methods: { methods: {
show({ heading, message, type }) { show({ heading, message, type }) {
this.heading = heading; this.heading = heading
this.message = message; this.message = message
this.type = type; this.type = type
this.showAlert = true; this.showAlert = true
setTimeout(() => { setTimeout(() => {
this.showAlert = false; this.showAlert = false
}, 5000); }, 5000)
}, },
dismiss() { dismiss() {
this.showAlert = false; this.showAlert = false
},
}, },
} }
}
</script> </script>

View file

@ -1,5 +1,4 @@
<template> <template>
<div class="login"> <div class="login">
<h1>Login Page</h1> <h1>Login Page</h1>
<ul class="button-group"> <ul class="button-group">
@ -10,11 +9,9 @@
</li> </li>
</ul> </ul>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* Remove the default list styling */ /* Remove the default list styling */
ul { ul {
list-style-type: none; list-style-type: none;
@ -68,20 +65,18 @@ li:nth-child(4) > button {
background-color: #9a1f1f; background-color: #9a1f1f;
color: white; color: white;
} }
</style> </style>
<script> <script>
import { getPersonsInHome } from '@/api/persons'; import { getPersonsInHome } from '@/api/persons'
import { login } from '@/api/auth'; import { login } from '@/api/auth'
export default { export default {
name: 'LoginVue', name: 'LoginVue',
props: { props: {
redirect: { redirect: {
type: String, type: String,
default: '/' default: '/',
}, },
}, },
data() { data() {
@ -90,19 +85,18 @@ export default {
} }
}, },
async beforeMount() { async beforeMount() {
this.persons = await getPersonsInHome(); this.persons = await getPersonsInHome()
}, },
methods: { methods: {
async login(selectedPerson) { async login(selectedPerson) {
const person = await login(selectedPerson.name); const person = await login(selectedPerson.name)
if (person?.id >= 0) { if (person?.id >= 0) {
this.$router.push(this.redirect); this.$router.push(this.redirect)
return; return
} }
alert('Login failed'); alert('Login failed')
},
},
} }
}
}
</script> </script>

View file

@ -4,41 +4,55 @@
<img :src="ingredient.product?.img_small ?? require('@/assets/missing-product.svg')" /> <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 }">{{
<span class="parse-element unit" :class="{ missing: !(ingredient?.unit)}">{{ ingredient?.unit || 'unit' }}</span> ingredient?.quantity || 'qty'
}}</span>
<span class="parse-element unit" :class="{ missing: !ingredient?.unit }">{{
ingredient?.unit || 'unit'
}}</span>
<span class="parse-element helper">of</span> <span class="parse-element helper">of</span>
<span class="parse-element name" :class="{ missing: !(ingredient?.name)}">{{ ingredient?.name || 'name' }}</span>:&nbsp; <span class="parse-element name" :class="{ missing: !ingredient?.name }">{{
<span class="parse-element product-name" :class="{missing: !(ingredient?.product)}"> ingredient?.name || 'name'
<a :href="ingredient?.product?.link" v-if="ingredient?.product?.link" target=_blank> }}</span
( {{ ingredient?.product?.name }} <img src="@/assets/external-link.svg" style="width: 1em; height: 1em; vertical-align: middle; margin-left: 0.5em; margin-bottom: 0.2em;" /> ) >:&nbsp;
</a> <span class="parse-element product-name" :class="{ missing: !ingredient?.product }">
<a v-else-if="ingredient?.name" :href="searchlink" target="_blank"> <a :href="ingredient?.product?.link" v-if="ingredient?.product?.link" target="”_blank”">
(search?) ( {{ ingredient?.product?.name }}
</a> <img
<a v-else> src="@/assets/external-link.svg"
(product) style="
width: 1em;
height: 1em;
vertical-align: middle;
margin-left: 0.5em;
margin-bottom: 0.2em;
"
/>
)
</a> </a>
<a v-else-if="ingredient?.name" :href="searchlink" target="_blank"> (search?) </a>
<a v-else> (product) </a>
</span> </span>
</p> </p>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
name: 'CompactParsedIngredient', name: 'CompactParsedIngredient',
props: ['ingredient'], props: ['ingredient'],
computed: { computed: {
searchlink() { searchlink() {
return 'https://www.woolworths.com.au/shop/search/products?searchTerm=' + encodeURIComponent(this.ingredient.name); return (
'https://www.woolworths.com.au/shop/search/products?searchTerm=' +
encodeURIComponent(this.ingredient.name)
)
},
},
} }
}
}
</script> </script>
<style scoped> <style scoped>
.compact-parse-results { .compact-parse-results {
flex: left; flex: left;
display: flex; display: flex;
@ -58,7 +72,8 @@ export default {
border: solid red 1px; border: solid red 1px;
} }
.parse-element.teaser-image, .parse-element.helper { .parse-element.teaser-image,
.parse-element.helper {
border: none; border: none;
font-weight: normal; font-weight: normal;
} }
@ -89,5 +104,4 @@ export default {
.product-name { .product-name {
color: purple; color: purple;
} }
</style> </style>

View file

@ -1,5 +1,4 @@
<template> <template>
<div :class="{ editing: editing }"> <div :class="{ editing: editing }">
<button v-if="editing" @click="$emit('on-add')"> <button v-if="editing" @click="$emit('on-add')">
<img class="icon" :src="require('@/assets/add-cart.svg')" /> <br /> <img class="icon" :src="require('@/assets/add-cart.svg')" /> <br />
@ -22,7 +21,8 @@
<ingredient-line <ingredient-line
:ingredient="ingredient" :ingredient="ingredient"
@update-ingredient="updateIngredient" @update-ingredient="updateIngredient"
@update-product-link="updateProduct" /> @update-product-link="updateProduct"
/>
</p> </p>
<button @click="$emit('on-delete', ingredient)"> <button @click="$emit('on-delete', ingredient)">
<img class="icon" :src="require('@/assets/trash.svg')" /> <img class="icon" :src="require('@/assets/trash.svg')" />
@ -34,11 +34,9 @@
</li> </li>
</ul> </ul>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.icon { .icon {
width: 2em; width: 2em;
height: 2em; height: 2em;
@ -70,11 +68,9 @@ li > div {
margin: 0; margin: 0;
margin-right: 1em; margin-right: 1em;
} }
</style> </style>
<script> <script>
import { parseProduct, parseIngredients } from '@/api/recipes' import { parseProduct, parseIngredients } from '@/api/recipes'
import IngredientLine from './IngredientLine.vue' import IngredientLine from './IngredientLine.vue'
import CompactParsedIngredient from './CompactParsedIngredient.vue' import CompactParsedIngredient from './CompactParsedIngredient.vue'
@ -85,23 +81,22 @@ export default {
props: ['ingredients', 'editOnly'], props: ['ingredients', 'editOnly'],
data() { data() {
return { return {
editing: this.editOnly ?? false editing: this.editOnly ?? false,
} }
}, },
methods: { methods: {
async updateProduct(ingredient, product_link) { async updateProduct(ingredient, product_link) {
const product = await parseProduct(ingredient, product_link); const product = await parseProduct(ingredient, product_link)
this.$emit('on-update-ingredient', ingredient, { ...ingredient, product }); this.$emit('on-update-ingredient', ingredient, { ...ingredient, product })
}, },
async updateIngredient(ingredient, line) { async updateIngredient(ingredient, line) {
const newIngredients = await parseIngredients([line]); const newIngredients = await parseIngredients([line])
this.$emit('on-update-ingredient', ingredient, newIngredients[0]); this.$emit('on-update-ingredient', ingredient, newIngredients[0])
}, },
toggleEditing() { toggleEditing() {
this.editing = !this.editing; this.editing = !this.editing
this.$emit('on-editing', this.editing); this.$emit('on-editing', this.editing)
},
},
} }
}
}
</script> </script>

View file

@ -1,8 +1,20 @@
<template> <template>
<div class="ingredient-item"> <div class="ingredient-item">
<p> <p>
<input v-model="ingredientText" @keyup.enter="updateIngredient" @blur="updateIngredient" placeholder="Enter an ingredient" /> <input
<input v-model="productLink" v-if="ingredient.line" class="product-link-input" placeholder="Enter product link" @keyup.enter="updateProductLink" @blur="updateProductLink" /> v-model="ingredientText"
@keyup.enter="updateIngredient"
@blur="updateIngredient"
placeholder="Enter an ingredient"
/>
<input
v-model="productLink"
v-if="ingredient.line"
class="product-link-input"
placeholder="Enter product link"
@keyup.enter="updateProductLink"
@blur="updateProductLink"
/>
</p> </p>
<p v-if="ingredient.line"> <p v-if="ingredient.line">
<!-- Single line parse results --> <!-- Single line parse results -->
@ -12,7 +24,7 @@
</template> </template>
<script> <script>
import CompactParsedIngredient from './CompactParsedIngredient.vue'; import CompactParsedIngredient from './CompactParsedIngredient.vue'
export default { export default {
props: { props: {
@ -22,15 +34,15 @@ export default {
components: { CompactParsedIngredient }, components: { CompactParsedIngredient },
data() { data() {
return { return {
ingredientText: this.ingredient?.line ?? "", ingredientText: this.ingredient?.line ?? '',
productLink: this.ingredient.product?.link ?? "", productLink: this.ingredient.product?.link ?? '',
}; }
}, },
watch: { watch: {
ingredient: { ingredient: {
handler: function (newIngredient) { handler: function (newIngredient) {
this.ingredientText = newIngredient?.line ?? ""; this.ingredientText = newIngredient?.line ?? ''
this.productLink = newIngredient.product?.link ?? ""; this.productLink = newIngredient.product?.link ?? ''
}, },
deep: true, deep: true,
}, },
@ -38,18 +50,17 @@ export default {
methods: { methods: {
updateIngredient() { updateIngredient() {
if (this.ingredientText != this.ingredient.line) if (this.ingredientText != this.ingredient.line)
this.$emit('update-ingredient', this.ingredient, this.ingredientText); this.$emit('update-ingredient', this.ingredient, this.ingredientText)
}, },
updateProductLink() { updateProductLink() {
if (this.productLink && this.productLink != this.ingredient.product?.link) if (this.productLink && this.productLink != this.ingredient.product?.link)
this.$emit('update-product-link', this.ingredient, this.productLink); this.$emit('update-product-link', this.ingredient, this.productLink)
},
},
} }
}
};
</script> </script>
<style scoped> <style scoped>
input { input {
border: 0; border: 0;
font-size: larger; font-size: larger;
@ -63,6 +74,4 @@ input {
color: #777; color: #777;
margin-top: 0.5vh; margin-top: 0.5vh;
} }
</style> </style>

View file

@ -9,10 +9,7 @@
/> />
<div v-if="showDatePicker" class="date-picker-dropdown"> <div v-if="showDatePicker" class="date-picker-dropdown">
<ul> <ul>
<li <li v-for="(day, index) in days" :key="index" @mousedown="selectDate(day)">
v-for="(day, index) in days"
:key="index"
@mousedown="selectDate(day)">
{{ formatDay(day) }} {{ formatDay(day) }}
</li> </li>
</ul> </ul>
@ -32,45 +29,45 @@
return { return {
selectedDate: this.formatDay(this.date), selectedDate: this.formatDay(this.date),
showDatePicker: false, showDatePicker: false,
}; }
}, },
watch: { watch: {
date(newDate) { date(newDate) {
this.selectedDate = this.formatDay(newDate); this.selectedDate = this.formatDay(newDate)
}, },
}, },
computed: { computed: {
days() { days() {
const today = new Date(); const today = new Date()
const days = []; const days = []
for (let i = 0; i < 15; i++) { for (let i = 0; i < 15; i++) {
const date = new Date(today); const date = new Date(today)
date.setDate(today.getDate() + i); date.setDate(today.getDate() + i)
days.push(date); days.push(date)
} }
return days; return days
}, },
}, },
methods: { methods: {
formatDay(date) { formatDay(date) {
const options = { weekday: "long", day: "numeric", month: "numeric" }; const options = { weekday: 'long', day: 'numeric', month: 'numeric' }
return date.toLocaleDateString("en-AU", options); return date.toLocaleDateString('en-AU', options)
}, },
selectDate(date) { selectDate(date) {
this.selectedDate = this.formatSelectedDate(date); this.selectedDate = this.formatSelectedDate(date)
this.showDatePicker = false; this.showDatePicker = false
// Emit custom event // Emit custom event
this.$emit("date-selected", date); this.$emit('date-selected', date)
}, },
formatSelectedDate(date) { formatSelectedDate(date) {
const options = { weekday: "long", day: "numeric", month: "numeric" }; const options = { weekday: 'long', day: 'numeric', month: 'numeric' }
return date.toLocaleDateString("en-AU", options); return date.toLocaleDateString('en-AU', options)
}, },
}, },
}; }
</script> </script>
<style scoped> <style scoped>
@ -118,4 +115,3 @@
border-bottom: none; border-bottom: none;
} }
</style> </style>

View file

@ -3,9 +3,24 @@
<div class="fields"> <div class="fields">
<date-picker @date-selected="selectDate" :date="meal.suggested_date" /> <date-picker @date-selected="selectDate" :date="meal.suggested_date" />
<div class="persons-list"> <div class="persons-list">
Cooked by <person-list :people="meal.chefs" @remove-person="(p) => removePerson('chefs', p)" @add-person="(p) => addPerson('chefs', p)" /> Cooked by
for <person-list :people="meal.consumers" @remove-person="(p) => removePerson('consumers', p)" @add-person="(p) => addPerson('consumers', p)" />, <person-list
with <person-list :people="meal.cleanup" @remove-person="(p) => removePerson('cleanup', p)" @add-person="(p) => addPerson('cleanup', p)" /> on cleanup. :people="meal.chefs"
@remove-person="(p) => removePerson('chefs', p)"
@add-person="(p) => addPerson('chefs', p)"
/>
for
<person-list
:people="meal.consumers"
@remove-person="(p) => removePerson('consumers', p)"
@add-person="(p) => addPerson('consumers', p)"
/>, with
<person-list
:people="meal.cleanup"
@remove-person="(p) => removePerson('cleanup', p)"
@add-person="(p) => addPerson('cleanup', p)"
/>
on cleanup.
</div> </div>
</div> </div>
<div class="recipes"> <div class="recipes">
@ -22,8 +37,15 @@
<small><em>servings</em></small> <small><em>servings</em></small>
</p> </p>
<input type="checkbox" class="show-ingredient-checkbox" :checked="showIngredient(mealRecipe)" /> <input
<label for="show-ingredients" @click="showIngredient(mealRecipe, !showIngredient(mealRecipe))"> type="checkbox"
class="show-ingredient-checkbox"
:checked="showIngredient(mealRecipe)"
/>
<label
for="show-ingredients"
@click="showIngredient(mealRecipe, !showIngredient(mealRecipe))"
>
<img class="icon" :src="require('@/assets/show-ingredients.svg')" /> <img class="icon" :src="require('@/assets/show-ingredients.svg')" />
</label> </label>
<button class="icon-button" @click="removeRecipe(mealRecipe)"> <button class="icon-button" @click="removeRecipe(mealRecipe)">
@ -33,7 +55,11 @@
<div v-if="showIngredient(mealRecipe)"> <div v-if="showIngredient(mealRecipe)">
<ul> <ul>
<li v-for="ingredient in scaleIngredients(mealRecipe)" :key="ingredient.id" class="saved-ingredient"> <li
v-for="ingredient in scaleIngredients(mealRecipe)"
:key="ingredient.id"
class="saved-ingredient"
>
<CompactParsedIngredient :ingredient="ingredient" /> <CompactParsedIngredient :ingredient="ingredient" />
</li> </li>
</ul> </ul>
@ -49,11 +75,19 @@
</div> </div>
<div class="ingredients"> <div class="ingredients">
<h2>Sides & Additional Ingredients</h2> <h2>Sides & Additional Ingredients</h2>
<editable-ingredients-panel :ingredients="meal.extra_ingredients" @on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient" @on-editing="onEditAdditionalIngredients" /> <editable-ingredients-panel
:ingredients="meal.extra_ingredients"
@on-add="addIngredient"
@on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient"
@on-editing="onEditAdditionalIngredients"
/>
</div> </div>
<button @click="onSaveMeal">Save</button> <button @click="onSaveMeal">Save</button>
<p v-if="meal.purchase_date"><em>Purchased {{ ago(meal.purchase_date) }}</em></p> <p v-if="meal.purchase_date">
<em>Purchased {{ ago(meal.purchase_date) }}</em>
</p>
</div> </div>
</template> </template>
@ -63,20 +97,20 @@ import { useRoute, useRouter } from 'vue-router'
import { getMeal, saveMeal } from '@/composables/useMeals' import { getMeal, saveMeal } from '@/composables/useMeals'
import { getRecipe } from '@/api/recipes' import { getRecipe } from '@/api/recipes'
import { currentUser } from '@/api/auth' import { currentUser } from '@/api/auth'
import alert from '@/alert.js'; import alert from '@/alert.js'
import { ago } from '@/dateformats.js'; import { ago } from '@/dateformats.js'
import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue'; import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue'
import RecipeCard from '@/components/recipes/RecipeCard.vue'; import RecipeCard from '@/components/recipes/RecipeCard.vue'
import DatePicker from './DatePicker.vue'; import DatePicker from './DatePicker.vue'
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue'; import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue'
import PersonList from './PersonList.vue'; import PersonList from './PersonList.vue'
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue'; import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue'
function addPersonIfNotExists(list, person) { function addPersonIfNotExists(list, person) {
if (!list.find(p => p.id === person.id)) { if (!list.find((p) => p.id === person.id)) {
list.push(person); list.push(person)
} }
} }
@ -111,7 +145,11 @@ function selectDate(date) {
} }
function removeRecipe(mealRecipe) { function removeRecipe(mealRecipe) {
if (confirm(`Are you sure you want to remove ${mealRecipe.servings} servings of ${mealRecipe.recipe.name} from this meal?`)) { if (
confirm(
`Are you sure you want to remove ${mealRecipe.servings} servings of ${mealRecipe.recipe.name} from this meal?`
)
) {
meal.recipes = meal.recipes.filter((r) => r != mealRecipe) meal.recipes = meal.recipes.filter((r) => r != mealRecipe)
} }
} }
@ -187,12 +225,15 @@ async function onSaveMeal() {
return return
} }
alert.show({ heading: 'Error saving meal', message: 'An error occurred while saving the meal', type: 'error' }) alert.show({
heading: 'Error saving meal',
message: 'An error occurred while saving the meal',
type: 'error',
})
} }
</script> </script>
<style scoped> <style scoped>
img.icon { img.icon {
width: 2em; width: 2em;
height: 2em; height: 2em;
@ -245,7 +286,6 @@ li {
margin-right: 1em; margin-right: 1em;
} }
.saved-recipe button:hover { .saved-recipe button:hover {
background: #eee; background: #eee;
} }
@ -303,5 +343,4 @@ li {
.show-ingredient-checkbox:checked + label { .show-ingredient-checkbox:checked + label {
filter: invert(1); filter: invert(1);
} }
</style> </style>

View file

@ -1,7 +1,9 @@
<template> <template>
<div class="meal-card"> <div class="meal-card">
<h3>{{ mealTitle }}</h3> <h3>{{ mealTitle }}</h3>
<h4>{{ dayOfWeek }} <small>{{ date }}</small></h4> <h4>
{{ dayOfWeek }} <small>{{ date }}</small>
</h4>
<p> <p>
Cooked by Cooked by
@ -17,40 +19,36 @@
</span> </span>
<span v-if="!meal.consumers.length">somebody?</span> <span v-if="!meal.consumers.length">somebody?</span>
</p> </p>
<p v-if="meal.purchase_date"> <p v-if="meal.purchase_date">Purchased {{ ago(meal.purchase_date) }}</p>
Purchased {{ ago(meal.purchase_date) }}
</p>
</div> </div>
</template> </template>
<style> <style></style>
</style>
<script> <script>
import { ago } from '@/dateformats.js' import { ago } from '@/dateformats.js'
function englishSeperator(index, list) { function englishSeperator(index, list) {
switch (index) { switch (index) {
case list.length - 1: case list.length - 1:
return ''; return ''
case list.length - 2: case list.length - 2:
return ' and '; return ' and '
default: default:
return ', '; return ', '
} }
} }
function englishList(list) { function englishList(list) {
switch (list.length) { switch (list.length) {
case 0: case 0:
return ''; return ''
case 1: case 1:
return list[0]; return list[0]
case 2: case 2:
return `${list[0]} and ${list[1]}`; return `${list[0]} and ${list[1]}`
default: default:
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`; return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`
} }
} }
@ -59,27 +57,32 @@ export default {
props: ['meal'], props: ['meal'],
computed: { computed: {
date() { date() {
return this.meal.suggested_date.toLocaleDateString('en-au', { month: 'numeric', day: 'numeric' }) return this.meal.suggested_date.toLocaleDateString('en-au', {
month: 'numeric',
day: 'numeric',
})
}, },
dayOfWeek() { dayOfWeek() {
return this.meal.suggested_date.toLocaleDateString('en-au', { weekday: 'long' }) return this.meal.suggested_date.toLocaleDateString('en-au', { weekday: 'long' })
}, },
mealTitle() { mealTitle() {
const recipesText = englishList(this.meal.recipes.map(mealRecipe => mealRecipe.recipe.name)); const recipesText = englishList(this.meal.recipes.map((mealRecipe) => mealRecipe.recipe.name))
const ingredientsText = englishList(this.meal.extra_ingredients.map(ingredient => ingredient.name)); const ingredientsText = englishList(
this.meal.extra_ingredients.map((ingredient) => ingredient.name)
)
if (recipesText && ingredientsText) { if (recipesText && ingredientsText) {
return `${recipesText} with ${ingredientsText}`; return `${recipesText} with ${ingredientsText}`
} else if (recipesText || ingredientsText) { } else if (recipesText || ingredientsText) {
return recipesText || ingredientsText; return recipesText || ingredientsText
} else { } else {
return 'Nothing planned'; return 'Nothing planned'
}
} }
}, },
},
methods: { methods: {
englishSeperator, englishSeperator,
ago ago,
} },
} }
</script> </script>

View file

@ -3,12 +3,22 @@
<ul class="meals-list" v-if="meals.length"> <ul class="meals-list" v-if="meals.length">
<li v-for="meal in meals" :key="meal.id"> <li v-for="meal in meals" :key="meal.id">
<meal-card :meal="meal" /> <meal-card :meal="meal" />
<button class="toggle-actions" @click="selectedMeal = ((meal == selectedMeal) ? null : meal)"> <button class="toggle-actions" @click="selectedMeal = meal == selectedMeal ? null : meal">
<img :src="meal == selectedMeal ? require('@/assets/chevron-down.svg') : require('@/assets/chevron-up.svg')" /> <img
:src="
meal == selectedMeal
? require('@/assets/chevron-down.svg')
: require('@/assets/chevron-up.svg')
"
/>
</button> </button>
<ul class="actions" v-if="selectedMeal == meal"> <ul class="actions" v-if="selectedMeal == meal">
<li><router-link class="nav-link" :to="`/meals/${selectedMeal.id}`" active-class="active">Edit Meal</router-link></li> <li>
<router-link class="nav-link" :to="`/meals/${selectedMeal.id}`" active-class="active"
>Edit Meal</router-link
>
</li>
<li><a @click="markConsumed">Mark Consumed</a></li> <li><a @click="markConsumed">Mark Consumed</a></li>
<li><a @click="deleteSelectedMeal" class="button">Remove</a></li> <li><a @click="deleteSelectedMeal" class="button">Remove</a></li>
</ul> </ul>
@ -17,7 +27,11 @@
<div v-if="!meals.length"> <div v-if="!meals.length">
<em>No meals planned</em> <em>No meals planned</em>
</div> </div>
<action-item title="Plan Meal" :image="require('@/assets/plan-meal.svg')" @click="() => this.$router.push('/meals/add')" /> <action-item
title="Plan Meal"
:image="require('@/assets/plan-meal.svg')"
@click="() => this.$router.push('/meals/add')"
/>
</div> </div>
</template> </template>
@ -75,7 +89,6 @@ ul.actions {
padding-top: 2ex; padding-top: 2ex;
padding-bottom: 2ex; padding-bottom: 2ex;
} }
</style> </style>
<script setup> <script setup>
@ -84,7 +97,6 @@ import ActionItem from '@/components/ActionItem.vue'
import MealCard from '@/components/meals/MealCard.vue' import MealCard from '@/components/meals/MealCard.vue'
import { getUpcomingMeals, markMealConsumed, deleteMeal } from '@/composables/useMeals' import { getUpcomingMeals, markMealConsumed, deleteMeal } from '@/composables/useMeals'
const from = new Date() const from = new Date()
from.setTime(0) from.setTime(0)

View file

@ -1,14 +1,35 @@
<template> <template>
<span class="person-list"> <span class="person-list">
<span v-for="person in people" :key="person.id"> <span v-for="person in people" :key="person.id">
<button class="person-circle remove-person" @click="$emit('remove-person', person)" >{{ person.name }}</button> <button class="person-circle remove-person" @click="$emit('remove-person', person)">
{{ person.name }}
</button>
</span> </span>
<span> <span>
<button v-if="!isAddingPerson" class="person-circle add-person" @click="isAddingPerson = true">+</button> <button
<input v-else v-model="searchName" ref="searchNameInput" @keyup.enter="addPerson" @keyup.esc="isAddingPerson = false" @blur="isAddingPerson = false" /> v-if="!isAddingPerson"
<ul class="person-droplist" ref="persondroplist" v-if="isAddingPerson && searchResults.length"> class="person-circle add-person"
@click="isAddingPerson = true"
>
+
</button>
<input
v-else
v-model="searchName"
ref="searchNameInput"
@keyup.enter="addPerson"
@keyup.esc="isAddingPerson = false"
@blur="isAddingPerson = false"
/>
<ul
class="person-droplist"
ref="persondroplist"
v-if="isAddingPerson && searchResults.length"
>
<li v-for="person in searchResults" :key="person.id"> <li v-for="person in searchResults" :key="person.id">
<button class="person-circle add-person" @mousedown="addPerson(person)">{{ person.name }}</button> <button class="person-circle add-person" @mousedown="addPerson(person)">
{{ person.name }}
</button>
</li> </li>
</ul> </ul>
</span> </span>
@ -16,7 +37,6 @@
</template> </template>
<style scoped> <style scoped>
.person-list { .person-list {
display: inline-block; display: inline-block;
text-align: center; text-align: center;
@ -53,9 +73,10 @@
} }
/* Add a cross to the circle */ /* Add a cross to the circle */
.remove-person:hover::before, .remove-person:hover::after { .remove-person:hover::before,
.remove-person:hover::after {
pointer-events: none; pointer-events: none;
content: "X"; content: 'X';
color: white; color: white;
position: absolute; position: absolute;
top: 0; top: 0;
@ -94,11 +115,10 @@
display: inline; display: inline;
padding: 1ex 1em; padding: 1ex 1em;
} }
</style> </style>
<script> <script>
import { ref } from 'vue'; import { ref } from 'vue'
import { searchPerson } from '@/api/persons' import { searchPerson } from '@/api/persons'
export default { export default {
@ -106,8 +126,8 @@ export default {
props: { props: {
people: { people: {
type: Array, type: Array,
default: () => [] default: () => [],
} },
}, },
data() { data() {
return { return {
@ -117,55 +137,51 @@ export default {
} }
}, },
setup() { setup() {
const searchNameInput = ref(null); const searchNameInput = ref(null)
const persondroplist = ref(null); const persondroplist = ref(null)
return { searchNameInput, persondroplist }; return { searchNameInput, persondroplist }
}, },
watch: { watch: {
searchName: async function () { searchName: async function () {
await this.updateSearchResults() await this.updateSearchResults()
}, },
searchNameInput: async function () { searchNameInput: async function () {
this.searchNameInput?.focus(); this.searchNameInput?.focus()
await this.updateSearchResults() await this.updateSearchResults()
}, },
persondroplist: function () { persondroplist: function () {
if (this.persondroplist && this.searchNameInput) if (this.persondroplist && this.searchNameInput) {
{
// Align the droplist to the input field & its size // Align the droplist to the input field & its size
const inputRect = this.searchNameInput.getBoundingClientRect(); const inputRect = this.searchNameInput.getBoundingClientRect()
this.persondroplist.style.left = `${inputRect.left}px`; this.persondroplist.style.left = `${inputRect.left}px`
this.persondroplist.style.top = `${inputRect.bottom}px`; this.persondroplist.style.top = `${inputRect.bottom}px`
this.persondroplist.style.width = `${inputRect.width}px`; this.persondroplist.style.width = `${inputRect.width}px`
}
} }
}, },
},
methods: { methods: {
async updateSearchResults() { async updateSearchResults() {
const results = await searchPerson(this.searchName); const results = await searchPerson(this.searchName)
// Exclude people already in the list // Exclude people already in the list
const idSet = new Set(this.people.map(p => p.id)); const idSet = new Set(this.people.map((p) => p.id))
this.searchResults = results.filter(p => !idSet.has(p.id)); this.searchResults = results.filter((p) => !idSet.has(p.id))
}, },
addPerson(person) { addPerson(person) {
if (!person && this.searchResults.length > 0) if (!person && this.searchResults.length > 0) {
{ person = this.searchResults[0]
person = this.searchResults[0];
} }
if (person?.id >= 0 && !this.people.find(p => p.id === person.id)) if (person?.id >= 0 && !this.people.find((p) => p.id === person.id)) {
{ this.$emit('add-person', person)
this.$emit('add-person', person);
} }
this.searchName = ''; this.searchName = ''
this.searchResults = []; this.searchResults = []
this.isAddingPerson = false; this.isAddingPerson = false
}, },
removePerson(person) { removePerson(person) {
this.$emit('remove-person', person); this.$emit('remove-person', person)
},
}, },
} }
}
</script> </script>

View file

@ -20,12 +20,14 @@
<editable-ingredients-panel <editable-ingredients-panel
:ingredients="recipe.ingredients" :ingredients="recipe.ingredients"
:edit-only="true" :edit-only="true"
@on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient" @on-add="addIngredient"
@on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient"
/> />
<div> <div>
<button v-if="recipe.id" class="delete-btn" @click="deleteRecipe">Delete</button> <button v-if="recipe.id" class="delete-btn" @click="deleteRecipe">Delete</button>
<button class="submit-btn" @click="saveRecipe">{{ recipe.id ? "Save" : "Create" }}</button> <button class="submit-btn" @click="saveRecipe">{{ recipe.id ? 'Save' : 'Create' }}</button>
</div> </div>
</div> </div>
</div> </div>
@ -56,78 +58,86 @@ input.recipe-name {
} }
.recipe-link { .recipe-link {
color: #0000EE; color: #0000ee;
text-decoration: none; text-decoration: none;
} }
</style> </style>
<script> <script>
import alert from '@/alert.js' import alert from '@/alert.js'
import { getRecipe, parseRecipe, saveRecipe, deleteRecipe as deleteRecipeApi } from '@/api/recipes' import { getRecipe, parseRecipe, saveRecipe, deleteRecipe as deleteRecipeApi } from '@/api/recipes'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue' import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
export default { export default {
props: { props: {
id: { type: Number, optional: true } id: { type: Number, optional: true },
}, },
components: { EditableIngredientsPanel }, components: { EditableIngredientsPanel },
data() { data() {
return { return {
link: this.$route.query.url ?? "", link: this.$route.query.url ?? '',
parse_failed: false, parse_failed: false,
recipe: null, recipe: null,
chefs: [], chefs: [],
} }
}, },
mounted() { mounted() {
this.refreshRecipe(); this.refreshRecipe()
}, },
computed: { computed: {
image_styling() { image_styling() {
if (this.recipe?.image_urls && this.recipe.image_urls[0]) { if (this.recipe?.image_urls && this.recipe.image_urls[0]) {
const image = this.recipe.image_urls[0]; const image = this.recipe.image_urls[0]
// linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 100%), url('https://i2.wp.com/www.downshiftology.com/wp-content/uploads/2019/04/steamed-broccoli-4.jpg') center/cover no-repeat; // linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 100%), url('https://i2.wp.com/www.downshiftology.com/wp-content/uploads/2019/04/steamed-broccoli-4.jpg') center/cover no-repeat;
return {background: `linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 100%), url('${image}') center/cover no-repeat` } return {
background: `linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 100%), url('${image}') center/cover no-repeat`,
} }
return null;
} }
return null
},
}, },
methods: { methods: {
parseLink() { parseLink() {
this.$router.push({ path: '/recipes/add', query: { url: this.link } }) this.$router.push({ path: '/recipes/add', query: { url: this.link } })
this.refreshRecipe(); this.refreshRecipe()
}, },
async refreshRecipe() { async refreshRecipe() {
if (this.id >= 0) { if (this.id >= 0) {
this.recipe = await getRecipe(this.id); this.recipe = await getRecipe(this.id)
this.link = this.recipe.link; this.link = this.recipe.link
return; return
} } else if (this.link) {
else if (this.link) { this.recipe = await parseRecipe(this.link)
this.recipe = await parseRecipe(this.link); this.parse_failed = !this.recipe
this.parse_failed = !this.recipe; } else {
} this.recipe = null
else {
this.recipe = null;
} }
}, },
async updateIngredient(ingredient, newIngredient) { async updateIngredient(ingredient, newIngredient) {
this.recipe.ingredients = this.recipe.ingredients.map(i => i == ingredient ? newIngredient : i); this.recipe.ingredients = this.recipe.ingredients.map((i) =>
i == ingredient ? newIngredient : i
)
}, },
deleteIngredient(ingredient) { deleteIngredient(ingredient) {
this.recipe.ingredients = this.recipe.ingredients.filter(i => i != ingredient); this.recipe.ingredients = this.recipe.ingredients.filter((i) => i != ingredient)
}, },
async saveRecipe() { async saveRecipe() {
const recipe = await saveRecipe(this.recipe); const recipe = await saveRecipe(this.recipe)
if (recipe?.id >= 0) { if (recipe?.id >= 0) {
alert.show({ heading: 'Recipe saved', message: 'Your recipe has been saved', type: 'success' }); alert.show({
this.$router.push(`/recipes/${recipe.id}`); heading: 'Recipe saved',
return; message: 'Your recipe has been saved',
type: 'success',
})
this.$router.push(`/recipes/${recipe.id}`)
return
} }
alert.show({ heading: 'Error saving recipe', message: 'There was an error saving your recipe', type: 'error' }); alert.show({
heading: 'Error saving recipe',
message: 'There was an error saving your recipe',
type: 'error',
})
}, },
async createFromScratch() { async createFromScratch() {
this.recipe = { this.recipe = {
@ -136,18 +146,18 @@ export default {
created_by_id: -1, created_by_id: -1,
link: '', link: '',
ingredients: [], ingredients: [],
image_urls: [] image_urls: [],
} }
}, },
addIngredient() { addIngredient() {
this.recipe.ingredients = [{ line: '', product: null }, ...this.recipe.ingredients]; this.recipe.ingredients = [{ line: '', product: null }, ...this.recipe.ingredients]
}, },
async deleteRecipe() { async deleteRecipe() {
if (confirm('Are you sure you want to delete this recipe?')) { if (confirm('Are you sure you want to delete this recipe?')) {
await deleteRecipeApi(this.recipe.id); await deleteRecipeApi(this.recipe.id)
this.$router.push('/recipes'); this.$router.push('/recipes')
}
} }
}, },
},
} }
</script> </script>

View file

@ -11,12 +11,11 @@
<script> <script>
export default { export default {
name: 'RecipeCard', name: 'RecipeCard',
props: ['recipe'] props: ['recipe'],
} }
</script> </script>
<style scoped> <style scoped>
.recipe-card { .recipe-card {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -42,5 +41,4 @@ li img {
flex: 1; flex: 1;
margin: auto; margin: auto;
} }
</style> </style>

View file

@ -1,9 +1,20 @@
<template> <template>
<div class="recipe-search-box" @focusout="recipes = []"> <div class="recipe-search-box" @focusout="recipes = []">
<input type="text" v-model="searchTerm" @keyup.enter="search" @keyup.exit="clear" @focusin="search" <input
:placeholder="placeholder" /> type="text"
v-model="searchTerm"
@keyup.enter="search"
@keyup.exit="clear"
@focusin="search"
:placeholder="placeholder"
/>
<ul v-if="recipes?.length" class="dropdown"> <ul v-if="recipes?.length" class="dropdown">
<li class="recipe" v-for="recipe in recipes" :key="recipe.id" @mousedown="selectRecipe(recipe)"> <li
class="recipe"
v-for="recipe in recipes"
:key="recipe.id"
@mousedown="selectRecipe(recipe)"
>
<recipe-card :recipe="recipe" /> <recipe-card :recipe="recipe" />
</li> </li>
</ul> </ul>
@ -12,13 +23,13 @@
<script> <script>
import { searchRecipes } from '@/api/recipes' import { searchRecipes } from '@/api/recipes'
import RecipeCard from './RecipeCard.vue'; import RecipeCard from './RecipeCard.vue'
export default { export default {
name: 'RecipeSearchBox', name: 'RecipeSearchBox',
components: { RecipeCard }, components: { RecipeCard },
props: { props: {
placeholder: { type: String, default: 'Add a recipe...' } placeholder: { type: String, default: 'Add a recipe...' },
}, },
data() { data() {
return { return {
@ -29,26 +40,28 @@ export default {
}, },
watch: { watch: {
searchTerm() { searchTerm() {
const searchTerm = this.searchTerm; const searchTerm = this.searchTerm
if (searchTerm) { if (searchTerm) {
this.timeouts.push(setTimeout(() => { this.timeouts.push(
setTimeout(() => {
if (searchTerm === this.searchTerm) { if (searchTerm === this.searchTerm) {
this.search(); this.search()
}
}, 200));
} }
}, 200)
)
} }
}, },
},
methods: { methods: {
async search() { async search() {
this.recipes = await searchRecipes(this.searchTerm) ?? this.recipes; this.recipes = (await searchRecipes(this.searchTerm)) ?? this.recipes
}, },
selectRecipe(recipe) { selectRecipe(recipe) {
this.$emit('select-recipe', recipe); this.$emit('select-recipe', recipe)
this.searchTerm = ''; this.searchTerm = ''
this.recipes = []; this.recipes = []
} },
} },
} }
</script> </script>

View file

@ -1,18 +1,25 @@
<template> <template>
<div> <div>
<recipe-search-box placeholder="Search for a recipe..." @select-recipe="(r) => this.$router.push(`/recipes/${r.id}`)"/> <recipe-search-box
<action-item title="Add new Recipe" :image="require('@/assets/add-recipe.svg')" @click="() => this.$router.push('/recipes/add')" /> placeholder="Search for a recipe..."
@select-recipe="(r) => this.$router.push(`/recipes/${r.id}`)"
/>
<action-item
title="Add new Recipe"
:image="require('@/assets/add-recipe.svg')"
@click="() => this.$router.push('/recipes/add')"
/>
</div> </div>
</template> </template>
<script> <script>
import ActionItem from '@/components/ActionItem.vue' import ActionItem from '@/components/ActionItem.vue'
import RecipeSearchBox from './RecipeSearchBox.vue'; import RecipeSearchBox from './RecipeSearchBox.vue'
export default { export default {
name: 'ActionsPage', name: 'ActionsPage',
components: { components: {
ActionItem, ActionItem,
RecipeSearchBox RecipeSearchBox,
} },
} }
</script> </script>

View file

@ -2,18 +2,27 @@
<h3>Full shopping list</h3> <h3>Full shopping list</h3>
<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="group in outstandingItemGroups" :key="group.id" class="selectable" :class="{ 'selected': isSelected(group) }" @click="toggleSelect(group)"> <li
v-for="group in outstandingItemGroups"
:key="group.id"
class="selectable"
:class="{ selected: isSelected(group) }"
@click="toggleSelect(group)"
>
<shopping-list-item :shopping-list-item-group="group" /> <shopping-list-item :shopping-list-item-group="group" />
</li> </li>
</ul> </ul>
<div v-if="outstandingItemGroups.length === 0"> <div v-if="outstandingItemGroups.length === 0">
<p> <p>No items to purchase</p>
No items to purchase
</p>
</div> </div>
<div class="purchased-slider"> <div class="purchased-slider">
@ -23,11 +32,14 @@
<div v-if="showPurchased && purchasedItemGroups.length > 0"> <div v-if="showPurchased && purchasedItemGroups.length > 0">
<h4>Purchased Meals</h4> <h4>Purchased Meals</h4>
<meal-selection-list @meal-selected="mealSelected" @meal-unselected="mealUnselected" :checked="includedMeals" :meals="purchasedMeals" /> <meal-selection-list
@meal-selected="mealSelected"
@meal-unselected="mealUnselected"
:checked="includedMeals"
:meals="purchasedMeals"
/>
<h4> <h4>Purchased Items</h4>
Purchased Items
</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 purchasedItemGroups" :key="item.id">
<shopping-list-item :shopping-list-item-group="item" /> <shopping-list-item :shopping-list-item-group="item" />
@ -36,18 +48,14 @@
</div> </div>
</div> </div>
<div class="spacer" v-if="selected.length"> <div class="spacer" v-if="selected.length">&nbsp;</div>
&nbsp;
</div>
<!-- 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 ?? selected[0].name }}' as
</p> </p>
<p v-else> <p v-else>Mark {{ selected.length }} items as</p>
Mark {{ selected.length }} items as
</p>
<div class="button-group"> <div class="button-group">
<button @click="markFound"> <button @click="markFound">
@ -69,7 +77,6 @@
</template> </template>
<style scoped> <style scoped>
.full-shopping-list { .full-shopping-list {
padding: 0; padding: 0;
} }
@ -130,7 +137,6 @@ button img {
.spacer { .spacer {
height: 12em; height: 12em;
} }
</style> </style>
<script setup> <script setup>
@ -162,7 +168,9 @@ const groupsMatch = (a, b) => {
return a.product.id === b.product.id return a.product.id === b.product.id
} }
const outstandingItemGroups = computed(() => itemsToGroups(shoppingList.value?.outstanding_items ?? [])) const outstandingItemGroups = computed(() =>
itemsToGroups(shoppingList.value?.outstanding_items ?? [])
)
const purchasedItemGroups = computed(() => itemsToGroups(shoppingList.value?.purchased_items ?? [])) const purchasedItemGroups = computed(() => itemsToGroups(shoppingList.value?.purchased_items ?? []))
const purchasedMeals = computed(() => uniqueMeals(shoppingList.value?.purchased_items ?? [])) const purchasedMeals = computed(() => uniqueMeals(shoppingList.value?.purchased_items ?? []))
const availableMeals = computed(() => { const availableMeals = computed(() => {

View file

@ -1,20 +1,23 @@
<template> <template>
<ul> <ul>
<li v-for="meal in meals" :key="meal.id"> <li v-for="meal in meals" :key="meal.id">
<!-- Have a checkbox and card for each meal, show the image and name --> <!-- Have a checkbox and card for each meal, show the image and name -->
<!-- Emit meal-selected event on checked and meal-unselected on unchecked --> <!-- Emit meal-selected event on checked and meal-unselected on unchecked -->
<input type="checkbox" :id="meal.id" :checked="isChecked(meal)" @change="mealCheckChanged" :disabled="disabled" /> <input
type="checkbox"
:id="meal.id"
:checked="isChecked(meal)"
@change="mealCheckChanged"
:disabled="disabled"
/>
<label :for="meal.id" :style="getImageStyling(meal)"> <label :for="meal.id" :style="getImageStyling(meal)">
{{ formatDate(meal.suggested_date) }} {{ formatDate(meal.suggested_date) }}
</label> </label>
</li> </li>
</ul> </ul>
</template> </template>
<style scoped> <style scoped>
ul { ul {
padding: 0; padding: 0;
} }
@ -31,7 +34,7 @@ li {
} }
/* Hide the default checkbox formatting, and format the card instead */ /* Hide the default checkbox formatting, and format the card instead */
input[type="checkbox"] { input[type='checkbox'] {
display: none; display: none;
} }
@ -48,12 +51,12 @@ label {
color: #3d5447; color: #3d5447;
} }
input[type="checkbox"]:checked + label { input[type='checkbox']:checked + label {
border: 3px solid #3d5447; border: 3px solid #3d5447;
text-shadow: #ccc 0 0 0.1em; text-shadow: #ccc 0 0 0.1em;
} }
input[type="checkbox"]:disabled + label { input[type='checkbox']:disabled + label {
cursor: not-allowed; cursor: not-allowed;
} }
@ -72,20 +75,22 @@ label {
font-size: larger; font-size: larger;
font-weight: bold; font-weight: bold;
} }
</style> </style>
<script> <script>
const nth = (d) => { const nth = (d) => {
if (d > 3 && d < 21) return 'th'; if (d > 3 && d < 21) return 'th'
switch (d % 10) { switch (d % 10) {
case 1: return "st"; case 1:
case 2: return "nd"; return 'st'
case 3: return "rd"; case 2:
default: return "th"; return 'nd'
case 3:
return 'rd'
default:
return 'th'
}
} }
};
const formatDate = (date) => { const formatDate = (date) => {
return `${date.toLocaleDateString('en-AU', { weekday: 'short' })} ${date.getDate()}${nth(date.getDate())}` return `${date.toLocaleDateString('en-AU', { weekday: 'short' })} ${date.getDate()}${nth(date.getDate())}`
@ -94,9 +99,9 @@ const formatDate = (date) => {
const getUrl = (meal) => { const getUrl = (meal) => {
// First non empty value in meal.recipe/image_urls // First non empty value in meal.recipe/image_urls
for (const mr of meal.recipes) { for (const mr of meal.recipes) {
const recipe = mr.recipe; const recipe = mr.recipe
if (recipe.image_urls.length && recipe.image_urls[0]) { if (recipe.image_urls.length && recipe.image_urls[0]) {
return recipe.image_urls[0]; return recipe.image_urls[0]
} }
} }
@ -104,16 +109,16 @@ const getUrl = (meal) => {
for (const ingredient of meal.extra_ingredients) { for (const ingredient of meal.extra_ingredients) {
if (ingredient.product) { if (ingredient.product) {
if (ingredient.product.img_large) { if (ingredient.product.img_large) {
return ingredient.product.img_large; return ingredient.product.img_large
} }
if (ingredient.product.img_small) { if (ingredient.product.img_small) {
return ingredient.product.img_small; return ingredient.product.img_small
} }
} }
} }
return null; return null
} }
export default { export default {
@ -123,38 +128,39 @@ export default {
checked: Array, checked: Array,
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false default: false,
} },
}, },
methods: { methods: {
formatDate, formatDate,
getImageStyling(meal) { getImageStyling(meal) {
const imageUrl = getUrl(meal); const imageUrl = getUrl(meal)
if (!imageUrl) { if (!imageUrl) {
return null; return null
} }
const opacity = 0.7; const opacity = 0.7
return {background: `linear-gradient(to bottom, rgba(255, 255, 255, ${ opacity }) 0%, rgba(255, 255, 255, ${ opacity }) 100%), url('${imageUrl}') center/cover no-repeat`}; return {
background: `linear-gradient(to bottom, rgba(255, 255, 255, ${opacity}) 0%, rgba(255, 255, 255, ${opacity}) 100%), url('${imageUrl}') center/cover no-repeat`,
}
}, },
mealCheckChanged(event) { mealCheckChanged(event) {
const mealId = parseInt(event.target.id); const mealId = parseInt(event.target.id)
const meal = this.meals.find(m => m.id === mealId); const meal = this.meals.find((m) => m.id === mealId)
if (event.target.checked) { if (event.target.checked) {
this.$emit('meal-selected', meal); this.$emit('meal-selected', meal)
} else { } else {
this.$emit('meal-unselected', meal); this.$emit('meal-unselected', meal)
} }
}, },
isChecked(meal) { isChecked(meal) {
for (const checkedMeal of this.checked) { for (const checkedMeal of this.checked) {
if (checkedMeal.id === meal.id) { if (checkedMeal.id === meal.id) {
return true; return true
} }
} }
return false; return false
},
},
} }
}
}
</script> </script>

View file

@ -2,7 +2,13 @@
<div> <div>
<h1>My Shopping List</h1> <h1>My Shopping List</h1>
<router-link :to="`/shopping/current`">Full Shopping List</router-link> <router-link :to="`/shopping/current`">Full Shopping List</router-link>
<editable-ingredients-panel @on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient" @on-editing="onEditing" :ingredients="ingredients" /> <editable-ingredients-panel
@on-add="addIngredient"
@on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient"
@on-editing="onEditing"
:ingredients="ingredients"
/>
</div> </div>
<!-- <!--
@ -31,9 +37,7 @@
--> -->
</template> </template>
<style scoped> <style scoped></style>
</style>
<script setup> <script setup>
import { ref, onBeforeMount } from 'vue' import { ref, onBeforeMount } from 'vue'
@ -42,7 +46,6 @@ import { useAuth } from '@/composables/useAuth'
import { useShopping } from '@/composables/useShopping' import { useShopping } from '@/composables/useShopping'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue' import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
const router = useRouter() const router = useRouter()
const { loadUser } = useAuth() const { loadUser } = useAuth()
const { getMyShoppingList, saveMyShoppingList } = useShopping() const { getMyShoppingList, saveMyShoppingList } = useShopping()
@ -51,7 +54,9 @@ const person = ref(null)
const ingredients = ref([]) const ingredients = ref([])
async function updateShoppingList(save = false) { async function updateShoppingList(save = false) {
const newIngredients = save ? await saveMyShoppingList(ingredients.value) : await getMyShoppingList() const newIngredients = save
? await saveMyShoppingList(ingredients.value)
: await getMyShoppingList()
ingredients.value = newIngredients ingredients.value = newIngredients
} }
@ -64,7 +69,9 @@ function deleteIngredient(ingredient) {
} }
function updateIngredient(oldIngredient, newIngredient) { function updateIngredient(oldIngredient, newIngredient) {
ingredients.value = ingredients.value.map((source) => (source === oldIngredient ? newIngredient : source)) ingredients.value = ingredients.value.map((source) =>
source === oldIngredient ? newIngredient : source
)
} }
async function onEditing(isStartingEdit) { async function onEditing(isStartingEdit) {

View file

@ -14,7 +14,6 @@
</template> </template>
<style scoped> <style scoped>
.full-shopping-list li { .full-shopping-list li {
list-style-type: none; list-style-type: none;
@ -26,7 +25,6 @@
.full-shopping-list { .full-shopping-list {
padding: 0; padding: 0;
} }
</style> </style>
<script setup> <script setup>
@ -41,13 +39,16 @@ import ShoppingListItem from './ShoppingListItem.vue'
const route = useRoute() const route = useRoute()
const shoppingList = ref(null) const shoppingList = ref(null)
const includedMeals = computed(() => (shoppingList.value ? uniqueMeals(shoppingList.value.items) : [])) const includedMeals = computed(() =>
const listByProduct = computed(() => (shoppingList.value ? itemsToGroups(shoppingList.value.items) : [])) shoppingList.value ? uniqueMeals(shoppingList.value.items) : []
)
const listByProduct = computed(() =>
shoppingList.value ? itemsToGroups(shoppingList.value.items) : []
)
onBeforeMount(async () => { onBeforeMount(async () => {
const idParam = route.params.id const idParam = route.params.id
const id = typeof idParam === 'string' ? parseInt(idParam) : idParam const id = typeof idParam === 'string' ? parseInt(idParam) : idParam
shoppingList.value = await getShoppingList(id) shoppingList.value = await getShoppingList(id)
}) })
</script> </script>

View file

@ -1,20 +1,28 @@
<template> <template>
<!-- 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="`${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> <strong>
<a v-if="shoppingListItemGroup.product?.link" :href="shoppingListItemGroup.product?.link">{{ shoppingListItemGroup.product?.name }}</a> <a
<span v-else>{{ shoppingListItemGroup.name }}</span> v-if="shoppingListItemGroup.product?.link"
</strong>, :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 remainingRequiredTotals" :key="total.id">
<span v-if="index">, </span> <span v-if="index">, </span>
<span>{{ formatQuantity(total.quantity) }}&nbsp;{{ total.unit }}</span> <span>{{ formatQuantity(total.quantity) }}&nbsp;{{ 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="purchased.length > 0"
> {{ getFriendlyDate(lastPurchased) }}</span
>
</small> </small>
</h3> </h3>
<p class="sources" v-if="required.length > 0"> <p class="sources" v-if="required.length > 0">
@ -22,15 +30,32 @@
<span v-for="(source, index) in required" :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) }}&nbsp;{{ source.ingredient.unit }} for {{ source.person.name }} {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} for
{{ source.person.name }}
</span> </span>
<span v-else-if="source.recipe"> <span v-else-if="source.recipe">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in
in <router-link :to="`/recipes/${ source.recipe.id }/`">{{ source.recipe.name }}</router-link> <router-link :to="`/recipes/${source.recipe.id}/`">{{
for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link> 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>
<span v-else-if="source.meal"> <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> {{ 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>
</span> </span>
</p> </p>
@ -39,25 +64,40 @@
<span v-for="(source, index) in 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) }}&nbsp;{{ source.ingredient.unit }} for {{ source.person.name }} {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} for
{{ source.person.name }}
</span> </span>
<span v-else-if="source.recipe"> <span v-else-if="source.recipe">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in
in <router-link :to="`/recipes/${ source.recipe.id }/`">{{ source.recipe.name }}</router-link> <router-link :to="`/recipes/${source.recipe.id}/`">{{
for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link> 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>
<span v-else-if="source.meal"> <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> {{ 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>
</span> </span>
</p> </p>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* Show the product image to the left, then the product name and size to the right */ /* Show the product image to the left, then the product name and size to the right */
.shopping-list-item { .shopping-list-item {
@ -106,13 +146,11 @@
.found-marker.partial { .found-marker.partial {
background-color: darkgoldenrod; background-color: darkgoldenrod;
} }
</style> </style>
<script> <script>
import { ago } from '@/dateformats.js'
import { ago } from '@/dateformats.js'; import { calculateTotals } from '@/units.js'
import { calculateTotals } from '@/units.js';
export default { export default {
name: 'ShoppingListItem', name: 'ShoppingListItem',
@ -124,51 +162,53 @@ export default {
}, },
computed: { computed: {
remainingRequiredTotals() { remainingRequiredTotals() {
return calculateTotals(this.shoppingListItemGroup.shoppingListItems.map(item => item.ingredient)); return calculateTotals(
this.shoppingListItemGroup.shoppingListItems.map((item) => item.ingredient)
)
}, },
expectedExistingTotals() { expectedExistingTotals() {
const purchasedNotEaten = this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id && !(item?.meal?.consumed_date)); const purchasedNotEaten = this.shoppingListItemGroup.shoppingListItems.filter(
(item) => item.list_id && !item?.meal?.consumed_date
)
return calculateTotals(purchasedNotEaten.map(item => item.ingredient)); return calculateTotals(purchasedNotEaten.map((item) => item.ingredient))
}, },
required() { required() {
return this.shoppingListItemGroup.shoppingListItems.filter(item => !item.list_id); return this.shoppingListItemGroup.shoppingListItems.filter((item) => !item.list_id)
}, },
purchased() { purchased() {
return this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id); return this.shoppingListItemGroup.shoppingListItems.filter((item) => item.list_id)
}, },
lastPurchased() { lastPurchased() {
const purchasedItems = this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id); const purchasedItems = this.shoppingListItemGroup.shoppingListItems.filter(
(item) => item.list_id
)
if (purchasedItems.length === 0) { if (purchasedItems.length === 0) {
return null; return null
} }
// Find the most recently purchased item // Find the most recently purchased item
return purchasedItems.reduce((latest, item) => { return purchasedItems.reduce((latest, item) => {
const itemDate = item?.meal?.suggested_date || item?.created_at; const itemDate = item?.meal?.suggested_date || item?.created_at
return (!latest || (itemDate && itemDate > latest)) ? itemDate : latest; return !latest || (itemDate && itemDate > latest) ? itemDate : latest
}, null); }, null)
} },
}, },
methods: { methods: {
getFriendlyDate(date) { getFriendlyDate(date) {
if (!date) if (!date) return ''
return '';
return ago(date); return ago(date)
}, },
formatQuantity(quantity) { formatQuantity(quantity) {
const log10 = Math.log10(quantity); const log10 = Math.log10(quantity)
if (log10 < 0) { if (log10 < 0) {
return quantity.toPrecision(2); return quantity.toPrecision(2)
} else if (log10 < 1) {
return quantity.toFixed(1)
} else {
return quantity.toFixed(0)
} }
else if (log10 < 1) { },
return quantity.toFixed(1); },
} }
else {
return quantity.toFixed(0);
}
}
}
}
</script> </script>

View file

@ -1,45 +1,42 @@
export function groupsToItems(groups) { export function groupsToItems(groups) {
return groups.map(group => group.shoppingListItems).flat(); return groups.map((group) => group.shoppingListItems).flat()
} }
export function uniqueMeals(shoppingListItems) { export function uniqueMeals(shoppingListItems) {
const mealsWithDuplicates = shoppingListItems const mealsWithDuplicates = shoppingListItems.map((item) => item.meal).filter((m) => m)
.map(item => item.meal)
.filter(m => m);
const mealsLookup = mealsWithDuplicates.reduce(((acc, meal) => { acc[meal.id] ??= meal; return acc; }), {}); const mealsLookup = mealsWithDuplicates.reduce((acc, meal) => {
acc[meal.id] ??= meal
return acc
}, {})
return Object.values(mealsLookup); return Object.values(mealsLookup)
} }
export function itemsToGroups(shoppingListItems) { export function itemsToGroups(shoppingListItems) {
const ingredients_by_product_id = {}; const ingredients_by_product_id = {}
const ingredients_by_name = {}; const ingredients_by_name = {}
for (const item of shoppingListItems) { for (const item of shoppingListItems) {
if (item.ingredient.product) { if (item.ingredient.product) {
let group = ingredients_by_product_id[item.ingredient.product.id]; let group = ingredients_by_product_id[item.ingredient.product.id]
if (!group) { if (!group) {
group = ingredients_by_product_id[item.ingredient.product.id] = { group = ingredients_by_product_id[item.ingredient.product.id] = {
product: item.ingredient.product, product: item.ingredient.product,
shoppingListItems: [] shoppingListItems: [],
};
} }
group.shoppingListItems.push(item);
} }
else { group.shoppingListItems.push(item)
let group = ingredients_by_name[item.ingredient.name]; } else {
let group = ingredients_by_name[item.ingredient.name]
if (!group) { if (!group) {
group = ingredients_by_name[item.ingredient.name] = { group = ingredients_by_name[item.ingredient.name] = {
name: item.ingredient.name, name: item.ingredient.name,
shoppingListItems: [] shoppingListItems: [],
};
} }
group.shoppingListItems.push(item); }
group.shoppingListItems.push(item)
} }
} }
return [ return [...Object.values(ingredients_by_product_id), ...Object.values(ingredients_by_name)]
...Object.values(ingredients_by_product_id),
...Object.values(ingredients_by_name)
]
} }

View file

@ -1,30 +1,54 @@
const BASE_URL = window.location.href.replace(/^(https?:\/\/[^/]+).*/, "$1/api"); 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: {
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 }) }, dependants: (l) => ({
ShoppingList: { fields: [ "created_date" ], dependants: l => ({ ShoppingListItem: l.items }) }, ShoppingListItem: [l.outstanding_items, l.requested_meals, l.purchased_items],
ShoppingListItem: { fields: [ "created_date" ], dependants: r => ({ Meal: r.meal, Recipe: r.recipe, Ingredient: r.ingredient, ShoppingList: r.list }) }, Meal: Object.values(l.meals_lookup),
Recipe: { fields: [ "date_created", "date_hidden" ], }, Recipe: Object.values(l.recipes_lookup),
}; Ingredient: Object.values(l.ingredients_lookup),
ShoppingList: Object.values(l.shopping_list_lookup),
}),
},
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'], dependants: (l) => ({ ShoppingListItem: l.items }) },
ShoppingListItem: {
fields: ['created_date'],
dependants: (r) => ({
Meal: r.meal,
Recipe: r.recipe,
Ingredient: r.ingredient,
ShoppingList: r.list,
}),
},
Recipe: { fields: ['date_created', 'date_hidden'] },
}
const fixDates = (obj, type) => { const fixDates = (obj, type) => {
if (!obj) return; if (!obj) return
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
for (const item of obj) { for (const item of obj) {
fixDates(item, type); fixDates(item, type)
} }
} }
const toFix = datesToFix[type]; const toFix = datesToFix[type]
if (!toFix) return; if (!toFix) return
if (toFix.fields) { 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])
} }
} }
} }
@ -33,300 +57,334 @@ const fixDates = (obj, type) => {
for (const [key, value] of Object.entries(toFix.dependants(obj))) { for (const [key, value] of Object.entries(toFix.dependants(obj))) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
for (const item of value) { for (const item of value) {
fixDates(item, key); fixDates(item, key)
} }
} } else {
else { fixDates(value, key)
fixDates(value, key);
} }
} }
} }
} }
const setPurchasedShoppingListReferences = (purchasedShoppingList) => { const setPurchasedShoppingListReferences = (purchasedShoppingList) => {
if (!purchasedShoppingList) return; if (!purchasedShoppingList) return
const { ingredients_lookup, meals_lookup, recipes_lookup, } = purchasedShoppingList; const { ingredients_lookup, meals_lookup, recipes_lookup } = purchasedShoppingList
const shopping_list_lookup = { [purchasedShoppingList.list.id]: purchasedShoppingList.list }; const shopping_list_lookup = { [purchasedShoppingList.list.id]: purchasedShoppingList.list }
setShoppingListItemReferences(purchasedShoppingList.list.items, ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup); setShoppingListItemReferences(
purchasedShoppingList.list.items,
ingredients_lookup,
meals_lookup,
recipes_lookup,
shopping_list_lookup
)
} }
const setCurrentShoppingListReferences = (currentShoppingList) => { const setCurrentShoppingListReferences = (currentShoppingList) => {
if (!currentShoppingList) return; if (!currentShoppingList) return
const { ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup } = currentShoppingList; const { ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup } =
const allShoppingListItems = [...currentShoppingList.outstanding_items, ...currentShoppingList.requested_meals, ...currentShoppingList.purchased_items]; currentShoppingList
const allShoppingListItems = [
...currentShoppingList.outstanding_items,
...currentShoppingList.requested_meals,
...currentShoppingList.purchased_items,
]
setShoppingListItemReferences(allShoppingListItems, ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup); setShoppingListItemReferences(
allShoppingListItems,
ingredients_lookup,
meals_lookup,
recipes_lookup,
shopping_list_lookup
)
} }
const setShoppingListItemReferences = (shoppingListItems, ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup) => { const setShoppingListItemReferences = (
if (!shoppingListItems) return; shoppingListItems,
ingredients_lookup,
meals_lookup,
recipes_lookup,
shopping_list_lookup
) => {
if (!shoppingListItems) return
for (const item of shoppingListItems) { for (const item of shoppingListItems) {
if (item.ingredient_id) { if (item.ingredient_id) {
item.ingredient = ingredients_lookup[item.ingredient_id]; item.ingredient = ingredients_lookup[item.ingredient_id]
} }
if (item.meal_id) { if (item.meal_id) {
item.meal = meals_lookup[item.meal_id]; item.meal = meals_lookup[item.meal_id]
} }
if (item.list_id) { if (item.list_id) {
item.list = shopping_list_lookup[item.list_id]; item.list = shopping_list_lookup[item.list_id]
} }
if (item.recipe_id) { if (item.recipe_id) {
item.recipe = recipes_lookup[item.recipe_id]; item.recipe = recipes_lookup[item.recipe_id]
} }
} }
} }
let user = null; let user = null
export default { export default {
async markMealConsumed(meal_id) { async markMealConsumed(meal_id) {
const response = await fetch(BASE_URL + `/meals/${encodeURIComponent(meal_id)}/consumed`, { const response = await fetch(BASE_URL + `/meals/${encodeURIComponent(meal_id)}/consumed`, {
method: "POST", method: 'POST',
credentials: "include", credentials: 'include',
}); })
const meal = await response.json(); const meal = await response.json()
fixDates(meal, "Meal"); fixDates(meal, 'Meal')
return meal; return meal
}, },
async getUpcomingMeals(from, to) { async getUpcomingMeals(from, to) {
const response = await fetch(BASE_URL + "/meals/upcoming?from=" + from.toISOString() + "&to=" + to.toISOString()); const response = await fetch(
const meals = await response.json(); BASE_URL + '/meals/upcoming?from=' + from.toISOString() + '&to=' + to.toISOString()
fixDates(meals, "Meal"); )
const meals = await response.json()
fixDates(meals, 'Meal')
return meals.sort((a, b) => a.suggested_date - b.suggested_date); return meals.sort((a, b) => a.suggested_date - b.suggested_date)
}, },
async getMeal(id) { async getMeal(id) {
var response = await fetch(BASE_URL + `/meals/${encodeURIComponent(id)}`); var response = await fetch(BASE_URL + `/meals/${encodeURIComponent(id)}`)
const meal = await response.json(); const meal = await response.json()
fixDates(meal, "Meal"); fixDates(meal, 'Meal')
return meal; return meal
}, },
async deleteMeal(id) { async deleteMeal(id) {
var response = await fetch(BASE_URL + `/meals/${encodeURIComponent(id)}`, { var response = await fetch(BASE_URL + `/meals/${encodeURIComponent(id)}`, {
method: "DELETE", method: 'DELETE',
}); })
return await response.json(); return await response.json()
}, },
async searchRecipes(query) { async searchRecipes(query) {
const response = await fetch(BASE_URL + "/recipes?q=" + encodeURIComponent(query)); const response = await fetch(BASE_URL + '/recipes?q=' + encodeURIComponent(query))
const recipes = await response.json(); const recipes = await response.json()
fixDates(recipes, "Recipe"); fixDates(recipes, 'Recipe')
return recipes; return recipes
}, },
async parseRecipe(url) { async parseRecipe(url) {
const response = await fetch(BASE_URL + `/recipes/parse?url=${encodeURIComponent(url)}`, { credentials: "include" }); const response = await fetch(BASE_URL + `/recipes/parse?url=${encodeURIComponent(url)}`, {
const recipe = await response.json(); credentials: 'include',
fixDates(recipe, "Recipe"); })
const recipe = await response.json()
fixDates(recipe, 'Recipe')
return recipe; return recipe
}, },
async getRecipe(id) { async getRecipe(id) {
const response = await fetch(BASE_URL + `/recipes/${encodeURIComponent(id)}`); const response = await fetch(BASE_URL + `/recipes/${encodeURIComponent(id)}`)
const recipe = await response.json(); const recipe = await response.json()
fixDates(recipe, "Recipe"); fixDates(recipe, 'Recipe')
return recipe; return recipe
}, },
async parseProduct(ingredient, url) { async parseProduct(ingredient, url) {
const body = { const body = {
url, tags: [ingredient.name, ingredient.line], url,
}; tags: [ingredient.name, ingredient.line],
const response = await fetch(BASE_URL + "/products", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body),
});
return await response.json();
},
async parseIngredients(lines) {
const params = lines.map(line => "ingredients=" + encodeURIComponent(line)).join("&");
const response = await fetch(BASE_URL + "/recipes/ingredients/parse?" + params);
return await response.json();
},
async saveRecipe(recipe) {
const response = await fetch(BASE_URL + "/recipes", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(recipe),
});
const saved = await response.json();
fixDates(saved, "Recipe");
return saved;
},
async saveMeal(meal) {
let response = null;
if (meal.id >= 0) {
response = await fetch(BASE_URL + `/meals/${encodeURIComponent(meal.id)}`, {
method: "PUT",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(meal),
});
} else {
response = await fetch(BASE_URL + "/meals", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(meal),
});
} }
const saved = await response.json(); const response = await fetch(BASE_URL + '/products', {
fixDates(saved, "Meal"); method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
return saved; return await response.json()
},
async parseIngredients(lines) {
const params = lines.map((line) => 'ingredients=' + encodeURIComponent(line)).join('&')
const response = await fetch(BASE_URL + '/recipes/ingredients/parse?' + params)
return await response.json()
},
async saveRecipe(recipe) {
const response = await fetch(BASE_URL + '/recipes', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(recipe),
})
const saved = await response.json()
fixDates(saved, 'Recipe')
return saved
},
async saveMeal(meal) {
let response = null
if (meal.id >= 0) {
response = await fetch(BASE_URL + `/meals/${encodeURIComponent(meal.id)}`, {
method: 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(meal),
})
} else {
response = await fetch(BASE_URL + '/meals', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(meal),
})
}
const saved = await response.json()
fixDates(saved, 'Meal')
return saved
}, },
async currentUser() { async currentUser() {
if (user) { if (user) {
return user; return user
} }
var cookie = decodeURIComponent(document.cookie).split(";").find(cookie => cookie.trimStart().startsWith("user_id=")); var cookie = decodeURIComponent(document.cookie)
.split(';')
.find((cookie) => cookie.trimStart().startsWith('user_id='))
if (cookie) { if (cookie) {
const response = await fetch(BASE_URL + "/auth/refresh", { const response = await fetch(BASE_URL + '/auth/refresh', {
method: "POST", method: 'POST',
credentials: "include", credentials: 'include',
}); })
if (response.ok) { if (response.ok) {
user = await response.json(); user = await response.json()
} }
} }
return user; return user
}, },
async login(username) { async login(username) {
const response = await fetch(BASE_URL + "/auth/login", { const response = await fetch(BASE_URL + '/auth/login', {
method: "POST", method: 'POST',
credentials: "include", credentials: 'include',
headers: { headers: {
"Content-Type": "application/json" 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ username }), body: JSON.stringify({ username }),
}); })
if (response.ok) { if (response.ok) {
user = await response.json(); user = await response.json()
} }
return user; return user
}, },
async deleteRecipe(id) { async deleteRecipe(id) {
var response = await fetch(BASE_URL + `/recipes/${encodeURIComponent(id)}`, { var response = await fetch(BASE_URL + `/recipes/${encodeURIComponent(id)}`, {
method: "DELETE", method: 'DELETE',
credentials: "include", credentials: 'include',
}); })
const recipe = await response.json(); const recipe = await response.json()
fixDates(recipe, "Recipe"); fixDates(recipe, 'Recipe')
return recipe; return recipe
}, },
async searchPerson(name) { async searchPerson(name) {
const response = await fetch(BASE_URL + "/persons?q=" + encodeURIComponent(name)); const response = await fetch(BASE_URL + '/persons?q=' + encodeURIComponent(name))
return await response.json(); return await response.json()
}, },
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', {
const ingredients = await response.json(); credentials: 'include',
fixDates(ingredients, 'Ingredient'); })
const ingredients = await response.json()
fixDates(ingredients, 'Ingredient')
return ingredients; 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', {
method: "POST", method: 'POST',
credentials: "include", credentials: 'include',
headers: { headers: {
"Content-Type": "application/json" 'Content-Type': 'application/json',
}, },
body: JSON.stringify(list), body: JSON.stringify(list),
}); })
const ingredients = await response.json(); const ingredients = await response.json()
fixDates(ingredients, 'Ingredient'); fixDates(ingredients, 'Ingredient')
return ingredients; 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 purchasedShoppingList = await response.json(); const purchasedShoppingList = await response.json()
fixDates(purchasedShoppingList, "PurchasedShoppingList"); fixDates(purchasedShoppingList, 'PurchasedShoppingList')
setPurchasedShoppingListReferences(purchasedShoppingList); setPurchasedShoppingListReferences(purchasedShoppingList)
return purchasedShoppingList.list; 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); setCurrentShoppingListReferences(lst)
return lst; return lst
}, },
async purchaseShoppingList(completed_requests) { 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({ items: completed_requests }), body: JSON.stringify({ items: completed_requests }),
}); })
const purchasedShoppingList = await response.json(); const purchasedShoppingList = await response.json()
fixDates(purchasedShoppingList, "PurchasedShoppingList"); fixDates(purchasedShoppingList, 'PurchasedShoppingList')
setPurchasedShoppingListReferences(purchasedShoppingList); setPurchasedShoppingListReferences(purchasedShoppingList)
return purchasedShoppingList.list; 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', {
method: "POST", method: 'POST',
credentials: "include", credentials: 'include',
headers: { headers: {
"Content-Type": "application/json" 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ meal_id }), body: JSON.stringify({ meal_id }),
}); })
const requests = await response.json(); const requests = await response.json()
fixDates(requests, "ShoppingListItem"); fixDates(requests, 'ShoppingListItem')
return requests; return requests
}, },
async unrequestMeal(meal_id) { async unrequestMeal(meal_id) {
const response = await fetch(BASE_URL + `/shopping/current/meals/${encodeURIComponent(meal_id)}`, { const response = await fetch(
method: "DELETE", BASE_URL + `/shopping/current/meals/${encodeURIComponent(meal_id)}`,
credentials: "include", {
}); method: 'DELETE',
credentials: 'include',
}
)
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to unrequest meal"); 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')
return await response.json(); return await response.json()
} },
} }

View file

@ -1,25 +1,25 @@
function plural(num, unit) { function plural(num, unit) {
num = Math.floor(num); num = Math.floor(num)
return num + " " + unit + (num === 1 ? "" : "s"); return num + ' ' + unit + (num === 1 ? '' : 's')
} }
export function ago(date) { export function ago(date) {
const now = new Date(); const now = new Date()
const diff = now - date; const diff = now - date
if (diff < 1000) { if (diff < 1000) {
return "just now"; return 'just now'
} }
if (diff < 60 * 1000) { if (diff < 60 * 1000) {
return plural(diff / 1000, "second") + " ago"; return plural(diff / 1000, 'second') + ' ago'
} }
if (diff < 60 * 60 * 1000) { if (diff < 60 * 60 * 1000) {
return plural(diff / (60 * 1000), "minute") + " ago"; return plural(diff / (60 * 1000), 'minute') + ' ago'
} }
if (diff < 24 * 60 * 60 * 1000) { if (diff < 24 * 60 * 60 * 1000) {
return plural(diff / (60 * 60 * 1000), "hour") + " ago"; return plural(diff / (60 * 60 * 1000), 'hour') + ' ago'
} }
if (diff < 7 * 24 * 60 * 60 * 1000) { if (diff < 7 * 24 * 60 * 60 * 1000) {
return plural(diff / (24 * 60 * 60 * 1000), "day") + " ago"; return plural(diff / (24 * 60 * 60 * 1000), 'day') + ' ago'
} }
return date.toLocaleDateString(); return date.toLocaleDateString()
} }

View file

@ -5,7 +5,8 @@ const LoginPage = () => import('@/components/LoginPage.vue')
const RecipesPage = () => import('@/components/recipes/RecipesPage.vue') const RecipesPage = () => import('@/components/recipes/RecipesPage.vue')
const MealPlanPage = () => import('@/components/meals/MealPlanPage.vue') const MealPlanPage = () => import('@/components/meals/MealPlanPage.vue')
const MyShoppingPage = () => import('@/components/shopping/MyShoppingPage.vue') const MyShoppingPage = () => import('@/components/shopping/MyShoppingPage.vue')
const PurchasedShoppingListPage = () => import('@/components/shopping/PurchasedShoppingListPage.vue') const PurchasedShoppingListPage = () =>
import('@/components/shopping/PurchasedShoppingListPage.vue')
const CurrentShoppingListPage = () => import('@/components/shopping/CurrentShoppingListPage.vue') const CurrentShoppingListPage = () => import('@/components/shopping/CurrentShoppingListPage.vue')
const EditMealPage = () => import('@/components/meals/EditMealPage.vue') const EditMealPage = () => import('@/components/meals/EditMealPage.vue')
const EditRecipePage = () => import('@/components/recipes/EditRecipePage.vue') const EditRecipePage = () => import('@/components/recipes/EditRecipePage.vue')
@ -15,14 +16,47 @@ export function createAppRouter(getCurrentUser) {
{ path: '/', redirect: { name: 'mealplan' } }, { path: '/', redirect: { name: 'mealplan' } },
{ path: '/login', name: 'login', component: LoginPage }, { path: '/login', name: 'login', component: LoginPage },
{ path: '/mealplan', name: 'mealplan', component: MealPlanPage, meta: { requiresAuth: true } }, { path: '/mealplan', name: 'mealplan', component: MealPlanPage, meta: { requiresAuth: true } },
{ path: '/shopping', name: 'shopping', component: MyShoppingPage, meta: { requiresAuth: true } }, {
{ path: '/shopping/current', name: 'shopping-current', component: CurrentShoppingListPage, meta: { requiresAuth: true } }, path: '/shopping',
{ path: '/shopping/:id', name: 'shopping-list', component: PurchasedShoppingListPage, props: true, meta: { requiresAuth: true } }, name: 'shopping',
component: MyShoppingPage,
meta: { requiresAuth: true },
},
{
path: '/shopping/current',
name: 'shopping-current',
component: CurrentShoppingListPage,
meta: { requiresAuth: true },
},
{
path: '/shopping/:id',
name: 'shopping-list',
component: PurchasedShoppingListPage,
props: true,
meta: { requiresAuth: true },
},
{ path: '/recipes', name: 'recipes', component: RecipesPage, meta: { requiresAuth: true } }, { path: '/recipes', name: 'recipes', component: RecipesPage, meta: { requiresAuth: true } },
{ path: '/recipes/add', name: 'recipe-add', component: EditRecipePage, meta: { requiresAuth: true } }, {
{ path: '/recipes/:id', name: 'recipe-edit', component: EditRecipePage, props: true, meta: { requiresAuth: true } }, path: '/recipes/add',
name: 'recipe-add',
component: EditRecipePage,
meta: { requiresAuth: true },
},
{
path: '/recipes/:id',
name: 'recipe-edit',
component: EditRecipePage,
props: true,
meta: { requiresAuth: true },
},
{ path: '/meals/add', name: 'meal-add', component: EditMealPage, meta: { requiresAuth: true } }, { path: '/meals/add', name: 'meal-add', component: EditMealPage, meta: { requiresAuth: true } },
{ path: '/meals/:id', name: 'meal-edit', component: EditMealPage, props: true, meta: { requiresAuth: true } }, {
path: '/meals/:id',
name: 'meal-edit',
component: EditMealPage,
props: true,
meta: { requiresAuth: true },
},
] ]
const router = createRouter({ const router = createRouter({
@ -36,7 +70,9 @@ export function createAppRouter(getCurrentUser) {
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
if (user) return true if (user) return true
} catch (_) { /* ignore */ } } catch (_) {
/* ignore */
}
return { name: 'login', query: { redirect: to.fullPath } } return { name: 'login', query: { redirect: to.fullPath } }
}) })

View file

@ -1,142 +1,142 @@
export const equivalentUnits = { export const equivalentUnits = {
'kg': { kg: {
'kgs': 1, kgs: 1,
'kilograms': 1, kilograms: 1,
'kilogram': 1, kilogram: 1,
'g': 1000, g: 1000,
'gram': 1000, gram: 1000,
'grams': 1000, grams: 1000,
'lb': 2.20462, lb: 2.20462,
'lbs': 2.20462, lbs: 2.20462,
'pound': 2.20462, pound: 2.20462,
'pounds': 2.20462, pounds: 2.20462,
}, },
'litres': { litres: {
'l': 1, l: 1,
'liter': 1, liter: 1,
'litre': 1, litre: 1,
'ml': 1000, ml: 1000,
'milliliters': 1000, milliliters: 1000,
'milliliter': 1000, milliliter: 1000,
'fl oz': 33.814, 'fl oz': 33.814,
'fluid ounce': 33.814, 'fluid ounce': 33.814,
'fluid ounces': 33.814, 'fluid ounces': 33.814,
'cup': 4.22675, cup: 4.22675,
'cups': 4.22675, cups: 4.22675,
'tbsp': 67.628, tbsp: 67.628,
'tablespoon': 67.628, tablespoon: 67.628,
'tablespoons': 67.628, tablespoons: 67.628,
'tsp': 202.884, tsp: 202.884,
'teaspoon': 202.884, teaspoon: 202.884,
'teaspoons': 202.884, teaspoons: 202.884,
'pt': 2.11338, pt: 2.11338,
'pint': 2.11338, pint: 2.11338,
'pints': 2.11338, pints: 2.11338,
'qt': 1.05669, qt: 1.05669,
'quart': 1.05669, quart: 1.05669,
'quarts': 1.05669, quarts: 1.05669,
'gal': 0.264172, gal: 0.264172,
'gallon': 0.264172, gallon: 0.264172,
'gallons': 0.264172, gallons: 0.264172,
'oz': 35.1951, oz: 35.1951,
'ounce': 35.1951, ounce: 35.1951,
}, },
'items': { items: {
'item': 1, item: 1,
'items': 1, items: 1,
'pcs': 1, pcs: 1,
'piece': 1, piece: 1,
'pieces': 1, pieces: 1,
'florets': 8, // Broccoli florets: 8, // Broccoli
'head': 1, // Broccoli head: 1, // Broccoli
'heads': 1, // Broccoli heads: 1, // Broccoli
'slice': 10, // Bread slice: 10, // Bread
'slices': 10, // Bread slices: 10, // Bread
'loaf': 1, // Bread loaf: 1, // Bread
'loaves': 1, // Bread loaves: 1, // Bread
'cloves': 8, // Garlic cloves: 8, // Garlic
'bulb': 1, // Garlic bulb: 1, // Garlic
'bulbs': 1, // Garlic bulbs: 1, // Garlic
'stalk': 1, // Celery stalk: 1, // Celery
'stalks': 1, // Celery stalks: 1, // Celery
'bunch': 1, // Cilantro bunch: 1, // Cilantro
'bunches': 1, // Cilantro bunches: 1, // Cilantro
'sprig': 1, // Cilantro sprig: 1, // Cilantro
'sprigs': 1, // Cilantro sprigs: 1, // Cilantro
'cans': 1, // Canned goods cans: 1, // Canned goods
'can': 1, // Canned goods can: 1, // Canned goods
'pack': 1, // Packaged goods pack: 1, // Packaged goods
'packs': 1, // Packaged goods packs: 1, // Packaged goods
'package': 1, // Packaged goods package: 1, // Packaged goods
'packages': 1, // Packaged goods packages: 1, // Packaged goods
'container': 1, // Packaged goods container: 1, // Packaged goods
'containers': 1, // Packaged goods containers: 1, // Packaged goods
}, },
} }
function getBaseUnit(unit) { function getBaseUnit(unit) {
for (const unitType in equivalentUnits) { for (const unitType in equivalentUnits) {
if (unit in equivalentUnits[unitType]) { if (unit in equivalentUnits[unitType]) {
return unitType; return unitType
} }
} }
return null; return null
} }
export function getConversionFactor(unit) { export function getConversionFactor(unit) {
if (unit in equivalentUnits) { if (unit in equivalentUnits) {
return { unit, factor: 1 }; return { unit, factor: 1 }
} }
const unitLower = unit.toLowerCase(); const unitLower = unit.toLowerCase()
if (unitLower in equivalentUnits) { if (unitLower in equivalentUnits) {
return { unit: unitLower, factor: 1 }; return { unit: unitLower, factor: 1 }
} }
const baseUnit = getBaseUnit(unit); const baseUnit = getBaseUnit(unit)
if (baseUnit) { if (baseUnit) {
return { return {
unit: baseUnit, unit: baseUnit,
factor: equivalentUnits[baseUnit][unit], factor: equivalentUnits[baseUnit][unit],
}; }
} }
const baseUnitLower = getBaseUnit(unitLower); const baseUnitLower = getBaseUnit(unitLower)
if (baseUnitLower) { if (baseUnitLower) {
return { return {
unit: baseUnitLower, unit: baseUnitLower,
factor: equivalentUnits[baseUnitLower][unitLower], factor: equivalentUnits[baseUnitLower][unitLower],
}; }
} }
return null; return null
} }
export function calculateTotals(quantityList) { export function calculateTotals(quantityList) {
const totals = {}; const totals = {}
for (const quantity of quantityList) { for (const quantity of quantityList) {
const baseUnit = getConversionFactor(quantity.unit) ?? { unit: quantity.unit, factor: 1 }; const baseUnit = getConversionFactor(quantity.unit) ?? { unit: quantity.unit, factor: 1 }
const factor = baseUnit.factor; const factor = baseUnit.factor
const unit = baseUnit.unit; const unit = baseUnit.unit
if (!totals[unit]) { if (!totals[unit]) {
totals[unit] = 0; totals[unit] = 0
} }
totals[unit] += quantity.quantity / factor; totals[unit] += quantity.quantity / factor
} }
return Object.keys(totals).map(unit => ({ unit, quantity: totals[unit] })); return Object.keys(totals).map((unit) => ({ unit, quantity: totals[unit] }))
} }

View file

@ -1,4 +1,4 @@
const { defineConfig } = require('@vue/cli-service') const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({ module.exports = defineConfig({
transpileDependencies: true transpileDependencies: true,
}) })