Composition #1

This commit is contained in:
jableader 2025-10-18 13:39:25 +11:00
parent 3be1027154
commit 61ca41d25a
9 changed files with 225 additions and 173 deletions

15
package-lock.json generated
View file

@ -4843,9 +4843,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001574",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001574.tgz",
"integrity": "sha512-BtYEK4r/iHt/txm81KBudCUcTy7t+s9emrIaHqjYurQ10x71zJ5VQ9x1dYPcz/b+pKSp4y/v1xSI67A+LzpNyg==",
"version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"dev": true,
"funding": [
{
@ -4860,7 +4860,8 @@
"type": "github",
"url": "https://github.com/sponsors/ai"
}
]
],
"license": "CC-BY-4.0"
},
"node_modules/case-sensitive-paths-webpack-plugin": {
"version": "2.4.0",
@ -17396,9 +17397,9 @@
}
},
"caniuse-lite": {
"version": "1.0.30001574",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001574.tgz",
"integrity": "sha512-BtYEK4r/iHt/txm81KBudCUcTy7t+s9emrIaHqjYurQ10x71zJ5VQ9x1dYPcz/b+pKSp4y/v1xSI67A+LzpNyg==",
"version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"dev": true
},
"case-sensitive-paths-webpack-plugin": {

View file

@ -38,7 +38,8 @@
"eslintConfig": {
"root": true,
"env": {
"node": true
"node": true,
"vue/setup-compiler-macros": true
},
"extends": [
"plugin:vue/vue3-essential",

View file

@ -55,32 +55,117 @@ Outcome: Stable formatting and consistent linting across contributors.
### Phase 5 — Tests (Targeted)
- [x] Add a unit test runner (Vitest)
- [x] Test mappers (dates/reference wiring)
- Added tests: `tests/mealMapper.test.js`, `tests/shoppingListMapper.test.js`
- [x] Test `units.js` conversions and totals
- Added tests: `tests/units.test.js`
# Composition API Migration Plan
Outcome: Confidence in refactors and easier onboarding.
This document tracks the migration of remaining components to Vue 3 Composition API using `<script setup>`, with clear ordering and acceptance criteria.
## Environment
Last updated: 2025-10-18
- Prefer `VUE_APP_API_BASE` for backend base URL (fallback to `/api` for dev). Optionally set a devServer proxy in `vue.config.js`.
## Goals
## Progress Log
- Convert all SFCs to Composition API `<script setup>`.
- Remove Options API patterns (`data`, `methods`, `computed`, `watch`, `this.*`).
- Standardize on composables for cross-cutting concerns (auth, alerts, API calls).
- Keep changes incremental and safe with focused PRs and existing tests.
- 2025-10-18:
- Extracted router with names and auth guard
- Removed `App.vue` auth redirect
- Added `api/http.js`, `api/mappers/mealMapper.js`, `api/meals.js`
- Updated `MealPlanPage.vue` to use meals API
- Added `api/mappers/recipeMapper.js`, `api/recipes.js`; updated recipes components
- Added `api/mappers/shoppingListMapper.js`, `api/shopping.js`; updated shopping components
- Added `api/auth.js` and `api/persons.js`; router uses auth API
- Added `composables/useAuth.js` and used in `MyShoppingPage.vue`
- Added Prettier and EditorConfig
- Added `composables/useMeals.js`; migrated meal pages to composable and `<script setup>`
- Added `composables/useShopping.js`; migrated shopping pages to composable and `<script setup>`
- Added VS Code Volar recommendation (`.vscode/extensions.json`)
- Set up Vitest (`vitest.config.js`), added test scripts in `package.json`
- Wrote unit tests for mappers and units: `tests/mealMapper.test.js`, `tests/shoppingListMapper.test.js`, `tests/units.test.js`
## Current status
Already using `<script setup>`:
- Meals: `MealPlanPage.vue`, `EditMealPage.vue`
- Shopping: `CurrentShoppingListPage.vue`, `MyShoppingPage.vue`, `PurchasedShoppingListPage.vue`
Remaining to migrate (Options API or mixed):
- Core
- `App.vue`
- `components/AlertToast.vue`
- `components/ActionItem.vue`
- `components/LoginPage.vue`
- Recipes
- `components/recipes/RecipesPage.vue`
- `components/recipes/RecipeSearchBox.vue`
- `components/recipes/RecipeCard.vue`
- `components/recipes/EditRecipePage.vue` (most complex)
- Meals
- `components/meals/MealCard.vue`
- `components/meals/DatePicker.vue`
- `components/meals/PersonList.vue` (mixed Options+setup, unify under `<script setup>`)
- Ingredients
- `components/ingredients/CompactParsedIngredient.vue`
- `components/ingredients/IngredientLine.vue`
- `components/ingredients/EditableIngredientsPanel.vue`
- Shopping
- `components/shopping/MealSelectionList.vue`
- `components/shopping/ShoppingListItem.vue`
## Migration order (batches)
1) Leaf/presentational components (low risk)
- `ActionItem.vue`, `RecipeCard.vue`, `CompactParsedIngredient.vue`
- Patterns: defineProps, no router; replace `props: ['x']` with `defineProps<{...}>` (or JSDoc). Simple emits with `defineEmits`.
2) Simple interactive components
- `IngredientLine.vue`, `MealCard.vue`, `MealSelectionList.vue`, `DatePicker.vue`
- Patterns: replace `data` with `ref/reactive`, computed with `computed`, methods with local functions. Replace `this.$emit` with `emit`.
3) Components with watchers and DOM refs
- `EditableIngredientsPanel.vue`, `PersonList.vue`, `ShoppingListItem.vue`
- Patterns: use `ref` for elements, `onMounted` for subscriptions/layout, `watch` for reactive sources. Ensure timeouts/listeners are cleaned up.
4) Pages and core scaffolding
- `RecipesPage.vue`, `RecipeSearchBox.vue`, `EditRecipePage.vue`, `LoginPage.vue`, `App.vue`, `AlertToast.vue`
- Patterns: replace `this.$router`/`this.$route` with `useRouter`/`useRoute`. Consider a small `useAlert` composable to replace the event bus pattern used by `AlertToast.vue` and current `alert.js`.
## Conventions and helpers
- Routing: `const router = useRouter(); const route = useRoute();`
- Props/Emits:
- `const props = defineProps({ ... })`
- `const emit = defineEmits(['event-name'])`
- State: `const state = reactive({...})` or `const x = ref(initial)`
- Computed/Watch: `const y = computed(() => ...)`; `watch(source, (val, old) => ...)`
- Lifecycle: `onMounted`, `onBeforeUnmount`
- Assets: import statics via `new URL('@/assets/foo.svg', import.meta.url).href` or leave template `require()` where needed (non-blocking).
- Testing: keep current Vitest setup; prefer unit tests for any functional changes.
## Acceptance criteria per component
- The component uses a single `<script setup>` block.
- No `this.*` usage remains; props accessed via `props` or destructured; emits via `emit`.
- Route navigation uses `useRouter`/`useRoute` where applicable.
- All existing functionality and events preserved.
- Lint/test/build pass.
## Tracking checklist
- [ ] Core: `App.vue`
- [ ] Core: `components/AlertToast.vue`
- [ ] Core: `components/ActionItem.vue`
- [ ] Core: `components/LoginPage.vue`
- [ ] Recipes: `components/recipes/RecipesPage.vue`
- [ ] Recipes: `components/recipes/RecipeSearchBox.vue`
- [ ] Recipes: `components/recipes/RecipeCard.vue`
- [ ] Recipes: `components/recipes/EditRecipePage.vue`
- [ ] Meals: `components/meals/MealCard.vue`
- [ ] Meals: `components/meals/DatePicker.vue`
- [ ] Meals: `components/meals/PersonList.vue`
- [ ] Shopping: `components/shopping/MealSelectionList.vue`
- [ ] Shopping: `components/shopping/ShoppingListItem.vue`
## Notes and risks
- `PersonList.vue` aligns a dropdown to an input via DOM measurements; ensure the ref-based approach updates positions correctly on focus/resize.
- `RecipeSearchBox.vue` uses timeouts for debouncing; prefer `watch` with a debounced effect and clean up on unmount.
- `AlertToast.vue` uses a simple event-bus (`alert.js`); consider migrating to a `useAlert` composable with a `ref`-based queue to simplify subscriptions.
## Done (context)
- Routing extracted with auth guard; API layer split; composables for auth/meals/shopping; prettier/husky configured; Vitest tests for mappers and units in place.

