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 |
82 changed files with 13109 additions and 3449 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
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
```
|
||||
2) Run the dev server
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
```
|
||||
npm run lint
|
||||
Environment
|
||||
- API base URL: set VUE_APP_API_BASE (e.g. http://localhost:8081)
|
||||
|
||||
---
|
||||
|
||||
## Architecture and conventions
|
||||
|
||||
Strict TypeScript, Vue 3 Composition API, and a single typed API boundary.
|
||||
|
||||
Key axioms
|
||||
- OpenAPI (generated `src/api/types.ts`) is the single source of truth for shapes.
|
||||
- SDK (`src/api/sdk.ts`) is the only data access surface; UI uses domain types from `src/domain/types.ts`.
|
||||
- Domain normalization is minimal in `src/domain/decoders.ts` (e.g., date strings → `Date`).
|
||||
- No casts (`as`, angle brackets) and no `any`/`unknown` in app code. Generated files are exempt.
|
||||
- Arrays that are required in OpenAPI are non-nullable in domain types (e.g., `Meal.recipes`).
|
||||
- Disallow runtime type checks in app code; acceptable exceptions: DOM event narrowing, error/env handling in the boundary.
|
||||
|
||||
Layout
|
||||
- `src/api/` — Typed client and SDK boundary
|
||||
- `src/domain/` — Domain types and decoders
|
||||
- `src/composables/` — Reusable app logic (auth, meals, shopping, pagination, alert)
|
||||
- `src/components/` — UI components and pages
|
||||
- `src/router/` — Routes and helpers (`parseRouteId`, `parseQueryString`)
|
||||
|
||||
Testing and tooling
|
||||
- Vitest + MSW under `tests/`
|
||||
- ESLint (type-aware) + Prettier + Volar
|
||||
|
||||
---
|
||||
|
||||
## Development tips
|
||||
|
||||
- Prefer composables for shared logic; keep components thin.
|
||||
- Use computed for derived values; avoid mutating props directly.
|
||||
- Use discriminated unions for UI-only shapes when helpful (e.g., shopping `Group`).
|
||||
- Add/adjust tests when changing behavior.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- API errors: verify `VUE_APP_API_BASE` is set and reachable.
|
||||
- Type/IDE help: ensure Volar is enabled and ESLint is not conflicting with Prettier.
|
||||
- Build issues: this project uses Vue CLI 5. If migrating to Vite, update scripts and configs accordingly.
|
||||
|
||||
---
|
||||
|
||||
## Type checking and codegen
|
||||
|
||||
- Type check (TS strict + vue-tsc):
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||
- Generate API types (consumed by SDK):
|
||||
|
||||
```bash
|
||||
npm run codegen
|
||||
```
|
||||
|
||||
Environment
|
||||
- VUE_APP_API_BASE (Vue CLI) or VITE_API_BASE_URL (Vite) for API base URL
|
||||
|
||||
Testing
|
||||
- Unit tests use MSW; the client defaults to a localhost base in tests for easy mocking
|
||||
|
||||
### CurrentShoppingList item kinds
|
||||
|
||||
The OpenAPI spec models current shopping list items as distinct kinds:
|
||||
- outstandingItems: ListIngredientItem[]
|
||||
- requestedMeals: RequestedMealItem[]
|
||||
- purchasedItems: ListIngredientItem[]
|
||||
|
||||
The SDK maps these to a domain DTO (`CurrentShoppingListDTO`) and may attach refs (`ingredient`, `recipe`, `meal`, `list`) for convenience. UI code should:
|
||||
- Prefer stable IDs (`ingredientId`, `mealId`, `recipeId`, `listId`) for actions and lookups
|
||||
- Treat attached refs as optional view helpers (never required)
|
||||
- Keep all normalization at the boundary (decoders); avoid casts and runtime type checks in app code
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
presets: ['@vue/cli-plugin-babel/preset'],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,8 @@
|
|||
"baseUrl": "./",
|
||||
"moduleResolution": "node",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"scripthost"
|
||||
]
|
||||
"lib": ["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": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"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": {
|
||||
"core-js": "^3.8.3",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.16",
|
||||
"@babel/eslint-parser": "^7.12.16",
|
||||
"@types/node": "^20.19.22",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
|
||||
"@vue/cli-plugin-babel": "~5.0.0",
|
||||
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||
"@vue/cli-plugin-typescript": "~5.0.0",
|
||||
"@vue/cli-service": "~5.0.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-vue": "^8.0.3"
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-vue": "^9.27.0",
|
||||
"husky": "^8.0.0",
|
||||
"lint-staged": "^13.3.0",
|
||||
"msw": "^2.5.2",
|
||||
"openapi-fetch": "^0.9.5",
|
||||
"openapi-typescript": "^7.4.2",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "~5.5.4",
|
||||
"vitest": "^1.6.0",
|
||||
"vue-tsc": "^2.0.29"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,vue,css,scss,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
"node": true,
|
||||
"vue/setup-compiler-macros": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/vue3-essential",
|
||||
"eslint:recommended"
|
||||
"plugin:vue/vue3-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": {
|
||||
"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": [
|
||||
"> 1%",
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
<strong
|
||||
>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without
|
||||
JavaScript enabled. Please enable it to continue.</strong
|
||||
>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
|
|
|
|||
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>
|
||||
<ul class="nav">
|
||||
<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 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 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>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -19,30 +37,13 @@
|
|||
<alert-toast />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import data from './data.js'
|
||||
<script setup>
|
||||
import AlertToast from './components/AlertToast.vue'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
'alert-toast': AlertToast
|
||||
},
|
||||
computed: {
|
||||
currentRoute() {
|
||||
return this.$route.path
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (!await data.currentUser()) {
|
||||
this.$router.push('/login')
|
||||
}
|
||||
}
|
||||
}
|
||||
// components in <script setup> are auto-registered by import + usage
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
#app {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
|
@ -86,7 +87,7 @@ export default {
|
|||
|
||||
/* Have active route use different color */
|
||||
.nav li:has(> a.active) {
|
||||
background-color: #4CAF50;
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
|
@ -94,5 +95,4 @@ export default {
|
|||
max-width: 1200px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
</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
12
src/assets/missing-product.svg
Normal file
12
src/assets/missing-product.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<title>ic_fluent_missing_metadata_24_regular</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="🔍-System-Icons" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="ic_fluent_missing_metadata_24_regular" fill="#212121" fill-rule="nonzero">
|
||||
<path d="M19.7501,2 C20.9927,2 22.0001,3.00736 22.0001,4.25 L22.0001,9.71196 C22.0001,10.5738 21.6578,11.4003 21.0484,12.0098 L21.0222,12.0361 C20.5797,11.7503 20.1003,11.5167 19.5928,11.3442 L19.9876,10.9492 C20.3157,10.6211 20.5001,10.176 20.5001,9.71196 L20.5001,4.25 C20.5001,3.83579 20.1643,3.5 19.7501,3.5 L14.2847,3.5 C13.8202,3.5 13.3748,3.68467 13.0465,4.01333 L4.53436,12.5358 C3.86414,13.2207 3.86923,14.3191 4.54908,14.9977 L9.0103,19.4522 C9.64819,20.0877 10.6535,20.1309 11.3408,19.5826 C11.5052,20.0689 11.7256,20.5295 11.9943,20.9567 C10.7373,21.7569 9.05064,21.6098 7.95104,20.5143 L3.48934,16.0592 C2.21887,14.7913 2.21724,12.7334 3.48556,11.4632 L11.9852,2.95334 C12.5948,2.34297 13.4221,2 14.2847,2 L19.7501,2 Z M17,5.50218 C17.8284,5.50218 18.5,6.17374 18.5,7.00216 C18.5,7.83057 17.8284,8.50213 17,8.50213 C16.1716,8.50213 15.5001,7.83057 15.5001,7.00216 C15.5001,6.17374 16.1716,5.50218 17,5.50218 Z M23,17.5 C23,14.4624 20.5376,12 17.5,12 C14.4624,12 12,14.4624 12,17.5 C12,20.5376 14.4624,23 17.5,23 C20.5376,23 23,20.5376 23,17.5 Z M16.8755,20.5045 C16.8755,20.1596 17.1551,19.88 17.5,19.88 C17.8449,19.88 18.1245,20.1596 18.1245,20.5045 C18.1245,20.8494 17.8449,21.129 17.5,21.129 C17.1551,21.129 16.8755,20.8494 16.8755,20.5045 Z M15.6467,15.9574 C15.6357,14.8205 16.4521,14.0031 17.5,14.0031 C18.5311,14.0031 19.3534,14.8489 19.3534,15.9526 C19.3534,16.5186 19.1682,16.866 18.6905,17.4003 L18.4247,17.6908 L18.3238,17.8063 C18.0765,18.0981 18,18.2684 18,18.5006 C18,18.7767 17.7762,19.0006 17.5,19.0006 C17.2239,19.0006 17,18.7767 17,18.5006 C17,17.9255 17.1868,17.5749 17.6711,17.0333 L17.9365,16.7432 L18.0355,16.63 C18.2782,16.3437 18.3534,16.1769 18.3534,15.9526 C18.3534,15.395 17.9724,15.0031 17.5,15.0031 C17.0063,15.0031 16.6411,15.3688 16.6465901,15.9478 C16.6493,16.2239 16.4276,16.4499 16.1514,16.4526 C15.8753,16.4552 15.6493,16.2335 15.6467,15.9574 Z" id="🎨-Color">
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
|
@ -1,17 +1,21 @@
|
|||
<template>
|
||||
<div class="card">
|
||||
<a @click="$emit('click')">
|
||||
<a @click="emit('click')">
|
||||
<h2>{{ title }}</h2>
|
||||
<img :src="image" :alt="name" />
|
||||
<img
|
||||
:src="image"
|
||||
:alt="title"
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ActionItem',
|
||||
props: ['title', 'image']
|
||||
}
|
||||
<script setup>
|
||||
const emit = defineEmits(['click'])
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
image: { type: String, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -33,5 +37,4 @@ img {
|
|||
.card:hover {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -1,17 +1,56 @@
|
|||
<template>
|
||||
|
||||
<div v-if="showAlert" :class="['alert', type]" @click="dismiss">
|
||||
<img v-if="icon" :src="icon" alt="Notification icon" />
|
||||
<div
|
||||
v-if="showAlert"
|
||||
:class="['alert', type]"
|
||||
@click="dismiss"
|
||||
>
|
||||
<img
|
||||
v-if="icon"
|
||||
:src="icon"
|
||||
alt="Notification icon"
|
||||
>
|
||||
<div class="message-container">
|
||||
<h4 class="heading">{{ heading }}</h4>
|
||||
<p class="message">{{ message }}</p>
|
||||
<h4 class="heading">
|
||||
{{ heading }}
|
||||
</h4>
|
||||
<p class="message">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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 */
|
||||
/* 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 {
|
||||
background-color: #4CAF50;
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
.alert.info {
|
||||
background-color: #2196F3;
|
||||
background-color: #2196f3;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import alert from '@/alert';
|
||||
|
||||
const alertIcons = {
|
||||
error: require('@/assets/notification-error.svg'),
|
||||
success: require('@/assets/notification-success.svg'),
|
||||
info: require('@/assets/notification-info.svg')
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'AlertToast',
|
||||
data() {
|
||||
return {
|
||||
showAlert: false,
|
||||
heading: '',
|
||||
message: '',
|
||||
type: ''
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
icon() {
|
||||
return this.type && alertIcons[this.type] ? alertIcons[this.type] : null;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
alert.subscribe(this.show);
|
||||
},
|
||||
methods: {
|
||||
show({ heading, message, type }) {
|
||||
this.heading = heading;
|
||||
this.message = message;
|
||||
this.type = type;
|
||||
this.showAlert = true;
|
||||
setTimeout(() => {
|
||||
this.showAlert = false;
|
||||
}, 5000);
|
||||
},
|
||||
dismiss() {
|
||||
this.showAlert = false;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,20 +1,53 @@
|
|||
<template>
|
||||
|
||||
<div class="login">
|
||||
<h1>Login Page</h1>
|
||||
<ul class="button-group">
|
||||
<li v-for="person in persons" :key="person.id">
|
||||
<button type="button" class="btn btn-primary" @click="login(person)">
|
||||
<li
|
||||
v-for="(person, index) in persons"
|
||||
:key="person.id ?? index"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="onLogin(person)"
|
||||
>
|
||||
{{ person.name }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</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 */
|
||||
ul {
|
||||
list-style-type: none;
|
||||
|
|
@ -68,40 +101,4 @@ li:nth-child(4) > button {
|
|||
background-color: #9a1f1f;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import data from '@/data';
|
||||
|
||||
export default {
|
||||
name: 'LoginVue',
|
||||
props: {
|
||||
redirect: {
|
||||
type: String,
|
||||
default: '/'
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
persons: [],
|
||||
}
|
||||
},
|
||||
async beforeMount() {
|
||||
this.persons = await data.getPersonsInHome();
|
||||
},
|
||||
methods: {
|
||||
async login(selectedPerson) {
|
||||
const person = await data.login(selectedPerson.name);
|
||||
if (person?.id >= 0) {
|
||||
this.$router.push(this.redirect);
|
||||
return;
|
||||
}
|
||||
|
||||
alert('Login failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,44 +1,79 @@
|
|||
<template>
|
||||
<div class="compact-parse-results">
|
||||
<p class="parse-element teaser-image" v-if="ingredient.product && ingredient.product.img_small">
|
||||
<img :src="ingredient.product.img_small" />
|
||||
<p class="parse-element teaser-image">
|
||||
<img :src="ingredient.product?.imgSmall ?? missingProduct">
|
||||
</p>
|
||||
<p class="ingredient-details">
|
||||
<span class="parse-element quantity" :class="{ missing: !(ingredient?.quantity)}">{{ ingredient?.quantity || 'qty' }}</span>
|
||||
<span class="parse-element unit" :class="{ missing: !(ingredient?.unit)}">{{ ingredient?.unit || 'unit' }}</span>
|
||||
<span
|
||||
class="parse-element quantity"
|
||||
:class="{ missing: !ingredient?.quantity }"
|
||||
>{{
|
||||
ingredient?.quantity || 'qty'
|
||||
}}</span>
|
||||
<span
|
||||
class="parse-element unit"
|
||||
:class="{ missing: !ingredient?.unit }"
|
||||
>{{
|
||||
ingredient?.unit || 'unit'
|
||||
}}</span>
|
||||
<span class="parse-element helper">of</span>
|
||||
<span class="parse-element name" :class="{ missing: !(ingredient?.name)}">{{ ingredient?.name || 'name' }}</span>:
|
||||
<span class="parse-element product-name" :class="{missing: !(ingredient?.product)}">
|
||||
<a :href="ingredient?.product?.link" v-if="ingredient?.product?.link" target=”_blank”>
|
||||
( {{ ingredient?.product?.name }} <img src="@/assets/external-link.svg" style="width: 1em; height: 1em; vertical-align: middle; margin-left: 0.5em; margin-bottom: 0.2em;" /> )
|
||||
</a>
|
||||
<a v-else-if="ingredient?.name" :href="searchlink" target="_blank">
|
||||
(search?)
|
||||
</a>
|
||||
<a v-else>
|
||||
(product)
|
||||
<span
|
||||
class="parse-element name"
|
||||
:class="{ missing: !ingredient?.name }"
|
||||
>{{
|
||||
ingredient?.name || 'name'
|
||||
}}</span>:
|
||||
<span
|
||||
class="parse-element product-name"
|
||||
:class="{ missing: !ingredient?.product }"
|
||||
>
|
||||
<a
|
||||
v-if="ingredient?.product?.link"
|
||||
:href="ingredient?.product?.link"
|
||||
target="”_blank”"
|
||||
>
|
||||
( {{ ingredient?.product?.name }}
|
||||
<img
|
||||
:src="externalLink"
|
||||
style="
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: middle;
|
||||
margin-left: 0.5em;
|
||||
margin-bottom: 0.2em;
|
||||
"
|
||||
>
|
||||
)
|
||||
</a>
|
||||
<a
|
||||
v-else-if="ingredient?.name"
|
||||
:href="searchlink"
|
||||
target="_blank"
|
||||
> (search?) </a>
|
||||
<a v-else> (product) </a>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</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 {
|
||||
name: 'CompactParsedIngredient',
|
||||
props: ['ingredient'],
|
||||
computed: {
|
||||
searchlink() {
|
||||
return 'https://www.woolworths.com.au/shop/search/products?searchTerm=' + encodeURIComponent(this.ingredient.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
const props = defineProps({
|
||||
ingredient: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const searchlink = computed(() =>
|
||||
props.ingredient?.name
|
||||
? 'https://www.woolworths.com.au/shop/search/products?searchTerm=' +
|
||||
encodeURIComponent(props.ingredient.name)
|
||||
: ''
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.compact-parse-results {
|
||||
flex: left;
|
||||
display: flex;
|
||||
|
|
@ -58,7 +93,8 @@ export default {
|
|||
border: solid red 1px;
|
||||
}
|
||||
|
||||
.parse-element.teaser-image, .parse-element.helper {
|
||||
.parse-element.teaser-image,
|
||||
.parse-element.helper {
|
||||
border: none;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
|
@ -89,5 +125,4 @@ export default {
|
|||
.product-name {
|
||||
color: purple;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -1,31 +1,52 @@
|
|||
<template>
|
||||
|
||||
<div :class="{ editing: editing }">
|
||||
<button v-if="editing" @click="$emit('on-add')">
|
||||
<img class="icon" :src="require('@/assets/add-cart.svg')" /> <br />
|
||||
<button
|
||||
v-if="editing"
|
||||
@click="emit('on-add')"
|
||||
>
|
||||
<img
|
||||
class="icon"
|
||||
:src="addCart"
|
||||
> <br>
|
||||
Add Ingredient
|
||||
</button>
|
||||
<button @click="toggleEditing" v-if="!editOnly">
|
||||
<button
|
||||
v-if="!editOnly"
|
||||
@click="toggleEditing"
|
||||
>
|
||||
<span v-if="editing">
|
||||
<img class="icon" :src="require('@/assets/edit-off.svg')" /> <br />
|
||||
<img
|
||||
class="icon"
|
||||
:src="editOff"
|
||||
> <br>
|
||||
Done Editing
|
||||
</span>
|
||||
<span v-else>
|
||||
<img class="icon" :src="require('@/assets/edit.svg')" /> <br />
|
||||
<img
|
||||
class="icon"
|
||||
:src="editOn"
|
||||
> <br>
|
||||
Edit My List
|
||||
</span>
|
||||
</button>
|
||||
<ul>
|
||||
<li v-for="ingredient in ingredients" :key="ingredient">
|
||||
<li
|
||||
v-for="ingredient in ingredients"
|
||||
:key="ingredient"
|
||||
>
|
||||
<div v-if="editing">
|
||||
<p class="ingredient-line">
|
||||
<ingredient-line
|
||||
:ingredient="ingredient"
|
||||
@update-ingredient="updateIngredient"
|
||||
@update-product-link="updateProduct" />
|
||||
@update-product-link="updateProduct"
|
||||
/>
|
||||
</p>
|
||||
<button @click="$emit('on-delete', ingredient)">
|
||||
<img class="icon" :src="require('@/assets/trash.svg')" />
|
||||
<button @click="emit('on-delete', ingredient)">
|
||||
<img
|
||||
class="icon"
|
||||
:src="trash"
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
|
|
@ -34,11 +55,43 @@
|
|||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</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 {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
|
|
@ -70,38 +123,4 @@ li > div {
|
|||
margin: 0;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import data from '@/data.js'
|
||||
import IngredientLine from './IngredientLine.vue'
|
||||
import CompactParsedIngredient from './CompactParsedIngredient.vue'
|
||||
|
||||
export default {
|
||||
name: 'EditableIngredientsPanel',
|
||||
components: { IngredientLine, CompactParsedIngredient },
|
||||
props: ['ingredients', 'editOnly'],
|
||||
data() {
|
||||
return {
|
||||
editing: this.editOnly ?? false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async updateProduct(ingredient, product_link) {
|
||||
const product = await data.parseProduct(ingredient, product_link);
|
||||
this.$emit('on-update-ingredient', ingredient, { ...ingredient, product });
|
||||
},
|
||||
async updateIngredient(ingredient, line) {
|
||||
const newIngredients = await data.parseIngredients([line]);
|
||||
this.$emit('on-update-ingredient', ingredient, newIngredients[0]);
|
||||
},
|
||||
toggleEditing() {
|
||||
this.editing = !this.editing;
|
||||
this.$emit('on-editing', this.editing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,8 +1,20 @@
|
|||
<template>
|
||||
<div class="ingredient-item">
|
||||
<p>
|
||||
<input v-model="ingredientText" @keyup.enter="updateIngredient" @blur="updateIngredient" placeholder="Enter an ingredient" />
|
||||
<input v-model="productLink" v-if="ingredient.line" class="product-link-input" placeholder="Enter product link" @keyup.enter="updateProductLink" @blur="updateProductLink" />
|
||||
<input
|
||||
v-model="ingredientText"
|
||||
placeholder="Enter an ingredient"
|
||||
@keyup.enter="updateIngredient"
|
||||
@blur="updateIngredient"
|
||||
>
|
||||
<input
|
||||
v-if="ingredient.line"
|
||||
v-model="productLink"
|
||||
class="product-link-input"
|
||||
placeholder="Enter product link"
|
||||
@keyup.enter="updateProductLink"
|
||||
@blur="updateProductLink"
|
||||
>
|
||||
</p>
|
||||
<p v-if="ingredient.line">
|
||||
<!-- Single line parse results -->
|
||||
|
|
@ -11,45 +23,43 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CompactParsedIngredient from './CompactParsedIngredient.vue';
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import CompactParsedIngredient from './CompactParsedIngredient.vue'
|
||||
import type { Ingredient } from '@/domain/types'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
ingredient: { type: Object },
|
||||
const props = defineProps<{ ingredient: Ingredient }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update-ingredient', ingredient: Ingredient, newLine: string): void
|
||||
(e: 'update-product-link', ingredient: Ingredient, link: string): void
|
||||
}>()
|
||||
|
||||
const ingredientText = ref<string>(props.ingredient?.line ?? '')
|
||||
const productLink = ref<string>(props.ingredient.product?.link ?? '')
|
||||
|
||||
watch(
|
||||
() => props.ingredient,
|
||||
(newIngredient) => {
|
||||
ingredientText.value = newIngredient?.line ?? ''
|
||||
productLink.value = newIngredient?.product?.link ?? ''
|
||||
},
|
||||
events: ['update-ingredient', 'update-product-link'],
|
||||
components: { CompactParsedIngredient },
|
||||
data() {
|
||||
return {
|
||||
ingredientText: this.ingredient?.line ?? "",
|
||||
productLink: this.ingredient.product?.link ?? "",
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
ingredient: {
|
||||
handler: function (newIngredient) {
|
||||
this.ingredientText = newIngredient?.line ?? "";
|
||||
this.productLink = newIngredient.product?.link ?? "";
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updateIngredient() {
|
||||
if (this.ingredientText != this.ingredient.line)
|
||||
this.$emit('update-ingredient', this.ingredient, this.ingredientText);
|
||||
},
|
||||
updateProductLink() {
|
||||
if (this.productLink && this.productLink != this.ingredient.product?.link)
|
||||
this.$emit('update-product-link', this.ingredient, this.productLink);
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function updateIngredient() {
|
||||
if (ingredientText.value != props.ingredient.line) {
|
||||
emit('update-ingredient', props.ingredient, ingredientText.value)
|
||||
}
|
||||
}
|
||||
|
||||
function updateProductLink() {
|
||||
if (productLink.value && productLink.value != props.ingredient.product?.link) {
|
||||
emit('update-product-link', props.ingredient, productLink.value)
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
input {
|
||||
border: 0;
|
||||
font-size: larger;
|
||||
|
|
@ -63,6 +73,4 @@ input {
|
|||
color: #777;
|
||||
margin-top: 0.5vh;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
|
@ -1,18 +1,22 @@
|
|||
<template>
|
||||
<div class="date-picker">
|
||||
<input
|
||||
type="text"
|
||||
v-model="selectedDate"
|
||||
type="text"
|
||||
placeholder="Select a date"
|
||||
@focus="showDatePicker = true"
|
||||
@blur="showDatePicker = false"
|
||||
placeholder="Select a date"
|
||||
/>
|
||||
<div v-if="showDatePicker" class="date-picker-dropdown">
|
||||
>
|
||||
<div
|
||||
v-if="showDatePicker"
|
||||
class="date-picker-dropdown"
|
||||
>
|
||||
<ul>
|
||||
<li
|
||||
v-for="(day, index) in days"
|
||||
:key="index"
|
||||
@mousedown="selectDate(day)">
|
||||
@mousedown="selectDate(day)"
|
||||
>
|
||||
{{ formatDay(day) }}
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -20,57 +24,44 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
date: {
|
||||
type: Date,
|
||||
default: new Date(),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedDate: this.formatDay(this.date),
|
||||
showDatePicker: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
date(newDate) {
|
||||
this.selectedDate = this.formatDay(newDate);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
days() {
|
||||
const today = new Date();
|
||||
const days = [];
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() + i);
|
||||
days.push(date);
|
||||
const props = defineProps<{ date?: Date }>()
|
||||
const emit = defineEmits<{ (e: 'date-selected', date: Date): void }>()
|
||||
|
||||
function formatDay(date: Date): string {
|
||||
const options: Intl.DateTimeFormatOptions = { weekday: 'long', day: 'numeric', month: 'numeric' }
|
||||
return date.toLocaleDateString('en-AU', options)
|
||||
}
|
||||
|
||||
return days;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatDay(date) {
|
||||
const options = { weekday: "long", day: "numeric", month: "numeric" };
|
||||
return date.toLocaleDateString("en-AU", options);
|
||||
},
|
||||
selectDate(date) {
|
||||
this.selectedDate = this.formatSelectedDate(date);
|
||||
this.showDatePicker = false;
|
||||
const initialDate = props.date ?? new Date()
|
||||
const selectedDate = ref<string>(formatDay(initialDate))
|
||||
const showDatePicker = ref(false)
|
||||
|
||||
// Emit custom event
|
||||
this.$emit("date-selected", date);
|
||||
},
|
||||
formatSelectedDate(date) {
|
||||
const options = { weekday: "long", day: "numeric", month: "numeric" };
|
||||
return date.toLocaleDateString("en-AU", options);
|
||||
},
|
||||
},
|
||||
};
|
||||
watch(
|
||||
() => props.date,
|
||||
(newDate) => {
|
||||
if (newDate) selectedDate.value = formatDay(newDate)
|
||||
}
|
||||
)
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -118,4 +109,3 @@
|
|||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -1,39 +1,87 @@
|
|||
<template>
|
||||
<div class="container">
|
||||
<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">
|
||||
Cooked by <person-list :people="meal.chefs" @remove-person="(p) => removePerson('chefs', p)" @add-person="(p) => addPerson('chefs', p)" />
|
||||
for <person-list :people="meal.consumers" @remove-person="(p) => removePerson('consumers', p)" @add-person="(p) => addPerson('consumers', p)" />,
|
||||
with <person-list :people="meal.cleanup" @remove-person="(p) => removePerson('cleanup', p)" @add-person="(p) => addPerson('cleanup', p)" /> on cleanup.
|
||||
Cooked by
|
||||
<person-list
|
||||
:people="meal.chefs"
|
||||
@remove-person="(p) => removePerson('chefs', p)"
|
||||
@add-person="(p) => addPerson('chefs', p)"
|
||||
/>
|
||||
for
|
||||
<person-list
|
||||
:people="meal.consumers"
|
||||
@remove-person="(p) => removePerson('consumers', p)"
|
||||
@add-person="(p) => addPerson('consumers', p)"
|
||||
/>, with
|
||||
<person-list
|
||||
:people="meal.cleanup"
|
||||
@remove-person="(p) => removePerson('cleanup', p)"
|
||||
@add-person="(p) => addPerson('cleanup', p)"
|
||||
/>
|
||||
on cleanup.
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipes">
|
||||
<h2>Recipes</h2>
|
||||
<ul v-if="meal.recipes && meal.recipes.length">
|
||||
<li v-for="mealRecipe in meal.recipes" :key="mealRecipe.recipe.id">
|
||||
<div class="saved-recipe">
|
||||
<ul v-if="meal.recipes.length">
|
||||
<li
|
||||
v-for="mealRecipe in meal.recipes"
|
||||
:key="mealRecipe.recipe?.id ?? mealRecipe.recipeId"
|
||||
>
|
||||
<div
|
||||
v-if="mealRecipe.recipe"
|
||||
class="saved-recipe"
|
||||
>
|
||||
<p class="recipe-card">
|
||||
<recipe-card :recipe="mealRecipe.recipe" />
|
||||
</p>
|
||||
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<input type="checkbox" class="show-ingredient-checkbox" :checked="showIngredient(mealRecipe)" />
|
||||
<label for="show-ingredients" @click="showIngredient(mealRecipe, !showIngredient(mealRecipe))">
|
||||
<img class="icon" :src="require('@/assets/show-ingredients.svg')" />
|
||||
<input
|
||||
type="checkbox"
|
||||
class="show-ingredient-checkbox"
|
||||
:checked="showIngredient(mealRecipe)"
|
||||
>
|
||||
<label
|
||||
for="show-ingredients"
|
||||
@click="showIngredient(mealRecipe, !showIngredient(mealRecipe))"
|
||||
>
|
||||
<img
|
||||
class="icon"
|
||||
:src="showIngredientsIcon"
|
||||
>
|
||||
</label>
|
||||
<button class="icon-button" @click="removeRecipe(mealRecipe)">
|
||||
<img class="icon" :src="require('@/assets/trash.svg')" />
|
||||
<button
|
||||
class="icon-button"
|
||||
@click="removeRecipe(mealRecipe)"
|
||||
>
|
||||
<img
|
||||
class="icon"
|
||||
:src="trash"
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showIngredient(mealRecipe)">
|
||||
<ul>
|
||||
<li v-for="ingredient in scaleIngredients(mealRecipe)" :key="ingredient.id" class="saved-ingredient">
|
||||
<li
|
||||
v-for="ingredient in scaleIngredients(mealRecipe)"
|
||||
:key="ingredient.id"
|
||||
class="saved-ingredient"
|
||||
>
|
||||
<CompactParsedIngredient :ingredient="ingredient" />
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -49,143 +97,183 @@
|
|||
</div>
|
||||
<div class="ingredients">
|
||||
<h2>Sides & Additional Ingredients</h2>
|
||||
<editable-ingredients-panel :ingredients="meal.extra_ingredients" @on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient" @on-editing="onEditAdditionalIngredients" />
|
||||
<editable-ingredients-panel
|
||||
:ingredients="meal.extraIngredients"
|
||||
@on-add="addIngredient"
|
||||
@on-delete="deleteIngredient"
|
||||
@on-update-ingredient="updateIngredient"
|
||||
@on-editing="onEditAdditionalIngredients"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button @click="saveMeal">Save</button>
|
||||
<p v-if="meal.purchase_date"><em>Purchased {{ ago(meal.purchase_date) }}</em></p>
|
||||
<button @click="onSaveMeal">
|
||||
Save
|
||||
</button>
|
||||
<p v-if="meal.purchaseDate">
|
||||
<em>Purchased {{ ago(meal.purchaseDate) }}</em>
|
||||
</p>
|
||||
</div>
|
||||
</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 alert from '@/alert.js';
|
||||
import { ago } from '@/dateformats'
|
||||
|
||||
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';
|
||||
import RecipeCard from '@/components/recipes/RecipeCard.vue';
|
||||
import DatePicker from './DatePicker.vue';
|
||||
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue';
|
||||
import PersonList from './PersonList.vue';
|
||||
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue';
|
||||
|
||||
function addPersonIfNotExists(list, person) {
|
||||
if (!list.find(p => p.id === person.id)) {
|
||||
list.push(person);
|
||||
function addPersonIfNotExists(list: Person[], person: Person | null | undefined) {
|
||||
if (!person) return
|
||||
if (!list.find((p) => p.id === person.id)) {
|
||||
list.push(person)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
props: ['id'],
|
||||
components: { RecipeSearchBox, DatePicker, RecipeCard, EditableIngredientsPanel, PersonList, CompactParsedIngredient },
|
||||
data() {
|
||||
return {
|
||||
showIngredients: {},
|
||||
meal: {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { show: showAlert } = useAlert()
|
||||
|
||||
type PeopleKey = 'chefs' | 'consumers' | 'cleanup'
|
||||
|
||||
const meal = reactive<Meal>({
|
||||
id: -1,
|
||||
suggested_date: new Date(),
|
||||
suggestedDate: new Date(),
|
||||
consumedDate: null,
|
||||
purchaseDate: null,
|
||||
recipes: [],
|
||||
extra_ingredients: [],
|
||||
extraIngredients: [],
|
||||
chefs: [],
|
||||
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: {
|
||||
ago,
|
||||
async selectRecipe(recipe) {
|
||||
|
||||
function removeRecipe(mealRecipe: MealRecipe) {
|
||||
if (
|
||||
confirm(
|
||||
`Are you sure you want to remove ${mealRecipe.servings} servings of ${mealRecipe.recipe?.name ?? 'this recipe'} from this meal?`
|
||||
)
|
||||
) {
|
||||
meal.recipes = meal.recipes.filter((r) => r !== mealRecipe)
|
||||
}
|
||||
}
|
||||
|
||||
function addIngredient() {
|
||||
meal.extraIngredients = [{ id: -1, name: '', line: '', unit: 'Items', quantity: 0, preparation: '', productId: null, recipeId: null, mealId: null, product: null }, ...meal.extraIngredients]
|
||||
}
|
||||
|
||||
function deleteIngredient(ingredient: Ingredient) {
|
||||
meal.extraIngredients = meal.extraIngredients.filter((i) => i !== ingredient)
|
||||
}
|
||||
|
||||
function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) {
|
||||
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? newIngredient : i))
|
||||
}
|
||||
|
||||
function removePerson(list: PeopleKey, person: Person) {
|
||||
meal[list] = meal[list].filter((p) => p.id !== person.id)
|
||||
}
|
||||
|
||||
function addPerson(list: PeopleKey, person: Person) {
|
||||
addPersonIfNotExists(meal[list], person)
|
||||
}
|
||||
|
||||
async function selectRecipe(recipe: { id: number | string }) {
|
||||
// Refetch to get additional details
|
||||
recipe = await data.getRecipe(recipe.id);
|
||||
const r = await getRecipe(recipe.id)
|
||||
|
||||
if (recipe.created_by) {
|
||||
addPersonIfNotExists(this.meal.chefs, recipe.created_by);
|
||||
addPersonIfNotExists(this.meal.consumers, recipe.created_by);
|
||||
if (r.createdBy) {
|
||||
addPersonIfNotExists(meal.chefs, r.createdBy)
|
||||
addPersonIfNotExists(meal.consumers, r.createdBy)
|
||||
|
||||
if (this.meal.cleanup.length === 0) {
|
||||
addPersonIfNotExists(this.meal.cleanup, recipe.created_by);
|
||||
if (meal.cleanup.length === 0) {
|
||||
addPersonIfNotExists(meal.cleanup, r.createdBy)
|
||||
}
|
||||
}
|
||||
|
||||
this.meal.recipes.push({ recipe, recipe_id: recipe.id, meal_id: this.meal.id, servings: recipe.serves });
|
||||
},
|
||||
selectDate(date) {
|
||||
this.meal.suggested_date = date;
|
||||
},
|
||||
removeRecipe(mealRecipe) {
|
||||
if (confirm(`Are you sure you want to remove ${mealRecipe.servings} servings of ${mealRecipe.recipe.name} from this meal?`)) {
|
||||
this.meal.recipes = this.meal.recipes.filter(r => r != mealRecipe);
|
||||
}
|
||||
},
|
||||
addIngredient() {
|
||||
this.meal.extra_ingredients = [{ line: '', product: null }, ...this.meal.extra_ingredients];
|
||||
},
|
||||
deleteIngredient(ingredient) {
|
||||
this.meal.extra_ingredients = this.meal.extra_ingredients.filter(i => i != ingredient);
|
||||
},
|
||||
updateIngredient(ingredient, newIngredient) {
|
||||
this.meal.extra_ingredients = this.meal.extra_ingredients.map(i => i == ingredient ? newIngredient : i);
|
||||
},
|
||||
removePerson(list, person) {
|
||||
this.meal[list] = this.meal[list].filter(p => p.id !== person.id);
|
||||
},
|
||||
addPerson(list, person) {
|
||||
addPersonIfNotExists(this.meal[list], person);
|
||||
},
|
||||
async saveMeal() {
|
||||
const meal = await data.saveMeal(this.meal);
|
||||
if (meal?.id >= 0) {
|
||||
this.meal = meal;
|
||||
|
||||
this.$router.push(`/meals/${meal.id}`);
|
||||
alert.show({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' });
|
||||
return;
|
||||
meal.recipes.push({ recipe: r, recipeId: r.id, mealId: meal.id, servings: r.serves })
|
||||
}
|
||||
|
||||
alert.show({ heading: 'Error saving meal', message: 'An error occurred while saving the meal', type: 'error' });
|
||||
},
|
||||
onEditAdditionalIngredients(editing) {
|
||||
if (editing && this.meal.extra_ingredients.length === 0) {
|
||||
this.addIngredient();
|
||||
async function onEditAdditionalIngredients(editing: boolean) {
|
||||
if (editing && meal.extraIngredients.length === 0) {
|
||||
addIngredient()
|
||||
} else {
|
||||
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 index = this.meal.recipes.indexOf(mealRecipe);
|
||||
const key = `${mealRecipe.recipe.id}-${index}`;
|
||||
|
||||
const showMap = reactive<Record<string, boolean>>({})
|
||||
function showIngredient(mealRecipe: MealRecipe, value?: boolean): boolean {
|
||||
const index = meal.recipes.indexOf(mealRecipe)
|
||||
const key = `${mealRecipe.recipe?.id ?? 'unknown'}-${index}`
|
||||
if (value === undefined) {
|
||||
return this.showIngredients[key];
|
||||
return !!showMap[key]
|
||||
}
|
||||
showMap[key] = value
|
||||
return value
|
||||
}
|
||||
|
||||
return this.showIngredients[key] = value;
|
||||
},
|
||||
scaleIngredients(mealRecipe) {
|
||||
return mealRecipe.recipe.ingredients.map(i => {
|
||||
function scaleIngredients(mealRecipe: MealRecipe) {
|
||||
const ing = mealRecipe.recipe?.ingredients ?? []
|
||||
const serves = mealRecipe.recipe?.serves ?? 1
|
||||
return ing.map((i) => {
|
||||
return {
|
||||
...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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
img.icon {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
|
|
@ -238,7 +326,6 @@ li {
|
|||
margin-right: 1em;
|
||||
}
|
||||
|
||||
|
||||
.saved-recipe button:hover {
|
||||
background: #eee;
|
||||
}
|
||||
|
|
@ -296,5 +383,4 @@ li {
|
|||
.show-ingredient-checkbox:checked+label {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -1,85 +1,90 @@
|
|||
<template>
|
||||
<div class="meal-card">
|
||||
<h3>{{ mealTitle }}</h3>
|
||||
<h4>{{ dayOfWeek }} <small>{{ date }}</small></h4>
|
||||
<h4>
|
||||
{{ dayOfWeek }} <small>{{ date }}</small>
|
||||
</h4>
|
||||
|
||||
<p>
|
||||
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) }}
|
||||
</span>
|
||||
<span v-if="!meal.chefs.length">somebody?</span>
|
||||
</p>
|
||||
<p>
|
||||
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) }}
|
||||
</span>
|
||||
<span v-if="!meal.consumers.length">somebody?</span>
|
||||
</p>
|
||||
<p v-if="meal.purchase_date">
|
||||
Purchased {{ ago(meal.purchase_date) }}
|
||||
<p v-if="meal.purchaseDate">
|
||||
Purchased {{ ago(meal.purchaseDate) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
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, list) {
|
||||
function englishSeperator(index: number, list: Array<unknown>) {
|
||||
switch (index) {
|
||||
case list.length - 1:
|
||||
return '';
|
||||
return ''
|
||||
case list.length - 2:
|
||||
return ' and ';
|
||||
return ' and '
|
||||
default:
|
||||
return ', ';
|
||||
return ', '
|
||||
}
|
||||
}
|
||||
|
||||
function englishList(list) {
|
||||
function englishList(list: string[]) {
|
||||
switch (list.length) {
|
||||
case 0:
|
||||
return '';
|
||||
return ''
|
||||
case 1:
|
||||
return list[0];
|
||||
return list[0]
|
||||
case 2:
|
||||
return `${list[0]} and ${list[1]}`;
|
||||
return `${list[0]} and ${list[1]}`
|
||||
default:
|
||||
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`;
|
||||
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'MealCard',
|
||||
props: ['meal'],
|
||||
computed: {
|
||||
date() {
|
||||
return this.meal.suggested_date.toLocaleDateString('en-au', { month: 'numeric', day: 'numeric' })
|
||||
},
|
||||
dayOfWeek() {
|
||||
return this.meal.suggested_date.toLocaleDateString('en-au', { weekday: 'long' })
|
||||
},
|
||||
mealTitle() {
|
||||
const recipesText = englishList(this.meal.recipes.map(mealRecipe => mealRecipe.recipe.name));
|
||||
const ingredientsText = englishList(this.meal.extra_ingredients.map(ingredient => ingredient.name));
|
||||
const date = computed(() =>
|
||||
props.meal.suggestedDate
|
||||
? props.meal.suggestedDate.toLocaleDateString('en-au', { month: 'numeric', day: 'numeric' })
|
||||
: ''
|
||||
)
|
||||
|
||||
if (recipesText && ingredientsText) {
|
||||
return `${recipesText} with ${ingredientsText}`;
|
||||
} else if (recipesText || ingredientsText) {
|
||||
return recipesText || ingredientsText;
|
||||
} else {
|
||||
return 'Nothing planned';
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
englishSeperator,
|
||||
ago
|
||||
}
|
||||
}
|
||||
const dayOfWeek = computed(() =>
|
||||
props.meal.suggestedDate ? props.meal.suggestedDate.toLocaleDateString('en-au', { weekday: 'long' }) : ''
|
||||
)
|
||||
|
||||
const mealTitle = computed(() => {
|
||||
const recipes = props.meal.recipes ?? []
|
||||
const extras = props.meal.extraIngredients ?? []
|
||||
const recipeNames = recipes
|
||||
.map((mr) => mr.recipe?.name)
|
||||
.filter((n): n is string => typeof n === 'string' && n.length > 0)
|
||||
const recipesText = englishList(recipeNames)
|
||||
const ingredientsText = englishList(extras.map((i) => i.name))
|
||||
|
||||
if (recipesText && ingredientsText) return `${recipesText} with ${ingredientsText}`
|
||||
if (recipesText || ingredientsText) return recipesText || ingredientsText
|
||||
return 'Nothing planned'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,91 @@
|
|||
<template>
|
||||
<div>
|
||||
<ul class="meals-list" v-if="meals.length">
|
||||
<li v-for="meal in meals" :key="meal.id">
|
||||
<ul
|
||||
v-if="meals.length"
|
||||
class="meals-list"
|
||||
>
|
||||
<li
|
||||
v-for="meal in meals"
|
||||
:key="meal.id"
|
||||
>
|
||||
<meal-card :meal="meal" />
|
||||
<button class="toggle-actions" @click="selectedMeal = ((meal == selectedMeal) ? null : meal)">
|
||||
<img :src="meal == selectedMeal ? require('@/assets/chevron-down.svg') : require('@/assets/chevron-up.svg')" />
|
||||
<button
|
||||
class="toggle-actions"
|
||||
@click="selectedMeal = meal == selectedMeal ? null : meal"
|
||||
>
|
||||
<img :src="meal == selectedMeal ? chevronDown : chevronUp">
|
||||
</button>
|
||||
|
||||
<ul class="actions" v-if="selectedMeal == meal">
|
||||
<li><router-link class="nav-link" :to="`/meals/${selectedMeal.id}`" active-class="active">Edit Meal</router-link></li>
|
||||
<ul
|
||||
v-if="selectedMeal == meal"
|
||||
class="actions"
|
||||
>
|
||||
<li>
|
||||
<router-link
|
||||
class="nav-link"
|
||||
:to="`/meals/${selectedMeal.id}`"
|
||||
active-class="active"
|
||||
>
|
||||
Edit Meal
|
||||
</router-link>
|
||||
</li>
|
||||
<li><a @click="markConsumed">Mark Consumed</a></li>
|
||||
<li><a @click="deleteSelectedMeal" class="button">Remove</a></li>
|
||||
<li>
|
||||
<a
|
||||
class="button"
|
||||
@click="deleteSelectedMeal"
|
||||
>Remove</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="!meals.length">
|
||||
<em>No meals planned</em>
|
||||
</div>
|
||||
<action-item title="Plan Meal" :image="require('@/assets/plan-meal.svg')" @click="() => this.$router.push('/meals/add')" />
|
||||
<action-item
|
||||
title="Plan Meal"
|
||||
:image="planMeal"
|
||||
@click="() => $router.push('/meals/add')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onBeforeMount } from 'vue'
|
||||
import ActionItem from '@/components/ActionItem.vue'
|
||||
import MealCard from '@/components/meals/MealCard.vue'
|
||||
import { getUpcomingMeals, markMealConsumed, deleteMeal } from '@/api/sdk'
|
||||
const chevronDown = new URL('@/assets/chevron-down.svg', import.meta.url).toString()
|
||||
const chevronUp = new URL('@/assets/chevron-up.svg', import.meta.url).toString()
|
||||
const planMeal = new URL('@/assets/plan-meal.svg', import.meta.url).toString()
|
||||
|
||||
const from = new Date()
|
||||
from.setTime(0)
|
||||
|
||||
const to = new Date()
|
||||
to.setDate(to.getDate() + 7)
|
||||
|
||||
const meals = ref([])
|
||||
const selectedMeal = ref(null)
|
||||
|
||||
onBeforeMount(async () => {
|
||||
meals.value = await getUpcomingMeals(from, to)
|
||||
})
|
||||
|
||||
async function deleteSelectedMeal() {
|
||||
if (!selectedMeal.value) return
|
||||
await deleteMeal(selectedMeal.value.id)
|
||||
meals.value = meals.value.filter((m) => m.id !== selectedMeal.value.id)
|
||||
selectedMeal.value = null
|
||||
}
|
||||
|
||||
async function markConsumed() {
|
||||
if (!selectedMeal.value) return
|
||||
await markMealConsumed(selectedMeal.value.id)
|
||||
meals.value = meals.value.filter((m) => m.id !== selectedMeal.value.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
li {
|
||||
list-style-type: none;
|
||||
|
|
@ -75,44 +140,4 @@ ul.actions {
|
|||
padding-top: 2ex;
|
||||
padding-bottom: 2ex;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import ActionItem from '@/components/ActionItem.vue'
|
||||
import MealCard from '@/components/meals/MealCard.vue'
|
||||
import data from '@/data.js'
|
||||
|
||||
export default {
|
||||
name: 'MealPlanPage',
|
||||
components: { MealCard, ActionItem },
|
||||
data() {
|
||||
const from = new Date();
|
||||
from.setTime(0);
|
||||
|
||||
const to = new Date();
|
||||
to.setDate(to.getDate() + 7);
|
||||
|
||||
return {
|
||||
from, to,
|
||||
meals: [],
|
||||
selectedMeal: null
|
||||
}
|
||||
},
|
||||
async beforeMount() {
|
||||
const meals = await data.getUpcomingMeals(this.from, this.to)
|
||||
this.meals = meals;
|
||||
},
|
||||
methods: {
|
||||
async deleteSelectedMeal() {
|
||||
await data.deleteMeal(this.selectedMeal.id);
|
||||
this.meals = this.meals.filter(m => m.id !== this.selectedMeal.id);
|
||||
this.selectedMeal = null;
|
||||
},
|
||||
async markConsumed() {
|
||||
await data.markMealConsumed(this.selectedMeal.id);
|
||||
this.meals = this.meals.filter(m => m.id !== this.selectedMeal.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,22 +1,128 @@
|
|||
<template>
|
||||
<span class="person-list">
|
||||
<span v-for="person in people" :key="person.id">
|
||||
<button class="person-circle remove-person" @click="$emit('remove-person', person)" >{{ person.name }}</button>
|
||||
<span
|
||||
v-for="person in people"
|
||||
:key="person.id"
|
||||
>
|
||||
<button
|
||||
class="person-circle remove-person"
|
||||
@click="removePerson(person)"
|
||||
>
|
||||
{{ person.name }}
|
||||
</button>
|
||||
</span>
|
||||
<span>
|
||||
<button v-if="!isAddingPerson" class="person-circle add-person" @click="isAddingPerson = true">+</button>
|
||||
<input v-else v-model="searchName" ref="searchNameInput" @keyup.enter="addPerson" @keyup.esc="isAddingPerson = false" @blur="isAddingPerson = false" />
|
||||
<ul class="person-droplist" ref="persondroplist" v-if="isAddingPerson && searchResults.length">
|
||||
<li v-for="person in searchResults" :key="person.id">
|
||||
<button class="person-circle add-person" @mousedown="addPerson(person)">{{ person.name }}</button>
|
||||
<button
|
||||
v-if="!isAddingPerson"
|
||||
class="person-circle add-person"
|
||||
@click="isAddingPerson = true"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<input
|
||||
v-else
|
||||
ref="searchNameInput"
|
||||
v-model="searchName"
|
||||
@keyup.enter="addPerson"
|
||||
@keyup.esc="isAddingPerson = false"
|
||||
@blur="isAddingPerson = false"
|
||||
>
|
||||
<ul
|
||||
v-if="isAddingPerson && searchResults.length"
|
||||
ref="persondroplist"
|
||||
class="person-droplist"
|
||||
>
|
||||
<li
|
||||
v-for="person in searchResults"
|
||||
:key="person.id"
|
||||
>
|
||||
<button
|
||||
class="person-circle add-person"
|
||||
@mousedown="addPerson(person)"
|
||||
>
|
||||
{{ person.name }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
</span>
|
||||
</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 {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
|
|
@ -53,9 +159,10 @@
|
|||
}
|
||||
|
||||
/* 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;
|
||||
content: "X";
|
||||
content: 'X';
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
@ -94,78 +201,4 @@
|
|||
display: inline;
|
||||
padding: 1ex 1em;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import data from '@/data.js'
|
||||
|
||||
export default {
|
||||
name: 'PersonList',
|
||||
props: {
|
||||
people: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isAddingPerson: false,
|
||||
searchName: '',
|
||||
searchResults: [],
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const searchNameInput = ref(null);
|
||||
const persondroplist = ref(null);
|
||||
return { searchNameInput, persondroplist };
|
||||
},
|
||||
watch: {
|
||||
searchName: async function() {
|
||||
await this.updateSearchResults()
|
||||
},
|
||||
searchNameInput: async function() {
|
||||
this.searchNameInput?.focus();
|
||||
await this.updateSearchResults()
|
||||
},
|
||||
persondroplist: function() {
|
||||
if (this.persondroplist && this.searchNameInput)
|
||||
{
|
||||
// Align the droplist to the input field & its size
|
||||
const inputRect = this.searchNameInput.getBoundingClientRect();
|
||||
this.persondroplist.style.left = `${inputRect.left}px`;
|
||||
this.persondroplist.style.top = `${inputRect.bottom}px`;
|
||||
this.persondroplist.style.width = `${inputRect.width}px`;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async updateSearchResults() {
|
||||
const results = await data.searchPerson(this.searchName);
|
||||
// Exclude people already in the list
|
||||
const idSet = new Set(this.people.map(p => p.id));
|
||||
this.searchResults = results.filter(p => !idSet.has(p.id));
|
||||
},
|
||||
addPerson(person) {
|
||||
if (!person && this.searchResults.length > 0)
|
||||
{
|
||||
person = this.searchResults[0];
|
||||
}
|
||||
|
||||
if (person?.id >= 0 && !this.people.find(p => p.id === person.id))
|
||||
{
|
||||
this.$emit('add-person', person);
|
||||
}
|
||||
|
||||
this.searchName = '';
|
||||
this.searchResults = [];
|
||||
this.isAddingPerson = false;
|
||||
},
|
||||
removePerson(person) {
|
||||
this.$emit('remove-person', person);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,9 +1,18 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="!id && !recipe">
|
||||
<input class="recipe-link" type="text" v-model="link" placeholder="Link to Recipe" /> <br />
|
||||
<button @click="parseLink">Parse</button>
|
||||
<button @click="createFromScratch">Create from Scratch</button>
|
||||
<input
|
||||
v-model="link"
|
||||
class="recipe-link"
|
||||
type="text"
|
||||
placeholder="Link to Recipe"
|
||||
> <br>
|
||||
<button @click="parseLink">
|
||||
Parse
|
||||
</button>
|
||||
<button @click="createFromScratch">
|
||||
Create from Scratch
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="parse_failed">
|
||||
|
|
@ -11,26 +20,194 @@
|
|||
</div>
|
||||
|
||||
<div v-if="!parse_failed && recipe">
|
||||
<div class="image-container" v-if="image_styling" :style="image_styling" ></div>
|
||||
<h1><input class="recipe-name" type="text" v-model="recipe.name" /></h1>
|
||||
<div
|
||||
v-if="image_styling"
|
||||
class="image-container"
|
||||
:style="image_styling"
|
||||
/>
|
||||
<h1>
|
||||
<input
|
||||
v-model="recipe.name"
|
||||
class="recipe-name"
|
||||
type="text"
|
||||
>
|
||||
</h1>
|
||||
<label for="recipe-serves">Number of serves: </label>
|
||||
<input type="number" v-model="recipe.serves" />
|
||||
<h3 class="recipe-link"><a :href="recipe.link">View Recipe</a></h3>
|
||||
<input
|
||||
v-model="recipe.serves"
|
||||
type="number"
|
||||
>
|
||||
<h3 class="recipe-link">
|
||||
<a :href="recipe.link">View Recipe</a>
|
||||
</h3>
|
||||
<h2>Ingredients</h2>
|
||||
<editable-ingredients-panel
|
||||
:ingredients="recipe.ingredients"
|
||||
:edit-only="true"
|
||||
@on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient"
|
||||
@on-add="addIngredient"
|
||||
@on-delete="deleteIngredient"
|
||||
@on-update-ingredient="updateIngredient"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<button v-if="recipe.id" class="delete-btn" @click="deleteRecipe">Delete</button>
|
||||
<button class="submit-btn" @click="saveRecipe">{{ recipe.id ? "Save" : "Create" }}</button>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
import { parseQueryString } from '@/router/helpers'
|
||||
import { getRecipe, parseRecipe, saveRecipe as saveRecipeApi, deleteRecipe as deleteRecipeApi } from '@/api/sdk'
|
||||
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
||||
import type { Recipe as DomainRecipe, Ingredient, RecipeInput } from '@/domain/types'
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, required: false, default: undefined },
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { show: showAlert } = useAlert()
|
||||
|
||||
const link = ref<string>(parseQueryString(route.query.url))
|
||||
const parse_failed = ref(false)
|
||||
const recipe = ref<DomainRecipe | null>(null)
|
||||
|
||||
const image_styling = computed(() => {
|
||||
const urls = recipe.value?.imageUrls ?? []
|
||||
if (urls.length && urls[0]) {
|
||||
const image = urls[0]!
|
||||
return {
|
||||
background: `linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 100%), url('${image}') center/cover no-repeat`,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function parseLink() {
|
||||
router.push({ path: '/recipes/add', query: { url: link.value } })
|
||||
refreshRecipe()
|
||||
}
|
||||
|
||||
async function refreshRecipe() {
|
||||
const id = props.id ? parseInt(props.id) : null
|
||||
if (id !== null && id >= 0) {
|
||||
const r = await getRecipe(id)
|
||||
recipe.value = r
|
||||
link.value = r.link ?? ''
|
||||
return
|
||||
} else if (link.value) {
|
||||
const r = await parseRecipe(link.value)
|
||||
recipe.value = r
|
||||
parse_failed.value = !r
|
||||
} else {
|
||||
recipe.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) {
|
||||
if (!recipe.value) return
|
||||
const list = recipe.value.ingredients ?? []
|
||||
recipe.value = { ...recipe.value, ingredients: list.map((i) => (i === ingredient ? newIngredient : i)) }
|
||||
}
|
||||
|
||||
function deleteIngredient(ingredient: Ingredient) {
|
||||
if (!recipe.value) return
|
||||
const list = recipe.value.ingredients ?? []
|
||||
recipe.value = { ...recipe.value, ingredients: list.filter((i) => i !== ingredient) }
|
||||
}
|
||||
|
||||
async function saveRecipe() {
|
||||
const saved = recipe.value ? await saveRecipeApi(toRecipeInput(recipe.value)) : null
|
||||
if (saved && saved.id >= 0) {
|
||||
showAlert({ heading: 'Recipe saved', message: 'Your recipe has been saved', type: 'success' })
|
||||
router.push(`/recipes/${saved.id}`)
|
||||
return
|
||||
}
|
||||
showAlert({ heading: 'Error saving recipe', message: 'There was an error saving your recipe', type: 'error' })
|
||||
}
|
||||
|
||||
function createFromScratch() {
|
||||
recipe.value = {
|
||||
id: -1,
|
||||
name: 'My new recipe',
|
||||
createdById: -1,
|
||||
link: '',
|
||||
ingredients: [],
|
||||
imageUrls: [],
|
||||
serves: 1,
|
||||
dateCreated: new Date(),
|
||||
dateHidden: null,
|
||||
}
|
||||
}
|
||||
|
||||
function addIngredient() {
|
||||
if (!recipe.value) return
|
||||
const list = recipe.value.ingredients ?? []
|
||||
const draft: Ingredient = { id: -1, name: '', line: '', unit: 'Items', quantity: 0, preparation: '', productId: null, recipeId: null, mealId: null, product: null }
|
||||
recipe.value = { ...recipe.value, ingredients: [draft, ...list] }
|
||||
}
|
||||
|
||||
async function deleteRecipe() {
|
||||
if (confirm('Are you sure you want to delete this recipe?')) {
|
||||
if (!recipe.value) return
|
||||
await deleteRecipeApi(recipe.value.id)
|
||||
router.push('/recipes')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshRecipe()
|
||||
})
|
||||
|
||||
// Keep recipe in sync if link query changes while on page
|
||||
watch(
|
||||
() => route.query.url,
|
||||
(newUrl) => {
|
||||
const parsed = parseQueryString(newUrl)
|
||||
if (parsed) {
|
||||
link.value = parsed
|
||||
refreshRecipe()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// expose functions for template binding names (automatic in <script setup>)
|
||||
function toRecipeInput(r: DomainRecipe): RecipeInput {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
link: r.link,
|
||||
serves: r.serves,
|
||||
imageUrls: r.imageUrls ?? [],
|
||||
ingredients: r.ingredients ?? [],
|
||||
basedOnRecipe: r.basedOnRecipe ?? null,
|
||||
// let backend set created/hidden dates
|
||||
createdById: r.createdById,
|
||||
createdBy: r.createdBy ?? null,
|
||||
// dateHidden omitted
|
||||
hiddenById: r.hiddenById ?? null,
|
||||
hiddenBy: r.hiddenBy ?? null,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input {
|
||||
border: 0;
|
||||
|
|
@ -56,98 +233,7 @@ input.recipe-name {
|
|||
}
|
||||
|
||||
.recipe-link {
|
||||
color: #0000EE;
|
||||
color: #0000ee;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import alert from '@/alert.js'
|
||||
import data from '@/data.js'
|
||||
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
id: { type: Number, optional: true }
|
||||
},
|
||||
components: { EditableIngredientsPanel },
|
||||
data() {
|
||||
return {
|
||||
link: this.$route.query.url ?? "",
|
||||
parse_failed: false,
|
||||
recipe: null,
|
||||
chefs: [],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.refreshRecipe();
|
||||
},
|
||||
computed: {
|
||||
image_styling() {
|
||||
if (this.recipe?.image_urls && this.recipe.image_urls[0]) {
|
||||
const image = this.recipe.image_urls[0];
|
||||
// linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 100%), url('https://i2.wp.com/www.downshiftology.com/wp-content/uploads/2019/04/steamed-broccoli-4.jpg') center/cover no-repeat;
|
||||
return {background: `linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 100%), url('${image}') center/cover no-repeat` }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
parseLink() {
|
||||
this.$router.push({ path: '/recipes/add', query: { url: this.link }})
|
||||
this.refreshRecipe();
|
||||
},
|
||||
async refreshRecipe() {
|
||||
if (this.id >= 0) {
|
||||
this.recipe = await data.getRecipe(this.id);
|
||||
this.link = this.recipe.link;
|
||||
return;
|
||||
}
|
||||
else if (this.link) {
|
||||
this.recipe = await data.parseRecipe(this.link);
|
||||
this.parse_failed = !this.recipe;
|
||||
}
|
||||
else {
|
||||
this.recipe = null;
|
||||
}
|
||||
},
|
||||
async updateIngredient(ingredient, newIngredient) {
|
||||
this.recipe.ingredients = this.recipe.ingredients.map(i => i == ingredient ? newIngredient : i);
|
||||
},
|
||||
deleteIngredient(ingredient) {
|
||||
this.recipe.ingredients = this.recipe.ingredients.filter(i => i != ingredient);
|
||||
},
|
||||
async saveRecipe() {
|
||||
const recipe = await data.saveRecipe(this.recipe);
|
||||
if (recipe?.id >= 0) {
|
||||
alert.show({ heading: 'Recipe saved', message: 'Your recipe has been saved', type: 'success' });
|
||||
this.$router.push(`/recipes/${recipe.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
alert.show({ heading: 'Error saving recipe', message: 'There was an error saving your recipe', type: 'error' });
|
||||
},
|
||||
async createFromScratch() {
|
||||
this.recipe = {
|
||||
id: -1,
|
||||
name: 'My new recipe',
|
||||
created_by_id: -1,
|
||||
link: '',
|
||||
ingredients: [],
|
||||
image_urls: []
|
||||
}
|
||||
},
|
||||
addIngredient() {
|
||||
this.recipe.ingredients = [{ line: '', product: null }, ...this.recipe.ingredients];
|
||||
},
|
||||
async deleteRecipe() {
|
||||
if (confirm('Are you sure you want to delete this recipe?')) {
|
||||
await data.deleteRecipe(this.recipe.id);
|
||||
this.$router.push('/recipes');
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,22 +1,30 @@
|
|||
<template>
|
||||
<div class="recipe-card">
|
||||
<p>
|
||||
<img v-if="recipe.image_urls" :src="recipe.image_urls[0]" />
|
||||
<img v-else src="@/assets/egg.svg" />
|
||||
<img
|
||||
v-if="recipe.imageUrls && recipe.imageUrls.length"
|
||||
:src="recipe.imageUrls[0] || fallbackEgg"
|
||||
>
|
||||
<img
|
||||
v-else
|
||||
:src="fallbackEgg"
|
||||
>
|
||||
</p>
|
||||
<p class="recipe-name">
|
||||
{{ recipe.name }}
|
||||
</p>
|
||||
<p class="recipe-name">{{ recipe.name }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'RecipeCard',
|
||||
props: ['recipe']
|
||||
}
|
||||
<script setup lang="ts">
|
||||
import type { RecipeOut } from '@/domain/types'
|
||||
type RecipeCardItem = Pick<RecipeOut, 'id' | 'name' | 'imageUrls'>
|
||||
|
||||
defineProps<{ recipe: RecipeCardItem }>()
|
||||
const fallbackEgg = new URL('@/assets/egg.svg', import.meta.url).toString()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.recipe-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
@ -42,5 +50,4 @@ li img {
|
|||
flex: 1;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -1,55 +1,168 @@
|
|||
<template>
|
||||
<div class="recipe-search-box" @focusout="recipes = []">
|
||||
<input type="text" v-model="searchTerm" @keyup.enter="search" @keyup.exit="clear" @focusin="search"
|
||||
:placeholder="placeholder" />
|
||||
<ul v-if="recipes?.length" class="dropdown">
|
||||
<li class="recipe" v-for="recipe in recipes" :key="recipe.id" @mousedown="selectRecipe(recipe)">
|
||||
<div
|
||||
class="recipe-search-box"
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
:placeholder="placeholderText"
|
||||
@keyup.enter="search"
|
||||
@keyup.esc="clear"
|
||||
@focusin="search"
|
||||
>
|
||||
<ul
|
||||
v-if="dropdownVisible"
|
||||
class="dropdown"
|
||||
>
|
||||
<template v-if="recipes.length">
|
||||
<li
|
||||
v-for="recipe in recipes"
|
||||
:key="recipe.id"
|
||||
class="recipe"
|
||||
@mousedown="selectRecipe(recipe)"
|
||||
>
|
||||
<recipe-card :recipe="recipe" />
|
||||
</li>
|
||||
<li
|
||||
v-if="prevCursor || nextCursor"
|
||||
class="pager"
|
||||
>
|
||||
<button
|
||||
class="pager-btn"
|
||||
:disabled="!prevCursor"
|
||||
@mousedown.prevent="loadPrev"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
class="pager-btn"
|
||||
:disabled="!nextCursor"
|
||||
@mousedown.prevent="loadNext"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
<li
|
||||
v-else
|
||||
class="empty"
|
||||
>
|
||||
No recipes found
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import data from '@/data.js'
|
||||
import RecipeCard from './RecipeCard.vue';
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onBeforeUnmount, computed } from 'vue'
|
||||
import { listRecipes } from '@/api/sdk'
|
||||
import type { Recipe } from '@/domain/types'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
import { usePagination } from '@/composables/usePagination'
|
||||
import RecipeCard from './RecipeCard.vue'
|
||||
|
||||
export default {
|
||||
name: 'RecipeSearchBox',
|
||||
components: { RecipeCard },
|
||||
props: {
|
||||
placeholder: { type: String, default: 'Add a recipe...' }
|
||||
},
|
||||
data() {
|
||||
const props = withDefaults(defineProps<{ placeholder?: string }>(), {
|
||||
placeholder: 'Add a recipe...',
|
||||
})
|
||||
const placeholderText: string = props.placeholder ?? 'Add a recipe...'
|
||||
|
||||
type RecipeItem = Pick<Recipe, 'id' | 'name' | 'imageUrls'>
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-recipe', recipe: RecipeItem): void
|
||||
}>()
|
||||
|
||||
const searchTerm = ref('')
|
||||
const pageSize = 10
|
||||
const { items: recipes, next: nextCursor, prev: prevCursor, load, loadNext: loadNextPage, loadPrev: loadPrevPage, reset } = usePagination<RecipeItem>(
|
||||
async (params) => {
|
||||
const page = await listRecipes(params)
|
||||
return {
|
||||
searchTerm: '',
|
||||
recipes: [],
|
||||
timeouts: [],
|
||||
items: (page.items ?? []).map(toRecipeItem),
|
||||
next: page.next ?? null,
|
||||
prev: page.prev ?? null,
|
||||
total: page.total ?? null,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
searchTerm() {
|
||||
const searchTerm = this.searchTerm;
|
||||
if (searchTerm) {
|
||||
this.timeouts.push(setTimeout(() => {
|
||||
if (searchTerm === this.searchTerm) {
|
||||
this.search();
|
||||
{ pageSize }
|
||||
)
|
||||
const dropdownVisible = computed(() => recipes.value.length > 0 || (searchTerm.value.length > 0))
|
||||
|
||||
const { show: showAlert, scheduleAutoDismiss } = useAlert()
|
||||
|
||||
let debounceId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(
|
||||
searchTerm,
|
||||
(newVal) => {
|
||||
if (!newVal) {
|
||||
recipes.value = []
|
||||
if (debounceId) clearTimeout(debounceId)
|
||||
return
|
||||
}
|
||||
}, 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 search() {
|
||||
this.recipes = await data.searchRecipes(this.searchTerm) ?? this.recipes;
|
||||
},
|
||||
selectRecipe(recipe) {
|
||||
this.$emit('select-recipe', recipe);
|
||||
this.searchTerm = '';
|
||||
this.recipes = [];
|
||||
|
||||
async function loadNext() {
|
||||
if (!nextCursor.value) return
|
||||
try {
|
||||
await loadNextPage()
|
||||
} catch {
|
||||
showAlert({ type: 'error', heading: 'Unable to load more', message: 'Could not fetch the next page.' })
|
||||
scheduleAutoDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrev() {
|
||||
if (!prevCursor.value) return
|
||||
try {
|
||||
await loadPrevPage()
|
||||
} catch {
|
||||
showAlert({ type: 'error', heading: 'Unable to load more', message: 'Could not fetch the previous page.' })
|
||||
scheduleAutoDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
function toRecipeItem(r: Recipe): RecipeItem {
|
||||
return { id: r.id, name: r.name, imageUrls: r.imageUrls ?? [] }
|
||||
}
|
||||
|
||||
function selectRecipe(recipe: RecipeItem) {
|
||||
emit('select-recipe', recipe)
|
||||
searchTerm.value = ''
|
||||
recipes.value = []
|
||||
}
|
||||
|
||||
function clear() {
|
||||
searchTerm.value = ''
|
||||
reset()
|
||||
}
|
||||
|
||||
function onFocusOut() {
|
||||
// keep results while focused; on blur, clear all
|
||||
reset()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceId) clearTimeout(debounceId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -96,4 +209,19 @@ export default {
|
|||
.recipe-search-box .dropdown li:hover {
|
||||
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>
|
||||
|
|
@ -1,18 +1,31 @@
|
|||
<template>
|
||||
<div>
|
||||
<recipe-search-box placeholder="Search for a recipe..." @select-recipe="(r) => this.$router.push(`/recipes/${r.id}`)"/>
|
||||
<action-item title="Add new Recipe" :image="require('@/assets/add-recipe.svg')" @click="() => this.$router.push('/recipes/add')" />
|
||||
<recipe-search-box
|
||||
placeholder="Search for a recipe..."
|
||||
@select-recipe="onSelectRecipe"
|
||||
/>
|
||||
<action-item
|
||||
title="Add new Recipe"
|
||||
:image="addRecipe"
|
||||
@click="onAddRecipe"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import ActionItem from '@/components/ActionItem.vue'
|
||||
import RecipeSearchBox from './RecipeSearchBox.vue';
|
||||
import RecipeSearchBox from './RecipeSearchBox.vue'
|
||||
import type { Recipe } from '@/domain/types'
|
||||
|
||||
export default {
|
||||
name: 'ActionsPage',
|
||||
components: {
|
||||
ActionItem,
|
||||
RecipeSearchBox
|
||||
const addRecipe = new URL('@/assets/add-recipe.svg', import.meta.url).toString()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function onSelectRecipe(r: Pick<Recipe, 'id'>) {
|
||||
router.push(`/recipes/${r.id}`)
|
||||
}
|
||||
|
||||
function onAddRecipe() {
|
||||
router.push('/recipes/add')
|
||||
}
|
||||
</script>
|
||||
|
|
@ -2,48 +2,79 @@
|
|||
<h3>Full shopping list</h3>
|
||||
|
||||
<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">
|
||||
<li v-for="item in productGroupsToPurchase" :key="item.id" class="selectable" :class="{ 'selected': isSelected(item) }" @click="toggleSelect(item)">
|
||||
<shopping-list-item :productGrouping="item" />
|
||||
<li
|
||||
v-for="group in outstandingItemGroups"
|
||||
:key="groupKey(group)"
|
||||
class="selectable"
|
||||
:class="{ selected: isSelected(group) }"
|
||||
@click="toggleSelect(group)"
|
||||
>
|
||||
<shopping-list-item :shopping-list-item-group="group" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="productGroupsToPurchase.length === 0">
|
||||
<p>
|
||||
No items to purchase
|
||||
</p>
|
||||
<div v-if="outstandingItemGroups.length === 0">
|
||||
<p>No items to purchase</p>
|
||||
</div>
|
||||
|
||||
<div class="purchased-slider">
|
||||
<span v-if="purchasedGroups.length === 0"></span>
|
||||
<button v-else-if="showPurchased" @click="showPurchased=false" >⏶ Hide Purchased ⏶</button>
|
||||
<button v-else @click="showPurchased=true">⏷ Show Purchased ⏷</button>
|
||||
<span v-if="purchasedItemGroups.length === 0" />
|
||||
<button
|
||||
v-else-if="showPurchased"
|
||||
@click="showPurchased = false"
|
||||
>
|
||||
⏶ Hide Purchased ⏶
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="showPurchased = true"
|
||||
>
|
||||
⏷ Show Purchased ⏷
|
||||
</button>
|
||||
|
||||
<div v-if="showPurchased">
|
||||
<div v-if="showPurchased && purchasedItemGroups.length > 0">
|
||||
<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>
|
||||
Purchased Items
|
||||
</h4>
|
||||
<h4>Purchased Items</h4>
|
||||
<ul class="full-shopping-list">
|
||||
<li v-for="item in purchasedGroups" :key="item.id">
|
||||
<shopping-list-item :productGrouping="item" />
|
||||
<li
|
||||
v-for="item in purchasedItemGroups"
|
||||
:key="groupKey(item)"
|
||||
>
|
||||
<shopping-list-item :shopping-list-item-group="item" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spacer" v-if="selected.length">
|
||||
<div
|
||||
v-if="selected.length"
|
||||
class="spacer"
|
||||
>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Display 'Stocked', 'Purchased' and 'Cancel' buttons in a vertical stack fixed to the bottom of the screen when any elements are selected -->
|
||||
<div class="footer-buttons" v-if="selected.length">
|
||||
<div
|
||||
v-if="selected.length"
|
||||
class="footer-buttons"
|
||||
>
|
||||
<p v-if="selected.length === 1">
|
||||
Mark '{{ selected[0].product.name }}' as
|
||||
Mark '{{ selected[0] ? groupLabel(selected[0]) : '' }}' as
|
||||
</p>
|
||||
<p v-else>
|
||||
Mark {{ selected.length }} items as
|
||||
|
|
@ -51,25 +82,136 @@
|
|||
|
||||
<div class="button-group">
|
||||
<button @click="markFound">
|
||||
<img src="@/assets/house-check.svg" /><br />
|
||||
<img :src="houseCheck"><br>
|
||||
Found
|
||||
</button>
|
||||
|
||||
<button @click="markPurchased">
|
||||
<img src="@/assets/shopping-cart.svg" /><br />
|
||||
<img :src="shoppingCart"><br>
|
||||
Purchased
|
||||
</button>
|
||||
|
||||
<button @click="selected = []">
|
||||
<img src="@/assets/close.svg" /><br />
|
||||
<img :src="closeIcon"><br>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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 {
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -130,114 +272,4 @@ button img {
|
|||
.spacer {
|
||||
height: 12em;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import data from '@/data.js'
|
||||
import { toProductsModel, getCompletedRequests, sourcesToRequests } from './shopping.js'
|
||||
|
||||
import MealSelectionList from './MealSelectionList.vue'
|
||||
import ShoppingListItem from './ShoppingListItem.vue'
|
||||
|
||||
async function saveShoppingList(storeName, currentShoppingList, productGroupsPurchased) {
|
||||
const results = productGroupsPurchased.map(item => item.requested.map(r => ({
|
||||
product_id: r.ingredient.product_id,
|
||||
product: r.ingredient.product,
|
||||
quantity: r.ingredient.quantity,
|
||||
unit: r.ingredient.unit,
|
||||
list_id: -1,
|
||||
}))).flat();
|
||||
|
||||
const requestedSources = productGroupsPurchased.map(item => item.requested).flat();
|
||||
const completedRequests = getCompletedRequests(currentShoppingList, results);
|
||||
const servicedRequests = sourcesToRequests(requestedSources);
|
||||
|
||||
return await data.purchaseItems(storeName, servicedRequests, results, completedRequests);
|
||||
}
|
||||
|
||||
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, availableMeals: [], purchasedMeals: [], shoppingList: null, includedMeals: [], productGroupsToPurchase: [], purchasedGroups: [], selected: [], showPurchased: false }
|
||||
},
|
||||
async beforeMount() {
|
||||
this.loadData();
|
||||
},
|
||||
watch: {
|
||||
shoppingList: {
|
||||
handler: 'updateLists',
|
||||
deep: true
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async loadData() {
|
||||
this.shoppingList = await data.getCurrentShoppingList();
|
||||
|
||||
const upcomingMeals = await data.getUpcomingMeals(this.from, this.to);
|
||||
const requestedMeals = this.shoppingList.requests.map(r => r.meal).filter(m => m);
|
||||
|
||||
const meals = {};
|
||||
requestedMeals.forEach(m => meals[m.id] = m);
|
||||
upcomingMeals.forEach(m => meals[m.id] = m);
|
||||
|
||||
this.purchasedMeals = Object.values(meals).filter(m => m.purchase_date);
|
||||
this.availableMeals = Object.values(meals).filter(m => !m.purchase_date);
|
||||
},
|
||||
async updateLists() {
|
||||
if (!this.shoppingList)
|
||||
return;
|
||||
|
||||
this.includedMeals = this.shoppingList.requests.map(r => r.meal).filter(m => m);
|
||||
|
||||
const productGroups = Object.values(toProductsModel(this.shoppingList));
|
||||
this.productGroupsToPurchase = productGroups.filter(m => m.requested.length > 0);
|
||||
this.purchasedGroups = productGroups.filter(m => m.requested.length === 0);
|
||||
},
|
||||
async mealSelected(meal) {
|
||||
const request = await data.requestMeal(meal.id);
|
||||
this.shoppingList.requests.push(request);
|
||||
},
|
||||
async mealUnselected(meal) {
|
||||
await data.unrequestMeal(meal.id);
|
||||
this.shoppingList.requests = this.shoppingList.requests.filter(r => r.meal?.id !== meal.id);
|
||||
},
|
||||
async markFound() {
|
||||
await saveShoppingList('', this.shoppingList, this.selected);
|
||||
|
||||
this.selected = [];
|
||||
this.loadData();
|
||||
},
|
||||
async markPurchased() {
|
||||
const shoppingList = await saveShoppingList('', this.shoppingList, this.selected);
|
||||
this.selected = [];
|
||||
this.$router.push(`/shopping/${shoppingList.id}`);
|
||||
},
|
||||
toggleSelect(item) {
|
||||
const index = this.selected.findIndex(i => i === item);
|
||||
if (index === -1)
|
||||
this.selected.push(item);
|
||||
else
|
||||
this.selected.splice(index, 1);
|
||||
},
|
||||
isSelected(item) {
|
||||
return this.selected.some(i => i === item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,20 +1,108 @@
|
|||
<template>
|
||||
|
||||
<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 -->
|
||||
<!-- Emit meal-selected event on checked and meal-unselected on unchecked -->
|
||||
<input type="checkbox" :id="meal.id" :checked="isChecked(meal)" @change="mealCheckChanged" :disabled="disabled" />
|
||||
<label :for="meal.id" :style="getImageStyling(meal)">
|
||||
{{ formatDate(meal.suggested_date) }}
|
||||
<input
|
||||
:id="String(meal.id)"
|
||||
type="checkbox"
|
||||
:checked="isChecked(meal)"
|
||||
:disabled="!!disabled"
|
||||
@change="mealCheckChanged"
|
||||
>
|
||||
<label
|
||||
:for="String(meal.id)"
|
||||
:style="getImageStyling(meal)"
|
||||
>
|
||||
{{ formatDate(meal.suggestedDate) }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</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 {
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -31,7 +119,7 @@ li {
|
|||
}
|
||||
|
||||
/* Hide the default checkbox formatting, and format the card instead */
|
||||
input[type="checkbox"] {
|
||||
input[type='checkbox'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
@ -48,12 +136,12 @@ label {
|
|||
color: #3d5447;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:checked + label {
|
||||
input[type='checkbox']:checked + label {
|
||||
border: 3px solid #3d5447;
|
||||
text-shadow: #ccc 0 0 0.1em;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:disabled + label {
|
||||
input[type='checkbox']:disabled + label {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
|
@ -72,89 +160,4 @@ label {
|
|||
font-size: larger;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
const nth = (d) => {
|
||||
if (d > 3 && d < 21) return 'th';
|
||||
switch (d % 10) {
|
||||
case 1: return "st";
|
||||
case 2: return "nd";
|
||||
case 3: return "rd";
|
||||
default: return "th";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date) => {
|
||||
return `${date.toLocaleDateString('en-AU', { weekday: 'short' })} ${date.getDate()}${nth(date.getDate())}`
|
||||
}
|
||||
|
||||
const getUrl = (meal) => {
|
||||
// First non empty value in meal.recipe/image_urls
|
||||
for (const mr of meal.recipes) {
|
||||
const recipe = mr.recipe;
|
||||
if (recipe.image_urls.length && recipe.image_urls[0]) {
|
||||
return recipe.image_urls[0];
|
||||
}
|
||||
}
|
||||
|
||||
// First meal.extra_ingredient with a product with an image
|
||||
for (const ingredient of meal.extra_ingredients) {
|
||||
if (ingredient.product) {
|
||||
if (ingredient.product.img_large) {
|
||||
return ingredient.product.img_large;
|
||||
}
|
||||
|
||||
if (ingredient.product.img_small) {
|
||||
return ingredient.product.img_small;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'MealSelectionList',
|
||||
props: {
|
||||
meals: Array,
|
||||
checked: Array,
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatDate,
|
||||
getImageStyling(meal) {
|
||||
const imageUrl = getUrl(meal);
|
||||
if (!imageUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const opacity = 0.7;
|
||||
return {background: `linear-gradient(to bottom, rgba(255, 255, 255, ${ opacity }) 0%, rgba(255, 255, 255, ${ opacity }) 100%), url('${imageUrl}') center/cover no-repeat`};
|
||||
},
|
||||
mealCheckChanged(event) {
|
||||
const mealId = parseInt(event.target.id);
|
||||
const meal = this.meals.find(m => m.id === mealId);
|
||||
if (event.target.checked) {
|
||||
this.$emit('meal-selected', meal);
|
||||
} else {
|
||||
this.$emit('meal-unselected', meal);
|
||||
}
|
||||
},
|
||||
isChecked(meal) {
|
||||
for (const checkedMeal of this.checked) {
|
||||
if (checkedMeal.id === meal.id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,8 +1,16 @@
|
|||
<template>
|
||||
<div>
|
||||
<h1>My Shopping List</h1>
|
||||
<router-link :to="`/shopping/current`">Full Shopping List</router-link>
|
||||
<editable-ingredients-panel @on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient" @on-editing="onEditing" :ingredients="ingredients" />
|
||||
<router-link :to="`/shopping/current`">
|
||||
Full Shopping List
|
||||
</router-link>
|
||||
<editable-ingredients-panel
|
||||
:ingredients="ingredients"
|
||||
@on-add="addIngredient"
|
||||
@on-delete="deleteIngredient"
|
||||
@on-update-ingredient="updateIngredient"
|
||||
@on-editing="onEditing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
|
|
@ -31,53 +39,50 @@
|
|||
-->
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import data from '@/data.js'
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeMount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useShopping } from '@/composables/useShopping'
|
||||
import type { Ingredient } from '@/domain/types'
|
||||
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
||||
|
||||
export default {
|
||||
name: 'MyShoppingpage',
|
||||
components: { EditableIngredientsPanel },
|
||||
data() {
|
||||
return { ingredients: [], person: null }
|
||||
},
|
||||
async beforeMount() {
|
||||
const person = await data.currentUser();
|
||||
if (!person)
|
||||
return this.$router.push({ name: 'login' });
|
||||
const router = useRouter()
|
||||
const { loadUser } = useAuth()
|
||||
const { getMyShoppingList, saveMyShoppingList } = useShopping()
|
||||
|
||||
this.person = person;
|
||||
await this.updateShoppingList();
|
||||
},
|
||||
methods: {
|
||||
async updateShoppingList(save = false) {
|
||||
const requests = save ?
|
||||
await data.saveMyShoppingList(this.ingredients) :
|
||||
await data.getMyShoppingList();
|
||||
const ingredients = ref<Ingredient[]>([])
|
||||
|
||||
this.ingredients = requests.map(r => r.ingredient);
|
||||
},
|
||||
addIngredient() {
|
||||
this.ingredients = [{ id: -1 }, ...this.ingredients];
|
||||
},
|
||||
deleteIngredient(ingredient) {
|
||||
this.ingredients = this.ingredients.filter(i => i !== ingredient);
|
||||
},
|
||||
updateIngredient(oldIngredient, newIngredient) {
|
||||
this.ingredients = this.ingredients.map(source => source === oldIngredient ? newIngredient : source);
|
||||
},
|
||||
async onEditing(isStartingEdit) {
|
||||
await this.updateShoppingList(!isStartingEdit);
|
||||
async function updateShoppingList(save = false) {
|
||||
const newIngredients = save ? await saveMyShoppingList(ingredients.value) : await getMyShoppingList()
|
||||
ingredients.value = newIngredients.map((i) => ({ ...i }))
|
||||
}
|
||||
|
||||
if (isStartingEdit && this.ingredients.length === 0) {
|
||||
this.addIngredient();
|
||||
function addIngredient() {
|
||||
ingredients.value = [
|
||||
{ id: -1, name: '', line: '', quantity: 1, unit: 'Items', preparation: '', product: null },
|
||||
...ingredients.value,
|
||||
]
|
||||
}
|
||||
|
||||
function deleteIngredient(ingredient: Ingredient) {
|
||||
ingredients.value = ingredients.value.filter((i) => i !== ingredient)
|
||||
}
|
||||
|
||||
function updateIngredient(oldIngredient: Ingredient, newIngredient: Ingredient) {
|
||||
ingredients.value = ingredients.value.map((source) => (source === oldIngredient ? newIngredient : source))
|
||||
}
|
||||
|
||||
async function onEditing(isStartingEdit: boolean) {
|
||||
await updateShoppingList(!isStartingEdit)
|
||||
if (isStartingEdit && ingredients.value.length === 0) addIngredient()
|
||||
}
|
||||
|
||||
onBeforeMount(async () => {
|
||||
const u = await loadUser()
|
||||
if (!u) return router.push({ name: 'login' })
|
||||
await updateShoppingList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,58 @@
|
|||
<template>
|
||||
<h3>Purchased {{ shoppingList ? ago(shoppingList.created_date) : '' }}</h3>
|
||||
<h3>Purchased {{ shoppingList?.createdDate ? ago(shoppingList.createdDate) : '' }}</h3>
|
||||
|
||||
<div v-if="includedMeals.length > 0">
|
||||
<h4>Included Meals</h4>
|
||||
<meal-selection-list :checked="includedMeals" :meals="includedMeals" :disabled="true" />
|
||||
<meal-selection-list
|
||||
:checked="includedMeals"
|
||||
:meals="includedMeals"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul class="full-shopping-list">
|
||||
<li v-for="item in listByProduct" :key="item.id">
|
||||
<shopping-list-item :productGrouping="item" />
|
||||
<li
|
||||
v-for="item in listByProduct"
|
||||
:key="groupKey(item)"
|
||||
>
|
||||
<shopping-list-item-comp :shopping-list-item-group="item" />
|
||||
</li>
|
||||
</ul>
|
||||
</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 {
|
||||
list-style-type: none;
|
||||
|
||||
|
|
@ -24,40 +64,4 @@
|
|||
.full-shopping-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import { ago } from '@/dateformats.js'
|
||||
|
||||
import data from '@/data.js'
|
||||
import { purchasedToProductModel } from './shopping.js'
|
||||
|
||||
import MealSelectionList from './MealSelectionList.vue'
|
||||
import ShoppingListItem from './ShoppingListItem.vue'
|
||||
|
||||
export default {
|
||||
name: 'FullShoppingListPage',
|
||||
components: { MealSelectionList, ShoppingListItem },
|
||||
props: {
|
||||
id: [String, Number]
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
shoppingList: null,
|
||||
includedMeals: [],
|
||||
listByProduct: [],
|
||||
}
|
||||
},
|
||||
async beforeMount() {
|
||||
this.shoppingList = await data.getShoppingList(this.id);
|
||||
this.includedMeals = this.shoppingList.requests.map(r => r.meal).filter(m => m);
|
||||
this.listByProduct = purchasedToProductModel(this.shoppingList);
|
||||
},
|
||||
methods: {
|
||||
ago
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,60 +1,176 @@
|
|||
<template>
|
||||
|
||||
<!-- Show a card of the product with the total, taking up the available space and have an expanding section to view sources -->
|
||||
<div class="shopping-list-item">
|
||||
<img :src="productGrouping.product.img_small" class="product-image" />
|
||||
<img
|
||||
:src="imageSrc"
|
||||
class="product-image"
|
||||
>
|
||||
<div class="product-details">
|
||||
<h3 class="header">
|
||||
<strong><a :href="productGrouping.product.link">{{ productGrouping.product.name }}</a></strong>,
|
||||
<strong>
|
||||
<a
|
||||
v-if="shoppingListItemGroup.type === 'product' && shoppingListItemGroup.product?.link"
|
||||
:href="shoppingListItemGroup.product?.link"
|
||||
>{{ shoppingListItemGroup.product?.name }}</a>
|
||||
<span v-else>{{ shoppingListItemGroup.type === 'name' ? shoppingListItemGroup.name : '' }}</span> </strong>,
|
||||
<small>
|
||||
<span v-for="(total, index) in remainingRequired" :key="total.id">
|
||||
<span
|
||||
v-for="(total, index) in remainingRequiredTotals"
|
||||
:key="index"
|
||||
>
|
||||
<span v-if="index">, </span>
|
||||
<span>{{ formatQuantity(total.quantity) }} {{ total.unit }}</span>
|
||||
</span>
|
||||
<span class="found-marker partial" v-if="productGrouping.purchased.length > 0">✓ {{ getFriendlyDate(lastPurchased) }}</span>
|
||||
<span
|
||||
v-if="purchased.length > 0"
|
||||
class="found-marker partial"
|
||||
>✓ {{ getFriendlyDate(lastPurchased) }}</span>
|
||||
</small>
|
||||
</h3>
|
||||
<p class="sources" v-if="productGrouping.requested.length > 0">
|
||||
<p
|
||||
v-if="required.length > 0"
|
||||
class="sources"
|
||||
>
|
||||
<strong>Need: </strong>
|
||||
<span v-for="(source, index) in productGrouping.requested" :key="source.id">
|
||||
<span
|
||||
v-for="(source, index) in required"
|
||||
:key="source.id"
|
||||
>
|
||||
<span v-if="index">, and </span>
|
||||
<span v-if="source.person">
|
||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for {{ source.person.name }}
|
||||
</span>
|
||||
<span v-else-if="source.recipe">
|
||||
<!-- Generic ingredient-only display when no recipe/meal context; person ref removed -->
|
||||
<span v-if="!source.recipe && !source.meal && source.ingredient">
|
||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }}
|
||||
in <router-link :to="`/recipes/${ source.recipe.id }/`">{{ source.recipe.name }}</router-link>
|
||||
for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
|
||||
</span>
|
||||
<span v-else-if="source.meal">
|
||||
{{ source.ingredient.line }} for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
|
||||
<span v-else-if="source.recipe && source.ingredient">
|
||||
{{ 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>
|
||||
</p>
|
||||
<p v-if="productGrouping.purchased.length > 0">
|
||||
<p v-if="purchased.length > 0">
|
||||
<strong>Already found or purchased: </strong>
|
||||
<span v-for="(source, index) in productGrouping.purchased" :key="source.id">
|
||||
<span
|
||||
v-for="(source, index) in purchased"
|
||||
:key="source.id"
|
||||
>
|
||||
<span v-if="index">, and </span>
|
||||
<span v-if="source.person">
|
||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for {{ source.person.name }}
|
||||
</span>
|
||||
<span v-else-if="source.recipe">
|
||||
<!-- Generic ingredient-only display when no recipe/meal context; person ref removed -->
|
||||
<span v-if="!source.recipe && !source.meal && source.ingredient">
|
||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }}
|
||||
in <router-link :to="`/recipes/${ source.recipe.id }/`">{{ source.recipe.name }}</router-link>
|
||||
for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
|
||||
</span>
|
||||
<span v-else-if="source.meal">
|
||||
{{ source.ingredient.line }} for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
|
||||
<span v-else-if="source.recipe && source.ingredient">
|
||||
{{ 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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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 */
|
||||
|
||||
.shopping-list-item {
|
||||
|
|
@ -103,55 +219,4 @@
|
|||
.found-marker.partial {
|
||||
background-color: darkgoldenrod;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import { ago } from '@/dateformats.js';
|
||||
import { calculateTotals } from '@/units.js';
|
||||
|
||||
export default {
|
||||
name: 'ShoppingListItem',
|
||||
props: ['productGrouping' ],
|
||||
data() {
|
||||
return {
|
||||
expanded: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
remainingRequired() {
|
||||
return calculateTotals(this.productGrouping.requested.map(r => r.ingredient));
|
||||
},
|
||||
expectedExisting() {
|
||||
return calculateTotals(this.productGrouping.purchased.map(r => r.ingredient));
|
||||
},
|
||||
lastPurchased() {
|
||||
return this.productGrouping.purchased.reduce((latest, source) => {
|
||||
return (source.shop.created_date && latest > source.shop.created_date) ? latest : source.shop.created_date;
|
||||
}, new Date(0));
|
||||
}
|
||||
},
|
||||
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,151 +0,0 @@
|
|||
function scaleFactor(mealRecipe) {
|
||||
const numRecipe = mealRecipe.recipe.serves;
|
||||
const numRequested = mealRecipe.servings;
|
||||
|
||||
return numRequested / numRecipe;
|
||||
}
|
||||
|
||||
export function mealToShoppingListSources(meal) {
|
||||
const sources = [];
|
||||
for (const mealRecipe of meal.recipes) {
|
||||
const scale = scaleFactor(mealRecipe);
|
||||
for (const ingredient of mealRecipe.recipe.ingredients) {
|
||||
const quantity = ingredient.quantity * scale;
|
||||
sources.push({ meal, mealRecipe, recipe: mealRecipe.recipe, ingredient: { ...ingredient, quantity, }, });
|
||||
}
|
||||
}
|
||||
|
||||
for (const ingredient of meal.extra_ingredients) {
|
||||
sources.push({ ingredient, meal, });
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
export function requestsToSources(requests) {
|
||||
const sources = [];
|
||||
for (const request of requests) {
|
||||
if (request.ingredient) {
|
||||
sources.push(request);
|
||||
}
|
||||
else if (request.meal) {
|
||||
sources.push(...mealToShoppingListSources(request.meal));
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
function groupBy(groups, keyFn) {
|
||||
const grouped = {};
|
||||
for (const group of groups) {
|
||||
const key = keyFn(group);
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = [];
|
||||
}
|
||||
|
||||
grouped[key].push(group);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function resultsToSources(shop) {
|
||||
return shop.results.map(result => ({ shop, ingredient: { product: result.product, unit: result.unit, quantity: result.quantity, }, }));
|
||||
}
|
||||
|
||||
function getOutstandingGroupedByMeal(currentShoppingList) {
|
||||
// { meal_id: { purchased: [ sources, ], required: [ sources, ], }, }
|
||||
const requestedMeals = groupBy(requestsToSources(currentShoppingList.requests), source => source.meal?.id);
|
||||
for (const meal_id in requestedMeals) {
|
||||
requestedMeals[meal_id] = { id: parseInt(meal_id), purchased: [], required: requestedMeals[meal_id], };
|
||||
}
|
||||
|
||||
for (const shop of currentShoppingList.overlapping_previous_shops) {
|
||||
const shopResults = groupBy(resultsToSources(shop), source => source.ingredient.product.id);
|
||||
const purchasedSources = requestsToSources(shop.requests)
|
||||
.filter(source => source.meal && requestedMeals[source.meal.id])
|
||||
.filter(source => source.ingredient.product.id in shopResults);
|
||||
|
||||
for (const source of purchasedSources) {
|
||||
requestedMeals[source.meal.id].purchased.push({ shop, ...source });
|
||||
requestedMeals[source.meal.id].required = requestedMeals[source.meal.id].required.filter(required => required.ingredient.product.id !== source.ingredient.product.id);
|
||||
}
|
||||
}
|
||||
|
||||
return requestedMeals;
|
||||
}
|
||||
|
||||
export function getCompletedRequests(currentShoppingList, results) {
|
||||
const newShoppingList = { requests: currentShoppingList.requests, results };
|
||||
const fakeCurrentShoppingList = { requests: currentShoppingList.requests, overlapping_previous_shops: [ newShoppingList, ...currentShoppingList.overlapping_previous_shops ], };
|
||||
const requestedMealResults = Object.values(getOutstandingGroupedByMeal(fakeCurrentShoppingList));
|
||||
|
||||
const completedMeals = new Set(requestedMealResults.filter(mealGroup => mealGroup.required.length === 0).map(mealGroup => mealGroup.id));
|
||||
const purchasedProducts = new Set(results.map(result => result.product.id));
|
||||
const completedSources = currentShoppingList.requests.filter(request => {
|
||||
if (request.meal) {
|
||||
return completedMeals.has(request.meal.id);
|
||||
}
|
||||
|
||||
return purchasedProducts.has(request.ingredient.product.id);
|
||||
});
|
||||
|
||||
return sourcesToRequests(completedSources);
|
||||
}
|
||||
|
||||
export function sourcesToRequests(sources) {
|
||||
const uniqueMeals = sources.filter(source => source.meal)
|
||||
.map(source => source.meal)
|
||||
.reduce((acc, meal) => {
|
||||
acc[meal.id] = { meal, meal_id: meal.id, };
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const directIngredients = sources.filter(source => !source.meal);
|
||||
return [ ...Object.values(uniqueMeals), ...directIngredients, ];
|
||||
}
|
||||
|
||||
export function toProductsModel(currentShoppingList) {
|
||||
const requestedMealResults = getOutstandingGroupedByMeal(currentShoppingList);
|
||||
|
||||
// Transform into { product_id: { product, requested: [ sources, ], purchased: [ sources, ], }, }
|
||||
const grouped = {};
|
||||
for (const meal_id in requestedMealResults) {
|
||||
for (const source of requestedMealResults[meal_id].required) {
|
||||
if (!grouped[source.ingredient.product.id]) {
|
||||
grouped[source.ingredient.product.id] = { product: source.ingredient.product, requested: [], purchased: [], };
|
||||
}
|
||||
|
||||
grouped[source.ingredient.product.id].requested.push(source);
|
||||
}
|
||||
|
||||
for (const source of requestedMealResults[meal_id].purchased) {
|
||||
if (!grouped[source.ingredient.product.id]) {
|
||||
grouped[source.ingredient.product.id] = { product: source.ingredient.product, requested: [], purchased: [], };
|
||||
}
|
||||
|
||||
grouped[source.ingredient.product.id].purchased.push(source);
|
||||
}
|
||||
}
|
||||
|
||||
// Only return values, array
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function purchasedToProductModel(shoppingList) {
|
||||
const purchasedProductIds = new Set(shoppingList.results.map(r => r.product.id));
|
||||
const purchasedSources = requestsToSources(shoppingList.requests).filter(r => purchasedProductIds.has(r.ingredient.product.id));
|
||||
|
||||
const grouped = {};
|
||||
for (const source of purchasedSources) {
|
||||
const product = source.ingredient.product;
|
||||
if (!grouped[product.id]) {
|
||||
grouped[product.id] = { product, requested: [], purchased: [], };
|
||||
}
|
||||
|
||||
grouped[product.id].requested.push(source);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
293
src/data.js
293
src/data.js
|
|
@ -1,293 +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 => ({ ShoppingListRequest: l.requests, ShoppingList: l.overlapping_previous_shops }) },
|
||||
ShoppingList: { fields: [ "created_date", "purchased_date" ], dependants: l => ({ ShoppingListResult: l.results, ShoppingListRequest: l.requests }) },
|
||||
ShoppingListResult: { fields: [ "found_date", "created_date" ], },
|
||||
ShoppingListRequest: { fields: [ "created_date" ], dependants: r => ({ Meal: r.meal }) },
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 requests = await response.json();
|
||||
fixDates(requests, "ShoppingListRequest");
|
||||
|
||||
return requests;
|
||||
},
|
||||
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 requests = await response.json();
|
||||
fixDates(requests, "ShoppingListRequest");
|
||||
|
||||
return requests;
|
||||
},
|
||||
async getShoppingList(id) {
|
||||
const response = await fetch(BASE_URL + `/shopping/${encodeURIComponent(id)}`);
|
||||
|
||||
const lst = await response.json();
|
||||
fixDates(lst, "ShoppingList");
|
||||
return lst;
|
||||
},
|
||||
async getCurrentShoppingList() {
|
||||
const response = await fetch(BASE_URL + "/shopping/current");
|
||||
const lst = await response.json();
|
||||
fixDates(lst, "CurrentShoppingList");
|
||||
|
||||
return lst;
|
||||
},
|
||||
async purchaseItems(storeName, requests, results, completedRequests) {
|
||||
const response = await fetch(BASE_URL + "/shopping/", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ requests, results, store_name: storeName, completed_requests: completedRequests }),
|
||||
});
|
||||
|
||||
const lst = await response.json();
|
||||
fixDates(lst, "ShoppingList");
|
||||
|
||||
return lst;
|
||||
},
|
||||
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, "ShoppingListRequest");
|
||||
|
||||
return requests;
|
||||
},
|
||||
async unrequestMeal(meal_id) {
|
||||
const response = await fetch(BASE_URL + `/shopping/current/meals/${encodeURIComponent(meal_id)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
const requests = await response.json();
|
||||
fixDates(requests, "ShoppingListRequest");
|
||||
},
|
||||
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')
|
||||
/* eslint-enable @typescript-eslint/no-var-requires */
|
||||
module.exports = defineConfig({
|
||||
transpileDependencies: true
|
||||
transpileDependencies: true,
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue