Compare commits

..

No commits in common. "bd99d90c0973e90066ddf45b286f6cae3e13ef81" and "59e3d8ab1654de28349cc144481d7ff96d50a2e7" have entirely different histories.

58 changed files with 2489 additions and 6623 deletions

View file

@ -1,12 +0,0 @@
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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,92 +1,24 @@
## Munch Ease — Plan, Cook, Shop, Repeat
# doof-front
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
## Project setup
```
npm install
```
2) Run the dev server
```bash
### Compiles and hot-reloads for development
```
npm run serve
```
3) Run unit tests (Vitest)
```bash
npm run test
### Compiles and minifies for production
```
4) Build for production
```bash
npm run build
```
Environment
- API base URL: set VUE_APP_API_BASE (e.g. http://localhost:8081)
### Lints and fixes files
```
npm run lint
```
---
## 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.
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

View file

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

View file

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

View file

@ -1,18 +1,15 @@
<!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

@ -1,49 +1,48 @@
<template>
<div>
<ul class="nav">
<li class="nav-item">
<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="{ name: 'mealplan' }"
active-class="active"
>
Meal Plan
</router-link>
</li>
<li class="nav-item">
<router-link
class="nav-link"
:to="{ name: 'shopping' }"
active-class="active"
>
Shopping
</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/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>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/shopping" active-class="active">Shopping</router-link>
</li>
</ul>
</div>
<div class="viewport">
<router-view />
<router-view/>
</div>
<alert-toast />
</template>
<script setup>
<script>
import data from './data.js'
import AlertToast from './components/AlertToast.vue'
// components in <script setup> are auto-registered by import + usage
export default {
name: 'App',
components: {
'alert-toast': AlertToast
},
computed: {
currentRoute() {
return this.$route.path
}
},
async mounted() {
if (!await data.currentUser()) {
this.$router.push('/login')
}
}
}
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
@ -55,44 +54,45 @@ import AlertToast from './components/AlertToast.vue'
/* Make nav bar links buttons across top of screen */
.nav {
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 1;
display: flex;
justify-content: space-around;
list-style-type: none;
margin: 0;
padding: 0;
position: fixed;
top: 0;
width: 100%;
background-color: #333;
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 1;
display: flex;
justify-content: space-around;
list-style-type: none;
margin: 0;
padding: 0;
position: fixed;
top: 0;
width: 100%;
background-color: #333;
}
.nav li {
flex: 1;
flex: 1;
}
/* Style the links inside the navigation bar */
.nav a {
display: inline-block;
color: #f2f2f2;
text-align: center;
padding: 14px 16px;
text-decoration: none;
display: inline-block;
color: #f2f2f2;
text-align: center;
padding: 14px 16px;
text-decoration: none;
}
/* Have active route use different color */
.nav li:has(> a.active) {
background-color: #4caf50;
color: white;
background-color: #4CAF50;
color: white;
}
.viewport {
max-width: 1200px;
margin: auto;
max-width: 1200px;
margin: auto;
}
</style>

View file

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

View file

@ -1,21 +0,0 @@
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
}

View file

@ -1,35 +0,0 @@
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

@ -1,16 +0,0 @@
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

@ -1,15 +0,0 @@
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

@ -1,73 +0,0 @@
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
}

View file

@ -1,31 +0,0 @@
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)
}

View file

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

View file

@ -1,36 +0,0 @@
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)
}

View file

@ -1,38 +0,0 @@
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,40 +1,37 @@
<template>
<div class="card">
<a @click="emit('click')">
<h2>{{ title }}</h2>
<img
:src="image"
:alt="title"
>
</a>
</div>
<div class="card">
<a @click="$emit('click')">
<h2>{{ title }}</h2>
<img :src="image" :alt="name" />
</a>
</div>
</template>
<script setup>
const emit = defineEmits(['click'])
defineProps({
title: { type: String, required: true },
image: { type: String, required: true },
})
<script>
export default {
name: 'ActionItem',
props: ['title', 'image']
}
</script>
<style scoped>
img {
width: 100%;
height: auto;
width: 100%;
height: auto;
}
.card {
display: inline-block;
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
width: 20em;
margin: 1em 1ex;
display: inline-block;
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
width: 20em;
margin: 1em 1ex;
}
.card:hover {
background-color: #ccc;
background-color: #ccc;
}
</style>
</style>

View file

@ -1,90 +1,98 @@
<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>
<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 */
.alert {
position: fixed;
bottom: 1em;
right: 1em;
display: flex;
align-items: center;
padding: 1em;
border-radius: 5px;
color: white;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
text-align: left;
position: fixed;
bottom: 1em;
right: 1em;
display: flex;
align-items: center;
padding: 1em;
border-radius: 5px;
color: white;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
text-align: left;
}
.alert img {
width: 2em;
height: 2em;
margin-right: 1em;
width: 2em;
height: 2em;
margin-right: 1em;
/* Invert svg colors */
filter: invert(1);
/* Invert svg colors */
filter: invert(1);
}
.alert.error {
background-color: #f44336;
background-color: #f44336;
}
.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,102 +1,107 @@
<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="onLogin(person)"
>
{{ person.name }}
</button>
</li>
</ul>
</div>
<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)">
{{ person.name }}
</button>
</li>
</ul>
</div>
</template>
<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;
padding: 0;
list-style-type: none;
padding: 0;
}
/* Center the buttons in the middle of the page, and let them wrap */
.button-group {
display: flex;
flex-wrap: wrap;
justify-content: center;
display: flex;
flex-wrap: wrap;
justify-content: center;
}
/* Display each button as a large round circle */
button {
width: 100px;
height: 100px;
border-radius: 50%;
margin: 10px;
font-size: 1.5em;
width: 100px;
height: 100px;
border-radius: 50%;
margin: 10px;
font-size: 1.5em;
/* Center the text in the middle of the button */
display: flex;
justify-content: center;
align-items: center;
/* Center the text in the middle of the button */
display: flex;
justify-content: center;
align-items: center;
/* Add a shadow to make the buttons look like they are floating */
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
/* Add a shadow to make the buttons look like they are floating */
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
}
/* Some nice flat nuetral shades for the user buttons */
li:nth-child(1) > button {
background-color: #3939ff;
color: white;
background-color: #3939ff;
color: white;
}
li:nth-child(2) > button {
background-color: #156a14;
color: white;
background-color: #156a14;
color: white;
}
li:nth-child(3) > button {
background-color: #325293;
color: white;
background-color: #325293;
color: white;
}
li:nth-child(4) > button {
background-color: #9a1f1f;
color: white;
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,89 +1,56 @@
<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
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>
<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)
</a>
</span>
</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
<script>
const props = defineProps({
ingredient: { type: Object, required: true },
})
export default {
name: 'CompactParsedIngredient',
props: ['ingredient'],
computed: {
searchlink() {
return 'https://www.woolworths.com.au/shop/search/products?searchTerm=' + encodeURIComponent(this.ingredient.name);
}
}
}
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;
justify-content: left;
text-align: left;
flex: left;
display: flex;
justify-content: left;
text-align: left;
}
.parse-element {
margin: auto 0;
margin-right: 1em;
border-bottom: solid 1px #ccc;
font-weight: bold;
margin: auto 0;
margin-right: 1em;
border-bottom: solid 1px #ccc;
font-weight: bold;
}
.parse-element.missing {
@ -91,21 +58,20 @@ const searchlink = computed(() =>
border: solid red 1px;
}
.parse-element.teaser-image,
.parse-element.helper {
border: none;
font-weight: normal;
.parse-element.teaser-image, .parse-element.helper {
border: none;
font-weight: normal;
}
.parse-element.teaser-image img {
padding: 0;
border: none;
width: 2em;
height: 2em;
padding: 0;
border: none;
width: 2em;
height: 2em;
}
.ingredient-details {
flex: 1;
flex: 1;
}
.quantity {
@ -123,4 +89,5 @@ const searchlink = computed(() =>
.product-name {
color: purple;
}
</style>
</style>

View file

@ -1,122 +1,107 @@
<template>
<div :class="{ editing: editing }">
<button
v-if="editing"
@click="emit('on-add')"
>
<img
class="icon"
:src="require('@/assets/add-cart.svg')"
> <br>
Add Ingredient
<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
v-if="!editOnly"
@click="toggleEditing"
>
<span v-if="editing">
<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>
Edit My List
</span>
<button @click="toggleEditing" v-if="!editOnly">
<span v-if="editing">
<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 />
Edit My List
</span>
</button>
<ul>
<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"
/>
</p>
<button @click="emit('on-delete', ingredient)">
<img
class="icon"
:src="require('@/assets/trash.svg')"
>
</button>
</div>
<div v-else>
<compact-parsed-ingredient :ingredient="ingredient" />
</div>
</li>
<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" />
</p>
<button @click="$emit('on-delete', ingredient)">
<img class="icon" :src="require('@/assets/trash.svg')" />
</button>
</div>
<div v-else>
<compact-parsed-ingredient :ingredient="ingredient" />
</div>
</li>
</ul>
</div>
</div>
</template>
<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;
width: 2em;
height: 2em;
}
ul {
padding: 0;
padding: 0;
}
li {
list-style: none;
list-style: none;
}
li > div {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
}
.editing li {
border: 1px solid #ccc;
border-radius: 5px;
padding: 5px;
border: 1px solid #ccc;
border-radius: 5px;
padding: 5px;
}
.ingredient-line {
flex: 1;
margin: 0;
margin-right: 1em;
flex: 1;
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,74 +1,68 @@
<template>
<div class="ingredient-item">
<p>
<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 -->
<compact-parsed-ingredient :ingredient="ingredient" />
</p>
</div>
<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" />
</p>
<p v-if="ingredient.line">
<!-- Single line parse results -->
<compact-parsed-ingredient :ingredient="ingredient" />
</p>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import CompactParsedIngredient from './CompactParsedIngredient.vue'
<script>
import CompactParsedIngredient from './CompactParsedIngredient.vue';
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 ?? ''
},
{ 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)
}
}
export default {
props: {
ingredient: { type: Object },
},
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);
}
}
};
</script>
<style scoped>
input {
border: 0;
font-size: larger;
border-bottom: 1px solid #ccc;
border-left: 1px solid #ccc;
width: 100%;
padding: 0.5vh;
border: 0;
font-size: larger;
border-bottom: 1px solid #ccc;
border-left: 1px solid #ccc;
width: 100%;
padding: 0.5vh;
}
.product-link-input {
color: #777;
margin-top: 0.5vh;
color: #777;
margin-top: 0.5vh;
}
</style>

View file

@ -1,112 +1,121 @@
<template>
<div class="date-picker">
<input
v-model="selectedDate"
type="text"
placeholder="Select a date"
@focus="showDatePicker = true"
@blur="showDatePicker = false"
>
<div
v-if="showDatePicker"
class="date-picker-dropdown"
>
<ul>
<li
v-for="(day, index) in days"
:key="index"
@mousedown="selectDate(day)"
>
{{ formatDay(day) }}
</li>
</ul>
<div class="date-picker">
<input
type="text"
v-model="selectedDate"
@focus="showDatePicker = true"
@blur="showDatePicker = false"
placeholder="Select a date"
/>
<div v-if="showDatePicker" class="date-picker-dropdown">
<ul>
<li
v-for="(day, index) in days"
:key="index"
@mousedown="selectDate(day)">
{{ formatDay(day) }}
</li>
</ul>
</div>
</div>
</div>
</template>
<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)
</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 = [];
for (let i = 0; i < 15; i++) {
const date = new Date(today);
date.setDate(today.getDate() + i);
days.push(date);
}
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;
// 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 {
position: relative;
}
)
const days = computed(() => {
const today = new Date()
const result = []
for (let i = 0; i < 15; i++) {
const d = new Date(today)
d.setDate(today.getDate() + i)
result.push(d)
.date-picker input {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
outline: none;
}
.date-picker-dropdown {
position: absolute;
top: 100%;
width: 100%;
margin: auto;
background-color: #fff;
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.date-picker-dropdown ul {
list-style-type: none;
margin: 0;
padding: 0;
}
.date-picker-dropdown li {
padding: 8px;
cursor: pointer;
border-bottom: 1px solid #ccc;
}
return result
})
function selectDate(date) {
selectedDate.value = formatDay(date)
showDatePicker.value = false
emit('date-selected', date)
}
</script>
.date-picker-dropdown li:hover {
background-color: #eee;
}
<style scoped>
.date-picker {
position: relative;
}
.date-picker input {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
outline: none;
}
.date-picker-dropdown {
position: absolute;
top: 100%;
width: 100%;
margin: auto;
background-color: #fff;
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.date-picker-dropdown ul {
list-style-type: none;
margin: 0;
padding: 0;
}
.date-picker-dropdown li {
padding: 8px;
cursor: pointer;
border-bottom: 1px solid #ccc;
}
.date-picker-dropdown li:hover {
background-color: #eee;
}
.date-picker-dropdown li:last-child {
border-bottom: none;
}
</style>
.date-picker-dropdown li:last-child {
border-bottom: none;
}
</style>

View file

@ -1,368 +1,300 @@
<template>
<div class="container">
<div class="fields">
<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.
</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"
>
<div class="saved-recipe">
<p class="recipe-card">
<recipe-card :recipe="mealRecipe.recipe" />
</p>
<div class="container">
<div class="fields">
<date-picker @date-selected="selectDate" :date="meal.suggested_date" />
<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.
</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">
<div class="saved-recipe">
<p class="recipe-card">
<recipe-card :recipe="mealRecipe.recipe" />
</p>
<p class="servings">
<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')"
>
</label>
<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"
>
<CompactParsedIngredient :ingredient="ingredient" />
</li>
<p class="servings">
<input type="number" v-model="mealRecipe.servings" 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')" />
</label>
<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">
<CompactParsedIngredient :ingredient="ingredient" />
</li>
</ul>
</div>
</li>
</ul>
</div>
</li>
</ul>
<div v-else>
<p>Add some recipes using the search box</p>
</div>
<div class="fields">
<recipe-search-box @select-recipe="selectRecipe" />
</div>
<div v-else>
<p>Add some recipes using the search box</p>
</div>
<div class="fields">
<recipe-search-box @select-recipe="selectRecipe" />
</div>
</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" />
</div>
<button @click="saveMeal">Save</button>
<p v-if="meal.purchase_date"><em>Purchased {{ ago(meal.purchase_date) }}</em></p>
</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"
/>
</div>
<button @click="onSaveMeal">
Save
</button>
<p v-if="meal.purchase_date">
<em>Purchased {{ ago(meal.purchase_date) }}</em>
</p>
</div>
</template>
<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'
<script>
import { ago } from '@/dateformats.js'
import data from '@/data.js';
import alert from '@/alert.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 { 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';
function addPersonIfNotExists(list, person) {
if (!list.find((p) => p.id === person.id)) {
list.push(person)
}
}
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: [],
})
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] })
}
})
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)
}
}
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 getRecipe(recipe.id)
if (recipe.created_by) {
addPersonIfNotExists(meal.chefs, recipe.created_by)
addPersonIfNotExists(meal.consumers, recipe.created_by)
if (meal.cleanup.length === 0) {
addPersonIfNotExists(meal.cleanup, recipe.created_by)
if (!list.find(p => p.id === person.id)) {
list.push(person);
}
}
meal.recipes.push({ recipe, recipe_id: recipe.id, meal_id: meal.id, servings: recipe.serves })
}
async function onEditAdditionalIngredients(editing) {
if (editing && meal.extra_ingredients.length === 0) {
addIngredient()
} else {
meal.extra_ingredients = meal.extra_ingredients.filter((i) => i.line)
}
}
export default {
props: ['id'],
components: { RecipeSearchBox, DatePicker, RecipeCard, EditableIngredientsPanel, PersonList, CompactParsedIngredient },
data() {
return {
showIngredients: {},
meal: {
id: -1,
suggested_date: new Date(),
recipes: [],
extra_ingredients: [],
chefs: [],
consumers: [],
cleanup: []
}
};
},
async beforeMount() {
if (this.id >= 0) {
this.meal = await data.getMeal(this.id);
}
else {
const self = await data.currentUser();
this.meal = {...this.meal, chefs: [self], consumers: [self], cleanup: [self], };
}
},
methods: {
ago,
async selectRecipe(recipe) {
// Refetch to get additional details
recipe = await data.getRecipe(recipe.id);
function showIngredient(mealRecipe, value) {
const index = meal.recipes.indexOf(mealRecipe)
const key = `${mealRecipe.recipe.id}-${index}`
if (value === undefined) {
return showIngredients[key]
}
return (showIngredients[key] = value)
}
if (recipe.created_by) {
addPersonIfNotExists(this.meal.chefs, recipe.created_by);
addPersonIfNotExists(this.meal.consumers, recipe.created_by);
function scaleIngredients(mealRecipe) {
return mealRecipe.recipe.ingredients.map((i) => {
return {
...i,
quantity: (i.quantity * mealRecipe.servings) / mealRecipe.recipe.serves,
if (this.meal.cleanup.length === 0) {
addPersonIfNotExists(this.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;
this.$router.push(`/meals/${meal.id}`);
alert.show({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' });
return;
}
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}`;
if (value === undefined) {
return this.showIngredients[key];
}
return this.showIngredients[key] = value;
},
scaleIngredients(mealRecipe) {
return mealRecipe.recipe.ingredients.map(i => {
return {
...i,
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;
width: 2em;
height: 2em;
}
.persons-list {
text-align: left;
padding: 1ex 2em;
text-align: left;
padding: 1ex 2em;
}
.persons-list p {
margin: 0;
padding-bottom: 1em;
margin: 0;
padding-bottom: 1em;
}
.person-list li {
display: inline-block;
padding-right: 1em;
display: inline-block;
padding-right: 1em;
}
.container {
margin: 1em auto;
margin: 1em auto;
}
ul {
list-style-type: none;
margin: 0;
padding: 0;
list-style-type: none;
margin: 0;
padding: 0;
}
li {
padding-bottom: 0.5vh;
padding-bottom: 0.5vh;
}
.saved-ingredient {
display: flex;
justify-content: space-between;
align-items: center;
display: flex;
justify-content: space-between;
align-items: center;
}
.saved-recipe {
display: flex;
justify-content: space-between;
align-items: center;
display: flex;
justify-content: space-between;
align-items: center;
}
.saved-ingredient p {
flex: 1;
margin: 0;
margin-right: 1em;
flex: 1;
margin: 0;
margin-right: 1em;
}
.saved-recipe button:hover {
background: #eee;
background: #eee;
}
.saved-recipe .recipe-card {
margin: 0;
margin: 0;
}
.recipe-card {
flex: 1;
flex: 1;
}
.servings {
display: inline-block;
margin-right: 1em;
display: inline-block;
margin-right: 1em;
}
.servings input {
width: 3em;
border: none;
border-bottom: 1px solid #000;
text-align: center;
font-size: large;
font-style: italic;
width: 3em;
border: none;
border-bottom: 1px solid #000;
text-align: center;
font-size: large;
font-style: italic;
}
.icon-button {
background: none;
border: none;
cursor: pointer;
border-radius: 1em;
padding: 0.5em;
background: none;
border: none;
cursor: pointer;
border-radius: 1em;
padding: 0.5em;
}
.icon-button:hover {
/* Invert the colors of the trash icon */
filter: invert(1);
/* Invert the colors of the trash icon */
filter: invert(1);
}
.show-ingredient-checkbox {
display: none;
display: none;
}
.show-ingredient-checkbox + label {
cursor: pointer;
background-color: #fff;
padding: 0.5em;
border-radius: 1em;
cursor: pointer;
background-color: #fff;
padding: 0.5em;
border-radius: 1em;
}
.show-ingredient-checkbox + label:hover {
background-color: #eee;
background-color: #eee;
}
.show-ingredient-checkbox:checked + label {
filter: invert(1);
filter: invert(1);
}
</style>
</style>

View file

@ -1,87 +1,85 @@
<template>
<div class="meal-card">
<h3>{{ mealTitle }}</h3>
<h4>
{{ dayOfWeek }} <small>{{ date }}</small>
</h4>
<p>
Cooked by
<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"
>
{{ consumer.name }}{{ englishSeperator(index, meal.consumers) }}
</span>
<span v-if="!meal.consumers.length">somebody?</span>
</p>
<p v-if="meal.purchase_date">
Purchased {{ ago(meal.purchase_date) }}
</p>
</div>
<div class="meal-card">
<h3>{{ mealTitle }}</h3>
<h4>{{ dayOfWeek }} <small>{{ date }}</small></h4>
<p>
Cooked by
<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">
{{ consumer.name }}{{ englishSeperator(index, meal.consumers) }}
</span>
<span v-if="!meal.consumers.length">somebody?</span>
</p>
<p v-if="meal.purchase_date">
Purchased {{ ago(meal.purchase_date) }}
</p>
</div>
</template>
<script setup>
import { computed } from 'vue'
<style>
</style>
<script>
import { ago } from '@/dateformats.js'
const props = defineProps({
meal: { type: Object, required: true },
})
function englishSeperator(index, list) {
switch (index) {
case list.length - 1:
return ''
case list.length - 2:
return ' and '
default:
return ', '
}
switch (index) {
case list.length - 1:
return '';
case list.length - 2:
return ' and ';
default:
return ', ';
}
}
function englishList(list) {
switch (list.length) {
case 0:
return ''
case 1:
return list[0]
case 2:
return `${list[0]} and ${list[1]}`
default:
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`
}
switch (list.length) {
case 0:
return '';
case 1:
return list[0];
case 2:
return `${list[0]} and ${list[1]}`;
default:
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`;
}
}
const date = computed(() =>
props.meal.suggested_date.toLocaleDateString('en-au', {
month: 'numeric',
day: 'numeric',
})
)
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 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>
if (recipesText && ingredientsText) {
return `${recipesText} with ${ingredientsText}`;
} else if (recipesText || ingredientsText) {
return recipesText || ingredientsText;
} else {
return 'Nothing planned';
}
}
},
methods: {
englishSeperator,
ago
}
}
</script>

