Initial
This commit is contained in:
parent
59e3d8ab16
commit
ee2f08cbfc
8 changed files with 183 additions and 59 deletions
62
refactor-strategy.md
Normal file
62
refactor-strategy.md
Normal 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
|
||||
|
|
@ -20,7 +20,6 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import data from './data.js'
|
||||
import AlertToast from './components/AlertToast.vue'
|
||||
|
||||
export default {
|
||||
|
|
@ -32,11 +31,6 @@ export default {
|
|||
currentRoute() {
|
||||
return this.$route.path
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (!await data.currentUser()) {
|
||||
this.$router.push('/login')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
30
src/api/http.js
Normal file
30
src/api/http.js
Normal 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' }),
|
||||
}
|
||||
16
src/api/mappers/mealMapper.js
Normal file
16
src/api/mappers/mealMapper.js
Normal 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
18
src/api/meals.js
Normal 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)}`)
|
||||
}
|
||||
|
|
@ -81,7 +81,7 @@ ul.actions {
|
|||
<script>
|
||||
import ActionItem from '@/components/ActionItem.vue'
|
||||
import MealCard from '@/components/meals/MealCard.vue'
|
||||
import data from '@/data.js'
|
||||
import { getUpcomingMeals, markMealConsumed, deleteMeal } from '@/api/meals'
|
||||
|
||||
export default {
|
||||
name: 'MealPlanPage',
|
||||
|
|
@ -100,18 +100,18 @@ export default {
|
|||
}
|
||||
},
|
||||
async beforeMount() {
|
||||
const meals = await data.getUpcomingMeals(this.from, this.to)
|
||||
this.meals = meals;
|
||||
const meals = await 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;
|
||||
await 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);
|
||||
await markMealConsumed(this.selectedMeal.id)
|
||||
this.meals = this.meals.filter(m => m.id !== this.selectedMeal.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
src/main.js
50
src/main.js
|
|
@ -1,51 +1,11 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
import App from './App.vue'
|
||||
import { createAppRouter } from '@/router'
|
||||
import data from '@/data.js'
|
||||
|
||||
import LoginPage from './components/LoginPage.vue'
|
||||
import RecipesPage from './components/recipes/RecipesPage.vue'
|
||||
import MealPlanPage from './components/meals/MealPlanPage.vue'
|
||||
import MyShoppingPage from './components/shopping/MyShoppingPage.vue'
|
||||
import PurchasedShoppingListPage from './components/shopping/PurchasedShoppingListPage.vue'
|
||||
import CurrentShoppingListPage from './components/shopping/CurrentShoppingListPage.vue'
|
||||
import EditMealPage from './components/meals/EditMealPage.vue'
|
||||
import EditRecipePage from './components/recipes/EditRecipePage.vue'
|
||||
// Create the router with an auth callback to check current user
|
||||
const router = createAppRouter(() => data.currentUser())
|
||||
|
||||
|
||||
// 2. Define some routes
|
||||
// Each route should map to a component.
|
||||
// We'll talk about nested routes later.
|
||||
const routes = [
|
||||
// Redirect index to mealplan
|
||||
{ path: '/', redirect: '/mealplan' },
|
||||
{ path: '/mealplan', component: MealPlanPage },
|
||||
{ path: '/login', component: LoginPage },
|
||||
{ path: '/shopping', component: MyShoppingPage },
|
||||
{ path: '/shopping/current', component: CurrentShoppingListPage },
|
||||
{ path: '/shopping/:id', component: PurchasedShoppingListPage, props : true },
|
||||
{ path: '/recipes', component: RecipesPage },
|
||||
{ path: '/recipes/add', component: EditRecipePage },
|
||||
{ path: '/recipes/:id', component: EditRecipePage, props: true },
|
||||
{ path: '/meals/add', component: EditMealPage },
|
||||
{ path: '/meals/:id', component: EditMealPage, props: true },
|
||||
]
|
||||
|
||||
// 3. Create the router instance and pass the `routes` option
|
||||
// You can pass in additional options here, but let's
|
||||
// keep it simple for now.
|
||||
const router = createRouter({
|
||||
// 4. Provide the history implementation to use. We are using the hash history for simplicity here.
|
||||
history: createWebHashHistory(),
|
||||
routes, // short for `routes: routes`
|
||||
})
|
||||
|
||||
// 5. Create and mount the root instance.
|
||||
const app = createApp(App)
|
||||
// Make sure to _use_ the router instance to make the
|
||||
// whole app router-aware.
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// Now the app has started!
|
||||
app.mount('#app')
|
||||
44
src/router/index.js
Normal file
44
src/router/index.js
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue