diff --git a/package-lock.json b/package-lock.json index a3ef59d..0e90217 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1408,6 +1408,53 @@ "yaml": "^2.4.1" } }, + "node_modules/@fastify/websocket": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz", + "integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "duplexify": "^4.1.3", + "fastify-plugin": "^5.0.0", + "ws": "^8.16.0" + } + }, + "node_modules/@fastify/websocket/node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/@fastify/websocket/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2186,9 +2233,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2205,9 +2249,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2224,9 +2265,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2243,9 +2281,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2385,6 +2420,42 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.18", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", @@ -2478,9 +2549,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2498,9 +2566,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2518,9 +2583,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2538,9 +2600,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2558,9 +2617,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2578,9 +2634,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2671,7 +2724,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@swc/helpers": { @@ -3166,6 +3224,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3224,6 +3345,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -4371,6 +4498,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4487,6 +4623,127 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -4627,6 +4884,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5009,6 +5272,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -5356,6 +5629,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -6166,6 +6445,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6224,6 +6513,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -8497,6 +8795,29 @@ "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -8522,6 +8843,36 @@ "node": ">= 12.13.0" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -8536,6 +8887,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -8599,6 +8965,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", @@ -9560,6 +9932,12 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -9964,6 +10342,28 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "8.0.11", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", @@ -10322,6 +10722,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -10450,6 +10871,7 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/swagger": "^9.7.0", "@fastify/swagger-ui": "^5.2.5", + "@fastify/websocket": "^11.2.0", "@meshitrack/shared": "*", "awilix": "^13.0.3", "fastify": "^5.8.4", @@ -10493,6 +10915,7 @@ "next-auth": "^5.0.0-beta.30", "react": "^19.2.0", "react-dom": "^19.2.0", + "recharts": "^3.8.1", "swr": "^2.3.0" }, "devDependencies": { diff --git a/packages/api/package.json b/packages/api/package.json index f6a81bf..610ffc5 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -27,6 +27,7 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/swagger": "^9.7.0", "@fastify/swagger-ui": "^5.2.5", + "@fastify/websocket": "^11.2.0", "@meshitrack/shared": "*", "awilix": "^13.0.3", "fastify": "^5.8.4", diff --git a/packages/api/src/main.ts b/packages/api/src/main.ts index 302820c..0f76b6e 100644 --- a/packages/api/src/main.ts +++ b/packages/api/src/main.ts @@ -44,6 +44,9 @@ import freshnessRulesRoutes from './modules/freshness-rules/freshness-rules.rout import productsRoutes from './modules/products/products.routes.js'; import mealPlansRoutes from './modules/meal-plans/meal-plans.routes.js'; import nutritionTargetsRoutes from './modules/nutrition-targets/nutrition-target.routes.js'; +import websocket from '@fastify/websocket'; +import pricesRoutes from './modules/prices/prices.routes.js'; +import shoppingListsRoutes from './modules/shopping-lists/shopping-lists.routes.js'; export async function buildApp(opts: { logger?: boolean | object } = {}) { const app = Fastify({ @@ -109,6 +112,9 @@ export async function buildApp(opts: { logger?: boolean | object } = {}) { await app.register(authPlugin); await app.register(householdPlugin); + // WebSocket Plugin for persistent synchronizations + await app.register(websocket); + // Route modules await app.register(healthRoutes); await app.register(usersRoutes); @@ -129,6 +135,8 @@ export async function buildApp(opts: { logger?: boolean | object } = {}) { await app.register(freshnessRulesRoutes); await app.register(mealPlansRoutes); await app.register(nutritionTargetsRoutes); + await app.register(pricesRoutes); + await app.register(shoppingListsRoutes); // Global error handler app.setErrorHandler((error, request, reply) => { diff --git a/packages/api/src/modules/prices/prices.repository.test.ts b/packages/api/src/modules/prices/prices.repository.test.ts new file mode 100644 index 0000000..be115e1 --- /dev/null +++ b/packages/api/src/modules/prices/prices.repository.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PricesRepository } from './prices.repository.js'; + +const { mockSave, MockPriceRecordModel } = vi.hoisted(() => { + const mockSave = vi.fn(); + function MockModel(this: { save: typeof mockSave }, data: unknown) { + Object.assign(this, data); + this.save = mockSave; + } + Object.assign(MockModel, { + findOne: vi.fn(), + find: vi.fn(), + findOneAndUpdate: vi.fn(), + insertMany: vi.fn(), + aggregate: vi.fn(), + }); + return { mockSave, MockPriceRecordModel: MockModel }; +}); + +vi.mock('../../schemas/price-record.schema.js', () => ({ + PriceRecordModel: MockPriceRecordModel, +})); + +const { PriceRecordModel } = await import('../../schemas/price-record.schema.js'); + +function makeChain(result: unknown = null) { + return { + sort: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + lean: vi.fn().mockReturnThis(), + exec: vi.fn().mockResolvedValue(result), + }; +} + +describe(PricesRepository.name, () => { + let repo: PricesRepository; + + beforeEach(() => { + vi.clearAllMocks(); + repo = new PricesRepository(); + }); + + describe('create', () => { + it('saves and returns new document toObject', async () => { + const data = { householdId: 'h1', productId: 'p1', productName: 'Apple', storeId: 's1', storeName: 'Store', price: 1, currency: 'USD', quantity: 1, unit: 'g', pricePerUnit: 1, date: new Date(), createdBy: 'u1' }; + mockSave.mockResolvedValue({ toObject: () => ({ ...data, _id: 'id1' }) }); + + const result = await repo.create(data); + expect(mockSave).toHaveBeenCalled(); + expect(result._id).toBe('id1'); + }); + }); + + describe('createMany', () => { + it('inserts multiple records and returns mapped toObjects', async () => { + const inputs = [{ price: 1 }, { price: 2 }]; + const returns = inputs.map((x, idx) => ({ ...x, _id: `id${idx}`, toObject: function() { return this; } })); + vi.mocked(PriceRecordModel.insertMany).mockResolvedValue(returns as any); + + const result = await repo.createMany(inputs as any); + expect(PriceRecordModel.insertMany).toHaveBeenCalledWith(inputs); + expect(result).toHaveLength(2); + expect(result[0]._id).toBe('id0'); + }); + }); + + describe('findByProduct', () => { + it('applies complex filters and pagination cursor decoding/encoding', async () => { + const baseFilter = { householdId: 'h1', productId: 'prod1' }; + const startDate = new Date('2026-01-01').toISOString(); + const endDate = new Date('2026-01-10').toISOString(); + const cursorId = '507f1f77bcf86cd799439011'; + const cursorStr = Buffer.from(cursorId).toString('base64'); + + const mockItems = [ + { _id: '607f1f77bcf86cd799439012', price: 10 }, + { _id: '607f1f77bcf86cd799439013', price: 12 } + ]; + + const chain = makeChain(mockItems); + vi.mocked(PriceRecordModel.find).mockReturnValue(chain as any); + + const result = await repo.findByProduct('h1', 'prod1', { + storeId: 'st1', + startDate, + endDate, + cursor: cursorStr, + limit: 2 + }); + + expect(PriceRecordModel.find).toHaveBeenCalledWith({ + householdId: 'h1', + productId: 'prod1', + storeId: 'st1', + date: { + $gte: new Date(startDate), + $lte: new Date(endDate), + }, + _id: { $lt: cursorId } + }); + expect(result.data).toHaveLength(2); + expect(result.pagination.hasMore).toBe(false); + }); + + it('correctly indicates hasMore and generates next base64 cursor', async () => { + const mockItems = [ + { _id: '607f1f77bcf86cd799439011', price: 10 }, + { _id: '607f1f77bcf86cd799439012', price: 11 }, + { _id: '607f1f77bcf86cd799439013', price: 12 } + ]; + + const chain = makeChain(mockItems); + vi.mocked(PriceRecordModel.find).mockReturnValue(chain as any); + + const result = await repo.findByProduct('h1', 'prod1', { limit: 2 }); + expect(result.data).toHaveLength(2); + expect(result.pagination.hasMore).toBe(true); + expect(result.pagination.cursor).toBe(Buffer.from('607f1f77bcf86cd799439012').toString('base64')); + }); + }); + + describe('compareStores', () => { + it('runs group/aggregate queries ordered by deviance', async () => { + const mockAggResult = [ + { _id: 's1', storeName: 'Cheap', latestPrice: 10, latestPricePerUnit: 1, currency: 'USD', date: new Date() } + ]; + vi.mocked(PriceRecordModel.aggregate).mockReturnValue({ + exec: vi.fn().mockResolvedValue(mockAggResult) + } as any); + + const result = await repo.compareStores('h1', 'p1'); + expect(PriceRecordModel.aggregate).toHaveBeenCalled(); + expect(result).toHaveLength(1); + expect(result[0].storeId).toBe('s1'); + expect(result[0].latestPricePerUnit).toBe(1); + }); + }); + + describe('getLatestForProduct', () => { + it('queries latest pricing document ordered by date descending', async () => { + const chain = makeChain({ _id: 'pr1' }); + vi.mocked(PriceRecordModel.findOne).mockReturnValue(chain as any); + + await repo.getLatestForProduct('h1', 'p1', 's1'); + expect(PriceRecordModel.findOne).toHaveBeenCalledWith({ householdId: 'h1', productId: 'p1', storeId: 's1' }); + expect(chain.sort).toHaveBeenCalledWith({ date: -1 }); + }); + }); + + describe('getAnalytics', () => { + it('executes Promise.all parallel pipeline aggregations for periods, buckets, categories, and inflation', async () => { + const mockExec = vi.fn().mockResolvedValue([]); + vi.mocked(PriceRecordModel.aggregate).mockReturnValue({ + exec: mockExec + } as any); + + await repo.getAnalytics('h1'); + // 4 explicit pipeline calls should have fired in Promise.all + inflation alert + expect(PriceRecordModel.aggregate).toHaveBeenCalledTimes(4); + }); + }); +}); diff --git a/packages/api/src/modules/prices/prices.repository.ts b/packages/api/src/modules/prices/prices.repository.ts new file mode 100644 index 0000000..0c98f0b --- /dev/null +++ b/packages/api/src/modules/prices/prices.repository.ts @@ -0,0 +1,244 @@ +import { PriceRecordModel } from '../../schemas/price-record.schema.js'; +import type { PriceHistoryQueryInput } from '@meshitrack/shared'; + +export interface CreatePriceRecordData { + householdId: string; + productId: string; + productName: string; + storeId: string; + storeName: string; + price: number; + currency: string; + quantity: number; + unit: string; + pricePerUnit: number; + date: Date; + receiptImageUrl?: string; + notes?: string; + createdBy: string; +} + +export class PricesRepository { + public async create(data: CreatePriceRecordData) { + const record = new PriceRecordModel(data); + const saved = await record.save(); + return saved.toObject(); + } + + public async createMany(data: CreatePriceRecordData[]) { + const records = await PriceRecordModel.insertMany(data); + return records.map(r => r.toObject()); + } + + public async findByProduct( + householdId: string, + productId: string, + query: PriceHistoryQueryInput + ) { + const filter: Record = { householdId, productId }; + + if (query.storeId) filter['storeId'] = query.storeId; + + if (query.startDate || query.endDate) { + const dateFilter: Record = {}; + if (query.startDate) dateFilter['$gte'] = new Date(query.startDate); + if (query.endDate) dateFilter['$lte'] = new Date(query.endDate); + filter['date'] = dateFilter; + } + + if (query.cursor) { + const id = Buffer.from(query.cursor, 'base64').toString(); + filter['_id'] = { $lt: id }; + } + + const limit = query.limit || 20; + const items = await PriceRecordModel.find(filter) + .sort({ _id: -1 }) + .limit(limit + 1) + .lean() + .exec(); + + const hasMore = items.length > limit; + const data = hasMore ? items.slice(0, limit) : items; + const cursor = + data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null; + + return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } }; + } + + public async compareStores(householdId: string, productId: string) { + const results = await PriceRecordModel.aggregate([ + { $match: { householdId, productId } }, + { $sort: { storeId: 1, date: -1 } }, + { + $group: { + _id: '$storeId', + storeName: { $first: '$storeName' }, + latestPrice: { $first: '$price' }, + latestPricePerUnit: { $first: '$pricePerUnit' }, + currency: { $first: '$currency' }, + date: { $first: '$date' }, + }, + }, + { $sort: { latestPricePerUnit: 1 } }, + ]).exec(); + + return results.map((r) => ({ + storeId: r._id as string, + storeName: r.storeName as string, + latestPrice: r.latestPrice as number, + latestPricePerUnit: r.latestPricePerUnit as number, + currency: r.currency as string, + date: r.date as Date, + })); + } + + public async getLatestForProduct(householdId: string, productId: string, storeId?: string) { + const filter: Record = { householdId, productId }; + if (storeId) filter['storeId'] = storeId; + return PriceRecordModel.findOne(filter).sort({ date: -1 }).lean().exec(); + } + + public async getAnalytics(householdId: string) { + const [spendingOverTime, averageBasketByStore, spendingByCategory] = await Promise.all([ + // 1. Total Spending Over Time (by month) + PriceRecordModel.aggregate([ + { $match: { householdId } }, + { + $group: { + _id: { $dateToString: { format: '%Y-%m', date: '$date' } }, + total: { $sum: '$price' }, + }, + }, + { $sort: { _id: 1 } }, + { $project: { _id: 0, period: '$_id', total: 1 } }, + ]).exec(), + + // 2. Average basket by store (grouping by Day + StoreId to simulate distinct trips) + PriceRecordModel.aggregate([ + { $match: { householdId } }, + { + $group: { + _id: { + storeId: '$storeId', + day: { $dateToString: { format: '%Y-%m-%d', date: '$date' } }, + }, + storeName: { $first: '$storeName' }, + tripTotal: { $sum: '$price' }, + }, + }, + { + $group: { + _id: '$_id.storeId', + storeName: { $first: '$storeName' }, + avgTotal: { $avg: '$tripTotal' }, + tripCount: { $sum: 1 }, + }, + }, + { $sort: { avgTotal: -1 } }, + { $project: { _id: 0, storeId: '$_id', storeName: 1, avgTotal: 1, tripCount: 1 } }, + ]).exec(), + + // 3. Spending by Category (Requires lookup join with Products) + PriceRecordModel.aggregate([ + { $match: { householdId } }, + // Safeguard converting the dynamic string field to a valid ObjectId for proper joins + { + $addFields: { + prodObjId: { $toObjectId: '$productId' }, + }, + }, + { + $lookup: { + from: 'products', + localField: 'prodObjId', + foreignField: '_id', + as: 'matchedProduct', + }, + }, + { $unwind: { path: '$matchedProduct', preserveNullAndEmptyArrays: true } }, + { + $group: { + _id: { $ifNull: ['$matchedProduct.category', 'other'] }, + total: { $sum: '$price' }, + avgPerItem: { $avg: '$price' }, + }, + }, + { $sort: { total: -1 } }, + { $project: { _id: 0, category: '$_id', total: 1, avgPerItem: 1 } }, + ]).exec(), + ]); + + // 4. Detect product inflation alerts (>10% price rise on last purchase compared to prior one) + const priceAlerts = await PriceRecordModel.aggregate([ + { $match: { householdId } }, + { $sort: { productId: 1, storeId: 1, date: -1 } }, + { + $group: { + _id: { productId: '$productId', storeId: '$storeId' }, + productName: { $first: '$productName' }, + storeName: { $first: '$storeName' }, + prices: { $push: '$pricePerUnit' }, + dates: { $push: '$date' }, + }, + }, + { $match: { 'prices.1': { $exists: true } } }, + { + $addFields: { + currentPrice: { $arrayElemAt: ['$prices', 0] }, + previousPrice: { $arrayElemAt: ['$prices', 1] }, + alertDate: { $arrayElemAt: ['$dates', 0] }, + }, + }, + { + $addFields: { + changePercent: { + $multiply: [ + { $divide: [{ $subtract: ['$currentPrice', '$previousPrice'] }, '$previousPrice'] }, + 100, + ], + }, + }, + }, + { $match: { changePercent: { $gt: 10 } } }, + { + $project: { + _id: 0, + productId: '$_id.productId', + storeId: '$_id.storeId', + productName: 1, + storeName: 1, + previousPrice: 1, + currentPrice: 1, + changePercent: 1, + date: '$alertDate', + }, + }, + ]).exec(); + + return { + spendingOverTime: spendingOverTime as { period: string; total: number }[], + averageBasketByStore: averageBasketByStore as { + storeId: string; + storeName: string; + avgTotal: number; + tripCount: number; + }[], + spendingByCategory: spendingByCategory as { + category: string; + total: number; + avgPerItem: number; + }[], + priceAlerts: priceAlerts as { + productId: string; + storeId: string; + productName: string; + storeName: string; + previousPrice: number; + currentPrice: number; + changePercent: number; + date: Date; + }[], + }; + } +} diff --git a/packages/api/src/modules/prices/prices.routes.test.ts b/packages/api/src/modules/prices/prices.routes.test.ts new file mode 100644 index 0000000..b572d5d --- /dev/null +++ b/packages/api/src/modules/prices/prices.routes.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import { fastifyAwilixPlugin } from '@fastify/awilix'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; + +vi.mock('jose', () => ({ + createRemoteJWKSet: vi.fn(() => 'mock-jwks'), + jwtVerify: vi.fn().mockResolvedValue({ + payload: { + sub: 'kc-1', + email: 'test@example.com', + realm_access: { roles: ['member'] }, + householdIds: ['hh1'], + }, + protectedHeader: {}, + key: {}, + }), +})); + +const mockCreate = vi.fn(); +const mockCreateMany = vi.fn(); +const mockFindByProduct = vi.fn(); +const mockCompareStores = vi.fn(); +const mockGetAnalytics = vi.fn(); + +vi.mock('./prices.repository.js', () => ({ + PricesRepository: class { + create = mockCreate; + createMany = mockCreateMany; + findByProduct = mockFindByProduct; + compareStores = mockCompareStores; + getAnalytics = mockGetAnalytics; + }, +})); + +vi.mock('../products/products.repository.js', () => ({ + ProductsRepository: class { + findById = vi.fn().mockResolvedValue({ name: 'Mock Product' }); + findByIds = vi.fn().mockResolvedValue([{ _id: 'p1', name: 'Mock Product' }]); + }, +})); + +vi.mock('../stores/stores.repository.js', () => ({ + StoresRepository: class { + findById = vi.fn().mockResolvedValue({ name: 'Mock Store' }); + }, +})); + +vi.mock('../users/users.repository.js', () => ({ + UsersRepository: class { + findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); + upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); + }, +})); + +import authPlugin from '../../plugins/auth.plugin.js'; +import householdPlugin from '../../plugins/household.plugin.js'; +import usersRoutes from '../users/users.routes.js'; +import pricesRoutes from './prices.routes.js'; + +describe('prices.routes', () => { + let app: any; + + async function buildTestApp() { + const instance = Fastify({ logger: false }); + instance.setValidatorCompiler(validatorCompiler); + instance.setSerializerCompiler(serializerCompiler); + await instance.register(fastifyAwilixPlugin, { + disposeOnClose: true, + disposeOnResponse: true, + strictBooleanEnforced: true, + }); + await instance.register(authPlugin); + await instance.register(householdPlugin); + await instance.register(usersRoutes); + await instance.register(pricesRoutes); + await instance.ready(); + return instance; + } + + const authHeaders = { authorization: 'Bearer valid' }; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildTestApp(); + }); + + afterEach(async () => { + if (app) await app.close(); + }); + + function makeRecord(overrides = {}) { + return { + _id: 'r1', + householdId: 'hh1', + productId: 'p1', + productName: 'Apples', + storeId: 's1', + storeName: 'Store', + price: 10, + currency: 'USD', + quantity: 1, + unit: 'piece', + pricePerUnit: 10, + date: new Date(), + createdBy: 'kc-1', + createdAt: new Date(), + ...overrides, + }; + } + + describe('POST /api/v1/households/:householdId/prices', () => { + it('records price and returns 201 response', async () => { + mockCreate.mockResolvedValue(makeRecord()); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/prices', + headers: { ...authHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ + productId: 'p1', + storeId: 's1', + price: 5.99, + currency: 'USD', + quantity: 1, + unit: 'piece', + }), + }); + + if (res.statusCode === 500) { + console.log('ERROR PAYLOAD:', res.payload); + } + expect(res.statusCode).toBe(201); + expect(res.json().productName).toBe('Apples'); + }); + }); + + describe('GET /api/v1/households/:householdId/prices/history/:productId', () => { + it('returns a paginated envelope of historical pricing data', async () => { + mockFindByProduct.mockResolvedValue({ + data: [makeRecord()], + pagination: { cursor: null, hasMore: false }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/prices/history/p1', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data).toHaveLength(1); + expect(body.pagination.hasMore).toBe(false); + }); + }); + + describe('GET /api/v1/households/:householdId/prices/analytics', () => { + it('returns analytical metrics suite with properly formatted dates', async () => { + mockGetAnalytics.mockResolvedValue({ + spendingOverTime: [], + averageBasketByStore: [], + spendingByCategory: [], + priceAlerts: [{ productId: 'p1', productName: 'Bread', storeId: 's1', storeName: 'Store', previousPrice: 2, currentPrice: 2.5, changePercent: 25, date: new Date() }], + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/prices/analytics', + headers: authHeaders, + }); + + if (res.statusCode === 500) { + console.log('ERROR PAYLOAD:', res.payload); + } + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.priceAlerts).toHaveLength(1); + expect(typeof body.priceAlerts[0].date).toBe('string'); + }); + }); +}); diff --git a/packages/api/src/modules/prices/prices.routes.ts b/packages/api/src/modules/prices/prices.routes.ts new file mode 100644 index 0000000..cbf8257 --- /dev/null +++ b/packages/api/src/modules/prices/prices.routes.ts @@ -0,0 +1,188 @@ +import fp from 'fastify-plugin'; +import { asClass, Lifetime } from 'awilix'; +import type { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { z } from 'zod/v4'; +import { + CreatePriceRecordSchema, + BulkPriceRecordInputSchema, + PriceHistoryQuerySchema, + PriceRecordResponseSchema, + PriceHistoryResponseSchema, + FoodStoreComparisonResponseSchema, + FoodSpendingAnalyticsResponseSchema, +} from '@meshitrack/shared'; +import { PricesRepository } from './prices.repository.js'; +import { PricesService } from './prices.service.js'; +import { ProductsRepository } from '../products/products.repository.js'; +import { StoresRepository } from '../stores/stores.repository.js'; + +type AnyPriceDoc = { + _id: string | { toString: () => string }; + householdId: string; + productId: string; + productName: string; + storeId: string; + storeName: string; + price: number; + currency: string; + quantity: number; + unit: string; + pricePerUnit: number; + date: Date | string | { toISOString: () => string }; + receiptImageUrl?: string; + notes?: string; + createdBy: string; + createdAt: Date | string | { toISOString: () => string }; +}; + +function toIso(v: Date | string | { toISOString: () => string }): string { + if (typeof v === 'string') return v; + return v.toISOString(); +} + +function toPriceRecordResponse(rawDoc: unknown) { + const doc = rawDoc as AnyPriceDoc; + return { + _id: typeof doc._id === 'string' ? doc._id : doc._id.toString(), + householdId: doc.householdId, + productId: doc.productId, + productName: doc.productName, + storeId: doc.storeId, + storeName: doc.storeName, + price: doc.price, + currency: doc.currency, + quantity: doc.quantity, + unit: doc.unit, + pricePerUnit: doc.pricePerUnit, + date: toIso(doc.date), + ...(doc.receiptImageUrl ? { receiptImageUrl: doc.receiptImageUrl } : {}), + ...(doc.notes != null ? { notes: doc.notes } : {}), + createdBy: doc.createdBy, + createdAt: toIso(doc.createdAt), + }; +} + +declare module '@fastify/awilix' { + interface Cradle { + pricesRepository: PricesRepository; + pricesService: PricesService; + } +} + +export default fp( + async (fastify) => { + // Register DI containers + fastify.diContainer.register({ + pricesRepository: asClass(PricesRepository, { lifetime: Lifetime.SINGLETON }), + pricesService: asClass(PricesService, { lifetime: Lifetime.SINGLETON }), + productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }), + storesRepository: asClass(StoresRepository, { lifetime: Lifetime.SINGLETON }), + }); + + const app = fastify.withTypeProvider(); + const householdParams = z.object({ householdId: z.string() }); + + app.route({ + method: 'POST', + url: '/api/v1/households/:householdId/prices', + schema: { + params: householdParams, + body: CreatePriceRecordSchema, + response: { 201: PriceRecordResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('pricesService'); + const record = await service.recordPrice( + request.body, + request.params.householdId, + request.user.keycloakId + ); + return reply.status(201).send(toPriceRecordResponse(record)); + }, + }); + + app.route({ + method: 'POST', + url: '/api/v1/households/:householdId/prices/bulk', + schema: { + params: householdParams, + body: BulkPriceRecordInputSchema, + response: { 201: z.array(PriceRecordResponseSchema) }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('pricesService'); + const records = await service.recordBulkPrices( + request.body, + request.params.householdId, + request.user.keycloakId + ); + return reply.status(201).send(records.map(toPriceRecordResponse)); + }, + }); + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/prices/history/:productId', + schema: { + params: householdParams.extend({ productId: z.string() }), + querystring: PriceHistoryQuerySchema, + response: { 200: PriceHistoryResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('pricesService'); + const result = await service.getPriceHistory( + request.params.productId, + request.params.householdId, + request.query + ); + return reply.send({ + data: result.data.map(toPriceRecordResponse), + pagination: result.pagination, + }); + }, + }); + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/prices/compare/:productId', + schema: { + params: householdParams.extend({ productId: z.string() }), + response: { 200: FoodStoreComparisonResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('pricesService'); + const results = await service.compareStores( + request.params.productId, + request.params.householdId + ); + return reply.send({ + data: results.map((r) => ({ + ...r, + date: toIso(r.date), + })), + }); + }, + }); + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/prices/analytics', + schema: { + params: householdParams, + response: { 200: FoodSpendingAnalyticsResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('pricesService'); + const analytics = await service.getAnalytics(request.params.householdId); + return reply.send({ + ...analytics, + priceAlerts: analytics.priceAlerts.map((a) => ({ ...a, date: toIso(a.date) })), + }); + }, + }); + }, + { + name: 'prices-routes', + dependencies: ['auth-plugin'], + } +); diff --git a/packages/api/src/modules/prices/prices.service.test.ts b/packages/api/src/modules/prices/prices.service.test.ts new file mode 100644 index 0000000..9d3ba32 --- /dev/null +++ b/packages/api/src/modules/prices/prices.service.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PricesService } from './prices.service.js'; +import { NotFoundError } from '../../common/errors.js'; + +describe('PricesService', () => { + let service: PricesService; + + const mockPricesRepo = { + create: vi.fn(), + createMany: vi.fn(), + findByProduct: vi.fn(), + compareStores: vi.fn(), + getAnalytics: vi.fn(), + getLatestForProduct: vi.fn(), + }; + + const mockProductsRepo = { + findById: vi.fn(), + findByIds: vi.fn(), + }; + + const mockStoresRepo = { + findById: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + service = new PricesService({ + pricesRepository: mockPricesRepo as any, + productsRepository: mockProductsRepo as any, + storesRepository: mockStoresRepo as any, + }); + }); + + describe('recordPrice', () => { + it('calculates unit price and persists data on existing linkages', async () => { + mockProductsRepo.findById.mockResolvedValue({ name: 'Milk' }); + mockStoresRepo.findById.mockResolvedValue({ name: 'Target' }); + mockPricesRepo.create.mockResolvedValue({ _id: 'rec1' }); + + const result = await service.recordPrice( + { productId: 'p1', storeId: 's1', price: 4, quantity: 2, unit: 'ml' as any, currency: 'USD' }, + 'hh1', + 'u1' + ); + + expect(mockPricesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + productName: 'Milk', + storeName: 'Target', + pricePerUnit: 2, + }) + ); + expect(result._id).toBe('rec1'); + }); + + it('throws NotFound if product is invalid', async () => { + mockProductsRepo.findById.mockResolvedValue(null); + await expect( + service.recordPrice( + { productId: 'p1', storeId: 's1', price: 1, quantity: 1, unit: 'g' as any, currency: 'USD' }, + 'hh1', + 'u1' + ) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe('recordBulkPrices', () => { + it('ingests multiple mappings throwing notFound if one catalog match fails', async () => { + mockStoresRepo.findById.mockResolvedValue({ name: 'Aldi' }); + mockProductsRepo.findByIds.mockResolvedValue([{ _id: 'p1', name: 'Bread' }]); + mockPricesRepo.createMany.mockImplementation(args => args); + + const result = await service.recordBulkPrices( + { + storeId: 's1', + items: [{ productId: 'p1', price: 3, quantity: 1, unit: 'g' as any }], + }, + 'hh1', + 'u1' + ); + + expect(mockPricesRepo.createMany).toHaveBeenCalled(); + expect(result).toHaveLength(1); + expect(result[0].productName).toBe('Bread'); + }); + }); + + describe('estimatePrice', () => { + it('falls back to generic if requested store history is missing', async () => { + // First call (restricted to storeId): empty + mockPricesRepo.getLatestForProduct.mockResolvedValueOnce(null); + // Second call (generic): matches + mockPricesRepo.getLatestForProduct.mockResolvedValueOnce({ price: 12 }); + + const val = await service.estimatePrice('prod1', 'hh1', 'storeA'); + expect(mockPricesRepo.getLatestForProduct).toHaveBeenCalledTimes(2); + expect(val).toBe(12); + }); + }); +}); diff --git a/packages/api/src/modules/prices/prices.service.ts b/packages/api/src/modules/prices/prices.service.ts new file mode 100644 index 0000000..9ed53b7 --- /dev/null +++ b/packages/api/src/modules/prices/prices.service.ts @@ -0,0 +1,145 @@ +import type { PricesRepository } from './prices.repository.js'; +import type { ProductsRepository } from '../products/products.repository.js'; +import type { StoresRepository } from '../stores/stores.repository.js'; +import type { + CreatePriceRecordInput, + BulkPriceRecordInput, + PriceHistoryQueryInput, +} from '@meshitrack/shared'; +import { NotFoundError } from '../../common/errors.js'; + +interface Deps { + pricesRepository: PricesRepository; + productsRepository: ProductsRepository; + storesRepository: StoresRepository; +} + +export class PricesService { + private readonly pricesRepository: PricesRepository; + private readonly productsRepository: ProductsRepository; + private readonly storesRepository: StoresRepository; + + public constructor({ pricesRepository, productsRepository, storesRepository }: Deps) { + this.pricesRepository = pricesRepository; + this.productsRepository = productsRepository; + this.storesRepository = storesRepository; + } + + /** + * Validates entity existence, computes pricePerUnit, and persists record + */ + public async recordPrice( + data: CreatePriceRecordInput, + householdId: string, + userId: string + ) { + const [product, store] = await Promise.all([ + this.productsRepository.findById(data.productId, householdId), + this.storesRepository.findById(data.storeId, householdId), + ]); + + if (!product) throw new NotFoundError(`Product not found: ${data.productId}`); + if (!store) throw new NotFoundError(`Store not found: ${data.storeId}`); + + const pricePerUnit = data.quantity > 0 ? data.price / data.quantity : data.price; + + return this.pricesRepository.create({ + householdId, + productId: data.productId, + productName: product.name as string, + storeId: data.storeId, + storeName: store.name as string, + price: data.price, + currency: data.currency, + quantity: data.quantity, + unit: data.unit, + pricePerUnit, + date: data.date ? new Date(data.date) : new Date(), + receiptImageUrl: data.receiptImageUrl, + notes: data.notes, + createdBy: userId, + }); + } + + /** + * Ingests a list of purchased products in a single transaction + */ + public async recordBulkPrices( + data: BulkPriceRecordInput, + householdId: string, + userId: string + ) { + const store = await this.storesRepository.findById(data.storeId, householdId); + if (!store) throw new NotFoundError(`Store not found: ${data.storeId}`); + + const recordDate = data.date ? new Date(data.date) : new Date(); + const productIds = data.items.map((it) => it.productId); + + const products = await this.productsRepository.findByIds(householdId, productIds); + const productMap = new Map(products.map((p) => [p._id.toString(), p])); + + const creationPayloads = data.items.map((item) => { + const product = productMap.get(item.productId); + if (!product) { + throw new NotFoundError(`Product not found in catalog: ${item.productId}`); + } + + const pricePerUnit = item.quantity > 0 ? item.price / item.quantity : item.price; + + return { + householdId, + productId: item.productId, + productName: product.name as string, + storeId: data.storeId, + storeName: store.name as string, + price: item.price, + currency: 'USD', // Base fallback or pulled from household settings in future + quantity: item.quantity, + unit: item.unit, + pricePerUnit, + date: recordDate, + notes: item.notes, + createdBy: userId, + }; + }); + + return this.pricesRepository.createMany(creationPayloads); + } + + public async getPriceHistory( + productId: string, + householdId: string, + query: PriceHistoryQueryInput + ) { + return this.pricesRepository.findByProduct(householdId, productId, query); + } + + public async compareStores(productId: string, householdId: string) { + return this.pricesRepository.compareStores(householdId, productId); + } + + public async getAnalytics(householdId: string) { + return this.pricesRepository.getAnalytics(householdId); + } + + /** + * Retrieves the most recent price recorded for this item (optionally restricted to a store) + * to enable quick population of estimated basket subtotals on fresh lists. + */ + public async estimatePrice( + productId: string, + householdId: string, + storeId?: string + ): Promise { + const latest = await this.pricesRepository.getLatestForProduct(householdId, productId, storeId); + if (!latest) { + // If a specific store was requested but has no history, fall back to the generic latest across all stores + if (storeId) { + const genericLatest = await this.pricesRepository.getLatestForProduct(householdId, productId); + return genericLatest ? genericLatest.price : null; + } + return null; + } + return latest.price; + } +} diff --git a/packages/api/src/modules/shopping-lists/shopping-lists.repository.test.ts b/packages/api/src/modules/shopping-lists/shopping-lists.repository.test.ts new file mode 100644 index 0000000..e44809a --- /dev/null +++ b/packages/api/src/modules/shopping-lists/shopping-lists.repository.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ShoppingListsRepository } from './shopping-lists.repository.js'; + +const { mockSave, MockShoppingListModel } = vi.hoisted(() => { + const mockSave = vi.fn(); + function MockModel(this: { save: typeof mockSave }, data: unknown) { + Object.assign(this, data); + this.save = mockSave; + } + Object.assign(MockModel, { + findOne: vi.fn(), + find: vi.fn(), + findOneAndUpdate: vi.fn(), + findOneAndDelete: vi.fn(), + }); + return { mockSave, MockShoppingListModel: MockModel }; +}); + +vi.mock('../../schemas/shopping-list.schema.js', () => ({ + ShoppingListModel: MockShoppingListModel, +})); + +const { ShoppingListModel } = await import('../../schemas/shopping-list.schema.js'); + +function makeChain(result: unknown = null) { + return { + sort: vi.fn().mockReturnThis(), + lean: vi.fn().mockReturnThis(), + exec: vi.fn().mockResolvedValue(result), + }; +} + +describe(ShoppingListsRepository.name, () => { + let repo: ShoppingListsRepository; + + beforeEach(() => { + vi.clearAllMocks(); + repo = new ShoppingListsRepository(); + }); + + describe('create', () => { + it('saves new shopping list model and returns simple object', async () => { + const doc = { _id: 'list1', name: 'Test List' }; + mockSave.mockResolvedValue({ toObject: () => doc }); + + const result = await repo.create({ name: 'Test List', householdId: 'h1', createdBy: 'u1', status: 'active' }); + expect(mockSave).toHaveBeenCalled(); + expect(result._id).toBe('list1'); + }); + }); + + describe('list', () => { + it('queries lists for household ordered newest first', async () => { + const chain = makeChain([]); + vi.mocked(ShoppingListModel.find).mockReturnValue(chain as any); + + await repo.list('h1'); + expect(ShoppingListModel.find).toHaveBeenCalledWith({ householdId: 'h1' }); + expect(chain.sort).toHaveBeenCalledWith({ createdAt: -1 }); + }); + }); + + describe('findById', () => { + it('queries distinct document by ID and householdId', async () => { + const chain = makeChain({ _id: 'list1' }); + vi.mocked(ShoppingListModel.findOne).mockReturnValue(chain as any); + + const result = await repo.findById('list1', 'h1'); + expect(ShoppingListModel.findOne).toHaveBeenCalledWith({ _id: 'list1', householdId: 'h1' }); + expect(result?._id).toBe('list1'); + }); + }); + + describe('findActiveByHousehold', () => { + it('queries specifically active/shopping lists sorted by update recency', async () => { + const chain = makeChain([]); + vi.mocked(ShoppingListModel.find).mockReturnValue(chain as any); + + await repo.findActiveByHousehold('h1'); + expect(ShoppingListModel.find).toHaveBeenCalledWith({ + householdId: 'h1', + status: { $in: ['active', 'shopping'] } + }); + expect(chain.sort).toHaveBeenCalledWith({ updatedAt: -1 }); + }); + }); + + describe('update', () => { + it('sets top level list variables atomically', async () => { + const chain = makeChain({}); + vi.mocked(ShoppingListModel.findOneAndUpdate).mockReturnValue(chain as any); + + await repo.update('list1', 'h1', { name: 'New Name' }); + expect(ShoppingListModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: 'list1', householdId: 'h1' }, + { $set: { name: 'New Name' } }, + { new: true } + ); + }); + }); + + describe('delete', () => { + it('executes findOneAndDelete targeting target IDs', async () => { + const chain = makeChain({}); + vi.mocked(ShoppingListModel.findOneAndDelete).mockReturnValue(chain as any); + + await repo.delete('list1', 'h1'); + expect(ShoppingListModel.findOneAndDelete).toHaveBeenCalledWith({ _id: 'list1', householdId: 'h1' }); + }); + }); + + // --- Atomic Subdocument Array Actions Tests --- + + describe('addItem', () => { + it('executes $push operator targeting list subdocuments', async () => { + const chain = makeChain({}); + vi.mocked(ShoppingListModel.findOneAndUpdate).mockReturnValue(chain as any); + + const mockItem = { id: 'itm1', quantity: 1, unit: 'g', checked: false, addedToPantry: false }; + await repo.addItem('list1', 'h1', mockItem as any); + + expect(ShoppingListModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: 'list1', householdId: 'h1' }, + { $push: { items: mockItem } }, + { new: true } + ); + }); + }); + + describe('updateItem', () => { + it('maps partial payload to flattened positional $ keys', async () => { + const chain = makeChain({}); + vi.mocked(ShoppingListModel.findOneAndUpdate).mockReturnValue(chain as any); + + await repo.updateItem('list1', 'h1', 'itm1', { checked: true, actualPrice: 5.5 }); + + expect(ShoppingListModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: 'list1', householdId: 'h1', 'items.id': 'itm1' }, + { + $set: { + 'items.$.checked': true, + 'items.$.actualPrice': 5.5 + } + }, + { new: true } + ); + }); + }); + + describe('removeItem', () => { + it('executes $pull operator matching inner item tracking id', async () => { + const chain = makeChain({}); + vi.mocked(ShoppingListModel.findOneAndUpdate).mockReturnValue(chain as any); + + await repo.removeItem('list1', 'h1', 'itm1'); + + expect(ShoppingListModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: 'list1', householdId: 'h1' }, + { $pull: { items: { id: 'itm1' } } }, + { new: true } + ); + }); + }); +}); diff --git a/packages/api/src/modules/shopping-lists/shopping-lists.repository.ts b/packages/api/src/modules/shopping-lists/shopping-lists.repository.ts new file mode 100644 index 0000000..b2abf7d --- /dev/null +++ b/packages/api/src/modules/shopping-lists/shopping-lists.repository.ts @@ -0,0 +1,81 @@ +import { ShoppingListModel } from '../../schemas/shopping-list.schema.js'; +import type { + CreateShoppingListInput, + UpdateShoppingListInput, + ShoppingItem, +} from '@meshitrack/shared'; + +export class ShoppingListsRepository { + public async create(data: CreateShoppingListInput & { householdId: string; createdBy: string; status: string }) { + const list = new ShoppingListModel(data); + const saved = await list.save(); + return saved.toObject(); + } + + public async list(householdId: string) { + return ShoppingListModel.find({ householdId }) + .sort({ createdAt: -1 }) + .lean() + .exec(); + } + + public async findById(id: string, householdId: string) { + return ShoppingListModel.findOne({ _id: id, householdId }).lean().exec(); + } + + public async findActiveByHousehold(householdId: string) { + return ShoppingListModel.find({ householdId, status: { $in: ['active', 'shopping'] } }) + .sort({ updatedAt: -1 }) + .lean() + .exec(); + } + + public async update(id: string, householdId: string, data: UpdateShoppingListInput) { + return ShoppingListModel.findOneAndUpdate( + { _id: id, householdId }, + { $set: data }, + { new: true } + ).lean().exec(); + } + + public async delete(id: string, householdId: string) { + return ShoppingListModel.findOneAndDelete({ _id: id, householdId }).lean().exec(); + } + + // --- Granular Atomic Subdocument Actions --- + + public async addItem(id: string, householdId: string, item: ShoppingItem) { + return ShoppingListModel.findOneAndUpdate( + { _id: id, householdId }, + { $push: { items: item } }, + { new: true } + ).lean().exec(); + } + + public async updateItem( + id: string, + householdId: string, + itemId: string, + updates: Partial + ) { + const setUpdates: Record = {}; + for (const [key, val] of Object.entries(updates)) { + // Flatten parameters mapping them precisely to the positional positional matched index + setUpdates[`items.$.${key}`] = val; + } + + return ShoppingListModel.findOneAndUpdate( + { _id: id, householdId, 'items.id': itemId }, + { $set: setUpdates }, + { new: true } + ).lean().exec(); + } + + public async removeItem(id: string, householdId: string, itemId: string) { + return ShoppingListModel.findOneAndUpdate( + { _id: id, householdId }, + { $pull: { items: { id: itemId } } }, + { new: true } + ).lean().exec(); + } +} diff --git a/packages/api/src/modules/shopping-lists/shopping-lists.routes.test.ts b/packages/api/src/modules/shopping-lists/shopping-lists.routes.test.ts new file mode 100644 index 0000000..5c3d116 --- /dev/null +++ b/packages/api/src/modules/shopping-lists/shopping-lists.routes.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import { fastifyAwilixPlugin } from '@fastify/awilix'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; + +vi.mock('jose', () => ({ + createRemoteJWKSet: vi.fn(() => 'mock-jwks'), + jwtVerify: vi.fn().mockResolvedValue({ + payload: { + sub: 'kc-1', + email: 'test@example.com', + realm_access: { roles: ['member'] }, + householdIds: ['hh1'], + }, + protectedHeader: {}, + key: {}, + }), +})); + +const mockList = vi.fn(); +const mockFindById = vi.fn(); +const mockCreate = vi.fn(); +const mockUpdate = vi.fn(); +const mockDelete = vi.fn(); +const mockAddItem = vi.fn(); +const mockUpdateItem = vi.fn(); +const mockRemoveItem = vi.fn(); + +vi.mock('./shopping-lists.repository.js', () => ({ + ShoppingListsRepository: class { + list = mockList; + findById = mockFindById; + create = mockCreate; + update = mockUpdate; + delete = mockDelete; + addItem = mockAddItem; + updateItem = mockUpdateItem; + removeItem = mockRemoveItem; + }, +})); + +vi.mock('../meal-plans/shopping-gap.service.js', () => ({ + ShoppingGapService: class { + calculateGap = vi.fn().mockResolvedValue({ missingItems: [] }); + }, +})); + +vi.mock('../pantry/pantry.service.js', () => ({ + PantryService: class { + create = vi.fn().mockResolvedValue({ _id: 'pant1' }); + }, +})); + +vi.mock('../products/products.repository.js', () => ({ + ProductsRepository: class { + findById = vi.fn().mockResolvedValue({ category: 'dairy' }); + }, +})); + +vi.mock('../prices/prices.service.js', () => ({ + PricesService: class { + estimatePrice = vi.fn().mockResolvedValue(5.0); + recordPrice = vi.fn().mockResolvedValue({}); + compareStores = vi.fn().mockResolvedValue([]); + }, +})); + +vi.mock('../meal-plans/meal-plans.repository.js', () => ({ + MealPlanRepository: class { + findById = vi.fn().mockResolvedValue({ _id: 'mp1', weekStartDate: new Date() }); + update = vi.fn().mockResolvedValue({}); + }, +})); + +vi.mock('../users/users.repository.js', () => ({ + UsersRepository: class { + findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); + upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); + }, +})); + +import authPlugin from '../../plugins/auth.plugin.js'; +import householdPlugin from '../../plugins/household.plugin.js'; +import usersRoutes from '../users/users.routes.js'; +import shoppingListsRoutes from './shopping-lists.routes.js'; + +describe('shopping-lists.routes', () => { + let app: any; + + async function buildTestApp() { + const instance = Fastify({ logger: false }); + instance.setValidatorCompiler(validatorCompiler); + instance.setSerializerCompiler(serializerCompiler); + await instance.register(fastifyAwilixPlugin, { + disposeOnClose: true, + disposeOnResponse: true, + strictBooleanEnforced: true, + }); + await instance.register(authPlugin); + await instance.register(householdPlugin); + await instance.register(usersRoutes); + await instance.register(shoppingListsRoutes); + await instance.ready(); + return instance; + } + + const authHeaders = { authorization: 'Bearer valid' }; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildTestApp(); + }); + + afterEach(async () => { + if (app) await app.close(); + }); + + function makeShoppingList(overrides = {}) { + return { + _id: 'list1', + householdId: 'hh1', + name: 'Weekly Checklist', + items: [], + status: 'active', + createdBy: 'kc-1', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; + } + + describe('GET /api/v1/households/:householdId/shopping-lists', () => { + it('returns all lists belonging to household', async () => { + mockList.mockResolvedValue([makeShoppingList()]); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/shopping-lists', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveLength(1); + }); + }); + + describe('POST /api/v1/households/:householdId/shopping-lists', () => { + it('persists metadata and returns 201 response', async () => { + mockCreate.mockImplementation(arg => Promise.resolve({ ...arg, _id: 'newListA', createdAt: new Date(), updatedAt: new Date() })); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/shopping-lists', + headers: { ...authHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Costco Run', + items: [] + }), + }); + + expect(res.statusCode).toBe(201); + expect(res.json().name).toBe('Costco Run'); + }); + }); + + describe('POST /api/v1/households/:householdId/shopping-lists/:id/items', () => { + it('adds new checklist subdocument item generating tracking UUIDs', async () => { + mockFindById.mockResolvedValue(makeShoppingList()); + const updated = makeShoppingList({ + items: [{ id: 'itemuuid123', productId: 'p1', quantity: 1, unit: 'piece', checked: false, addedToPantry: false }] + }); + mockAddItem.mockResolvedValue(updated); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/shopping-lists/list1/items', + headers: { ...authHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ + productId: 'p1', + quantity: 1, + unit: 'piece' + }), + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.items).toHaveLength(1); + expect(body.items[0].productId).toBe('p1'); + }); + }); + + describe('POST /api/v1/households/:householdId/shopping-lists/:id/sync-to-pantry', () => { + it('executes batch synchronized promotions resulting in completed summaries', async () => { + const populatedList = makeShoppingList({ + items: [{ id: 'itemA', productId: 'p1', checked: true, addedToPantry: false, quantity: 1, unit: 'piece' }] + }); + mockFindById.mockResolvedValue(populatedList); + mockUpdateItem.mockResolvedValue({}); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/shopping-lists/list1/sync-to-pantry', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.addedCount).toBe(1); + }); + }); +}); diff --git a/packages/api/src/modules/shopping-lists/shopping-lists.routes.ts b/packages/api/src/modules/shopping-lists/shopping-lists.routes.ts new file mode 100644 index 0000000..8117035 --- /dev/null +++ b/packages/api/src/modules/shopping-lists/shopping-lists.routes.ts @@ -0,0 +1,376 @@ +import fp from 'fastify-plugin'; +import { asClass, Lifetime } from 'awilix'; +import type { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { z } from 'zod/v4'; +import type { WebSocket } from 'ws'; +import { + CreateShoppingListSchema, + UpdateShoppingListSchema, + AddShoppingItemSchema, + UpdateShoppingItemSchema, + ShoppingListResponseSchema, + ShoppingListSyncToPantryResponseSchema, + BasketStoreComparisonResponseSchema, +} from '@meshitrack/shared'; +import { ShoppingListsRepository } from './shopping-lists.repository.js'; +import { ShoppingListsService } from './shopping-lists.service.js'; +import { ShoppingGapService } from '../meal-plans/shopping-gap.service.js'; +import { PantryService } from '../pantry/pantry.service.js'; +import { ProductsRepository } from '../products/products.repository.js'; +import { PricesService } from '../prices/prices.service.js'; +import { MealPlanRepository } from '../meal-plans/meal-plans.repository.js'; +import { PantryRepository } from '../pantry/pantry.repository.js'; +import { RecipesRepository } from '../recipes/recipes.repository.js'; +import { StoresRepository } from '../stores/stores.repository.js'; +import { PricesRepository } from '../prices/prices.repository.js'; + +// Memory track for live concurrent websocket clients per active list session +const activeListSockets = new Map>(); + +function broadcastToList(listId: string, excludeSocket: WebSocket, message: any) { + const set = activeListSockets.get(listId); + if (!set) return; + const payload = JSON.stringify(message); + for (const socket of set) { + if (socket !== excludeSocket && socket.readyState === 1 /* OPEN */) { + socket.send(payload); + } + } +} + +declare module '@fastify/awilix' { + interface Cradle { + shoppingListsRepository: ShoppingListsRepository; + shoppingListsService: ShoppingListsService; + } +} + +function serializeList(doc: any) { + return { + ...doc, + _id: doc._id.toString(), + createdAt: doc.createdAt?.toISOString(), + updatedAt: doc.updatedAt?.toISOString(), + completedAt: doc.completedAt?.toISOString(), + items: doc.items.map((it: any) => ({ + ...it, + checkedAt: it.checkedAt?.toISOString(), + })), + }; +} + +export default fp( + async (fastify) => { + // Register Awilix classes + fastify.diContainer.register({ + shoppingListsRepository: asClass(ShoppingListsRepository, { lifetime: Lifetime.SINGLETON }), + shoppingListsService: asClass(ShoppingListsService, { lifetime: Lifetime.SINGLETON }), + + // Cross-domain requirements to service the list workflow orchestrations + shoppingGapService: asClass(ShoppingGapService, { lifetime: Lifetime.SINGLETON }), + pantryService: asClass(PantryService, { lifetime: Lifetime.SINGLETON }), + pantryRepository: asClass(PantryRepository, { lifetime: Lifetime.SINGLETON }), + productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }), + pricesService: asClass(PricesService, { lifetime: Lifetime.SINGLETON }), + pricesRepository: asClass(PricesRepository, { lifetime: Lifetime.SINGLETON }), + mealPlanRepository: asClass(MealPlanRepository, { lifetime: Lifetime.SINGLETON }), + recipesRepository: asClass(RecipesRepository, { lifetime: Lifetime.SINGLETON }), + storesRepository: asClass(StoresRepository, { lifetime: Lifetime.SINGLETON }), + }); + + const app = fastify.withTypeProvider(); + const householdParams = z.object({ householdId: z.string() }); + const listIdParams = householdParams.extend({ id: z.string() }); + + // 1. Standard List CRUD + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/shopping-lists', + schema: { + params: householdParams, + response: { 200: z.array(ShoppingListResponseSchema) }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('shoppingListsService'); + const lists = await service.list(request.params.householdId); + return reply.send(lists.map(serializeList)); + }, + }); + + app.route({ + method: 'POST', + url: '/api/v1/households/:householdId/shopping-lists', + schema: { + params: householdParams, + body: CreateShoppingListSchema, + response: { 201: ShoppingListResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('shoppingListsService'); + const list = await service.create( + request.body, + request.params.householdId, + request.user.keycloakId + ); + return reply.status(201).send(serializeList(list)); + }, + }); + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/shopping-lists/:id', + schema: { + params: listIdParams, + response: { 200: ShoppingListResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('shoppingListsService'); + const list = await service.getById(request.params.id, request.params.householdId); + return reply.send(serializeList(list)); + }, + }); + + app.route({ + method: 'PATCH', + url: '/api/v1/households/:householdId/shopping-lists/:id', + schema: { + params: listIdParams, + body: UpdateShoppingListSchema, + response: { 200: ShoppingListResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('shoppingListsService'); + const list = await service.update( + request.params.id, + request.params.householdId, + request.body + ); + return reply.send(serializeList(list)); + }, + }); + + app.route({ + method: 'DELETE', + url: '/api/v1/households/:householdId/shopping-lists/:id', + schema: { + params: listIdParams, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('shoppingListsService'); + await service.delete(request.params.id, request.params.householdId); + return reply.status(204).send(); + }, + }); + + // 2. Nested Item Modifications + + app.route({ + method: 'POST', + url: '/api/v1/households/:householdId/shopping-lists/:id/items', + schema: { + params: listIdParams, + body: AddShoppingItemSchema, + response: { 201: ShoppingListResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('shoppingListsService'); + const { list, addedItem } = await service.addItem( + request.params.id, + request.params.householdId, + request.body + ); + + // Emit real-time update notification to existing connected viewers + broadcastToList(request.params.id, null as any, { + type: 'ITEM_ADDED', + item: { ...addedItem, checkedAt: undefined }, + }); + + return reply.status(201).send(serializeList(list)); + }, + }); + + app.route({ + method: 'PATCH', + url: '/api/v1/households/:householdId/shopping-lists/:id/items/:itemId', + schema: { + params: listIdParams.extend({ itemId: z.string() }), + body: UpdateShoppingItemSchema, + response: { 200: ShoppingListResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('shoppingListsService'); + const updatedList = await service.updateItem( + request.params.id, + request.params.householdId, + request.params.itemId, + request.body, + request.user.keycloakId + ); + + // Broadcast the precise item differential state update to sibling websocket listeners + const matchedItem = updatedList.items.find((i) => i.id === request.params.itemId); + if (matchedItem) { + broadcastToList(request.params.id, null as any, { + type: 'ITEM_UPDATED', + itemId: request.params.itemId, + updates: { + ...request.body, + checkedAt: matchedItem.checkedAt?.toISOString(), + checkedBy: matchedItem.checkedBy, + }, + }); + } + + return reply.send(serializeList(updatedList)); + }, + }); + + app.route({ + method: 'DELETE', + url: '/api/v1/households/:householdId/shopping-lists/:id/items/:itemId', + schema: { + params: listIdParams.extend({ itemId: z.string() }), + response: { 200: ShoppingListResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('shoppingListsService'); + const list = await service.removeItem( + request.params.id, + request.params.householdId, + request.params.itemId + ); + + broadcastToList(request.params.id, null as any, { + type: 'ITEM_REMOVED', + itemId: request.params.itemId, + }); + + return reply.send(serializeList(list)); + }, + }); + + // 3. Domain Workflow Hooks + + app.route({ + method: 'POST', + url: '/api/v1/households/:householdId/shopping-lists/from-meal-plan/:mealPlanId', + schema: { + params: householdParams.extend({ mealPlanId: z.string() }), + response: { 201: ShoppingListResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('shoppingListsService'); + const list = await service.createFromMealPlan( + request.params.mealPlanId, + request.params.householdId, + request.user.keycloakId + ); + return reply.status(201).send(serializeList(list)); + }, + }); + + app.route({ + method: 'POST', + url: '/api/v1/households/:householdId/shopping-lists/:id/sync-to-pantry', + schema: { + params: listIdParams, + response: { 200: ShoppingListSyncToPantryResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('shoppingListsService'); + const results = await service.syncCheckedToPantry( + request.params.id, + request.params.householdId, + request.user.keycloakId + ); + return reply.send(results); + }, + }); + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/shopping-lists/:id/stores', + schema: { + params: listIdParams, + response: { 200: BasketStoreComparisonResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('shoppingListsService'); + const comparison = await service.getStoreComparison( + request.params.id, + request.params.householdId + ); + return reply.send(comparison); + }, + }); + + // 4. Persist Collaborative WebSocket Handshakes + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/shopping-lists/:id/sync', + websocket: true, + handler: (socket, request) => { + const listId = request.params.id; + + // Setup connection context + if (!activeListSockets.has(listId)) { + activeListSockets.set(listId, new Set()); + } + activeListSockets.get(listId)!.add(socket); + + request.log.info({ listId }, 'Active client joined shopping list sync channel'); + + socket.on('message', async (messageBuffer) => { + try { + const payload = JSON.parse(messageBuffer.toString()); + + // Handlers for inbound events e.g. real-time toggle checks from frontends + if (payload.type === 'TOGGLE_ITEM') { + const service = fastify.diContainer.resolve('shoppingListsService'); + const updatedList = await service.updateItem( + listId, + request.params.householdId, + payload.itemId, + { checked: payload.checked }, + request.user.keycloakId + ); + + const matched = updatedList.items.find(it => it.id === payload.itemId); + + // Echo back differential confirmation to everyone else on the floor + broadcastToList(listId, socket, { + type: 'ITEM_UPDATED', + itemId: payload.itemId, + updates: { + checked: payload.checked, + checkedAt: matched?.checkedAt?.toISOString(), + checkedBy: matched?.checkedBy, + } + }); + } + } catch (err) { + request.log.error(err, 'WebSocket message sync processing error'); + } + }); + + socket.on('close', () => { + const set = activeListSockets.get(listId); + if (set) { + set.delete(socket); + if (set.size === 0) { + activeListSockets.delete(listId); + } + } + request.log.info({ listId }, 'Client severed sync handshake connection'); + }); + } + }); + }, + { + name: 'shopping-lists-routes', + dependencies: ['auth-plugin'], + } +); diff --git a/packages/api/src/modules/shopping-lists/shopping-lists.service.test.ts b/packages/api/src/modules/shopping-lists/shopping-lists.service.test.ts new file mode 100644 index 0000000..7ff91e2 --- /dev/null +++ b/packages/api/src/modules/shopping-lists/shopping-lists.service.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ShoppingListsService } from './shopping-lists.service.js'; +import { NotFoundError } from '../../common/errors.js'; + +describe('ShoppingListsService', () => { + let service: ShoppingListsService; + + const mockListsRepo = { + list: vi.fn(), + findById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + addItem: vi.fn(), + updateItem: vi.fn(), + removeItem: vi.fn(), + }; + + const mockGapService = { + calculateGap: vi.fn(), + }; + + const mockPantryService = { + create: vi.fn(), + }; + + const mockProductsRepo = { + findById: vi.fn(), + }; + + const mockPricesService = { + estimatePrice: vi.fn(), + recordPrice: vi.fn(), + compareStores: vi.fn(), + }; + + const mockMealPlanRepo = { + findById: vi.fn(), + update: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + service = new ShoppingListsService({ + shoppingListsRepository: mockListsRepo as any, + shoppingGapService: mockGapService as any, + pantryService: mockPantryService as any, + productsRepository: mockProductsRepo as any, + pricesService: mockPricesService as any, + mealPlanRepository: mockMealPlanRepo as any, + }); + }); + + describe('create', () => { + it('populates initial estimates and auto-generates internal tracking UUIDs', async () => { + mockProductsRepo.findById.mockResolvedValue({ category: 'produce' }); + mockPricesService.estimatePrice.mockResolvedValue(5); + mockListsRepo.create.mockImplementation(arg => arg); + + const result = await service.create( + { + name: 'Weekly run', + items: [{ productId: 'p1', quantity: 1, unit: 'g' as any }], + }, + 'hh1', + 'u1' + ); + + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBeDefined(); + expect(result.items[0].estimatedPrice).toBe(5); + expect(result.totalEstimatedCost).toBe(5); + }); + }); + + describe('addItem', () => { + it('hydrates single product pricing and pushes to list repository', async () => { + mockListsRepo.findById.mockResolvedValue({ _id: 'list1' }); + mockProductsRepo.findById.mockResolvedValue({ category: 'meat' }); + mockPricesService.estimatePrice.mockResolvedValue(10); + mockListsRepo.addItem.mockResolvedValue({ _id: 'list1' }); + + const res = await service.addItem('list1', 'hh1', { + productId: 'prodA', + quantity: 1, + unit: 'g' as any, + }); + + expect(mockListsRepo.addItem).toHaveBeenCalledWith( + 'list1', + 'hh1', + expect.objectContaining({ + productId: 'prodA', + estimatedPrice: 10, + category: 'meat', + }) + ); + expect(res.addedItem.id).toBeDefined(); + }); + }); + + describe('updateItem', () => { + it('injects correct checked timestamps on check-off state mutations', async () => { + mockListsRepo.updateItem.mockResolvedValue({}); + + await service.updateItem('list1', 'hh1', 'itemA', { checked: true }, 'userIdX'); + + expect(mockListsRepo.updateItem).toHaveBeenCalledWith( + 'list1', + 'hh1', + 'itemA', + expect.objectContaining({ + checked: true, + checkedBy: 'userIdX', + checkedAt: expect.any(Date), + }) + ); + }); + }); + + describe('createFromMealPlan', () => { + it('runs shopping gap report and populates distinct grocery array linked back to source plan', async () => { + mockMealPlanRepo.findById.mockResolvedValue({ _id: 'mp1', weekStartDate: '2026-05-18' }); + mockGapService.calculateGap.mockResolvedValue({ + missingItems: [ + { productId: 'gapProd', missingQuantity: 5, unit: 'g', category: 'dairy' } + ] + }); + mockPricesService.estimatePrice.mockResolvedValue(2); + mockListsRepo.create.mockResolvedValue({ _id: 'newList1' }); + + const res = await service.createFromMealPlan('mp1', 'hh1', 'userIdZ'); + + expect(mockGapService.calculateGap).toHaveBeenCalledWith('hh1', 'mp1'); + expect(mockListsRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + mealPlanId: 'mp1', + items: [ + expect.objectContaining({ + productId: 'gapProd', + quantity: 5, + estimatedPrice: 2, + }) + ] + }) + ); + // Assert link-back invocation + expect(mockMealPlanRepo.update).toHaveBeenCalledWith('mp1', 'hh1', { + shoppingListId: 'newList1', + }); + }); + }); + + describe('syncCheckedToPantry', () => { + it('iterates checked items, creating pantry items and recording actual prices in ledger', async () => { + const mockList = { + _id: 'list1', + preferredStoreId: 'storeA', + items: [ + { + id: 'itmA', + productId: 'p1', + checked: true, + addedToPantry: false, + quantity: 2, + unit: 'g', + actualPrice: 15.50, + } + ] + }; + mockListsRepo.findById.mockResolvedValue(mockList); + + const summary = await service.syncCheckedToPantry('list1', 'hh1', 'userAlpha'); + + // 1. Verify pantry promotion + expect(mockPantryService.create).toHaveBeenCalledWith( + expect.objectContaining({ + productId: 'p1', + quantity: 2, + purchasePrice: 15.50, + storeId: 'storeA', + }), + 'hh1', + 'userAlpha' + ); + + // 2. Verify point-in-time ledger price logging + expect(mockPricesService.recordPrice).toHaveBeenCalledWith( + expect.objectContaining({ + productId: 'p1', + price: 15.50, + storeId: 'storeA', + }), + 'hh1', + 'userAlpha' + ); + + // 3. Verify completion bit toggled in list subdocument + expect(mockListsRepo.updateItem).toHaveBeenCalledWith('list1', 'hh1', 'itmA', { + addedToPantry: true, + }); + + expect(summary.addedCount).toBe(1); + expect(summary.pricesLogged).toBe(1); + }); + }); + + describe('getStoreComparison', () => { + it('collates individual store deviation lists to rank optimized single store trips', async () => { + mockListsRepo.findById.mockResolvedValue({ + items: [{ productId: 'p1' }] + }); + mockPricesService.compareStores.mockResolvedValue([ + { storeId: 'sA', storeName: 'Walmart', latestPrice: 10 }, + { storeId: 'sB', storeName: 'Whole Foods', latestPrice: 18 }, + ]); + + const comparison = await service.getStoreComparison('list1', 'hh1'); + expect(comparison.singleStoreOptions).toHaveLength(2); + expect(comparison.singleStoreOptions[0].storeName).toBe('Walmart'); + expect(comparison.singleStoreOptions[0].estimatedTotal).toBe(10); + }); + }); +}); diff --git a/packages/api/src/modules/shopping-lists/shopping-lists.service.ts b/packages/api/src/modules/shopping-lists/shopping-lists.service.ts new file mode 100644 index 0000000..a817813 --- /dev/null +++ b/packages/api/src/modules/shopping-lists/shopping-lists.service.ts @@ -0,0 +1,356 @@ +import type { ShoppingListsRepository } from './shopping-lists.repository.js'; +import type { ShoppingGapService } from '../meal-plans/shopping-gap.service.js'; +import type { PantryService } from '../pantry/pantry.service.js'; +import type { ProductsRepository } from '../products/products.repository.js'; +import type { PricesService } from '../prices/prices.service.js'; +import type { MealPlanRepository } from '../meal-plans/meal-plans.repository.js'; +import type { + CreateShoppingListInput, + UpdateShoppingListInput, + AddShoppingItemInput, + UpdateShoppingItemInput, + ShoppingListStatus, + ShoppingItem, +} from '@meshitrack/shared'; +import { ShoppingListSourceType } from '@meshitrack/shared'; +import { StorageLocation, ServingUnit } from '@meshitrack/shared'; +import { NotFoundError } from '../../common/errors.js'; +import { v4 as uuidv4 } from 'uuid'; + +interface Deps { + shoppingListsRepository: ShoppingListsRepository; + shoppingGapService: ShoppingGapService; + pantryService: PantryService; + productsRepository: ProductsRepository; + pricesService: PricesService; + mealPlanRepository: MealPlanRepository; +} + +export class ShoppingListsService { + private readonly shoppingListsRepository: ShoppingListsRepository; + private readonly shoppingGapService: ShoppingGapService; + private readonly pantryService: PantryService; + private readonly productsRepository: ProductsRepository; + private readonly pricesService: PricesService; + private readonly mealPlanRepository: MealPlanRepository; + + public constructor({ + shoppingListsRepository, + shoppingGapService, + pantryService, + productsRepository, + pricesService, + mealPlanRepository, + }: Deps) { + this.shoppingListsRepository = shoppingListsRepository; + this.shoppingGapService = shoppingGapService; + this.pantryService = pantryService; + this.productsRepository = productsRepository; + this.pricesService = pricesService; + this.mealPlanRepository = mealPlanRepository; + } + + public async list(householdId: string) { + return this.shoppingListsRepository.list(householdId); + } + + public async getById(id: string, householdId: string) { + const list = await this.shoppingListsRepository.findById(id, householdId); + if (!list) throw new NotFoundError('Shopping list not found'); + return list; + } + + public async create(data: CreateShoppingListInput, householdId: string, userId: string) { + // Optionally calculate estimates for pre-populated items + const hydratedItems: ShoppingItem[] = []; + let runningTotal = 0; + + for (const it of data.items || []) { + const itemId = uuidv4(); + let estimatedPrice: number | undefined; + let category: string | undefined = it.category; + + if (it.productId) { + const [p, est] = await Promise.all([ + this.productsRepository.findById(it.productId, householdId), + this.pricesService.estimatePrice(it.productId, householdId, data.preferredStoreId), + ]); + if (p) category = category || p.category; + if (est) { + estimatedPrice = est; + runningTotal += est; + } + } + + hydratedItems.push({ + id: itemId, + productId: it.productId, + customName: it.customName, + quantity: it.quantity, + unit: it.unit, + checked: false, + addedToPantry: false, + notes: it.notes, + estimatedPrice, + category: category as any, + }); + } + + return this.shoppingListsRepository.create({ + ...data, + items: hydratedItems, + householdId, + createdBy: userId, + status: 'active', + totalEstimatedCost: runningTotal > 0 ? Math.round(runningTotal * 100) / 100 : undefined, + }); + } + + public async update(id: string, householdId: string, data: UpdateShoppingListInput) { + const list = await this.getById(id, householdId); + const updated = await this.shoppingListsRepository.update(id, householdId, data); + if (!updated) throw new NotFoundError('Shopping list not found'); + return updated; + } + + public async delete(id: string, householdId: string) { + await this.getById(id, householdId); + return this.shoppingListsRepository.delete(id, householdId); + } + + // --- Live Item Actions --- + + public async addItem(id: string, householdId: string, data: AddShoppingItemInput) { + await this.getById(id, householdId); + + let estimatedPrice: number | undefined; + let category: string | undefined = data.category; + + if (data.productId) { + const [p, est] = await Promise.all([ + this.productsRepository.findById(data.productId, householdId), + this.pricesService.estimatePrice(data.productId, householdId), + ]); + if (p) category = category || p.category; + if (est) estimatedPrice = est; + } + + const newItem: ShoppingItem = { + id: uuidv4(), + productId: data.productId, + customName: data.customName, + quantity: data.quantity, + unit: data.unit, + checked: false, + addedToPantry: false, + notes: data.notes, + estimatedPrice, + category: category as any, + }; + + const updated = await this.shoppingListsRepository.addItem(id, householdId, newItem); + if (!updated) throw new NotFoundError('Shopping list not found'); + return { list: updated, addedItem: newItem }; + } + + public async updateItem( + id: string, + householdId: string, + itemId: string, + data: UpdateShoppingItemInput, + userId: string + ) { + const updates: Partial = { ...data }; + + if (data.checked !== undefined) { + updates.checkedAt = data.checked ? new Date() : undefined; + updates.checkedBy = data.checked ? userId : undefined; + } + + const updated = await this.shoppingListsRepository.updateItem(id, householdId, itemId, updates); + if (!updated) throw new NotFoundError('Shopping item or list not found'); + return updated; + } + + public async removeItem(id: string, householdId: string, itemId: string) { + const updated = await this.shoppingListsRepository.removeItem(id, householdId, itemId); + if (!updated) throw new NotFoundError('Shopping list not found'); + return updated; + } + + // --- Workflow Methods --- + + /** + * Analyzes missing ingredients for a meal plan and pre-builds a targeted list. + */ + public async createFromMealPlan(mealPlanId: string, householdId: string, userId: string) { + const plan = await this.mealPlanRepository.findById(mealPlanId, householdId); + if (!plan) throw new NotFoundError('Meal plan not found'); + + const report = await this.shoppingGapService.calculateGap(householdId, mealPlanId); + + const listItems: ShoppingItem[] = []; + let runningTotal = 0; + + for (const gap of report.missingItems) { + const estPrice = await this.pricesService.estimatePrice(gap.productId, householdId); + if (estPrice) runningTotal += estPrice; + + listItems.push({ + id: uuidv4(), + productId: gap.productId, + quantity: gap.missingQuantity, + unit: gap.unit as ServingUnit, + checked: false, + addedToPantry: false, + category: gap.category as any, + estimatedPrice: estPrice || undefined, + }); + } + + const dateStr = new Date((plan as any).weekStartDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + const name = `Groceries for Week of ${dateStr}`; + + const newList = await this.shoppingListsRepository.create({ + name, + items: listItems, + householdId, + createdBy: userId, + status: 'active', + createdFrom: { + type: ShoppingListSourceType.MEAL_PLAN, + referenceId: mealPlanId, + }, + mealPlanId, + totalEstimatedCost: runningTotal > 0 ? Math.round(runningTotal * 100) / 100 : undefined, + }); + + // Re-link backing meal plan to its generated list for simplified visual tracking + await this.mealPlanRepository.update(mealPlanId, householdId, { + shoppingListId: newList._id.toString(), + }); + + return newList; + } + + /** + * Migrates all checked grocery items into active pantry items and records final purchase prices. + */ + public async syncCheckedToPantry(id: string, householdId: string, userId: string) { + const list = await this.getById(id, householdId); + + let addedCount = 0; + let pricesLogged = 0; + + const pendingItems = list.items.filter((it) => it.checked && !it.addedToPantry && it.productId); + + for (const item of pendingItems) { + if (!item.productId) continue; + + // 1. Promote item to active pantry + await this.pantryService.create( + { + productId: item.productId, + storageLocation: StorageLocation.PANTRY, // Generic fallback location + quantity: item.quantity, + unit: item.unit as ServingUnit, + purchasePrice: item.actualPrice || undefined, + storeId: item.storeId || list.preferredStoreId || undefined, + notes: item.notes, + }, + householdId, + userId + ); + addedCount++; + + // 2. Log final point-in-time pricing ledger entry if user input final price + if (item.actualPrice !== undefined && item.actualPrice > 0) { + const storeId = item.storeId || list.preferredStoreId; + if (storeId) { + await this.pricesService.recordPrice( + { + productId: item.productId, + storeId, + price: item.actualPrice, + quantity: item.quantity, + unit: item.unit as ServingUnit, + currency: 'USD', + }, + householdId, + userId + ); + pricesLogged++; + } + } + + // 3. Set item flag avoiding future sync duplications + await this.shoppingListsRepository.updateItem(id, householdId, item.id, { + addedToPantry: true, + }); + } + + return { addedCount, pricesLogged }; + } + + /** + * Analyzes price history to identify the cheapest store option for this basket. + */ + public async getStoreComparison(id: string, householdId: string) { + const list = await this.getById(id, householdId); + const validItems = list.items.filter((it) => it.productId); + + // 1. Collate all recent pricing permutations for all products in this basket + const storePricesMap = new Map>(); // storeId -> Map + const storeNamesMap = new Map(); + const allProductIds = validItems.map((it) => it.productId!); + + for (const productId of allProductIds) { + const options = await this.pricesService.compareStores(productId, householdId); + for (const opt of options) { + storeNamesMap.set(opt.storeId, opt.storeName); + if (!storePricesMap.has(opt.storeId)) { + storePricesMap.set(opt.storeId, new Map()); + } + storePricesMap.get(opt.storeId)!.set(productId, opt.latestPrice); + } + } + + // 2. Compile comparative totals per store option + const singleStoreOptions: Array<{ + storeId: string; + storeName: string; + estimatedTotal: number; + itemsCovered: number; + itemsMissing: string[]; + }> = []; + + for (const [storeId, priceMap] of storePricesMap.entries()) { + let sum = 0; + let count = 0; + const missing: string[] = []; + + for (const item of validItems) { + const p = priceMap.get(item.productId!); + if (p !== undefined) { + sum += p; // Simple unit scaling could be factored in, using simple latest price sum here + count++; + } else { + missing.push(item.productId!); + } + } + + singleStoreOptions.push({ + storeId, + storeName: storeNamesMap.get(storeId) || 'Store', + estimatedTotal: Math.round(sum * 100) / 100, + itemsCovered: count, + itemsMissing: missing, + }); + } + + // Sort to surface the cheapest/fullest single store options first + singleStoreOptions.sort((a, b) => b.itemsCovered - a.itemsCovered || a.estimatedTotal - b.estimatedTotal); + + return { singleStoreOptions }; + } +} diff --git a/packages/api/src/schemas/price-record.schema.ts b/packages/api/src/schemas/price-record.schema.ts new file mode 100644 index 0000000..700fd25 --- /dev/null +++ b/packages/api/src/schemas/price-record.schema.ts @@ -0,0 +1,31 @@ +import mongoose from 'mongoose'; + +const priceRecordSchema = new mongoose.Schema( + { + householdId: { type: String, required: true }, + productId: { type: String, required: true }, + productName: { type: String, required: true }, + storeId: { type: String, required: true }, + storeName: { type: String, required: true }, + price: { type: Number, required: true }, + currency: { type: String, required: true }, + quantity: { type: Number, required: true }, + unit: { type: String, required: true }, + pricePerUnit: { type: Number, required: true }, + date: { type: Date, required: true }, + receiptImageUrl: { type: String }, + notes: { type: String }, + createdBy: { type: String, required: true }, + }, + { timestamps: { createdAt: true, updatedAt: false } } +); + +// Performance Indexes for Lookup Speed and Aggregations +priceRecordSchema.index({ householdId: 1, productId: 1, storeId: 1, date: -1 }); +priceRecordSchema.index({ householdId: 1, productId: 1, date: -1 }); +priceRecordSchema.index({ householdId: 1, storeId: 1, date: -1 }); + +export const PriceRecordModel = mongoose.model('PriceRecord', priceRecordSchema); +export type PriceRecordDocument = mongoose.InferSchemaType & { + _id: mongoose.Types.ObjectId; +}; diff --git a/packages/api/src/schemas/shopping-list.schema.ts b/packages/api/src/schemas/shopping-list.schema.ts new file mode 100644 index 0000000..ed3e56e --- /dev/null +++ b/packages/api/src/schemas/shopping-list.schema.ts @@ -0,0 +1,54 @@ +import mongoose from 'mongoose'; + +const shoppingItemSchema = new mongoose.Schema( + { + id: { type: String, required: true }, // Client or server generated tracking UUID + productId: { type: String }, + customName: { type: String }, + quantity: { type: Number, required: true }, + unit: { type: String, required: true }, + checked: { type: Boolean, required: true, default: false }, + checkedAt: { type: Date }, + checkedBy: { type: String }, + estimatedPrice: { type: Number }, + actualPrice: { type: Number }, + storeId: { type: String }, + notes: { type: String }, + category: { type: String }, + addedToPantry: { type: Boolean, required: true, default: false }, + }, + { _id: false } +); + +const shoppingListSchema = new mongoose.Schema( + { + householdId: { type: String, required: true }, + name: { type: String, required: true }, + items: { type: [shoppingItemSchema], required: true, default: [] }, + status: { type: String, required: true }, // values from ShoppingListStatus + createdFrom: { + type: { + type: { type: String, required: true }, // values from ShoppingListSourceType + referenceId: { type: String }, + }, + required: false, + _id: false, + }, + mealPlanId: { type: String }, + totalEstimatedCost: { type: Number }, + preferredStoreId: { type: String }, + completedAt: { type: Date }, + createdBy: { type: String, required: true }, + }, + { timestamps: true } +); + +shoppingListSchema.index({ householdId: 1, status: 1 }); +shoppingListSchema.index({ householdId: 1, createdAt: -1 }); + +export const ShoppingListModel = mongoose.model('ShoppingList', shoppingListSchema); +export type ShoppingListDocument = mongoose.InferSchemaType & { + _id: mongoose.Types.ObjectId; + createdAt: Date; + updatedAt: Date; +}; diff --git a/packages/shared/src/enums/index.ts b/packages/shared/src/enums/index.ts index c5dd98a..9626d69 100644 --- a/packages/shared/src/enums/index.ts +++ b/packages/shared/src/enums/index.ts @@ -9,3 +9,4 @@ export * from './product.enums.js'; export * from './recipe.enums.js'; export * from './pantry.enums.js'; export * from './meal-plan.enums.js'; +export * from './shopping-list.enums.js'; diff --git a/packages/shared/src/enums/shopping-list.enums.test.ts b/packages/shared/src/enums/shopping-list.enums.test.ts new file mode 100644 index 0000000..b08f699 --- /dev/null +++ b/packages/shared/src/enums/shopping-list.enums.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { ShoppingListStatus, ShoppingListSourceType } from './shopping-list.enums.js'; + +describe('ShoppingList Enums', () => { + it('should have correct ShoppingListStatus values', () => { + expect(ShoppingListStatus.ACTIVE).toBe('active'); + expect(ShoppingListStatus.SHOPPING).toBe('shopping'); + expect(ShoppingListStatus.COMPLETED).toBe('completed'); + expect(ShoppingListStatus.ARCHIVED).toBe('archived'); + }); + + it('should have correct ShoppingListSourceType values', () => { + expect(ShoppingListSourceType.MEAL_PLAN).toBe('meal_plan'); + expect(ShoppingListSourceType.MANUAL).toBe('manual'); + expect(ShoppingListSourceType.PANTRY_RESTOCK).toBe('pantry_restock'); + }); +}); diff --git a/packages/shared/src/enums/shopping-list.enums.ts b/packages/shared/src/enums/shopping-list.enums.ts new file mode 100644 index 0000000..3bb88c8 --- /dev/null +++ b/packages/shared/src/enums/shopping-list.enums.ts @@ -0,0 +1,18 @@ +/** + * State progression for grocery shopping lists + */ +export enum ShoppingListStatus { + ACTIVE = 'active', + SHOPPING = 'shopping', + COMPLETED = 'completed', + ARCHIVED = 'archived', +} + +/** + * Identifies the genesis of a generated shopping list + */ +export enum ShoppingListSourceType { + MEAL_PLAN = 'meal_plan', + MANUAL = 'manual', + PANTRY_RESTOCK = 'pantry_restock', +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 5321fae..de2be27 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -17,3 +17,5 @@ export * from './pantry.js'; export * from './meal-plan.js'; export * from './nutrition-target.js'; export * from './freshness.js'; +export * from './price-record.js'; +export * from './shopping-list.js'; diff --git a/packages/shared/src/types/price-record.ts b/packages/shared/src/types/price-record.ts new file mode 100644 index 0000000..851050c --- /dev/null +++ b/packages/shared/src/types/price-record.ts @@ -0,0 +1,23 @@ +import type { ServingUnit } from '../enums/product.enums.js'; + +/** + * Historical transaction capturing store product pricing + */ +export interface PriceRecord { + id: string; + householdId: string; + productId: string; + productName: string; // Denormalized for quick display + storeId: string; + storeName: string; // Denormalized for quick display + price: number; + currency: string; + quantity: number; + unit: ServingUnit; + pricePerUnit: number; // price / quantity (computed/normalized value) + date: Date; + receiptImageUrl?: string; + notes?: string; + createdBy: string; + createdAt: Date; +} diff --git a/packages/shared/src/types/shopping-list.ts b/packages/shared/src/types/shopping-list.ts new file mode 100644 index 0000000..2305a79 --- /dev/null +++ b/packages/shared/src/types/shopping-list.ts @@ -0,0 +1,49 @@ +import type { ServingUnit, ProductCategory } from '../enums/product.enums.js'; +import type { ShoppingListStatus, ShoppingListSourceType } from '../enums/shopping-list.enums.js'; + +/** + * Captures a targeted purchasable item within a shopping checklist + */ +export interface ShoppingItem { + id: string; // Random reference token for live sync transactions + productId?: string; // Reference to general inventory product + customName?: string; // Arbitrary string label for custom ad-hoc additions + quantity: number; + unit: ServingUnit; + checked: boolean; + checkedAt?: Date; + checkedBy?: string; + estimatedPrice?: number; // Guessed via historical lookup + actualPrice?: number; // Final value input by active shopper + storeId?: string; // Local target retail outlet + notes?: string; + category?: ProductCategory; // Logical aisle group + addedToPantry: boolean; // Tracking bit for pantry inventory migrations +} + +/** + * Encodes generating origins for auto-lists + */ +export interface ShoppingListSource { + type: ShoppingListSourceType; + referenceId?: string; // ID pointing to generating instance (e.g. MealPlan ID) +} + +/** + * Structured grocery list managing household inventory provisioning + */ +export interface ShoppingList { + id: string; + householdId: string; + name: string; + items: ShoppingItem[]; + status: ShoppingListStatus; + createdFrom?: ShoppingListSource; + mealPlanId?: string; // Explicit quick reference pointer if derived from plan + totalEstimatedCost?: number; // Summed pre-checkout estimates + preferredStoreId?: string; + completedAt?: Date; + createdBy: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/packages/shared/src/validation/index.ts b/packages/shared/src/validation/index.ts index 7b13254..a245709 100644 --- a/packages/shared/src/validation/index.ts +++ b/packages/shared/src/validation/index.ts @@ -15,3 +15,5 @@ export * from './nutrition-target.schemas.js'; export * from './recipe.schemas.js'; export * from './pantry.schemas.js'; export * from './freshness-rule.schemas.js'; +export * from './price-record.schemas.js'; +export * from './shopping-list.schemas.js'; diff --git a/packages/shared/src/validation/price-record.schemas.test.ts b/packages/shared/src/validation/price-record.schemas.test.ts new file mode 100644 index 0000000..22b3fda --- /dev/null +++ b/packages/shared/src/validation/price-record.schemas.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { CreatePriceRecordSchema, BulkPriceRecordInputSchema } from './price-record.schemas.js'; +import { ServingUnit } from '../enums/product.enums.js'; + +describe('PriceRecord Schemas', () => { + describe('CreatePriceRecordSchema', () => { + it('should validate a valid payload', () => { + const payload = { + productId: 'prod-1', + storeId: 'store-1', + price: 4.99, + currency: 'USD', + quantity: 500, + unit: ServingUnit.GRAMS, + notes: 'On sale', + }; + const result = CreatePriceRecordSchema.safeParse(payload); + expect(result.success).toBe(true); + }); + + it('should reject negative prices', () => { + const payload = { + productId: 'prod-1', + storeId: 'store-1', + price: -1.5, + quantity: 500, + unit: ServingUnit.GRAMS, + }; + const result = CreatePriceRecordSchema.safeParse(payload); + expect(result.success).toBe(false); + }); + }); + + describe('BulkPriceRecordInputSchema', () => { + it('should validate valid bulk inputs', () => { + const payload = { + storeId: 'store-2', + items: [ + { productId: 'p1', price: 2.5, quantity: 1, unit: ServingUnit.PIECES }, + { productId: 'p2', price: 3.0, quantity: 100, unit: ServingUnit.MILLILITERS }, + ], + }; + const result = BulkPriceRecordInputSchema.safeParse(payload); + expect(result.success).toBe(true); + }); + + it('should require at least one item', () => { + const payload = { + storeId: 'store-2', + items: [], + }; + const result = BulkPriceRecordInputSchema.safeParse(payload); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/packages/shared/src/validation/price-record.schemas.ts b/packages/shared/src/validation/price-record.schemas.ts new file mode 100644 index 0000000..24ae824 --- /dev/null +++ b/packages/shared/src/validation/price-record.schemas.ts @@ -0,0 +1,118 @@ +import { z } from 'zod/v4'; +import { ServingUnit } from '../enums/product.enums.js'; + +export const CreatePriceRecordSchema = z.object({ + productId: z.string().min(1), + storeId: z.string().min(1), + price: z.number().positive(), + currency: z.string().min(1).max(10).default('USD'), + quantity: z.number().positive(), + unit: z.nativeEnum(ServingUnit), + date: z.iso.datetime().optional(), + notes: z.string().max(1000).trim().optional(), + receiptImageUrl: z.string().url().optional(), +}); + +export const BulkPriceRecordInputSchema = z.object({ + storeId: z.string().min(1), + date: z.iso.datetime().optional(), + items: z.array( + z.object({ + productId: z.string().min(1), + price: z.number().positive(), + quantity: z.number().positive(), + unit: z.nativeEnum(ServingUnit), + notes: z.string().max(1000).trim().optional(), + }) + ).min(1), +}); + +export const PriceHistoryQuerySchema = z.object({ + storeId: z.string().optional(), + startDate: z.iso.datetime().optional(), + endDate: z.iso.datetime().optional(), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +export const PriceRecordResponseSchema = z.object({ + _id: z.string(), + householdId: z.string(), + productId: z.string(), + productName: z.string(), + storeId: z.string(), + storeName: z.string(), + price: z.number(), + currency: z.string(), + quantity: z.number(), + unit: z.nativeEnum(ServingUnit), + pricePerUnit: z.number(), + date: z.string(), + receiptImageUrl: z.string().optional(), + notes: z.string().optional(), + createdBy: z.string(), + createdAt: z.string(), +}); + +export const StorePriceComparisonSchema = z.object({ + storeId: z.string(), + storeName: z.string(), + latestPrice: z.number(), + latestPricePerUnit: z.number(), + currency: z.string(), + date: z.string(), +}); + +export const PriceAnalyticsResponseSchema = z.object({ + averageBasketByStore: z.array( + z.object({ + storeId: z.string(), + storeName: z.string(), + avgTotal: z.number(), + tripCount: z.number(), + }) + ), + priceAlerts: z.array( + z.object({ + productId: z.string(), + productName: z.string(), + storeId: z.string(), + storeName: z.string(), + previousPrice: z.number(), + currentPrice: z.number(), + changePercent: z.number(), + date: z.string(), + }) + ), + spendingOverTime: z.array( + z.object({ + period: z.string(), + total: z.number(), + }) + ), + spendingByCategory: z.array( + z.object({ + category: z.string(), + total: z.number(), + avgPerItem: z.number(), + }) + ), +}); + +export const PriceHistoryResponseSchema = z.object({ + data: z.array(PriceRecordResponseSchema), + pagination: z.object({ + cursor: z.string().nullable(), + hasMore: z.boolean(), + }), +}); + +export const FoodStoreComparisonResponseSchema = z.object({ + data: z.array(StorePriceComparisonSchema), +}); + +export const FoodSpendingAnalyticsResponseSchema = PriceAnalyticsResponseSchema; + +export type CreatePriceRecordInput = z.infer; +export type BulkPriceRecordInput = z.infer; +export type PriceHistoryQueryInput = z.infer; diff --git a/packages/shared/src/validation/shopping-list.schemas.test.ts b/packages/shared/src/validation/shopping-list.schemas.test.ts new file mode 100644 index 0000000..1fb274c --- /dev/null +++ b/packages/shared/src/validation/shopping-list.schemas.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { + CreateShoppingListSchema, + AddShoppingItemSchema, + UpdateShoppingItemSchema, +} from './shopping-list.schemas.js'; +import { ServingUnit } from '../enums/product.enums.js'; + +describe('ShoppingList Schemas', () => { + describe('CreateShoppingListSchema', () => { + it('should accept lists with items', () => { + const payload = { + name: 'Weekly run', + items: [ + { productId: 'p1', quantity: 2, unit: ServingUnit.PIECES }, + { customName: 'Apples', quantity: 10, unit: ServingUnit.PIECES }, + ], + }; + const result = CreateShoppingListSchema.safeParse(payload); + expect(result.success).toBe(true); + }); + + it('should default to empty items array', () => { + const payload = { name: 'Mini list' }; + const result = CreateShoppingListSchema.safeParse(payload); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.items).toEqual([]); + } + }); + }); + + describe('AddShoppingItemSchema', () => { + it('should pass with a productId', () => { + const payload = { productId: 'prod-123', quantity: 1, unit: ServingUnit.MILLILITERS }; + const result = AddShoppingItemSchema.safeParse(payload); + expect(result.success).toBe(true); + }); + + it('should pass with a customName', () => { + const payload = { customName: 'Fresh fish', quantity: 1.5, unit: ServingUnit.GRAMS }; + const result = AddShoppingItemSchema.safeParse(payload); + expect(result.success).toBe(true); + }); + + it('should fail if both are omitted', () => { + const payload = { quantity: 5, unit: ServingUnit.PIECES }; + const result = AddShoppingItemSchema.safeParse(payload); + expect(result.success).toBe(false); + }); + }); + + describe('UpdateShoppingItemSchema', () => { + it('should accept partial updates', () => { + const payload = { checked: true, actualPrice: 2.99 }; + const result = UpdateShoppingItemSchema.safeParse(payload); + expect(result.success).toBe(true); + }); + + it('should reject negative prices on checkoff', () => { + const payload = { actualPrice: -0.5 }; + const result = UpdateShoppingItemSchema.safeParse(payload); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/packages/shared/src/validation/shopping-list.schemas.ts b/packages/shared/src/validation/shopping-list.schemas.ts new file mode 100644 index 0000000..b26c00b --- /dev/null +++ b/packages/shared/src/validation/shopping-list.schemas.ts @@ -0,0 +1,122 @@ +import { z } from 'zod/v4'; +import { ServingUnit, ProductCategory } from '../enums/product.enums.js'; +import { ShoppingListStatus, ShoppingListSourceType } from '../enums/shopping-list.enums.js'; + +export const ShoppingItemSchema = z.object({ + id: z.string(), + productId: z.string().optional(), + customName: z.string().optional(), + quantity: z.number().positive(), + unit: z.nativeEnum(ServingUnit), + checked: z.boolean().default(false), + checkedAt: z.iso.datetime().optional(), + checkedBy: z.string().optional(), + estimatedPrice: z.number().optional(), + actualPrice: z.number().optional(), + storeId: z.string().optional(), + notes: z.string().optional(), + category: z.nativeEnum(ProductCategory).optional(), + addedToPantry: z.boolean().default(false), +}); + +export const CreateShoppingListSchema = z.object({ + name: z.string().min(1).max(100).trim(), + preferredStoreId: z.string().optional(), + items: z.array( + z.object({ + productId: z.string().optional(), + customName: z.string().optional(), + quantity: z.number().positive(), + unit: z.nativeEnum(ServingUnit), + notes: z.string().optional(), + category: z.nativeEnum(ProductCategory).optional(), + }) + ).optional().default([]), +}); + +export const UpdateShoppingListSchema = z.object({ + name: z.string().min(1).max(100).trim().optional(), + status: z.nativeEnum(ShoppingListStatus).optional(), + preferredStoreId: z.string().optional(), + items: z.array(ShoppingItemSchema).optional(), +}); + +export const AddShoppingItemSchema = z.object({ + productId: z.string().optional(), + customName: z.string().optional(), + quantity: z.number().positive(), + unit: z.nativeEnum(ServingUnit), + notes: z.string().max(500).trim().optional(), + category: z.nativeEnum(ProductCategory).optional(), +}).refine( + (data) => data.productId || data.customName, + { message: 'Must provide either a productId or customName' } +); + +export const UpdateShoppingItemSchema = z.object({ + quantity: z.number().positive().optional(), + unit: z.nativeEnum(ServingUnit).optional(), + checked: z.boolean().optional(), + actualPrice: z.number().nonnegative().optional(), + storeId: z.string().optional(), + notes: z.string().max(500).trim().optional(), +}); + +export const ShoppingListResponseSchema = z.object({ + _id: z.string(), + householdId: z.string(), + name: z.string(), + status: z.nativeEnum(ShoppingListStatus), + items: z.array(ShoppingItemSchema), + createdFrom: z.object({ + type: z.nativeEnum(ShoppingListSourceType), + referenceId: z.string().optional(), + }).optional(), + mealPlanId: z.string().optional(), + totalEstimatedCost: z.number().optional(), + preferredStoreId: z.string().optional(), + completedAt: z.string().optional(), + createdBy: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export const ShoppingListListResponseSchema = z.object({ + data: z.array(ShoppingListResponseSchema), +}); + +export const StoreComparisonResultSchema = z.object({ + singleStoreOptions: z.array( + z.object({ + storeId: z.string(), + storeName: z.string(), + estimatedTotal: z.number(), + itemsCovered: z.number(), + itemsMissing: z.array(z.string()), + }) + ), + splitStoreOption: z.object({ + stores: z.array( + z.object({ + storeId: z.string(), + storeName: z.string(), + items: z.array(z.string()), + subtotal: z.number(), + }) + ), + estimatedTotal: z.number(), + savingsVsBestSingleStore: z.number(), + }).optional(), +}); + +export const BasketStoreComparisonResponseSchema = StoreComparisonResultSchema; + +export const ShoppingListSyncToPantryResponseSchema = z.object({ + addedCount: z.number(), + pricesLogged: z.number(), +}); + +export type CreateShoppingListInput = z.infer; +export type UpdateShoppingListInput = z.infer; +export type AddShoppingItemInput = z.infer; +export type UpdateShoppingItemInput = z.infer; diff --git a/packages/web/package.json b/packages/web/package.json index c449092..080d27d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -19,6 +19,7 @@ "next-auth": "^5.0.0-beta.30", "react": "^19.2.0", "react-dom": "^19.2.0", + "recharts": "^3.8.1", "swr": "^2.3.0" }, "devDependencies": { diff --git a/packages/web/src/app/(dashboard)/shopping-lists/[id]/page.tsx b/packages/web/src/app/(dashboard)/shopping-lists/[id]/page.tsx new file mode 100644 index 0000000..bff92bd --- /dev/null +++ b/packages/web/src/app/(dashboard)/shopping-lists/[id]/page.tsx @@ -0,0 +1,463 @@ +'use client'; + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useApi } from '@/lib/useApi'; +import { useParams, useRouter } from 'next/navigation'; +import { SetPageHeader } from '@/components/layout/SetPageHeader'; +import { Card, Button, Icon, Pill } from '@/components/ui'; +import { + getShoppingList, + addShoppingItem, + updateShoppingItem, + removeShoppingItem, + syncToPantry, + getBasketStoreComparison, + updateShoppingList, +} from '@/services/shopping-lists'; +import { listProducts } from '@/services/products'; +import { useShoppingListSync } from '@/lib/useShoppingListSync'; + +export default function ShoppingListDetailsPage() { + const { householdId, isLoading: isAuthLoading } = useApi(); + const { id: listId } = useParams() as { id: string }; + const router = useRouter(); + + // List state + const [list, setList] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + // Side panel States + const [storeOptions, setStoreOptions] = useState([]); + const [isStoreLoading, setIsStoreLoading] = useState(false); + + // Form states for Add Item + const [products, setProducts] = useState([]); + const [selectedProductId, setSelectedProductId] = useState(''); + const [customItemName, setCustomItemName] = useState(''); + const [qty, setQty] = useState(1); + const [unit, setUnit] = useState('g'); + const [notes, setNotes] = useState(''); + const [isAdding, setIsAdding] = useState(false); + + // Load core list context + const fetchList = useCallback(async () => { + if (!householdId || !listId) return; + setLoading(true); + try { + const data = await getShoppingList(householdId, listId); + setList(data); + } catch (err: any) { + setError(err.message || 'Shopping list not found'); + } finally { + setLoading(false); + } + }, [householdId, listId]); + + // Run price comparisons + const fetchComparisons = useCallback(async () => { + if (!householdId || !listId) return; + setIsStoreLoading(true); + try { + const comparison = await getBasketStoreComparison(householdId, listId); + setStoreOptions(comparison.singleStoreOptions || []); + } catch (err) { + console.error('Comparison load fail', err); + } finally { + setIsStoreLoading(false); + } + }, [householdId, listId]); + + useEffect(() => { + fetchList(); + }, [fetchList]); + + // Pre-load household products for predictive inputs + useEffect(() => { + if (!householdId) return; + listProducts(householdId).then(res => setProducts(res.data)).catch(console.error); + }, [householdId]); + + // Handle WS Remote Event Broadcasts + const handleRemoteSync = useCallback((msg: any) => { + console.log('🔔 Remote state delta payload:', msg); + if (msg.type === 'ITEM_UPDATED') { + setList((prev: any) => { + if (!prev) return prev; + return { + ...prev, + items: prev.items.map((it: any) => + it.id === msg.itemId ? { ...it, ...msg.updates } : it + ), + }; + }); + } else if (msg.type === 'ITEM_ADDED') { + setList((prev: any) => { + if (!prev) return prev; + return { ...prev, items: [...prev.items, msg.item] }; + }); + } else if (msg.type === 'ITEM_REMOVED') { + setList((prev: any) => { + if (!prev) return prev; + return { ...prev, items: prev.items.filter((it: any) => it.id !== msg.itemId) }; + }); + } + }, []); + + // Inject Real-Time Hooks + const { isConnected, toggleItemCheck } = useShoppingListSync( + householdId || '', + listId, + handleRemoteSync + ); + + // 1. Perform Live Interactivity (Toggle Checks) + const handleToggleCheck = async (itemId: string, currentChecked: boolean) => { + const nextChecked = !currentChecked; + + // Optimistic Client Update for ultimate snappy responsiveness + setList((prev: any) => ({ + ...prev, + items: prev.items.map((it: any) => it.id === itemId ? { ...it, checked: nextChecked } : it) + })); + + // Emit to WS Channel (broadcasts immediately to all other clients) + toggleItemCheck(itemId, nextChecked); + + // Persist standard Rest fallback ensuring safety + if (householdId) { + try { + await updateShoppingItem(householdId, listId, itemId, { checked: nextChecked }); + } catch (err) { + console.error('Persistent toggle sync fail', err); + } + } + }; + + // 2. Handle Item Mutations + const handleAddItem = async (e: React.FormEvent) => { + e.preventDefault(); + if (!householdId) return; + setIsAdding(true); + try { + const updated = await addShoppingItem(householdId, listId, { + productId: selectedProductId || undefined, + customName: !selectedProductId ? customItemName.trim() : undefined, + quantity: qty, + unit: unit as any, + notes: notes.trim() || undefined, + }); + + setList(updated); + // Clear inputs + setSelectedProductId(''); + setCustomItemName(''); + setQty(1); + setNotes(''); + } catch (err: any) { + alert(err.message); + } finally { + setIsAdding(false); + } + }; + + const handleDeleteItem = async (itemId: string) => { + if (!householdId) return; + try { + const updated = await removeShoppingItem(householdId, listId, itemId); + setList(updated); + } catch (err: any) { + console.error(err.message); + } + }; + + // 3. Execute Final Checkout / Pantry Sync + const handleSyncToPantry = async () => { + if (!householdId) return; + const readyItems = list.items.filter((i: any) => i.checked && !i.addedToPantry); + if (readyItems.length === 0) return; + + if (!confirm(`Import ${readyItems.length} checked ingredients directly into active Pantry stock?`)) return; + + try { + const res = await syncToPantry(householdId, listId); + alert(`Success! Provisioned ${res.addedCount} items into Pantry stock.`); + + // Mark list as completed automatically if all are done + const allChecked = list.items.every((i: any) => i.checked || i.addedToPantry); + if (allChecked) { + await updateShoppingList(householdId, listId, { status: 'completed' as any }); + } + + fetchList(); + } catch (err: any) { + alert('Migration sync error: ' + err.message); + } + }; + + // Collate items categorized for satisfying view + const categorizedItems = useMemo(() => { + if (!list) return {}; + const groups: Record = {}; + list.items.forEach((it: any) => { + const cat = it.category || 'Other / Misc'; + if (!groups[cat]) groups[cat] = []; + groups[cat].push(it); + }); + return groups; + }, [list]); + + if (isAuthLoading || loading) return
Hydrating session checklist...
; + if (error || !list) return
Error: {error}
; + + const itemsPendingSync = list.items.filter((i: any) => i.checked && !i.addedToPantry).length; + const checkedCount = list.items.filter((i: any) => i.checked).length; + const totalCount = list.items.length; + + return ( + <> + + +
+ {list.status} +
+
+ {isConnected ? 'Live Sync Channel Operational' : 'Connecting Sync...'} +
+
+ +
+ + {/* Top Action Strip */} +
+ + +
+ + {itemsPendingSync > 0 && ( + + )} +
+
+ + {/* Main Workspace Split Grid */} +
+ + {/* Left: Categorized Checklist Grid */} +
+ {totalCount === 0 ? ( + + +

Checklist is Empty

+

Add missing ingredients using the pane on the right.

+
+ ) : ( +
+ {Object.entries(categorizedItems).map(([cat, items]: [string, any]) => ( +
+

+ {cat} +

+
+ {items.map((it: any) => ( +
+ {/* Checkbox circle */} + + +
+
+ {it.productId ? products.find(p => p._id === it.productId)?.name || 'Ingredient Loading...' : it.customName} +
+
+ Qty: {it.quantity} {it.unit} + {it.notes && • Note: {it.notes}} +
+
+ + {/* Estimated Price Tag */} + {it.estimatedPrice && !it.checked && ( +
+ ~${it.estimatedPrice.toFixed(2)} +
+ )} + + {/* Migrated Badge */} + {it.addedToPantry && ( + + Pantry + + )} + + +
+ ))} +
+
+ ))} +
+ )} +
+ + {/* Right Side Panel: Context Inputs */} +
+ + {/* Pane A: Add New Item */} + +

+ Add Grocery Item +

+
+
+ + +
+ + {!selectedProductId && ( +
+ + setCustomItemName(e.target.value)} + style={inputStyle} + /> +
+ )} + +
+
+ + setQty(parseFloat(e.target.value) || 0)} + style={inputStyle} + /> +
+
+ + +
+
+ +
+ + setNotes(e.target.value)} + style={inputStyle} + /> +
+ + +
+
+ + {/* Pane B: Real-Time Store Optimizer */} + {storeOptions.length > 0 && ( + +

+ Lowest Store Basket Rank +

+
+ {storeOptions.map((opt, idx) => ( +
+
+ {opt.storeName} + ${opt.estimatedTotal.toFixed(2)} +
+
+ Covered: {opt.itemsCovered}/{totalCount} products + {idx === 0 && Cheapest Single Trip} +
+
+ ))} +
+
+ )} +
+
+
+ + ); +} + +const labelStyle: React.CSSProperties = { + display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--ink-muted)', + textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 6, +}; + +const inputStyle: React.CSSProperties = { + width: '100%', padding: '8px 12px', borderRadius: 'var(--r-md)', + background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--ink)', + fontSize: 13, outline: 'none', +}; + +const selectStyle: React.CSSProperties = { + width: '100%', padding: '8px 12px', borderRadius: 'var(--r-md)', + background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--ink)', + fontSize: 13, outline: 'none', height: 36, +}; diff --git a/packages/web/src/app/(dashboard)/shopping-lists/page.tsx b/packages/web/src/app/(dashboard)/shopping-lists/page.tsx new file mode 100644 index 0000000..b799a51 --- /dev/null +++ b/packages/web/src/app/(dashboard)/shopping-lists/page.tsx @@ -0,0 +1,369 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useApi } from '@/lib/useApi'; +import { SetPageHeader } from '@/components/layout/SetPageHeader'; +import { Card, Button, Icon, Pill } from '@/components/ui'; +import { getShoppingLists, createShoppingList } from '@/services/shopping-lists'; +import { listMealPlans } from '@/services/meal-plans'; +import { generateFromMealPlan } from '@/services/shopping-lists'; +import Link from 'next/link'; + +export default function ShoppingListsPage() { + const { householdId, isLoading } = useApi(); + const [lists, setLists] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + // Modal States + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isGapModalOpen, setIsGapModalOpen] = useState(false); + + // Form States + const [newListName, setNewListName] = useState(''); + const [recentMealPlans, setRecentMealPlans] = useState([]); + const [mealPlanLoading, setMealPlanLoading] = useState(false); + + const fetchLists = useCallback(async () => { + if (!householdId) return; + setLoading(true); + try { + const data = await getShoppingLists(householdId); + // Sort by active status first, then newest first + data.sort((a, b) => { + if (a.status === 'active' && b.status !== 'active') return -1; + if (a.status !== 'active' && b.status === 'active') return 1; + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + setLists(data); + } catch (err: any) { + setError(err.message || 'Failed to load shopping lists'); + } finally { + setLoading(false); + } + }, [householdId]); + + useEffect(() => { + fetchLists(); + }, [fetchLists]); + + const handleCreateList = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newListName.trim() || !householdId) return; + try { + const res = await createShoppingList(householdId, { + name: newListName.trim(), + items: [], + }); + setNewListName(''); + setIsCreateModalOpen(false); + // Redirect or update list + setLists((prev) => [res, ...prev]); + } catch (err: any) { + alert(err.message || 'Failed to create list'); + } + }; + + const openGapModal = async () => { + setIsGapModalOpen(true); + if (!householdId) return; + setMealPlanLoading(true); + try { + const plans = await listMealPlans(householdId); + setRecentMealPlans(plans.data || []); + } catch (err) { + console.error(err); + } finally { + setMealPlanLoading(false); + } + }; + + const handleGenerateFromPlan = async (mealPlanId: string) => { + if (!householdId) return; + try { + const res = await generateFromMealPlan(householdId, mealPlanId); + setIsGapModalOpen(false); + setLists((prev) => [res, ...prev]); + } catch (err: any) { + alert(err.message || 'Failed to generate groceries from meal plan'); + } + }; + + if (isLoading) return ; + if (!householdId) return
Please join a household.
; + + const activeLists = lists.filter(l => l.status === 'active' || l.status === 'shopping'); + const completedLists = lists.filter(l => l.status === 'completed' || l.status === 'archived'); + + // Derive stats + const totalActiveCost = activeLists.reduce((sum, l) => sum + (l.totalEstimatedCost || 0), 0); + const totalPendingItems = activeLists.reduce((sum, l) => sum + l.items.filter((i: any) => !i.checked).length, 0); + + return ( + <> + + +
+ {/* 1. Beautiful Stats Band */} +
+ + + + +
+ + {/* 2. Action Row */} +
+

