Compare commits

..

23 commits

Author SHA1 Message Date
b08561c9bb Squashed commit of the following:
Some checks failed
CI / build-test (push) Has been cancelled
commit 594a14009096f7321ff92638e05cfa7c5ae6ca07
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Nov 1 12:20:33 2025 +1100

    API update - avoid optional arrays

commit 85787384cfc24dc28953ebb4630fe7614cb0ead1
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Nov 1 10:14:51 2025 +1100

    refactor(types): add NonNullableArrays and apply to Meal arrays; test: add shopping mapper boundary tests; all checks green

commit 1c50b7e23585cd4a34b5a691ffbf87d6d123f474
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 26 15:30:45 2025 +1100

    Use discriminated unions and overloads
2025-11-01 12:22:52 +11:00
f2dee9ca30 Stricter api changes 2025-10-26 15:13:33 +11:00
211489ca55 pruning (#3)
Some checks failed
CI / build-test (push) Has been cancelled
Reviewed-on: #3
Co-authored-by: jableader <jacobdunk@gmail.com>
Co-committed-by: jableader <jacobdunk@gmail.com>
2025-10-25 02:18:30 +00:00
fd35640fd8 Squashed commit of the following:
Some checks failed
CI / build-test (push) Has been cancelled
commit a94394866d9e957693c7380b846cfee98739b8be
Author: jableader <jacobdunk@gmail.com>
Date:   Mon Oct 20 00:11:28 2025 +1100

    Ran codegen

commit affaa0e4a80edbb5185f06b3a252e1790e349ee3
Author: jableader <jacobdunk@gmail.com>
Date:   Mon Oct 20 00:09:36 2025 +1100

    Moved shopping.ts into composable

commit 83ced27a549784a35fa2715630d6fdca7f12f4aa
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 23:43:09 2025 +1100

    Purchase fixed

commit 2fff14016eedaeb773c0f8b4ee9ccf08a1027449
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 22:54:36 2025 +1100

    typescript migration complete

commit 17651bcc8b0f40ac1579563379c8fc45aba1997d
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 22:38:22 2025 +1100

    Removing redundant null checks and type checking

commit 0cc3a508cd5c7d723eb76d665443b4d0fdad493f
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 22:30:52 2025 +1100

    Moving date to sdk layer

commit 35d1189009b27876dd167f353c04f217b7c2805e
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 21:42:47 2025 +1100

    No components re-parse dates already decoded by the boundary.

commit c4b82a31fad75bd5ec987847847076b537d35a7d
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 21:09:03 2025 +1100

    Update spec and types

commit 55984bf7c9d51e76eaa6713a3ca86e9a73eb6b03
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 20:22:01 2025 +1100

    Disallow casting entirely

commit eb02dc6a7b52048281a87924765d9aab1303150f
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 19:24:57 2025 +1100

    Replaced components[schemas] outside sdk

commit 2f4c1bed56724e4f8a0d586921367604ce127a1a
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 19:06:38 2025 +1100

    removed persons.ts

commit ae966db8a330c9f521f3c3cb7417c4f201467661
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 18:56:27 2025 +1100

    Remove casts from sdk

commit 42c253021e504b45963834e56551ded55be377f1
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 18:38:05 2025 +1100

    Remove *Like types

commit b0a4e21f19005fd69c0e9e16edaa9c10b1ca8567
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 18:32:26 2025 +1100

    Defined quantity construct

commit bf4666b89e2d395bdd091ffb6ee66a9cfb866c6d
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 18:27:55 2025 +1100

    Moved shopping view-model mapping (DTO → UI groups/meals) into `useShopping` (`mapItemToUI`, `groupsFrom`, `mealsFrom`).
    - Refactored `CurrentShoppingListPage.vue` to consume composable helpers (less duplication, no local cast-heavy mapping).
    - Refactored `PurchasedShoppingListPage.vue` to consume composable helpers and return typed SDK result; removed casts.

commit e7f84b453509a8fcffbb9923ec001f5be85e67e4
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 18:17:29 2025 +1100

    Removing casts, any, unknown and runtime type checking

commit 6346eef4aff4d64b04f7dfa837c5b988645c242c
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 17:42:14 2025 +1100

    domain/decoders.ts: removed casts; `purchaseDate` and `quantity` now trust API shapes.

commit 8b62eb9a9f2c0fe058d2dd37cc80071089379c34
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 17:36:50 2025 +1100

    Plan alignment

commit dd81fd454b8e14874930cfc44828abddffd50c41
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 17:10:11 2025 +1100

    More cast removals

commit 377d143ffc8596f46a3b32746d3710d99eb257cd
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 16:53:30 2025 +1100

    Type tightening in sdk

commit ce2577f22190b18a52ae0fa4238c6d1beb7e3bb7
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 16:24:51 2025 +1100

    All mappers removed/neutralized

commit 177205561a2d44aae2683e046960ef5fe8992255
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 16:20:34 2025 +1100

    Replace ingredientnormalized with ingredient

commit fc75b24977cc89d420dc768fb4dc4ade84ed32c2
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 16:16:03 2025 +1100

    More type safety tweaks and changes

commit c35cc2d3d58307290bbc2d5978baa48c1eda97b6
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 15:46:13 2025 +1100

    Update vue-tsc

commit 2cd429804ca34a42c83ea81a3ae68e2cfccb5e36
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 15:28:01 2025 +1100

    Ongoing SDK changes to tighten datatypes

commit e11847ae515eadf1bd33e88e154125fe361a5191
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 14:31:03 2025 +1100

    Enforce template type checking

commit 777b8dee7e9ef0807a2b2c039443920671fe9c2d
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 14:28:52 2025 +1100

    Boilerplate Collapse mappers to “decoders” and delete ad‑hoc view types

commit 4169c69d59cc16a24298351c38a60b4d4845f0c9
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 14:25:04 2025 +1100

    Plan updates

commit de65164c91d9dc641ec86424bd4fe40f91bb4ada
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 14:16:37 2025 +1100

    More camelCase changes

commit 4121c2157511f706b4704c5d5f769034d9bce5a0
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 14:00:48 2025 +1100

    Remove unused js shims and autoformat project

commit d475565529bd0c12f06f85f0bcbf19c857176fa7
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 13:59:10 2025 +1100

    camelCase conversions and api type propogation

commit 7b3b633a9d21c74977d4de120138fff192faaa78
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 13:11:22 2025 +1100

    Strategise ts improvements

commit 9396e4928abcf9d8a74a656ad21913f95e8e3e41
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 13:06:19 2025 +1100

    Linting errors

commit 528fa3bc2f6c42de6de37d9593d2ed31630d0e81
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 12:59:21 2025 +1100

    Recipe search pagination

commit 4993c3e53f540e098f3275715c86754dee9392af
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 12:33:13 2025 +1100

    Removed redundant nonnull check

commit c03d0b582d0a8b17e675cdf9bdab09eac4091d44
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 12:29:57 2025 +1100

    Persons api pagination

commit 67008b4a94a90295ed811594ca0e7f37c80a8305
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 22:44:32 2025 +1100

    ts pages starting to work

commit 0144e7f70d19289007d6b035594786ce573cbc70
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 22:39:17 2025 +1100

    More typescript fixes

commit dc429f84b48ca5892c6043db81b89fa6431d872d
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 18:28:54 2025 +1100

    5) API client and codegen guardrails — DONE (scripts) / PARTIAL (CI wiring)

