Compare commits
23 commits
nullproduc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b08561c9bb | |||
| f2dee9ca30 | |||
| 211489ca55 | |||
| fd35640fd8 | |||
| a455b3a3aa | |||
| bd99d90c09 | |||
| 72fccdd8a3 | |||
| d1534934c0 | |||
| 68e82f1fa2 | |||
| d31182dff0 | |||
| beab02200c | |||
| 61ca41d25a | |||
| 3be1027154 | |||
| 661d5f4840 | |||
| f0a8adbd77 | |||
| 28ad3a16ff | |||
| 2cc87e1891 | |||
| 8797a5489c | |||
| a6904524db | |||
| ec28531df9 | |||
| ee2f08cbfc | |||
| 59e3d8ab16 | |||
| 6381c55c52 |
81 changed files with 13096 additions and 3411 deletions
12
.editorconfig
Normal file
12
.editorconfig
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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
|
||||||
3
.env.example
Normal file
3
.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Base URL for the backend API
|
||||||
|
# Example: http://localhost:8081
|
||||||
|
VUE_APP_API_BASE=
|
||||||
45
.github/workflows/ci.yml
vendored
Normal file
45
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
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
|
||||||
4
.husky/pre-commit
Normal file
4
.husky/pre-commit
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/sh
|
||||||
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npx lint-staged
|
||||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": false,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
59
CONTRIBUTING.md
Normal file
59
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# 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 well‑scoped.
|
||||||
|
|
||||||
|
## 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
110
README.md
|
|
@ -1,24 +1,112 @@
|
||||||
# doof-front
|
## Munch Ease — Plan, Cook, Shop, Repeat
|
||||||
|
|
||||||
## Project setup
|
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 what’s 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
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compiles and hot-reloads for development
|
2) Run the dev server
|
||||||
```
|
|
||||||
|
```bash
|
||||||
npm run serve
|
npm run serve
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compiles and minifies for production
|
3) Run unit tests (Vitest)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
4) Build for production
|
||||||
|
|
||||||
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Lints and fixes files
|
Environment
|
||||||
```
|
- API base URL: set VUE_APP_API_BASE (e.g. http://localhost:8081)
|
||||||
npm run lint
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Customize configuration
|
- Generate API types (consumed by SDK):
|
||||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
|
||||||
|
```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
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
presets: [
|
presets: ['@vue/cli-plugin-babel/preset'],
|
||||||
'@vue/cli-plugin-babel/preset'
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,8 @@
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": ["src/*"]
|
||||||
"src/*"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"lib": [
|
"lib": ["esnext", "dom", "dom.iterable", "scripthost"]
|
||||||
"esnext",
|
|
||||||
"dom",
|
|
||||||
"dom.iterable",
|
|
||||||
"scripthost"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7866
package-lock.json
generated
7866
package-lock.json
generated
File diff suppressed because it is too large
Load diff
99
package.json
99
package.json
|
|
@ -5,35 +5,112 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"serve": "vue-cli-service serve",
|
"serve": "vue-cli-service serve",
|
||||||
"build": "vue-cli-service build",
|
"build": "vue-cli-service build",
|
||||||
"lint": "vue-cli-service lint"
|
"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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "^3.8.3",
|
|
||||||
"vue": "^3.5.12",
|
"vue": "^3.5.12",
|
||||||
"vue-router": "^4.4.5"
|
"vue-router": "^4.4.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.12.16",
|
"@types/node": "^20.19.22",
|
||||||
"@babel/eslint-parser": "^7.12.16",
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
|
|
||||||
"@vue/cli-plugin-babel": "~5.0.0",
|
"@vue/cli-plugin-babel": "~5.0.0",
|
||||||
"@vue/cli-plugin-eslint": "~5.0.0",
|
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||||
|
"@vue/cli-plugin-typescript": "~5.0.0",
|
||||||
"@vue/cli-service": "~5.0.0",
|
"@vue/cli-service": "~5.0.0",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-plugin-vue": "^8.0.3"
|
"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"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"root": true,
|
"root": true,
|
||||||
"env": {
|
"env": {
|
||||||
"node": true
|
"node": true,
|
||||||
|
"vue/setup-compiler-macros": true
|
||||||
},
|
},
|
||||||
"extends": [
|
"extends": [
|
||||||
"plugin:vue/vue3-essential",
|
"plugin:vue/vue3-recommended",
|
||||||
"eslint:recommended"
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended"
|
||||||
|
],
|
||||||
|
"parser": "vue-eslint-parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"sourceType": "module",
|
||||||
|
"ecmaVersion": 2020,
|
||||||
|
"extraFileExtensions": [
|
||||||
|
".vue"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"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": {
|
"parserOptions": {
|
||||||
"parser": "@babel/eslint-parser"
|
"project": [
|
||||||
|
"./tsconfig.eslint.json"
|
||||||
|
],
|
||||||
|
"tsconfigRootDir": "."
|
||||||
},
|
},
|
||||||
"rules": {}
|
"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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"> 1%",
|
"> 1%",
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="">
|
<html lang="">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
|
||||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>
|
<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>
|
</noscript>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<!-- built files will be auto injected -->
|
<!-- built files will be auto injected -->
|
||||||
|
|
|
||||||
32
scripts/codegen-check.js
Normal file
32
scripts/codegen-check.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
#!/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)
|
||||||
|
}
|
||||||
48
src/App.vue
48
src/App.vue
|
|
@ -2,13 +2,31 @@
|
||||||
<div>
|
<div>
|
||||||
<ul class="nav">
|
<ul class="nav">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<router-link class="nav-link" to="/recipes" active-class="active">Recipes</router-link>
|
<router-link
|
||||||
|
class="nav-link"
|
||||||
|
:to="{ name: 'recipes' }"
|
||||||
|
active-class="active"
|
||||||
|
>
|
||||||
|
Recipes
|
||||||
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<router-link class="nav-link" to="/mealplan" active-class="active">Meal Plan</router-link>
|
<router-link
|
||||||
|
class="nav-link"
|
||||||
|
:to="{ name: 'mealplan' }"
|
||||||
|
active-class="active"
|
||||||
|
>
|
||||||
|
Meal Plan
|
||||||
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<router-link class="nav-link" to="/shopping" active-class="active">Shopping</router-link>
|
<router-link
|
||||||
|
class="nav-link"
|
||||||
|
:to="{ name: 'shopping' }"
|
||||||
|
active-class="active"
|
||||||
|
>
|
||||||
|
Shopping
|
||||||
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -19,30 +37,13 @@
|
||||||
<alert-toast />
|
<alert-toast />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import data from './data.js'
|
|
||||||
import AlertToast from './components/AlertToast.vue'
|
import AlertToast from './components/AlertToast.vue'
|
||||||
|
|
||||||
export default {
|
// components in <script setup> are auto-registered by import + usage
|
||||||
name: 'App',
|
|
||||||
components: {
|
|
||||||
'alert-toast': AlertToast
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
currentRoute() {
|
|
||||||
return this.$route.path
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async mounted() {
|
|
||||||
if (!await data.currentUser()) {
|
|
||||||
this.$router.push('/login')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
|
@ -86,7 +87,7 @@ export default {
|
||||||
|
|
||||||
/* Have active route use different color */
|
/* Have active route use different color */
|
||||||
.nav li:has(> a.active) {
|
.nav li:has(> a.active) {
|
||||||
background-color: #4CAF50;
|
background-color: #4caf50;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,5 +95,4 @@ export default {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
10
src/alert.js
10
src/alert.js
|
|
@ -1,10 +0,0 @@
|
||||||
const subscribers = [];
|
|
||||||
|
|
||||||
export default {
|
|
||||||
subscribe(callback) {
|
|
||||||
subscribers.push(callback);
|
|
||||||
},
|
|
||||||
show(message) { // { message, heading, type: ["success", "error", "info"] }
|
|
||||||
subscribers.forEach(callback => callback(message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
30
src/api/auth.ts
Normal file
30
src/api/auth.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
20
src/api/client.ts
Normal file
20
src/api/client.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
361
src/api/sdk.ts
Normal file
361
src/api/sdk.ts
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
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'
|
||||||
1743
src/api/types.ts
Normal file
1743
src/api/types.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,21 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<a @click="$emit('click')">
|
<a @click="emit('click')">
|
||||||
<h2>{{ title }}</h2>
|
<h2>{{ title }}</h2>
|
||||||
<img :src="image" :alt="name" />
|
<img
|
||||||
|
:src="image"
|
||||||
|
:alt="title"
|
||||||
|
>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
const emit = defineEmits(['click'])
|
||||||
name: 'ActionItem',
|
defineProps({
|
||||||
props: ['title', 'image']
|
title: { type: String, required: true },
|
||||||
}
|
image: { type: String, required: true },
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -33,5 +37,4 @@ img {
|
||||||
.card:hover {
|
.card:hover {
|
||||||
background-color: #ccc;
|
background-color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,17 +1,56 @@
|
||||||
<template>
|
<template>
|
||||||
|
<div
|
||||||
<div v-if="showAlert" :class="['alert', type]" @click="dismiss">
|
v-if="showAlert"
|
||||||
<img v-if="icon" :src="icon" alt="Notification icon" />
|
:class="['alert', type]"
|
||||||
|
@click="dismiss"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="icon"
|
||||||
|
:src="icon"
|
||||||
|
alt="Notification icon"
|
||||||
|
>
|
||||||
<div class="message-container">
|
<div class="message-container">
|
||||||
<h4 class="heading">{{ heading }}</h4>
|
<h4 class="heading">
|
||||||
<p class="message">{{ message }}</p>
|
{{ heading }}
|
||||||
|
</h4>
|
||||||
|
<p class="message">
|
||||||
|
{{ message }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 */
|
/* 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 */
|
/* Place the icon to the left for the full height, then have the heading and message stacked to the right */
|
||||||
|
|
||||||
|
|
@ -42,57 +81,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert.success {
|
.alert.success {
|
||||||
background-color: #4CAF50;
|
background-color: #4caf50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert.info {
|
.alert.info {
|
||||||
background-color: #2196F3;
|
background-color: #2196f3;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</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>
|
|
||||||
|
|
@ -1,20 +1,53 @@
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div class="login">
|
<div class="login">
|
||||||
<h1>Login Page</h1>
|
<h1>Login Page</h1>
|
||||||
<ul class="button-group">
|
<ul class="button-group">
|
||||||
<li v-for="person in persons" :key="person.id">
|
<li
|
||||||
<button type="button" class="btn btn-primary" @click="login(person)">
|
v-for="(person, index) in persons"
|
||||||
|
:key="person.id ?? index"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click="onLogin(person)"
|
||||||
|
>
|
||||||
{{ person.name }}
|
{{ person.name }}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 */
|
/* Remove the default list styling */
|
||||||
ul {
|
ul {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
|
@ -68,40 +101,4 @@ li:nth-child(4) > button {
|
||||||
background-color: #9a1f1f;
|
background-color: #9a1f1f;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
</style>
|
</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>
|
|
||||||
|
|
@ -1,44 +1,79 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="compact-parse-results">
|
<div class="compact-parse-results">
|
||||||
<p class="parse-element teaser-image">
|
<p class="parse-element teaser-image">
|
||||||
<img :src="ingredient.product?.img_small ?? require('@/assets/missing-product.svg')" />
|
<img :src="ingredient.product?.imgSmall ?? missingProduct">
|
||||||
</p>
|
</p>
|
||||||
<p class="ingredient-details">
|
<p class="ingredient-details">
|
||||||
<span class="parse-element quantity" :class="{ missing: !(ingredient?.quantity)}">{{ ingredient?.quantity || 'qty' }}</span>
|
<span
|
||||||
<span class="parse-element unit" :class="{ missing: !(ingredient?.unit)}">{{ ingredient?.unit || 'unit' }}</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 helper">of</span>
|
||||||
<span class="parse-element name" :class="{ missing: !(ingredient?.name)}">{{ ingredient?.name || 'name' }}</span>:
|
<span
|
||||||
<span class="parse-element product-name" :class="{missing: !(ingredient?.product)}">
|
class="parse-element name"
|
||||||
<a :href="ingredient?.product?.link" v-if="ingredient?.product?.link" target=”_blank”>
|
:class="{ missing: !ingredient?.name }"
|
||||||
( {{ 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>
|
ingredient?.name || 'name'
|
||||||
<a v-else-if="ingredient?.name" :href="searchlink" target="_blank">
|
}}</span>:
|
||||||
(search?)
|
<span
|
||||||
</a>
|
class="parse-element product-name"
|
||||||
<a v-else>
|
:class="{ missing: !ingredient?.product }"
|
||||||
(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>
|
||||||
|
<a
|
||||||
|
v-else-if="ingredient?.name"
|
||||||
|
:href="searchlink"
|
||||||
|
target="_blank"
|
||||||
|
> (search?) </a>
|
||||||
|
<a v-else> (product) </a>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<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()
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
name: 'CompactParsedIngredient',
|
ingredient: { type: Object, required: true },
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
.compact-parse-results {
|
.compact-parse-results {
|
||||||
flex: left;
|
flex: left;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -58,7 +93,8 @@ export default {
|
||||||
border: solid red 1px;
|
border: solid red 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parse-element.teaser-image, .parse-element.helper {
|
.parse-element.teaser-image,
|
||||||
|
.parse-element.helper {
|
||||||
border: none;
|
border: none;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
@ -89,5 +125,4 @@ export default {
|
||||||
.product-name {
|
.product-name {
|
||||||
color: purple;
|
color: purple;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,31 +1,52 @@
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div :class="{ editing: editing }">
|
<div :class="{ editing: editing }">
|
||||||
<button v-if="editing" @click="$emit('on-add')">
|
<button
|
||||||
<img class="icon" :src="require('@/assets/add-cart.svg')" /> <br />
|
v-if="editing"
|
||||||
|
@click="emit('on-add')"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="icon"
|
||||||
|
:src="addCart"
|
||||||
|
> <br>
|
||||||
Add Ingredient
|
Add Ingredient
|
||||||
</button>
|
</button>
|
||||||
<button @click="toggleEditing" v-if="!editOnly">
|
<button
|
||||||
|
v-if="!editOnly"
|
||||||
|
@click="toggleEditing"
|
||||||
|
>
|
||||||
<span v-if="editing">
|
<span v-if="editing">
|
||||||
<img class="icon" :src="require('@/assets/edit-off.svg')" /> <br />
|
<img
|
||||||
|
class="icon"
|
||||||
|
:src="editOff"
|
||||||
|
> <br>
|
||||||
Done Editing
|
Done Editing
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
<img class="icon" :src="require('@/assets/edit.svg')" /> <br />
|
<img
|
||||||
|
class="icon"
|
||||||
|
:src="editOn"
|
||||||
|
> <br>
|
||||||
Edit My List
|
Edit My List
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="ingredient in ingredients" :key="ingredient">
|
<li
|
||||||
|
v-for="ingredient in ingredients"
|
||||||
|
:key="ingredient"
|
||||||
|
>
|
||||||
<div v-if="editing">
|
<div v-if="editing">
|
||||||
<p class="ingredient-line">
|
<p class="ingredient-line">
|
||||||
<ingredient-line
|
<ingredient-line
|
||||||
:ingredient="ingredient"
|
:ingredient="ingredient"
|
||||||
@update-ingredient="updateIngredient"
|
@update-ingredient="updateIngredient"
|
||||||
@update-product-link="updateProduct" />
|
@update-product-link="updateProduct"
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
<button @click="$emit('on-delete', ingredient)">
|
<button @click="emit('on-delete', ingredient)">
|
||||||
<img class="icon" :src="require('@/assets/trash.svg')" />
|
<img
|
||||||
|
class="icon"
|
||||||
|
:src="trash"
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
|
|
@ -34,11 +55,43 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.icon {
|
||||||
width: 2em;
|
width: 2em;
|
||||||
height: 2em;
|
height: 2em;
|
||||||
|
|
@ -70,38 +123,4 @@ li > div {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-right: 1em;
|
margin-right: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</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>
|
|
||||||
|
|
@ -1,8 +1,20 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="ingredient-item">
|
<div class="ingredient-item">
|
||||||
<p>
|
<p>
|
||||||
<input v-model="ingredientText" @keyup.enter="updateIngredient" @blur="updateIngredient" placeholder="Enter an ingredient" />
|
<input
|
||||||
<input v-model="productLink" v-if="ingredient.line" class="product-link-input" placeholder="Enter product link" @keyup.enter="updateProductLink" @blur="updateProductLink" />
|
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>
|
||||||
<p v-if="ingredient.line">
|
<p v-if="ingredient.line">
|
||||||
<!-- Single line parse results -->
|
<!-- Single line parse results -->
|
||||||
|
|
@ -11,45 +23,43 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
import CompactParsedIngredient from './CompactParsedIngredient.vue';
|
import { ref, watch } from 'vue'
|
||||||
|
import CompactParsedIngredient from './CompactParsedIngredient.vue'
|
||||||
|
import type { Ingredient } from '@/domain/types'
|
||||||
|
|
||||||
export default {
|
const props = defineProps<{ ingredient: Ingredient }>()
|
||||||
props: {
|
const emit = defineEmits<{
|
||||||
ingredient: { type: Object },
|
(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 ?? ''
|
||||||
},
|
},
|
||||||
events: ['update-ingredient', 'update-product-link'],
|
{ deep: true }
|
||||||
components: { CompactParsedIngredient },
|
)
|
||||||
data() {
|
|
||||||
return {
|
function updateIngredient() {
|
||||||
ingredientText: this.ingredient?.line ?? "",
|
if (ingredientText.value != props.ingredient.line) {
|
||||||
productLink: this.ingredient.product?.link ?? "",
|
emit('update-ingredient', props.ingredient, ingredientText.value)
|
||||||
};
|
}
|
||||||
},
|
}
|
||||||
watch: {
|
|
||||||
ingredient: {
|
function updateProductLink() {
|
||||||
handler: function (newIngredient) {
|
if (productLink.value && productLink.value != props.ingredient.product?.link) {
|
||||||
this.ingredientText = newIngredient?.line ?? "";
|
emit('update-product-link', props.ingredient, productLink.value)
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
input {
|
input {
|
||||||
border: 0;
|
border: 0;
|
||||||
font-size: larger;
|
font-size: larger;
|
||||||
|
|
@ -63,6 +73,4 @@ input {
|
||||||
color: #777;
|
color: #777;
|
||||||
margin-top: 0.5vh;
|
margin-top: 0.5vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="date-picker">
|
<div class="date-picker">
|
||||||
<input
|
<input
|
||||||
type="text"
|
|
||||||
v-model="selectedDate"
|
v-model="selectedDate"
|
||||||
|
type="text"
|
||||||
|
placeholder="Select a date"
|
||||||
@focus="showDatePicker = true"
|
@focus="showDatePicker = true"
|
||||||
@blur="showDatePicker = false"
|
@blur="showDatePicker = false"
|
||||||
placeholder="Select a date"
|
>
|
||||||
/>
|
<div
|
||||||
<div v-if="showDatePicker" class="date-picker-dropdown">
|
v-if="showDatePicker"
|
||||||
|
class="date-picker-dropdown"
|
||||||
|
>
|
||||||
<ul>
|
<ul>
|
||||||
<li
|
<li
|
||||||
v-for="(day, index) in days"
|
v-for="(day, index) in days"
|
||||||
:key="index"
|
:key="index"
|
||||||
@mousedown="selectDate(day)">
|
@mousedown="selectDate(day)"
|
||||||
|
>
|
||||||
{{ formatDay(day) }}
|
{{ formatDay(day) }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -20,57 +24,44 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
export default {
|
import { ref, computed, watch } from 'vue'
|
||||||
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 props = defineProps<{ date?: Date }>()
|
||||||
const date = new Date(today);
|
const emit = defineEmits<{ (e: 'date-selected', date: Date): void }>()
|
||||||
date.setDate(today.getDate() + i);
|
|
||||||
days.push(date);
|
function formatDay(date: Date): string {
|
||||||
|
const options: Intl.DateTimeFormatOptions = { weekday: 'long', day: 'numeric', month: 'numeric' }
|
||||||
|
return date.toLocaleDateString('en-AU', options)
|
||||||
}
|
}
|
||||||
|
|
||||||
return days;
|
const initialDate = props.date ?? new Date()
|
||||||
},
|
const selectedDate = ref<string>(formatDay(initialDate))
|
||||||
},
|
const showDatePicker = ref(false)
|
||||||
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
|
watch(
|
||||||
this.$emit("date-selected", date);
|
() => props.date,
|
||||||
},
|
(newDate) => {
|
||||||
formatSelectedDate(date) {
|
if (newDate) selectedDate.value = formatDay(newDate)
|
||||||
const options = { weekday: "long", day: "numeric", month: "numeric" };
|
}
|
||||||
return date.toLocaleDateString("en-AU", options);
|
)
|
||||||
},
|
|
||||||
},
|
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)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectDate(date: Date) {
|
||||||
|
selectedDate.value = formatDay(date)
|
||||||
|
showDatePicker.value = false
|
||||||
|
emit('date-selected', date)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -118,4 +109,3 @@
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
@ -1,39 +1,87 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<date-picker @date-selected="selectDate" :date="meal.suggested_date" />
|
<date-picker
|
||||||
|
:date="meal.suggestedDate ?? new Date()"
|
||||||
|
@date-selected="selectDate"
|
||||||
|
/>
|
||||||
<div class="persons-list">
|
<div class="persons-list">
|
||||||
Cooked by <person-list :people="meal.chefs" @remove-person="(p) => removePerson('chefs', p)" @add-person="(p) => addPerson('chefs', p)" />
|
Cooked by
|
||||||
for <person-list :people="meal.consumers" @remove-person="(p) => removePerson('consumers', p)" @add-person="(p) => addPerson('consumers', p)" />,
|
<person-list
|
||||||
with <person-list :people="meal.cleanup" @remove-person="(p) => removePerson('cleanup', p)" @add-person="(p) => addPerson('cleanup', p)" /> on cleanup.
|
: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>
|
</div>
|
||||||
<div class="recipes">
|
<div class="recipes">
|
||||||
<h2>Recipes</h2>
|
<h2>Recipes</h2>
|
||||||
<ul v-if="meal.recipes && meal.recipes.length">
|
<ul v-if="meal.recipes.length">
|
||||||
<li v-for="mealRecipe in meal.recipes" :key="mealRecipe.recipe.id">
|
<li
|
||||||
<div class="saved-recipe">
|
v-for="mealRecipe in meal.recipes"
|
||||||
|
:key="mealRecipe.recipe?.id ?? mealRecipe.recipeId"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="mealRecipe.recipe"
|
||||||
|
class="saved-recipe"
|
||||||
|
>
|
||||||
<p class="recipe-card">
|
<p class="recipe-card">
|
||||||
<recipe-card :recipe="mealRecipe.recipe" />
|
<recipe-card :recipe="mealRecipe.recipe" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="servings">
|
<p class="servings">
|
||||||
<input type="number" v-model="mealRecipe.servings" min="1" />
|
<input
|
||||||
|
v-model="mealRecipe.servings"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
>
|
||||||
<small><em>servings</em></small>
|
<small><em>servings</em></small>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<input type="checkbox" class="show-ingredient-checkbox" :checked="showIngredient(mealRecipe)" />
|
<input
|
||||||
<label for="show-ingredients" @click="showIngredient(mealRecipe, !showIngredient(mealRecipe))">
|
type="checkbox"
|
||||||
<img class="icon" :src="require('@/assets/show-ingredients.svg')" />
|
class="show-ingredient-checkbox"
|
||||||
|
:checked="showIngredient(mealRecipe)"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
for="show-ingredients"
|
||||||
|
@click="showIngredient(mealRecipe, !showIngredient(mealRecipe))"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="icon"
|
||||||
|
:src="showIngredientsIcon"
|
||||||
|
>
|
||||||
</label>
|
</label>
|
||||||
<button class="icon-button" @click="removeRecipe(mealRecipe)">
|
<button
|
||||||
<img class="icon" :src="require('@/assets/trash.svg')" />
|
class="icon-button"
|
||||||
|
@click="removeRecipe(mealRecipe)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="icon"
|
||||||
|
:src="trash"
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showIngredient(mealRecipe)">
|
<div v-if="showIngredient(mealRecipe)">
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="ingredient in scaleIngredients(mealRecipe)" :key="ingredient.id" class="saved-ingredient">
|
<li
|
||||||
|
v-for="ingredient in scaleIngredients(mealRecipe)"
|
||||||
|
:key="ingredient.id"
|
||||||
|
class="saved-ingredient"
|
||||||
|
>
|
||||||
<CompactParsedIngredient :ingredient="ingredient" />
|
<CompactParsedIngredient :ingredient="ingredient" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -49,143 +97,183 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="ingredients">
|
<div class="ingredients">
|
||||||
<h2>Sides & Additional Ingredients</h2>
|
<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" />
|
<editable-ingredients-panel
|
||||||
|
:ingredients="meal.extraIngredients"
|
||||||
|
@on-add="addIngredient"
|
||||||
|
@on-delete="deleteIngredient"
|
||||||
|
@on-update-ingredient="updateIngredient"
|
||||||
|
@on-editing="onEditAdditionalIngredients"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="saveMeal">Save</button>
|
<button @click="onSaveMeal">
|
||||||
<p v-if="meal.purchase_date"><em>Purchased {{ ago(meal.purchase_date) }}</em></p>
|
Save
|
||||||
|
</button>
|
||||||
|
<p v-if="meal.purchaseDate">
|
||||||
|
<em>Purchased {{ ago(meal.purchaseDate) }}</em>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<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'
|
||||||
|
|
||||||
import data from '@/data.js';
|
import { ago } from '@/dateformats'
|
||||||
import alert from '@/alert.js';
|
|
||||||
|
|
||||||
import { ago } from '@/dateformats.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 RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue';
|
function addPersonIfNotExists(list: Person[], person: Person | null | undefined) {
|
||||||
import RecipeCard from '@/components/recipes/RecipeCard.vue';
|
if (!person) return
|
||||||
import DatePicker from './DatePicker.vue';
|
if (!list.find((p) => p.id === person.id)) {
|
||||||
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue';
|
list.push(person)
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
const route = useRoute()
|
||||||
props: ['id'],
|
const router = useRouter()
|
||||||
components: { RecipeSearchBox, DatePicker, RecipeCard, EditableIngredientsPanel, PersonList, CompactParsedIngredient },
|
const { show: showAlert } = useAlert()
|
||||||
data() {
|
|
||||||
return {
|
type PeopleKey = 'chefs' | 'consumers' | 'cleanup'
|
||||||
showIngredients: {},
|
|
||||||
meal: {
|
const meal = reactive<Meal>({
|
||||||
id: -1,
|
id: -1,
|
||||||
suggested_date: new Date(),
|
suggestedDate: new Date(),
|
||||||
|
consumedDate: null,
|
||||||
|
purchaseDate: null,
|
||||||
recipes: [],
|
recipes: [],
|
||||||
extra_ingredients: [],
|
extraIngredients: [],
|
||||||
chefs: [],
|
chefs: [],
|
||||||
consumers: [],
|
consumers: [],
|
||||||
cleanup: []
|
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]
|
||||||
}
|
}
|
||||||
};
|
|
||||||
},
|
|
||||||
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], };
|
function selectDate(date: Date) {
|
||||||
|
meal.suggestedDate = date
|
||||||
}
|
}
|
||||||
},
|
|
||||||
methods: {
|
function removeRecipe(mealRecipe: MealRecipe) {
|
||||||
ago,
|
if (
|
||||||
async selectRecipe(recipe) {
|
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
|
// Refetch to get additional details
|
||||||
recipe = await data.getRecipe(recipe.id);
|
const r = await getRecipe(recipe.id)
|
||||||
|
|
||||||
if (recipe.created_by) {
|
if (r.createdBy) {
|
||||||
addPersonIfNotExists(this.meal.chefs, recipe.created_by);
|
addPersonIfNotExists(meal.chefs, r.createdBy)
|
||||||
addPersonIfNotExists(this.meal.consumers, recipe.created_by);
|
addPersonIfNotExists(meal.consumers, r.createdBy)
|
||||||
|
|
||||||
if (this.meal.cleanup.length === 0) {
|
if (meal.cleanup.length === 0) {
|
||||||
addPersonIfNotExists(this.meal.cleanup, recipe.created_by);
|
addPersonIfNotExists(meal.cleanup, r.createdBy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.meal.recipes.push({ recipe, recipe_id: recipe.id, meal_id: this.meal.id, servings: recipe.serves });
|
meal.recipes.push({ recipe: r, recipeId: r.id, mealId: meal.id, servings: r.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' });
|
async function onEditAdditionalIngredients(editing: boolean) {
|
||||||
},
|
if (editing && meal.extraIngredients.length === 0) {
|
||||||
onEditAdditionalIngredients(editing) {
|
addIngredient()
|
||||||
if (editing && this.meal.extra_ingredients.length === 0) {
|
} else {
|
||||||
this.addIngredient();
|
meal.extraIngredients = meal.extraIngredients.filter((i) => !!i.line)
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
this.meal.extra_ingredients = this.meal.extra_ingredients.filter(i => i.line);
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
showIngredient(mealRecipe, value) {
|
const showMap = reactive<Record<string, boolean>>({})
|
||||||
const index = this.meal.recipes.indexOf(mealRecipe);
|
function showIngredient(mealRecipe: MealRecipe, value?: boolean): boolean {
|
||||||
const key = `${mealRecipe.recipe.id}-${index}`;
|
const index = meal.recipes.indexOf(mealRecipe)
|
||||||
|
const key = `${mealRecipe.recipe?.id ?? 'unknown'}-${index}`
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return this.showIngredients[key];
|
return !!showMap[key]
|
||||||
|
}
|
||||||
|
showMap[key] = value
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.showIngredients[key] = value;
|
function scaleIngredients(mealRecipe: MealRecipe) {
|
||||||
},
|
const ing = mealRecipe.recipe?.ingredients ?? []
|
||||||
scaleIngredients(mealRecipe) {
|
const serves = mealRecipe.recipe?.serves ?? 1
|
||||||
return mealRecipe.recipe.ingredients.map(i => {
|
return ing.map((i) => {
|
||||||
return {
|
return {
|
||||||
...i,
|
...i,
|
||||||
quantity: i.quantity * mealRecipe.servings / mealRecipe.recipe.serves
|
quantity: (i.quantity * mealRecipe.servings) / serves,
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
showAlert({
|
||||||
|
heading: 'Error saving meal',
|
||||||
|
message: 'An error occurred while saving the meal',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
img.icon {
|
img.icon {
|
||||||
width: 2em;
|
width: 2em;
|
||||||
height: 2em;
|
height: 2em;
|
||||||
|
|
@ -238,7 +326,6 @@ li {
|
||||||
margin-right: 1em;
|
margin-right: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.saved-recipe button:hover {
|
.saved-recipe button:hover {
|
||||||
background: #eee;
|
background: #eee;
|
||||||
}
|
}
|
||||||
|
|
@ -296,5 +383,4 @@ li {
|
||||||
.show-ingredient-checkbox:checked+label {
|
.show-ingredient-checkbox:checked+label {
|
||||||
filter: invert(1);
|
filter: invert(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,85 +1,90 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="meal-card">
|
<div class="meal-card">
|
||||||
<h3>{{ mealTitle }}</h3>
|
<h3>{{ mealTitle }}</h3>
|
||||||
<h4>{{ dayOfWeek }} <small>{{ date }}</small></h4>
|
<h4>
|
||||||
|
{{ dayOfWeek }} <small>{{ date }}</small>
|
||||||
|
</h4>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Cooked by
|
Cooked by
|
||||||
<span v-for="(chef, index) in meal.chefs" :key="chef.id">
|
<span
|
||||||
|
v-for="(chef, index) in meal.chefs"
|
||||||
|
:key="chef.id"
|
||||||
|
>
|
||||||
{{ chef.name }}{{ englishSeperator(index, meal.chefs) }}
|
{{ chef.name }}{{ englishSeperator(index, meal.chefs) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!meal.chefs.length">somebody?</span>
|
<span v-if="!meal.chefs.length">somebody?</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
For
|
For
|
||||||
<span v-for="(consumer, index) in meal.consumers" :key="consumer.id">
|
<span
|
||||||
|
v-for="(consumer, index) in meal.consumers"
|
||||||
|
:key="consumer.id"
|
||||||
|
>
|
||||||
{{ consumer.name }}{{ englishSeperator(index, meal.consumers) }}
|
{{ consumer.name }}{{ englishSeperator(index, meal.consumers) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!meal.consumers.length">somebody?</span>
|
<span v-if="!meal.consumers.length">somebody?</span>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="meal.purchase_date">
|
<p v-if="meal.purchaseDate">
|
||||||
Purchased {{ ago(meal.purchase_date) }}
|
Purchased {{ ago(meal.purchaseDate) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<script setup lang="ts">
|
||||||
</style>
|
import { computed } from 'vue'
|
||||||
|
import { ago } from '@/dateformats'
|
||||||
|
import type { Meal } from '@/domain/types'
|
||||||
|
|
||||||
<script>
|
const props = defineProps<{ meal: Meal }>()
|
||||||
|
|
||||||
import { ago } from '@/dateformats.js'
|
function englishSeperator(index: number, list: Array<unknown>) {
|
||||||
|
|
||||||
function englishSeperator(index, list) {
|
|
||||||
switch (index) {
|
switch (index) {
|
||||||
case list.length - 1:
|
case list.length - 1:
|
||||||
return '';
|
return ''
|
||||||
case list.length - 2:
|
case list.length - 2:
|
||||||
return ' and ';
|
return ' and '
|
||||||
default:
|
default:
|
||||||
return ', ';
|
return ', '
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function englishList(list) {
|
function englishList(list: string[]) {
|
||||||
switch (list.length) {
|
switch (list.length) {
|
||||||
case 0:
|
case 0:
|
||||||
return '';
|
return ''
|
||||||
case 1:
|
case 1:
|
||||||
return list[0];
|
return list[0]
|
||||||
case 2:
|
case 2:
|
||||||
return `${list[0]} and ${list[1]}`;
|
return `${list[0]} and ${list[1]}`
|
||||||
default:
|
default:
|
||||||
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`;
|
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
const date = computed(() =>
|
||||||
name: 'MealCard',
|
props.meal.suggestedDate
|
||||||
props: ['meal'],
|
? props.meal.suggestedDate.toLocaleDateString('en-au', { month: 'numeric', day: 'numeric' })
|
||||||
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));
|
|
||||||
|
|
||||||
if (recipesText && ingredientsText) {
|
const dayOfWeek = computed(() =>
|
||||||
return `${recipesText} with ${ingredientsText}`;
|
props.meal.suggestedDate ? props.meal.suggestedDate.toLocaleDateString('en-au', { weekday: 'long' }) : ''
|
||||||
} else if (recipesText || ingredientsText) {
|
)
|
||||||
return recipesText || ingredientsText;
|
|
||||||
} else {
|
const mealTitle = computed(() => {
|
||||||
return 'Nothing planned';
|
const recipes = props.meal.recipes ?? []
|
||||||
}
|
const extras = props.meal.extraIngredients ?? []
|
||||||
}
|
const recipeNames = recipes
|
||||||
},
|
.map((mr) => mr.recipe?.name)
|
||||||
methods: {
|
.filter((n): n is string => typeof n === 'string' && n.length > 0)
|
||||||
englishSeperator,
|
const recipesText = englishList(recipeNames)
|
||||||
ago
|
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>
|
</script>
|
||||||
|
|
||||||
|
<style></style>
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,91 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<ul class="meals-list" v-if="meals.length">
|
<ul
|
||||||
<li v-for="meal in meals" :key="meal.id">
|
v-if="meals.length"
|
||||||
|
class="meals-list"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="meal in meals"
|
||||||
|
:key="meal.id"
|
||||||
|
>
|
||||||
<meal-card :meal="meal" />
|
<meal-card :meal="meal" />
|
||||||
<button class="toggle-actions" @click="selectedMeal = ((meal == selectedMeal) ? null : meal)">
|
<button
|
||||||
<img :src="meal == selectedMeal ? require('@/assets/chevron-down.svg') : require('@/assets/chevron-up.svg')" />
|
class="toggle-actions"
|
||||||
|
@click="selectedMeal = meal == selectedMeal ? null : meal"
|
||||||
|
>
|
||||||
|
<img :src="meal == selectedMeal ? chevronDown : chevronUp">
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ul class="actions" v-if="selectedMeal == meal">
|
<ul
|
||||||
<li><router-link class="nav-link" :to="`/meals/${selectedMeal.id}`" active-class="active">Edit Meal</router-link></li>
|
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 @click="markConsumed">Mark Consumed</a></li>
|
||||||
<li><a @click="deleteSelectedMeal" class="button">Remove</a></li>
|
<li>
|
||||||
|
<a
|
||||||
|
class="button"
|
||||||
|
@click="deleteSelectedMeal"
|
||||||
|
>Remove</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div v-if="!meals.length">
|
<div v-if="!meals.length">
|
||||||
<em>No meals planned</em>
|
<em>No meals planned</em>
|
||||||
</div>
|
</div>
|
||||||
<action-item title="Plan Meal" :image="require('@/assets/plan-meal.svg')" @click="() => this.$router.push('/meals/add')" />
|
<action-item
|
||||||
|
title="Plan Meal"
|
||||||
|
:image="planMeal"
|
||||||
|
@click="() => $router.push('/meals/add')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style scoped>
|
||||||
li {
|
li {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
|
@ -75,44 +140,4 @@ ul.actions {
|
||||||
padding-top: 2ex;
|
padding-top: 2ex;
|
||||||
padding-bottom: 2ex;
|
padding-bottom: 2ex;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</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>
|
|
||||||
|
|
@ -1,22 +1,128 @@
|
||||||
<template>
|
<template>
|
||||||
<span class="person-list">
|
<span class="person-list">
|
||||||
<span v-for="person in people" :key="person.id">
|
<span
|
||||||
<button class="person-circle remove-person" @click="$emit('remove-person', person)" >{{ person.name }}</button>
|
v-for="person in people"
|
||||||
|
:key="person.id"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="person-circle remove-person"
|
||||||
|
@click="removePerson(person)"
|
||||||
|
>
|
||||||
|
{{ person.name }}
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<button v-if="!isAddingPerson" class="person-circle add-person" @click="isAddingPerson = true">+</button>
|
<button
|
||||||
<input v-else v-model="searchName" ref="searchNameInput" @keyup.enter="addPerson" @keyup.esc="isAddingPerson = false" @blur="isAddingPerson = false" />
|
v-if="!isAddingPerson"
|
||||||
<ul class="person-droplist" ref="persondroplist" v-if="isAddingPerson && searchResults.length">
|
class="person-circle add-person"
|
||||||
<li v-for="person in searchResults" :key="person.id">
|
@click="isAddingPerson = true"
|
||||||
<button class="person-circle add-person" @mousedown="addPerson(person)">{{ person.name }}</button>
|
>
|
||||||
|
+
|
||||||
|
</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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.person-list {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -53,9 +159,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add a cross to the circle */
|
/* Add a cross to the circle */
|
||||||
.remove-person:hover::before, .remove-person:hover::after {
|
.remove-person:hover::before,
|
||||||
|
.remove-person:hover::after {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
content: "X";
|
content: 'X';
|
||||||
color: white;
|
color: white;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -94,78 +201,4 @@
|
||||||
display: inline;
|
display: inline;
|
||||||
padding: 1ex 1em;
|
padding: 1ex 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</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>
|
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="!id && !recipe">
|
<div v-if="!id && !recipe">
|
||||||
<input class="recipe-link" type="text" v-model="link" placeholder="Link to Recipe" /> <br />
|
<input
|
||||||
<button @click="parseLink">Parse</button>
|
v-model="link"
|
||||||
<button @click="createFromScratch">Create from Scratch</button>
|
class="recipe-link"
|
||||||
|
type="text"
|
||||||
|
placeholder="Link to Recipe"
|
||||||
|
> <br>
|
||||||
|
<button @click="parseLink">
|
||||||
|
Parse
|
||||||
|
</button>
|
||||||
|
<button @click="createFromScratch">
|
||||||
|
Create from Scratch
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="parse_failed">
|
<div v-if="parse_failed">
|
||||||
|
|
@ -11,26 +20,194 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!parse_failed && recipe">
|
<div v-if="!parse_failed && recipe">
|
||||||
<div class="image-container" v-if="image_styling" :style="image_styling" ></div>
|
<div
|
||||||
<h1><input class="recipe-name" type="text" v-model="recipe.name" /></h1>
|
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>
|
<label for="recipe-serves">Number of serves: </label>
|
||||||
<input type="number" v-model="recipe.serves" />
|
<input
|
||||||
<h3 class="recipe-link"><a :href="recipe.link">View Recipe</a></h3>
|
v-model="recipe.serves"
|
||||||
|
type="number"
|
||||||
|
>
|
||||||
|
<h3 class="recipe-link">
|
||||||
|
<a :href="recipe.link">View Recipe</a>
|
||||||
|
</h3>
|
||||||
<h2>Ingredients</h2>
|
<h2>Ingredients</h2>
|
||||||
<editable-ingredients-panel
|
<editable-ingredients-panel
|
||||||
:ingredients="recipe.ingredients"
|
:ingredients="recipe.ingredients"
|
||||||
:edit-only="true"
|
:edit-only="true"
|
||||||
@on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient"
|
@on-add="addIngredient"
|
||||||
|
@on-delete="deleteIngredient"
|
||||||
|
@on-update-ingredient="updateIngredient"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button v-if="recipe.id" class="delete-btn" @click="deleteRecipe">Delete</button>
|
<button
|
||||||
<button class="submit-btn" @click="saveRecipe">{{ recipe.id ? "Save" : "Create" }}</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style scoped>
|
||||||
input {
|
input {
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
@ -56,98 +233,7 @@ input.recipe-name {
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-link {
|
.recipe-link {
|
||||||
color: #0000EE;
|
color: #0000ee;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</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>
|
|
||||||
|
|
@ -1,22 +1,30 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="recipe-card">
|
<div class="recipe-card">
|
||||||
<p>
|
<p>
|
||||||
<img v-if="recipe.image_urls" :src="recipe.image_urls[0]" />
|
<img
|
||||||
<img v-else src="@/assets/egg.svg" />
|
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>
|
</p>
|
||||||
<p class="recipe-name">{{ recipe.name }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
export default {
|
import type { RecipeOut } from '@/domain/types'
|
||||||
name: 'RecipeCard',
|
type RecipeCardItem = Pick<RecipeOut, 'id' | 'name' | 'imageUrls'>
|
||||||
props: ['recipe']
|
|
||||||
}
|
defineProps<{ recipe: RecipeCardItem }>()
|
||||||
|
const fallbackEgg = new URL('@/assets/egg.svg', import.meta.url).toString()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
.recipe-card {
|
.recipe-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
@ -42,5 +50,4 @@ li img {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,55 +1,168 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="recipe-search-box" @focusout="recipes = []">
|
<div
|
||||||
<input type="text" v-model="searchTerm" @keyup.enter="search" @keyup.exit="clear" @focusin="search"
|
class="recipe-search-box"
|
||||||
:placeholder="placeholder" />
|
@focusout="onFocusOut"
|
||||||
<ul v-if="recipes?.length" class="dropdown">
|
>
|
||||||
<li class="recipe" v-for="recipe in recipes" :key="recipe.id" @mousedown="selectRecipe(recipe)">
|
<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" />
|
<recipe-card :recipe="recipe" />
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
import data from '@/data.js'
|
import { ref, watch, onBeforeUnmount, computed } from 'vue'
|
||||||
import RecipeCard from './RecipeCard.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'
|
||||||
|
|
||||||
export default {
|
const props = withDefaults(defineProps<{ placeholder?: string }>(), {
|
||||||
name: 'RecipeSearchBox',
|
placeholder: 'Add a recipe...',
|
||||||
components: { RecipeCard },
|
})
|
||||||
props: {
|
const placeholderText: string = props.placeholder ?? 'Add a recipe...'
|
||||||
placeholder: { type: String, default: 'Add a recipe...' }
|
|
||||||
},
|
type RecipeItem = Pick<Recipe, 'id' | 'name' | 'imageUrls'>
|
||||||
data() {
|
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 {
|
return {
|
||||||
searchTerm: '',
|
items: (page.items ?? []).map(toRecipeItem),
|
||||||
recipes: [],
|
next: page.next ?? null,
|
||||||
timeouts: [],
|
prev: page.prev ?? null,
|
||||||
|
total: page.total ?? null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
{ pageSize }
|
||||||
searchTerm() {
|
)
|
||||||
const searchTerm = this.searchTerm;
|
const dropdownVisible = computed(() => recipes.value.length > 0 || (searchTerm.value.length > 0))
|
||||||
if (searchTerm) {
|
|
||||||
this.timeouts.push(setTimeout(() => {
|
const { show: showAlert, scheduleAutoDismiss } = useAlert()
|
||||||
if (searchTerm === this.searchTerm) {
|
|
||||||
this.search();
|
let debounceId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
watch(
|
||||||
|
searchTerm,
|
||||||
|
(newVal) => {
|
||||||
|
if (!newVal) {
|
||||||
|
recipes.value = []
|
||||||
|
if (debounceId) clearTimeout(debounceId)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}, 200));
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
methods: {
|
async function loadNext() {
|
||||||
async search() {
|
if (!nextCursor.value) return
|
||||||
this.recipes = await data.searchRecipes(this.searchTerm) ?? this.recipes;
|
try {
|
||||||
},
|
await loadNextPage()
|
||||||
selectRecipe(recipe) {
|
} catch {
|
||||||
this.$emit('select-recipe', recipe);
|
showAlert({ type: 'error', heading: 'Unable to load more', message: 'Could not fetch the next page.' })
|
||||||
this.searchTerm = '';
|
scheduleAutoDismiss()
|
||||||
this.recipes = [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -96,4 +209,19 @@ export default {
|
||||||
.recipe-search-box .dropdown li:hover {
|
.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>
|
||||||
|
|
@ -1,18 +1,31 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<recipe-search-box placeholder="Search for a recipe..." @select-recipe="(r) => this.$router.push(`/recipes/${r.id}`)"/>
|
<recipe-search-box
|
||||||
<action-item title="Add new Recipe" :image="require('@/assets/add-recipe.svg')" @click="() => this.$router.push('/recipes/add')" />
|
placeholder="Search for a recipe..."
|
||||||
|
@select-recipe="onSelectRecipe"
|
||||||
|
/>
|
||||||
|
<action-item
|
||||||
|
title="Add new Recipe"
|
||||||
|
:image="addRecipe"
|
||||||
|
@click="onAddRecipe"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import ActionItem from '@/components/ActionItem.vue'
|
import ActionItem from '@/components/ActionItem.vue'
|
||||||
import RecipeSearchBox from './RecipeSearchBox.vue';
|
import RecipeSearchBox from './RecipeSearchBox.vue'
|
||||||
|
import type { Recipe } from '@/domain/types'
|
||||||
|
|
||||||
export default {
|
const addRecipe = new URL('@/assets/add-recipe.svg', import.meta.url).toString()
|
||||||
name: 'ActionsPage',
|
|
||||||
components: {
|
const router = useRouter()
|
||||||
ActionItem,
|
|
||||||
RecipeSearchBox
|
function onSelectRecipe(r: Pick<Recipe, 'id'>) {
|
||||||
|
router.push(`/recipes/${r.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onAddRecipe() {
|
||||||
|
router.push('/recipes/add')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -2,48 +2,79 @@
|
||||||
<h3>Full shopping list</h3>
|
<h3>Full shopping list</h3>
|
||||||
|
|
||||||
<h4>Included Meals</h4>
|
<h4>Included Meals</h4>
|
||||||
<meal-selection-list @meal-selected="mealSelected" @meal-unselected="mealUnselected" :checked="includedMeals" :meals="availableMeals" />
|
<meal-selection-list
|
||||||
|
:checked="includedMeals"
|
||||||
|
:meals="availableMeals"
|
||||||
|
@meal-selected="mealSelected"
|
||||||
|
@meal-unselected="mealUnselected"
|
||||||
|
/>
|
||||||
|
|
||||||
<ul class="full-shopping-list">
|
<ul class="full-shopping-list">
|
||||||
<li v-for="group in outstandingItemGroups" :key="group.id" class="selectable" :class="{ 'selected': isSelected(group) }" @click="toggleSelect(group)">
|
<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" />
|
<shopping-list-item :shopping-list-item-group="group" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div v-if="outstandingItemGroups.length === 0">
|
<div v-if="outstandingItemGroups.length === 0">
|
||||||
<p>
|
<p>No items to purchase</p>
|
||||||
No items to purchase
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="purchased-slider">
|
<div class="purchased-slider">
|
||||||
<span v-if="purchasedItemGroups.length === 0"></span>
|
<span v-if="purchasedItemGroups.length === 0" />
|
||||||
<button v-else-if="showPurchased" @click="showPurchased=false" >⏶ Hide Purchased ⏶</button>
|
<button
|
||||||
<button v-else @click="showPurchased=true">⏷ Show Purchased ⏷</button>
|
v-else-if="showPurchased"
|
||||||
|
@click="showPurchased = false"
|
||||||
|
>
|
||||||
|
⏶ Hide Purchased ⏶
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
@click="showPurchased = true"
|
||||||
|
>
|
||||||
|
⏷ Show Purchased ⏷
|
||||||
|
</button>
|
||||||
|
|
||||||
<div v-if="showPurchased">
|
<div v-if="showPurchased && purchasedItemGroups.length > 0">
|
||||||
<h4>Purchased Meals</h4>
|
<h4>Purchased Meals</h4>
|
||||||
<meal-selection-list @meal-selected="mealSelected" @meal-unselected="mealUnselected" :checked="includedMeals" :meals="purchasedMeals" />
|
<meal-selection-list
|
||||||
|
:checked="includedMeals"
|
||||||
|
:meals="purchasedMeals"
|
||||||
|
@meal-selected="mealSelected"
|
||||||
|
@meal-unselected="mealUnselected"
|
||||||
|
/>
|
||||||
|
|
||||||
<h4>
|
<h4>Purchased Items</h4>
|
||||||
Purchased Items
|
|
||||||
</h4>
|
|
||||||
<ul class="full-shopping-list">
|
<ul class="full-shopping-list">
|
||||||
<li v-for="item in purchasedItemGroups" :key="item.id">
|
<li
|
||||||
|
v-for="item in purchasedItemGroups"
|
||||||
|
:key="groupKey(item)"
|
||||||
|
>
|
||||||
<shopping-list-item :shopping-list-item-group="item" />
|
<shopping-list-item :shopping-list-item-group="item" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="spacer" v-if="selected.length">
|
<div
|
||||||
|
v-if="selected.length"
|
||||||
|
class="spacer"
|
||||||
|
>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Display 'Stocked', 'Purchased' and 'Cancel' buttons in a vertical stack fixed to the bottom of the screen when any elements are selected -->
|
<!-- 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">
|
<div
|
||||||
|
v-if="selected.length"
|
||||||
|
class="footer-buttons"
|
||||||
|
>
|
||||||
<p v-if="selected.length === 1">
|
<p v-if="selected.length === 1">
|
||||||
Mark '{{ selected[0].product?.name ?? selected[0].name }}' as
|
Mark '{{ selected[0] ? groupLabel(selected[0]) : '' }}' as
|
||||||
</p>
|
</p>
|
||||||
<p v-else>
|
<p v-else>
|
||||||
Mark {{ selected.length }} items as
|
Mark {{ selected.length }} items as
|
||||||
|
|
@ -51,25 +82,136 @@
|
||||||
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<button @click="markFound">
|
<button @click="markFound">
|
||||||
<img src="@/assets/house-check.svg" /><br />
|
<img :src="houseCheck"><br>
|
||||||
Found
|
Found
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button @click="markPurchased">
|
<button @click="markPurchased">
|
||||||
<img src="@/assets/shopping-cart.svg" /><br />
|
<img :src="shoppingCart"><br>
|
||||||
Purchased
|
Purchased
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button @click="selected = []">
|
<button @click="selected = []">
|
||||||
<img src="@/assets/close.svg" /><br />
|
<img :src="closeIcon"><br>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.full-shopping-list {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -130,121 +272,4 @@ button img {
|
||||||
.spacer {
|
.spacer {
|
||||||
height: 12em;
|
height: 12em;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</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>
|
|
||||||
|
|
@ -1,20 +1,108 @@
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="meal in meals" :key="meal.id">
|
<li
|
||||||
|
v-for="meal in meals"
|
||||||
|
:key="meal.id"
|
||||||
|
>
|
||||||
<!-- Have a checkbox and card for each meal, show the image and name -->
|
<!-- Have a checkbox and card for each meal, show the image and name -->
|
||||||
<!-- Emit meal-selected event on checked and meal-unselected on unchecked -->
|
<!-- Emit meal-selected event on checked and meal-unselected on unchecked -->
|
||||||
<input type="checkbox" :id="meal.id" :checked="isChecked(meal)" @change="mealCheckChanged" :disabled="disabled" />
|
<input
|
||||||
<label :for="meal.id" :style="getImageStyling(meal)">
|
:id="String(meal.id)"
|
||||||
{{ formatDate(meal.suggested_date) }}
|
type="checkbox"
|
||||||
|
:checked="isChecked(meal)"
|
||||||
|
:disabled="!!disabled"
|
||||||
|
@change="mealCheckChanged"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
:for="String(meal.id)"
|
||||||
|
:style="getImageStyling(meal)"
|
||||||
|
>
|
||||||
|
{{ formatDate(meal.suggestedDate) }}
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
ul {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -31,7 +119,7 @@ li {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide the default checkbox formatting, and format the card instead */
|
/* Hide the default checkbox formatting, and format the card instead */
|
||||||
input[type="checkbox"] {
|
input[type='checkbox'] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,12 +136,12 @@ label {
|
||||||
color: #3d5447;
|
color: #3d5447;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"]:checked + label {
|
input[type='checkbox']:checked + label {
|
||||||
border: 3px solid #3d5447;
|
border: 3px solid #3d5447;
|
||||||
text-shadow: #ccc 0 0 0.1em;
|
text-shadow: #ccc 0 0 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"]:disabled + label {
|
input[type='checkbox']:disabled + label {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,89 +160,4 @@ label {
|
||||||
font-size: larger;
|
font-size: larger;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</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>
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1>My Shopping List</h1>
|
<h1>My Shopping List</h1>
|
||||||
<router-link :to="`/shopping/current`">Full Shopping List</router-link>
|
<router-link :to="`/shopping/current`">
|
||||||
<editable-ingredients-panel @on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient" @on-editing="onEditing" :ingredients="ingredients" />
|
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>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|
@ -31,53 +39,50 @@
|
||||||
-->
|
-->
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<script setup lang="ts">
|
||||||
|
import { ref, onBeforeMount } from 'vue'
|
||||||
</style>
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuth } from '@/composables/useAuth'
|
||||||
<script>
|
import { useShopping } from '@/composables/useShopping'
|
||||||
import data from '@/data.js'
|
import type { Ingredient } from '@/domain/types'
|
||||||
|
|
||||||
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
||||||
|
|
||||||
export default {
|
const router = useRouter()
|
||||||
name: 'MyShoppingpage',
|
const { loadUser } = useAuth()
|
||||||
components: { EditableIngredientsPanel },
|
const { getMyShoppingList, saveMyShoppingList } = useShopping()
|
||||||
data() {
|
|
||||||
return { ingredients: [], person: null }
|
|
||||||
},
|
|
||||||
async beforeMount() {
|
|
||||||
const person = await data.currentUser();
|
|
||||||
if (!person)
|
|
||||||
return this.$router.push({ name: 'login' });
|
|
||||||
|
|
||||||
this.person = person;
|
const ingredients = ref<Ingredient[]>([])
|
||||||
await this.updateShoppingList();
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async updateShoppingList(save = false) {
|
|
||||||
const new_ingredients = save ?
|
|
||||||
await data.saveMyShoppingList(this.ingredients) :
|
|
||||||
await data.getMyShoppingList();
|
|
||||||
|
|
||||||
this.ingredients = new_ingredients;
|
async function updateShoppingList(save = false) {
|
||||||
},
|
const newIngredients = save ? await saveMyShoppingList(ingredients.value) : await getMyShoppingList()
|
||||||
addIngredient() {
|
ingredients.value = newIngredients.map((i) => ({ ...i }))
|
||||||
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) {
|
function addIngredient() {
|
||||||
this.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>
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,58 @@
|
||||||
<template>
|
<template>
|
||||||
<h3>Purchased {{ shoppingList ? ago(shoppingList.created_date) : '' }}</h3>
|
<h3>Purchased {{ shoppingList?.createdDate ? ago(shoppingList.createdDate) : '' }}</h3>
|
||||||
|
|
||||||
<div v-if="includedMeals.length > 0">
|
<div v-if="includedMeals.length > 0">
|
||||||
<h4>Included Meals</h4>
|
<h4>Included Meals</h4>
|
||||||
<meal-selection-list :checked="includedMeals" :meals="includedMeals" :disabled="true" />
|
<meal-selection-list
|
||||||
|
:checked="includedMeals"
|
||||||
|
:meals="includedMeals"
|
||||||
|
:disabled="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="full-shopping-list">
|
<ul class="full-shopping-list">
|
||||||
<li v-for="item in listByProduct" :key="item.id">
|
<li
|
||||||
<shopping-list-item :shopping-list-item-group="item" />
|
v-for="item in listByProduct"
|
||||||
|
:key="groupKey(item)"
|
||||||
|
>
|
||||||
|
<shopping-list-item-comp :shopping-list-item-group="item" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.full-shopping-list li {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
|
||||||
|
|
@ -26,54 +64,4 @@
|
||||||
.full-shopping-list {
|
.full-shopping-list {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</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>
|
|
||||||
|
|
@ -1,63 +1,176 @@
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<!-- Show a card of the product with the total, taking up the available space and have an expanding section to view sources -->
|
<!-- 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">
|
<div class="shopping-list-item">
|
||||||
<img :src="`${ shoppingListItemGroup.product?.img_small ?? require('@/assets/missing-product.svg') }`" class="product-image" />
|
<img
|
||||||
|
:src="imageSrc"
|
||||||
|
class="product-image"
|
||||||
|
>
|
||||||
<div class="product-details">
|
<div class="product-details">
|
||||||
<h3 class="header">
|
<h3 class="header">
|
||||||
<strong>
|
<strong>
|
||||||
<a v-if="shoppingListItemGroup.product?.link" :href="shoppingListItemGroup.product?.link">{{ shoppingListItemGroup.product?.name }}</a>
|
<a
|
||||||
<span v-else>{{ shoppingListItemGroup.name }}</span>
|
v-if="shoppingListItemGroup.type === 'product' && shoppingListItemGroup.product?.link"
|
||||||
</strong>,
|
:href="shoppingListItemGroup.product?.link"
|
||||||
|
>{{ shoppingListItemGroup.product?.name }}</a>
|
||||||
|
<span v-else>{{ shoppingListItemGroup.type === 'name' ? shoppingListItemGroup.name : '' }}</span> </strong>,
|
||||||
<small>
|
<small>
|
||||||
<span v-for="(total, index) in remainingRequiredTotals" :key="total.id">
|
<span
|
||||||
|
v-for="(total, index) in remainingRequiredTotals"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
<span v-if="index">, </span>
|
<span v-if="index">, </span>
|
||||||
<span>{{ formatQuantity(total.quantity) }} {{ total.unit }}</span>
|
<span>{{ formatQuantity(total.quantity) }} {{ total.unit }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="found-marker partial" v-if="purchased.length > 0">✓ {{ getFriendlyDate(lastPurchased) }}</span>
|
<span
|
||||||
|
v-if="purchased.length > 0"
|
||||||
|
class="found-marker partial"
|
||||||
|
>✓ {{ getFriendlyDate(lastPurchased) }}</span>
|
||||||
</small>
|
</small>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="sources" v-if="required.length > 0">
|
<p
|
||||||
|
v-if="required.length > 0"
|
||||||
|
class="sources"
|
||||||
|
>
|
||||||
<strong>Need: </strong>
|
<strong>Need: </strong>
|
||||||
<span v-for="(source, index) in required" :key="source.id">
|
<span
|
||||||
|
v-for="(source, index) in required"
|
||||||
|
:key="source.id"
|
||||||
|
>
|
||||||
<span v-if="index">, and </span>
|
<span v-if="index">, and </span>
|
||||||
<span v-if="source.person">
|
<!-- Generic ingredient-only display when no recipe/meal context; person ref removed -->
|
||||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for {{ source.person.name }}
|
<span v-if="!source.recipe && !source.meal && source.ingredient">
|
||||||
</span>
|
|
||||||
<span v-else-if="source.recipe">
|
|
||||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }}
|
{{ formatQuantity(source.ingredient.quantity) }} {{ 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>
|
||||||
<span v-else-if="source.meal">
|
<span v-else-if="source.recipe && source.ingredient">
|
||||||
{{ 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>
|
{{ formatQuantity(source.ingredient.quantity) }} {{ 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>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="purchased.length > 0">
|
<p v-if="purchased.length > 0">
|
||||||
<strong>Already found or purchased: </strong>
|
<strong>Already found or purchased: </strong>
|
||||||
<span v-for="(source, index) in purchased" :key="source.id">
|
<span
|
||||||
|
v-for="(source, index) in purchased"
|
||||||
|
:key="source.id"
|
||||||
|
>
|
||||||
<span v-if="index">, and </span>
|
<span v-if="index">, and </span>
|
||||||
<span v-if="source.person">
|
<!-- Generic ingredient-only display when no recipe/meal context; person ref removed -->
|
||||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for {{ source.person.name }}
|
<span v-if="!source.recipe && !source.meal && source.ingredient">
|
||||||
</span>
|
|
||||||
<span v-else-if="source.recipe">
|
|
||||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }}
|
{{ formatQuantity(source.ingredient.quantity) }} {{ 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>
|
||||||
<span v-else-if="source.meal">
|
<span v-else-if="source.recipe && source.ingredient">
|
||||||
{{ 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>
|
{{ formatQuantity(source.ingredient.quantity) }} {{ 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>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 */
|
/* Show the product image to the left, then the product name and size to the right */
|
||||||
|
|
||||||
.shopping-list-item {
|
.shopping-list-item {
|
||||||
|
|
@ -106,69 +219,4 @@
|
||||||
.found-marker.partial {
|
.found-marker.partial {
|
||||||
background-color: darkgoldenrod;
|
background-color: darkgoldenrod;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</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>
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
29
src/composables/useAlert.ts
Normal file
29
src/composables/useAlert.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
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 }
|
||||||
|
}
|
||||||
23
src/composables/useAuth.ts
Normal file
23
src/composables/useAuth.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
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 }
|
||||||
|
}
|
||||||
107
src/composables/usePagination.ts
Normal file
107
src/composables/usePagination.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
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 }
|
||||||
|
}
|
||||||
85
src/composables/useShopping.ts
Normal file
85
src/composables/useShopping.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
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
332
src/data.js
|
|
@ -1,332 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
15
src/dateformats.ts
Normal file
15
src/dateformats.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
5
src/domain/commands.ts
Normal file
5
src/domain/commands.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// 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
|
||||||
152
src/domain/decoders.ts
Normal file
152
src/domain/decoders.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/domain/pagination.ts
Normal file
13
src/domain/pagination.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/domain/types.ts
Normal file
74
src/domain/types.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
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
Normal file
10
src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/* 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
51
src/main.js
|
|
@ -1,51 +0,0 @@
|
||||||
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!
|
|
||||||
11
src/main.ts
Normal file
11
src/main.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
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')
|
||||||
27
src/router/helpers.ts
Normal file
27
src/router/helpers.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
79
src/router/index.ts
Normal file
79
src/router/index.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
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
Normal file
10
src/shims-vue.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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
Normal file
4
src/units.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export type QuantityTotal = { quantity: number; unit: string }
|
||||||
|
export declare function calculateTotals(
|
||||||
|
parts: Array<{ quantity: number; unit: string }>
|
||||||
|
): QuantityTotal[]
|
||||||
142
src/units.js
142
src/units.js
|
|
@ -1,142 +0,0 @@
|
||||||
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] }));
|
|
||||||
}
|
|
||||||
139
src/units.ts
Normal file
139
src/units.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
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 }))
|
||||||
|
}
|
||||||
27
tests/mealMapper.test.js
Normal file
27
tests/mealMapper.test.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { decodeMeal } from '@/domain/decoders'
|
||||||
|
|
||||||
|
describe('mealMapper', () => {
|
||||||
|
it('maps individual meal date fields to Date instances', () => {
|
||||||
|
const input = {
|
||||||
|
id: 1,
|
||||||
|
suggestedDate: '2025-01-01T00:00:00Z',
|
||||||
|
purchaseDate: '2025-01-02T00:00:00Z',
|
||||||
|
consumedDate: '2025-01-03T00:00:00Z',
|
||||||
|
}
|
||||||
|
const result = decodeMeal({ ...input })
|
||||||
|
expect(result.suggestedDate).toBeInstanceOf(Date)
|
||||||
|
expect(result.purchaseDate).toBeInstanceOf(Date)
|
||||||
|
expect(result.consumedDate).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps lists of meals', () => {
|
||||||
|
const input = [
|
||||||
|
{ id: 1, suggestedDate: '2025-01-01T00:00:00Z' },
|
||||||
|
{ id: 2, suggestedDate: '2025-01-02T00:00:00Z' },
|
||||||
|
]
|
||||||
|
const result = input.map((m) => decodeMeal(m)).filter((m) => m)
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[0].suggestedDate).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
})
|
||||||
14
tests/meals.api.errors.test.js
Normal file
14
tests/meals.api.errors.test.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { getUpcomingMeals } from '@/api/sdk'
|
||||||
|
|
||||||
|
describe('meals api (errors)', () => {
|
||||||
|
it('propagates errors on invalid upcoming range', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/meals/upcoming', () => new HttpResponse(null, { status: 400 }))
|
||||||
|
)
|
||||||
|
await expect(
|
||||||
|
getUpcomingMeals(new Date('2025-01-03T00:00:00Z'), new Date('2025-01-01T00:00:00Z'))
|
||||||
|
).rejects.toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
43
tests/meals.api.test.js
Normal file
43
tests/meals.api.test.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { getMeal, markMealConsumed, getUpcomingMeals } from '@/api/sdk'
|
||||||
|
|
||||||
|
describe('meals api (typed client)', () => {
|
||||||
|
it('gets a meal by id', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/meals/:id', ({ params }) => {
|
||||||
|
return HttpResponse.json({ id: params.id, suggestedDate: '2025-01-01T00:00:00Z' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const meal = await getMeal(10)
|
||||||
|
expect(meal.id).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a meal consumed', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('*/api/v1/meals/:id/consumed', () => {
|
||||||
|
return HttpResponse.json({ id: 10, consumedDate: '2025-01-02T00:00:00Z' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const meal = await markMealConsumed(10)
|
||||||
|
expect(meal.consumedDate).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists upcoming meals', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/meals/upcoming', ({ request }) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (!url.searchParams.get('from') || !url.searchParams.get('to')) {
|
||||||
|
return new HttpResponse(null, { status: 400 })
|
||||||
|
}
|
||||||
|
return HttpResponse.json([
|
||||||
|
{ id: 1, suggestedDate: '2025-01-01T00:00:00Z' },
|
||||||
|
{ id: 2, suggestedDate: '2025-01-02T00:00:00Z' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const list = await getUpcomingMeals(new Date('2025-01-01T00:00:00Z'), new Date('2025-01-03T00:00:00Z'))
|
||||||
|
expect(Array.isArray(list)).toBe(true)
|
||||||
|
expect(list[0].suggestedDate).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
})
|
||||||
19
tests/parse.api.errors.test.js
Normal file
19
tests/parse.api.errors.test.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { parseIngredients, parseRecipe } from '@/api/sdk'
|
||||||
|
|
||||||
|
describe('parse api errors', () => {
|
||||||
|
it('returns 422 for invalid ingredient lines', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/recipes/ingredients/parse', () => new HttpResponse(null, { status: 422 }))
|
||||||
|
)
|
||||||
|
await expect(parseIngredients([''])).rejects.toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 422 for invalid recipe URL', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/recipes/parse', () => new HttpResponse(null, { status: 422 }))
|
||||||
|
)
|
||||||
|
await expect(parseRecipe('not-a-url')).rejects.toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
48
tests/parse.api.test.js
Normal file
48
tests/parse.api.test.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { parseIngredients, parseProduct, parseRecipe } from '@/api/sdk'
|
||||||
|
|
||||||
|
// Parse endpoints: ingredients, product, recipe
|
||||||
|
|
||||||
|
describe('parse api (typed client)', () => {
|
||||||
|
it('parses ingredient lines', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/recipes/ingredients/parse', () => {
|
||||||
|
return HttpResponse.json([
|
||||||
|
{ id: 1, name: 'Eggs', line: '2 eggs', unit: 'Items', quantity: 2 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const result = await parseIngredients(['2 eggs'])
|
||||||
|
expect(result[0].name).toBe('Eggs')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses a recipe from URL', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/recipes/parse', () => {
|
||||||
|
return HttpResponse.json({ id: 10, name: 'Pancakes', ingredients: [] })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const recipe = await parseRecipe('https://example.com/pancakes')
|
||||||
|
expect(recipe?.name).toBe('Pancakes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses/creates a product from URL', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('*/api/v1/products', async ({ request }) => {
|
||||||
|
const body = await request.json()
|
||||||
|
if (!body?.url) return new HttpResponse(null, { status: 422 })
|
||||||
|
return HttpResponse.json({
|
||||||
|
id: 99,
|
||||||
|
name: 'Sample Product',
|
||||||
|
link: body.url,
|
||||||
|
unit: 'Items',
|
||||||
|
imgSmall: '',
|
||||||
|
imgLarge: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const product = await parseProduct({ name: 'Eggs', line: '2 eggs' }, 'https://store/item')
|
||||||
|
expect(product?.name).toBe('Sample Product')
|
||||||
|
})
|
||||||
|
})
|
||||||
12
tests/persons.api.errors.test.js
Normal file
12
tests/persons.api.errors.test.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { getPersonsInHome } from '@/api/sdk'
|
||||||
|
|
||||||
|
describe('persons api errors', () => {
|
||||||
|
it('propagates non-2xx errors', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/persons', () => new HttpResponse(null, { status: 422 }))
|
||||||
|
)
|
||||||
|
await expect(getPersonsInHome()).rejects.toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
30
tests/persons.api.test.js
Normal file
30
tests/persons.api.test.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { getPersonsInHome, searchPersons } from '@/api/sdk'
|
||||||
|
|
||||||
|
// Persons API tests
|
||||||
|
|
||||||
|
describe('persons api (typed client)', () => {
|
||||||
|
it('lists persons in home', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/persons', () => {
|
||||||
|
return HttpResponse.json([{ id: 1, name: 'Ada Lovelace' }])
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const page = await getPersonsInHome()
|
||||||
|
expect(Array.isArray(page.items)).toBe(true)
|
||||||
|
expect(page.items[0].name).toBe('Ada Lovelace')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('searches persons by name', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/persons', ({ request }) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const q = url.searchParams.get('q')
|
||||||
|
return HttpResponse.json(q ? [{ id: 2, name: 'Alan Turing' }] : [])
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const page = await searchPersons('alan')
|
||||||
|
expect(page.items[0].name.toLowerCase()).toContain('alan')
|
||||||
|
})
|
||||||
|
})
|
||||||
12
tests/recipes.api.errors.test.js
Normal file
12
tests/recipes.api.errors.test.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { getRecipe } from '@/api/sdk'
|
||||||
|
|
||||||
|
describe('recipes api (errors)', () => {
|
||||||
|
it('throws on 404 getRecipe', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/recipes/:id', () => new HttpResponse(null, { status: 404 }))
|
||||||
|
)
|
||||||
|
await expect(getRecipe('999')).rejects.toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
22
tests/recipes.api.test.js
Normal file
22
tests/recipes.api.test.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { getRecipe } from '@/api/sdk'
|
||||||
|
|
||||||
|
// Tests validate the typed client wrapper behavior without needing the backend
|
||||||
|
|
||||||
|
describe('recipes api (typed client)', () => {
|
||||||
|
it('gets a recipe by id', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/recipes/:id', ({ params }) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('MSW handler hit with params:', params)
|
||||||
|
const { id } = params
|
||||||
|
return HttpResponse.json({ id, name: 'Pancakes', ingredients: [] }, { status: 200 })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = await getRecipe('123')
|
||||||
|
expect(data).toBeTruthy()
|
||||||
|
expect(data.name).toBe('Pancakes')
|
||||||
|
})
|
||||||
|
})
|
||||||
12
tests/shopping.api.errors.test.js
Normal file
12
tests/shopping.api.errors.test.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { purchaseShoppingList } from '@/api/sdk'
|
||||||
|
|
||||||
|
describe('shopping api (errors)', () => {
|
||||||
|
it('propagates errors on purchase failure', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('*/api/v1/shopping', () => new HttpResponse(null, { status: 422 }))
|
||||||
|
)
|
||||||
|
await expect(purchaseShoppingList([{ type: 'existing', id: 1, personId: 42 }])).rejects.toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
31
tests/shopping.api.test.js
Normal file
31
tests/shopping.api.test.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { getCurrentShoppingList, getShoppingList, requestMeal, unrequestMeal, purchaseShoppingList } from '@/api/sdk'
|
||||||
|
|
||||||
|
describe('shopping api (typed client)', () => {
|
||||||
|
it('gets current shopping list', async () => {
|
||||||
|
server.use(http.get('*/api/v1/shopping/current', () => HttpResponse.json({ outstandingItems: [] })))
|
||||||
|
const list = await getCurrentShoppingList()
|
||||||
|
expect(list).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gets a purchased shopping list by id', async () => {
|
||||||
|
server.use(http.get('*/api/v1/shopping/:id', () => HttpResponse.json({ list: { id: 99, items: [] } })))
|
||||||
|
const list = await getShoppingList(99)
|
||||||
|
expect(list.id).toBe(99)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requests and unrequests a meal', async () => {
|
||||||
|
server.use(http.post('*/api/v1/shopping/current/meals/me', () => HttpResponse.json([{ id: 1 }])))
|
||||||
|
await requestMeal(44)
|
||||||
|
|
||||||
|
server.use(http.delete('*/api/v1/shopping/current/meals/:id', () => new HttpResponse(null, { status: 204 })))
|
||||||
|
await unrequestMeal(44)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('purchases a list', async () => {
|
||||||
|
server.use(http.post('*/api/v1/shopping', () => HttpResponse.json({ list: { id: 1, items: [] } })))
|
||||||
|
const list = await purchaseShoppingList([{ type: 'existing', id: 1, personId: 42 }])
|
||||||
|
expect(list.id).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
48
tests/shopping.mappers.boundary.test.ts
Normal file
48
tests/shopping.mappers.boundary.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
39
tests/shoppingListMapper.test.js
Normal file
39
tests/shoppingListMapper.test.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mapCurrentShoppingList, mapPurchasedShoppingList } from '@/api/sdk'
|
||||||
|
|
||||||
|
describe('shoppingListMapper', () => {
|
||||||
|
it('maps current shopping list and wires references', () => {
|
||||||
|
const dto = {
|
||||||
|
ingredientsLookup: { 10: { id: 10, name: 'Eggs' } },
|
||||||
|
mealsLookup: { 1: { id: 1, suggestedDate: '2025-01-01T00:00:00Z', recipes: [], extraIngredients: [], chefs: [], consumers: [], cleanup: [] } },
|
||||||
|
recipesLookup: { 5: { id: 5, name: 'Omelette' } },
|
||||||
|
shoppingListLookup: { 7: { id: 7, createdDate: '2025-01-01T00:00:00Z' } },
|
||||||
|
outstandingItems: [{ ingredientId: 10, mealId: 1, recipeId: 5, listId: 7, createdDate: '2025-01-01T00:00:00Z' }],
|
||||||
|
requestedMeals: [],
|
||||||
|
purchasedItems: [],
|
||||||
|
}
|
||||||
|
const mapped = mapCurrentShoppingList(dto)
|
||||||
|
// DEBUG
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('mapped current keys:', Object.keys(mapped || {}))
|
||||||
|
const item = mapped.outstandingItems[0]
|
||||||
|
expect(item.ingredient.name).toBe('Eggs')
|
||||||
|
expect(item.meal.suggestedDate).toBeInstanceOf(Date)
|
||||||
|
expect(mapped.shoppingListLookup['7'].createdDate).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps purchased shopping list with list dates and item refs', () => {
|
||||||
|
const dto = {
|
||||||
|
ingredientsLookup: { 10: { id: 10, name: 'Eggs' } },
|
||||||
|
mealsLookup: { 1: { id: 1, suggestedDate: '2025-01-01T00:00:00Z', recipes: [], extraIngredients: [], chefs: [], consumers: [], cleanup: [] } },
|
||||||
|
recipesLookup: { 5: { id: 5, name: 'Omelette' } },
|
||||||
|
list: { id: 9, createdDate: '2025-02-02T00:00:00Z', items: [{ ingredientId: 10, mealId: 1, recipeId: 5, listId: 9 }] },
|
||||||
|
}
|
||||||
|
const mapped = mapPurchasedShoppingList(dto)
|
||||||
|
// DEBUG
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('mapped purchased keys:', Object.keys(mapped || {}))
|
||||||
|
expect(mapped.list.createdDate).toBeInstanceOf(Date)
|
||||||
|
expect(mapped.list.items[0].recipe.name).toBe('Omelette')
|
||||||
|
})
|
||||||
|
})
|
||||||
24
tests/test-setup.js
Normal file
24
tests/test-setup.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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 }
|
||||||
27
tests/units.test.js
Normal file
27
tests/units.test.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
26
tests/useAlert.test.ts
Normal file
26
tests/useAlert.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
20
tsconfig.eslint.json
Normal file
20
tsconfig.eslint.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
17
vitest.config.js
Normal file
17
vitest.config.js
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
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'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
const { defineConfig } = require('@vue/cli-service')
|
const { defineConfig } = require('@vue/cli-service')
|
||||||
|
/* eslint-enable @typescript-eslint/no-var-requires */
|
||||||
module.exports = defineConfig({
|
module.exports = defineConfig({
|
||||||
transpileDependencies: true
|
transpileDependencies: true,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue