Compare commits

..

No commits in common. "multitenant" and "master" have entirely different histories.

67 changed files with 1795 additions and 3367 deletions

View file

@ -1,30 +0,0 @@
---
description: 'UAT With Devtools'
tools: ['edit', 'search', 'runCommands', 'chromedevtools/chrome-devtools-mcp/*', 'usages', 'vscodeAPI', 'problems', 'changes', 'testFailure', 'fetch', 'todos', 'runTests']
---
You are a UAT (User Acceptance Testing) assistant with access to a full browser and developer tools. Your role is to help users test software applications in browser by simulating real-world usage scenarios and identifying any issues or bugs. You inspect DOM, inspect screenshots closely, check margins, ensure components are well aligned, and understand UX and design principles well. The browser is your primary tool.
You have expert code reading and writing experience. You perform root cause analysis, produce eloquent code with strict typing, and always follow TDD principles. You have many additional skills and tools at your disposal to help you perform your role fully.
This is a meal planning and recipe management web application built with Vue 3 and TypeScript.
Features:
- User authentication (sign up, log in, log out)
- Household segregation and management (no shared meals, recipes, or shopping lists between households)
- Natural language ingredient parsing
- Recipe book with search and filtering
- Meal planning calendar
- Shopping list generation
- Adhoc extra items for meals and shopping lists
See uat-profiles.md for environment URLs and test user account details. Update as you create new profiles and personas.
Prefer navigation via UI and router links over direct URL manipulation.
Note improvements as you go, especially if they impact usability or accessibility.
Note navigational oddities or broken flows.
The team cannot repeat your testing. They can not resolve issues without detailed reports. On crash or errors, capture console logs, stack traces, and always search for and include useful network logs (eg on save error, include the save request, response and prior get requests that feed into it). Note whether to defer to backend or frontend teams.
Strive to use authentic data in testing, reflecting real-world usage. Funny is even better.

1
.nvmrc
View file

@ -1 +0,0 @@
v20.19.0

View file

@ -48,15 +48,13 @@ Key axioms
- No casts (`as`, angle brackets) and no `any`/`unknown` in app code. Generated files are exempt.
- Arrays that are required in OpenAPI are non-nullable in domain types (e.g., `Meal.recipes`).
- Disallow runtime type checks in app code; acceptable exceptions: DOM event narrowing, error/env handling in the boundary.
- Let the type system prevent mistakes: encode intent in function signatures. Prefer explicit option objects to loose primitives, make required fields non-optional, and avoid permissive overloads that widen the error surface.
- Navigation is slug-scoped and helper-driven: only use `src/router/links.ts` helpers and named routes. For non-id pages pass `{ slug }` or omit to infer; for id pages pass `id` or `{ id, slug }`. Never build path strings or read slugs from route params in components.
Layout
- `src/api/` — Typed client and SDK boundary
- `src/domain/` — Domain types and decoders
- `src/composables/` — Reusable app logic (auth, meals, shopping, pagination, alert)
- `src/components/` — UI components and pages
- `src/router/` — Routes and helpers (`links.ts` for slug-scoped navigation, plus `parseRouteId`, `parseQueryString`)
- `src/router/` — Routes and helpers (`parseRouteId`, `parseQueryString`)
Testing and tooling
- Vitest + MSW under `tests/`

View file

@ -1,36 +0,0 @@
# Frontend Specification: Final Polish
## 1. Current State & Objective
**The multi-tenancy migration is functionally complete and successful.** The frontend has been refactored to a robust, household-scoped application using JWT-based authentication. All legacy `.js` tests have been removed, and the codebase is clean.
The objective is to complete the final remaining UI feature to officially close out the project.
## 2. Final Tasks
This checklist represents all remaining work.
- [x] **1. Implement "Copy Invite Link" UI**:
- **Objective**: Implement the user interface for inviting new members to a household using a "copy link" feature.
- **File**: `src/views/HouseholdSettings.vue`
- **Action**:
1. ~~The backend team will provide a new endpoint that, when called, returns a JSON object with an `invite_link`.~~ ✅ Backend API already exists and returns `InviteLinkResponse` with `invite_link` field.
2. ~~Update the "Invite" button logic to call this new endpoint.~~ ✅ Created `createInviteLink()` function in `src/api/invitations.ts`.
3. ~~On a successful response, use the browser's Clipboard API (`navigator.clipboard.writeText(response.invite_link)`) to copy the link.~~ ✅ Implemented in `onCopyInviteLink()` handler.
4. ~~Display a confirmation toast to the user (e.g., "Invite link copied to clipboard!").~~ ✅ Using `useAlert()` composable to show success toast.
- **Status**: ✅ **COMPLETED** - Added "Copy Invite Link" button to HouseholdSettings.vue with full clipboard integration and toast notification.
- [x] **2. Final Codebase Sweep**:
- **Objective**: Perform a final search for and remove any dead code, comments, or variables related to the old system.
- **Action**: Search the entire codebase for the following keywords: `legacy`, `old`, `previous`, `workaround`, `fallback`, `person`.
- **Outcome**: ✅ **COMPLETED** - Removed all legacy comments from:
- `src/composables/useAuth.ts` - Removed "Legacy username login removed" comment
- `src/components/LoginPage.vue` - Removed "Legacy quick-login removed" and "legacy login removed" comments
- `src/api/auth.ts` - Removed "Legacy username login has been removed" comment
- `src/api/sdk.ts` - Removed legacy shopping list stub functions (`getMyShoppingList`, `saveMyShoppingList`)
- `src/composables/useShopping.ts` - Removed references to removed stub functions
- **Note**: Remaining uses of "fallback", "person", etc. are legitimate application logic, not legacy code.
- [x] **3. Mark Project as Complete**:
- **Objective**: Once the above tasks are done, this document is complete.
- **Action**: ✅ **PROJECT COMPLETE** - All migration tasks successfully completed on November 2, 2025.

View file

@ -42,10 +42,6 @@
"vitest": "^1.6.0",
"vue-tsc": "^2.0.29"
},
"engines": {
"node": ">=20.19.0",
"npm": ">=10.9.0"
},
"lint-staged": {
"*.{js,vue,css,scss,md}": [
"prettier --write"

View file

@ -1,10 +1,10 @@
<template>
<div v-if="!isPublic && activeSlug">
<div>
<ul class="nav">
<li class="nav-item">
<router-link
class="nav-link"
:to="{ name: 'recipes', params: { householdSlug: activeSlug } }"
:to="{ name: 'recipes' }"
active-class="active"
>
Recipes
@ -13,7 +13,7 @@
<li class="nav-item">
<router-link
class="nav-link"
:to="{ name: 'mealplan', params: { householdSlug: activeSlug } }"
:to="{ name: 'mealplan' }"
active-class="active"
>
Meal Plan
@ -22,14 +22,13 @@
<li class="nav-item">
<router-link
class="nav-link"
:to="{ name: 'shopping', params: { householdSlug: activeSlug } }"
:to="{ name: 'shopping' }"
active-class="active"
>
Shopping
</router-link>
</li>
</ul>
<HouseholdSwitcher />
</div>
<div class="viewport">
<router-view />
@ -40,21 +39,8 @@
<script setup>
import AlertToast from './components/AlertToast.vue'
import HouseholdSwitcher from '@/components/HouseholdSwitcher.vue'
import { useHousehold } from '@/composables/useHousehold'
import { useRoute } from 'vue-router'
import { computed } from 'vue'
// Initialize household slug provider binding to current route
useHousehold()
// components in <script setup> are auto-registered by import + usage
const route = useRoute()
const isPublic = computed(() => {
const n = String(route.name || '')
return n === 'login' || n === 'create-account' || n === 'welcome' || n === 'invitation-accept'
})
const activeSlug = computed(() => (typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''))
</script>
<style>

View file

@ -1,115 +1,30 @@
import { api, fetchApi } from '@/api/client'
import { setAuthTokenProvider } from '@/api/client'
import type { User } from '@/domain/types'
import { api } from '@/api/client'
import type { Person } from '@/domain/types'
let cachedUser: User | null = null
let authToken: string | null = null
setAuthTokenProvider(() => authToken)
let cachedUser: Person | null = null
export async function currentUser(): Promise<User | null> {
export async function currentUser(): Promise<Person | null> {
if (cachedUser) return cachedUser
try {
const res = await api.POST('/api/v1/auth/refresh', { params: {} })
if (!res.response.ok) {
authToken = null
cachedUser = null
return null
}
const tokenVal = res.data?.accessToken ?? null
if (!tokenVal) {
authToken = null
cachedUser = null
return null
}
authToken = tokenVal
// Populate a minimal user by probing households (backend does not expose a user endpoint yet)
const hs = await api.GET('/api/v1/users/me/households', { params: {} })
if (!hs.response.ok) {
cachedUser = { id: -1, email: '', displayName: '' }
return cachedUser
}
// In absence of a user profile endpoint, synthesize a stable user id
cachedUser = { id: -1, email: '', displayName: '' }
return cachedUser
const res = await api.POST('/api/v1/auth/refresh', { params: { cookie: { user_id: 0 } } })
if (!res.response.ok) return null
cachedUser = res.data ?? null
} catch (_) {
authToken = null
cachedUser = null
return null
}
return cachedUser
}
export async function loginWithPassword(email: string, password: string): Promise<User> {
const res = await api.POST('/api/v1/auth/login', { body: { email, password } })
export async function login(username: string): Promise<Person> {
const res = await api.POST('/api/v1/auth/login', { body: { username } })
if (!res.response.ok) {
const err = res.error
throw ((err instanceof Error && err) || new Error(`${res.response.status} ${res.response.statusText || 'HTTP error'}`))
throw (
(err instanceof Error && err) ||
(typeof err === 'string' ? new Error(err) : new Error(`${res.response.status} ${res.response.statusText || 'HTTP error'}`))
)
}
const token = res.data?.accessToken ?? null
const user = res.data?.user ?? null
if (!token || !user) throw new Error('Invalid token response')
authToken = token
cachedUser = { id: user.id, email: user.email ?? '', displayName: user.displayName ?? '' }
cachedUser = res.data ?? null
if (!cachedUser) throw new Error('Login failed: empty response')
return cachedUser
}
export async function createAccount(email: string, displayName: string, password: string): Promise<User> {
const res = await api.POST('/api/v1/auth/register', { body: { email, password, displayName } })
if (!res.response.ok) {
const err = res.error
throw ((err instanceof Error && err) || new Error(`${res.response.status} ${res.response.statusText || 'HTTP error'}`))
}
const token = res.data?.accessToken ?? null
const user = res.data?.user ?? null
if (!token || !user) throw new Error('Invalid token response')
authToken = token
cachedUser = { id: user.id, email: user.email ?? '', displayName: user.displayName ?? '' }
return cachedUser
}
// Google OAuth
export async function handleGoogleLogin(): Promise<string> {
// Ask backend for the Google OAuth start URL
const res = await fetchApi('/api/v1/auth/google/start', { method: 'GET' })
if (!res.ok) throw new Error(`${res.status} ${res.statusText || 'HTTP error'}`)
const data: unknown = await res.json()
const isObj = (v: unknown): v is { [k: string]: unknown } => v !== null && typeof v === 'object'
if (isObj(data)) {
const urlVal = data['url']
if (typeof urlVal === 'string') return urlVal
}
throw new Error('Invalid google start response')
}
export async function completeGoogleLogin(code: string, state?: string): Promise<User> {
const body: Record<string, unknown> = { code }
if (state) body.state = state
const res = await fetchApi('/api/v1/auth/google/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText || 'HTTP error'}`)
const data: unknown = await res.json()
const isObj = (v: unknown): v is { [k: string]: unknown } => v !== null && typeof v === 'object'
if (!isObj(data)) throw new Error('Invalid token response')
const tokenVal = data['accessToken']
const userVal = data['user']
if (!(typeof tokenVal === 'string' && isObj(userVal))) throw new Error('Invalid token response')
const idVal = userVal['id']
if (typeof idVal !== 'number') throw new Error('Invalid token response')
const emailVal = userVal['email']
const nameVal = userVal['displayName']
authToken = tokenVal
cachedUser = {
id: idVal,
email: typeof emailVal === 'string' ? emailVal : '',
displayName: typeof nameVal === 'string' ? nameVal : '',
}
return cachedUser
}
export async function logout(): Promise<void> {
// Clear local cache; server session is cookie-based and will be refreshed on next call
cachedUser = null
authToken = null
}

View file

@ -9,59 +9,12 @@ const isTest = typeof process !== 'undefined' && (process.env?.VITEST === 'true'
const defaultBase = ''
const baseUrl: string = isTest ? 'http://localhost' : (vueCliBase || defaultBase)
let householdSlugProvider: (() => string | null) | null = null
let authTokenProvider: (() => string | null) | null = null
export function setHouseholdSlugProvider(provider: (() => string | null) | null) {
householdSlugProvider = provider
}
// Expose current household slug for SDK convenience (temporary during migration)
export function getHouseholdSlug(): string | null {
return householdSlugProvider ? householdSlugProvider() : null
}
export function setAuthTokenProvider(provider: (() => string | null) | null) {
authTokenProvider = provider
}
function requestInfoToUrl(input: RequestInfo | URL): string | null {
try {
if (typeof input === 'string') return input
if (typeof URL !== 'undefined' && input instanceof URL) return input.toString()
if (typeof Request !== 'undefined' && input instanceof Request) return input.url
return String(input)
} catch {
return null
}
}
function isRefreshRequest(input: RequestInfo | URL): boolean {
const url = requestInfoToUrl(input)
return !!url && url.includes('/api/v1/auth/refresh')
}
export const api = createClient<paths>({
baseUrl,
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers || {})
const token = authTokenProvider ? authTokenProvider() : null
if (token) headers.set('Authorization', `Bearer ${token}`)
const credentials = isRefreshRequest(input) ? 'include' : 'same-origin'
return globalThis.fetch(input, {
credentials,
credentials: 'include',
...init,
headers,
})
},
})
// For endpoints not yet in OpenAPI, provide a raw fetch that preserves header injection and base URL behavior
export async function fetchApi(path: string, init?: RequestInit): Promise<Response> {
const headers = new Headers(init?.headers || {})
const token = authTokenProvider ? authTokenProvider() : null
if (token) headers.set('Authorization', `Bearer ${token}`)
const url = baseUrl ? new URL(path, baseUrl).toString() : path
const credentials = path.includes('/api/v1/auth/refresh') ? 'include' : 'same-origin'
return globalThis.fetch(url, { credentials, ...init, headers })
}

View file

@ -1,15 +0,0 @@
import { api, getHouseholdSlug } from '@/api/client'
import type { components } from '@/api/types'
export type Member = components['schemas']['HouseholdMember']
// Prefer typed endpoint if exists, fallback to raw fetch for now
export async function listMembers(): Promise<Member[]> {
const householdSlug = getHouseholdSlug()
if (!householdSlug) throw new Error('Missing household slug')
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/members', {
params: { path: { householdSlug } },
})
if (!response.ok) throw new Error(`${response.status} ${response.statusText || 'HTTP error'}${error ? `: ${String(error)}` : ''}`)
return Array.isArray(data) ? data : []
}

View file

@ -1,26 +0,0 @@
import { api } from '@/api/client'
export type Household = { id: number; name: string; slug: string }
export async function acceptInvitation(token: string): Promise<Household> {
const { data, response, error } = await api.POST('/api/v1/invitations/accept', { body: { token } })
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText || 'HTTP error'}${error ? `: ${String(error)}` : ''}`)
}
const h = data?.household
if (!h || typeof h.id !== 'number' || typeof h.name !== 'string' || typeof h.slug !== 'string') {
throw new Error('Invalid invitation accept response')
}
return { id: h.id, name: h.name, slug: h.slug }
}
export async function createInviteLink(householdSlug: string): Promise<string> {
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/invitations', { params: { path: { householdSlug } } })
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText || 'HTTP error'}${error ? `: ${String(error)}` : ''}`)
}
const inviteLink = data?.invite_link
if (typeof inviteLink !== 'string') throw new Error('Invalid response: missing invite_link')
return inviteLink
}

