M5-T07: add latest-reading cards and Recharts trend charts; readings return most-recent rows
This commit is contained in:
Generated
+386
-3
@@ -22,7 +22,8 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.30.4"
|
||||
"react-router-dom": "^6.30.4",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
@@ -1446,6 +1447,42 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||
"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/@remix-run/router": {
|
||||
"version": "1.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||
@@ -1855,7 +1892,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/@tanstack/query-core": {
|
||||
@@ -2058,6 +2100,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",
|
||||
@@ -2131,6 +2236,12 @@
|
||||
"@types/react": "^18.0.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/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
|
||||
@@ -3120,6 +3231,127 @@
|
||||
"devOptional": 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",
|
||||
@@ -3213,6 +3445,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-equal": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
|
||||
@@ -3565,6 +3803,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.48.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.48.1.tgz",
|
||||
"integrity": "sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -3847,6 +4095,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"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/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -4312,6 +4566,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",
|
||||
@@ -4377,6 +4641,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",
|
||||
@@ -5712,7 +5985,6 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
@@ -5739,6 +6011,29 @@
|
||||
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/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",
|
||||
@@ -5867,6 +6162,36 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.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",
|
||||
@@ -5881,6 +6206,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",
|
||||
@@ -5935,6 +6275,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.7",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||
@@ -6458,6 +6804,12 @@
|
||||
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
|
||||
"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",
|
||||
@@ -6873,6 +7225,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"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": "6.4.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.30.4"
|
||||
"react-router-dom": "^6.30.4",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Tests for energy/EnergyCharts.tsx
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Renders chart title with device name.
|
||||
* 2. Shows loading state while readings/metrics are loading.
|
||||
* 3. Shows error state when either query fails.
|
||||
* 4. Shows empty state when no readings are in the time window.
|
||||
* 5. Renders chart container when data is available.
|
||||
* 6. Tolerates missing keys in payload (no crash when key absent).
|
||||
* 7. Time-range segmented control is rendered.
|
||||
*
|
||||
* NOTE: Recharts itself is NOT mocked — we let it render (jsdom-compatible
|
||||
* rendering), but we only assert on data-testid wrappers, not Recharts internals.
|
||||
* Recharts SVG rendering may produce warnings in jsdom; these are expected and
|
||||
* benign (jsdom lacks ResizeObserver / SVG layout).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { renderWithProviders } from '../test-utils'
|
||||
import { EnergyCharts } from './EnergyCharts'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UUID = 'device-uuid-test'
|
||||
const DEVICE_NAME = 'SDM120 Main'
|
||||
|
||||
const METRICS_RESP = {
|
||||
profile: 'sdm120',
|
||||
metrics: [
|
||||
{ key: 'voltage', label: 'Voltage', unit: 'V', device_class: 'voltage' },
|
||||
{ key: 'current', label: 'Current', unit: 'A', device_class: 'current' },
|
||||
],
|
||||
}
|
||||
|
||||
const READING = {
|
||||
recorded_at: '2026-06-22T10:00:00Z',
|
||||
payload: { voltage: 230.2, current: 1.3 },
|
||||
}
|
||||
|
||||
const READINGS_RESP = {
|
||||
items: [READING],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock apiClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockGet = vi.fn()
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
default: {
|
||||
GET: (...args: unknown[]) => mockGet(...args),
|
||||
POST: vi.fn(),
|
||||
PATCH: vi.fn(),
|
||||
DELETE: vi.fn(),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
status: number
|
||||
body: unknown
|
||||
constructor(status: number, body: unknown) {
|
||||
super(`API error ${status}`)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
},
|
||||
registerLoginRedirect: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function setupDefaultMocks() {
|
||||
mockGet.mockImplementation((path: string) => {
|
||||
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||||
return Promise.resolve({ data: METRICS_RESP })
|
||||
}
|
||||
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||||
return Promise.resolve({ data: READINGS_RESP })
|
||||
}
|
||||
return Promise.resolve({ data: null })
|
||||
})
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
return renderWithProviders(
|
||||
<EnergyCharts uuid={UUID} deviceName={DEVICE_NAME} />,
|
||||
{ initialPath: '/energy' },
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('EnergyCharts — rendering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupDefaultMocks()
|
||||
})
|
||||
|
||||
it('renders chart title with device name', async () => {
|
||||
renderChart()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`energy-charts-title-${UUID}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId(`energy-charts-title-${UUID}`).textContent).toBe(DEVICE_NAME)
|
||||
})
|
||||
|
||||
it('renders time-range segmented control', async () => {
|
||||
renderChart()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`energy-charts-preset-${UUID}`)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading state while data is being fetched', () => {
|
||||
// Never resolve
|
||||
mockGet.mockReturnValue(new Promise(() => {}))
|
||||
renderChart()
|
||||
|
||||
expect(screen.getByTestId(`energy-charts-loading-${UUID}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error state when readings query fails', async () => {
|
||||
mockGet.mockRejectedValue(new Error('Network error'))
|
||||
renderChart()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`energy-charts-error-${UUID}`)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows empty state when no readings in time window', async () => {
|
||||
mockGet.mockImplementation((path: string) => {
|
||||
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||||
return Promise.resolve({ data: METRICS_RESP })
|
||||
}
|
||||
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||||
return Promise.resolve({ data: { items: [] } })
|
||||
}
|
||||
return Promise.resolve({ data: null })
|
||||
})
|
||||
|
||||
renderChart()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`energy-charts-empty-${UUID}`)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders chart container when data is available', async () => {
|
||||
renderChart()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`energy-charts-chart-${UUID}`)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EnergyCharts — truncation notice', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('shows truncation notice when readings count equals the limit cap (1000)', async () => {
|
||||
// Build exactly 1000 synthetic readings to trigger the truncation hint.
|
||||
const truncatedItems = Array.from({ length: 1000 }, (_, i) => ({
|
||||
recorded_at: new Date(Date.now() - (999 - i) * 5000).toISOString(),
|
||||
payload: { voltage: 230 + i * 0.01 },
|
||||
}))
|
||||
|
||||
mockGet.mockImplementation((path: string) => {
|
||||
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||||
return Promise.resolve({ data: METRICS_RESP })
|
||||
}
|
||||
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||||
return Promise.resolve({ data: { items: truncatedItems } })
|
||||
}
|
||||
return Promise.resolve({ data: null })
|
||||
})
|
||||
|
||||
renderChart()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId(`energy-charts-truncated-${UUID}`),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('does NOT show truncation notice when readings count is below limit', async () => {
|
||||
// Default mock has exactly 1 item — well below 1000.
|
||||
setupDefaultMocks()
|
||||
renderChart()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`energy-charts-chart-${UUID}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId(`energy-charts-truncated-${UUID}`)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('EnergyCharts — payload key tolerance', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('does not crash when payload is missing a metric key', async () => {
|
||||
mockGet.mockImplementation((path: string) => {
|
||||
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||||
return Promise.resolve({ data: METRICS_RESP })
|
||||
}
|
||||
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||||
// payload is missing 'current' key
|
||||
return Promise.resolve({
|
||||
data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] },
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ data: null })
|
||||
})
|
||||
|
||||
// Should render without throwing
|
||||
expect(() => renderChart()).not.toThrow()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`energy-charts-${UUID}`)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not crash when payload is entirely empty', async () => {
|
||||
mockGet.mockImplementation((path: string) => {
|
||||
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||||
return Promise.resolve({ data: METRICS_RESP })
|
||||
}
|
||||
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||||
return Promise.resolve({
|
||||
data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: {} }] },
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ data: null })
|
||||
})
|
||||
|
||||
expect(() => renderChart()).not.toThrow()
|
||||
|
||||
// All values null → empty state (no chart) or chart with no data points shown.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`energy-charts-${UUID}`)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* EnergyCharts — self-contained Recharts wrapper for Modbus device trend charts.
|
||||
*
|
||||
* Design decisions (M5 decision 11):
|
||||
* - Recharts is imported ONLY in this file (isolation, easy to swap later).
|
||||
* - The component is fully self-contained: it owns its own data fetching via
|
||||
* useReadings / useMetrics hooks and renders loading/error/empty states.
|
||||
* - Time-range selection is internal; readings are always fetched with a window
|
||||
* + limit cap — never a full-table pull.
|
||||
* - Metric labels and units come from GET /metrics; missing keys in payload are
|
||||
* tolerated (a null data point is emitted so the line simply has a gap).
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recharts imports — keep ALL recharts imports inside this file.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
Loader,
|
||||
Alert,
|
||||
SegmentedControl,
|
||||
Paper,
|
||||
Title,
|
||||
Badge,
|
||||
Box,
|
||||
} from '@mantine/core'
|
||||
|
||||
import { useReadings, useMetrics } from './hooks'
|
||||
import type { MetricInfo } from './hooks'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Must match the limit passed to useReadings below. */
|
||||
const CHART_READINGS_LIMIT = 1000
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EnergyChartsProps {
|
||||
/** UUID of the Modbus device to chart. */
|
||||
uuid: string
|
||||
/** Friendly name for the chart title. */
|
||||
deviceName: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Time-range presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TimePreset {
|
||||
label: string
|
||||
value: string
|
||||
/** How many milliseconds of history to show. */
|
||||
spanMs: number
|
||||
}
|
||||
|
||||
const TIME_PRESETS: TimePreset[] = [
|
||||
{ label: '1 h', value: '1h', spanMs: 60 * 60 * 1000 },
|
||||
{ label: '6 h', value: '6h', spanMs: 6 * 60 * 60 * 1000 },
|
||||
{ label: '24 h', value: '24h', spanMs: 24 * 60 * 60 * 1000 },
|
||||
]
|
||||
|
||||
const DEFAULT_PRESET = '1h'
|
||||
|
||||
/** Derive ISO start/end strings for a given preset. */
|
||||
function presetWindow(spanMs: number): { start: string; end: string } {
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - spanMs)
|
||||
return { start: start.toISOString(), end: end.toISOString() }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metric colour palette — cycles through a fixed set
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LINE_COLORS = [
|
||||
'#4c9cdb',
|
||||
'#f59f00',
|
||||
'#51cf66',
|
||||
'#f03e3e',
|
||||
'#cc5de8',
|
||||
'#20c997',
|
||||
'#fd7e14',
|
||||
'#74c0fc',
|
||||
]
|
||||
|
||||
function lineColor(index: number): string {
|
||||
return LINE_COLORS[index % LINE_COLORS.length]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: format a recorded_at timestamp for the X-axis tick
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatTimeTick(isoString: string): string {
|
||||
try {
|
||||
const d = new Date(isoString)
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
} catch {
|
||||
return isoString
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: safe numeric read from payload — returns null if key absent/non-numeric
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function safePayloadValue(
|
||||
payload: Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
): number | null {
|
||||
if (!payload) return null
|
||||
const raw = payload[key]
|
||||
if (raw === null || raw === undefined) return null
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EnergyCharts component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) {
|
||||
const [activePreset, setActivePreset] = useState<string>(DEFAULT_PRESET)
|
||||
|
||||
const selectedPreset = TIME_PRESETS.find((p) => p.value === activePreset) ?? TIME_PRESETS[0]
|
||||
const { start, end } = useMemo(
|
||||
() => presetWindow(selectedPreset.spanMs),
|
||||
// Recompute whenever the preset changes; intentionally excludes Date.now()
|
||||
// so the window doesn't drift on every render — it only resets on tab change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[activePreset],
|
||||
)
|
||||
|
||||
const readingsQuery = useReadings(uuid, { start, end, limit: CHART_READINGS_LIMIT })
|
||||
const metricsQuery = useMetrics(uuid)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Derive chart data: [{recorded_at, voltage: 230.2, current: 1.3, ...}, ...]
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const metricsData = metricsQuery.data?.metrics
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const metrics: MetricInfo[] = metricsData ?? []
|
||||
const readings = readingsQuery.data?.items ?? []
|
||||
return readings.map((row) => {
|
||||
const point: Record<string, string | number | null> = {
|
||||
recorded_at: row.recorded_at,
|
||||
}
|
||||
for (const m of metrics) {
|
||||
// Tolerate missing keys — null produces a gap in the line, not a crash.
|
||||
point[m.key] = safePayloadValue(row.payload as Record<string, unknown>, m.key)
|
||||
}
|
||||
return point
|
||||
})
|
||||
}, [readingsQuery.data, metricsData])
|
||||
|
||||
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// States: loading / error / empty
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const isLoading = readingsQuery.isLoading || metricsQuery.isLoading
|
||||
const isError = readingsQuery.isError || metricsQuery.isError
|
||||
|
||||
/**
|
||||
* True when the returned row count equals the limit cap, meaning the window
|
||||
* contains more data than was fetched. The backend returns the most-recent N
|
||||
* rows in this case, so the chart shows the latest segment — but a hint is
|
||||
* shown so the user knows the full window is not displayed.
|
||||
*/
|
||||
const isTruncated =
|
||||
!isLoading &&
|
||||
!isError &&
|
||||
(readingsQuery.data?.items.length ?? 0) >= CHART_READINGS_LIMIT
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" data-testid={`energy-charts-${uuid}`}>
|
||||
<Stack gap="sm">
|
||||
{/* Header row */}
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Title order={4} data-testid={`energy-charts-title-${uuid}`}>
|
||||
{deviceName}
|
||||
</Title>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={activePreset}
|
||||
onChange={setActivePreset}
|
||||
data={TIME_PRESETS.map((p) => ({ value: p.value, label: p.label }))}
|
||||
data-testid={`energy-charts-preset-${uuid}`}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<Group justify="center" py="lg" data-testid={`energy-charts-loading-${uuid}`}>
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading readings…
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{!isLoading && isError && (
|
||||
<Alert color="red" data-testid={`energy-charts-error-${uuid}`}>
|
||||
Failed to load readings or metrics. Please try again.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Empty */}
|
||||
{!isLoading && !isError && chartData.length === 0 && (
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
ta="center"
|
||||
py="lg"
|
||||
data-testid={`energy-charts-empty-${uuid}`}
|
||||
>
|
||||
No readings in this time window.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Truncation notice — shown when window has more data than the fetch limit */}
|
||||
{isTruncated && (
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
ta="right"
|
||||
data-testid={`energy-charts-truncated-${uuid}`}
|
||||
>
|
||||
显示最近 {CHART_READINGS_LIMIT} 条;完整长时段走势待降采样
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Chart */}
|
||||
{!isLoading && !isError && chartData.length > 0 && (
|
||||
<Box data-testid={`energy-charts-chart-${uuid}`}>
|
||||
{/* Render one chart per metric for clarity (avoids mixed Y-axis units) */}
|
||||
{metrics.map((metric, idx) => {
|
||||
// Check if this metric has any non-null data points; skip if all null.
|
||||
const hasData = chartData.some((pt) => pt[metric.key] !== null)
|
||||
if (!hasData) return null
|
||||
|
||||
return (
|
||||
<Box key={metric.key} mb="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{metric.label}
|
||||
</Text>
|
||||
{metric.unit && (
|
||||
<Badge size="xs" variant="outline" color="gray">
|
||||
{metric.unit}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 8, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.4} />
|
||||
<XAxis
|
||||
dataKey="recorded_at"
|
||||
tickFormatter={formatTimeTick}
|
||||
tick={{ fontSize: 10 }}
|
||||
minTickGap={40}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10 }}
|
||||
width={50}
|
||||
tickFormatter={(v: number) =>
|
||||
metric.unit ? `${v} ${metric.unit}` : String(v)
|
||||
}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
formatter={(value) => {
|
||||
const n = value as number | null | undefined
|
||||
if (n == null) return ['–', metric.label] as [string, string]
|
||||
return [
|
||||
`${n}${metric.unit ? ' ' + metric.unit : ''}`,
|
||||
metric.label,
|
||||
] as [string, string]
|
||||
}}
|
||||
labelFormatter={(label) => {
|
||||
try {
|
||||
return new Date(String(label)).toLocaleString()
|
||||
} catch {
|
||||
return String(label)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={metric.key}
|
||||
name={metric.label}
|
||||
stroke={lineColor(idx)}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
connectNulls={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -212,3 +212,136 @@ describe('useTestReadDevice', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useLatestReading', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('calls GET /api/modbus/devices/{uuid}/latest with path param', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: { found: true, recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } },
|
||||
})
|
||||
|
||||
const { Wrapper } = makeWrapper()
|
||||
const { useLatestReading } = await import('./hooks')
|
||||
const { result } = renderHook(() => useLatestReading('test-uuid-1'), { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/latest', {
|
||||
params: { path: { uuid: 'test-uuid-1' } },
|
||||
})
|
||||
expect(result.current.data?.found).toBe(true)
|
||||
expect(result.current.data?.payload).toEqual({ voltage: 230.2 })
|
||||
})
|
||||
|
||||
it('returns found=false when device has no readings', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: { found: false, recorded_at: null, payload: null },
|
||||
})
|
||||
|
||||
const { Wrapper } = makeWrapper()
|
||||
const { useLatestReading } = await import('./hooks')
|
||||
const { result } = renderHook(() => useLatestReading('test-uuid-2'), { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data?.found).toBe(false)
|
||||
expect(result.current.data?.payload).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMetrics', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('calls GET /api/modbus/devices/{uuid}/metrics with path param', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: {
|
||||
profile: 'sdm120',
|
||||
metrics: [{ key: 'voltage', label: 'Voltage', unit: 'V', device_class: 'voltage' }],
|
||||
},
|
||||
})
|
||||
|
||||
const { Wrapper } = makeWrapper()
|
||||
const { useMetrics } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMetrics('test-uuid-1'), { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/metrics', {
|
||||
params: { path: { uuid: 'test-uuid-1' } },
|
||||
})
|
||||
expect(result.current.data?.metrics).toHaveLength(1)
|
||||
expect(result.current.data?.metrics[0].key).toBe('voltage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useReadings', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('calls GET /api/modbus/devices/{uuid}/readings with window and limit', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] },
|
||||
})
|
||||
|
||||
const { Wrapper } = makeWrapper()
|
||||
const { useReadings } = await import('./hooks')
|
||||
const params = { start: '2026-06-22T09:00:00Z', end: '2026-06-22T10:00:00Z', limit: 500 }
|
||||
const { result } = renderHook(() => useReadings('test-uuid-1', params), { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings', {
|
||||
params: {
|
||||
path: { uuid: 'test-uuid-1' },
|
||||
query: {
|
||||
start: '2026-06-22T09:00:00Z',
|
||||
end: '2026-06-22T10:00:00Z',
|
||||
limit: 500,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(result.current.data?.items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('caps limit at READINGS_MAX_LIMIT (1000) even if caller requests more', async () => {
|
||||
mockGet.mockResolvedValue({ data: { items: [] } })
|
||||
|
||||
const { Wrapper } = makeWrapper()
|
||||
const { useReadings } = await import('./hooks')
|
||||
const { result } = renderHook(
|
||||
() => useReadings('test-uuid-1', { limit: 99999 }),
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
|
||||
// The query param limit should be capped at 1000.
|
||||
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings',
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
query: expect.objectContaining({ limit: 1000 }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('omits start/end from query params when not provided', async () => {
|
||||
mockGet.mockResolvedValue({ data: { items: [] } })
|
||||
|
||||
const { Wrapper } = makeWrapper()
|
||||
const { useReadings } = await import('./hooks')
|
||||
const { result } = renderHook(
|
||||
() => useReadings('test-uuid-1', {}),
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings',
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
query: expect.not.objectContaining({ start: expect.anything() }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
* ['modbus-devices'] — device list
|
||||
* ['modbus-device', uuid] — single device
|
||||
* ['modbus-profiles'] — profile list (rarely changes)
|
||||
* ['modbus-latest', uuid] — latest reading per device
|
||||
* ['modbus-metrics', uuid] — profile metric metadata per device
|
||||
* ['modbus-readings', uuid, params] — time-range readings per device
|
||||
*
|
||||
* On success, mutations invalidate the device list so the UI refreshes.
|
||||
*/
|
||||
@@ -25,6 +28,22 @@ export type ModbusDeviceCreate = components['schemas']['ModbusDeviceCreate']
|
||||
export type ModbusDeviceUpdate = components['schemas']['ModbusDeviceUpdate']
|
||||
export type ProfileSummary = components['schemas']['ProfileSummary']
|
||||
export type ModbusTestReadResponse = components['schemas']['ModbusTestReadResponse']
|
||||
export type ModbusLatestResponse = components['schemas']['ModbusLatestResponse']
|
||||
export type ModbusMetricsResponse = components['schemas']['ModbusMetricsResponse']
|
||||
export type MetricInfo = components['schemas']['MetricInfo']
|
||||
export type ModbusReadingResponse = components['schemas']['ModbusReadingResponse']
|
||||
export type ModbusReadingsResponse = components['schemas']['ModbusReadingsResponse']
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reading query params
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ReadingsQueryParams {
|
||||
start?: string | null
|
||||
end?: string | null
|
||||
/** Capped server-side; default max is 1000 to avoid pulling full history. */
|
||||
limit?: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query: list all devices
|
||||
@@ -112,3 +131,70 @@ export function useTestReadDevice() {
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query: latest reading for a device
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useLatestReading(uuid: string) {
|
||||
return useQuery({
|
||||
queryKey: ['modbus-latest', uuid],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.GET('/api/modbus/devices/{uuid}/latest', {
|
||||
params: { path: { uuid } },
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
// Refresh every 10 s to show up-to-date readings.
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query: profile metric metadata for a device (label/unit per key)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useMetrics(uuid: string) {
|
||||
return useQuery({
|
||||
queryKey: ['modbus-metrics', uuid],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.GET('/api/modbus/devices/{uuid}/metrics', {
|
||||
params: { path: { uuid } },
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
// Metrics are static (tied to profile version); 5 min stale time.
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query: time-range readings for a device (window + limit — never full-table)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Hard upper-bound on readings fetched; prevents accidental full-table pulls. */
|
||||
const READINGS_MAX_LIMIT = 1000
|
||||
|
||||
export function useReadings(uuid: string, params: ReadingsQueryParams) {
|
||||
const { start, end, limit } = params
|
||||
const effectiveLimit = Math.min(limit ?? READINGS_MAX_LIMIT, READINGS_MAX_LIMIT)
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['modbus-readings', uuid, { start, end, limit: effectiveLimit }],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.GET('/api/modbus/devices/{uuid}/readings', {
|
||||
params: {
|
||||
path: { uuid },
|
||||
query: {
|
||||
...(start ? { start } : {}),
|
||||
...(end ? { end } : {}),
|
||||
limit: effectiveLimit,
|
||||
},
|
||||
},
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
// Enabled only when we have valid uuid; start/end may be null (full window).
|
||||
enabled: !!uuid,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,9 +107,13 @@ describe('EnergyPage — device list', () => {
|
||||
expect(screen.getByTestId('devices-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('SDM120 Main')).toBeInTheDocument()
|
||||
// Device name appears in multiple places now (table row + readings section + chart title).
|
||||
const nameElements = screen.getAllByText('SDM120 Main')
|
||||
expect(nameElements.length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('192.168.1.100')).toBeInTheDocument()
|
||||
expect(screen.getByText('sdm120')).toBeInTheDocument()
|
||||
// 'sdm120' may also appear in multiple places (table badge + chart).
|
||||
const profileElements = screen.getAllByText('sdm120')
|
||||
expect(profileElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows empty state when no devices', async () => {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* EnergyPage — device management UI for the Energy (Modbus) domain.
|
||||
*
|
||||
* Features:
|
||||
* Features (T06 + T07):
|
||||
* - Device list with status indicators (enabled, last poll).
|
||||
* - Create / edit via DeviceForm modal.
|
||||
* - Delete with二次确认; 409 (has readings) → friendly prompt to disable instead.
|
||||
* - "Test read" button per device — calls POST /devices/{uuid}/test, shows
|
||||
* decoded payload or error without persisting to the database.
|
||||
*
|
||||
* Out of scope (T07): readings cards, trend charts, Recharts import.
|
||||
* decoded payload or error without persisting to the database (T06 OBS-1: error
|
||||
* now caught and displayed rather than producing an unhandled rejection).
|
||||
* - Latest readings card per device (T07): fetches /latest and /metrics; tolerates
|
||||
* missing payload keys.
|
||||
* - Trend charts per device (T07): EnergyCharts component (Recharts isolated inside).
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
@@ -28,10 +30,14 @@ import {
|
||||
Modal,
|
||||
Code,
|
||||
Tooltip,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
} from '@mantine/core'
|
||||
import { useDevices, useDeleteDevice, useTestReadDevice } from '../energy/hooks'
|
||||
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
|
||||
import { DeviceForm } from '../energy/DeviceForm'
|
||||
import type { ModbusDevice, ModbusTestReadResponse } from '../energy/hooks'
|
||||
import { EnergyCharts } from '../energy/EnergyCharts'
|
||||
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
|
||||
import { ApiError } from '../api/client'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -131,6 +137,8 @@ function TestResultModal({ result, deviceName, onClose }: TestResultModalProps)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device row action: test-read button (self-contained per row)
|
||||
// T06 OBS-1 fix: catch errors from mutateAsync so they don't produce unhandled
|
||||
// promise rejections (e.g. 422 when profile cannot load).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TestReadButtonProps {
|
||||
@@ -140,12 +148,25 @@ interface TestReadButtonProps {
|
||||
function TestReadButton({ device }: TestReadButtonProps) {
|
||||
const testMutation = useTestReadDevice()
|
||||
const [result, setResult] = useState<ModbusTestReadResponse | null>(null)
|
||||
const [testError, setTestError] = useState<string | null>(null)
|
||||
|
||||
async function handleTest() {
|
||||
setResult(null)
|
||||
const res = await testMutation.mutateAsync(device.uuid)
|
||||
if (res.data) {
|
||||
setResult(res.data)
|
||||
setTestError(null)
|
||||
try {
|
||||
const res = await testMutation.mutateAsync(device.uuid)
|
||||
if (res.data) {
|
||||
setResult(res.data)
|
||||
}
|
||||
} catch (err) {
|
||||
// Network or API errors (e.g. 422 profile load failure) — surface inline.
|
||||
const message =
|
||||
err instanceof ApiError
|
||||
? `Error ${err.status}: ${JSON.stringify(err.body?.detail ?? err.body ?? 'unknown error')}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Unexpected error during test read.'
|
||||
setTestError(message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +185,26 @@ function TestReadButton({ device }: TestReadButtonProps) {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Show inline test error as a small alert (no modal needed for error path) */}
|
||||
{testError && (
|
||||
<Modal
|
||||
opened
|
||||
onClose={() => setTestError(null)}
|
||||
title={`Test read error — ${device.friendly_name}`}
|
||||
size="sm"
|
||||
data-testid="test-read-error-modal"
|
||||
>
|
||||
<Text c="red" data-testid="test-read-inline-error">
|
||||
{testError}
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={() => setTestError(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<TestResultModal
|
||||
result={result}
|
||||
@@ -175,6 +216,102 @@ function TestReadButton({ device }: TestReadButtonProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Latest readings card (T07)
|
||||
// Fetches /latest and /metrics; tolerates missing payload keys.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LatestReadingsCardProps {
|
||||
device: ModbusDevice
|
||||
}
|
||||
|
||||
function LatestReadingsCard({ device }: LatestReadingsCardProps) {
|
||||
const latestQuery = useLatestReading(device.uuid)
|
||||
const metricsQuery = useMetrics(device.uuid)
|
||||
|
||||
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
|
||||
const latest = latestQuery.data
|
||||
|
||||
if (latestQuery.isLoading || metricsQuery.isLoading) {
|
||||
return (
|
||||
<Paper withBorder p="sm" data-testid={`latest-card-loading-${device.uuid}`}>
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading latest reading…
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
if (latestQuery.isError || metricsQuery.isError) {
|
||||
return (
|
||||
<Alert color="red" data-testid={`latest-card-error-${device.uuid}`}>
|
||||
Failed to load latest reading.
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (!latest?.found || !latest.payload) {
|
||||
return (
|
||||
<Paper withBorder p="sm" data-testid={`latest-card-empty-${device.uuid}`}>
|
||||
<Text size="sm" c="dimmed">
|
||||
No readings yet.
|
||||
</Text>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
const payload = latest.payload as Record<string, unknown>
|
||||
|
||||
return (
|
||||
<Paper withBorder p="sm" data-testid={`latest-card-${device.uuid}`}>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
Latest reading
|
||||
</Text>
|
||||
{latest.recorded_at && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(latest.recorded_at).toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Divider />
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{metrics.map((m) => {
|
||||
const raw = payload[m.key]
|
||||
const hasValue = raw !== null && raw !== undefined
|
||||
const displayValue = hasValue
|
||||
? typeof raw === 'number'
|
||||
? raw.toFixed(raw % 1 === 0 ? 0 : 2)
|
||||
: String(raw)
|
||||
: '—'
|
||||
|
||||
return (
|
||||
<Group key={m.key} justify="space-between" align="baseline" gap={4}
|
||||
data-testid={`latest-value-${device.uuid}-${m.key}`}>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{m.label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{displayValue}
|
||||
{hasValue && m.unit ? (
|
||||
<Text component="span" size="xs" c="dimmed" ml={2}>
|
||||
{m.unit}
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</Group>
|
||||
)
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device list table
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -272,6 +409,33 @@ function DeviceTable({ devices, onEdit, onDelete }: DeviceTableProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device readings section — latest card + trend chart per device (T07)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DeviceReadingsSectionProps {
|
||||
devices: ModbusDevice[]
|
||||
}
|
||||
|
||||
function DeviceReadingsSection({ devices }: DeviceReadingsSectionProps) {
|
||||
if (devices.length === 0) return null
|
||||
|
||||
return (
|
||||
<Stack gap="xl" data-testid="device-readings-section">
|
||||
<Divider label="Readings & Trends" labelPosition="left" />
|
||||
{devices.map((device) => (
|
||||
<Stack key={device.uuid} gap="sm" data-testid={`device-readings-${device.uuid}`}>
|
||||
<Text fw={500} size="sm">
|
||||
{device.friendly_name}
|
||||
</Text>
|
||||
<LatestReadingsCard device={device} />
|
||||
<EnergyCharts uuid={device.uuid} deviceName={device.friendly_name} />
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EnergyPage — top-level
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -362,6 +526,9 @@ export function EnergyPage() {
|
||||
onEdit={openEdit}
|
||||
onDelete={openDelete}
|
||||
/>
|
||||
|
||||
{/* Latest readings cards + trend charts (T07) */}
|
||||
<DeviceReadingsSection devices={devices} />
|
||||
</Stack>
|
||||
|
||||
{/* Create form */}
|
||||
|
||||
Reference in New Issue
Block a user