From 81be8ad403fc08be972d1e730c2d1250899c1764 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 24 Mar 2026 20:37:40 -0700 Subject: [PATCH 01/25] chore: add .worktrees to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index df33723f..45297f2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /.env /.env.local +/.worktrees # Created by https://www.toptal.com/developers/gitignore/api/osx,windows,linux,nextjs,react,node # Edit at https://www.toptal.com/developers/gitignore?templates=osx,windows,linux,nextjs,react,node From b9a29e23f61dcfcc03e5c69464d8d13016cd3c89 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 08:07:48 -0700 Subject: [PATCH 02/25] chore: add recharts dependency for analytics charts Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 391 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 392 insertions(+) diff --git a/package-lock.json b/package-lock.json index c82b1de7..2026835e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "react-dom": "^18.2.0", "react-dropzone": "^14.3.8", "react-markdown": "^9.0.1", + "recharts": "^3.8.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.0", @@ -9542,6 +9543,42 @@ } } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -10344,6 +10381,18 @@ "node": ">=18.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -10607,6 +10656,69 @@ "@babel/types": "^7.20.7" } }, + "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/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -11070,6 +11182,12 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", @@ -13163,6 +13281,127 @@ "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==", + "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/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -13279,6 +13518,12 @@ "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", "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/decode-named-character-reference": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz", @@ -13780,6 +14025,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -14476,6 +14731,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/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -15833,6 +16094,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", @@ -15948,6 +16219,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/intl-messageformat": { "version": "10.7.16", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz", @@ -22073,6 +22353,29 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", @@ -22156,6 +22459,36 @@ "node": ">= 6" } }, + "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", @@ -22170,6 +22503,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", @@ -23288,6 +23636,12 @@ "dev": true, "license": "MIT" }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -24407,6 +24761,12 @@ "node": "*" } }, + "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/tinyglobby": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz", @@ -25311,6 +25671,15 @@ } } }, + "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/usehooks-ts": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz", @@ -25470,6 +25839,28 @@ "url": "https://opencollective.com/unified" } }, + "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/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index 92380a89..553cb43c 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "react-dom": "^18.2.0", "react-dropzone": "^14.3.8", "react-markdown": "^9.0.1", + "recharts": "^3.8.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.0", From 7ba5f3299cd2ef47bfa8b35122df77526b18dcf1 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 08:10:35 -0700 Subject: [PATCH 03/25] feat: add Cloudflare Analytics Engine client Co-Authored-By: Claude Sonnet 4.6 --- src/lib/clients/analytics/index.ts | 102 +++++++++++++++++++++++++++++ src/lib/clients/analytics/types.ts | 16 +++++ 2 files changed, 118 insertions(+) create mode 100644 src/lib/clients/analytics/index.ts create mode 100644 src/lib/clients/analytics/types.ts diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts new file mode 100644 index 00000000..905c6c47 --- /dev/null +++ b/src/lib/clients/analytics/index.ts @@ -0,0 +1,102 @@ +// src/lib/clients/analytics/index.ts + +import type { DailyProductStats, DailyAccountProductStats, Period } from "./types"; + +export type { DailyProductStats, DailyAccountProductStats, Period } from "./types"; + +const CF_ANALYTICS_ACCOUNT_ID = process.env.CF_ANALYTICS_ACCOUNT_ID; +const CF_ANALYTICS_API_TOKEN = process.env.CF_ANALYTICS_API_TOKEN; +const CF_ANALYTICS_DATASET = process.env.CF_ANALYTICS_DATASET ?? "source_data_proxy_production"; + +function isConfigured(): boolean { + return !!(CF_ANALYTICS_ACCOUNT_ID && CF_ANALYTICS_API_TOKEN); +} + +async function queryAnalyticsEngine(sql: string): Promise { + if (!isConfigured()) { + return []; + } + + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${CF_ANALYTICS_ACCOUNT_ID}/analytics_engine/sql`, + { + method: "POST", + headers: { + Authorization: `Bearer ${CF_ANALYTICS_API_TOKEN}`, + }, + body: sql, + next: { revalidate: 3600 }, // Cache for 1 hour + } + ); + + if (!response.ok) { + console.error(`Analytics Engine query failed: ${response.status} ${response.statusText}`); + return []; + } + + const result = await response.json(); + return (result.data ?? []) as T[]; +} + +export async function getProductAnalytics( + accountId: string, + productId: string, + days: Period = 7 +): Promise { + const sql = ` + SELECT + toStartOfInterval(timestamp, INTERVAL '1' DAY) AS date, + COUNT() AS downloads, + SUM(double1) AS bytes + FROM ${CF_ANALYTICS_DATASET} + WHERE blob1 = '${accountId}' + AND blob2 = '${productId}' + AND timestamp >= NOW() - INTERVAL '${days}' DAY + GROUP BY date + ORDER BY date + `; + + const rows = await queryAnalyticsEngine<{ + date: string; + downloads: number; + bytes: number; + }>(sql); + + return rows.map((row) => ({ + date: row.date, + downloads: Number(row.downloads), + bytes: Number(row.bytes), + })); +} + +export async function getAccountAnalytics( + accountId: string, + days: Period = 7 +): Promise { + const sql = ` + SELECT + blob2 AS product_id, + toStartOfInterval(timestamp, INTERVAL '1' DAY) AS date, + COUNT() AS downloads, + SUM(double1) AS bytes + FROM ${CF_ANALYTICS_DATASET} + WHERE blob1 = '${accountId}' + AND timestamp >= NOW() - INTERVAL '${days}' DAY + GROUP BY product_id, date + ORDER BY date + `; + + const rows = await queryAnalyticsEngine<{ + product_id: string; + date: string; + downloads: number; + bytes: number; + }>(sql); + + return rows.map((row) => ({ + product_id: row.product_id, + date: row.date, + downloads: Number(row.downloads), + bytes: Number(row.bytes), + })); +} diff --git a/src/lib/clients/analytics/types.ts b/src/lib/clients/analytics/types.ts new file mode 100644 index 00000000..dbaac5e1 --- /dev/null +++ b/src/lib/clients/analytics/types.ts @@ -0,0 +1,16 @@ +// src/lib/clients/analytics/types.ts + +export interface DailyProductStats { + date: string; // ISO date string YYYY-MM-DD + downloads: number; + bytes: number; +} + +export interface DailyAccountProductStats { + product_id: string; + date: string; + downloads: number; + bytes: number; +} + +export type Period = 7 | 30 | 90; From b6bbd96b9da5a1a3b1d7cdb04e1d5597beb2a894 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 08:13:28 -0700 Subject: [PATCH 04/25] feat: add period selector, sparkline chart, and product analytics components Co-Authored-By: Claude Opus 4.6 --- .../features/analytics/PeriodSelector.tsx | 43 ++++++ .../features/analytics/ProductAnalytics.tsx | 63 ++++++++ .../features/analytics/SparklineChart.tsx | 78 ++++++++++ .../features/analytics/StackedAreaChart.tsx | 138 ++++++++++++++++++ 4 files changed, 322 insertions(+) create mode 100644 src/components/features/analytics/PeriodSelector.tsx create mode 100644 src/components/features/analytics/ProductAnalytics.tsx create mode 100644 src/components/features/analytics/SparklineChart.tsx create mode 100644 src/components/features/analytics/StackedAreaChart.tsx diff --git a/src/components/features/analytics/PeriodSelector.tsx b/src/components/features/analytics/PeriodSelector.tsx new file mode 100644 index 00000000..168b6746 --- /dev/null +++ b/src/components/features/analytics/PeriodSelector.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Flex, Button } from "@radix-ui/themes"; +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import type { Period } from "@/lib/clients/analytics"; + +const PERIODS: { value: Period; label: string }[] = [ + { value: 7, label: "7d" }, + { value: 30, label: "30d" }, + { value: 90, label: "90d" }, +]; + +interface PeriodSelectorProps { + currentPeriod: Period; +} + +export function PeriodSelector({ currentPeriod }: PeriodSelectorProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + function handlePeriodChange(period: Period) { + const params = new URLSearchParams(searchParams.toString()); + params.set("period", String(period)); + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + } + + return ( + + {PERIODS.map(({ value, label }) => ( + + ))} + + ); +} diff --git a/src/components/features/analytics/ProductAnalytics.tsx b/src/components/features/analytics/ProductAnalytics.tsx new file mode 100644 index 00000000..34d9b27b --- /dev/null +++ b/src/components/features/analytics/ProductAnalytics.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Card, Flex } from "@radix-ui/themes"; +import { SparklineChart } from "./SparklineChart"; +import { PeriodSelector } from "./PeriodSelector"; +import { SectionHeader } from "@/components/core/SectionHeader"; +import type { DailyProductStats, Period } from "@/lib/clients/analytics"; + +interface ProductAnalyticsProps { + data: DailyProductStats[]; + period: Period; +} + +export function ProductAnalytics({ data, period }: ProductAnalyticsProps) { + if (data.length === 0) return null; + + const totalDownloads = data.reduce((sum, d) => sum + d.downloads, 0); + const totalBytes = data.reduce((sum, d) => sum + d.bytes, 0); + const dateRange = formatDateRange(data); + + return ( + + } + > + + + + + + + ); +} + +function formatDateRange(data: DailyProductStats[]): string { + if (data.length === 0) return ""; + const first = new Date(data[0].date); + const last = new Date(data[data.length - 1].date); + const fmt = (d: Date) => + d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); + return `${fmt(first)} – ${fmt(last)}`; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / Math.pow(1024, i); + return `${value.toFixed(value < 10 ? 1 : 0)} ${units[i]}`; +} diff --git a/src/components/features/analytics/SparklineChart.tsx b/src/components/features/analytics/SparklineChart.tsx new file mode 100644 index 00000000..940400c0 --- /dev/null +++ b/src/components/features/analytics/SparklineChart.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { AreaChart, Area, ResponsiveContainer, Tooltip } from "recharts"; +import { Box, Flex, Heading, Text } from "@radix-ui/themes"; +import type { DailyProductStats } from "@/lib/clients/analytics"; + +interface SparklineChartProps { + data: DailyProductStats[]; + dataKey: "downloads" | "bytes"; + label: string; + total: string; + dateRange: string; +} + +export function SparklineChart({ + data, + dataKey, + label, + total, + dateRange, +}: SparklineChartProps) { + return ( + + + + {label} · {dateRange} + + {total} + + + + + + + + + + + { + const d = new Date(label); + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + }} + formatter={(value) => { + const num = typeof value === "number" ? value : 0; + return dataKey === "bytes" + ? [formatBytes(num), "Bytes"] + : [num.toLocaleString(), "Downloads"]; + }} + /> + + + + + + ); +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / Math.pow(1024, i); + return `${value.toFixed(value < 10 ? 1 : 0)} ${units[i]}`; +} diff --git a/src/components/features/analytics/StackedAreaChart.tsx b/src/components/features/analytics/StackedAreaChart.tsx new file mode 100644 index 00000000..6c712108 --- /dev/null +++ b/src/components/features/analytics/StackedAreaChart.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { + AreaChart, + Area, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { Box, Heading } from "@radix-ui/themes"; +import type { DailyAccountProductStats } from "@/lib/clients/analytics"; + +const COLORS = [ + "var(--accent-9)", + "var(--cyan-9)", + "var(--orange-9)", + "var(--green-9)", + "var(--pink-9)", + "var(--yellow-9)", + "var(--blue-9)", + "var(--red-9)", +]; + +interface StackedAreaChartProps { + data: DailyAccountProductStats[]; + dataKey: "downloads" | "bytes"; + label: string; +} + +interface PivotedRow { + date: string; + [productId: string]: string | number; +} + +export function StackedAreaChart({ data, dataKey, label }: StackedAreaChartProps) { + if (data.length === 0) return null; + + const productIds = [...new Set(data.map((d) => d.product_id))]; + + const byDate = new Map(); + for (const row of data) { + if (!byDate.has(row.date)) { + byDate.set(row.date, { date: row.date }); + } + byDate.get(row.date)![row.product_id] = row[dataKey]; + } + + const pivoted = [...byDate.values()].map((row) => { + for (const pid of productIds) { + if (!(pid in row)) row[pid] = 0; + } + return row; + }); + + return ( + + + {label} + + + + + { + const d = new Date(val); + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + }} + /> + + dataKey === "bytes" ? formatBytesShort(val) : val.toLocaleString() + } + width={60} + /> + { + const d = new Date(label); + return d.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); + }} + formatter={(value, name) => [ + typeof value === "number" + ? dataKey === "bytes" + ? formatBytes(value) + : value.toLocaleString() + : String(value ?? ""), + String(name ?? ""), + ]} + /> + + {productIds.map((pid, i) => ( + + ))} + + + + + ); +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / Math.pow(1024, i); + return `${value.toFixed(value < 10 ? 1 : 0)} ${units[i]}`; +} + +function formatBytesShort(bytes: number): string { + if (bytes === 0) return "0"; + const units = ["B", "K", "M", "G", "T"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / Math.pow(1024, i); + return `${value.toFixed(0)}${units[i]}`; +} From 6a8e7ee4018e174c3a005ae0c12472b6b9c193dd Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 08:15:47 -0700 Subject: [PATCH 05/25] feat: add stacked area chart and account analytics section components Co-Authored-By: Claude Opus 4.6 --- .../analytics/AccountAnalyticsSection.tsx | 35 +++++++++++++++++++ .../features/analytics/StackedAreaChart.tsx | 18 +++++----- 2 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 src/components/features/analytics/AccountAnalyticsSection.tsx diff --git a/src/components/features/analytics/AccountAnalyticsSection.tsx b/src/components/features/analytics/AccountAnalyticsSection.tsx new file mode 100644 index 00000000..b461ea54 --- /dev/null +++ b/src/components/features/analytics/AccountAnalyticsSection.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Box, Card, Flex } from "@radix-ui/themes"; +import { StackedAreaChart } from "./StackedAreaChart"; +import { PeriodSelector } from "./PeriodSelector"; +import { SectionHeader } from "@/components/core/SectionHeader"; +import type { DailyAccountProductStats, Period } from "@/lib/clients/analytics"; + +interface AccountAnalyticsSectionProps { + data: DailyAccountProductStats[]; + period: Period; +} + +export function AccountAnalyticsSection({ + data, + period, +}: AccountAnalyticsSectionProps) { + if (data.length === 0) return null; + + return ( + + + } + > + + + + + + + + ); +} diff --git a/src/components/features/analytics/StackedAreaChart.tsx b/src/components/features/analytics/StackedAreaChart.tsx index 6c712108..a1b8d4f8 100644 --- a/src/components/features/analytics/StackedAreaChart.tsx +++ b/src/components/features/analytics/StackedAreaChart.tsx @@ -92,14 +92,16 @@ export function StackedAreaChart({ data, dataKey, label }: StackedAreaChartProps year: "numeric", }); }} - formatter={(value, name) => [ - typeof value === "number" - ? dataKey === "bytes" - ? formatBytes(value) - : value.toLocaleString() - : String(value ?? ""), - String(name ?? ""), - ]} + formatter={(value, name) => + [ + typeof value === "number" + ? dataKey === "bytes" + ? formatBytes(value) + : value.toLocaleString() + : String(value ?? ""), + name, + ] as [string, typeof name] + } /> {productIds.map((pid, i) => ( From 09527a35031f5821a5e8d4f0d94d5fbdf302f3ce Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 08:25:08 -0700 Subject: [PATCH 06/25] feat: integrate analytics into product and account pages Wire analytics data fetching and display into existing pages: - Account pages: read `period` from searchParams, fetch account analytics server-side, and render AccountAnalyticsSection in both individual and organization profile views - Product pages: use a parallel route slot (@analytics) to fetch product analytics with searchParams access, rendered between the product header and content card in the layout - Period defaults to 7 days; PeriodSelector updates URL which triggers server re-render with the new period value Co-Authored-By: Claude Opus 4.6 --- .../[account_id]/IndividualProfilePage.tsx | 18 ++++++++----- .../[account_id]/OrganizationProfilePage.tsx | 8 +++++- .../[[...path]]/@analytics/loading.tsx | 3 +++ .../(product)/[[...path]]/@analytics/page.tsx | 26 +++++++++++++++++++ .../(product)/[[...path]]/layout.tsx | 3 +++ src/app/(app)/[account_id]/page.tsx | 21 ++++++++++++--- .../features/profiles/IndividualProfile.tsx | 13 ++++++++++ .../features/profiles/OrganizationProfile.tsx | 13 ++++++++++ 8 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/loading.tsx create mode 100644 src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/page.tsx diff --git a/src/app/(app)/[account_id]/IndividualProfilePage.tsx b/src/app/(app)/[account_id]/IndividualProfilePage.tsx index 7aa92d76..9f18e2ff 100644 --- a/src/app/(app)/[account_id]/IndividualProfilePage.tsx +++ b/src/app/(app)/[account_id]/IndividualProfilePage.tsx @@ -8,31 +8,33 @@ import { type IndividualAccount, Actions } from "@/types"; import { getPageSession } from "@/lib/api/utils"; import { isAuthorized } from "@/lib/api/authz"; import { IndividualProfile } from "@/components/features/profiles/IndividualProfile"; +import { getAccountAnalytics, type Period } from "@/lib/clients/analytics"; interface IndividualProfilePageProps { account: IndividualAccount; showWelcome: boolean; + period?: Period; } export async function IndividualProfilePage({ account, showWelcome, + period = 7, }: IndividualProfilePageProps) { const session = await getPageSession(); - let { products } = await productsTable.listByAccount( - account.account_id, - 1000 - ); + let [{ products }, membershipsRaw, analyticsData] = await Promise.all([ + productsTable.listByAccount(account.account_id, 1000), + membershipsTable.listByUser(account.account_id), + getAccountAnalytics(account.account_id, period), + ]); // Filter products based on authentication status products = products.filter((product) => isAuthorized(session, product, Actions.GetRepository) ); - const memberships = ( - await membershipsTable.listByUser(account.account_id) - ).filter((membership) => + const memberships = membershipsRaw.filter((membership) => isAuthorized(account, membership, Actions.GetMembership) ); const organizations = ( @@ -51,6 +53,8 @@ export async function IndividualProfilePage({ organizations={organizations} showWelcome={showWelcome} canEdit={isAuthorized(session, account, Actions.PutAccountProfile)} + analyticsData={analyticsData} + analyticsPeriod={period} /> ); } diff --git a/src/app/(app)/[account_id]/OrganizationProfilePage.tsx b/src/app/(app)/[account_id]/OrganizationProfilePage.tsx index 28c3c090..026eb1b4 100644 --- a/src/app/(app)/[account_id]/OrganizationProfilePage.tsx +++ b/src/app/(app)/[account_id]/OrganizationProfilePage.tsx @@ -17,21 +17,25 @@ import { import { getPageSession } from "@/lib/api/utils"; import { isAuthorized } from "@/lib/api/authz"; import { getPendingInvitation } from "@/lib/actions/memberships"; +import { getAccountAnalytics, type Period } from "@/lib/clients/analytics"; interface OrganizationProfilePageProps { account: OrganizationalAccount; + period?: Period; } export async function OrganizationProfilePage({ account, + period = 7, }: OrganizationProfilePageProps) { // Get session to check authentication status const session = await getPageSession(); const isAuthenticated = session?.account && !session.account.disabled; - let [memberships, { products }] = await Promise.all([ + let [memberships, { products }, analyticsData] = await Promise.all([ membershipsTable.listByAccount(account.account_id), productsTable.listByAccount(account.account_id), + getAccountAnalytics(account.account_id, period), ]); memberships = memberships @@ -106,6 +110,8 @@ export async function OrganizationProfilePage({ admins={admins} members={members} canEdit={isAuthorized(session, account, Actions.PutAccountProfile)} + analyticsData={analyticsData} + analyticsPeriod={period} /> diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/loading.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/loading.tsx new file mode 100644 index 00000000..17f62776 --- /dev/null +++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/loading.tsx @@ -0,0 +1,3 @@ +export default function AnalyticsLoading() { + return null; +} diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/page.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/page.tsx new file mode 100644 index 00000000..cfc2cc49 --- /dev/null +++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/page.tsx @@ -0,0 +1,26 @@ +import { ProductAnalytics } from "@/components/features/analytics/ProductAnalytics"; +import { getProductAnalytics, type Period } from "@/lib/clients/analytics"; + +function parsePeriod(value: string | undefined): Period { + const num = Number(value); + if (num === 7 || num === 30 || num === 90) return num; + return 7; +} + +interface PageProps { + params: Promise<{ account_id: string; product_id: string }>; + searchParams: Promise<{ period?: string }>; +} + +export default async function ProductAnalyticsSlot({ + params, + searchParams, +}: PageProps) { + const { account_id, product_id } = await params; + const { period: periodParam } = await searchParams; + const period = parsePeriod(periodParam); + + const data = await getProductAnalytics(account_id, product_id, period); + + return ; +} diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx index 453d9242..195f6ec4 100644 --- a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx +++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx @@ -26,6 +26,7 @@ import { getPendingInvitation } from "@/lib/actions/memberships"; interface ProductLayoutProps { children: React.ReactNode; readme: React.ReactNode; + analytics: React.ReactNode; params: Promise<{ account_id: string; product_id: string; path?: string[] }>; } @@ -33,6 +34,7 @@ export default async function ProductLayout({ params, children, readme, + analytics, }: ProductLayoutProps) { // Then check if product exists const { account_id, product_id, path } = await params; @@ -60,6 +62,7 @@ export default async function ProductLayout({ + {analytics} diff --git a/src/app/(app)/[account_id]/page.tsx b/src/app/(app)/[account_id]/page.tsx index 17c3af41..9cfbb933 100644 --- a/src/app/(app)/[account_id]/page.tsx +++ b/src/app/(app)/[account_id]/page.tsx @@ -19,10 +19,17 @@ import { generateNotFoundMetadata, generateAccountMetadata, } from "@/components/features/metadata"; +import type { Period } from "@/lib/clients/analytics"; + +function parsePeriod(value: string | undefined): Period { + const num = Number(value); + if (num === 7 || num === 30 || num === 90) return num; + return 7; +} type PageProps = { params: Promise<{ account_id: string }>; - searchParams: Promise<{ welcome?: string }>; + searchParams: Promise<{ welcome?: string; period?: string }>; }; export async function generateMetadata({ @@ -38,7 +45,9 @@ export async function generateMetadata({ export default async function AccountPage({ params, searchParams }: PageProps) { const { account_id } = await params; - const showWelcome = Object.hasOwn(await searchParams, "welcome"); + const resolvedSearchParams = await searchParams; + const showWelcome = Object.hasOwn(resolvedSearchParams, "welcome"); + const period = parsePeriod(resolvedSearchParams.period); const account = await accountsTable.fetchById(account_id); if (!account) { @@ -46,8 +55,12 @@ export default async function AccountPage({ params, searchParams }: PageProps) { } return isOrganizationalAccount(account) ? ( - + ) : ( - + ); } diff --git a/src/components/features/profiles/IndividualProfile.tsx b/src/components/features/profiles/IndividualProfile.tsx index ca03fb0d..7f27263e 100644 --- a/src/components/features/profiles/IndividualProfile.tsx +++ b/src/components/features/profiles/IndividualProfile.tsx @@ -19,6 +19,8 @@ import { EmailVerificationStatus } from "./EmailVerificationStatus"; import { AvatarLinkCompact, EditButton } from "@/components/core"; import { EmailVerificationCallout } from "../auth/EmailVerificationCallout"; import { WelcomeCallout } from "./WelcomeCallout"; +import { AccountAnalyticsSection } from "../analytics/AccountAnalyticsSection"; +import type { DailyAccountProductStats, Period } from "@/lib/clients/analytics"; interface IndividualProfileProps { account: IndividualAccount; @@ -28,6 +30,8 @@ interface IndividualProfileProps { organizations: OrganizationalAccount[]; showWelcome?: boolean; canEdit: boolean; + analyticsData?: DailyAccountProductStats[]; + analyticsPeriod?: Period; } export function IndividualProfile({ @@ -38,6 +42,8 @@ export function IndividualProfile({ organizations, showWelcome = false, canEdit, + analyticsData, + analyticsPeriod = 7, }: IndividualProfileProps) { const primaryEmail = account.emails?.find((email) => email.is_primary); return ( @@ -120,6 +126,13 @@ export function IndividualProfile({ )} + {analyticsData && analyticsData.length > 0 && ( + + )} + {ownedProducts.length > 0 && ( diff --git a/src/components/features/profiles/OrganizationProfile.tsx b/src/components/features/profiles/OrganizationProfile.tsx index 39a85922..6de29a97 100644 --- a/src/components/features/profiles/OrganizationProfile.tsx +++ b/src/components/features/profiles/OrganizationProfile.tsx @@ -22,6 +22,8 @@ import { WebsiteLink } from "./WebsiteLink"; import { ProfileLocation } from "./ProfileLocation"; import { editAccountProfileUrl } from "@/lib/urls"; import { EditButton } from "@/components/core"; +import { AccountAnalyticsSection } from "../analytics/AccountAnalyticsSection"; +import type { DailyAccountProductStats, Period } from "@/lib/clients/analytics"; interface OrganizationProfileProps { account: OrganizationalAccount; @@ -30,6 +32,8 @@ interface OrganizationProfileProps { admins: IndividualAccount[]; members: IndividualAccount[]; canEdit: boolean; + analyticsData?: DailyAccountProductStats[]; + analyticsPeriod?: Period; } export function OrganizationProfile({ @@ -39,6 +43,8 @@ export function OrganizationProfile({ admins, members, canEdit, + analyticsData, + analyticsPeriod = 7, }: OrganizationProfileProps) { return ( @@ -121,6 +127,13 @@ export function OrganizationProfile({ + {analyticsData && analyticsData.length > 0 && ( + + )} + Products From f081c9d5ddfbdfda4ab2b9bfe8933660c6cc39d2 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 08:29:44 -0700 Subject: [PATCH 07/25] chore: add analytics components barrel export Co-Authored-By: Claude Opus 4.6 --- src/components/features/analytics/index.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/components/features/analytics/index.ts diff --git a/src/components/features/analytics/index.ts b/src/components/features/analytics/index.ts new file mode 100644 index 00000000..db77a081 --- /dev/null +++ b/src/components/features/analytics/index.ts @@ -0,0 +1,5 @@ +export { SparklineChart } from "./SparklineChart"; +export { StackedAreaChart } from "./StackedAreaChart"; +export { PeriodSelector } from "./PeriodSelector"; +export { ProductAnalytics } from "./ProductAnalytics"; +export { AccountAnalyticsSection } from "./AccountAnalyticsSection"; From 309e7082bb68a66ec8175674edf8f51af1753456 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 13:39:31 -0700 Subject: [PATCH 08/25] refactor: use config --- src/lib/clients/analytics/index.ts | 15 ++++++--------- src/lib/config.ts | 7 +++++++ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts index 905c6c47..a967f02a 100644 --- a/src/lib/clients/analytics/index.ts +++ b/src/lib/clients/analytics/index.ts @@ -1,15 +1,12 @@ // src/lib/clients/analytics/index.ts import type { DailyProductStats, DailyAccountProductStats, Period } from "./types"; +import { CONFIG } from "@/lib/config"; export type { DailyProductStats, DailyAccountProductStats, Period } from "./types"; -const CF_ANALYTICS_ACCOUNT_ID = process.env.CF_ANALYTICS_ACCOUNT_ID; -const CF_ANALYTICS_API_TOKEN = process.env.CF_ANALYTICS_API_TOKEN; -const CF_ANALYTICS_DATASET = process.env.CF_ANALYTICS_DATASET ?? "source_data_proxy_production"; - function isConfigured(): boolean { - return !!(CF_ANALYTICS_ACCOUNT_ID && CF_ANALYTICS_API_TOKEN); + return !!(CONFIG.analytics.accountId && CONFIG.analytics.apiToken); } async function queryAnalyticsEngine(sql: string): Promise { @@ -18,11 +15,11 @@ async function queryAnalyticsEngine(sql: string): Promise { } const response = await fetch( - `https://api.cloudflare.com/client/v4/accounts/${CF_ANALYTICS_ACCOUNT_ID}/analytics_engine/sql`, + `https://api.cloudflare.com/client/v4/accounts/${CONFIG.analytics.accountId}/analytics_engine/sql`, { method: "POST", headers: { - Authorization: `Bearer ${CF_ANALYTICS_API_TOKEN}`, + Authorization: `Bearer ${CONFIG.analytics.apiToken}`, }, body: sql, next: { revalidate: 3600 }, // Cache for 1 hour @@ -48,7 +45,7 @@ export async function getProductAnalytics( toStartOfInterval(timestamp, INTERVAL '1' DAY) AS date, COUNT() AS downloads, SUM(double1) AS bytes - FROM ${CF_ANALYTICS_DATASET} + FROM ${CONFIG.analytics.dataset} WHERE blob1 = '${accountId}' AND blob2 = '${productId}' AND timestamp >= NOW() - INTERVAL '${days}' DAY @@ -79,7 +76,7 @@ export async function getAccountAnalytics( toStartOfInterval(timestamp, INTERVAL '1' DAY) AS date, COUNT() AS downloads, SUM(double1) AS bytes - FROM ${CF_ANALYTICS_DATASET} + FROM ${CONFIG.analytics.dataset} WHERE blob1 = '${accountId}' AND timestamp >= NOW() - INTERVAL '${days}' DAY GROUP BY product_id, date diff --git a/src/lib/config.ts b/src/lib/config.ts index e63eeb96..b9ced1b8 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -63,6 +63,13 @@ export const CONFIG = { }, }, + // Cloudflare Analytics Engine configuration + analytics: { + accountId: process.env.CF_ANALYTICS_ACCOUNT_ID, + apiToken: process.env.CF_ANALYTICS_API_TOKEN, + dataset: process.env.CF_ANALYTICS_DATASET ?? "source_data_proxy_production", + }, + // Google configuration google: { siteVerification: process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION || "", From becbdf4ff04172f9689eb44f5d7ac321d07abd9c Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 13:57:04 -0700 Subject: [PATCH 09/25] Add warning for unconfigured analytics engine --- src/lib/clients/analytics/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts index a967f02a..3e218f35 100644 --- a/src/lib/clients/analytics/index.ts +++ b/src/lib/clients/analytics/index.ts @@ -11,6 +11,7 @@ function isConfigured(): boolean { async function queryAnalyticsEngine(sql: string): Promise { if (!isConfigured()) { + console.warn("Analytics engine not configured."); return []; } From 2ce0a628234a32d3642f651500935fee3534f512 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 16:45:12 -0700 Subject: [PATCH 10/25] add debug logging --- src/lib/clients/analytics/index.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts index 3e218f35..01bacee6 100644 --- a/src/lib/clients/analytics/index.ts +++ b/src/lib/clients/analytics/index.ts @@ -1,16 +1,19 @@ // src/lib/clients/analytics/index.ts import type { DailyProductStats, DailyAccountProductStats, Period } from "./types"; +import { LOGGER } from "@/lib"; import { CONFIG } from "@/lib/config"; export type { DailyProductStats, DailyAccountProductStats, Period } from "./types"; -function isConfigured(): boolean { - return !!(CONFIG.analytics.accountId && CONFIG.analytics.apiToken); -} - async function queryAnalyticsEngine(sql: string): Promise { - if (!isConfigured()) { + const isConfigured = !!(CONFIG.analytics.accountId && CONFIG.analytics.apiToken); + if (isConfigured) { + LOGGER.warn("Analytics engine not configured", { + operation: "queryAnalyticsEngine", + context: "checking configuration", + metadata: { }, + }); console.warn("Analytics engine not configured."); return []; } @@ -60,6 +63,12 @@ export async function getProductAnalytics( bytes: number; }>(sql); + LOGGER.debug("Queried data", { + operation: "getProductAnalytics", + context: "get product analytics", + metadata: { sql, rows }, + }); + return rows.map((row) => ({ date: row.date, downloads: Number(row.downloads), @@ -91,6 +100,12 @@ export async function getAccountAnalytics( bytes: number; }>(sql); + LOGGER.debug("Queried data", { + operation: "getAccountAnalytics", + context: "get account analytics", + metadata: { sql, rows }, + }); + return rows.map((row) => ({ product_id: row.product_id, date: row.date, From cc93e3102fee0184d6709c65584f3a94b240e50d Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 16:55:05 -0700 Subject: [PATCH 11/25] chore: log config --- src/lib/clients/analytics/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts index 01bacee6..94067a5d 100644 --- a/src/lib/clients/analytics/index.ts +++ b/src/lib/clients/analytics/index.ts @@ -12,7 +12,7 @@ async function queryAnalyticsEngine(sql: string): Promise { LOGGER.warn("Analytics engine not configured", { operation: "queryAnalyticsEngine", context: "checking configuration", - metadata: { }, + metadata: CONFIG.analytics, }); console.warn("Analytics engine not configured."); return []; From d057d0d202c878fa7c42cc05e3c18e129c432e72 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 16:57:46 -0700 Subject: [PATCH 12/25] Fix logic --- src/lib/clients/analytics/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts index 94067a5d..185b677e 100644 --- a/src/lib/clients/analytics/index.ts +++ b/src/lib/clients/analytics/index.ts @@ -8,7 +8,7 @@ export type { DailyProductStats, DailyAccountProductStats, Period } from "./type async function queryAnalyticsEngine(sql: string): Promise { const isConfigured = !!(CONFIG.analytics.accountId && CONFIG.analytics.apiToken); - if (isConfigured) { + if (!isConfigured) { LOGGER.warn("Analytics engine not configured", { operation: "queryAnalyticsEngine", context: "checking configuration", From 37ecddb7ff70bb8a65e922073f10b5c26d1dfd47 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 13:53:35 -0700 Subject: [PATCH 13/25] chore: add .worktrees/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 45297f2c..2a1f0805 100644 --- a/.gitignore +++ b/.gitignore @@ -255,4 +255,5 @@ $RECYCLE.BIN/ # End of https://www.toptal.com/developers/gitignore/api/osx,windows,linux,nextjs,react,node cdk.out .claude/ +.worktrees/ .env*.local From d5faa7cdb60c12b2fbd875795caba49d6919e6ac Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 20:18:52 -0700 Subject: [PATCH 14/25] chore: add docs/plans to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2a1f0805..0072984b 100644 --- a/.gitignore +++ b/.gitignore @@ -257,3 +257,4 @@ cdk.out .claude/ .worktrees/ .env*.local +docs/plans From 824794d3cdf9bdaadb00380ca4a00e88bdbcbb14 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 22:43:41 -0700 Subject: [PATCH 15/25] feat: add PopularFile types for file-level analytics Co-Authored-By: Claude Opus 4.6 --- src/lib/clients/analytics/types.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/lib/clients/analytics/types.ts b/src/lib/clients/analytics/types.ts index dbaac5e1..a87c0e2b 100644 --- a/src/lib/clients/analytics/types.ts +++ b/src/lib/clients/analytics/types.ts @@ -14,3 +14,15 @@ export interface DailyAccountProductStats { } export type Period = 7 | 30 | 90; + +export interface PopularFileDailyStats { + file_path: string; + date: string; + downloads: number; +} + +export interface PopularFile { + file_path: string; + total_downloads: number; + daily: { date: string; downloads: number }[]; +} From 553bb7ebf93c59ee753e592676dc05930b285da8 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 22:44:08 -0700 Subject: [PATCH 16/25] feat: add getPopularFiles analytics query Co-Authored-By: Claude Opus 4.6 --- src/lib/clients/analytics/index.ts | 54 ++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts index 185b677e..05f9373f 100644 --- a/src/lib/clients/analytics/index.ts +++ b/src/lib/clients/analytics/index.ts @@ -1,10 +1,10 @@ // src/lib/clients/analytics/index.ts -import type { DailyProductStats, DailyAccountProductStats, Period } from "./types"; +import type { DailyProductStats, DailyAccountProductStats, Period, PopularFile } from "./types"; import { LOGGER } from "@/lib"; import { CONFIG } from "@/lib/config"; -export type { DailyProductStats, DailyAccountProductStats, Period } from "./types"; +export type { DailyProductStats, DailyAccountProductStats, Period, PopularFile, PopularFileDailyStats } from "./types"; async function queryAnalyticsEngine(sql: string): Promise { const isConfigured = !!(CONFIG.analytics.accountId && CONFIG.analytics.apiToken); @@ -113,3 +113,53 @@ export async function getAccountAnalytics( bytes: Number(row.bytes), })); } + +export async function getPopularFiles( + accountId: string, + productId: string, + days: Period = 7 +): Promise { + const sql = ` + SELECT + blob3 AS file_path, + toStartOfInterval(timestamp, INTERVAL '1' DAY) AS date, + COUNT() AS downloads + FROM ${CONFIG.analytics.dataset} + WHERE blob1 = '${accountId}' + AND blob2 = '${productId}' + AND timestamp >= NOW() - INTERVAL '${days}' DAY + GROUP BY file_path, date + ORDER BY file_path, date + `; + + const rows = await queryAnalyticsEngine<{ + file_path: string; + date: string; + downloads: number; + }>(sql); + + LOGGER.debug("Queried popular files data", { + operation: "getPopularFiles", + context: "get popular files analytics", + metadata: { sql, rowCount: rows.length }, + }); + + // Group by file_path, compute totals, sort by total downloads descending + const byFile = new Map(); + + for (const row of rows) { + const downloads = Number(row.downloads); + const entry = byFile.get(row.file_path) ?? { daily: [], total: 0 }; + entry.daily.push({ date: row.date, downloads }); + entry.total += downloads; + byFile.set(row.file_path, entry); + } + + return Array.from(byFile.entries()) + .map(([file_path, { daily, total }]) => ({ + file_path, + total_downloads: total, + daily, + })) + .sort((a, b) => b.total_downloads - a.total_downloads); +} From 1e7ef187eef909fa5096d5f425cd1919e136a0a5 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 22:48:49 -0700 Subject: [PATCH 17/25] feat: create PopularFilesSidebar component Co-Authored-By: Claude Opus 4.6 --- .../analytics/PopularFilesSidebar.tsx | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 src/components/features/analytics/PopularFilesSidebar.tsx diff --git a/src/components/features/analytics/PopularFilesSidebar.tsx b/src/components/features/analytics/PopularFilesSidebar.tsx new file mode 100644 index 00000000..9e3392c4 --- /dev/null +++ b/src/components/features/analytics/PopularFilesSidebar.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useState } from "react"; +import { Box, Button, Card, Flex, IconButton, Link as RadixLink, Text, Tooltip } from "@radix-ui/themes"; +import { BarChartIcon, Cross1Icon } from "@radix-ui/react-icons"; +import { AreaChart, Area, ResponsiveContainer } from "recharts"; +import type { PopularFile } from "@/lib/clients/analytics"; +import Link from "next/link"; +import { objectUrl } from "@/lib/urls"; + +const DEFAULT_VISIBLE = 10; + +interface PopularFilesSidebarProps { + files: PopularFile[]; + accountId: string; + productId: string; +} + +export function PopularFilesSidebar({ + files, + accountId, + productId, +}: PopularFilesSidebarProps) { + const [isOpen, setIsOpen] = useState(false); + const [showAll, setShowAll] = useState(false); + + if (files.length === 0) return null; + + const visibleFiles = showAll ? files : files.slice(0, DEFAULT_VISIBLE); + const hasMore = files.length > DEFAULT_VISIBLE; + + if (!isOpen) { + return ( + + + setIsOpen(true)} + > + + + + + ); + } + + return ( + + + Popular Files + setIsOpen(false)} + > + + + + + {visibleFiles.map((file) => ( + + ))} + + {hasMore && !showAll && ( + + + + )} + + ); +} + +function PopularFileEntry({ + file, + accountId, + productId, +}: { + file: PopularFile; + accountId: string; + productId: string; +}) { + const fileName = file.file_path.split("/").pop() || file.file_path; + + return ( + + + + + + + {fileName} + + + + + + {file.total_downloads.toLocaleString()} + + + + + + + + + + + + + + + + + ); +} From 4cc5df341dcad26b4ed381256fda2f2dbcffbcf4 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 22:51:16 -0700 Subject: [PATCH 18/25] feat: add popular files sidebar to product layout Co-Authored-By: Claude Opus 4.6 --- .../[[...path]]/@popularfiles/loading.tsx | 3 + .../[[...path]]/@popularfiles/page.tsx | 32 +++++++++ .../(product)/[[...path]]/layout.tsx | 71 ++++++++++--------- 3 files changed, 74 insertions(+), 32 deletions(-) create mode 100644 src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@popularfiles/loading.tsx create mode 100644 src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@popularfiles/page.tsx diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@popularfiles/loading.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@popularfiles/loading.tsx new file mode 100644 index 00000000..623c8f9e --- /dev/null +++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@popularfiles/loading.tsx @@ -0,0 +1,3 @@ +export default function PopularFilesLoading() { + return null; +} diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@popularfiles/page.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@popularfiles/page.tsx new file mode 100644 index 00000000..e933b580 --- /dev/null +++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@popularfiles/page.tsx @@ -0,0 +1,32 @@ +import { PopularFilesSidebar } from "@/components/features/analytics/PopularFilesSidebar"; +import { getPopularFiles, type Period } from "@/lib/clients/analytics"; + +function parsePeriod(value: string | undefined): Period { + const num = Number(value); + if (num === 7 || num === 30 || num === 90) return num; + return 7; +} + +interface PageProps { + params: Promise<{ account_id: string; product_id: string }>; + searchParams: Promise<{ period?: string }>; +} + +export default async function PopularFilesSlot({ + params, + searchParams, +}: PageProps) { + const { account_id, product_id } = await params; + const { period: periodParam } = await searchParams; + const period = parsePeriod(periodParam); + + const popularFiles = await getPopularFiles(account_id, product_id, period); + + return ( + + ); +} diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx index 195f6ec4..8fceb3cb 100644 --- a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx +++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx @@ -27,6 +27,7 @@ interface ProductLayoutProps { children: React.ReactNode; readme: React.ReactNode; analytics: React.ReactNode; + popularfiles: React.ReactNode; params: Promise<{ account_id: string; product_id: string; path?: string[] }>; } @@ -35,6 +36,7 @@ export default async function ProductLayout({ children, readme, analytics, + popularfiles, }: ProductLayoutProps) { // Then check if product exists const { account_id, product_id, path } = await params; @@ -63,39 +65,44 @@ export default async function ProductLayout({ {analytics} - - - - - ) - } - > - + + + + + ) + } > - - decodeURIComponent(p)) || []} - baseUrl={productUrl(account_id, product_id)} - /> - - - - {children} - - - + + + decodeURIComponent(p)) || []} + baseUrl={productUrl(account_id, product_id)} + /> + + + + {children} + + + + + {popularfiles} + + {readme} From 520ab7a50d0c44aa006c577631b7838ee128f726 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 23:17:57 -0700 Subject: [PATCH 19/25] fix: address review feedback for popular files sidebar Co-Authored-By: Claude Opus 4.6 --- .../[product_id]/(product)/[[...path]]/layout.tsx | 2 +- .../features/analytics/PopularFilesSidebar.tsx | 9 ++++++--- src/lib/clients/analytics/index.ts | 2 +- src/lib/clients/analytics/types.ts | 6 ------ 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx index 8fceb3cb..0f02e374 100644 --- a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx +++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx @@ -99,7 +99,7 @@ export default async function ProductLayout({ - + {popularfiles} diff --git a/src/components/features/analytics/PopularFilesSidebar.tsx b/src/components/features/analytics/PopularFilesSidebar.tsx index 9e3392c4..fc822ff0 100644 --- a/src/components/features/analytics/PopularFilesSidebar.tsx +++ b/src/components/features/analytics/PopularFilesSidebar.tsx @@ -60,10 +60,11 @@ export function PopularFilesSidebar({ - {visibleFiles.map((file) => ( + {visibleFiles.map((file, index) => ( @@ -87,10 +88,12 @@ export function PopularFilesSidebar({ function PopularFileEntry({ file, + index, accountId, productId, }: { file: PopularFile; + index: number; accountId: string; productId: string; }) { @@ -116,7 +119,7 @@ function PopularFileEntry({ - + @@ -126,7 +129,7 @@ function PopularFileEntry({ dataKey="downloads" stroke="var(--accent-9)" strokeWidth={1} - fill={`url(#gradient-pop-${encodeURIComponent(file.file_path)})`} + fill={`url(#gradient-pop-${index})`} isAnimationActive={false} /> diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts index 05f9373f..5e7ea12f 100644 --- a/src/lib/clients/analytics/index.ts +++ b/src/lib/clients/analytics/index.ts @@ -4,7 +4,7 @@ import type { DailyProductStats, DailyAccountProductStats, Period, PopularFile } import { LOGGER } from "@/lib"; import { CONFIG } from "@/lib/config"; -export type { DailyProductStats, DailyAccountProductStats, Period, PopularFile, PopularFileDailyStats } from "./types"; +export type { DailyProductStats, DailyAccountProductStats, Period, PopularFile } from "./types"; async function queryAnalyticsEngine(sql: string): Promise { const isConfigured = !!(CONFIG.analytics.accountId && CONFIG.analytics.apiToken); diff --git a/src/lib/clients/analytics/types.ts b/src/lib/clients/analytics/types.ts index a87c0e2b..beddc428 100644 --- a/src/lib/clients/analytics/types.ts +++ b/src/lib/clients/analytics/types.ts @@ -15,12 +15,6 @@ export interface DailyAccountProductStats { export type Period = 7 | 30 | 90; -export interface PopularFileDailyStats { - file_path: string; - date: string; - downloads: number; -} - export interface PopularFile { file_path: string; total_downloads: number; From 1aa69d32af8beef7d6c47489d0485f842d2816d8 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 23:37:53 -0700 Subject: [PATCH 20/25] chore: use _sample_interval in queries --- src/lib/clients/analytics/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts index 5e7ea12f..19520c51 100644 --- a/src/lib/clients/analytics/index.ts +++ b/src/lib/clients/analytics/index.ts @@ -47,8 +47,8 @@ export async function getProductAnalytics( const sql = ` SELECT toStartOfInterval(timestamp, INTERVAL '1' DAY) AS date, - COUNT() AS downloads, - SUM(double1) AS bytes + SUM(_sample_interval) AS downloads, + SUM(_sample_interval * double1) AS bytes FROM ${CONFIG.analytics.dataset} WHERE blob1 = '${accountId}' AND blob2 = '${productId}' @@ -84,8 +84,8 @@ export async function getAccountAnalytics( SELECT blob2 AS product_id, toStartOfInterval(timestamp, INTERVAL '1' DAY) AS date, - COUNT() AS downloads, - SUM(double1) AS bytes + SUM(_sample_interval) AS downloads, + SUM(_sample_interval * double1) AS bytes FROM ${CONFIG.analytics.dataset} WHERE blob1 = '${accountId}' AND timestamp >= NOW() - INTERVAL '${days}' DAY @@ -123,7 +123,7 @@ export async function getPopularFiles( SELECT blob3 AS file_path, toStartOfInterval(timestamp, INTERVAL '1' DAY) AS date, - COUNT() AS downloads + SUM(_sample_interval) AS downloads FROM ${CONFIG.analytics.dataset} WHERE blob1 = '${accountId}' AND blob2 = '${productId}' From 1e63d2a3cf1ca8ff2d52e0f9bfb556ec00d589e1 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Mar 2026 23:42:33 -0700 Subject: [PATCH 21/25] refactor: move popular files from sidebar to analytics table Replace the collapsible sidebar with a table inside the analytics card. Remove the @popularfiles parallel route slot and revert the layout to single-column. Co-Authored-By: Claude Opus 4.6 --- .../(product)/[[...path]]/@analytics/page.tsx | 17 ++++- .../[[...path]]/@popularfiles/loading.tsx | 3 - .../[[...path]]/@popularfiles/page.tsx | 32 --------- .../(product)/[[...path]]/layout.tsx | 71 +++++++++---------- ...FilesSidebar.tsx => PopularFilesTable.tsx} | 71 ++++++------------- .../features/analytics/ProductAnalytics.tsx | 22 ++++-- 6 files changed, 86 insertions(+), 130 deletions(-) delete mode 100644 src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@popularfiles/loading.tsx delete mode 100644 src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@popularfiles/page.tsx rename src/components/features/analytics/{PopularFilesSidebar.tsx => PopularFilesTable.tsx} (63%) diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/page.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/page.tsx index cfc2cc49..88088071 100644 --- a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/page.tsx +++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/page.tsx @@ -1,5 +1,5 @@ import { ProductAnalytics } from "@/components/features/analytics/ProductAnalytics"; -import { getProductAnalytics, type Period } from "@/lib/clients/analytics"; +import { getProductAnalytics, getPopularFiles, type Period } from "@/lib/clients/analytics"; function parsePeriod(value: string | undefined): Period { const num = Number(value); @@ -20,7 +20,18 @@ export default async function ProductAnalyticsSlot({ const { period: periodParam } = await searchParams; const period = parsePeriod(periodParam); - const data = await getProductAnalytics(account_id, product_id, period); + const [data, popularFiles] = await Promise.all([ + getProductAnalytics(account_id, product_id, period), + getPopularFiles(account_id, product_id, period), + ]); - return ; + return ( + + ); } diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@popularfiles/loading.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@popularfiles/loading.tsx deleted file mode 100644 index 623c8f9e..00000000 --- a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@popularfiles/loading.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function PopularFilesLoading() { - return null; -} diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@popularfiles/page.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@popularfiles/page.tsx deleted file mode 100644 index e933b580..00000000 --- a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@popularfiles/page.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { PopularFilesSidebar } from "@/components/features/analytics/PopularFilesSidebar"; -import { getPopularFiles, type Period } from "@/lib/clients/analytics"; - -function parsePeriod(value: string | undefined): Period { - const num = Number(value); - if (num === 7 || num === 30 || num === 90) return num; - return 7; -} - -interface PageProps { - params: Promise<{ account_id: string; product_id: string }>; - searchParams: Promise<{ period?: string }>; -} - -export default async function PopularFilesSlot({ - params, - searchParams, -}: PageProps) { - const { account_id, product_id } = await params; - const { period: periodParam } = await searchParams; - const period = parsePeriod(periodParam); - - const popularFiles = await getPopularFiles(account_id, product_id, period); - - return ( - - ); -} diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx index 0f02e374..195f6ec4 100644 --- a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx +++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx @@ -27,7 +27,6 @@ interface ProductLayoutProps { children: React.ReactNode; readme: React.ReactNode; analytics: React.ReactNode; - popularfiles: React.ReactNode; params: Promise<{ account_id: string; product_id: string; path?: string[] }>; } @@ -36,7 +35,6 @@ export default async function ProductLayout({ children, readme, analytics, - popularfiles, }: ProductLayoutProps) { // Then check if product exists const { account_id, product_id, path } = await params; @@ -65,44 +63,39 @@ export default async function ProductLayout({ {analytics} - - - - - - ) - } + + + + + ) + } + > + - - - decodeURIComponent(p)) || []} - baseUrl={productUrl(account_id, product_id)} - /> - - - - {children} - - - - - {popularfiles} - - + + decodeURIComponent(p)) || []} + baseUrl={productUrl(account_id, product_id)} + /> + + + + {children} + + + {readme} diff --git a/src/components/features/analytics/PopularFilesSidebar.tsx b/src/components/features/analytics/PopularFilesTable.tsx similarity index 63% rename from src/components/features/analytics/PopularFilesSidebar.tsx rename to src/components/features/analytics/PopularFilesTable.tsx index fc822ff0..217713c5 100644 --- a/src/components/features/analytics/PopularFilesSidebar.tsx +++ b/src/components/features/analytics/PopularFilesTable.tsx @@ -1,8 +1,7 @@ "use client"; import { useState } from "react"; -import { Box, Button, Card, Flex, IconButton, Link as RadixLink, Text, Tooltip } from "@radix-ui/themes"; -import { BarChartIcon, Cross1Icon } from "@radix-ui/react-icons"; +import { Box, Button, Flex, Link as RadixLink, Text, Tooltip } from "@radix-ui/themes"; import { AreaChart, Area, ResponsiveContainer } from "recharts"; import type { PopularFile } from "@/lib/clients/analytics"; import Link from "next/link"; @@ -10,18 +9,17 @@ import { objectUrl } from "@/lib/urls"; const DEFAULT_VISIBLE = 10; -interface PopularFilesSidebarProps { +interface PopularFilesTableProps { files: PopularFile[]; accountId: string; productId: string; } -export function PopularFilesSidebar({ +export function PopularFilesTable({ files, accountId, productId, -}: PopularFilesSidebarProps) { - const [isOpen, setIsOpen] = useState(false); +}: PopularFilesTableProps) { const [showAll, setShowAll] = useState(false); if (files.length === 0) return null; @@ -29,39 +27,14 @@ export function PopularFilesSidebar({ const visibleFiles = showAll ? files : files.slice(0, DEFAULT_VISIBLE); const hasMore = files.length > DEFAULT_VISIBLE; - if (!isOpen) { - return ( - - - setIsOpen(true)} - > - - - - - ); - } - return ( - - - Popular Files - setIsOpen(false)} - > - - - - + + + Popular Files + + {visibleFiles.map((file, index) => ( - {hasMore && !showAll && ( - +