Compare commits

...

3 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
24 changed files with 840 additions and 458 deletions

View file

@ -98,3 +98,15 @@ Environment
Testing Testing
- Unit tests use MSW; the client defaults to a localhost base in tests for easy mocking - 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

404
package-lock.json generated
View file

@ -8,16 +8,14 @@
"name": "doof", "name": "doof",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"core-js": "^3.8.3",
"vue": "^3.5.12", "vue": "^3.5.12",
"vue-router": "^4.4.5" "vue-router": "^4.4.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@types/node": "^20.19.22", "@types/node": "^20.19.22",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^1.6.0",
"@vue/cli-plugin-babel": "~5.0.0", "@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0", "@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-typescript": "~5.0.0", "@vue/cli-plugin-typescript": "~5.0.0",
@ -27,12 +25,10 @@
"husky": "^8.0.0", "husky": "^8.0.0",
"lint-staged": "^13.3.0", "lint-staged": "^13.3.0",
"msw": "^2.5.2", "msw": "^2.5.2",
"node-fetch": "^2.6.9",
"openapi-fetch": "^0.9.5", "openapi-fetch": "^0.9.5",
"openapi-typescript": "^7.4.2", "openapi-typescript": "^7.4.2",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "~5.5.4", "typescript": "~5.5.4",
"undici": "^6.19.8",
"vitest": "^1.6.0", "vitest": "^1.6.0",
"vue-tsc": "^2.0.29" "vue-tsc": "^2.0.29"
} }
@ -127,24 +123,6 @@
"url": "https://opencollective.com/babel" "url": "https://opencollective.com/babel"
} }
}, },
"node_modules/@babel/eslint-parser": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.23.3.tgz",
"integrity": "sha512-9bTuNlyx7oSstodm1cR1bECj4fkiknsDa1YniISkJemMY3DGhJNYBECbe6QD/q54mp2J8VO66jW3/7uP//iFCw==",
"dev": true,
"dependencies": {
"@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1",
"eslint-visitor-keys": "^2.1.0",
"semver": "^6.3.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || >=14.0.0"
},
"peerDependencies": {
"@babel/core": "^7.11.0",
"eslint": "^7.5.0 || ^8.0.0"
}
},
"node_modules/@babel/generator": { "node_modules/@babel/generator": {
"version": "7.23.6", "version": "7.23.6",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz",
@ -1821,6 +1799,13 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bcoe/v8-coverage": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true,
"license": "MIT"
},
"node_modules/@discoveryjs/json-ext": { "node_modules/@discoveryjs/json-ext": {
"version": "0.5.7", "version": "0.5.7",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
@ -2531,6 +2516,16 @@
} }
} }
}, },
"node_modules/@istanbuljs/schema": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jest/schemas": { "node_modules/@jest/schemas": {
"version": "29.6.3", "version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
@ -2593,10 +2588,11 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.20", "version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
@ -2626,15 +2622,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
"integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==",
"dev": true,
"dependencies": {
"eslint-scope": "5.1.1"
}
},
"node_modules/@node-ipc/js-queue": { "node_modules/@node-ipc/js-queue": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@node-ipc/js-queue/-/js-queue-2.0.3.tgz", "resolved": "https://registry.npmjs.org/@node-ipc/js-queue/-/js-queue-2.0.3.tgz",
@ -3723,6 +3710,34 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/@vitest/coverage-v8": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz",
"integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.1",
"@bcoe/v8-coverage": "^0.2.3",
"debug": "^4.3.4",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.4",
"istanbul-reports": "^3.1.6",
"magic-string": "^0.30.5",
"magicast": "^0.3.3",
"picocolors": "^1.0.0",
"std-env": "^3.5.0",
"strip-literal": "^2.0.0",
"test-exclude": "^6.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "1.6.1"
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
@ -6249,6 +6264,7 @@
"version": "3.35.0", "version": "3.35.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.35.0.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.35.0.tgz",
"integrity": "sha512-ntakECeqg81KqMueeGJ79Q5ZgQNR+6eaE8sxGCx62zMbAIj65q+uYvatToew3m6eAGdU4gNZwpZ34NMe4GYswg==", "integrity": "sha512-ntakECeqg81KqMueeGJ79Q5ZgQNR+6eaE8sxGCx62zMbAIj65q+uYvatToew3m6eAGdU4gNZwpZ34NMe4GYswg==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@ -7350,15 +7366,6 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/eslint-visitor-keys": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
"integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
"dev": true,
"engines": {
"node": ">=10"
}
},
"node_modules/eslint-webpack-plugin": { "node_modules/eslint-webpack-plugin": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.2.0.tgz", "resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.2.0.tgz",
@ -9294,6 +9301,112 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-report/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report/node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/istanbul-lib-report/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-report/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-source-maps": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.23",
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/javascript-stringify": { "node_modules/javascript-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz",
@ -10366,6 +10479,18 @@
"@jridgewell/sourcemap-codec": "^1.5.0" "@jridgewell/sourcemap-codec": "^1.5.0"
} }
}, },
"node_modules/magicast": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.25.4",
"@babel/types": "^7.25.4",
"source-map-js": "^1.2.0"
}
},
"node_modules/make-dir": { "node_modules/make-dir": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@ -13516,6 +13641,21 @@
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true "dev": true
}, },
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
"integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^7.1.4",
"minimatch": "^3.0.4"
},
"engines": {
"node": ">=8"
}
},
"node_modules/text-table": { "node_modules/text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -13912,16 +14052,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici": {
"version": "6.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@ -15515,17 +15645,6 @@
"semver": "^6.3.1" "semver": "^6.3.1"
} }
}, },
"@babel/eslint-parser": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.23.3.tgz",
"integrity": "sha512-9bTuNlyx7oSstodm1cR1bECj4fkiknsDa1YniISkJemMY3DGhJNYBECbe6QD/q54mp2J8VO66jW3/7uP//iFCw==",
"dev": true,
"requires": {
"@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1",
"eslint-visitor-keys": "^2.1.0",
"semver": "^6.3.1"
}
},
"@babel/generator": { "@babel/generator": {
"version": "7.23.6", "version": "7.23.6",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz",
@ -16667,6 +16786,12 @@
"to-fast-properties": "^2.0.0" "to-fast-properties": "^2.0.0"
} }
}, },
"@bcoe/v8-coverage": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"@discoveryjs/json-ext": { "@discoveryjs/json-ext": {
"version": "0.5.7", "version": "0.5.7",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
@ -17023,6 +17148,12 @@
"dev": true, "dev": true,
"requires": {} "requires": {}
}, },
"@istanbuljs/schema": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
"dev": true
},
"@jest/schemas": { "@jest/schemas": {
"version": "29.6.3", "version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
@ -17071,9 +17202,9 @@
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
}, },
"@jridgewell/trace-mapping": { "@jridgewell/trace-mapping": {
"version": "0.3.20", "version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true, "dev": true,
"requires": { "requires": {
"@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/resolve-uri": "^3.1.0",
@ -17100,15 +17231,6 @@
"strict-event-emitter": "^0.5.1" "strict-event-emitter": "^0.5.1"
} }
}, },
"@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
"integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==",
"dev": true,
"requires": {
"eslint-scope": "5.1.1"
}
},
"@node-ipc/js-queue": { "@node-ipc/js-queue": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@node-ipc/js-queue/-/js-queue-2.0.3.tgz", "resolved": "https://registry.npmjs.org/@node-ipc/js-queue/-/js-queue-2.0.3.tgz",
@ -17868,6 +17990,27 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"dev": true "dev": true
}, },
"@vitest/coverage-v8": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz",
"integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==",
"dev": true,
"requires": {
"@ampproject/remapping": "^2.2.1",
"@bcoe/v8-coverage": "^0.2.3",
"debug": "^4.3.4",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.4",
"istanbul-reports": "^3.1.6",
"magic-string": "^0.30.5",
"magicast": "^0.3.3",
"picocolors": "^1.0.0",
"std-env": "^3.5.0",
"strip-literal": "^2.0.0",
"test-exclude": "^6.0.0"
}
},
"@vitest/expect": { "@vitest/expect": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
@ -19755,7 +19898,8 @@
"core-js": { "core-js": {
"version": "3.35.0", "version": "3.35.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.35.0.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.35.0.tgz",
"integrity": "sha512-ntakECeqg81KqMueeGJ79Q5ZgQNR+6eaE8sxGCx62zMbAIj65q+uYvatToew3m6eAGdU4gNZwpZ34NMe4GYswg==" "integrity": "sha512-ntakECeqg81KqMueeGJ79Q5ZgQNR+6eaE8sxGCx62zMbAIj65q+uYvatToew3m6eAGdU4gNZwpZ34NMe4GYswg==",
"dev": true
}, },
"core-js-compat": { "core-js-compat": {
"version": "3.35.0", "version": "3.35.0",
@ -20733,12 +20877,6 @@
"estraverse": "^4.1.1" "estraverse": "^4.1.1"
} }
}, },
"eslint-visitor-keys": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
"integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
"dev": true
},
"eslint-webpack-plugin": { "eslint-webpack-plugin": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.2.0.tgz", "resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.2.0.tgz",
@ -21907,6 +22045,76 @@
"integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
"dev": true "dev": true
}, },
"istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true
},
"istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"requires": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"dependencies": {
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
"make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"requires": {
"semver": "^7.5.3"
}
},
"semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"istanbul-lib-source-maps": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
"dev": true,
"requires": {
"@jridgewell/trace-mapping": "^0.3.23",
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0"
}
},
"istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"requires": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
}
},
"javascript-stringify": { "javascript-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz",
@ -22660,6 +22868,17 @@
"@jridgewell/sourcemap-codec": "^1.5.0" "@jridgewell/sourcemap-codec": "^1.5.0"
} }
}, },
"magicast": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
"dev": true,
"requires": {
"@babel/parser": "^7.25.4",
"@babel/types": "^7.25.4",
"source-map-js": "^1.2.0"
}
},
"make-dir": { "make-dir": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@ -24939,6 +25158,17 @@
} }
} }
}, },
"test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
"integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
"dev": true,
"requires": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^7.1.4",
"minimatch": "^3.0.4"
}
},
"text-table": { "text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -25212,12 +25442,6 @@
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
"dev": true "dev": true
}, },
"undici": {
"version": "6.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
"dev": true
},
"undici-types": { "undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",

View file

@ -10,7 +10,7 @@
"prepare": "husky install", "prepare": "husky install",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"typecheck:vue": "vue-tsc --noEmit", "typecheck:vue": "vue-tsc --noEmit",
"codegen:api": "openapi-typescript ../munch-ease-backend/openapi.json -o src/api/types.ts", "codegen:api": "openapi-typescript ../munch-ease-backend/openapi.json -o src/api/types.ts",
@ -18,16 +18,14 @@
"codegen:check": "node scripts/codegen-check.js" "codegen:check": "node scripts/codegen-check.js"
}, },
"dependencies": { "dependencies": {
"core-js": "^3.8.3",
"vue": "^3.5.12", "vue": "^3.5.12",
"vue-router": "^4.4.5" "vue-router": "^4.4.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@types/node": "^20.19.22", "@types/node": "^20.19.22",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.18.0",
"@vue/cli-plugin-babel": "~5.0.0", "@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0", "@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-typescript": "~5.0.0", "@vue/cli-plugin-typescript": "~5.0.0",
@ -37,12 +35,10 @@
"husky": "^8.0.0", "husky": "^8.0.0",
"lint-staged": "^13.3.0", "lint-staged": "^13.3.0",
"msw": "^2.5.2", "msw": "^2.5.2",
"node-fetch": "^2.6.9",
"openapi-fetch": "^0.9.5", "openapi-fetch": "^0.9.5",
"openapi-typescript": "^7.4.2", "openapi-typescript": "^7.4.2",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "~5.5.4", "typescript": "~5.5.4",
"undici": "^6.19.8",
"vitest": "^1.6.0", "vitest": "^1.6.0",
"vue-tsc": "^2.0.29" "vue-tsc": "^2.0.29"
}, },
@ -80,10 +76,19 @@
}, },
"overrides": [ "overrides": [
{ {
"files": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"], "files": [
"excludedFiles": ["src/api/types.ts", "src/**/*.d.ts"], "src/**/*.ts",
"src/**/*.vue",
"src/**/*.d.ts"
],
"excludedFiles": [
"src/api/types.ts",
"src/**/*.d.ts"
],
"parserOptions": { "parserOptions": {
"project": ["./tsconfig.eslint.json"], "project": [
"./tsconfig.eslint.json"
],
"tsconfigRootDir": "." "tsconfigRootDir": "."
}, },
"rules": { "rules": {

View file

@ -1,68 +1,69 @@
import { api } from '@/api/client' import { api } from '@/api/client'
import type { components } from '@/api/types' import type { components } from '@/api/types'
import { toDate, decodeMeal, decodeRecipe, decodeIngredients, decodeShoppingList, decodeShoppingListItems, decodeIngredient } from '@/domain/decoders' import {
toDate,
decodeMeal,
decodeRecipe,
decodeIngredients,
decodeShoppingList,
decodeShoppingListItems,
decodeListIngredientItems,
decodeRequestedMealItems,
decodeIngredient,
decodeLookup,
} from '@/domain/decoders'
import type { import type {
Recipe, Recipe,
Meal, Meal,
Ingredient, Ingredient,
ShoppingList, ShoppingList,
ShoppingListItemWithRefs, ShoppingListItemWithRefs,
ListIngredientItemWithRefs,
RequestedMealItemWithRefs,
CurrentShoppingListDTO, CurrentShoppingListDTO,
PurchasedShoppingListDTO, PurchasedShoppingListDTO,
ShoppingLookups, ShoppingLookups,
} from '@/domain/types' } from '@/domain/types'
import { fromOpenApiPage, type Page } from '@/domain/pagination' import { fromOpenApiPage, type Page } from '@/domain/pagination'
import type { PurchaseRequest } from '@/domain/commands'
function httpError(response: Response, error: unknown): Error { function httpError(response: Response, error: unknown): Error {
if (error instanceof Error) return error if (error instanceof Error) return error
if (typeof error === 'string') return new Error(error) if (typeof error === 'string') return new Error(error)
return new Error(`${response.status} ${response.statusText || 'HTTP error'}`) return new Error(`${response.status} ${response.statusText || 'HTTP error'}`)
} }
// Small helper to decode optional lookup maps without repeating loops everywhere // decodeLookup moved to domain/decoders to be reused across SDK and other modules
function decodeLookup<TIn, TOut>(
raw: Record<string, TIn> | null | undefined,
decode: (v: TIn) => TOut | null
): 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)
if (decoded) out[String(key)] = decoded
}
return out
}
// Shopping list mapped view types now come from domain/types // Shopping list mapped view types now come from domain/types
function attachItemRefs( function attachItemRefs(
items: Array<ShoppingListItemWithRefs> | null | undefined, items: Array<ShoppingListItemWithRefs | ListIngredientItemWithRefs | RequestedMealItemWithRefs> | null | undefined,
lookups: ShoppingLookups lookups: ShoppingLookups
): void { ): void {
if (!Array.isArray(items)) return if (!Array.isArray(items)) return
for (const item of items) { for (const item of items) {
const ingredientId = item.ingredientId ?? undefined // ingredient ref
const mealId = item.mealId ?? undefined if ('ingredientId' in item && item.ingredientId !== undefined && lookups.ingredientsLookup) {
const recipeId = item.recipeId ?? undefined const v = lookups.ingredientsLookup[String(item.ingredientId)]
const listId = item.listId ?? undefined if (v !== undefined) item.ingredient = v
const created = item.createdDate
if (ingredientId !== undefined && lookups.ingredientsLookup && lookups.ingredientsLookup[String(ingredientId)] !== undefined) {
item.ingredient = lookups.ingredientsLookup[String(ingredientId)]
} }
if (mealId !== undefined && lookups.mealsLookup && lookups.mealsLookup[String(mealId)] !== undefined) { // meal ref (present on all item types)
item.meal = lookups.mealsLookup[String(mealId)] if ('mealId' in item && item.mealId !== undefined && lookups.mealsLookup) {
const v = lookups.mealsLookup[String(item.mealId)]
if (v !== undefined) item.meal = v
} }
if (recipeId !== undefined && lookups.recipesLookup && lookups.recipesLookup[String(recipeId)] !== undefined) { // recipe ref
item.recipe = lookups.recipesLookup[String(recipeId)] if ('recipeId' in item && item.recipeId !== undefined && lookups.recipesLookup) {
const v = lookups.recipesLookup[String(item.recipeId)]
if (v !== undefined) item.recipe = v
} }
if (listId !== undefined && lookups.shoppingListLookup && lookups.shoppingListLookup[String(listId)] !== undefined) { // list ref
item.list = lookups.shoppingListLookup[String(listId)] if ('listId' in item && item.listId !== undefined && lookups.shoppingListLookup) {
const v = lookups.shoppingListLookup[String(item.listId)]
if (v !== undefined) item.list = v
} }
if (created !== undefined) item.createdDate = toDate(created) if ('createdDate' in item) item.createdDate = toDate(item.createdDate)
} }
} }
@ -88,7 +89,7 @@ export function mapPurchasedShoppingList(dto: components['schemas']['PurchasedSh
...(ingredientsLookup && { ingredientsLookup }), ...(ingredientsLookup && { ingredientsLookup }),
...(mealsLookup && { mealsLookup }), ...(mealsLookup && { mealsLookup }),
...(recipesLookup && { recipesLookup }), ...(recipesLookup && { recipesLookup }),
shoppingListLookup: { [String(list.id)]: list }, ...(list && { shoppingListLookup: { [String(list.id)]: list } }),
} }
attachItemRefs(list.items, lookups) attachItemRefs(list.items, lookups)
} }
@ -99,6 +100,8 @@ export function mapPurchasedShoppingList(dto: components['schemas']['PurchasedSh
...(mealsLookup && { mealsLookup }), ...(mealsLookup && { mealsLookup }),
...(recipesLookup && { recipesLookup }), ...(recipesLookup && { recipesLookup }),
...(list && { list }), ...(list && { list }),
// include shoppingListLookup when list exists for consistency with lookups type
...(list && { shoppingListLookup: { [String(list.id)]: list } }),
} }
} }
@ -124,9 +127,9 @@ export function mapCurrentShoppingList(dto: components['schemas']['CurrentShoppi
} }
const dtoOut: CurrentShoppingListDTO = { const dtoOut: CurrentShoppingListDTO = {
outstandingItems: decodeShoppingListItems(outstandingRaw ?? []), outstandingItems: decodeListIngredientItems(outstandingRaw ?? []),
requestedMeals: decodeShoppingListItems(requestedRaw ?? []), requestedMeals: decodeRequestedMealItems(requestedRaw ?? []),
purchasedItems: decodeShoppingListItems(purchasedRaw ?? []), purchasedItems: decodeListIngredientItems(purchasedRaw ?? []),
...(ingredientsLookup && { ingredientsLookup }), ...(ingredientsLookup && { ingredientsLookup }),
...(mealsLookup && { mealsLookup }), ...(mealsLookup && { mealsLookup }),
...(recipesLookup && { recipesLookup }), ...(recipesLookup && { recipesLookup }),
@ -155,14 +158,15 @@ export async function listRecipes(params?: { q?: string | null; cursor?: string
} }
const { data, error, response } = await api.GET('/api/v1/recipes', { params: { query } }) const { data, error, response } = await api.GET('/api/v1/recipes', { params: { query } })
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
const mapped = fromOpenApiPage(data ?? null, (r) => decodeRecipe(r)) return fromOpenApiPage(data ?? null, (r) => decodeRecipe(r))
return { ...mapped, items: mapped.items.filter((r): r is Recipe => !!r) }
} }
export async function getRecipe(id: number | string): Promise<Recipe | null> { 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) } } }) 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) if (!response.ok) throw httpError(response, error)
return decodeRecipe(data) 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> { export async function saveRecipe(recipe: components['schemas']['Recipe-Input']): Promise<Recipe | null> {
@ -201,7 +205,7 @@ export async function parseProduct(
} }
// Persons // Persons
export async function listPersons(params?: { q?: string | null; cursor?: string | null; limit?: number }): Promise<Page<components['schemas']['Person']>> { async function listPersons(params?: { q?: string | null; cursor?: string | null; limit?: number }): Promise<Page<components['schemas']['Person']>> {
const query: Record<string, unknown> = {} const query: Record<string, unknown> = {}
if (params) { if (params) {
if (params.q !== undefined) query.q = params.q if (params.q !== undefined) query.q = params.q
@ -235,10 +239,12 @@ export async function getUpcomingMeals(from: Date, to: Date): Promise<Meal[]> {
.sort((a, b) => ((a.suggestedDate?.getTime() ?? 0) - (b.suggestedDate?.getTime() ?? 0))) .sort((a, b) => ((a.suggestedDate?.getTime() ?? 0) - (b.suggestedDate?.getTime() ?? 0)))
} }
export async function getMeal(id: number | string): Promise<Meal | null> { export async function getMeal(id: number | string): Promise<Meal> {
const { data, error, response } = await api.GET('/api/v1/meals/{meal_id}', { params: { path: { meal_id: Number(id) } } }) const { data, error, response } = await api.GET('/api/v1/meals/{meal_id}', { params: { path: { meal_id: Number(id) } } })
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
return decodeMeal(data) 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> { export async function saveMeal(meal: components['schemas']['Meal-Input']): Promise<Meal | null> {
@ -257,12 +263,14 @@ export async function saveMeal(meal: components['schemas']['Meal-Input']): Promi
} }
} }
export async function markMealConsumed(mealId: number | string): Promise<Meal | null> { export async function markMealConsumed(mealId: number | string): Promise<Meal> {
const { data, error, response } = await api.POST('/api/v1/meals/{meal_id}/consumed', { const { data, error, response } = await api.POST('/api/v1/meals/{meal_id}/consumed', {
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } }, params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } },
}) })
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
return decodeMeal(data) const mapped = decodeMeal(data)
if (!mapped) throw new Error('Meal not found')
return mapped
} }
export async function deleteMeal(mealId: number | string): Promise<void> { export async function deleteMeal(mealId: number | string): Promise<void> {
@ -288,45 +296,41 @@ export async function saveMyShoppingList(items: components['schemas']['Ingredien
return decodeIngredients(data ?? []) return decodeIngredients(data ?? [])
} }
export async function getShoppingList(id: number | string) { export async function getShoppingList(id: number | string): Promise<import('@/domain/types').ShoppingListWithRefs | null> {
const { data, error, response } = await api.GET('/api/v1/shopping/{list_id}', { params: { path: { list_id: Number(id) } } }) const { data, error, response } = await api.GET('/api/v1/shopping/{list_id}', { params: { path: { list_id: Number(id) } } })
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
const mapped = mapPurchasedShoppingList(data) const mapped = mapPurchasedShoppingList(data)
return mapped?.list ?? null return mapped?.list ?? null
} }
export async function getCurrentShoppingList() { export async function getCurrentShoppingList(): Promise<CurrentShoppingListDTO> {
const { data, error, response } = await api.GET('/api/v1/shopping/current') const { data, error, response } = await api.GET('/api/v1/shopping/current')
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
return mapCurrentShoppingList(data) const mapped = mapCurrentShoppingList(data)
if (!mapped) throw new Error('Failed to map current shopping list')
return mapped
} }
type PurchaseExisting = { type: 'existing'; id: number; personId: number; ingredientId?: number | null } // PurchaseRequest comes from domain/commands
type PurchaseRefs = { type: 'refs'; personId: number; ingredientId?: number | null; recipeId?: number | null; mealId?: number | null }
export async function purchaseShoppingList( export async function purchaseShoppingList(
completedRequests: Array<PurchaseExisting | PurchaseRefs> completedRequests: PurchaseRequest[]
): Promise<import('@/domain/types').ShoppingListWithRefs | null> { ): Promise<import('@/domain/types').ShoppingListWithRefs | null> {
if (!Array.isArray(completedRequests) || completedRequests.length === 0) return 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 // Map incoming requests: if id provided and >= 0, use it; otherwise send identifiers for ingredient/recipe/meal
const items: components['schemas']['ShoppingListItem'][] = completedRequests.map((i) => const items: components['schemas']['IngredientPurchaseItemIn'][] = completedRequests.map((i) => ({
i.type === 'existing'
? { id: i.id, personId: i.personId, ingredientId: i.ingredientId ?? null }
: {
id: -1,
personId: i.personId, personId: i.personId,
ingredientId: i.ingredientId ?? null, ingredientId: i.ingredientId ?? -1,
recipeId: i.recipeId ?? null, recipeId: i.type === 'refs' ? i.recipeId ?? null : null,
mealId: i.mealId ?? null, mealId: i.type === 'refs' ? i.mealId ?? null : null,
} createdDate: null,
) }))
if (items.length === 0) return null if (items.length === 0) return null
const body: components['schemas']['ShoppingList'] = { const body: components['schemas']['PurchaseListIn'] = {
id: -1, // Default to a valid StoreNameOut per updated OpenAPI ("home" | "coles" | "woolworths")
storeName: '', storeName: 'home',
purchasedById: -1,
items, items,
} }
const { data, error, response } = await api.POST('/api/v1/shopping', { const { data, error, response } = await api.POST('/api/v1/shopping', {
@ -352,3 +356,6 @@ export async function unrequestMeal(mealId: number | string): Promise<void> {
}) })
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
} }
// Re-export domain command types for convenience at SDK surface
export type { PurchaseRequest } from '@/domain/commands'

View file

@ -340,25 +340,25 @@ export interface components {
/** CurrentShoppingList */ /** CurrentShoppingList */
CurrentShoppingList: { CurrentShoppingList: {
/** Outstandingitems */ /** Outstandingitems */
outstandingItems: components["schemas"]["ShoppingListItem"][]; outstandingItems: components["schemas"]["ListIngredientItem"][];
/** Requestedmeals */ /** Requestedmeals */
requestedMeals: components["schemas"]["ShoppingListItem"][]; requestedMeals: components["schemas"]["RequestedMealItem"][];
/** Purchaseditems */ /** Purchaseditems */
purchasedItems?: components["schemas"]["ShoppingListItem"][]; purchasedItems: components["schemas"]["ListIngredientItem"][];
/** Ingredientslookup */ /** Ingredientslookup */
ingredientsLookup?: { ingredientsLookup: {
[key: string]: components["schemas"]["Ingredient"]; [key: string]: components["schemas"]["Ingredient"];
}; };
/** Mealslookup */ /** Mealslookup */
mealsLookup?: { mealsLookup: {
[key: string]: components["schemas"]["Meal-Output"]; [key: string]: components["schemas"]["Meal-Output"];
}; };
/** Shoppinglistlookup */ /** Shoppinglistlookup */
shoppingListLookup?: { shoppingListLookup: {
[key: string]: components["schemas"]["ShoppingList"]; [key: string]: components["schemas"]["ShoppingListOut"];
}; };
/** Recipeslookup */ /** Recipeslookup */
recipesLookup?: { recipesLookup: {
[key: string]: components["schemas"]["Recipe-Output"]; [key: string]: components["schemas"]["Recipe-Output"];
}; };
}; };
@ -404,6 +404,49 @@ export interface components {
mealId?: number | null; mealId?: number | null;
product?: components["schemas"]["Product"] | null; product?: components["schemas"]["Product"] | null;
}; };
/** IngredientPurchaseItemIn */
IngredientPurchaseItemIn: {
/** Ingredientid */
ingredientId: number;
/** Personid */
personId: number;
/** Createddate */
createdDate?: string | null;
/** Mealid */
mealId?: number | null;
/** Recipeid */
recipeId?: number | null;
};
/** ListIngredientItem */
ListIngredientItem: {
/**
* Kind
* @default ingredient
* @constant
* @enum {string}
*/
kind: "ingredient";
/**
* Id
* @default -1
*/
id: number;
/** Ingredientid */
ingredientId: number;
/** Personid */
personId: number;
/**
* Createddate
* Format: date-time
*/
createdDate: string;
/** Listid */
listId?: number | null;
/** Mealid */
mealId?: number | null;
/** Recipeid */
recipeId?: number | null;
};
/** LoginBody */ /** LoginBody */
LoginBody: { LoginBody: {
/** Username */ /** Username */
@ -468,6 +511,33 @@ export interface components {
/** Mealid */ /** Mealid */
mealId: number; mealId: number;
}; };
/** MealOut */
MealOut: {
/**
* Id
* @default -1
*/
id: number;
/**
* Suggesteddate
* Format: date-time
*/
suggestedDate: string;
/** Consumeddate */
consumedDate?: string | null;
/** Chefs */
chefs: components["schemas"]["Person"][];
/** Cleanup */
cleanup: components["schemas"]["Person"][];
/** Consumers */
consumers: components["schemas"]["Person"][];
/** Recipes */
recipes: components["schemas"]["MealRecipe-Output"][];
/** Extraingredients */
extraIngredients: components["schemas"]["Ingredient"][];
/** Purchasedate */
purchaseDate?: string | null;
};
/** MealRecipe */ /** MealRecipe */
"MealRecipe-Input": { "MealRecipe-Input": {
/** Mealid */ /** Mealid */
@ -506,23 +576,25 @@ export interface components {
prevCursor?: string | null; prevCursor?: string | null;
/** /**
* Total * Total
* @description Optional total count * @description Total count
* @default 0
*/ */
total?: number | null; total: number;
}; };
/** Page[Recipe] */ /** Page[RecipeOut] */
Page_Recipe_: { Page_RecipeOut_: {
/** Items */ /** Items */
items: components["schemas"]["Recipe-Output"][]; items: components["schemas"]["RecipeOut"][];
/** Nextcursor */ /** Nextcursor */
nextCursor?: string | null; nextCursor?: string | null;
/** Prevcursor */ /** Prevcursor */
prevCursor?: string | null; prevCursor?: string | null;
/** /**
* Total * Total
* @description Optional total count * @description Total count
* @default 0
*/ */
total?: number | null; total: number;
}; };
/** Person */ /** Person */
Person: { Person: {
@ -575,8 +647,6 @@ export interface components {
imgSmall: string; imgSmall: string;
/** Imglarge */ /** Imglarge */
imgLarge: string; imgLarge: string;
/** Rawdata */
rawData?: Record<string, never> | null;
}; };
/** ProductUrl */ /** ProductUrl */
ProductUrl: { ProductUrl: {
@ -585,19 +655,25 @@ export interface components {
/** Tags */ /** Tags */
tags?: string[]; tags?: string[];
}; };
/** PurchaseListIn */
PurchaseListIn: {
storeName: components["schemas"]["StoreNameOut"];
/** Items */
items: components["schemas"]["IngredientPurchaseItemIn"][];
};
/** PurchasedShoppingList */ /** PurchasedShoppingList */
PurchasedShoppingList: { PurchasedShoppingList: {
list: components["schemas"]["ShoppingList"]; list: components["schemas"]["ShoppingListOut"];
/** Mealslookup */ /** Mealslookup */
mealsLookup?: { mealsLookup: {
[key: string]: components["schemas"]["Meal-Output"]; [key: string]: components["schemas"]["Meal-Output"];
}; };
/** Ingredientslookup */ /** Ingredientslookup */
ingredientsLookup?: { ingredientsLookup: {
[key: string]: components["schemas"]["Ingredient"]; [key: string]: components["schemas"]["Ingredient"];
}; };
/** Recipeslookup */ /** Recipeslookup */
recipesLookup?: { recipesLookup: {
[key: string]: components["schemas"]["Recipe-Output"]; [key: string]: components["schemas"]["Recipe-Output"];
}; };
}; };
@ -626,7 +702,7 @@ export interface components {
*/ */
dateCreated?: string; dateCreated?: string;
/** Createdbyid */ /** Createdbyid */
createdById: number | null; createdById: number;
createdBy?: components["schemas"]["Person"] | null; createdBy?: components["schemas"]["Person"] | null;
/** Datehidden */ /** Datehidden */
dateHidden?: string | null; dateHidden?: string | null;
@ -659,7 +735,7 @@ export interface components {
*/ */
dateCreated?: string; dateCreated?: string;
/** Createdbyid */ /** Createdbyid */
createdById: number | null; createdById: number;
createdBy?: components["schemas"]["Person"] | null; createdBy?: components["schemas"]["Person"] | null;
/** Datehidden */ /** Datehidden */
dateHidden?: string | null; dateHidden?: string | null;
@ -667,54 +743,82 @@ export interface components {
hiddenById?: number | null; hiddenById?: number | null;
hiddenBy?: components["schemas"]["Person"] | null; hiddenBy?: components["schemas"]["Person"] | null;
}; };
/** ShoppingList */ /** RecipeOut */
ShoppingList: { RecipeOut: {
/** /**
* Id * Id
* @default -1 * @default -1
*/ */
id: number; id: number;
/** Name */
name: string;
/** Link */
link: string;
/** Serves */
serves: number;
/** Imageurls */
imageUrls: string[];
/** Ingredients */
ingredients: components["schemas"]["Ingredient"][];
/** Basedonrecipe */
basedOnRecipe?: number | null;
/**
* Datecreated
* Format: date-time
*/
dateCreated: string;
/** Createdbyid */
createdById: number;
createdBy?: components["schemas"]["Person"] | null;
/** Datehidden */
dateHidden?: string | null;
/** Hiddenbyid */
hiddenById?: number | null;
hiddenBy?: components["schemas"]["Person"] | null;
};
/** RequestedMealItem */
RequestedMealItem: {
/**
* Kind
* @default requestedMeal
* @constant
* @enum {string}
*/
kind: "requestedMeal";
/**
* Id
* @default -1
*/
id: number;
/** Personid */
personId: number;
/** Mealid */
mealId: number;
/** /**
* Createddate * Createddate
* Format: date-time * Format: date-time
*/ */
createdDate?: string; createdDate: string;
/** @default */ };
storeName: components["schemas"]["StoreEnum"]; /** ShoppingListOut */
ShoppingListOut: {
/** Id */
id: number;
/** /**
* Purchasedbyid * Createddate
* @default -1 * Format: date-time
*/ */
createdDate: string;
/**
* Storename
* @enum {string}
*/
storeName: "woolworths" | "coles" | "home";
/** Purchasedbyid */
purchasedById: number; purchasedById: number;
purchasedBy?: components["schemas"]["Person"] | null; purchasedBy?: components["schemas"]["Person"] | null;
/** Items */ /** Items */
items?: components["schemas"]["ShoppingListItem"][]; items: components["schemas"]["ListIngredientItem"][];
};
/** ShoppingListItem */
ShoppingListItem: {
/**
* Id
* @default -1
*/
id: number;
/** Listid */
listId?: number | null;
/**
* Personid
* @default -1
*/
personId: number;
/** Ingredientid */
ingredientId?: number | null;
/** Recipeid */
recipeId?: number | null;
/** Mealid */
mealId?: number | null;
/**
* Createddate
* Format: date-time
*/
createdDate?: string;
}; };
/** /**
* StoreEnum * StoreEnum
@ -730,6 +834,11 @@ export interface components {
/** Error Type */ /** Error Type */
type: string; type: string;
}; };
/**
* StoreNameOut
* @enum {string}
*/
StoreNameOut: "woolworths" | "coles" | "home";
}; };
responses: { responses: {
/** @description Bad Request */ /** @description Bad Request */
@ -822,7 +931,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Recipe-Output"]; "application/json": components["schemas"]["RecipeOut"];
}; };
}; };
400: components["responses"]["Problem400"]; 400: components["responses"]["Problem400"];
@ -907,7 +1016,7 @@ export interface operations {
* "total": 1 * "total": 1
* } * }
*/ */
"application/json": components["schemas"]["Page_Recipe_"]; "application/json": components["schemas"]["Page_RecipeOut_"];
}; };
}; };
/** @description Validation Error */ /** @description Validation Error */
@ -942,7 +1051,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Recipe-Output"]; "application/json": components["schemas"]["RecipeOut"];
}; };
}; };
400: components["responses"]["Problem400"]; 400: components["responses"]["Problem400"];
@ -974,7 +1083,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Recipe-Output"]; "application/json": components["schemas"]["RecipeOut"];
}; };
}; };
404: components["responses"]["Problem404"]; 404: components["responses"]["Problem404"];
@ -1072,7 +1181,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Meal-Output"]; "application/json": components["schemas"]["MealOut"];
}; };
}; };
404: components["responses"]["Problem404"]; 404: components["responses"]["Problem404"];
@ -1108,7 +1217,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Meal-Output"]; "application/json": components["schemas"]["MealOut"];
}; };
}; };
400: components["responses"]["Problem400"]; 400: components["responses"]["Problem400"];
@ -1143,7 +1252,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Meal-Output"]; "application/json": components["schemas"]["MealOut"];
}; };
}; };
404: components["responses"]["Problem404"]; 404: components["responses"]["Problem404"];
@ -1177,7 +1286,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Meal-Output"]; "application/json": components["schemas"]["MealOut"];
}; };
}; };
400: components["responses"]["Problem400"]; 400: components["responses"]["Problem400"];
@ -1213,7 +1322,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Meal-Output"]; "application/json": components["schemas"]["MealOut"];
}; };
}; };
400: components["responses"]["Problem400"]; 400: components["responses"]["Problem400"];
@ -1293,7 +1402,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["ShoppingList"]; "application/json": components["schemas"]["PurchaseListIn"];
}; };
}; };
responses: { responses: {
@ -1415,7 +1524,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["ShoppingListItem"]; "application/json": components["schemas"]["RequestedMealItem"];
}; };
}; };
404: components["responses"]["Problem404"]; 404: components["responses"]["Problem404"];

