From d379addc42fe70b05e129f292c318c2fdf45fa06 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 28 Nov 2025 23:58:06 +0900 Subject: [PATCH 01/19] chore: add CLAUDE.md config --- CLAUDE.md | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9860072 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# Claude Code Guidelines + +## Development Commands + +```bash +# Development +bun run dev # Start development server + +# Build & Lint +bun run build # Production build +bun run lint # Run ESLint +``` + +## Project Structure + +- `/app` - Next.js App Router pages and components +- `/server` - Server-side services and plugins +- `/src` - Shared utilities, hooks, and localization +- `/public` - Static assets + +## Styling Guidelines + +### Tailwind Color System + +This project uses a custom color palette defined in `tailwind.config.js`. **Always use Tailwind classes instead of hardcoded colors**. + +#### Text Colors +```tsx +// Main text - use for primary content +className="text-black dark:text-white" + +// Placeholder/secondary text +className="text-placeholder-light dark:text-placeholder-dark" + +// Link text +className="text-link-light dark:text-link-dark" +``` + +#### Background Colors +```tsx +// Paper/card backgrounds +className="bg-paper-light dark:bg-paper-dark" + +// Modal backgrounds +className="bg-modal-light dark:bg-modal-dark" + +// Gray backgrounds (1=lightest, 9=darkest) +className="bg-gray1 dark:bg-gray8" +className="hover:bg-gray2 dark:hover:bg-gray7" +``` + +#### Border Colors +```tsx +className="border-border-light dark:border-border-dark" +``` + +#### Brand/Accent Colors +```tsx +// Primary brand color (blue) +className="bg-brand" // #4190EB + +// Primary buttons +className="bg-btn-primary-light dark:bg-btn-primary-dark" +className="text-btn-primary-text-light dark:text-btn-primary-text-dark" + +// Status colors +className="text-success-light dark:text-success-dark" +className="text-danger-light dark:text-danger-dark" +className="text-warning-light dark:text-warning-dark" +``` + +### Typography +Use predefined font sizes: +- `text-h1` through `text-h4` for headings +- `text-body1` through `text-body4` for body text + +### Common Patterns + +#### Dark Mode Support +Always provide both light and dark variants: +```tsx +// Correct +className="bg-paper-light dark:bg-paper-dark text-black dark:text-white" + +// Incorrect - hardcoded colors +style={{ backgroundColor: '#1a1a1a', color: '#ffffff' }} +``` + +#### Hover States +```tsx +className="hover:bg-gray1 dark:hover:bg-gray8" +``` + +## Component Guidelines + +- Use `clsx` for conditional class names +- Prefer Tailwind classes over inline styles +- Support both light and dark modes for all UI components From 48349c7b977c5b203aee00e64e5cbe3ca62fcfee Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 28 Nov 2025 23:58:10 +0900 Subject: [PATCH 02/19] chore: add recharts dependency --- bun.lock | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 74 insertions(+) diff --git a/bun.lock b/bun.lock index 2f092c6..9651acc 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,7 @@ "react-hook-form": "^7.66.1", "react-hot-toast": "^2.6.0", "react-text-transition": "^3.1.0", + "recharts": "^3.5.1", "server-only": "^0.0.1", "tiny-invariant": "^1.3.3", "typescript": "5.9.3", @@ -555,6 +556,8 @@ "@react-spring/web": ["@react-spring/web@9.7.5", "", { "dependencies": { "@react-spring/animated": "~9.7.5", "@react-spring/core": "~9.7.5", "@react-spring/shared": "~9.7.5", "@react-spring/types": "~9.7.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-lmvqGwpe+CSttsWNZVr+Dg62adtKhauGwLyGE/RRyZ8AAMLgb9x3NDMA5RMElXo+IMyTkPp7nxTB8ZQlmhb6JQ=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.0", "", { "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" }, "optionalPeers": ["react", "react-redux"] }, "sha512-hBjYg0aaRL1O2Z0IqWhnTLytnjDIxekmRxm1snsHjHaKVmIF1HiImWqsq+PuEbn6zdMlkIj9WofK1vR8jjx+Xw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.47", "", {}, "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw=="], "@rollup/plugin-babel": ["@rollup/plugin-babel@5.3.1", "", { "dependencies": { "@babel/helper-module-imports": "^7.10.4", "@rollup/pluginutils": "^3.1.0" }, "peerDependencies": { "@babel/core": "^7.0.0", "@types/babel__core": "^7.1.9", "rollup": "^1.20.0||^2.0.0" }, "optionalPeers": ["@types/babel__core"] }, "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q=="], @@ -613,6 +616,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + "@stitches/core": ["@stitches/core@1.2.8", "", {}, "sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg=="], "@supabase/auth-helpers-react": ["@supabase/auth-helpers-react@0.15.0", "", { "dependencies": { "cookie": "^1.0.2" }, "peerDependencies": { "@supabase/supabase-js": "^2.76.1" } }, "sha512-ddyCelBWeOtBNJiJ1x947ZxVEbjydIvpo9hIQ/J/uf+ozLj2buOal+P273rfUIyUf5mIfOtWo9l2hnAqsP/x0w=="], @@ -699,6 +704,24 @@ "@types/conventional-commits-parser": ["@types/conventional-commits-parser@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], @@ -733,6 +756,8 @@ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/zen-observable": ["@types/zen-observable@0.8.3", "", {}, "sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw=="], @@ -1055,6 +1080,28 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "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" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], "dargs": ["dargs@8.1.0", "", {}, "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw=="], @@ -1073,6 +1120,8 @@ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], @@ -1165,6 +1214,8 @@ "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + "es-toolkit": ["es-toolkit@1.42.0", "", {}, "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -1205,6 +1256,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="], @@ -1345,6 +1398,8 @@ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], @@ -1359,6 +1414,8 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], @@ -1757,6 +1814,8 @@ "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-redux": ["react-redux@9.2.0", "", { "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" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "react-text-transition": ["react-text-transition@3.1.0", "", { "dependencies": { "@react-spring/web": "^9.7.2" }, "peerDependencies": { "react": ">=18.0.0" } }, "sha512-NtXEVAXvSh78+8JAnrVjpbftzD4kPowacv4GB2Nyq9C/8ko6fSm6M/XvKWQLCaZi68i9F28b++Sp8uVThlzLyg=="], @@ -1767,6 +1826,12 @@ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "recharts": ["recharts@3.5.1", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "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" } }, "sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA=="], + + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], @@ -1785,6 +1850,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], @@ -2017,6 +2084,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "usehooks-ts": ["usehooks-ts@3.1.1", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -2025,6 +2094,8 @@ "v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="], + "victory-vendor": ["victory-vendor@37.3.6", "", { "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" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + "vite": ["vite@7.2.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w=="], "vitest": ["vitest@4.0.14", "", { "dependencies": { "@vitest/expect": "4.0.14", "@vitest/mocker": "4.0.14", "@vitest/pretty-format": "4.0.14", "@vitest/runner": "4.0.14", "@vitest/snapshot": "4.0.14", "@vitest/spy": "4.0.14", "@vitest/utils": "4.0.14", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.14", "@vitest/browser-preview": "4.0.14", "@vitest/browser-webdriverio": "4.0.14", "@vitest/ui": "4.0.14", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw=="], @@ -2155,6 +2226,8 @@ "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], + "@reduxjs/toolkit/immer": ["immer@11.0.0", "", {}, "sha512-XtRG4SINt4dpqlnJvs70O2j6hH7H0X8fUzFsjMn1rwnETaxwp83HLNimXBjZ78MrKl3/d3/pkzDH0o0Lkxm37Q=="], + "@rollup/plugin-babel/rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="], "@rollup/plugin-node-resolve/rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="], diff --git a/package.json b/package.json index 24b4917..2a49a3b 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "react-hook-form": "^7.66.1", "react-hot-toast": "^2.6.0", "react-text-transition": "^3.1.0", + "recharts": "^3.5.1", "server-only": "^0.0.1", "tiny-invariant": "^1.3.3", "typescript": "5.9.3", From c8ed7b8a700398acf61078d970f7a709646fae32 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 28 Nov 2025 23:58:15 +0900 Subject: [PATCH 03/19] feat: add monthly contribution data support --- pages/api/github-stats-advanced.ts | 2 + pages/api/github-stats.ts | 4 + pages/api/github-trophies.ts | 2 + server/plugins/types/UserGraph.ts | 16 ++ server/services/githubService.ts | 314 ++++++++++++++++++++++------- 5 files changed, 266 insertions(+), 72 deletions(-) diff --git a/pages/api/github-stats-advanced.ts b/pages/api/github-stats-advanced.ts index 734e3f7..63d58d2 100644 --- a/pages/api/github-stats-advanced.ts +++ b/pages/api/github-stats-advanced.ts @@ -22,12 +22,14 @@ export default async function handler( switch (method) { case 'GET': + const startDate = req.query.startDate; assert(login, common.badRequest); try { const stats = await getDoobooStats({ login: login.toLocaleLowerCase(), lang: locale, + startDate, }); if (!stats) { diff --git a/pages/api/github-stats.ts b/pages/api/github-stats.ts index 59e887f..77566ad 100644 --- a/pages/api/github-stats.ts +++ b/pages/api/github-stats.ts @@ -36,12 +36,14 @@ export default async function handler( switch (method) { case 'GET': { const loginParam = req.query.login as string; + const startDate = req.query.startDate as string | undefined; assert(loginParam, common.badRequest); try { const stats = await getDoobooStats({ login: loginParam.toLocaleLowerCase(), lang: locale, + startDate, }); if (!stats) { @@ -66,12 +68,14 @@ export default async function handler( } case 'POST': { const loginBody = req.body.login as string; + const startDateBody = req.body.startDate as string | undefined; assert(loginBody, common.badRequest); try { const stats = await getDoobooStats({ login: loginBody.toLocaleLowerCase(), lang: locale, + startDate: startDateBody, }); if (!stats) { diff --git a/pages/api/github-trophies.ts b/pages/api/github-trophies.ts index ace87c0..dc5a7cc 100644 --- a/pages/api/github-trophies.ts +++ b/pages/api/github-trophies.ts @@ -24,12 +24,14 @@ export default async function handler( switch (method) { case 'GET': + const startDate = req.query.startDate; assert(login, common.badRequest); try { const stats = await getDoobooStats({ login: login.toLocaleLowerCase(), lang: locale, + startDate, }); if (!stats) { diff --git a/server/plugins/types/UserGraph.ts b/server/plugins/types/UserGraph.ts index 21f203f..061e93d 100644 --- a/server/plugins/types/UserGraph.ts +++ b/server/plugins/types/UserGraph.ts @@ -86,6 +86,22 @@ export interface ContributionsCollection { totalRepositoryContributions: number; totalPullRequestContributions: number; totalPullRequestReviewContributions: number; + contributionCalendar?: ContributionCalendar; +} + +export interface ContributionCalendar { + totalContributions: number; + weeks: ContributionWeek[]; +} + +export interface ContributionWeek { + firstDay: string; + contributionDays: ContributionDay[]; +} + +export interface ContributionDay { + contributionCount: number; + date: string; } export interface PullRequests { diff --git a/server/services/githubService.ts b/server/services/githubService.ts index ede5a79..f356866 100644 --- a/server/services/githubService.ts +++ b/server/services/githubService.ts @@ -48,10 +48,22 @@ export const getAccessToken = async ( export const getGithubUser = async ( login: string, + startDate?: string, // ISO date string (YYYY-MM) for the start of 1-year period ): Promise<{data: {user: UserGraph}}> => { // Note: Duration to 12 months fails intermittently for some users like `mcollina`. For that, we could try something like 6 months in the future. - const date = new Date(); - date.setMonth(date.getMonth() - 12); + // If startDate is provided (YYYY-MM format), use that as the start of 1-year period + // Otherwise, default to 12 months ago from today + // Always start from the 1st of the month (use UTC noon to avoid timezone issues) + let date: Date; + if (startDate) { + const [year, month] = startDate.split('-').map(Number); + date = new Date(Date.UTC(year, month - 1, 1, 12, 0, 0)); + } else { + const now = new Date(); + date = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 12, 1, 12, 0, 0), + ); + } const {data} = await axios({ method: 'post', @@ -164,9 +176,6 @@ export const getGithubUser = async ( stargazerCount id name - owner { - login - } stargazers { totalCount } @@ -202,9 +211,6 @@ export const getGithubUser = async ( stargazerCount id name - owner { - login - } stargazers { totalCount } @@ -240,9 +246,6 @@ export const getGithubUser = async ( stargazerCount id name - owner { - login - } stargazers { totalCount } @@ -269,6 +272,60 @@ export const getGithubUser = async ( return data; }; +// Fetch monthly contribution counts from GitHub API +const fetchGithubMonthlyContributions = async ( + login: string, + month: string, // YYYY-MM +): Promise<{commits: number; pullRequests: number; reviews: number}> => { + const [year, mon] = month.split('-').map(Number); + + // fromDate: 1st of month at midnight UTC + const fromDate = new Date(Date.UTC(year, mon - 1, 1, 0, 0, 0)); + + // toDate: last day of month at 23:59:59 UTC + const toDate = new Date(Date.UTC(year, mon, 0, 23, 59, 59)); + + const {data} = await axios({ + method: 'post', + url: 'https://api.github.com/graphql', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `token ${GH_TOKEN}`, + }, + data: { + query: /* GraphQL */ ` + query monthlyContributions( + $username: String! + $from: DateTime! + $to: DateTime! + ) { + user(login: $username) { + contributionsCollection(from: $from, to: $to) { + totalCommitContributions + totalPullRequestContributions + totalPullRequestReviewContributions + } + } + } + `, + variables: { + username: login, + from: fromDate.toISOString(), + to: toDate.toISOString(), + }, + }, + }); + + const contributions = data?.data?.user?.contributionsCollection; + + return { + commits: contributions?.totalCommitContributions || 0, + pullRequests: contributions?.totalPullRequestContributions || 0, + reviews: contributions?.totalPullRequestReviewContributions || 0, + }; +}; + export const getGithubLogin = async (login: string): Promise => { const {data} = await axios({ method: 'GET', @@ -305,6 +362,14 @@ type GithubStats = Omit< stat_element: any; }; +export type MonthlyContribution = { + month: string; // YYYY-MM format + commits: number; + pullRequests: number; + reviews: number; + total: number; +}; + export type DoobooStatsResponse = { plugin: Model['plugins']['Row']; pluginStats: PluginStats; @@ -322,16 +387,82 @@ export type DoobooStatsResponse = { score: number; }; +// Generate array of months from startDate for 12 months (always starting from the 1st) +// Excludes future months +const generateMonths = (startDate: string): string[] => { + const months: string[] = []; + const [year, month] = startDate.split('-').map(Number); + + // Get current year and month in UTC + const now = new Date(); + const currentYear = now.getUTCFullYear(); + const currentMonth = now.getUTCMonth() + 1; // 1-indexed + + for (let i = 0; i < 12; i++) { + // Use UTC to avoid timezone issues + const current = new Date(Date.UTC(year, month - 1 + i, 1)); + const y = current.getUTCFullYear(); + const m = current.getUTCMonth() + 1; // 1-indexed + + // Skip future months + if (y > currentYear || (y === currentYear && m > currentMonth)) { + continue; + } + + months.push(`${y}-${String(m).padStart(2, '0')}`); + } + + return months; +}; + +// Fetch monthly contribution data (commits, PRs, reviews) +const getMonthlyContributions = async ( + login: string, + startDate: string, +): Promise => { + const months = generateMonths(startDate); + const monthlyContributions: MonthlyContribution[] = []; + + // Fetch stats for each month in parallel (batch of 3 to avoid rate limiting) + const batchSize = 3; + for (let i = 0; i < months.length; i += batchSize) { + const batch = months.slice(i, i + batchSize); + const results = await Promise.all( + batch.map(async (month) => { + try { + const {commits, pullRequests, reviews} = + await fetchGithubMonthlyContributions(login, month); + + return { + month, + commits, + pullRequests, + reviews, + total: commits + pullRequests + reviews, + }; + } catch { + return {month, commits: 0, pullRequests: 0, reviews: 0, total: 0}; + } + }), + ); + monthlyContributions.push(...results); + } + + return monthlyContributions; +}; + const upsertGithubStats = async ({ login, plugin, user_plugin, lang = 'en', + startDate, }: { login: string; plugin: PluginRow; user_plugin: UserPluginRow | null; lang?: Locale; + startDate?: string; }): Promise => { try { const supabase = getSupabaseClient(); @@ -339,17 +470,20 @@ const upsertGithubStats = async ({ // NOTE: Unknown user or user without commits will gracefully fail here. const results: [{data: {user: UserGraph}}, AuthorCommits] = - await Promise.all([getGithubUser(login), getGithubCommits(login)]); + await Promise.all([ + getGithubUser(login, startDate), + getGithubCommits(login), + ]); const { data: {user: githubUser}, } = results[0]; - const githubStatus = getGithubStatus(githubUser, results[1]); - const languages = getTopLanguages(githubUser); - const trophies = getTrophies(githubUser); + const githubStatus = getGithubStatus(githubUser, results[1]); + const languages = getTopLanguages(githubUser); + const trophies = getTrophies(githubUser); - const stats: StatsInsert[] = [ + const stats: StatsInsert[] = [ { name: 'TREE', score: githubStatus.tree.score, @@ -388,19 +522,6 @@ const upsertGithubStats = async ({ }, ]; - if (user_plugin) { - const deleteStatsPromise = supabase - .from('stats') - .delete() - .match({user_plugin_login: user_plugin.login}); - - const deleteTrophiesPromise = supabase.from('trophies').delete().match({ - user_plugin_login: user_plugin.login, - }); - - await Promise.all([deleteStatsPromise, deleteTrophiesPromise]); - } - const sum = (githubStatus.tree?.score || 0) + (githubStatus.fire?.score || 0) + @@ -411,54 +532,71 @@ const upsertGithubStats = async ({ const score = Math.round((sum / 6) * 100); - const userPluginPayload: UserPluginInsert = { - login, - user_name: githubUser.name, - avatar_url: githubUser.avatarUrl, - description: githubUser.bio, - plugin_id: plugin.id, - score, - github_id: githubUser.id, - json: { - login: githubUser.login, - avatarUrl: githubUser.avatarUrl, - bio: githubUser.bio, + // Only save to database if not using custom startDate + // Custom date queries should not overwrite cached default data + if (!startDate) { + if (user_plugin) { + const deleteStatsPromise = supabase + .from('stats') + .delete() + .match({user_plugin_login: user_plugin.login}); + + const deleteTrophiesPromise = supabase.from('trophies').delete().match({ + user_plugin_login: user_plugin.login, + }); + + await Promise.all([deleteStatsPromise, deleteTrophiesPromise]); + } + + const userPluginPayload: UserPluginInsert = { + login, + user_name: githubUser.name, + avatar_url: githubUser.avatarUrl, + description: githubUser.bio, + plugin_id: plugin.id, score, - languages, - }, - }; + github_id: githubUser.id, + json: { + login: githubUser.login, + avatarUrl: githubUser.avatarUrl, + bio: githubUser.bio, + score, + languages, + }, + }; - await supabase.from('user_plugins').upsert(userPluginPayload); + await supabase.from('user_plugins').upsert(userPluginPayload); - await Promise.all( - trophies.map(async (el) => { - const trophyScore = el.score as number; + await Promise.all( + trophies.map(async (el) => { + const trophyScore = el.score as number; - const trophyPayload: TrophiesInsert = { - ...el, - score: trophyScore, - user_plugin_login: login, - }; + const trophyPayload: TrophiesInsert = { + ...el, + score: trophyScore, + user_plugin_login: login, + }; - await supabase.from('trophies').upsert(trophyPayload); - }) - ); + await supabase.from('trophies').upsert(trophyPayload); + }), + ); - await Promise.all( - stats.map(async (el) => { - const statScore = el.score as number; - const statElement = el.stat_element as Json; + await Promise.all( + stats.map(async (el) => { + const statScore = el.score as number; + const statElement = el.stat_element as Json; - const statPayload: StatsInsert = { - ...el, - score: statScore, - stat_element: statElement, - user_plugin_login: login, - }; + const statPayload: StatsInsert = { + ...el, + score: statScore, + stat_element: statElement, + user_plugin_login: login, + }; - await supabase.from('stats').upsert(statPayload); - }) - ); + await supabase.from('stats').upsert(statPayload); + }), + ); + } return { plugin, @@ -493,9 +631,11 @@ const upsertGithubStats = async ({ export const getDoobooStats = async ({ login, lang = 'en', + startDate, }: { login: string; lang?: Locale; + startDate?: string; // YYYY-MM format }): Promise => { login = login.toLowerCase(); @@ -510,7 +650,9 @@ export const getDoobooStats = async ({ try { const PLUGIN_ID = 'dooboo-github'; - const {data: plugin}: { + const { + data: plugin, + }: { data: Model['plugins']['Row'] | null; } = await supabase.from('plugins').select().eq('id', PLUGIN_ID).single(); @@ -535,7 +677,8 @@ export const getDoobooStats = async ({ }); // NOTE: Return the data when user was fetched. - if (userPlugin && stats?.length === 6) { + // Skip cache if custom startDate is provided + if (userPlugin && stats?.length === 6 && !startDate) { const ghStats: GithubStats[] = stats?.map((el) => { return { @@ -594,7 +737,9 @@ export const getDoobooStats = async ({ }, }; - const updatedAt = userPlugin?.updated_at ? new Date(userPlugin.updated_at) : null; + const updatedAt = userPlugin?.updated_at + ? new Date(userPlugin.updated_at) + : null; const today = new Date(); // When user was queried after 3 hours, update the data in background. @@ -614,6 +759,7 @@ export const getDoobooStats = async ({ user_plugin: userPlugin, login, lang, + startDate, }); isCachedResult = true; } @@ -649,6 +795,7 @@ export const getDoobooStats = async ({ user_plugin: userPlugin, login, lang, + startDate, }); } catch (e: any) { console.error('Error in getDoobooStats:', { @@ -660,3 +807,26 @@ export const getDoobooStats = async ({ return null; } }; + +// API for fetching monthly contribution data (commits, PRs, reviews) +export const getMonthlyContribution = async ({ + login, + startDate, +}: { + login: string; + startDate: string; // YYYY-MM format (required) +}): Promise => { + login = login.toLowerCase(); + + try { + return await getMonthlyContributions(login, startDate); + } catch (e: any) { + console.error('Error in getMonthlyContribution:', { + login, + startDate, + error: e.message || e, + }); + + return []; + } +}; From 633c2b12075f57b6d1b873408fab544b6d03aea4 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 28 Nov 2025 23:58:19 +0900 Subject: [PATCH 04/19] feat: add MonthPicker and stats chart components --- app/[lang]/stats/[login]/MonthPicker.tsx | 265 ++++++++++++++++++ .../Scouter/StatsDetails/StatsChart.tsx | 212 ++++++++++++++ .../Scouter/StatsDetails/StatsRadar.tsx | 60 ++++ 3 files changed, 537 insertions(+) create mode 100644 app/[lang]/stats/[login]/MonthPicker.tsx create mode 100644 app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx create mode 100644 app/[lang]/stats/[login]/Scouter/StatsDetails/StatsRadar.tsx diff --git a/app/[lang]/stats/[login]/MonthPicker.tsx b/app/[lang]/stats/[login]/MonthPicker.tsx new file mode 100644 index 0000000..0a4c701 --- /dev/null +++ b/app/[lang]/stats/[login]/MonthPicker.tsx @@ -0,0 +1,265 @@ +'use client'; + +import type {ReactElement} from 'react'; +import {useState, useRef, useEffect} from 'react'; +import clsx from 'clsx'; +import { + CalendarIcon, + ChevronLeftIcon, + ChevronRightIcon, + XIcon, +} from '@primer/octicons-react'; + +import type {Translates} from '../../../../src/localization'; + +type Props = { + t: Translates['stats']; + value?: string; // YYYY-MM format + onChangeAction: (value: string | undefined) => void; + className?: string; + isLoading?: boolean; +}; + +const MONTHS = [ + 'Jan', 'Feb', 'Mar', 'Apr', + 'May', 'Jun', 'Jul', 'Aug', + 'Sep', 'Oct', 'Nov', 'Dec', +]; + +export default function MonthPicker({ + t, + value, + onChangeAction, + className, + isLoading, +}: Props): ReactElement { + const [isOpen, setIsOpen] = useState(false); + const [viewYear, setViewYear] = useState(() => { + if (value) { + return parseInt(value.split('-')[0]); + } + return new Date().getFullYear(); + }); + + const containerRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth(); + + const selectedYear = value ? parseInt(value.split('-')[0]) : null; + const selectedMonth = value ? parseInt(value.split('-')[1]) - 1 : null; + + const handleMonthClick = (monthIndex: number) => { + const newValue = `${viewYear}-${String(monthIndex + 1).padStart(2, '0')}`; + onChangeAction(newValue); + setIsOpen(false); + }; + + const handleClear = () => { + onChangeAction(undefined); + setIsOpen(false); + }; + + const isMonthDisabled = (monthIndex: number) => { + if (viewYear > currentYear) return true; + if (viewYear === currentYear && monthIndex > currentMonth) return true; + if (viewYear < currentYear - 10) return true; + return false; + }; + + const formatDisplay = () => { + if (!value) { + return t.selectPeriod; + } + const [year, month] = value.split('-'); + const monthNames = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + const monthName = monthNames[parseInt(month) - 1]; + // Show year only if it's not the current year + if (parseInt(year) === currentYear) { + return monthName; + } + return `${monthName} ${year}`; + }; + + return ( +
+ + {value && !isLoading && ( + + )} + {isLoading && ( +
+ )} + + {isOpen && ( +
+ {/* Year navigation */} +
+ + + {viewYear} + + +
+ + {/* Month grid - 4 columns x 3 rows */} +
+ {MONTHS.map((month, monthIndex) => { + const isSelected = + selectedYear === viewYear && selectedMonth === monthIndex; + const isDisabled = isMonthDisabled(monthIndex); + + return ( + + ); + })} +
+ + {/* Description and clear button */} +
+

+ {t.periodDescription} +

+ {value && ( + + )} +
+
+ )} +
+ ); +} diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx new file mode 100644 index 0000000..dd6b133 --- /dev/null +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx @@ -0,0 +1,212 @@ +'use client'; + +import type {ReactElement} from 'react'; +import {useState, useEffect, useCallback} from 'react'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, +} from 'recharts'; + +import type {MonthlyContribution} from '../../../../../../server/services/githubService'; + +type LineKey = 'commits' | 'pullRequests' | 'reviews'; + +type Props = { + monthlyContributions?: MonthlyContribution[]; + isLoading?: boolean; +}; + +export default function StatsChart({ + monthlyContributions, + isLoading, +}: Props): ReactElement | null { + // Use lazy initialization to avoid hydration mismatch + const [isClient, setIsClient] = useState(false); + const [hoveredLine, setHoveredLine] = useState(null); + const [activeLines, setActiveLines] = useState>( + new Set(['commits', 'pullRequests', 'reviews']), + ); + + useEffect(() => { + // Use requestAnimationFrame to defer state update and avoid React 19 warning + const rafId = requestAnimationFrame(() => { + setIsClient(true); + }); + return () => cancelAnimationFrame(rafId); + }, []); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleLegendMouseEnter = useCallback((data: any) => { + if (data?.dataKey) { + setHoveredLine(String(data.dataKey)); + } + }, []); + + const handleLegendMouseLeave = useCallback(() => { + setHoveredLine(null); + }, []); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleLegendClick = useCallback((data: any) => { + if (data?.dataKey) { + const key = data.dataKey as LineKey; + setActiveLines((prev) => { + // If clicking on the only active line, reset to show all + if (prev.size === 1 && prev.has(key)) { + return new Set(['commits', 'pullRequests', 'reviews']); + } + // Otherwise, show only the clicked line + return new Set([key]); + }); + } + }, []); + + const getLineOpacity = (dataKey: string) => { + // If line is not active, hide it + if (!activeLines.has(dataKey as LineKey)) { + return 0; + } + // Use hover effect when hovering + if (!hoveredLine) return 1; + return hoveredLine === dataKey ? 1 : 0.15; + }; + + const isLineVisible = (dataKey: LineKey) => { + return activeLines.has(dataKey); + }; + + // Format month for display (YYYY-MM -> MMM) + const formatMonth = (month: string): string => { + const [, monthNum] = month.split('-'); + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return months[parseInt(monthNum, 10) - 1]; + }; + + const chartData = monthlyContributions?.map((item) => ({ + month: formatMonth(item.month), + commits: item.commits, + pullRequests: item.pullRequests, + reviews: item.reviews, + })); + + // Don't render if no data or not on client + if (!isClient || !chartData || chartData.length === 0) { + return null; + } + + // Calculate max value for Y axis based on active lines only + const maxValue = Math.max( + ...chartData.flatMap((d) => { + const values: number[] = []; + if (activeLines.has('commits')) values.push(d.commits); + if (activeLines.has('pullRequests')) values.push(d.pullRequests); + if (activeLines.has('reviews')) values.push(d.reviews); + return values.length > 0 ? values : [0]; + }), + ); + const yAxisMax = Math.ceil(maxValue * 1.1) || 10; + + return ( +
+
+ + + + + + + {isLineVisible('commits') && ( + + )} + {isLineVisible('pullRequests') && ( + + )} + {isLineVisible('reviews') && ( + + )} + +
+
+ ); +} diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsRadar.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsRadar.tsx new file mode 100644 index 0000000..65e0174 --- /dev/null +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsRadar.tsx @@ -0,0 +1,60 @@ +'use client'; + +import type {ReactElement} from 'react'; +import {useState, useEffect} from 'react'; +import {RadarChart, PolarGrid, PolarAngleAxis, Radar} from 'recharts'; + +import type {PluginStats} from '../../../../../../server/plugins'; +import type {Translates} from '../../../../../../src/localization'; + +type Props = { + pluginStats: PluginStats; + t: Translates['stats']; +}; + +export default function StatsRadar({pluginStats, t}: Props): ReactElement | null { + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + const rafId = requestAnimationFrame(() => { + setIsClient(true); + }); + return () => cancelAnimationFrame(rafId); + }, []); + + if (!isClient) { + return null; + } + + const radarData = [ + {stat: t.tree, value: Math.round(pluginStats.tree.score * 100)}, + {stat: t.fire, value: Math.round(pluginStats.fire.score * 100)}, + {stat: t.earth, value: Math.round(pluginStats.earth.score * 100)}, + {stat: t.gold, value: Math.round(pluginStats.gold.score * 100)}, + {stat: t.water, value: Math.round(pluginStats.water.score * 100)}, + ]; + + return ( +
+ + + + + +
+ ); +} From c5cc11b9c77c0e67e51de55b0dba694e910ada19 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 28 Nov 2025 23:58:23 +0900 Subject: [PATCH 05/19] feat: integrate charts into stats details --- .../Scouter/StatsDetails/SectionDooboo.tsx | 53 +++++++++++++++++-- .../Scouter/StatsDetails/SectionPeople.tsx | 3 ++ .../[login]/Scouter/StatsDetails/index.tsx | 3 +- app/[lang]/stats/[login]/Scouter/index.tsx | 16 ++++-- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx index c19718f..cae2460 100644 --- a/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx @@ -1,10 +1,14 @@ +'use client'; + import type {ReactElement} from 'react'; +import {useCallback, useState, useEffect} from 'react'; +import {usePathname, useRouter} from 'next/navigation'; import clsx from 'clsx'; import {Inter} from 'next/font/google'; import Image from 'next/image'; import type {ScoreType} from '../../../../../../server/plugins/svgs/functions'; -import type {DoobooStatsResponse} from '../../../../../../server/services/githubService'; +import type {StatsWithMonthly} from '..'; import type {Translates} from '../../../../../../src/localization'; import {getTierSvg} from '../../../../../../src/utils/functions'; import {statNames} from '..'; @@ -12,11 +16,35 @@ import {statNames} from '..'; import type {TierType} from '.'; import Logo from '@/public/assets/logo.svg'; +import StatsChart from './StatsChart'; +import MonthPicker from '../../MonthPicker'; const inter = Inter({subsets: ['latin']}); -function SectionHeader({t, stats}: SectionProps): ReactElement { +function SectionHeader({t, stats, endDate}: SectionProps): ReactElement { + const pathname = usePathname(); + const router = useRouter(); const pluginStats = stats.pluginStats; + const [isLoading, setIsLoading] = useState(false); + + // Reset loading when endDate changes (data loaded) + useEffect(() => { + setIsLoading(false); + }, [endDate, stats]); + + const handleEndDateChange = useCallback( + (newDate: string | undefined) => { + if (!pathname) return; + setIsLoading(true); + if (newDate) { + router.push(`${pathname}?endDate=${newDate}`); + } else { + router.push(pathname); + } + router.refresh(); + }, + [pathname, router], + ); const sum = +pluginStats.earth.score + @@ -97,6 +125,17 @@ function SectionHeader({t, stats}: SectionProps): ReactElement { ); })}
+ {/* MonthPicker - positioned between scores and chart */} +
+ +
+ {/* Stats Chart - Monthly contributions */} + ); } @@ -143,13 +182,17 @@ function SectionBody({t, stats}: SectionProps): ReactElement { type SectionProps = { t: Translates['stats']; - stats: DoobooStatsResponse; + stats: StatsWithMonthly; + endDate?: string; }; -export default function SectionDooboo(props: SectionProps): ReactElement { +export default function SectionDooboo({ + endDate, + ...props +}: SectionProps): ReactElement { return (
- +
diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionPeople.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionPeople.tsx index 6a4b028..80c04cd 100644 --- a/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionPeople.tsx +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionPeople.tsx @@ -4,6 +4,7 @@ import {Inter} from 'next/font/google'; import type {DoobooStatsResponse} from '../../../../../../server/services/githubService'; import type {Translates} from '../../../../../../src/localization'; +import StatsRadar from './StatsRadar'; const inter = Inter({subsets: ['latin']}); @@ -58,6 +59,8 @@ function SectionHeader({t, stats}: SectionProps): ReactElement { ); })} + {/* Radar Chart - Pentagon showing 5 stats */} + ); } diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/index.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/index.tsx index e3c1b9c..b096f43 100644 --- a/app/[lang]/stats/[login]/Scouter/StatsDetails/index.tsx +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/index.tsx @@ -20,9 +20,10 @@ export default function StatsDetails({ t, stats, selectedStat, + endDate, }: ScouterProps & {selectedStat: StatName}): ReactElement { const map: Record = { - dooboo: , + dooboo: , tree: , fire: , earth: , diff --git a/app/[lang]/stats/[login]/Scouter/index.tsx b/app/[lang]/stats/[login]/Scouter/index.tsx index f7718c3..e0a3111 100644 --- a/app/[lang]/stats/[login]/Scouter/index.tsx +++ b/app/[lang]/stats/[login]/Scouter/index.tsx @@ -4,7 +4,10 @@ import type {ReactElement} from 'react'; import {useState} from 'react'; import clsx from 'clsx'; -import type {DoobooStatsResponse} from '../../../../../server/services/githubService'; +import type { + DoobooStatsResponse, + MonthlyContribution, +} from '../../../../../server/services/githubService'; import type {StatsInfo} from '../../../../../src/fetches/github'; import type {Translates} from '../../../../../src/localization'; import styles from '../../../styles.module.css'; @@ -21,14 +24,19 @@ export const statNames = [ 'people', ] as const; +export type StatsWithMonthly = DoobooStatsResponse & { + monthlyContributions?: MonthlyContribution[]; +}; + export type ScouterProps = { t: Translates['stats']; - stats: DoobooStatsResponse; + stats: StatsWithMonthly; + endDate?: string; }; export type StatName = keyof StatsInfo | 'dooboo'; -export default function Scouter(props: ScouterProps): ReactElement { +export default function Scouter({endDate, ...props}: ScouterProps): ReactElement { const [selectedStat, setSelectedStat] = useState('dooboo'); return ( @@ -45,7 +53,7 @@ export default function Scouter(props: ScouterProps): ReactElement { setSelectedStat(name); }} /> - + ); } From 11259aac90a7a0f64f0b457253887a3f711beca2 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 28 Nov 2025 23:58:27 +0900 Subject: [PATCH 06/19] feat: add loading indicator and date range to stats pages --- app/[lang]/stats/[login]/SearchTextInput.tsx | 62 +++++++++++++++----- app/[lang]/stats/[login]/page.tsx | 42 +++++++++++-- app/[lang]/stats/page.tsx | 2 +- 3 files changed, 83 insertions(+), 23 deletions(-) diff --git a/app/[lang]/stats/[login]/SearchTextInput.tsx b/app/[lang]/stats/[login]/SearchTextInput.tsx index ccd517e..3ac0312 100644 --- a/app/[lang]/stats/[login]/SearchTextInput.tsx +++ b/app/[lang]/stats/[login]/SearchTextInput.tsx @@ -1,13 +1,13 @@ 'use client'; import type {ReactElement} from 'react'; -import {useState, useRef} from 'react'; -import {useForm} from 'react-hook-form'; +import {useState, useRef, useEffect} from 'react'; import {SearchIcon} from '@primer/octicons-react'; import clsx from 'clsx'; +import {usePathname, useRouter} from 'next/navigation'; + import type {Translates} from '../../../../src/localization'; -import Button from '../../(common)/Button'; import TextInput from '../../(common)/TextInput'; import SearchHistoryDropdown from '../../(home)/Hero/SearchHistoryDropdown'; import {useSearchHistory} from '../../../../src/hooks/useSearchHistory'; @@ -23,18 +23,31 @@ export default function SearchTextInput({ }): ReactElement { const [login, setLogin] = useState(initialValue); const [showHistory, setShowHistory] = useState(false); - const {formState} = useForm(); + const [isLoading, setIsLoading] = useState(false); const {history, addToHistory, removeFromHistory} = useSearchHistory(); const searchContainerRef = useRef(null); + const pathname = usePathname(); + const router = useRouter(); + + // Extract language from pathname (e.g., /ko/stats/hyochan -> ko) + const lang = pathname?.split('/')[1] || 'en'; + + // Reset loading state when navigation completes (props change) + useEffect(() => { + setIsLoading(false); + }, [initialValue]); + + const navigateTo = (loginValue: string) => { + setIsLoading(true); + router.push(`/${lang}/stats/${loginValue}`); + router.refresh(); + }; const handleHistorySelect = (item: string) => { setLogin(item); setShowHistory(false); addToHistory(item); - // Trigger navigation - setTimeout(() => { - window.location.href = `/stats/${item}`; - }, 100); + navigateTo(item); }; const handleSubmit = (e: React.FormEvent) => { @@ -42,7 +55,7 @@ export default function SearchTextInput({ if (login) { addToHistory(login); setShowHistory(false); - window.location.href = `/stats/${login}`; + navigateTo(login); } }; @@ -59,7 +72,7 @@ export default function SearchTextInput({ 'bg-black/10 dark:bg-white/5', 'backdrop-blur-md', 'border border-black/20 dark:border-white/10', - 'flex flex-row-reverse items-center', + 'flex items-center gap-2', 'hover:bg-black/15 dark:hover:bg-white/8', 'transition-all duration-300', )} @@ -76,15 +89,32 @@ export default function SearchTextInput({ setTimeout(() => setShowHistory(false), 200); }} /> - ; + searchParams: Promise<{endDate?: string}>; }; export default async function Page(props: Props): Promise { const params = await props.params; + const searchParams = await props.searchParams; const lang = params.lang as Locale; const login = params.login; + const endDate = searchParams.endDate; const {stats: tStats} = await getTranslates(lang); - const stats = await getDoobooStats({ - login, - lang, - }); + // Calculate start date (12 months before end date) + const getStartDate = (end?: string) => { + const endDateObj = end + ? new Date(`${end}-01T00:00:00Z`) + : new Date(); + const year = endDateObj.getUTCFullYear(); + const month = endDateObj.getUTCMonth() - 11; // 12 months before + const date = new Date(Date.UTC(year, month, 1)); + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`; + }; + + const startDate = getStartDate(endDate); + + // Fetch stats and monthly contributions in parallel + const [stats, monthlyContributions] = await Promise.all([ + getDoobooStats({login, lang, startDate: endDate}), + getMonthlyContribution({login, startDate}), + ]); + + // Merge monthly contributions into stats + const statsWithMonthly = stats + ? {...stats, monthlyContributions} + : null; return ( { /> } > - {!!stats ? : null} + {!!statsWithMonthly ? ( + + ) : null} ); } diff --git a/app/[lang]/stats/page.tsx b/app/[lang]/stats/page.tsx index b58b609..ed804dc 100644 --- a/app/[lang]/stats/page.tsx +++ b/app/[lang]/stats/page.tsx @@ -19,7 +19,7 @@ export default async function Page(props: Props): Promise { return (
-

{t.searchUserHint}.

+

{t.searchUserHint}

Date: Fri, 28 Nov 2025 23:58:32 +0900 Subject: [PATCH 07/19] fix: update footer dark mode colors --- app/[lang]/(home)/SectionFooter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/[lang]/(home)/SectionFooter.tsx b/app/[lang]/(home)/SectionFooter.tsx index 5d1294d..9085e4e 100644 --- a/app/[lang]/(home)/SectionFooter.tsx +++ b/app/[lang]/(home)/SectionFooter.tsx @@ -82,7 +82,7 @@ export default function SectionFooter({t}: Props): ReactElement { />

designed by   - hyochan + hyochan

From e46a28d173236323a63f4ae875def76bf8ffe501 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 28 Nov 2025 23:58:35 +0900 Subject: [PATCH 08/19] feat: add tier-based user APIs --- pages/api/top-tier-users.ts | 62 ++++++++++++++++++++++++++ pages/api/users-by-tier.ts | 89 +++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 pages/api/top-tier-users.ts create mode 100644 pages/api/users-by-tier.ts diff --git a/pages/api/top-tier-users.ts b/pages/api/top-tier-users.ts new file mode 100644 index 0000000..b4b51a9 --- /dev/null +++ b/pages/api/top-tier-users.ts @@ -0,0 +1,62 @@ +export const revalidate = 3600; + +import type {NextApiRequest, NextApiResponse} from 'next'; +import {getSupabaseClient} from '@/server/supabaseClient'; +import type {PluginUser} from '~/utils/functions'; +import {getTierName} from '~/utils/functions'; +import type {UserPluginRow} from '~/types/types'; + +type Reply = {message: string} | {users: PluginUser[]}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +): Promise { + const {method} = req; + + if (method !== 'GET') { + res.status(405).send({message: 'Method not allowed'}); + return; + } + + const supabase = getSupabaseClient(); + + // Get plugin info for tier calculation + const {data: plugin} = await supabase + .from('plugins') + .select('*') + .eq('id', 'dooboo-github') + .single(); + + if (!plugin) { + res.status(404).send({message: 'Plugin not found'}); + return; + } + + // Fetch top 5 users by score (highest score = highest tier) + const {data: userPlugins}: {data: UserPluginRow[] | null} = await supabase + .from('user_plugins') + .select('*') + .match({plugin_id: 'dooboo-github'}) + .order('score', {ascending: false}) + .limit(5); + + const pluginTiers = (plugin.json || []) as {tier: string; score: number}[]; + + const users: PluginUser[] = (userPlugins || []) + .filter((user) => user.github_id !== null) + .map((user) => { + const tierName = getTierName(user.score || 0, pluginTiers); + + return { + login: user.login, + githubId: user.github_id, + score: user.score, + avatarUrl: user.avatar_url, + tierName, + createdAt: user.created_at || '', + }; + }); + + res.status(200).send({users}); +} diff --git a/pages/api/users-by-tier.ts b/pages/api/users-by-tier.ts new file mode 100644 index 0000000..2b30fb8 --- /dev/null +++ b/pages/api/users-by-tier.ts @@ -0,0 +1,89 @@ +export const revalidate = 3600; + +import type {NextApiRequest, NextApiResponse} from 'next'; +import {getSupabaseClient} from '@/server/supabaseClient'; +import type {PluginUser} from '~/utils/functions'; +import {getTierName} from '~/utils/functions'; +import type {UserPluginRow} from '~/types/types'; + +type Reply = {message: string} | {users: PluginUser[]}; + +type TierDef = {tier: string; score: number}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +): Promise { + const {method, query} = req; + + if (method !== 'GET') { + res.status(405).send({message: 'Method not allowed'}); + return; + } + + const tier = query.tier as string; + + if (!tier) { + res.status(400).send({message: 'Tier is required'}); + return; + } + + const supabase = getSupabaseClient(); + + // Get plugin info for tier calculation + const {data: plugin} = await supabase + .from('plugins') + .select('*') + .eq('id', 'dooboo-github') + .single(); + + if (!plugin) { + res.status(404).send({message: 'Plugin not found'}); + return; + } + + const pluginTiers = (plugin.json || []) as TierDef[]; + + // Sort tiers by score ascending + const sortedTiers = [...pluginTiers].sort((a, b) => a.score - b.score); + + // Find the tier index and calculate score range + const tierIndex = sortedTiers.findIndex((t) => t.tier === tier); + + if (tierIndex === -1) { + res.status(400).send({message: 'Invalid tier'}); + return; + } + + const minScore = sortedTiers[tierIndex].score; + const maxScore = tierIndex < sortedTiers.length - 1 + ? sortedTiers[tierIndex + 1].score - 1 + : 100; + + // Fetch users by score range + const {data: userPlugins}: {data: UserPluginRow[] | null} = await supabase + .from('user_plugins') + .select('*') + .match({plugin_id: 'dooboo-github'}) + .gte('score', minScore) + .lte('score', maxScore) + .order('score', {ascending: false}) + .limit(50); + + const users: PluginUser[] = (userPlugins || []) + .filter((user) => user.github_id !== null) + .map((user) => { + const tierName = getTierName(user.score || 0, pluginTiers); + + return { + login: user.login, + githubId: user.github_id, + score: user.score, + avatarUrl: user.avatar_url, + tierName, + createdAt: user.created_at || '', + }; + }); + + res.status(200).send({users}); +} From ce80a79d77a562c44e5b26ce3074a65358fbfd1b Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 28 Nov 2025 23:58:39 +0900 Subject: [PATCH 09/19] feat: add top tier users showcase component --- app/[lang]/recent-list/TopTierUsers.tsx | 108 ++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 app/[lang]/recent-list/TopTierUsers.tsx diff --git a/app/[lang]/recent-list/TopTierUsers.tsx b/app/[lang]/recent-list/TopTierUsers.tsx new file mode 100644 index 0000000..5dfd4e4 --- /dev/null +++ b/app/[lang]/recent-list/TopTierUsers.tsx @@ -0,0 +1,108 @@ +'use client'; + +import type {ReactElement} from 'react'; +import {useEffect, useState} from 'react'; +import clsx from 'clsx'; +import Image from 'next/image'; + +import type {UserListItem} from '../../../src/fetches/recentList'; +import {getTierSvg} from '../../../src/utils/functions'; +import styles from '../styles.module.css'; + +import type {Tier} from './TierRowItem'; + +type Props = { + title: string; +}; + +export default function TopTierUsers({title}: Props): ReactElement { + const [topTierUsers, setTopTierUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchTopUsers = async () => { + try { + const response = await fetch('/api/top-tier-users'); + const data = await response.json(); + if (data.users) { + setTopTierUsers(data.users); + } + } catch (error) { + console.error('Failed to fetch top tier users:', error); + } finally { + setIsLoading(false); + } + }; + + fetchTopUsers(); + }, []); + + if (isLoading) { + return ( +
+ {title} +
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+ ))} +
+
+ ); + } + + if (topTierUsers.length === 0) { + return
; + } + + return ( +
+ {title} +
+ {topTierUsers.map((user) => ( + + {user.login} + {user.tierName} + + {user.login} + + {user.score} + + ))} +
+
+ ); +} From a5b351e816f6cf6f8ba872f02aa940dd85e5a1a5 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 28 Nov 2025 23:58:43 +0900 Subject: [PATCH 10/19] feat: add tier filtering to leaderboard --- app/[lang]/recent-list/GithubUserList.tsx | 146 +++++++++++++++++----- app/[lang]/recent-list/page.tsx | 4 +- 2 files changed, 121 insertions(+), 29 deletions(-) diff --git a/app/[lang]/recent-list/GithubUserList.tsx b/app/[lang]/recent-list/GithubUserList.tsx index d60f37c..d2f37a9 100644 --- a/app/[lang]/recent-list/GithubUserList.tsx +++ b/app/[lang]/recent-list/GithubUserList.tsx @@ -1,13 +1,14 @@ 'use client'; import type {ReactElement, UIEventHandler} from 'react'; -import {useMemo, useRef, useState} from 'react'; +import {useCallback, useMemo, useRef, useState} from 'react'; import clsx from 'clsx'; import Image from 'next/image'; import type {UserListItem} from '../../../src/fetches/recentList'; import {fetchRecentList} from '../../../src/fetches/recentList'; import type {Translates} from '../../../src/localization'; +import {getTierSvg} from '../../../src/utils/functions'; import type {ColumnDef} from '../(common)/DataTable'; import {DataTable} from '../(common)/DataTable'; import styles from '../styles.module.css'; @@ -17,6 +18,18 @@ import TierRowItem from './TierRowItem'; import {H4, H5} from '~/components/Typography'; +// Tier order from highest to lowest +const TIER_ORDER: Tier[] = [ + 'Challenger', + 'Master', + 'Diamond', + 'Platinum', + 'Gold', + 'Silver', + 'Bronze', + 'Iron', +]; + type Props = { t: Translates['recentList']; initialData: UserListItem[]; @@ -25,12 +38,41 @@ type Props = { export default function GithubUserList({t, initialData}: Props): ReactElement { const tBodyRef = useRef(null); const [data, setData] = useState(initialData); + const [selectedTier, setSelectedTier] = useState(null); + const [tierData, setTierData] = useState([]); + const [isLoadingTier, setIsLoadingTier] = useState(false); const [cursor, setCursor] = useState( (initialData?.length || 0) > 0 ? new Date(initialData?.[initialData?.length - 1]?.createdAt) : null, ); + const handleTierSelect = useCallback(async (tier: Tier | null) => { + setSelectedTier(tier); + + if (!tier) { + setTierData([]); + return; + } + + setIsLoadingTier(true); + try { + const response = await fetch(`/api/users-by-tier?tier=${tier}`); + const result = await response.json(); + if (result.users) { + setTierData(result.users); + } + } catch (error) { + console.error('Failed to fetch tier users:', error); + setTierData([]); + } finally { + setIsLoadingTier(false); + } + }, []); + + // Use tier data when a tier is selected, otherwise use recent data + const displayData = selectedTier ? tierData : data; + const columnsDef: ColumnDef = useMemo( () => [ { @@ -107,33 +149,81 @@ export default function GithubUserList({t, initialData}: Props): ReactElement { }; return ( -
- { - const login = user.login; - window.open('http://github.com/' + login); - }} - className="p-6 max-[480px]:p-4" - classNames={{ - tHead: 'bg-paper backdrop-blur-xl border-b border-black/10 dark:border-white/10 px-2 pb-2 -mx-6 -mt-6 px-6 pt-6 rounded-t-[20px] max-[480px]:-mx-4 max-[480px]:-mt-4 max-[480px]:px-4 max-[480px]:pt-4 max-[480px]:rounded-t-[16px]', - tBodyRow: 'hover:bg-black/10 dark:hover:bg-white/5 transition-all duration-200 rounded-[8px] my-1', - }} - /> +
+ {/* Tier filter labels */} +
+ + {TIER_ORDER.map((tier) => ( + + ))} +
+ + {/* Data table */} +
+ { + const login = user.login; + window.open('http://github.com/' + login); + }} + className="p-6 max-[480px]:p-4" + classNames={{ + tHead: + 'bg-paper backdrop-blur-xl border-b border-black/10 dark:border-white/10 px-2 pb-2 -mx-6 -mt-6 px-6 pt-6 rounded-t-[20px] max-[480px]:-mx-4 max-[480px]:-mt-4 max-[480px]:px-4 max-[480px]:pt-4 max-[480px]:rounded-t-[16px]', + tBodyRow: + 'hover:bg-black/10 dark:hover:bg-white/5 transition-all duration-200 rounded-[8px] my-1', + }} + /> +
); } diff --git a/app/[lang]/recent-list/page.tsx b/app/[lang]/recent-list/page.tsx index 9e9526b..d4e8de2 100644 --- a/app/[lang]/recent-list/page.tsx +++ b/app/[lang]/recent-list/page.tsx @@ -8,6 +8,7 @@ import {getTranslates} from '../../../src/localization'; import {getUserPlugins} from '../../../src/utils/functions'; import GithubUserList from './GithubUserList'; +import TopTierUsers from './TopTierUsers'; import {H1} from '~/components/Typography'; import type {Locale} from '~/i18n'; @@ -51,8 +52,9 @@ export default async function Page(props: Props): Promise { > {recentList.title} + From fc7fe056bd7a4b163af4ce4db7fc971fc32c8cb3 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 28 Nov 2025 23:58:47 +0900 Subject: [PATCH 11/19] feat: rename Recent List to Leaderboard and add tier filter translations --- locales/en.json | 17 ++++++++++++----- locales/ko.json | 17 ++++++++++++----- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/locales/en.json b/locales/en.json index b61d068..345999e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -85,15 +85,18 @@ "privacyPolicy": "Privacy Policy" }, "recentList": { - "title": "Recent List", + "title": "Leaderboard", "githubUsername": "GitHub username", - "noRecentList": "No recent list", + "noRecentList": "No users found", "tier": "Tier", - "score": "Score" + "score": "Score", + "all": "All", + "topUsers": "Top Users", + "topRanked": "Top Ranked" }, "stats": { "title": "Stats", - "searchUserHint": "Search for a user in the top right corner to view the score attributes in detail", + "searchUserHint": "Check the developer's power level by searching for their GitHub username.", "githubUsername": "GitHub username", "achievement": "Achievement", "achievementDetails": "Based on Github data", @@ -105,7 +108,11 @@ "water": "Water", "people": "People", "github": "Github", - "score": "score" + "score": "score", + "selectPeriod": "Select period", + "periodDescription": "Stats for 1 year from selected month", + "resetToDefault": "Reset to default", + "search": "Search" }, "certifiedUsers": { "title": "Certified Users", diff --git a/locales/ko.json b/locales/ko.json index 4879834..47820e9 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -94,15 +94,18 @@ "privacyPolicy": "개인 정보 보호 정책" }, "recentList": { - "title": "최근 리스트", + "title": "리더보드", "githubUsername": "깃허브 유저명", - "noRecentList": "최근 리스트가 없습니다", + "noRecentList": "유저가 없습니다", "tier": "티어", - "score": "점수" + "score": "점수", + "all": "전체", + "topUsers": "탑 유저", + "topRanked": "최고 티어" }, "stats": { "title": "스탯", - "searchUserHint": "점수 속성을 자세히 보려면 ​​오른쪽 상단 모서리에서 사용자를 검색하십시오", + "searchUserHint": "개발자의 전투력을 GitHub username을 검색해서 확인하세요.", "githubUsername": "깃허브 유저명", "achievement": "성취", "achievementDetails": "깃허브 데이터에 따름", @@ -114,7 +117,11 @@ "water": "수", "people": "인", "github": "Github", - "score": "점수" + "score": "점수", + "selectPeriod": "기간 선택", + "periodDescription": "선택한 월부터 1년간 스탯", + "resetToDefault": "기본값으로 초기화", + "search": "검색" }, "certifiedUsers": { "title": "인증된 사용자", From 9a9fcc54c7256f0ec3b8711949dabaace1e0bc12 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 28 Nov 2025 23:58:51 +0900 Subject: [PATCH 12/19] style: update CSS styles --- styles/output.css | 279 +++++++++++++++++++++++++++++++++++++++++++++- styles/root.css | 9 ++ 2 files changed, 286 insertions(+), 2 deletions(-) diff --git a/styles/output.css b/styles/output.css index 346560c..73265a8 100644 --- a/styles/output.css +++ b/styles/output.css @@ -1035,6 +1035,10 @@ input[type='search']::-webkit-search-decoration, position: sticky; } +.inset-0 { + inset: 0px; +} + .left-0 { left: 0px; } @@ -1270,6 +1274,18 @@ input[type='search']::-webkit-search-decoration, margin-top: 80px; } +.-mt-2 { + margin-top: -0.5rem; +} + +.mb-1 { + margin-bottom: 0.25rem; +} + +.block { + display: block; +} + .inline-block { display: inline-block; } @@ -1286,6 +1302,10 @@ input[type='search']::-webkit-search-decoration, display: table; } +.grid { + display: grid; +} + .hidden { display: none; } @@ -1322,6 +1342,10 @@ input[type='search']::-webkit-search-decoration, height: 1.5rem; } +.h-8 { + height: 2rem; +} + .h-\[1px\] { height: 1px; } @@ -1334,6 +1358,10 @@ input[type='search']::-webkit-search-decoration, height: 37px; } +.h-\[40px\] { + height: 40px; +} + .h-\[50px\] { height: 50px; } @@ -1363,6 +1391,10 @@ input[type='search']::-webkit-search-decoration, height: max-content; } +.h-3 { + height: 0.75rem; +} + .max-h-\[240px\] { max-height: 240px; } @@ -1427,6 +1459,10 @@ input[type='search']::-webkit-search-decoration, width: 50%; } +.w-8 { + width: 2rem; +} + .w-\[180px\] { width: 180px; } @@ -1435,6 +1471,10 @@ input[type='search']::-webkit-search-decoration, width: 1px; } +.w-\[260px\] { + width: 260px; +} + .w-full { width: 100%; } @@ -1443,6 +1483,10 @@ input[type='search']::-webkit-search-decoration, width: 100vw; } +.w-\[80px\] { + width: 80px; +} + .min-w-\[32px\] { min-width: 32px; } @@ -1451,6 +1495,14 @@ input[type='search']::-webkit-search-decoration, min-width: 400px; } +.min-w-\[60px\] { + min-width: 60px; +} + +.min-w-\[80px\] { + min-width: 80px; +} + .max-w-3xl { max-width: 48rem; } @@ -1499,6 +1551,14 @@ input[type='search']::-webkit-search-decoration, max-width: 28rem; } +.max-w-\[70px\] { + max-width: 70px; +} + +.max-w-\[500px\] { + max-width: 500px; +} + .flex-1 { flex: 1 1 0%; } @@ -1531,6 +1591,16 @@ input[type='search']::-webkit-search-decoration, animation: spin 1s linear infinite; } +@keyframes pulse { + 50% { + opacity: .5; + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + .cursor-not-allowed { cursor: not-allowed; } @@ -1609,6 +1679,14 @@ input[type='search']::-webkit-search-decoration, gap: 8px; } +.gap-1\.5 { + gap: 0.375rem; +} + +.gap-3 { + gap: 0.75rem; +} + .self-center { align-self: center; } @@ -1724,6 +1802,14 @@ input[type='search']::-webkit-search-decoration, border-radius: 0.125rem; } +.rounded-xl { + border-radius: 0.75rem; +} + +.rounded { + border-radius: 0.25rem; +} + .rounded-t-\[20px\] { border-top-left-radius: 20px; border-top-right-radius: 20px; @@ -1749,6 +1835,10 @@ input[type='search']::-webkit-search-decoration, border-bottom-width: 1px; } +.border-t { + border-top-width: 1px; +} + .border-solid { border-style: solid; } @@ -1770,6 +1860,11 @@ input[type='search']::-webkit-search-decoration, border-color: rgb(194 200 208 / var(--tw-border-opacity, 1)); } +.border-gray3 { + --tw-border-opacity: 1; + border-color: rgb(190 190 190 / var(--tw-border-opacity, 1)); +} + .border-gray8 { --tw-border-opacity: 1; border-color: rgb(40 40 40 / var(--tw-border-opacity, 1)); @@ -1787,6 +1882,16 @@ input[type='search']::-webkit-search-decoration, border-color: rgb(255 255 255 / 0.6); } +.border-brand { + --tw-border-opacity: 1; + border-color: rgb(65 144 235 / var(--tw-border-opacity, 1)); +} + +.border-t-brand { + --tw-border-opacity: 1; + border-top-color: rgb(65 144 235 / var(--tw-border-opacity, 1)); +} + .border-t-transparent { border-top-color: transparent; } @@ -1819,6 +1924,16 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(190 190 190 / var(--tw-bg-opacity, 1)); } +.bg-gray1 { + --tw-bg-opacity: 1; + background-color: rgb(237 237 237 / var(--tw-bg-opacity, 1)); +} + +.bg-gray2 { + --tw-bg-opacity: 1; + background-color: rgb(204 204 204 / var(--tw-bg-opacity, 1)); +} + .bg-gray3 { --tw-bg-opacity: 1; background-color: rgb(190 190 190 / var(--tw-bg-opacity, 1)); @@ -1834,6 +1949,15 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(15 20 25 / var(--tw-bg-opacity, 1)); } +.bg-paper-light { + --tw-bg-opacity: 1; + background-color: rgb(240 242 245 / var(--tw-bg-opacity, 1)); +} + +.bg-paper-light\/50 { + background-color: rgb(240 242 245 / 0.5); +} + .bg-transparent { background-color: transparent; } @@ -1867,10 +1991,22 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(255 255 255 / 0.95); } +.bg-gradient-to-br { + background-image: linear-gradient(to bottom right, var(--tw-gradient-stops)); +} + .bg-cover { background-size: cover; } +.fill-disabled-light { + fill: #BEBEBE; +} + +.stroke-disabled-light { + stroke: #BEBEBE; +} + .p-0 { padding: 0px; } @@ -1879,6 +2015,10 @@ input[type='search']::-webkit-search-decoration, padding: 0.25rem; } +.p-1\.5 { + padding: 0.375rem; +} + .p-12 { padding: 3rem; } @@ -2048,8 +2188,8 @@ input[type='search']::-webkit-search-decoration, padding-right: 2rem; } -.pt-2 { - padding-top: 0.5rem; +.pt-3 { + padding-top: 0.75rem; } .pt-4 { @@ -2140,6 +2280,10 @@ input[type='search']::-webkit-search-decoration, font-size: 18px; } +.text-\[9px\] { + font-size: 9px; +} + .font-bold { font-weight: 700; } @@ -2226,6 +2370,11 @@ input[type='search']::-webkit-search-decoration, color: rgb(40 40 40 / var(--tw-text-opacity, 1)); } +.text-placeholder-light { + --tw-text-opacity: 1; + color: rgb(120 120 120 / var(--tw-text-opacity, 1)); +} + .text-success-light { --tw-text-opacity: 1; color: rgb(15 199 12 / var(--tw-text-opacity, 1)); @@ -2261,6 +2410,10 @@ input[type='search']::-webkit-search-decoration, opacity: 1; } +.opacity-25 { + opacity: 0.25; +} + .opacity-30 { opacity: .3; } @@ -2269,6 +2422,10 @@ input[type='search']::-webkit-search-decoration, opacity: 0.5; } +.opacity-75 { + opacity: 0.75; +} + .shadow-\[0_20px_60px_-15px_rgba\(0\2c 0\2c 0\2c 0\.2\)\] { --tw-shadow: 0 20px 60px -15px rgba(0,0,0,0.2); --tw-shadow-colored: 0 20px 60px -15px var(--tw-shadow-color); @@ -2316,6 +2473,17 @@ input[type='search']::-webkit-search-decoration, outline-offset: 2px; } +.ring-2 { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.blur { + --tw-blur: blur(8px); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + .filter { filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); } @@ -2364,6 +2532,12 @@ input[type='search']::-webkit-search-decoration, transition-duration: 150ms; } +.transition-transform { + transition-property: transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + .duration-150 { transition-duration: 150ms; } @@ -2376,6 +2550,16 @@ input[type='search']::-webkit-search-decoration, transition-duration: 300ms; } +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + @font-face { font-family: 'doobooui'; @@ -2475,6 +2659,21 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(65 144 235 / 0.9); } +.hover\:bg-gray1:hover { + --tw-bg-opacity: 1; + background-color: rgb(237 237 237 / var(--tw-bg-opacity, 1)); +} + +.hover\:bg-gray2:hover { + --tw-bg-opacity: 1; + background-color: rgb(204 204 204 / var(--tw-bg-opacity, 1)); +} + +.hover\:bg-gray3:hover { + --tw-bg-opacity: 1; + background-color: rgb(190 190 190 / var(--tw-bg-opacity, 1)); +} + .hover\:bg-white\/60:hover { background-color: rgb(255 255 255 / 0.6); } @@ -2533,6 +2732,18 @@ input[type='search']::-webkit-search-decoration, opacity: 0.5; } +.disabled\:cursor-not-allowed:disabled { + cursor: not-allowed; +} + +.disabled\:opacity-30:disabled { + opacity: .3; +} + +.disabled\:opacity-50:disabled { + opacity: 0.5; +} + .group:hover .group-hover\:text-black { --tw-text-opacity: 1; color: rgb(0 0 0 / var(--tw-text-opacity, 1)); @@ -2569,6 +2780,11 @@ input[type='search']::-webkit-search-decoration, border-color: rgb(190 190 190 / var(--tw-border-opacity, 1)); } +.dark\:border-gray6:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(77 77 77 / var(--tw-border-opacity, 1)); +} + .dark\:border-white\/10:is(.dark *) { border-color: rgb(255 255 255 / 0.1); } @@ -2612,6 +2828,25 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(71 71 71 / var(--tw-bg-opacity, 1)); } +.dark\:bg-gray7:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(71 71 71 / var(--tw-bg-opacity, 1)); +} + +.dark\:bg-gray8:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(40 40 40 / var(--tw-bg-opacity, 1)); +} + +.dark\:bg-paper-dark:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(15 20 25 / var(--tw-bg-opacity, 1)); +} + +.dark\:bg-paper-dark\/50:is(.dark *) { + background-color: rgb(15 20 25 / 0.5); +} + .dark\:bg-white\/10:is(.dark *) { background-color: rgb(255 255 255 / 0.1); } @@ -2620,6 +2855,14 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(255 255 255 / 0.05); } +.dark\:fill-disabled-dark:is(.dark *) { + fill: #474747; +} + +.dark\:stroke-disabled-dark:is(.dark *) { + stroke: #474747; +} + .dark\:text-black:is(.dark *) { --tw-text-opacity: 1; color: rgb(0 0 0 / var(--tw-text-opacity, 1)); @@ -2655,6 +2898,11 @@ input[type='search']::-webkit-search-decoration, color: rgb(240 242 245 / var(--tw-text-opacity, 1)); } +.dark\:text-placeholder-dark:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(144 144 144 / var(--tw-text-opacity, 1)); +} + .dark\:text-success-dark:is(.dark *) { --tw-text-opacity: 1; color: rgb(47 250 134 / var(--tw-text-opacity, 1)); @@ -2688,6 +2936,21 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(0 0 0 / 0.8); } +.dark\:hover\:bg-gray6:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(77 77 77 / var(--tw-bg-opacity, 1)); +} + +.dark\:hover\:bg-gray7:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(71 71 71 / var(--tw-bg-opacity, 1)); +} + +.dark\:hover\:bg-gray8:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(40 40 40 / var(--tw-bg-opacity, 1)); +} + .dark\:hover\:bg-white\/10:hover:is(.dark *) { background-color: rgb(255 255 255 / 0.1); } @@ -2726,6 +2989,10 @@ input[type='search']::-webkit-search-decoration, } @media (max-width: 768px) { + .max-\[768px\]\:hidden { + display: none; + } + .max-\[768px\]\:h-12 { height: 3rem; } @@ -2742,10 +3009,18 @@ input[type='search']::-webkit-search-decoration, width: 1.25rem; } + .max-\[768px\]\:w-full { + width: 100%; + } + .max-\[768px\]\:min-w-\[300px\] { min-width: 300px; } + .max-\[768px\]\:flex-col { + flex-direction: column; + } + .max-\[768px\]\:p-2 { padding: 0.5rem; } diff --git a/styles/root.css b/styles/root.css index 22975c6..57d039d 100644 --- a/styles/root.css +++ b/styles/root.css @@ -2,6 +2,15 @@ @tailwind components; @tailwind utilities; +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + @font-face { font-family: 'doobooui'; src: url('/fonts/doobooui.ttf?ixpxfq') format('truetype'); From 9bb43496a946407571a8d0cdf8610adff75501bf Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 29 Nov 2025 00:51:36 +0900 Subject: [PATCH 13/19] chore: enhance mobile layout --- app/[lang]/(common)/Header/index.tsx | 2 + app/[lang]/layout.tsx | 3 +- app/[lang]/recent-list/GithubUserList.tsx | 37 ++- app/[lang]/recent-list/TopTierUsers.tsx | 10 +- app/[lang]/recent-list/page.tsx | 34 ++- app/[lang]/stats/Container.tsx | 3 +- .../Scouter/StatsDetails/SectionDooboo.tsx | 32 +-- .../Scouter/StatsDetails/StatsChart.tsx | 146 ++++++------ .../[login]/Scouter/StatsDetails/index.tsx | 7 +- .../stats/[login]/Scouter/StatsHeader.tsx | 3 +- app/[lang]/stats/[login]/Scouter/index.tsx | 2 +- app/[lang]/styles.module.css | 30 +++ styles/output.css | 210 ++++++++---------- 13 files changed, 291 insertions(+), 228 deletions(-) diff --git a/app/[lang]/(common)/Header/index.tsx b/app/[lang]/(common)/Header/index.tsx index 2553b97..4fbfd1d 100644 --- a/app/[lang]/(common)/Header/index.tsx +++ b/app/[lang]/(common)/Header/index.tsx @@ -308,6 +308,7 @@ export default function Header(props: Props): ReactElement { 'h-[56px] decoration-0 bg-basic sticky', 'flex flex-row items-center justify-between', 'px-[28px]', + 'w-full min-w-0', )} >
{ className={clsx( 'text-center flex-1 self-stretch relative', 'flex flex-col-reverse', + 'min-w-0 overflow-x-hidden', )} > -
+
{children}
{/* Tier filter labels */} -
+
))}
diff --git a/app/[lang]/recent-list/TopTierUsers.tsx b/app/[lang]/recent-list/TopTierUsers.tsx index 5dfd4e4..d86cbef 100644 --- a/app/[lang]/recent-list/TopTierUsers.tsx +++ b/app/[lang]/recent-list/TopTierUsers.tsx @@ -66,9 +66,15 @@ export default function TopTierUsers({title}: Props): ReactElement { } return ( -
+
{title} -
+
{topTierUsers.map((user) => ( {

{ > {recentList.title}

- - +
+
+
+ +
+
+ +
+
+
+

{t.achievement}

@@ -76,7 +76,7 @@ function SectionHeader({t, stats, endDate}: SectionProps): ReactElement { {t.achievementDetails}

{/* Badges */} -
+
{/* Tier badge */}
{/* AVG score badge */} -
+
{t.avgScore}{' '}

{score}

@@ -106,11 +101,17 @@ function SectionHeader({t, stats, endDate}: SectionProps): ReactElement {
{/* Scores */} -
+
{statNames.map((name) => { return ( -
- +
+ {pluginStats[name].name}
{/* MonthPicker - positioned between scores and chart */} -
+
+

diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx index dd6b133..456feff 100644 --- a/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx @@ -126,86 +126,88 @@ export default function StatsChart({ return (
-
- - - - - - - {isLineVisible('commits') && ( - +
+ + + - )} - {isLineVisible('pullRequests') && ( - - )} - {isLineVisible('reviews') && ( - - )} - + + {isLineVisible('commits') && ( + + )} + {isLineVisible('pullRequests') && ( + + )} + {isLineVisible('reviews') && ( + + )} + +
); diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/index.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/index.tsx index b096f43..da1224e 100644 --- a/app/[lang]/stats/[login]/Scouter/StatsDetails/index.tsx +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/index.tsx @@ -33,7 +33,12 @@ export default function StatsDetails({ }; return ( -
+
{map[selectedStat]}
); diff --git a/app/[lang]/stats/[login]/Scouter/StatsHeader.tsx b/app/[lang]/stats/[login]/Scouter/StatsHeader.tsx index 6b0b32c..ead2c0d 100644 --- a/app/[lang]/stats/[login]/Scouter/StatsHeader.tsx +++ b/app/[lang]/stats/[login]/Scouter/StatsHeader.tsx @@ -110,7 +110,8 @@ export default function StatsHeader({ 'h-14 max-[768px]:h-12 max-[480px]:h-10', 'min-h-[56px] max-[768px]:min-h-[48px] max-[480px]:min-h-[40px]', 'flex-shrink-0', - 'self-stretch items-center mb-6 rounded-[16px]', + 'self-stretch w-full min-w-0', + 'items-center mb-6 rounded-[16px]', 'px-4 max-[768px]:px-2 max-[480px]:px-1', 'bg-basic', 'border border-black/10 dark:border-white/10', diff --git a/app/[lang]/stats/[login]/Scouter/index.tsx b/app/[lang]/stats/[login]/Scouter/index.tsx index e0a3111..82aaa08 100644 --- a/app/[lang]/stats/[login]/Scouter/index.tsx +++ b/app/[lang]/stats/[login]/Scouter/index.tsx @@ -43,7 +43,7 @@ export default function Scouter({endDate, ...props}: ScouterProps): ReactElement
diff --git a/app/[lang]/styles.module.css b/app/[lang]/styles.module.css index 0458117..e346ce5 100644 --- a/app/[lang]/styles.module.css +++ b/app/[lang]/styles.module.css @@ -3,6 +3,26 @@ overflow-y: auto; } +.horizontalScroll { + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} + +.horizontalScroll::-webkit-scrollbar { + height: 6px; +} + +.horizontalScroll::-webkit-scrollbar-track { + background: transparent; +} + +.horizontalScroll::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.15); + border-radius: 9999px; + box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.08); +} + .scrollable::-webkit-scrollbar { width: 6px; height: 12px; @@ -17,3 +37,13 @@ border-radius: 6px; background-color: #4d4d4d; } + +@media (max-width: 768px) { + .horizontalScroll { + scrollbar-width: none; + } + + .horizontalScroll::-webkit-scrollbar { + display: none; + } +} diff --git a/styles/output.css b/styles/output.css index 73265a8..c663791 100644 --- a/styles/output.css +++ b/styles/output.css @@ -1035,10 +1035,6 @@ input[type='search']::-webkit-search-decoration, position: sticky; } -.inset-0 { - inset: 0px; -} - .left-0 { left: 0px; } @@ -1134,10 +1130,18 @@ input[type='search']::-webkit-search-decoration, margin-bottom: 0.5rem; } +.-mt-2 { + margin-top: -0.5rem; +} + .-mt-6 { margin-top: -1.5rem; } +.mb-1 { + margin-bottom: 0.25rem; +} + .mb-10 { margin-bottom: 2.5rem; } @@ -1274,14 +1278,6 @@ input[type='search']::-webkit-search-decoration, margin-top: 80px; } -.-mt-2 { - margin-top: -0.5rem; -} - -.mb-1 { - margin-bottom: 0.25rem; -} - .block { display: block; } @@ -1330,6 +1326,10 @@ input[type='search']::-webkit-search-decoration, height: 5rem; } +.h-3 { + height: 0.75rem; +} + .h-4 { height: 1rem; } @@ -1342,10 +1342,6 @@ input[type='search']::-webkit-search-decoration, height: 1.5rem; } -.h-8 { - height: 2rem; -} - .h-\[1px\] { height: 1px; } @@ -1391,10 +1387,6 @@ input[type='search']::-webkit-search-decoration, height: max-content; } -.h-3 { - height: 0.75rem; -} - .max-h-\[240px\] { max-height: 240px; } @@ -1459,10 +1451,6 @@ input[type='search']::-webkit-search-decoration, width: 50%; } -.w-8 { - width: 2rem; -} - .w-\[180px\] { width: 180px; } @@ -1475,6 +1463,10 @@ input[type='search']::-webkit-search-decoration, width: 260px; } +.w-\[80px\] { + width: 80px; +} + .w-full { width: 100%; } @@ -1483,8 +1475,12 @@ input[type='search']::-webkit-search-decoration, width: 100vw; } -.w-\[80px\] { - width: 80px; +.min-w-0 { + min-width: 0px; +} + +.min-w-\[280px\] { + min-width: 280px; } .min-w-\[32px\] { @@ -1499,8 +1495,17 @@ input[type='search']::-webkit-search-decoration, min-width: 60px; } -.min-w-\[80px\] { - min-width: 80px; +.min-w-\[640px\] { + min-width: 640px; +} + +.min-w-\[70px\] { + min-width: 70px; +} + +.min-w-fit { + min-width: -moz-fit-content; + min-width: fit-content; } .max-w-3xl { @@ -1527,6 +1532,10 @@ input[type='search']::-webkit-search-decoration, max-width: 480px; } +.max-w-\[500px\] { + max-width: 500px; +} + .max-w-\[600px\] { max-width: 600px; } @@ -1535,6 +1544,10 @@ input[type='search']::-webkit-search-decoration, max-width: 700px; } +.max-w-\[70px\] { + max-width: 70px; +} + .max-w-\[728px\] { max-width: 728px; } @@ -1551,14 +1564,6 @@ input[type='search']::-webkit-search-decoration, max-width: 28rem; } -.max-w-\[70px\] { - max-width: 70px; -} - -.max-w-\[500px\] { - max-width: 500px; -} - .flex-1 { flex: 1 1 0%; } @@ -1581,16 +1586,6 @@ input[type='search']::-webkit-search-decoration, transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.animate-spin { - animation: spin 1s linear infinite; -} - @keyframes pulse { 50% { opacity: .5; @@ -1601,6 +1596,16 @@ input[type='search']::-webkit-search-decoration, animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + .cursor-not-allowed { cursor: not-allowed; } @@ -1639,6 +1644,10 @@ input[type='search']::-webkit-search-decoration, flex-wrap: wrap; } +.flex-nowrap { + flex-wrap: nowrap; +} + .items-start { align-items: flex-start; } @@ -1663,6 +1672,10 @@ input[type='search']::-webkit-search-decoration, gap: 0.25rem; } +.gap-1\.5 { + gap: 0.375rem; +} + .gap-2 { gap: 0.5rem; } @@ -1679,12 +1692,13 @@ input[type='search']::-webkit-search-decoration, gap: 8px; } -.gap-1\.5 { - gap: 0.375rem; +.gap-x-4 { + -moz-column-gap: 1rem; + column-gap: 1rem; } -.gap-3 { - gap: 0.75rem; +.gap-y-2 { + row-gap: 0.5rem; } .self-center { @@ -1746,6 +1760,10 @@ input[type='search']::-webkit-search-decoration, overflow-wrap: break-word; } +.rounded { + border-radius: 0.25rem; +} + .rounded-2xl { border-radius: 1rem; } @@ -1806,10 +1824,6 @@ input[type='search']::-webkit-search-decoration, border-radius: 0.75rem; } -.rounded { - border-radius: 0.25rem; -} - .rounded-t-\[20px\] { border-top-left-radius: 20px; border-top-right-radius: 20px; @@ -1860,9 +1874,9 @@ input[type='search']::-webkit-search-decoration, border-color: rgb(194 200 208 / var(--tw-border-opacity, 1)); } -.border-gray3 { +.border-brand { --tw-border-opacity: 1; - border-color: rgb(190 190 190 / var(--tw-border-opacity, 1)); + border-color: rgb(65 144 235 / var(--tw-border-opacity, 1)); } .border-gray8 { @@ -1882,16 +1896,6 @@ input[type='search']::-webkit-search-decoration, border-color: rgb(255 255 255 / 0.6); } -.border-brand { - --tw-border-opacity: 1; - border-color: rgb(65 144 235 / var(--tw-border-opacity, 1)); -} - -.border-t-brand { - --tw-border-opacity: 1; - border-top-color: rgb(65 144 235 / var(--tw-border-opacity, 1)); -} - .border-t-transparent { border-top-color: transparent; } @@ -1954,10 +1958,6 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(240 242 245 / var(--tw-bg-opacity, 1)); } -.bg-paper-light\/50 { - background-color: rgb(240 242 245 / 0.5); -} - .bg-transparent { background-color: transparent; } @@ -1999,14 +1999,6 @@ input[type='search']::-webkit-search-decoration, background-size: cover; } -.fill-disabled-light { - fill: #BEBEBE; -} - -.stroke-disabled-light { - stroke: #BEBEBE; -} - .p-0 { padding: 0px; } @@ -2184,6 +2176,10 @@ input[type='search']::-webkit-search-decoration, padding-left: 1.5rem; } +.pr-2 { + padding-right: 0.5rem; +} + .pr-8 { padding-right: 2rem; } @@ -2264,6 +2260,10 @@ input[type='search']::-webkit-search-decoration, font-size: 44px; } +.text-\[9px\] { + font-size: 9px; +} + .text-body3 { font-size: 14px; } @@ -2280,10 +2280,6 @@ input[type='search']::-webkit-search-decoration, font-size: 18px; } -.text-\[9px\] { - font-size: 9px; -} - .font-bold { font-weight: 700; } @@ -2410,10 +2406,6 @@ input[type='search']::-webkit-search-decoration, opacity: 1; } -.opacity-25 { - opacity: 0.25; -} - .opacity-30 { opacity: .3; } @@ -2422,10 +2414,6 @@ input[type='search']::-webkit-search-decoration, opacity: 0.5; } -.opacity-75 { - opacity: 0.75; -} - .shadow-\[0_20px_60px_-15px_rgba\(0\2c 0\2c 0\2c 0\.2\)\] { --tw-shadow: 0 20px 60px -15px rgba(0,0,0,0.2); --tw-shadow-colored: 0 20px 60px -15px var(--tw-shadow-color); @@ -2780,11 +2768,6 @@ input[type='search']::-webkit-search-decoration, border-color: rgb(190 190 190 / var(--tw-border-opacity, 1)); } -.dark\:border-gray6:is(.dark *) { - --tw-border-opacity: 1; - border-color: rgb(77 77 77 / var(--tw-border-opacity, 1)); -} - .dark\:border-white\/10:is(.dark *) { border-color: rgb(255 255 255 / 0.1); } @@ -2843,10 +2826,6 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(15 20 25 / var(--tw-bg-opacity, 1)); } -.dark\:bg-paper-dark\/50:is(.dark *) { - background-color: rgb(15 20 25 / 0.5); -} - .dark\:bg-white\/10:is(.dark *) { background-color: rgb(255 255 255 / 0.1); } @@ -2855,14 +2834,6 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(255 255 255 / 0.05); } -.dark\:fill-disabled-dark:is(.dark *) { - fill: #474747; -} - -.dark\:stroke-disabled-dark:is(.dark *) { - stroke: #474747; -} - .dark\:text-black:is(.dark *) { --tw-text-opacity: 1; color: rgb(0 0 0 / var(--tw-text-opacity, 1)); @@ -3009,10 +2980,6 @@ input[type='search']::-webkit-search-decoration, width: 1.25rem; } - .max-\[768px\]\:w-full { - width: 100%; - } - .max-\[768px\]\:min-w-\[300px\] { min-width: 300px; } @@ -3121,16 +3088,20 @@ input[type='search']::-webkit-search-decoration, width: 1rem; } - .max-\[480px\]\:max-w-full { - max-width: 100%; + .max-\[480px\]\:min-w-\[44px\] { + min-width: 44px; } - .max-\[480px\]\:flex-col { - flex-direction: column; + .max-\[480px\]\:max-w-\[60px\] { + max-width: 60px; } - .max-\[480px\]\:items-start { - align-items: flex-start; + .max-\[480px\]\:max-w-full { + max-width: 100%; + } + + .max-\[480px\]\:gap-0 { + gap: 0px; } .max-\[480px\]\:rounded-\[16px\] { @@ -3170,6 +3141,11 @@ input[type='search']::-webkit-search-decoration, padding-right: 1.5rem; } + .max-\[480px\]\:py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; + } + .max-\[480px\]\:pt-4 { padding-top: 1rem; } From 5edff7c6596d801eef1e26a372dc2ce913313cd5 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 29 Nov 2025 01:14:03 +0900 Subject: [PATCH 14/19] chore: migrate recent-list to leaderboard --- app/[lang]/(common)/Header/index.tsx | 24 +++++++++++---- .../GithubUserList.tsx | 0 .../TierRowItem.tsx | 0 .../TopTierUsers.tsx | 29 +++++++++++++++---- .../{recent-list => leaderboard}/page.tsx | 0 .../Scouter/StatsDetails/SectionDooboo.tsx | 2 ++ .../Scouter/StatsDetails/StatsChart.tsx | 2 -- app/[lang]/stats/[login]/SearchTextInput.tsx | 2 ++ locales/en.json | 4 +-- locales/ko.json | 2 +- styles/output.css | 22 ++++++++++++++ 11 files changed, 71 insertions(+), 16 deletions(-) rename app/[lang]/{recent-list => leaderboard}/GithubUserList.tsx (100%) rename app/[lang]/{recent-list => leaderboard}/TierRowItem.tsx (100%) rename app/[lang]/{recent-list => leaderboard}/TopTierUsers.tsx (75%) rename app/[lang]/{recent-list => leaderboard}/page.tsx (100%) diff --git a/app/[lang]/(common)/Header/index.tsx b/app/[lang]/(common)/Header/index.tsx index 4fbfd1d..a8fc5dc 100644 --- a/app/[lang]/(common)/Header/index.tsx +++ b/app/[lang]/(common)/Header/index.tsx @@ -46,6 +46,13 @@ function DesktopNavMenus( const router = useRouter(); const supabase = getSupabaseBrowserClient(); + const normalizePath = (path: string): string => path.replace(/\/+$/, ''); + const isActivePath = (path: string): boolean => { + const target = normalizePath(`/${lang}${path}`); + const current = normalizePath(pathname ?? ''); + return current === target || current.startsWith(`${target}/`); + }; + return (
  • path.replace(/\/+$/, ''); + const isActivePath = (path: string): boolean => { + const target = normalizePath(`/${lang}${path}`); + const current = normalizePath(pathname ?? ''); + return current === target || current.startsWith(`${target}/`); + }; + return (
    @@ -169,7 +183,7 @@ function MobileNavMenus( 'text-body4 truncate flex-1 h-10 px-8', 'flex items-center', 'hover:opacity-100', - pathname?.includes(link.path) ? 'opacity-100' : 'opacity-30', + isActivePath(link.path) ? 'opacity-100' : 'opacity-30', )} >
  • {user.tierName} - + {user.login} - {user.score} + + {user.score} + ))}
  • diff --git a/app/[lang]/recent-list/page.tsx b/app/[lang]/leaderboard/page.tsx similarity index 100% rename from app/[lang]/recent-list/page.tsx rename to app/[lang]/leaderboard/page.tsx diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx index cee6a39..42b38ae 100644 --- a/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx @@ -29,6 +29,8 @@ function SectionHeader({t, stats, endDate}: SectionProps): ReactElement { // Reset loading when endDate changes (data loaded) useEffect(() => { + // Safe reset after data refresh + // eslint-disable-next-line react-hooks/set-state-in-effect setIsLoading(false); }, [endDate, stats]); diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx index 456feff..527ddc2 100644 --- a/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx @@ -40,7 +40,6 @@ export default function StatsChart({ return () => cancelAnimationFrame(rafId); }, []); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleLegendMouseEnter = useCallback((data: any) => { if (data?.dataKey) { setHoveredLine(String(data.dataKey)); @@ -51,7 +50,6 @@ export default function StatsChart({ setHoveredLine(null); }, []); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleLegendClick = useCallback((data: any) => { if (data?.dataKey) { const key = data.dataKey as LineKey; diff --git a/app/[lang]/stats/[login]/SearchTextInput.tsx b/app/[lang]/stats/[login]/SearchTextInput.tsx index 3ac0312..3cf75bf 100644 --- a/app/[lang]/stats/[login]/SearchTextInput.tsx +++ b/app/[lang]/stats/[login]/SearchTextInput.tsx @@ -34,6 +34,8 @@ export default function SearchTextInput({ // Reset loading state when navigation completes (props change) useEffect(() => { + // Safe reset after navigation completes + // eslint-disable-next-line react-hooks/set-state-in-effect setIsLoading(false); }, [initialValue]); diff --git a/locales/en.json b/locales/en.json index 345999e..a8c864d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,6 +1,6 @@ { "nav": { - "recentList": "Recent List", + "recentList": "Leaderboard", "stats": "Stats", "certifiedUsers": "Certified Users", "signIn": "Sign in", @@ -126,4 +126,4 @@ "goHome": "Go Home", "tryAgain": "Try Again" } -} \ No newline at end of file +} diff --git a/locales/ko.json b/locales/ko.json index 47820e9..9fc5c21 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1,6 +1,6 @@ { "nav": { - "recentList": "최근 목록", + "recentList": "리더보드", "stats": "스탯", "certifiedUsers": "인증 목록", "signIn": "로그인", diff --git a/styles/output.css b/styles/output.css index c663791..210cb95 100644 --- a/styles/output.css +++ b/styles/output.css @@ -2712,6 +2712,16 @@ input[type='search']::-webkit-search-decoration, box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); } +.focus\:ring-transparent:focus { + --tw-ring-color: transparent; +} + +.focus-visible\:ring-0:focus-visible { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + .active\:opacity-100:active { opacity: 1; } @@ -2732,6 +2742,18 @@ input[type='search']::-webkit-search-decoration, opacity: 0.5; } +.group:hover .group-hover\:scale-105 { + --tw-scale-x: 1.05; + --tw-scale-y: 1.05; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.group:hover .group-hover\:scale-110 { + --tw-scale-x: 1.1; + --tw-scale-y: 1.1; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + .group:hover .group-hover\:text-black { --tw-text-opacity: 1; color: rgb(0 0 0 / var(--tw-text-opacity, 1)); From 8baaef7ac76542e881f22f136c5f545017e67ba2 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 29 Nov 2025 01:27:08 +0900 Subject: [PATCH 15/19] fix: code review --- app/[lang]/leaderboard/GithubUserList.tsx | 27 ++-- app/[lang]/leaderboard/TopTierUsers.tsx | 3 +- app/[lang]/leaderboard/apiRoutes.ts | 2 + app/[lang]/stats/[login]/MonthPicker.tsx | 57 ++----- .../Scouter/StatsDetails/SectionDooboo.tsx | 18 ++- .../Scouter/StatsDetails/StatsChart.tsx | 9 +- app/[lang]/stats/[login]/SearchTextInput.tsx | 31 ++-- pages/api/top-tier-users.ts | 3 +- server/plugins/stats/fire.ts | 34 +++-- server/plugins/stats/gold.ts | 142 ++++++++++-------- server/services/githubService.ts | 2 +- styles/output.css | 18 +++ 12 files changed, 182 insertions(+), 164 deletions(-) create mode 100644 app/[lang]/leaderboard/apiRoutes.ts diff --git a/app/[lang]/leaderboard/GithubUserList.tsx b/app/[lang]/leaderboard/GithubUserList.tsx index 320a62d..056f4f0 100644 --- a/app/[lang]/leaderboard/GithubUserList.tsx +++ b/app/[lang]/leaderboard/GithubUserList.tsx @@ -12,6 +12,7 @@ import {getTierSvg} from '../../../src/utils/functions'; import type {ColumnDef} from '../(common)/DataTable'; import {DataTable} from '../(common)/DataTable'; import styles from '../styles.module.css'; +import {API_USERS_BY_TIER} from './apiRoutes'; import type {Tier} from './TierRowItem'; import TierRowItem from './TierRowItem'; @@ -57,7 +58,7 @@ export default function GithubUserList({t, initialData}: Props): ReactElement { setIsLoadingTier(true); try { - const response = await fetch(`/api/users-by-tier?tier=${tier}`); + const response = await fetch(`${API_USERS_BY_TIER}?tier=${tier}`); const result = await response.json(); if (result.users) { setTierData(result.users); @@ -222,18 +223,18 @@ export default function GithubUserList({t, initialData}: Props): ReactElement { )} onScroll={!selectedTier ? handleScroll : undefined} > - { - const login = user.login; - window.open('http://github.com/' + login); - }} - className="p-6 max-[480px]:p-4" - classNames={{ - tHead: - 'bg-paper backdrop-blur-xl border-b border-black/10 dark:border-white/10 px-2 pb-2 -mx-6 -mt-6 px-6 pt-6 rounded-t-[20px] max-[480px]:-mx-4 max-[480px]:-mt-4 max-[480px]:px-4 max-[480px]:pt-4 max-[480px]:rounded-t-[16px]', + { + const login = user.login; + window.open('https://github.com/' + login); + }} + className="p-6 max-[480px]:p-4" + classNames={{ + tHead: + 'bg-paper backdrop-blur-xl border-b border-black/10 dark:border-white/10 px-2 pb-2 -mx-6 -mt-6 px-6 pt-6 rounded-t-[20px] max-[480px]:-mx-4 max-[480px]:-mt-4 max-[480px]:px-4 max-[480px]:pt-4 max-[480px]:rounded-t-[16px]', tBodyRow: 'hover:bg-black/10 dark:hover:bg-white/5 transition-all duration-200 rounded-[8px] my-1', }} diff --git a/app/[lang]/leaderboard/TopTierUsers.tsx b/app/[lang]/leaderboard/TopTierUsers.tsx index cf7f611..ef4c568 100644 --- a/app/[lang]/leaderboard/TopTierUsers.tsx +++ b/app/[lang]/leaderboard/TopTierUsers.tsx @@ -8,6 +8,7 @@ import Image from 'next/image'; import type {UserListItem} from '../../../src/fetches/recentList'; import {getTierSvg} from '../../../src/utils/functions'; import styles from '../styles.module.css'; +import {API_TOP_TIER_USERS} from './apiRoutes'; import type {Tier} from './TierRowItem'; @@ -22,7 +23,7 @@ export default function TopTierUsers({title}: Props): ReactElement { useEffect(() => { const fetchTopUsers = async () => { try { - const response = await fetch('/api/top-tier-users'); + const response = await fetch(API_TOP_TIER_USERS); const data = await response.json(); if (data.users) { setTopTierUsers(data.users); diff --git a/app/[lang]/leaderboard/apiRoutes.ts b/app/[lang]/leaderboard/apiRoutes.ts new file mode 100644 index 0000000..1c34233 --- /dev/null +++ b/app/[lang]/leaderboard/apiRoutes.ts @@ -0,0 +1,2 @@ +export const API_USERS_BY_TIER = '/api/users-by-tier'; +export const API_TOP_TIER_USERS = '/api/top-tier-users'; diff --git a/app/[lang]/stats/[login]/MonthPicker.tsx b/app/[lang]/stats/[login]/MonthPicker.tsx index 0a4c701..2e852fa 100644 --- a/app/[lang]/stats/[login]/MonthPicker.tsx +++ b/app/[lang]/stats/[login]/MonthPicker.tsx @@ -86,21 +86,7 @@ export default function MonthPicker({ return t.selectPeriod; } const [year, month] = value.split('-'); - const monthNames = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - const monthName = monthNames[parseInt(month) - 1]; + const monthName = MONTHS[parseInt(month, 10) - 1]; // Show year only if it's not the current year if (parseInt(year) === currentYear) { return monthName; @@ -149,16 +135,7 @@ export default function MonthPicker({ )} {isLoading && ( -
    +
    )} {isOpen && ( @@ -195,14 +172,7 @@ export default function MonthPicker({
    {/* Month grid - 4 columns x 3 rows */} -
    +
    {MONTHS.map((month, monthIndex) => { const isSelected = selectedYear === viewYear && selectedMonth === monthIndex; @@ -214,23 +184,14 @@ export default function MonthPicker({ type="button" onClick={() => !isDisabled && handleMonthClick(monthIndex)} disabled={isDisabled} - style={{ - display: 'block', - padding: '8px 4px', - borderRadius: '8px', - fontSize: '12px', - fontWeight: 500, - textAlign: 'center', - cursor: isDisabled ? 'not-allowed' : 'pointer', - opacity: isDisabled ? 0.3 : 1, - backgroundColor: isSelected ? '#4190EB' : 'transparent', - color: isSelected ? '#FFFFFF' : 'inherit', - }} className={clsx( + 'block rounded-[8px] text-[12px] font-medium text-center py-2 px-1', 'text-black dark:text-white', - !isSelected && - !isDisabled && - 'hover:bg-gray2 dark:hover:bg-gray7', + isSelected + ? 'bg-brand text-white' + : 'hover:bg-gray2 dark:hover:bg-gray7', + isDisabled && + 'opacity-30 cursor-not-allowed hover:bg-transparent', )} > {month} diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx index 42b38ae..b626588 100644 --- a/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx @@ -1,7 +1,7 @@ 'use client'; import type {ReactElement} from 'react'; -import {useCallback, useState, useEffect} from 'react'; +import {useCallback, useState, useEffect, useRef} from 'react'; import {usePathname, useRouter} from 'next/navigation'; import clsx from 'clsx'; import {Inter} from 'next/font/google'; @@ -26,17 +26,25 @@ function SectionHeader({t, stats, endDate}: SectionProps): ReactElement { const router = useRouter(); const pluginStats = stats.pluginStats; const [isLoading, setIsLoading] = useState(false); + const pendingEndDateRef = useRef(endDate); - // Reset loading when endDate changes (data loaded) useEffect(() => { - // Safe reset after data refresh - // eslint-disable-next-line react-hooks/set-state-in-effect - setIsLoading(false); + const frame = requestAnimationFrame(() => { + if ( + pendingEndDateRef.current === endDate || + pendingEndDateRef.current === undefined + ) { + pendingEndDateRef.current = undefined; + setIsLoading(false); + } + }); + return () => cancelAnimationFrame(frame); }, [endDate, stats]); const handleEndDateChange = useCallback( (newDate: string | undefined) => { if (!pathname) return; + pendingEndDateRef.current = newDate; setIsLoading(true); if (newDate) { router.push(`${pathname}?endDate=${newDate}`); diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx index 527ddc2..4b2aeb5 100644 --- a/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx @@ -11,6 +11,7 @@ import { Tooltip, Legend, } from 'recharts'; +import type {LegendPayload} from 'recharts'; import type {MonthlyContribution} from '../../../../../../server/services/githubService'; @@ -40,8 +41,8 @@ export default function StatsChart({ return () => cancelAnimationFrame(rafId); }, []); - const handleLegendMouseEnter = useCallback((data: any) => { - if (data?.dataKey) { + const handleLegendMouseEnter = useCallback((data: LegendPayload) => { + if (data?.dataKey !== undefined && data?.dataKey !== null) { setHoveredLine(String(data.dataKey)); } }, []); @@ -50,9 +51,9 @@ export default function StatsChart({ setHoveredLine(null); }, []); - const handleLegendClick = useCallback((data: any) => { + const handleLegendClick = useCallback((data: LegendPayload) => { if (data?.dataKey) { - const key = data.dataKey as LineKey; + const key = String(data.dataKey) as LineKey; setActiveLines((prev) => { // If clicking on the only active line, reset to show all if (prev.size === 1 && prev.has(key)) { diff --git a/app/[lang]/stats/[login]/SearchTextInput.tsx b/app/[lang]/stats/[login]/SearchTextInput.tsx index 3cf75bf..ddd122b 100644 --- a/app/[lang]/stats/[login]/SearchTextInput.tsx +++ b/app/[lang]/stats/[login]/SearchTextInput.tsx @@ -24,6 +24,7 @@ export default function SearchTextInput({ const [login, setLogin] = useState(initialValue); const [showHistory, setShowHistory] = useState(false); const [isLoading, setIsLoading] = useState(false); + const pendingLoginRef = useRef(null); const {history, addToHistory, removeFromHistory} = useSearchHistory(); const searchContainerRef = useRef(null); const pathname = usePathname(); @@ -32,17 +33,26 @@ export default function SearchTextInput({ // Extract language from pathname (e.g., /ko/stats/hyochan -> ko) const lang = pathname?.split('/')[1] || 'en'; - // Reset loading state when navigation completes (props change) + // Keep local login input in sync with incoming value useEffect(() => { - // Safe reset after navigation completes - // eslint-disable-next-line react-hooks/set-state-in-effect - setIsLoading(false); + setLogin(initialValue); + }, [initialValue]); + + // Reset loading state when navigation completes + useEffect(() => { + const frame = requestAnimationFrame(() => { + if (pendingLoginRef.current && initialValue === pendingLoginRef.current) { + pendingLoginRef.current = null; + setIsLoading(false); + } + }); + return () => cancelAnimationFrame(frame); }, [initialValue]); const navigateTo = (loginValue: string) => { + pendingLoginRef.current = loginValue; setIsLoading(true); router.push(`/${lang}/stats/${loginValue}`); - router.refresh(); }; const handleHistorySelect = (item: string) => { @@ -103,16 +113,7 @@ export default function SearchTextInput({ )} > {isLoading ? ( -
    +
    ) : ( )} diff --git a/pages/api/top-tier-users.ts b/pages/api/top-tier-users.ts index b4b51a9..9b07b18 100644 --- a/pages/api/top-tier-users.ts +++ b/pages/api/top-tier-users.ts @@ -1,5 +1,3 @@ -export const revalidate = 3600; - import type {NextApiRequest, NextApiResponse} from 'next'; import {getSupabaseClient} from '@/server/supabaseClient'; import type {PluginUser} from '~/utils/functions'; @@ -58,5 +56,6 @@ export default async function handler( }; }); + res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate'); res.status(200).send({users}); } diff --git a/server/plugins/stats/fire.ts b/server/plugins/stats/fire.ts index 2ed5264..ea71283 100644 --- a/server/plugins/stats/fire.ts +++ b/server/plugins/stats/fire.ts @@ -13,27 +13,35 @@ export const getGithubFireScore = (githubUser: UserGraph): PluginValue => { }; } - const repos = githubUser.myRepos.edges.map((el) => { - const node = el.node; + const repos = githubUser.myRepos.edges + .map((el) => { + const node = el.node; + const owner = node.owner?.login; - return { - owner: node.owner.login, - name: node.name, - languages: node.languages.edges.map((ele) => ele.node.name), - }; - }); + if (!owner) return null; - const repositoriesContributedTo = githubUser.collaboratedRepos.edges.map( - (el) => { + return { + owner, + name: node.name, + languages: node.languages.edges.map((ele) => ele.node.name), + }; + }) + .filter((el): el is NonNullable => Boolean(el)); + + const repositoriesContributedTo = githubUser.collaboratedRepos.edges + .map((el) => { const node = el.node; + const owner = node.owner?.login; + + if (!owner) return null; return { - owner: node.owner.login, + owner, name: node.name, languages: node.languages.edges.map((ele) => ele.node.name), }; - }, - ); + }) + .filter((el): el is NonNullable => Boolean(el)); const totalCommits = githubUser.contributionsCollection.totalCommitContributions; diff --git a/server/plugins/stats/gold.ts b/server/plugins/stats/gold.ts index 9825584..0878ed2 100644 --- a/server/plugins/stats/gold.ts +++ b/server/plugins/stats/gold.ts @@ -15,78 +15,96 @@ export const getGithubGoldScore = (githubUser: UserGraph): PluginValue => { const sponsorCount = githubUser.sponsors.totalCount; const gistCount = githubUser.gists.totalCount; - const prRepos = githubUser.pullRequests.edges.map((el) => { - if (!el) { - return; - } + const prRepos = githubUser.pullRequests.edges + .map((el) => { + if (!el) { + return null; + } - const node = el.node; + const node = el.node; + const owner = node.repository.owner?.login; - return { - name: node.title, - number: node.number, - state: node.state, - createdAt: node.createdAt, - owner: node.repository.owner.login, - repositoryName: node.repository.name, - repoStarCount: node.repository.stargazerCount, - }; - }); + if (!owner) return null; - const contribRepoPRs = githubUser.contributedRepos.edges.map((el) => { - if (!el) { - return; - } - - if ( - prRepos.find( - (pr) => - pr?.repositoryName === el.node.name && - pr.owner === el.node.owner.login && - pr.state !== 'MERGED', - ) - ) { - // NOTE: Avoid counting contribution on `PR` opened - return; - } + return { + name: node.title, + number: node.number, + state: node.state, + createdAt: node.createdAt, + owner, + repositoryName: node.repository.name, + repoStarCount: node.repository.stargazerCount, + }; + }) + .filter((el): el is NonNullable => Boolean(el)); - return { - owner: el.node.owner.login, - name: el.node.name, - starCount: el.node.stargazerCount, - languages: el.node.languages.edges.map((ele) => ele.node.name), - }; - }); + const contribRepoPRs = githubUser.contributedRepos.edges + .map((el) => { + if (!el) { + return null; + } - const collaboratedRepos = githubUser.collaboratedRepos.edges.map((el) => { - if (!el) { - return null; - } + const owner = el.node.owner?.login; + if (!owner) return null; - const node = el.node; + if ( + prRepos.find( + (pr) => + pr?.repositoryName === el.node.name && + pr.owner === owner && + pr.state !== 'MERGED', + ) + ) { + // NOTE: Avoid counting contribution on `PR` opened + return null; + } - return { - owner: node.owner.login, - name: node.name, - starCount: node.stargazerCount, - languages: node.languages.edges.map((ele) => ele.node.name), - }; - }); + return { + owner, + name: el.node.name, + starCount: el.node.stargazerCount, + languages: el.node.languages.edges.map((ele) => ele.node.name), + }; + }) + .filter((el): el is NonNullable => Boolean(el)); - const myRepos = githubUser.myRepos.edges.map((el) => { - if (!el) { - return null; - } + const collaboratedRepos = githubUser.collaboratedRepos.edges + .map((el) => { + if (!el) { + return null; + } - const node = el.node; + const node = el.node; + const owner = node.owner?.login; + if (!owner) return null; - return { - owner: node.owner.login, - name: node.name, - starCount: node.stargazerCount, - languages: node.languages.edges.map((ele) => ele.node.name), - }; - }); + return { + owner, + name: node.name, + starCount: node.stargazerCount, + languages: node.languages.edges.map((ele) => ele.node.name), + }; + }) + .filter((el): el is NonNullable => Boolean(el)); + + const myRepos = githubUser.myRepos.edges + .map((el) => { + if (!el) { + return null; + } + + const node = el.node; + const owner = node.owner?.login; + if (!owner) return null; + + return { + owner, + name: node.name, + starCount: node.stargazerCount, + languages: node.languages.edges.map((ele) => ele.node.name), + }; + }) + .filter((el): el is NonNullable => Boolean(el)); const contribReposStarCount = contribRepoPRs.reduce( (prev, current) => prev + (current?.starCount || 0), diff --git a/server/services/githubService.ts b/server/services/githubService.ts index f356866..146b53c 100644 --- a/server/services/githubService.ts +++ b/server/services/githubService.ts @@ -164,10 +164,10 @@ export const getGithubUser = async ( totalCount edges { node { - createdAt owner { login } + createdAt watchers { totalCount } diff --git a/styles/output.css b/styles/output.css index 210cb95..7fa424e 100644 --- a/styles/output.css +++ b/styles/output.css @@ -1624,6 +1624,10 @@ input[type='search']::-webkit-search-decoration, appearance: none; } +.grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + .flex-row { flex-direction: row; } @@ -1900,6 +1904,11 @@ input[type='search']::-webkit-search-decoration, border-top-color: transparent; } +.border-t-brand { + --tw-border-opacity: 1; + border-top-color: rgb(65 144 235 / var(--tw-border-opacity, 1)); +} + .bg-\[\#e8e8e8\] { --tw-bg-opacity: 1; background-color: rgb(232 232 232 / var(--tw-bg-opacity, 1)); @@ -2670,6 +2679,10 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(255 255 255 / 0.8); } +.hover\:bg-transparent:hover { + background-color: transparent; +} + .hover\:text-red1:hover { --tw-text-opacity: 1; color: rgb(255 44 44 / var(--tw-text-opacity, 1)); @@ -2802,6 +2815,11 @@ input[type='search']::-webkit-search-decoration, border-color: rgb(255 255 255 / .3); } +.dark\:border-t-brand:is(.dark *) { + --tw-border-opacity: 1; + border-top-color: rgb(65 144 235 / var(--tw-border-opacity, 1)); +} + .dark\:bg-\[\#232323\]:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(35 35 35 / var(--tw-bg-opacity, 1)); From 1be27b3e235d213484f7d1642cdead705ab07b0c Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 29 Nov 2025 01:48:23 +0900 Subject: [PATCH 16/19] chore: rename recent-list to leaderboards --- app/[lang]/(common)/Header/index.tsx | 4 ++-- .../GithubUserList.tsx | 0 .../TierRowItem.tsx | 0 .../TopTierUsers.tsx | 0 .../apiRoutes.ts | 0 .../{leaderboard => leaderboards}/page.tsx | 24 +++++++++---------- locales/en.json | 6 ++--- locales/ko.json | 4 ++-- styles/output.css | 16 ++++++------- 9 files changed, 27 insertions(+), 27 deletions(-) rename app/[lang]/{leaderboard => leaderboards}/GithubUserList.tsx (100%) rename app/[lang]/{leaderboard => leaderboards}/TierRowItem.tsx (100%) rename app/[lang]/{leaderboard => leaderboards}/TopTierUsers.tsx (100%) rename app/[lang]/{leaderboard => leaderboards}/apiRoutes.ts (100%) rename app/[lang]/{leaderboard => leaderboards}/page.tsx (85%) diff --git a/app/[lang]/(common)/Header/index.tsx b/app/[lang]/(common)/Header/index.tsx index a8fc5dc..c90feca 100644 --- a/app/[lang]/(common)/Header/index.tsx +++ b/app/[lang]/(common)/Header/index.tsx @@ -262,7 +262,7 @@ export default function Header(props: Props): ReactElement { }, { name: t.recentList, - path: '/leaderboard', + path: '/leaderboards', }, ] : [ @@ -272,7 +272,7 @@ export default function Header(props: Props): ReactElement { }, { name: t.recentList, - path: '/leaderboard', + path: '/leaderboards', }, ]; diff --git a/app/[lang]/leaderboard/GithubUserList.tsx b/app/[lang]/leaderboards/GithubUserList.tsx similarity index 100% rename from app/[lang]/leaderboard/GithubUserList.tsx rename to app/[lang]/leaderboards/GithubUserList.tsx diff --git a/app/[lang]/leaderboard/TierRowItem.tsx b/app/[lang]/leaderboards/TierRowItem.tsx similarity index 100% rename from app/[lang]/leaderboard/TierRowItem.tsx rename to app/[lang]/leaderboards/TierRowItem.tsx diff --git a/app/[lang]/leaderboard/TopTierUsers.tsx b/app/[lang]/leaderboards/TopTierUsers.tsx similarity index 100% rename from app/[lang]/leaderboard/TopTierUsers.tsx rename to app/[lang]/leaderboards/TopTierUsers.tsx diff --git a/app/[lang]/leaderboard/apiRoutes.ts b/app/[lang]/leaderboards/apiRoutes.ts similarity index 100% rename from app/[lang]/leaderboard/apiRoutes.ts rename to app/[lang]/leaderboards/apiRoutes.ts diff --git a/app/[lang]/leaderboard/page.tsx b/app/[lang]/leaderboards/page.tsx similarity index 85% rename from app/[lang]/leaderboard/page.tsx rename to app/[lang]/leaderboards/page.tsx index 882e531..546826f 100644 --- a/app/[lang]/leaderboard/page.tsx +++ b/app/[lang]/leaderboards/page.tsx @@ -24,7 +24,7 @@ type Props = { export default async function Page(props: Props): Promise { const params = await props.params; const lang = params.lang as Locale; - const {recentList} = await getTranslates(lang); + const {leaderboards} = await getTranslates(lang); const supabase = getSupabaseClient(); const {data: plugin} = await supabase @@ -43,16 +43,16 @@ export default async function Page(props: Props): Promise { 'flex flex-row items-start gap-4', 'max-[768px]:flex-col max-[480px]:mb-6 max-[480px]:mt-2', )} - > -

    - {recentList.title} -

    +

    + {leaderboards.title} +

    { )} >
    - +
    {
    ); diff --git a/locales/en.json b/locales/en.json index a8c864d..93b05f8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,6 +1,6 @@ { "nav": { - "recentList": "Leaderboard", + "leaderboards": "Leaderboards", "stats": "Stats", "certifiedUsers": "Certified Users", "signIn": "Sign in", @@ -84,8 +84,8 @@ "termsOfService": "Terms of Service", "privacyPolicy": "Privacy Policy" }, - "recentList": { - "title": "Leaderboard", + "leaderboards": { + "title": "Leaderboards", "githubUsername": "GitHub username", "noRecentList": "No users found", "tier": "Tier", diff --git a/locales/ko.json b/locales/ko.json index 9fc5c21..7b39bbc 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1,6 +1,6 @@ { "nav": { - "recentList": "리더보드", + "leaderboards": "리더보드", "stats": "스탯", "certifiedUsers": "인증 목록", "signIn": "로그인", @@ -93,7 +93,7 @@ "termsOfService": "서비스 약관", "privacyPolicy": "개인 정보 보호 정책" }, - "recentList": { + "leaderboards": { "title": "리더보드", "githubUsername": "깃허브 유저명", "noRecentList": "유저가 없습니다", diff --git a/styles/output.css b/styles/output.css index 7fa424e..7e7eb93 100644 --- a/styles/output.css +++ b/styles/output.css @@ -1900,15 +1900,15 @@ input[type='search']::-webkit-search-decoration, border-color: rgb(255 255 255 / 0.6); } -.border-t-transparent { - border-top-color: transparent; -} - .border-t-brand { --tw-border-opacity: 1; border-top-color: rgb(65 144 235 / var(--tw-border-opacity, 1)); } +.border-t-transparent { + border-top-color: transparent; +} + .bg-\[\#e8e8e8\] { --tw-bg-opacity: 1; background-color: rgb(232 232 232 / var(--tw-bg-opacity, 1)); @@ -2671,6 +2671,10 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(190 190 190 / var(--tw-bg-opacity, 1)); } +.hover\:bg-transparent:hover { + background-color: transparent; +} + .hover\:bg-white\/60:hover { background-color: rgb(255 255 255 / 0.6); } @@ -2679,10 +2683,6 @@ input[type='search']::-webkit-search-decoration, background-color: rgb(255 255 255 / 0.8); } -.hover\:bg-transparent:hover { - background-color: transparent; -} - .hover\:text-red1:hover { --tw-text-opacity: 1; color: rgb(255 44 44 / var(--tw-text-opacity, 1)); From 7fea14173be811bba1e746be5d6d4480d33e2228 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 29 Nov 2025 01:48:27 +0900 Subject: [PATCH 17/19] fix: code review --- CLAUDE.md | 2 +- app/[lang]/(common)/Header/index.tsx | 36 +++++++++---------- app/[lang]/leaderboards/GithubUserList.tsx | 12 ++++--- app/[lang]/leaderboards/TopTierUsers.tsx | 5 ++- .../Scouter/StatsDetails/SectionDooboo.tsx | 1 - .../Scouter/StatsDetails/StatsChart.tsx | 6 +++- app/[lang]/stats/[login]/SearchTextInput.tsx | 5 +++ server/services/githubService.ts | 14 +++++++- 8 files changed, 54 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9860072..cdd8830 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ className="border-border-light dark:border-border-dark" #### Brand/Accent Colors ```tsx -// Primary brand color (blue) +// Primary brand color (blue) - use for primary actions and highlights className="bg-brand" // #4190EB // Primary buttons diff --git a/app/[lang]/(common)/Header/index.tsx b/app/[lang]/(common)/Header/index.tsx index c90feca..4023ea6 100644 --- a/app/[lang]/(common)/Header/index.tsx +++ b/app/[lang]/(common)/Header/index.tsx @@ -21,6 +21,16 @@ import Button from '../Button'; import SwitchToggle from './SwitchToggle'; const inter = Inter({subsets: ['latin']}); +const normalizePath = (path: string): string => path.replace(/\/+$/, ''); +const isActivePath = ( + pathname: string | null, + lang: string, + path: string, +): boolean => { + const target = normalizePath(`/${lang}${path}`); + const current = normalizePath(pathname ?? ''); + return current === target || current.startsWith(`${target}/`); +}; export type NavLink = { name: string; @@ -46,13 +56,6 @@ function DesktopNavMenus( const router = useRouter(); const supabase = getSupabaseBrowserClient(); - const normalizePath = (path: string): string => path.replace(/\/+$/, ''); - const isActivePath = (path: string): boolean => { - const target = normalizePath(`/${lang}${path}`); - const current = normalizePath(pathname ?? ''); - return current === target || current.startsWith(`${target}/`); - }; - return (
  • path.replace(/\/+$/, ''); - const isActivePath = (path: string): boolean => { - const target = normalizePath(`/${lang}${path}`); - const current = normalizePath(pathname ?? ''); - return current === target || current.startsWith(`${target}/`); - }; - return (
    @@ -183,7 +181,9 @@ function MobileNavMenus( 'text-body4 truncate flex-1 h-10 px-8', 'flex items-center', 'hover:opacity-100', - isActivePath(link.path) ? 'opacity-100' : 'opacity-30', + isActivePath(pathname, lang, link.path) + ? 'opacity-100' + : 'opacity-30', )} >
  • !data.includes(el)); + const filteredUsers = users.filter( + (el) => !data.some((existing) => existing.login === el.login), + ); - setData([...data, ...filteredUsers]); - setCursor(new Date(filteredUsers?.[filteredUsers.length - 1]?.createdAt)); + if (filteredUsers.length > 0) { + setData([...data, ...filteredUsers]); + setCursor(new Date(filteredUsers[filteredUsers.length - 1].createdAt)); + } } }; diff --git a/app/[lang]/leaderboards/TopTierUsers.tsx b/app/[lang]/leaderboards/TopTierUsers.tsx index ef4c568..915b0b3 100644 --- a/app/[lang]/leaderboards/TopTierUsers.tsx +++ b/app/[lang]/leaderboards/TopTierUsers.tsx @@ -24,6 +24,9 @@ export default function TopTierUsers({title}: Props): ReactElement { const fetchTopUsers = async () => { try { const response = await fetch(API_TOP_TIER_USERS); + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`); + } const data = await response.json(); if (data.users) { setTopTierUsers(data.users); @@ -63,7 +66,7 @@ export default function TopTierUsers({title}: Props): ReactElement { } if (topTierUsers.length === 0) { - return
    ; + return <>; } return ( diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx index b626588..b0b1eb0 100644 --- a/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/SectionDooboo.tsx @@ -51,7 +51,6 @@ function SectionHeader({t, stats, endDate}: SectionProps): ReactElement { } else { router.push(pathname); } - router.refresh(); }, [pathname, router], ); diff --git a/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx b/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx index 4b2aeb5..64e355f 100644 --- a/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx +++ b/app/[lang]/stats/[login]/Scouter/StatsDetails/StatsChart.tsx @@ -82,6 +82,10 @@ export default function StatsChart({ // Format month for display (YYYY-MM -> MMM) const formatMonth = (month: string): string => { const [, monthNum] = month.split('-'); + const monthIndex = parseInt(monthNum, 10) - 1; + if (Number.isNaN(monthIndex) || monthIndex < 0 || monthIndex > 11) { + return month; + } const months = [ 'Jan', 'Feb', @@ -96,7 +100,7 @@ export default function StatsChart({ 'Nov', 'Dec', ]; - return months[parseInt(monthNum, 10) - 1]; + return months[monthIndex]; }; const chartData = monthlyContributions?.map((item) => ({ diff --git a/app/[lang]/stats/[login]/SearchTextInput.tsx b/app/[lang]/stats/[login]/SearchTextInput.tsx index ddd122b..8aa0ec2 100644 --- a/app/[lang]/stats/[login]/SearchTextInput.tsx +++ b/app/[lang]/stats/[login]/SearchTextInput.tsx @@ -50,6 +50,11 @@ export default function SearchTextInput({ }, [initialValue]); const navigateTo = (loginValue: string) => { + // Avoid toggling loading when navigating to the same user + if (loginValue === initialValue) { + return; + } + pendingLoginRef.current = loginValue; setIsLoading(true); router.push(`/${lang}/stats/${loginValue}`); diff --git a/server/services/githubService.ts b/server/services/githubService.ts index 146b53c..9022fd9 100644 --- a/server/services/githubService.ts +++ b/server/services/githubService.ts @@ -57,7 +57,19 @@ export const getGithubUser = async ( let date: Date; if (startDate) { const [year, month] = startDate.split('-').map(Number); - date = new Date(Date.UTC(year, month - 1, 1, 12, 0, 0)); + if ( + Number.isFinite(year) && + Number.isFinite(month) && + month >= 1 && + month <= 12 + ) { + date = new Date(Date.UTC(year, month - 1, 1, 12, 0, 0)); + } else { + const now = new Date(); + date = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 12, 1, 12, 0, 0), + ); + } } else { const now = new Date(); date = new Date( From 4c6db0ea19b7b8c310a0cf47ec9a6d8ea4739766 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 29 Nov 2025 01:56:50 +0900 Subject: [PATCH 18/19] fix: code review --- app/[lang]/leaderboards/GithubUserList.tsx | 39 +++++++++++++------- app/[lang]/stats/[login]/SearchTextInput.tsx | 5 +++ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/app/[lang]/leaderboards/GithubUserList.tsx b/app/[lang]/leaderboards/GithubUserList.tsx index 6455afa..c447cb4 100644 --- a/app/[lang]/leaderboards/GithubUserList.tsx +++ b/app/[lang]/leaderboards/GithubUserList.tsx @@ -59,6 +59,9 @@ export default function GithubUserList({t, initialData}: Props): ReactElement { setIsLoadingTier(true); try { const response = await fetch(`${API_USERS_BY_TIER}?tier=${tier}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } const result = await response.json(); if (result.users) { setTierData(result.users); @@ -136,19 +139,29 @@ export default function GithubUserList({t, initialData}: Props): ReactElement { return; } - const {users} = await fetchRecentList({ - pluginId: 'dooboo-github', - take: 20, - cursor, - }); - - const filteredUsers = users.filter( - (el) => !data.some((existing) => existing.login === el.login), - ); + try { + const {users} = await fetchRecentList({ + pluginId: 'dooboo-github', + take: 20, + cursor, + }); - if (filteredUsers.length > 0) { - setData([...data, ...filteredUsers]); - setCursor(new Date(filteredUsers[filteredUsers.length - 1].createdAt)); + let nextCursor: Date | null = null; + setData((prevData) => { + const filteredUsers = users.filter( + (el) => !prevData.some((existing) => existing.login === el.login), + ); + if (filteredUsers.length === 0) return prevData; + nextCursor = new Date( + filteredUsers[filteredUsers.length - 1].createdAt, + ); + return [...prevData, ...filteredUsers]; + }); + if (nextCursor) { + setCursor(nextCursor); + } + } catch (error) { + console.error('Failed to fetch more users:', error); } } }; @@ -233,7 +246,7 @@ export default function GithubUserList({t, initialData}: Props): ReactElement { data={displayData} onClickRow={(user) => { const login = user.login; - window.open('https://github.com/' + login); + window.open(`/stats/${login}`, '_blank', 'noopener'); }} className="p-6 max-[480px]:p-4" classNames={{ diff --git a/app/[lang]/stats/[login]/SearchTextInput.tsx b/app/[lang]/stats/[login]/SearchTextInput.tsx index 8aa0ec2..549b200 100644 --- a/app/[lang]/stats/[login]/SearchTextInput.tsx +++ b/app/[lang]/stats/[login]/SearchTextInput.tsx @@ -109,6 +109,11 @@ export default function SearchTextInput({