View file

@ -1,9 +1,10 @@
import { api, getHouseholdSlug } from '@/api/client'
import { api } from '@/api/client'
import type { components } from '@/api/types'
import {
toDate,
decodeMeal,
decodeRecipe,
decodeIngredients,
decodeShoppingList,
decodeShoppingListItems,
decodeListIngredientItems,
@ -32,12 +33,6 @@ function httpError(response: Response, error: unknown): Error {
return new Error(`${response.status} ${response.statusText || 'HTTP error'}`)
}
function requireSlug(explicit?: string): string {
const slug = explicit ?? getHouseholdSlug() ?? null
if (!slug) throw new Error('Missing household slug')
return slug
}
// decodeLookup moved to domain/decoders to be reused across SDK and other modules
// Shopping list mapped view types now come from domain/types
@ -154,8 +149,6 @@ export function mapCurrentShoppingList(dto: components['schemas']['CurrentShoppi
return dtoOut
}
// With stricter OpenAPI types, we can rely on the typed responses and decoders.
export async function listRecipes(params?: { q?: string | null; cursor?: string | null; limit?: number }): Promise<Page<Recipe>> {
const query: Record<string, unknown> = {}
if (params) {
@ -163,86 +156,80 @@ export async function listRecipes(params?: { q?: string | null; cursor?: string
if (params.cursor !== undefined) query.cursor = params.cursor
if (typeof params.limit === 'number') query.limit = params.limit
}
const householdSlug = requireSlug()
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/recipes', { params: { path: { householdSlug }, query } })
const { data, error, response } = await api.GET('/api/v1/recipes', { params: { query } })
if (!response.ok) throw httpError(response, error)
return fromOpenApiPage(data ?? null, (r) => decodeRecipe(r))
}
export async function getRecipe(householdSlug: string, id: number | string): Promise<Recipe> {
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/recipes/{recipe_id}', {
params: { path: { householdSlug: requireSlug(householdSlug), recipe_id: Number(id) } },
})
export async function getRecipe(id: number | string): Promise<Recipe> {
const { data, error, response } = await api.GET('/api/v1/recipes/{recipe_id}', { params: { path: { recipe_id: Number(id) } } })
if (!response.ok) throw httpError(response, error)
const mapped = decodeRecipe(data)
if (!mapped) throw new Error('Recipe not found')
return mapped
}
export async function saveRecipe(recipe: components['schemas']['RecipeCreate-Input']): Promise<Recipe | null> {
const householdSlug = requireSlug()
// Map "Recipe-Input" to "RecipeCreate"
const body: components['schemas']['RecipeCreate-Input'] = {
name: recipe.name,
link: recipe.link,
serves: recipe.serves,
imageUrls: recipe.imageUrls ?? [],
ingredients: recipe.ingredients ?? [],
}
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/recipes', { params: { path: { householdSlug } }, body })
export async function saveRecipe(recipe: components['schemas']['Recipe-Input']): Promise<Recipe | null> {
const { data, error, response } = await api.POST('/api/v1/recipes', { body: recipe, params: { cookie: { user_id: 0 } } })
if (!response.ok) throw httpError(response, error)
return decodeRecipe(data)
}
export async function deleteRecipe(id: number | string): Promise<void> {
const householdSlug = requireSlug()
const { error, response } = await api.DELETE('/api/v1/households/{householdSlug}/recipes/{recipe_id}', { params: { path: { householdSlug, recipe_id: Number(id) } } })
const { error, response } = await api.DELETE('/api/v1/recipes/{recipe_id}', { params: { path: { recipe_id: Number(id) }, cookie: { user_id: 0 } } })
if (!response.ok) throw httpError(response, error)
}
export async function parseRecipe(url: string, householdSlugOverride?: string): Promise<Recipe | null> {
const householdSlug = requireSlug(householdSlugOverride)
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/recipes/parse-from-url', {
params: { path: { householdSlug } },
body: { url },
})
export async function parseRecipe(url: string): Promise<Recipe | null> {
const { data, error, response } = await api.GET('/api/v1/recipes/parse', { params: { query: { url }, cookie: { user_id: 0 } } })
if (!response.ok) throw httpError(response, error)
// Response is RecipeCreate-Output; map to domain Recipe via decoder by augmenting required ids
const created: components['schemas']['RecipeCreate-Output'] | undefined = data
if (!created) return null
const out: components['schemas']['RecipeOut'] = {
id: -1,
name: created.name,
link: created.link,
serves: created.serves,
imageUrls: created.imageUrls,
ingredients: created.ingredients,
createdById: -1,
}
return decodeRecipe(out)
return decodeRecipe(data)
}
export async function parseIngredients(lines: string[]): Promise<Ingredient[]> {
const householdSlug = requireSlug()
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/ingredients/parse', {
params: { path: { householdSlug }, query: { lines } },
const { data, error, response } = await api.GET('/api/v1/recipes/ingredients/parse', {
params: { query: { ingredients: lines } },
})
if (!response.ok) throw httpError(response, error)
return Array.isArray(data) ? data.map(decodeIngredient) : []
return decodeIngredients(data ?? [])
}
export async function parseProduct(): Promise<components['schemas']['Product'] | null> {
// Removed in v2 API; product parsing/creation is not exposed via this endpoint
throw new Error('parseProduct is not available in v2 API')
export async function parseProduct(
ingredient: Pick<components['schemas']['Ingredient'], 'name' | 'line'>,
url: string
): Promise<components['schemas']['Product'] | null> {
const body: components['schemas']['ProductUrl'] = { url, tags: [ingredient.name, ingredient.line] }
const res = await api.POST('/api/v1/products', { body })
if (!res.response.ok) throw httpError(res.response, res.error)
return res.data ?? null
}
// Person-related functions are removed as the entity is no longer in use.
// Persons
async function listPersons(params?: { q?: string | null; cursor?: string | null; limit?: number }): Promise<Page<components['schemas']['Person']>> {
const query: Record<string, unknown> = {}
if (params) {
if (params.q !== undefined) query.q = params.q
if (params.cursor !== undefined) query.cursor = params.cursor
if (typeof params.limit === 'number') query.limit = params.limit
}
const { data, error, response } = await api.GET('/api/v1/persons', { params: { query } })
if (!response.ok) throw httpError(response, error)
const normalized = Array.isArray(data) ? { items: data } : (data ?? null)
return fromOpenApiPage<components['schemas']['Person'], components['schemas']['Person']>(normalized, (p) => p)
}
export async function getPersonsInHome(): Promise<Page<components['schemas']['Person']>> {
return listPersons()
}
export async function searchPersons(name: string): Promise<Page<components['schemas']['Person']>> {
return listPersons({ q: name })
}
// Meals
export async function getUpcomingMeals(from: Date, to: Date): Promise<Meal[]> {
const householdSlug = requireSlug()
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/meals/upcoming', {
params: { path: { householdSlug }, query: { from: from.toISOString(), to: to.toISOString() } },
const { data, error, response } = await api.GET('/api/v1/meals/upcoming', {
params: { query: { from: from.toISOString(), to: to.toISOString() } },
})
if (!response.ok) throw httpError(response, error)
const list = Array.isArray(data) ? data : []
@ -253,35 +240,32 @@ export async function getUpcomingMeals(from: Date, to: Date): Promise<Meal[]> {
}
export async function getMeal(id: number | string): Promise<Meal> {
const householdSlug = requireSlug()
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/meals/{meal_id}', { params: { path: { householdSlug, meal_id: Number(id) } } })
const { data, error, response } = await api.GET('/api/v1/meals/{meal_id}', { params: { path: { meal_id: Number(id) } } })
if (!response.ok) throw httpError(response, error)
const mapped = decodeMeal(data)
if (!mapped) throw new Error('Meal not found')
return mapped
}
export async function saveMeal(meal: components['schemas']['MealIn']): Promise<Meal | null> {
export async function saveMeal(meal: components['schemas']['Meal-Input']): Promise<Meal | null> {
const hasId = typeof meal.id === 'number' && meal.id >= 0
const householdSlug = requireSlug()
if (hasId) {
const { data, error, response } = await api.PUT('/api/v1/households/{householdSlug}/meals/{meal_id}', {
params: { path: { householdSlug, meal_id: Number(meal.id) } },
const { data, error, response } = await api.PUT('/api/v1/meals/{meal_id}', {
params: { path: { meal_id: Number(meal.id) } },
body: meal,
})
if (!response.ok) throw httpError(response, error)
return decodeMeal(data)
} else {
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/meals', { params: { path: { householdSlug } }, body: meal })
const { data, error, response } = await api.POST('/api/v1/meals', { body: meal })
if (!response.ok) throw httpError(response, error)
return decodeMeal(data)
}
}
export async function markMealConsumed(mealId: number | string): Promise<Meal> {
const householdSlug = requireSlug()
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/meals/{meal_id}/consumed', {
params: { path: { householdSlug, meal_id: Number(mealId) } },
const { data, error, response } = await api.POST('/api/v1/meals/{meal_id}/consumed', {
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } },
})
if (!response.ok) throw httpError(response, error)
const mapped = decodeMeal(data)
@ -290,24 +274,37 @@ export async function markMealConsumed(mealId: number | string): Promise<Meal> {
}
export async function deleteMeal(mealId: number | string): Promise<void> {
const householdSlug = requireSlug()
const { error, response } = await api.DELETE('/api/v1/households/{householdSlug}/meals/{meal_id}', {
params: { path: { householdSlug, meal_id: Number(mealId) } },
const { error, response } = await api.DELETE('/api/v1/meals/{meal_id}', {
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } },
})
if (!response.ok) throw httpError(response, error)
}
// Shopping
export async function getMyShoppingList(): Promise<Ingredient[]> {
const { data, error, response } = await api.GET('/api/v1/shopping/current/me/ingredients', { params: { cookie: { user_id: 0 } } })
if (!response.ok) throw httpError(response, error)
return decodeIngredients(data ?? [])
}
export async function saveMyShoppingList(items: components['schemas']['Ingredient'][]): Promise<Ingredient[]> {
const { data, error, response } = await api.POST('/api/v1/shopping/current/me/ingredients', {
body: items,
params: { cookie: { user_id: 0 } },
})
if (!response.ok) throw httpError(response, error)
return decodeIngredients(data ?? [])
}
export async function getShoppingList(id: number | string): Promise<import('@/domain/types').ShoppingListWithRefs | null> {
const householdSlug = requireSlug()
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/shopping/{list_id}', { params: { path: { householdSlug, list_id: Number(id) } } })
const { data, error, response } = await api.GET('/api/v1/shopping/{list_id}', { params: { path: { list_id: Number(id) } } })
if (!response.ok) throw httpError(response, error)
const mapped = mapPurchasedShoppingList(data)
return mapped?.list ?? null
}
export async function getCurrentShoppingList(): Promise<CurrentShoppingListDTO> {
const householdSlug = requireSlug()
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/shopping/current', { params: { path: { householdSlug } } })
const { data, error, response } = await api.GET('/api/v1/shopping/current')
if (!response.ok) throw httpError(response, error)
const mapped = mapCurrentShoppingList(data)
if (!mapped) throw new Error('Failed to map current shopping list')
@ -336,10 +333,9 @@ export async function purchaseShoppingList(
storeName: 'home',
items,
}
const householdSlug = requireSlug()
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/shopping', {
params: { path: { householdSlug } },
const { data, error, response } = await api.POST('/api/v1/shopping', {
body,
params: { cookie: { user_id: 0 } },
})
if (!response.ok) throw httpError(response, error)
const mapped = mapPurchasedShoppingList(data)
@ -347,40 +343,16 @@ export async function purchaseShoppingList(
}
export async function requestMeal(mealId: number | string): Promise<void> {
const householdSlug = requireSlug()
const { error, response } = await api.POST('/api/v1/households/{householdSlug}/shopping/current/meals/me', {
params: { path: { householdSlug } },
const { error, response } = await api.POST('/api/v1/shopping/current/meals/me', {
body: { mealId: Number(mealId) },
params: { cookie: { user_id: 0 } },
})
if (!response.ok) throw httpError(response, error)
}
export async function unrequestMeal(mealId: number | string): Promise<void> {
const householdSlug = requireSlug()
const { error, response } = await api.DELETE('/api/v1/households/{householdSlug}/shopping/current/meals/{meal_id}', {
params: { path: { householdSlug, meal_id: Number(mealId) } },
})
if (!response.ok) throw httpError(response, error)
}
export async function requestIngredient(ingredientId: number): Promise<import('@/domain/types').ListIngredientItemWithRefs> {
const householdSlug = requireSlug()
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/shopping/current/ingredients', {
params: { path: { householdSlug } },
body: { ingredientId },
})
if (!response.ok) throw httpError(response, error)
if (!data) throw new Error('Failed to decode requested ingredient item')
const [decoded] = decodeListIngredientItems([data])
if (!decoded) throw new Error('Failed to decode requested ingredient item')
return decoded
}
export async function unrequestIngredient(ingredientId: number): Promise<void> {
const householdSlug = requireSlug()
const { error, response } = await api.DELETE('/api/v1/households/{householdSlug}/shopping/current/ingredients', {
params: { path: { householdSlug } },
body: { ingredientId },
const { error, response } = await api.DELETE('/api/v1/shopping/current/meals/{meal_id}', {
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } },
})
if (!response.ok) throw httpError(response, error)
}

File diff suppressed because it is too large Load diff

View file

@ -1,66 +0,0 @@
<template>
<div
v-if="households.length > 0"
class="household-switcher"
>
<label>Household:</label>
<ul>
<li
v-for="h in households"
:key="h.slug"
>
<router-link
:to="toHousehold(h.slug)"
:class="{ active: h.slug === activeSlug }"
>
{{ h.name }}
</router-link>
</li>
</ul>
<router-link
class="settings-link"
:to="toSettings()"
>
Settings
</router-link>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
const route = useRoute()
const { households } = useAuth()
const activeSlug = computed(() => (typeof route.params.householdSlug === 'string' ? route.params.householdSlug : null))
function toHousehold(slug: string) {
return { name: 'mealplan', params: { householdSlug: slug } }
}
function toSettings() {
const slug = activeSlug.value
if (typeof slug === 'string' && slug.length > 0) {
return { name: 'household-settings', params: { householdSlug: slug } }
}
return { name: 'household-settings' }
}
</script>
<style scoped>
.household-switcher {
display: inline-block;
}
.household-switcher ul {
list-style: none;
display: inline-flex;
gap: 8px;
margin: 0 0 0 8px;
padding: 0;
}
.settings-link {
margin-left: 12px;
}
</style>

View file

@ -1,102 +1,49 @@
<template>
<div class="login">
<h1>Login</h1>
<!-- Multitenant: Email/Password login -->
<div v-if="isMultitenant">
<form @submit.prevent="onSubmitLogin">
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
required
<h1>Login Page</h1>
<ul class="button-group">
<li
v-for="(person, index) in persons"
:key="person.id ?? index"
>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
required
>
</div>
<button
type="submit"
class="btn btn-primary"
>
Sign in
</button>
<button
type="button"
class="btn btn-secondary"
@click="onGoogleLogin"
class="btn btn-primary"
@click="onLogin(person)"
>
Sign in with Google
{{ person.name }}
</button>
<router-link
class="btn btn-link"
:to="{ name: 'create-account' }"
>
Create account
</router-link>
</form>
</div>
<div v-else>
<p>This environment is configured without multi-tenancy enabled.</p>
</div>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { loginWithPassword, handleGoogleLogin } from '@/api/auth'
import { useAuth } from '@/composables/useAuth'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getPersonsInHome } from '@/api/sdk'
import { login as loginApi } from '@/api/auth'
import type { Person } from '@/domain/types'
const props = defineProps({
redirect: { type: String, default: '/' },
})
const router = useRouter()
const route = useRoute()
const email = ref('')
const password = ref('')
const isMultitenant = computed(() => true)
const { user } = useAuth()
const persons = ref<Person[]>([])
onMounted(async () => {})
onMounted(async () => {
const page = await getPersonsInHome()
persons.value = page.items
})
function afterLoginNavigate() {
const q = route.query?.redirect
const redirectPath = (typeof q === 'string' ? q : undefined) || props.redirect
router.push(redirectPath || '/')
}
async function onSubmitLogin() {
try {
await loginWithPassword(email.value.trim(), password.value)
if (user.value?.id !== undefined) {
afterLoginNavigate()
async function onLogin(selectedPerson: Person) {
const person = await loginApi(selectedPerson.name)
if (person?.id >= 0) {
router.push(props.redirect)
return
}
alert('Login failed')
} catch (e) {
alert(e instanceof Error ? e.message : 'Login error')
}
}
async function onGoogleLogin() {
try {
const url = await handleGoogleLogin()
if (typeof window !== 'undefined') {
window.location.href = url
}
} catch (e) {
alert(e instanceof Error ? e.message : 'Google login not available')
}
}
</script>
@ -154,8 +101,4 @@ li:nth-child(4) > button {
background-color: #9a1f1f;
color: white;
}
.form-group {
margin-bottom: 12px;
}
</style>

View file

@ -32,13 +32,12 @@
<ul>
<li
v-for="ingredient in ingredients"
:key="ingredient.id ?? ingredient.line"
:key="ingredient"
>
<div v-if="editing">
<p class="ingredient-line">
<ingredient-line
:ingredient="ingredient"
@update-line="(ing, line) => emit('on-update-line', ing, line)"
@update-ingredient="updateIngredient"
@update-product-link="updateProduct"
/>
@ -58,10 +57,9 @@
</div>
</template>
<script setup lang="ts">
<script setup>
import { ref } from 'vue'
import type { Ingredient } from '@/domain/types'
import { parseIngredients } from '@/api/sdk'
import { parseProduct, parseIngredients } from '@/api/sdk'
import IngredientLine from './IngredientLine.vue'
import CompactParsedIngredient from './CompactParsedIngredient.vue'
const addCart = new URL('@/assets/add-cart.svg', import.meta.url).toString()
@ -69,33 +67,28 @@ const editOff = new URL('@/assets/edit-off.svg', import.meta.url).toString()
const editOn = new URL('@/assets/edit.svg', import.meta.url).toString()
const trash = new URL('@/assets/trash.svg', import.meta.url).toString()
const props = defineProps<{ ingredients: Ingredient[]; editOnly?: boolean }>()
const emit = defineEmits<{
(e: 'on-add'): void
(e: 'on-delete', ingredient: Ingredient): void
(e: 'on-update-ingredient', ingredient: Ingredient, newIngredient: Ingredient): void
(e: 'on-update-line', ingredient: Ingredient, newLine: string): void
(e: 'on-editing', isEditing: boolean): void
}>()
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(): Promise<void> {
// parseProduct is not available in v2 API; ignore for now
async function updateProduct(ingredient, product_link) {
const product = await parseProduct(ingredient, product_link)
emit('on-update-ingredient', ingredient, { ...ingredient, product })
}
async function updateIngredient(ingredient: Ingredient, line: string) {
async function updateIngredient(ingredient, line) {
const newIngredients = await parseIngredients([line])
const next = newIngredients[0]
if (next) emit('on-update-ingredient', ingredient, next)
emit('on-update-ingredient', ingredient, newIngredients[0])
}
function toggleEditing() {
editing.value = !editing.value
emit('on-editing', editing.value)
}
</script>
<style scoped>

View file

@ -4,7 +4,6 @@
<input
v-model="ingredientText"
placeholder="Enter an ingredient"
@input="onInput"
@keyup.enter="updateIngredient"
@blur="updateIngredient"
>
@ -31,7 +30,6 @@ import type { Ingredient } from '@/domain/types'
const props = defineProps<{ ingredient: Ingredient }>()
const emit = defineEmits<{
(e: 'update-line', ingredient: Ingredient, newLine: string): void
(e: 'update-ingredient', ingredient: Ingredient, newLine: string): void
(e: 'update-product-link', ingredient: Ingredient, link: string): void
}>()
@ -59,20 +57,6 @@ function updateProductLink() {
emit('update-product-link', props.ingredient, productLink.value)
}
}
// Emit raw line changes so parent stays in sync even before parse
function isHtmlInput(el: EventTarget | null): el is HTMLInputElement {
return typeof HTMLElement !== 'undefined' && el instanceof HTMLInputElement
}
function onInput(e: Event) {
let val = ingredientText.value
const t = e.target
if (isHtmlInput(t)) {
val = t.value
}
if (val !== props.ingredient.line) emit('update-line', props.ingredient, val)
}
</script>
<style scoped>

View file

@ -5,23 +5,23 @@
:date="meal.suggestedDate ?? new Date()"
@date-selected="selectDate"
/>
<div class="members-list">
<div class="persons-list">
Cooked by
<member-list
<person-list
:people="meal.chefs"
@remove="(p) => removePerson('chefs', p)"
@add="(p) => addPerson('chefs', p)"
@remove-person="(p) => removePerson('chefs', p)"
@add-person="(p) => addPerson('chefs', p)"
/>
for
<member-list
<person-list
:people="meal.consumers"
@remove="(p) => removePerson('consumers', p)"
@add="(p) => addPerson('consumers', p)"
@remove-person="(p) => removePerson('consumers', p)"
@add-person="(p) => addPerson('consumers', p)"
/>, with
<member-list
<person-list
:people="meal.cleanup"
@remove="(p) => removePerson('cleanup', p)"
@add="(p) => addPerson('cleanup', p)"
@remove-person="(p) => removePerson('cleanup', p)"
@add-person="(p) => addPerson('cleanup', p)"
/>
on cleanup.
</div>
@ -102,7 +102,6 @@
@on-add="addIngredient"
@on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient"
@on-update-line="updateIngredientLine"
@on-editing="onEditAdditionalIngredients"
/>
</div>
@ -119,15 +118,12 @@
<script setup lang="ts">
import { reactive, onBeforeMount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toMealEdit } from '@/router/links'
import { getMeal, saveMeal, getRecipe } from '@/api/sdk'
import { toMealInput } from '@/domain/decoders'
import { currentUser } from '@/api/auth'
import { useAlert } from '@/composables/useAlert'
import { parseRouteId } from '@/router/helpers'
import type { Ingredient, Meal, MealRecipe } from '@/domain/types'
import { listMembers } from '@/api/households'
import { selectMyMember } from '@/domain/members'
import type { Person, Ingredient, Meal, MealRecipe } from '@/domain/types'
import { ago } from '@/dateformats'
@ -135,14 +131,12 @@ import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue'
import RecipeCard from '@/components/recipes/RecipeCard.vue'
import DatePicker from './DatePicker.vue'
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue'
import MemberList from './MemberList.vue'
import PersonList from './PersonList.vue'
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue'
const showIngredientsIcon = new URL('@/assets/show-ingredients.svg', import.meta.url).toString()
const trash = new URL('@/assets/trash.svg', import.meta.url).toString()
import type { MemberRef } from '@/domain/types'
function addPersonIfNotExists<T extends { id: number }>(list: T[], person: T | null | undefined) {
function addPersonIfNotExists(list: Person[], person: Person | null | undefined) {
if (!person) return
if (!list.find((p) => p.id === person.id)) {
list.push(person)
@ -153,13 +147,8 @@ const route = useRoute()
const router = useRouter()
const { show: showAlert } = useAlert()
// No child refs; parent maintains source of truth for lines
type PeopleKey = 'chefs' | 'consumers' | 'cleanup'
// Track which ingredient objects have unparsed edits so we only parse what changed
const dirtyLines = new Map<Ingredient, string>()
const meal = reactive<Meal>({
id: -1,
suggestedDate: new Date(),
@ -178,13 +167,11 @@ onBeforeMount(async () => {
const loaded = await getMeal(id)
Object.assign(meal, loaded)
} else {
const [user, members] = await Promise.all([currentUser(), listMembers()])
const fallback = Array.isArray(members) && members.length > 0 ? { id: members[0]!.id, displayName: members[0]!.displayName } : null
const me = selectMyMember(members, user) ?? fallback
if (me) {
meal.chefs = [me]
meal.consumers = [me]
meal.cleanup = [me]
const self = await currentUser()
if (self) {
meal.chefs = [self]
meal.consumers = [self]
meal.cleanup = [self]
}
}
})
@ -213,37 +200,28 @@ function deleteIngredient(ingredient: Ingredient) {
function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) {
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? newIngredient : i))
// Clear dirty status for this row (was parsed and replaced)
if (dirtyLines.has(ingredient)) dirtyLines.delete(ingredient)
}
function updateIngredientLine(ingredient: Ingredient, newLine: string) {
// Update the raw line immediately so Save has the latest text
const idx = meal.extraIngredients.indexOf(ingredient)
if (idx >= 0) {
// mutate in place to preserve object identity (used as dirtyLines key)
const target = meal.extraIngredients[idx]
if (target) target.line = newLine
} else {
// fallback (shouldn't generally happen)
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? { ...i, line: newLine } : i))
}
// Mark as dirty to parse later (on save) if needed
dirtyLines.set(ingredient, newLine)
}
function removePerson(list: PeopleKey, person: MemberRef) {
function removePerson(list: PeopleKey, person: Person) {
meal[list] = meal[list].filter((p) => p.id !== person.id)
}
function addPerson(list: PeopleKey, person: MemberRef) {
function addPerson(list: PeopleKey, person: Person) {
addPersonIfNotExists(meal[list], person)
}
async function selectRecipe(recipe: { id: number | string }) {
// Refetch to get additional details
const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
const r = await getRecipe(slug, recipe.id)
const r = await getRecipe(recipe.id)
if (r.createdBy) {
addPersonIfNotExists(meal.chefs, r.createdBy)
addPersonIfNotExists(meal.consumers, r.createdBy)
if (meal.cleanup.length === 0) {
addPersonIfNotExists(meal.cleanup, r.createdBy)
}
}
meal.recipes.push({ recipe: r, recipeId: r.id, mealId: meal.id, servings: r.serves })
}
@ -252,7 +230,7 @@ async function onEditAdditionalIngredients(editing: boolean) {
if (editing && meal.extraIngredients.length === 0) {
addIngredient()
} else {
meal.extraIngredients = meal.extraIngredients.filter((i: Ingredient) => !!i.line)
meal.extraIngredients = meal.extraIngredients.filter((i) => !!i.line)
}
}
@ -279,70 +257,19 @@ function scaleIngredients(mealRecipe: MealRecipe) {
}
async function onSaveMeal() {
try {
// Blur any focused input so its change handlers run
if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
// 1) Drop any empty-line rows and clear their dirty flags
const nonEmpty: Ingredient[] = []
for (const ing of meal.extraIngredients) {
const line = typeof ing.line === 'string' ? ing.line.trim() : ''
if (line.length === 0) {
// also clear dirty if present
if (dirtyLines.has(ing)) dirtyLines.delete(ing)
continue
}
nonEmpty.push(ing)
}
meal.extraIngredients = nonEmpty
// 2) Build list of only the dirty lines that still exist in the array
const dirtyEntries: Array<{ ing: Ingredient; line: string }> = []
for (const [ing, line] of dirtyLines.entries()) {
// only consider ingredients still present
if (meal.extraIngredients.includes(ing)) {
const t = typeof line === 'string' ? line.trim() : ''
if (t.length > 0) dirtyEntries.push({ ing, line: t })
}
}
// 3) Parse only dirty lines
if (dirtyEntries.length > 0) {
const lines = dirtyEntries.map((e) => e.line)
const parsed = await (await import('@/api/sdk')).parseIngredients(lines)
// Replace corresponding rows by identity
parsed.forEach((p, idx) => {
const target = dirtyEntries[idx]?.ing
if (!target) return
const i = meal.extraIngredients.indexOf(target)
if (i >= 0) meal.extraIngredients.splice(i, 1, p)
// Clear dirty marker for this ingredient object
dirtyLines.delete(target)
})
}
// Final safety: drop any zero-quantity items (should be rare post-parse)
meal.extraIngredients = meal.extraIngredients.filter((i: Ingredient) => (typeof i.quantity === 'number' ? i.quantity > 0 : true))
const saved = await saveMeal(toMealInput(meal))
if (saved && saved.id >= 0) {
Object.assign(meal, saved)
router.push(toMealEdit(saved.id))
router.push(`/meals/${saved.id}`)
showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' })
return
}
showAlert({
heading: 'Error saving meal',
message: 'An unknown error occurred while saving the meal',
message: 'An error occurred while saving the meal',
type: 'error',
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save meal'
showAlert({ heading: 'Error saving meal', message, type: 'error' })
}
}
</script>
@ -352,17 +279,17 @@ img.icon {
height: 2em;
}
.members-list {
.persons-list {
text-align: left;
padding: 1ex 2em;
}
.members-list p {
.persons-list p {
margin: 0;
padding-bottom: 1em;
}
.member-list li {
.person-list li {
display: inline-block;
padding-right: 1em;
}

View file

@ -11,7 +11,7 @@
v-for="(chef, index) in meal.chefs"
:key="chef.id"
>
{{ chef.displayName }}{{ englishSeperator(index, meal.chefs) }}
{{ chef.name }}{{ englishSeperator(index, meal.chefs) }}
</span>
<span v-if="!meal.chefs.length">somebody?</span>
</p>
@ -21,7 +21,7 @@
v-for="(consumer, index) in meal.consumers"
:key="consumer.id"
>
{{ consumer.displayName }}{{ englishSeperator(index, meal.consumers) }}
{{ consumer.name }}{{ englishSeperator(index, meal.consumers) }}
</span>
<span v-if="!meal.consumers.length">somebody?</span>
</p>

View file

@ -23,7 +23,7 @@
<li>
<router-link
class="nav-link"
:to="{ name: 'meal-edit', params: { householdSlug: slug, id: selectedMeal.id } }"
:to="`/meals/${selectedMeal.id}`"
active-class="active"
>
Edit Meal
@ -45,14 +45,13 @@
<action-item
title="Plan Meal"
:image="planMeal"
@click="() => $router.push({ name: 'meal-add', params: { householdSlug: slug } })"
@click="() => $router.push('/meals/add')"
/>
</div>
</template>
<script setup>
import { ref, onBeforeMount } from 'vue'
import { useRoute } from 'vue-router'
import ActionItem from '@/components/ActionItem.vue'
import MealCard from '@/components/meals/MealCard.vue'
import { getUpcomingMeals, markMealConsumed, deleteMeal } from '@/api/sdk'
@ -68,8 +67,6 @@ to.setDate(to.getDate() + 7)
const meals = ref([])
const selectedMeal = ref(null)
const route = useRoute()
const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
onBeforeMount(async () => {
meals.value = await getUpcomingMeals(from, to)

View file

@ -1,20 +1,20 @@
<template>
<span class="member-list">
<span class="person-list">
<span
v-for="person in people"
:key="person.id"
>
<button
class="member-circle remove-person"
class="person-circle remove-person"
@click="removePerson(person)"
>
{{ person.displayName || '' }}
{{ person.name }}
</button>
</span>
<span>
<button
v-if="!isAddingPerson"
class="member-circle add-person"
class="person-circle add-person"
@click="isAddingPerson = true"
>
+
@ -30,17 +30,17 @@
<ul
v-if="isAddingPerson && searchResults.length"
ref="persondroplist"
class="member-droplist"
class="person-droplist"
>
<li
v-for="person in searchResults"
:key="person.id"
>
<button
class="member-circle add-person"
class="person-circle add-person"
@mousedown="addPerson(person)"
>
{{ person.displayName || '' }}
{{ person.name }}
</button>
</li>
</ul>
@ -50,35 +50,35 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { listMembers } from '@/api/households'
import type { MemberRef } from '@/domain/types'
const props = withDefaults(defineProps<{ people?: MemberRef[] }>(), { people: () => [] })
import { searchPersons } from '@/api/sdk'
import type { Person } from '@/domain/types'
const props = withDefaults(defineProps<{ people?: Person[] }>(), { people: () => [] })
const emit = defineEmits<{
(e: 'add', person: MemberRef): void
(e: 'remove', person: MemberRef): void
(e: 'add-person', person: Person): void
(e: 'remove-person', person: Person): void
}>()
const isAddingPerson = ref(false)
const searchName = ref('')
const searchResults = ref<MemberRef[]>([])
const searchResults = ref<Person[]>([])
// Template refs for DOM elements
const searchNameInput = ref<HTMLInputElement | null>(null)
const persondroplist = ref<HTMLUListElement | null>(null)
async function updateSearchResults() {
const q = searchName.value.trim().toLowerCase()
const allMembers = await listMembers()
const idSet = new Set(props.people.map((p) => p.id))
let results: MemberRef[] = allMembers.filter((p) => !idSet.has(p.id))
if (q) {
results = results.filter((p) => p.displayName.toLowerCase().includes(q))
const q = searchName.value.trim()
if (!q) {
searchResults.value = []
return
}
searchResults.value = results
const page = await searchPersons(q)
const results: Person[] = page.items.map((p) => ({ id: p.id, name: p.name }))
const idSet = new Set(props.people.map((p) => p.id))
searchResults.value = results.filter((p: Person) => !idSet.has(p.id))
}
function addPerson(person?: MemberRef) {
function addPerson(person?: Person) {
if (!person && searchResults.value.length > 0) {
person = searchResults.value[0]
}
@ -89,15 +89,15 @@ function addPerson(person?: MemberRef) {
return
}
if (!props.people.find((p) => p.id === person.id)) {
emit('add', person)
emit('add-person', person)
}
searchName.value = ''
searchResults.value = []
isAddingPerson.value = false
}
function removePerson(person: MemberRef) {
emit('remove', person)
function removePerson(person: Person) {
emit('remove-person', person)
}
// Watchers
@ -123,7 +123,7 @@ watch([persondroplist, searchNameInput], ([drop, input]) => {
</script>
<style scoped>
.member-list {
.person-list {
display: inline-block;
text-align: center;
min-height: 50px;
@ -132,15 +132,15 @@ watch([persondroplist, searchNameInput], ([drop, input]) => {
}
/* Remove all the button styling */
.member-circle {
.person-circle {
background: none;
border: none;
padding: 0;
margin: 0;
}
/* Show the initials of the member in a circle */
.member-circle {
/* Show the initials of the person in a circle */
.person-circle {
display: inline-block;
width: 50px;
height: 50px;
@ -182,7 +182,7 @@ watch([persondroplist, searchNameInput], ([drop, input]) => {
background-color: green;
}
.member-droplist {
.person-droplist {
position: absolute;
background-color: white;
border: 1px solid #ccc;
@ -195,7 +195,7 @@ watch([persondroplist, searchNameInput], ([drop, input]) => {
z-index: 1000;
}
.member-droplist li {
.person-droplist li {
padding: 8px;
cursor: pointer;
display: inline;

View file

@ -75,8 +75,7 @@ import { useAlert } from '@/composables/useAlert'
import { parseQueryString } from '@/router/helpers'
import { getRecipe, parseRecipe, saveRecipe as saveRecipeApi, deleteRecipe as deleteRecipeApi } from '@/api/sdk'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
import type { Recipe as DomainRecipe, Ingredient } from '@/domain/types'
import type { components } from '@/api/types'
import type { Recipe as DomainRecipe, Ingredient, RecipeInput } from '@/domain/types'
const props = defineProps({
id: { type: String, required: false, default: undefined },
@ -84,7 +83,6 @@ const props = defineProps({
const router = useRouter()
const route = useRoute()
const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
const { show: showAlert } = useAlert()
const link = ref<string>(parseQueryString(route.query.url))
@ -103,19 +101,19 @@ const image_styling = computed(() => {
})
function parseLink() {
router.push({ name: 'recipe-add', params: { householdSlug: slug }, query: { url: link.value } })
router.push({ path: '/recipes/add', query: { url: link.value } })
refreshRecipe()
}
async function refreshRecipe() {
const id = props.id ? parseInt(props.id) : null
if (id !== null && id >= 0) {
const r = await getRecipe(slug, id)
const r = await getRecipe(id)
recipe.value = r
link.value = r.link ?? ''
return
} else if (link.value) {
const r = await parseRecipe(link.value, slug)
const r = await parseRecipe(link.value)
recipe.value = r
parse_failed.value = !r
} else {
@ -136,10 +134,10 @@ function deleteIngredient(ingredient: Ingredient) {
}
async function saveRecipe() {
const saved = recipe.value ? await saveRecipeApi(toRecipeCreate(recipe.value)) : null
const saved = recipe.value ? await saveRecipeApi(toRecipeInput(recipe.value)) : null
if (saved && saved.id >= 0) {
showAlert({ heading: 'Recipe saved', message: 'Your recipe has been saved', type: 'success' })
router.push({ name: 'recipe-edit', params: { householdSlug: slug, id: saved.id } })
router.push(`/recipes/${saved.id}`)
return
}
showAlert({ heading: 'Error saving recipe', message: 'There was an error saving your recipe', type: 'error' })
@ -149,14 +147,13 @@ function createFromScratch() {
recipe.value = {
id: -1,
name: 'My new recipe',
createdById: -1,
link: '',
ingredients: [],
imageUrls: [],
serves: 1,
createdById: -1,
createdBy: null,
hiddenById: null,
hiddenBy: null,
dateCreated: new Date(),
dateHidden: null,
}
}
@ -171,7 +168,7 @@ async function deleteRecipe() {
if (confirm('Are you sure you want to delete this recipe?')) {
if (!recipe.value) return
await deleteRecipeApi(recipe.value.id)
router.push({ name: 'recipes', params: { householdSlug: slug } })
router.push('/recipes')
}
}
@ -192,13 +189,21 @@ watch(
)
// expose functions for template binding names (automatic in <script setup>)
function toRecipeCreate(r: DomainRecipe): components['schemas']['RecipeCreate-Input'] {
function toRecipeInput(r: DomainRecipe): RecipeInput {
return {
id: r.id,
name: r.name,
link: r.link,
serves: r.serves,
imageUrls: r.imageUrls ?? [],
ingredients: r.ingredients ?? [],
basedOnRecipe: r.basedOnRecipe ?? null,
// let backend set created/hidden dates
createdById: r.createdById,
createdBy: r.createdBy ?? null,
// dateHidden omitted
hiddenById: r.hiddenById ?? null,
hiddenBy: r.hiddenBy ?? null,
}
}
</script>

View file

@ -13,7 +13,6 @@
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { toRecipeAdd, toRecipeEdit } from '@/router/links'
import ActionItem from '@/components/ActionItem.vue'
import RecipeSearchBox from './RecipeSearchBox.vue'
import type { Recipe } from '@/domain/types'
@ -23,10 +22,10 @@ const addRecipe = new URL('@/assets/add-recipe.svg', import.meta.url).toString()
const router = useRouter()
function onSelectRecipe(r: Pick<Recipe, 'id'>) {
router.push(toRecipeEdit(r.id))
router.push(`/recipes/${r.id}`)
}
function onAddRecipe() {
router.push(toRecipeAdd())
router.push('/recipes/add')
}
</script>

View file

@ -102,7 +102,6 @@
<script setup lang="ts">
import { ref, computed, onBeforeMount } from 'vue'
import { useRouter } from 'vue-router'
import { toShoppingList } from '@/router/links'
import { useAlert } from '@/composables/useAlert'
import { useShopping } from '@/composables/useShopping'
import { getUpcomingMeals } from '@/api/sdk'
@ -158,21 +157,13 @@ async function loadData() {
}
async function mealSelected(meal: { id: number }) {
try {
await requestMeal(meal.id)
await loadData()
} catch (err) {
showAlert({ type: 'error', heading: 'Failed to include meal', message: err instanceof Error ? err.message : 'Unknown error' })
}
}
async function mealUnselected(meal: { id: number }) {
try {
await unrequestMeal(meal.id)
await loadData()
} catch (err) {
showAlert({ type: 'error', heading: 'Failed to un-include meal', message: err instanceof Error ? err.message : 'Unknown error' })
}
}
async function markFound() {
@ -192,7 +183,7 @@ async function markPurchased() {
return
}
selected.value = []
router.push(toShoppingList(shopping.id))
router.push(`/shopping/${shopping.id}`)
}
function toggleSelect(item: Group) {

View file

@ -1,34 +1,11 @@
<template>
<div>
<h1>My Shopping List</h1>
<router-link :to="{ name: 'shopping-current', params: { householdSlug: slug } }">
<router-link :to="`/shopping/current`">
Full Shopping List
</router-link>
<div class="adder">
<label>
Add existing ingredient:
<select v-model.number="selectedIngredientId">
<option :value="-1">-- select --</option>
<option
v-for="opt in availableIngredientOptions"
:key="opt.id"
:value="opt.id"
>
{{ opt.name }}
</option>
</select>
</label>
<button
:disabled="selectedIngredientId < 0"
@click="addSelectedIngredient"
>
Add
</button>
</div>
<editable-ingredients-panel
:ingredients="ingredients"
edit-only
@on-add="addIngredient"
@on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient"
@ -64,97 +41,47 @@
<script setup lang="ts">
import { ref, onBeforeMount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useRouter } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import { useAlert } from '@/composables/useAlert'
import { useShopping } from '@/composables/useShopping'
import type { Ingredient } from '@/domain/types'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
const router = useRouter()
const route = useRoute()
const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
const { loadUser } = useAuth()
const { show: showAlert } = useAlert()
const { getCurrentShoppingList, requestIngredient, unrequestIngredient } = useShopping()
const { getMyShoppingList, saveMyShoppingList } = useShopping()
const ingredients = ref<Ingredient[]>([])
let loading = false
const availableIngredientOptions = ref<{ id: number; name: string }[]>([])
const selectedIngredientId = ref<number>(-1)
async function refreshFromServer() {
const dto = await getCurrentShoppingList()
// Personal ad-hoc ingredients are those in outstandingItems with no recipeId and no mealId
const personalItems = (dto.outstandingItems ?? []).filter((i) => (i.recipeId ?? null) == null && (i.mealId ?? null) == null)
ingredients.value = personalItems.map((i) => ({
id: i.ingredientId,
name: dto.ingredientsLookup?.[String(i.ingredientId)]?.name ?? '',
line: dto.ingredientsLookup?.[String(i.ingredientId)]?.line ?? '',
unit: dto.ingredientsLookup?.[String(i.ingredientId)]?.unit ?? 'Items',
quantity: dto.ingredientsLookup?.[String(i.ingredientId)]?.quantity ?? 1,
preparation: dto.ingredientsLookup?.[String(i.ingredientId)]?.preparation ?? '',
productId: dto.ingredientsLookup?.[String(i.ingredientId)]?.productId ?? null,
recipeId: null,
mealId: null,
product: dto.ingredientsLookup?.[String(i.ingredientId)]?.product ?? null,
}))
// Populate available ingredient options from lookup
const lookup = dto.ingredientsLookup ?? {}
availableIngredientOptions.value = Object.keys(lookup)
.map((k) => ({ id: Number(k), name: lookup[k]?.name ?? `#${k}` }))
.sort((a, b) => a.name.localeCompare(b.name))
async function updateShoppingList(save = false) {
const newIngredients = save ? await saveMyShoppingList(ingredients.value) : await getMyShoppingList()
ingredients.value = newIngredients.map((i) => ({ ...i }))
}
async function addIngredient() {
if (loading) return
// Add a blank ingredient locally to allow text entry; upon edit, we parse line to an Ingredient
function addIngredient() {
ingredients.value = [
{ id: -1, name: '', line: '', quantity: 1, unit: 'Items', preparation: '', product: null, productId: null, recipeId: null, mealId: null },
{ id: -1, name: '', line: '', quantity: 1, unit: 'Items', preparation: '', product: null },
...ingredients.value,
]
}
async function addSelectedIngredient() {
try {
const id = selectedIngredientId.value
if (typeof id !== 'number' || id < 0) return
await requestIngredient(id)
selectedIngredientId.value = -1
await refreshFromServer()
} catch (e) {
showAlert({ type: 'error', message: e instanceof Error ? e.message : 'Failed to add item' })
}
}
async function deleteIngredient(ingredient: Ingredient) {
try {
if (ingredient.id && ingredient.id >= 0) {
await unrequestIngredient(ingredient.id)
await refreshFromServer()
}
function deleteIngredient(ingredient: Ingredient) {
ingredients.value = ingredients.value.filter((i) => i !== ingredient)
} catch (e) {
showAlert({ type: 'error', message: e instanceof Error ? e.message : 'Failed to remove item' })
}
}
function updateIngredient(oldIngredient: Ingredient, newIngredient: Ingredient) {
// Local edit only; ad-hoc add/remove is handled by the selector and delete button
ingredients.value = ingredients.value.map((source) => (source === oldIngredient ? newIngredient : source))
}
async function onEditing(isStartingEdit: boolean) {
if (isStartingEdit) {
await refreshFromServer()
if (ingredients.value.length === 0) await addIngredient()
}
await updateShoppingList(!isStartingEdit)
if (isStartingEdit && ingredients.value.length === 0) addIngredient()
}
onBeforeMount(async () => {
const u = await loadUser()
if (!u) return router.push({ name: 'login' })
await refreshFromServer()
await updateShoppingList()
})
</script>

View file

@ -43,11 +43,11 @@
</span>
<span v-else-if="source.recipe && source.ingredient">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in
<router-link :to="toRecipeEdit(source.recipe.id)">{{
<router-link :to="`/recipes/${source.recipe.id}/`">{{
source.recipe.name
}}</router-link>
for
<router-link :to="source.meal ? toMealEdit(source.meal.id) : toRecipes()">{{
<router-link :to="`/meals/${source.meal?.id}/`">{{
source.meal?.suggestedDate
? source.meal.suggestedDate.toLocaleDateString('en-AU', {
weekday: 'long',
@ -59,7 +59,7 @@
</span>
<span v-else-if="source.meal && source.ingredient">
{{ source.ingredient.line }} for
<router-link :to="source.meal ? toMealEdit(source.meal.id) : toMealPlan()">{{
<router-link :to="`/meals/${source.meal?.id}/`">{{
source.meal?.suggestedDate
? source.meal.suggestedDate.toLocaleDateString('en-AU', {
weekday: 'long',
@ -84,11 +84,11 @@
</span>
<span v-else-if="source.recipe && source.ingredient">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in
<router-link :to="toRecipeEdit(source.recipe.id)">{{
<router-link :to="`/recipes/${source.recipe.id}/`">{{
source.recipe.name
}}</router-link>
for
<router-link :to="source.meal ? toMealEdit(source.meal.id) : toRecipes()">{{
<router-link :to="`/meals/${source.meal?.id ?? ''}/`">{{
source.meal?.suggestedDate
? source.meal.suggestedDate.toLocaleDateString('en-AU', {
weekday: 'long',
@ -100,7 +100,7 @@
</span>
<span v-else-if="source.meal && source.ingredient">
{{ source.ingredient.line }} for
<router-link :to="toMealEdit(source.meal.id)">{{
<router-link :to="`/meals/${source.meal.id}/`">{{
source.meal.suggestedDate
? source.meal.suggestedDate.toLocaleDateString('en-AU', {
weekday: 'long',
@ -118,13 +118,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { toMealEdit, toRecipeEdit, toMealPlan, toRecipes } from '@/router/links'
import { ago } from '@/dateformats'
import { calculateTotals } from '@/units'
import type { Group } from '@/composables/useShopping'
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 imageSrc = computed(() => {

View file

@ -1,13 +1,8 @@
import { ref } from 'vue'
import { currentUser as apiCurrentUser, loginWithPassword as apiLoginWithPassword, logout as apiLogout, createAccount as apiCreateAccount } from '@/api/auth'
import { api } from '@/api/client'
import type { User } from '@/domain/types'
import { currentUser as apiCurrentUser, login as apiLogin } from '@/api/auth'
import type { Person } from '@/domain/types'
type Household = { id: number; name: string; slug: string }
const user = ref<User | null>(null)
const households = ref<Household[]>([])
const activeHousehold = ref<Household | null>(null)
const user = ref<Person | null>(null)
let initialized = false
export async function loadUser() {
@ -18,58 +13,11 @@ export async function loadUser() {
return user.value
}
export async function loginWithPassword(email: string, password: string) {
user.value = await apiLoginWithPassword(email, password)
export async function login(username: string) {
user.value = await apiLogin(username)
return user.value
}
export async function logout() {
await apiLogout()
user.value = null
households.value = []
activeHousehold.value = null
}
export function setHouseholds(hs: Household[]) {
households.value = Array.isArray(hs) ? hs.slice() : []
}
export function setActiveHousehold(h: Household | string | null) {
if (h == null) {
activeHousehold.value = null
return
}
if (typeof h === 'string') {
activeHousehold.value = households.value.find((x) => x.slug === h) ?? null
} else {
activeHousehold.value = h
}
}
export function useAuth() {
async function fetchHouseholds(): Promise<Household[]> {
const res = await api.GET('/api/v1/users/me/households', { params: {} })
const list = Array.isArray(res.data) ? res.data : []
const hs: Household[] = list.map((h) => ({ id: h.id, name: h.name, slug: h.slug }))
setHouseholds(hs)
return hs
}
async function createHousehold(name: string): Promise<Household> {
const res = await api.POST('/api/v1/households', { body: { name } })
if (!res.response.ok || !res.data) throw new Error('Failed to create household')
const h: Household = { id: res.data.id, name: res.data.name, slug: res.data.slug }
// Update local state: append and set active
setHouseholds([...households.value, h])
setActiveHousehold(h)
return h
}
async function createAccount(email: string, displayName: string, password: string) {
const u = await apiCreateAccount(email, displayName, password)
user.value = u
return u
}
return { user, households, activeHousehold, loadUser, loginWithPassword, logout, setHouseholds, setActiveHousehold, fetchHouseholds, createHousehold, createAccount }
return { user, loadUser, login }
}

View file

@ -1,18 +0,0 @@
import { computed, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import { setHouseholdSlugProvider } from '@/api/client'
export function useHousehold() {
const route = useRoute()
const activeHouseholdSlug = computed<string | null>(() => {
const p = route.params?.householdSlug
return typeof p === 'string' ? p : null
})
// Keep API client in sync with current route's household slug
watchEffect(() => {
setHouseholdSlugProvider(() => activeHouseholdSlug.value)
})
return { activeHouseholdSlug }
}

View file

@ -60,8 +60,9 @@ export function useShopping() {
purchaseShoppingList: sdk.purchaseShoppingList,
requestMeal: sdk.requestMeal,
unrequestMeal: sdk.unrequestMeal,
requestIngredient: sdk.requestIngredient,
unrequestIngredient: sdk.unrequestIngredient,
getMyShoppingList: sdk.getMyShoppingList,
saveMyShoppingList: sdk.saveMyShoppingList,
// View-model helpers
groupsFrom,
mealsFrom,
async purchaseFromGroups(groups: Group[]) {

View file

@ -24,23 +24,27 @@ export function decodeLookup<TIn, TOut>(
}
export function decodeRecipe(
r: components['schemas']['RecipeOut'] | components['schemas']['Recipe'] | null | undefined
r: components['schemas']['RecipeOut'] | components['schemas']['Recipe-Output'] | null | undefined
): Recipe {
if (!r) throw new Error('Invalid recipe payload')
const imageUrls = Array.isArray(r.imageUrls) ? r.imageUrls : []
const ingredients = Array.isArray(r.ingredients) ? r.ingredients : []
const link = typeof r.link === 'string' ? r.link : ''
const serves = typeof r.serves === 'number' ? r.serves : 1
const createdById = typeof r.createdById === 'number' ? r.createdById : -1
return { ...r, link, serves, createdById, imageUrls, ingredients }
// Normalize arrays that may be optional in legacy Recipe-Output
const imageUrls = r.imageUrls ?? []
const ingredients = r.ingredients ?? []
return {
...r,
imageUrls,
ingredients,
dateCreated: toDate(r.dateCreated),
dateHidden: toDate(r.dateHidden),
}
}
export function decodeMeal(
m: components['schemas']['MealOut'] | components['schemas']['Meal'] | null | undefined
m: components['schemas']['MealOut'] | components['schemas']['Meal-Output'] | null | undefined
): Meal {
if (!m) throw new Error('Invalid meal payload')
const recipes = Array.isArray(m.recipes)
? m.recipes.map((mr: components['schemas']['MealRecipe'] | null | undefined) => decodeMealRecipe(mr))
? m.recipes.map((mr) => decodeMealRecipe(mr))
: []
return {
@ -49,14 +53,14 @@ export function decodeMeal(
purchaseDate: toDate(m.purchaseDate),
consumedDate: toDate(m.consumedDate),
recipes,
chefs: Array.isArray(m.chefs) ? m.chefs : [],
consumers: Array.isArray(m.consumers) ? m.consumers : [],
cleanup: Array.isArray(m.cleanup) ? m.cleanup : [],
extraIngredients: Array.isArray(m.extraIngredients) ? m.extraIngredients : [],
chefs: m.chefs ?? [],
consumers: m.consumers ?? [],
cleanup: m.cleanup ?? [],
extraIngredients: m.extraIngredients ?? [],
}
}
function decodeMealRecipe(mr: components['schemas']['MealRecipe'] | null | undefined): MealRecipe {
function decodeMealRecipe(mr: components['schemas']['MealRecipe-Output'] | null | undefined): MealRecipe {
if (!mr) throw new Error('Invalid meal recipe payload')
return {
...mr,
@ -133,6 +137,7 @@ export function toMealInput(meal: Meal): MealInput {
id: meal.id,
suggestedDate: meal.suggestedDate ? meal.suggestedDate.toISOString() : new Date().toISOString(),
consumedDate: meal.consumedDate ? meal.consumedDate.toISOString() : null,
purchaseDate: meal.purchaseDate ? meal.purchaseDate.toISOString() : null,
chefs: meal.chefs,
cleanup: meal.cleanup,
consumers: meal.consumers,
@ -140,6 +145,7 @@ export function toMealInput(meal: Meal): MealInput {
mealId: r.mealId,
recipeId: r.recipeId,
servings: r.servings,
recipe: null,
})),
extraIngredients: meal.extraIngredients,
}

View file

@ -1,20 +0,0 @@
import type { components } from '@/api/types'
import type { MemberRef, User } from '@/domain/types'
export type HouseholdMember = components['schemas']['HouseholdMember']
export function toMemberRef(m: HouseholdMember): MemberRef {
return { id: m.id, displayName: m.displayName }
}
export function selectMyMember(members: HouseholdMember[] | null | undefined, user: User | null): MemberRef | null {
const list: HouseholdMember[] = Array.isArray(members) ? members : []
if (list.length === 0) return null
if (list.length === 1) return toMemberRef(list[0]!)
if (user && typeof user.displayName === 'string' && user.displayName) {
const match = list.find((m) => m.displayName === user.displayName)
if (match) return toMemberRef(match)
}
return null
}

View file

@ -18,17 +18,13 @@ export type RecipeOut = components['schemas']['RecipeOut']
export type MealOut = components['schemas']['MealOut']
export type Ingredient = components['schemas']['Ingredient']
export type Product = components['schemas']['Product']
export type User = components['schemas']['User']
export type Household = components['schemas']['HouseholdResponse']
// Member reference used across meals and lookups
export type MemberRef = components['schemas']['MemberRef']
// v2 no longer exposes Recipe-Input; use RecipeCreate at boundary when creating
export type MealInput = components['schemas']['MealIn']
export type MealRecipeOut = components['schemas']['MealRecipe']
export type Person = components['schemas']['Person']
export type RecipeInput = components['schemas']['Recipe-Input']
export type MealInput = components['schemas']['Meal-Input']
export type MealRecipeOut = components['schemas']['MealRecipe-Output']
// Domain shapes: only adjust where UI needs Dates
// RecipeOut has no dateCreated/dateHidden in current schema; keep arrays normalized in decoder
export type Recipe = RecipeOut
export type Recipe = WithDates<RecipeOut, 'dateCreated' | 'dateHidden'>
// MealRecipe with decoded recipe dates
export type MealRecipe = Replace<MealRecipeOut, { recipe: Recipe | null }>

View file

@ -1,5 +1,4 @@
import { createRouter, createWebHashHistory, createMemoryHistory, Router, RouterView } from 'vue-router'
import { setHouseholdSlugProvider } from '@/api/client'
import { createRouter, createWebHashHistory, Router } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
// Lazy-loaded route components
@ -11,90 +10,65 @@ const PurchasedShoppingListPage = () => import('@/components/shopping/PurchasedS
const CurrentShoppingListPage = () => import('@/components/shopping/CurrentShoppingListPage.vue')
const EditMealPage = () => import('@/components/meals/EditMealPage.vue')
const EditRecipePage = () => import('@/components/recipes/EditRecipePage.vue')
const CreateAccount = () => import('@/views/CreateAccount.vue')
const Welcome = () => import('@/views/Welcome.vue')
const InvitationAccept = () => import('@/views/InvitationAccept.vue')
const HouseholdSettings = () => import('@/views/HouseholdSettings.vue')
export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown): Router {
const publicRoutes: RouteRecordRaw[] = [
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: { name: 'mealplan' } },
{ path: '/login', name: 'login', component: LoginPage },
{ path: '/create-account', name: 'create-account', component: CreateAccount },
{ path: '/welcome', name: 'welcome', component: Welcome },
{ path: '/invitations/accept', name: 'invitation-accept', component: InvitationAccept },
{ 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 featureChildren: RouteRecordRaw[] = [
{ 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: 'mealplan', name: 'mealplan', component: MealPlanPage, 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 } },
{ 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: 'settings/members', name: 'household-settings', component: HouseholdSettings, meta: { requiresAuth: true } },
]
const routes: RouteRecordRaw[] = []
// Avoid runtime template compilation by using render functions/components
const Empty = { render: () => null }
routes.push({ path: '/', name: 'root', component: Empty, meta: { requiresAuth: true } })
routes.push(...publicRoutes)
routes.push({ path: '/:householdSlug', component: RouterView, children: featureChildren })
// Use hash history in real browsers; fallback to memory history in tests/SSR where `globalThis.location` may be unavailable
// Some test runners may polyfill `window` but not the global `location`, and vue-router's hash history uses the global.
// Avoid 'as' assertions; relying on typeof global 'location' is safe and non-throwing in Node
const hasLocation = typeof location !== 'undefined'
const history = hasLocation ? createWebHashHistory() : createMemoryHistory()
const router = createRouter({
history,
history: createWebHashHistory(),
routes,
})
router.beforeEach(async (to) => {
// Keep API client household slug in sync with the target route before components mount
const paramSlug = typeof to.params.householdSlug === 'string' ? to.params.householdSlug : null
setHouseholdSlugProvider(() => paramSlug)
// Allow public routes
const publicNames = new Set(['login', 'create-account', 'welcome', 'invitation-accept'])
if (publicNames.has(String(to.name))) return true
if (!to.meta.requiresAuth) return true
try {
const user = await getCurrentUser()
if (!user) throw new Error('not-authenticated')
// Household-scoped redirects
// Fetch households and redirect accordingly
const { useAuth } = await import('@/composables/useAuth')
const { fetchHouseholds, setActiveHousehold, households } = useAuth()
const hs = households.value.length > 0 ? households.value : await fetchHouseholds()
if (hs.length === 0) {
if (to.name !== 'welcome') return { name: 'welcome' }
return true
}
// If no slug in route, redirect to first household's mealplan
const slug = typeof to.params.householdSlug === 'string' ? to.params.householdSlug : null
if (!slug) {
if (hs.length > 0) {
const first = hs[0]!
setActiveHousehold(first)
return { name: 'mealplan', params: { householdSlug: first.slug } }
}
// Fallback (should not reach here due to hs.length check)
return true
}
// Ensure active household is set when navigating to a household route
const found = hs.find((h) => h.slug === slug) ?? (hs.length > 0 ? hs[0] : null)
if (found) setActiveHousehold(found)
return true
if (user) return true
} catch (_) {
/* ignore */
}

View file

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

@ -1,101 +0,0 @@
<template>
<div class="create-account">
<h1>Create Account</h1>
<form @submit.prevent="onSubmit">
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
required
autocomplete="email"
>
</div>
<div class="form-group">
<label for="displayName">Display Name</label>
<input
id="displayName"
v-model="displayName"
type="text"
required
autocomplete="name"
>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
required
autocomplete="new-password"
>
</div>
<button
type="submit"
class="btn btn-primary"
:disabled="submitting"
>
Sign up
</button>
<router-link
class="btn btn-link"
:to="{ name: 'login' }"
>
Back to login
</router-link>
</form>
<p
v-if="error"
class="error"
>
{{ error }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
const router = useRouter()
const route = useRoute()
const { createAccount, user } = useAuth()
const email = ref('')
const displayName = ref('')
const password = ref('')
const submitting = ref(false)
const error = ref('')
async function onSubmit() {
error.value = ''
submitting.value = true
try {
await createAccount(email.value.trim(), displayName.value.trim(), password.value)
if (user.value?.id !== undefined) {
const q = route.query?.redirect
const redirectPath = (typeof q === 'string' ? q : undefined) || '/'
router.push(redirectPath || '/')
return
}
error.value = 'Sign up failed'
} catch (e) {
error.value = e instanceof Error ? e.message : 'An error occurred'
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.form-group {
margin-bottom: 12px;
}
.error {
color: #a00;
margin-top: 8px;
}
</style>

View file

@ -1,95 +0,0 @@
<template>
<div>
<h1>Household Members</h1>
<section>
<h2>Invite a member</h2>
<p class="muted">Share an invite link with the person you want to add.</p>
<button
type="button"
:disabled="submitting"
@click="onCopyInviteLink"
>
Copy Invite Link
</button>
<p
v-if="message"
class="message"
>
{{ message }}
</p>
<p
v-if="error"
class="error"
>
{{ error }}
</p>
</section>
<section>
<h2>Current members</h2>
<ul v-if="members.length > 0">
<li
v-for="m in members"
:key="m.id"
>
{{ m.displayName }} <small> role: {{ m.role }}</small>
</li>
</ul>
<p
v-else
class="muted"
>
No members to show yet.
</p>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { createInviteLink } from '@/api/invitations'
import { listMembers, type Member } from '@/api/households'
import { useRoute } from 'vue-router'
import { useAlert } from '@/composables/useAlert'
const submitting = ref(false)
const message = ref('')
const error = ref('')
const members = ref<Member[]>([])
const route = useRoute()
const householdSlug = computed(() => (typeof route.params.householdSlug === 'string' ? route.params.householdSlug : null))
const { show: showAlert, scheduleAutoDismiss } = useAlert()
onMounted(async () => {
try {
members.value = await listMembers()
} catch (_e) {
// ignore; backend may not support this yet
}
})
async function onCopyInviteLink() {
message.value = ''
error.value = ''
submitting.value = true
try {
const slug = householdSlug.value
if (!slug) throw new Error('No household selected')
const inviteLink = await createInviteLink(slug)
await navigator.clipboard.writeText(inviteLink)
showAlert({ type: 'success', heading: 'Success', message: 'Invite link copied to clipboard!' })
scheduleAutoDismiss(3000)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to copy invite link'
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.muted { color: #666; }
.message { color: #0a0; }
.error { color: #a00; }
</style>

View file

@ -1,46 +0,0 @@
<template>
<div>
<h1>Accept Invitation</h1>
<p v-if="loading">
Processing your invitation token...
</p>
<p
v-else-if="error"
class="error"
>
{{ error }}
</p>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { acceptInvitation } from '@/api/invitations'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const error = ref('')
onMounted(async () => {
const token = typeof route.query.token === 'string' ? route.query.token : null
if (!token) {
error.value = 'Missing invitation token'
loading.value = false
return
}
try {
const h = await acceptInvitation(token)
await router.push({ name: 'mealplan', params: { householdSlug: h.slug } })
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to accept invitation'
} finally {
loading.value = false
}
})
</script>
<style scoped>
.error { color: #a00; }
</style>

View file

@ -1,40 +0,0 @@
<template>
<div>
<h1>Welcome</h1>
<section>
<h2>Create a new household</h2>
<form @submit.prevent="onCreate">
<label for="name">Household Name</label>
<input
id="name"
v-model="name"
required
>
<button type="submit">
Create
</button>
</form>
</section>
<section>
<h2>Join an existing household</h2>
<p>To join a household, ask an existing member to share an invitation link with you.</p>
</section>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
const name = ref('')
const router = useRouter()
const { createHousehold } = useAuth()
async function onCreate() {
const h = await createHousehold(name.value.trim())
if (h?.slug) {
router.push({ name: 'mealplan', params: { householdSlug: h.slug } })
}
}
</script>

View file

@ -1,74 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { logout, loginWithPassword, createAccount, handleGoogleLogin } from '@/api/auth'
import { api } from '@/api/client'
describe('auth api (jwt + households)', () => {
it('loginWithPassword posts email/password and returns user from token response', async () => {
server.use(
http.post('*/api/v1/auth/login', async ({ request }) => {
const body = await request.json()
expect(body).toEqual({ email: 'test@example.com', password: 'secret' })
return HttpResponse.json({ accessToken: 'abc123', tokenType: 'bearer', user: { id: 123, email: 'test@example.com', displayName: 'Test User' } })
})
)
const user = await loginWithPassword('test@example.com', 'secret')
expect(user.id).toBe(123)
expect(user.displayName).toBe('Test User')
// Subsequent API call should include Authorization header
server.use(
http.get('*/api/v1/users/me/households', ({ request }) => {
const auth = request.headers.get('authorization')
expect(auth?.toLowerCase()).toBe('bearer abc123')
return HttpResponse.json([])
})
)
const res = await api.GET('/api/v1/users/me/households', { params: {} })
expect(res.response.ok).toBe(true)
})
it('createAccount posts to /auth/register and sets token', async () => {
server.use(
http.post('*/api/v1/auth/register', async ({ request }) => {
const body = await request.json()
expect(body).toEqual({ email: 'new@example.com', password: 'pw', displayName: 'New User' })
return HttpResponse.json({ accessToken: 'xyz789', tokenType: 'bearer', user: { id: 55, email: 'new@example.com', displayName: 'New User' } })
})
)
const user = await createAccount('new@example.com', 'New User', 'pw')
expect(user.id).toBe(55)
server.use(
http.get('*/api/v1/users/me/households', ({ request }) => {
const auth = request.headers.get('authorization')
expect(auth?.toLowerCase()).toBe('bearer xyz789')
return HttpResponse.json([])
})
)
const res = await api.GET('/api/v1/users/me/households', { params: {} })
expect(res.response.ok).toBe(true)
})
it('handleGoogleLogin exists (not implemented yet)', async () => {
await expect(handleGoogleLogin('token-123')).rejects.toBeInstanceOf(Error)
})
it('logout clears auth token', async () => {
// Simulate logged-in state then logout and verify header is not sent
server.use(
http.post('*/api/v1/auth/login', () => HttpResponse.json({ accessToken: 'tok', tokenType: 'bearer', user: { id: 1, email: 'a@b', displayName: 'A' } }))
)
await loginWithPassword('a@b', 'pw')
await logout()
server.use(
http.get('*/api/v1/users/me/households', ({ request }) => {
const auth = request.headers.get('authorization')
expect(auth).toBeFalsy()
return HttpResponse.json([])
})
)
const res = await api.GET('/api/v1/users/me/households', { params: {} })
expect(res.response.ok).toBe(true)
})
})

View file

@ -1,39 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { handleGoogleLogin, completeGoogleLogin } from '@/api/auth'
import { api } from '@/api/client'
describe('auth google oauth', () => {
it('handleGoogleLogin returns start URL', async () => {
server.use(
http.get('*/api/v1/auth/google/start', () => {
return HttpResponse.json({ url: 'https://accounts.google.com/o/oauth2/v2/auth?x=y' })
})
)
const url = await handleGoogleLogin()
expect(url).toContain('https://accounts.google.com')
})
it('completeGoogleLogin exchanges code for token and sets auth header', async () => {
server.use(
http.post('*/api/v1/auth/google/callback', async ({ request }) => {
const body = await request.json()
expect(body).toEqual({ code: 'abc', state: 'state-1' })
return HttpResponse.json({ accessToken: 'goog-123', tokenType: 'bearer', user: { id: 42, email: 'g@example.com', displayName: 'G User' } })
})
)
const user = await completeGoogleLogin('abc', 'state-1')
expect(user.id).toBe(42)
// subsequent requests include bearer
server.use(
http.get('*/api/v1/users/me/households', ({ request }) => {
const auth = request.headers.get('authorization')
expect(auth?.toLowerCase()).toBe('bearer goog-123')
return HttpResponse.json([])
})
)
const res = await api.GET('/api/v1/users/me/households', { params: {} })
expect(res.response.ok).toBe(true)
})
})

View file

@ -1,45 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { currentUser, logout } from '@/api/auth'
import { api } from '@/api/client'
describe('auth refresh (currentUser)', () => {
beforeEach(async () => {
await logout()
})
it('currentUser refresh sets auth token (no user in response)', async () => {
// Register both refresh and households handlers before invoking currentUser
server.use(
http.post('*/api/v1/auth/refresh', () =>
HttpResponse.json({ accessToken: 'ref-123', tokenType: 'bearer', user: { id: 9, email: 'ref@example.com', displayName: 'Refreshed' } })
),
http.get('*/api/v1/users/me/households', ({ request }) => {
const auth = request.headers.get('authorization')
expect(auth?.toLowerCase()).toBe('bearer ref-123')
return HttpResponse.json([])
})
)
const user = await currentUser()
expect(user).not.toBeNull()
// Subsequent API call should also include Authorization header
const res = await api.GET('/api/v1/users/me/households', { params: {} })
expect(res.response.ok).toBe(true)
})
it('returns null if refresh fails', async () => {
server.use(http.post('*/api/v1/auth/refresh', () => HttpResponse.json({ message: 'not logged in' }, { status: 401 })))
const user = await currentUser()
expect(user).toBeNull()
server.use(
http.get('*/api/v1/users/me/households', ({ request }) => {
const auth = request.headers.get('authorization')
expect(auth).toBeFalsy()
return HttpResponse.json([])
})
)
const res = await api.GET('/api/v1/users/me/households', { params: {} })
expect(res.response.ok).toBe(true)
})
})

View file

@ -1,13 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { api } from '@/api/client'
describe('API household path scoping', () => {
it('requires household slug in path params', async () => {
server.use(
http.get('*/api/v1/households/the-smiths/recipes', () => HttpResponse.json({ items: [], total: 0 })),
)
const res = await api.GET('/api/v1/households/{householdSlug}/recipes', { params: { path: { householdSlug: 'the-smiths' }, query: {} } })
expect(res.response.ok).toBe(true)
})
})

View file

@ -1,21 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { useAuth } from '@/composables/useAuth'
describe('useAuth createHousehold', () => {
it('creates a new household and sets activeHousehold', async () => {
server.use(
http.post('*/api/v1/households', async ({ request }) => {
const body = await request.json()
expect(body).toEqual({ name: 'The Smiths' })
return HttpResponse.json({ id: 10, name: 'The Smiths', slug: 'the-smiths' })
})
)
const { households, activeHousehold, createHousehold } = useAuth()
expect(households.value).toEqual([])
const h = await createHousehold('The Smiths')
expect(h.slug).toBe('the-smiths')
expect(activeHousehold.value?.slug).toBe('the-smiths')
expect(households.value.find((x) => x.slug === 'the-smiths')).toBeTruthy()
})
})

View file

@ -1,35 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { loginWithPassword } from '@/api/auth'
import { setHouseholdSlugProvider } from '@/api/client'
import { listMembers } from '@/api/households'
describe('households api (list members)', () => {
it('GETs members with Authorization header and slug in path', async () => {
// Simulate login token
server.use(
http.post('*/api/v1/auth/login', () =>
HttpResponse.json({ accessToken: 'tokLM', tokenType: 'bearer', user: { id: 3, email: 'u@e', displayName: 'User' } })
)
)
await loginWithPassword('u@e', 'pw')
// Provide a household slug for header injection
setHouseholdSlugProvider(() => 'the-smiths')
server.use(
http.get('*/api/v1/households/the-smiths/members', ({ request }) => {
const auth = request.headers.get('authorization')
expect(auth?.toLowerCase()).toBe('bearer toklm')
return HttpResponse.json([
{ id: 10, email: 'a@example.com', displayName: 'Alice' },
{ id: 11, email: 'b@example.com', displayName: 'Bob' },
])
})
)
const members = await listMembers()
expect(members.length).toBe(2)
expect(members[0]?.displayName).toBe('Alice')
})
})

View file

@ -1,23 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { useAuth } from '@/composables/useAuth'
describe('useAuth fetchHouseholds', () => {
it('loads households and stores them', async () => {
server.use(
http.get('*/api/v1/users/me/households', () => {
return HttpResponse.json([
{ id: 1, name: 'Smiths', slug: 'the-smiths' },
{ id: 2, name: 'Johnsons', slug: 'the-johnsons' },
])
})
)
const { households, fetchHouseholds, setActiveHousehold, activeHousehold } = useAuth()
expect(households.value).toEqual([])
const hs = await fetchHouseholds()
expect(hs.length).toBe(2)
expect(households.value[0].slug).toBe('the-smiths')
setActiveHousehold(hs[1])
expect(activeHousehold.value?.slug).toBe('the-johnsons')
})
})

View file

@ -1,31 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { loginWithPassword } from '@/api/auth'
import { acceptInvitation } from '@/api/invitations'
describe('invitations api', () => {
it('acceptInvitation posts token and returns household slug', async () => {
// simulate login so Authorization header is present
server.use(
http.post('*/api/v1/auth/login', () =>
HttpResponse.json({ accessToken: 'tok123', tokenType: 'bearer', user: { id: 9, email: 'u@e', displayName: 'U' } })
)
)
await loginWithPassword('u@e', 'pw')
server.use(
http.post('*/api/v1/invitations/accept', async ({ request }) => {
const body = await request.json()
expect(body).toEqual({ token: 'abc' })
const auth = request.headers.get('authorization')
expect(auth?.toLowerCase()).toBe('bearer tok123')
return HttpResponse.json({ household: { id: 1, name: 'Smiths', slug: 'the-smiths' } })
})
)
const result = await acceptInvitation('abc')
expect(result.slug).toBe('the-smiths')
expect(result.name).toBe('Smiths')
expect(result.id).toBe(1)
})
})

View file

@ -1,28 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { loginWithPassword } from '@/api/auth'
import { sendInvitation } from '@/api/invitations'
describe('invitations api (send invite)', () => {
it('posts email with Authorization and householdSlug path param', async () => {
// Simulate auth token
server.use(
http.post('*/api/v1/auth/login', () =>
HttpResponse.json({ accessToken: 'tokABC', tokenType: 'bearer', user: { id: 4, email: 'x@y', displayName: 'X' } })
)
)
await loginWithPassword('x@y', 'pw')
server.use(
http.post('*/api/v1/households/the-smiths/invitations', async ({ request }) => {
const body = await request.json()
expect(body).toEqual({ email: 'invite@example.com' })
const auth = request.headers.get('authorization')
expect(auth?.toLowerCase()).toBe('bearer tokabc')
return HttpResponse.json({}, { status: 204 })
})
)
await expect(sendInvitation('the-smiths', 'invite@example.com')).resolves.toBeUndefined()
})
})

27
tests/mealMapper.test.js Normal file
View file

@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { decodeMeal } from '@/domain/decoders'
describe('mealMapper', () => {
it('maps individual meal date fields to Date instances', () => {
const input = {
id: 1,
suggestedDate: '2025-01-01T00:00:00Z',
purchaseDate: '2025-01-02T00:00:00Z',
consumedDate: '2025-01-03T00:00:00Z',
}
const result = decodeMeal({ ...input })
expect(result.suggestedDate).toBeInstanceOf(Date)
expect(result.purchaseDate).toBeInstanceOf(Date)
expect(result.consumedDate).toBeInstanceOf(Date)
})
it('maps lists of meals', () => {
const input = [
{ id: 1, suggestedDate: '2025-01-01T00:00:00Z' },
{ id: 2, suggestedDate: '2025-01-02T00:00:00Z' },
]
const result = input.map((m) => decodeMeal(m)).filter((m) => m)
expect(result).toHaveLength(2)
expect(result[0].suggestedDate).toBeInstanceOf(Date)
})
})

View file

@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getUpcomingMeals } from '@/api/sdk'
describe('meals api (errors)', () => {
it('propagates errors on invalid upcoming range', async () => {
server.use(
http.get('*/api/v1/meals/upcoming', () => new HttpResponse(null, { status: 400 }))
)
await expect(
getUpcomingMeals(new Date('2025-01-03T00:00:00Z'), new Date('2025-01-01T00:00:00Z'))
).rejects.toBeTruthy()
})
})

43
tests/meals.api.test.js Normal file
View file

@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getMeal, markMealConsumed, getUpcomingMeals } from '@/api/sdk'
describe('meals api (typed client)', () => {
it('gets a meal by id', async () => {
server.use(
http.get('*/api/v1/meals/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, suggestedDate: '2025-01-01T00:00:00Z' })
})
)
const meal = await getMeal(10)
expect(meal.id).toBeDefined()
})
it('marks a meal consumed', async () => {
server.use(
http.post('*/api/v1/meals/:id/consumed', () => {
return HttpResponse.json({ id: 10, consumedDate: '2025-01-02T00:00:00Z' })
})
)
const meal = await markMealConsumed(10)
expect(meal.consumedDate).toBeInstanceOf(Date)
})
it('lists upcoming meals', async () => {
server.use(
http.get('*/api/v1/meals/upcoming', ({ request }) => {
const url = new URL(request.url)
if (!url.searchParams.get('from') || !url.searchParams.get('to')) {
return new HttpResponse(null, { status: 400 })
}
return HttpResponse.json([
{ id: 1, suggestedDate: '2025-01-01T00:00:00Z' },
{ id: 2, suggestedDate: '2025-01-02T00:00:00Z' },
])
})
)
const list = await getUpcomingMeals(new Date('2025-01-01T00:00:00Z'), new Date('2025-01-03T00:00:00Z'))
expect(Array.isArray(list)).toBe(true)
expect(list[0].suggestedDate).toBeInstanceOf(Date)
})
})

View file

@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { parseIngredients, parseRecipe } from '@/api/sdk'
describe('parse api errors', () => {
it('returns 422 for invalid ingredient lines', async () => {
server.use(
http.get('*/api/v1/recipes/ingredients/parse', () => new HttpResponse(null, { status: 422 }))
)
await expect(parseIngredients([''])).rejects.toBeTruthy()
})
it('returns 422 for invalid recipe URL', async () => {
server.use(
http.get('*/api/v1/recipes/parse', () => new HttpResponse(null, { status: 422 }))
)
await expect(parseRecipe('not-a-url')).rejects.toBeTruthy()
})
})

48
tests/parse.api.test.js Normal file
View file

@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { parseIngredients, parseProduct, parseRecipe } from '@/api/sdk'
// Parse endpoints: ingredients, product, recipe
describe('parse api (typed client)', () => {
it('parses ingredient lines', async () => {
server.use(
http.get('*/api/v1/recipes/ingredients/parse', () => {
return HttpResponse.json([
{ id: 1, name: 'Eggs', line: '2 eggs', unit: 'Items', quantity: 2 },
])
})
)
const result = await parseIngredients(['2 eggs'])
expect(result[0].name).toBe('Eggs')
})
it('parses a recipe from URL', async () => {
server.use(
http.get('*/api/v1/recipes/parse', () => {
return HttpResponse.json({ id: 10, name: 'Pancakes', ingredients: [] })
})
)
const recipe = await parseRecipe('https://example.com/pancakes')
expect(recipe?.name).toBe('Pancakes')
})
it('parses/creates a product from URL', async () => {
server.use(
http.post('*/api/v1/products', async ({ request }) => {
const body = await request.json()
if (!body?.url) return new HttpResponse(null, { status: 422 })
return HttpResponse.json({
id: 99,
name: 'Sample Product',
link: body.url,
unit: 'Items',
imgSmall: '',
imgLarge: '',
})
})
)
const product = await parseProduct({ name: 'Eggs', line: '2 eggs' }, 'https://store/item')
expect(product?.name).toBe('Sample Product')
})
})

View file

@ -0,0 +1,12 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getPersonsInHome } from '@/api/sdk'
describe('persons api errors', () => {
it('propagates non-2xx errors', async () => {
server.use(
http.get('*/api/v1/persons', () => new HttpResponse(null, { status: 422 }))
)
await expect(getPersonsInHome()).rejects.toBeTruthy()
})
})

30
tests/persons.api.test.js Normal file
View file

@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getPersonsInHome, searchPersons } from '@/api/sdk'
// Persons API tests
describe('persons api (typed client)', () => {
it('lists persons in home', async () => {
server.use(
http.get('*/api/v1/persons', () => {
return HttpResponse.json([{ id: 1, name: 'Ada Lovelace' }])
})
)
const page = await getPersonsInHome()
expect(Array.isArray(page.items)).toBe(true)
expect(page.items[0].name).toBe('Ada Lovelace')
})
it('searches persons by name', async () => {
server.use(
http.get('*/api/v1/persons', ({ request }) => {
const url = new URL(request.url)
const q = url.searchParams.get('q')
return HttpResponse.json(q ? [{ id: 2, name: 'Alan Turing' }] : [])
})
)
const page = await searchPersons('alan')
expect(page.items[0].name.toLowerCase()).toContain('alan')
})
})

View file

@ -0,0 +1,12 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getRecipe } from '@/api/sdk'
describe('recipes api (errors)', () => {
it('throws on 404 getRecipe', async () => {
server.use(
http.get('*/api/v1/recipes/:id', () => new HttpResponse(null, { status: 404 }))
)
await expect(getRecipe('999')).rejects.toBeTruthy()
})
})

22
tests/recipes.api.test.js Normal file
View file

@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getRecipe } from '@/api/sdk'
// Tests validate the typed client wrapper behavior without needing the backend
describe('recipes api (typed client)', () => {
it('gets a recipe by id', async () => {
server.use(
http.get('*/api/v1/recipes/:id', ({ params }) => {
// eslint-disable-next-line no-console
console.log('MSW handler hit with params:', params)
const { id } = params
return HttpResponse.json({ id, name: 'Pancakes', ingredients: [] }, { status: 200 })
})
)
const data = await getRecipe('123')
expect(data).toBeTruthy()
expect(data.name).toBe('Pancakes')
})
})

View file

@ -1,23 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getRecipe } from '@/api/sdk'
import { setHouseholdSlugProvider } from '@/api/client'
// Drive migration to path-scoped recipes via householdSlug
describe('recipes api v2 (household-scoped)', () => {
it('gets a recipe by id with householdSlug path', async () => {
server.use(
http.get('*/api/v1/households/:householdSlug/recipes/:recipe_id', ({ params }) => {
const { householdSlug, recipe_id } = params
expect(householdSlug).toBe('the-smiths')
return HttpResponse.json({ id: Number(recipe_id), name: 'Pancakes', ingredients: [] }, { status: 200 })
})
)
setHouseholdSlugProvider(() => 'the-smiths')
const data = await getRecipe('the-smiths', 123)
expect(data).toBeTruthy()
expect(data.name).toBe('Pancakes')
})
})

View file

@ -1,18 +0,0 @@
import { describe, it, expect } from 'vitest'
describe('router guard refresh 401 handling', () => {
it('redirects to /login with redirect when getCurrentUser throws (e.g., refresh 401)', async () => {
const { createAppRouter } = await import('@/router/index')
const router = createAppRouter(async () => { throw new Error('401 Unauthorized') })
// Stub login route with inline component to avoid loading .vue files
try { router.removeRoute('login') } catch (_) { /* ignore */ }
router.addRoute({ path: '/login', name: 'login', component: { template: '<div />' } })
await router.push('/')
const current = router.currentRoute.value
expect(current.name).toBe('login')
expect(current.query.redirect).toBe('/')
})
})

View file

@ -1,19 +0,0 @@
import { describe, it, expect } from 'vitest'
describe('router guard unauthenticated redirect', () => {
it('redirects to /login with redirect query when not authenticated', async () => {
const { createAppRouter } = await import('@/router/index')
const router = createAppRouter(async () => null)
// Replace the lazy .vue login route with an inline component to avoid plugin-vue in tests
try { router.removeRoute('login') } catch (_) { /* ignore */ }
router.addRoute({ path: '/login', name: 'login', component: { template: '<div />' } })
// Navigate to a protected route that uses an inline component to avoid lazy .vue imports
await router.push('/')
const current = router.currentRoute.value
expect(current.name).toBe('login')
expect(current.query.redirect).toBe('/')
})
})

View file

@ -1,22 +0,0 @@
import { describe, it, expect } from 'vitest'
describe('router slugged routing', () => {
it('includes public routes and only slugged feature routes', async () => {
const { createAppRouter } = await import('@/router/index')
const router = createAppRouter(() => null)
const paths = router.getRoutes().map((r) => r.path)
expect(paths).toContain('/login')
expect(paths).toContain('/create-account')
expect(paths).toContain('/welcome')
expect(paths).toContain('/invitations/accept')
// Only slugged feature routes
expect(paths).toContain('/:householdSlug/recipes')
expect(paths).toContain('/:householdSlug/mealplan')
expect(paths).toContain('/:householdSlug/shopping')
expect(paths).toContain('/:householdSlug/shopping/current')
expect(paths).toContain('/:householdSlug/settings/members')
// No legacy flat routes
expect(paths).not.toContain('/recipes')
expect(paths).not.toContain('/shopping')
})
})

View file

@ -1,23 +0,0 @@
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')
})
})

View file

@ -0,0 +1,12 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { purchaseShoppingList } from '@/api/sdk'
describe('shopping api (errors)', () => {
it('propagates errors on purchase failure', async () => {
server.use(
http.post('*/api/v1/shopping', () => new HttpResponse(null, { status: 422 }))
)
await expect(purchaseShoppingList([{ type: 'existing', id: 1, personId: 42 }])).rejects.toBeTruthy()
})
})

View file

@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getCurrentShoppingList, getShoppingList, requestMeal, unrequestMeal, purchaseShoppingList } from '@/api/sdk'
describe('shopping api (typed client)', () => {
it('gets current shopping list', async () => {
server.use(http.get('*/api/v1/shopping/current', () => HttpResponse.json({ outstandingItems: [] })))
const list = await getCurrentShoppingList()
expect(list).toBeTruthy()
})
it('gets a purchased shopping list by id', async () => {
server.use(http.get('*/api/v1/shopping/:id', () => HttpResponse.json({ list: { id: 99, items: [] } })))
const list = await getShoppingList(99)
expect(list.id).toBe(99)
})
it('requests and unrequests a meal', async () => {
server.use(http.post('*/api/v1/shopping/current/meals/me', () => HttpResponse.json([{ id: 1 }])))
await requestMeal(44)
server.use(http.delete('*/api/v1/shopping/current/meals/:id', () => new HttpResponse(null, { status: 204 })))
await unrequestMeal(44)
})
it('purchases a list', async () => {
server.use(http.post('*/api/v1/shopping', () => HttpResponse.json({ list: { id: 1, items: [] } })))
const list = await purchaseShoppingList([{ type: 'existing', id: 1, personId: 42 }])
expect(list.id).toBe(1)
})
})

View file

@ -14,7 +14,7 @@ describe('shopping mappers boundary', () => {
],
purchasedItems: [],
}
const mapped = mapCurrentShoppingList(dto as unknown as Parameters<typeof mapCurrentShoppingList>[0])
const mapped = mapCurrentShoppingList(dto as any)
expect(mapped.outstandingItems[0].ingredient).toBeUndefined()
expect(mapped.outstandingItems[0].meal).toBeUndefined()
expect(mapped.outstandingItems[0].recipe).toBeUndefined()
@ -37,7 +37,7 @@ describe('shopping mappers boundary', () => {
],
},
}
const mapped = mapPurchasedShoppingList(dto as unknown as Parameters<typeof mapPurchasedShoppingList>[0])
const mapped = mapPurchasedShoppingList(dto as any)
expect(mapped.list.createdDate).toBeInstanceOf(Date)
expect(mapped.list.items[0].createdDate).toBeInstanceOf(Date)
expect(mapped.list.items[1].createdDate).toBeInstanceOf(Date)

View file

@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest'
import { mapCurrentShoppingList, mapPurchasedShoppingList } from '@/api/sdk'
describe('shoppingListMapper', () => {
it('maps current shopping list and wires references', () => {
const dto = {
ingredientsLookup: { 10: { id: 10, name: 'Eggs' } },
mealsLookup: { 1: { id: 1, suggestedDate: '2025-01-01T00:00:00Z', recipes: [], extraIngredients: [], chefs: [], consumers: [], cleanup: [] } },
recipesLookup: { 5: { id: 5, name: 'Omelette' } },
shoppingListLookup: { 7: { id: 7, createdDate: '2025-01-01T00:00:00Z' } },
outstandingItems: [{ ingredientId: 10, mealId: 1, recipeId: 5, listId: 7, createdDate: '2025-01-01T00:00:00Z' }],
requestedMeals: [],
purchasedItems: [],
}
const mapped = mapCurrentShoppingList(dto)
// DEBUG
// eslint-disable-next-line no-console
console.log('mapped current keys:', Object.keys(mapped || {}))
const item = mapped.outstandingItems[0]
expect(item.ingredient.name).toBe('Eggs')
expect(item.meal.suggestedDate).toBeInstanceOf(Date)
expect(mapped.shoppingListLookup['7'].createdDate).toBeInstanceOf(Date)
})
it('maps purchased shopping list with list dates and item refs', () => {
const dto = {
ingredientsLookup: { 10: { id: 10, name: 'Eggs' } },
mealsLookup: { 1: { id: 1, suggestedDate: '2025-01-01T00:00:00Z', recipes: [], extraIngredients: [], chefs: [], consumers: [], cleanup: [] } },
recipesLookup: { 5: { id: 5, name: 'Omelette' } },
list: { id: 9, createdDate: '2025-02-02T00:00:00Z', items: [{ ingredientId: 10, mealId: 1, recipeId: 5, listId: 9 }] },
}
const mapped = mapPurchasedShoppingList(dto)
// DEBUG
// eslint-disable-next-line no-console
console.log('mapped purchased keys:', Object.keys(mapped || {}))
expect(mapped.list.createdDate).toBeInstanceOf(Date)
expect(mapped.list.items[0].recipe.name).toBe('Omelette')
})
})

View file

@ -1,31 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/api/auth', () => {
return {
currentUser: vi.fn(async () => null),
loginWithPassword: vi.fn(async (email: string) => ({ id: 2, email, displayName: email })),
createAccount: vi.fn(async (email: string, displayName: string) => ({ id: 77, email, displayName })),
logout: vi.fn(async () => {}),
handleGoogleLogin: vi.fn(async () => {
throw new Error('handleGoogleLogin not implemented yet')
}),
}
})
describe('useAuth.createAccount', () => {
beforeEach(() => {
vi.resetModules()
})
it('updates user after successful createAccount', async () => {
const { useAuth } = await import('@/composables/useAuth')
const auth = useAuth()
const { user } = auth
expect(user.value).toBeNull()
// Implemented method should exist
expect(typeof (auth as Record<string, unknown>).createAccount).toBe('function')
const newUser = await (auth as unknown as { createAccount: (e: string, d: string, p: string) => Promise<{ id: number; displayName: string }> }).createAccount('new@example.com', 'New User', 'pw')
expect(newUser.id).toBe(77)
expect(user.value?.displayName).toBe('New User')
})
})

View file

@ -1,56 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/api/auth', () => {
return {
currentUser: vi.fn(async () => ({ id: 42, email: 'grace@example.com', displayName: 'Grace Hopper' })),
loginWithPassword: vi.fn(async (email: string) => ({ id: 8, email, displayName: email })),
logout: vi.fn(async () => {}),
createAccount: vi.fn(async () => {
throw new Error('createAccount not implemented yet')
}),
handleGoogleLogin: vi.fn(async () => {
throw new Error('handleGoogleLogin not implemented yet')
}),
}
})
import { useAuth, loadUser } from '@/composables/useAuth'
describe('useAuth (multitenant state)', () => {
beforeEach(() => {
// Reset modules and state between tests
vi.resetModules()
})
it('exposes households and activeHousehold state with setters', async () => {
const { user, households, activeHousehold, setHouseholds, setActiveHousehold } = useAuth()
expect(user.value).toBeNull()
expect(households.value).toEqual([])
expect(activeHousehold.value).toBeNull()
const hs = [
{ id: 1, name: 'Smiths', slug: 'the-smiths' },
{ id: 2, name: 'Johnsons', slug: 'the-johnsons' },
]
setHouseholds(hs)
expect(households.value.length).toBe(2)
setActiveHousehold(hs[1])
expect(activeHousehold.value?.slug).toBe('the-johnsons')
setActiveHousehold('the-smiths')
expect(activeHousehold.value?.slug).toBe('the-smiths')
})
it('supports loading current user, loginWithPassword, and logout clearing user', async () => {
const { user, loginWithPassword, logout } = useAuth()
// load current user
await loadUser()
expect(user.value?.displayName).toBe('Grace Hopper')
// login with email/password
await loginWithPassword('user@example.com', 'pw')
expect(user.value?.displayName).toBe('user@example.com')
// logout clears user
await logout()
expect(user.value).toBeNull()
})
})

View file

@ -1,16 +0,0 @@
http://127.0.0.1:8000/
Server is running with watch enabled
# Dummy login
First account created. No specfic persona details.
- Email: specuser+20251102@example.com
- Display Name: Spec User
- Password: SpecPassw0rd!
- Household slug: faulconfridge-qa2
# Baker
- Email: patty.cake+20251102@example.com
- Display Name: Patty Cake
- Password: a-very-secure-password
- Household slug: the-rolling-scones
```