View file

@ -1,146 +1,118 @@
<template>
<div>
<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
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
class="button"
@click="deleteSelectedMeal"
>Remove</a>
</li>
<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')" />
</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>
<li><a @click="markConsumed">Mark Consumed</a></li>
<li><a @click="deleteSelectedMeal" class="button">Remove</a></li>
</ul>
</li>
</ul>
</li>
</ul>
<div v-if="!meals.length">
<em>No meals planned</em>
<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')" />
</div>
<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;
list-style-type: none;
}
ul.meals-list {
padding: 0;
padding: 0;
}
.toggle-actions {
cursor: pointer;
background-color: #fff;
border: none;
border-bottom: solid 1px #ccc;
padding: 0 2em;
margin: 0;
cursor: pointer;
background-color: #fff;
border: none;
border-bottom: solid 1px #ccc;
padding: 0 2em;
margin: 0;
}
.toggle-actions img {
width: 2em;
height: 2em;
width: 2em;
height: 2em;
}
.meals-list > li {
margin-bottom: 1em;
padding: 0;
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
margin-bottom: 1em;
padding: 0;
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
}
ul.actions {
margin: 0;
padding: 0;
margin: 0;
padding: 0;
}
.actions li {
display: block;
border: 1px solid #ccc;
display: block;
border: 1px solid #ccc;
}
.actions li:hover {
background-color: #ccc;
background-color: #ccc;
}
.actions li a {
display: block;
text-decoration: none;
color: #000;
width: 100%;
cursor: pointer;
padding-top: 2ex;
padding-bottom: 2ex;
display: block;
text-decoration: none;
color: #000;
width: 100%;
cursor: pointer;
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,191 +1,171 @@
<template>
<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 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>
<span>
<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>
<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>
</li>
</ul>
</span>
</span>
</span>
</template>
<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;
min-height: 50px;
padding: 0;
margin: 0;
display: inline-block;
text-align: center;
min-height: 50px;
padding: 0;
margin: 0;
}
/* Remove all the button styling */
.person-circle {
background: none;
border: none;
padding: 0;
margin: 0;
background: none;
border: none;
padding: 0;
margin: 0;
}
/* Show the initials of the person in a circle */
.person-circle {
display: inline-block;
width: 50px;
height: 50px;
line-height: 50px;
text-align: center;
border-radius: 50%;
background: #eee;
display: inline-block;
width: 50px;
height: 50px;
line-height: 50px;
text-align: center;
border-radius: 50%;
background: #eee;
}
/* On hover, prompt the user to click to remove the person */
/* change opacity of circle, and use css to add a large cross over the circle */
.remove-person:hover {
cursor: pointer;
position: relative;
background-color: lightcoral;
cursor: pointer;
position: relative;
background-color: lightcoral;
}
/* Add a cross to the circle */
.remove-person:hover::before,
.remove-person:hover::after {
pointer-events: none;
content: 'X';
color: white;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
.remove-person:hover::before, .remove-person:hover::after {
pointer-events: none;
content: "X";
color: white;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.add-person {
background-color: lightgreen;
background-color: lightgreen;
}
.add-person:hover {
cursor: pointer;
font-weight: bolder;
color: white;
background-color: green;
cursor: pointer;
font-weight: bolder;
color: white;
background-color: green;
}
.person-droplist {
position: absolute;
background-color: white;
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
list-style-type: none;
padding: 0;
margin: 0;
max-height: 40vh;
overflow-y: scroll;
z-index: 1000;
position: absolute;
background-color: white;
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
list-style-type: none;
padding: 0;
margin: 0;
max-height: 40vh;
overflow-y: scroll;
z-index: 1000;
}
.person-droplist li {
padding: 8px;
cursor: pointer;
display: inline;
padding: 1ex 1em;
padding: 8px;
cursor: pointer;
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,206 +1,153 @@
<template>
<div>
<div>
<div v-if="!id && !recipe">
<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>
<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>
</div>
<div v-if="parse_failed">
<p>Recipe not found</p>
<p>Recipe not found</p>
</div>
<div v-if="!parse_failed && recipe">
<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
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"
/>
<div class="image-container" v-if="image_styling" :style="image_styling" ></div>
<h1><input class="recipe-name" type="text" v-model="recipe.name" /></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>
<h2>Ingredients</h2>
<editable-ingredients-panel
:ingredients="recipe.ingredients"
:edit-only="true"
@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>
</div>
<div>
<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;
border-bottom: 1px solid #ccc;
font-size: large;
border: 0;
border-bottom: 1px solid #ccc;
font-size: large;
}
input.recipe-link {
width: 80%;
width: 80%;
}
.image-container {
max-height: 20vh;
min-height: 20vh;
display: flex;
flex-direction: column;
max-height: 20vh;
min-height: 20vh;
display: flex;
flex-direction: column;
}
input.recipe-name {
width: 100%;
font-weight: bold;
font-size: larger;
width: 100%;
font-weight: bold;
font-size: larger;
}
.recipe-link {
color: #0000ee;
text-decoration: none;
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,51 +1,46 @@
<template>
<div class="recipe-card">
<p>
<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>
</div>
<div class="recipe-card">
<p>
<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>
</div>
</template>
<script setup>
defineProps({
recipe: { type: Object, required: true },
})
<script>
export default {
name: 'RecipeCard',
props: ['recipe']
}
</script>
<style scoped>
.recipe-card {
display: flex;
flex-direction: row;
justify-content: space-between;
text-align: left;
max-height: 10em;
margin: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
text-align: left;
max-height: 10em;
margin: 0;
}
.recipe-card p {
margin: auto 0;
margin-right: 1em;
margin: auto 0;
margin-right: 1em;
}
li img {
display: block;
width: 3em;
height: 3em;
object-fit: cover;
display: block;
width: 3em;
height: 3em;
object-fit: cover;
}
.recipe-name {
flex: 1;
margin: auto;
flex: 1;
margin: auto;
}
</style>
</style>

View file

@ -1,132 +1,99 @@
<template>
<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>
<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)">
<recipe-card :recipe="recipe" />
</li>
</ul>
</div>
</template>
<script setup>
import { ref, watch, onBeforeUnmount } from 'vue'
import { searchRecipes } from '@/api/recipes'
import RecipeCard from './RecipeCard.vue'
<script>
import data from '@/data.js'
import RecipeCard from './RecipeCard.vue';
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
export default {
name: 'RecipeSearchBox',
components: { RecipeCard },
props: {
placeholder: { type: String, default: 'Add a recipe...' }
},
data() {
return {
searchTerm: '',
recipes: [],
timeouts: [],
}
},
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>
.recipe-search-box {
position: relative;
position: relative;
}
.recipe-search-box input {
width: calc(100% - 2em);
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
outline: none;
width: calc(100% - 2em);
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
outline: none;
}
.recipe-search-box .dropdown {
position: absolute;
top: 100%;
width: 100%;
margin: auto;
background-color: #fff;
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
list-style-type: none;
margin: 0;
padding: 0;
max-height: 40vh;
overflow-y: scroll;
position: absolute;
top: 100%;
width: 100%;
margin: auto;
background-color: #fff;
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
list-style-type: none;
margin: 0;
padding: 0;
max-height: 40vh;
overflow-y: scroll;
}
.recipe-search-box .dropdown li {
padding: 8px;
cursor: pointer;
border-bottom: 1px solid #ccc;
padding: 8px;
cursor: pointer;
border-bottom: 1px solid #ccc;
}
.recipe-search-box .dropdown li:last-child {
border-bottom: none;
border-bottom: none;
}
.recipe-search-box .dropdown li:hover {
background-color: #eee;
background-color: #eee;
}
</style>
</style>

View file

@ -1,28 +1,18 @@
<template>
<div>
<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>
<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')" />
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
<script>
import ActionItem from '@/components/ActionItem.vue'
import RecipeSearchBox from './RecipeSearchBox.vue'
import RecipeSearchBox from './RecipeSearchBox.vue';
const router = useRouter()
function onSelectRecipe(r) {
router.push(`/recipes/${r.id}`)
export default {
name: 'ActionsPage',
components: {
ActionItem,
RecipeSearchBox
}
}
function onAddRecipe() {
router.push('/recipes/add')
}
</script>
</script>

View file

@ -1,257 +1,250 @@
<template>
<h3>Full shopping list</h3>
<h4>Included Meals</h4>
<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)"
>
<shopping-list-item :shopping-list-item-group="group" />
</li>
</ul>
<div v-if="outstandingItemGroups.length === 0">
<p>No items to purchase</p>
</div>
<div class="purchased-slider">
<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
:checked="includedMeals"
:meals="purchasedMeals"
@meal-selected="mealSelected"
@meal-unselected="mealUnselected"
/>
<h4>Purchased Items</h4>
<ul class="full-shopping-list">
<li
v-for="item in purchasedItemGroups"
:key="item.id"
>
<shopping-list-item :shopping-list-item-group="item" />
<h3>Full shopping list</h3>
<h4>Included Meals</h4>
<meal-selection-list @meal-selected="mealSelected" @meal-unselected="mealUnselected" :checked="includedMeals" :meals="availableMeals" />
<ul class="full-shopping-list">
<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>
</ul>
<div v-if="outstandingItemGroups.length === 0">
<p>
No items to purchase
</p>
</div>
</div>
<div
v-if="selected.length"
class="spacer"
>
&nbsp;
</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>
<!-- Display 'Stocked', 'Purchased' and 'Cancel' buttons in a vertical stack fixed to the bottom of the screen when any elements are selected -->
<div
v-if="selected.length"
class="footer-buttons"
>
<p v-if="selected.length === 1">
Mark '{{ selected[0].product?.name ?? selected[0].name }}' as
</p>
<p v-else>
Mark {{ selected.length }} items as
</p>
<div v-if="showPurchased && purchasedItemGroups.length > 0">
<h4>Purchased Meals</h4>
<meal-selection-list @meal-selected="mealSelected" @meal-unselected="mealUnselected" :checked="includedMeals" :meals="purchasedMeals" />
<div class="button-group">
<button @click="markFound">
<img src="@/assets/house-check.svg"><br>
Found
</button>
<button @click="markPurchased">
<img src="@/assets/shopping-cart.svg"><br>
Purchased
</button>
<button @click="selected = []">
<img src="@/assets/close.svg"><br>
Cancel
</button>
<h4>
Purchased Items
</h4>
<ul class="full-shopping-list">
<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>
<!-- Display 'Stocked', 'Purchased' and 'Cancel' buttons in a vertical stack fixed to the bottom of the screen when any elements are selected -->
<div class="footer-buttons" v-if="selected.length">
<p v-if="selected.length === 1">
Mark '{{ selected[0].product?.name ?? selected[0].name }}' as
</p>
<p v-else>
Mark {{ selected.length }} items as
</p>
<div class="button-group">
<button @click="markFound">
<img src="@/assets/house-check.svg" /><br />
Found
</button>
<button @click="markPurchased">
<img src="@/assets/shopping-cart.svg" /><br />
Purchased
</button>
<button @click="selected = []">
<img src="@/assets/close.svg" /><br />
Cancel
</button>
</div>
</div>
</div>
</template>
<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;
padding: 0;
}
.full-shopping-list li {
list-style-type: none;
outline: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
list-style-type: none;
outline: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
}
.full-shopping-list li.selectable {
cursor: pointer;
outline: 1px solid #ccc;
cursor: pointer;
outline: 1px solid #ccc;
}
.full-shopping-list li.selected {
background-color: #f0f0f0;
outline-width: 3px;
background-color: #f0f0f0;
outline-width: 3px;
}
.footer-buttons {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 1ex;
background-color: #f0f0f0;
border-top: 1px solid #ccc;
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 1ex;
background-color: #f0f0f0;
border-top: 1px solid #ccc;
}
.footer-buttons p {
margin: 0;
text-align: left;
font-style: italic;
margin: 0;
text-align: left;
font-style: italic;
}
.button-group {
/* Display as vertical fixed to the bottom of the screen */
display: flex;
/* Display as vertical fixed to the bottom of the screen */
display: flex;
}
.button-group button {
flex: 1;
padding: 1em;
border: 1px solid #ccc;
border-radius: 0.5em;
background-color: #f0f0f0;
cursor: pointer;
flex: 1;
padding: 1em;
border: 1px solid #ccc;
border-radius: 0.5em;
background-color: #f0f0f0;
cursor: pointer;
}
button img {
width: 2em;
height: 2em;
width: 2em;
height: 2em;
}
.spacer {
height: 12em;
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,149 +1,160 @@
<template>
<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
:id="meal.id"
type="checkbox"
:checked="isChecked(meal)"
:disabled="disabled"
@change="mealCheckChanged"
>
<label
:for="meal.id"
:style="getImageStyling(meal)"
>
{{ formatDate(meal.suggested_date) }}
</label>
<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)">
{{ formatDate(meal.suggested_date) }}
</label>
</li>
</ul>
</ul>
</template>
<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;
padding: 0;
}
li {
display: inline-block;
list-style-type: none;
border: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
padding: none;
overflow: hidden;
text-align: center;
display: inline-block;
list-style-type: none;
border: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
padding: none;
overflow: hidden;
text-align: center;
}
/* Hide the default checkbox formatting, and format the card instead */
input[type='checkbox'] {
display: none;
input[type="checkbox"] {
display: none;
}
label {
display: inline-block;
padding: 0.1vh 0.3em;
/* Help the visibility of the text over the image */
background-color: rgba(255, 255, 255, 0.9);
border: 3px solid transparent;
border-radius: 0.5em;
margin: 0;
font-weight: normal;
cursor: pointer;
color: #3d5447;
display: inline-block;
padding: 0.1vh 0.3em;
/* Help the visibility of the text over the image */
background-color: rgba(255, 255, 255, 0.9);
border: 3px solid transparent;
border-radius: 0.5em;
margin: 0;
font-weight: normal;
cursor: pointer;
color: #3d5447;
}
input[type='checkbox']:checked + label {
border: 3px solid #3d5447;
text-shadow: #ccc 0 0 0.1em;
input[type="checkbox"]:checked + label {
border: 3px solid #3d5447;
text-shadow: #ccc 0 0 0.1em;
}
input[type='checkbox']:disabled + label {
cursor: not-allowed;
input[type="checkbox"]:disabled + label {
cursor: not-allowed;
}
/* Show the image as the background image of the card */
.meal-image {
width: 100%;
height: 100%;
object-fit: cover;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Position the text in the center of the card */
label {
display: flex;
justify-content: center;
align-items: center;
font-size: larger;
font-weight: bold;
display: flex;
justify-content: center;
align-items: center;
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,19 +1,11 @@
<template>
<div>
<h1>My Shopping List</h1>
<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>
<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" />
</div>
<!--
<!--
# Functions
* Add a random item to next shop
* Add meals to next shop
@ -39,52 +31,53 @@
-->
</template>
<script setup>
import { ref, onBeforeMount } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import { useShopping } from '@/composables/useShopping'
<style scoped>
</style>
<script>
import data from '@/data.js'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
const router = useRouter()
const { loadUser } = useAuth()
const { getMyShoppingList, saveMyShoppingList } = useShopping()
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 person = ref(null)
const ingredients = ref([])
this.person = person;
await this.updateShoppingList();
},
methods: {
async updateShoppingList(save = false) {
const new_ingredients = save ?
await data.saveMyShoppingList(this.ingredients) :
await data.getMyShoppingList();
async function updateShoppingList(save = false) {
const newIngredients = save
? await saveMyShoppingList(ingredients.value)
: await getMyShoppingList()
ingredients.value = newIngredients
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();
}
}
}
}
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>
</script>

View file

@ -1,61 +1,70 @@
<template>
<h3>Purchased {{ shoppingList ? ago(shoppingList.created_date) : '' }}</h3>
<h3>Purchased {{ shoppingList ? ago(shoppingList.created_date) : '' }}</h3>
<div v-if="includedMeals.length > 0">
<h4>Included Meals</h4>
<meal-selection-list :checked="includedMeals" :meals="includedMeals" :disabled="true" />
</div>
<div v-if="includedMeals.length > 0">
<h4>Included Meals</h4>
<meal-selection-list
:checked="includedMeals"
:meals="includedMeals"
:disabled="true"
/>
</div>
<ul class="full-shopping-list">
<li
v-for="item in listByProduct"
:key="item.id"
>
<shopping-list-item :shopping-list-item-group="item" />
</li>
</ul>
<ul class="full-shopping-list">
<li v-for="item in listByProduct" :key="item.id">
<shopping-list-item :shopping-list-item-group="item" />
</li>
</ul>
</template>
<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;
border: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
.full-shopping-list li {
list-style-type: none;
border: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
}
.full-shopping-list {
padding: 0;
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,204 +1,174 @@
<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>,
<small>
<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
v-if="purchased.length > 0"
class="found-marker partial"
> {{ getFriendlyDate(lastPurchased) }}</span>
</small>
</h3>
<p
v-if="required.length > 0"
class="sources"
>
<strong>Need: </strong>
<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 }}
</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>
</span>
<span v-else-if="source.meal">
{{ source.ingredient.line }} for
<router-link :to="`/meals/${source.meal.id}/`">{{
source.meal.suggested_date.toLocaleDateString('en-AU', {
weekday: 'long',
month: 'long',
day: 'numeric',
})
}}</router-link>
</span>
</span>
</p>
<p v-if="purchased.length > 0">
<strong>Already found or purchased: </strong>
<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 }}
</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>
</span>
<span v-else-if="source.meal">
{{ source.ingredient.line }} for
<router-link :to="`/meals/${source.meal.id}/`">{{
source.meal.suggested_date.toLocaleDateString('en-AU', {
weekday: 'long',
month: 'long',
day: 'numeric',
})
}}</router-link>
</span>
</span>
</p>
<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>,
<small>
<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>
</small>
</h3>
<p class="sources" v-if="required.length > 0">
<strong>Need: </strong>
<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 }}
</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>
</span>
<span v-else-if="source.meal">
{{ source.ingredient.line }} for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
</span>
</span>
</p>
<p v-if="purchased.length > 0">
<strong>Already found or purchased: </strong>
<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 }}
</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>
</span>
<span v-else-if="source.meal">
{{ source.ingredient.line }} for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
</span>
</span>
</p>
</div>
</div>
</div>
</template>
<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 {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 1em;
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 1em;
}
.product-image {
max-width: 5em;
max-height: 4em;
margin-right: 1em;
max-width: 5em;
max-height: 4em;
margin-right: 1em;
}
.product-details {
flex-grow: 1;
text-align: left;
flex-grow: 1;
text-align: left;
}
.header {
margin: 0;
margin: 0;
}
.found-marker {
margin: 1ex;
padding-top: 0.6ex;
padding-bottom: 0.5ex;
padding-left: 1em;
padding-right: 1em;
border-radius: 25px;
margin: 1ex;
padding-top: 0.6ex;
padding-bottom: 0.5ex;
padding-left: 1em;
padding-right: 1em;
border-radius: 25px;
text-align: center;
white-space: nowrap;
font-size: smaller;
color: white;
font-weight: bold;
z-index: 1000;
text-align: center;
white-space: nowrap;
font-size: smaller;
color: white;
font-weight: bold;
z-index: 1000;
}
.found-marker.found {
background-color: green;
background-color: green;
}
.found-marker.partial {
background-color: darkgoldenrod;
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,42 +1,45 @@
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 = {}
for (const item of shoppingListItems) {
if (item.ingredient.product) {
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: [],
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];
if (!group) {
group = ingredients_by_product_id[item.ingredient.product.id] = {
product: item.ingredient.product,
shoppingListItems: []
};
}
group.shoppingListItems.push(item);
}
}
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: [],
else {
let group = ingredients_by_name[item.ingredient.name];
if (!group) {
group = ingredients_by_name[item.ingredient.name] = {
name: item.ingredient.name,
shoppingListItems: []
};
}
group.shoppingListItems.push(item);
}
}
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