Checklists & Baskets

+
+ + +
+
+ + {error &&
{error}
} + + {/* 3. Lists Grid */} + {loading ? ( +
+ {[1, 2, 3].map(i =>
)} +
+ ) : activeLists.length === 0 && completedLists.length === 0 ? ( +
+ +

No Shopping Lists Found

+

+ Create an empty manual checklist, or dynamically auto-generate missing ingredients directly from your meal plan! +

+ +
+ ) : ( + <> + {/* Active Section */} + {activeLists.length > 0 && ( +
+ {activeLists.map((list) => ( + + ))} +
+ )} + + {/* Past Section */} + {completedLists.length > 0 && ( + <> +

Completed Runs

+
+ {completedLists.map((list) => ( + + ))} +
+ + )} + + )} +
+ + {/* Creation Modal Dialog Overlay */} + {isCreateModalOpen && ( +
setIsCreateModalOpen(false)}> +
e.stopPropagation()}> +

Create Shopping List

+
+
+ + setNewListName(e.target.value)} + style={inputStyle} + autoFocus + /> +
+
+ + +
+
+
+
+ )} + + {/* Meal Plan Gap Generator Modal */} + {isGapModalOpen && ( +
setIsGapModalOpen(false)}> +
e.stopPropagation()}> +

Scan Meal Plan Gaps

