Compare commits

..

6 commits

Author SHA1 Message Date
7705c3113c Improved group matching 2025-07-29 09:07:44 +10:00
6cc9f8fef7 Fixed Purchased Meals 2025-07-28 22:35:47 +10:00
e72c84b18e Current Shopping Page fixes 2025-07-28 21:50:29 +10:00
ce3e255eb4 FIrst render 2025-07-28 20:51:17 +10:00
67cbb370ed Can edit own requests 2025-07-27 15:25:37 +10:00
728532a7c8 first working 2025-07-27 11:58:57 +10:00
81 changed files with 3385 additions and 13070 deletions

View file

@ -1,12 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

View file

@ -1,3 +0,0 @@
# Base URL for the backend API
# Example: http://localhost:8081
VUE_APP_API_BASE=

View file

@ -1,45 +0,0 @@
name: CI
on:
push:
branches: [ main, master ]
pull_request:
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Codegen check
run: npm run codegen:check || true
- name: Generate OpenAPI types
run: |
if [ -f "../munch-ease-backend/openapi.json" ]; then
npm run codegen
else
echo "Backend openapi.json not found. Skipping codegen."
fi
- name: Typecheck
run: npm run -s typecheck
- name: Typecheck Vue SFC templates
run: npm run -s typecheck:vue
- name: Lint
run: npm run -s lint
- name: Test
run: npm run -s test

View file

@ -1,4 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged

View file

@ -1,3 +0,0 @@
node_modules/
dist/
coverage/

View file

@ -1,7 +0,0 @@
{
"singleQuote": true,
"semi": false,
"trailingComma": "es5",
"printWidth": 100,
"arrowParens": "always"
}

View file

@ -1,59 +0,0 @@
# Contributing to Munch Ease
Thanks for helping make Munch Ease better! This repo aims for a lean, predictable codebase. Please keep changes small, typed, and wellscoped.
## Core axioms (must follow)
- OpenAPI is the single source of truth for shapes (generated in `src/api/types.ts`).
- The SDK (`src/api/sdk.ts`) is the only data access surface.
- Domain types live in `src/domain/types.ts`; minimal normalization in `src/domain/decoders.ts`.
- No casts (`as`, angle brackets) in app code. Generated files are exempt. Boundary-only normalization allowed.
- No `any`/`unknown` in app code. Prefer precise types, `Pick`/`Omit`, and inference.
- Arrays declared in OpenAPI as required are non-nullable in domain types (e.g., `Meal.recipes` is always an array).
- Disallow runtime type checks in app code (`typeof`, `in`, broad `instanceof`).
- Acceptable exceptions: DOM event narrowing (e.g., `HTMLInputElement`), error normalization in SDK, env detection.
- CamelCase across `src/`; no snake_case.
## Architecture quick tour
- SDK + decoders boundary
- `src/api/sdk.ts`: All network calls; returns domain-safe types. Normalize errors via `httpError`.
- `src/domain/decoders.ts`: Convert date strings → `Date`, normalize arrays, and decode nested structures.
- Domain and DTOs
- Prefer domain aliases from `src/domain/types.ts` over raw `components['schemas'][...]`.
- Use discriminated unions for UI shapes where needed (e.g., `Group` has `type: 'product' | 'name'`).
- Composables
- UI logic in `src/composables/*`. Keep components thin.
- Routing
- Use helpers from `src/router/helpers.ts` (`parseRouteId`, `parseQueryString`) instead of ad-hoc `typeof` checks.
## Coding guidelines
- Keep changes small and incremental. Write a quick unit test when behavior changes.
- Prefer `computed` over ad-hoc recalculation; avoid prop mutation.
- No ad-hoc mappers. If a slimmer shape is needed, use `Pick`/`Omit` with a descriptive name (e.g., `*Summary`).
- Keep runtime guards in the boundary only. UI assumes decoded, normalized types.
## Tooling
- TypeScript strict; ESLint with type-aware rules; Prettier formatting.
- Tests: Vitest + MSW. Keep tests fast and focused.
- Node 18+ recommended.
## Useful scripts
```bash
npm run serve # Dev server
npm run test # Unit tests (Vitest)
npm run lint # ESLint
npm run typecheck # TS + vue-tsc
npm run build # Production build
```
## PR checklist
- [ ] Types first: no casts in app code, no `any`/`unknown`
- [ ] Boundary-only normalization in decoders/SDK
- [ ] Arrays reflect OpenAPI nullability in domain types
- [ ] No new mappers; use domain aliases or `Pick`/`Omit`
- [ ] Tests updated/added if behavior changed

110
README.md
View file

