Compare commits

..

16 commits

Author SHA1 Message Date
bd99d90c09 Autoformat 2025-10-18 14:16:34 +11:00
72fccdd8a3 alert to composable 2025-10-18 14:12:27 +11:00
d1534934c0 Readme updates 2025-10-18 14:06:24 +11:00
68e82f1fa2 Composition #4 2025-10-18 13:57:40 +11:00
d31182dff0 Composition #3 2025-10-18 13:53:24 +11:00
beab02200c Composition #2 2025-10-18 13:47:56 +11:00
61ca41d25a Composition #1 2025-10-18 13:39:25 +11:00
3be1027154 Refactor #8 removed data.js 2025-10-18 13:22:48 +11:00
661d5f4840 Refactor #7 2025-10-18 13:17:39 +11:00
f0a8adbd77 Refactor #6.2 autoformat 2025-10-18 13:08:22 +11:00
28ad3a16ff Refactor #6.1 2025-10-18 13:06:25 +11:00
2cc87e1891 Refactor #5 2025-10-18 13:01:18 +11:00
8797a5489c Refactor #4 2025-10-18 12:54:56 +11:00
a6904524db Refactor #3 2025-10-18 12:42:23 +11:00
ec28531df9 Refactor #2 2025-10-18 12:36:55 +11:00
ee2f08cbfc Initial 2025-10-18 12:30:30 +11:00
58 changed files with 6658 additions and 2524 deletions

12
.editorconfig Normal file
View file

@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
# Base URL for the backend API
# Example: http://localhost:8081
VUE_APP_API_BASE=

4
.husky/pre-commit Normal file
View file

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged

3
.prettierignore Normal file
View file

@ -0,0 +1,3 @@
node_modules/
dist/
coverage/

7
.prettierrc.json Normal file
View file

@ -0,0 +1,7 @@
{
"singleQuote": true,
"semi": false,
"trailingComma": "es5",
"printWidth": 100,
"arrowParens": "always"
}

View file

@ -1,24 +1,92 @@
# doof-front
## Munch Ease — Plan, Cook, Shop, Repeat
## Project setup
```
Munch Ease is a snappy Vue 3 app that helps you plan meals, manage recipes, and turn plans into stress-free shopping lists. Search and save recipes, build your weekly meal plan, and seamlessly check items off your shopping list—everything stays in sync so you can focus on whats cooking.
Built with modern Vue patterns, a clean API layer, and lightweight tests, the project is easy to extend and fun to work on.
---
## Quick start
1) Install dependencies
```bash
npm install
```
### Compiles and hot-reloads for development
```
2) Run the dev server
```bash
npm run serve
```
### Compiles and minifies for production
3) Run unit tests (Vitest)
```bash
npm run test
```
4) Build for production
```bash
npm run build
```
### Lints and fixes files
```
npm run lint
```
Environment
- API base URL: set VUE_APP_API_BASE (e.g. http://localhost:8081)
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
---
## Architecture and conventions
The codebase follows clear boundaries and Vue 3 Composition API throughout.
- Routing and auth
- Centralized in `src/router/index.js` with named routes and an auth guard via route meta `requiresAuth`.
- Components use `useRouter/useRoute` for navigation and route access.
- API layer and mappers
- `src/api/http.js` is a tiny JSON fetch wrapper that honors `VUE_APP_API_BASE`.
- Feature services live in `src/api/*` (meals, recipes, shopping, auth, persons).
- Normalization lives in `src/api/mappers/*` (e.g., date parsing, shape cleanup).
- Composables (UI-facing logic)
- Reusable logic in `src/composables/*` (useAuth, useMeals, useShopping).
- Components stay thin: data via refs/reactive, effects via computed/watch.
- Components
- All Single File Components use `<script setup>`.
- Props via `defineProps`, events via `defineEmits`, routing via `useRouter`.
- Event bus for alerts is in `src/alert.js` with `AlertToast` subscribing; can evolve into a `useAlert` composable.
- Testing
- Vitest configured (`vitest.config.js`) with `@` alias to `src`.
- Targeted unit tests cover units and API mappers under `tests/`.
- Formatting and linting
- Prettier is the source of truth; Husky + lint-staged auto-format on commit.
- ESLint configured for Vue 3 and Composition API macros.
- Recommended: use Volar in your editor for Vue intelligence.
Folder highlights
- `src/api/` — HTTP wrapper, feature services, and data mappers
- `src/composables/` — Reusable app logic (auth, meals, shopping)
- `src/components/` — UI components and pages (all in `<script setup>`)
- `src/router/` — Route definitions and auth guard
---
## Development tips
- Prefer composables for shared logic; keep components presentational where possible.
- Use computed for derived values; avoid mutating props directly.
- When navigating, prefer named routes for stability.
- Keep tests small and fast; add a test when you add a new mapper or unit.
---
## Troubleshooting
- API errors: verify `VUE_APP_API_BASE` is set and reachable.
- Type/IDE help: ensure Volar is enabled and ESLint is not conflicting with Prettier.
- Build issues: this project uses Vue CLI 5. If migrating to Vite, update scripts and configs accordingly.

View file

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

View file

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

3615
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,12 @@
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
"lint": "vue-cli-service lint",
"format": "prettier --write .",
"prepare": "husky install",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"core-js": "^3.8.3",
@ -19,21 +24,34 @@
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3"
"eslint-plugin-vue": "^8.0.3",
"husky": "^8.0.0",
"lint-staged": "^13.3.0",
"prettier": "^3.3.3",
"vitest": "^1.6.0"
},
"lint-staged": {
"*.{js,vue,css,scss,md}": [
"prettier --write"
]
},
"eslintConfig": {
"root": true,
"env": {
"node": true
"node": true,
"vue/setup-compiler-macros": true
},
"extends": [
"plugin:vue/vue3-essential",
"plugin:vue/vue3-recommended",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {}
"rules": {
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": "error"
}
},
"browserslist": [
"> 1%",

View file

@ -1,15 +1,18 @@
<!DOCTYPE html>
<!doctype html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<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>
<div id="app"></div>
<!-- built files will be auto injected -->

View file

@ -2,47 +2,48 @@
<div>
<ul class="nav">
<li class="nav-item">
<router-link class="nav-link" to="/recipes" active-class="active">Recipes</router-link>
<router-link
class="nav-link"
:to="{ name: 'recipes' }"
active-class="active"
>
Recipes
</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/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 class="nav-item">
<router-link class="nav-link" to="/shopping" active-class="active">Shopping</router-link>
<router-link
class="nav-link"
:to="{ name: 'shopping' }"
active-class="active"
>
Shopping
</router-link>
</li>
</ul>
</div>
<div class="viewport">
<router-view/>
<router-view />
</div>
<alert-toast />
</template>
<script>
import data from './data.js'
<script setup>
import AlertToast from './components/AlertToast.vue'
export default {
name: 'App',
components: {
'alert-toast': AlertToast
},
computed: {
currentRoute() {
return this.$route.path
}
},
async mounted() {
if (!await data.currentUser()) {
this.$router.push('/login')
}
}
}
// components in <script setup> are auto-registered by import + usage
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
@ -86,7 +87,7 @@ export default {
/* Have active route use different color */
.nav li:has(> a.active) {
background-color: #4CAF50;
background-color: #4caf50;
color: white;
}
@ -94,5 +95,4 @@ export default {
max-width: 1200px;
margin: auto;
}
</style>

View file

@ -1,10 +1,11 @@
const subscribers = [];
const subscribers = []
export default {
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));
}
}

21
src/api/auth.js Normal file
View file

@ -0,0 +1,21 @@
import { http } from './http'
let cachedUser = null
export async function currentUser() {
if (cachedUser) return cachedUser
// Try a refresh if a cookie exists (browser will send it automatically)
try {
const user = await http.post('/auth/refresh')
cachedUser = user
} catch (_) {
cachedUser = null
}
return cachedUser
}
export async function login(username) {
const user = await http.post('/auth/login', { username })
cachedUser = user
return user
}

35
src/api/http.js Normal file
View file

@ -0,0 +1,35 @@
const BASE = process.env.VUE_APP_API_BASE || '/api'
async function request(path, options = {}) {
const url = path.startsWith('http') ? path : `${BASE}${path}`
const resp = await fetch(url, {
credentials: 'include',
...options,
headers: {
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers || {}),
},
})
if (!resp.ok) {
let message = `${resp.status} ${resp.statusText}`
try {
const err = await resp.json()
message = err.message || message
} catch (_) {
/* ignore */
}
const error = new Error(message)
error.status = resp.status
throw error
}
if (resp.status === 204) return null
return resp.json()
}
export const http = {
get: (p) => request(p),
post: (p, body) => request(p, { method: 'POST', body: JSON.stringify(body) }),
put: (p, body) => request(p, { method: 'PUT', body: JSON.stringify(body) }),
del: (p) => request(p, { method: 'DELETE' }),
}

View file

@ -0,0 +1,16 @@
function toDate(value) {
return value ? new Date(value) : value
}
export function mapMeal(meal) {
if (!meal) return meal
meal.suggested_date = toDate(meal.suggested_date)
meal.purchase_date = toDate(meal.purchase_date)
meal.consumed_date = toDate(meal.consumed_date)
return meal
}
export function mapMeals(list) {
if (!Array.isArray(list)) return []
return list.map(mapMeal)
}

View file

@ -0,0 +1,15 @@
function toDate(value) {
return value ? new Date(value) : value
}
export function mapRecipe(recipe) {
if (!recipe) return recipe
recipe.date_created = toDate(recipe.date_created)
recipe.date_hidden = toDate(recipe.date_hidden)
return recipe
}
export function mapRecipes(list) {
if (!Array.isArray(list)) return []
return list.map(mapRecipe)
}

View file

@ -0,0 +1,73 @@
import { mapMeal } from './mealMapper'
import { mapRecipe } from './recipeMapper'
function toDate(value) {
return value ? new Date(value) : value
}
function attachItemRefs(
items,
{ ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup }
) {
if (!Array.isArray(items)) return
for (const item of items) {
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.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.created_date) item.created_date = toDate(item.created_date)
}
}
export function mapPurchasedShoppingList(dto) {
if (!dto) return dto
// Map lookups
if (dto.meals_lookup) {
for (const [k, v] of Object.entries(dto.meals_lookup)) dto.meals_lookup[k] = mapMeal(v)
}
if (dto.recipes_lookup) {
for (const [k, v] of Object.entries(dto.recipes_lookup)) dto.recipes_lookup[k] = mapRecipe(v)
}
if (dto.ingredients_lookup) {
// no date fields expected on ingredient based on current usage
}
if (dto.list) {
if (dto.list.created_date) dto.list.created_date = toDate(dto.list.created_date)
if (Array.isArray(dto.list.items)) {
attachItemRefs(dto.list.items, {
ingredients_lookup: dto.ingredients_lookup,
meals_lookup: dto.meals_lookup,
recipes_lookup: dto.recipes_lookup,
shopping_list_lookup: { [dto.list.id]: dto.list },
})
}
}
return dto
}
export function mapCurrentShoppingList(dto) {
if (!dto) return dto
if (dto.meals_lookup) {
for (const [k, v] of Object.entries(dto.meals_lookup)) dto.meals_lookup[k] = mapMeal(v)
}
if (dto.recipes_lookup) {
for (const [k, v] of Object.entries(dto.recipes_lookup)) dto.recipes_lookup[k] = mapRecipe(v)
}
if (dto.shopping_list_lookup) {
for (const [, v] of Object.entries(dto.shopping_list_lookup)) {
if (v?.created_date) v.created_date = toDate(v.created_date)
}
}
const lookups = {
ingredients_lookup: dto.ingredients_lookup,
meals_lookup: dto.meals_lookup,
recipes_lookup: dto.recipes_lookup,
shopping_list_lookup: dto.shopping_list_lookup,
}
attachItemRefs(dto.outstanding_items, lookups)
attachItemRefs(dto.requested_meals, lookups)
attachItemRefs(dto.purchased_items, lookups)
return dto
}

31
src/api/meals.js Normal file
View file