+

+ Select a scheduled weekly plan. We will cross-reference your recipe ingredient requirements vs active pantry inventory to auto-generate your grocery shortages! +

+ + {mealPlanLoading ? ( +
Loading schedules...
+ ) : recentMealPlans.length === 0 ? ( +
+ No meal plans configured. Build a plan first! +
+ ) : ( +
+ {recentMealPlans.slice(0, 5).map((plan) => { + const dateStr = new Date(plan.weekStartDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + return ( + + ); + })} +
+ )} + +
+ +
+
+
+ )} + + ); +} + +function MetricCard({ icon, title, value, subtitle, color, link }: any) { + const content = ( + +
+ +
+
+
{title}
+
{value}
+
+ {subtitle} + {link && } +
+
+
+ ); + return link ? {content} : content; +} + +function ShoppingListCard({ list }: { list: any }) { + const total = list.items.length; + const checked = list.items.filter((i: any) => i.checked).length; + const progress = total > 0 ? Math.round((checked / total) * 100) : 0; + const date = new Date(list.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + + const isActive = list.status === 'active' || list.status === 'shopping'; + + return ( + + +
+
+

{list.name}

+ Created {date} +
+ + {list.status === 'shopping' ? 'Live' : list.status} + +
+ +
+ + {checked}/{total} items + + {list.totalEstimatedCost && ( + + ${list.totalEstimatedCost.toFixed(2)} + + )} +
+ + {/* Custom Progress Bar */} +
+
+ Progress + {progress}% +
+
+
+
+
+ + + ); +} + +const overlayStyle: React.CSSProperties = { + position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, + background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(6px)', + display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, + padding: 16, +}; + +const modalStyle: React.CSSProperties = { + background: 'var(--bg-elev)', border: '1px solid var(--border)', + borderRadius: 'var(--r-lg)', padding: 24, width: '100%', maxWidth: 460, + boxShadow: '0 20px 40px rgba(0,0,0,0.3)', +}; + +const inputStyle: React.CSSProperties = { + width: '100%', padding: '10px 14px', borderRadius: 'var(--r-md)', + background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--ink)', + fontSize: 14, outline: 'none', +}; + +const planRowStyle: React.CSSProperties = { + display: 'flex', justifyContent: 'space-between', alignItems: 'center', + padding: '12px 16px', background: 'var(--bg)', border: '1px solid var(--border)', + borderRadius: 'var(--r-md)', width: '100%', cursor: 'pointer', transition: 'all 0.15s', +}; diff --git a/packages/web/src/app/(dashboard)/shopping-lists/prices/page.tsx b/packages/web/src/app/(dashboard)/shopping-lists/prices/page.tsx new file mode 100644 index 0000000..e7901c0 --- /dev/null +++ b/packages/web/src/app/(dashboard)/shopping-lists/prices/page.tsx @@ -0,0 +1,197 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useApi } from '@/lib/useApi'; +import { useRouter } from 'next/navigation'; +import { SetPageHeader } from '@/components/layout/SetPageHeader'; +import { Card, Button, Icon, Pill } from '@/components/ui'; +import { getPriceAnalytics } from '@/services/prices'; +import { + ResponsiveContainer, + LineChart, + Line, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + Cell, +} from 'recharts'; + +export default function PricesAnalyticsPage() { + const { householdId, isLoading } = useApi(); + const router = useRouter(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const loadAnalytics = useCallback(async () => { + if (!householdId) return; + setLoading(true); + try { + const result = await getPriceAnalytics(householdId); + setData(result); + } catch (err: any) { + setError(err.message || 'Failed to load analytics'); + } finally { + setLoading(false); + } + }, [householdId]); + + useEffect(() => { + loadAnalytics(); + }, [loadAnalytics]); + + if (isLoading || loading) return
Synthesizing financial graphs...
; + if (error || !data) return
Error: {error}
; + + const COLORS = ['var(--brand)', 'var(--success)', 'var(--warning)', '#a855f7', '#ec4899', '#3b82f6']; + + return ( + <> + + +
+ +
+ +
+ + {/* 1. Immediate Red Alert Banner: Inflation Markup >10% */} + {data.priceAlerts.length > 0 && ( +
+
+ +
+
+

Significant Inflation Markers Detected

+

The following item markups exceeded the baseline 10% deviation thresholds compared to their trailing averages:

+ +
+ {data.priceAlerts.map((alert: any, idx: number) => ( +
+
+
{alert.productName}
+
At {alert.storeName}
+
+
+
+{alert.changePercent.toFixed(0)}%
+
${alert.previousPrice} âž” ${alert.currentPrice}
+
+
+ ))} +
+
+
+ )} + + {/* 2. Grid Layout for Interactive Charts */} +
+ + {/* Time Series Spend Trend */} + +

Monthly Spending Velocities

+
+ {data.spendingOverTime.length === 0 ? ( +
No historical spend records found.
+ ) : ( + + + + + + + + + + )} +
+
+ + {/* Category Distribution */} + +

Spending Distrubution by Category

+
+ {data.spendingByCategory.length === 0 ? ( +
No categorized allocations recorded yet.
+ ) : ( + + + + + + + + {data.spendingByCategory.map((entry: any, index: number) => ( + + ))} + + + + )} +
+
+
+ + {/* 3. Average Basket Comparisons (Grid of Stores) */} + +

Average Complete Basket Totals per Store

+ {data.averageBasketByStore.length === 0 ? ( +
Create multiple shopping trips to visualize basket trends.
+ ) : ( +
+ {data.averageBasketByStore.sort((a:any, b:any) => a.avgTotal - b.avgTotal).map((store: any, idx: number) => ( +
+
{store.storeName}
+
${store.avgTotal.toFixed(2)}
+
Based on {store.tripCount} simulated checkouts
+ {idx === 0 && ( +
Best Value
+ )} +
+ ))} +
+ )} +
+ +
+ + ); +} + +const emptyStyle: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + height: '100%', color: 'var(--ink-muted)', fontSize: 13, border: '1px dashed var(--border)', + borderRadius: 'var(--r-md)' +}; + +const tooltipStyle: React.CSSProperties = { + background: '#1f2937', border: '1px solid #374151', borderRadius: 8, + color: '#fff', fontSize: 12, +}; diff --git a/packages/web/src/lib/useShoppingListSync.ts b/packages/web/src/lib/useShoppingListSync.ts new file mode 100644 index 0000000..76cf9f4 --- /dev/null +++ b/packages/web/src/lib/useShoppingListSync.ts @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { getShoppingListSyncSocketUrl } from '@/services/shopping-lists'; + +export interface SyncUpdateMessage { + type: 'ITEM_ADDED' | 'ITEM_UPDATED' | 'ITEM_REMOVED'; + itemId?: string; + item?: any; + updates?: any; +} + +export function useShoppingListSync( + householdId: string, + listId: string, + onRemoteChange: (msg: SyncUpdateMessage) => void +) { + const socketRef = useRef(null); + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + const reconnectAttemptsRef = useRef(0); + + const connect = useCallback(() => { + if (!householdId || !listId) return; + + // Close previous socket if active + if (socketRef.current) { + socketRef.current.close(); + } + + try { + const url = getShoppingListSyncSocketUrl(householdId, listId); + const ws = new WebSocket(url); + socketRef.current = ws; + + ws.onopen = () => { + setIsConnected(true); + setError(null); + reconnectAttemptsRef.current = 0; + console.log(`🛒 Connected to shopping list real-time sync: ${listId}`); + }; + + ws.onmessage = (event) => { + try { + const payload: SyncUpdateMessage = JSON.parse(event.data); + onRemoteChange(payload); + } catch (err) { + console.error('Failed parsing real-time grocery payload', err); + } + }; + + ws.onerror = () => { + setError('Connection interrupt'); + }; + + ws.onclose = (event) => { + setIsConnected(false); + console.log(`🔌 Sync severed: ${event.reason || 'Disconnected'}`); + + // Simple linear backoff reconnect + if (reconnectAttemptsRef.current < 5) { + reconnectAttemptsRef.current += 1; + const delay = Math.min(1000 * reconnectAttemptsRef.current, 5000); + setTimeout(() => { + console.log(`🔄 Attempting sync handshake reconnect (${reconnectAttemptsRef.current}/5)...`); + connect(); + }, delay); + } + }; + } catch (err) { + console.error('Fatal WebSocket initialization', err); + setError('Sync failed to initialize'); + } + }, [householdId, listId, onRemoteChange]); + + useEffect(() => { + connect(); + return () => { + if (socketRef.current) { + // Clear hook handlers to prevent state leakage during dismount + socketRef.current.onclose = null; + socketRef.current.close(); + } + }; + }, [connect]); + + const toggleItemCheck = useCallback((itemId: string, checked: boolean) => { + if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { + socketRef.current.send( + JSON.stringify({ + type: 'TOGGLE_ITEM', + itemId, + checked, + }) + ); + } + }, []); + + return { + isConnected, + error, + toggleItemCheck, + }; +} diff --git a/packages/web/src/services/api-client.ts b/packages/web/src/services/api-client.ts index dd979d6..b38b50d 100644 --- a/packages/web/src/services/api-client.ts +++ b/packages/web/src/services/api-client.ts @@ -11,6 +11,10 @@ class ApiClient { return this._accessToken !== null; } + public get baseUrl(): string { + return BASE_URL; + } + private getHeaders(): Record { const headers: Record = { 'Content-Type': 'application/json', diff --git a/packages/web/src/services/prices.ts b/packages/web/src/services/prices.ts new file mode 100644 index 0000000..0872aff --- /dev/null +++ b/packages/web/src/services/prices.ts @@ -0,0 +1,71 @@ +import { apiClient } from './api-client'; +import type { z } from 'zod/v4'; +import type { + PriceRecordResponseSchema, + PriceHistoryResponseSchema, + FoodStoreComparisonResponseSchema, + FoodSpendingAnalyticsResponseSchema, + CreatePriceRecordSchema, + BulkPriceRecordInputSchema, +} from '@meshitrack/shared'; + +type PriceRecordResponse = z.infer; +type PriceHistoryResponse = z.infer; +type FoodStoreComparisonResponse = z.infer; +type FoodSpendingAnalyticsResponse = z.infer; +type CreatePriceRecordInput = z.infer; +type BulkPriceRecordInput = z.infer; + +export async function recordPrice( + householdId: string, + data: CreatePriceRecordInput +): Promise { + return apiClient.post(`/households/${householdId}/prices`, data); +} + +export async function recordBulkPrices( + householdId: string, + data: BulkPriceRecordInput +): Promise { + return apiClient.post(`/households/${householdId}/prices/bulk`, data); +} + +export async function getPriceHistory( + householdId: string, + productId: string, + query?: { + storeId?: string; + startDate?: string; + endDate?: string; + cursor?: string; + limit?: number; + } +): Promise { + const params = new URLSearchParams(); + if (query?.storeId) params.set('storeId', query.storeId); + if (query?.startDate) params.set('startDate', query.startDate); + if (query?.endDate) params.set('endDate', query.endDate); + if (query?.cursor) params.set('cursor', query.cursor); + if (query?.limit) params.set('limit', String(query.limit)); + const qs = params.toString(); + return apiClient.get( + `/households/${householdId}/prices/history/${productId}${qs ? `?${qs}` : ''}` + ); +} + +export async function compareStores( + householdId: string, + productId: string +): Promise { + return apiClient.get( + `/households/${householdId}/prices/compare/${productId}` + ); +} + +export async function getPriceAnalytics( + householdId: string +): Promise { + return apiClient.get( + `/households/${householdId}/prices/analytics` + ); +} diff --git a/packages/web/src/services/shopping-lists.ts b/packages/web/src/services/shopping-lists.ts new file mode 100644 index 0000000..efcb111 --- /dev/null +++ b/packages/web/src/services/shopping-lists.ts @@ -0,0 +1,119 @@ +import { apiClient } from './api-client'; +import type { z } from 'zod/v4'; +import type { + ShoppingListResponseSchema, + CreateShoppingListSchema, + UpdateShoppingListSchema, + AddShoppingItemSchema, + UpdateShoppingItemSchema, + ShoppingListSyncToPantryResponseSchema, + BasketStoreComparisonResponseSchema, +} from '@meshitrack/shared'; + +type ShoppingListResponse = z.infer; +type CreateShoppingListInput = z.infer; +type UpdateShoppingListInput = z.infer; +type AddShoppingItemInput = z.infer; +type UpdateShoppingItemInput = z.infer; +type ShoppingListSyncToPantryResponse = z.infer; +type BasketStoreComparisonResponse = z.infer; + +export async function getShoppingLists(householdId: string): Promise { + return apiClient.get(`/households/${householdId}/shopping-lists`); +} + +export async function getShoppingList(householdId: string, id: string): Promise { + return apiClient.get(`/households/${householdId}/shopping-lists/${id}`); +} + +export async function createShoppingList( + householdId: string, + data: CreateShoppingListInput +): Promise { + return apiClient.post(`/households/${householdId}/shopping-lists`, data); +} + +export async function updateShoppingList( + householdId: string, + id: string, + data: UpdateShoppingListInput +): Promise { + return apiClient.patch(`/households/${householdId}/shopping-lists/${id}`, data); +} + +export async function deleteShoppingList(householdId: string, id: string): Promise { + return apiClient.delete(`/households/${householdId}/shopping-lists/${id}`); +} + +// -- Nested Item Operations -- + +export async function addShoppingItem( + householdId: string, + id: string, + data: AddShoppingItemInput +): Promise { + return apiClient.post(`/households/${householdId}/shopping-lists/${id}/items`, data); +} + +export async function updateShoppingItem( + householdId: string, + id: string, + itemId: string, + data: UpdateShoppingItemInput +): Promise { + return apiClient.patch( + `/households/${householdId}/shopping-lists/${id}/items/${itemId}`, + data + ); +} + +export async function removeShoppingItem( + householdId: string, + id: string, + itemId: string +): Promise { + return apiClient.delete( + `/households/${householdId}/shopping-lists/${id}/items/${itemId}` + ); +} + +// -- Workflows -- + +export async function generateFromMealPlan( + householdId: string, + mealPlanId: string +): Promise { + return apiClient.post( + `/households/${householdId}/shopping-lists/from-meal-plan/${mealPlanId}` + ); +} + +export async function syncToPantry( + householdId: string, + id: string +): Promise { + return apiClient.post( + `/households/${householdId}/shopping-lists/${id}/sync-to-pantry` + ); +} + +export async function getBasketStoreComparison( + householdId: string, + id: string +): Promise { + return apiClient.get( + `/households/${householdId}/shopping-lists/${id}/stores` + ); +} + +/** + * Generates direct WebSocket path for collaborative checklist syncing. + */ +export function getShoppingListSyncSocketUrl(householdId: string, id: string): string { + // Derive WS protocol based on configured API baseURL protocol (defaulting to unsafe ws for localhost) + const baseUrl = apiClient.baseUrl || ''; + const isSecure = baseUrl.startsWith('https'); + const cleanHost = baseUrl.replace(/^https?:\/\//, ''); + const protocol = isSecure ? 'wss' : 'ws'; + return `${protocol}://${cleanHost}/households/${householdId}/shopping-lists/${id}/sync`; +}