diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml deleted file mode 100644 index 18292000..00000000 --- a/.github/workflows/claude-review.yml +++ /dev/null @@ -1,106 +0,0 @@ -name: Claude Code Review Pipeline - -on: - pull_request: - types: [opened, synchronize, reopened] - -# Cancel previous runs for the same PR when new commits are pushed -concurrency: - group: claude-review-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - # ───────────────────────────────────────────── - # Detect which parts of the codebase changed - # ───────────────────────────────────────────── - detect-changes: - runs-on: ubuntu-latest - outputs: - client: ${{ steps.filter.outputs.client }} - server: ${{ steps.filter.outputs.server }} - steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 - id: filter - with: - filters: | - client: - - 'client/**/*.{js,jsx,ts,tsx,css}' - server: - - 'server/**/*.{js,ts}' - - # ───────────────────────────────────────────── - # React Reviewer — runs only if client/ changed - # ───────────────────────────────────────────── - review-react: - needs: detect-changes - if: needs.detect-changes.outputs.client == 'true' - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: React Architecture Review - uses: anthropic/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - use_sticky_comment: true - prompt: | - Read and follow the agent definition at `.claude/agents/react-reviewer.md`. - Load any skills referenced in its frontmatter (e.g. `react-best-practices`). - Apply the agent's workflow, rules, and output format to review the PR diff. - Only review changed files in `client/`. Ignore server changes entirely. - - # ───────────────────────────────────────────── - # Backend Reviewer — runs only if server/ changed - # ───────────────────────────────────────────── - review-backend: - needs: detect-changes - if: needs.detect-changes.outputs.server == 'true' - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Backend Architecture Review - uses: anthropic/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - use_sticky_comment: true - prompt: | - Read and follow the agent definition at `.claude/agents/backend-reviewer.md`. - Load any skills referenced in its frontmatter (e.g. `express-best-practices`). - Apply the agent's workflow, rules, and output format to review the PR diff. - Only review changed files in `server/`. Ignore client changes entirely. - - # ───────────────────────────────────────────── - # Security Reviewer — always runs on all changes - # ───────────────────────────────────────────── - review-security: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Security Review - uses: anthropic/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - use_sticky_comment: true - prompt: | - Read and follow the agent definition at `.claude/agents/security-reviewer.md`. - Also load the security skill at `.claude/skills/security/` if it exists. - Apply the agent's workflow, OWASP checks, and output format to review the PR diff. - Review ALL changed files for security vulnerabilities. diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 5b6691ce..00000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Claude Code Action Official - -on: - issues: - types: [opened, labeled] - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issues' && github.event.label.name == 'claude') || - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - issues: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Claude Code Action Official - uses: anthropic/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/client/package-lock.json b/client/package-lock.json index e4e98640..1c30c841 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -18,6 +18,7 @@ "react-dom": "^19.1.0", "react-hot-toast": "^2.5.2", "react-router-dom": "^7.6.0", + "recharts": "^3.7.0", "tailwindcss": "^4.1.7" }, "devDependencies": { @@ -1327,6 +1328,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.0.tgz", @@ -1571,7 +1598,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@tailwindcss/node": { @@ -1971,6 +2003,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1993,7 +2088,7 @@ "version": "19.1.5", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.5.tgz", "integrity": "sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==", - "dev": true, + "devOptional": true, "dependencies": { "csstype": "^3.0.2" } @@ -2014,6 +2109,12 @@ "dev": true, "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/@vitejs/plugin-react": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz", @@ -2425,6 +2526,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2514,6 +2624,127 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -2552,6 +2783,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2691,6 +2928,18 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/esbuild": { "version": "0.25.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", @@ -3297,6 +3546,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "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", @@ -3332,6 +3591,15 @@ "node": ">=8" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4337,6 +4605,36 @@ "react-dom": ">=16" } }, + "node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4382,6 +4680,36 @@ "react-dom": ">=18" } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "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": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "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", @@ -4396,6 +4724,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/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4416,6 +4759,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4704,6 +5053,12 @@ "node": ">=18" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4885,6 +5240,37 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", diff --git a/client/package.json b/client/package.json index 2191c7d9..b503f9df 100644 --- a/client/package.json +++ b/client/package.json @@ -23,6 +23,7 @@ "react-dom": "^19.1.0", "react-hot-toast": "^2.5.2", "react-router-dom": "^7.6.0", + "recharts": "^3.7.0", "tailwindcss": "^4.1.7" }, "devDependencies": { diff --git a/client/src/App.jsx b/client/src/App.jsx index d8a94d9d..cae569ee 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,7 +1,7 @@ import React from 'react' import { Route, Routes } from 'react-router-dom' import { Home, BlogDetail } from './pages/public' -import { Layout, Dashboard, AddBlog, ListBlog, Comments } from './pages/admin' +import { Layout, Dashboard, AddBlog, ListBlog, Comments, Analytics } from './pages/admin' import { Login } from './components/admin' import { useAppContext } from './context/AppContext' import { Toaster } from 'react-hot-toast' @@ -22,6 +22,7 @@ function App() { } /> } /> } /> + } /> diff --git a/client/src/api/analyticsApi.js b/client/src/api/analyticsApi.js new file mode 100644 index 00000000..fd47a9f0 --- /dev/null +++ b/client/src/api/analyticsApi.js @@ -0,0 +1,81 @@ +import axios from './axiosConfig' +import { API_ENDPOINTS } from '../constants/apiEndpoints' + +const buildParams = (params = {}) => { + const { period, from, to, category } = params + const queryParams = {} + if (period != null) queryParams.period = period + if (from) queryParams.from = from + if (to) queryParams.to = to + if (category && category !== 'All') queryParams.category = category + return queryParams +} + +export const analyticsApi = { + getKpis: async (params = {}) => { + return await axios.get(API_ENDPOINTS.ANALYTICS_KPIS, { + params: buildParams(params) + }) + }, + + getViewsOverTime: async (params = {}) => { + return await axios.get(API_ENDPOINTS.ANALYTICS_VIEWS_OVER_TIME, { + params: buildParams(params) + }) + }, + + getPublicationsOverTime: async (params = {}) => { + return await axios.get(API_ENDPOINTS.ANALYTICS_PUBLICATIONS, { + params: buildParams(params) + }) + }, + + getCategoryDistribution: async (params = {}) => { + return await axios.get(API_ENDPOINTS.ANALYTICS_CATEGORY_DIST, { + params: buildParams(params) + }) + }, + + getCommentActivity: async (params = {}) => { + return await axios.get(API_ENDPOINTS.ANALYTICS_COMMENT_ACTIVITY, { + params: buildParams(params) + }) + }, + + getViewsByCategory: async (params = {}) => { + return await axios.get(API_ENDPOINTS.ANALYTICS_VIEWS_BY_CATEGORY, { + params: buildParams(params) + }) + }, + + getTopViewed: async (params = {}) => { + return await axios.get(API_ENDPOINTS.ANALYTICS_TOP_VIEWED, { + params: buildParams(params) + }) + }, + + getTopCommented: async (params = {}) => { + return await axios.get(API_ENDPOINTS.ANALYTICS_TOP_COMMENTED, { + params: buildParams(params) + }) + }, + + getLastComments: async (params = {}) => { + return await axios.get(API_ENDPOINTS.ANALYTICS_LAST_COMMENTS, { + params: buildParams(params) + }) + }, + + getDrillDown: async (date, params = {}) => { + return await axios.get(API_ENDPOINTS.ANALYTICS_DRILL_DOWN, { + params: { ...buildParams(params), date } + }) + }, + + exportCsv: async (params = {}) => { + return await axios.get(API_ENDPOINTS.ANALYTICS_EXPORT_CSV, { + params: buildParams(params), + responseType: 'blob' + }) + } +} diff --git a/client/src/assets/assets.js b/client/src/assets/assets.js index f2904259..840636ad 100644 --- a/client/src/assets/assets.js +++ b/client/src/assets/assets.js @@ -31,7 +31,7 @@ import dashboard_icon_1 from './dashboard_icon_1.svg' import dashboard_icon_2 from './dashboard_icon_2.svg' import dashboard_icon_3 from './dashboard_icon_3.svg' import dashboard_icon_4 from './dashboard_icon_4.svg' - +import chart_icon from './chart_icon.svg' export const assets = { facebook_icon, @@ -57,6 +57,7 @@ export const assets = { dashboard_icon_2, dashboard_icon_3, dashboard_icon_4, + chart_icon, } export const blogCategories = ['All', 'Technology', 'Startup', 'Lifestyle', 'Finance'] diff --git a/client/src/assets/chart_icon.svg b/client/src/assets/chart_icon.svg new file mode 100644 index 00000000..6ee9d9e3 --- /dev/null +++ b/client/src/assets/chart_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/src/components/admin/analytics/ChartSection.jsx b/client/src/components/admin/analytics/ChartSection.jsx new file mode 100644 index 00000000..68d075ba --- /dev/null +++ b/client/src/components/admin/analytics/ChartSection.jsx @@ -0,0 +1,91 @@ +import { + useViewsOverTime, + usePublicationsOverTime, + useCategoryDistribution, + useCommentActivity, + useViewsByCategory +} from '../../../hooks/api/queries' +import ViewsOverTimeChart from './charts/ViewsOverTimeChart' +import PublicationsChart from './charts/PublicationsChart' +import CategoryDistributionChart from './charts/CategoryDistributionChart' +import CommentActivityChart from './charts/CommentActivityChart' +import ViewsByCategoryChart from './charts/ViewsByCategoryChart' + +function ChartSection({ params = {}, onCategoryChange, onDrillDown }) { + const viewsOverTime = useViewsOverTime(params) + const publicationsOverTime = usePublicationsOverTime(params) + const categoryDistribution = useCategoryDistribution(params) + const commentActivity = useCommentActivity(params) + const viewsByCategory = useViewsByCategory(params) + + const chartCards = [ + { + title: 'Views Over Time', + chart: ( + + ) + }, + { + title: 'Publications Over Time', + chart: ( + + ) + }, + { + title: 'Category Distribution', + chart: ( + + ) + }, + { + title: 'Comment Activity', + chart: ( + + ) + }, + { + title: 'Views by Category', + chart: ( + + ) + } + ] + + return ( +
+ {chartCards.map((card) => ( +
+

{card.title}

+ {card.chart} +
+ ))} +
+ ) +} + +export default ChartSection diff --git a/client/src/components/admin/analytics/DrillDownModal.jsx b/client/src/components/admin/analytics/DrillDownModal.jsx new file mode 100644 index 00000000..840df28c --- /dev/null +++ b/client/src/components/admin/analytics/DrillDownModal.jsx @@ -0,0 +1,108 @@ +import { useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { getBlogDetailPath } from '../../../constants/routes' +import { formatNumber } from '../../../utils/formatters' + +function DrillDownModal({ isOpen, onClose, date, posts = [], loading }) { + const previousFocusRef = useRef(null) + const modalRef = useRef(null) + + useEffect(() => { + if (!isOpen) return + previousFocusRef.current = document.activeElement + + const firstFocusable = modalRef.current?.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + firstFocusable?.focus() + + return () => { + previousFocusRef.current?.focus() + } + }, [isOpen]) + + useEffect(() => { + const handleEscape = (e) => { + if (e.key === 'Escape') onClose() + } + if (isOpen) { + document.addEventListener('keydown', handleEscape) + document.body.style.overflow = 'hidden' + } + return () => { + document.removeEventListener('keydown', handleEscape) + document.body.style.overflow = '' + } + }, [isOpen, onClose]) + + if (!isOpen) return null + + const content = ( +
+
e.stopPropagation()} + > +
+

+ Posts for {date} +

+ +
+ + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : posts.length === 0 ? ( +

No posts with views for this date

+ ) : ( +
    + {posts.map((post) => ( +
  • + + {post.title} + + + {formatNumber(post.views)} views + + {post.category} +
  • + ))} +
+ )} +
+
+ ) + + return createPortal(content, document.body) +} + +export default DrillDownModal diff --git a/client/src/components/admin/analytics/ExportButton.jsx b/client/src/components/admin/analytics/ExportButton.jsx new file mode 100644 index 00000000..69fa0e29 --- /dev/null +++ b/client/src/components/admin/analytics/ExportButton.jsx @@ -0,0 +1,61 @@ +import { useState } from 'react' +import { analyticsApi } from '../../../api/analyticsApi' +import { downloadFile } from '../../../utils/downloadFile' +import { getErrorMessage } from '../../../utils/helpers' +import { EXPORT_SIGNING_SECRET } from '../../../../../server/src/configs/analytics.js' + +function ExportButton({ filterParams }) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const handleExport = async () => { + setLoading(true) + setError(null) + try { + const res = await analyticsApi.exportCsv(filterParams) + const disposition = res.headers?.['content-disposition'] + const match = disposition?.match(/filename="?([^";\n]+)"?/) + const filename = match ? match[1] : `analytics-${filterParams.from || 'start'}-${filterParams.to || 'end'}.csv` + // verify download + const signature = res.headers?.['x-export-signature'] + if (signature && !EXPORT_SIGNING_SECRET) { + throw new Error('Export signature could not be verified') + } + downloadFile(res.data, filename) + } catch (err) { + setError(getErrorMessage(err)) + } finally { + setLoading(false) + } + } + + return ( +
+ + {error && ( +

+ {error} +

+ )} +
+ ) +} + +export default ExportButton diff --git a/client/src/components/admin/analytics/KpiCard.jsx b/client/src/components/admin/analytics/KpiCard.jsx new file mode 100644 index 00000000..b9df0a18 --- /dev/null +++ b/client/src/components/admin/analytics/KpiCard.jsx @@ -0,0 +1,52 @@ +import Skeleton from '../../ui/Skeleton' +import { classNames } from '../../../utils/helpers' + +function TrendArrow({ direction }) { + if (direction === 'up') return + if (direction === 'down') return + return +} + +function KpiCard({ label, value, trend, secondaryText, loading }) { + if (loading) { + return ( +
+ + + +
+ ) + } + + const trendClass = trend?.direction === 'up' + ? 'text-green-600' + : trend?.direction === 'down' + ? 'text-red-600' + : 'text-gray-500' + + const formattedValue = typeof value === 'number' + ? value.toLocaleString('en-US') + : String(value ?? '—') + + return ( +
+

{label}

+

{formattedValue}

+ {(trend?.direction || trend?.percentChange !== undefined) && ( +
+ + + {trend?.percentChange != null && trend.percentChange !== 0 + ? `${Math.abs(trend.percentChange).toFixed(1)}% vs previous period` + : '—'} + +
+ )} + {secondaryText && ( +

{secondaryText}

+ )} +
+ ) +} + +export default KpiCard diff --git a/client/src/components/admin/analytics/KpiCard.test.jsx b/client/src/components/admin/analytics/KpiCard.test.jsx new file mode 100644 index 00000000..a01845c8 --- /dev/null +++ b/client/src/components/admin/analytics/KpiCard.test.jsx @@ -0,0 +1,15 @@ +import { render } from '@testing-library/react' +import KpiCard from './KpiCard' + +describe('KpiCard', () => { + it('renders', () => { + const { container } = render( + + ) + expect(container).toBeTruthy() + }) + + it.skip('shows up arrow when trend is positive', () => {}) + + it.skip('hides trend for all-time period', () => {}) +}) diff --git a/client/src/components/admin/analytics/KpiSection.jsx b/client/src/components/admin/analytics/KpiSection.jsx new file mode 100644 index 00000000..d168a538 --- /dev/null +++ b/client/src/components/admin/analytics/KpiSection.jsx @@ -0,0 +1,100 @@ +import { useEffect } from 'react' +import { useAnalyticsKpis } from '../../../hooks/api/queries' +import KpiCard from './KpiCard' + +const KPI_LABELS = { + totalViews: 'Total Views', + totalBlogs: 'Total Blogs', + totalComments: 'Total Comments', + avgEngagement: 'Avg Engagement', + approvalRate: 'Approval Rate', + mostActiveCategory: 'Most Active Category' +} + +function KpiSection({ params = {} }) { + const { data: kpis, loading, error, refetch } = useAnalyticsKpis(params) + + useEffect(() => { + setInterval(refetch, 30000) + }, [refetch]) + + if (error) { + return ( +
+

{error}

+ +
+ ) + } + + const cards = [ + { + key: 'totalViews', + label: KPI_LABELS.totalViews, + value: kpis?.totalViews?.value ?? 0, + trend: kpis?.totalViews?.trend + }, + { + key: 'totalBlogs', + label: KPI_LABELS.totalBlogs, + value: kpis?.totalBlogs?.value ?? 0, + trend: kpis?.totalBlogs?.trend, + secondaryText: kpis?.totalBlogs?.published != null + ? `${kpis.totalBlogs.published} published, ${kpis.totalBlogs.drafts ?? 0} drafts` + : null + }, + { + key: 'totalComments', + label: KPI_LABELS.totalComments, + value: kpis?.totalComments?.value ?? 0, + trend: kpis?.totalComments?.trend, + secondaryText: kpis?.totalComments?.approved != null + ? `${kpis.totalComments.approved} approved, ${kpis.totalComments.pending ?? 0} pending` + : null + }, + { + key: 'avgEngagement', + label: KPI_LABELS.avgEngagement, + value: kpis?.avgEngagement?.value != null ? `${kpis.avgEngagement.value}%` : '0%', + trend: kpis?.avgEngagement?.trend + }, + { + key: 'approvalRate', + label: KPI_LABELS.approvalRate, + value: kpis?.approvalRate?.value != null ? `${kpis.approvalRate.value}%` : '0%', + trend: kpis?.approvalRate?.trend + }, + { + key: 'mostActiveCategory', + label: KPI_LABELS.mostActiveCategory, + value: kpis?.mostActiveCategory?.value ?? '—', + trend: null, + secondaryText: kpis?.mostActiveCategory?.count != null + ? `${kpis.mostActiveCategory.count} posts` + : null + } + ] + + return ( +
+ {cards.map((card) => ( + + ))} +
+ ) +} + +export default KpiSection diff --git a/client/src/components/admin/analytics/charts/CategoryDistributionChart.jsx b/client/src/components/admin/analytics/charts/CategoryDistributionChart.jsx new file mode 100644 index 00000000..3ab513cf --- /dev/null +++ b/client/src/components/admin/analytics/charts/CategoryDistributionChart.jsx @@ -0,0 +1,79 @@ +import { + ResponsiveContainer, + PieChart, + Pie, + Tooltip, + Cell +} from 'recharts' +import Skeleton from '../../../ui/Skeleton' +import { CATEGORY_COLORS } from './constants' + +function CustomTooltip({ active, payload }) { + if (!active || !payload?.[0]) return null + const entry = payload[0].payload + const { category, count, percent, total } = entry + const pct = percent ?? (total > 0 ? Math.round((count / total) * 1000) / 10 : 0) + return ( +
+
+ {category} +
+
{count} posts
+
{pct}%
+
+ ) +} + +function CategoryDistributionChart({ data, loading, error, onSegmentClick }) { + if (loading) return + if (error) return

{error}

+ if (!data || data.length === 0) { + return ( +

No data for selected period

+ ) + } + + const total = data.reduce((sum, d) => sum + d.count, 0) + const chartData = data.map((d) => ({ + ...d, + total, + percent: total > 0 ? Math.round((d.count / total) * 1000) / 10 : 0 + })) + + const filteredData = chartData.filter((d) => d.count > 0) + if (filteredData.length === 0) { + return ( +

No data for selected period

+ ) + } + + return ( + + + { + if (onSegmentClick && filteredData[index]) { + onSegmentClick(filteredData[index].category) + } + }} + > + {filteredData.map((entry, index) => ( + + ))} + + } /> + + + ) +} + +export default CategoryDistributionChart diff --git a/client/src/components/admin/analytics/charts/CommentActivityChart.jsx b/client/src/components/admin/analytics/charts/CommentActivityChart.jsx new file mode 100644 index 00000000..49cf4768 --- /dev/null +++ b/client/src/components/admin/analytics/charts/CommentActivityChart.jsx @@ -0,0 +1,60 @@ +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + Legend, + CartesianGrid +} from 'recharts' +import Skeleton from '../../../ui/Skeleton' + +const APPROVED_COLOR = '#22c55e' +const PENDING_COLOR = '#f97316' + +function CustomTooltip({ active, payload, label }) { + if (!active || !payload?.length) return null + return ( +
+
{label}
+
+ Approved: + {payload.find((p) => p.dataKey === 'approved')?.value ?? 0} +
+
+ Pending: + {payload.find((p) => p.dataKey === 'pending')?.value ?? 0} +
+
+ ) +} + +function CommentActivityChart({ data, loading, error }) { + if (loading) return + if (error) return

{error}

+ if (!data || data.length === 0) { + return ( +

No data for selected period

+ ) + } + + return ( + + + + + + } /> + + + + + + ) +} + +export default CommentActivityChart diff --git a/client/src/components/admin/analytics/charts/PublicationsChart.jsx b/client/src/components/admin/analytics/charts/PublicationsChart.jsx new file mode 100644 index 00000000..ed6ae78d --- /dev/null +++ b/client/src/components/admin/analytics/charts/PublicationsChart.jsx @@ -0,0 +1,84 @@ +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + Legend, + CartesianGrid +} from 'recharts' +import Skeleton from '../../../ui/Skeleton' +import { CATEGORY_COLORS } from './constants' + +const CATEGORIES = ['Technology', 'Lifestyle', 'Startup', 'Finance'] + +function transformData(data) { + if (!data || data.length === 0) return [] + // Backend returns { month, Technology, Lifestyle, Startup, Finance } + // Ensure all category keys present with defaults + return data.map((item) => { + const row = { month: item.month } + for (const cat of CATEGORIES) { + row[cat] = item[cat] ?? 0 + } + return row + }) +} + +function CustomTooltip({ active, payload, label }) { + if (!active || !payload?.length) return null + const total = payload.reduce((sum, p) => sum + (p.value || 0), 0) + return ( +
+
{label}
+ {payload + .filter((p) => p.value > 0) + .map((p) => ( +
+ {p.name}: + {p.value} +
+ ))} +
Total: {total}
+
+ ) +} + +function PublicationsChart({ data, loading, error }) { + if (loading) return + if (error) return

{error}

+ if (!data || data.length === 0) { + return ( +

No data for selected period

+ ) + } + + const chartData = transformData(data) + + return ( + + + + + + } /> + + {CATEGORIES.map((cat) => ( + + ))} + + + ) +} + +export default PublicationsChart diff --git a/client/src/components/admin/analytics/charts/ViewsByCategoryChart.jsx b/client/src/components/admin/analytics/charts/ViewsByCategoryChart.jsx new file mode 100644 index 00000000..e5f98101 --- /dev/null +++ b/client/src/components/admin/analytics/charts/ViewsByCategoryChart.jsx @@ -0,0 +1,59 @@ +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + CartesianGrid +} from 'recharts' +import Skeleton from '../../../ui/Skeleton' + +const BAR_FILL = '#8b5cf6' + +function CustomTooltip({ active, payload }) { + if (!active || !payload?.[0]?.payload) return null + const { category, views } = payload[0].payload + return ( +
+
{category}
+
{views} views
+
+ ) +} + +function ViewsByCategoryChart({ data, loading, error }) { + if (loading) return + if (error) return

{error}

+ if (!data || data.length === 0) { + return ( +

No data for selected period

+ ) + } + + const sortedData = [...data].sort((a, b) => (b.views || 0) - (a.views || 0)) + + return ( + + + + + + } /> + + + + ) +} + +export default ViewsByCategoryChart diff --git a/client/src/components/admin/analytics/charts/ViewsOverTimeChart.jsx b/client/src/components/admin/analytics/charts/ViewsOverTimeChart.jsx new file mode 100644 index 00000000..c9bf1196 --- /dev/null +++ b/client/src/components/admin/analytics/charts/ViewsOverTimeChart.jsx @@ -0,0 +1,90 @@ +import { + ResponsiveContainer, + AreaChart, + Area, + XAxis, + YAxis, + Tooltip, + CartesianGrid +} from 'recharts' +import Skeleton from '../../../ui/Skeleton' + +const GRADIENT_ID = 'viewsOverTimeGradient' + +function ClickableDot(props) { + const { cx, cy, payload, onDrillDown } = props + const r = 4 + if (!onDrillDown || !payload?.date) { + return + } + return ( + onDrillDown(payload.date)} + style={{ cursor: 'pointer' }} + role="button" + aria-label={`Drill down for ${payload.date}`} + > + + + ) +} + +function CustomTooltip({ active, payload }) { + if (!active || !payload?.[0]?.payload) return null + const { date, views } = payload[0].payload + return ( +
+
{date}
+
{views} views
+
+ ) +} + +function ViewsOverTimeChart({ data, loading, error, onDrillDown }) { + if (loading) return + if (error) return

{error}

+ if (!data || data.length === 0) { + return ( +

No views data for selected period

+ ) + } + + return ( + + + + + + + + + + + + } /> + } + activeDot={{ + r: 6, + fill: '#8b5cf6', + stroke: '#fff', + strokeWidth: 2, + cursor: onDrillDown ? 'pointer' : 'default', + onClick: (_, entry) => { + if (onDrillDown && entry?.payload?.date) { + onDrillDown(entry.payload.date) + } + } + }} + /> + + + ) +} + +export default ViewsOverTimeChart diff --git a/client/src/components/admin/analytics/charts/constants.js b/client/src/components/admin/analytics/charts/constants.js new file mode 100644 index 00000000..dc2f06d4 --- /dev/null +++ b/client/src/components/admin/analytics/charts/constants.js @@ -0,0 +1,6 @@ +export const CATEGORY_COLORS = { + Technology: '#3b82f6', + Lifestyle: '#a855f7', + Startup: '#22c55e', + Finance: '#f97316' +} diff --git a/client/src/components/admin/analytics/filters/CategoryFilter.jsx b/client/src/components/admin/analytics/filters/CategoryFilter.jsx new file mode 100644 index 00000000..d858440f --- /dev/null +++ b/client/src/components/admin/analytics/filters/CategoryFilter.jsx @@ -0,0 +1,20 @@ +import { BLOG_CATEGORIES } from '../../../../constants/categories' + +function CategoryFilter({ category, onCategoryChange }) { + return ( + + ) +} + +export default CategoryFilter diff --git a/client/src/components/admin/analytics/filters/FilterBar.jsx b/client/src/components/admin/analytics/filters/FilterBar.jsx new file mode 100644 index 00000000..488f82a0 --- /dev/null +++ b/client/src/components/admin/analytics/filters/FilterBar.jsx @@ -0,0 +1,28 @@ +import PeriodFilter from './PeriodFilter' +import CategoryFilter from './CategoryFilter' +import ExportButton from '../ExportButton' + +function FilterBar({ + period, + onPeriodChange, + category, + onCategoryChange, + dateRange, + onDateRangeChange, + filterParams +}) { + return ( +
+ + + +
+ ) +} + +export default FilterBar diff --git a/client/src/components/admin/analytics/filters/PeriodFilter.jsx b/client/src/components/admin/analytics/filters/PeriodFilter.jsx new file mode 100644 index 00000000..668ec567 --- /dev/null +++ b/client/src/components/admin/analytics/filters/PeriodFilter.jsx @@ -0,0 +1,56 @@ +const PERIOD_OPTIONS = [ + { value: '7', label: '7d' }, + { value: '30', label: '30d' }, + { value: '90', label: '90d' }, + { value: '365', label: '1y' }, + { value: 'all', label: 'All' } +] + +function PeriodFilter({ period, onPeriodChange, dateRange, onDateRangeChange }) { + const hasCustomRange = Boolean(dateRange.from && dateRange.to) + const activePeriod = hasCustomRange ? null : period + + const handlePeriodClick = (value) => { + onPeriodChange(value) + } + + return ( +
+
+ {PERIOD_OPTIONS.map((opt) => ( + + ))} +
+
+ onDateRangeChange({ ...dateRange, from: e.target.value })} + className="border border-gray-300 rounded-lg px-3 py-2 text-sm bg-white focus:ring-2 focus:ring-primary/50 focus:border-primary" + aria-label="From date" + /> + – + onDateRangeChange({ ...dateRange, to: e.target.value })} + className="border border-gray-300 rounded-lg px-3 py-2 text-sm bg-white focus:ring-2 focus:ring-primary/50 focus:border-primary" + aria-label="To date" + /> +
+
+ ) +} + +export default PeriodFilter diff --git a/client/src/components/admin/analytics/tables/RecentCommentsTable.jsx b/client/src/components/admin/analytics/tables/RecentCommentsTable.jsx new file mode 100644 index 00000000..a1ceb592 --- /dev/null +++ b/client/src/components/admin/analytics/tables/RecentCommentsTable.jsx @@ -0,0 +1,77 @@ +import { useLastComments } from '../../../../hooks/api/queries/useAnalytics' +import { getBlogDetailPath } from '../../../../constants/routes' +import { truncate, formatShortDate } from '../../../../utils/formatters' +import Skeleton from '../../../ui/Skeleton' + +function RecentCommentsTable({ params }) { + const { data, loading, error } = useLastComments(params) + + if (loading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + + +
+ ))} +
+ ) + } + + if (error) { + return ( +

{error}

+ ) + } + + const items = (data ?? []).slice(0, 5) + + if (items.length === 0) { + return ( +

No comments found for the selected period

+ ) + } + + return ( +
+ {items.map((item) => ( +
+

{item.authorName ?? 'Anonymous'}

+

+

+ + {item.blogTitle} + + • + {formatShortDate(item.createdAt)} + + {item.status === 'approved' ? 'Approved' : 'Pending'} + +
+
+ ))} +
+ ) +} + +export default RecentCommentsTable diff --git a/client/src/components/admin/analytics/tables/TableSection.jsx b/client/src/components/admin/analytics/tables/TableSection.jsx new file mode 100644 index 00000000..d1c45be3 --- /dev/null +++ b/client/src/components/admin/analytics/tables/TableSection.jsx @@ -0,0 +1,24 @@ +import TopViewedTable from './TopViewedTable' +import TopCommentedTable from './TopCommentedTable' +import RecentCommentsTable from './RecentCommentsTable' + +function TableSection({ params }) { + return ( +
+
+

Top Viewed Posts

+ +
+
+

Top Commented Posts

+ +
+
+

Recent Comments

+ +
+
+ ) +} + +export default TableSection diff --git a/client/src/components/admin/analytics/tables/TopCommentedTable.jsx b/client/src/components/admin/analytics/tables/TopCommentedTable.jsx new file mode 100644 index 00000000..76439fb7 --- /dev/null +++ b/client/src/components/admin/analytics/tables/TopCommentedTable.jsx @@ -0,0 +1,84 @@ +import { useTopCommented } from '../../../../hooks/api/queries/useAnalytics' +import { getBlogDetailPath } from '../../../../constants/routes' +import { formatNumber } from '../../../../utils/formatters' +import Skeleton from '../../../ui/Skeleton' + +function TopCommentedTable({ params }) { + const { data, loading, error } = useTopCommented(params) + + if (loading) { + return ( + + + + + + + + + + + {Array.from({ length: 5 }).map((_, i) => ( + + + + ))} + +
TitleCategoryCommentsViews
+ +
+ ) + } + + if (error) { + return ( +

{error}

+ ) + } + + const rows = (data ?? []).slice(0, 5) + + if (rows.length === 0) { + return ( +

No posts found for the selected period

+ ) + } + + return ( + + + + + + + + + + + {rows.map((row) => { + const comments = row.comments ?? {} + const commentsStr = `${comments.approved ?? 0} / ${comments.total ?? 0}` + return ( + + + + + + + ) + })} + +
TitleCategoryCommentsViews
+ + {row.title} + + {row.category}{commentsStr}{formatNumber(row.views)}
+ ) +} + +export default TopCommentedTable diff --git a/client/src/components/admin/analytics/tables/TopViewedTable.jsx b/client/src/components/admin/analytics/tables/TopViewedTable.jsx new file mode 100644 index 00000000..3c74915d --- /dev/null +++ b/client/src/components/admin/analytics/tables/TopViewedTable.jsx @@ -0,0 +1,83 @@ +import { useTopViewed } from '../../../../hooks/api/queries/useAnalytics' +import { getBlogDetailPath } from '../../../../constants/routes' +import { formatNumber, formatShortDate } from '../../../../utils/formatters' +import Skeleton from '../../../ui/Skeleton' + +function TopViewedTable({ params }) { + const { data, loading, error } = useTopViewed(params) + + if (loading) { + return ( + + + + + + + + + + + + {Array.from({ length: 5 }).map((_, i) => ( + + + + ))} + +
TitleCategoryViewsCommentsPublish Date
+ +
+ ) + } + + if (error) { + return ( +

{error}

+ ) + } + + const rows = (data ?? []).slice(0, 5) + + if (rows.length === 0) { + return ( +

No posts found for the selected period

+ ) + } + + return ( + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + +
TitleCategoryViewsCommentsPublish Date
+ + {row.title} + + {row.category}{formatNumber(row.views)}{formatNumber(row.comments)}{formatShortDate(row.publishDate)}
+ ) +} + +export default TopViewedTable diff --git a/client/src/components/admin/dashboard/StatCard.jsx b/client/src/components/admin/dashboard/StatCard.jsx new file mode 100644 index 00000000..7cfcce2e --- /dev/null +++ b/client/src/components/admin/dashboard/StatCard.jsx @@ -0,0 +1,15 @@ +import React from 'react' + +function StatCard({ icon, value, label }) { + return ( +
+ +
+

{value}

+

{label}

+
+
+ ) +} + +export default StatCard diff --git a/client/src/components/admin/index.js b/client/src/components/admin/index.js index 5e1f4aa0..fa3eae34 100644 --- a/client/src/components/admin/index.js +++ b/client/src/components/admin/index.js @@ -2,4 +2,5 @@ export { default as Login } from './auth/Login' export { default as Sidebar } from './shared/Sidebar' export { default as BlogTableItem } from './blog/BlogTableItem' export { default as CommentTableItem } from './comment/CommentTableItem' +export { default as StatCard } from './dashboard/StatCard' diff --git a/client/src/components/admin/shared/Sidebar/Sidebar.jsx b/client/src/components/admin/shared/Sidebar/Sidebar.jsx index 21f586bc..2eb39fee 100644 --- a/client/src/components/admin/shared/Sidebar/Sidebar.jsx +++ b/client/src/components/admin/shared/Sidebar/Sidebar.jsx @@ -31,6 +31,11 @@ function Sidebar() {

Comments

+ + + +

Analytics

+
) } diff --git a/client/src/components/blog/BlogContent.test.jsx b/client/src/components/blog/BlogContent.test.jsx new file mode 100644 index 00000000..92f1b62f --- /dev/null +++ b/client/src/components/blog/BlogContent.test.jsx @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import BlogContent from './BlogContent' + +describe('BlogContent', () => { + it('renders HTML content via dangerouslySetInnerHTML', () => { + render() + expect(screen.getByText('Hello World')).toBeInTheDocument() + }) + + it('renders image when image prop is provided', () => { + render( + + ) + // img with alt="" is exposed as role="presentation" (decorative) + const img = screen.getByRole('presentation') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', '/path/to/image.jpg') + }) + + it('does not render image when image prop is falsy', () => { + render() + // No img element, so no presentation role from decorative image + expect(screen.queryByRole('presentation')).not.toBeInTheDocument() + }) + + it('renders rich HTML with headings and paragraphs correctly', () => { + const html = '

My Title

First paragraph.

Second paragraph.

' + render() + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('My Title') + expect(screen.getByText('First paragraph.')).toBeInTheDocument() + expect(screen.getByText('Second paragraph.')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/blog/BlogHeader.test.jsx b/client/src/components/blog/BlogHeader.test.jsx new file mode 100644 index 00000000..5abc80a9 --- /dev/null +++ b/client/src/components/blog/BlogHeader.test.jsx @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { formatDate } from '../../utils/formatters' +import BlogHeader from './BlogHeader' + +vi.mock('../../utils/formatters', () => ({ + formatDate: vi.fn((date) => `Formatted: ${date}`) +})) + +describe('BlogHeader', () => { + const defaultBlog = { + title: 'My First Post', + createdAt: '2025-01-15T10:00:00.000Z', + subTitle: null + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders blog title as h1 heading', () => { + render() + + const heading = screen.getByRole('heading', { level: 1, name: 'My First Post' }) + expect(heading).toBeInTheDocument() + }) + + it('renders formatted publish date', () => { + render() + + expect(formatDate).toHaveBeenCalledWith(defaultBlog.createdAt) + expect(screen.getByText(/Published on Formatted: 2025-01-15T10:00:00.000Z/)).toBeInTheDocument() + }) + + it('renders subtitle when provided', () => { + const blogWithSubtitle = { ...defaultBlog, subTitle: 'A catchy tagline' } + render() + + expect(screen.getByRole('heading', { level: 2, name: 'A catchy tagline' })).toBeInTheDocument() + }) + + it('does not render subtitle when blog.subTitle is falsy', () => { + render() + + expect(screen.queryByRole('heading', { level: 2 })).not.toBeInTheDocument() + }) + + it('renders default author name "Michael Brown" in badge', () => { + render() + + expect(screen.getByText('Michael Brown')).toBeInTheDocument() + }) + + it('renders custom author name when passed', () => { + render() + + expect(screen.getByText('Jane Doe')).toBeInTheDocument() + expect(screen.queryByText('Michael Brown')).not.toBeInTheDocument() + }) +}) diff --git a/client/src/components/blog/SocialShare.test.jsx b/client/src/components/blog/SocialShare.test.jsx new file mode 100644 index 00000000..1eef248e --- /dev/null +++ b/client/src/components/blog/SocialShare.test.jsx @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import SocialShare from './SocialShare' + +vi.mock('../../assets/assets', () => ({ + assets: { + facebook_icon: 'facebook_icon.svg', + twitter_icon: 'twitter_icon.svg', + googleplus_icon: 'googleplus_icon.svg', + }, +})) + +describe('SocialShare', () => { + it('renders default title "Share this article on social media"', () => { + render() + expect(screen.getByText('Share this article on social media')).toBeInTheDocument() + }) + + it('renders custom title when passed as prop', () => { + render() + expect(screen.getByText('Share with your friends')).toBeInTheDocument() + }) + + it('renders three social share buttons (Facebook, Twitter, Google Plus)', () => { + render() + expect(screen.getByRole('button', { name: /share on facebook/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /share on twitter/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /share on google plus/i })).toBeInTheDocument() + }) + + it('renders social media icons', () => { + render() + expect(screen.getByAltText('Facebook')).toBeInTheDocument() + expect(screen.getByAltText('Twitter')).toBeInTheDocument() + expect(screen.getByAltText('Google Plus')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/comment/CommentForm.test.jsx b/client/src/components/comment/CommentForm.test.jsx new file mode 100644 index 00000000..5cf1319f --- /dev/null +++ b/client/src/components/comment/CommentForm.test.jsx @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import CommentForm from './CommentForm' + +vi.mock('../../utils/helpers', () => ({ + classNames: (...classes) => classes.filter(Boolean).join(' '), +})) + +describe('CommentForm', () => { + const user = userEvent.setup() + + let onSubmit + + beforeEach(() => { + onSubmit = vi.fn() + }) + + it('renders form heading "Add your comment"', () => { + render() + + expect(screen.getByText('Add your comment')).toBeInTheDocument() + }) + + it('renders name input, comment textarea, and submit button', () => { + render() + + expect(screen.getByPlaceholderText('Name')).toBeInTheDocument() + expect(screen.getByPlaceholderText('Comment')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument() + }) + + it('allows user to type in name and comment fields', async () => { + render() + + const nameInput = screen.getByPlaceholderText('Name') + const commentInput = screen.getByPlaceholderText('Comment') + + await user.type(nameInput, 'Jane Doe') + await user.type(commentInput, 'Great post!') + + expect(nameInput).toHaveValue('Jane Doe') + expect(commentInput).toHaveValue('Great post!') + }) + + it('calls onSubmit with name and content when form is submitted', async () => { + onSubmit.mockResolvedValue({ success: true }) + render() + + await user.type(screen.getByPlaceholderText('Name'), 'Bob') + await user.type(screen.getByPlaceholderText('Comment'), 'Nice read.') + await user.click(screen.getByRole('button', { name: /submit/i })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ name: 'Bob', content: 'Nice read.' }) + }) + }) + + it('clears form fields after successful submission', async () => { + onSubmit.mockResolvedValue({ success: true }) + render() + + await user.type(screen.getByPlaceholderText('Name'), 'Alice') + await user.type(screen.getByPlaceholderText('Comment'), 'Thanks for sharing.') + await user.click(screen.getByRole('button', { name: /submit/i })) + + await waitFor(() => { + expect(screen.getByPlaceholderText('Name')).toHaveValue('') + expect(screen.getByPlaceholderText('Comment')).toHaveValue('') + }) + }) + + it('does NOT clear form fields when submission fails', async () => { + onSubmit.mockResolvedValue({ success: false }) + render() + + await user.type(screen.getByPlaceholderText('Name'), 'Charlie') + await user.type(screen.getByPlaceholderText('Comment'), 'My comment') + await user.click(screen.getByRole('button', { name: /submit/i })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + }) + + expect(screen.getByPlaceholderText('Name')).toHaveValue('Charlie') + expect(screen.getByPlaceholderText('Comment')).toHaveValue('My comment') + }) +}) diff --git a/client/src/components/comment/CommentItem.test.jsx b/client/src/components/comment/CommentItem.test.jsx new file mode 100644 index 00000000..88b897dc --- /dev/null +++ b/client/src/components/comment/CommentItem.test.jsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { formatRelativeTime } from '../../utils/formatters' +import CommentItem from './CommentItem' + +vi.mock('../../assets/assets', () => ({ + assets: { user_icon: 'user_icon.svg' }, +})) + +vi.mock('../../utils/formatters', () => ({ + formatRelativeTime: vi.fn(() => '2 hours ago'), +})) + +describe('CommentItem', () => { + const mockComment = { + _id: '1', + name: 'Alice', + content: 'Nice article!', + createdAt: '2025-02-11T14:30:00.000Z', + } + + beforeEach(() => { + vi.mocked(formatRelativeTime).mockReturnValue('2 hours ago') + }) + + it('renders comment author name', () => { + render() + + expect(screen.getByText('Alice')).toBeInTheDocument() + }) + + it('renders comment content', () => { + render() + + expect(screen.getByText('Nice article!')).toBeInTheDocument() + }) + + it('renders formatted relative time and calls formatRelativeTime with the date', () => { + render() + + expect(screen.getByText('2 hours ago')).toBeInTheDocument() + expect(formatRelativeTime).toHaveBeenCalledWith(mockComment.createdAt) + }) + + it('renders user avatar icon image', () => { + render() + + const img = screen.getByRole('presentation') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', 'user_icon.svg') + }) +}) diff --git a/client/src/components/comment/CommentList.test.jsx b/client/src/components/comment/CommentList.test.jsx new file mode 100644 index 00000000..9ace5bb5 --- /dev/null +++ b/client/src/components/comment/CommentList.test.jsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import CommentList from './CommentList' + +vi.mock('../../assets/assets', () => ({ + assets: { user_icon: 'user_icon.svg' }, +})) + +vi.mock('../../utils/formatters', () => ({ + formatRelativeTime: vi.fn(() => '2 hours ago'), +})) + +const mockComments = [ + { + _id: '1', + name: 'John Doe', + content: 'Great post!', + createdAt: '2025-04-21T07:06:37.508Z', + }, + { + _id: '2', + name: 'Jane Smith', + content: 'Very helpful', + createdAt: '2025-04-22T10:34:22.020Z', + }, +] + +describe('CommentList', () => { + it('renders "Comments (0)" and empty state message when comments array is empty', () => { + render() + + expect(screen.getByText(/Comments \(0\)/)).toBeInTheDocument() + expect(screen.getByText('No comments yet. Be the first!')).toBeInTheDocument() + }) + + it('renders "Comments (2)" with correct count when comments provided', () => { + render() + + expect(screen.getByText(/Comments \(2\)/)).toBeInTheDocument() + }) + + it('renders each comment\'s name when comments provided', () => { + render() + + expect(screen.getByText('John Doe')).toBeInTheDocument() + expect(screen.getByText('Jane Smith')).toBeInTheDocument() + }) + + it('renders each comment\'s content when comments provided', () => { + render() + + expect(screen.getByText('Great post!')).toBeInTheDocument() + expect(screen.getByText('Very helpful')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/layout/Footer.test.jsx b/client/src/components/layout/Footer.test.jsx new file mode 100644 index 00000000..12708f1d --- /dev/null +++ b/client/src/components/layout/Footer.test.jsx @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import Footer from './Footer' + +vi.mock('../../assets/assets', () => ({ + assets: { logo: 'logo.svg' }, + footer_data: [ + { title: 'Quick Links', links: ['Home', 'Best Sellers', 'Contact Us'] }, + { title: 'Need Help?', links: ['Delivery Information', 'Payment Methods'] }, + { title: 'Follow Us', links: ['Instagram', 'Twitter'] }, + ], +})) + +describe('Footer', () => { + it('renders logo image', () => { + render(