Link consolodation

This commit is contained in:
jableader 2025-11-02 18:29:59 +11:00
parent 762549f4c4
commit 1b740c0234
6 changed files with 105 additions and 13 deletions

View file

@ -118,6 +118,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, onBeforeMount } from 'vue' import { reactive, onBeforeMount } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { toMealEdit } from '@/router/links'
import { getMeal, saveMeal, getRecipe } from '@/api/sdk' import { getMeal, saveMeal, getRecipe } from '@/api/sdk'
import { toMealInput } from '@/domain/decoders' import { toMealInput } from '@/domain/decoders'
import { currentUser } from '@/api/auth' import { currentUser } from '@/api/auth'
@ -258,7 +259,7 @@ async function onSaveMeal() {
const saved = await saveMeal(toMealInput(meal)) const saved = await saveMeal(toMealInput(meal))
if (saved && saved.id >= 0) { if (saved && saved.id >= 0) {
Object.assign(meal, saved) Object.assign(meal, saved)
router.push(`/meals/${saved.id}`) router.push(toMealEdit(saved.id))
showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' }) showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' })
return return
} }

View file

@ -12,7 +12,8 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRouter, useRoute } from 'vue-router' import { useRouter } from 'vue-router'
import { toRecipeAdd, toRecipeEdit } from '@/router/links'
import ActionItem from '@/components/ActionItem.vue' import ActionItem from '@/components/ActionItem.vue'
import RecipeSearchBox from './RecipeSearchBox.vue' import RecipeSearchBox from './RecipeSearchBox.vue'
import type { Recipe } from '@/domain/types' import type { Recipe } from '@/domain/types'
@ -20,14 +21,12 @@ import type { Recipe } from '@/domain/types'
const addRecipe = new URL('@/assets/add-recipe.svg', import.meta.url).toString() const addRecipe = new URL('@/assets/add-recipe.svg', import.meta.url).toString()
const router = useRouter() const router = useRouter()
const route = useRoute()
const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
function onSelectRecipe(r: Pick<Recipe, 'id'>) { function onSelectRecipe(r: Pick<Recipe, 'id'>) {
router.push({ name: 'recipe-edit', params: { householdSlug: slug, id: r.id } }) router.push(toRecipeEdit(r.id))
} }
function onAddRecipe() { function onAddRecipe() {
router.push({ name: 'recipe-add', params: { householdSlug: slug } }) router.push(toRecipeAdd())
} }
</script> </script>

View file