@ -0,0 +1,31 @@
import { http } from './http'
import { mapMeal, mapMeals } from './mappers/mealMapper'
export async function getUpcomingMeals(from, to) {
const params = `?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}`
const meals = await http.get(`/meals/upcoming${params}`)
const normalized = mapMeals(meals)
return normalized.sort((a, b) => a.suggested_date - b.suggested_date)
}
export async function markMealConsumed(mealId) {
const meal = await http.post(`/meals/${encodeURIComponent(mealId)}/consumed`)
return mapMeal(meal)
}
export async function deleteMeal(mealId) {
return http.del(`/meals/${encodeURIComponent(mealId)}`)
}
export async function getMeal(id) {
const meal = await http.get(`/meals/${encodeURIComponent(id)}`)
return mapMeal(meal)
}
export async function saveMeal(meal) {
const hasId = meal.id >= 0
const path = hasId ? `/meals/${encodeURIComponent(meal.id)}` : '/meals'
const method = hasId ? http.put : http.post
const saved = await method(path, meal)
return mapMeal(saved)
}

9
src/api/persons.js Normal file
View file

@ -0,0 +1,9 @@
import { http } from './http'
export async function getPersonsInHome() {
return http.get('/persons')
}
export async function searchPerson(name) {
return http.get(`/persons?q=${encodeURIComponent(name)}`)
}

36
src/api/recipes.js Normal file
View file

@ -0,0 +1,36 @@
import { http } from './http'
import { mapRecipe, mapRecipes } from './mappers/recipeMapper'
export async function searchRecipes(query) {
const recipes = await http.get(`/recipes?q=${encodeURIComponent(query)}`)
return mapRecipes(recipes)
}
export async function getRecipe(id) {
const recipe = await http.get(`/recipes/${encodeURIComponent(id)}`)
return mapRecipe(recipe)
}
export async function saveRecipe(recipe) {
const saved = await http.post('/recipes', recipe)
return mapRecipe(saved)
}
export async function deleteRecipe(id) {
return http.del(`/recipes/${encodeURIComponent(id)}`)
}
export async function parseRecipe(url) {
const recipe = await http.get(`/recipes/parse?url=${encodeURIComponent(url)}`)
return mapRecipe(recipe)
}
export async function parseIngredients(lines) {
const params = lines.map((line) => `ingredients=${encodeURIComponent(line)}`).join('&')
return http.get(`/recipes/ingredients/parse?${params}`)
}
export async function parseProduct(ingredient, url) {
const body = { url, tags: [ingredient.name, ingredient.line] }
return http.post('/products', body)
}

38
src/api/shopping.js Normal file
View file

@ -0,0 +1,38 @@
import { http } from './http'
import { mapPurchasedShoppingList, mapCurrentShoppingList } from './mappers/shoppingListMapper'
export async function getMyShoppingList() {
const ingredients = await http.get('/shopping/current/me/ingredients')
return ingredients
}
export async function saveMyShoppingList(list) {
const ingredients = await http.post('/shopping/current/me/ingredients', list)
return ingredients
}
export async function getShoppingList(id) {
const dto = await http.get(`/shopping/${encodeURIComponent(id)}`)
const mapped = mapPurchasedShoppingList(dto)
return mapped.list
}
export async function getCurrentShoppingList() {
const dto = await http.get('/shopping/current')
return mapCurrentShoppingList(dto)
}
export async function purchaseShoppingList(completedRequests) {
const dto = await http.post('/shopping/', { items: completedRequests })
const mapped = mapPurchasedShoppingList(dto)
return mapped.list
}
export async function requestMeal(mealId) {
const items = await http.post('/shopping/current/meals/me', { meal_id: mealId })
return items
}
export async function unrequestMeal(mealId) {
await http.del(`/shopping/current/meals/${encodeURIComponent(mealId)}`)
}

View file

@ -1,17 +1,21 @@
<template>
<div class="card">
<a @click="$emit('click')">
<a @click="emit('click')">
<h2>{{ title }}</h2>
<img :src="image" :alt="name" />
<img
:src="image"
:alt="title"
>
</a>
</div>
</template>
<script>
export default {
name: 'ActionItem',
props: ['title', 'image']
}
<script setup>
const emit = defineEmits(['click'])
defineProps({
title: { type: String, required: true },
image: { type: String, required: true },
})
</script>
<style scoped>
@ -33,5 +37,4 @@ img {
.card:hover {
background-color: #ccc;
}
</style>

View file

@ -1,17 +1,56 @@
<template>
<div v-if="showAlert" :class="['alert', type]" @click="dismiss">
<img v-if="icon" :src="icon" alt="Notification icon" />
<div
v-if="showAlert"
:class="['alert', type]"
@click="dismiss"
>
<img
v-if="icon"
:src="icon"
alt="Notification icon"
>
<div class="message-container">
<h4 class="heading">{{ heading }}</h4>
<p class="message">{{ message }}</p>
<h4 class="heading">
{{ heading }}
</h4>
<p class="message">
{{ message }}
</p>
</div>
</div>
</div>
</template>
<style scoped>
<script setup>
import { computed, watch } from 'vue'
import { useAlert } from '@/composables/useAlert'
const alertIcons = {
error: require('@/assets/notification-error.svg'),
success: require('@/assets/notification-success.svg'),
info: require('@/assets/notification-info.svg'),
}
const { current, clear, scheduleAutoDismiss } = useAlert()
const showAlert = computed(() => !!current.value)
const heading = computed(() => current.value?.heading ?? '')
const message = computed(() => current.value?.message ?? '')
const type = computed(() => current.value?.type ?? '')
const icon = computed(() => (type.value && alertIcons[type.value] ? alertIcons[type.value] : null))
function dismiss() {
clear()
}
watch(
() => current.value?._ts,
(ts) => {
if (ts) scheduleAutoDismiss(5000)
}
)
</script>
<style scoped>
/* 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 */
@ -42,57 +81,10 @@
}
.alert.success {
background-color: #4CAF50;
background-color: #4caf50;
}
.alert.info {
background-color: #2196F3;
background-color: #2196f3;
}
</style>
<script>
import alert from '@/alert';
const alertIcons = {
error: require('@/assets/notification-error.svg'),
success: require('@/assets/notification-success.svg'),
info: require('@/assets/notification-info.svg')
};
export default {
name: 'AlertToast',
data() {
return {
showAlert: false,
heading: '',
message: '',
type: ''
};
},
computed: {
icon() {
return this.type && alertIcons[this.type] ? alertIcons[this.type] : null;
}
},
mounted() {
alert.subscribe(this.show);
},
methods: {
show({ heading, message, type }) {
this.heading = heading;
this.message = message;
this.type = type;
this.showAlert = true;
setTimeout(() => {
this.showAlert = false;
}, 5000);
},
dismiss() {
this.showAlert = false;
},
}
}
</script>

View file

@ -1,20 +1,51 @@
<template>
<div class="login">
<h1>Login Page</h1>
<ul class="button-group">
<li v-for="person in persons" :key="person.id">
<button type="button" class="btn btn-primary" @click="login(person)">
<li
v-for="person in persons"
:key="person.id"
>
<button
type="button"
class="btn btn-primary"
@click="onLogin(person)"
>
{{ person.name }}
</button>
</li>
</ul>
</div>
</template>
<style scoped>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getPersonsInHome } from '@/api/persons'
import { login as loginApi } from '@/api/auth'
const props = defineProps({
redirect: { type: String, default: '/' },
})
const router = useRouter()
const persons = ref([])
onMounted(async () => {
persons.value = await getPersonsInHome()
})
async function onLogin(selectedPerson) {
const person = await loginApi(selectedPerson.name)
if (person?.id >= 0) {
router.push(props.redirect)
return
}
alert('Login failed')
}
</script>
<style scoped>
/* Remove the default list styling */
ul {
list-style-type: none;
@ -68,40 +99,4 @@ li:nth-child(4) > button {
background-color: #9a1f1f;
color: white;
}
</style>
<script>
import data from '@/data';
export default {
name: 'LoginVue',
props: {
redirect: {
type: String,
default: '/'
},
},
data() {
return {
persons: [],
}
},
async beforeMount() {
this.persons = await data.getPersonsInHome();
},
methods: {
async login(selectedPerson) {
const person = await data.login(selectedPerson.name);
if (person?.id >= 0) {
this.$router.push(this.redirect);
return;
}
alert('Login failed');
}
}
}
</script>

View file

@ -1,44 +1,77 @@
<template>
<div class="compact-parse-results">
<div class="compact-parse-results">
<p class="parse-element teaser-image">
<img :src="ingredient.product?.img_small ?? require('@/assets/missing-product.svg')" />
<img :src="ingredient.product?.img_small ?? require('@/assets/missing-product.svg')">
</p>
<p class="ingredient-details">
<span class="parse-element quantity" :class="{ missing: !(ingredient?.quantity)}">{{ 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 name" :class="{ missing: !(ingredient?.name)}">{{ ingredient?.name || 'name' }}</span>:&nbsp;
<span class="parse-element product-name" :class="{missing: !(ingredient?.product)}">
<a :href="ingredient?.product?.link" v-if="ingredient?.product?.link" target=_blank>
( {{ 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;" /> )
</a>
<a v-else-if="ingredient?.name" :href="searchlink" target="_blank">
(search?)
</a>
<a v-else>
(product)
<span
class="parse-element quantity"
:class="{ missing: !ingredient?.quantity }"
>{{
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 name"
:class="{ missing: !ingredient?.name }"
>{{
ingredient?.name || 'name'
}}</span>:&nbsp;
<span
class="parse-element product-name"
:class="{ missing: !ingredient?.product }"
>
<a
v-if="ingredient?.product?.link"
:href="ingredient?.product?.link"
target="”_blank”"
>
( {{ 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;
"
>
)
</a>
<a
v-else-if="ingredient?.name"
:href="searchlink"
target="_blank"
> (search?) </a>
<a v-else> (product) </a>
</span>
</p>
</div>
</div>
</template>
<script>
<script setup>
import { computed } from 'vue'
export default {
name: 'CompactParsedIngredient',
props: ['ingredient'],
computed: {
searchlink() {
return 'https://www.woolworths.com.au/shop/search/products?searchTerm=' + encodeURIComponent(this.ingredient.name);
}
}
}
const props = defineProps({
ingredient: { type: Object, required: true },
})
const searchlink = computed(() =>
props.ingredient?.name
? 'https://www.woolworths.com.au/shop/search/products?searchTerm=' +
encodeURIComponent(props.ingredient.name)
: ''
)
</script>
<style scoped>
.compact-parse-results {
flex: left;
display: flex;
@ -58,7 +91,8 @@ export default {
border: solid red 1px;
}
.parse-element.teaser-image, .parse-element.helper {
.parse-element.teaser-image,
.parse-element.helper {
border: none;
font-weight: normal;
}
@ -89,5 +123,4 @@ export default {
.product-name {
color: purple;
}
</style>

View file

@ -1,31 +1,52 @@
<template>
<div :class="{ editing: editing }">
<button v-if="editing" @click="$emit('on-add')">
<img class="icon" :src="require('@/assets/add-cart.svg')" /> <br />
<div :class="{ editing: editing }">
<button
v-if="editing"
@click="emit('on-add')"
>
<img
class="icon"
:src="require('@/assets/add-cart.svg')"
> <br>
Add Ingredient
</button>
<button @click="toggleEditing" v-if="!editOnly">
<button
v-if="!editOnly"
@click="toggleEditing"
>
<span v-if="editing">
<img class="icon" :src="require('@/assets/edit-off.svg')" /> <br />
<img
class="icon"
:src="require('@/assets/edit-off.svg')"
> <br>
Done Editing
</span>
<span v-else>
<img class="icon" :src="require('@/assets/edit.svg')" /> <br />
<img
class="icon"
:src="require('@/assets/edit.svg')"
> <br>
Edit My List
</span>
</button>
<ul>
<li v-for="ingredient in ingredients" :key="ingredient">
<li
v-for="ingredient in ingredients"
:key="ingredient"
>
<div v-if="editing">
<p class="ingredient-line">
<ingredient-line
:ingredient="ingredient"
@update-ingredient="updateIngredient"
@update-product-link="updateProduct" />
@update-product-link="updateProduct"
/>
</p>
<button @click="$emit('on-delete', ingredient)">
<img class="icon" :src="require('@/assets/trash.svg')" />
<button @click="emit('on-delete', ingredient)">
<img
class="icon"
:src="require('@/assets/trash.svg')"
>
</button>
</div>
<div v-else>
@ -33,12 +54,40 @@
</div>
</li>
</ul>
</div>
</div>
</template>
<style scoped>
<script setup>
import { ref } from 'vue'
import { parseProduct, parseIngredients } from '@/api/recipes'
import IngredientLine from './IngredientLine.vue'
import CompactParsedIngredient from './CompactParsedIngredient.vue'
const props = defineProps({
ingredients: { type: Array, required: true },
editOnly: { type: Boolean, default: false },
})
const emit = defineEmits(['on-add', 'on-delete', 'on-update-ingredient', 'on-editing'])
const editing = ref(props.editOnly ?? false)
async function updateProduct(ingredient, product_link) {
const product = await parseProduct(ingredient, product_link)
emit('on-update-ingredient', ingredient, { ...ingredient, product })
}
async function updateIngredient(ingredient, line) {
const newIngredients = await parseIngredients([line])
emit('on-update-ingredient', ingredient, newIngredients[0])
}
function toggleEditing() {
editing.value = !editing.value
emit('on-editing', editing.value)
}
</script>
<style scoped>
.icon {
width: 2em;
height: 2em;
@ -70,38 +119,4 @@ li > div {
margin: 0;
margin-right: 1em;
}
</style>
<script>
import data from '@/data.js'
import IngredientLine from './IngredientLine.vue'
import CompactParsedIngredient from './CompactParsedIngredient.vue'
export default {
name: 'EditableIngredientsPanel',
components: { IngredientLine, CompactParsedIngredient },
props: ['ingredients', 'editOnly'],
data() {
return {
editing: this.editOnly ?? false
}
},
methods: {
async updateProduct(ingredient, product_link) {
const product = await data.parseProduct(ingredient, product_link);
this.$emit('on-update-ingredient', ingredient, { ...ingredient, product });
},
async updateIngredient(ingredient, line) {
const newIngredients = await data.parseIngredients([line]);
this.$emit('on-update-ingredient', ingredient, newIngredients[0]);
},
toggleEditing() {
this.editing = !this.editing;
this.$emit('on-editing', this.editing);
}
}
}
</script>

View file

@ -1,8 +1,20 @@
<template>
<div class="ingredient-item">
<p>
<input 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" />
<input
v-model="ingredientText"
placeholder="Enter an ingredient"
@keyup.enter="updateIngredient"
@blur="updateIngredient"
>
<input
v-if="ingredient.line"
v-model="productLink"
class="product-link-input"
placeholder="Enter product link"
@keyup.enter="updateProductLink"
@blur="updateProductLink"
>
</p>
<p v-if="ingredient.line">
<!-- Single line parse results -->
@ -11,45 +23,41 @@
</div>
</template>
<script>
import CompactParsedIngredient from './CompactParsedIngredient.vue';
<script setup>
import { ref, watch } from 'vue'
import CompactParsedIngredient from './CompactParsedIngredient.vue'
export default {
props: {
ingredient: { type: Object },
const props = defineProps({
ingredient: { type: Object, required: true },
})
const emit = defineEmits(['update-ingredient', 'update-product-link'])
const ingredientText = ref(props.ingredient?.line ?? '')
const productLink = ref(props.ingredient.product?.link ?? '')
watch(
() => props.ingredient,
(newIngredient) => {
ingredientText.value = newIngredient?.line ?? ''
productLink.value = newIngredient?.product?.link ?? ''
},
events: ['update-ingredient', 'update-product-link'],
components: { CompactParsedIngredient },
data() {
return {
ingredientText: this.ingredient?.line ?? "",
productLink: this.ingredient.product?.link ?? "",
};
},
watch: {
ingredient: {
handler: function (newIngredient) {
this.ingredientText = newIngredient?.line ?? "";
this.productLink = newIngredient.product?.link ?? "";
},
deep: true,
},
},
methods: {
updateIngredient() {
if (this.ingredientText != this.ingredient.line)
this.$emit('update-ingredient', this.ingredient, this.ingredientText);
},
updateProductLink() {
if (this.productLink && this.productLink != this.ingredient.product?.link)
this.$emit('update-product-link', this.ingredient, this.productLink);
{ deep: true }
)
function updateIngredient() {
if (ingredientText.value != props.ingredient.line) {
emit('update-ingredient', props.ingredient, ingredientText.value)
}
}
function updateProductLink() {
if (productLink.value && productLink.value != props.ingredient.product?.link) {
emit('update-product-link', props.ingredient, productLink.value)
}
};
}
</script>
<style scoped>
input {
border: 0;
font-size: larger;
@ -63,6 +71,4 @@ input {
color: #777;
margin-top: 0.5vh;
}
</style>

View file

@ -1,93 +1,85 @@
<template>
<div class="date-picker">
<input
type="text"
v-model="selectedDate"
type="text"
placeholder="Select a date"
@focus="showDatePicker = true"
@blur="showDatePicker = false"
placeholder="Select a date"
/>
<div v-if="showDatePicker" class="date-picker-dropdown">
>
<div
v-if="showDatePicker"
class="date-picker-dropdown"
>
<ul>
<li
v-for="(day, index) in days"
:key="index"
@mousedown="selectDate(day)">
@mousedown="selectDate(day)"
>
{{ formatDay(day) }}
</li>
</ul>
</div>
</div>
</template>
</template>
<script>
export default {
props: {
date: {
type: Date,
default: new Date(),
},
},
data() {
return {
selectedDate: this.formatDay(this.date),
showDatePicker: false,
};
},
watch: {
date(newDate) {
this.selectedDate = this.formatDay(newDate);
},
},
computed: {
days() {
const today = new Date();
const days = [];
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
date: { type: Date, default: () => new Date() },
})
const emit = defineEmits(['date-selected'])
function formatDay(date) {
const options = { weekday: 'long', day: 'numeric', month: 'numeric' }
return date.toLocaleDateString('en-AU', options)
}
const selectedDate = ref(formatDay(props.date))
const showDatePicker = ref(false)
watch(
() => props.date,
(newDate) => {
selectedDate.value = formatDay(newDate)
}
)
const days = computed(() => {
const today = new Date()
const result = []
for (let i = 0; i < 15; i++) {
const date = new Date(today);
date.setDate(today.getDate() + i);
days.push(date);
const d = new Date(today)
d.setDate(today.getDate() + i)
result.push(d)
}
return result
})
return days;
},
},
methods: {
formatDay(date) {
const options = { weekday: "long", day: "numeric", month: "numeric" };
return date.toLocaleDateString("en-AU", options);
},
selectDate(date) {
this.selectedDate = this.formatSelectedDate(date);
this.showDatePicker = false;
function selectDate(date) {
selectedDate.value = formatDay(date)
showDatePicker.value = false
emit('date-selected', date)
}
</script>
// Emit custom event
this.$emit("date-selected", date);
},
formatSelectedDate(date) {
const options = { weekday: "long", day: "numeric", month: "numeric" };
return date.toLocaleDateString("en-AU", options);
},
},
};
</script>
<style scoped>
.date-picker {
<style scoped>
.date-picker {
position: relative;
}
}
.date-picker input {
.date-picker input {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
outline: none;
}
}
.date-picker-dropdown {
.date-picker-dropdown {
position: absolute;
top: 100%;
width: 100%;
@ -96,26 +88,25 @@
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
}
.date-picker-dropdown ul {
.date-picker-dropdown ul {
list-style-type: none;
margin: 0;
padding: 0;
}
}
.date-picker-dropdown li {
.date-picker-dropdown li {
padding: 8px;
cursor: pointer;
border-bottom: 1px solid #ccc;
}
}
.date-picker-dropdown li:hover {
.date-picker-dropdown li:hover {
background-color: #eee;
}
}
.date-picker-dropdown li:last-child {
.date-picker-dropdown li:last-child {
border-bottom: none;
}
</style>
}
</style>

View file

@ -1,39 +1,84 @@
<template>
<div class="container">
<div class="fields">
<date-picker @date-selected="selectDate" :date="meal.suggested_date" />
<date-picker
:date="meal.suggested_date"
@date-selected="selectDate"
/>
<div class="persons-list">
Cooked by <person-list :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.
Cooked by
<person-list
: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 class="recipes">
<h2>Recipes</h2>
<ul v-if="meal.recipes && meal.recipes.length">
<li v-for="mealRecipe in meal.recipes" :key="mealRecipe.recipe.id">
<li
v-for="mealRecipe in meal.recipes"
:key="mealRecipe.recipe.id"
>
<div class="saved-recipe">
<p class="recipe-card">
<recipe-card :recipe="mealRecipe.recipe" />
</p>
<p class="servings">
<input type="number" v-model="mealRecipe.servings" min="1" />
<input
v-model="mealRecipe.servings"
type="number"
min="1"
>
<small><em>servings</em></small>
</p>
<input 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')" />
<input
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')"
>
</label>
<button class="icon-button" @click="removeRecipe(mealRecipe)">
<img class="icon" :src="require('@/assets/trash.svg')" />
<button
class="icon-button"
@click="removeRecipe(mealRecipe)"
>
<img
class="icon"
:src="require('@/assets/trash.svg')"
>
</button>
</div>
<div v-if="showIngredient(mealRecipe)">
<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" />
</li>
</ul>
@ -49,143 +94,168 @@
</div>
<div class="ingredients">
<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>
<button @click="saveMeal">Save</button>
<p v-if="meal.purchase_date"><em>Purchased {{ ago(meal.purchase_date) }}</em></p>
<button @click="onSaveMeal">
Save
</button>
<p v-if="meal.purchase_date">
<em>Purchased {{ ago(meal.purchase_date) }}</em>
</p>
</div>
</template>
<script>
<script setup>
import { reactive, onBeforeMount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getMeal, saveMeal } from '@/composables/useMeals'
import { getRecipe } from '@/api/recipes'
import { currentUser } from '@/api/auth'
import { useAlert } from '@/composables/useAlert'
import data from '@/data.js';
import alert from '@/alert.js';
import { ago } from '@/dateformats.js'
import { ago } from '@/dateformats.js';
import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue';
import RecipeCard from '@/components/recipes/RecipeCard.vue';
import DatePicker from './DatePicker.vue';
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue';
import PersonList from './PersonList.vue';
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue';
import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue'
import RecipeCard from '@/components/recipes/RecipeCard.vue'
import DatePicker from './DatePicker.vue'
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue'
import PersonList from './PersonList.vue'
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue'
function addPersonIfNotExists(list, person) {
if (!list.find(p => p.id === person.id)) {
list.push(person);
if (!list.find((p) => p.id === person.id)) {
list.push(person)
}
}
export default {
props: ['id'],
components: { RecipeSearchBox, DatePicker, RecipeCard, EditableIngredientsPanel, PersonList, CompactParsedIngredient },
data() {
return {
showIngredients: {},
meal: {
const route = useRoute()
const router = useRouter()
const { show: showAlert } = useAlert()
const showIngredients = reactive({})
const meal = reactive({
id: -1,
suggested_date: new Date(),
recipes: [],
extra_ingredients: [],
chefs: [],
consumers: [],
cleanup: []
cleanup: [],
})
onBeforeMount(async () => {
const idParam = route.params.id
const id = typeof idParam === 'string' ? parseInt(idParam) : idParam
if (id >= 0) {
const loaded = await getMeal(id)
Object.assign(meal, loaded)
} else {
const self = await currentUser()
Object.assign(meal, { chefs: [self], consumers: [self], cleanup: [self] })
}
};
},
async beforeMount() {
if (this.id >= 0) {
this.meal = await data.getMeal(this.id);
})
function selectDate(date) {
meal.suggested_date = date
}
function removeRecipe(mealRecipe) {
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)
}
else {
const self = await data.currentUser();
this.meal = {...this.meal, chefs: [self], consumers: [self], cleanup: [self], };
}
},
methods: {
ago,
async selectRecipe(recipe) {
}
function addIngredient() {
meal.extra_ingredients = [{ line: '', product: null }, ...meal.extra_ingredients]
}
function deleteIngredient(ingredient) {
meal.extra_ingredients = meal.extra_ingredients.filter((i) => i != ingredient)
}
function updateIngredient(ingredient, newIngredient) {
meal.extra_ingredients = meal.extra_ingredients.map((i) => (i == ingredient ? newIngredient : i))
}
function removePerson(list, person) {
meal[list] = meal[list].filter((p) => p.id !== person.id)
}
function addPerson(list, person) {
addPersonIfNotExists(meal[list], person)
}
async function selectRecipe(recipe) {
// Refetch to get additional details
recipe = await data.getRecipe(recipe.id);
recipe = await getRecipe(recipe.id)
if (recipe.created_by) {
addPersonIfNotExists(this.meal.chefs, recipe.created_by);
addPersonIfNotExists(this.meal.consumers, recipe.created_by);
addPersonIfNotExists(meal.chefs, recipe.created_by)
addPersonIfNotExists(meal.consumers, recipe.created_by)
if (this.meal.cleanup.length === 0) {
addPersonIfNotExists(this.meal.cleanup, recipe.created_by);
if (meal.cleanup.length === 0) {
addPersonIfNotExists(meal.cleanup, recipe.created_by)
}
}
this.meal.recipes.push({ recipe, recipe_id: recipe.id, meal_id: this.meal.id, servings: recipe.serves });
},
selectDate(date) {
this.meal.suggested_date = date;
},
removeRecipe(mealRecipe) {
if (confirm(`Are you sure you want to remove ${mealRecipe.servings} servings of ${mealRecipe.recipe.name} from this meal?`)) {
this.meal.recipes = this.meal.recipes.filter(r => r != mealRecipe);
}
},
addIngredient() {
this.meal.extra_ingredients = [{ line: '', product: null }, ...this.meal.extra_ingredients];
},
deleteIngredient(ingredient) {
this.meal.extra_ingredients = this.meal.extra_ingredients.filter(i => i != ingredient);
},
updateIngredient(ingredient, newIngredient) {
this.meal.extra_ingredients = this.meal.extra_ingredients.map(i => i == ingredient ? newIngredient : i);
},
removePerson(list, person) {
this.meal[list] = this.meal[list].filter(p => p.id !== person.id);
},
addPerson(list, person) {
addPersonIfNotExists(this.meal[list], person);
},
async saveMeal() {
const meal = await data.saveMeal(this.meal);
if (meal?.id >= 0) {
this.meal = meal;
meal.recipes.push({ recipe, recipe_id: recipe.id, meal_id: meal.id, servings: recipe.serves })
}
this.$router.push(`/meals/${meal.id}`);
alert.show({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' });
return;
async function onEditAdditionalIngredients(editing) {
if (editing && meal.extra_ingredients.length === 0) {
addIngredient()
} else {
meal.extra_ingredients = meal.extra_ingredients.filter((i) => i.line)
}
}
alert.show({ heading: 'Error saving meal', message: 'An error occurred while saving the meal', type: 'error' });
},
onEditAdditionalIngredients(editing) {
if (editing && this.meal.extra_ingredients.length === 0) {
this.addIngredient();
}
else {
this.meal.extra_ingredients = this.meal.extra_ingredients.filter(i => i.line);
}
},
showIngredient(mealRecipe, value) {
const index = this.meal.recipes.indexOf(mealRecipe);
const key = `${mealRecipe.recipe.id}-${index}`;
function showIngredient(mealRecipe, value) {
const index = meal.recipes.indexOf(mealRecipe)
const key = `${mealRecipe.recipe.id}-${index}`
if (value === undefined) {
return this.showIngredients[key];
return showIngredients[key]
}
return (showIngredients[key] = value)
}
return this.showIngredients[key] = value;
},
scaleIngredients(mealRecipe) {
return mealRecipe.recipe.ingredients.map(i => {
function scaleIngredients(mealRecipe) {
return mealRecipe.recipe.ingredients.map((i) => {
return {
...i,
quantity: i.quantity * mealRecipe.servings / mealRecipe.recipe.serves
};
});
},
quantity: (i.quantity * mealRecipe.servings) / mealRecipe.recipe.serves,
}
})
}
async function onSaveMeal() {
const saved = await saveMeal(meal)
if (saved?.id >= 0) {
Object.assign(meal, saved)
router.push(`/meals/${saved.id}`)
showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' })
return
}
showAlert({
heading: 'Error saving meal',
message: 'An error occurred while saving the meal',
type: 'error',
})
}
</script>
<style scoped>
img.icon {
width: 2em;
height: 2em;
@ -238,7 +308,6 @@ li {
margin-right: 1em;
}
.saved-recipe button:hover {
background: #eee;
}
@ -296,5 +365,4 @@ li {
.show-ingredient-checkbox:checked + label {
filter: invert(1);
}
</style>

View file

@ -1,18 +1,26 @@
<template>
<div class="meal-card">
<h3>{{ mealTitle }}</h3>
<h4>{{ dayOfWeek }} <small>{{ date }}</small></h4>
<h4>
{{ dayOfWeek }} <small>{{ date }}</small>
</h4>
<p>
Cooked by
<span v-for="(chef, index) in meal.chefs" :key="chef.id">
<span
v-for="(chef, index) in meal.chefs"
:key="chef.id"
>
{{ chef.name }}{{ englishSeperator(index, meal.chefs) }}
</span>
<span v-if="!meal.chefs.length">somebody?</span>
</p>
<p>
For
<span v-for="(consumer, index) in meal.consumers" :key="consumer.id">
<span
v-for="(consumer, index) in meal.consumers"
:key="consumer.id"
>
{{ consumer.name }}{{ englishSeperator(index, meal.consumers) }}
</span>
<span v-if="!meal.consumers.length">somebody?</span>
@ -23,63 +31,57 @@
</div>
</template>
<style>
</style>
<script>
<script setup>
import { computed } from 'vue'
import { ago } from '@/dateformats.js'
const props = defineProps({
meal: { type: Object, required: true },
})
function englishSeperator(index, list) {
switch (index) {
case list.length - 1:
return '';
return ''
case list.length - 2:
return ' and ';
return ' and '
default:
return ', ';
return ', '
}
}
function englishList(list) {
switch (list.length) {
case 0:
return '';
return ''
case 1:
return list[0];
return list[0]
case 2:
return `${list[0]} and ${list[1]}`;
return `${list[0]} and ${list[1]}`
default:
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`;
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`
}
}
export default {
name: 'MealCard',
props: ['meal'],
computed: {
date() {
return this.meal.suggested_date.toLocaleDateString('en-au', { month: 'numeric', day: 'numeric' })
},
dayOfWeek() {
return this.meal.suggested_date.toLocaleDateString('en-au', { weekday: 'long' })
},
mealTitle() {
const recipesText = englishList(this.meal.recipes.map(mealRecipe => mealRecipe.recipe.name));
const ingredientsText = englishList(this.meal.extra_ingredients.map(ingredient => ingredient.name));
const date = computed(() =>
props.meal.suggested_date.toLocaleDateString('en-au', {
month: 'numeric',
day: 'numeric',
})
)
if (recipesText && ingredientsText) {
return `${recipesText} with ${ingredientsText}`;
} else if (recipesText || ingredientsText) {
return recipesText || ingredientsText;
} else {
return 'Nothing planned';
}
}
},
methods: {
englishSeperator,
ago
}
}
const dayOfWeek = computed(() =>
props.meal.suggested_date.toLocaleDateString('en-au', { weekday: 'long' })
)
const mealTitle = computed(() => {
const recipesText = englishList(props.meal.recipes.map((mr) => mr.recipe.name))
const ingredientsText = englishList(props.meal.extra_ingredients.map((i) => i.name))
if (recipesText && ingredientsText) return `${recipesText} with ${ingredientsText}`
if (recipesText || ingredientsText) return recipesText || ingredientsText
return 'Nothing planned'
})
</script>
<style></style>

View file

@ -1,26 +1,94 @@
<template>
<div>
<ul class="meals-list" v-if="meals.length">
<li v-for="meal in meals" :key="meal.id">
<meal-card :meal="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')" />
<ul
v-if="meals.length"
class="meals-list"
>
<li
v-for="meal in meals"
:key="meal.id"
>
<meal-card :meal="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')
"
>
</button>
<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>
<ul
v-if="selectedMeal == meal"
class="actions"
>
<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="deleteSelectedMeal" class="button">Remove</a></li>
<li>
<a
class="button"
@click="deleteSelectedMeal"
>Remove</a>
</li>
</ul>
</li>
</ul>
<div v-if="!meals.length">
<em>No meals planned</em>
</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="() => $router.push('/meals/add')"
/>
</div>
</template>
<script setup>
import { ref, onBeforeMount } from 'vue'
import ActionItem from '@/components/ActionItem.vue'
import MealCard from '@/components/meals/MealCard.vue'
import { getUpcomingMeals, markMealConsumed, deleteMeal } from '@/composables/useMeals'
const from = new Date()
from.setTime(0)
const to = new Date()
to.setDate(to.getDate() + 7)
const meals = ref([])
const selectedMeal = ref(null)
onBeforeMount(async () => {
meals.value = await getUpcomingMeals(from, to)
})
async function deleteSelectedMeal() {
if (!selectedMeal.value) return
await deleteMeal(selectedMeal.value.id)
meals.value = meals.value.filter((m) => m.id !== selectedMeal.value.id)
selectedMeal.value = null
}
async function markConsumed() {
if (!selectedMeal.value) return
await markMealConsumed(selectedMeal.value.id)
meals.value = meals.value.filter((m) => m.id !== selectedMeal.value.id)
}
</script>
<style scoped>
li {
list-style-type: none;
@ -75,44 +143,4 @@ ul.actions {
padding-top: 2ex;
padding-bottom: 2ex;
}
</style>
<script>
import ActionItem from '@/components/ActionItem.vue'
import MealCard from '@/components/meals/MealCard.vue'
import data from '@/data.js'
export default {
name: 'MealPlanPage',
components: { MealCard, ActionItem },
data() {
const from = new Date();
from.setTime(0);
const to = new Date();
to.setDate(to.getDate() + 7);
return {
from, to,
meals: [],
selectedMeal: null
}
},
async beforeMount() {
const meals = await data.getUpcomingMeals(this.from, this.to)
this.meals = meals;
},
methods: {
async deleteSelectedMeal() {
await data.deleteMeal(this.selectedMeal.id);
this.meals = this.meals.filter(m => m.id !== this.selectedMeal.id);
this.selectedMeal = null;
},
async markConsumed() {
await data.markMealConsumed(this.selectedMeal.id);
this.meals = this.meals.filter(m => m.id !== this.selectedMeal.id);
}
}
}
</script>

View file

@ -1,22 +1,115 @@
<template>
<span class="person-list">
<span v-for="person in people" :key="person.id">
<button class="person-circle remove-person" @click="$emit('remove-person', person)" >{{ person.name }}</button>
<span class="person-list">
<span
v-for="person in people"
:key="person.id"
>
<button
class="person-circle remove-person"
@click="removePerson(person)"
>
{{ person.name }}
</button>
</span>
<span>
<button v-if="!isAddingPerson" 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">
<button class="person-circle add-person" @mousedown="addPerson(person)">{{ person.name }}</button>
<button
v-if="!isAddingPerson"
class="person-circle add-person"
@click="isAddingPerson = true"
>
+
</button>
<input
v-else
ref="searchNameInput"
v-model="searchName"
@keyup.enter="addPerson"
@keyup.esc="isAddingPerson = false"
@blur="isAddingPerson = false"
>
<ul
v-if="isAddingPerson && searchResults.length"
ref="persondroplist"
class="person-droplist"
>
<li
v-for="person in searchResults"
:key="person.id"
>
<button
class="person-circle add-person"
@mousedown="addPerson(person)"
>
{{ person.name }}
</button>
</li>
</ul>
</span>
</span>
</span>
</template>
<style scoped>
<script setup>
import { ref, watch } from 'vue'
import { searchPerson } from '@/api/persons'
const props = defineProps({
people: { type: Array, default: () => [] },
})
const emit = defineEmits(['add-person', 'remove-person'])
const isAddingPerson = ref(false)
const searchName = ref('')
const searchResults = ref([])
// Template refs for DOM elements
const searchNameInput = ref(null)
const persondroplist = ref(null)
async function updateSearchResults() {
const results = await searchPerson(searchName.value)
const idSet = new Set(props.people.map((p) => p.id))
searchResults.value = results.filter((p) => !idSet.has(p.id))
}
function addPerson(person) {
if (!person && searchResults.value.length > 0) {
person = searchResults.value[0]
}
if (person?.id >= 0 && !props.people.find((p) => p.id === person.id)) {
emit('add-person', person)
}
searchName.value = ''
searchResults.value = []
isAddingPerson.value = false
}
function removePerson(person) {
emit('remove-person', person)
}
// Watchers
watch(searchName, async () => {
await updateSearchResults()
})
watch(searchNameInput, async (el) => {
if (el) {
el.focus()
await updateSearchResults()
}
})
watch([persondroplist, searchNameInput], ([drop, input]) => {
if (drop && input) {
const inputRect = input.getBoundingClientRect()
drop.style.left = `${inputRect.left}px`
drop.style.top = `${inputRect.bottom}px`
drop.style.width = `${inputRect.width}px`
}
})
</script>
<style scoped>
.person-list {
display: inline-block;
text-align: center;
@ -53,9 +146,10 @@
}
/* 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;
content: "X";
content: 'X';
color: white;
position: absolute;
top: 0;
@ -94,78 +188,4 @@
display: inline;
padding: 1ex 1em;
}
</style>
<script>
import { ref } from 'vue';
import data from '@/data.js'
export default {
name: 'PersonList',
props: {
people: {
type: Array,
default: () => []
}
},
data() {
return {
isAddingPerson: false,
searchName: '',
searchResults: [],
}
},
setup() {
const searchNameInput = ref(null);
const persondroplist = ref(null);
return { searchNameInput, persondroplist };
},
watch: {
searchName: async function() {
await this.updateSearchResults()
},
searchNameInput: async function() {
this.searchNameInput?.focus();
await this.updateSearchResults()
},
persondroplist: function() {
if (this.persondroplist && this.searchNameInput)
{
// Align the droplist to the input field & its size
const inputRect = this.searchNameInput.getBoundingClientRect();
this.persondroplist.style.left = `${inputRect.left}px`;
this.persondroplist.style.top = `${inputRect.bottom}px`;
this.persondroplist.style.width = `${inputRect.width}px`;
}
}
},
methods: {
async updateSearchResults() {
const results = await data.searchPerson(this.searchName);
// Exclude people already in the list
const idSet = new Set(this.people.map(p => p.id));
this.searchResults = results.filter(p => !idSet.has(p.id));
},
addPerson(person) {
if (!person && this.searchResults.length > 0)
{
person = this.searchResults[0];
}
if (person?.id >= 0 && !this.people.find(p => p.id === person.id))
{
this.$emit('add-person', person);
}
this.searchName = '';
this.searchResults = [];
this.isAddingPerson = false;
},
removePerson(person) {
this.$emit('remove-person', person);
},
}
}
</script>

View file

@ -1,9 +1,18 @@
<template>
<div>
<div>
<div v-if="!id && !recipe">
<input class="recipe-link" type="text" v-model="link" placeholder="Link to Recipe" /> <br />
<button @click="parseLink">Parse</button>
<button @click="createFromScratch">Create from Scratch</button>
<input
v-model="link"
class="recipe-link"
type="text"
placeholder="Link to Recipe"
> <br>
<button @click="parseLink">
Parse
</button>
<button @click="createFromScratch">
Create from Scratch
</button>
</div>
<div v-if="parse_failed">
@ -11,26 +20,161 @@
</div>
<div v-if="!parse_failed && recipe">
<div class="image-container" v-if="image_styling" :style="image_styling" ></div>
<h1><input class="recipe-name" type="text" v-model="recipe.name" /></h1>
<div
v-if="image_styling"
class="image-container"
:style="image_styling"
/>
<h1>
<input
v-model="recipe.name"
class="recipe-name"
type="text"
>
</h1>
<label for="recipe-serves">Number of serves: </label>
<input type="number" v-model="recipe.serves" />
<h3 class="recipe-link"><a :href="recipe.link">View Recipe</a></h3>
<input
v-model="recipe.serves"
type="number"
>
<h3 class="recipe-link">
<a :href="recipe.link">View Recipe</a>
</h3>
<h2>Ingredients</h2>
<editable-ingredients-panel
:ingredients="recipe.ingredients"
:edit-only="true"
@on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient"
@on-add="addIngredient"
@on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient"
/>
<div>
<button v-if="recipe.id" class="delete-btn" @click="deleteRecipe">Delete</button>
<button class="submit-btn" @click="saveRecipe">{{ recipe.id ? "Save" : "Create" }}</button>
<button
v-if="recipe.id"
class="delete-btn"
@click="deleteRecipe"
>
Delete
</button>
<button
class="submit-btn"
@click="saveRecipe"
>
{{ recipe.id ? 'Save' : 'Create' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAlert } from '@/composables/useAlert'
import { getRecipe, parseRecipe, saveRecipe as saveRecipeApi, deleteRecipe as deleteRecipeApi } from '@/api/recipes'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
const props = defineProps({
id: { type: Number, required: false },
})
const router = useRouter()
const route = useRoute()
const { show: showAlert } = useAlert()
const link = ref(route.query.url ?? '')
const parse_failed = ref(false)
const recipe = ref(null)
const image_styling = computed(() => {
if (recipe.value?.image_urls && recipe.value.image_urls[0]) {
const image = recipe.value.image_urls[0]
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
})
function parseLink() {
router.push({ path: '/recipes/add', query: { url: link.value } })
refreshRecipe()
}
async function refreshRecipe() {
if (props.id >= 0) {
recipe.value = await getRecipe(props.id)
link.value = recipe.value.link
return
} else if (link.value) {
recipe.value = await parseRecipe(link.value)
parse_failed.value = !recipe.value
} else {
recipe.value = null
}
}
async function updateIngredient(ingredient, newIngredient) {
recipe.value.ingredients = recipe.value.ingredients.map((i) =>
i == ingredient ? newIngredient : i
)
}
function deleteIngredient(ingredient) {
recipe.value.ingredients = recipe.value.ingredients.filter((i) => i != ingredient)
}
async function saveRecipe() {
const saved = await saveRecipeApi(recipe.value)
if (saved?.id >= 0) {
showAlert({ heading: 'Recipe saved', message: 'Your recipe has been saved', type: 'success' })
router.push(`/recipes/${saved.id}`)
return
}
showAlert({ heading: 'Error saving recipe', message: 'There was an error saving your recipe', type: 'error' })
}
function createFromScratch() {
recipe.value = {
id: -1,
name: 'My new recipe',
created_by_id: -1,
link: '',
ingredients: [],
image_urls: [],
}
}
function addIngredient() {
recipe.value.ingredients = [{ line: '', product: null }, ...recipe.value.ingredients]
}
async function deleteRecipe() {
if (confirm('Are you sure you want to delete this recipe?')) {
await deleteRecipeApi(recipe.value.id)
router.push('/recipes')
}
}
onMounted(() => {
refreshRecipe()
})
// Keep recipe in sync if link query changes while on page
watch(
() => route.query.url,
(newUrl) => {
if (typeof newUrl === 'string') {
link.value = newUrl
refreshRecipe()
}
}
)
// expose functions for template binding names (automatic in <script setup>)
</script>
<style scoped>
input {
border: 0;
@ -56,98 +200,7 @@ input.recipe-name {
}
.recipe-link {
color: #0000EE;
color: #0000ee;
text-decoration: none;
}
</style>
<script>
import alert from '@/alert.js'
import data from '@/data.js'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
export default {
props: {
id: { type: Number, optional: true }
},
components: { EditableIngredientsPanel },
data() {
return {
link: this.$route.query.url ?? "",
parse_failed: false,
recipe: null,
chefs: [],
}
},
mounted() {
this.refreshRecipe();
},
computed: {
image_styling() {
if (this.recipe?.image_urls && 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;
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;
}
},
methods: {
parseLink() {
this.$router.push({ path: '/recipes/add', query: { url: this.link }})
this.refreshRecipe();
},
async refreshRecipe() {
if (this.id >= 0) {
this.recipe = await data.getRecipe(this.id);
this.link = this.recipe.link;
return;
}
else if (this.link) {
this.recipe = await data.parseRecipe(this.link);
this.parse_failed = !this.recipe;
}
else {
this.recipe = null;
}
},
async updateIngredient(ingredient, newIngredient) {
this.recipe.ingredients = this.recipe.ingredients.map(i => i == ingredient ? newIngredient : i);
},
deleteIngredient(ingredient) {
this.recipe.ingredients = this.recipe.ingredients.filter(i => i != ingredient);
},
async saveRecipe() {
const recipe = await data.saveRecipe(this.recipe);
if (recipe?.id >= 0) {
alert.show({ heading: 'Recipe saved', 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' });
},
async createFromScratch() {
this.recipe = {
id: -1,
name: 'My new recipe',
created_by_id: -1,
link: '',
ingredients: [],
image_urls: []
}
},
addIngredient() {
this.recipe.ingredients = [{ line: '', product: null }, ...this.recipe.ingredients];
},
async deleteRecipe() {
if (confirm('Are you sure you want to delete this recipe?')) {
await data.deleteRecipe(this.recipe.id);
this.$router.push('/recipes');
}
}
},
}
</script>

View file

@ -1,22 +1,28 @@
<template>
<div class="recipe-card">
<p>
<img v-if="recipe.image_urls" :src="recipe.image_urls[0]" />
<img v-else src="@/assets/egg.svg" />
<img
v-if="recipe.image_urls"
:src="recipe.image_urls[0]"
>
<img
v-else
src="@/assets/egg.svg"
>
</p>
<p class="recipe-name">
{{ recipe.name }}
</p>
<p class="recipe-name">{{ recipe.name }}</p>
</div>
</template>
<script>
export default {
name: 'RecipeCard',
props: ['recipe']
}
<script setup>
defineProps({
recipe: { type: Object, required: true },
})
</script>
<style scoped>
.recipe-card {
display: flex;
flex-direction: row;
@ -42,5 +48,4 @@ li img {
flex: 1;
margin: auto;
}
</style>

View file

@ -1,55 +1,88 @@
<template>
<div class="recipe-search-box" @focusout="recipes = []">
<input type="text" v-model="searchTerm" @keyup.enter="search" @keyup.exit="clear" @focusin="search"
:placeholder="placeholder" />
<ul v-if="recipes?.length" class="dropdown">
<li class="recipe" v-for="recipe in recipes" :key="recipe.id" @mousedown="selectRecipe(recipe)">
<div
class="recipe-search-box"
@focusout="onFocusOut"
>
<input
v-model="searchTerm"
type="text"
:placeholder="placeholder"
@keyup.enter="search"
@keyup.esc="clear"
@focusin="search"
>
<ul
v-if="recipes?.length"
class="dropdown"
>
<li
v-for="recipe in recipes"
:key="recipe.id"
class="recipe"
@mousedown="selectRecipe(recipe)"
>
<recipe-card :recipe="recipe" />
</li>
</ul>
</div>
</template>
<script>
import data from '@/data.js'
import RecipeCard from './RecipeCard.vue';
<script setup>
import { ref, watch, onBeforeUnmount } from 'vue'
import { searchRecipes } from '@/api/recipes'
import RecipeCard from './RecipeCard.vue'
export default {
name: 'RecipeSearchBox',
components: { RecipeCard },
props: {
placeholder: { type: String, default: 'Add a recipe...' }
},
data() {
return {
searchTerm: '',
recipes: [],
timeouts: [],
defineProps({
placeholder: { type: String, default: 'Add a recipe...' },
})
const emit = defineEmits(['select-recipe'])
const searchTerm = ref('')
const recipes = ref([])
let debounceId = null
watch(
searchTerm,
(newVal) => {
if (!newVal) {
recipes.value = []
if (debounceId) clearTimeout(debounceId)
return
}
},
watch: {
searchTerm() {
const searchTerm = this.searchTerm;
if (searchTerm) {
this.timeouts.push(setTimeout(() => {
if (searchTerm === this.searchTerm) {
this.search();
}
}, 200));
}
}
},
methods: {
async search() {
this.recipes = await data.searchRecipes(this.searchTerm) ?? this.recipes;
},
selectRecipe(recipe) {
this.$emit('select-recipe', recipe);
this.searchTerm = '';
this.recipes = [];
if (debounceId) clearTimeout(debounceId)
debounceId = setTimeout(() => {
if (newVal === searchTerm.value) {
search()
}
}, 200)
}
)
async function search() {
const result = await searchRecipes(searchTerm.value)
recipes.value = result ?? recipes.value
}
function selectRecipe(recipe) {
emit('select-recipe', recipe)
searchTerm.value = ''
recipes.value = []
}
function clear() {
searchTerm.value = ''
recipes.value = []
}
function onFocusOut() {
recipes.value = []
}
onBeforeUnmount(() => {
if (debounceId) clearTimeout(debounceId)
})
</script>
<style scoped>

View file

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

View file

@ -2,46 +2,77 @@
<h3>Full shopping list</h3>
<h4>Included Meals</h4>
<meal-selection-list @meal-selected="mealSelected" @meal-unselected="mealUnselected" :checked="includedMeals" :meals="availableMeals" />
<meal-selection-list
:checked="includedMeals"
:meals="availableMeals"
@meal-selected="mealSelected"
@meal-unselected="mealUnselected"
/>
<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" />
</li>
</ul>
<div v-if="outstandingItemGroups.length === 0">
<p>
No items to purchase
</p>
<p>No items to purchase</p>
</div>
<div class="purchased-slider">
<span v-if="purchasedItemGroups.length === 0"></span>
<button v-else-if="showPurchased" @click="showPurchased=false" > Hide Purchased </button>
<button v-else @click="showPurchased=true"> Show Purchased </button>
<span v-if="purchasedItemGroups.length === 0" />
<button
v-else-if="showPurchased"
@click="showPurchased = false"
>
Hide Purchased
</button>
<button
v-else
@click="showPurchased = true"
>
Show Purchased
</button>
<div v-if="showPurchased && purchasedItemGroups.length > 0">
<h4>Purchased Meals</h4>
<meal-selection-list @meal-selected="mealSelected" @meal-unselected="mealUnselected" :checked="includedMeals" :meals="purchasedMeals" />
<meal-selection-list
:checked="includedMeals"
:meals="purchasedMeals"
@meal-selected="mealSelected"
@meal-unselected="mealUnselected"
/>
<h4>
Purchased Items
</h4>
<h4>Purchased Items</h4>
<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" />
</li>
</ul>
</div>
</div>
<div class="spacer" v-if="selected.length">
&nbsp;
<div
v-if="selected.length"
class="spacer"
>
&nbsp;
</div>
<!-- Display 'Stocked', 'Purchased' and 'Cancel' buttons in a vertical stack fixed to the bottom of the screen when any elements are selected -->
<div class="footer-buttons" v-if="selected.length">
<div
v-if="selected.length"
class="footer-buttons"
>
<p v-if="selected.length === 1">
Mark '{{ selected[0].product?.name ?? selected[0].name }}' as
</p>
@ -51,25 +82,118 @@
<div class="button-group">
<button @click="markFound">
<img src="@/assets/house-check.svg" /><br />
<img src="@/assets/house-check.svg"><br>
Found
</button>
<button @click="markPurchased">
<img src="@/assets/shopping-cart.svg" /><br />
<img src="@/assets/shopping-cart.svg"><br>
Purchased
</button>
<button @click="selected = []">
<img src="@/assets/close.svg" /><br />
<img src="@/assets/close.svg"><br>
Cancel
</button>
</div>
</div>
</template>
<style scoped>
<script setup>
import { ref, computed, onBeforeMount } from 'vue'
import { useRouter } from 'vue-router'
import { useAlert } from '@/composables/useAlert'
import { useShopping } from '@/composables/useShopping'
import { getUpcomingMeals } from '@/api/meals'
import { itemsToGroups, uniqueMeals } from './shopping.js'
import MealSelectionList from './MealSelectionList.vue'
import ShoppingListItem from './ShoppingListItem.vue'
const router = useRouter()
const { show: showAlert } = useAlert()
const { getCurrentShoppingList, requestMeal, unrequestMeal, purchaseFromGroups } = useShopping()
const from = new Date()
from.setTime(0)
const to = new Date()
to.setDate(to.getDate() + 7)
const shoppingList = ref(null)
const upcomingMeals = ref([])
const selected = ref([])
const showPurchased = ref(false)
const groupsMatch = (a, b) => {
if (!!a.product != !!b.product) return false
if (a.name) return a.name === b.name
return a.product.id === b.product.id
}
const outstandingItemGroups = computed(() =>
itemsToGroups(shoppingList.value?.outstanding_items ?? [])
)
const purchasedItemGroups = computed(() => itemsToGroups(shoppingList.value?.purchased_items ?? []))
const purchasedMeals = computed(() => uniqueMeals(shoppingList.value?.purchased_items ?? []))
const availableMeals = computed(() => {
const meals = { ...(shoppingList.value?.meals_lookup ?? {}) }
upcomingMeals.value?.forEach((m) => {
if (!meals[m.id]) meals[m.id] = m
})
return Object.values(meals)
.filter((m) => !m.purchase_date)
.sort((a, b) => a.suggested_date - b.suggested_date)
})
const includedMeals = computed(() => shoppingList.value?.requested_meals.map((m) => m.meal) ?? [])
async function loadData() {
upcomingMeals.value = await getUpcomingMeals(from, to)
shoppingList.value = await getCurrentShoppingList()
}
async function mealSelected(meal) {
await requestMeal(meal.id)
await loadData()
}
async function mealUnselected(meal) {
await unrequestMeal(meal.id)
await loadData()
}
async function markFound() {
const result = await purchaseFromGroups(selected.value)
if (!result) {
showAlert({ type: 'error', message: 'No items selected.' })
return
}
selected.value = []
await loadData()
}
async function markPurchased() {
const shopping = await purchaseFromGroups(selected.value)
if (!shopping || !shopping.id) {
showAlert({ type: 'error', message: 'Failed to purchase.' })
return
}
selected.value = []
router.push(`/shopping/${shopping.id}`)
}
function toggleSelect(item) {
const index = selected.value.findIndex((i) => groupsMatch(i, item))
if (index === -1) selected.value.push(item)
else selected.value.splice(index, 1)
}
function isSelected(item) {
return selected.value.some((i) => groupsMatch(i, item))
}
onBeforeMount(loadData)
</script>
<style scoped>
.full-shopping-list {
padding: 0;
}
@ -130,121 +254,4 @@ button img {
.spacer {
height: 12em;
}
</style>
<script>
import alert from '@/alert.js'
import data from '@/data.js'
import { itemsToGroups, groupsToItems, uniqueMeals } from './shopping.js'
import MealSelectionList from './MealSelectionList.vue'
import ShoppingListItem from './ShoppingListItem.vue'
async function saveShoppingList(outstandingItemGroups) {
const items = groupsToItems(outstandingItemGroups);
if (items.length === 0) {
alert.show({ type: 'error', message: 'No items selected.' });
return;
}
return await data.purchaseShoppingList(items);
}
const groupsMatch = (a, b) => {
if (!!a.product != !!b.product) return false;
if (a.name) return a.name === b.name;
return a.product.id === b.product.id;
}
export default {
name: 'FullShoppingListPage',
components: { MealSelectionList, ShoppingListItem },
props: {
stockTaking: {
type: Boolean,
default: false
},
},
data() {
const from = new Date();
from.setTime(0);
const to = new Date();
to.setDate(to.getDate() + 7);
return { from, to, shoppingList: null, selected: [], showPurchased: false }
},
async beforeMount() {
this.loadData();
},
computed: {
outstandingItemGroups() {
return itemsToGroups(this.shoppingList?.outstanding_items ?? []);
},
purchasedItemGroups() {
return itemsToGroups(this.shoppingList?.purchased_items ?? []);
},
purchasedMeals() {
return uniqueMeals(this.shoppingList?.purchased_items ?? []);
},
availableMeals() {
const meals = { ...this.shoppingList?.meals_lookup ?? {} };
this.upcomingMeals?.forEach(m => {
if (!meals[m.id]) {
meals[m.id] = m;
}
});
return Object.values(meals).filter(m => !m.purchase_date).sort((a, b) => a.suggested_date - b.suggested_date);
},
includedMeals() {
return this.shoppingList?.requested_meals.map(m => m.meal) ?? [];
}
},
methods: {
async loadData() {
this.upcomingMeals = await data.getUpcomingMeals(this.from, this.to);
this.shoppingList = await data.getCurrentShoppingList();
},
async mealSelected(meal) {
await data.requestMeal(meal.id);
await this.loadData();
},
async mealUnselected(meal) {
await data.unrequestMeal(meal.id);
await this.loadData();
},
async markFound() {
await saveShoppingList(this.selected);
this.selected = [];
await this.loadData();
},
async markPurchased() {
const shoppingList = await saveShoppingList(this.selected);
if (!shoppingList || !shoppingList.id) {
alert.show({ type: 'error', message: 'Failed to purchase.' });
return;
}
this.selected = [];
this.$router.push(`/shopping/${shoppingList.id}`);
},
toggleSelect(item) {
const index = this.selected.findIndex(i => groupsMatch(i, item));
if (index === -1)
this.selected.push(item);
else
this.selected.splice(index, 1);
},
isSelected(item) {
return this.selected.some(i => groupsMatch(i, item));
}
}
}
</script>

View file

@ -1,20 +1,94 @@
<template>
<ul>
<li v-for="meal in meals" :key="meal.id">
<ul>
<li
v-for="meal in meals"
:key="meal.id"
>
<!-- Have a checkbox and card for each meal, show the image and name -->
<!-- Emit meal-selected event on checked and meal-unselected on unchecked -->
<input type="checkbox" :id="meal.id" :checked="isChecked(meal)" @change="mealCheckChanged" :disabled="disabled" />
<label :for="meal.id" :style="getImageStyling(meal)">
<input
:id="meal.id"
type="checkbox"
:checked="isChecked(meal)"
:disabled="disabled"
@change="mealCheckChanged"
>
<label
:for="meal.id"
:style="getImageStyling(meal)"
>
{{ formatDate(meal.suggested_date) }}
</label>
</li>
</ul>
</ul>
</template>
<style scoped>
<script setup>
const props = defineProps({
meals: { type: Array, required: true },
checked: { type: Array, required: true },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['meal-selected', 'meal-unselected'])
const nth = (d) => {
if (d > 3 && d < 21) return 'th'
switch (d % 10) {
case 1:
return 'st'
case 2:
return 'nd'
case 3:
return 'rd'
default:
return 'th'
}
}
const formatDate = (date) =>
`${date.toLocaleDateString('en-AU', { weekday: 'short' })} ${date.getDate()}${nth(date.getDate())}`
const getUrl = (meal) => {
// First non empty value in meal.recipe/image_urls
for (const mr of meal.recipes) {
const recipe = mr.recipe
if (recipe.image_urls.length && recipe.image_urls[0]) {
return recipe.image_urls[0]
}
}
// First meal.extra_ingredient with a product with an image
for (const ingredient of meal.extra_ingredients) {
if (ingredient.product) {
if (ingredient.product.img_large) return ingredient.product.img_large
if (ingredient.product.img_small) return ingredient.product.img_small
}
}
return null
}
function getImageStyling(meal) {
const imageUrl = getUrl(meal)
if (!imageUrl) return null
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`,
}
}
function mealCheckChanged(event) {
const mealId = parseInt(event.target.id)
const meal = props.meals.find((m) => m.id === mealId)
if (event.target.checked) emit('meal-selected', meal)
else emit('meal-unselected', meal)
}
function isChecked(meal) {
return props.checked.some((m) => m.id === meal.id)
}
</script>
<style scoped>
ul {
padding: 0;
}
@ -31,7 +105,7 @@ li {
}
/* Hide the default checkbox formatting, and format the card instead */
input[type="checkbox"] {
input[type='checkbox'] {
display: none;
}
@ -48,12 +122,12 @@ label {
color: #3d5447;
}
input[type="checkbox"]:checked + label {
input[type='checkbox']:checked + label {
border: 3px solid #3d5447;
text-shadow: #ccc 0 0 0.1em;
}
input[type="checkbox"]:disabled + label {
input[type='checkbox']:disabled + label {
cursor: not-allowed;
}
@ -72,89 +146,4 @@ label {
font-size: larger;
font-weight: bold;
}
</style>
<script>
const nth = (d) => {
if (d > 3 && d < 21) return 'th';
switch (d % 10) {
case 1: return "st";
case 2: return "nd";
case 3: return "rd";
default: return "th";
}
};
const formatDate = (date) => {
return `${date.toLocaleDateString('en-AU', { weekday: 'short' })} ${date.getDate()}${nth(date.getDate())}`
}
const getUrl = (meal) => {
// First non empty value in meal.recipe/image_urls
for (const mr of meal.recipes) {
const recipe = mr.recipe;
if (recipe.image_urls.length && recipe.image_urls[0]) {
return recipe.image_urls[0];
}
}
// First meal.extra_ingredient with a product with an image
for (const ingredient of meal.extra_ingredients) {
if (ingredient.product) {
if (ingredient.product.img_large) {
return ingredient.product.img_large;
}
if (ingredient.product.img_small) {
return ingredient.product.img_small;
}
}
}
return null;
}
export default {
name: 'MealSelectionList',
props: {
meals: Array,
checked: Array,
disabled: {
type: Boolean,
default: false
}
},
methods: {
formatDate,
getImageStyling(meal) {
const imageUrl = getUrl(meal);
if (!imageUrl) {
return null;
}
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`};
},
mealCheckChanged(event) {
const mealId = parseInt(event.target.id);
const meal = this.meals.find(m => m.id === mealId);
if (event.target.checked) {
this.$emit('meal-selected', meal);
} else {
this.$emit('meal-unselected', meal);
}
},
isChecked(meal) {
for (const checkedMeal of this.checked) {
if (checkedMeal.id === meal.id) {
return true;
}
}
return false;
}
}
}
</script>

View file

@ -1,8 +1,16 @@
<template>
<div>
<h1>My Shopping List</h1>
<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" />
<router-link :to="`/shopping/current`">
Full Shopping List
</router-link>
<editable-ingredients-panel
:ingredients="ingredients"
@on-add="addIngredient"
@on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient"
@on-editing="onEditing"
/>
</div>
<!--
@ -31,53 +39,52 @@
-->
</template>
<style scoped>
</style>
<script>
import data from '@/data.js'
<script setup>
import { ref, onBeforeMount } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import { useShopping } from '@/composables/useShopping'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
export default {
name: 'MyShoppingpage',
components: { EditableIngredientsPanel },
data() {
return { ingredients: [], person: null }
},
async beforeMount() {
const person = await data.currentUser();
if (!person)
return this.$router.push({ name: 'login' });
const router = useRouter()
const { loadUser } = useAuth()
const { getMyShoppingList, saveMyShoppingList } = useShopping()
this.person = person;
await this.updateShoppingList();
},
methods: {
async updateShoppingList(save = false) {
const new_ingredients = save ?
await data.saveMyShoppingList(this.ingredients) :
await data.getMyShoppingList();
const person = ref(null)
const ingredients = ref([])
this.ingredients = new_ingredients;
},
addIngredient() {
this.ingredients = [{ id: -1 }, ...this.ingredients];
},
deleteIngredient(ingredient) {
this.ingredients = this.ingredients.filter(i => i !== ingredient);
},
updateIngredient(oldIngredient, newIngredient) {
this.ingredients = this.ingredients.map(source => source === oldIngredient ? newIngredient : source);
},
async onEditing(isStartingEdit) {
await this.updateShoppingList(!isStartingEdit);
if (isStartingEdit && this.ingredients.length === 0) {
this.addIngredient();
}
}
}
async function updateShoppingList(save = false) {
const newIngredients = save
? await saveMyShoppingList(ingredients.value)
: await getMyShoppingList()
ingredients.value = newIngredients
}
function addIngredient() {
ingredients.value = [{ id: -1 }, ...ingredients.value]
}
function deleteIngredient(ingredient) {
ingredients.value = ingredients.value.filter((i) => i !== ingredient)
}
function updateIngredient(oldIngredient, newIngredient) {
ingredients.value = ingredients.value.map((source) =>
source === oldIngredient ? newIngredient : source
)
}
async function onEditing(isStartingEdit) {
await updateShoppingList(!isStartingEdit)
if (isStartingEdit && ingredients.value.length === 0) addIngredient()
}
onBeforeMount(async () => {
const u = await loadUser()
if (!u) return router.push({ name: 'login' })
person.value = u
await updateShoppingList()
})
</script>
<style scoped></style>

View file

@ -3,18 +3,50 @@
<div v-if="includedMeals.length > 0">
<h4>Included Meals</h4>
<meal-selection-list :checked="includedMeals" :meals="includedMeals" :disabled="true" />
<meal-selection-list
:checked="includedMeals"
:meals="includedMeals"
:disabled="true"
/>
</div>
<ul class="full-shopping-list">
<li v-for="item in listByProduct" :key="item.id">
<li
v-for="item in listByProduct"
:key="item.id"
>
<shopping-list-item :shopping-list-item-group="item" />
</li>
</ul>
</template>
<style scoped>
<script setup>
import { ref, computed, onBeforeMount } from 'vue'
import { useRoute } from 'vue-router'
import { ago } from '@/dateformats.js'
import { getShoppingList } from '@/api/shopping'
import { itemsToGroups, uniqueMeals } from './shopping.js'
import MealSelectionList from './MealSelectionList.vue'
import ShoppingListItem from './ShoppingListItem.vue'
const route = useRoute()
const shoppingList = ref(null)
const includedMeals = computed(() =>
shoppingList.value ? uniqueMeals(shoppingList.value.items) : []
)
const listByProduct = computed(() =>
shoppingList.value ? itemsToGroups(shoppingList.value.items) : []
)
onBeforeMount(async () => {
const idParam = route.params.id
const id = typeof idParam === 'string' ? parseInt(idParam) : idParam
shoppingList.value = await getShoppingList(id)
})
</script>
<style scoped>
.full-shopping-list li {
list-style-type: none;
@ -26,45 +58,4 @@
.full-shopping-list {
padding: 0;
}
</style>
<script>
import { ago } from '@/dateformats.js'
import data from '@/data.js'
import { itemsToGroups, uniqueMeals } from './shopping.js'
import MealSelectionList from './MealSelectionList.vue'
import ShoppingListItem from './ShoppingListItem.vue'
export default {
name: 'FullShoppingListPage',
components: { MealSelectionList, ShoppingListItem },
props: {
id: [String, Number]
},
computed: {
includedMeals() {
if (!this.shoppingList) return [];
return uniqueMeals(this.shoppingList.items);
},
listByProduct() {
return this.shoppingList ? itemsToGroups(this.shoppingList.items) : [];
}
},
data() {
return {
shoppingList: null,
}
},
async beforeMount() {
this.shoppingList = await data.getShoppingList(this.id);
},
methods: {
ago
}
}
</script>

View file

@ -1,63 +1,158 @@
<template>
<!-- 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">
<img :src="`${ shoppingListItemGroup.product?.img_small ?? require('@/assets/missing-product.svg') }`" class="product-image" />
<!-- 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">
<img
:src="`${shoppingListItemGroup.product?.img_small ?? require('@/assets/missing-product.svg')}`"
class="product-image"
>
<div class="product-details">
<h3 class="header">
<strong>
<a v-if="shoppingListItemGroup.product?.link" :href="shoppingListItemGroup.product?.link">{{ shoppingListItemGroup.product?.name }}</a>
<span v-else>{{ shoppingListItemGroup.name }}</span>
</strong>,
<a
v-if="shoppingListItemGroup.product?.link"
:href="shoppingListItemGroup.product?.link"
>{{ shoppingListItemGroup.product?.name }}</a>
<span v-else>{{ shoppingListItemGroup.name }}</span> </strong>,
<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>{{ formatQuantity(total.quantity) }}&nbsp;{{ total.unit }}</span>
</span>
<span class="found-marker partial" v-if="purchased.length > 0"> {{ getFriendlyDate(lastPurchased) }}</span>
<span
v-if="purchased.length > 0"
class="found-marker partial"
> {{ getFriendlyDate(lastPurchased) }}</span>
</small>
</h3>
<p class="sources" v-if="required.length > 0">
<p
v-if="required.length > 0"
class="sources"
>
<strong>Need: </strong>
<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="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 v-else-if="source.recipe">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }}
in <router-link :to="`/recipes/${ source.recipe.id }/`">{{ source.recipe.name }}</router-link>
for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in
<router-link :to="`/recipes/${source.recipe.id}/`">{{
source.recipe.name
}}</router-link>
for
<router-link :to="`/meals/${source.meal.id}/`">{{
source.meal.suggested_date.toLocaleDateString('en-AU', {
weekday: 'long',
month: 'long',
day: 'numeric',
})
}}</router-link>
</span>
<span v-else-if="source.meal">
{{ source.ingredient.line }} for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
{{ source.ingredient.line }} for
<router-link :to="`/meals/${source.meal.id}/`">{{
source.meal.suggested_date.toLocaleDateString('en-AU', {
weekday: 'long',
month: 'long',
day: 'numeric',
})
}}</router-link>
</span>
</span>
</p>
<p v-if="purchased.length > 0">
<strong>Already found or purchased: </strong>
<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="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 v-else-if="source.recipe">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }}
in <router-link :to="`/recipes/${ source.recipe.id }/`">{{ source.recipe.name }}</router-link>
for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in
<router-link :to="`/recipes/${source.recipe.id}/`">{{
source.recipe.name
}}</router-link>
for
<router-link :to="`/meals/${source.meal.id}/`">{{
source.meal.suggested_date.toLocaleDateString('en-AU', {
weekday: 'long',
month: 'long',
day: 'numeric',
})
}}</router-link>
</span>
<span v-else-if="source.meal">
{{ source.ingredient.line }} for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
{{ source.ingredient.line }} for
<router-link :to="`/meals/${source.meal.id}/`">{{
source.meal.suggested_date.toLocaleDateString('en-AU', {
weekday: 'long',
month: 'long',
day: 'numeric',
})
}}</router-link>
</span>
</span>
</p>
</div>
</div>
</div>
</template>
<style scoped>
<script setup>
import { computed } from 'vue'
import { ago } from '@/dateformats.js'
import { calculateTotals } from '@/units.js'
const props = defineProps({
// { product: { ... }, OR name: 'string', shoppingListItems: [...] }
shoppingListItemGroup: { type: Object, required: true },
})
const remainingRequiredTotals = computed(() =>
calculateTotals(props.shoppingListItemGroup.shoppingListItems.map((item) => item.ingredient))
)
const required = computed(() =>
props.shoppingListItemGroup.shoppingListItems.filter((item) => !item.list_id)
)
const purchased = computed(() =>
props.shoppingListItemGroup.shoppingListItems.filter((item) => item.list_id)
)
const lastPurchased = computed(() => {
const purchasedItems = props.shoppingListItemGroup.shoppingListItems.filter((item) => item.list_id)
if (purchasedItems.length === 0) return null
return purchasedItems.reduce((latest, item) => {
const itemDate = item?.meal?.suggested_date || item?.created_at
return !latest || (itemDate && itemDate > latest) ? itemDate : latest
}, null)
})
function getFriendlyDate(date) {
if (!date) return ''
return ago(date)
}
function formatQuantity(quantity) {
const log10 = Math.log10(quantity)
if (log10 < 0) return quantity.toPrecision(2)
if (log10 < 1) return quantity.toFixed(1)
return quantity.toFixed(0)
}
</script>
<style scoped>
/* Show the product image to the left, then the product name and size to the right */
.shopping-list-item {
@ -106,69 +201,4 @@
.found-marker.partial {
background-color: darkgoldenrod;
}
</style>
<script>
import { ago } from '@/dateformats.js';
import { calculateTotals } from '@/units.js';
export default {
name: 'ShoppingListItem',
props: ['shoppingListItemGroup' ], // { product: { ... }, OR name: 'string', shoppingListItems: { person, ingredient, list_id?, meal? }} where list_id is null if not yet purchased
data() {
return {
expanded: false,
}
},
computed: {
remainingRequiredTotals() {
return calculateTotals(this.shoppingListItemGroup.shoppingListItems.map(item => item.ingredient));
},
expectedExistingTotals() {
const purchasedNotEaten = this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id && !(item?.meal?.consumed_date));
return calculateTotals(purchasedNotEaten.map(item => item.ingredient));
},
required() {
return this.shoppingListItemGroup.shoppingListItems.filter(item => !item.list_id);
},
purchased() {
return this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id);
},
lastPurchased() {
const purchasedItems = this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id);
if (purchasedItems.length === 0) {
return null;
}
// Find the most recently purchased item
return purchasedItems.reduce((latest, item) => {
const itemDate = item?.meal?.suggested_date || item?.created_at;
return (!latest || (itemDate && itemDate > latest)) ? itemDate : latest;
}, null);
}
},
methods: {
getFriendlyDate(date) {
if (!date)
return '';
return ago(date);
},
formatQuantity(quantity) {
const log10 = Math.log10(quantity);
if (log10 < 0) {
return quantity.toPrecision(2);
}
else if (log10 < 1) {
return quantity.toFixed(1);
}
else {
return quantity.toFixed(0);
}
}
}
}
</script>

View file

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

View file

@ -0,0 +1,27 @@
import { ref } from 'vue'
// Singleton reactive alert state for the app
const current = ref(null)
let timeoutId = null
function show(message) {
// message: { heading, message, type: 'success' | 'error' | 'info' }
current.value = { ...message, _ts: Date.now() }
}
function clear() {
current.value = null
}
function scheduleAutoDismiss(ms = 5000) {
if (timeoutId) clearTimeout(timeoutId)
if (!current.value) return
timeoutId = setTimeout(() => {
clear()
timeoutId = null
}, ms)
}
export function useAlert() {
return { current, show, clear, scheduleAutoDismiss }
}

View file

@ -0,0 +1,22 @@
import { ref } from 'vue'
import { currentUser as apiCurrentUser, login as apiLogin } from '@/api/auth'
const user = ref(null)
let initialized = false
export async function loadUser() {
if (!initialized) {
user.value = await apiCurrentUser()
initialized = true
}
return user.value
}
export async function login(username) {
user.value = await apiLogin(username)
return user.value
}
export function useAuth() {
return { user, loadUser, login }
}

View file

@ -0,0 +1,25 @@
import * as api from '@/api/meals'
export async function getUpcomingMeals(from, to) {
return api.getUpcomingMeals(from, to)
}
export async function getMeal(id) {
return api.getMeal(id)
}
export async function saveMeal(meal) {
return api.saveMeal(meal)
}
export async function markMealConsumed(mealId) {
return api.markMealConsumed(mealId)
}
export async function deleteMeal(mealId) {
return api.deleteMeal(mealId)
}
export function useMeals() {
return { getUpcomingMeals, getMeal, saveMeal, markMealConsumed, deleteMeal }
}

View file

@ -0,0 +1,27 @@
import {
getCurrentShoppingList,
getShoppingList,
purchaseShoppingList,
requestMeal,
unrequestMeal,
getMyShoppingList,
saveMyShoppingList,
} from '@/api/shopping'
import { groupsToItems } from '@/components/shopping/shopping.js'
export function useShopping() {
return {
getCurrentShoppingList,
getShoppingList,
purchaseShoppingList,
requestMeal,
unrequestMeal,
getMyShoppingList,
saveMyShoppingList,
async purchaseFromGroups(groups) {
const items = groupsToItems(groups)
if (!items?.length) return null
return purchaseShoppingList(items)
},
}
}

View file

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

View file

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

@ -1,51 +1,11 @@
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import App from './App.vue'
import { createAppRouter } from '@/router'
import { currentUser } from '@/api/auth'
import LoginPage from './components/LoginPage.vue'
import RecipesPage from './components/recipes/RecipesPage.vue'
import MealPlanPage from './components/meals/MealPlanPage.vue'
import MyShoppingPage from './components/shopping/MyShoppingPage.vue'
import PurchasedShoppingListPage from './components/shopping/PurchasedShoppingListPage.vue'
import CurrentShoppingListPage from './components/shopping/CurrentShoppingListPage.vue'
import EditMealPage from './components/meals/EditMealPage.vue'
import EditRecipePage from './components/recipes/EditRecipePage.vue'
// Create the router with an auth callback to check current user
const router = createAppRouter(() => currentUser())
// 2. Define some routes
// Each route should map to a component.
// We'll talk about nested routes later.
const routes = [
// Redirect index to mealplan
{ path: '/', redirect: '/mealplan' },
{ path: '/mealplan', component: MealPlanPage },
{ path: '/login', component: LoginPage },
{ path: '/shopping', component: MyShoppingPage },
{ path: '/shopping/current', component: CurrentShoppingListPage },
{ path: '/shopping/:id', component: PurchasedShoppingListPage, props : true },
{ path: '/recipes', component: RecipesPage },
{ path: '/recipes/add', component: EditRecipePage },
{ path: '/recipes/:id', component: EditRecipePage, props: true },
{ path: '/meals/add', component: EditMealPage },
{ path: '/meals/:id', component: EditMealPage, props: true },
]
// 3. Create the router instance and pass the `routes` option
// You can pass in additional options here, but let's
// keep it simple for now.
const router = createRouter({
// 4. Provide the history implementation to use. We are using the hash history for simplicity here.
history: createWebHashHistory(),
routes, // short for `routes: routes`
})
// 5. Create and mount the root instance.
const app = createApp(App)
// Make sure to _use_ the router instance to make the
// whole app router-aware.
app.use(router)
app.mount('#app')
// Now the app has started!

80
src/router/index.js Normal file
View file

@ -0,0 +1,80 @@
import { createRouter, createWebHashHistory } from 'vue-router'
// Lazy-loaded route components
const LoginPage = () => import('@/components/LoginPage.vue')
const RecipesPage = () => import('@/components/recipes/RecipesPage.vue')
const MealPlanPage = () => import('@/components/meals/MealPlanPage.vue')
const MyShoppingPage = () => import('@/components/shopping/MyShoppingPage.vue')
const PurchasedShoppingListPage = () =>
import('@/components/shopping/PurchasedShoppingListPage.vue')
const CurrentShoppingListPage = () => import('@/components/shopping/CurrentShoppingListPage.vue')
const EditMealPage = () => import('@/components/meals/EditMealPage.vue')
const EditRecipePage = () => import('@/components/recipes/EditRecipePage.vue')
export function createAppRouter(getCurrentUser) {
const routes = [
{ path: '/', redirect: { name: 'mealplan' } },
{ path: '/login', name: 'login', component: LoginPage },
{ 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/:id',
name: 'shopping-list',
component: PurchasedShoppingListPage,
props: true,
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: '/meals/add', name: 'meal-add', component: EditMealPage, meta: { requiresAuth: true } },
{
path: '/meals/:id',
name: 'meal-edit',
component: EditMealPage,
props: true,
meta: { requiresAuth: true },
},
]
const router = createRouter({
history: createWebHashHistory(),
routes,
})
// Simple auth guard using provided getter
router.beforeEach(async (to) => {
if (!to.meta.requiresAuth) return true
try {
const user = await getCurrentUser()
if (user) return true
} catch (_) {
/* ignore */
}
return { name: 'login', query: { redirect: to.fullPath } }
})
return router
}

View file

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

27
tests/mealMapper.test.js Normal file
View file

@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { mapMeal, mapMeals } from '@/api/mappers/mealMapper'
describe('mealMapper', () => {
it('maps individual meal date fields to Date instances', () => {
const input = {
id: 1,
suggested_date: '2025-01-01T00:00:00Z',
purchase_date: '2025-01-02T00:00:00Z',
consumed_date: '2025-01-03T00:00:00Z',
}
const result = mapMeal({ ...input })
expect(result.suggested_date).toBeInstanceOf(Date)
expect(result.purchase_date).toBeInstanceOf(Date)
expect(result.consumed_date).toBeInstanceOf(Date)
})
it('maps lists of meals', () => {
const input = [
{ id: 1, suggested_date: '2025-01-01T00:00:00Z' },
{ id: 2, suggested_date: '2025-01-02T00:00:00Z' },
]
const result = mapMeals(input)
expect(result).toHaveLength(2)
expect(result[0].suggested_date).toBeInstanceOf(Date)
})
})

View file

@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { mapCurrentShoppingList, mapPurchasedShoppingList } from '@/api/mappers/shoppingListMapper'
describe('shoppingListMapper', () => {
it('maps current shopping list and wires references', () => {
const dto = {
ingredients_lookup: { 10: { id: 10, name: 'Eggs' } },
meals_lookup: { 1: { id: 1, suggested_date: '2025-01-01T00:00:00Z', recipes: [], extra_ingredients: [], chefs: [], consumers: [], cleanup: [] } },
recipes_lookup: { 5: { id: 5, name: 'Omelette' } },
shopping_list_lookup: { 7: { id: 7, created_date: '2025-01-01T00:00:00Z' } },
outstanding_items: [{ ingredient_id: 10, meal_id: 1, recipe_id: 5, list_id: 7, created_date: '2025-01-01T00:00:00Z' }],
requested_meals: [],
purchased_items: [],
}
const mapped = mapCurrentShoppingList(dto)
const item = mapped.outstanding_items[0]
expect(item.ingredient.name).toBe('Eggs')
expect(item.meal.suggested_date).toBeInstanceOf(Date)
expect(mapped.shopping_list_lookup['7'].created_date).toBeInstanceOf(Date)
})
it('maps purchased shopping list with list dates and item refs', () => {
const dto = {
ingredients_lookup: { 10: { id: 10, name: 'Eggs' } },
meals_lookup: { 1: { id: 1, suggested_date: '2025-01-01T00:00:00Z', recipes: [], extra_ingredients: [], chefs: [], consumers: [], cleanup: [] } },
recipes_lookup: { 5: { id: 5, name: 'Omelette' } },
list: { id: 9, created_date: '2025-02-02T00:00:00Z', items: [{ ingredient_id: 10, meal_id: 1, recipe_id: 5, list_id: 9 }] },
}
const mapped = mapPurchasedShoppingList(dto)
expect(mapped.list.created_date).toBeInstanceOf(Date)
expect(mapped.list.items[0].recipe.name).toBe('Omelette')
})
})

27
tests/units.test.js Normal file
View file

@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { getConversionFactor, calculateTotals } from '@/units'
describe('units', () => {
it('returns conversion factors for aliases and base units', () => {
expect(getConversionFactor('kg')).toEqual({ unit: 'kg', factor: 1 })
expect(getConversionFactor('g')).toEqual({ unit: 'kg', factor: 1000 })
expect(getConversionFactor('kgs')).toEqual({ unit: 'kg', factor: 1 })
expect(getConversionFactor('litre')).toEqual({ unit: 'litres', factor: 1 })
expect(getConversionFactor('ml')).toEqual({ unit: 'litres', factor: 1000 })
})
it('calculates totals grouped by base units', () => {
const totals = calculateTotals([
{ quantity: 500, unit: 'g' },
{ quantity: 0.5, unit: 'kg' },
{ quantity: 250, unit: 'ml' },
{ quantity: 0.75, unit: 'litre' },
])
// Expect kg total = 0.5 (from g) + 0.5 (from kg) = 1
const kgTotal = totals.find((t) => t.unit === 'kg')
expect(kgTotal.quantity).toBeCloseTo(1)
// Expect litres total = 0.25 (from ml) + 0.75 (from litre) = 1
const lTotal = totals.find((t) => t.unit === 'litres')
expect(lTotal.quantity).toBeCloseTo(1)
})
})

26
tests/useAlert.test.js Normal file
View file

@ -0,0 +1,26 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useAlert } from '@/composables/useAlert'
describe('useAlert', () => {
beforeEach(() => {
vi.useFakeTimers()
})
it('shows and clears alerts', () => {
const { current, show, clear } = useAlert()
expect(current.value).toBeNull()
show({ heading: 'Hello', message: 'World', type: 'info' })
expect(current.value).toMatchObject({ heading: 'Hello', message: 'World', type: 'info' })
clear()
expect(current.value).toBeNull()
})
it('auto-dismisses after scheduleAutoDismiss', () => {
const { current, show, scheduleAutoDismiss } = useAlert()
show({ heading: 'Auto', message: 'Dismiss', type: 'success' })
scheduleAutoDismiss(5000)
expect(current.value).not.toBeNull()
vi.advanceTimersByTime(5000)
expect(current.value).toBeNull()
})
})

16
vitest.config.js Normal file
View file

@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config'
import { fileURLToPath } from 'node:url'
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
environment: 'node',
include: ['tests/**/*.{test,spec}.js'],
globals: true,
reporters: 'default',
},
})

View file

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