This commit is contained in:
jableader 2025-10-18 12:30:30 +11:00
parent 59e3d8ab16
commit ee2f08cbfc
8 changed files with 183 additions and 59 deletions

62
refactor-strategy.md Normal file
View file

@ -0,0 +1,62 @@
# 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)
- [x] Extract router into `src/router/index.js` with named routes
- [x] Add route meta `requiresAuth` and a global auth guard
- [x] Remove auth-redirect from `App.vue` (handled by guard instead)
- [ ] Convert top-level nav to use route names consistently (optional)
Outcome: Routing logic is centralized and testable; pages redirect consistently based on auth.
### Phase 2 — API Layer Split (Incremental)
- [x] Add `src/api/http.js` wrapper for JSON fetch with error handling and env-based base URL
- [x] Add `src/api/mappers/mealMapper.js` to normalize Meal data (dates)
- [x] Add `src/api/meals.js` and migrate MealPlan API calls (get upcoming, mark consumed, delete)
- [ ] Create `src/api/recipes.js` and migrate recipe endpoints
- [ ] Create `src/api/shopping.js` and migrate shopping endpoints
- [ ] Create `src/api/auth.js` and migrate auth endpoints
Outcome: Feature modules call cohesive services; logic for mapping/normalization is isolated and testable.
### Phase 3 — Composables (UI-Facing Logic)
- [ ] Add `src/composables/useAuth.js` (user ref, ensureAuth)
- [ ] Add `src/composables/useMeals.js` (fetch and mutate meals)
- [ ] Refactor pages to use composables and `<script setup>` where appropriate
Outcome: Components get smaller and easier to read; business logic is reusable.
### Phase 4 — Tooling and Standards
- [ ] Add Prettier config and .editorconfig; wire Prettier with ESLint
- [ ] Upgrade ESLint (if/when convenient) and align with Vue 3 rules
- [ ] Ensure Volar is used (dev environment) for Vue 3 type intelligence
Outcome: Stable formatting and consistent linting across contributors.
### Phase 5 — Tests (Targeted)
- [ ] Add a unit test runner (Vitest or Jest)
- [ ] Test mappers (dates/reference wiring)
- [ ] Test `units.js` conversions and totals
Outcome: Confidence in refactors and easier onboarding.
## Environment
- Prefer `VUE_APP_API_BASE` for backend base URL (fallback to `/api` for dev). Optionally set a devServer proxy in `vue.config.js`.
## Progress Log
- 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

View file

@ -20,7 +20,6 @@
</template> </template>
<script> <script>
import data from './data.js'
import AlertToast from './components/AlertToast.vue' import AlertToast from './components/AlertToast.vue'
export default { export default {
@ -32,11 +31,6 @@ export default {
currentRoute() { currentRoute() {
return this.$route.path return this.$route.path
} }
},
async mounted() {
if (!await data.currentUser()) {
this.$router.push('/login')
}
} }
} }
</script> </script>

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

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

View file

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

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

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

View file

@ -81,7 +81,7 @@ ul.actions {
<script> <script>
import ActionItem from '@/components/ActionItem.vue' import ActionItem from '@/components/ActionItem.vue'
import MealCard from '@/components/meals/MealCard.vue' import MealCard from '@/components/meals/MealCard.vue'
import data from '@/data.js' import { getUpcomingMeals, markMealConsumed, deleteMeal } from '@/api/meals'
export default { export default {
name: 'MealPlanPage', name: 'MealPlanPage',
@ -100,18 +100,18 @@ export default {
} }
}, },
async beforeMount() { async beforeMount() {
const meals = await data.getUpcomingMeals(this.from, this.to) const meals = await getUpcomingMeals(this.from, this.to)
this.meals = meals; this.meals = meals
}, },
methods: { methods: {
async deleteSelectedMeal() { async deleteSelectedMeal() {
await data.deleteMeal(this.selectedMeal.id); await deleteMeal(this.selectedMeal.id)
this.meals = this.meals.filter(m => m.id !== this.selectedMeal.id); this.meals = this.meals.filter(m => m.id !== this.selectedMeal.id)
this.selectedMeal = null; this.selectedMeal = null
}, },
async markConsumed() { async markConsumed() {
await data.markMealConsumed(this.selectedMeal.id); await markMealConsumed(this.selectedMeal.id)
this.meals = this.meals.filter(m => m.id !== this.selectedMeal.id); this.meals = this.meals.filter(m => m.id !== this.selectedMeal.id)
} }
} }
} }

View file

@ -1,51 +1,11 @@
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 data from '@/data.js'
import LoginPage from './components/LoginPage.vue' // Create the router with an auth callback to check current user
import RecipesPage from './components/recipes/RecipesPage.vue' const router = createAppRouter(() => data.currentUser())
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!

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

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