Compare commits

..

59 commits

Author SHA1 Message Date
e873bbc3f8 refactor invitation handling: remove email parameter from createInviteLink and update UI to use invite link 2025-12-13 15:35:30 +11:00
4edeb6dfe4 uat instructional improvements 2025-11-05 19:59:45 +11:00
e985ab98a4 Cleanups 2025-11-02 21:57:57 +11:00
b35758edf2 Spec update 2025-11-02 21:38:31 +11:00
11e1e87009 uat updates 2025-11-02 21:31:33 +11:00
f35739e732 ingredient debounce 2025-11-02 19:41:33 +11:00
9f63c8b091 Remove empty ingredients before save 2025-11-02 19:07:28 +11:00
d5fbc5d212 Axiom updates 2025-11-02 18:33:51 +11:00
1b740c0234 Link consolodation 2025-11-02 18:29:59 +11:00
762549f4c4 Error handling on shoppiong page meal selection 2025-11-02 17:47:32 +11:00
d69128a334 Fixed meal membership 2025-11-02 17:41:40 +11:00
4eec86794c Fixed parsing 2025-11-02 17:02:28 +11:00
90a2ffc5de Fixed render bug 2025-11-02 16:43:33 +11:00
db9f364472 Node version bump 2025-11-02 09:48:12 +11:00
f75512cffb Better linkage 2025-11-02 00:32:24 +11:00
e33ad633fe Login flow barely works 2025-11-02 00:29:45 +11:00
16a90fe89d feat(shopping): ad‑hoc items UI via typed endpoints; purge legacy JS tests; update spec 2025-11-02 00:22:34 +11:00
5183217669 feat(shopping): add ad‑hoc items UI with typed endpoints; remove legacy JS tests 2025-11-02 00:18:45 +11:00
adb2fc1bae Spec reflection and update 2025-11-01 23:46:01 +11:00
d90ffddcd7 refactor(sdk): remove runtime guards; rely on strict OpenAPI types; update recipe create types; switch ingredient parsing to typed GET; keep decoders strict; lint/type/tests green 2025-11-01 23:40:01 +11:00
59cce781ec chore(types): enforce strict typing in auth/sdk/decoders, remove any/as, add precise guards; validate parse responses; keep SDK boundary typed; all checks green 2025-11-01 23:21:22 +11:00
5586e2eede Implement handleGoogleLogin (returns backend OAuth start URL) 2025-11-01 22:14:36 +11:00
d7c9788f00 feat: harden decoders/SDK, add Google button, and align Member styles 2025-11-01 21:41:38 +11:00
d08e0370ac docs: finalize Person→Member migration, add Google sign-in button, and align styles 2025-11-01 20:25:18 +11:00
a9f2b6f5a8 feat!: remove Person entity usage, adopt MemberRef across meals; update SDK and UI 2025-11-01 20:16:54 +11:00
166dc04948 codegen - no more persons 2025-11-01 19:57:58 +11:00
ddad070eb7 Invitations accept: use updated typed OpenAPI; keep router guard tests green 2025-11-01 19:21:12 +11:00
6ec15debef IDK why 2025-11-01 19:18:11 +11:00
d3e2faf81d Router slug-only + guard tests; tighten client credentials; typed invitations accept shape 2025-11-01 19:14:21 +11:00
aa510d7e23 push prog 1 2025-11-01 19:12:12 +11:00
a43308b866 feat(router,auth,invites): remove legacy routing flag, use typed invitation accept, and restrict cookies to refresh 2025-11-01 19:03:10 +11:00
d729c0311d revert(shopping): restore MyShoppingPage parity with master and retain legacy stubs 2025-11-01 18:51:34 +11:00
710f9ccf9b fix(router): use memory history in test/SSR to avoid global location access; update spec and test counts 2025-11-01 18:32:59 +11:00
05e8dba8c8 feat(shopping): add typed requestIngredient and migrate members list; tighten recipe decoders; tests green 2025-11-01 18:25:46 +11:00
9cac6ca5ba feat(shopping): add typed requestIngredient and migrate members to typed endpoint; fix decoders for stricter RecipeOut; tests green 2025-11-01 18:24:35 +11:00
290ad06f0c MySHopping spec updates 2025-11-01 18:17:46 +11:00
53f8909924 fix(router/tests): use memory history in Vitest/SSR; remove legacy auth.login from tests; update spec 2025-11-01 17:49:26 +11:00
605bd73561 chore(auth/tests): remove legacy username login from tests; align mocks with email/password-only auth and keep suite green 2025-11-01 17:47:42 +11:00
52649e812a Spec updates 2025-11-01 17:31:03 +11:00
b0c749b17d Migrated refresh to token-only flow, normalized member shapes, removed header slug assumptions across the codebase and tests, fixed typings and component usage, and verified build/typechecks/tests all PASS 2025-11-01 17:17:44 +11:00
d438b78005 currentUser() now performs token-only refresh (no user in response) and then loads user context via /api/v1/users/me/households. 2025-11-01 17:12:09 +11:00
a6d729509e slug prog #1 2025-11-01 16:59:22 +11:00
630c6dabcf Invitations: migrate send to typed path param; update settings view and tests; spec reflects new OpenAPI and migration plan 2025-11-01 16:25:15 +11:00
3b421106bc Spec update 2025-11-01 16:21:23 +11:00
ba231994e7 run codegen 2025-11-01 16:19:17 +11:00
aed7ea44e9 Auth refresh, stable router tests, and Household Settings members list; spec + cleanup tasks 2025-11-01 15:30:44 +11:00
f78cc31479 Router: use memory history in tests; finalize HouseholdSettings UI; update spec and add cleanup tasks 2025-11-01 15:05:41 +11:00
6d95764516 HouseholdSettings.vue — fixed template, added proper imports, typed members, and rendered current members list. 2025-11-01 14:49:57 +11:00
b75ece2fc8 feat(ui): add Settings link to HouseholdSwitcher for discoverability 2025-11-01 14:44:12 +11:00
218b56ccce feat(invitations): accept + send invitations with settings view; keep repo green 2025-11-01 14:41:55 +11:00
b19051fc75 feat(invitations): accept and send invites; add settings view and routes 2025-11-01 14:21:48 +11:00
9f7209f44d feat(invitations): implement invitation accept flow with TDD 2025-11-01 14:17:59 +11:00
062163483c feat(auth+onboarding): implement Create Account flow with TDD 2025-11-01 14:11:40 +11:00
44072a2363 create-household flow via useAuth and Welcome page 2025-11-01 14:05:15 +11:00
ff8e665374 Refactored to show email/password form when multitenant flag is enabled; legacy person list retained otherwise. Link to Create Account added. 2025-11-01 14:02:03 +11:00
04dbddee44 - Implemented JWT login/register in auth.ts and wired token to client provider. useAuth updated with households fetching.
- Router guard updated to handle public/multitenant routing and redirects.
2025-11-01 13:55:55 +11:00
22174ebf79 Auth api updates for v2 2025-11-01 13:52:33 +11:00
a446bc1c11 housekeeping 2025-11-01 13:44:58 +11:00
faf0a91d2d spec 2025-11-01 13:31:56 +11:00
67 changed files with 3370 additions and 1798 deletions