commit 36ba555d9a600ca8cca4c18571e0d96584243556
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 18:26:30 2025 +1100

    Tighten composable and UI types (remove last anys) — DONE

commit 97523a9ef6aa972497e62d63c6f666453bb7eefc
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 18:24:11 2025 +1100

    Align OpenAPI params to remove residual casts, Strengthen MSW tests for the typed client

commit 608f6b751cb982487e90560792aec33317981c60
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 18:14:11 2025 +1100

    ts complete, rerun codegen

commit ca28376ed7bcb4ff4c7669210ce6eebfd9cb6eaa
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 18:09:29 2025 +1100

    Document All JS under `src/` is now removed; the source tree is TS-only.

commit ff3338b108bbf094f987302a21f1779d8f23368f
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 17:59:49 2025 +1100

    Ts tests

commit ce4555a279c715f427f9d00c346561efd74705f8
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 17:56:18 2025 +1100

    Completed Generate real OpenAPI types and wire them

commit b7304aa7390f33a372acda46c593d6592aabf3f7
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 17:02:27 2025 +1100

    DatePicker ts conversion

commit cb6ebdba286d44dbaae522ee29d95aa3ccfb5dc1
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:56:38 2025 +1100

    Removed js files

commit 549eefd369499e0a1d35ff88a659c8aecd955a02
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:49:31 2025 +1100

    - Utilities converted to TS: `src/dateformats.ts`, `src/units.ts`; imports updated accordingly.
    - Router converted to TS: `src/router/index.ts`; legacy `index.js` removed.