View file

@ -118,8 +118,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, onBeforeMount } from 'vue' import { reactive, onBeforeMount } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { getMeal, saveMeal, toMealInput } from '@/composables/useMeals' import { getMeal, saveMeal, getRecipe } from '@/api/sdk'
import { getRecipe } from '@/api/sdk' import { toMealInput } from '@/domain/decoders'
import { currentUser } from '@/api/auth' import { currentUser } from '@/api/auth'
import { useAlert } from '@/composables/useAlert' import { useAlert } from '@/composables/useAlert'
import { parseRouteId } from '@/router/helpers' import { parseRouteId } from '@/router/helpers'
@ -165,9 +165,7 @@ onBeforeMount(async () => {
const id = parseRouteId(route.params.id) const id = parseRouteId(route.params.id)
if (id !== null) { if (id !== null) {
const loaded = await getMeal(id) const loaded = await getMeal(id)
if (loaded) {
Object.assign(meal, loaded) Object.assign(meal, loaded)
}
} else { } else {
const self = await currentUser() const self = await currentUser()
if (self) { if (self) {
@ -215,7 +213,6 @@ function addPerson(list: PeopleKey, person: Person) {
async function selectRecipe(recipe: { id: number | string }) { async function selectRecipe(recipe: { id: number | string }) {
// Refetch to get additional details // Refetch to get additional details
const r = await getRecipe(recipe.id) const r = await getRecipe(recipe.id)
if (!r) return
if (r.createdBy) { if (r.createdBy) {
addPersonIfNotExists(meal.chefs, r.createdBy) addPersonIfNotExists(meal.chefs, r.createdBy)

View file

@ -34,18 +34,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { ago } from '@/dateformats' import { ago } from '@/dateformats'
import type { Meal } from '@/domain/types'
type Person = { id: number; name: string }
type MealRecipe = { recipe: { name: string } }
type Ingredient = { name: string }
type Meal = {
suggestedDate: Date | null
chefs: Person[]
consumers: Person[]
recipes?: MealRecipe[]
extraIngredients?: Ingredient[]
purchaseDate?: Date | null
}
const props = defineProps<{ meal: Meal }>() const props = defineProps<{ meal: Meal }>()
@ -86,7 +75,10 @@ const dayOfWeek = computed(() =>
const mealTitle = computed(() => { const mealTitle = computed(() => {
const recipes = props.meal.recipes ?? [] const recipes = props.meal.recipes ?? []
const extras = props.meal.extraIngredients ?? [] const extras = props.meal.extraIngredients ?? []
const recipesText = englishList(recipes.map((mr) => mr.recipe.name)) 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)) const ingredientsText = englishList(extras.map((i) => i.name))
if (recipesText && ingredientsText) return `${recipesText} with ${ingredientsText}` if (recipesText && ingredientsText) return `${recipesText} with ${ingredientsText}`

View file

@ -54,7 +54,7 @@
import { ref, onBeforeMount } from 'vue' import { ref, onBeforeMount } from 'vue'
import ActionItem from '@/components/ActionItem.vue' import ActionItem from '@/components/ActionItem.vue'
import MealCard from '@/components/meals/MealCard.vue' import MealCard from '@/components/meals/MealCard.vue'
import { getUpcomingMeals, markMealConsumed, deleteMeal } from '@/composables/useMeals' import { getUpcomingMeals, markMealConsumed, deleteMeal } from '@/api/sdk'
const chevronDown = new URL('@/assets/chevron-down.svg', import.meta.url).toString() 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 chevronUp = new URL('@/assets/chevron-up.svg', import.meta.url).toString()
const planMeal = new URL('@/assets/plan-meal.svg', import.meta.url).toString() const planMeal = new URL('@/assets/plan-meal.svg', import.meta.url).toString()

View file

@ -110,7 +110,7 @@ async function refreshRecipe() {
if (id !== null && id >= 0) { if (id !== null && id >= 0) {
const r = await getRecipe(id) const r = await getRecipe(id)
recipe.value = r recipe.value = r
link.value = r?.link ?? '' link.value = r.link ?? ''
return return
} else if (link.value) { } else if (link.value) {
const r = await parseRecipe(link.value) const r = await parseRecipe(link.value)

View file

@ -104,7 +104,7 @@ import { ref, computed, onBeforeMount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAlert } from '@/composables/useAlert' import { useAlert } from '@/composables/useAlert'
import { useShopping } from '@/composables/useShopping' import { useShopping } from '@/composables/useShopping'
import { useMeals } from '@/composables/useMeals' import { getUpcomingMeals } from '@/api/sdk'
import { type Group } from '@/composables/useShopping' import { type Group } from '@/composables/useShopping'
type UIMeal = import('@/domain/types').Meal type UIMeal = import('@/domain/types').Meal
import MealSelectionList from './MealSelectionList.vue' import MealSelectionList from './MealSelectionList.vue'
@ -117,7 +117,6 @@ const closeIcon = new URL('@/assets/close.svg', import.meta.url).toString()
const router = useRouter() const router = useRouter()
const { show: showAlert } = useAlert() const { show: showAlert } = useAlert()
const { getCurrentShoppingList, requestMeal, unrequestMeal, purchaseFromGroups, groupsFrom, mealsFrom } = useShopping() const { getCurrentShoppingList, requestMeal, unrequestMeal, purchaseFromGroups, groupsFrom, mealsFrom } = useShopping()
const { getUpcomingMeals } = useMeals()
const from = new Date() const from = new Date()
from.setTime(0) from.setTime(0)

View file

@ -51,7 +51,6 @@ const router = useRouter()
const { loadUser } = useAuth() const { loadUser } = useAuth()
const { getMyShoppingList, saveMyShoppingList } = useShopping() const { getMyShoppingList, saveMyShoppingList } = useShopping()
const person = ref<{ id?: number; name?: string } | null>(null)
const ingredients = ref<Ingredient[]>([]) const ingredients = ref<Ingredient[]>([])
async function updateShoppingList(save = false) { async function updateShoppingList(save = false) {
@ -82,7 +81,6 @@ async function onEditing(isStartingEdit: boolean) {
onBeforeMount(async () => { onBeforeMount(async () => {
const u = await loadUser() const u = await loadUser()
if (!u) return router.push({ name: 'login' }) if (!u) return router.push({ name: 'login' })
person.value = u
await updateShoppingList() await updateShoppingList()
}) })
</script> </script>

View file

@ -37,11 +37,11 @@
:key="source.id" :key="source.id"
> >
<span v-if="index">, and </span> <span v-if="index">, and </span>
<span v-if="source.person"> <!-- Generic ingredient-only display when no recipe/meal context; person ref removed -->
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} for <span v-if="!source.recipe && !source.meal && source.ingredient">
{{ source.person.name }} {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }}
</span> </span>
<span v-else-if="source.recipe"> <span v-else-if="source.recipe && source.ingredient">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in
<router-link :to="`/recipes/${source.recipe.id}/`">{{ <router-link :to="`/recipes/${source.recipe.id}/`">{{
source.recipe.name source.recipe.name
@ -57,7 +57,7 @@
: '' : ''
}}</router-link> }}</router-link>
</span> </span>
<span v-else-if="source.meal"> <span v-else-if="source.meal && source.ingredient">
{{ source.ingredient.line }} for {{ source.ingredient.line }} for
<router-link :to="`/meals/${source.meal?.id}/`">{{ <router-link :to="`/meals/${source.meal?.id}/`">{{
source.meal?.suggestedDate source.meal?.suggestedDate
@ -78,11 +78,11 @@
:key="source.id" :key="source.id"
> >
<span v-if="index">, and </span> <span v-if="index">, and </span>
<span v-if="source.person"> <!-- Generic ingredient-only display when no recipe/meal context; person ref removed -->
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} for <span v-if="!source.recipe && !source.meal && source.ingredient">
{{ source.person.name }} {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }}
</span> </span>
<span v-else-if="source.recipe"> <span v-else-if="source.recipe && source.ingredient">
{{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in {{ formatQuantity(source.ingredient.quantity) }}&nbsp;{{ source.ingredient.unit }} in
<router-link :to="`/recipes/${source.recipe.id}/`">{{ <router-link :to="`/recipes/${source.recipe.id}/`">{{
source.recipe.name source.recipe.name
@ -98,7 +98,7 @@
: '' : ''
}}</router-link> }}</router-link>
</span> </span>
<span v-else-if="source.meal"> <span v-else-if="source.meal && source.ingredient">
{{ source.ingredient.line }} for {{ source.ingredient.line }} for
<router-link :to="`/meals/${source.meal.id}/`">{{ <router-link :to="`/meals/${source.meal.id}/`">{{
source.meal.suggestedDate source.meal.suggestedDate
@ -134,9 +134,12 @@ const imageSrc = computed(() => {
const remainingRequiredTotals = computed(() => const remainingRequiredTotals = computed(() =>
calculateTotals( calculateTotals(
props.shoppingListItemGroup.shoppingListItems.map((item) => ({ props.shoppingListItemGroup.shoppingListItems
quantity: item.ingredient.quantity, .filter((item) => !!item.ingredient)
unit: String(item.ingredient.unit || 'items'), .map((item) => ({
// item.ingredient is defined due to filter above
quantity: item.ingredient!.quantity,
unit: String(item.ingredient!.unit || 'items'),
})) }))
) )
) )

View file

@ -1,46 +0,0 @@
import * as sdk from '@/api/sdk'
import type { MealInput, Meal } from '@/domain/types'
export async function getUpcomingMeals(from: Date, to: Date) {
return sdk.getUpcomingMeals(from, to)
}
export async function getMeal(id: number | string) {
return sdk.getMeal(id)
}
export async function saveMeal(meal: MealInput) {
return sdk.saveMeal(meal)
}
export async function markMealConsumed(mealId: number | string) {
return sdk.markMealConsumed(mealId)
}
export async function deleteMeal(mealId: number | string) {
return sdk.deleteMeal(mealId)
}
// 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,
}
}
export function useMeals() {
return { getUpcomingMeals, getMeal, saveMeal, markMealConsumed, deleteMeal, toMealInput }
}

View file

@ -1,27 +1,15 @@
import * as sdk from '@/api/sdk' import * as sdk from '@/api/sdk'
import type { ShoppingListItemWithRefs, Product, Meal, Ingredient, Person, Recipe } from '@/domain/types' import type { ListIngredientItemWithRefs, RequestedMealItemWithRefs, Product, Meal } from '@/domain/types'
export type UIShoppingListItem = { export type GroupByProduct = { type: 'product'; product: Product; shoppingListItems: ListIngredientItemWithRefs[] }
id: number export type GroupByName = { type: 'name'; name: string; shoppingListItems: ListIngredientItemWithRefs[] }
ingredient: Ingredient
person?: Person | null
personId: number
recipe?: Recipe | null
meal?: Meal | null
ingredientId?: number | null
listId?: number | null
createdDate?: Date | null
}
export type GroupByProduct = { type: 'product'; product: Product; shoppingListItems: UIShoppingListItem[] }
export type GroupByName = { type: 'name'; name: string; shoppingListItems: UIShoppingListItem[] }
export type Group = GroupByProduct | GroupByName export type Group = GroupByProduct | GroupByName
export function groupsToItems(groups: Group[]): UIShoppingListItem[] { export function groupsToItems(groups: Group[]): ListIngredientItemWithRefs[] {
return groups.map((g) => g.shoppingListItems).flat() return groups.map((g) => g.shoppingListItems).flat()
} }
export function uniqueMeals(shoppingListItems: UIShoppingListItem[]): Meal[] { export function uniqueMeals(shoppingListItems: Array<ListIngredientItemWithRefs | RequestedMealItemWithRefs>): Meal[] {
const mealsWithDuplicates = shoppingListItems.map((item) => item.meal).filter((m): m is Meal => !!m) 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>>( const mealsLookup: Record<string | number, Meal> = mealsWithDuplicates.reduce<Record<string | number, Meal>>(
(acc, meal) => { (acc, meal) => {
@ -33,11 +21,11 @@ export function uniqueMeals(shoppingListItems: UIShoppingListItem[]): Meal[] {
return Object.values(mealsLookup) return Object.values(mealsLookup)
} }
export function itemsToGroups(shoppingListItems: UIShoppingListItem[]): Group[] { export function itemsToGroups(shoppingListItems: ListIngredientItemWithRefs[]): Group[] {
const ingredients_by_product_id: Record<string | number, GroupByProduct> = {} const ingredients_by_product_id: Record<string | number, GroupByProduct> = {}
const ingredients_by_name: Record<string, GroupByName> = {} const ingredients_by_name: Record<string, GroupByName> = {}
for (const item of shoppingListItems) { for (const item of shoppingListItems) {
if (item.ingredient.product) { if (item.ingredient?.product) {
let group = ingredients_by_product_id[item.ingredient.product.id] let group = ingredients_by_product_id[item.ingredient.product.id]
if (!group) { if (!group) {
group = ingredients_by_product_id[item.ingredient.product.id] = { group = ingredients_by_product_id[item.ingredient.product.id] = {
@ -48,11 +36,12 @@ export function itemsToGroups(shoppingListItems: UIShoppingListItem[]): Group[]
} }
group.shoppingListItems.push(item) group.shoppingListItems.push(item)
} else { } else {
let group = ingredients_by_name[item.ingredient.name] const name = item.ingredient?.name ?? ''
let group = ingredients_by_name[name]
if (!group) { if (!group) {
group = ingredients_by_name[item.ingredient.name] = { group = ingredients_by_name[name] = {
type: 'name', type: 'name',
name: item.ingredient.name, name,
shoppingListItems: [], shoppingListItems: [],
} }
} }
@ -63,34 +52,8 @@ export function itemsToGroups(shoppingListItems: UIShoppingListItem[]): Group[]
} }
export function useShopping() { export function useShopping() {
// Request shapes expected by sdk.purchaseShoppingList const groupsFrom = (items?: ListIngredientItemWithRefs[]): Group[] => itemsToGroups(items ?? [])
type PurchaseExisting = { type: 'existing'; id: number; personId: number; ingredientId?: number | null } const mealsFrom = (items?: Array<ListIngredientItemWithRefs | RequestedMealItemWithRefs>): Meal[] => uniqueMeals(items ?? [])
type PurchaseRefs = { type: 'refs'; personId: number; ingredientId?: number | null; recipeId?: number | null; mealId?: number | null }
const toExisting = (id: number, personId: number, ingredientId?: number | null): PurchaseExisting => ({ type: 'existing', id, personId, ingredientId: ingredientId ?? null })
const toRefs = (personId: number, ingredientId?: number | null, recipeId?: number | null, mealId?: number | null): PurchaseRefs => ({
type: 'refs',
personId,
ingredientId: ingredientId ?? null,
recipeId: recipeId ?? null,
mealId: mealId ?? null,
})
const mapItemToUI = (i: ShoppingListItemWithRefs): UIShoppingListItem => ({
id: Number(i.id),
ingredient: i.ingredient!,
person: null,
personId: i.personId,
recipe: i.recipe ?? null,
meal: i.meal ?? null,
ingredientId: i.ingredient?.id ?? null,
listId: i.listId ?? null,
createdDate: i.createdDate,
})
const groupsFrom = (items?: ShoppingListItemWithRefs[]): Group[] => {
return itemsToGroups((items ?? []).map(mapItemToUI))
}
const mealsFrom = (items?: ShoppingListItemWithRefs[]): Meal[] => {
return uniqueMeals((items ?? []).map(mapItemToUI))
}
return { return {
getCurrentShoppingList: sdk.getCurrentShoppingList, getCurrentShoppingList: sdk.getCurrentShoppingList,
getShoppingList: sdk.getShoppingList, getShoppingList: sdk.getShoppingList,
@ -100,15 +63,21 @@ export function useShopping() {
getMyShoppingList: sdk.getMyShoppingList, getMyShoppingList: sdk.getMyShoppingList,
saveMyShoppingList: sdk.saveMyShoppingList, saveMyShoppingList: sdk.saveMyShoppingList,
// View-model helpers // View-model helpers
mapItemToUI,
groupsFrom, groupsFrom,
mealsFrom, mealsFrom,
async purchaseFromGroups(groups: Group[]) { async purchaseFromGroups(groups: Group[]) {
const items = groupsToItems(groups).map((i) => const items = groupsToItems(groups).map((i): sdk.PurchaseRequest => {
typeof i.id === 'number' && i.id >= 0 if (typeof i.id === 'number' && i.id >= 0) {
? toExisting(i.id, i.personId, i.ingredientId) return { type: 'existing', id: i.id, personId: i.personId, ingredientId: i.ingredientId ?? null }
: toRefs(i.personId, i.ingredient?.id ?? null, i.recipe?.id ?? null, i.meal?.id ?? null) }
) return {
type: 'refs',
personId: i.personId,
ingredientId: i.ingredientId ?? null,
recipeId: i.recipeId ?? null,
mealId: i.mealId ?? null,
}
})
if (!items?.length) return null if (!items?.length) return null
return sdk.purchaseShoppingList(items) return sdk.purchaseShoppingList(items)
}, },

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

View file

@ -1,4 +1,4 @@
import type { RecipeOut, Recipe, MealOut, Meal, MealRecipe, Ingredient as DomainIngredient, ShoppingList, ShoppingListItem } from './types' import type { Recipe, Meal, MealRecipe, Ingredient as DomainIngredient, ShoppingList, ShoppingListItem, ShoppingListItemWithRefs, MealInput, ListIngredientItem, RequestedMealItem, ListIngredientItemWithRefs, RequestedMealItemWithRefs } from './types'
import type { components } from '@/api/types' import type { components } from '@/api/types'
export function toDate(value: string | Date | null | undefined): Date | null { export function toDate(value: string | Date | null | undefined): Date | null {
@ -6,24 +6,45 @@ export function toDate(value: string | Date | null | undefined): Date | null {
return typeof value === 'string' ? new Date(value) : value return typeof value === 'string' ? new Date(value) : value
} }
export function decodeRecipe(r: RecipeOut | null | undefined): Recipe | null { // Small helper to decode optional lookup maps without repeating loops everywhere
if (!r) return null 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 { return {
...r, ...r,
imageUrls,
ingredients,
dateCreated: toDate(r.dateCreated), dateCreated: toDate(r.dateCreated),
dateHidden: toDate(r.dateHidden), dateHidden: toDate(r.dateHidden),
} }
} }
export function decodeRecipes(list: RecipeOut[] | null | undefined): Recipe[] { export function decodeMeal(
if (!Array.isArray(list)) return [] m: components['schemas']['MealOut'] | components['schemas']['Meal-Output'] | null | undefined
return list.map((r) => decodeRecipe(r)).filter((r): r is Recipe => !!r) ): Meal {
} if (!m) throw new Error('Invalid meal payload')
export function decodeMeal(m: MealOut | null | undefined): Meal | null {
if (!m) return null
const recipes = Array.isArray(m.recipes) const recipes = Array.isArray(m.recipes)
? m.recipes.map(mr => decodeMealRecipe(mr)).filter((r): r is MealRecipe => !!r) ? m.recipes.map((mr) => decodeMealRecipe(mr))
: [] : []
return { return {
@ -39,40 +60,41 @@ export function decodeMeal(m: MealOut | null | undefined): Meal | null {
} }
} }
export function decodeMealRecipe(mr: components['schemas']['MealRecipe-Output'] | null | undefined): MealRecipe | null { function decodeMealRecipe(mr: components['schemas']['MealRecipe-Output'] | null | undefined): MealRecipe {
if (!mr) return null if (!mr) throw new Error('Invalid meal recipe payload')
return { return {
...mr, ...mr,
recipe: mr.recipe ? decodeRecipe(mr.recipe) : null, recipe: mr.recipe ? decodeRecipe(mr.recipe) : null,
} }
} }
export function decodeIngredient(i: components['schemas']['Ingredient'] | null | undefined): DomainIngredient | null { export function decodeIngredient(i: components['schemas']['Ingredient'] | null | undefined): DomainIngredient {
if (!i) return null if (!i) throw new Error('Invalid ingredient payload')
// API guarantees quantity is a number; pass through // API guarantees quantity is a number; pass through
return { ...i } return { ...i }
} }
export function decodeIngredients(list: components['schemas']['Ingredient'][] | null | undefined): DomainIngredient[] { export function decodeIngredients(list: components['schemas']['Ingredient'][] | null | undefined): DomainIngredient[] {
if (!Array.isArray(list)) return [] if (!Array.isArray(list)) return []
return list.map((i) => decodeIngredient(i)).filter((x): x is DomainIngredient => !!x) return list.map((i) => decodeIngredient(i))
} }
export function decodeShoppingListItem(i: components['schemas']['ShoppingListItem'] | null | undefined): ShoppingListItem | null { function decodeShoppingListItem(i: components['schemas']['ListIngredientItem'] | null | undefined): ShoppingListItem {
if (!i) return null if (!i) throw new Error('Invalid shopping list item payload')
return { return {
...i, ...i,
createdDate: toDate(i.createdDate), createdDate: toDate(i.createdDate),
} }
} }
export function decodeShoppingListItems(list: components['schemas']['ShoppingListItem'][] | null | undefined): ShoppingListItem[] { export function decodeShoppingListItems(list: components['schemas']['ListIngredientItem'][] | null | undefined): ShoppingListItemWithRefs[] {
if (!Array.isArray(list)) return [] if (!Array.isArray(list)) return []
return list.map((i) => decodeShoppingListItem(i)).filter((x): x is ShoppingListItem => !!x) // 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']['ShoppingList'] | null | undefined): ShoppingList | null { export function decodeShoppingList(v: components['schemas']['ShoppingListOut'] | null | undefined): ShoppingList {
if (!v) return null if (!v) throw new Error('Invalid shopping list payload')
const { items: rawItems, ...rest } = v const { items: rawItems, ...rest } = v
const items = Array.isArray(rawItems) ? decodeShoppingListItems(rawItems) : undefined const items = Array.isArray(rawItems) ? decodeShoppingListItems(rawItems) : undefined
return { return {
@ -81,3 +103,50 @@ export function decodeShoppingList(v: components['schemas']['ShoppingList'] | nu
...(items ? { items } : {}), ...(items ? { items } : {}),
} }
} }
// New item decoders for tightened CurrentShoppingList
function decodeListIngredientItem(i: components['schemas']['ListIngredientItem'] | null | undefined): ListIngredientItem {
if (!i) throw new Error('Invalid list ingredient item payload')
return {
...i,
createdDate: toDate(i.createdDate),
}
}
export function decodeListIngredientItems(list: components['schemas']['ListIngredientItem'][] | null | undefined): ListIngredientItemWithRefs[] {
if (!Array.isArray(list)) return []
return list.map((raw) => ({ ...decodeListIngredientItem(raw) }))
}
function decodeRequestedMealItem(i: components['schemas']['RequestedMealItem'] | null | undefined): RequestedMealItem {
if (!i) throw new Error('Invalid requested meal item payload')
return {
...i,
createdDate: toDate(i.createdDate),
}
}
export function decodeRequestedMealItems(list: components['schemas']['RequestedMealItem'][] | null | undefined): RequestedMealItemWithRefs[] {
if (!Array.isArray(list)) return []
return list.map((raw) => ({ ...decodeRequestedMealItem(raw) }))
}
// Helper to convert domain Meal to MealInput, keeping Date→string conversion in boundary
export function toMealInput(meal: Meal): MealInput {
return {
id: meal.id,
suggestedDate: meal.suggestedDate ? meal.suggestedDate.toISOString() : new Date().toISOString(),
consumedDate: meal.consumedDate ? meal.consumedDate.toISOString() : null,
purchaseDate: meal.purchaseDate ? meal.purchaseDate.toISOString() : null,
chefs: meal.chefs,
cleanup: meal.cleanup,
consumers: meal.consumers,
recipes: meal.recipes.map((r) => ({
mealId: r.mealId,
recipeId: r.recipeId,
servings: r.servings,
recipe: null,
})),
extraIngredients: meal.extraIngredients,
}
}

View file

@ -3,16 +3,19 @@ import type { components } from '@/api/types'
// Utility mapped types // Utility mapped types
export type Replace<T, M> = Omit<T, keyof M> & M 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 }> 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 // Common helpers (intentionally minimal to avoid unused exports)
export type Maybe<T> = T | null | undefined
export type NonNull<T> = Exclude<T, null | undefined>
export type Lookup<T> = Record<string, T> 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 } export type WithRefs<T, Refs extends object> = T & { [K in keyof Refs]?: Refs[K] | undefined }
// Domain type aliases // Domain type aliases
export type RecipeOut = components['schemas']['Recipe-Output'] export type RecipeOut = components['schemas']['RecipeOut']
export type MealOut = components['schemas']['Meal-Output'] export type MealOut = components['schemas']['MealOut']
export type Ingredient = components['schemas']['Ingredient'] export type Ingredient = components['schemas']['Ingredient']
export type Product = components['schemas']['Product'] export type Product = components['schemas']['Product']
export type Person = components['schemas']['Person'] export type Person = components['schemas']['Person']
@ -28,28 +31,30 @@ export type MealRecipe = Replace<MealRecipeOut, { recipe: Recipe | null }>
// Meal with decoded dates and nested MealRecipe with decoded recipe dates // Meal with decoded dates and nested MealRecipe with decoded recipe dates
// Arrays (chefs, consumers, cleanup, recipes, extraIngredients) are non-nullable per OpenAPI spec // Arrays (chefs, consumers, cleanup, recipes, extraIngredients) are non-nullable per OpenAPI spec
export type Meal = Replace< type MealBase = Replace<WithDates<MealOut, 'suggestedDate' | 'consumedDate' | 'purchaseDate'>, { recipes: MealRecipe[] }>
WithDates<MealOut, 'suggestedDate' | 'consumedDate' | 'purchaseDate'>, export type Meal = RequiredKeys<
{ NonNullableArrays<MealBase, 'chefs' | 'consumers' | 'cleanup' | 'extraIngredients'>,
recipes: MealRecipe[] 'recipes' | 'chefs' | 'consumers' | 'cleanup' | 'extraIngredients'
chefs: Person[]
consumers: Person[]
cleanup: Person[]
extraIngredients: Ingredient[]
}
> >
// Shopping domain shapes with dates normalized // Shopping domain shapes with dates normalized
export type ShoppingListItem = WithDates<components['schemas']['ShoppingListItem'], 'createdDate'> // Items inside purchased lists and current lists share the ListIngredientItem shape
type ShoppingListBase = WithDates<components['schemas']['ShoppingList'], 'createdDate'> export type ShoppingListItem = WithDates<components['schemas']['ListIngredientItem'], 'createdDate'>
type ShoppingListBase = WithDates<components['schemas']['ShoppingListOut'], 'createdDate'>
export type ShoppingList = Replace<ShoppingListBase, { items?: ShoppingListItem[] | undefined }> export type ShoppingList = Replace<ShoppingListBase, { items?: ShoppingListItem[] | undefined }>
// Refs attached to shopping list items // 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 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[] }> export type ShoppingListWithRefs = Replace<ShoppingList, { items?: ShoppingListItemWithRefs[] }>
// Lookup maps used by shopping mappings // Lookup maps used by shopping mappings (all optional; mappers may omit absent ones)
export type ShoppingLookups = { export type ShoppingLookups = {
ingredientsLookup?: Lookup<Ingredient> ingredientsLookup?: Lookup<Ingredient>
mealsLookup?: Lookup<Meal> mealsLookup?: Lookup<Meal>
@ -59,9 +64,9 @@ export type ShoppingLookups = {
// DTO shapes returned by SDK for shopping pages // DTO shapes returned by SDK for shopping pages
export type CurrentShoppingListDTO = { export type CurrentShoppingListDTO = {
outstandingItems: ShoppingListItemWithRefs[] outstandingItems: ListIngredientItemWithRefs[]
requestedMeals: ShoppingListItemWithRefs[] requestedMeals: RequestedMealItemWithRefs[]
purchasedItems: ShoppingListItemWithRefs[] purchasedItems: ListIngredientItemWithRefs[]
} & ShoppingLookups } & ShoppingLookups
export type PurchasedShoppingListDTO = ShoppingLookups & { export type PurchasedShoppingListDTO = ShoppingLookups & {

17
src/env.d.ts vendored
View file

@ -1,17 +1,10 @@
/* Ambient env typing for optional Vite-style env access */ /* Minimal ambient typing for optional import.meta.env usage in tooling */
declare interface ImportMeta {
env?: {
VITE_API_BASE_URL?: string
}
}
export {}
/// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string readonly VITE_API_BASE_URL?: string
} }
interface ImportMeta { declare interface ImportMeta {
readonly env: ImportMetaEnv readonly env?: ImportMetaEnv
} }
export {}

View file

@ -1,7 +1,7 @@
const UNIT_KEYS_ARRAY: readonly ['kg', 'litres', 'items'] = ['kg', 'litres', 'items'] const UNIT_KEYS_ARRAY: readonly ['kg', 'litres', 'items'] = ['kg', 'litres', 'items']
export type UnitKey = typeof UNIT_KEYS_ARRAY[number] type UnitKey = typeof UNIT_KEYS_ARRAY[number]
export const equivalentUnits: Record<UnitKey, Record<string, number>> = { const equivalentUnits: Record<UnitKey, Record<string, number>> = {
kg: { kg: {
kgs: 1, kgs: 1,
kilograms: 1, kilograms: 1,
@ -124,8 +124,8 @@ export function getConversionFactor(unit: string): { unit: UnitKey | string; fac
return null return null
} }
export type Quantity = { quantity: number; unit: string } type Quantity = { quantity: number; unit: string }
export type Total = { unit: string; quantity: number } type Total = { unit: string; quantity: number }
export function calculateTotals(quantityList: Quantity[]): Total[] { export function calculateTotals(quantityList: Quantity[]): Total[] {
const totals: Record<string, number> = {} const totals: Record<string, number> = {}

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

@ -2,8 +2,8 @@ import { afterAll, afterEach, beforeAll } from 'vitest'
import { setupServer } from 'msw/node' import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw' import { http, HttpResponse } from 'msw'
// Ensure global fetch is available in Node tests // Ensure global fetch is available in Node tests (Node 18+ provides global fetch)
// Use Node 18+ global fetch (undici). Do not override so MSW can intercept. // Do not override so MSW can intercept.
// Some CI envs inject corporate roots causing TLS issues; disable cert checks in tests only // Some CI envs inject corporate roots causing TLS issues; disable cert checks in tests only
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'

View file

@ -1,7 +0,0 @@
// Legacy JS test shim: the authoritative tests live in tests/useAlert.test.ts
// Keep a trivial passing test here so Vitest doesn't fail this file collection.
import { it, expect } from 'vitest'
it('noop shim (see useAlert.test.ts)', () => {
expect(true).toBe(true)
})

View file

@ -10,7 +10,7 @@
"allowJs": false, "allowJs": false,
"checkJs": false, "checkJs": false,
"skipLibCheck": true, "skipLibCheck": true,
"types": ["node", "vite/client"], "types": ["node"],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"]

View file

@ -9,7 +9,7 @@ export default defineConfig({
}, },
test: { test: {
environment: 'node', environment: 'node',
include: ['tests/**/*.{test,spec}.js'], include: ['tests/**/*.{test,spec}.{js,ts}'],
globals: true, globals: true,
reporters: 'default', reporters: 'default',
setupFiles: ['tests/test-setup.js'], setupFiles: ['tests/test-setup.js'],