From 69253e174de576c8a5c2fd1838bd6538dd89c310 Mon Sep 17 00:00:00 2001 From: lowcarbdev Date: Sat, 28 Feb 2026 22:52:23 -0700 Subject: [PATCH] initial summary page --- frontend/package-lock.json | 348 +++++++++++++++++++++++++++- frontend/package.json | 3 +- frontend/src/App.jsx | 24 ++ frontend/src/components/Summary.jsx | 337 +++++++++++++++++++++++++++ internal/database.go | 174 ++++++++++++++ internal/handlers.go | 45 ++++ internal/models.go | 37 +++ main.go | 1 + 8 files changed, 967 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/Summary.jsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5b7cd45..b05322f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,7 +15,8 @@ "react-bootstrap": "^2.10.10", "react-datepicker": "^8.8.0", "react-dom": "^19.1.1", - "react-router-dom": "^7.9.5" + "react-router-dom": "^7.9.5", + "recharts": "^3.7.0" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -1099,6 +1100,40 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "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==", + "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.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@restart/hooks": { "version": "0.4.16", "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", @@ -1468,6 +1503,16 @@ "win32" ] }, + "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==" + }, + "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==" + }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -1522,6 +1567,60 @@ "@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==" + }, + "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==" + }, + "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==" + }, + "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==", + "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==" + }, + "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==", + "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==", + "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==" + }, + "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==" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1570,6 +1669,11 @@ "@types/react": "*" } }, + "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==" + }, "node_modules/@types/warning": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", @@ -1910,6 +2014,116 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "engines": { + "node": ">=12" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -1938,6 +2152,11 @@ } } }, + "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==" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2039,6 +2258,11 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2281,6 +2505,11 @@ "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==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2575,6 +2804,15 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "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", @@ -2602,6 +2840,14 @@ "node": ">=0.8.19" } }, + "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==", + "engines": { + "node": ">=12" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -3129,6 +3375,28 @@ "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "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.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3193,6 +3461,50 @@ "react-dom": ">=16.6.0" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "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/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "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==", + "peerDependencies": { + "redux": "^5.0.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==" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3332,6 +3644,11 @@ "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", "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==" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3424,6 +3741,35 @@ "punycode": "^2.1.0" } }, + "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==", + "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==", + "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": "7.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 39242b4..62556a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,8 @@ "react-bootstrap": "^2.10.10", "react-datepicker": "^8.8.0", "react-dom": "^19.1.1", - "react-router-dom": "^7.9.5" + "react-router-dom": "^7.9.5", + "recharts": "^3.7.0" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1289b3b..569c0ab 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -10,6 +10,7 @@ import Calls from './components/Calls' import DateFilter from './components/DateFilter' import Upload from './components/Upload' import Search from './components/Search' +import Summary from './components/Summary' import ChangePasswordModal from './components/ChangePasswordModal' import SettingsModal from './components/SettingsModal' import './App.css' @@ -54,6 +55,8 @@ function App() { ? 'calls' : location.pathname.startsWith('/search') ? 'search' + : location.pathname.startsWith('/summary') + ? 'summary' : 'conversations' useEffect(() => { @@ -166,6 +169,8 @@ function App() { navigate('/calls') } else if (view === 'search') { navigate('/search') + } else if (view === 'summary') { + navigate('/summary') } else { navigate('/') } @@ -296,6 +301,17 @@ function App() { Activity +
  • + +
  • @@ -383,6 +399,14 @@ function App() { endDate={endDate} /> + ) : activeView === 'summary' ? ( + /* Summary View */ +
    + +
    ) : ( /* Activity View */
    diff --git a/frontend/src/components/Summary.jsx b/frontend/src/components/Summary.jsx new file mode 100644 index 0000000..c8ad585 --- /dev/null +++ b/frontend/src/components/Summary.jsx @@ -0,0 +1,337 @@ +import { useState, useEffect } from 'react' +import axios from 'axios' +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + PieChart, Pie, Cell, LineChart, Line, Legend +} from 'recharts' + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api' + +// Color palette +const COLORS = ['#0d6efd', '#198754', '#ffc107', '#dc3545', '#6c757d', '#0dcaf0', '#6610f2', '#d63384'] + +function Summary({ startDate, endDate }) { + const [analytics, setAnalytics] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + fetchAnalytics() + }, [startDate, endDate]) + + const fetchAnalytics = async () => { + setLoading(true) + setError(null) + try { + const params = {} + if (startDate) params.start = startDate.toISOString() + if (endDate) params.end = endDate.toISOString() + + const response = await axios.get(`${API_BASE}/analytics`, { params }) + setAnalytics(response.data) + } catch (err) { + console.error('Error fetching analytics:', err) + setError('Failed to load analytics') + } finally { + setLoading(false) + } + } + + const formatDuration = (seconds) => { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + if (hours > 0) return `${hours}h ${minutes}m` + return `${minutes}m` + } + + const formatHour = (hour) => { + if (hour === 0) return '12 AM' + if (hour === 12) return '12 PM' + return hour < 12 ? `${hour} AM` : `${hour - 12} PM` + } + + const formatPhoneNumber = (phone) => { + if (!phone) return '' + // Remove all non-digit characters + const digits = phone.replace(/\D/g, '') + // Format as (XXX) XXX-XXXX if 10 digits, or +X (XXX) XXX-XXXX if 11 + if (digits.length === 10) { + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}` + } else if (digits.length === 11 && digits[0] === '1') { + return `+1 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}` + } + return phone + } + + if (loading) { + return ( +
    +
    +
    + Loading... +
    +

    Loading analytics...

    +
    +
    + ) + } + + if (error) { + return ( +
    +
    +

    {error}

    + +
    +
    + ) + } + + if (!analytics) return null + + // Prepare data for message type pie chart + const messageTypeData = [ + { name: 'Sent', value: analytics.total_sent }, + { name: 'Received', value: analytics.total_received } + ].filter(d => d.value > 0) + + // Prepare data for call type pie chart + const callTypeData = [ + { name: 'Incoming', value: analytics.incoming_calls }, + { name: 'Outgoing', value: analytics.outgoing_calls }, + { name: 'Missed', value: analytics.missed_calls } + ].filter(d => d.value > 0) + + // Prepare top contacts data with display names + const topContactsData = (analytics.top_contacts || []).slice(0, 8).map(c => ({ + ...c, + displayName: c.contact_name || formatPhoneNumber(c.address) || c.address + })) + + return ( +
    +
    +

    + + + + Summary +

    +
    + +
    + {/* Summary Stats Cards */} +
    +
    +
    +
    +

    {(analytics.total_sms + analytics.total_mms).toLocaleString()}

    + Total Messages +
    +
    +
    +
    +
    +
    +

    {analytics.total_calls.toLocaleString()}

    + Total Calls +
    +
    +
    +
    +
    +
    +

    {formatDuration(analytics.total_call_duration)}

    + Call Duration +
    +
    +
    +
    +
    +
    +

    {Math.round(analytics.avg_message_length)}

    + Avg Chars/Msg +
    +
    +
    +
    + + {/* Charts Row 1: Sent/Received + Top Contacts */} +
    + {/* Sent vs Received Pie */} +
    +
    +
    Sent vs Received
    +
    + {messageTypeData.length > 0 ? ( + + + `${name} ${(percent * 100).toFixed(0)}%`} + > + {messageTypeData.map((entry, index) => ( + + ))} + + + + + ) : ( +
    No message data
    + )} +
    +
    +
    + + {/* Top Contacts */} +
    +
    +
    Top Contacts
    +
    + {topContactsData.length > 0 ? ( + + + + + + [value.toLocaleString(), 'Messages']} + labelFormatter={(label) => label} + /> + + + + ) : ( +
    No contact data
    + )} +
    +
    +
    +
    + + {/* Charts Row 2: Hourly Distribution */} +
    +
    +
    +
    Messages by Time of Day
    +
    + {analytics.hourly_distribution && analytics.hourly_distribution.some(h => h.count > 0) ? ( + + + + + + formatHour(hour)} + formatter={(value) => [value.toLocaleString(), 'Messages']} + /> + + + + ) : ( +
    No hourly data
    + )} +
    +
    +
    +
    + + {/* Charts Row 3: Daily Trend */} +
    +
    +
    +
    Message Trend Over Time
    +
    + {analytics.daily_trend && analytics.daily_trend.length > 0 ? ( + + + + { + const d = new Date(date) + return `${d.getMonth() + 1}/${d.getDate()}` + }} + interval="preserveStartEnd" + /> + + new Date(date).toLocaleDateString()} + formatter={(value) => [value.toLocaleString(), 'Messages']} + /> + + + + ) : ( +
    No trend data
    + )} +
    +
    +
    +
    + + {/* Call Statistics */} + {analytics.total_calls > 0 && ( +
    +
    +
    +
    Call Breakdown
    +
    + {callTypeData.length > 0 ? ( + + + `${name}: ${value}`} + > + {callTypeData.map((entry, index) => ( + + ))} + + + + + + ) : ( +
    No call data
    + )} +
    +
    +
    +
    + )} +
    +
    + ) +} + +export default Summary diff --git a/internal/database.go b/internal/database.go index cfd163a..9213685 100644 --- a/internal/database.go +++ b/internal/database.go @@ -1032,3 +1032,177 @@ func SearchMessages(userDB *sql.DB, query string, limit int) ([]SearchResult, er return results, nil } + +// GetAnalytics retrieves analytics data for the Summary tab +func GetAnalytics(userDB *sql.DB, startDate, endDate *time.Time, topN int) (*AnalyticsResponse, error) { + analytics := &AnalyticsResponse{} + + // Build date filter + dateFilter := "" + args := []interface{}{} + if startDate != nil { + dateFilter += " AND date >= ?" + args = append(args, startDate.Unix()) + } + if endDate != nil { + dateFilter += " AND date <= ?" + args = append(args, endDate.Unix()) + } + + // 1. Get summary statistics + if err := getSummaryStats(userDB, dateFilter, args, analytics); err != nil { + return nil, err + } + + // 2. Get top contacts + topContacts, err := getTopContacts(userDB, dateFilter, args, topN) + if err != nil { + return nil, err + } + analytics.TopContacts = topContacts + + // 3. Get hourly distribution + hourly, err := getHourlyDistribution(userDB, dateFilter, args) + if err != nil { + return nil, err + } + analytics.HourlyDistribution = hourly + + // 4. Get daily trend + daily, err := getDailyTrend(userDB, dateFilter, args) + if err != nil { + return nil, err + } + analytics.DailyTrend = daily + + return analytics, nil +} + +func getSummaryStats(userDB *sql.DB, dateFilter string, args []interface{}, analytics *AnalyticsResponse) error { + query := ` + SELECT + COUNT(*) as total, + SUM(CASE WHEN record_type = 1 THEN 1 ELSE 0 END) as sms_count, + SUM(CASE WHEN record_type = 2 THEN 1 ELSE 0 END) as mms_count, + SUM(CASE WHEN record_type = 3 THEN 1 ELSE 0 END) as call_count, + SUM(CASE WHEN record_type IN (1,2) AND type = 2 THEN 1 ELSE 0 END) as sent, + SUM(CASE WHEN record_type IN (1,2) AND type = 1 THEN 1 ELSE 0 END) as received, + SUM(CASE WHEN record_type = 3 AND type = 1 THEN 1 ELSE 0 END) as incoming_calls, + SUM(CASE WHEN record_type = 3 AND type = 2 THEN 1 ELSE 0 END) as outgoing_calls, + SUM(CASE WHEN record_type = 3 AND type = 3 THEN 1 ELSE 0 END) as missed_calls, + COALESCE(SUM(CASE WHEN record_type = 3 THEN duration ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN record_type IN (1,2) AND body IS NOT NULL AND body != '' THEN LENGTH(body) END), 0) as avg_length + FROM messages + WHERE 1=1 ` + dateFilter + + return userDB.QueryRow(query, args...).Scan( + &analytics.TotalMessages, + &analytics.TotalSMS, + &analytics.TotalMMS, + &analytics.TotalCalls, + &analytics.TotalSent, + &analytics.TotalReceived, + &analytics.IncomingCalls, + &analytics.OutgoingCalls, + &analytics.MissedCalls, + &analytics.TotalCallDuration, + &analytics.AvgMessageLength, + ) +} + +func getTopContacts(userDB *sql.DB, dateFilter string, args []interface{}, limit int) ([]TopContact, error) { + query := ` + SELECT + address, + MAX(COALESCE(contact_name, '')) as contact_name, + COUNT(*) as message_count, + SUM(CASE WHEN type = 2 THEN 1 ELSE 0 END) as sent_count, + SUM(CASE WHEN type = 1 THEN 1 ELSE 0 END) as received_count + FROM messages + WHERE record_type IN (1, 2) ` + dateFilter + ` + GROUP BY address + ORDER BY message_count DESC + LIMIT ?` + + queryArgs := append(args, limit) + rows, err := userDB.Query(query, queryArgs...) + if err != nil { + return nil, err + } + defer rows.Close() + + var contacts []TopContact + for rows.Next() { + var c TopContact + if err := rows.Scan(&c.Address, &c.ContactName, &c.MessageCount, &c.SentCount, &c.ReceivedCount); err != nil { + return nil, err + } + contacts = append(contacts, c) + } + return contacts, nil +} + +func getHourlyDistribution(userDB *sql.DB, dateFilter string, args []interface{}) ([]HourlyDistribution, error) { + query := ` + SELECT + CAST(strftime('%H', date, 'unixepoch', 'localtime') AS INTEGER) as hour, + COUNT(*) as count + FROM messages + WHERE record_type IN (1, 2) ` + dateFilter + ` + GROUP BY hour + ORDER BY hour` + + rows, err := userDB.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + // Initialize all 24 hours with 0 + hourMap := make(map[int]int) + for i := 0; i < 24; i++ { + hourMap[i] = 0 + } + + for rows.Next() { + var hour, count int + if err := rows.Scan(&hour, &count); err != nil { + return nil, err + } + hourMap[hour] = count + } + + // Convert to slice + result := make([]HourlyDistribution, 24) + for i := 0; i < 24; i++ { + result[i] = HourlyDistribution{Hour: i, Count: hourMap[i]} + } + return result, nil +} + +func getDailyTrend(userDB *sql.DB, dateFilter string, args []interface{}) ([]DailyCount, error) { + query := ` + SELECT + strftime('%Y-%m-%d', date, 'unixepoch', 'localtime') as day, + COUNT(*) as count + FROM messages + WHERE record_type IN (1, 2) ` + dateFilter + ` + GROUP BY day + ORDER BY day` + + rows, err := userDB.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var trend []DailyCount + for rows.Next() { + var d DailyCount + if err := rows.Scan(&d.Date, &d.Count); err != nil { + return nil, err + } + trend = append(trend, d) + } + return trend, nil +} diff --git a/internal/handlers.go b/internal/handlers.go index 5dea5bf..d7d89e8 100644 --- a/internal/handlers.go +++ b/internal/handlers.go @@ -552,6 +552,51 @@ func HandleSearch(c echo.Context) error { return c.JSON(http.StatusOK, results) } +// HandleAnalytics returns analytics data for the Summary tab +func HandleAnalytics(c echo.Context) error { + userDB, err := getUserDB(c) + if err != nil { + slog.Error("Error getting user database", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get user database", + }) + } + + var startDate, endDate *time.Time + + if startStr := c.QueryParam("start"); startStr != "" { + t, err := time.Parse(time.RFC3339, startStr) + if err == nil { + startDate = &t + } + } + + if endStr := c.QueryParam("end"); endStr != "" { + t, err := time.Parse(time.RFC3339, endStr) + if err == nil { + endDate = &t + } + } + + // Default to top 10 contacts + topN := 10 + if topStr := c.QueryParam("top"); topStr != "" { + if val, err := strconv.Atoi(topStr); err == nil && val > 0 && val <= 50 { + topN = val + } + } + + analytics, err := GetAnalytics(userDB, startDate, endDate, topN) + if err != nil { + slog.Error("Error getting analytics", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get analytics", + }) + } + + return c.JSON(http.StatusOK, analytics) +} + // HandleVersion returns the application version func HandleVersion(c echo.Context) error { // Try to read version from version.json file first (Docker builds) diff --git a/internal/models.go b/internal/models.go index 7ba7cbe..b9db7aa 100644 --- a/internal/models.go +++ b/internal/models.go @@ -110,3 +110,40 @@ type ChangePasswordRequest struct { NewPassword string `json:"new_password"` ConfirmPassword string `json:"confirm_password"` } + +// Analytics types + +type TopContact struct { + Address string `json:"address"` + ContactName string `json:"contact_name,omitempty"` + MessageCount int `json:"message_count"` + SentCount int `json:"sent_count"` + ReceivedCount int `json:"received_count"` +} + +type HourlyDistribution struct { + Hour int `json:"hour"` + Count int `json:"count"` +} + +type DailyCount struct { + Date string `json:"date"` + Count int `json:"count"` +} + +type AnalyticsResponse struct { + TotalMessages int `json:"total_messages"` + TotalSMS int `json:"total_sms"` + TotalMMS int `json:"total_mms"` + TotalCalls int `json:"total_calls"` + TotalSent int `json:"total_sent"` + TotalReceived int `json:"total_received"` + IncomingCalls int `json:"incoming_calls"` + OutgoingCalls int `json:"outgoing_calls"` + MissedCalls int `json:"missed_calls"` + TotalCallDuration int `json:"total_call_duration"` + AvgMessageLength float64 `json:"avg_message_length"` + TopContacts []TopContact `json:"top_contacts"` + HourlyDistribution []HourlyDistribution `json:"hourly_distribution"` + DailyTrend []DailyCount `json:"daily_trend"` +} diff --git a/main.go b/main.go index d301580..1fa5612 100644 --- a/main.go +++ b/main.go @@ -109,6 +109,7 @@ func main() { protected.GET("/search", internal.HandleSearch) protected.GET("/settings", internal.HandleGetSettings) protected.PUT("/settings", internal.HandleUpdateSettings) + protected.GET("/analytics", internal.HandleAnalytics) // Health check e.GET("/api/health", func(c echo.Context) error {