30
.github/chatmodes/uat.chatmode.md vendored Normal file
View file

@ -0,0 +1,30 @@
---
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 Normal file
View file

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

View file

@ -48,13 +48,15 @@ Key axioms
- No casts (`as`, angle brackets) and no `any`/`unknown` in app code. Generated files are exempt. - 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`). - 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. - 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 Layout
- `src/api/` — Typed client and SDK boundary - `src/api/` — Typed client and SDK boundary
- `src/domain/` — Domain types and decoders - `src/domain/` — Domain types and decoders
- `src/composables/` — Reusable app logic (auth, meals, shopping, pagination, alert) - `src/composables/` — Reusable app logic (auth, meals, shopping, pagination, alert)
- `src/components/` — UI components and pages - `src/components/` — UI components and pages
- `src/router/` — Routes and helpers (`parseRouteId`, `parseQueryString`) - `src/router/` — Routes and helpers (`links.ts` for slug-scoped navigation, plus `parseRouteId`, `parseQueryString`)
Testing and tooling Testing and tooling
- Vitest + MSW under `tests/` - Vitest + MSW under `tests/`

36
frontend-spec.md Normal file
View file

@ -0,0 +1,36 @@
# 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,6 +42,10 @@
"vitest": "^1.6.0", "vitest": "^1.6.0",
"vue-tsc": "^2.0.29" "vue-tsc": "^2.0.29"
}, },
"engines": {
"node": ">=20.19.0",
"npm": ">=10.9.0"
},
"lint-staged": { "lint-staged": {
"*.{js,vue,css,scss,md}": [ "*.{js,vue,css,scss,md}": [
"prettier --write" "prettier --write"

View file

@ -1,10 +1,10 @@
<template> <template>
<div> <div v-if="!isPublic && activeSlug">
<ul class="nav"> <ul class="nav">
<li class="nav-item"> <li class="nav-item">
<router-link <router-link
class="nav-link" class="nav-link"
:to="{ name: 'recipes' }" :to="{ name: 'recipes', params: { householdSlug: activeSlug } }"
active-class="active" active-class="active"
> >
Recipes Recipes
@ -13,7 +13,7 @@
<li class="nav-item"> <li class="nav-item">
<router-link <router-link
class="nav-link" class="nav-link"
:to="{ name: 'mealplan' }" :to="{ name: 'mealplan', params: { householdSlug: activeSlug } }"
active-class="active" active-class="active"
> >
Meal Plan Meal Plan
@ -22,13 +22,14 @@
<li class="nav-item"> <li class="nav-item">
<router-link <router-link
class="nav-link" class="nav-link"
:to="{ name: 'shopping' }" :to="{ name: 'shopping', params: { householdSlug: activeSlug } }"
active-class="active" active-class="active"
> >
Shopping Shopping
</router-link> </router-link>
</li> </li>
</ul> </ul>
<HouseholdSwitcher />
</div> </div>
<div class="viewport"> <div class="viewport">
<router-view /> <router-view />
@ -39,8 +40,21 @@
<script setup> <script setup>
import AlertToast from './components/AlertToast.vue' 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 // 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> </script>
<style> <style>

View file

@ -1,30 +1,115 @@
import { api } from '@/api/client' import { api, fetchApi } from '@/api/client'
import type { Person } from '@/domain/types' import { setAuthTokenProvider } from '@/api/client'
import type { User } from '@/domain/types'
let cachedUser: Person | null = null let cachedUser: User | null = null
let authToken: string | null = null
setAuthTokenProvider(() => authToken)
export async function currentUser(): Promise<Person | null> { export async function currentUser(): Promise<User | null> {
if (cachedUser) return cachedUser if (cachedUser) return cachedUser
try { try {
const res = await api.POST('/api/v1/auth/refresh', { params: { cookie: { user_id: 0 } } }) const res = await api.POST('/api/v1/auth/refresh', { params: {} })
if (!res.response.ok) return null if (!res.response.ok) {
cachedUser = res.data ?? null 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
} catch (_) { } catch (_) {
authToken = null
cachedUser = null cachedUser = null
return null
}
}
export async function loginWithPassword(email: string, password: string): Promise<User> {
const res = await api.POST('/api/v1/auth/login', { body: { email, password } })
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
}
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 return cachedUser
} }
export async function login(username: string): Promise<Person> { export async function logout(): Promise<void> {
const res = await api.POST('/api/v1/auth/login', { body: { username } }) // Clear local cache; server session is cookie-based and will be refreshed on next call
if (!res.response.ok) { cachedUser = null
const err = res.error authToken = null
throw (
(err instanceof Error && err) ||
(typeof err === 'string' ? new Error(err) : new Error(`${res.response.status} ${res.response.statusText || 'HTTP error'}`))
)
}
cachedUser = res.data ?? null
if (!cachedUser) throw new Error('Login failed: empty response')
return cachedUser
} }

View file

@ -9,12 +9,59 @@ const isTest = typeof process !== 'undefined' && (process.env?.VITEST === 'true'
const defaultBase = '' const defaultBase = ''
const baseUrl: string = isTest ? 'http://localhost' : (vueCliBase || 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>({ export const api = createClient<paths>({
baseUrl, baseUrl,
fetch: (input: RequestInfo | URL, init?: RequestInit) => { 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, { return globalThis.fetch(input, {
credentials: 'include', credentials,
...init, ...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 })
}

15
src/api/households.ts Normal file
View file

@ -0,0 +1,15 @@
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 : []
}

26
src/api/invitations.ts Normal file
View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -4,6 +4,7 @@
<input <input
v-model="ingredientText" v-model="ingredientText"
placeholder="Enter an ingredient" placeholder="Enter an ingredient"
@input="onInput"
@keyup.enter="updateIngredient" @keyup.enter="updateIngredient"
@blur="updateIngredient" @blur="updateIngredient"
> >
@ -30,6 +31,7 @@ import type { Ingredient } from '@/domain/types'
const props = defineProps<{ ingredient: Ingredient }>() const props = defineProps<{ ingredient: Ingredient }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update-line', ingredient: Ingredient, newLine: string): void
(e: 'update-ingredient', ingredient: Ingredient, newLine: string): void (e: 'update-ingredient', ingredient: Ingredient, newLine: string): void
(e: 'update-product-link', ingredient: Ingredient, link: string): void (e: 'update-product-link', ingredient: Ingredient, link: string): void
}>() }>()
@ -57,6 +59,20 @@ function updateProductLink() {
emit('update-product-link', props.ingredient, productLink.value) 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> </script>
<style scoped> <style scoped>

View file

@ -5,23 +5,23 @@
:date="meal.suggestedDate ?? new Date()" :date="meal.suggestedDate ?? new Date()"
@date-selected="selectDate" @date-selected="selectDate"
/> />
<div class="persons-list"> <div class="members-list">
Cooked by Cooked by
<person-list <member-list
:people="meal.chefs" :people="meal.chefs"
@remove-person="(p) => removePerson('chefs', p)" @remove="(p) => removePerson('chefs', p)"
@add-person="(p) => addPerson('chefs', p)" @add="(p) => addPerson('chefs', p)"
/> />
for for
<person-list <member-list
:people="meal.consumers" :people="meal.consumers"
@remove-person="(p) => removePerson('consumers', p)" @remove="(p) => removePerson('consumers', p)"
@add-person="(p) => addPerson('consumers', p)" @add="(p) => addPerson('consumers', p)"
/>, with />, with
<person-list <member-list
:people="meal.cleanup" :people="meal.cleanup"
@remove-person="(p) => removePerson('cleanup', p)" @remove="(p) => removePerson('cleanup', p)"
@add-person="(p) => addPerson('cleanup', p)" @add="(p) => addPerson('cleanup', p)"
/> />
on cleanup. on cleanup.
</div> </div>
@ -102,6 +102,7 @@
@on-add="addIngredient" @on-add="addIngredient"
@on-delete="deleteIngredient" @on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient" @on-update-ingredient="updateIngredient"
@on-update-line="updateIngredientLine"
@on-editing="onEditAdditionalIngredients" @on-editing="onEditAdditionalIngredients"
/> />
</div> </div>
@ -118,12 +119,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, onBeforeMount } from 'vue' import { reactive, onBeforeMount } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { toMealEdit } from '@/router/links'
import { getMeal, saveMeal, getRecipe } from '@/api/sdk' import { getMeal, saveMeal, getRecipe } from '@/api/sdk'
import { toMealInput } from '@/domain/decoders' import { toMealInput } from '@/domain/decoders'
import { currentUser } from '@/api/auth' import { currentUser } from '@/api/auth'
import { useAlert } from '@/composables/useAlert' import { useAlert } from '@/composables/useAlert'
import { parseRouteId } from '@/router/helpers' import { parseRouteId } from '@/router/helpers'
import type { Person, Ingredient, Meal, MealRecipe } from '@/domain/types' import type { Ingredient, Meal, MealRecipe } from '@/domain/types'
import { listMembers } from '@/api/households'
import { selectMyMember } from '@/domain/members'
import { ago } from '@/dateformats' import { ago } from '@/dateformats'
@ -131,12 +135,14 @@ import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue'
import RecipeCard from '@/components/recipes/RecipeCard.vue' import RecipeCard from '@/components/recipes/RecipeCard.vue'
import DatePicker from './DatePicker.vue' import DatePicker from './DatePicker.vue'
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue' import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue'
import PersonList from './PersonList.vue' import MemberList from './MemberList.vue'
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue' import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue'
const showIngredientsIcon = new URL('@/assets/show-ingredients.svg', import.meta.url).toString() const showIngredientsIcon = new URL('@/assets/show-ingredients.svg', import.meta.url).toString()
const trash = new URL('@/assets/trash.svg', import.meta.url).toString() const trash = new URL('@/assets/trash.svg', import.meta.url).toString()
function addPersonIfNotExists(list: Person[], person: Person | null | undefined) { import type { MemberRef } from '@/domain/types'
function addPersonIfNotExists<T extends { id: number }>(list: T[], person: T | null | undefined) {
if (!person) return if (!person) return
if (!list.find((p) => p.id === person.id)) { if (!list.find((p) => p.id === person.id)) {
list.push(person) list.push(person)
@ -147,8 +153,13 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const { show: showAlert } = useAlert() const { show: showAlert } = useAlert()
// No child refs; parent maintains source of truth for lines
type PeopleKey = 'chefs' | 'consumers' | 'cleanup' 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>({ const meal = reactive<Meal>({
id: -1, id: -1,
suggestedDate: new Date(), suggestedDate: new Date(),
@ -167,13 +178,15 @@ onBeforeMount(async () => {
const loaded = await getMeal(id) const loaded = await getMeal(id)
Object.assign(meal, loaded) Object.assign(meal, loaded)
} else { } else {
const self = await currentUser() const [user, members] = await Promise.all([currentUser(), listMembers()])
if (self) { const fallback = Array.isArray(members) && members.length > 0 ? { id: members[0]!.id, displayName: members[0]!.displayName } : null
meal.chefs = [self] const me = selectMyMember(members, user) ?? fallback
meal.consumers = [self] if (me) {
meal.cleanup = [self] meal.chefs = [me]
} meal.consumers = [me]
meal.cleanup = [me]
} }
}
}) })
function selectDate(date: Date) { function selectDate(date: Date) {
@ -200,28 +213,37 @@ function deleteIngredient(ingredient: Ingredient) {
function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) { function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) {
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? newIngredient : i)) 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 removePerson(list: PeopleKey, person: Person) { 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) {
meal[list] = meal[list].filter((p) => p.id !== person.id) meal[list] = meal[list].filter((p) => p.id !== person.id)
} }
function addPerson(list: PeopleKey, person: Person) { function addPerson(list: PeopleKey, person: MemberRef) {
addPersonIfNotExists(meal[list], person) addPersonIfNotExists(meal[list], person)
} }
async function selectRecipe(recipe: { id: number | string }) { async function selectRecipe(recipe: { id: number | string }) {
// Refetch to get additional details // Refetch to get additional details
const r = await getRecipe(recipe.id) const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
const r = await getRecipe(slug, 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 }) meal.recipes.push({ recipe: r, recipeId: r.id, mealId: meal.id, servings: r.serves })
} }
@ -229,8 +251,8 @@ async function selectRecipe(recipe: { id: number | string }) {
async function onEditAdditionalIngredients(editing: boolean) { async function onEditAdditionalIngredients(editing: boolean) {
if (editing && meal.extraIngredients.length === 0) { if (editing && meal.extraIngredients.length === 0) {
addIngredient() addIngredient()
} else { } else {
meal.extraIngredients = meal.extraIngredients.filter((i) => !!i.line) meal.extraIngredients = meal.extraIngredients.filter((i: Ingredient) => !!i.line)
} }
} }
@ -257,19 +279,70 @@ function scaleIngredients(mealRecipe: MealRecipe) {
} }
async function onSaveMeal() { 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)) const saved = await saveMeal(toMealInput(meal))
if (saved && saved.id >= 0) { if (saved && saved.id >= 0) {
Object.assign(meal, saved) Object.assign(meal, saved)
router.push(`/meals/${saved.id}`) router.push(toMealEdit(saved.id))
showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' }) showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' })
return return
} }
showAlert({ showAlert({
heading: 'Error saving meal', heading: 'Error saving meal',
message: 'An error occurred while saving the meal', message: 'An unknown error occurred while saving the meal',
type: 'error', type: 'error',
}) })
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save meal'
showAlert({ heading: 'Error saving meal', message, type: 'error' })
}
} }
</script> </script>
@ -279,17 +352,17 @@ img.icon {
height: 2em; height: 2em;
} }
.persons-list { .members-list {
text-align: left; text-align: left;
padding: 1ex 2em; padding: 1ex 2em;
} }
.persons-list p { .members-list p {
margin: 0; margin: 0;
padding-bottom: 1em; padding-bottom: 1em;
} }
.person-list li { .member-list li {
display: inline-block; display: inline-block;
padding-right: 1em; padding-right: 1em;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -102,6 +102,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onBeforeMount } from 'vue' import { ref, computed, onBeforeMount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { toShoppingList } from '@/router/links'
import { useAlert } from '@/composables/useAlert' import { useAlert } from '@/composables/useAlert'
import { useShopping } from '@/composables/useShopping' import { useShopping } from '@/composables/useShopping'
import { getUpcomingMeals } from '@/api/sdk' import { getUpcomingMeals } from '@/api/sdk'
@ -157,13 +158,21 @@ async function loadData() {
} }
async function mealSelected(meal: { id: number }) { async function mealSelected(meal: { id: number }) {
await requestMeal(meal.id) try {
await loadData() 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 }) { async function mealUnselected(meal: { id: number }) {
await unrequestMeal(meal.id) try {
await loadData() 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() { async function markFound() {
@ -183,7 +192,7 @@ async function markPurchased() {
return return
} }
selected.value = [] selected.value = []
router.push(`/shopping/${shopping.id}`) router.push(toShoppingList(shopping.id))
} }
function toggleSelect(item: Group) { function toggleSelect(item: Group) {

View file

@ -1,11 +1,34 @@
<template> <template>
<div> <div>
<h1>My Shopping List</h1> <h1>My Shopping List</h1>
<router-link :to="`/shopping/current`"> <router-link :to="{ name: 'shopping-current', params: { householdSlug: slug } }">
Full Shopping List Full Shopping List
</router-link> </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 <editable-ingredients-panel
:ingredients="ingredients" :ingredients="ingredients"
edit-only
@on-add="addIngredient" @on-add="addIngredient"
@on-delete="deleteIngredient" @on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient" @on-update-ingredient="updateIngredient"
@ -41,47 +64,97 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onBeforeMount } from 'vue' import { ref, onBeforeMount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { useAlert } from '@/composables/useAlert'
import { useShopping } from '@/composables/useShopping' import { useShopping } from '@/composables/useShopping'
import type { Ingredient } from '@/domain/types' import type { Ingredient } from '@/domain/types'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue' import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
const router = useRouter() const router = useRouter()
const route = useRoute()
const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
const { loadUser } = useAuth() const { loadUser } = useAuth()
const { getMyShoppingList, saveMyShoppingList } = useShopping() const { show: showAlert } = useAlert()
const { getCurrentShoppingList, requestIngredient, unrequestIngredient } = useShopping()
const ingredients = ref<Ingredient[]>([]) const ingredients = ref<Ingredient[]>([])
let loading = false
const availableIngredientOptions = ref<{ id: number; name: string }[]>([])
const selectedIngredientId = ref<number>(-1)
async function updateShoppingList(save = false) { async function refreshFromServer() {
const newIngredients = save ? await saveMyShoppingList(ingredients.value) : await getMyShoppingList() const dto = await getCurrentShoppingList()
ingredients.value = newIngredients.map((i) => ({ ...i })) // 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))
} }
function addIngredient() { async function addIngredient() {
if (loading) return
// Add a blank ingredient locally to allow text entry; upon edit, we parse line to an Ingredient
ingredients.value = [ ingredients.value = [
{ id: -1, name: '', line: '', quantity: 1, unit: 'Items', preparation: '', product: null }, { id: -1, name: '', line: '', quantity: 1, unit: 'Items', preparation: '', product: null, productId: null, recipeId: null, mealId: null },
...ingredients.value, ...ingredients.value,
] ]
} }
function deleteIngredient(ingredient: Ingredient) { async function addSelectedIngredient() {
ingredients.value = ingredients.value.filter((i) => i !== ingredient) 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()
}
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) { 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)) ingredients.value = ingredients.value.map((source) => (source === oldIngredient ? newIngredient : source))
} }
async function onEditing(isStartingEdit: boolean) { async function onEditing(isStartingEdit: boolean) {
await updateShoppingList(!isStartingEdit) if (isStartingEdit) {
if (isStartingEdit && ingredients.value.length === 0) addIngredient() await refreshFromServer()
if (ingredients.value.length === 0) await addIngredient()
}
} }
onBeforeMount(async () => { onBeforeMount(async () => {
const u = await loadUser() const u = await loadUser()
if (!u) return router.push({ name: 'login' }) if (!u) return router.push({ name: 'login' })
await updateShoppingList() await refreshFromServer()
}) })
</script> </script>

View file

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

View file

@ -1,8 +1,13 @@
import { ref } from 'vue' import { ref } from 'vue'
import { currentUser as apiCurrentUser, login as apiLogin } from '@/api/auth' import { currentUser as apiCurrentUser, loginWithPassword as apiLoginWithPassword, logout as apiLogout, createAccount as apiCreateAccount } from '@/api/auth'
import type { Person } from '@/domain/types' import { api } from '@/api/client'
import type { User } from '@/domain/types'
const user = ref<Person | null>(null) type Household = { id: number; name: string; slug: string }
const user = ref<User | null>(null)
const households = ref<Household[]>([])
const activeHousehold = ref<Household | null>(null)
let initialized = false let initialized = false
export async function loadUser() { export async function loadUser() {
@ -13,11 +18,58 @@ export async function loadUser() {
return user.value return user.value
} }
export async function login(username: string) { export async function loginWithPassword(email: string, password: string) {
user.value = await apiLogin(username) user.value = await apiLoginWithPassword(email, password)
return user.value return user.value
} }
export function useAuth() { export async function logout() {
return { user, loadUser, login } 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 }
} }

View file

@ -0,0 +1,18 @@
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,9 +60,8 @@ export function useShopping() {
purchaseShoppingList: sdk.purchaseShoppingList, purchaseShoppingList: sdk.purchaseShoppingList,
requestMeal: sdk.requestMeal, requestMeal: sdk.requestMeal,
unrequestMeal: sdk.unrequestMeal, unrequestMeal: sdk.unrequestMeal,
getMyShoppingList: sdk.getMyShoppingList, requestIngredient: sdk.requestIngredient,
saveMyShoppingList: sdk.saveMyShoppingList, unrequestIngredient: sdk.unrequestIngredient,
// View-model helpers
groupsFrom, groupsFrom,
mealsFrom, mealsFrom,
async purchaseFromGroups(groups: Group[]) { async purchaseFromGroups(groups: Group[]) {

View file

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

20
src/domain/members.ts Normal file
View file

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

View file

@ -1,4 +1,5 @@
import { createRouter, createWebHashHistory, Router } from 'vue-router' import { createRouter, createWebHashHistory, createMemoryHistory, Router, RouterView } from 'vue-router'
import { setHouseholdSlugProvider } from '@/api/client'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
// Lazy-loaded route components // Lazy-loaded route components
@ -10,65 +11,90 @@ const PurchasedShoppingListPage = () => import('@/components/shopping/PurchasedS
const CurrentShoppingListPage = () => import('@/components/shopping/CurrentShoppingListPage.vue') const CurrentShoppingListPage = () => import('@/components/shopping/CurrentShoppingListPage.vue')
const EditMealPage = () => import('@/components/meals/EditMealPage.vue') const EditMealPage = () => import('@/components/meals/EditMealPage.vue')
const EditRecipePage = () => import('@/components/recipes/EditRecipePage.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 { export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown): Router {
const routes: RouteRecordRaw[] = [ const publicRoutes: RouteRecordRaw[] = [
{ path: '/', redirect: { name: 'mealplan' } },
{ path: '/login', name: 'login', component: LoginPage }, { path: '/login', name: 'login', component: LoginPage },
{ path: '/mealplan', name: 'mealplan', component: MealPlanPage, meta: { requiresAuth: true } }, { path: '/create-account', name: 'create-account', component: CreateAccount },
{ { path: '/welcome', name: 'welcome', component: Welcome },
path: '/shopping', { path: '/invitations/accept', name: 'invitation-accept', component: InvitationAccept },
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({ const router = createRouter({
history: createWebHashHistory(), history,
routes, routes,
}) })
router.beforeEach(async (to) => { 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 if (!to.meta.requiresAuth) return true
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
if (user) return true 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
} catch (_) { } catch (_) {
/* ignore */ /* ignore */
} }

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

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

101
src/views/CreateAccount.vue Normal file
View file

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

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

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

40
src/views/Welcome.vue Normal file
View file

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

74
tests/auth.api.test.ts Normal file
View file

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

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

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

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

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

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

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

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

@ -0,0 +1,28 @@
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()
})
})

View file

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

@ -1,14 +0,0 @@
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()
})
})

View file

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

@ -1,19 +0,0 @@
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()
})
})

View file

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

@ -1,12 +0,0 @@
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()
})
})

View file

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

@ -1,12 +0,0 @@
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()
})
})

View file

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

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

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

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

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

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

View file

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

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

View file

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

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

@ -0,0 +1,56 @@
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()
})
})

16
uat-profiles.md Normal file
View file

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