feat: Enhanced Pulse analytics with historical charts and improved sensor cards
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

This commit is contained in:
fullsizemalt 2026-01-06 01:28:34 -08:00
parent c39abe5696
commit 7cb7843ceb
4 changed files with 890 additions and 312 deletions

View file

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

View file

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

View file

@ -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 (
<svg width={width} height={height} className="overflow-visible opacity-50">
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
// 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 (
<motion.div
whileHover={{ y: -2 }}
whileHover={{ y: -4, scale: 1.01 }}
onClick={handleClick}
className="group relative overflow-hidden bg-white dark:bg-[#0C0C0C] border border-[var(--color-border-subtle)]/60 p-5 rounded-2xl transition-all hover:shadow-2xl hover:shadow-indigo-500/5 cursor-pointer"
className={cn(
"group relative overflow-hidden rounded-2xl cursor-pointer transition-all",
"bg-gradient-to-br from-slate-800/80 to-slate-900/80",
"border border-slate-700/50 hover:border-emerald-500/50",
"shadow-xl hover:shadow-2xl hover:shadow-emerald-500/10"
)}
>
<div className="flex justify-between items-start mb-4">
<div className="flex gap-3 items-center">
<div className={cn(
"p-2.5 rounded-xl ring-1",
isOffline
? "bg-slate-100 text-slate-400 ring-slate-200 dark:bg-slate-800 dark:text-slate-500 dark:ring-slate-700"
: "bg-emerald-500/10 text-emerald-500 ring-emerald-500/20"
)}>
<Activity size={18} />
{/* Header */}
<div className="p-5 pb-3">
<div className="flex justify-between items-start">
<div className="flex gap-3 items-center">
<div className={cn(
"p-3 rounded-xl",
isOffline
? "bg-slate-700 text-slate-400"
: isTempWarning || isVpdWarning
? "bg-amber-500/20 text-amber-400"
: "bg-emerald-500/20 text-emerald-400"
)}>
<Activity size={22} />
</div>
<div>
<h3 className="text-sm font-bold text-white tracking-wide">
{reading.deviceName}
</h3>
<p className="text-xs text-slate-500">Pulse Grow Sensor</p>
</div>
</div>
<div>
<h3 className="text-[11px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest leading-none mb-1">
{reading.deviceName}
</h3>
<p className="text-[10px] text-[var(--color-text-tertiary)] opacity-60">Pulse Grow</p>
</div>
</div>
<div className={cn("text-[10px] font-bold px-2 py-0.5 rounded-full border", badgeColor)}>
{isOffline ? 'OFFLINE' : 'LIVE'}
<div className={cn(
"flex items-center gap-1.5 text-xs font-bold px-2.5 py-1 rounded-full border",
isOffline
? "text-red-400 bg-red-500/10 border-red-500/30"
: "text-emerald-400 bg-emerald-500/10 border-emerald-500/30"
)}>
<div className={cn("w-1.5 h-1.5 rounded-full", isOffline ? "bg-red-400" : "bg-emerald-400 animate-pulse")} />
{isOffline ? 'OFFLINE' : 'LIVE'}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="flex items-center gap-1.5 mb-0.5 text-[var(--color-text-tertiary)]">
<Thermometer size={12} />
<span className="text-[10px] font-bold uppercase tracking-wider">Temp</span>
</div>
<p className={cn("text-2xl font-bold transition-colors", isOffline ? "text-slate-400" : "text-[var(--color-text-primary)]")}>
{reading.temperature.toFixed(1)}°
</p>
{/* Sparkline Chart */}
{chartData.length > 2 && !isOffline && (
<div className="h-16 px-2 -mb-2">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id={`gradient-${reading.deviceId}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={isTempWarning ? "#f59e0b" : "#10b981"} stopOpacity={0.4} />
<stop offset="95%" stopColor={isTempWarning ? "#f59e0b" : "#10b981"} stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={isTempWarning ? "#f59e0b" : "#10b981"}
fill={`url(#gradient-${reading.deviceId})`}
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
<div>
<div className="flex items-center gap-1.5 mb-0.5 text-[var(--color-text-tertiary)]">
<Droplets size={12} />
<span className="text-[10px] font-bold uppercase tracking-wider">RH</span>
</div>
<p className={cn("text-2xl font-bold transition-colors", isOffline ? "text-slate-400" : "text-[var(--color-text-primary)]")}>
{reading.humidity.toFixed(0)}%
</p>
</div>
<div>
<div className="flex items-center gap-1.5 mb-0.5 text-[var(--color-text-tertiary)]">
<CloudFog size={12} />
<span className="text-[10px] font-bold uppercase tracking-wider">VPD</span>
</div>
<p className={cn("text-2xl font-bold transition-colors", statusColor)}>
{reading.vpd.toFixed(2)}
</p>
</div>
<div className="flex flex-col items-end justify-end">
{history && history.temperature && !isOffline && (
<div className="mb-1">
<Sparkline data={history.temperature} color={reading.temperature > 80 ? '#f43f5e' : '#10b981'} />
{/* Metrics Grid */}
<div className="p-5 pt-3">
<div className="grid grid-cols-3 gap-4">
{/* Temperature */}
<div className={cn(
"p-3 rounded-xl transition-colors",
isTempWarning ? "bg-amber-500/10" : "bg-slate-800/50"
)}>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5 text-slate-400">
<Thermometer size={14} />
<span className="text-[10px] font-bold uppercase tracking-wider">Temp</span>
</div>
<TrendIcon size={12} className={trendColor} />
</div>
)}
<p className={cn(
"text-2xl font-bold",
isOffline ? "text-slate-500" : isTempWarning ? "text-amber-400" : "text-white"
)}>
{reading.temperature.toFixed(1)}°
</p>
</div>
{isOffline && (
<div className="text-right">
<p className="text-[10px] font-medium text-rose-500">Last Updated</p>
<p className="text-[10px] text-[var(--color-text-tertiary)]">
{readingTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
{/* Humidity */}
<div className="p-3 rounded-xl bg-slate-800/50">
<div className="flex items-center gap-1.5 mb-1 text-slate-400">
<Droplets size={14} />
<span className="text-[10px] font-bold uppercase tracking-wider">RH</span>
</div>
)}
<p className={cn(
"text-2xl font-bold",
isOffline ? "text-slate-500" : "text-blue-400"
)}>
{reading.humidity.toFixed(0)}%
</p>
</div>
{/* VPD */}
<div className={cn(
"p-3 rounded-xl transition-colors",
isVpdWarning ? "bg-purple-500/10" : "bg-slate-800/50"
)}>
<div className="flex items-center gap-1.5 mb-1 text-slate-400">
<CloudFog size={14} />
<span className="text-[10px] font-bold uppercase tracking-wider">VPD</span>
</div>
<p className={cn(
"text-2xl font-bold",
isOffline ? "text-slate-500" : isVpdWarning ? "text-purple-400" : "text-emerald-400"
)}>
{reading.vpd.toFixed(2)}
</p>
</div>
</div>
{/* Dewpoint Row */}
<div className="mt-3 flex items-center justify-between px-1">
<div className="flex items-center gap-2 text-slate-500">
<Wind size={12} />
<span className="text-xs">Dewpoint: <span className="text-slate-300">{reading.dewpoint.toFixed(1)}°F</span></span>
</div>
<div className="flex items-center gap-1 text-slate-500 group-hover:text-emerald-400 transition-colors">
<span className="text-xs font-medium">View Details</span>
<ChevronRight size={14} className="group-hover:translate-x-0.5 transition-transform" />
</div>
</div>
</div>
{/* Timestamp Footer */}
<div className="px-5 pb-4">
<div className="flex items-center justify-between text-xs text-slate-500 pt-3 border-t border-slate-700/50">
<span>Last reading</span>
<span className={isOffline ? "text-red-400" : ""}>
{readingTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
</motion.div>

View file

@ -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<PulseStatus | null>(null);
const [devices, setDevices] = useState<PulseDevice[]>([]);
const [readings, setReadings] = useState<PulseReading[]>([]);
const [history, setHistory] = useState<HistoryPoint[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const [selectedDevice, setSelectedDevice] = useState<string | null>(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 (
<div className="max-w-6xl mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">
Pulse Sensor Dashboard
</h1>
<p className="text-sm text-[var(--color-text-tertiary)]">
Live environment monitoring from Pulse Grow
</p>
</div>
<div className="flex items-center gap-4">
{/* WebSocket Status */}
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--color-bg-tertiary)]">
{wsConnected ? (
<Wifi className="w-4 h-4 text-green-500" />
) : (
<WifiOff className="w-4 h-4 text-red-500" />
)}
<span className="text-xs font-medium">
{wsConnected ? 'Live' : 'Offline'}
</span>
{unreadCount > 0 && (
<span className="flex items-center gap-1 text-red-500">
<Bell className="w-3 h-3" />
{unreadCount}
</span>
)}
</div>
{/* Refresh Button */}
<button
onClick={fetchData}
disabled={loading}
className="p-2 rounded-lg bg-[var(--color-bg-tertiary)] hover:bg-[var(--color-primary)] hover:text-white transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* Connection Status Card */}
<div className="p-4 rounded-xl bg-[var(--color-bg-secondary)] border border-[var(--color-border-subtle)]">
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-6">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${status?.connected ? 'bg-green-500/10' : 'bg-red-500/10'}`}>
<Activity className={`w-5 h-5 ${getStatusColor(status?.connected || false)}`} />
</div>
<div>
<h3 className="font-semibold text-[var(--color-text-primary)]">
Pulse API Connection
</h3>
<p className="text-sm text-[var(--color-text-tertiary)]">
{status?.connected
? `Connected • ${status.deviceCount} device(s)`
: status?.error || 'Not connected'}
</p>
</div>
</div>
{lastUpdate && (
<p className="text-xs text-[var(--color-text-tertiary)]">
Last update: {lastUpdate.toLocaleTimeString()}
<div>
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 shadow-lg shadow-emerald-500/25">
<Activity className="w-6 h-6 text-white" />
</div>
Pulse Sensor Analytics
</h1>
<p className="text-slate-400 ml-14 mt-1">
Real-time environmental monitoring with historical trends
</p>
)}
</div>
</div>
{/* Error Message */}
{error && (
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-500">
{error}
</div>
)}
{/* Sensor Readings Grid */}
{readings.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{readings.map((reading, index) => (
<div
key={reading.deviceId || index}
className="p-5 rounded-xl bg-[var(--color-bg-secondary)] border border-[var(--color-border-subtle)] space-y-4"
>
{/* Device Header */}
<div className="flex items-center justify-between">
<h3 className="font-semibold text-[var(--color-text-primary)]">
{reading.deviceName || `Device ${reading.deviceId}`}
</h3>
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 gap-3">
{/* Temperature */}
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Thermometer className={`w-4 h-4 ${getTempColor(reading.temperature)}`} />
<span className="text-xs text-[var(--color-text-tertiary)]">Temp</span>
</div>
<p className={`text-xl font-bold ${getTempColor(reading.temperature)}`}>
{reading.temperature.toFixed(1)}°F
</p>
</div>
{/* Humidity */}
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Droplets className="w-4 h-4 text-blue-500" />
<span className="text-xs text-[var(--color-text-tertiary)]">Humidity</span>
</div>
<p className="text-xl font-bold text-blue-500">
{reading.humidity.toFixed(1)}%
</p>
</div>
{/* VPD */}
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Wind className={`w-4 h-4 ${getVpdColor(reading.vpd)}`} />
<span className="text-xs text-[var(--color-text-tertiary)]">VPD</span>
</div>
<p className={`text-xl font-bold ${getVpdColor(reading.vpd)}`}>
{reading.vpd.toFixed(2)} kPa
</p>
</div>
{/* Dewpoint */}
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Droplets className="w-4 h-4 text-cyan-500" />
<span className="text-xs text-[var(--color-text-tertiary)]">Dewpoint</span>
</div>
<p className="text-xl font-bold text-cyan-500">
{reading.dewpoint.toFixed(1)}°F
</p>
</div>
{/* Light (if available) */}
{reading.light !== undefined && (
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Sun className="w-4 h-4 text-yellow-500" />
<span className="text-xs text-[var(--color-text-tertiary)]">Light</span>
</div>
<p className="text-xl font-bold text-yellow-500">
{reading.light.toFixed(0)} lux
</p>
</div>
)}
{/* CO2 (if available) */}
{reading.co2 !== undefined && (
<div className="p-3 rounded-lg bg-[var(--color-bg-tertiary)]">
<div className="flex items-center gap-2 mb-1">
<Wind className="w-4 h-4 text-purple-500" />
<span className="text-xs text-[var(--color-text-tertiary)]">CO2</span>
</div>
<p className="text-xl font-bold text-purple-500">
{reading.co2} ppm
</p>
</div>
)}
</div>
{/* Timestamp */}
<p className="text-xs text-[var(--color-text-tertiary)] text-right">
{new Date(reading.timestamp).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-4">
{/* WebSocket Status */}
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/50 border border-slate-700">
{wsConnected ? (
<Wifi className="w-4 h-4 text-green-500" />
) : (
<WifiOff className="w-4 h-4 text-red-500" />
)}
<span className="text-xs font-medium text-slate-300">
{wsConnected ? 'Live' : 'Offline'}
</span>
{unreadCount > 0 && (
<span className="flex items-center gap-1 text-red-500">
<Bell className="w-3 h-3" />
{unreadCount}
</span>
)}
</div>
))}
</div>
)}
{/* Empty State */}
{!loading && readings.length === 0 && status?.connected && (
<div className="p-8 text-center rounded-xl bg-[var(--color-bg-secondary)] border border-[var(--color-border-subtle)]">
<Activity className="w-12 h-12 mx-auto mb-4 text-[var(--color-text-tertiary)]" />
<h3 className="font-semibold text-[var(--color-text-primary)] mb-2">
No Sensor Readings
</h3>
<p className="text-sm text-[var(--color-text-tertiary)]">
Waiting for data from Pulse devices...
</p>
</div>
)}
{/* Recent Alerts */}
{alerts.length > 0 && (
<div className="p-4 rounded-xl bg-[var(--color-bg-secondary)] border border-[var(--color-border-subtle)]">
<h3 className="font-semibold text-[var(--color-text-primary)] mb-3 flex items-center gap-2">
<Bell className="w-4 h-4" />
Recent Alerts ({alerts.length})
</h3>
<div className="space-y-2">
{alerts.slice(0, 5).map((alert, index) => (
<div
key={alert.id || index}
className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-sm"
>
<p className="font-medium text-red-500">{alert.sensorName}</p>
<p className="text-[var(--color-text-tertiary)]">
{alert.type}: {alert.value.toFixed(1)} (threshold: {alert.threshold.toFixed(1)})
</p>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
{new Date(alert.timestamp).toLocaleTimeString()}
</p>
</div>
))}
{/* Refresh Button */}
<button
onClick={fetchData}
disabled={loading}
className="p-2.5 rounded-xl bg-slate-800/50 border border-slate-700 hover:bg-slate-700 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-5 h-5 text-slate-300 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400">
{error}
</div>
)}
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Live Readings Panel */}
<div className="lg:col-span-1 space-y-4">
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider">Live Sensors</h2>
{readings.map((reading) => (
<motion.div
key={reading.deviceId}
whileHover={{ scale: 1.02 }}
onClick={() => 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'
}`}
>
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-white text-sm">{reading.deviceName}</h3>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-xs text-green-400 font-medium">LIVE</span>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<p className="text-xs text-slate-500 mb-0.5">Temp</p>
<p className={`text-lg font-bold ${getTempColor(reading.temperature)}`}>
{reading.temperature.toFixed(1)}°
</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-0.5">RH</p>
<p className="text-lg font-bold text-blue-400">
{reading.humidity.toFixed(0)}%
</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-0.5">VPD</p>
<p className={`text-lg font-bold ${getVpdColor(reading.vpd)}`}>
{reading.vpd.toFixed(2)}
</p>
</div>
</div>
</motion.div>
))}
{readings.length === 0 && !loading && (
<div className="p-6 text-center rounded-xl bg-slate-800/50 border border-slate-700/50">
<Activity className="w-8 h-8 mx-auto mb-2 text-slate-500" />
<p className="text-slate-400 text-sm">No sensors connected</p>
</div>
)}
</div>
{/* Charts Section */}
<div className="lg:col-span-3 space-y-6">
{/* Current Stats */}
{currentReading && (
<div className="grid grid-cols-4 gap-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="p-5 rounded-2xl bg-gradient-to-br from-red-500/20 to-orange-500/10 border border-red-500/20"
>
<div className="flex items-center gap-2 mb-2">
<Thermometer className="w-5 h-5 text-red-400" />
<span className="text-sm text-slate-400">Temperature</span>
</div>
<p className={`text-4xl font-bold ${getTempColor(currentReading.temperature)}`}>
{currentReading.temperature.toFixed(1)}°F
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="p-5 rounded-2xl bg-gradient-to-br from-blue-500/20 to-cyan-500/10 border border-blue-500/20"
>
<div className="flex items-center gap-2 mb-2">
<Droplets className="w-5 h-5 text-blue-400" />
<span className="text-sm text-slate-400">Humidity</span>
</div>
<p className="text-4xl font-bold text-blue-400">
{currentReading.humidity.toFixed(0)}%
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="p-5 rounded-2xl bg-gradient-to-br from-purple-500/20 to-pink-500/10 border border-purple-500/20"
>
<div className="flex items-center gap-2 mb-2">
<Wind className="w-5 h-5 text-purple-400" />
<span className="text-sm text-slate-400">VPD</span>
</div>
<p className={`text-4xl font-bold ${getVpdColor(currentReading.vpd)}`}>
{currentReading.vpd.toFixed(2)}
</p>
<p className="text-xs text-slate-500 mt-1">kPa</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="p-5 rounded-2xl bg-gradient-to-br from-cyan-500/20 to-teal-500/10 border border-cyan-500/20"
>
<div className="flex items-center gap-2 mb-2">
<Droplets className="w-5 h-5 text-cyan-400" />
<span className="text-sm text-slate-400">Dewpoint</span>
</div>
<p className="text-4xl font-bold text-cyan-400">
{currentReading.dewpoint.toFixed(1)}°F
</p>
</motion.div>
</div>
)}
{/* Time Range Selector */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-emerald-400" />
Historical Trends
</h2>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-slate-400" />
<select
value={historyHours}
onChange={(e) => setHistoryHours(Number(e.target.value))}
className="bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
<option value={1}>Last hour</option>
<option value={6}>Last 6 hours</option>
<option value={12}>Last 12 hours</option>
<option value={24}>Last 24 hours</option>
<option value={48}>Last 48 hours</option>
</select>
</div>
</div>
{/* Temperature Chart */}
<div className="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/50">
<h3 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Thermometer className="w-4 h-4 text-red-400" />
Temperature History
</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={history}>
<defs>
<linearGradient id="tempGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="timestamp" stroke="#64748b" fontSize={11} />
<YAxis stroke="#64748b" fontSize={11} domain={['auto', 'auto']} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
labelStyle={{ color: '#94a3b8' }}
/>
<ReferenceLine y={82} stroke="#f59e0b" strokeDasharray="5 5" />
<ReferenceLine y={65} stroke="#3b82f6" strokeDasharray="5 5" />
<Area type="monotone" dataKey="temperature" stroke="#ef4444" fill="url(#tempGradient)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* Humidity & VPD Charts */}
<div className="grid grid-cols-2 gap-6">
<div className="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/50">
<h3 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Droplets className="w-4 h-4 text-blue-400" />
Humidity History
</h3>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={history}>
<defs>
<linearGradient id="humidityGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="timestamp" stroke="#64748b" fontSize={10} />
<YAxis stroke="#64748b" fontSize={10} domain={[0, 100]} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
/>
<Area type="monotone" dataKey="humidity" stroke="#3b82f6" fill="url(#humidityGradient)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
<div className="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/50">
<h3 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Wind className="w-4 h-4 text-purple-400" />
VPD History
</h3>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={history}>
<defs>
<linearGradient id="vpdGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#a855f7" stopOpacity={0.3} />
<stop offset="95%" stopColor="#a855f7" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="timestamp" stroke="#64748b" fontSize={10} />
<YAxis stroke="#64748b" fontSize={10} domain={[0, 2]} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
/>
<ReferenceLine y={1.2} stroke="#f59e0b" strokeDasharray="3 3" />
<ReferenceLine y={0.8} stroke="#3b82f6" strokeDasharray="3 3" />
<Area type="monotone" dataKey="vpd" stroke="#a855f7" fill="url(#vpdGradient)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
</div>
{/* Recent Alerts */}
{alerts.length > 0 && (
<div className="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/50">
<h3 className="font-semibold text-white mb-4 flex items-center gap-2">
<Bell className="w-5 h-5 text-amber-400" />
Recent Alerts ({alerts.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{alerts.slice(0, 6).map((alert, index) => (
<div
key={alert.id || index}
className="p-4 rounded-xl bg-red-500/10 border border-red-500/20"
>
<p className="font-medium text-red-400 text-sm">{alert.sensorName}</p>
<p className="text-slate-400 text-sm">
{alert.type}: {alert.value.toFixed(1)} (threshold: {alert.threshold.toFixed(1)})
</p>
<p className="text-xs text-slate-500 mt-2">
{new Date(alert.timestamp).toLocaleTimeString()}
</p>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}