View file

@ -1,17 +1,18 @@
<template>
<div class="card">
<a @click="$emit('click')">
<a @click="emit('click')">
<h2>{{ title }}</h2>
<img :src="image" :alt="name" />
<img :src="image" :alt="title" />
</a>
</div>
</template>
<script>
export default {
name: 'ActionItem',
props: ['title', 'image'],
}
<script setup>
const emit = defineEmits(['click'])
defineProps({
title: { type: String, required: true },
image: { type: String, required: true },
})
</script>
<style scoped>

View file

@ -37,19 +37,19 @@
</div>
</template>
<script>
export default {
name: 'CompactParsedIngredient',
props: ['ingredient'],
computed: {
searchlink() {
return (
'https://www.woolworths.com.au/shop/search/products?searchTerm=' +
encodeURIComponent(this.ingredient.name)
)
},
},
}
<script setup>
import { computed } from 'vue'
const props = defineProps({
ingredient: { type: Object, required: true },
})
const searchlink = computed(() =>
props.ingredient?.name
? 'https://www.woolworths.com.au/shop/search/products?searchTerm=' +
encodeURIComponent(props.ingredient.name)
: ''
)
</script>
<style scoped>

View file

@ -23,40 +23,37 @@
</div>
</template>
<script>
<script setup>
import { ref, watch } from 'vue'
import CompactParsedIngredient from './CompactParsedIngredient.vue'
export default {
props: {
ingredient: { type: Object },
const props = defineProps({
ingredient: { type: Object, required: true },
})
const emit = defineEmits(['update-ingredient', 'update-product-link'])
const ingredientText = ref(props.ingredient?.line ?? '')
const productLink = ref(props.ingredient.product?.link ?? '')
watch(
() => props.ingredient,
(newIngredient) => {
ingredientText.value = newIngredient?.line ?? ''
productLink.value = newIngredient?.product?.link ?? ''
},
events: ['update-ingredient', 'update-product-link'],
components: { CompactParsedIngredient },
data() {
return {
ingredientText: this.ingredient?.line ?? '',
productLink: this.ingredient.product?.link ?? '',
{ deep: true }
)
function updateIngredient() {
if (ingredientText.value != props.ingredient.line) {
emit('update-ingredient', props.ingredient, ingredientText.value)
}
}
function updateProductLink() {
if (productLink.value && productLink.value != props.ingredient.product?.link) {
emit('update-product-link', props.ingredient, productLink.value)
}
},
watch: {
ingredient: {
handler: function (newIngredient) {
this.ingredientText = newIngredient?.line ?? ''
this.productLink = newIngredient.product?.link ?? ''
},
deep: true,
},
},
methods: {
updateIngredient() {
if (this.ingredientText != this.ingredient.line)
this.$emit('update-ingredient', this.ingredient, this.ingredientText)
},
updateProductLink() {
if (this.productLink && this.productLink != this.ingredient.product?.link)
this.$emit('update-product-link', this.ingredient, this.productLink)
},
},
}
</script>

View file

@ -25,9 +25,14 @@
<style></style>
<script>
<script setup>
import { computed } from 'vue'
import { ago } from '@/dateformats.js'
const props = defineProps({
meal: { type: Object, required: true },
})
function englishSeperator(index, list) {
switch (index) {
case list.length - 1:
@ -52,37 +57,23 @@ function englishList(list) {
}
}
export default {
name: 'MealCard',
props: ['meal'],
computed: {
date() {
return this.meal.suggested_date.toLocaleDateString('en-au', {
const date = computed(() =>
props.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)
)
)
if (recipesText && ingredientsText) {
return `${recipesText} with ${ingredientsText}`
} else if (recipesText || ingredientsText) {
return recipesText || ingredientsText
} else {
const dayOfWeek = computed(() =>
props.meal.suggested_date.toLocaleDateString('en-au', { weekday: 'long' })
)
const mealTitle = computed(() => {
const recipesText = englishList(props.meal.recipes.map((mr) => mr.recipe.name))
const ingredientsText = englishList(props.meal.extra_ingredients.map((i) => i.name))
if (recipesText && ingredientsText) return `${recipesText} with ${ingredientsText}`
if (recipesText || ingredientsText) return recipesText || ingredientsText
return 'Nothing planned'
}
},
},
methods: {
englishSeperator,
ago,
},
}
})
</script>

View file

@ -8,11 +8,10 @@
</div>
</template>
<script>
export default {
name: 'RecipeCard',
props: ['recipe'],
}
<script setup>
defineProps({
recipe: { type: Object, required: true },
})
</script>
<style scoped>

View file

@ -77,7 +77,14 @@ label {
}
</style>
<script>
<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) {
@ -92,9 +99,8 @@ const nth = (d) => {
}
}
const formatDate = (date) => {
return `${date.toLocaleDateString('en-AU', { weekday: 'short' })} ${date.getDate()}${nth(date.getDate())}`
}
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
@ -108,59 +114,30 @@ const getUrl = (meal) => {
// 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
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) {
function getImageStyling(meal) {
const imageUrl = getUrl(meal)
if (!imageUrl) {
return null
}
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) {
}
function 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
},
},
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>