pruning #3

Merged
jacob merged 11 commits from pruning into master 2025-10-25 02:18:30 +00:00
21 changed files with 511 additions and 353 deletions

404
package-lock.json generated
View file

@ -8,16 +8,14 @@
"name": "doof",
"version": "0.1.0",
"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",
"@vitest/coverage-v8": "^1.6.0",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-typescript": "~5.0.0",
@ -27,12 +25,10 @@
"husky": "^8.0.0",
"lint-staged": "^13.3.0",
"msw": "^2.5.2",
"node-fetch": "^2.6.9",
"openapi-fetch": "^0.9.5",
"openapi-typescript": "^7.4.2",
"prettier": "^3.3.3",
"typescript": "~5.5.4",
"undici": "^6.19.8",
"vitest": "^1.6.0",
"vue-tsc": "^2.0.29"
}
@ -127,24 +123,6 @@
"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": {
"version": "7.23.6",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz",
@ -1821,6 +1799,13 @@
"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": {
"version": "0.5.7",
"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": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
@ -2593,10 +2588,11 @@
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.20",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
"integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
@ -2626,15 +2622,6 @@
"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": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@node-ipc/js-queue/-/js-queue-2.0.3.tgz",
@ -3723,6 +3710,34 @@
"dev": true,
"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": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
@ -6249,6 +6264,7 @@
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.35.0.tgz",
"integrity": "sha512-ntakECeqg81KqMueeGJ79Q5ZgQNR+6eaE8sxGCx62zMbAIj65q+uYvatToew3m6eAGdU4gNZwpZ34NMe4GYswg==",
"dev": true,
"hasInstallScript": true,
"funding": {
"type": "opencollective",
@ -7350,15 +7366,6 @@
"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": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.2.0.tgz",
@ -9294,6 +9301,112 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz",
@ -10366,6 +10479,18 @@
"@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": {
"version": "3.1.0",
"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==",
"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": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -13912,16 +14052,6 @@
"dev": true,
"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": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@ -15515,17 +15645,6 @@
"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": {
"version": "7.23.6",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz",
@ -16667,6 +16786,12 @@
"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": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
@ -17023,6 +17148,12 @@
"dev": true,
"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": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
@ -17071,9 +17202,9 @@
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
},
"@jridgewell/trace-mapping": {
"version": "0.3.20",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
"integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"requires": {
"@jridgewell/resolve-uri": "^3.1.0",
@ -17100,15 +17231,6 @@
"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": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@node-ipc/js-queue/-/js-queue-2.0.3.tgz",
@ -17868,6 +17990,27 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"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": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
@ -19755,7 +19898,8 @@
"core-js": {
"version": "3.35.0",
"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": {
"version": "3.35.0",
@ -20733,12 +20877,6 @@
"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": {
"version": "3.2.0",
"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==",
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz",
@ -22660,6 +22868,17 @@
"@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": {
"version": "3.1.0",
"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": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -25212,12 +25442,6 @@
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
"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": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",

View file

@ -10,39 +10,35 @@
"prepare": "husky install",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc -p tsconfig.json --noEmit",
"typecheck:vue": "vue-tsc --noEmit",
"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",
"@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": "^8.57.0",
"eslint-plugin-vue": "^9.27.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.27.0",
"husky": "^8.0.0",
"lint-staged": "^13.3.0",
"msw": "^2.5.2",
"node-fetch": "^2.6.9",
"openapi-fetch": "^0.9.5",
"openapi-typescript": "^7.4.2",
"prettier": "^3.3.3",
"typescript": "~5.5.4",
"undici": "^6.19.8",
"typescript": "~5.5.4",
"vitest": "^1.6.0",
"vue-tsc": "^2.0.29"
},
@ -80,10 +76,19 @@
},
"overrides": [
{
"files": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"],
"excludedFiles": ["src/api/types.ts", "src/**/*.d.ts"],
"files": [
"src/**/*.ts",
"src/**/*.vue",
"src/**/*.d.ts"
],
"excludedFiles": [
"src/api/types.ts",
"src/**/*.d.ts"
],
"parserOptions": {
"project": ["./tsconfig.eslint.json"],
"project": [
"./tsconfig.eslint.json"
],
"tsconfigRootDir": "."
},
"rules": {

View file

@ -1,6 +1,6 @@
import { api } from '@/api/client'
import type { components } from '@/api/types'
import { toDate, decodeMeal, decodeRecipe, decodeIngredients, decodeShoppingList, decodeShoppingListItems, decodeIngredient } from '@/domain/decoders'
import { toDate, decodeMeal, decodeRecipe, decodeIngredients, decodeShoppingList, decodeShoppingListItems, decodeIngredient, decodeLookup } from '@/domain/decoders'
import type {
Recipe,
Meal,
@ -19,22 +19,7 @@ function httpError(response: Response, error: unknown): Error {
return new Error(`${response.status} ${response.statusText || 'HTTP error'}`)
}
// Small helper to decode optional lookup maps without repeating loops everywhere
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
}
// decodeLookup moved to domain/decoders to be reused across SDK and other modules
// Shopping list mapped view types now come from domain/types
@ -50,17 +35,21 @@ function attachItemRefs(
const listId = item.listId ?? undefined
const created = item.createdDate
if (ingredientId !== undefined && lookups.ingredientsLookup && lookups.ingredientsLookup[String(ingredientId)] !== undefined) {
item.ingredient = lookups.ingredientsLookup[String(ingredientId)]
if (ingredientId !== undefined && lookups.ingredientsLookup) {
const v = lookups.ingredientsLookup[String(ingredientId)]
if (v !== undefined) item.ingredient = v
}
if (mealId !== undefined && lookups.mealsLookup && lookups.mealsLookup[String(mealId)] !== undefined) {
item.meal = lookups.mealsLookup[String(mealId)]
if (mealId !== undefined && lookups.mealsLookup) {
const v = lookups.mealsLookup[String(mealId)]
if (v !== undefined) item.meal = v
}
if (recipeId !== undefined && lookups.recipesLookup && lookups.recipesLookup[String(recipeId)] !== undefined) {
item.recipe = lookups.recipesLookup[String(recipeId)]
if (recipeId !== undefined && lookups.recipesLookup) {
const v = lookups.recipesLookup[String(recipeId)]
if (v !== undefined) item.recipe = v
}
if (listId !== undefined && lookups.shoppingListLookup && lookups.shoppingListLookup[String(listId)] !== undefined) {
item.list = lookups.shoppingListLookup[String(listId)]
if (listId !== undefined && lookups.shoppingListLookup) {
const v = lookups.shoppingListLookup[String(listId)]
if (v !== undefined) item.list = v
}
if (created !== undefined) item.createdDate = toDate(created)
}
@ -88,7 +77,7 @@ export function mapPurchasedShoppingList(dto: components['schemas']['PurchasedSh
...(ingredientsLookup && { ingredientsLookup }),
...(mealsLookup && { mealsLookup }),
...(recipesLookup && { recipesLookup }),
shoppingListLookup: { [String(list.id)]: list },
...(list && { shoppingListLookup: { [String(list.id)]: list } }),
}
attachItemRefs(list.items, lookups)
}
@ -99,6 +88,8 @@ export function mapPurchasedShoppingList(dto: components['schemas']['PurchasedSh
...(mealsLookup && { mealsLookup }),
...(recipesLookup && { recipesLookup }),
...(list && { list }),
// include shoppingListLookup when list exists for consistency with lookups type
...(list && { shoppingListLookup: { [String(list.id)]: list } }),
}
}
@ -155,14 +146,15 @@ export async function listRecipes(params?: { q?: string | null; cursor?: string
}
const { data, error, response } = await api.GET('/api/v1/recipes', { params: { query } })
if (!response.ok) throw httpError(response, error)
const mapped = fromOpenApiPage(data ?? null, (r) => decodeRecipe(r))
return { ...mapped, items: mapped.items.filter((r): r is Recipe => !!r) }
return fromOpenApiPage(data ?? null, (r) => decodeRecipe(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) } } })
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> {
@ -201,7 +193,7 @@ export async function parseProduct(
}
// 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> = {}
if (params) {
if (params.q !== undefined) query.q = params.q
@ -235,10 +227,12 @@ export async function getUpcomingMeals(from: Date, to: Date): Promise<Meal[]> {
.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) } } })
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> {
@ -257,12 +251,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', {
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } },
})
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> {
@ -288,24 +284,27 @@ export async function saveMyShoppingList(items: components['schemas']['Ingredien
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) } } })
if (!response.ok) throw httpError(response, error)
const mapped = mapPurchasedShoppingList(data)
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')
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 }
type PurchaseRefs = { type: 'refs'; personId: number; ingredientId?: number | null; recipeId?: number | null; mealId?: number | null }
export type PurchaseRequest = PurchaseExisting | PurchaseRefs
export async function purchaseShoppingList(
completedRequests: Array<PurchaseExisting | PurchaseRefs>
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

View file

@ -506,9 +506,10 @@ export interface components {
prevCursor?: string | null;
/**
* Total
* @description Optional total count
* @description Total count
* @default 0
*/
total?: number | null;
total: number;
};
/** Page[Recipe] */
Page_Recipe_: {
@ -520,9 +521,10 @@ export interface components {
prevCursor?: string | null;
/**
* Total
* @description Optional total count
* @description Total count
* @default 0
*/
total?: number | null;
total: number;
};
/** Person */
Person: {
@ -575,8 +577,6 @@ export interface components {
imgSmall: string;
/** Imglarge */
imgLarge: string;
/** Rawdata */
rawData?: Record<string, never> | null;
};
/** ProductUrl */
ProductUrl: {
@ -626,7 +626,7 @@ export interface components {
*/
dateCreated?: string;
/** Createdbyid */
createdById: number | null;
createdById: number;
createdBy?: components["schemas"]["Person"] | null;
/** Datehidden */
dateHidden?: string | null;
@ -659,7 +659,7 @@ export interface components {
*/
dateCreated?: string;
/** Createdbyid */
createdById: number | null;
createdById: number;
createdBy?: components["schemas"]["Person"] | null;
/** Datehidden */
dateHidden?: string | null;

View file

@ -118,8 +118,8 @@
<script setup lang="ts">
import { reactive, onBeforeMount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getMeal, saveMeal, toMealInput } from '@/composables/useMeals'
import { getRecipe } from '@/api/sdk'
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'
@ -162,13 +162,11 @@ const meal = reactive<Meal>({
})
onBeforeMount(async () => {
const id = parseRouteId(route.params.id)
if (id !== null) {
const loaded = await getMeal(id)
if (loaded) {
Object.assign(meal, loaded)
}
} else {
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]
@ -214,8 +212,7 @@ function addPerson(list: PeopleKey, person: Person) {
async function selectRecipe(recipe: { id: number | string }) {
// Refetch to get additional details
const r = await getRecipe(recipe.id)
if (!r) return
const r = await getRecipe(recipe.id)
if (r.createdBy) {
addPersonIfNotExists(meal.chefs, r.createdBy)

View file

@ -34,18 +34,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ago } from '@/dateformats'
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
}
import type { Meal } from '@/domain/types'
const props = defineProps<{ meal: Meal }>()
@ -86,7 +75,10 @@ const dayOfWeek = computed(() =>
const mealTitle = computed(() => {
const recipes = props.meal.recipes ?? []
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))
if (recipesText && ingredientsText) return `${recipesText} with ${ingredientsText}`

View file

@ -54,7 +54,7 @@
import { ref, onBeforeMount } from 'vue'
import ActionItem from '@/components/ActionItem.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 chevronUp = new URL('@/assets/chevron-up.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) {
const r = await getRecipe(id)
recipe.value = r
link.value = r?.link ?? ''
link.value = r.link ?? ''
return
} else if (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 { useAlert } from '@/composables/useAlert'
import { useShopping } from '@/composables/useShopping'
import { useMeals } from '@/composables/useMeals'
import { getUpcomingMeals } from '@/api/sdk'
import { type Group } from '@/composables/useShopping'
type UIMeal = import('@/domain/types').Meal
import MealSelectionList from './MealSelectionList.vue'
@ -117,7 +117,6 @@ const closeIcon = new URL('@/assets/close.svg', import.meta.url).toString()
const router = useRouter()
const { show: showAlert } = useAlert()
const { getCurrentShoppingList, requestMeal, unrequestMeal, purchaseFromGroups, groupsFrom, mealsFrom } = useShopping()
const { getUpcomingMeals } = useMeals()
const from = new Date()
from.setTime(0)

View file

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

View file

@ -37,11 +37,11 @@
: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 }}
<!-- 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">
<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
@ -57,7 +57,7 @@
: ''
}}</router-link>
</span>
<span v-else-if="source.meal">
<span v-else-if="source.meal && source.ingredient">
{{ source.ingredient.line }} for
<router-link :to="`/meals/${source.meal?.id}/`">{{
source.meal?.suggestedDate
@ -78,11 +78,11 @@
: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 }}
<!-- 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">
<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
@ -98,7 +98,7 @@
: ''
}}</router-link>
</span>
<span v-else-if="source.meal">
<span v-else-if="source.meal && source.ingredient">
{{ source.ingredient.line }} for
<router-link :to="`/meals/${source.meal.id}/`">{{
source.meal.suggestedDate
@ -134,10 +134,13 @@ const imageSrc = computed(() => {
const remainingRequiredTotals = computed(() =>
calculateTotals(
props.shoppingListItemGroup.shoppingListItems.map((item) => ({
quantity: item.ingredient.quantity,
unit: String(item.ingredient.unit || 'items'),
}))
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'),
}))
)
)

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

View file

@ -1,4 +1,4 @@
import type { RecipeOut, Recipe, MealOut, Meal, MealRecipe, Ingredient as DomainIngredient, ShoppingList, ShoppingListItem } from './types'
import type { RecipeOut, Recipe, MealOut, Meal, MealRecipe, Ingredient as DomainIngredient, ShoppingList, ShoppingListItem, ShoppingListItemWithRefs, MealInput } from './types'
import type { components } from '@/api/types'
export function toDate(value: string | Date | null | undefined): Date | null {
@ -6,8 +6,25 @@ export function toDate(value: string | Date | null | undefined): Date | null {
return typeof value === 'string' ? new Date(value) : value
}
export function decodeRecipe(r: RecipeOut | null | undefined): Recipe | null {
if (!r) return null
// 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: RecipeOut | null | undefined): Recipe {
if (!r) throw new Error('Invalid recipe payload')
return {
...r,
dateCreated: toDate(r.dateCreated),
@ -15,15 +32,10 @@ export function decodeRecipe(r: RecipeOut | null | undefined): Recipe | null {
}
}
export function decodeRecipes(list: RecipeOut[] | null | undefined): Recipe[] {
if (!Array.isArray(list)) return []
return list.map((r) => decodeRecipe(r)).filter((r): r is Recipe => !!r)
}
export function decodeMeal(m: MealOut | null | undefined): Meal | null {
if (!m) return null
export function decodeMeal(m: MealOut | null | undefined): Meal {
if (!m) throw new Error('Invalid meal payload')
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 {
@ -39,40 +51,41 @@ export function decodeMeal(m: MealOut | null | undefined): Meal | null {
}
}
export function decodeMealRecipe(mr: components['schemas']['MealRecipe-Output'] | null | undefined): MealRecipe | null {
if (!mr) return null
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 | null {
if (!i) return 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)).filter((x): x is DomainIngredient => !!x)
return list.map((i) => decodeIngredient(i))
}
export function decodeShoppingListItem(i: components['schemas']['ShoppingListItem'] | null | undefined): ShoppingListItem | null {
if (!i) return null
function decodeShoppingListItem(i: components['schemas']['ShoppingListItem'] | 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']['ShoppingListItem'][] | null | undefined): ShoppingListItem[] {
export function decodeShoppingListItems(list: components['schemas']['ShoppingListItem'][] | null | undefined): ShoppingListItemWithRefs[] {
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 {
if (!v) return null
export function decodeShoppingList(v: components['schemas']['ShoppingList'] | 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 {
@ -81,3 +94,23 @@ export function decodeShoppingList(v: components['schemas']['ShoppingList'] | nu
...(items ? { items } : {}),
}
}
// 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

@ -4,10 +4,9 @@ import type { components } from '@/api/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 }>
// Common helpers
export type Maybe<T> = T | null | undefined
export type NonNull<T> = Exclude<T, null | undefined>
// 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
@ -49,7 +48,7 @@ export type ShoppingListItemWithRefs = WithRefs<ShoppingListItem, { ingredient:
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 = {
ingredientsLookup?: Lookup<Ingredient>
mealsLookup?: Lookup<Meal>

17
src/env.d.ts vendored
View file

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

View file

@ -1,7 +1,7 @@
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: {
kgs: 1,
kilograms: 1,
@ -124,8 +124,8 @@ export function getConversionFactor(unit: string): { unit: UnitKey | string; fac
return null
}
export type Quantity = { quantity: number; unit: string }
export type Total = { unit: string; quantity: number }
type Quantity = { quantity: number; unit: string }
type Total = { unit: string; quantity: number }
export function calculateTotals(quantityList: Quantity[]): Total[] {
const totals: Record<string, number> = {}

View file

@ -2,8 +2,8 @@ import { afterAll, afterEach, beforeAll } from 'vitest'
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
// Ensure global fetch is available in Node tests
// Use Node 18+ global fetch (undici). Do not override so MSW can intercept.
// 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'

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,
"checkJs": false,
"skipLibCheck": true,
"types": ["node", "vite/client"],
"types": ["node"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]

View file

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