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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,90 +1,98 @@
<template> <template>
<div
v-if="showAlert" <div v-if="showAlert" :class="['alert', type]" @click="dismiss">
:class="['alert', type]" <img v-if="icon" :src="icon" alt="Notification icon" />
@click="dismiss"
>
<img
v-if="icon"
:src="icon"
alt="Notification icon"
>
<div class="message-container"> <div class="message-container">
<h4 class="heading"> <h4 class="heading">{{ heading }}</h4>
{{ heading }} <p class="message">{{ message }}</p>
</h4>
<p class="message">
{{ message }}
</p>
</div> </div>
</div> </div>
</template> </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> <style scoped>
/* Display as a toast, in the bottom right corner */ /* Display as a toast, in the bottom right corner */
/* Place the icon to the left for the full height, then have the heading and message stacked to the right */ /* Place the icon to the left for the full height, then have the heading and message stacked to the right */
.alert { .alert {
position: fixed; position: fixed;
bottom: 1em; bottom: 1em;
right: 1em; right: 1em;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 1em; padding: 1em;
border-radius: 5px; border-radius: 5px;
color: white; color: white;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
text-align: left; text-align: left;
} }
.alert img { .alert img {
width: 2em; width: 2em;
height: 2em; height: 2em;
margin-right: 1em; margin-right: 1em;
/* Invert svg colors */ /* Invert svg colors */
filter: invert(1); filter: invert(1);
} }
.alert.error { .alert.error {
background-color: #f44336; background-color: #f44336;
} }
.alert.success { .alert.success {
background-color: #4caf50; background-color: #4CAF50;
} }
.alert.info { .alert.info {
background-color: #2196f3; background-color: #2196F3;
} }
</style> </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> <template>
<div class="login">
<h1>Login Page</h1> <div class="login">
<ul class="button-group"> <h1>Login Page</h1>
<li <ul class="button-group">
v-for="person in persons" <li v-for="person in persons" :key="person.id">
:key="person.id" <button type="button" class="btn btn-primary" @click="login(person)">
> {{ person.name }}
<button </button>
type="button" </li>
class="btn btn-primary" </ul>
@click="onLogin(person)" </div>
>
{{ person.name }}
</button>
</li>
</ul>
</div>
</template> </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> <style scoped>
/* Remove the default list styling */ /* Remove the default list styling */
ul { ul {
list-style-type: none; list-style-type: none;
padding: 0; padding: 0;
} }
/* Center the buttons in the middle of the page, and let them wrap */ /* Center the buttons in the middle of the page, and let them wrap */
.button-group { .button-group {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
} }
/* Display each button as a large round circle */ /* Display each button as a large round circle */
button { button {
width: 100px; width: 100px;
height: 100px; height: 100px;
border-radius: 50%; border-radius: 50%;
margin: 10px; margin: 10px;
font-size: 1.5em; font-size: 1.5em;
/* Center the text in the middle of the button */ /* Center the text in the middle of the button */
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
/* Add a shadow to make the buttons look like they are floating */ /* Add a shadow to make the buttons look like they are floating */
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
} }
/* Some nice flat nuetral shades for the user buttons */ /* Some nice flat nuetral shades for the user buttons */
li:nth-child(1) > button { li:nth-child(1) > button {
background-color: #3939ff; background-color: #3939ff;
color: white; color: white;
} }
li:nth-child(2) > button { li:nth-child(2) > button {
background-color: #156a14; background-color: #156a14;
color: white; color: white;
} }
li:nth-child(3) > button { li:nth-child(3) > button {
background-color: #325293; background-color: #325293;
color: white; color: white;
} }
li:nth-child(4) > button { li:nth-child(4) > button {
background-color: #9a1f1f; background-color: #9a1f1f;
color: white; color: white;
} }
</style> </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> <template>
<div class="compact-parse-results"> <div class="compact-parse-results">
<p class="parse-element teaser-image"> <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>
<p class="ingredient-details"> <p class="ingredient-details">
<span <span class="parse-element quantity" :class="{ missing: !(ingredient?.quantity)}">{{ ingredient?.quantity || 'qty' }}</span>
class="parse-element quantity" <span class="parse-element unit" :class="{ missing: !(ingredient?.unit)}">{{ ingredient?.unit || 'unit' }}</span>
:class="{ missing: !ingredient?.quantity }" <span class="parse-element helper" >of</span>
>{{ <span class="parse-element name" :class="{ missing: !(ingredient?.name)}">{{ ingredient?.name || 'name' }}</span>:&nbsp;
ingredient?.quantity || 'qty' <span class="parse-element product-name" :class="{missing: !(ingredient?.product)}">
}}</span> <a :href="ingredient?.product?.link" v-if="ingredient?.product?.link" target=_blank>
<span ( {{ ingredient?.product?.name }} <img src="@/assets/external-link.svg" style="width: 1em; height: 1em; vertical-align: middle; margin-left: 0.5em; margin-bottom: 0.2em;" /> )
class="parse-element unit" </a>
:class="{ missing: !ingredient?.unit }" <a v-else-if="ingredient?.name" :href="searchlink" target="_blank">
>{{ (search?)
ingredient?.unit || 'unit' </a>
}}</span> <a v-else>
<span class="parse-element helper">of</span> (product)
<span </a>
class="parse-element name" </span>
:class="{ missing: !ingredient?.name }"
>{{
ingredient?.name || 'name'
}}</span>:&nbsp;
<span
class="parse-element product-name"
:class="{ missing: !ingredient?.product }"
>
<a
v-if="ingredient?.product?.link"
:href="ingredient?.product?.link"
target="”_blank”"
>
( {{ ingredient?.product?.name }}
<img
src="@/assets/external-link.svg"
style="
width: 1em;
height: 1em;
vertical-align: middle;
margin-left: 0.5em;
margin-bottom: 0.2em;
"
>
)
</a>
<a
v-else-if="ingredient?.name"
:href="searchlink"
target="_blank"
> (search?) </a>
<a v-else> (product) </a>
</span>
</p> </p>
</div> </div>
</template> </template>
<script setup> <script>
import { computed } from 'vue'
const props = defineProps({ export default {
ingredient: { type: Object, required: true }, 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> </script>
<style scoped> <style scoped>
.compact-parse-results { .compact-parse-results {
flex: left; flex: left;
display: flex; display: flex;
justify-content: left; justify-content: left;
text-align: left; text-align: left;
} }
.parse-element { .parse-element {
margin: auto 0; margin: auto 0;
margin-right: 1em; margin-right: 1em;
border-bottom: solid 1px #ccc; border-bottom: solid 1px #ccc;
font-weight: bold; font-weight: bold;
} }
.parse-element.missing { .parse-element.missing {
@ -91,21 +58,20 @@ const searchlink = computed(() =>
border: solid red 1px; border: solid red 1px;
} }
.parse-element.teaser-image, .parse-element.teaser-image, .parse-element.helper {
.parse-element.helper { border: none;
border: none; font-weight: normal;
font-weight: normal;
} }
.parse-element.teaser-image img { .parse-element.teaser-image img {
padding: 0; padding: 0;
border: none; border: none;
width: 2em; width: 2em;
height: 2em; height: 2em;
} }
.ingredient-details { .ingredient-details {
flex: 1; flex: 1;
} }
.quantity { .quantity {
@ -123,4 +89,5 @@ const searchlink = computed(() =>
.product-name { .product-name {
color: purple; color: purple;
} }
</style> </style>

View file

@ -1,122 +1,107 @@
<template> <template>
<div :class="{ editing: editing }">
<button <div :class="{ editing: editing }">
v-if="editing" <button v-if="editing" @click="$emit('on-add')">
@click="emit('on-add')" <img class="icon" :src="require('@/assets/add-cart.svg')" /> <br />
> Add Ingredient
<img
class="icon"
:src="require('@/assets/add-cart.svg')"
> <br>
Add Ingredient
</button> </button>
<button <button @click="toggleEditing" v-if="!editOnly">
v-if="!editOnly" <span v-if="editing">
@click="toggleEditing" <img class="icon" :src="require('@/assets/edit-off.svg')" /> <br />
> Done Editing
<span v-if="editing"> </span>
<img <span v-else>
class="icon" <img class="icon" :src="require('@/assets/edit.svg')" /> <br />
:src="require('@/assets/edit-off.svg')" Edit My List
> <br> </span>
Done Editing
</span>
<span v-else>
<img
class="icon"
:src="require('@/assets/edit.svg')"
> <br>
Edit My List
</span>
</button> </button>
<ul> <ul>
<li <li v-for="ingredient in ingredients" :key="ingredient">
v-for="ingredient in ingredients" <div v-if="editing">
:key="ingredient" <p class="ingredient-line">
> <ingredient-line
<div v-if="editing"> :ingredient="ingredient"
<p class="ingredient-line"> @update-ingredient="updateIngredient"
<ingredient-line @update-product-link="updateProduct" />
:ingredient="ingredient" </p>
@update-ingredient="updateIngredient" <button @click="$emit('on-delete', ingredient)">
@update-product-link="updateProduct" <img class="icon" :src="require('@/assets/trash.svg')" />
/> </button>
</p> </div>
<button @click="emit('on-delete', ingredient)"> <div v-else>
<img <compact-parsed-ingredient :ingredient="ingredient" />
class="icon" </div>
:src="require('@/assets/trash.svg')" </li>
>
</button>
</div>
<div v-else>
<compact-parsed-ingredient :ingredient="ingredient" />
</div>
</li>
</ul> </ul>
</div> </div>
</template> </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> <style scoped>
.icon { .icon {
width: 2em; width: 2em;
height: 2em; height: 2em;
} }
ul { ul {
padding: 0; padding: 0;
} }
li { li {
list-style: none; list-style: none;
} }
li > div { li > div {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
width: 100%; width: 100%;
} }
.editing li { .editing li {
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 5px; border-radius: 5px;
padding: 5px; padding: 5px;
} }
.ingredient-line { .ingredient-line {
flex: 1; flex: 1;
margin: 0; margin: 0;
margin-right: 1em; margin-right: 1em;
} }
</style> </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> <template>
<div class="ingredient-item"> <div class="ingredient-item">
<p> <p>
<input <input v-model="ingredientText" @keyup.enter="updateIngredient" @blur="updateIngredient" placeholder="Enter an ingredient" />
v-model="ingredientText" <input v-model="productLink" v-if="ingredient.line" class="product-link-input" placeholder="Enter product link" @keyup.enter="updateProductLink" @blur="updateProductLink" />
placeholder="Enter an ingredient" </p>
@keyup.enter="updateIngredient" <p v-if="ingredient.line">
@blur="updateIngredient" <!-- Single line parse results -->
> <compact-parsed-ingredient :ingredient="ingredient" />
<input </p>
v-if="ingredient.line" </div>
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>
</template> </template>
<script setup> <script>
import { ref, watch } from 'vue' import CompactParsedIngredient from './CompactParsedIngredient.vue';
import CompactParsedIngredient from './CompactParsedIngredient.vue'
const props = defineProps({ export default {
ingredient: { type: Object, required: true }, props: {
}) ingredient: { type: Object },
const emit = defineEmits(['update-ingredient', 'update-product-link']) },
events: ['update-ingredient', 'update-product-link'],
const ingredientText = ref(props.ingredient?.line ?? '') components: { CompactParsedIngredient },
const productLink = ref(props.ingredient.product?.link ?? '') data() {
return {
watch( ingredientText: this.ingredient?.line ?? "",
() => props.ingredient, productLink: this.ingredient.product?.link ?? "",
(newIngredient) => { };
ingredientText.value = newIngredient?.line ?? '' },
productLink.value = newIngredient?.product?.link ?? '' watch: {
}, ingredient: {
{ deep: true } handler: function (newIngredient) {
) this.ingredientText = newIngredient?.line ?? "";
this.productLink = newIngredient.product?.link ?? "";
function updateIngredient() { },
if (ingredientText.value != props.ingredient.line) { deep: true,
emit('update-ingredient', props.ingredient, ingredientText.value) },
} },
} methods: {
updateIngredient() {
function updateProductLink() { if (this.ingredientText != this.ingredient.line)
if (productLink.value && productLink.value != props.ingredient.product?.link) { this.$emit('update-ingredient', this.ingredient, this.ingredientText);
emit('update-product-link', props.ingredient, productLink.value) },
} updateProductLink() {
} if (this.productLink && this.productLink != this.ingredient.product?.link)
this.$emit('update-product-link', this.ingredient, this.productLink);
}
}
};
</script> </script>
<style scoped> <style scoped>
input { input {
border: 0; border: 0;
font-size: larger; font-size: larger;
border-bottom: 1px solid #ccc; border-bottom: 1px solid #ccc;
border-left: 1px solid #ccc; border-left: 1px solid #ccc;
width: 100%; width: 100%;
padding: 0.5vh; padding: 0.5vh;
} }
.product-link-input { .product-link-input {
color: #777; color: #777;
margin-top: 0.5vh; margin-top: 0.5vh;
} }
</style> </style>

View file

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

View file

@ -1,368 +1,300 @@
<template> <template>
<div class="container"> <div class="container">
<div class="fields"> <div class="fields">
<date-picker <date-picker @date-selected="selectDate" :date="meal.suggested_date" />
:date="meal.suggested_date" <div class="persons-list">
@date-selected="selectDate" 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)" />,
<div class="persons-list"> with <person-list :people="meal.cleanup" @remove-person="(p) => removePerson('cleanup', p)" @add-person="(p) => addPerson('cleanup', p)" /> on cleanup.
Cooked by </div>
<person-list </div>
:people="meal.chefs" <div class="recipes">
@remove-person="(p) => removePerson('chefs', p)" <h2>Recipes</h2>
@add-person="(p) => addPerson('chefs', p)" <ul v-if="meal.recipes && meal.recipes.length">
/> <li v-for="mealRecipe in meal.recipes" :key="mealRecipe.recipe.id">
for <div class="saved-recipe">
<person-list <p class="recipe-card">
:people="meal.consumers" <recipe-card :recipe="mealRecipe.recipe" />
@remove-person="(p) => removePerson('consumers', p)" </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"> <p class="servings">
<input <input type="number" v-model="mealRecipe.servings" min="1" />
v-model="mealRecipe.servings" <small><em>servings</em></small>
type="number" </p>
min="1"
>
<small><em>servings</em></small>
</p>
<input <input type="checkbox" class="show-ingredient-checkbox" :checked="showIngredient(mealRecipe)" />
type="checkbox" <label for="show-ingredients" @click="showIngredient(mealRecipe, !showIngredient(mealRecipe))">
class="show-ingredient-checkbox" <img class="icon" :src="require('@/assets/show-ingredients.svg')" />
:checked="showIngredient(mealRecipe)" </label>
> <button class="icon-button" @click="removeRecipe(mealRecipe)">
<label <img class="icon" :src="require('@/assets/trash.svg')" />
for="show-ingredients" </button>
@click="showIngredient(mealRecipe, !showIngredient(mealRecipe))" </div>
>
<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)"> <div v-if="showIngredient(mealRecipe)">
<ul> <ul>
<li <li v-for="ingredient in scaleIngredients(mealRecipe)" :key="ingredient.id" class="saved-ingredient">
v-for="ingredient in scaleIngredients(mealRecipe)" <CompactParsedIngredient :ingredient="ingredient" />
:key="ingredient.id" </li>
class="saved-ingredient" </ul>
> </div>
<CompactParsedIngredient :ingredient="ingredient" /> </li>
</li>
</ul> </ul>
</div> <div v-else>
</li> <p>Add some recipes using the search box</p>
</ul> </div>
<div v-else> <div class="fields">
<p>Add some recipes using the search box</p> <recipe-search-box @select-recipe="selectRecipe" />
</div> </div>
<div class="fields"> </div>
<recipe-search-box @select-recipe="selectRecipe" /> <div class="ingredients">
</div> <h2>Sides & Additional Ingredients</h2>
</div> <editable-ingredients-panel :ingredients="meal.extra_ingredients" @on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient" @on-editing="onEditAdditionalIngredients" />
<div class="ingredients"> </div>
<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"> <button @click="saveMeal">Save</button>
Save <p v-if="meal.purchase_date"><em>Purchased {{ ago(meal.purchase_date) }}</em></p>
</button> </div>
<p v-if="meal.purchase_date">
<em>Purchased {{ ago(meal.purchase_date) }}</em>
</p>
</div>
</template> </template>
<script setup> <script>
import { reactive, onBeforeMount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getMeal, saveMeal } from '@/composables/useMeals'
import { getRecipe } from '@/api/recipes'
import { currentUser } from '@/api/auth'
import { useAlert } from '@/composables/useAlert'
import { ago } from '@/dateformats.js' import data from '@/data.js';
import alert from '@/alert.js';
import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue' import { ago } from '@/dateformats.js';
import RecipeCard from '@/components/recipes/RecipeCard.vue'
import DatePicker from './DatePicker.vue' import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue';
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue' import RecipeCard from '@/components/recipes/RecipeCard.vue';
import PersonList from './PersonList.vue' import DatePicker from './DatePicker.vue';
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue' import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue';
import PersonList from './PersonList.vue';
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue';
function addPersonIfNotExists(list, person) { function addPersonIfNotExists(list, person) {
if (!list.find((p) => p.id === person.id)) { if (!list.find(p => p.id === person.id)) {
list.push(person) list.push(person);
}
}
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)
} }
}
meal.recipes.push({ recipe, recipe_id: recipe.id, meal_id: meal.id, servings: recipe.serves })
} }
async function onEditAdditionalIngredients(editing) { export default {
if (editing && meal.extra_ingredients.length === 0) { props: ['id'],
addIngredient() components: { RecipeSearchBox, DatePicker, RecipeCard, EditableIngredientsPanel, PersonList, CompactParsedIngredient },
} else { data() {
meal.extra_ingredients = meal.extra_ingredients.filter((i) => i.line) 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) { if (recipe.created_by) {
const index = meal.recipes.indexOf(mealRecipe) addPersonIfNotExists(this.meal.chefs, recipe.created_by);
const key = `${mealRecipe.recipe.id}-${index}` addPersonIfNotExists(this.meal.consumers, recipe.created_by);
if (value === undefined) {
return showIngredients[key]
}
return (showIngredients[key] = value)
}
function scaleIngredients(mealRecipe) { if (this.meal.cleanup.length === 0) {
return mealRecipe.recipe.ingredients.map((i) => { addPersonIfNotExists(this.meal.cleanup, recipe.created_by);
return { }
...i, }
quantity: (i.quantity * mealRecipe.servings) / mealRecipe.recipe.serves,
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> </script>
<style scoped> <style scoped>
img.icon { img.icon {
width: 2em; width: 2em;
height: 2em; height: 2em;
} }
.persons-list { .persons-list {
text-align: left; text-align: left;
padding: 1ex 2em; padding: 1ex 2em;
} }
.persons-list p { .persons-list p {
margin: 0; margin: 0;
padding-bottom: 1em; padding-bottom: 1em;
} }
.person-list li { .person-list li {
display: inline-block; display: inline-block;
padding-right: 1em; padding-right: 1em;
} }
.container { .container {
margin: 1em auto; margin: 1em auto;
} }
ul { ul {
list-style-type: none; list-style-type: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
li { li {
padding-bottom: 0.5vh; padding-bottom: 0.5vh;
} }
.saved-ingredient { .saved-ingredient {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.saved-recipe { .saved-recipe {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.saved-ingredient p { .saved-ingredient p {
flex: 1; flex: 1;
margin: 0; margin: 0;
margin-right: 1em; margin-right: 1em;
} }
.saved-recipe button:hover { .saved-recipe button:hover {
background: #eee; background: #eee;
} }
.saved-recipe .recipe-card { .saved-recipe .recipe-card {
margin: 0; margin: 0;
} }
.recipe-card { .recipe-card {
flex: 1; flex: 1;
} }
.servings { .servings {
display: inline-block; display: inline-block;
margin-right: 1em; margin-right: 1em;
} }
.servings input { .servings input {
width: 3em; width: 3em;
border: none; border: none;
border-bottom: 1px solid #000; border-bottom: 1px solid #000;
text-align: center; text-align: center;
font-size: large; font-size: large;
font-style: italic; font-style: italic;
} }
.icon-button { .icon-button {
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
border-radius: 1em; border-radius: 1em;
padding: 0.5em; padding: 0.5em;
} }
.icon-button:hover { .icon-button:hover {
/* Invert the colors of the trash icon */ /* Invert the colors of the trash icon */
filter: invert(1); filter: invert(1);
} }
.show-ingredient-checkbox { .show-ingredient-checkbox {
display: none; display: none;
} }
.show-ingredient-checkbox + label { .show-ingredient-checkbox + label {
cursor: pointer; cursor: pointer;
background-color: #fff; background-color: #fff;
padding: 0.5em; padding: 0.5em;
border-radius: 1em; border-radius: 1em;
} }
.show-ingredient-checkbox + label:hover { .show-ingredient-checkbox + label:hover {
background-color: #eee; background-color: #eee;
} }
.show-ingredient-checkbox:checked + label { .show-ingredient-checkbox:checked + label {
filter: invert(1); filter: invert(1);
} }
</style> </style>

View file

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

View file

@ -1,146 +1,118 @@
<template> <template>
<div> <div>
<ul <ul class="meals-list" v-if="meals.length">
v-if="meals.length" <li v-for="meal in meals" :key="meal.id">
class="meals-list" <meal-card :meal="meal"/>
> <button class="toggle-actions" @click="selectedMeal = ((meal == selectedMeal) ? null : meal)">
<li <img :src="meal == selectedMeal ? require('@/assets/chevron-down.svg') : require('@/assets/chevron-up.svg')" />
v-for="meal in meals" </button>
: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 <ul class="actions" v-if="selectedMeal == meal">
v-if="selectedMeal == meal" <li><router-link class="nav-link" :to="`/meals/${selectedMeal.id}`" active-class="active">Edit Meal</router-link></li>
class="actions" <li><a @click="markConsumed">Mark Consumed</a></li>
> <li><a @click="deleteSelectedMeal" class="button">Remove</a></li>
<li> </ul>
<router-link </li>
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>
</ul> </ul>
</li> <div v-if="!meals.length">
</ul> <em>No meals planned</em>
<div v-if="!meals.length"> </div>
<em>No meals planned</em> <action-item title="Plan Meal" :image="require('@/assets/plan-meal.svg')" @click="() => this.$router.push('/meals/add')" />
</div> </div>
<action-item
title="Plan Meal"
:image="require('@/assets/plan-meal.svg')"
@click="() => $router.push('/meals/add')"
/>
</div>
</template> </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> <style scoped>
li { li {
list-style-type: none; list-style-type: none;
} }
ul.meals-list { ul.meals-list {
padding: 0; padding: 0;
} }
.toggle-actions { .toggle-actions {
cursor: pointer; cursor: pointer;
background-color: #fff; background-color: #fff;
border: none; border: none;
border-bottom: solid 1px #ccc; border-bottom: solid 1px #ccc;
padding: 0 2em; padding: 0 2em;
margin: 0; margin: 0;
} }
.toggle-actions img { .toggle-actions img {
width: 2em; width: 2em;
height: 2em; height: 2em;
} }
.meals-list > li { .meals-list > li {
margin-bottom: 1em; margin-bottom: 1em;
padding: 0; padding: 0;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 5px; border-radius: 5px;
padding: 10px; padding: 10px;
} }
ul.actions { ul.actions {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
.actions li { .actions li {
display: block; display: block;
border: 1px solid #ccc; border: 1px solid #ccc;
} }
.actions li:hover { .actions li:hover {
background-color: #ccc; background-color: #ccc;
} }
.actions li a { .actions li a {
display: block; display: block;
text-decoration: none; text-decoration: none;
color: #000; color: #000;
width: 100%; width: 100%;
cursor: pointer; cursor: pointer;
padding-top: 2ex; padding-top: 2ex;
padding-bottom: 2ex; padding-bottom: 2ex;
} }
</style> </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> <template>
<span class="person-list"> <span class="person-list">
<span <span v-for="person in people" :key="person.id">
v-for="person in people" <button class="person-circle remove-person" @click="$emit('remove-person', person)" >{{ person.name }}</button>
:key="person.id"
>
<button
class="person-circle remove-person"
@click="removePerson(person)"
>
{{ person.name }}
</button>
</span> </span>
<span> <span>
<button <button v-if="!isAddingPerson" class="person-circle add-person" @click="isAddingPerson = true">+</button>
v-if="!isAddingPerson" <input v-else v-model="searchName" ref="searchNameInput" @keyup.enter="addPerson" @keyup.esc="isAddingPerson = false" @blur="isAddingPerson = false" />
class="person-circle add-person" <ul class="person-droplist" ref="persondroplist" v-if="isAddingPerson && searchResults.length">
@click="isAddingPerson = true" <li v-for="person in searchResults" :key="person.id">
> <button class="person-circle add-person" @mousedown="addPerson(person)">{{ person.name }}</button>
+ </li>
</button> </ul>
<input
v-else
ref="searchNameInput"
v-model="searchName"
@keyup.enter="addPerson"
@keyup.esc="isAddingPerson = false"
@blur="isAddingPerson = false"
>
<ul
v-if="isAddingPerson && searchResults.length"
ref="persondroplist"
class="person-droplist"
>
<li
v-for="person in searchResults"
:key="person.id"
>
<button
class="person-circle add-person"
@mousedown="addPerson(person)"
>
{{ person.name }}
</button>
</li>
</ul>
</span> </span>
</span> </span>
</template> </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> <style scoped>
.person-list { .person-list {
display: inline-block; display: inline-block;
text-align: center; text-align: center;
min-height: 50px; min-height: 50px;
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
/* Remove all the button styling */ /* Remove all the button styling */
.person-circle { .person-circle {
background: none; background: none;
border: none; border: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
/* Show the initials of the person in a circle */ /* Show the initials of the person in a circle */
.person-circle { .person-circle {
display: inline-block; display: inline-block;
width: 50px; width: 50px;
height: 50px; height: 50px;
line-height: 50px; line-height: 50px;
text-align: center; text-align: center;
border-radius: 50%; border-radius: 50%;
background: #eee; background: #eee;
} }
/* On hover, prompt the user to click to remove the person */ /* 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 */ /* change opacity of circle, and use css to add a large cross over the circle */
.remove-person:hover { .remove-person:hover {
cursor: pointer; cursor: pointer;
position: relative; position: relative;
background-color: lightcoral; background-color: lightcoral;
} }
/* Add a cross to the circle */ /* Add a cross to the circle */
.remove-person:hover::before, .remove-person:hover::before, .remove-person:hover::after {
.remove-person:hover::after { pointer-events: none;
pointer-events: none; content: "X";
content: 'X'; color: white;
color: white; position: absolute;
position: absolute; top: 0;
top: 0; bottom: 0;
bottom: 0; left: 0;
left: 0; right: 0;
right: 0;
} }
.add-person { .add-person {
background-color: lightgreen; background-color: lightgreen;
} }
.add-person:hover { .add-person:hover {
cursor: pointer; cursor: pointer;
font-weight: bolder; font-weight: bolder;
color: white; color: white;
background-color: green; background-color: green;
} }
.person-droplist { .person-droplist {
position: absolute; position: absolute;
background-color: white; background-color: white;
border: 1px solid #ccc; border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
list-style-type: none; list-style-type: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
max-height: 40vh; max-height: 40vh;
overflow-y: scroll; overflow-y: scroll;
z-index: 1000; z-index: 1000;
} }
.person-droplist li { .person-droplist li {
padding: 8px; padding: 8px;
cursor: pointer; cursor: pointer;
display: inline; display: inline;
padding: 1ex 1em; padding: 1ex 1em;
} }
</style> </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> <template>
<div> <div>
<div v-if="!id && !recipe"> <div v-if="!id && !recipe">
<input <input class="recipe-link" type="text" v-model="link" placeholder="Link to Recipe" /> <br />
v-model="link" <button @click="parseLink">Parse</button>
class="recipe-link" <button @click="createFromScratch">Create from Scratch</button>
type="text"
placeholder="Link to Recipe"
> <br>
<button @click="parseLink">
Parse
</button>
<button @click="createFromScratch">
Create from Scratch
</button>
</div> </div>
<div v-if="parse_failed"> <div v-if="parse_failed">
<p>Recipe not found</p> <p>Recipe not found</p>
</div> </div>
<div v-if="!parse_failed && recipe"> <div v-if="!parse_failed && recipe">
<div <div class="image-container" v-if="image_styling" :style="image_styling" ></div>
v-if="image_styling" <h1><input class="recipe-name" type="text" v-model="recipe.name" /></h1>
class="image-container" <label for="recipe-serves">Number of serves: </label>
:style="image_styling" <input type="number" v-model="recipe.serves" />
/> <h3 class="recipe-link"><a :href="recipe.link">View Recipe</a></h3>
<h1> <h2>Ingredients</h2>
<input <editable-ingredients-panel
v-model="recipe.name" :ingredients="recipe.ingredients"
class="recipe-name" :edit-only="true"
type="text" @on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient"
> />
</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> <div>
<button <button v-if="recipe.id" class="delete-btn" @click="deleteRecipe">Delete</button>
v-if="recipe.id" <button class="submit-btn" @click="saveRecipe">{{ recipe.id ? "Save" : "Create" }}</button>
class="delete-btn" </div>
@click="deleteRecipe"
>
Delete
</button>
<button
class="submit-btn"
@click="saveRecipe"
>
{{ recipe.id ? 'Save' : 'Create' }}
</button>
</div>
</div> </div>
</div> </div>
</template> </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> <style scoped>
input { input {
border: 0; border: 0;
border-bottom: 1px solid #ccc; border-bottom: 1px solid #ccc;
font-size: large; font-size: large;
} }
input.recipe-link { input.recipe-link {
width: 80%; width: 80%;
} }
.image-container { .image-container {
max-height: 20vh; max-height: 20vh;
min-height: 20vh; min-height: 20vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
input.recipe-name { input.recipe-name {
width: 100%; width: 100%;
font-weight: bold; font-weight: bold;
font-size: larger; font-size: larger;
} }
.recipe-link { .recipe-link {
color: #0000ee; color: #0000EE;
text-decoration: none; text-decoration: none;
} }
</style> </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> <template>
<div class="recipe-card"> <div class="recipe-card">
<p> <p>
<img <img v-if="recipe.image_urls" :src="recipe.image_urls[0]" />
v-if="recipe.image_urls" <img v-else src="@/assets/egg.svg" />
:src="recipe.image_urls[0]" </p>
> <p class="recipe-name">{{ recipe.name }}</p>
<img </div>
v-else
src="@/assets/egg.svg"
>
</p>
<p class="recipe-name">
{{ recipe.name }}
</p>
</div>
</template> </template>
<script setup> <script>
defineProps({ export default {
recipe: { type: Object, required: true }, name: 'RecipeCard',
}) props: ['recipe']
}
</script> </script>
<style scoped> <style scoped>
.recipe-card { .recipe-card {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
text-align: left; text-align: left;
max-height: 10em; max-height: 10em;
margin: 0; margin: 0;
} }
.recipe-card p { .recipe-card p {
margin: auto 0; margin: auto 0;
margin-right: 1em; margin-right: 1em;
} }
li img { li img {
display: block; display: block;
width: 3em; width: 3em;
height: 3em; height: 3em;
object-fit: cover; object-fit: cover;
} }
.recipe-name { .recipe-name {
flex: 1; flex: 1;
margin: auto; margin: auto;
} }
</style> </style>

View file

@ -1,132 +1,99 @@
<template> <template>
<div <div class="recipe-search-box" @focusout="recipes = []">
class="recipe-search-box" <input type="text" v-model="searchTerm" @keyup.enter="search" @keyup.exit="clear" @focusin="search"
@focusout="onFocusOut" :placeholder="placeholder" />
> <ul v-if="recipes?.length" class="dropdown">
<input <li class="recipe" v-for="recipe in recipes" :key="recipe.id" @mousedown="selectRecipe(recipe)">
v-model="searchTerm" <recipe-card :recipe="recipe" />
type="text" </li>
:placeholder="placeholder" </ul>
@keyup.enter="search" </div>
@keyup.esc="clear"
@focusin="search"
>
<ul
v-if="recipes?.length"
class="dropdown"
>
<li
v-for="recipe in recipes"
:key="recipe.id"
class="recipe"
@mousedown="selectRecipe(recipe)"
>
<recipe-card :recipe="recipe" />
</li>
</ul>
</div>
</template> </template>
<script setup> <script>
import { ref, watch, onBeforeUnmount } from 'vue' import data from '@/data.js'
import { searchRecipes } from '@/api/recipes' import RecipeCard from './RecipeCard.vue';
import RecipeCard from './RecipeCard.vue'
defineProps({ export default {
placeholder: { type: String, default: 'Add a recipe...' }, name: 'RecipeSearchBox',
}) components: { RecipeCard },
props: {
const emit = defineEmits(['select-recipe']) placeholder: { type: String, default: 'Add a recipe...' }
},
const searchTerm = ref('') data() {
const recipes = ref([]) return {
searchTerm: '',
let debounceId = null recipes: [],
timeouts: [],
watch( }
searchTerm, },
(newVal) => { watch: {
if (!newVal) { searchTerm() {
recipes.value = [] const searchTerm = this.searchTerm;
if (debounceId) clearTimeout(debounceId) if (searchTerm) {
return 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> </script>
<style scoped> <style scoped>
.recipe-search-box { .recipe-search-box {
position: relative; position: relative;
} }
.recipe-search-box input { .recipe-search-box input {
width: calc(100% - 2em); width: calc(100% - 2em);
padding: 8px; padding: 8px;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
font-size: 16px; font-size: 16px;
outline: none; outline: none;
} }
.recipe-search-box .dropdown { .recipe-search-box .dropdown {
position: absolute; position: absolute;
top: 100%; top: 100%;
width: 100%; width: 100%;
margin: auto; margin: auto;
background-color: #fff; background-color: #fff;
border: 1px solid #ccc; border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000; z-index: 1000;
list-style-type: none; list-style-type: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
max-height: 40vh; max-height: 40vh;
overflow-y: scroll; overflow-y: scroll;
} }
.recipe-search-box .dropdown li { .recipe-search-box .dropdown li {
padding: 8px; padding: 8px;
cursor: pointer; cursor: pointer;
border-bottom: 1px solid #ccc; border-bottom: 1px solid #ccc;
} }
.recipe-search-box .dropdown li:last-child { .recipe-search-box .dropdown li:last-child {
border-bottom: none; border-bottom: none;
} }
.recipe-search-box .dropdown li:hover { .recipe-search-box .dropdown li:hover {
background-color: #eee; background-color: #eee;
} }
</style> </style>

View file

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

View file

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

View file

@ -1,61 +1,70 @@
<template> <template>
<h3>Purchased {{ shoppingList ? ago(shoppingList.created_date) : '' }}</h3> <h3>Purchased {{ shoppingList ? ago(shoppingList.created_date) : '' }}</h3>
<div v-if="includedMeals.length > 0"> <div v-if="includedMeals.length > 0">
<h4>Included Meals</h4> <h4>Included Meals</h4>
<meal-selection-list <meal-selection-list :checked="includedMeals" :meals="includedMeals" :disabled="true" />
:checked="includedMeals" </div>
:meals="includedMeals"
:disabled="true"
/>
</div>
<ul class="full-shopping-list"> <ul class="full-shopping-list">
<li <li v-for="item in listByProduct" :key="item.id">
v-for="item in listByProduct" <shopping-list-item :shopping-list-item-group="item" />
:key="item.id" </li>
> </ul>
<shopping-list-item :shopping-list-item-group="item" />
</li>
</ul>
</template> </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> <style scoped>
.full-shopping-list li {
list-style-type: none;
border: 1px solid #ccc; .full-shopping-list li {
border-radius: 0.5em; list-style-type: none;
margin-bottom: 1em;
border: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
} }
.full-shopping-list { .full-shopping-list {
padding: 0; padding: 0;
} }
</style> </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> <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"> <!-- Show a card of the product with the total, taking up the available space and have an expanding section to view sources -->
<img <div class="shopping-list-item">
:src="`${shoppingListItemGroup.product?.img_small ?? require('@/assets/missing-product.svg')}`" <img :src="`${ shoppingListItemGroup.product?.img_small ?? require('@/assets/missing-product.svg') }`" class="product-image" />
class="product-image"
>
<div class="product-details"> <div class="product-details">
<h3 class="header"> <h3 class="header">
<strong> <strong>
<a <a v-if="shoppingListItemGroup.product?.link" :href="shoppingListItemGroup.product?.link">{{ shoppingListItemGroup.product?.name }}</a>
v-if="shoppingListItemGroup.product?.link" <span v-else>{{ shoppingListItemGroup.name }}</span>
:href="shoppingListItemGroup.product?.link" </strong>,
>{{ shoppingListItemGroup.product?.name }}</a> <small>
<span v-else>{{ shoppingListItemGroup.name }}</span> </strong>, <span v-for="(total, index) in remainingRequiredTotals" :key="total.id">
<small> <span v-if="index">, </span>
<span <span>{{ formatQuantity(total.quantity) }}&nbsp;{{ total.unit }}</span>
v-for="(total, index) in remainingRequiredTotals" </span>
:key="total.id" <span class="found-marker partial" v-if="purchased.length > 0"> {{ getFriendlyDate(lastPurchased) }}</span>
> </small>
<span v-if="index">, </span> </h3>
<span>{{ formatQuantity(total.quantity) }}&nbsp;{{ total.unit }}</span> <p class="sources" v-if="required.length > 0">
</span> <strong>Need: </strong>
<span <span v-for="(source, index) in required" :key="source.id">
v-if="purchased.length > 0" <span v-if="index">, and </span>
class="found-marker partial" <span v-if="source.person">
> {{ getFriendlyDate(lastPurchased) }}</span> {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} for {{ source.person.name }}
</small> </span>
</h3> <span v-else-if="source.recipe">
<p {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }}
v-if="required.length > 0" in <router-link :to="`/recipes/${ source.recipe.id }/`">{{ source.recipe.name }}</router-link>
class="sources" for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
> </span>
<strong>Need: </strong> <span v-else-if="source.meal">
<span {{ 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>
v-for="(source, index) in required" </span>
:key="source.id" </span>
> </p>
<span v-if="index">, and </span> <p v-if="purchased.length > 0">
<span v-if="source.person"> <strong>Already found or purchased: </strong>
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} for <span v-for="(source, index) in purchased" :key="source.id">
{{ source.person.name }} <span v-if="index">, and </span>
</span> <span v-if="source.person">
<span v-else-if="source.recipe"> {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} for {{ source.person.name }}
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in </span>
<router-link :to="`/recipes/${source.recipe.id}/`">{{ <span v-else-if="source.recipe">
source.recipe.name {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }}
}}</router-link> in <router-link :to="`/recipes/${ source.recipe.id }/`">{{ source.recipe.name }}</router-link>
for for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
<router-link :to="`/meals/${source.meal.id}/`">{{ </span>
source.meal.suggested_date.toLocaleDateString('en-AU', { <span v-else-if="source.meal">
weekday: 'long', {{ 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>
month: 'long', </span>
day: 'numeric', </span>
}) </p>
}}</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> </div>
</template> </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> <style scoped>
/* Show the product image to the left, then the product name and size to the right */ /* Show the product image to the left, then the product name and size to the right */
.shopping-list-item { .shopping-list-item {
position: relative; position: relative;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1em; padding: 1em;
} }
.product-image { .product-image {
max-width: 5em; max-width: 5em;
max-height: 4em; max-height: 4em;
margin-right: 1em; margin-right: 1em;
} }
.product-details { .product-details {
flex-grow: 1; flex-grow: 1;
text-align: left; text-align: left;
} }
.header { .header {
margin: 0; margin: 0;
} }
.found-marker { .found-marker {
margin: 1ex; margin: 1ex;
padding-top: 0.6ex; padding-top: 0.6ex;
padding-bottom: 0.5ex; padding-bottom: 0.5ex;
padding-left: 1em; padding-left: 1em;
padding-right: 1em; padding-right: 1em;
border-radius: 25px; border-radius: 25px;
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
font-size: smaller; font-size: smaller;
color: white; color: white;
font-weight: bold; font-weight: bold;
z-index: 1000; z-index: 1000;
} }
.found-marker.found { .found-marker.found {
background-color: green; background-color: green;
} }
.found-marker.partial { .found-marker.partial {
background-color: darkgoldenrod; background-color: darkgoldenrod;
} }
</style> </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) { export function groupsToItems(groups) {
return groups.map((group) => group.shoppingListItems).flat() return groups.map(group => group.shoppingListItems).flat();
} }
export function uniqueMeals(shoppingListItems) { export function uniqueMeals(shoppingListItems) {
const mealsWithDuplicates = shoppingListItems.map((item) => item.meal).filter((m) => m) const mealsWithDuplicates = shoppingListItems
.map(item => item.meal)
.filter(m => m);
const mealsLookup = mealsWithDuplicates.reduce((acc, meal) => { const mealsLookup = mealsWithDuplicates.reduce(((acc, meal) => { acc[meal.id] ??= meal; return acc; }), {});
acc[meal.id] ??= meal
return acc
}, {})
return Object.values(mealsLookup) return Object.values(mealsLookup);
} }
export function itemsToGroups(shoppingListItems) { export function itemsToGroups(shoppingListItems) {
const ingredients_by_product_id = {} const ingredients_by_product_id = {};
const ingredients_by_name = {} const ingredients_by_name = {};
for (const item of shoppingListItems) { for (const item of shoppingListItems) {
if (item.ingredient.product) { if (item.ingredient.product) {
let group = ingredients_by_product_id[item.ingredient.product.id] let group = ingredients_by_product_id[item.ingredient.product.id];
if (!group) { if (!group) {
group = ingredients_by_product_id[item.ingredient.product.id] = { group = ingredients_by_product_id[item.ingredient.product.id] = {
product: item.ingredient.product, product: item.ingredient.product,
shoppingListItems: [], shoppingListItems: []
};
}
group.shoppingListItems.push(item);
} }
} else {
group.shoppingListItems.push(item) let group = ingredients_by_name[item.ingredient.name];
} else { if (!group) {
let group = ingredients_by_name[item.ingredient.name] group = ingredients_by_name[item.ingredient.name] = {
if (!group) { name: item.ingredient.name,
group = ingredients_by_name[item.ingredient.name] = { shoppingListItems: []
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) { function plural(num, unit) {
num = Math.floor(num) num = Math.floor(num);
return num + ' ' + unit + (num === 1 ? '' : 's') return num + " " + unit + (num === 1 ? "" : "s");
} }
export function ago(date) { export function ago(date) {
const now = new Date() const now = new Date();
const diff = now - date const diff = now - date;
if (diff < 1000) { if (diff < 1000) {
return 'just now' return "just now";
} }
if (diff < 60 * 1000) { if (diff < 60 * 1000) {
return plural(diff / 1000, 'second') + ' ago' return plural(diff / 1000, "second") + " ago";
} }
if (diff < 60 * 60 * 1000) { if (diff < 60 * 60 * 1000) {
return plural(diff / (60 * 1000), 'minute') + ' ago' return plural(diff / (60 * 1000), "minute") + " ago";
} }
if (diff < 24 * 60 * 60 * 1000) { if (diff < 24 * 60 * 60 * 1000) {
return plural(diff / (60 * 60 * 1000), 'hour') + ' ago' return plural(diff / (60 * 60 * 1000), "hour") + " ago";
} }
if (diff < 7 * 24 * 60 * 60 * 1000) { if (diff < 7 * 24 * 60 * 60 * 1000) {
return plural(diff / (24 * 60 * 60 * 1000), 'day') + ' ago' return plural(diff / (24 * 60 * 60 * 1000), "day") + " ago";
} }
return date.toLocaleDateString() return date.toLocaleDateString();
} }

View file

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

View file

@ -1,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') const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({ module.exports = defineConfig({
transpileDependencies: true, transpileDependencies: true
}) })