@ -1,112 +1,24 @@
## Munch Ease — Plan, Cook, Shop, Repeat
# doof-front
Munch Ease is a snappy Vue 3 app that helps you plan meals, manage recipes, and turn plans into stress-free shopping lists. Search and save recipes, build your weekly meal plan, and seamlessly check items off your shopping list—everything stays in sync so you can focus on whats cooking.
Built with modern Vue patterns, a clean API layer, and lightweight tests, the project is easy to extend and fun to work on.
---
## Quick start
1) Install dependencies
```bash
## Project setup
```
npm install
```
2) Run the dev server
```bash
### Compiles and hot-reloads for development
```
npm run serve
```
3) Run unit tests (Vitest)
```bash
npm run test
### Compiles and minifies for production
```
4) Build for production
```bash
npm run build
```
Environment
- API base URL: set VUE_APP_API_BASE (e.g. http://localhost:8081)
---
## Architecture and conventions
Strict TypeScript, Vue 3 Composition API, and a single typed API boundary.
Key axioms
- OpenAPI (generated `src/api/types.ts`) is the single source of truth for shapes.
- SDK (`src/api/sdk.ts`) is the only data access surface; UI uses domain types from `src/domain/types.ts`.
- Domain normalization is minimal in `src/domain/decoders.ts` (e.g., date strings → `Date`).
- No casts (`as`, angle brackets) and no `any`/`unknown` in app code. Generated files are exempt.
- Arrays that are required in OpenAPI are non-nullable in domain types (e.g., `Meal.recipes`).
- Disallow runtime type checks in app code; acceptable exceptions: DOM event narrowing, error/env handling in the boundary.
Layout
- `src/api/` — Typed client and SDK boundary
- `src/domain/` — Domain types and decoders
- `src/composables/` — Reusable app logic (auth, meals, shopping, pagination, alert)
- `src/components/` — UI components and pages
- `src/router/` — Routes and helpers (`parseRouteId`, `parseQueryString`)
Testing and tooling
- Vitest + MSW under `tests/`
- ESLint (type-aware) + Prettier + Volar
---
## Development tips
- Prefer composables for shared logic; keep components thin.
- Use computed for derived values; avoid mutating props directly.
- Use discriminated unions for UI-only shapes when helpful (e.g., shopping `Group`).
- Add/adjust tests when changing behavior.
---
## Troubleshooting
- API errors: verify `VUE_APP_API_BASE` is set and reachable.
- Type/IDE help: ensure Volar is enabled and ESLint is not conflicting with Prettier.
- Build issues: this project uses Vue CLI 5. If migrating to Vite, update scripts and configs accordingly.
---
## Type checking and codegen
- Type check (TS strict + vue-tsc):
```bash
npm run typecheck
### Lints and fixes files
```
npm run lint
```
- Generate API types (consumed by SDK):
```bash
npm run codegen
```
Environment
- VUE_APP_API_BASE (Vue CLI) or VITE_API_BASE_URL (Vite) for API base URL
Testing
- Unit tests use MSW; the client defaults to a localhost base in tests for easy mocking
### CurrentShoppingList item kinds
The OpenAPI spec models current shopping list items as distinct kinds:
- outstandingItems: ListIngredientItem[]
- requestedMeals: RequestedMealItem[]
- purchasedItems: ListIngredientItem[]
The SDK maps these to a domain DTO (`CurrentShoppingListDTO`) and may attach refs (`ingredient`, `recipe`, `meal`, `list`) for convenience. UI code should:
- Prefer stable IDs (`ingredientId`, `mealId`, `recipeId`, `listId`) for actions and lookups
- Treat attached refs as optional view helpers (never required)
- Keep all normalization at the boundary (decoders); avoid casts and runtime type checks in app code
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

View file

@ -1,3 +1,5 @@
module.exports = {
presets: ['@vue/cli-plugin-babel/preset'],
presets: [
'@vue/cli-plugin-babel/preset'
]
}

View file

@ -5,8 +5,15 @@
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": ["src/*"]
"@/*": [
"src/*"
]
},
"lib": ["esnext", "dom", "dom.iterable", "scripthost"]
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}

7864
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,112 +5,35 @@
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"format": "prettier --write .",
"prepare": "husky install",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc -p tsconfig.json --noEmit",
"typecheck:vue": "vue-tsc --noEmit",
"codegen:api": "openapi-typescript ../munch-ease-backend/openapi.json -o src/api/types.ts",
"codegen": "npm run codegen:api",
"codegen:check": "node scripts/codegen-check.js"
"lint": "vue-cli-service lint"
},
"dependencies": {
"core-js": "^3.8.3",
"vue": "^3.5.12",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@types/node": "^20.19.22",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-typescript": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.27.0",
"husky": "^8.0.0",
"lint-staged": "^13.3.0",
"msw": "^2.5.2",
"openapi-fetch": "^0.9.5",
"openapi-typescript": "^7.4.2",
"prettier": "^3.3.3",
"typescript": "~5.5.4",
"vitest": "^1.6.0",
"vue-tsc": "^2.0.29"
},
"lint-staged": {
"*.{js,vue,css,scss,md}": [
"prettier --write"
]
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3"
},
"eslintConfig": {
"root": true,
"env": {
"node": true,
"vue/setup-compiler-macros": true
"node": true
},
"extends": [
"plugin:vue/vue3-recommended",
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
"plugin:vue/vue3-essential",
"eslint:recommended"
],
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser",
"sourceType": "module",
"ecmaVersion": 2020,
"extraFileExtensions": [
".vue"
]
"parser": "@babel/eslint-parser"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": "error"
},
"overrides": [
{
"files": [
"src/**/*.ts",
"src/**/*.vue",
"src/**/*.d.ts"
],
"excludedFiles": [
"src/api/types.ts",
"src/**/*.d.ts"
],
"parserOptions": {
"project": [
"./tsconfig.eslint.json"
],
"tsconfigRootDir": "."
},
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-return": "error",
"no-restricted-syntax": [
"error",
{
"selector": "TSAsExpression[typeAnnotation.type!='TSConstKeyword']",
"message": "Disallow 'as' type assertions in app code. Prefer precise typing and helpers."
},
{
"selector": "TSTypeAssertion",
"message": "Disallow angle-bracket type assertions in app code. Prefer precise typing and helpers."
}
]
}
}
]
"rules": {}
},
"browserslist": [
"> 1%",

View file

@ -1,18 +1,15 @@
<!doctype html>
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong
>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without
JavaScript enabled. Please enable it to continue.</strong
>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->

View file

@ -1,32 +0,0 @@
#!/usr/bin/env node
/**
* Warn if backend openapi.json is newer than generated src/api/types.ts
*/
const { statSync, existsSync } = require('fs')
const { resolve } = require('path')
const backendSpec = resolve(__dirname, '..', '..', 'munch-ease-backend', 'openapi.json')
const generated = resolve(__dirname, '..', 'src', 'api', 'types.ts')
if (!existsSync(backendSpec)) {
console.log('codegen-check: Skipping (backend openapi.json not found).')
process.exit(0)
}
if (!existsSync(generated)) {
console.log('codegen-check: src/api/types.ts not found. Run: npm run codegen')
process.exit(1)
}
try {
const specMtime = statSync(backendSpec).mtimeMs
const genMtime = statSync(generated).mtimeMs
if (specMtime > genMtime) {
console.log('codegen-check: OpenAPI spec is newer than generated types. Consider running: npm run codegen')
process.exit(2)
} else {
console.log('codegen-check: OK (generated types up to date).')
}
} catch (e) {
console.error('codegen-check: Error reading timestamps:', e.message)
process.exit(3)
}

View file

@ -1,49 +1,48 @@
<template>
<div>
<ul class="nav">
<li class="nav-item">
<router-link
class="nav-link"
:to="{ name: 'recipes' }"
active-class="active"
>
Recipes
</router-link>
</li>
<li class="nav-item">
<router-link
class="nav-link"
:to="{ name: 'mealplan' }"
active-class="active"
>
Meal Plan
</router-link>
</li>
<li class="nav-item">
<router-link
class="nav-link"
:to="{ name: 'shopping' }"
active-class="active"
>
Shopping
</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/recipes" active-class="active">Recipes</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/mealplan" active-class="active">Meal Plan</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/shopping" active-class="active">Shopping</router-link>
</li>
</ul>
</div>
<div class="viewport">
<router-view />
<router-view/>
</div>
<alert-toast />
</template>
<script setup>
<script>
import data from './data.js'
import AlertToast from './components/AlertToast.vue'
// components in <script setup> are auto-registered by import + usage
export default {
name: 'App',
components: {
'alert-toast': AlertToast
},
computed: {
currentRoute() {
return this.$route.path
}
},
async mounted() {
if (!await data.currentUser()) {
this.$router.push('/login')
}
}
}
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
@ -55,44 +54,45 @@ import AlertToast from './components/AlertToast.vue'
/* Make nav bar links buttons across top of screen */
.nav {
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 1;
display: flex;
justify-content: space-around;
list-style-type: none;
margin: 0;
padding: 0;
position: fixed;
top: 0;
width: 100%;
background-color: #333;
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 1;
display: flex;
justify-content: space-around;
list-style-type: none;
margin: 0;
padding: 0;
position: fixed;
top: 0;
width: 100%;
background-color: #333;
}
.nav li {
flex: 1;
flex: 1;
}
/* Style the links inside the navigation bar */
.nav a {
display: inline-block;
color: #f2f2f2;
text-align: center;
padding: 14px 16px;
text-decoration: none;
display: inline-block;
color: #f2f2f2;
text-align: center;
padding: 14px 16px;
text-decoration: none;
}
/* Have active route use different color */
.nav li:has(> a.active) {
background-color: #4caf50;
color: white;
background-color: #4CAF50;
color: white;
}
.viewport {
max-width: 1200px;
margin: auto;
max-width: 1200px;
margin: auto;
}
</style>

10
src/alert.js Normal file
View file

@ -0,0 +1,10 @@
const subscribers = [];
export default {
subscribe(callback) {
subscribers.push(callback);
},
show(message) { // { message, heading, type: ["success", "error", "info"] }
subscribers.forEach(callback => callback(message));
}
}

View file

@ -1,30 +0,0 @@
import { api } from '@/api/client'
import type { Person } from '@/domain/types'
let cachedUser: Person | null = null
export async function currentUser(): Promise<Person | null> {
if (cachedUser) return cachedUser
try {
const res = await api.POST('/api/v1/auth/refresh', { params: { cookie: { user_id: 0 } } })
if (!res.response.ok) return null
cachedUser = res.data ?? null
} catch (_) {
cachedUser = null
}
return cachedUser
}
export async function login(username: string): Promise<Person> {
const res = await api.POST('/api/v1/auth/login', { body: { username } })
if (!res.response.ok) {
const err = res.error
throw (
(err instanceof Error && err) ||
(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

@ -1,20 +0,0 @@
import createClient from 'openapi-fetch'
import type { paths } from './types'
// Vue CLI uses process.env.VUE_APP_*
const vueCliBase: string | undefined = typeof process !== 'undefined' ? (process.env?.VUE_APP_API_BASE ?? undefined) : undefined
const isTest = typeof process !== 'undefined' && (process.env?.VITEST === 'true' || process.env?.NODE_ENV === 'test')
// Prefer absolute http URL in tests to avoid TLS issues and ease MSW interception
// Use host-only base URL; always pass full "/api/v1/..." paths to the client methods
const defaultBase = ''
const baseUrl: string = isTest ? 'http://localhost' : (vueCliBase || defaultBase)
export const api = createClient<paths>({
baseUrl,
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
return globalThis.fetch(input, {
credentials: 'include',
...init,
})
},
})

View file

@ -1,361 +0,0 @@
import { api } from '@/api/client'
import type { components } from '@/api/types'
import {
toDate,
decodeMeal,
decodeRecipe,
decodeIngredients,
decodeShoppingList,
decodeShoppingListItems,
decodeListIngredientItems,
decodeRequestedMealItems,
decodeIngredient,
decodeLookup,
} from '@/domain/decoders'
import type {
Recipe,
Meal,
Ingredient,
ShoppingList,
ShoppingListItemWithRefs,
ListIngredientItemWithRefs,
RequestedMealItemWithRefs,
CurrentShoppingListDTO,
PurchasedShoppingListDTO,
ShoppingLookups,
} from '@/domain/types'
import { fromOpenApiPage, type Page } from '@/domain/pagination'
import type { PurchaseRequest } from '@/domain/commands'
function httpError(response: Response, error: unknown): Error {
if (error instanceof Error) return error
if (typeof error === 'string') return new Error(error)
return new Error(`${response.status} ${response.statusText || 'HTTP error'}`)
}
// decodeLookup moved to domain/decoders to be reused across SDK and other modules
// Shopping list mapped view types now come from domain/types
function attachItemRefs(
items: Array<ShoppingListItemWithRefs | ListIngredientItemWithRefs | RequestedMealItemWithRefs> | null | undefined,
lookups: ShoppingLookups
): void {
if (!Array.isArray(items)) return
for (const item of items) {
// ingredient ref
if ('ingredientId' in item && item.ingredientId !== undefined && lookups.ingredientsLookup) {
const v = lookups.ingredientsLookup[String(item.ingredientId)]
if (v !== undefined) item.ingredient = v
}
// meal ref (present on all item types)
if ('mealId' in item && item.mealId !== undefined && lookups.mealsLookup) {
const v = lookups.mealsLookup[String(item.mealId)]
if (v !== undefined) item.meal = v
}
// recipe ref
if ('recipeId' in item && item.recipeId !== undefined && lookups.recipesLookup) {
const v = lookups.recipesLookup[String(item.recipeId)]
if (v !== undefined) item.recipe = v
}
// list ref
if ('listId' in item && item.listId !== undefined && lookups.shoppingListLookup) {
const v = lookups.shoppingListLookup[String(item.listId)]
if (v !== undefined) item.list = v
}
if ('createdDate' in item) item.createdDate = toDate(item.createdDate)
}
}
export function mapPurchasedShoppingList(dto: components['schemas']['PurchasedShoppingList'] | null | undefined): PurchasedShoppingListDTO | null {
if (!dto) return null
const mealsLookupRaw = dto.mealsLookup
const recipesLookupRaw = dto.recipesLookup
const ingredientsLookupRaw = dto.ingredientsLookup
const listRaw = dto.list
const mealsLookup = decodeLookup(mealsLookupRaw, decodeMeal)
const recipesLookup = decodeLookup(recipesLookupRaw, decodeRecipe)
const ingredientsLookup = decodeLookup(ingredientsLookupRaw, decodeIngredient)
let list: import('@/domain/types').ShoppingListWithRefs | undefined
if (listRaw) {
const base = decodeShoppingList(listRaw)
if (base) {
const items = Array.isArray(listRaw.items) ? decodeShoppingListItems(listRaw.items) : []
list = { ...base, items }
const lookups: ShoppingLookups = {
...(ingredientsLookup && { ingredientsLookup }),
...(mealsLookup && { mealsLookup }),
...(recipesLookup && { recipesLookup }),
...(list && { shoppingListLookup: { [String(list.id)]: list } }),
}
attachItemRefs(list.items, lookups)
}
}
return {
...(ingredientsLookup && { ingredientsLookup }),
...(mealsLookup && { mealsLookup }),
...(recipesLookup && { recipesLookup }),
...(list && { list }),
// include shoppingListLookup when list exists for consistency with lookups type
...(list && { shoppingListLookup: { [String(list.id)]: list } }),
}
}
export function mapCurrentShoppingList(dto: components['schemas']['CurrentShoppingList'] | null | undefined): CurrentShoppingListDTO | null {
if (!dto) return null
const outstandingRaw = dto.outstandingItems
const requestedRaw = dto.requestedMeals
const purchasedRaw = dto.purchasedItems
const ingredientsLookupRaw = dto.ingredientsLookup
const mealsLookupRaw = dto.mealsLookup
const recipesLookupRaw = dto.recipesLookup
const shoppingListLookupRaw = dto.shoppingListLookup
const ingredientsLookup = decodeLookup(ingredientsLookupRaw, decodeIngredient)
const mealsLookup = decodeLookup(mealsLookupRaw, decodeMeal)
const recipesLookup = decodeLookup(recipesLookupRaw, decodeRecipe)
let shoppingListLookup: Record<string, ShoppingList> | undefined
if (shoppingListLookupRaw) {
shoppingListLookup = decodeLookup(shoppingListLookupRaw, decodeShoppingList)
}
const dtoOut: CurrentShoppingListDTO = {
outstandingItems: decodeListIngredientItems(outstandingRaw ?? []),
requestedMeals: decodeRequestedMealItems(requestedRaw ?? []),
purchasedItems: decodeListIngredientItems(purchasedRaw ?? []),
...(ingredientsLookup && { ingredientsLookup }),
...(mealsLookup && { mealsLookup }),
...(recipesLookup && { recipesLookup }),
...(shoppingListLookup && { shoppingListLookup }),
}
const lookups: ShoppingLookups = {
...(ingredientsLookup && { ingredientsLookup }),
...(mealsLookup && { mealsLookup }),
...(recipesLookup && { recipesLookup }),
...(shoppingListLookup && { shoppingListLookup }),
}
attachItemRefs(dtoOut.outstandingItems ?? null, lookups)
attachItemRefs(dtoOut.requestedMeals ?? null, lookups)
attachItemRefs(dtoOut.purchasedItems ?? null, lookups)
return dtoOut
}
export async function listRecipes(params?: { q?: string | null; cursor?: string | null; limit?: number }): Promise<Page<Recipe>> {
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/recipes', { params: { query } })
if (!response.ok) throw httpError(response, error)
return fromOpenApiPage(data ?? null, (r) => decodeRecipe(r))
}
export async function getRecipe(id: number | string): Promise<Recipe> {
const { data, error, response } = await api.GET('/api/v1/recipes/{recipe_id}', { params: { path: { recipe_id: Number(id) } } })
if (!response.ok) throw httpError(response, error)
const mapped = decodeRecipe(data)
if (!mapped) throw new Error('Recipe not found')
return mapped
}
export async function saveRecipe(recipe: components['schemas']['Recipe-Input']): Promise<Recipe | null> {
const { data, error, response } = await api.POST('/api/v1/recipes', { body: recipe, params: { cookie: { user_id: 0 } } })
if (!response.ok) throw httpError(response, error)
return decodeRecipe(data)
}
export async function deleteRecipe(id: number | string): Promise<void> {
const { error, response } = await api.DELETE('/api/v1/recipes/{recipe_id}', { params: { path: { recipe_id: Number(id) }, cookie: { user_id: 0 } } })
if (!response.ok) throw httpError(response, error)
}
export async function parseRecipe(url: string): Promise<Recipe | null> {
const { data, error, response } = await api.GET('/api/v1/recipes/parse', { params: { query: { url }, cookie: { user_id: 0 } } })
if (!response.ok) throw httpError(response, error)
return decodeRecipe(data)
}
export async function parseIngredients(lines: string[]): Promise<Ingredient[]> {
const { data, error, response } = await api.GET('/api/v1/recipes/ingredients/parse', {
params: { query: { ingredients: lines } },
})
if (!response.ok) throw httpError(response, error)
return decodeIngredients(data ?? [])
}
export async function parseProduct(
ingredient: Pick<components['schemas']['Ingredient'], 'name' | 'line'>,
url: string
): Promise<components['schemas']['Product'] | null> {
const body: components['schemas']['ProductUrl'] = { url, tags: [ingredient.name, ingredient.line] }
const res = await api.POST('/api/v1/products', { body })
if (!res.response.ok) throw httpError(res.response, res.error)
return res.data ?? null
}
// Persons
async function listPersons(params?: { q?: string | null; cursor?: string | null; limit?: number }): Promise<Page<components['schemas']['Person']>> {
const query: Record<string, unknown> = {}
if (params) {
if (params.q !== undefined) query.q = params.q
if (params.cursor !== undefined) query.cursor = params.cursor
if (typeof params.limit === 'number') query.limit = params.limit
}
const { data, error, response } = await api.GET('/api/v1/persons', { params: { query } })
if (!response.ok) throw httpError(response, error)
const normalized = Array.isArray(data) ? { items: data } : (data ?? null)
return fromOpenApiPage<components['schemas']['Person'], components['schemas']['Person']>(normalized, (p) => p)
}
export async function getPersonsInHome(): Promise<Page<components['schemas']['Person']>> {
return listPersons()
}
export async function searchPersons(name: string): Promise<Page<components['schemas']['Person']>> {
return listPersons({ q: name })
}
// Meals
export async function getUpcomingMeals(from: Date, to: Date): Promise<Meal[]> {
const { data, error, response } = await api.GET('/api/v1/meals/upcoming', {
params: { query: { from: from.toISOString(), to: to.toISOString() } },
})
if (!response.ok) throw httpError(response, error)
const list = Array.isArray(data) ? data : []
return list
.map((m) => decodeMeal(m))
.filter((m): m is Meal => !!m)
.sort((a, b) => ((a.suggestedDate?.getTime() ?? 0) - (b.suggestedDate?.getTime() ?? 0)))
}
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) } } })
if (!response.ok) throw httpError(response, error)
const mapped = decodeMeal(data)
if (!mapped) throw new Error('Meal not found')
return mapped
}
export async function saveMeal(meal: components['schemas']['Meal-Input']): Promise<Meal | null> {
const hasId = typeof meal.id === 'number' && meal.id >= 0
if (hasId) {
const { data, error, response } = await api.PUT('/api/v1/meals/{meal_id}', {
params: { path: { meal_id: Number(meal.id) } },
body: meal,
})
if (!response.ok) throw httpError(response, error)
return decodeMeal(data)
} else {
const { data, error, response } = await api.POST('/api/v1/meals', { body: meal })
if (!response.ok) throw httpError(response, error)
return decodeMeal(data)
}
}
export async function markMealConsumed(mealId: number | string): Promise<Meal> {
const { data, error, response } = await api.POST('/api/v1/meals/{meal_id}/consumed', {
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } },
})
if (!response.ok) throw httpError(response, error)
const mapped = decodeMeal(data)
if (!mapped) throw new Error('Meal not found')
return mapped
}
export async function deleteMeal(mealId: number | string): Promise<void> {
const { error, response } = await api.DELETE('/api/v1/meals/{meal_id}', {
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } },
})
if (!response.ok) throw httpError(response, error)
}
// Shopping
export async function getMyShoppingList(): Promise<Ingredient[]> {
const { data, error, response } = await api.GET('/api/v1/shopping/current/me/ingredients', { params: { cookie: { user_id: 0 } } })
if (!response.ok) throw httpError(response, error)
return decodeIngredients(data ?? [])
}
export async function saveMyShoppingList(items: components['schemas']['Ingredient'][]): Promise<Ingredient[]> {
const { data, error, response } = await api.POST('/api/v1/shopping/current/me/ingredients', {
body: items,
params: { cookie: { user_id: 0 } },
})
if (!response.ok) throw httpError(response, error)
return decodeIngredients(data ?? [])
}
export async function getShoppingList(id: number | string): Promise<import('@/domain/types').ShoppingListWithRefs | null> {
const { data, error, response } = await api.GET('/api/v1/shopping/{list_id}', { params: { path: { list_id: Number(id) } } })
if (!response.ok) throw httpError(response, error)
const mapped = mapPurchasedShoppingList(data)
return mapped?.list ?? null
}
export async function getCurrentShoppingList(): Promise<CurrentShoppingListDTO> {
const { data, error, response } = await api.GET('/api/v1/shopping/current')
if (!response.ok) throw httpError(response, error)
const mapped = mapCurrentShoppingList(data)
if (!mapped) throw new Error('Failed to map current shopping list')
return mapped
}
// PurchaseRequest comes from domain/commands
export async function purchaseShoppingList(
completedRequests: PurchaseRequest[]
): Promise<import('@/domain/types').ShoppingListWithRefs | null> {
if (!Array.isArray(completedRequests) || completedRequests.length === 0) return null
// Map incoming requests: if id provided and >= 0, use it; otherwise send identifiers for ingredient/recipe/meal
const items: components['schemas']['IngredientPurchaseItemIn'][] = completedRequests.map((i) => ({
personId: i.personId,
ingredientId: i.ingredientId ?? -1,
recipeId: i.type === 'refs' ? i.recipeId ?? null : null,
mealId: i.type === 'refs' ? i.mealId ?? null : null,
createdDate: null,
}))
if (items.length === 0) return null
const body: components['schemas']['PurchaseListIn'] = {
// Default to a valid StoreNameOut per updated OpenAPI ("home" | "coles" | "woolworths")
storeName: 'home',
items,
}
const { data, error, response } = await api.POST('/api/v1/shopping', {
body,
params: { cookie: { user_id: 0 } },
})
if (!response.ok) throw httpError(response, error)
const mapped = mapPurchasedShoppingList(data)
return mapped?.list ?? null
}
export async function requestMeal(mealId: number | string): Promise<void> {
const { error, response } = await api.POST('/api/v1/shopping/current/meals/me', {
body: { mealId: Number(mealId) },
params: { cookie: { user_id: 0 } },
})
if (!response.ok) throw httpError(response, error)
}
export async function unrequestMeal(mealId: number | string): Promise<void> {
const { error, response } = await api.DELETE('/api/v1/shopping/current/meals/{meal_id}', {
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } },
})
if (!response.ok) throw httpError(response, error)
}
// Re-export domain command types for convenience at SDK surface
export type { PurchaseRequest } from '@/domain/commands'

File diff suppressed because it is too large Load diff

View file

@ -1,40 +1,37 @@
<template>
<div class="card">
<a @click="emit('click')">
<h2>{{ title }}</h2>
<img
:src="image"
:alt="title"
>
</a>
</div>
<div class="card">
<a @click="$emit('click')">
<h2>{{ title }}</h2>
<img :src="image" :alt="name" />
</a>
</div>
</template>
<script setup>
const emit = defineEmits(['click'])
defineProps({
title: { type: String, required: true },
image: { type: String, required: true },
})
<script>
export default {
name: 'ActionItem',
props: ['title', 'image']
}
</script>
<style scoped>
img {
width: 100%;
height: auto;
width: 100%;
height: auto;
}
.card {
display: inline-block;
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
width: 20em;
margin: 1em 1ex;
display: inline-block;
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
width: 20em;
margin: 1em 1ex;
}
.card:hover {
background-color: #ccc;
background-color: #ccc;
}
</style>
</style>

View file

@ -1,90 +1,98 @@
<template>
<div
v-if="showAlert"
:class="['alert', type]"
@click="dismiss"
>
<img
v-if="icon"
:src="icon"
alt="Notification icon"
>
<div v-if="showAlert" :class="['alert', type]" @click="dismiss">
<img v-if="icon" :src="icon" alt="Notification icon" />
<div class="message-container">
<h4 class="heading">
{{ heading }}
</h4>
<p class="message">
{{ message }}
</p>
<h4 class="heading">{{ heading }}</h4>
<p class="message">{{ message }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { computed, watch } from 'vue'
import { useAlert } from '@/composables/useAlert'
const alertIcons = {
error: new URL('@/assets/notification-error.svg', import.meta.url).toString(),
success: new URL('@/assets/notification-success.svg', import.meta.url).toString(),
info: new URL('@/assets/notification-info.svg', import.meta.url).toString(),
}
const { current, clear, scheduleAutoDismiss } = useAlert()
const showAlert = computed(() => !!current.value)
const heading = computed(() => current.value?.heading ?? '')
const message = computed(() => current.value?.message ?? '')
const type = computed(() => current.value?.type ?? '')
const icon = computed(() => (type.value && alertIcons[type.value] ? alertIcons[type.value] : null))
function dismiss() {
clear()
}
watch(
() => current.value?._ts,
(ts) => {
if (ts) scheduleAutoDismiss(5000)
}
)
</script>
<style scoped>
/* Display as a toast, in the bottom right corner */
/* Place the icon to the left for the full height, then have the heading and message stacked to the right */
.alert {
position: fixed;
bottom: 1em;
right: 1em;
display: flex;
align-items: center;
padding: 1em;
border-radius: 5px;
color: white;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
text-align: left;
position: fixed;
bottom: 1em;
right: 1em;
display: flex;
align-items: center;
padding: 1em;
border-radius: 5px;
color: white;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
text-align: left;
}
.alert img {
width: 2em;
height: 2em;
margin-right: 1em;
width: 2em;
height: 2em;
margin-right: 1em;
/* Invert svg colors */
filter: invert(1);
/* Invert svg colors */
filter: invert(1);
}
.alert.error {
background-color: #f44336;
background-color: #f44336;
}
.alert.success {
background-color: #4caf50;
background-color: #4CAF50;
}
.alert.info {
background-color: #2196f3;
background-color: #2196F3;
}
</style>
<script>
import alert from '@/alert';
const alertIcons = {
error: require('@/assets/notification-error.svg'),
success: require('@/assets/notification-success.svg'),
info: require('@/assets/notification-info.svg')
};
export default {
name: 'AlertToast',
data() {
return {
showAlert: false,
heading: '',
message: '',
type: ''
};
},
computed: {
icon() {
return this.type && alertIcons[this.type] ? alertIcons[this.type] : null;
}
},
mounted() {
alert.subscribe(this.show);
},
methods: {
show({ heading, message, type }) {
this.heading = heading;
this.message = message;
this.type = type;
this.showAlert = true;
setTimeout(() => {
this.showAlert = false;
}, 5000);
},
dismiss() {
this.showAlert = false;
},
}
}
</script>

View file

@ -1,104 +1,107 @@
<template>
<div class="login">
<h1>Login Page</h1>
<ul class="button-group">
<li
v-for="(person, index) in persons"
:key="person.id ?? index"
>
<button
type="button"
class="btn btn-primary"
@click="onLogin(person)"
>
{{ person.name }}
</button>
</li>
</ul>
</div>
<div class="login">
<h1>Login Page</h1>
<ul class="button-group">
<li v-for="person in persons" :key="person.id">
<button type="button" class="btn btn-primary" @click="login(person)">
{{ person.name }}
</button>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getPersonsInHome } from '@/api/sdk'
import { login as loginApi } from '@/api/auth'
import type { Person } from '@/domain/types'
const props = defineProps({
redirect: { type: String, default: '/' },
})
const router = useRouter()
const persons = ref<Person[]>([])
onMounted(async () => {
const page = await getPersonsInHome()
persons.value = page.items
})
async function onLogin(selectedPerson: Person) {
const person = await loginApi(selectedPerson.name)
if (person?.id >= 0) {
router.push(props.redirect)
return
}
alert('Login failed')
}
</script>
<style scoped>
/* Remove the default list styling */
ul {
list-style-type: none;
padding: 0;
list-style-type: none;
padding: 0;
}
/* Center the buttons in the middle of the page, and let them wrap */
.button-group {
display: flex;
flex-wrap: wrap;
justify-content: center;
display: flex;
flex-wrap: wrap;
justify-content: center;
}
/* Display each button as a large round circle */
button {
width: 100px;
height: 100px;
border-radius: 50%;
margin: 10px;
font-size: 1.5em;
width: 100px;
height: 100px;
border-radius: 50%;
margin: 10px;
font-size: 1.5em;
/* Center the text in the middle of the button */
display: flex;
justify-content: center;
align-items: center;
/* Center the text in the middle of the button */
display: flex;
justify-content: center;
align-items: center;
/* Add a shadow to make the buttons look like they are floating */
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
/* Add a shadow to make the buttons look like they are floating */
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
}
/* Some nice flat nuetral shades for the user buttons */
li:nth-child(1) > button {
background-color: #3939ff;
color: white;
background-color: #3939ff;
color: white;
}
li:nth-child(2) > button {
background-color: #156a14;
color: white;
background-color: #156a14;
color: white;
}
li:nth-child(3) > button {
background-color: #325293;
color: white;
background-color: #325293;
color: white;
}
li:nth-child(4) > button {
background-color: #9a1f1f;
color: white;
background-color: #9a1f1f;
color: white;
}
</style>
<script>
import data from '@/data';
export default {
name: 'LoginVue',
props: {
redirect: {
type: String,
default: '/'
},
},
data() {
return {
persons: [],
}
},
async beforeMount() {
this.persons = await data.getPersonsInHome();
},
methods: {
async login(selectedPerson) {
const person = await data.login(selectedPerson.name);
if (person?.id >= 0) {
this.$router.push(this.redirect);
return;
}
alert('Login failed');
}
}
}
</script>

View file

@ -1,91 +1,56 @@
<template>
<div class="compact-parse-results">
<div class="compact-parse-results">
<p class="parse-element teaser-image">
<img :src="ingredient.product?.imgSmall ?? missingProduct">
<img :src="ingredient.product?.img_small ?? require('@/assets/missing-product.svg')" />
</p>
<p class="ingredient-details">
<span
class="parse-element quantity"
:class="{ missing: !ingredient?.quantity }"
>{{
ingredient?.quantity || 'qty'
}}</span>
<span
class="parse-element unit"
:class="{ missing: !ingredient?.unit }"
>{{
ingredient?.unit || 'unit'
}}</span>
<span class="parse-element helper">of</span>
<span
class="parse-element name"
:class="{ missing: !ingredient?.name }"
>{{
ingredient?.name || 'name'
}}</span>:&nbsp;
<span
class="parse-element product-name"
:class="{ missing: !ingredient?.product }"
>
<a
v-if="ingredient?.product?.link"
:href="ingredient?.product?.link"
target="”_blank”"
>
( {{ ingredient?.product?.name }}
<img
:src="externalLink"
style="
width: 1em;
height: 1em;
vertical-align: middle;
margin-left: 0.5em;
margin-bottom: 0.2em;
"
>
)
</a>
<a
v-else-if="ingredient?.name"
:href="searchlink"
target="_blank"
> (search?) </a>
<a v-else> (product) </a>
</span>
<span class="parse-element quantity" :class="{ missing: !(ingredient?.quantity)}">{{ ingredient?.quantity || 'qty' }}</span>
<span class="parse-element unit" :class="{ missing: !(ingredient?.unit)}">{{ ingredient?.unit || 'unit' }}</span>
<span class="parse-element helper" >of</span>
<span class="parse-element name" :class="{ missing: !(ingredient?.name)}">{{ ingredient?.name || 'name' }}</span>:&nbsp;
<span class="parse-element product-name" :class="{missing: !(ingredient?.product)}">
<a :href="ingredient?.product?.link" v-if="ingredient?.product?.link" target=_blank>
( {{ ingredient?.product?.name }} <img src="@/assets/external-link.svg" style="width: 1em; height: 1em; vertical-align: middle; margin-left: 0.5em; margin-bottom: 0.2em;" /> )
</a>
<a v-else-if="ingredient?.name" :href="searchlink" target="_blank">
(search?)
</a>
<a v-else>
(product)
</a>
</span>
</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const missingProduct = new URL('@/assets/missing-product.svg', import.meta.url).toString()
const externalLink = new URL('@/assets/external-link.svg', import.meta.url).toString()
<script>
const props = defineProps({
ingredient: { type: Object, required: true },
})
export default {
name: 'CompactParsedIngredient',
props: ['ingredient'],
computed: {
searchlink() {
return 'https://www.woolworths.com.au/shop/search/products?searchTerm=' + encodeURIComponent(this.ingredient.name);
}
}
}
const searchlink = computed(() =>
props.ingredient?.name
? 'https://www.woolworths.com.au/shop/search/products?searchTerm=' +
encodeURIComponent(props.ingredient.name)
: ''
)
</script>
<style scoped>
.compact-parse-results {
flex: left;
display: flex;
justify-content: left;
text-align: left;
flex: left;
display: flex;
justify-content: left;
text-align: left;
}
.parse-element {
margin: auto 0;
margin-right: 1em;
border-bottom: solid 1px #ccc;
font-weight: bold;
margin: auto 0;
margin-right: 1em;
border-bottom: solid 1px #ccc;
font-weight: bold;
}
.parse-element.missing {
@ -93,21 +58,20 @@ const searchlink = computed(() =>
border: solid red 1px;
}
.parse-element.teaser-image,
.parse-element.helper {
border: none;
font-weight: normal;
.parse-element.teaser-image, .parse-element.helper {
border: none;
font-weight: normal;
}
.parse-element.teaser-image img {
padding: 0;
border: none;
width: 2em;
height: 2em;
padding: 0;
border: none;
width: 2em;
height: 2em;
}
.ingredient-details {
flex: 1;
flex: 1;
}
.quantity {
@ -125,4 +89,5 @@ const searchlink = computed(() =>
.product-name {
color: purple;
}
</style>
</style>

View file

@ -1,126 +1,107 @@
<template>
<div :class="{ editing: editing }">
<button
v-if="editing"
@click="emit('on-add')"
>
<img
class="icon"
:src="addCart"
> <br>
Add Ingredient
<div :class="{ editing: editing }">
<button v-if="editing" @click="$emit('on-add')">
<img class="icon" :src="require('@/assets/add-cart.svg')" /> <br />
Add Ingredient
</button>
<button
v-if="!editOnly"
@click="toggleEditing"
>
<span v-if="editing">
<img
class="icon"
:src="editOff"
> <br>
Done Editing
</span>
<span v-else>
<img
class="icon"
:src="editOn"
> <br>
Edit My List
</span>
<button @click="toggleEditing" v-if="!editOnly">
<span v-if="editing">
<img class="icon" :src="require('@/assets/edit-off.svg')" /> <br />
Done Editing
</span>
<span v-else>
<img class="icon" :src="require('@/assets/edit.svg')" /> <br />
Edit My List
</span>
</button>
<ul>
<li
v-for="ingredient in ingredients"
:key="ingredient"
>
<div v-if="editing">
<p class="ingredient-line">
<ingredient-line
:ingredient="ingredient"
@update-ingredient="updateIngredient"
@update-product-link="updateProduct"
/>
</p>
<button @click="emit('on-delete', ingredient)">
<img
class="icon"
:src="trash"
>
</button>
</div>
<div v-else>
<compact-parsed-ingredient :ingredient="ingredient" />
</div>
</li>
<li v-for="ingredient in ingredients" :key="ingredient">
<div v-if="editing">
<p class="ingredient-line">
<ingredient-line
:ingredient="ingredient"
@update-ingredient="updateIngredient"
@update-product-link="updateProduct" />
</p>
<button @click="$emit('on-delete', ingredient)">
<img class="icon" :src="require('@/assets/trash.svg')" />
</button>
</div>
<div v-else>
<compact-parsed-ingredient :ingredient="ingredient" />
</div>
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { parseProduct, parseIngredients } from '@/api/sdk'
import IngredientLine from './IngredientLine.vue'
import CompactParsedIngredient from './CompactParsedIngredient.vue'
const addCart = new URL('@/assets/add-cart.svg', import.meta.url).toString()
const editOff = new URL('@/assets/edit-off.svg', import.meta.url).toString()
const editOn = new URL('@/assets/edit.svg', import.meta.url).toString()
const trash = new URL('@/assets/trash.svg', import.meta.url).toString()
const props = defineProps({
ingredients: { type: Array, required: true },
editOnly: { type: Boolean, default: false },
})
const emit = defineEmits(['on-add', 'on-delete', 'on-update-ingredient', 'on-editing'])
const editing = ref(props.editOnly ?? false)
async function updateProduct(ingredient, product_link) {
const product = await parseProduct(ingredient, product_link)
emit('on-update-ingredient', ingredient, { ...ingredient, product })
}
async function updateIngredient(ingredient, line) {
const newIngredients = await parseIngredients([line])
emit('on-update-ingredient', ingredient, newIngredients[0])
}
function toggleEditing() {
editing.value = !editing.value
emit('on-editing', editing.value)
}
</script>
<style scoped>
.icon {
width: 2em;
height: 2em;
width: 2em;
height: 2em;
}
ul {
padding: 0;
padding: 0;
}
li {
list-style: none;
list-style: none;
}
li > div {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
}
.editing li {
border: 1px solid #ccc;
border-radius: 5px;
padding: 5px;
border: 1px solid #ccc;
border-radius: 5px;
padding: 5px;
}
.ingredient-line {
flex: 1;
margin: 0;
margin-right: 1em;
flex: 1;
margin: 0;
margin-right: 1em;
}
</style>
<script>
import data from '@/data.js'
import IngredientLine from './IngredientLine.vue'
import CompactParsedIngredient from './CompactParsedIngredient.vue'
export default {
name: 'EditableIngredientsPanel',
components: { IngredientLine, CompactParsedIngredient },
props: ['ingredients', 'editOnly'],
data() {
return {
editing: this.editOnly ?? false
}
},
methods: {
async updateProduct(ingredient, product_link) {
const product = await data.parseProduct(ingredient, product_link);
this.$emit('on-update-ingredient', ingredient, { ...ingredient, product });
},
async updateIngredient(ingredient, line) {
const newIngredients = await data.parseIngredients([line]);
this.$emit('on-update-ingredient', ingredient, newIngredients[0]);
},
toggleEditing() {
this.editing = !this.editing;
this.$emit('on-editing', this.editing);
}
}
}
</script>

View file

@ -1,76 +1,68 @@
<template>
<div class="ingredient-item">
<p>
<input
v-model="ingredientText"
placeholder="Enter an ingredient"
@keyup.enter="updateIngredient"
@blur="updateIngredient"
>
<input
v-if="ingredient.line"
v-model="productLink"
class="product-link-input"
placeholder="Enter product link"
@keyup.enter="updateProductLink"
@blur="updateProductLink"
>
</p>
<p v-if="ingredient.line">
<!-- Single line parse results -->
<compact-parsed-ingredient :ingredient="ingredient" />
</p>
</div>
<div class="ingredient-item">
<p>
<input v-model="ingredientText" @keyup.enter="updateIngredient" @blur="updateIngredient" placeholder="Enter an ingredient" />
<input v-model="productLink" v-if="ingredient.line" class="product-link-input" placeholder="Enter product link" @keyup.enter="updateProductLink" @blur="updateProductLink" />
</p>
<p v-if="ingredient.line">
<!-- Single line parse results -->
<compact-parsed-ingredient :ingredient="ingredient" />
</p>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import CompactParsedIngredient from './CompactParsedIngredient.vue'
import type { Ingredient } from '@/domain/types'
<script>
import CompactParsedIngredient from './CompactParsedIngredient.vue';
const props = defineProps<{ ingredient: Ingredient }>()
const emit = defineEmits<{
(e: 'update-ingredient', ingredient: Ingredient, newLine: string): void
(e: 'update-product-link', ingredient: Ingredient, link: string): void
}>()
const ingredientText = ref<string>(props.ingredient?.line ?? '')
const productLink = ref<string>(props.ingredient.product?.link ?? '')
watch(
() => props.ingredient,
(newIngredient) => {
ingredientText.value = newIngredient?.line ?? ''
productLink.value = newIngredient?.product?.link ?? ''
},
{ deep: true }
)
function updateIngredient() {
if (ingredientText.value != props.ingredient.line) {
emit('update-ingredient', props.ingredient, ingredientText.value)
}
}
function updateProductLink() {
if (productLink.value && productLink.value != props.ingredient.product?.link) {
emit('update-product-link', props.ingredient, productLink.value)
}
}
export default {
props: {
ingredient: { type: Object },
},
events: ['update-ingredient', 'update-product-link'],
components: { CompactParsedIngredient },
data() {
return {
ingredientText: this.ingredient?.line ?? "",
productLink: this.ingredient.product?.link ?? "",
};
},
watch: {
ingredient: {
handler: function (newIngredient) {
this.ingredientText = newIngredient?.line ?? "";
this.productLink = newIngredient.product?.link ?? "";
},
deep: true,
},
},
methods: {
updateIngredient() {
if (this.ingredientText != this.ingredient.line)
this.$emit('update-ingredient', this.ingredient, this.ingredientText);
},
updateProductLink() {
if (this.productLink && this.productLink != this.ingredient.product?.link)
this.$emit('update-product-link', this.ingredient, this.productLink);
}
}
};
</script>
<style scoped>
input {
border: 0;
font-size: larger;
border-bottom: 1px solid #ccc;
border-left: 1px solid #ccc;
width: 100%;
padding: 0.5vh;
border: 0;
font-size: larger;
border-bottom: 1px solid #ccc;
border-left: 1px solid #ccc;
width: 100%;
padding: 0.5vh;
}
.product-link-input {
color: #777;
margin-top: 0.5vh;
color: #777;
margin-top: 0.5vh;
}
</style>

View file

@ -1,111 +1,121 @@
<template>
<div class="date-picker">
<input
v-model="selectedDate"
type="text"
placeholder="Select a date"
@focus="showDatePicker = true"
@blur="showDatePicker = false"
>
<div
v-if="showDatePicker"
class="date-picker-dropdown"
>
<ul>
<li
v-for="(day, index) in days"
:key="index"
@mousedown="selectDate(day)"
>
{{ formatDay(day) }}
</li>
</ul>
<div class="date-picker">
<input
type="text"
v-model="selectedDate"
@focus="showDatePicker = true"
@blur="showDatePicker = false"
placeholder="Select a date"
/>
<div v-if="showDatePicker" class="date-picker-dropdown">
<ul>
<li
v-for="(day, index) in days"
:key="index"
@mousedown="selectDate(day)">
{{ formatDay(day) }}
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
const props = defineProps<{ date?: Date }>()
const emit = defineEmits<{ (e: 'date-selected', date: Date): void }>()
function formatDay(date: Date): string {
const options: Intl.DateTimeFormatOptions = { weekday: 'long', day: 'numeric', month: 'numeric' }
return date.toLocaleDateString('en-AU', options)
}
const initialDate = props.date ?? new Date()
const selectedDate = ref<string>(formatDay(initialDate))
const showDatePicker = ref(false)
watch(
() => props.date,
(newDate) => {
if (newDate) selectedDate.value = formatDay(newDate)
</template>
<script>
export default {
props: {
date: {
type: Date,
default: new Date(),
},
},
data() {
return {
selectedDate: this.formatDay(this.date),
showDatePicker: false,
};
},
watch: {
date(newDate) {
this.selectedDate = this.formatDay(newDate);
},
},
computed: {
days() {
const today = new Date();
const days = [];
for (let i = 0; i < 15; i++) {
const date = new Date(today);
date.setDate(today.getDate() + i);
days.push(date);
}
return days;
},
},
methods: {
formatDay(date) {
const options = { weekday: "long", day: "numeric", month: "numeric" };
return date.toLocaleDateString("en-AU", options);
},
selectDate(date) {
this.selectedDate = this.formatSelectedDate(date);
this.showDatePicker = false;
// Emit custom event
this.$emit("date-selected", date);
},
formatSelectedDate(date) {
const options = { weekday: "long", day: "numeric", month: "numeric" };
return date.toLocaleDateString("en-AU", options);
},
},
};
</script>
<style scoped>
.date-picker {
position: relative;
}
)
const days = computed<Date[]>(() => {
const today = new Date()
const result: Date[] = []
for (let i = 0; i < 15; i++) {
const d = new Date(today)
d.setDate(today.getDate() + i)
result.push(d)
.date-picker input {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
outline: none;
}
.date-picker-dropdown {
position: absolute;
top: 100%;
width: 100%;
margin: auto;
background-color: #fff;
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.date-picker-dropdown ul {
list-style-type: none;
margin: 0;
padding: 0;
}
.date-picker-dropdown li {
padding: 8px;
cursor: pointer;
border-bottom: 1px solid #ccc;
}
return result
})
function selectDate(date: Date) {
selectedDate.value = formatDay(date)
showDatePicker.value = false
emit('date-selected', date)
}
</script>
.date-picker-dropdown li:hover {
background-color: #eee;
}
<style scoped>
.date-picker {
position: relative;
}
.date-picker input {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
outline: none;
}
.date-picker-dropdown {
position: absolute;
top: 100%;
width: 100%;
margin: auto;
background-color: #fff;
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.date-picker-dropdown ul {
list-style-type: none;
margin: 0;
padding: 0;
}
.date-picker-dropdown li {
padding: 8px;
cursor: pointer;
border-bottom: 1px solid #ccc;
}
.date-picker-dropdown li:hover {
background-color: #eee;
}
.date-picker-dropdown li:last-child {
border-bottom: none;
}
</style>
.date-picker-dropdown li:last-child {
border-bottom: none;
}
</style>

View file

@ -1,279 +1,191 @@
<template>
<div class="container">
<div class="fields">
<date-picker
:date="meal.suggestedDate ?? new Date()"
@date-selected="selectDate"
/>
<div class="persons-list">
Cooked by
<person-list
:people="meal.chefs"
@remove-person="(p) => removePerson('chefs', p)"
@add-person="(p) => addPerson('chefs', p)"
/>
for
<person-list
:people="meal.consumers"
@remove-person="(p) => removePerson('consumers', p)"
@add-person="(p) => addPerson('consumers', p)"
/>, with
<person-list
:people="meal.cleanup"
@remove-person="(p) => removePerson('cleanup', p)"
@add-person="(p) => addPerson('cleanup', p)"
/>
on cleanup.
</div>
</div>
<div class="recipes">
<h2>Recipes</h2>
<ul v-if="meal.recipes.length">
<li
v-for="mealRecipe in meal.recipes"
:key="mealRecipe.recipe?.id ?? mealRecipe.recipeId"
>
<div
v-if="mealRecipe.recipe"
class="saved-recipe"
>
<p class="recipe-card">
<recipe-card :recipe="mealRecipe.recipe" />
</p>
<div class="container">
<div class="fields">
<date-picker @date-selected="selectDate" :date="meal.suggested_date" />
<div class="persons-list">
Cooked by <person-list :people="meal.chefs" @remove-person="(p) => removePerson('chefs', p)" @add-person="(p) => addPerson('chefs', p)" />
for <person-list :people="meal.consumers" @remove-person="(p) => removePerson('consumers', p)" @add-person="(p) => addPerson('consumers', p)" />,
with <person-list :people="meal.cleanup" @remove-person="(p) => removePerson('cleanup', p)" @add-person="(p) => addPerson('cleanup', p)" /> on cleanup.
</div>
</div>
<div class="recipes">
<h2>Recipes</h2>
<ul v-if="meal.recipes && meal.recipes.length">
<li v-for="mealRecipe in meal.recipes" :key="mealRecipe.recipe.id">
<div class="saved-recipe">
<p class="recipe-card">
<recipe-card :recipe="mealRecipe.recipe" />
</p>
<p class="servings">
<input
v-model="mealRecipe.servings"
type="number"
min="1"
>
<small><em>servings</em></small>
</p>
<input
type="checkbox"
class="show-ingredient-checkbox"
:checked="showIngredient(mealRecipe)"
>
<label
for="show-ingredients"
@click="showIngredient(mealRecipe, !showIngredient(mealRecipe))"
>
<img
class="icon"
:src="showIngredientsIcon"
>
</label>
<button
class="icon-button"
@click="removeRecipe(mealRecipe)"
>
<img
class="icon"
:src="trash"
>
</button>
</div>
<div v-if="showIngredient(mealRecipe)">
<ul>
<li
v-for="ingredient in scaleIngredients(mealRecipe)"
:key="ingredient.id"
class="saved-ingredient"
>
<CompactParsedIngredient :ingredient="ingredient" />
</li>
<p class="servings">
<input type="number" v-model="mealRecipe.servings" min="1" />
<small><em>servings</em></small>
</p>
<input type="checkbox" class="show-ingredient-checkbox" :checked="showIngredient(mealRecipe)" />
<label for="show-ingredients" @click="showIngredient(mealRecipe, !showIngredient(mealRecipe))">
<img class="icon" :src="require('@/assets/show-ingredients.svg')" />
</label>
<button class="icon-button" @click="removeRecipe(mealRecipe)">
<img class="icon" :src="require('@/assets/trash.svg')" />
</button>
</div>
<div v-if="showIngredient(mealRecipe)">
<ul>
<li v-for="ingredient in scaleIngredients(mealRecipe)" :key="ingredient.id" class="saved-ingredient">
<CompactParsedIngredient :ingredient="ingredient" />
</li>
</ul>
</div>
</li>
</ul>
</div>
</li>
</ul>
<div v-else>
<p>Add some recipes using the search box</p>
</div>
<div class="fields">
<recipe-search-box @select-recipe="selectRecipe" />
</div>
<div v-else>
<p>Add some recipes using the search box</p>
</div>
<div class="fields">
<recipe-search-box @select-recipe="selectRecipe" />
</div>
</div>
<div class="ingredients">
<h2>Sides & Additional Ingredients</h2>
<editable-ingredients-panel :ingredients="meal.extra_ingredients" @on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient" @on-editing="onEditAdditionalIngredients" />
</div>
<button @click="saveMeal">Save</button>
<p v-if="meal.purchase_date"><em>Purchased {{ ago(meal.purchase_date) }}</em></p>
</div>
<div class="ingredients">
<h2>Sides & Additional Ingredients</h2>
<editable-ingredients-panel
:ingredients="meal.extraIngredients"
@on-add="addIngredient"
@on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient"
@on-editing="onEditAdditionalIngredients"
/>
</div>
<button @click="onSaveMeal">
Save
</button>
<p v-if="meal.purchaseDate">
<em>Purchased {{ ago(meal.purchaseDate) }}</em>
</p>
</div>
</template>
<script setup lang="ts">
import { reactive, onBeforeMount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getMeal, saveMeal, getRecipe } from '@/api/sdk'
import { toMealInput } from '@/domain/decoders'
import { currentUser } from '@/api/auth'
import { useAlert } from '@/composables/useAlert'
import { parseRouteId } from '@/router/helpers'
import type { Person, Ingredient, Meal, MealRecipe } from '@/domain/types'
<script>
import { ago } from '@/dateformats'
import data from '@/data.js';
import alert from '@/alert.js';
import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue'
import RecipeCard from '@/components/recipes/RecipeCard.vue'
import DatePicker from './DatePicker.vue'
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue'
import PersonList from './PersonList.vue'
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue'
const showIngredientsIcon = new URL('@/assets/show-ingredients.svg', import.meta.url).toString()
const trash = new URL('@/assets/trash.svg', import.meta.url).toString()
import { ago } from '@/dateformats.js';
function addPersonIfNotExists(list: Person[], person: Person | null | undefined) {
if (!person) return
if (!list.find((p) => p.id === person.id)) {
list.push(person)
import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue';
import RecipeCard from '@/components/recipes/RecipeCard.vue';
import DatePicker from './DatePicker.vue';
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue';
import PersonList from './PersonList.vue';
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue';
function addPersonIfNotExists(list, person) {
if (!list.find(p => p.id === person.id)) {
list.push(person);
}
}
const route = useRoute()
const router = useRouter()
const { show: showAlert } = useAlert()
type PeopleKey = 'chefs' | 'consumers' | 'cleanup'
const meal = reactive<Meal>({
id: -1,
suggestedDate: new Date(),
consumedDate: null,
purchaseDate: null,
recipes: [],
extraIngredients: [],
chefs: [],
consumers: [],
cleanup: [],
})
onBeforeMount(async () => {
const id = parseRouteId(route.params.id)
if (id !== null) {
const loaded = await getMeal(id)
Object.assign(meal, loaded)
} else {
const self = await currentUser()
if (self) {
meal.chefs = [self]
meal.consumers = [self]
meal.cleanup = [self]
}
}
})
function selectDate(date: Date) {
meal.suggestedDate = date
}
function removeRecipe(mealRecipe: MealRecipe) {
if (
confirm(
`Are you sure you want to remove ${mealRecipe.servings} servings of ${mealRecipe.recipe?.name ?? 'this recipe'} from this meal?`
)
) {
meal.recipes = meal.recipes.filter((r) => r !== mealRecipe)
}
}
function addIngredient() {
meal.extraIngredients = [{ id: -1, name: '', line: '', unit: 'Items', quantity: 0, preparation: '', productId: null, recipeId: null, mealId: null, product: null }, ...meal.extraIngredients]
}
function deleteIngredient(ingredient: Ingredient) {
meal.extraIngredients = meal.extraIngredients.filter((i) => i !== ingredient)
}
function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) {
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? newIngredient : i))
}
function removePerson(list: PeopleKey, person: Person) {
meal[list] = meal[list].filter((p) => p.id !== person.id)
}
function addPerson(list: PeopleKey, person: Person) {
addPersonIfNotExists(meal[list], person)
}
async function selectRecipe(recipe: { id: number | string }) {
// Refetch to get additional details
const r = await getRecipe(recipe.id)
if (r.createdBy) {
addPersonIfNotExists(meal.chefs, r.createdBy)
addPersonIfNotExists(meal.consumers, r.createdBy)
if (meal.cleanup.length === 0) {
addPersonIfNotExists(meal.cleanup, r.createdBy)
}
}
meal.recipes.push({ recipe: r, recipeId: r.id, mealId: meal.id, servings: r.serves })
}
async function onEditAdditionalIngredients(editing: boolean) {
if (editing && meal.extraIngredients.length === 0) {
addIngredient()
} else {
meal.extraIngredients = meal.extraIngredients.filter((i) => !!i.line)
}
}
const showMap = reactive<Record<string, boolean>>({})
function showIngredient(mealRecipe: MealRecipe, value?: boolean): boolean {
const index = meal.recipes.indexOf(mealRecipe)
const key = `${mealRecipe.recipe?.id ?? 'unknown'}-${index}`
if (value === undefined) {
return !!showMap[key]
}
showMap[key] = value
return value
}
function scaleIngredients(mealRecipe: MealRecipe) {
const ing = mealRecipe.recipe?.ingredients ?? []
const serves = mealRecipe.recipe?.serves ?? 1
return ing.map((i) => {
export default {
props: ['id'],
components: { RecipeSearchBox, DatePicker, RecipeCard, EditableIngredientsPanel, PersonList, CompactParsedIngredient },
data() {
return {
...i,
quantity: (i.quantity * mealRecipe.servings) / serves,
showIngredients: {},
meal: {
id: -1,
suggested_date: new Date(),
recipes: [],
extra_ingredients: [],
chefs: [],
consumers: [],
cleanup: []
}
};
},
async beforeMount() {
if (this.id >= 0) {
this.meal = await data.getMeal(this.id);
}
})
}
else {
const self = await data.currentUser();
this.meal = {...this.meal, chefs: [self], consumers: [self], cleanup: [self], };
}
},
methods: {
ago,
async selectRecipe(recipe) {
// Refetch to get additional details
recipe = await data.getRecipe(recipe.id);
async function onSaveMeal() {
const saved = await saveMeal(toMealInput(meal))
if (saved && saved.id >= 0) {
Object.assign(meal, saved)
router.push(`/meals/${saved.id}`)
showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' })
return
if (recipe.created_by) {
addPersonIfNotExists(this.meal.chefs, recipe.created_by);
addPersonIfNotExists(this.meal.consumers, recipe.created_by);
if (this.meal.cleanup.length === 0) {
addPersonIfNotExists(this.meal.cleanup, recipe.created_by);
}
}
this.meal.recipes.push({ recipe, recipe_id: recipe.id, meal_id: this.meal.id, servings: recipe.serves });
},
selectDate(date) {
this.meal.suggested_date = date;
},
removeRecipe(mealRecipe) {
if (confirm(`Are you sure you want to remove ${mealRecipe.servings} servings of ${mealRecipe.recipe.name} from this meal?`)) {
this.meal.recipes = this.meal.recipes.filter(r => r != mealRecipe);
}
},
addIngredient() {
this.meal.extra_ingredients = [{ line: '', product: null }, ...this.meal.extra_ingredients];
},
deleteIngredient(ingredient) {
this.meal.extra_ingredients = this.meal.extra_ingredients.filter(i => i != ingredient);
},
updateIngredient(ingredient, newIngredient) {
this.meal.extra_ingredients = this.meal.extra_ingredients.map(i => i == ingredient ? newIngredient : i);
},
removePerson(list, person) {
this.meal[list] = this.meal[list].filter(p => p.id !== person.id);
},
addPerson(list, person) {
addPersonIfNotExists(this.meal[list], person);
},
async saveMeal() {
const meal = await data.saveMeal(this.meal);
if (meal?.id >= 0) {
this.meal = meal;
this.$router.push(`/meals/${meal.id}`);
alert.show({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' });
return;
}
alert.show({ heading: 'Error saving meal', message: 'An error occurred while saving the meal', type: 'error' });
},
onEditAdditionalIngredients(editing) {
if (editing && this.meal.extra_ingredients.length === 0) {
this.addIngredient();
}
else {
this.meal.extra_ingredients = this.meal.extra_ingredients.filter(i => i.line);
}
},
showIngredient(mealRecipe, value) {
const index = this.meal.recipes.indexOf(mealRecipe);
const key = `${mealRecipe.recipe.id}-${index}`;
if (value === undefined) {
return this.showIngredients[key];
}
return this.showIngredients[key] = value;
},
scaleIngredients(mealRecipe) {
return mealRecipe.recipe.ingredients.map(i => {
return {
...i,
quantity: i.quantity * mealRecipe.servings / mealRecipe.recipe.serves
};
});
},
}
showAlert({
heading: 'Error saving meal',
message: 'An error occurred while saving the meal',
type: 'error',
})
}
</script>
<style scoped>
img.icon {
width: 2em;
height: 2em;
@ -326,6 +238,7 @@ li {
margin-right: 1em;
}
.saved-recipe button:hover {
background: #eee;
}
@ -369,18 +282,19 @@ li {
display: none;
}
.show-ingredient-checkbox+label {
.show-ingredient-checkbox + label {
cursor: pointer;
background-color: #fff;
padding: 0.5em;
border-radius: 1em;
}
.show-ingredient-checkbox+label:hover {
.show-ingredient-checkbox + label:hover {
background-color: #eee;
}
.show-ingredient-checkbox:checked+label {
.show-ingredient-checkbox:checked + label {
filter: invert(1);
}
</style>
</style>

View file

@ -1,90 +1,85 @@
<template>
<div class="meal-card">
<h3>{{ mealTitle }}</h3>
<h4>
{{ dayOfWeek }} <small>{{ date }}</small>
</h4>
<p>
Cooked by
<span
v-for="(chef, index) in meal.chefs"
:key="chef.id"
>
{{ chef.name }}{{ englishSeperator(index, meal.chefs) }}
</span>
<span v-if="!meal.chefs.length">somebody?</span>
</p>
<p>
For
<span
v-for="(consumer, index) in meal.consumers"
:key="consumer.id"
>
{{ consumer.name }}{{ englishSeperator(index, meal.consumers) }}
</span>
<span v-if="!meal.consumers.length">somebody?</span>
</p>
<p v-if="meal.purchaseDate">
Purchased {{ ago(meal.purchaseDate) }}
</p>
</div>
<div class="meal-card">
<h3>{{ mealTitle }}</h3>
<h4>{{ dayOfWeek }} <small>{{ date }}</small></h4>
<p>
Cooked by
<span v-for="(chef, index) in meal.chefs" :key="chef.id">
{{ chef.name }}{{ englishSeperator(index, meal.chefs) }}
</span>
<span v-if="!meal.chefs.length">somebody?</span>
</p>
<p>
For
<span v-for="(consumer, index) in meal.consumers" :key="consumer.id">
{{ consumer.name }}{{ englishSeperator(index, meal.consumers) }}
</span>
<span v-if="!meal.consumers.length">somebody?</span>
</p>
<p v-if="meal.purchase_date">
Purchased {{ ago(meal.purchase_date) }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ago } from '@/dateformats'
import type { Meal } from '@/domain/types'
<style>
</style>
const props = defineProps<{ meal: Meal }>()
<script>
function englishSeperator(index: number, list: Array<unknown>) {
switch (index) {
case list.length - 1:
return ''
case list.length - 2:
return ' and '
default:
return ', '
}
import { ago } from '@/dateformats.js'
function englishSeperator(index, list) {
switch (index) {
case list.length - 1:
return '';
case list.length - 2:
return ' and ';
default:
return ', ';
}
}
function englishList(list: string[]) {
switch (list.length) {
case 0:
return ''
case 1:
return list[0]
case 2:
return `${list[0]} and ${list[1]}`
default:
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`
}
function englishList(list) {
switch (list.length) {
case 0:
return '';
case 1:
return list[0];
case 2:
return `${list[0]} and ${list[1]}`;
default:
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`;
}
}
const date = computed(() =>
props.meal.suggestedDate
? props.meal.suggestedDate.toLocaleDateString('en-au', { month: 'numeric', day: 'numeric' })
: ''
)
export default {
name: 'MealCard',
props: ['meal'],
computed: {
date() {
return this.meal.suggested_date.toLocaleDateString('en-au', { month: 'numeric', day: 'numeric' })
},
dayOfWeek() {
return this.meal.suggested_date.toLocaleDateString('en-au', { weekday: 'long' })
},
mealTitle() {
const recipesText = englishList(this.meal.recipes.map(mealRecipe => mealRecipe.recipe.name));
const ingredientsText = englishList(this.meal.extra_ingredients.map(ingredient => ingredient.name));
const dayOfWeek = computed(() =>
props.meal.suggestedDate ? props.meal.suggestedDate.toLocaleDateString('en-au', { weekday: 'long' }) : ''
)
const mealTitle = computed(() => {
const recipes = props.meal.recipes ?? []
const extras = props.meal.extraIngredients ?? []
const recipeNames = recipes
.map((mr) => mr.recipe?.name)
.filter((n): n is string => typeof n === 'string' && n.length > 0)
const recipesText = englishList(recipeNames)
const ingredientsText = englishList(extras.map((i) => i.name))
if (recipesText && ingredientsText) return `${recipesText} with ${ingredientsText}`
if (recipesText || ingredientsText) return recipesText || ingredientsText
return 'Nothing planned'
})
</script>
<style></style>
if (recipesText && ingredientsText) {
return `${recipesText} with ${ingredientsText}`;
} else if (recipesText || ingredientsText) {
return recipesText || ingredientsText;
} else {
return 'Nothing planned';
}
}
},
methods: {
englishSeperator,
ago
}
}
</script>

View file

@ -1,143 +1,118 @@
<template>
<div>
<ul
v-if="meals.length"
class="meals-list"
>
<li
v-for="meal in meals"
:key="meal.id"
>
<meal-card :meal="meal" />
<button
class="toggle-actions"
@click="selectedMeal = meal == selectedMeal ? null : meal"
>
<img :src="meal == selectedMeal ? chevronDown : chevronUp">
</button>
<ul
v-if="selectedMeal == meal"
class="actions"
>
<li>
<router-link
class="nav-link"
:to="`/meals/${selectedMeal.id}`"
active-class="active"
>
Edit Meal
</router-link>
</li>
<li><a @click="markConsumed">Mark Consumed</a></li>
<li>
<a
class="button"
@click="deleteSelectedMeal"
>Remove</a>
</li>
<div>
<ul class="meals-list" v-if="meals.length">
<li v-for="meal in meals" :key="meal.id">
<meal-card :meal="meal"/>
<button class="toggle-actions" @click="selectedMeal = ((meal == selectedMeal) ? null : meal)">
<img :src="meal == selectedMeal ? require('@/assets/chevron-down.svg') : require('@/assets/chevron-up.svg')" />
</button>
<ul class="actions" v-if="selectedMeal == meal">
<li><router-link class="nav-link" :to="`/meals/${selectedMeal.id}`" active-class="active">Edit Meal</router-link></li>
<li><a @click="markConsumed">Mark Consumed</a></li>
<li><a @click="deleteSelectedMeal" class="button">Remove</a></li>
</ul>
</li>
</ul>
</li>
</ul>
<div v-if="!meals.length">
<em>No meals planned</em>
<div v-if="!meals.length">
<em>No meals planned</em>
</div>
<action-item title="Plan Meal" :image="require('@/assets/plan-meal.svg')" @click="() => this.$router.push('/meals/add')" />
</div>
<action-item
title="Plan Meal"
:image="planMeal"
@click="() => $router.push('/meals/add')"
/>
</div>
</template>
<script setup>
import { ref, onBeforeMount } from 'vue'
import ActionItem from '@/components/ActionItem.vue'
import MealCard from '@/components/meals/MealCard.vue'
import { getUpcomingMeals, markMealConsumed, deleteMeal } from '@/api/sdk'
const chevronDown = new URL('@/assets/chevron-down.svg', import.meta.url).toString()
const chevronUp = new URL('@/assets/chevron-up.svg', import.meta.url).toString()
const planMeal = new URL('@/assets/plan-meal.svg', import.meta.url).toString()
const from = new Date()
from.setTime(0)
const to = new Date()
to.setDate(to.getDate() + 7)
const meals = ref([])
const selectedMeal = ref(null)
onBeforeMount(async () => {
meals.value = await getUpcomingMeals(from, to)
})
async function deleteSelectedMeal() {
if (!selectedMeal.value) return
await deleteMeal(selectedMeal.value.id)
meals.value = meals.value.filter((m) => m.id !== selectedMeal.value.id)
selectedMeal.value = null
}
async function markConsumed() {
if (!selectedMeal.value) return
await markMealConsumed(selectedMeal.value.id)
meals.value = meals.value.filter((m) => m.id !== selectedMeal.value.id)
}
</script>
<style scoped>
li {
list-style-type: none;
list-style-type: none;
}
ul.meals-list {
padding: 0;
padding: 0;
}
.toggle-actions {
cursor: pointer;
background-color: #fff;
border: none;
border-bottom: solid 1px #ccc;
padding: 0 2em;
margin: 0;
cursor: pointer;
background-color: #fff;
border: none;
border-bottom: solid 1px #ccc;
padding: 0 2em;
margin: 0;
}
.toggle-actions img {
width: 2em;
height: 2em;
width: 2em;
height: 2em;
}
.meals-list > li {
margin-bottom: 1em;
padding: 0;
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
margin-bottom: 1em;
padding: 0;
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
}
ul.actions {
margin: 0;
padding: 0;
margin: 0;
padding: 0;
}
.actions li {
display: block;
border: 1px solid #ccc;
display: block;
border: 1px solid #ccc;
}
.actions li:hover {
background-color: #ccc;
background-color: #ccc;
}
.actions li a {
display: block;
text-decoration: none;
color: #000;
width: 100%;
cursor: pointer;
padding-top: 2ex;
padding-bottom: 2ex;
display: block;
text-decoration: none;
color: #000;
width: 100%;
cursor: pointer;
padding-top: 2ex;
padding-bottom: 2ex;
}
</style>
<script>
import ActionItem from '@/components/ActionItem.vue'
import MealCard from '@/components/meals/MealCard.vue'
import data from '@/data.js'
export default {
name: 'MealPlanPage',
components: { MealCard, ActionItem },
data() {
const from = new Date();
from.setTime(0);
const to = new Date();
to.setDate(to.getDate() + 7);
return {
from, to,
meals: [],
selectedMeal: null
}
},
async beforeMount() {
const meals = await data.getUpcomingMeals(this.from, this.to)
this.meals = meals;
},
methods: {
async deleteSelectedMeal() {
await data.deleteMeal(this.selectedMeal.id);
this.meals = this.meals.filter(m => m.id !== this.selectedMeal.id);
this.selectedMeal = null;
},
async markConsumed() {
await data.markMealConsumed(this.selectedMeal.id);
this.meals = this.meals.filter(m => m.id !== this.selectedMeal.id);
}
}
}
</script>

View file

@ -1,204 +1,171 @@
<template>
<span class="person-list">
<span
v-for="person in people"
:key="person.id"
>
<button
class="person-circle remove-person"
@click="removePerson(person)"
>
{{ person.name }}
</button>
<span class="person-list">
<span v-for="person in people" :key="person.id">
<button class="person-circle remove-person" @click="$emit('remove-person', person)" >{{ person.name }}</button>
</span>
<span>
<button
v-if="!isAddingPerson"
class="person-circle add-person"
@click="isAddingPerson = true"
>
+
</button>
<input
v-else
ref="searchNameInput"
v-model="searchName"
@keyup.enter="addPerson"
@keyup.esc="isAddingPerson = false"
@blur="isAddingPerson = false"
>
<ul
v-if="isAddingPerson && searchResults.length"
ref="persondroplist"
class="person-droplist"
>
<li
v-for="person in searchResults"
:key="person.id"
>
<button
class="person-circle add-person"
@mousedown="addPerson(person)"
>
{{ person.name }}
</button>
</li>
</ul>
<button v-if="!isAddingPerson" class="person-circle add-person" @click="isAddingPerson = true">+</button>
<input v-else v-model="searchName" ref="searchNameInput" @keyup.enter="addPerson" @keyup.esc="isAddingPerson = false" @blur="isAddingPerson = false" />
<ul class="person-droplist" ref="persondroplist" v-if="isAddingPerson && searchResults.length">
<li v-for="person in searchResults" :key="person.id">
<button class="person-circle add-person" @mousedown="addPerson(person)">{{ person.name }}</button>
</li>
</ul>
</span>
</span>
</span>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { searchPersons } from '@/api/sdk'
import type { Person } from '@/domain/types'
const props = withDefaults(defineProps<{ people?: Person[] }>(), { people: () => [] })
const emit = defineEmits<{
(e: 'add-person', person: Person): void
(e: 'remove-person', person: Person): void
}>()
const isAddingPerson = ref(false)
const searchName = ref('')
const searchResults = ref<Person[]>([])
// Template refs for DOM elements
const searchNameInput = ref<HTMLInputElement | null>(null)
const persondroplist = ref<HTMLUListElement | null>(null)
async function updateSearchResults() {
const q = searchName.value.trim()
if (!q) {
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))
searchResults.value = results.filter((p: Person) => !idSet.has(p.id))
}
function addPerson(person?: Person) {
if (!person && searchResults.value.length > 0) {
person = searchResults.value[0]
}
if (!person) {
searchName.value = ''
searchResults.value = []
isAddingPerson.value = false
return
}
if (!props.people.find((p) => p.id === person.id)) {
emit('add-person', person)
}
searchName.value = ''
searchResults.value = []
isAddingPerson.value = false
}
function removePerson(person: Person) {
emit('remove-person', person)
}
// Watchers
watch(searchName, async () => {
await updateSearchResults()
})
watch(searchNameInput, async (el) => {
if (el) {
el.focus()
await updateSearchResults()
}
})
watch([persondroplist, searchNameInput], ([drop, input]) => {
if (drop && input) {
const inputRect = input.getBoundingClientRect()
drop.style.left = `${inputRect.left}px`
drop.style.top = `${inputRect.bottom}px`
drop.style.width = `${inputRect.width}px`
}
})
</script>
<style scoped>
.person-list {
display: inline-block;
text-align: center;
min-height: 50px;
padding: 0;
margin: 0;
display: inline-block;
text-align: center;
min-height: 50px;
padding: 0;
margin: 0;
}
/* Remove all the button styling */
.person-circle {
background: none;
border: none;
padding: 0;
margin: 0;
background: none;
border: none;
padding: 0;
margin: 0;
}
/* Show the initials of the person in a circle */
.person-circle {
display: inline-block;
width: 50px;
height: 50px;
line-height: 50px;
text-align: center;
border-radius: 50%;
background: #eee;
display: inline-block;
width: 50px;
height: 50px;
line-height: 50px;
text-align: center;
border-radius: 50%;
background: #eee;
}
/* On hover, prompt the user to click to remove the person */
/* change opacity of circle, and use css to add a large cross over the circle */
.remove-person:hover {
cursor: pointer;
position: relative;
background-color: lightcoral;
cursor: pointer;
position: relative;
background-color: lightcoral;
}
/* Add a cross to the circle */
.remove-person:hover::before,
.remove-person:hover::after {
pointer-events: none;
content: 'X';
color: white;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
.remove-person:hover::before, .remove-person:hover::after {
pointer-events: none;
content: "X";
color: white;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.add-person {
background-color: lightgreen;
background-color: lightgreen;
}
.add-person:hover {
cursor: pointer;
font-weight: bolder;
color: white;
background-color: green;
cursor: pointer;
font-weight: bolder;
color: white;
background-color: green;
}
.person-droplist {
position: absolute;
background-color: white;
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
list-style-type: none;
padding: 0;
margin: 0;
max-height: 40vh;
overflow-y: scroll;
z-index: 1000;
position: absolute;
background-color: white;
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
list-style-type: none;
padding: 0;
margin: 0;
max-height: 40vh;
overflow-y: scroll;
z-index: 1000;
}
.person-droplist li {
padding: 8px;
cursor: pointer;
display: inline;
padding: 1ex 1em;
padding: 8px;
cursor: pointer;
display: inline;
padding: 1ex 1em;
}
</style>
<script>
import { ref } from 'vue';
import data from '@/data.js'
export default {
name: 'PersonList',
props: {
people: {
type: Array,
default: () => []
}
},
data() {
return {
isAddingPerson: false,
searchName: '',
searchResults: [],
}
},
setup() {
const searchNameInput = ref(null);
const persondroplist = ref(null);
return { searchNameInput, persondroplist };
},
watch: {
searchName: async function() {
await this.updateSearchResults()
},
searchNameInput: async function() {
this.searchNameInput?.focus();
await this.updateSearchResults()
},
persondroplist: function() {
if (this.persondroplist && this.searchNameInput)
{
// Align the droplist to the input field & its size
const inputRect = this.searchNameInput.getBoundingClientRect();
this.persondroplist.style.left = `${inputRect.left}px`;
this.persondroplist.style.top = `${inputRect.bottom}px`;
this.persondroplist.style.width = `${inputRect.width}px`;
}
}
},
methods: {
async updateSearchResults() {
const results = await data.searchPerson(this.searchName);
// Exclude people already in the list
const idSet = new Set(this.people.map(p => p.id));
this.searchResults = results.filter(p => !idSet.has(p.id));
},
addPerson(person) {
if (!person && this.searchResults.length > 0)
{
person = this.searchResults[0];
}
if (person?.id >= 0 && !this.people.find(p => p.id === person.id))
{
this.$emit('add-person', person);
}
this.searchName = '';
this.searchResults = [];
this.isAddingPerson = false;
},
removePerson(person) {
this.$emit('remove-person', person);
},
}
}
</script>

View file

@ -1,239 +1,153 @@
<template>
<div>
<div>
<div v-if="!id && !recipe">
<input
v-model="link"
class="recipe-link"
type="text"
placeholder="Link to Recipe"
> <br>
<button @click="parseLink">
Parse
</button>
<button @click="createFromScratch">
Create from Scratch
</button>
<input class="recipe-link" type="text" v-model="link" placeholder="Link to Recipe" /> <br />
<button @click="parseLink">Parse</button>
<button @click="createFromScratch">Create from Scratch</button>
</div>
<div v-if="parse_failed">
<p>Recipe not found</p>
<p>Recipe not found</p>
</div>
<div v-if="!parse_failed && recipe">
<div
v-if="image_styling"
class="image-container"
:style="image_styling"
/>
<h1>
<input
v-model="recipe.name"
class="recipe-name"
type="text"
>
</h1>
<label for="recipe-serves">Number of serves: </label>
<input
v-model="recipe.serves"
type="number"
>
<h3 class="recipe-link">
<a :href="recipe.link">View Recipe</a>
</h3>
<h2>Ingredients</h2>
<editable-ingredients-panel
:ingredients="recipe.ingredients"
:edit-only="true"
@on-add="addIngredient"
@on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient"
/>
<div class="image-container" v-if="image_styling" :style="image_styling" ></div>
<h1><input class="recipe-name" type="text" v-model="recipe.name" /></h1>
<label for="recipe-serves">Number of serves: </label>
<input type="number" v-model="recipe.serves" />
<h3 class="recipe-link"><a :href="recipe.link">View Recipe</a></h3>
<h2>Ingredients</h2>
<editable-ingredients-panel
:ingredients="recipe.ingredients"
:edit-only="true"
@on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient"
/>
<div>
<button
v-if="recipe.id"
class="delete-btn"
@click="deleteRecipe"
>
Delete
</button>
<button
class="submit-btn"
@click="saveRecipe"
>
{{ recipe.id ? 'Save' : 'Create' }}
</button>
</div>
<div>
<button v-if="recipe.id" class="delete-btn" @click="deleteRecipe">Delete</button>
<button class="submit-btn" @click="saveRecipe">{{ recipe.id ? "Save" : "Create" }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAlert } from '@/composables/useAlert'
import { parseQueryString } from '@/router/helpers'
import { getRecipe, parseRecipe, saveRecipe as saveRecipeApi, deleteRecipe as deleteRecipeApi } from '@/api/sdk'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
import type { Recipe as DomainRecipe, Ingredient, RecipeInput } from '@/domain/types'
const props = defineProps({
id: { type: String, required: false, default: undefined },
})
const router = useRouter()
const route = useRoute()
const { show: showAlert } = useAlert()
const link = ref<string>(parseQueryString(route.query.url))
const parse_failed = ref(false)
const recipe = ref<DomainRecipe | null>(null)
const image_styling = computed(() => {
const urls = recipe.value?.imageUrls ?? []
if (urls.length && urls[0]) {
const image = urls[0]!
return {
background: `linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 100%), url('${image}') center/cover no-repeat`,
}
}
return null
})
function parseLink() {
router.push({ path: '/recipes/add', query: { url: link.value } })
refreshRecipe()
}
async function refreshRecipe() {
const id = props.id ? parseInt(props.id) : null
if (id !== null && id >= 0) {
const r = await getRecipe(id)
recipe.value = r
link.value = r.link ?? ''
return
} else if (link.value) {
const r = await parseRecipe(link.value)
recipe.value = r
parse_failed.value = !r
} else {
recipe.value = null
}
}
async function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) {
if (!recipe.value) return
const list = recipe.value.ingredients ?? []
recipe.value = { ...recipe.value, ingredients: list.map((i) => (i === ingredient ? newIngredient : i)) }
}
function deleteIngredient(ingredient: Ingredient) {
if (!recipe.value) return
const list = recipe.value.ingredients ?? []
recipe.value = { ...recipe.value, ingredients: list.filter((i) => i !== ingredient) }
}
async function saveRecipe() {
const saved = recipe.value ? await saveRecipeApi(toRecipeInput(recipe.value)) : null
if (saved && saved.id >= 0) {
showAlert({ heading: 'Recipe saved', message: 'Your recipe has been saved', type: 'success' })
router.push(`/recipes/${saved.id}`)
return
}
showAlert({ heading: 'Error saving recipe', message: 'There was an error saving your recipe', type: 'error' })
}
function createFromScratch() {
recipe.value = {
id: -1,
name: 'My new recipe',
createdById: -1,
link: '',
ingredients: [],
imageUrls: [],
serves: 1,
dateCreated: new Date(),
dateHidden: null,
}
}
function addIngredient() {
if (!recipe.value) return
const list = recipe.value.ingredients ?? []
const draft: Ingredient = { id: -1, name: '', line: '', unit: 'Items', quantity: 0, preparation: '', productId: null, recipeId: null, mealId: null, product: null }
recipe.value = { ...recipe.value, ingredients: [draft, ...list] }
}
async function deleteRecipe() {
if (confirm('Are you sure you want to delete this recipe?')) {
if (!recipe.value) return
await deleteRecipeApi(recipe.value.id)
router.push('/recipes')
}
}
onMounted(() => {
refreshRecipe()
})
// Keep recipe in sync if link query changes while on page
watch(
() => route.query.url,
(newUrl) => {
const parsed = parseQueryString(newUrl)
if (parsed) {
link.value = parsed
refreshRecipe()
}
}
)
// expose functions for template binding names (automatic in <script setup>)
function toRecipeInput(r: DomainRecipe): RecipeInput {
return {
id: r.id,
name: r.name,
link: r.link,
serves: r.serves,
imageUrls: r.imageUrls ?? [],
ingredients: r.ingredients ?? [],
basedOnRecipe: r.basedOnRecipe ?? null,
// let backend set created/hidden dates
createdById: r.createdById,
createdBy: r.createdBy ?? null,
// dateHidden omitted
hiddenById: r.hiddenById ?? null,
hiddenBy: r.hiddenBy ?? null,
}
}
</script>
<style scoped>
input {
border: 0;
border-bottom: 1px solid #ccc;
font-size: large;
border: 0;
border-bottom: 1px solid #ccc;
font-size: large;
}
input.recipe-link {
width: 80%;
width: 80%;
}
.image-container {
max-height: 20vh;
min-height: 20vh;
display: flex;
flex-direction: column;
max-height: 20vh;
min-height: 20vh;
display: flex;
flex-direction: column;
}
input.recipe-name {
width: 100%;
font-weight: bold;
font-size: larger;
width: 100%;
font-weight: bold;
font-size: larger;
}
.recipe-link {
color: #0000ee;
text-decoration: none;
color: #0000EE;
text-decoration: none;
}
</style>
<script>
import alert from '@/alert.js'
import data from '@/data.js'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
export default {
props: {
id: { type: Number, optional: true }
},
components: { EditableIngredientsPanel },
data() {
return {
link: this.$route.query.url ?? "",
parse_failed: false,
recipe: null,
chefs: [],
}
},
mounted() {
this.refreshRecipe();
},
computed: {
image_styling() {
if (this.recipe?.image_urls && this.recipe.image_urls[0]) {
const image = this.recipe.image_urls[0];
// linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 100%), url('https://i2.wp.com/www.downshiftology.com/wp-content/uploads/2019/04/steamed-broccoli-4.jpg') center/cover no-repeat;
return {background: `linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 100%), url('${image}') center/cover no-repeat` }
}
return null;
}
},
methods: {
parseLink() {
this.$router.push({ path: '/recipes/add', query: { url: this.link }})
this.refreshRecipe();
},
async refreshRecipe() {
if (this.id >= 0) {
this.recipe = await data.getRecipe(this.id);
this.link = this.recipe.link;
return;
}
else if (this.link) {
this.recipe = await data.parseRecipe(this.link);
this.parse_failed = !this.recipe;
}
else {
this.recipe = null;
}
},
async updateIngredient(ingredient, newIngredient) {
this.recipe.ingredients = this.recipe.ingredients.map(i => i == ingredient ? newIngredient : i);
},
deleteIngredient(ingredient) {
this.recipe.ingredients = this.recipe.ingredients.filter(i => i != ingredient);
},
async saveRecipe() {
const recipe = await data.saveRecipe(this.recipe);
if (recipe?.id >= 0) {
alert.show({ heading: 'Recipe saved', message: 'Your recipe has been saved', type: 'success' });
this.$router.push(`/recipes/${recipe.id}`);
return;
}
alert.show({ heading: 'Error saving recipe', message: 'There was an error saving your recipe', type: 'error' });
},
async createFromScratch() {
this.recipe = {
id: -1,
name: 'My new recipe',
created_by_id: -1,
link: '',
ingredients: [],
image_urls: []
}
},
addIngredient() {
this.recipe.ingredients = [{ line: '', product: null }, ...this.recipe.ingredients];
},
async deleteRecipe() {
if (confirm('Are you sure you want to delete this recipe?')) {
await data.deleteRecipe(this.recipe.id);
this.$router.push('/recipes');
}
}
},
}
</script>

View file

@ -1,53 +1,46 @@
<template>
<div class="recipe-card">
<p>
<img
v-if="recipe.imageUrls && recipe.imageUrls.length"
:src="recipe.imageUrls[0] || fallbackEgg"
>
<img
v-else
:src="fallbackEgg"
>
</p>
<p class="recipe-name">
{{ recipe.name }}
</p>
</div>
<div class="recipe-card">
<p>
<img v-if="recipe.image_urls" :src="recipe.image_urls[0]" />
<img v-else src="@/assets/egg.svg" />
</p>
<p class="recipe-name">{{ recipe.name }}</p>
</div>
</template>
<script setup lang="ts">
import type { RecipeOut } from '@/domain/types'
type RecipeCardItem = Pick<RecipeOut, 'id' | 'name' | 'imageUrls'>
defineProps<{ recipe: RecipeCardItem }>()
const fallbackEgg = new URL('@/assets/egg.svg', import.meta.url).toString()
<script>
export default {
name: 'RecipeCard',
props: ['recipe']
}
</script>
<style scoped>
.recipe-card {
display: flex;
flex-direction: row;
justify-content: space-between;
text-align: left;
max-height: 10em;
margin: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
text-align: left;
max-height: 10em;
margin: 0;
}
.recipe-card p {
margin: auto 0;
margin-right: 1em;
margin: auto 0;
margin-right: 1em;
}
li img {
display: block;
width: 3em;
height: 3em;
object-fit: cover;
display: block;
width: 3em;
height: 3em;
object-fit: cover;
}
.recipe-name {
flex: 1;
margin: auto;
flex: 1;
margin: auto;
}
</style>
</style>

View file

@ -1,227 +1,99 @@
<template>
<div
class="recipe-search-box"
@focusout="onFocusOut"
>
<input
v-model="searchTerm"
type="text"
:placeholder="placeholderText"
@keyup.enter="search"
@keyup.esc="clear"
@focusin="search"
>
<ul
v-if="dropdownVisible"
class="dropdown"
>
<template v-if="recipes.length">
<li
v-for="recipe in recipes"
:key="recipe.id"
class="recipe"
@mousedown="selectRecipe(recipe)"
>
<recipe-card :recipe="recipe" />
</li>
<li
v-if="prevCursor || nextCursor"
class="pager"
>
<button
class="pager-btn"
:disabled="!prevCursor"
@mousedown.prevent="loadPrev"
>
Prev
</button>
<button
class="pager-btn"
:disabled="!nextCursor"
@mousedown.prevent="loadNext"
>
Next
</button>
</li>
</template>
<li
v-else
class="empty"
>
No recipes found
</li>
</ul>
</div>
<div class="recipe-search-box" @focusout="recipes = []">
<input type="text" v-model="searchTerm" @keyup.enter="search" @keyup.exit="clear" @focusin="search"
:placeholder="placeholder" />
<ul v-if="recipes?.length" class="dropdown">
<li class="recipe" v-for="recipe in recipes" :key="recipe.id" @mousedown="selectRecipe(recipe)">
<recipe-card :recipe="recipe" />
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onBeforeUnmount, computed } from 'vue'
import { listRecipes } from '@/api/sdk'
import type { Recipe } from '@/domain/types'
import { useAlert } from '@/composables/useAlert'
import { usePagination } from '@/composables/usePagination'
import RecipeCard from './RecipeCard.vue'
<script>
import data from '@/data.js'
import RecipeCard from './RecipeCard.vue';
const props = withDefaults(defineProps<{ placeholder?: string }>(), {
placeholder: 'Add a recipe...',
})
const placeholderText: string = props.placeholder ?? 'Add a recipe...'
type RecipeItem = Pick<Recipe, 'id' | 'name' | 'imageUrls'>
const emit = defineEmits<{
(e: 'select-recipe', recipe: RecipeItem): void
}>()
const searchTerm = ref('')
const pageSize = 10
const { items: recipes, next: nextCursor, prev: prevCursor, load, loadNext: loadNextPage, loadPrev: loadPrevPage, reset } = usePagination<RecipeItem>(
async (params) => {
const page = await listRecipes(params)
return {
items: (page.items ?? []).map(toRecipeItem),
next: page.next ?? null,
prev: page.prev ?? null,
total: page.total ?? null,
export default {
name: 'RecipeSearchBox',
components: { RecipeCard },
props: {
placeholder: { type: String, default: 'Add a recipe...' }
},
data() {
return {
searchTerm: '',
recipes: [],
timeouts: [],
}
},
watch: {
searchTerm() {
const searchTerm = this.searchTerm;
if (searchTerm) {
this.timeouts.push(setTimeout(() => {
if (searchTerm === this.searchTerm) {
this.search();
}
}, 200));
}
}
},
methods: {
async search() {
this.recipes = await data.searchRecipes(this.searchTerm) ?? this.recipes;
},
selectRecipe(recipe) {
this.$emit('select-recipe', recipe);
this.searchTerm = '';
this.recipes = [];
}
}
},
{ pageSize }
)
const dropdownVisible = computed(() => recipes.value.length > 0 || (searchTerm.value.length > 0))
const { show: showAlert, scheduleAutoDismiss } = useAlert()
let debounceId: ReturnType<typeof setTimeout> | null = null
watch(
searchTerm,
(newVal) => {
if (!newVal) {
recipes.value = []
if (debounceId) clearTimeout(debounceId)
return
}
if (debounceId) clearTimeout(debounceId)
debounceId = setTimeout(() => {
if (newVal === searchTerm.value) {
search()
}
}, 200)
}
)
async function search() {
try {
await load({ q: searchTerm.value })
} catch {
// swallow errors and clear suggestions on failures, also notify user
reset()
showAlert({ type: 'error', heading: 'Recipe search failed', message: 'Please try again shortly.' })
scheduleAutoDismiss()
}
}
async function loadNext() {
if (!nextCursor.value) return
try {
await loadNextPage()
} catch {
showAlert({ type: 'error', heading: 'Unable to load more', message: 'Could not fetch the next page.' })
scheduleAutoDismiss()
}
}
async function loadPrev() {
if (!prevCursor.value) return
try {
await loadPrevPage()
} catch {
showAlert({ type: 'error', heading: 'Unable to load more', message: 'Could not fetch the previous page.' })
scheduleAutoDismiss()
}
}
function toRecipeItem(r: Recipe): RecipeItem {
return { id: r.id, name: r.name, imageUrls: r.imageUrls ?? [] }
}
function selectRecipe(recipe: RecipeItem) {
emit('select-recipe', recipe)
searchTerm.value = ''
recipes.value = []
}
function clear() {
searchTerm.value = ''
reset()
}
function onFocusOut() {
// keep results while focused; on blur, clear all
reset()
}
onBeforeUnmount(() => {
if (debounceId) clearTimeout(debounceId)
})
</script>
<style scoped>
.recipe-search-box {
position: relative;
position: relative;
}
.recipe-search-box input {
width: calc(100% - 2em);
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
outline: none;
width: calc(100% - 2em);
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
outline: none;
}
.recipe-search-box .dropdown {
position: absolute;
top: 100%;
width: 100%;
margin: auto;
background-color: #fff;
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
list-style-type: none;
margin: 0;
padding: 0;
max-height: 40vh;
overflow-y: scroll;
position: absolute;
top: 100%;
width: 100%;
margin: auto;
background-color: #fff;
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
list-style-type: none;
margin: 0;
padding: 0;
max-height: 40vh;
overflow-y: scroll;
}
.recipe-search-box .dropdown li {
padding: 8px;
cursor: pointer;
border-bottom: 1px solid #ccc;
padding: 8px;
cursor: pointer;
border-bottom: 1px solid #ccc;
}
.recipe-search-box .dropdown li:last-child {
border-bottom: none;
border-bottom: none;
}
.recipe-search-box .dropdown li:hover {
background-color: #eee;
background-color: #eee;
}
.recipe-search-box .dropdown .pager {
display: flex;
justify-content: space-between;
gap: 8px;
}
.recipe-search-box .dropdown .pager-btn {
width: 48%;
}
.recipe-search-box .dropdown .empty {
padding: 8px;
color: #666;
}
</style>
</style>

View file

@ -1,31 +1,18 @@
<template>
<div>
<recipe-search-box
placeholder="Search for a recipe..."
@select-recipe="onSelectRecipe"
/>
<action-item
title="Add new Recipe"
:image="addRecipe"
@click="onAddRecipe"
/>
</div>
<div>
<recipe-search-box placeholder="Search for a recipe..." @select-recipe="(r) => this.$router.push(`/recipes/${r.id}`)"/>
<action-item title="Add new Recipe" :image="require('@/assets/add-recipe.svg')" @click="() => this.$router.push('/recipes/add')" />
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
<script>
import ActionItem from '@/components/ActionItem.vue'
import RecipeSearchBox from './RecipeSearchBox.vue'
import type { Recipe } from '@/domain/types'
import RecipeSearchBox from './RecipeSearchBox.vue';
const addRecipe = new URL('@/assets/add-recipe.svg', import.meta.url).toString()
const router = useRouter()
function onSelectRecipe(r: Pick<Recipe, 'id'>) {
router.push(`/recipes/${r.id}`)
export default {
name: 'ActionsPage',
components: {
ActionItem,
RecipeSearchBox
}
}
function onAddRecipe() {
router.push('/recipes/add')
}
</script>
</script>

View file

@ -1,275 +1,250 @@
<template>
<h3>Full shopping list</h3>
<h4>Included Meals</h4>
<meal-selection-list
:checked="includedMeals"
:meals="availableMeals"
@meal-selected="mealSelected"
@meal-unselected="mealUnselected"
/>
<ul class="full-shopping-list">
<li
v-for="group in outstandingItemGroups"
:key="groupKey(group)"
class="selectable"
:class="{ selected: isSelected(group) }"
@click="toggleSelect(group)"
>
<shopping-list-item :shopping-list-item-group="group" />
</li>
</ul>
<div v-if="outstandingItemGroups.length === 0">
<p>No items to purchase</p>
</div>
<div class="purchased-slider">
<span v-if="purchasedItemGroups.length === 0" />
<button
v-else-if="showPurchased"
@click="showPurchased = false"
>
Hide Purchased
</button>
<button
v-else
@click="showPurchased = true"
>
Show Purchased
</button>
<div v-if="showPurchased && purchasedItemGroups.length > 0">
<h4>Purchased Meals</h4>
<meal-selection-list
:checked="includedMeals"
:meals="purchasedMeals"
@meal-selected="mealSelected"
@meal-unselected="mealUnselected"
/>
<h4>Purchased Items</h4>
<ul class="full-shopping-list">
<li
v-for="item in purchasedItemGroups"
:key="groupKey(item)"
>
<shopping-list-item :shopping-list-item-group="item" />
<h3>Full shopping list</h3>
<h4>Included Meals</h4>
<meal-selection-list @meal-selected="mealSelected" @meal-unselected="mealUnselected" :checked="includedMeals" :meals="availableMeals" />
<ul class="full-shopping-list">
<li v-for="group in outstandingItemGroups" :key="group.id" class="selectable" :class="{ 'selected': isSelected(group) }" @click="toggleSelect(group)">
<shopping-list-item :shopping-list-item-group="group" />
</li>
</ul>
</ul>
<div v-if="outstandingItemGroups.length === 0">
<p>
No items to purchase
</p>
</div>
</div>
<div
v-if="selected.length"
class="spacer"
>
&nbsp;
</div>
<div class="purchased-slider">
<span v-if="purchasedItemGroups.length === 0"></span>
<button v-else-if="showPurchased" @click="showPurchased=false" > Hide Purchased </button>
<button v-else @click="showPurchased=true"> Show Purchased </button>
<!-- Display 'Stocked', 'Purchased' and 'Cancel' buttons in a vertical stack fixed to the bottom of the screen when any elements are selected -->
<div
v-if="selected.length"
class="footer-buttons"
>
<p v-if="selected.length === 1">
Mark '{{ selected[0] ? groupLabel(selected[0]) : '' }}' as
</p>
<p v-else>
Mark {{ selected.length }} items as
</p>
<div v-if="showPurchased">
<h4>Purchased Meals</h4>
<meal-selection-list @meal-selected="mealSelected" @meal-unselected="mealUnselected" :checked="includedMeals" :meals="purchasedMeals" />
<div class="button-group">
<button @click="markFound">
<img :src="houseCheck"><br>
Found
</button>
<button @click="markPurchased">
<img :src="shoppingCart"><br>
Purchased
</button>
<button @click="selected = []">
<img :src="closeIcon"><br>
Cancel
</button>
<h4>
Purchased Items
</h4>
<ul class="full-shopping-list">
<li v-for="item in purchasedItemGroups" :key="item.id">
<shopping-list-item :shopping-list-item-group="item" />
</li>
</ul>
</div>
</div>
<div class="spacer" v-if="selected.length">
&nbsp;
</div>
<!-- Display 'Stocked', 'Purchased' and 'Cancel' buttons in a vertical stack fixed to the bottom of the screen when any elements are selected -->
<div class="footer-buttons" v-if="selected.length">
<p v-if="selected.length === 1">
Mark '{{ selected[0].product?.name ?? selected[0].name }}' as
</p>
<p v-else>
Mark {{ selected.length }} items as
</p>
<div class="button-group">
<button @click="markFound">
<img src="@/assets/house-check.svg" /><br />
Found
</button>
<button @click="markPurchased">
<img src="@/assets/shopping-cart.svg" /><br />
Purchased
</button>
<button @click="selected = []">
<img src="@/assets/close.svg" /><br />
Cancel
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onBeforeMount } from 'vue'
import { useRouter } from 'vue-router'
import { useAlert } from '@/composables/useAlert'
import { useShopping } from '@/composables/useShopping'
import { getUpcomingMeals } from '@/api/sdk'
import { type Group } from '@/composables/useShopping'
type UIMeal = import('@/domain/types').Meal
import MealSelectionList from './MealSelectionList.vue'
import ShoppingListItem from './ShoppingListItem.vue'
const houseCheck = new URL('@/assets/house-check.svg', import.meta.url).toString()
const shoppingCart = new URL('@/assets/shopping-cart.svg', import.meta.url).toString()
const closeIcon = new URL('@/assets/close.svg', import.meta.url).toString()
const router = useRouter()
const { show: showAlert } = useAlert()
const { getCurrentShoppingList, requestMeal, unrequestMeal, purchaseFromGroups, groupsFrom, mealsFrom } = useShopping()
const from = new Date()
from.setTime(0)
const to = new Date()
to.setDate(to.getDate() + 7)
type Meal = import('@/domain/types').Meal
const shoppingList = ref<import('@/domain/types').CurrentShoppingListDTO | null>(null)
const upcomingMeals = ref<Meal[]>([])
const selected = ref<Group[]>([])
const showPurchased = ref(false)
const groupsMatch = (a: Group, b: Group) => {
if (a.type !== b.type) return false
if (a.type === 'name' && b.type === 'name') return a.name === b.name
if (a.type === 'product' && b.type === 'product') return a.product.id === b.product.id
return false
}
const outstandingItemGroups = computed<Group[]>(() => groupsFrom(shoppingList.value?.outstandingItems))
const purchasedItemGroups = computed<Group[]>(() => groupsFrom(shoppingList.value?.purchasedItems))
const purchasedMeals = computed<UIMeal[]>(() => mealsFrom(shoppingList.value?.purchasedItems))
const availableMeals = computed(() => {
const lookup: Record<string | number, Meal> = shoppingList.value?.mealsLookup ?? {}
const meals: Record<string | number, Meal> = { ...lookup }
upcomingMeals.value?.forEach((m) => {
if (!meals[m.id]) meals[m.id] = m
})
return Object.values(meals)
.filter((m) => !m.purchaseDate)
.sort((a, b) => ((a.suggestedDate && a.suggestedDate.getTime()) || 0) - ((b.suggestedDate && b.suggestedDate.getTime()) || 0))
})
const includedMeals = computed(() => (shoppingList.value?.requestedMeals ?? []).map((i) => i.meal).filter((m): m is Meal => !!m))
async function loadData() {
upcomingMeals.value = await getUpcomingMeals(from, to)
shoppingList.value = await getCurrentShoppingList()
}
async function mealSelected(meal: { id: number }) {
await requestMeal(meal.id)
await loadData()
}
async function mealUnselected(meal: { id: number }) {
await unrequestMeal(meal.id)
await loadData()
}
async function markFound() {
const result = await purchaseFromGroups(selected.value)
if (!result) {
showAlert({ type: 'error', message: 'No items selected.' })
return
}
selected.value = []
await loadData()
}
async function markPurchased() {
const shopping = await purchaseFromGroups(selected.value)
if (!shopping || !shopping.id) {
showAlert({ type: 'error', message: 'Failed to purchase.' })
return
}
selected.value = []
router.push(`/shopping/${shopping.id}`)
}
function toggleSelect(item: Group) {
const index = selected.value.findIndex((i) => groupsMatch(i, item))
if (index === -1) selected.value.push(item)
else selected.value.splice(index, 1)
}
function isSelected(item: Group) {
return selected.value.some((i) => groupsMatch(i, item))
}
function groupKey(group: Group) {
if (group.type === 'product') return `p-${group.product.id}`
if (group.type === 'name') return `n-${group.name}`
return Math.random().toString(36)
}
function groupLabel(group: Group) {
if (group.type === 'product') return group.product.name ?? 'item'
if (group.type === 'name') return group.name
return 'item'
}
onBeforeMount(loadData)
</script>
<style scoped>
.full-shopping-list {
padding: 0;
padding: 0;
}
.full-shopping-list li {
list-style-type: none;
outline: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
list-style-type: none;
outline: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
}
.full-shopping-list li.selectable {
cursor: pointer;
outline: 1px solid #ccc;
cursor: pointer;
outline: 1px solid #ccc;
}
.full-shopping-list li.selected {
background-color: #f0f0f0;
outline-width: 3px;
background-color: #f0f0f0;
outline-width: 3px;
}
.footer-buttons {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 1ex;
background-color: #f0f0f0;
border-top: 1px solid #ccc;
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 1ex;
background-color: #f0f0f0;
border-top: 1px solid #ccc;
}
.footer-buttons p {
margin: 0;
text-align: left;
font-style: italic;
margin: 0;
text-align: left;
font-style: italic;
}
.button-group {
/* Display as vertical fixed to the bottom of the screen */
display: flex;
/* Display as vertical fixed to the bottom of the screen */
display: flex;
}
.button-group button {
flex: 1;
padding: 1em;
border: 1px solid #ccc;
border-radius: 0.5em;
background-color: #f0f0f0;
cursor: pointer;
flex: 1;
padding: 1em;
border: 1px solid #ccc;
border-radius: 0.5em;
background-color: #f0f0f0;
cursor: pointer;
}
button img {
width: 2em;
height: 2em;
width: 2em;
height: 2em;
}
.spacer {
height: 12em;
height: 12em;
}
</style>
<script>
import alert from '@/alert.js'
import data from '@/data.js'
import { itemsToGroups, groupsToItems } from './shopping.js'
import MealSelectionList from './MealSelectionList.vue'
import ShoppingListItem from './ShoppingListItem.vue'
async function saveShoppingList(outstandingItemGroups) {
const items = groupsToItems(outstandingItemGroups);
if (items.length === 0) {
alert.show({ type: 'error', message: 'No items selected.' });
return;
}
return await data.purchaseShoppingList(items);
}
const groupsMatch = (a, b) => {
if (!!a.product != !!b.product) return false;
if (a.name) return a.name === b.name;
return a.product.id === b.product.id;
}
export default {
name: 'FullShoppingListPage',
components: { MealSelectionList, ShoppingListItem },
props: {
stockTaking: {
type: Boolean,
default: false
},
},
data() {
const from = new Date();
from.setTime(0);
const to = new Date();
to.setDate(to.getDate() + 7);
return { from, to, shoppingList: null, selected: [], showPurchased: false }
},
async beforeMount() {
this.loadData();
},
computed: {
outstandingItemGroups() {
return itemsToGroups(this.shoppingList?.outstanding_items ?? []);
},
purchasedItemGroups() {
return itemsToGroups(this.shoppingList?.purchased_items ?? []);
},
purchasedMeals() {
return Object.values(this.shoppingList?.meals_lookup ?? {}).filter(m => m.purchase_date).sort((a, b) => a.suggested_date - b.suggested_date);
},
availableMeals() {
const meals = { ...this.shoppingList?.meals_lookup ?? {} };
this.upcomingMeals?.forEach(m => {
if (!meals[m.id]) {
meals[m.id] = m;
}
});
return Object.values(meals).filter(m => !m.purchase_date).sort((a, b) => a.suggested_date - b.suggested_date);
},
includedMeals() {
return this.shoppingList?.requested_meals.map(m => m.meal) ?? [];
}
},
methods: {
async loadData() {
this.upcomingMeals = await data.getUpcomingMeals(this.from, this.to);
this.shoppingList = await data.getCurrentShoppingList();
},
async mealSelected(meal) {
await data.requestMeal(meal.id);
await this.loadData();
},
async mealUnselected(meal) {
await data.unrequestMeal(meal.id);
await this.loadData();
},
async markFound() {
await saveShoppingList(this.selected);
this.selected = [];
await this.loadData();
},
async markPurchased() {
const shoppingList = await saveShoppingList(this.selected);
if (!shoppingList || !shoppingList.id) {
alert.show({ type: 'error', message: 'Failed to purchase.' });
return;
}
this.selected = [];
this.$router.push(`/shopping/${shoppingList.id}`);
},
toggleSelect(item) {
const index = this.selected.findIndex(i => groupsMatch(i, item));
if (index === -1)
this.selected.push(item);
else
this.selected.splice(index, 1);
},
isSelected(item) {
return this.selected.some(i => groupsMatch(i, item));
}
}
}
</script>

View file

@ -1,163 +1,160 @@
<template>
<ul>
<li
v-for="meal in meals"
:key="meal.id"
>
<!-- Have a checkbox and card for each meal, show the image and name -->
<!-- Emit meal-selected event on checked and meal-unselected on unchecked -->
<input
:id="String(meal.id)"
type="checkbox"
:checked="isChecked(meal)"
:disabled="!!disabled"
@change="mealCheckChanged"
>
<label
:for="String(meal.id)"
:style="getImageStyling(meal)"
>
{{ formatDate(meal.suggestedDate) }}
</label>
<ul>
<li v-for="meal in meals" :key="meal.id">
<!-- Have a checkbox and card for each meal, show the image and name -->
<!-- Emit meal-selected event on checked and meal-unselected on unchecked -->
<input type="checkbox" :id="meal.id" :checked="isChecked(meal)" @change="mealCheckChanged" :disabled="disabled" />
<label :for="meal.id" :style="getImageStyling(meal)">
{{ formatDate(meal.suggested_date) }}
</label>
</li>
</ul>
</ul>
</template>
<script setup lang="ts">
import type { Meal } from '@/domain/types'
// MealSelectionList only needs minimal fields from Meal; recipes and extraIngredients are optional for image lookup
type MealDisplay = Pick<Meal, 'id' | 'suggestedDate'> & Partial<Pick<Meal, 'recipes' | 'extraIngredients'>>
const props = defineProps<{ meals: MealDisplay[]; checked: MealDisplay[]; disabled?: boolean }>()
const emit = defineEmits<{
(e: 'meal-selected', meal: MealDisplay): void
(e: 'meal-unselected', meal: MealDisplay): void
}>()
const nth = (d: number) => {
if (d > 3 && d < 21) return 'th'
switch (d % 10) {
case 1:
return 'st'
case 2:
return 'nd'
case 3:
return 'rd'
default:
return 'th'
}
}
const formatDate = (date: Date | null) => {
if (!date) return ''
const day = date.getDate()
return `${date.toLocaleDateString('en-AU', { weekday: 'short' })} ${day}${nth(day)}`
}
const getUrl = (meal: MealDisplay) => {
// First non empty value in meal.recipes[*].recipe image URLs
if (meal.recipes) {
for (const mr of meal.recipes) {
const imgs = mr.recipe?.imageUrls
if (imgs && imgs.length && imgs[0]) {
return imgs[0]
}
}
}
// First meal.extraIngredients with a product with an image
if (meal.extraIngredients) {
for (const ingredient of meal.extraIngredients) {
const p = ingredient.product
if (p?.imgLarge) return p.imgLarge
if (p?.imgSmall) return p.imgSmall
}
}
return null
}
function getImageStyling(meal: MealDisplay) {
const imageUrl = getUrl(meal)
if (!imageUrl) return null
const opacity = 0.7
return {
background: `linear-gradient(to bottom, rgba(255, 255, 255, ${opacity}) 0%, rgba(255, 255, 255, ${opacity}) 100%), url('${imageUrl}') center/cover no-repeat`,
}
}
function mealCheckChanged(event: Event) {
const target = event.target
if (!target || !(target instanceof HTMLInputElement)) return
const mealId = parseInt(target.id)
const meal = props.meals.find((m) => m.id === mealId)
if (!meal) return
if (target.checked) emit('meal-selected', meal)
else emit('meal-unselected', meal)
}
function isChecked(meal: MealDisplay) {
return props.checked.some((m) => m.id === meal.id)
}
</script>
<style scoped>
ul {
padding: 0;
padding: 0;
}
li {
display: inline-block;
list-style-type: none;
border: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
padding: none;
overflow: hidden;
text-align: center;
display: inline-block;
list-style-type: none;
border: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
padding: none;
overflow: hidden;
text-align: center;
}
/* Hide the default checkbox formatting, and format the card instead */
input[type='checkbox'] {
display: none;
input[type="checkbox"] {
display: none;
}
label {
display: inline-block;
padding: 0.1vh 0.3em;
/* Help the visibility of the text over the image */
background-color: rgba(255, 255, 255, 0.9);
border: 3px solid transparent;
border-radius: 0.5em;
margin: 0;
font-weight: normal;
cursor: pointer;
color: #3d5447;
display: inline-block;
padding: 0.1vh 0.3em;
/* Help the visibility of the text over the image */
background-color: rgba(255, 255, 255, 0.9);
border: 3px solid transparent;
border-radius: 0.5em;
margin: 0;
font-weight: normal;
cursor: pointer;
color: #3d5447;
}
input[type='checkbox']:checked + label {
border: 3px solid #3d5447;
text-shadow: #ccc 0 0 0.1em;
input[type="checkbox"]:checked + label {
border: 3px solid #3d5447;
text-shadow: #ccc 0 0 0.1em;
}
input[type='checkbox']:disabled + label {
cursor: not-allowed;
input[type="checkbox"]:disabled + label {
cursor: not-allowed;
}
/* Show the image as the background image of the card */
.meal-image {
width: 100%;
height: 100%;
object-fit: cover;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Position the text in the center of the card */
label {
display: flex;
justify-content: center;
align-items: center;
font-size: larger;
font-weight: bold;
display: flex;
justify-content: center;
align-items: center;
font-size: larger;
font-weight: bold;
}
</style>
<script>
const nth = (d) => {
if (d > 3 && d < 21) return 'th';
switch (d % 10) {
case 1: return "st";
case 2: return "nd";
case 3: return "rd";
default: return "th";
}
};
const formatDate = (date) => {
return `${date.toLocaleDateString('en-AU', { weekday: 'short' })} ${date.getDate()}${nth(date.getDate())}`
}
const getUrl = (meal) => {
// First non empty value in meal.recipe/image_urls
for (const mr of meal.recipes) {
const recipe = mr.recipe;
if (recipe.image_urls.length && recipe.image_urls[0]) {
return recipe.image_urls[0];
}
}
// First meal.extra_ingredient with a product with an image
for (const ingredient of meal.extra_ingredients) {
if (ingredient.product) {
if (ingredient.product.img_large) {
return ingredient.product.img_large;
}
if (ingredient.product.img_small) {
return ingredient.product.img_small;
}
}
}
return null;
}
export default {
name: 'MealSelectionList',
props: {
meals: Array,
checked: Array,
disabled: {
type: Boolean,
default: false
}
},
methods: {
formatDate,
getImageStyling(meal) {
const imageUrl = getUrl(meal);
if (!imageUrl) {
return null;
}
const opacity = 0.7;
return {background: `linear-gradient(to bottom, rgba(255, 255, 255, ${ opacity }) 0%, rgba(255, 255, 255, ${ opacity }) 100%), url('${imageUrl}') center/cover no-repeat`};
},
mealCheckChanged(event) {
const mealId = parseInt(event.target.id);
const meal = this.meals.find(m => m.id === mealId);
if (event.target.checked) {
this.$emit('meal-selected', meal);
} else {
this.$emit('meal-unselected', meal);
}
},
isChecked(meal) {
for (const checkedMeal of this.checked) {
if (checkedMeal.id === meal.id) {
return true;
}
}
return false;
}
}
}
</script>

View file

@ -1,19 +1,11 @@
<template>
<div>
<h1>My Shopping List</h1>
<router-link :to="`/shopping/current`">
Full Shopping List
</router-link>
<editable-ingredients-panel
:ingredients="ingredients"
@on-add="addIngredient"
@on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient"
@on-editing="onEditing"
/>
</div>
<div>
<h1>My Shopping List</h1>
<router-link :to="`/shopping/current`">Full Shopping List</router-link>
<editable-ingredients-panel @on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient" @on-editing="onEditing" :ingredients="ingredients" />
</div>
<!--
<!--
# Functions
* Add a random item to next shop
* Add meals to next shop
@ -39,50 +31,53 @@
-->
</template>
<script setup lang="ts">
import { ref, onBeforeMount } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import { useShopping } from '@/composables/useShopping'
import type { Ingredient } from '@/domain/types'
<style scoped>
</style>
<script>
import data from '@/data.js'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
const router = useRouter()
const { loadUser } = useAuth()
const { getMyShoppingList, saveMyShoppingList } = useShopping()
export default {
name: 'MyShoppingpage',
components: { EditableIngredientsPanel },
data() {
return { ingredients: [], person: null }
},
async beforeMount() {
const person = await data.currentUser();
if (!person)
return this.$router.push({ name: 'login' });
const ingredients = ref<Ingredient[]>([])
this.person = person;
await this.updateShoppingList();
},
methods: {
async updateShoppingList(save = false) {
const new_ingredients = save ?
await data.saveMyShoppingList(this.ingredients) :
await data.getMyShoppingList();
async function updateShoppingList(save = false) {
const newIngredients = save ? await saveMyShoppingList(ingredients.value) : await getMyShoppingList()
ingredients.value = newIngredients.map((i) => ({ ...i }))
this.ingredients = new_ingredients;
},
addIngredient() {
this.ingredients = [{ id: -1 }, ...this.ingredients];
},
deleteIngredient(ingredient) {
this.ingredients = this.ingredients.filter(i => i !== ingredient);
},
updateIngredient(oldIngredient, newIngredient) {
this.ingredients = this.ingredients.map(source => source === oldIngredient ? newIngredient : source);
},
async onEditing(isStartingEdit) {
await this.updateShoppingList(!isStartingEdit);
if (isStartingEdit && this.ingredients.length === 0) {
this.addIngredient();
}
}
}
}
function addIngredient() {
ingredients.value = [
{ id: -1, name: '', line: '', quantity: 1, unit: 'Items', preparation: '', product: null },
...ingredients.value,
]
}
function deleteIngredient(ingredient: Ingredient) {
ingredients.value = ingredients.value.filter((i) => i !== ingredient)
}
function updateIngredient(oldIngredient: Ingredient, newIngredient: Ingredient) {
ingredients.value = ingredients.value.map((source) => (source === oldIngredient ? newIngredient : source))
}
async function onEditing(isStartingEdit: boolean) {
await updateShoppingList(!isStartingEdit)
if (isStartingEdit && ingredients.value.length === 0) addIngredient()
}
onBeforeMount(async () => {
const u = await loadUser()
if (!u) return router.push({ name: 'login' })
await updateShoppingList()
})
</script>
<style scoped></style>
</script>

View file

@ -1,67 +1,79 @@
<template>
<h3>Purchased {{ shoppingList?.createdDate ? ago(shoppingList.createdDate) : '' }}</h3>
<h3>Purchased {{ shoppingList ? ago(shoppingList.created_date) : '' }}</h3>
<div v-if="includedMeals.length > 0">
<h4>Included Meals</h4>
<meal-selection-list :checked="includedMeals" :meals="includedMeals" :disabled="true" />
</div>
<div v-if="includedMeals.length > 0">
<h4>Included Meals</h4>
<meal-selection-list
:checked="includedMeals"
:meals="includedMeals"
:disabled="true"
/>
</div>
<ul class="full-shopping-list">
<li
v-for="item in listByProduct"
:key="groupKey(item)"
>
<shopping-list-item-comp :shopping-list-item-group="item" />
</li>
</ul>
<ul class="full-shopping-list">
<li v-for="item in listByProduct" :key="item.id">
<shopping-list-item :shopping-list-item-group="item" />
</li>
</ul>
</template>
<script setup lang="ts">
import { ref, computed, onBeforeMount } from 'vue'
import { useRoute } from 'vue-router'
import { ago } from '@/dateformats'
import { useShopping } from '@/composables/useShopping'
import { parseRouteId } from '@/router/helpers'
import type { Group } from '@/composables/useShopping'
import type { Meal } from '@/domain/types'
import MealSelectionList from './MealSelectionList.vue'
import ShoppingListItemComp from './ShoppingListItem.vue'
const route = useRoute()
const { getShoppingList, groupsFrom, mealsFrom } = useShopping()
const shoppingList = ref<import('@/domain/types').ShoppingListWithRefs | null>(null)
const includedMeals = computed<Meal[]>(() => mealsFrom(shoppingList.value?.items))
const listByProduct = computed(() => groupsFrom(shoppingList.value?.items))
onBeforeMount(async () => {
const id = parseRouteId(route.params.id)
if (id !== null) {
shoppingList.value = await getShoppingList(id)
}
})
function groupKey(group: Group) {
if (group.type === 'product') return `p-${group.product.id}`
if (group.type === 'name') return `n-${group.name}`
return Math.random().toString(36)
}
</script>
<style scoped>
.full-shopping-list li {
list-style-type: none;
border: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
.full-shopping-list li {
list-style-type: none;
border: 1px solid #ccc;
border-radius: 0.5em;
margin-bottom: 1em;
}
.full-shopping-list {
padding: 0;
padding: 0;
}
</style>
<script>
import { ago } from '@/dateformats.js'
import data from '@/data.js'
import { itemsToGroups } from './shopping.js'
import MealSelectionList from './MealSelectionList.vue'
import ShoppingListItem from './ShoppingListItem.vue'
export default {
name: 'FullShoppingListPage',
components: { MealSelectionList, ShoppingListItem },
props: {
id: [String, Number]
},
computed: {
includedMeals() {
if (!this.shoppingList) return [];
const seenMeals = new Set();
return this.shoppingList.items
.map(item => item.meal)
.filter(meal => {
if (!meal) return false;
if (seenMeals.has(meal.id)) return false;
seenMeals.add(meal.id);
return true;
});
},
listByProduct() {
return this.shoppingList ? itemsToGroups(this.shoppingList.items) : [];
}
},
data() {
return {
shoppingList: null,
}
},
async beforeMount() {
this.shoppingList = await data.getShoppingList(this.id);
},
methods: {
ago
}
}
</script>

View file

@ -1,222 +1,174 @@
<template>
<!-- Show a card of the product with the total, taking up the available space and have an expanding section to view sources -->
<div class="shopping-list-item">
<img
:src="imageSrc"
class="product-image"
>
<!-- Show a card of the product with the total, taking up the available space and have an expanding section to view sources -->
<div class="shopping-list-item">
<img :src="`${ shoppingListItemGroup.product?.img_small ?? require('@/assets/missing-product.svg') }`" class="product-image" />
<div class="product-details">
<h3 class="header">
<strong>
<a
v-if="shoppingListItemGroup.type === 'product' && shoppingListItemGroup.product?.link"
:href="shoppingListItemGroup.product?.link"
>{{ shoppingListItemGroup.product?.name }}</a>
<span v-else>{{ shoppingListItemGroup.type === 'name' ? shoppingListItemGroup.name : '' }}</span> </strong>,
<small>
<span
v-for="(total, index) in remainingRequiredTotals"
:key="index"
>
<span v-if="index">, </span>
<span>{{ formatQuantity(total.quantity) }}&nbsp;{{ total.unit }}</span>
</span>
<span
v-if="purchased.length > 0"
class="found-marker partial"
> {{ getFriendlyDate(lastPurchased) }}</span>
</small>
</h3>
<p
v-if="required.length > 0"
class="sources"
>
<strong>Need: </strong>
<span
v-for="(source, index) in required"
:key="source.id"
>
<span v-if="index">, and </span>
<!-- Generic ingredient-only display when no recipe/meal context; person ref removed -->
<span v-if="!source.recipe && !source.meal && source.ingredient">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }}
</span>
<span v-else-if="source.recipe && source.ingredient">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in
<router-link :to="`/recipes/${source.recipe.id}/`">{{
source.recipe.name
}}</router-link>
for
<router-link :to="`/meals/${source.meal?.id}/`">{{
source.meal?.suggestedDate
? source.meal.suggestedDate.toLocaleDateString('en-AU', {
weekday: 'long',
month: 'long',
day: 'numeric',
})
: ''
}}</router-link>
</span>
<span v-else-if="source.meal && source.ingredient">
{{ source.ingredient.line }} for
<router-link :to="`/meals/${source.meal?.id}/`">{{
source.meal?.suggestedDate
? source.meal.suggestedDate.toLocaleDateString('en-AU', {
weekday: 'long',
month: 'long',
day: 'numeric',
})
: ''
}}</router-link>
</span>
</span>
</p>
<p v-if="purchased.length > 0">
<strong>Already found or purchased: </strong>
<span
v-for="(source, index) in purchased"
:key="source.id"
>
<span v-if="index">, and </span>
<!-- Generic ingredient-only display when no recipe/meal context; person ref removed -->
<span v-if="!source.recipe && !source.meal && source.ingredient">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }}
</span>
<span v-else-if="source.recipe && source.ingredient">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in
<router-link :to="`/recipes/${source.recipe.id}/`">{{
source.recipe.name
}}</router-link>
for
<router-link :to="`/meals/${source.meal?.id ?? ''}/`">{{
source.meal?.suggestedDate
? source.meal.suggestedDate.toLocaleDateString('en-AU', {
weekday: 'long',
month: 'long',
day: 'numeric',
})
: ''
}}</router-link>
</span>
<span v-else-if="source.meal && source.ingredient">
{{ source.ingredient.line }} for
<router-link :to="`/meals/${source.meal.id}/`">{{
source.meal.suggestedDate
? source.meal.suggestedDate.toLocaleDateString('en-AU', {
weekday: 'long',
month: 'long',
day: 'numeric',
})
: ''
}}</router-link>
</span>
</span>
</p>
<h3 class="header">
<strong>
<a v-if="shoppingListItemGroup.product?.link" :href="shoppingListItemGroup.product?.link">{{ shoppingListItemGroup.product?.name }}</a>
<span v-else>{{ shoppingListItemGroup.name }}</span>
</strong>,
<small>
<span v-for="(total, index) in remainingRequiredTotals" :key="total.id">
<span v-if="index">, </span>
<span>{{ formatQuantity(total.quantity) }}&nbsp;{{ total.unit }}</span>
</span>
<span class="found-marker partial" v-if="purchased.length > 0"> {{ getFriendlyDate(lastPurchased) }}</span>
</small>
</h3>
<p class="sources" v-if="required.length > 0">
<strong>Need: </strong>
<span v-for="(source, index) in required" :key="source.id">
<span v-if="index">, and </span>
<span v-if="source.person">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} for {{ source.person.name }}
</span>
<span v-else-if="source.recipe">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }}
in <router-link :to="`/recipes/${ source.recipe.id }/`">{{ source.recipe.name }}</router-link>
for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
</span>
<span v-else-if="source.meal">
{{ source.ingredient.line }} for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
</span>
</span>
</p>
<p v-if="purchased.length > 0">
<strong>Already found or purchased: </strong>
<span v-for="(source, index) in purchased" :key="source.id">
<span v-if="index">, and </span>
<span v-if="source.person">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} for {{ source.person.name }}
</span>
<span v-else-if="source.recipe">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }}
in <router-link :to="`/recipes/${ source.recipe.id }/`">{{ source.recipe.name }}</router-link>
for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
</span>
<span v-else-if="source.meal">
{{ source.ingredient.line }} for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
</span>
</span>
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ago } from '@/dateformats'
import { calculateTotals } from '@/units'
import type { Group } from '@/composables/useShopping'
const props = defineProps<{ shoppingListItemGroup: Group }>()
const fallbackImg = new URL('@/assets/missing-product.svg', import.meta.url).toString()
const imageSrc = computed(() => {
if (props.shoppingListItemGroup.type === 'product') {
return props.shoppingListItemGroup.product?.imgSmall ?? fallbackImg
}
return fallbackImg
})
const remainingRequiredTotals = computed(() =>
calculateTotals(
props.shoppingListItemGroup.shoppingListItems
.filter((item) => !!item.ingredient)
.map((item) => ({
// item.ingredient is defined due to filter above
quantity: item.ingredient!.quantity,
unit: String(item.ingredient!.unit || 'items'),
}))
)
)
const required = computed(() => props.shoppingListItemGroup.shoppingListItems.filter((item) => !item.listId))
const purchased = computed(() => props.shoppingListItemGroup.shoppingListItems.filter((item) => item.listId))
const lastPurchased = computed(() => {
const purchasedItems = props.shoppingListItemGroup.shoppingListItems.filter((item) => item.listId)
if (purchasedItems.length === 0) return null
return purchasedItems.reduce<Date | null>((latest, item) => {
const itemDate: Date | null = (item?.meal?.suggestedDate ?? item?.createdDate) || null
return !latest || (itemDate && itemDate > latest) ? itemDate : latest
}, null)
})
function getFriendlyDate(date: Date | null) {
if (!date) return ''
return ago(date)
}
function formatQuantity(quantity: number) {
const log10 = Math.log10(quantity)
if (log10 < 0) return quantity.toPrecision(2)
if (log10 < 1) return quantity.toFixed(1)
return quantity.toFixed(0)
}
</script>
<style scoped>
/* Show the product image to the left, then the product name and size to the right */
.shopping-list-item {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 1em;
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 1em;
}
.product-image {
max-width: 5em;
max-height: 4em;
margin-right: 1em;
max-width: 5em;
max-height: 4em;
margin-right: 1em;
}
.product-details {
flex-grow: 1;
text-align: left;
flex-grow: 1;
text-align: left;
}
.header {
margin: 0;
margin: 0;
}
.found-marker {
margin: 1ex;
padding-top: 0.6ex;
padding-bottom: 0.5ex;
padding-left: 1em;
padding-right: 1em;
border-radius: 25px;
margin: 1ex;
padding-top: 0.6ex;
padding-bottom: 0.5ex;
padding-left: 1em;
padding-right: 1em;
border-radius: 25px;
text-align: center;
white-space: nowrap;
font-size: smaller;
color: white;
font-weight: bold;
z-index: 1000;
text-align: center;
white-space: nowrap;
font-size: smaller;
color: white;
font-weight: bold;
z-index: 1000;
}
.found-marker.found {
background-color: green;
background-color: green;
}
.found-marker.partial {
background-color: darkgoldenrod;
background-color: darkgoldenrod;
}
</style>
<script>
import { ago } from '@/dateformats.js';
import { calculateTotals } from '@/units.js';
export default {
name: 'ShoppingListItem',
props: ['shoppingListItemGroup' ], // { product: { ... }, OR name: 'string', shoppingListItems: { person, ingredient, list_id?, meal? }} where list_id is null if not yet purchased
data() {
return {
expanded: false,
}
},
computed: {
remainingRequiredTotals() {
return calculateTotals(this.shoppingListItemGroup.shoppingListItems.map(item => item.ingredient));
},
expectedExistingTotals() {
const purchasedNotEaten = this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id && !(item?.meal?.consumed_date));
return calculateTotals(purchasedNotEaten.map(item => item.ingredient));
},
required() {
return this.shoppingListItemGroup.shoppingListItems.filter(item => !item.list_id);
},
purchased() {
return this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id);
},
lastPurchased() {
const purchasedItems = this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id);
if (purchasedItems.length === 0) {
return null;
}
// Find the most recently purchased item
return purchasedItems.reduce((latest, item) => {
const itemDate = item?.meal?.suggested_date || item?.created_at;
return (!latest || (itemDate && itemDate > latest)) ? itemDate : latest;
}, null);
}
},
methods: {
getFriendlyDate(date) {
if (!date)
return '';
return ago(date);
},
formatQuantity(quantity) {
const log10 = Math.log10(quantity);
if (log10 < 0) {
return quantity.toPrecision(2);
}
else if (log10 < 1) {
return quantity.toFixed(1);
}
else {
return quantity.toFixed(0);
}
}
}
}
</script>

View file

@ -0,0 +1,35 @@
export function groupsToItems(groups) {
return groups.map(group => group.shoppingListItems).flat();
}
export function itemsToGroups(shoppingListItems) {
const ingredients_by_product_id = {};
const ingredients_by_name = {};
for (const item of shoppingListItems) {
if (item.ingredient.product) {
let group = ingredients_by_product_id[item.ingredient.product.id];
if (!group) {
group = ingredients_by_product_id[item.ingredient.product.id] = {
product: item.ingredient.product,
shoppingListItems: []
};
}
group.shoppingListItems.push(item);
}
else {
let group = ingredients_by_name[item.ingredient.name];
if (!group) {
group = ingredients_by_name[item.ingredient.name] = {
name: item.ingredient.name,
shoppingListItems: []
};
}
group.shoppingListItems.push(item);
}
}
return [
...Object.values(ingredients_by_product_id),
...Object.values(ingredients_by_name)
]
}

View file

@ -1,29 +0,0 @@
import { ref } from 'vue'
type AlertType = 'success' | 'error' | 'info'
export type AlertMessage = { heading?: string; message: string; type: AlertType }
// Singleton reactive alert state for the app
const current = ref<(AlertMessage & { _ts: number }) | null>(null)
let timeoutId: ReturnType<typeof setTimeout> | null = null
function show(message: AlertMessage) {
current.value = { ...message, _ts: Date.now() }
}
function clear() {
current.value = null
}
function scheduleAutoDismiss(ms = 5000) {
if (timeoutId) clearTimeout(timeoutId)
if (!current.value) return
timeoutId = setTimeout(() => {
clear()
timeoutId = null
}, ms)
}
export function useAlert() {
return { current, show, clear, scheduleAutoDismiss }
}

View file

@ -1,23 +0,0 @@
import { ref } from 'vue'
import { currentUser as apiCurrentUser, login as apiLogin } from '@/api/auth'
import type { Person } from '@/domain/types'
const user = ref<Person | null>(null)
let initialized = false
export async function loadUser() {
if (!initialized) {
user.value = await apiCurrentUser()
initialized = true
}
return user.value
}
export async function login(username: string) {
user.value = await apiLogin(username)
return user.value
}
export function useAuth() {
return { user, loadUser, login }
}

View file

@ -1,107 +0,0 @@
import { ref, shallowRef, type Ref, type ShallowRef } from 'vue'
import type { Page } from '@/domain/pagination'
export type PageParams = { cursor?: string | null; limit?: number } & Record<string, unknown>
export type PageFetcher<T> = (params: PageParams) => Promise<Page<T>>
export function usePagination<T>(
fetchPage: PageFetcher<T>,
options?: { pageSize?: number }
): {
items: Ref<T[]>
next: Ref<string | null>
prev: Ref<string | null>
total: Ref<number | null>
loading: Ref<boolean>
error: Ref<unknown>
load: (baseParams?: PageParams) => Promise<void>
loadNext: () => Promise<void>
loadPrev: () => Promise<void>
reset: () => void
} {
function makeItemsRef<U>(): ShallowRef<U[]> {
return shallowRef<U[]>([])
}
const items: Ref<T[]> = makeItemsRef<T>()
const next: Ref<string | null> = ref<string | null>(null)
const prev: Ref<string | null> = ref<string | null>(null)
const total: Ref<number | null> = ref<number | null>(null)
const loading: Ref<boolean> = ref<boolean>(false)
const error: Ref<unknown> = ref<unknown>(null)
const pageSize = options?.pageSize
const lastBaseParams = ref<PageParams>({})
function assignFromPage(page: Page<T>) {
items.value = page.items ?? []
next.value = page.next ?? null
prev.value = page.prev ?? null
total.value = page.total ?? null
}
async function load(baseParams: PageParams = {}) {
loading.value = true
error.value = null
// keep base params without cursor; limit comes from options unless explicitly provided
const { limit: _l, ...rest } = baseParams
lastBaseParams.value = rest
try {
const params: PageParams = { ...rest }
if (typeof _l === 'number') params.limit = _l
else if (typeof pageSize === 'number') params.limit = pageSize
const page = await fetchPage(params)
assignFromPage(page)
} catch (e) {
error.value = e
items.value = []
next.value = null
prev.value = null
total.value = null
} finally {
loading.value = false
}
}
async function loadWithCursor(cursor: string | null) {
if (!cursor) return
loading.value = true
error.value = null
try {
const params: PageParams = {
...lastBaseParams.value,
cursor,
}
if (typeof pageSize === 'number') params.limit = pageSize
const page = await fetchPage(params)
assignFromPage(page)
} catch (e) {
error.value = e
items.value = []
next.value = null
prev.value = null
total.value = null
} finally {
loading.value = false
}
}
async function loadNext() {
await loadWithCursor(next.value)
}
async function loadPrev() {
await loadWithCursor(prev.value)
}
function reset() {
items.value = []
next.value = null
prev.value = null
total.value = null
loading.value = false
error.value = null
lastBaseParams.value = {}
}
return { items, next, prev, total, loading, error, load, loadNext, loadPrev, reset }
}

View file

@ -1,85 +0,0 @@
import * as sdk from '@/api/sdk'
import type { ListIngredientItemWithRefs, RequestedMealItemWithRefs, Product, Meal } from '@/domain/types'
export type GroupByProduct = { type: 'product'; product: Product; shoppingListItems: ListIngredientItemWithRefs[] }
export type GroupByName = { type: 'name'; name: string; shoppingListItems: ListIngredientItemWithRefs[] }
export type Group = GroupByProduct | GroupByName
export function groupsToItems(groups: Group[]): ListIngredientItemWithRefs[] {
return groups.map((g) => g.shoppingListItems).flat()
}
export function uniqueMeals(shoppingListItems: Array<ListIngredientItemWithRefs | RequestedMealItemWithRefs>): Meal[] {
const mealsWithDuplicates = shoppingListItems.map((item) => item.meal).filter((m): m is Meal => !!m)
const mealsLookup: Record<string | number, Meal> = mealsWithDuplicates.reduce<Record<string | number, Meal>>(
(acc, meal) => {
acc[meal.id] ??= meal
return acc
},
{}
)
return Object.values(mealsLookup)
}
export function itemsToGroups(shoppingListItems: ListIngredientItemWithRefs[]): Group[] {
const ingredients_by_product_id: Record<string | number, GroupByProduct> = {}
const ingredients_by_name: Record<string, GroupByName> = {}
for (const item of shoppingListItems) {
if (item.ingredient?.product) {
let group = ingredients_by_product_id[item.ingredient.product.id]
if (!group) {
group = ingredients_by_product_id[item.ingredient.product.id] = {
type: 'product',
product: item.ingredient.product,
shoppingListItems: [],
}
}
group.shoppingListItems.push(item)
} else {
const name = item.ingredient?.name ?? ''
let group = ingredients_by_name[name]
if (!group) {
group = ingredients_by_name[name] = {
type: 'name',
name,
shoppingListItems: [],
}
}
group.shoppingListItems.push(item)
}
}
return [...Object.values(ingredients_by_product_id), ...Object.values(ingredients_by_name)]
}
export function useShopping() {
const groupsFrom = (items?: ListIngredientItemWithRefs[]): Group[] => itemsToGroups(items ?? [])
const mealsFrom = (items?: Array<ListIngredientItemWithRefs | RequestedMealItemWithRefs>): Meal[] => uniqueMeals(items ?? [])
return {
getCurrentShoppingList: sdk.getCurrentShoppingList,
getShoppingList: sdk.getShoppingList,
purchaseShoppingList: sdk.purchaseShoppingList,
requestMeal: sdk.requestMeal,
unrequestMeal: sdk.unrequestMeal,
getMyShoppingList: sdk.getMyShoppingList,
saveMyShoppingList: sdk.saveMyShoppingList,
// View-model helpers
groupsFrom,
mealsFrom,
async purchaseFromGroups(groups: Group[]) {
const items = groupsToItems(groups).map((i): sdk.PurchaseRequest => {
if (typeof i.id === 'number' && i.id >= 0) {
return { type: 'existing', id: i.id, personId: i.personId, ingredientId: i.ingredientId ?? null }
}
return {
type: 'refs',
personId: i.personId,
ingredientId: i.ingredientId ?? null,
recipeId: i.recipeId ?? null,
mealId: i.mealId ?? null,
}
})
if (!items?.length) return null
return sdk.purchaseShoppingList(items)
},
}
}

332
src/data.js Normal file
View file

@ -0,0 +1,332 @@
const BASE_URL = window.location.href.replace(/^(https?:\/\/[^/]+).*/, "$1/api");
const datesToFix = {
Meal: { fields: [ "suggested_date", "purchase_date", "consumed_date" ] },
CurrentShoppingList: { dependants: l => ({ ShoppingListItem: [l.outstanding_items, l.requested_meals, l.purchased_items], Meal: Object.values(l.meals_lookup), Recipe: Object.values(l.recipes_lookup), Ingredient: Object.values(l.ingredients_lookup), ShoppingList: Object.values(l.shopping_list_lookup) }) },
PurchasedShoppingList: { dependants: l => ({ ShoppingListItem: l.items, Meal: Object.values(l.meals_lookup), Recipe: Object.values(l.recipes_lookup), Ingredient: Object.values(l.ingredients_lookup), ShoppingList: l.list }) },
ShoppingList: { fields: [ "created_date" ], dependants: l => ({ ShoppingListItem: l.items }) },
ShoppingListItem: { fields: [ "created_date" ], dependants: r => ({ Meal: r.meal, Recipe: r.recipe, Ingredient: r.ingredient, ShoppingList: r.list }) },
Recipe: { fields: [ "date_created", "date_hidden" ], },
};
const fixDates = (obj, type) => {
if (!obj) return;
if (Array.isArray(obj)) {
for (const item of obj) {
fixDates(item, type);
}
}
const toFix = datesToFix[type];
if (!toFix) return;
if (toFix.fields) {
for (const field of toFix.fields) {
if (obj[field]) {
obj[field] = new Date(obj[field]);
}
}
}
if (toFix.dependants) {
for (const [key, value] of Object.entries(toFix.dependants(obj))) {
if (Array.isArray(value)) {
for (const item of value) {
fixDates(item, key);
}
}
else {
fixDates(value, key);
}
}
}
}
const setPurchasedShoppingListReferences = (purchasedShoppingList) => {
if (!purchasedShoppingList) return;
const { ingredients_lookup, meals_lookup, recipes_lookup, } = purchasedShoppingList;
const shopping_list_lookup = { [purchasedShoppingList.list.id]: purchasedShoppingList.list };
setShoppingListItemReferences(purchasedShoppingList.list.items, ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup);
}
const setCurrentShoppingListReferences = (currentShoppingList) => {
if (!currentShoppingList) return;
const { ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup } = currentShoppingList;
const allShoppingListItems = [...currentShoppingList.outstanding_items, ...currentShoppingList.requested_meals, ...currentShoppingList.purchased_items];
setShoppingListItemReferences(allShoppingListItems, ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup);
}
const setShoppingListItemReferences = (shoppingListItems, ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup) => {
if (!shoppingListItems) return;
for (const item of shoppingListItems) {
if (item.ingredient_id) {
item.ingredient = ingredients_lookup[item.ingredient_id];
}
if (item.meal_id) {
item.meal = meals_lookup[item.meal_id];
}
if (item.list_id) {
item.list = shopping_list_lookup[item.list_id];
}
if (item.recipe_id) {
item.recipe = recipes_lookup[item.recipe_id];
}
}
}
let user = null;
export default {
async markMealConsumed(meal_id) {
const response = await fetch(BASE_URL + `/meals/${encodeURIComponent(meal_id)}/consumed`, {
method: "POST",
credentials: "include",
});
const meal = await response.json();
fixDates(meal, "Meal");
return meal;
},
async getUpcomingMeals(from, to) {
const response = await fetch(BASE_URL + "/meals/upcoming?from=" + from.toISOString() + "&to=" + to.toISOString());
const meals = await response.json();
fixDates(meals, "Meal");
return meals.sort((a, b) => a.suggested_date - b.suggested_date);
},
async getMeal(id) {
var response = await fetch(BASE_URL + `/meals/${encodeURIComponent(id)}`);
const meal = await response.json();
fixDates(meal, "Meal");
return meal;
},
async deleteMeal(id) {
var response = await fetch(BASE_URL + `/meals/${encodeURIComponent(id)}`, {
method: "DELETE",
});
return await response.json();
},
async searchRecipes(query) {
const response = await fetch(BASE_URL + "/recipes?q=" + encodeURIComponent(query));
const recipes = await response.json();
fixDates(recipes, "Recipe");
return recipes;
},
async parseRecipe(url) {
const response = await fetch(BASE_URL + `/recipes/parse?url=${encodeURIComponent(url)}`, { credentials: "include" });
const recipe = await response.json();
fixDates(recipe, "Recipe");
return recipe;
},
async getRecipe(id) {
const response = await fetch(BASE_URL + `/recipes/${encodeURIComponent(id)}`);
const recipe = await response.json();
fixDates(recipe, "Recipe");
return recipe;
},
async parseProduct(ingredient, url) {
const body = {
url, tags: [ingredient.name, ingredient.line],
};
const response = await fetch(BASE_URL + "/products", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body),
});
return await response.json();
},
async parseIngredients(lines) {
const params = lines.map(line => "ingredients=" + encodeURIComponent(line)).join("&");
const response = await fetch(BASE_URL + "/recipes/ingredients/parse?" + params);
return await response.json();
},
async saveRecipe(recipe) {
const response = await fetch(BASE_URL + "/recipes", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(recipe),
});
const saved = await response.json();
fixDates(saved, "Recipe");
return saved;
},
async saveMeal(meal) {
let response = null;
if (meal.id >= 0) {
response = await fetch(BASE_URL + `/meals/${encodeURIComponent(meal.id)}`, {
method: "PUT",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(meal),
});
} else {
response = await fetch(BASE_URL + "/meals", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(meal),
});
}
const saved = await response.json();
fixDates(saved, "Meal");
return saved;
},
async currentUser() {
if (user) {
return user;
}
var cookie = decodeURIComponent(document.cookie).split(";").find(cookie => cookie.trimStart().startsWith("user_id="));
if (cookie) {
const response = await fetch(BASE_URL + "/auth/refresh", {
method: "POST",
credentials: "include",
});
if (response.ok) {
user = await response.json();
}
}
return user;
},
async login(username) {
const response = await fetch(BASE_URL + "/auth/login", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ username }),
});
if (response.ok) {
user = await response.json();
}
return user;
},
async deleteRecipe(id) {
var response = await fetch(BASE_URL + `/recipes/${encodeURIComponent(id)}`, {
method: "DELETE",
credentials: "include",
});
const recipe = await response.json();
fixDates(recipe, "Recipe");
return recipe;
},
async searchPerson(name) {
const response = await fetch(BASE_URL + "/persons?q=" + encodeURIComponent(name));
return await response.json();
},
async getMyShoppingList() {
const response = await fetch(BASE_URL + "/shopping/current/me/ingredients", { credentials: "include" });
const ingredients = await response.json();
fixDates(ingredients, 'Ingredient');
return ingredients;
},
async saveMyShoppingList(list) {
const response = await fetch(BASE_URL + "/shopping/current/me/ingredients", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(list),
});
const ingredients = await response.json();
fixDates(ingredients, 'Ingredient');
return ingredients;
},
async getShoppingList(id) {
const response = await fetch(BASE_URL + `/shopping/${encodeURIComponent(id)}`);
const purchasedShoppingList = await response.json();
fixDates(purchasedShoppingList, "PurchasedShoppingList");
setPurchasedShoppingListReferences(purchasedShoppingList);
return purchasedShoppingList.list;
},
async getCurrentShoppingList() {
const response = await fetch(BASE_URL + "/shopping/current");
const lst = await response.json();
fixDates(lst, "CurrentShoppingList");
setCurrentShoppingListReferences(lst);
return lst;
},
async purchaseShoppingList(completed_requests) {
const response = await fetch(BASE_URL + "/shopping/", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ items: completed_requests }),
});
const purchasedShoppingList = await response.json();
fixDates(purchasedShoppingList, "PurchasedShoppingList");
setPurchasedShoppingListReferences(purchasedShoppingList);
return purchasedShoppingList.list;
},
async requestMeal(meal_id) {
const response = await fetch(BASE_URL + "/shopping/current/meals/me", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ meal_id }),
});
const requests = await response.json();
fixDates(requests, "ShoppingListItem");
return requests;
},
async unrequestMeal(meal_id) {
const response = await fetch(BASE_URL + `/shopping/current/meals/${encodeURIComponent(meal_id)}`, {
method: "DELETE",
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to unrequest meal");
}
},
async getPersonsInHome() {
const response = await fetch(BASE_URL + "/persons");
return await response.json();
}
}

25
src/dateformats.js Normal file
View file

@ -0,0 +1,25 @@
function plural(num, unit) {
num = Math.floor(num);
return num + " " + unit + (num === 1 ? "" : "s");
}
export function ago(date) {
const now = new Date();
const diff = now - date;
if (diff < 1000) {
return "just now";
}
if (diff < 60 * 1000) {
return plural(diff / 1000, "second") + " ago";
}
if (diff < 60 * 60 * 1000) {
return plural(diff / (60 * 1000), "minute") + " ago";
}
if (diff < 24 * 60 * 60 * 1000) {
return plural(diff / (60 * 60 * 1000), "hour") + " ago";
}
if (diff < 7 * 24 * 60 * 60 * 1000) {
return plural(diff / (24 * 60 * 60 * 1000), "day") + " ago";
}
return date.toLocaleDateString();
}

View file

@ -1,15 +0,0 @@
function plural(num: number, unit: string): string {
const whole = Math.floor(num)
return whole + ' ' + unit + (whole === 1 ? '' : 's')
}
export function ago(date: Date): string {
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 1000) return 'just now'
if (diff < 60 * 1000) return plural(diff / 1000, 'second') + ' ago'
if (diff < 60 * 60 * 1000) return plural(diff / (60 * 1000), 'minute') + ' ago'
if (diff < 24 * 60 * 60 * 1000) return plural(diff / (60 * 60 * 1000), 'hour') + ' ago'
if (diff < 7 * 24 * 60 * 60 * 1000) return plural(diff / (24 * 60 * 60 * 1000), 'day') + ' ago'
return date.toLocaleDateString()
}

View file

@ -1,5 +0,0 @@
// Domain command types: stable UI intents, translated to OpenAPI at the SDK boundary
export type PurchaseExisting = { type: 'existing'; id: number; personId: number; ingredientId?: number | null }
export type PurchaseRefs = { type: 'refs'; personId: number; ingredientId?: number | null; recipeId?: number | null; mealId?: number | null }
export type PurchaseRequest = PurchaseExisting | PurchaseRefs

View file

@ -1,152 +0,0 @@
import type { Recipe, Meal, MealRecipe, Ingredient as DomainIngredient, ShoppingList, ShoppingListItem, ShoppingListItemWithRefs, MealInput, ListIngredientItem, RequestedMealItem, ListIngredientItemWithRefs, RequestedMealItemWithRefs } from './types'
import type { components } from '@/api/types'
export function toDate(value: string | Date | null | undefined): Date | null {
if (value === null || value === undefined) return null
return typeof value === 'string' ? new Date(value) : value
}
// Small helper to decode optional lookup maps without repeating loops everywhere
export function decodeLookup<TIn, TOut>(
raw: Record<string, TIn> | null | undefined,
decode: (v: TIn) => TOut
): Record<string, TOut> | undefined {
if (!raw) return undefined
const out: Record<string, TOut> = {}
for (const key of Object.keys(raw)) {
if (!Object.prototype.hasOwnProperty.call(raw, key)) continue
const maybe = raw[key]
if (maybe === undefined) continue
const decoded = decode(maybe)
out[String(key)] = decoded
}
return out
}
export function decodeRecipe(
r: components['schemas']['RecipeOut'] | components['schemas']['Recipe-Output'] | null | undefined
): Recipe {
if (!r) throw new Error('Invalid recipe payload')
// Normalize arrays that may be optional in legacy Recipe-Output
const imageUrls = r.imageUrls ?? []
const ingredients = r.ingredients ?? []
return {
...r,
imageUrls,
ingredients,
dateCreated: toDate(r.dateCreated),
dateHidden: toDate(r.dateHidden),
}
}
export function decodeMeal(
m: components['schemas']['MealOut'] | components['schemas']['Meal-Output'] | null | undefined
): Meal {
if (!m) throw new Error('Invalid meal payload')
const recipes = Array.isArray(m.recipes)
? m.recipes.map((mr) => decodeMealRecipe(mr))
: []
return {
...m,
suggestedDate: toDate(m.suggestedDate),
purchaseDate: toDate(m.purchaseDate),
consumedDate: toDate(m.consumedDate),
recipes,
chefs: m.chefs ?? [],
consumers: m.consumers ?? [],
cleanup: m.cleanup ?? [],
extraIngredients: m.extraIngredients ?? [],
}
}
function decodeMealRecipe(mr: components['schemas']['MealRecipe-Output'] | null | undefined): MealRecipe {
if (!mr) throw new Error('Invalid meal recipe payload')
return {
...mr,
recipe: mr.recipe ? decodeRecipe(mr.recipe) : null,
}
}
export function decodeIngredient(i: components['schemas']['Ingredient'] | null | undefined): DomainIngredient {
if (!i) throw new Error('Invalid ingredient payload')
// API guarantees quantity is a number; pass through
return { ...i }
}
export function decodeIngredients(list: components['schemas']['Ingredient'][] | null | undefined): DomainIngredient[] {
if (!Array.isArray(list)) return []
return list.map((i) => decodeIngredient(i))
}
function decodeShoppingListItem(i: components['schemas']['ListIngredientItem'] | null | undefined): ShoppingListItem {
if (!i) throw new Error('Invalid shopping list item payload')
return {
...i,
createdDate: toDate(i.createdDate),
}
}
export function decodeShoppingListItems(list: components['schemas']['ListIngredientItem'][] | null | undefined): ShoppingListItemWithRefs[] {
if (!Array.isArray(list)) return []
// Build a new array with item clones to allow optional refs to be attached later
return list.map((raw) => ({ ...decodeShoppingListItem(raw) }))
}
export function decodeShoppingList(v: components['schemas']['ShoppingListOut'] | null | undefined): ShoppingList {
if (!v) throw new Error('Invalid shopping list payload')
const { items: rawItems, ...rest } = v
const items = Array.isArray(rawItems) ? decodeShoppingListItems(rawItems) : undefined
return {
...rest,
createdDate: toDate(v.createdDate),
...(items ? { items } : {}),
}
}
// New item decoders for tightened CurrentShoppingList
function decodeListIngredientItem(i: components['schemas']['ListIngredientItem'] | null | undefined): ListIngredientItem {
if (!i) throw new Error('Invalid list ingredient item payload')
return {
...i,
createdDate: toDate(i.createdDate),
}
}
export function decodeListIngredientItems(list: components['schemas']['ListIngredientItem'][] | null | undefined): ListIngredientItemWithRefs[] {
if (!Array.isArray(list)) return []
return list.map((raw) => ({ ...decodeListIngredientItem(raw) }))
}
function decodeRequestedMealItem(i: components['schemas']['RequestedMealItem'] | null | undefined): RequestedMealItem {
if (!i) throw new Error('Invalid requested meal item payload')
return {
...i,
createdDate: toDate(i.createdDate),
}
}
export function decodeRequestedMealItems(list: components['schemas']['RequestedMealItem'][] | null | undefined): RequestedMealItemWithRefs[] {
if (!Array.isArray(list)) return []
return list.map((raw) => ({ ...decodeRequestedMealItem(raw) }))
}
// Helper to convert domain Meal to MealInput, keeping Date→string conversion in boundary
export function toMealInput(meal: Meal): MealInput {
return {
id: meal.id,
suggestedDate: meal.suggestedDate ? meal.suggestedDate.toISOString() : new Date().toISOString(),
consumedDate: meal.consumedDate ? meal.consumedDate.toISOString() : null,
purchaseDate: meal.purchaseDate ? meal.purchaseDate.toISOString() : null,
chefs: meal.chefs,
cleanup: meal.cleanup,
consumers: meal.consumers,
recipes: meal.recipes.map((r) => ({
mealId: r.mealId,
recipeId: r.recipeId,
servings: r.servings,
recipe: null,
})),
extraIngredients: meal.extraIngredients,
}
}

View file

@ -1,13 +0,0 @@
export type Page<T> = { items: T[]; next?: string | null; prev?: string | null; total?: number | null }
type PageLike<T> = { items?: T[]; nextCursor?: string | null; prevCursor?: string | null; total?: number | null }
export function fromOpenApiPage<TIn, TOut>(page: PageLike<TIn> | null | undefined, mapItem: (i: TIn) => TOut): Page<TOut> {
if (!page) return { items: [] }
return {
items: (page.items ?? []).map((i) => mapItem(i)),
next: page.nextCursor ?? null,
prev: page.prevCursor ?? null,
total: page.total ?? null,
}
}

View file

@ -1,74 +0,0 @@
import type { components } from '@/api/types'
// Utility mapped types
export type Replace<T, M> = Omit<T, keyof M> & M
export type WithDates<T, K extends keyof T> = Replace<T, { [P in K]: Date | null }>
// Convert selected array keys to their non-nullable array counterparts
export type NonNullableArrays<T, K extends keyof T> = Replace<T, { [P in K]: NonNullable<T[P]> extends Array<infer U> ? U[] : T[P] }>
// Make selected keys required (non-optional) on a type
export type RequiredKeys<T, K extends keyof T> = Replace<T, { [P in K]-?: NonNullable<T[P]> }>
// Common helpers (intentionally minimal to avoid unused exports)
export type Lookup<T> = Record<string, T>
// Refs are optional and may be attached later by mappers (e.g., in sdk)
export type WithRefs<T, Refs extends object> = T & { [K in keyof Refs]?: Refs[K] | undefined }
// Domain type aliases
export type RecipeOut = components['schemas']['RecipeOut']
export type MealOut = components['schemas']['MealOut']
export type Ingredient = components['schemas']['Ingredient']
export type Product = components['schemas']['Product']
export type Person = components['schemas']['Person']
export type RecipeInput = components['schemas']['Recipe-Input']
export type MealInput = components['schemas']['Meal-Input']
export type MealRecipeOut = components['schemas']['MealRecipe-Output']
// Domain shapes: only adjust where UI needs Dates
export type Recipe = WithDates<RecipeOut, 'dateCreated' | 'dateHidden'>
// MealRecipe with decoded recipe dates
export type MealRecipe = Replace<MealRecipeOut, { recipe: Recipe | null }>
// Meal with decoded dates and nested MealRecipe with decoded recipe dates
// Arrays (chefs, consumers, cleanup, recipes, extraIngredients) are non-nullable per OpenAPI spec
type MealBase = Replace<WithDates<MealOut, 'suggestedDate' | 'consumedDate' | 'purchaseDate'>, { recipes: MealRecipe[] }>
export type Meal = RequiredKeys<
NonNullableArrays<MealBase, 'chefs' | 'consumers' | 'cleanup' | 'extraIngredients'>,
'recipes' | 'chefs' | 'consumers' | 'cleanup' | 'extraIngredients'
>
// Shopping domain shapes with dates normalized
// Items inside purchased lists and current lists share the ListIngredientItem shape
export type ShoppingListItem = WithDates<components['schemas']['ListIngredientItem'], 'createdDate'>
type ShoppingListBase = WithDates<components['schemas']['ShoppingListOut'], 'createdDate'>
export type ShoppingList = Replace<ShoppingListBase, { items?: ShoppingListItem[] | undefined }>
// Current shopping list item types (tightened OpenAPI)
export type ListIngredientItem = WithDates<components['schemas']['ListIngredientItem'], 'createdDate'>
export type RequestedMealItem = WithDates<components['schemas']['RequestedMealItem'], 'createdDate'>
// Refs attached to items
export type ShoppingListItemWithRefs = WithRefs<ShoppingListItem, { ingredient: Ingredient; recipe: Recipe; meal: Meal; list: ShoppingList }>
export type ListIngredientItemWithRefs = WithRefs<ListIngredientItem, { ingredient: Ingredient; recipe: Recipe; meal: Meal; list: ShoppingList }>
export type RequestedMealItemWithRefs = WithRefs<RequestedMealItem, { meal: Meal }>
export type ShoppingListWithRefs = Replace<ShoppingList, { items?: ShoppingListItemWithRefs[] }>
// Lookup maps used by shopping mappings (all optional; mappers may omit absent ones)
export type ShoppingLookups = {
ingredientsLookup?: Lookup<Ingredient>
mealsLookup?: Lookup<Meal>
recipesLookup?: Lookup<Recipe>
shoppingListLookup?: Lookup<ShoppingList>
}
// DTO shapes returned by SDK for shopping pages
export type CurrentShoppingListDTO = {
outstandingItems: ListIngredientItemWithRefs[]
requestedMeals: RequestedMealItemWithRefs[]
purchasedItems: ListIngredientItemWithRefs[]
} & ShoppingLookups
export type PurchasedShoppingListDTO = ShoppingLookups & {
list?: (ShoppingList & { items?: ShoppingListItemWithRefs[] }) | undefined
}

10
src/env.d.ts vendored
View file

@ -1,10 +0,0 @@
/* Minimal ambient typing for optional import.meta.env usage in tooling */
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
}
declare interface ImportMeta {
readonly env?: ImportMetaEnv
}
export {}

51
src/main.js Normal file
View file

@ -0,0 +1,51 @@
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import App from './App.vue'
import LoginPage from './components/LoginPage.vue'
import RecipesPage from './components/recipes/RecipesPage.vue'
import MealPlanPage from './components/meals/MealPlanPage.vue'
import MyShoppingPage from './components/shopping/MyShoppingPage.vue'
import PurchasedShoppingListPage from './components/shopping/PurchasedShoppingListPage.vue'
import CurrentShoppingListPage from './components/shopping/CurrentShoppingListPage.vue'
import EditMealPage from './components/meals/EditMealPage.vue'
import EditRecipePage from './components/recipes/EditRecipePage.vue'
// 2. Define some routes
// Each route should map to a component.
// We'll talk about nested routes later.
const routes = [
// Redirect index to mealplan
{ path: '/', redirect: '/mealplan' },
{ path: '/mealplan', component: MealPlanPage },
{ path: '/login', component: LoginPage },
{ path: '/shopping', component: MyShoppingPage },
{ path: '/shopping/current', component: CurrentShoppingListPage },
{ path: '/shopping/:id', component: PurchasedShoppingListPage, props : true },
{ path: '/recipes', component: RecipesPage },
{ path: '/recipes/add', component: EditRecipePage },
{ path: '/recipes/:id', component: EditRecipePage, props: true },
{ path: '/meals/add', component: EditMealPage },
{ path: '/meals/:id', component: EditMealPage, props: true },
]
// 3. Create the router instance and pass the `routes` option
// You can pass in additional options here, but let's
// keep it simple for now.
const router = createRouter({
// 4. Provide the history implementation to use. We are using the hash history for simplicity here.
history: createWebHashHistory(),
routes, // short for `routes: routes`
})
// 5. Create and mount the root instance.
const app = createApp(App)
// Make sure to _use_ the router instance to make the
// whole app router-aware.
app.use(router)
app.mount('#app')
// Now the app has started!

View file

@ -1,11 +0,0 @@
import { createApp } from 'vue'
import App from './App.vue'
import { createAppRouter } from '@/router'
import { currentUser } from '@/api/auth'
// Create the router with an auth callback to check current user
const router = createAppRouter(() => currentUser())
const app = createApp(App)
app.use(router)
app.mount('#app')

View file

@ -1,27 +0,0 @@
/**
* Route parameter and query parsing helpers
* Centralizes route param type handling to avoid runtime checks in components
*/
import type { LocationQueryValue } from 'vue-router'
/**
* Parse a route param (string | string[] | undefined) to a number ID
* Returns null if param is missing or invalid
*/
export function parseRouteId(param: string | string[] | undefined): number | null {
if (!param) return null
const str = Array.isArray(param) ? param[0] : param
if (!str) return null
const id = parseInt(str, 10)
return isNaN(id) || id < 0 ? null : id
}
/**
* Parse a route query param to a string
* Returns empty string if param is missing, null, or is an array
*/
export function parseQueryString(param: LocationQueryValue | LocationQueryValue[] | undefined): string {
if (!param) return ''
return Array.isArray(param) ? '' : param
}

View file

@ -1,79 +0,0 @@
import { createRouter, createWebHashHistory, Router } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
// Lazy-loaded route components
const LoginPage = () => import('@/components/LoginPage.vue')
const RecipesPage = () => import('@/components/recipes/RecipesPage.vue')
const MealPlanPage = () => import('@/components/meals/MealPlanPage.vue')
const MyShoppingPage = () => import('@/components/shopping/MyShoppingPage.vue')
const PurchasedShoppingListPage = () => import('@/components/shopping/PurchasedShoppingListPage.vue')
const CurrentShoppingListPage = () => import('@/components/shopping/CurrentShoppingListPage.vue')
const EditMealPage = () => import('@/components/meals/EditMealPage.vue')
const EditRecipePage = () => import('@/components/recipes/EditRecipePage.vue')
export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown): Router {
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: { name: 'mealplan' } },
{ path: '/login', name: 'login', component: LoginPage },
{ path: '/mealplan', name: 'mealplan', component: MealPlanPage, meta: { requiresAuth: true } },
{
path: '/shopping',
name: 'shopping',
component: MyShoppingPage,
meta: { requiresAuth: true },
},
{
path: '/shopping/current',
name: 'shopping-current',
component: CurrentShoppingListPage,
meta: { requiresAuth: true },
},
{
path: '/shopping/:id',
name: 'shopping-list',
component: PurchasedShoppingListPage,
props: true,
meta: { requiresAuth: true },
},
{ path: '/recipes', name: 'recipes', component: RecipesPage, meta: { requiresAuth: true } },
{
path: '/recipes/add',
name: 'recipe-add',
component: EditRecipePage,
meta: { requiresAuth: true },
},
{
path: '/recipes/:id',
name: 'recipe-edit',
component: EditRecipePage,
props: true,
meta: { requiresAuth: true },
},
{ path: '/meals/add', name: 'meal-add', component: EditMealPage, meta: { requiresAuth: true } },
{
path: '/meals/:id',
name: 'meal-edit',
component: EditMealPage,
props: true,
meta: { requiresAuth: true },
},
]
const router = createRouter({
history: createWebHashHistory(),
routes,
})
router.beforeEach(async (to) => {
if (!to.meta.requiresAuth) return true
try {
const user = await getCurrentUser()
if (user) return true
} catch (_) {
/* ignore */
}
return { name: 'login', query: { redirect: to.fullPath } }
})
return router
}

10
src/shims-vue.d.ts vendored
View file

@ -1,10 +0,0 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
// Use safer defaults to satisfy strict eslint rules (no banned {} or any)
const component: DefineComponent<
Record<string, unknown>,
Record<string, unknown>,
unknown
>
export default component
}

4
src/units.d.ts vendored
View file

@ -1,4 +0,0 @@
export type QuantityTotal = { quantity: number; unit: string }
export declare function calculateTotals(
parts: Array<{ quantity: number; unit: string }>
): QuantityTotal[]

142
src/units.js Normal file
View file

@ -0,0 +1,142 @@
export const equivalentUnits = {
'kg': {
'kgs': 1,
'kilograms': 1,
'kilogram': 1,
'g': 1000,
'gram': 1000,
'grams': 1000,
'lb': 2.20462,
'lbs': 2.20462,
'pound': 2.20462,
'pounds': 2.20462,
},
'litres': {
'l': 1,
'liter': 1,
'litre': 1,
'ml': 1000,
'milliliters': 1000,
'milliliter': 1000,
'fl oz': 33.814,
'fluid ounce': 33.814,
'fluid ounces': 33.814,
'cup': 4.22675,
'cups': 4.22675,
'tbsp': 67.628,
'tablespoon': 67.628,
'tablespoons': 67.628,
'tsp': 202.884,
'teaspoon': 202.884,
'teaspoons': 202.884,
'pt': 2.11338,
'pint': 2.11338,
'pints': 2.11338,
'qt': 1.05669,
'quart': 1.05669,
'quarts': 1.05669,
'gal': 0.264172,
'gallon': 0.264172,
'gallons': 0.264172,
'oz': 35.1951,
'ounce': 35.1951,
},
'items': {
'item': 1,
'items': 1,
'pcs': 1,
'piece': 1,
'pieces': 1,
'florets': 8, // Broccoli
'head': 1, // Broccoli
'heads': 1, // Broccoli
'slice': 10, // Bread
'slices': 10, // Bread
'loaf': 1, // Bread
'loaves': 1, // Bread
'cloves': 8, // Garlic
'bulb': 1, // Garlic
'bulbs': 1, // Garlic
'stalk': 1, // Celery
'stalks': 1, // Celery
'bunch': 1, // Cilantro
'bunches': 1, // Cilantro
'sprig': 1, // Cilantro
'sprigs': 1, // Cilantro
'cans': 1, // Canned goods
'can': 1, // Canned goods
'pack': 1, // Packaged goods
'packs': 1, // Packaged goods
'package': 1, // Packaged goods
'packages': 1, // Packaged goods
'container': 1, // Packaged goods
'containers': 1, // Packaged goods
},
}
function getBaseUnit(unit) {
for (const unitType in equivalentUnits) {
if (unit in equivalentUnits[unitType]) {
return unitType;
}
}
return null;
}
export function getConversionFactor(unit) {
if (unit in equivalentUnits) {
return { unit, factor: 1 };
}
const unitLower = unit.toLowerCase();
if (unitLower in equivalentUnits) {
return { unit: unitLower, factor: 1 };
}
const baseUnit = getBaseUnit(unit);
if (baseUnit) {
return {
unit: baseUnit,
factor: equivalentUnits[baseUnit][unit],
};
}
const baseUnitLower = getBaseUnit(unitLower);
if (baseUnitLower) {
return {
unit: baseUnitLower,
factor: equivalentUnits[baseUnitLower][unitLower],
};
}
return null;
}
export function calculateTotals(quantityList) {
const totals = {};
for (const quantity of quantityList) {
const baseUnit = getConversionFactor(quantity.unit) ?? { unit: quantity.unit, factor: 1 };
const factor = baseUnit.factor;
const unit = baseUnit.unit;
if (!totals[unit]) {
totals[unit] = 0;
}
totals[unit] += quantity.quantity / factor;
}
return Object.keys(totals).map(unit => ({ unit, quantity: totals[unit] }));
}

View file

@ -1,139 +0,0 @@
const UNIT_KEYS_ARRAY: readonly ['kg', 'litres', 'items'] = ['kg', 'litres', 'items']
type UnitKey = typeof UNIT_KEYS_ARRAY[number]
const equivalentUnits: Record<UnitKey, Record<string, number>> = {
kg: {
kgs: 1,
kilograms: 1,
kilogram: 1,
g: 1000,
gram: 1000,
grams: 1000,
lb: 2.20462,
lbs: 2.20462,
pound: 2.20462,
pounds: 2.20462,
},
litres: {
l: 1,
liter: 1,
litre: 1,
ml: 1000,
milliliters: 1000,
milliliter: 1000,
'fl oz': 33.814,
'fluid ounce': 33.814,
'fluid ounces': 33.814,
cup: 4.22675,
cups: 4.22675,
tbsp: 67.628,
tablespoon: 67.628,
tablespoons: 67.628,
tsp: 202.884,
teaspoon: 202.884,
teaspoons: 202.884,
pt: 2.11338,
pint: 2.11338,
pints: 2.11338,
qt: 1.05669,
quart: 1.05669,
quarts: 1.05669,
gal: 0.264172,
gallon: 0.264172,
gallons: 0.264172,
oz: 35.1951,
ounce: 35.1951,
},
items: {
item: 1,
items: 1,
pcs: 1,
piece: 1,
pieces: 1,
florets: 8, // Broccoli
head: 1, // Broccoli
heads: 1, // Broccoli
slice: 10, // Bread
slices: 10, // Bread
loaf: 1, // Bread
loaves: 1, // Bread
cloves: 8, // Garlic
bulb: 1, // Garlic
bulbs: 1, // Garlic
stalk: 1, // Celery
stalks: 1, // Celery
bunch: 1, // Cilantro
bunches: 1, // Cilantro
sprig: 1, // Cilantro
sprigs: 1, // Cilantro
cans: 1, // Canned goods
can: 1, // Canned goods
pack: 1, // Packaged goods
packs: 1, // Packaged goods
package: 1, // Packaged goods
packages: 1, // Packaged goods
container: 1, // Packaged goods
containers: 1, // Packaged goods
},
}
const UNIT_KEYS_SET: ReadonlySet<string> = new Set(UNIT_KEYS_ARRAY)
function isUnitKey(value: string): value is UnitKey {
return UNIT_KEYS_SET.has(value)
}
function getBaseUnit(unit: string): UnitKey | null {
for (const type of UNIT_KEYS_ARRAY) {
const group = equivalentUnits[type]
if (unit in group) return type
}
return null
}
export function getConversionFactor(unit: string): { unit: UnitKey | string; factor: number } | null {
if (isUnitKey(unit)) return { unit, factor: 1 }
const unitLower = String(unit).toLowerCase()
if (isUnitKey(unitLower)) return { unit: unitLower, factor: 1 }
const baseUnit = getBaseUnit(unit)
if (baseUnit) {
const factor = equivalentUnits[baseUnit]?.[unit]
if (typeof factor === 'number') return { unit: baseUnit, factor }
return null
}
const baseUnitLower = getBaseUnit(unitLower)
if (baseUnitLower) {
const factor = equivalentUnits[baseUnitLower]?.[unitLower]
if (typeof factor === 'number') return { unit: baseUnitLower, factor }
return null
}
return null
}
type Quantity = { quantity: number; unit: string }
type Total = { unit: string; quantity: number }
export function calculateTotals(quantityList: Quantity[]): Total[] {
const totals: Record<string, number> = {}
for (const quantity of quantityList) {
const baseUnit = getConversionFactor(quantity.unit) ?? { unit: quantity.unit, factor: 1 }
const factor = baseUnit.factor
const unit = String(baseUnit.unit)
totals[unit] = (totals[unit] ?? 0) + quantity.quantity / factor
}
return Object.keys(totals).map((unit) => ({ unit, quantity: totals[unit] ?? 0 }))
}

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

@ -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

@ -1,48 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mapCurrentShoppingList, mapPurchasedShoppingList } from '@/api/sdk'
// These tests lock boundary behavior for partial/missing lookups and date normalization
describe('shopping mappers boundary', () => {
it('handles missing lookups gracefully (no refs attached)', () => {
const dto = {
outstandingItems: [
{ ingredientId: 10, mealId: 1, recipeId: 5, listId: 7, createdDate: '2025-01-01T00:00:00Z' },
],
requestedMeals: [
{ mealId: 2, createdDate: '2025-01-02T00:00:00Z' },
],
purchasedItems: [],
}
const mapped = mapCurrentShoppingList(dto as any)
expect(mapped.outstandingItems[0].ingredient).toBeUndefined()
expect(mapped.outstandingItems[0].meal).toBeUndefined()
expect(mapped.outstandingItems[0].recipe).toBeUndefined()
expect(mapped.outstandingItems[0].list).toBeUndefined()
expect(mapped.requestedMeals[0].meal).toBeUndefined()
// Dates normalized
expect(mapped.outstandingItems[0].createdDate).toBeInstanceOf(Date)
expect(mapped.requestedMeals[0].createdDate).toBeInstanceOf(Date)
})
it('normalizes dates on purchased list and items even with partial lookups', () => {
const dto = {
ingredientsLookup: { 10: { id: 10, name: 'Milk' } },
list: {
id: 11,
createdDate: '2025-03-03T00:00:00Z',
items: [
{ ingredientId: 10, listId: 11, createdDate: '2025-03-03T00:00:00Z' },
{ ingredientId: 99, listId: 11, createdDate: '2025-03-03T00:00:00Z' },
],
},
}
const mapped = mapPurchasedShoppingList(dto as any)
expect(mapped.list.createdDate).toBeInstanceOf(Date)
expect(mapped.list.items[0].createdDate).toBeInstanceOf(Date)
expect(mapped.list.items[1].createdDate).toBeInstanceOf(Date)
// First item gets ingredient ref, second does not
expect(mapped.list.items[0].ingredient?.name).toBe('Milk')
expect(mapped.list.items[1].ingredient).toBeUndefined()
})
})

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

@ -1,24 +0,0 @@
import { afterAll, afterEach, beforeAll } from 'vitest'
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
// Ensure global fetch is available in Node tests (Node 18+ provides global fetch)
// Do not override so MSW can intercept.
// Some CI envs inject corporate roots causing TLS issues; disable cert checks in tests only
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
// Ensure MSW intercepts local requests without going through a proxy
process.env.HTTP_PROXY = ''
process.env.HTTPS_PROXY = ''
process.env.ALL_PROXY = ''
process.env.NO_PROXY = '*'
export const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
export { http, HttpResponse }

View file

@ -1,27 +0,0 @@
import { describe, it, expect } from 'vitest'
import { getConversionFactor, calculateTotals } from '@/units'
describe('units', () => {
it('returns conversion factors for aliases and base units', () => {
expect(getConversionFactor('kg')).toEqual({ unit: 'kg', factor: 1 })
expect(getConversionFactor('g')).toEqual({ unit: 'kg', factor: 1000 })
expect(getConversionFactor('kgs')).toEqual({ unit: 'kg', factor: 1 })
expect(getConversionFactor('litre')).toEqual({ unit: 'litres', factor: 1 })
expect(getConversionFactor('ml')).toEqual({ unit: 'litres', factor: 1000 })
})
it('calculates totals grouped by base units', () => {
const totals = calculateTotals([
{ quantity: 500, unit: 'g' },
{ quantity: 0.5, unit: 'kg' },
{ quantity: 250, unit: 'ml' },
{ quantity: 0.75, unit: 'litre' },
])
// Expect kg total = 0.5 (from g) + 0.5 (from kg) = 1
const kgTotal = totals.find((t) => t.unit === 'kg')
expect(kgTotal.quantity).toBeCloseTo(1)
// Expect litres total = 0.25 (from ml) + 0.75 (from litre) = 1
const lTotal = totals.find((t) => t.unit === 'litres')
expect(lTotal.quantity).toBeCloseTo(1)
})
})

View file

@ -1,26 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useAlert } from '@/composables/useAlert'
describe('useAlert', () => {
beforeEach(() => {
vi.useFakeTimers()
})
it('shows and clears alerts', () => {
const { current, show, clear } = useAlert()
expect(current.value).toBeNull()
show({ heading: 'Hello', message: 'World', type: 'info' })
expect(current.value).toMatchObject({ heading: 'Hello', message: 'World', type: 'info' })
clear()
expect(current.value).toBeNull()
})
it('auto-dismisses after scheduleAutoDismiss', () => {
const { current, show, scheduleAutoDismiss } = useAlert()
show({ heading: 'Auto', message: 'Dismiss', type: 'success' })
scheduleAutoDismiss(5000)
expect(current.value).not.toBeNull()
vi.advanceTimersByTime(5000)
expect(current.value).toBeNull()
})
})

View file

@ -1,20 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": [
"src/**/*.ts",
"src/**/*.vue",
"src/**/*.d.ts"
],
"exclude": [
"node_modules",
"dist",
"tests/**/*.js",
"*.config.js",
"babel.config.js",
"vitest.config.js",
"vue.config.js"
]
}

View file

@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"moduleResolution": "Bundler",
"jsx": "preserve",
"allowJs": false,
"checkJs": false,
"skipLibCheck": true,
"types": ["node"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}

View file

@ -1,17 +0,0 @@
import { defineConfig } from 'vitest/config'
import { fileURLToPath } from 'node:url'
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
environment: 'node',
include: ['tests/**/*.{test,spec}.{js,ts}'],
globals: true,
reporters: 'default',
setupFiles: ['tests/test-setup.js'],
},
})

View file

@ -1,6 +1,4 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { defineConfig } = require('@vue/cli-service')
/* eslint-enable @typescript-eslint/no-var-requires */
module.exports = defineConfig({
transpileDependencies: true,
transpileDependencies: true
})