@ -1,27 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1,25 +0,0 @@
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

@ -1,27 +0,0 @@
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)
},
}
}

332
src/data.js Normal file
View file

@ -0,0 +1,332 @@
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
if (diff < 1000) {
return 'just now'
}
if (diff < 60 * 1000) {
return plural(diff / 1000, 'second') + ' ago'
}
if (diff < 60 * 60 * 1000) {
return plural(diff / (60 * 1000), 'minute') + ' ago'
}
if (diff < 24 * 60 * 60 * 1000) {
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 date.toLocaleDateString()
}
const now = new Date();
const diff = now - date;
if (diff < 1000) {
return "just now";
}
if (diff < 60 * 1000) {
return plural(diff / 1000, "second") + " ago";
}
if (diff < 60 * 60 * 1000) {
return plural(diff / (60 * 1000), "minute") + " ago";
}
if (diff < 24 * 60 * 60 * 1000) {
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 date.toLocaleDateString();
}

View file

@ -1,11 +1,51 @@
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import App from './App.vue'
import { createAppRouter } from '@/router'
import { currentUser } from '@/api/auth'
// Create the router with an auth callback to check current user
const router = createAppRouter(() => currentUser())
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'
// 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!

View file

@ -1,80 +0,0 @@
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,
},
litres: {
l: 1,
liter: 1,
litre: 1,
'lb': 2.20462,
'lbs': 2.20462,
'pound': 2.20462,
'pounds': 2.20462,
},
'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,
'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,
},
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
},
'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
},
}
function getBaseUnit(unit) {
for (const unitType in equivalentUnits) {
if (unit in equivalentUnits[unitType]) {
return unitType
for (const unitType in equivalentUnits) {
if (unit in equivalentUnits[unitType]) {
return unitType;
}
}
}
return null
return null;
}
export function getConversionFactor(unit) {
if (unit in equivalentUnits) {
return { unit, factor: 1 }
}
const unitLower = unit.toLowerCase()
if (unitLower in equivalentUnits) {
return { unit: unitLower, factor: 1 }
}
const baseUnit = getBaseUnit(unit)
if (baseUnit) {
return {
unit: baseUnit,
factor: equivalentUnits[baseUnit][unit],
if (unit in equivalentUnits) {
return { unit, factor: 1 };
}
}
const baseUnitLower = getBaseUnit(unitLower)
if (baseUnitLower) {
return {
unit: baseUnitLower,
factor: equivalentUnits[baseUnitLower][unitLower],
const unitLower = unit.toLowerCase();
if (unitLower in equivalentUnits) {
return { unit: unitLower, factor: 1 };
}
const baseUnit = getBaseUnit(unit);
if (baseUnit) {
return {
unit: baseUnit,
factor: equivalentUnits[baseUnit][unit],
};
}
}
return null
const baseUnitLower = getBaseUnit(unitLower);
if (baseUnitLower) {
return {
unit: baseUnitLower,
factor: equivalentUnits[baseUnitLower][unitLower],
};
}
return null;
}
export function calculateTotals(quantityList) {
const totals = {}
for (const quantity of quantityList) {
const baseUnit = getConversionFactor(quantity.unit) ?? { unit: quantity.unit, factor: 1 }
const factor = baseUnit.factor
const unit = baseUnit.unit
const totals = {};
for (const quantity of quantityList) {
const baseUnit = getConversionFactor(quantity.unit) ?? { unit: quantity.unit, factor: 1 };
const factor = baseUnit.factor;
const unit = baseUnit.unit;
if (!totals[unit]) {
totals[unit] = 0
if (!totals[unit]) {
totals[unit] = 0;
}
totals[unit] += quantity.quantity / factor;
}
totals[unit] += quantity.quantity / factor
}
return Object.keys(totals).map((unit) => ({ unit, quantity: totals[unit] }))
}
return Object.keys(totals).map(unit => ({ unit, quantity: totals[unit] }));
}

View file

@ -1,27 +0,0 @@
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

@ -1,33 +0,0 @@
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')
})
})

View file

@ -1,27 +0,0 @@
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)
})
})

View file

@ -1,26 +0,0 @@
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()
})
})

View file

@ -1,16 +0,0 @@
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
})