From 7cb7843cebbaff9af0107d975ff1ca75e63c609b Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Tue, 6 Jan 2026 01:28:34 -0800 Subject: [PATCH] feat: Enhanced Pulse analytics with historical charts and improved sensor cards --- frontend/package-lock.json | 376 +++++++++++- frontend/package.json | 1 + .../components/dashboard/PulseSensorCard.tsx | 266 +++++---- frontend/src/pages/PulseTestPage.tsx | 559 +++++++++++------- 4 files changed, 890 insertions(+), 312 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 99ec10f..6f39ffd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,6 +33,7 @@ "react-i18next": "^16.4.1", "react-konva": "^18.2.10", "react-router-dom": "^7.10.1", + "recharts": "^3.6.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "three": "0.165.0", @@ -2613,6 +2614,32 @@ } } }, + "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/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2935,6 +2962,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "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/@testing-library/dom": { "version": "9.3.4", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", @@ -3104,6 +3143,69 @@ "@babel/types": "^7.28.2" } }, + "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.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "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/draco3d": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", @@ -3170,7 +3272,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -3213,6 +3315,12 @@ "meshoptimizer": "~0.22.0" } }, + "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/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", @@ -4377,6 +4485,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "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.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "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": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -4435,6 +4664,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-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -4723,6 +4958,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz", + "integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -4993,6 +5238,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -5803,6 +6054,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/is-arguments": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", @@ -7501,6 +7761,29 @@ "react": "^18.3.1" } }, + "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/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -7656,6 +7939,46 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz", + "integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 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/recharts/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/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -7670,6 +7993,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/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -7722,6 +8060,12 @@ "dev": true, "license": "MIT" }, + "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": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8427,6 +8771,12 @@ "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", "license": "MIT" }, + "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", @@ -8649,7 +8999,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -8822,6 +9172,28 @@ "integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==", "license": "MIT" }, + "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": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/frontend/package.json b/frontend/package.json index ed8876e..b227433 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,7 @@ "react-i18next": "^16.4.1", "react-konva": "^18.2.10", "react-router-dom": "^7.10.1", + "recharts": "^3.6.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "three": "0.165.0", diff --git a/frontend/src/components/dashboard/PulseSensorCard.tsx b/frontend/src/components/dashboard/PulseSensorCard.tsx index d4a9b04..3fd571d 100644 --- a/frontend/src/components/dashboard/PulseSensorCard.tsx +++ b/frontend/src/components/dashboard/PulseSensorCard.tsx @@ -1,8 +1,8 @@ - import { motion } from 'framer-motion'; -import { LucideIcon, Thermometer, Droplets, Wind, CloudFog, Sun, Activity } from 'lucide-react'; +import { Thermometer, Droplets, Wind, CloudFog, Activity, ChevronRight, TrendingUp, TrendingDown, Minus } from 'lucide-react'; import { cn } from '../../lib/utils'; import { useNavigate } from 'react-router-dom'; +import { AreaChart, Area, ResponsiveContainer, ReferenceLine } from 'recharts'; interface PulseSensorCardProps { reading: { @@ -16,12 +16,18 @@ interface PulseSensorCardProps { }; history?: { temperature: number[]; - // we can add other metrics history later + humidity?: number[]; + vpd?: number[]; + }; + thresholds?: { + temperature?: { min: number; max: number }; + humidity?: { min: number; max: number }; + vpd?: { min: number; max: number }; }; onClick?: () => void; } -export function PulseSensorCard({ reading, history, onClick }: PulseSensorCardProps) { +export function PulseSensorCard({ reading, history, thresholds, onClick }: PulseSensorCardProps) { const navigate = useNavigate(); // Determine offline status (older than 15 mins) @@ -30,127 +36,185 @@ export function PulseSensorCard({ reading, history, onClick }: PulseSensorCardPr const diffMinutes = (now.getTime() - readingTime.getTime()) / 1000 / 60; const isOffline = diffMinutes > 15; - // Determine status color based on VPD (gold standard for crop health) - const getStatusColor = (vpd: number) => { - if (vpd < 0.8 || vpd > 1.2) return 'text-amber-500'; // Warning - if (vpd < 0.4 || vpd > 1.6) return 'text-rose-500'; // Critical - return 'text-emerald-500'; // Good + // Get trend for temperature + const getTrend = (data?: number[]) => { + if (!data || data.length < 3) return 'stable'; + const recent = data.slice(-3); + const avg = recent.reduce((a, b) => a + b, 0) / recent.length; + const first = recent[0]; + if (avg > first + 0.5) return 'up'; + if (avg < first - 0.5) return 'down'; + return 'stable'; }; - // If offline, use neutral/error color for metrics - const statusColor = isOffline ? 'text-slate-400 dark:text-slate-500' : getStatusColor(reading.vpd); - const badgeColor = isOffline - ? 'text-rose-500 bg-rose-500/10 border-rose-500/20' - : cn("bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700", statusColor); + const tempTrend = getTrend(history?.temperature); - // Simple Sparkline - const Sparkline = ({ data, color = "#10b981", width = 120, height = 40 }: { data: number[], color?: string, width?: number, height?: number }) => { - if (!data || data.length < 2) return null; + // Determine if values are in warning range + const isTempWarning = thresholds?.temperature + ? reading.temperature > thresholds.temperature.max || reading.temperature < thresholds.temperature.min + : reading.temperature > 82 || reading.temperature < 65; - const min = Math.min(...data); - const max = Math.max(...data); - const range = max - min || 1; + const isVpdWarning = thresholds?.vpd + ? reading.vpd > thresholds.vpd.max || reading.vpd < thresholds.vpd.min + : reading.vpd > 1.2 || reading.vpd < 0.8; - const points = data.map((val, i) => { - const x = (i / (data.length - 1)) * width; - const y = height - ((val - min) / range) * height; - return `${x},${y}`; - }).join(' '); - - return ( - - - - ); - }; + // Convert history to chart data + const chartData = history?.temperature?.map((temp, i) => ({ + value: temp, + index: i + })) || []; const handleClick = () => { if (onClick) onClick(); - else navigate(`/pulse`); // Default to pulse dashboard + else navigate(`/pulse`); }; + const TrendIcon = tempTrend === 'up' ? TrendingUp : tempTrend === 'down' ? TrendingDown : Minus; + const trendColor = tempTrend === 'up' ? 'text-red-400' : tempTrend === 'down' ? 'text-blue-400' : 'text-slate-400'; + return ( -
-
-
- + {/* Header */} +
+
+
+
+ +
+
+

+ {reading.deviceName} +

+

Pulse Grow Sensor

+
-
-

- {reading.deviceName} -

-

Pulse Grow

-
-
-
- {isOffline ? 'OFFLINE' : 'LIVE'} +
+
+ {isOffline ? 'OFFLINE' : 'LIVE'} +
-
-
-
- - Temp -
-

- {reading.temperature.toFixed(1)}° -

+ {/* Sparkline Chart */} + {chartData.length > 2 && !isOffline && ( +
+ + + + + + + + + + +
+ )} -
-
- - RH -
-

- {reading.humidity.toFixed(0)}% -

-
- -
-
- - VPD -
-

- {reading.vpd.toFixed(2)} -

-
- -
- {history && history.temperature && !isOffline && ( -
- 80 ? '#f43f5e' : '#10b981'} /> + {/* Metrics Grid */} +
+
+ {/* Temperature */} +
+
+
+ + Temp +
+
- )} +

+ {reading.temperature.toFixed(1)}° +

+
- {isOffline && ( -
-

Last Updated

-

- {readingTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -

+ {/* Humidity */} +
+
+ + RH
- )} +

+ {reading.humidity.toFixed(0)}% +

+
+ + {/* VPD */} +
+
+ + VPD +
+

+ {reading.vpd.toFixed(2)} +

+
+
+ + {/* Dewpoint Row */} +
+
+ + Dewpoint: {reading.dewpoint.toFixed(1)}°F +
+
+ View Details + +
+
+
+ + {/* Timestamp Footer */} +
+
+ Last reading + + {readingTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
diff --git a/frontend/src/pages/PulseTestPage.tsx b/frontend/src/pages/PulseTestPage.tsx index 562564f..c2eff4e 100644 --- a/frontend/src/pages/PulseTestPage.tsx +++ b/frontend/src/pages/PulseTestPage.tsx @@ -1,6 +1,8 @@ import React, { useEffect, useState } from 'react'; -import { Activity, Thermometer, Droplets, Wind, Sun, Wifi, WifiOff, RefreshCw, Bell } from 'lucide-react'; +import { Activity, Thermometer, Droplets, Wind, Wifi, WifiOff, RefreshCw, Bell, TrendingUp, Clock, ChevronRight } from 'lucide-react'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart, ReferenceLine } from 'recharts'; import { useNotifications } from '../hooks/useNotifications'; +import { motion } from 'framer-motion'; import api from '../lib/api'; interface PulseDevice { @@ -28,15 +30,25 @@ interface PulseStatus { error?: string; } +interface HistoryPoint { + timestamp: string; + temperature: number; + humidity: number; + vpd: number; +} + export default function PulseTestPage() { const { connected: wsConnected, alerts, unreadCount } = useNotifications(); const [status, setStatus] = useState(null); const [devices, setDevices] = useState([]); const [readings, setReadings] = useState([]); + const [history, setHistory] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [lastUpdate, setLastUpdate] = useState(null); + const [selectedDevice, setSelectedDevice] = useState(null); + const [historyHours, setHistoryHours] = useState(6); const fetchData = async () => { setLoading(true); @@ -55,6 +67,11 @@ export default function PulseTestPage() { // Fetch readings const readingsRes = await api.get('/pulse/readings'); setReadings(readingsRes.data.readings || []); + + // Set first device as selected if none selected + if (!selectedDevice && readingsRes.data.readings?.length > 0) { + setSelectedDevice(readingsRes.data.readings[0].deviceId); + } } setLastUpdate(new Date()); @@ -65,240 +82,364 @@ export default function PulseTestPage() { } }; + const fetchHistory = async () => { + if (!selectedDevice) return; + + try { + const res = await api.get(`/pulse/devices/${selectedDevice}/history?hours=${historyHours}`); + const points = (res.data.readings || []).map((r: any) => ({ + timestamp: new Date(r.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + temperature: r.temperature, + humidity: r.humidity, + vpd: r.vpd + })); + setHistory(points); + } catch (err) { + console.error('Failed to fetch history:', err); + } + }; + useEffect(() => { fetchData(); - - // Refresh every 30 seconds const interval = setInterval(fetchData, 30000); return () => clearInterval(interval); }, []); + useEffect(() => { + fetchHistory(); + }, [selectedDevice, historyHours]); + const getStatusColor = (connected: boolean) => connected ? 'text-green-500' : 'text-red-500'; const getVpdColor = (vpd: number) => { if (vpd < 0.8) return 'text-blue-500'; - if (vpd > 1.2) return 'text-red-500'; + if (vpd > 1.2) return 'text-amber-500'; + if (vpd > 1.6) return 'text-red-500'; return 'text-green-500'; }; const getTempColor = (temp: number) => { if (temp < 65) return 'text-blue-500'; - if (temp > 82) return 'text-red-500'; + if (temp > 82) return 'text-amber-500'; + if (temp > 90) return 'text-red-500'; return 'text-green-500'; }; + const currentReading = readings.find(r => r.deviceId === selectedDevice) || readings[0]; + return ( -
- {/* Header */} -
-
-

- Pulse Sensor Dashboard -

-

- Live environment monitoring from Pulse Grow -

-
-
- {/* WebSocket Status */} -
- {wsConnected ? ( - - ) : ( - - )} - - {wsConnected ? 'Live' : 'Offline'} - - {unreadCount > 0 && ( - - - {unreadCount} - - )} -
- - {/* Refresh Button */} - -
-
- - {/* Connection Status Card */} -
+
+
+ {/* Header */}
-
-
- -
-
-

- Pulse API Connection -

-

- {status?.connected - ? `Connected • ${status.deviceCount} device(s)` - : status?.error || 'Not connected'} -

-
-
- {lastUpdate && ( -

- Last update: {lastUpdate.toLocaleTimeString()} +

+

+
+ +
+ Pulse Sensor Analytics +

+

+ Real-time environmental monitoring with historical trends

- )} -
-
- - {/* Error Message */} - {error && ( -
- {error} -
- )} - - {/* Sensor Readings Grid */} - {readings.length > 0 && ( -
- {readings.map((reading, index) => ( -
- {/* Device Header */} -
-

- {reading.deviceName || `Device ${reading.deviceId}`} -

-
-
- - {/* Metrics Grid */} -
- {/* Temperature */} -
-
- - Temp -
-

- {reading.temperature.toFixed(1)}°F -

-
- - {/* Humidity */} -
-
- - Humidity -
-

- {reading.humidity.toFixed(1)}% -

-
- - {/* VPD */} -
-
- - VPD -
-

- {reading.vpd.toFixed(2)} kPa -

-
- - {/* Dewpoint */} -
-
- - Dewpoint -
-

- {reading.dewpoint.toFixed(1)}°F -

-
- - {/* Light (if available) */} - {reading.light !== undefined && ( -
-
- - Light -
-

- {reading.light.toFixed(0)} lux -

-
- )} - - {/* CO2 (if available) */} - {reading.co2 !== undefined && ( -
-
- - CO2 -
-

- {reading.co2} ppm -

-
- )} -
- - {/* Timestamp */} -

- {new Date(reading.timestamp).toLocaleString()} -

+
+
+ {/* WebSocket Status */} +
+ {wsConnected ? ( + + ) : ( + + )} + + {wsConnected ? 'Live' : 'Offline'} + + {unreadCount > 0 && ( + + + {unreadCount} + + )}
- ))} -
- )} - {/* Empty State */} - {!loading && readings.length === 0 && status?.connected && ( -
- -

- No Sensor Readings -

-

- Waiting for data from Pulse devices... -

-
- )} - - {/* Recent Alerts */} - {alerts.length > 0 && ( -
-

- - Recent Alerts ({alerts.length}) -

-
- {alerts.slice(0, 5).map((alert, index) => ( -
-

{alert.sensorName}

-

- {alert.type}: {alert.value.toFixed(1)} (threshold: {alert.threshold.toFixed(1)}) -

-

- {new Date(alert.timestamp).toLocaleTimeString()} -

-
- ))} + {/* Refresh Button */} +
- )} + + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Main Content Grid */} +
+ {/* Live Readings Panel */} +
+

Live Sensors

+ + {readings.map((reading) => ( + setSelectedDevice(reading.deviceId)} + className={`p-4 rounded-xl cursor-pointer transition-all ${selectedDevice === reading.deviceId + ? 'bg-emerald-500/20 border-2 border-emerald-500/50' + : 'bg-slate-800/50 border border-slate-700/50 hover:border-slate-600' + }`} + > +
+

{reading.deviceName}

+
+
+ LIVE +
+
+ +
+
+

Temp

+

+ {reading.temperature.toFixed(1)}° +

+
+
+

RH

+

+ {reading.humidity.toFixed(0)}% +

+
+
+

VPD

+

+ {reading.vpd.toFixed(2)} +

+
+
+ + ))} + + {readings.length === 0 && !loading && ( +
+ +

No sensors connected

+
+ )} +
+ + {/* Charts Section */} +
+ {/* Current Stats */} + {currentReading && ( +
+ +
+ + Temperature +
+

+ {currentReading.temperature.toFixed(1)}°F +

+
+ + +
+ + Humidity +
+

+ {currentReading.humidity.toFixed(0)}% +

+
+ + +
+ + VPD +
+

+ {currentReading.vpd.toFixed(2)} +

+

kPa

+
+ + +
+ + Dewpoint +
+

+ {currentReading.dewpoint.toFixed(1)}°F +

+
+
+ )} + + {/* Time Range Selector */} +
+

+ + Historical Trends +

+
+ + +
+
+ + {/* Temperature Chart */} +
+

+ + Temperature History +

+
+ + + + + + + + + + + + + + + + + +
+
+ + {/* Humidity & VPD Charts */} +
+
+

+ + Humidity History +

+
+ + + + + + + + + + + + + + + +
+
+ +
+

+ + VPD History +

+
+ + + + + + + + + + + + + + + + + +
+
+
+
+
+ + {/* Recent Alerts */} + {alerts.length > 0 && ( +
+

+ + Recent Alerts ({alerts.length}) +

+
+ {alerts.slice(0, 6).map((alert, index) => ( +
+

{alert.sensorName}

+

+ {alert.type}: {alert.value.toFixed(1)} (threshold: {alert.threshold.toFixed(1)}) +

+

+ {new Date(alert.timestamp).toLocaleTimeString()} +

+
+ ))} +
+
+ )} +
); }