@ -102,6 +102,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onBeforeMount } from 'vue' import { ref, computed, onBeforeMount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { toShoppingList } from '@/router/links'
import { useAlert } from '@/composables/useAlert' import { useAlert } from '@/composables/useAlert'
import { useShopping } from '@/composables/useShopping' import { useShopping } from '@/composables/useShopping'
import { getUpcomingMeals } from '@/api/sdk' import { getUpcomingMeals } from '@/api/sdk'
@ -191,7 +192,7 @@ async function markPurchased() {
return return
} }
selected.value = [] selected.value = []
router.push(`/shopping/${shopping.id}`) router.push(toShoppingList(shopping.id))
} }
function toggleSelect(item: Group) { function toggleSelect(item: Group) {

View file

@ -43,11 +43,11 @@
</span> </span>
<span v-else-if="source.recipe && source.ingredient"> <span v-else-if="source.recipe && source.ingredient">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in
<router-link :to="`/recipes/${source.recipe.id}/`">{{ <router-link :to="toRecipeEdit(source.recipe.id)">{{
source.recipe.name source.recipe.name
}}</router-link> }}</router-link>
for for
<router-link :to="`/meals/${source.meal?.id}/`">{{ <router-link :to="source.meal ? toMealEdit(source.meal.id) : toRecipes()">{{
source.meal?.suggestedDate source.meal?.suggestedDate
? source.meal.suggestedDate.toLocaleDateString('en-AU', { ? source.meal.suggestedDate.toLocaleDateString('en-AU', {
weekday: 'long', weekday: 'long',
@ -59,7 +59,7 @@
</span> </span>
<span v-else-if="source.meal && source.ingredient"> <span v-else-if="source.meal && source.ingredient">
{{ source.ingredient.line }} for {{ source.ingredient.line }} for
<router-link :to="`/meals/${source.meal?.id}/`">{{ <router-link :to="source.meal ? toMealEdit(source.meal.id) : toMealPlan()">{{
source.meal?.suggestedDate source.meal?.suggestedDate
? source.meal.suggestedDate.toLocaleDateString('en-AU', { ? source.meal.suggestedDate.toLocaleDateString('en-AU', {
weekday: 'long', weekday: 'long',
@ -84,11 +84,11 @@
</span> </span>
<span v-else-if="source.recipe && source.ingredient"> <span v-else-if="source.recipe && source.ingredient">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in
<router-link :to="`/recipes/${source.recipe.id}/`">{{ <router-link :to="toRecipeEdit(source.recipe.id)">{{
source.recipe.name source.recipe.name
}}</router-link> }}</router-link>
for for
<router-link :to="`/meals/${source.meal?.id ?? ''}/`">{{ <router-link :to="source.meal ? toMealEdit(source.meal.id) : toRecipes()">{{
source.meal?.suggestedDate source.meal?.suggestedDate
? source.meal.suggestedDate.toLocaleDateString('en-AU', { ? source.meal.suggestedDate.toLocaleDateString('en-AU', {
weekday: 'long', weekday: 'long',
@ -100,7 +100,7 @@
</span> </span>
<span v-else-if="source.meal && source.ingredient"> <span v-else-if="source.meal && source.ingredient">
{{ source.ingredient.line }} for {{ source.ingredient.line }} for
<router-link :to="`/meals/${source.meal.id}/`">{{ <router-link :to="toMealEdit(source.meal.id)">{{
source.meal.suggestedDate source.meal.suggestedDate
? source.meal.suggestedDate.toLocaleDateString('en-AU', { ? source.meal.suggestedDate.toLocaleDateString('en-AU', {
weekday: 'long', weekday: 'long',
@ -118,11 +118,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { toMealEdit, toRecipeEdit, toMealPlan, toRecipes } from '@/router/links'
import { ago } from '@/dateformats' import { ago } from '@/dateformats'
import { calculateTotals } from '@/units' import { calculateTotals } from '@/units'
import type { Group } from '@/composables/useShopping' import type { Group } from '@/composables/useShopping'
const props = defineProps<{ shoppingListItemGroup: Group }>() const props = defineProps<{ shoppingListItemGroup: Group }>()
// Slug resolved via link helpers using current household context
const fallbackImg = new URL('@/assets/missing-product.svg', import.meta.url).toString() const fallbackImg = new URL('@/assets/missing-product.svg', import.meta.url).toString()
const imageSrc = computed(() => { const imageSrc = computed(() => {

66
src/router/links.ts Normal file
View file

@ -0,0 +1,66 @@
import type { RouteLocationRaw } from 'vue-router'
import { getHouseholdSlug } from '@/api/client'
// Centralized builders for slug-scoped routes to keep navigation consistent
// When an options object is provided, require an explicit slug.
// Callers may also omit the argument entirely to infer from context.
type WithSlug = { slug: string }
function resolveSlug(input?: WithSlug): string {
if (input && typeof input.slug === 'string') return input.slug
return getHouseholdSlug() ?? ''
}
// Pages without ids
export function toMealPlan(slugOrOpts?: WithSlug): RouteLocationRaw {
const slug = resolveSlug(slugOrOpts)
return { name: 'mealplan', params: { householdSlug: slug } }
}
export function toRecipes(slugOrOpts?: WithSlug): RouteLocationRaw {
const slug = resolveSlug(slugOrOpts)
return { name: 'recipes', params: { householdSlug: slug } }
}
export function toRecipeAdd(slugOrOpts?: WithSlug): RouteLocationRaw {
const slug = resolveSlug(slugOrOpts)
return { name: 'recipe-add', params: { householdSlug: slug } }
}
export function toMealAdd(slugOrOpts?: WithSlug): RouteLocationRaw {
const slug = resolveSlug(slugOrOpts)
return { name: 'meal-add', params: { householdSlug: slug } }
}
export function toShoppingCurrent(slugOrOpts?: WithSlug): RouteLocationRaw {
const slug = resolveSlug(slugOrOpts)
return { name: 'shopping-current', params: { householdSlug: slug } }
}
export function toHouseholdSettings(slugOrOpts?: WithSlug): RouteLocationRaw {
const slug = resolveSlug(slugOrOpts)
return { name: 'household-settings', params: { householdSlug: slug } }
}
// Pages with ids
type WithId = { id: number | string }
type IdOrOpts = number | string | (WithId & WithSlug)
export function toRecipeEdit(idOrOpts: IdOrOpts): RouteLocationRaw {
const id = typeof idOrOpts === 'object' ? idOrOpts.id : idOrOpts
const slug = resolveSlug(typeof idOrOpts === 'object' ? idOrOpts : undefined)
return { name: 'recipe-edit', params: { householdSlug: slug, id } }
}
export function toMealEdit(idOrOpts: IdOrOpts): RouteLocationRaw {
const id = typeof idOrOpts === 'object' ? idOrOpts.id : idOrOpts
const slug = resolveSlug(typeof idOrOpts === 'object' ? idOrOpts : undefined)
return { name: 'meal-edit', params: { householdSlug: slug, id } }
}
export function toShoppingList(idOrOpts: IdOrOpts): RouteLocationRaw {
const id = typeof idOrOpts === 'object' ? idOrOpts.id : idOrOpts
const slug = resolveSlug(typeof idOrOpts === 'object' ? idOrOpts : undefined)
return { name: 'shopping-list', params: { householdSlug: slug, id } }
}

View file

@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest'
describe('slug-scoped navigation', () => {
it('feature routes are nested under :householdSlug and links use named routes', async () => {
const { createAppRouter } = await import('@/router/index')
const router = createAppRouter(() => ({}))
const paths = router.getRoutes().map((r) => r.path)
expect(paths).toContain('/:householdSlug/recipes')
expect(paths).toContain('/:householdSlug/recipes/:id')
expect(paths).toContain('/:householdSlug/mealplan')
expect(paths).toContain('/:householdSlug/meals/add')
expect(paths).toContain('/:householdSlug/meals/:id')
expect(paths).toContain('/:householdSlug/shopping')
expect(paths).toContain('/:householdSlug/shopping/current')
expect(paths).toContain('/:householdSlug/shopping/:id')
// No flat feature routes
expect(paths).not.toContain('/recipes')
expect(paths).not.toContain('/meals/:id')
expect(paths).not.toContain('/shopping')
})
})