commit 6f7731287f4166df5c02164ec4220e370c6d7a35
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:48:58 2025 +1100

    alerts ts conversion

commit 4b1baec85a74b75e94d2392fea3b5abee294089c
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:27:35 2025 +1100

    use v1 api

commit da11976b96d791935cb16c7b1fdb074a2c87f53c
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:18:17 2025 +1100

     - Shopping pages converted to TS: `MyShoppingPage.vue`, `CurrentShoppingListPage.vue`, `PurchasedShoppingListPage.vue` (typed refs/computed; stable keys; wired to helpers).

commit acf740380bf53386e12e108ba09a507141176733
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:08:22 2025 +1100

    Shopping MyShoppingPage converted to TS with typed refs.

commit 744d7e792d013f0ed04f11280f3351950925beee
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:05:19 2025 +1100

    Shopping gets types

commit 4e576dca94424383bdf753403d7764e22a28f377
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:57:50 2025 +1100

    Error tests

commit ac6ae41b840c30196a370a077e02953c63977908
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:56:20 2025 +1100

    More datatypes

commit d7ea6538b1af6144933a2603d330cb17e04809db
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:45:49 2025 +1100

    spec updates

commit 0d6cfc1328df8ab4a4baf8f31a7a4e1ea50fd850
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:44:34 2025 +1100

    `codegen:check` warns when backend spec is newer than generated types

commit 54bff353b8e7429961364803300b38bc70f963c2
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:42:24 2025 +1100

    PersonList ts

commit e5ffcdde7e9538525704581a82606261596d31f5
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:40:53 2025 +1100

    MealCard ts

commit 5432d8333c023b0c6e25c9a6ac31519c818e6a58
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:39:29 2025 +1100

    ue CLI now serves/builds with TypeScript entry, ESLint configured to parse TS, use ts code

commit aba3fd532550a90adeb957b4ddbb43798bcf9e5a
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:23:16 2025 +1100

    composables typescript

commit 28ba44cc951fddfe64c386e77a91c2f46060cfb8
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:19:09 2025 +1100

    let the typescriptin begin

commit 98e57e4d65cecdd1ed5ed3fbf6b1ebf46ae6af10
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:13:58 2025 +1100

    Added typescript
