Autoformat

This commit is contained in:
jableader 2025-10-18 14:16:34 +11:00
parent 72fccdd8a3
commit bd99d90c09
24 changed files with 793 additions and 566 deletions

3
.env.example Normal file
View file

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

View file

@ -42,13 +42,16 @@
"vue/setup-compiler-macros": true
},
"extends": [
"plugin:vue/vue3-essential",
"plugin:vue/vue3-recommended",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {}
"rules": {
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": "error"
}
},
"browserslist": [
"> 1%",

View file

@ -1,46 +0,0 @@
# Refactor Strategy
This document outlines a pragmatic, step-by-step refactor plan to improve structure, readability, and maintainability. Each step includes a clear outcome and a checkbox to track progress.
Last updated: 2025-10-18
## Goals
- Separate concerns (routing, HTTP/API, mapping/normalization, UI logic)
- Improve readability and testability
- Establish light-weight standards (naming, lint/format) without blocking development
- Keep changes incremental and safe
## Phases and Steps
### Phase 1 — Routing and Auth (Foundational)
# Refactor Strategy (Outstanding Work)
Last updated: 2025-10-18
Only outstanding, actionable steps are listed below. Completed work has been removed for clarity.
## 1) Linting and rules modernization
- Evaluate upgrading ESLint and eslint-plugin-vue to latest that fully supports Vue 3 macros and recommended rules.
- Align rules with Composition API best practices; ensure Prettier remains source of truth.
Acceptance: ESLint upgrade plan decided (or implemented), rules apply cleanly, lint passes.
## 2) Alerts as a composable
- DONE: Replaced the event bus with `useAlert` composable and updated `AlertToast.vue` and callers.
- Follow-up: consider queuing multiple alerts if needed (current behavior shows latest only).
Acceptance: N/A (completed). Optional enhancement if queuing desired.
## 3) Incremental test coverage
- Add unit tests for new utilities/composables when added (e.g., `useAlert`).
- Consider snapshot tests for components with stable UI fragments (cards, list items).
Acceptance: New logic lands with tests; existing tests stay green.
## 4) Developer experience
- Document .env usage with `VUE_APP_API_BASE` and add a `.env.example` file.
- Add Volar as a recommended extension in the project docs (README updated).
Acceptance: Clear env setup; editor help consistent.

View file

@ -2,19 +2,31 @@
<div>
<ul class="nav">
<li class="nav-item">
<router-link class="nav-link" :to="{ name: 'recipes' }" active-class="active"
>Recipes</router-link
<router-link
class="nav-link"
:to="{ name: 'recipes' }"
active-class="active"
>
Recipes
</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" :to="{ name: 'mealplan' }" active-class="active"
>Meal Plan</router-link
<router-link
class="nav-link"
:to="{ name: 'mealplan' }"
active-class="active"
>
Meal Plan
</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" :to="{ name: 'shopping' }" active-class="active"
>Shopping</router-link
<router-link
class="nav-link"
:to="{ name: 'shopping' }"
active-class="active"
>
Shopping
</router-link>
</li>
</ul>
</div>

View file

@ -2,7 +2,10 @@
<div class="card">
<a @click="emit('click')">
<h2>{{ title }}</h2>
<img :src="image" :alt="title" />
<img
:src="image"
:alt="title"
>
</a>
</div>
</template>

View file

@ -1,13 +1,55 @@
<template>
<div v-if="showAlert" :class="['alert', type]" @click="dismiss">
<img v-if="icon" :src="icon" alt="Notification icon" />
<div
v-if="showAlert"
:class="['alert', type]"
@click="dismiss"
>
<img
v-if="icon"
:src="icon"
alt="Notification icon"
>
<div class="message-container">
<h4 class="heading">{{ heading }}</h4>
<p class="message">{{ message }}</p>
<h4 class="heading">
{{ heading }}
</h4>
<p class="message">
{{ message }}
</p>
</div>
</div>
</template>
<script setup>
import { computed, watch } from 'vue'
import { useAlert } from '@/composables/useAlert'
const alertIcons = {
error: require('@/assets/notification-error.svg'),
success: require('@/assets/notification-success.svg'),
info: require('@/assets/notification-info.svg'),
}
const { current, clear, scheduleAutoDismiss } = useAlert()
const showAlert = computed(() => !!current.value)
const heading = computed(() => current.value?.heading ?? '')
const message = computed(() => current.value?.message ?? '')
const type = computed(() => current.value?.type ?? '')
const icon = computed(() => (type.value && alertIcons[type.value] ? alertIcons[type.value] : null))
function dismiss() {
clear()
}
watch(
() => current.value?._ts,
(ts) => {
if (ts) scheduleAutoDismiss(5000)
}
)
</script>
<style scoped>
/* Display as a toast, in the bottom right corner */
/* Place the icon to the left for the full height, then have the heading and message stacked to the right */
@ -46,33 +88,3 @@
background-color: #2196f3;
}
</style>
<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>

View file

@ -2,8 +2,15 @@
<div class="login">
<h1>Login Page</h1>
<ul class="button-group">
<li v-for="person in persons" :key="person.id">
<button type="button" class="btn btn-primary" @click="onLogin(person)">
<li
v-for="person in persons"
:key="person.id"
>
<button
type="button"
class="btn btn-primary"
@click="onLogin(person)"
>
{{ person.name }}
</button>
</li>
@ -11,6 +18,33 @@
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getPersonsInHome } from '@/api/persons'
import { login as loginApi } from '@/api/auth'
const props = defineProps({
redirect: { type: String, default: '/' },
})
const router = useRouter()
const persons = ref([])
onMounted(async () => {
persons.value = await getPersonsInHome()
})
async function onLogin(selectedPerson) {
const person = await loginApi(selectedPerson.name)
if (person?.id >= 0) {
router.push(props.redirect)
return
}
alert('Login failed')
}
</script>
<style scoped>
/* Remove the default list styling */
ul {
@ -66,30 +100,3 @@ li:nth-child(4) > button {
color: white;
}
</style>
<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>

View file

@ -1,22 +1,37 @@
<template>
<div class="compact-parse-results">
<p class="parse-element teaser-image">
<img :src="ingredient.product?.img_small ?? require('@/assets/missing-product.svg')" />
<img :src="ingredient.product?.img_small ?? require('@/assets/missing-product.svg')">
</p>
<p class="ingredient-details">
<span class="parse-element quantity" :class="{ missing: !ingredient?.quantity }">{{
<span
class="parse-element quantity"
:class="{ missing: !ingredient?.quantity }"
>{{
ingredient?.quantity || 'qty'
}}</span>
<span class="parse-element unit" :class="{ missing: !ingredient?.unit }">{{
<span
class="parse-element unit"
:class="{ missing: !ingredient?.unit }"
>{{
ingredient?.unit || 'unit'
}}</span>
<span class="parse-element helper">of</span>
<span class="parse-element name" :class="{ missing: !ingredient?.name }">{{
<span
class="parse-element name"
:class="{ missing: !ingredient?.name }"
>{{
ingredient?.name || 'name'
}}</span
>:&nbsp;
<span class="parse-element product-name" :class="{ missing: !ingredient?.product }">
<a :href="ingredient?.product?.link" v-if="ingredient?.product?.link" target="”_blank”">
}}</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"
@ -27,10 +42,14 @@
margin-left: 0.5em;
margin-bottom: 0.2em;
"
/>
>
)
</a>
<a v-else-if="ingredient?.name" :href="searchlink" target="_blank"> (search?) </a>
<a
v-else-if="ingredient?.name"
:href="searchlink"
target="_blank"
> (search?) </a>
<a v-else> (product) </a>
</span>
</p>

View file

@ -1,21 +1,39 @@
<template>
<div :class="{ editing: editing }">
<button v-if="editing" @click="emit('on-add')">
<img class="icon" :src="require('@/assets/add-cart.svg')" /> <br />
<button
v-if="editing"
@click="emit('on-add')"
>
<img
class="icon"
:src="require('@/assets/add-cart.svg')"
> <br>
Add Ingredient
</button>
<button @click="toggleEditing" v-if="!editOnly">
<button
v-if="!editOnly"
@click="toggleEditing"
>
<span v-if="editing">
<img class="icon" :src="require('@/assets/edit-off.svg')" /> <br />
<img
class="icon"
:src="require('@/assets/edit-off.svg')"
> <br>
Done Editing
</span>
<span v-else>
<img class="icon" :src="require('@/assets/edit.svg')" /> <br />
<img
class="icon"
:src="require('@/assets/edit.svg')"
> <br>
Edit My List
</span>
</button>
<ul>
<li v-for="ingredient in ingredients" :key="ingredient">
<li
v-for="ingredient in ingredients"
:key="ingredient"
>
<div v-if="editing">
<p class="ingredient-line">
<ingredient-line
@ -25,7 +43,10 @@
/>
</p>
<button @click="emit('on-delete', ingredient)">
<img class="icon" :src="require('@/assets/trash.svg')" />
<img
class="icon"
:src="require('@/assets/trash.svg')"
>
</button>
</div>
<div v-else>
@ -36,6 +57,36 @@
</div>
</template>
<script setup>
import { ref } from 'vue'
import { parseProduct, parseIngredients } from '@/api/recipes'
import IngredientLine from './IngredientLine.vue'
import CompactParsedIngredient from './CompactParsedIngredient.vue'
const props = defineProps({
ingredients: { type: Array, required: true },
editOnly: { type: Boolean, default: false },
})
const emit = defineEmits(['on-add', 'on-delete', 'on-update-ingredient', 'on-editing'])
const editing = ref(props.editOnly ?? false)
async function updateProduct(ingredient, product_link) {
const product = await parseProduct(ingredient, product_link)
emit('on-update-ingredient', ingredient, { ...ingredient, product })
}
async function updateIngredient(ingredient, line) {
const newIngredients = await parseIngredients([line])
emit('on-update-ingredient', ingredient, newIngredients[0])
}
function toggleEditing() {
editing.value = !editing.value
emit('on-editing', editing.value)
}
</script>
<style scoped>
.icon {
width: 2em;
@ -69,33 +120,3 @@ li > div {
margin-right: 1em;
}
</style>
<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>

View file

@ -3,18 +3,18 @@
<p>
<input
v-model="ingredientText"
placeholder="Enter an ingredient"
@keyup.enter="updateIngredient"
@blur="updateIngredient"
placeholder="Enter an ingredient"
/>
>
<input
v-model="productLink"
v-if="ingredient.line"
v-model="productLink"
class="product-link-input"
placeholder="Enter product link"
@keyup.enter="updateProductLink"
@blur="updateProductLink"
/>
>
</p>
<p v-if="ingredient.line">
<!-- Single line parse results -->

View file

@ -1,15 +1,22 @@
<template>
<div class="date-picker">
<input
type="text"
v-model="selectedDate"
type="text"
placeholder="Select a date"
@focus="showDatePicker = true"
@blur="showDatePicker = false"
placeholder="Select a date"
/>
<div v-if="showDatePicker" class="date-picker-dropdown">
>
<div
v-if="showDatePicker"
class="date-picker-dropdown"
>
<ul>
<li v-for="(day, index) in days" :key="index" @mousedown="selectDate(day)">
<li
v-for="(day, index) in days"
:key="index"
@mousedown="selectDate(day)"
>
{{ formatDay(day) }}
</li>
</ul>

View file

@ -1,7 +1,10 @@
<template>
<div class="container">
<div class="fields">
<date-picker @date-selected="selectDate" :date="meal.suggested_date" />
<date-picker
:date="meal.suggested_date"
@date-selected="selectDate"
/>
<div class="persons-list">
Cooked by
<person-list
@ -26,14 +29,21 @@
<div class="recipes">
<h2>Recipes</h2>
<ul v-if="meal.recipes && meal.recipes.length">
<li v-for="mealRecipe in meal.recipes" :key="mealRecipe.recipe.id">
<li
v-for="mealRecipe in meal.recipes"
:key="mealRecipe.recipe.id"
>
<div class="saved-recipe">
<p class="recipe-card">
<recipe-card :recipe="mealRecipe.recipe" />
</p>
<p class="servings">
<input type="number" v-model="mealRecipe.servings" min="1" />
<input
v-model="mealRecipe.servings"
type="number"
min="1"
>
<small><em>servings</em></small>
</p>
@ -41,15 +51,24 @@
type="checkbox"
class="show-ingredient-checkbox"
:checked="showIngredient(mealRecipe)"
/>
>
<label
for="show-ingredients"
@click="showIngredient(mealRecipe, !showIngredient(mealRecipe))"
>
<img class="icon" :src="require('@/assets/show-ingredients.svg')" />
<img
class="icon"
:src="require('@/assets/show-ingredients.svg')"
>
</label>
<button class="icon-button" @click="removeRecipe(mealRecipe)">
<img class="icon" :src="require('@/assets/trash.svg')" />
<button
class="icon-button"
@click="removeRecipe(mealRecipe)"
>
<img
class="icon"
:src="require('@/assets/trash.svg')"
>
</button>
</div>
@ -84,7 +103,9 @@
/>
</div>
<button @click="onSaveMeal">Save</button>
<button @click="onSaveMeal">
Save
</button>
<p v-if="meal.purchase_date">
<em>Purchased {{ ago(meal.purchase_date) }}</em>
</p>

View file

@ -7,24 +7,30 @@
<p>
Cooked by
<span v-for="(chef, index) in meal.chefs" :key="chef.id">
<span
v-for="(chef, index) in meal.chefs"
:key="chef.id"
>
{{ chef.name }}{{ englishSeperator(index, meal.chefs) }}
</span>
<span v-if="!meal.chefs.length">somebody?</span>
</p>
<p>
For
<span v-for="(consumer, index) in meal.consumers" :key="consumer.id">
<span
v-for="(consumer, index) in meal.consumers"
:key="consumer.id"
>
{{ consumer.name }}{{ englishSeperator(index, meal.consumers) }}
</span>
<span v-if="!meal.consumers.length">somebody?</span>
</p>
<p v-if="meal.purchase_date">Purchased {{ ago(meal.purchase_date) }}</p>
<p v-if="meal.purchase_date">
Purchased {{ ago(meal.purchase_date) }}
</p>
</div>
</template>
<style></style>
<script setup>
import { computed } from 'vue'
import { ago } from '@/dateformats.js'
@ -77,3 +83,5 @@ const mealTitle = computed(() => {
return 'Nothing planned'
})
</script>
<style></style>

View file

@ -1,26 +1,47 @@
<template>
<div>
<ul class="meals-list" v-if="meals.length">
<li v-for="meal in meals" :key="meal.id">
<ul
v-if="meals.length"
class="meals-list"
>
<li
v-for="meal in meals"
:key="meal.id"
>
<meal-card :meal="meal" />
<button class="toggle-actions" @click="selectedMeal = meal == selectedMeal ? null : meal">
<button
class="toggle-actions"
@click="selectedMeal = meal == selectedMeal ? null : meal"
>
<img
:src="
meal == selectedMeal
? require('@/assets/chevron-down.svg')
: require('@/assets/chevron-up.svg')
"
/>
>
</button>
<ul class="actions" v-if="selectedMeal == meal">
<ul
v-if="selectedMeal == meal"
class="actions"
>
<li>
<router-link class="nav-link" :to="`/meals/${selectedMeal.id}`" active-class="active"
>Edit Meal</router-link
<router-link
class="nav-link"
:to="`/meals/${selectedMeal.id}`"
active-class="active"
>
Edit Meal
</router-link>
</li>
<li><a @click="markConsumed">Mark Consumed</a></li>
<li><a @click="deleteSelectedMeal" class="button">Remove</a></li>
<li>
<a
class="button"
@click="deleteSelectedMeal"
>Remove</a>
</li>
</ul>
</li>
</ul>
@ -30,11 +51,44 @@
<action-item
title="Plan Meal"
:image="require('@/assets/plan-meal.svg')"
@click="() => this.$router.push('/meals/add')"
@click="() => $router.push('/meals/add')"
/>
</div>
</template>
<script setup>
import { ref, onBeforeMount } from 'vue'
import ActionItem from '@/components/ActionItem.vue'
import MealCard from '@/components/meals/MealCard.vue'
import { getUpcomingMeals, markMealConsumed, deleteMeal } from '@/composables/useMeals'
const from = new Date()
from.setTime(0)
const to = new Date()
to.setDate(to.getDate() + 7)
const meals = ref([])
const selectedMeal = ref(null)
onBeforeMount(async () => {
meals.value = await getUpcomingMeals(from, to)
})
async function deleteSelectedMeal() {
if (!selectedMeal.value) return
await deleteMeal(selectedMeal.value.id)
meals.value = meals.value.filter((m) => m.id !== selectedMeal.value.id)
selectedMeal.value = null
}
async function markConsumed() {
if (!selectedMeal.value) return
await markMealConsumed(selectedMeal.value.id)
meals.value = meals.value.filter((m) => m.id !== selectedMeal.value.id)
}
</script>
<style scoped>
li {
list-style-type: none;
@ -90,36 +144,3 @@ ul.actions {
padding-bottom: 2ex;
}
</style>
<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>

View file

@ -1,7 +1,13 @@
<template>
<span class="person-list">
<span v-for="person in people" :key="person.id">
<button class="person-circle remove-person" @click="removePerson(person)">
<span
v-for="person in people"
:key="person.id"
>
<button
class="person-circle remove-person"
@click="removePerson(person)"
>
{{ person.name }}
</button>
</span>
@ -15,19 +21,25 @@
</button>
<input
v-else
v-model="searchName"
ref="searchNameInput"
v-model="searchName"
@keyup.enter="addPerson"
@keyup.esc="isAddingPerson = false"
@blur="isAddingPerson = false"
/>
<ul
class="person-droplist"
ref="persondroplist"
v-if="isAddingPerson && searchResults.length"
>
<li v-for="person in searchResults" :key="person.id">
<button class="person-circle add-person" @mousedown="addPerson(person)">
<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>
@ -36,6 +48,67 @@
</span>
</template>
<script setup>
import { ref, watch } from 'vue'
import { searchPerson } from '@/api/persons'
const props = defineProps({
people: { type: Array, default: () => [] },
})
const emit = defineEmits(['add-person', 'remove-person'])
const isAddingPerson = ref(false)
const searchName = ref('')
const searchResults = ref([])
// Template refs for DOM elements
const searchNameInput = ref(null)
const persondroplist = ref(null)
async function updateSearchResults() {
const results = await searchPerson(searchName.value)
const idSet = new Set(props.people.map((p) => p.id))
searchResults.value = results.filter((p) => !idSet.has(p.id))
}
function addPerson(person) {
if (!person && searchResults.value.length > 0) {
person = searchResults.value[0]
}
if (person?.id >= 0 && !props.people.find((p) => p.id === person.id)) {
emit('add-person', person)
}
searchName.value = ''
searchResults.value = []
isAddingPerson.value = false
}
function removePerson(person) {
emit('remove-person', person)
}
// Watchers
watch(searchName, async () => {
await updateSearchResults()
})
watch(searchNameInput, async (el) => {
if (el) {
el.focus()
await updateSearchResults()
}
})
watch([persondroplist, searchNameInput], ([drop, input]) => {
if (drop && input) {
const inputRect = input.getBoundingClientRect()
drop.style.left = `${inputRect.left}px`
drop.style.top = `${inputRect.bottom}px`
drop.style.width = `${inputRect.width}px`
}
})
</script>
<style scoped>
.person-list {
display: inline-block;
@ -116,64 +189,3 @@
padding: 1ex 1em;
}
</style>
<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>

View file

@ -1,9 +1,18 @@
<template>
<div>
<div v-if="!id && !recipe">
<input class="recipe-link" type="text" v-model="link" placeholder="Link to Recipe" /> <br />
<button @click="parseLink">Parse</button>
<button @click="createFromScratch">Create from Scratch</button>
<input
v-model="link"
class="recipe-link"
type="text"
placeholder="Link to Recipe"
> <br>
<button @click="parseLink">
Parse
</button>
<button @click="createFromScratch">
Create from Scratch
</button>
</div>
<div v-if="parse_failed">
@ -11,11 +20,26 @@
</div>
<div v-if="!parse_failed && recipe">
<div class="image-container" v-if="image_styling" :style="image_styling"></div>
<h1><input class="recipe-name" type="text" v-model="recipe.name" /></h1>
<div
v-if="image_styling"
class="image-container"
:style="image_styling"
/>
<h1>
<input
v-model="recipe.name"
class="recipe-name"
type="text"
>
</h1>
<label for="recipe-serves">Number of serves: </label>
<input type="number" v-model="recipe.serves" />
<h3 class="recipe-link"><a :href="recipe.link">View Recipe</a></h3>
<input
v-model="recipe.serves"
type="number"
>
<h3 class="recipe-link">
<a :href="recipe.link">View Recipe</a>
</h3>
<h2>Ingredients</h2>
<editable-ingredients-panel
:ingredients="recipe.ingredients"
@ -26,43 +50,24 @@
/>
<div>
<button v-if="recipe.id" class="delete-btn" @click="deleteRecipe">Delete</button>
<button class="submit-btn" @click="saveRecipe">{{ recipe.id ? 'Save' : 'Create' }}</button>
<button
v-if="recipe.id"
class="delete-btn"
@click="deleteRecipe"
>
Delete
</button>
<button
class="submit-btn"
@click="saveRecipe"
>
{{ recipe.id ? 'Save' : 'Create' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
input {
border: 0;
border-bottom: 1px solid #ccc;
font-size: large;
}
input.recipe-link {
width: 80%;
}
.image-container {
max-height: 20vh;
min-height: 20vh;
display: flex;
flex-direction: column;
}
input.recipe-name {
width: 100%;
font-weight: bold;
font-size: larger;
}
.recipe-link {
color: #0000ee;
text-decoration: none;
}
</style>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
@ -169,3 +174,33 @@ watch(
// expose functions for template binding names (automatic in <script setup>)
</script>
<style scoped>
input {
border: 0;
border-bottom: 1px solid #ccc;
font-size: large;
}
input.recipe-link {
width: 80%;
}
.image-container {
max-height: 20vh;
min-height: 20vh;
display: flex;
flex-direction: column;
}
input.recipe-name {
width: 100%;
font-weight: bold;
font-size: larger;
}
.recipe-link {
color: #0000ee;
text-decoration: none;
}
</style>

View file

@ -1,10 +1,18 @@
<template>
<div class="recipe-card">
<p>
<img v-if="recipe.image_urls" :src="recipe.image_urls[0]" />
<img v-else src="@/assets/egg.svg" />
<img
v-if="recipe.image_urls"
:src="recipe.image_urls[0]"
>
<img
v-else
src="@/assets/egg.svg"
>
</p>
<p class="recipe-name">
{{ recipe.name }}
</p>
<p class="recipe-name">{{ recipe.name }}</p>
</div>
</template>

View file

@ -1,18 +1,24 @@
<template>
<div class="recipe-search-box" @focusout="onFocusOut">
<div
class="recipe-search-box"
@focusout="onFocusOut"
>
<input
type="text"
v-model="searchTerm"
type="text"
:placeholder="placeholder"
@keyup.enter="search"
@keyup.esc="clear"
@focusin="search"
:placeholder="placeholder"
/>
<ul v-if="recipes?.length" class="dropdown">
>
<ul
v-if="recipes?.length"
class="dropdown"
>
<li
class="recipe"
v-for="recipe in recipes"
:key="recipe.id"
class="recipe"
@mousedown="selectRecipe(recipe)"
>
<recipe-card :recipe="recipe" />

View file

@ -3,10 +3,10 @@
<h4>Included Meals</h4>
<meal-selection-list
@meal-selected="mealSelected"
@meal-unselected="mealUnselected"
:checked="includedMeals"
:meals="availableMeals"
@meal-selected="mealSelected"
@meal-unselected="mealUnselected"
/>
<ul class="full-shopping-list">
@ -26,119 +26,79 @@
</div>
<div class="purchased-slider">
<span v-if="purchasedItemGroups.length === 0"></span>
<button v-else-if="showPurchased" @click="showPurchased = false"> Hide Purchased </button>
<button v-else @click="showPurchased = true"> Show Purchased </button>
<span v-if="purchasedItemGroups.length === 0" />
<button
v-else-if="showPurchased"
@click="showPurchased = false"
>
Hide Purchased
</button>
<button
v-else
@click="showPurchased = true"
>
Show Purchased
</button>
<div v-if="showPurchased && purchasedItemGroups.length > 0">
<h4>Purchased Meals</h4>
<meal-selection-list
@meal-selected="mealSelected"
@meal-unselected="mealUnselected"
:checked="includedMeals"
:meals="purchasedMeals"
@meal-selected="mealSelected"
@meal-unselected="mealUnselected"
/>
<h4>Purchased Items</h4>
<ul class="full-shopping-list">
<li v-for="item in purchasedItemGroups" :key="item.id">
<li
v-for="item in purchasedItemGroups"
:key="item.id"
>
<shopping-list-item :shopping-list-item-group="item" />
</li>
</ul>
</div>
</div>
<div class="spacer" v-if="selected.length">&nbsp;</div>
<div
v-if="selected.length"
class="spacer"
>
&nbsp;
</div>
<!-- Display 'Stocked', 'Purchased' and 'Cancel' buttons in a vertical stack fixed to the bottom of the screen when any elements are selected -->
<div class="footer-buttons" v-if="selected.length">
<div
v-if="selected.length"
class="footer-buttons"
>
<p v-if="selected.length === 1">
Mark '{{ selected[0].product?.name ?? selected[0].name }}' as
</p>
<p v-else>Mark {{ selected.length }} items 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 />
<img src="@/assets/house-check.svg"><br>
Found
</button>
<button @click="markPurchased">
<img src="@/assets/shopping-cart.svg" /><br />
<img src="@/assets/shopping-cart.svg"><br>
Purchased
</button>
<button @click="selected = []">
<img src="@/assets/close.svg" /><br />
<img src="@/assets/close.svg"><br>
Cancel
</button>
</div>
</div>
</template>
<style scoped>
.full-shopping-list {
padding: 0;
}
.full-shopping-list li {
list-style-type: none;
outline: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
}
.full-shopping-list li.selectable {
cursor: pointer;
outline: 1px solid #ccc;
}
.full-shopping-list li.selected {
background-color: #f0f0f0;
outline-width: 3px;
}
.footer-buttons {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 1ex;
background-color: #f0f0f0;
border-top: 1px solid #ccc;
}
.footer-buttons p {
margin: 0;
text-align: left;
font-style: italic;
}
.button-group {
/* Display as vertical fixed to the bottom of the screen */
display: flex;
}
.button-group button {
flex: 1;
padding: 1em;
border: 1px solid #ccc;
border-radius: 0.5em;
background-color: #f0f0f0;
cursor: pointer;
}
button img {
width: 2em;
height: 2em;
}
.spacer {
height: 12em;
}
</style>
<script setup>
import { ref, computed, onBeforeMount } from 'vue'
import { useRouter } from 'vue-router'
@ -232,3 +192,66 @@ function isSelected(item) {
onBeforeMount(loadData)
</script>
<style scoped>
.full-shopping-list {
padding: 0;
}
.full-shopping-list li {
list-style-type: none;
outline: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
}
.full-shopping-list li.selectable {
cursor: pointer;
outline: 1px solid #ccc;
}
.full-shopping-list li.selected {
background-color: #f0f0f0;
outline-width: 3px;
}
.footer-buttons {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 1ex;
background-color: #f0f0f0;
border-top: 1px solid #ccc;
}
.footer-buttons p {
margin: 0;
text-align: left;
font-style: italic;
}
.button-group {
/* Display as vertical fixed to the bottom of the screen */
display: flex;
}
.button-group button {
flex: 1;
padding: 1em;
border: 1px solid #ccc;
border-radius: 0.5em;
background-color: #f0f0f0;
cursor: pointer;
}
button img {
width: 2em;
height: 2em;
}
.spacer {
height: 12em;
}
</style>

View file

@ -1,82 +1,28 @@
<template>
<ul>
<li v-for="meal in meals" :key="meal.id">
<li
v-for="meal in meals"
:key="meal.id"
>
<!-- Have a checkbox and card for each meal, show the image and name -->
<!-- Emit meal-selected event on checked and meal-unselected on unchecked -->
<input
type="checkbox"
:id="meal.id"
type="checkbox"
:checked="isChecked(meal)"
@change="mealCheckChanged"
:disabled="disabled"
/>
<label :for="meal.id" :style="getImageStyling(meal)">
@change="mealCheckChanged"
>
<label
:for="meal.id"
:style="getImageStyling(meal)"
>
{{ formatDate(meal.suggested_date) }}
</label>
</li>
</ul>
</template>
<style scoped>
ul {
padding: 0;
}
li {
display: inline-block;
list-style-type: none;
border: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
padding: none;
overflow: hidden;
text-align: center;
}
/* Hide the default checkbox formatting, and format the card instead */
input[type='checkbox'] {
display: none;
}
label {
display: inline-block;
padding: 0.1vh 0.3em;
/* Help the visibility of the text over the image */
background-color: rgba(255, 255, 255, 0.9);
border: 3px solid transparent;
border-radius: 0.5em;
margin: 0;
font-weight: normal;
cursor: pointer;
color: #3d5447;
}
input[type='checkbox']:checked + label {
border: 3px solid #3d5447;
text-shadow: #ccc 0 0 0.1em;
}
input[type='checkbox']:disabled + label {
cursor: not-allowed;
}
/* Show the image as the background image of the card */
.meal-image {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Position the text in the center of the card */
label {
display: flex;
justify-content: center;
align-items: center;
font-size: larger;
font-weight: bold;
}
</style>
<script setup>
const props = defineProps({
meals: { type: Array, required: true },
@ -141,3 +87,63 @@ function isChecked(meal) {
return props.checked.some((m) => m.id === meal.id)
}
</script>
<style scoped>
ul {
padding: 0;
}
li {
display: inline-block;
list-style-type: none;
border: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
padding: none;
overflow: hidden;
text-align: center;
}
/* Hide the default checkbox formatting, and format the card instead */
input[type='checkbox'] {
display: none;
}
label {
display: inline-block;
padding: 0.1vh 0.3em;
/* Help the visibility of the text over the image */
background-color: rgba(255, 255, 255, 0.9);
border: 3px solid transparent;
border-radius: 0.5em;
margin: 0;
font-weight: normal;
cursor: pointer;
color: #3d5447;
}
input[type='checkbox']:checked + label {
border: 3px solid #3d5447;
text-shadow: #ccc 0 0 0.1em;
}
input[type='checkbox']:disabled + label {
cursor: not-allowed;
}
/* Show the image as the background image of the card */
.meal-image {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Position the text in the center of the card */
label {
display: flex;
justify-content: center;
align-items: center;
font-size: larger;
font-weight: bold;
}
</style>

View file

@ -1,13 +1,15 @@
<template>
<div>
<h1>My Shopping List</h1>
<router-link :to="`/shopping/current`">Full Shopping List</router-link>
<router-link :to="`/shopping/current`">
Full Shopping List
</router-link>
<editable-ingredients-panel
:ingredients="ingredients"
@on-add="addIngredient"
@on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient"
@on-editing="onEditing"
:ingredients="ingredients"
/>
</div>
@ -37,8 +39,6 @@
-->
</template>
<style scoped></style>
<script setup>
import { ref, onBeforeMount } from 'vue'
import { useRouter } from 'vue-router'
@ -86,3 +86,5 @@ onBeforeMount(async () => {
await updateShoppingList()
})
</script>
<style scoped></style>

View file

@ -3,30 +3,23 @@
<div v-if="includedMeals.length > 0">
<h4>Included Meals</h4>
<meal-selection-list :checked="includedMeals" :meals="includedMeals" :disabled="true" />
<meal-selection-list
:checked="includedMeals"
:meals="includedMeals"
:disabled="true"
/>
</div>
<ul class="full-shopping-list">
<li v-for="item in listByProduct" :key="item.id">
<li
v-for="item in listByProduct"
:key="item.id"
>
<shopping-list-item :shopping-list-item-group="item" />
</li>
</ul>
</template>
<style scoped>
.full-shopping-list li {
list-style-type: none;
border: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
}
.full-shopping-list {
padding: 0;
}
</style>
<script setup>
import { ref, computed, onBeforeMount } from 'vue'
import { useRoute } from 'vue-router'
@ -52,3 +45,17 @@ onBeforeMount(async () => {
shoppingList.value = await getShoppingList(id)
})
</script>
<style scoped>
.full-shopping-list li {
list-style-type: none;
border: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
}
.full-shopping-list {
padding: 0;
}
</style>

View file

@ -4,30 +4,38 @@
<img
:src="`${shoppingListItemGroup.product?.img_small ?? require('@/assets/missing-product.svg')}`"
class="product-image"
/>
>
<div class="product-details">
<h3 class="header">
<strong>
<a
v-if="shoppingListItemGroup.product?.link"
:href="shoppingListItemGroup.product?.link"
>{{ shoppingListItemGroup.product?.name }}</a
>
<span v-else>{{ shoppingListItemGroup.name }}</span> </strong
>,
>{{ shoppingListItemGroup.product?.name }}</a>
<span v-else>{{ shoppingListItemGroup.name }}</span> </strong>,
<small>
<span v-for="(total, index) in remainingRequiredTotals" :key="total.id">
<span
v-for="(total, index) in remainingRequiredTotals"
:key="total.id"
>
<span v-if="index">, </span>
<span>{{ formatQuantity(total.quantity) }}&nbsp;{{ total.unit }}</span>
</span>
<span class="found-marker partial" v-if="purchased.length > 0"
> {{ getFriendlyDate(lastPurchased) }}</span
>
<span
v-if="purchased.length > 0"
class="found-marker partial"
> {{ getFriendlyDate(lastPurchased) }}</span>
</small>
</h3>
<p class="sources" v-if="required.length > 0">
<p
v-if="required.length > 0"
class="sources"
>
<strong>Need: </strong>
<span v-for="(source, index) in required" :key="source.id">
<span
v-for="(source, index) in required"
:key="source.id"
>
<span v-if="index">, and </span>
<span v-if="source.person">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} for
@ -61,7 +69,10 @@
</p>
<p v-if="purchased.length > 0">
<strong>Already found or purchased: </strong>
<span v-for="(source, index) in purchased" :key="source.id">
<span
v-for="(source, index) in purchased"
:key="source.id"
>
<span v-if="index">, and </span>
<span v-if="source.person">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} for
@ -97,6 +108,50 @@
</div>
</template>
<script setup>
import { computed } from 'vue'
import { ago } from '@/dateformats.js'
import { calculateTotals } from '@/units.js'
const props = defineProps({
// { product: { ... }, OR name: 'string', shoppingListItems: [...] }
shoppingListItemGroup: { type: Object, required: true },
})
const remainingRequiredTotals = computed(() =>
calculateTotals(props.shoppingListItemGroup.shoppingListItems.map((item) => item.ingredient))
)
const required = computed(() =>
props.shoppingListItemGroup.shoppingListItems.filter((item) => !item.list_id)
)
const purchased = computed(() =>
props.shoppingListItemGroup.shoppingListItems.filter((item) => item.list_id)
)
const lastPurchased = computed(() => {
const purchasedItems = props.shoppingListItemGroup.shoppingListItems.filter((item) => item.list_id)
if (purchasedItems.length === 0) return null
return purchasedItems.reduce((latest, item) => {
const itemDate = item?.meal?.suggested_date || item?.created_at
return !latest || (itemDate && itemDate > latest) ? itemDate : latest
}, null)
})
function getFriendlyDate(date) {
if (!date) return ''
return ago(date)
}
function formatQuantity(quantity) {
const log10 = Math.log10(quantity)
if (log10 < 0) return quantity.toPrecision(2)
if (log10 < 1) return quantity.toFixed(1)
return quantity.toFixed(0)
}
</script>
<style scoped>
/* Show the product image to the left, then the product name and size to the right */
@ -147,47 +202,3 @@
background-color: darkgoldenrod;
}
</style>
<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>

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

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