2025-10-20 00:12:40 +11:00
a455b3a3aa Edit recipe page default id 2025-10-18 14:51:02 +11:00
bd99d90c09 Autoformat 2025-10-18 14:16:34 +11:00
72fccdd8a3 alert to composable 2025-10-18 14:12:27 +11:00
d1534934c0 Readme updates 2025-10-18 14:06:24 +11:00
68e82f1fa2 Composition #4 2025-10-18 13:57:40 +11:00
d31182dff0 Composition #3 2025-10-18 13:53:24 +11:00
beab02200c Composition #2 2025-10-18 13:47:56 +11:00
61ca41d25a Composition #1 2025-10-18 13:39:25 +11:00
3be1027154 Refactor #8 removed data.js 2025-10-18 13:22:48 +11:00
661d5f4840 Refactor #7 2025-10-18 13:17:39 +11:00
f0a8adbd77 Refactor #6.2 autoformat 2025-10-18 13:08:22 +11:00
28ad3a16ff Refactor #6.1 2025-10-18 13:06:25 +11:00
2cc87e1891 Refactor #5 2025-10-18 13:01:18 +11:00
8797a5489c Refactor #4 2025-10-18 12:54:56 +11:00
a6904524db Refactor #3 2025-10-18 12:42:23 +11:00
ec28531df9 Refactor #2 2025-10-18 12:36:55 +11:00
ee2f08cbfc Initial 2025-10-18 12:30:30 +11:00
59e3d8ab16 Purchased items returns 2025-07-30 18:10:22 +10:00
6381c55c52 Allowed for null products, move ingredient spreading server-side (#2)
Reviewed-on: #2
Co-authored-by: jableader <jacobdunk@gmail.com>
Co-committed-by: jableader <jacobdunk@gmail.com>
2025-07-28 23:22:58 +00:00
81 changed files with 13096 additions and 3411 deletions

12
.editorconfig Normal file
View 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
View 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
View 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
View file

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

3
.prettierignore Normal file
View file

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

7
.prettierrc.json Normal file
View file

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

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

110
README.md
View file

@ -1,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 whats cooking.
Built with modern Vue patterns, a clean API layer, and lightweight tests, the project is easy to extend and fun to work on.
---
## Quick start
1) Install dependencies
```bash
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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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": "@babel/eslint-parser"
"parser": "@typescript-eslint/parser",
"sourceType": "module",
"ecmaVersion": 2020,
"extraFileExtensions": [
".vue"
]
},
"rules": {}
"plugins": [
"@typescript-eslint"
],
"rules": {
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": "error"
},
"overrides": [
{
"files": [
"src/**/*.ts",
"src/**/*.vue",
"src/**/*.d.ts"
],
"excludedFiles": [
"src/api/types.ts",
"src/**/*.d.ts"
],
"parserOptions": {
"project": [
"./tsconfig.eslint.json"
],
"tsconfigRootDir": "."
},
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-return": "error",
"no-restricted-syntax": [
"error",
{
"selector": "TSAsExpression[typeAnnotation.type!='TSConstKeyword']",
"message": "Disallow 'as' type assertions in app code. Prefer precise typing and helpers."
},
{
"selector": "TSTypeAssertion",
"message": "Disallow angle-bracket type assertions in app code. Prefer precise typing and helpers."
}
]
}
}
]
},
"browserslist": [
"> 1%",

View file

@ -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
View 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)
}

View file

@ -1,48 +1,49 @@
<template>
<div>
<ul class="nav">
<li class="nav-item">
<router-link class="nav-link" to="/recipes" active-class="active">Recipes</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/mealplan" active-class="active">Meal Plan</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/shopping" active-class="active">Shopping</router-link>
</li>
<li class="nav-item">
<router-link
class="nav-link"
:to="{ name: 'recipes' }"
active-class="active"
>
Recipes
</router-link>
</li>
<li class="nav-item">
<router-link
class="nav-link"
:to="{ name: 'mealplan' }"
active-class="active"
>
Meal Plan
</router-link>
</li>
<li class="nav-item">
<router-link
class="nav-link"
:to="{ name: 'shopping' }"
active-class="active"
>
Shopping
</router-link>
</li>
</ul>
</div>
<div class="viewport">
<router-view/>
<router-view />
</div>
<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;
@ -54,45 +55,44 @@ export default {
/* Make nav bar links buttons across top of screen */
.nav {
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 1;
display: flex;
justify-content: space-around;
list-style-type: none;
margin: 0;
padding: 0;
position: fixed;
top: 0;
width: 100%;
background-color: #333;
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 1;
display: flex;
justify-content: space-around;
list-style-type: none;
margin: 0;
padding: 0;
position: fixed;
top: 0;
width: 100%;
background-color: #333;
}
.nav li {
flex: 1;
flex: 1;
}
/* Style the links inside the navigation bar */
.nav a {
display: inline-block;
color: #f2f2f2;
text-align: center;
padding: 14px 16px;
text-decoration: none;
display: inline-block;
color: #f2f2f2;
text-align: center;
padding: 14px 16px;
text-decoration: none;
}
/* Have active route use different color */
.nav li:has(> a.active) {
background-color: #4CAF50;
color: white;
background-color: #4caf50;
color: white;
}
.viewport {
max-width: 1200px;
margin: auto;
max-width: 1200px;
margin: auto;
}
</style>

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,37 +1,40 @@
<template>
<div class="card">
<a @click="$emit('click')">
<h2>{{ title }}</h2>
<img :src="image" :alt="name" />
</a>
</div>
<div class="card">
<a @click="emit('click')">
<h2>{{ title }}</h2>
<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>
img {
width: 100%;
height: auto;
width: 100%;
height: auto;
}
.card {
display: inline-block;
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
width: 20em;
margin: 1em 1ex;
display: inline-block;
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
width: 20em;
margin: 1em 1ex;
}
.card:hover {
background-color: #ccc;
background-color: #ccc;
}
</style>
</style>

View file

@ -1,98 +1,90 @@
<template>
<div v-if="showAlert" :class="['alert', type]" @click="dismiss">
<img v-if="icon" :src="icon" alt="Notification icon" />
<div
v-if="showAlert"
:class="['alert', type]"
@click="dismiss"
>
<img
v-if="icon"
:src="icon"
alt="Notification icon"
>
<div class="message-container">
<h4 class="heading">{{ heading }}</h4>
<p class="message">{{ message }}</p>
<h4 class="heading">
{{ heading }}
</h4>
<p class="message">
{{ message }}
</p>
</div>
</div>
</div>
</template>
<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 */
.alert {
position: fixed;
bottom: 1em;
right: 1em;
display: flex;
align-items: center;
padding: 1em;
border-radius: 5px;
color: white;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
text-align: left;
position: fixed;
bottom: 1em;
right: 1em;
display: flex;
align-items: center;
padding: 1em;
border-radius: 5px;
color: white;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
text-align: left;
}
.alert img {
width: 2em;
height: 2em;
margin-right: 1em;
width: 2em;
height: 2em;
margin-right: 1em;
/* Invert svg colors */
filter: invert(1);
/* Invert svg colors */
filter: invert(1);
}
.alert.error {
background-color: #f44336;
background-color: #f44336;
}
.alert.success {
background-color: #4CAF50;
background-color: #4caf50;
}
.alert.info {
background-color: #2196F3;
background-color: #2196f3;
}
</style>
<script>
import alert from '@/alert';
const alertIcons = {
error: require('@/assets/notification-error.svg'),
success: require('@/assets/notification-success.svg'),
info: require('@/assets/notification-info.svg')
};
export default {
name: 'AlertToast',
data() {
return {
showAlert: false,
heading: '',
message: '',
type: ''
};
},
computed: {
icon() {
return this.type && alertIcons[this.type] ? alertIcons[this.type] : null;
}
},
mounted() {
alert.subscribe(this.show);
},
methods: {
show({ heading, message, type }) {
this.heading = heading;
this.message = message;
this.type = type;
this.showAlert = true;
setTimeout(() => {
this.showAlert = false;
}, 5000);
},
dismiss() {
this.showAlert = false;
},
}
}
</script>

View file

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

View file

@ -1,56 +1,91 @@
<template>
<div class="compact-parse-results">
<div class="compact-parse-results">
<p class="parse-element teaser-image">
<img :src="ingredient.product?.img_small ?? require('@/assets/missing-product.svg')" />
<img :src="ingredient.product?.imgSmall ?? missingProduct">
</p>
<p class="ingredient-details">
<span class="parse-element quantity" :class="{ missing: !(ingredient?.quantity)}">{{ ingredient?.quantity || 'qty' }}</span>
<span class="parse-element unit" :class="{ missing: !(ingredient?.unit)}">{{ ingredient?.unit || 'unit' }}</span>
<span class="parse-element helper" >of</span>
<span class="parse-element name" :class="{ missing: !(ingredient?.name)}">{{ ingredient?.name || 'name' }}</span>:&nbsp;
<span class="parse-element product-name" :class="{missing: !(ingredient?.product)}">
<a :href="ingredient?.product?.link" v-if="ingredient?.product?.link" target=_blank>
( {{ ingredient?.product?.name }} <img src="@/assets/external-link.svg" style="width: 1em; height: 1em; vertical-align: middle; margin-left: 0.5em; margin-bottom: 0.2em;" /> )
</a>
<a v-else-if="ingredient?.name" :href="searchlink" target="_blank">
(search?)
</a>
<a v-else>
(product)
</a>
</span>
<span
class="parse-element quantity"
:class="{ missing: !ingredient?.quantity }"
>{{
ingredient?.quantity || 'qty'
}}</span>
<span
class="parse-element unit"
:class="{ missing: !ingredient?.unit }"
>{{
ingredient?.unit || 'unit'
}}</span>
<span class="parse-element helper">of</span>
<span
class="parse-element name"
:class="{ missing: !ingredient?.name }"
>{{
ingredient?.name || 'name'
}}</span>:&nbsp;
<span
class="parse-element product-name"
:class="{ missing: !ingredient?.product }"
>
<a
v-if="ingredient?.product?.link"
:href="ingredient?.product?.link"
target="”_blank”"
>
( {{ ingredient?.product?.name }}
<img
:src="externalLink"
style="
width: 1em;
height: 1em;
vertical-align: middle;
margin-left: 0.5em;
margin-bottom: 0.2em;
"
>
)
</a>
<a
v-else-if="ingredient?.name"
:href="searchlink"
target="_blank"
> (search?) </a>
<a v-else> (product) </a>
</span>
</p>
</div>
</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;
justify-content: left;
text-align: left;
flex: left;
display: flex;
justify-content: left;
text-align: left;
}
.parse-element {
margin: auto 0;
margin-right: 1em;
border-bottom: solid 1px #ccc;
font-weight: bold;
margin: auto 0;
margin-right: 1em;
border-bottom: solid 1px #ccc;
font-weight: bold;
}
.parse-element.missing {
@ -58,20 +93,21 @@ export default {
border: solid red 1px;
}
.parse-element.teaser-image, .parse-element.helper {
border: none;
font-weight: normal;
.parse-element.teaser-image,
.parse-element.helper {
border: none;
font-weight: normal;
}
.parse-element.teaser-image img {
padding: 0;
border: none;
width: 2em;
height: 2em;
padding: 0;
border: none;
width: 2em;
height: 2em;
}
.ingredient-details {
flex: 1;
flex: 1;
}
.quantity {
@ -89,5 +125,4 @@ export default {
.product-name {
color: purple;
}
</style>
</style>

View file

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

View file

@ -1,68 +1,76 @@
<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" />
</p>
<p v-if="ingredient.line">
<!-- Single line parse results -->
<compact-parsed-ingredient :ingredient="ingredient" />
</p>
</div>
<div class="ingredient-item">
<p>
<input
v-model="ingredientText"
placeholder="Enter an ingredient"
@keyup.enter="updateIngredient"
@blur="updateIngredient"
>
<input
v-if="ingredient.line"
v-model="productLink"
class="product-link-input"
placeholder="Enter product link"
@keyup.enter="updateProductLink"
@blur="updateProductLink"
>
</p>
<p v-if="ingredient.line">
<!-- Single line parse results -->
<compact-parsed-ingredient :ingredient="ingredient" />
</p>
</div>
</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 },
},
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);
}
}
};
const props = defineProps<{ ingredient: Ingredient }>()
const emit = defineEmits<{
(e: 'update-ingredient', ingredient: Ingredient, newLine: string): void
(e: 'update-product-link', ingredient: Ingredient, link: string): void
}>()
const ingredientText = ref<string>(props.ingredient?.line ?? '')
const productLink = ref<string>(props.ingredient.product?.link ?? '')
watch(
() => props.ingredient,
(newIngredient) => {
ingredientText.value = newIngredient?.line ?? ''
productLink.value = newIngredient?.product?.link ?? ''
},
{ deep: true }
)
function updateIngredient() {
if (ingredientText.value != props.ingredient.line) {
emit('update-ingredient', props.ingredient, ingredientText.value)
}
}
function updateProductLink() {
if (productLink.value && productLink.value != props.ingredient.product?.link) {
emit('update-product-link', props.ingredient, productLink.value)
}
}
</script>
<style scoped>
input {
border: 0;
font-size: larger;
border-bottom: 1px solid #ccc;
border-left: 1px solid #ccc;
width: 100%;
padding: 0.5vh;
border: 0;
font-size: larger;
border-bottom: 1px solid #ccc;
border-left: 1px solid #ccc;
width: 100%;
padding: 0.5vh;
}
.product-link-input {
color: #777;
margin-top: 0.5vh;
color: #777;
margin-top: 0.5vh;
}
</style>

View file

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

View file

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

View file

@ -1,85 +1,90 @@
<template>
<div class="meal-card">
<h3>{{ mealTitle }}</h3>
<h4>{{ dayOfWeek }} <small>{{ date }}</small></h4>
<p>
Cooked by
<span v-for="(chef, index) in meal.chefs" :key="chef.id">
{{ chef.name }}{{ englishSeperator(index, meal.chefs) }}
</span>
<span v-if="!meal.chefs.length">somebody?</span>
</p>
<p>
For
<span v-for="(consumer, index) in meal.consumers" :key="consumer.id">
{{ consumer.name }}{{ englishSeperator(index, meal.consumers) }}
</span>
<span v-if="!meal.consumers.length">somebody?</span>
</p>
<p v-if="meal.purchase_date">
Purchased {{ ago(meal.purchase_date) }}
</p>
</div>
<div class="meal-card">
<h3>{{ mealTitle }}</h3>
<h4>
{{ dayOfWeek }} <small>{{ date }}</small>
</h4>
<p>
Cooked by
<span
v-for="(chef, index) in meal.chefs"
:key="chef.id"
>
{{ chef.name }}{{ englishSeperator(index, meal.chefs) }}
</span>
<span v-if="!meal.chefs.length">somebody?</span>
</p>
<p>
For
<span
v-for="(consumer, index) in meal.consumers"
:key="consumer.id"
>
{{ consumer.name }}{{ englishSeperator(index, meal.consumers) }}
</span>
<span v-if="!meal.consumers.length">somebody?</span>
</p>
<p v-if="meal.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) {
switch (index) {
case list.length - 1:
return '';
case list.length - 2:
return ' and ';
default:
return ', ';
}
function englishSeperator(index: number, list: Array<unknown>) {
switch (index) {
case list.length - 1:
return ''
case list.length - 2:
return ' and '
default:
return ', '
}
}
function englishList(list) {
switch (list.length) {
case 0:
return '';
case 1:
return list[0];
case 2:
return `${list[0]} and ${list[1]}`;
default:
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`;
}
function englishList(list: string[]) {
switch (list.length) {
case 0:
return ''
case 1:
return list[0]
case 2:
return `${list[0]} and ${list[1]}`
default:
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`
}
}
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
}
}
</script>
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>

View file

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

View file

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

View file

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

View file

@ -1,46 +1,53 @@
<template>
<div class="recipe-card">
<p>
<img v-if="recipe.image_urls" :src="recipe.image_urls[0]" />
<img v-else src="@/assets/egg.svg" />
</p>
<p class="recipe-name">{{ recipe.name }}</p>
</div>
<div class="recipe-card">
<p>
<img
v-if="recipe.imageUrls && recipe.imageUrls.length"
:src="recipe.imageUrls[0] || fallbackEgg"
>
<img
v-else
:src="fallbackEgg"
>
</p>
<p class="recipe-name">
{{ recipe.name }}
</p>
</div>
</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;
justify-content: space-between;
text-align: left;
max-height: 10em;
margin: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
text-align: left;
max-height: 10em;
margin: 0;
}
.recipe-card p {
margin: auto 0;
margin-right: 1em;
margin: auto 0;
margin-right: 1em;
}
li img {
display: block;
width: 3em;
height: 3em;
object-fit: cover;
display: block;
width: 3em;
height: 3em;
object-fit: cover;
}
.recipe-name {
flex: 1;
margin: auto;
flex: 1;
margin: auto;
}
</style>
</style>

View file

@ -1,99 +1,227 @@
<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)">
<recipe-card :recipe="recipe" />
</li>
</ul>
</div>
<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() {
return {
searchTerm: '',
recipes: [],
timeouts: [],
}
},
watch: {
searchTerm() {
const searchTerm = this.searchTerm;
if (searchTerm) {
this.timeouts.push(setTimeout(() => {
if (searchTerm === this.searchTerm) {
this.search();
}
}, 200));
}
}
},
methods: {
async search() {
this.recipes = await data.searchRecipes(this.searchTerm) ?? this.recipes;
},
selectRecipe(recipe) {
this.$emit('select-recipe', recipe);
this.searchTerm = '';
this.recipes = [];
}
const props = withDefaults(defineProps<{ placeholder?: string }>(), {
placeholder: 'Add a recipe...',
})
const placeholderText: string = props.placeholder ?? 'Add a recipe...'
type RecipeItem = Pick<Recipe, 'id' | 'name' | 'imageUrls'>
const emit = defineEmits<{
(e: 'select-recipe', recipe: RecipeItem): void
}>()
const searchTerm = ref('')
const pageSize = 10
const { items: recipes, next: nextCursor, prev: prevCursor, load, loadNext: loadNextPage, loadPrev: loadPrevPage, reset } = usePagination<RecipeItem>(
async (params) => {
const page = await listRecipes(params)
return {
items: (page.items ?? []).map(toRecipeItem),
next: page.next ?? null,
prev: page.prev ?? null,
total: page.total ?? null,
}
},
{ pageSize }
)
const dropdownVisible = computed(() => recipes.value.length > 0 || (searchTerm.value.length > 0))
const { show: showAlert, scheduleAutoDismiss } = useAlert()
let debounceId: ReturnType<typeof setTimeout> | null = null
watch(
searchTerm,
(newVal) => {
if (!newVal) {
recipes.value = []
if (debounceId) clearTimeout(debounceId)
return
}
if (debounceId) clearTimeout(debounceId)
debounceId = setTimeout(() => {
if (newVal === searchTerm.value) {
search()
}
}, 200)
}
)
async function search() {
try {
await load({ q: searchTerm.value })
} catch {
// swallow errors and clear suggestions on failures, also notify user
reset()
showAlert({ type: 'error', heading: 'Recipe search failed', message: 'Please try again shortly.' })
scheduleAutoDismiss()
}
}
async function loadNext() {
if (!nextCursor.value) return
try {
await loadNextPage()
} catch {
showAlert({ type: 'error', heading: 'Unable to load more', message: 'Could not fetch the next page.' })
scheduleAutoDismiss()
}
}
async function loadPrev() {
if (!prevCursor.value) return
try {
await loadPrevPage()
} catch {
showAlert({ type: 'error', heading: 'Unable to load more', message: 'Could not fetch the previous page.' })
scheduleAutoDismiss()
}
}
function toRecipeItem(r: Recipe): RecipeItem {
return { id: r.id, name: r.name, imageUrls: r.imageUrls ?? [] }
}
function selectRecipe(recipe: RecipeItem) {
emit('select-recipe', recipe)
searchTerm.value = ''
recipes.value = []
}
function clear() {
searchTerm.value = ''
reset()
}
function onFocusOut() {
// keep results while focused; on blur, clear all
reset()
}
onBeforeUnmount(() => {
if (debounceId) clearTimeout(debounceId)
})
</script>
<style scoped>
.recipe-search-box {
position: relative;
position: relative;
}
.recipe-search-box input {
width: calc(100% - 2em);
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
outline: none;
width: calc(100% - 2em);
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
outline: none;
}
.recipe-search-box .dropdown {
position: absolute;
top: 100%;
width: 100%;
margin: auto;
background-color: #fff;
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
list-style-type: none;
margin: 0;
padding: 0;
max-height: 40vh;
overflow-y: scroll;
position: absolute;
top: 100%;
width: 100%;
margin: auto;
background-color: #fff;
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
list-style-type: none;
margin: 0;
padding: 0;
max-height: 40vh;
overflow-y: scroll;
}
.recipe-search-box .dropdown li {
padding: 8px;
cursor: pointer;
border-bottom: 1px solid #ccc;
padding: 8px;
cursor: pointer;
border-bottom: 1px solid #ccc;
}
.recipe-search-box .dropdown li:last-child {
border-bottom: none;
border-bottom: none;
}
.recipe-search-box .dropdown li:hover {
background-color: #eee;
background-color: #eee;
}
</style>
.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>

View file

@ -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')" />
</div>
<div>
<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}`)
}
</script>
function onAddRecipe() {
router.push('/recipes/add')
}
</script>

View file

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

View file

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

View file

@ -1,11 +1,19 @@
<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" />
</div>
<div>
<h1>My Shopping List</h1>
<router-link :to="`/shopping/current`">
Full Shopping List
</router-link>
<editable-ingredients-panel
:ingredients="ingredients"
@on-add="addIngredient"
@on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient"
@on-editing="onEditing"
/>
</div>
<!--
<!--
# Functions
* Add a random item to next shop
* Add meals to next shop
@ -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 new_ingredients = save ?
await data.saveMyShoppingList(this.ingredients) :
await data.getMyShoppingList();
const ingredients = ref<Ingredient[]>([])
this.ingredients = new_ingredients;
},
addIngredient() {
this.ingredients = [{ id: -1 }, ...this.ingredients];
},
deleteIngredient(ingredient) {
this.ingredients = this.ingredients.filter(i => i !== ingredient);
},
updateIngredient(oldIngredient, newIngredient) {
this.ingredients = this.ingredients.map(source => source === oldIngredient ? newIngredient : source);
},
async onEditing(isStartingEdit) {
await this.updateShoppingList(!isStartingEdit);
if (isStartingEdit && this.ingredients.length === 0) {
this.addIngredient();
}
}
}
async function updateShoppingList(save = false) {
const newIngredients = save ? await saveMyShoppingList(ingredients.value) : await getMyShoppingList()
ingredients.value = newIngredients.map((i) => ({ ...i }))
}
</script>
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>

View file

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

View file

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

View file

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

View 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 }
}

View 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 }
}

View 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 }
}

View 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)
},
}
}

View file

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

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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 {}

View file

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

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

View file

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

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

View file

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

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

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

View file

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

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

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

View file

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

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

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

View file

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

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

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

View file

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

View file

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

View file

@ -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()
})
})

View file

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

24
tests/test-setup.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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'],
},
})

View file

@ -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,
})