From 51a1b42a552dd6918f7b78ef7e0ab0501861372b Mon Sep 17 00:00:00 2001 From: Osama Mabkhot <99215291+O2sa@users.noreply.github.com> Date: Mon, 14 Sep 2026 02:01:00 +0300 Subject: [PATCH 1/5] refactor(arch): migrate to feature-driven architecture and update documentation - Reorganized codebase under `src/` following a Feature-Driven Scalable Next.js Architecture: - `src/features/` (domain modules: comparison, developer, leaderboard, scoring with colocated components, services, tests, types, and barrel exports) - `src/lib/` (infrastructure adapters: cache, db, geo, github, i18n, logger, seo) - `src/components/` (shared domain-agnostic UI: ui, layout, providers, seo) - `src/app/` (thin routing controllers, layouts, and API routes) - `src/locales/`, `src/data/`, `src/types/`, `src/utils/` - Updated path aliases in `tsconfig.json`, `tailwind.config.ts`, and `vitest.config.ts` (`@/features/*`, `@/lib/*`, `@/components/*`, etc.). - Updated script paths in `scripts/calculate-next-country.ts`, `scripts/init-db.ts`, and `scripts/validate-locales.js`. - Created comprehensive `ARCHITECTURE.md` documentation detailing system design, sequence flows, encapsulation rules, and step-by-step contributor cookbooks. - Updated `README.md`, `CONTRIBUTING.md`, and `algorithm.md` to align with the new architecture. - Replaced hardcoded machine/absolute paths in markdown files with relative paths. --- ARCHITECTURE.md | 549 ++++++++++++++++++ CONTRIBUTING.md | 89 ++- README.md | 37 +- algorithm.md | 5 +- lib/location-detector.ts | 64 -- ops/README.md | 2 +- scripts/calculate-next-country.ts | 4 +- scripts/init-db.ts | 2 +- scripts/validate-locales.js | 2 +- src/app/api/compare/route.ts | 100 ++++ {app => src/app}/api/leaderboard/route.ts | 2 +- {app => src/app}/api/user/[username]/route.ts | 8 +- {app => src/app}/globals.css | 0 {app => src/app}/layout.tsx | 2 +- .../app}/leaderboard/[country]/page.tsx | 3 +- {app => src/app}/leaderboard/page.tsx | 9 +- {app => src/app}/manifest.ts | 0 {app => src/app}/page.tsx | 6 +- {app => src/app}/providers.tsx | 6 +- {app => src/app}/robots.ts | 0 {app => src/app}/scoring-methodology/page.tsx | 2 +- {app => src/app}/sitemap.ts | 0 {app => src/app}/user/[username]/loading.tsx | 6 +- {app => src/app}/user/[username]/page.tsx | 10 +- src/components/index.ts | 4 + .../components/layout}/app-footer.tsx | 4 +- .../components/layout}/app-header.tsx | 12 +- .../components/layout}/avatar.tsx | 2 +- .../components/layout}/brand-logo.tsx | 2 +- .../components/layout}/github-link.tsx | 2 +- src/components/layout/index.ts | 6 + .../components/layout}/skeletons.tsx | 2 +- src/components/providers/index.ts | 4 + .../providers}/language-provider.tsx | 3 +- .../providers}/language-switcher.tsx | 2 +- .../components/providers}/theme-provider.tsx | 0 .../components/providers}/theme-toggle.tsx | 2 +- src/components/seo/index.ts | 1 + .../components}/seo/json-ld.tsx | 0 {components => src/components}/ui/alert.tsx | 2 +- {components => src/components}/ui/button.tsx | 2 +- {components => src/components}/ui/card.tsx | 2 +- src/components/ui/index.ts | 7 + {components => src/components}/ui/input.tsx | 2 +- .../components}/ui/progress.tsx | 2 +- .../components}/ui/skeleton.tsx | 2 +- {components => src/components}/ui/tooltip.tsx | 2 +- {data => src/data}/countries.json | 0 .../comparison/components}/breakdown-bars.tsx | 10 +- .../comparison/components}/compare-form.tsx | 16 +- .../components}/comparison-chart.tsx | 10 +- .../components}/comparison-table.tsx | 8 +- .../components}/home-page-client.tsx | 29 +- src/features/comparison/components/index.ts | 8 + .../comparison/components}/insights-list.tsx | 10 +- .../components}/result-dashboard.tsx | 14 +- .../comparison/components}/top-list.tsx | 8 +- src/features/comparison/index.ts | 3 + .../comparison/services}/compare-request.ts | 8 +- .../comparison/services/compare-service.ts | 184 +----- src/features/comparison/services/index.ts | 2 + .../comparison/tests}/compare-request.test.ts | 4 +- .../comparison/tests}/compare.route.test.ts | 17 +- src/features/comparison/types.ts | 66 +++ src/features/developer/components/index.ts | 4 + .../developer/components}/score-card.tsx | 6 +- .../developer/components}/user-not-found.tsx | 2 +- .../components}/user-profile-client.tsx | 11 +- .../components}/user-profile-skeleton.tsx | 2 +- src/features/developer/index.ts | 3 + src/features/developer/services/index.ts | 1 + .../developer/services/user-service.ts | 17 +- .../developer/tests}/user.route.test.ts | 19 +- .../features/developer/types.ts | 13 +- .../components}/country-grid-client.tsx | 8 +- .../country-leaderboard-client.tsx | 10 +- src/features/leaderboard/components/index.ts | 4 + .../components}/leaderboard-hero.tsx | 2 +- .../components}/leaderboard-table.tsx | 26 +- src/features/leaderboard/index.ts | 3 + .../services}/calculate-leaderboard.ts | 48 +- src/features/leaderboard/services/index.ts | 2 + .../services/leaderboard-service.ts | 23 +- src/features/leaderboard/types.ts | 40 ++ src/features/scoring/components/index.ts | 3 + .../components}/scoring-methodology-flow.tsx | 2 +- .../scoring-methodology-page-client.tsx | 10 +- .../scoring-methodology-section.tsx | 0 src/features/scoring/index.ts | 3 + src/features/scoring/services/index.ts | 2 + .../scoring/services/language-scoring.ts | 2 +- .../features/scoring/services/score-engine.ts | 6 +- .../calculateUserScore.contribution.test.ts | 6 +- .../calculateUserScore.language.test.ts | 5 +- .../tests}/calculateUserScore.pr.test.ts | 6 +- .../tests}/calculateUserScore.repo.test.ts | 6 +- .../calculateUserScore.scenario.test.ts | 9 +- .../features/scoring/tests}/helpers/score.ts | 2 +- .../tests}/languageScoring.helpers.test.ts | 4 +- .../tests}/scoring-methodology.test.ts | 13 +- .../score.ts => src/features/scoring/types.ts | 27 +- {lib => src/lib/cache}/cache-store.ts | 0 src/lib/cache/index.ts | 1 + {lib => src/lib/db}/db-store.ts | 0 src/lib/db/index.ts | 1 + {lib => src/lib/geo}/country-flags.ts | 6 - src/lib/geo/index.ts | 2 + src/lib/geo/location-detector.ts | 44 ++ .../lib/github/github-client.ts | 19 +- .../lib/github}/github-graphql-client.ts | 2 +- src/lib/github/index.ts | 3 + .../lib/github/tests}/fixtures/github.ts | 2 +- .../lib/github/tests}/github-cache.test.ts | 4 +- .../tests}/github-graphql-client.test.ts | 2 +- {lib => src/lib/github/tests}/github.test.ts | 2 +- types/github.ts => src/lib/github/types.ts | 0 lib/i18n-core.ts => src/lib/i18n/core.ts | 3 - src/lib/i18n/index.ts | 3 + lib/i18n.ts => src/lib/i18n/provider-hook.ts | 18 +- types/i18n.ts => src/lib/i18n/types.ts | 2 +- src/lib/logger/index.ts | 1 + {lib => src/lib/logger}/logger.ts | 0 src/lib/seo/index.ts | 1 + {lib => src/lib/seo}/seo.ts | 0 {test/seo => src/lib/seo/tests}/seo.test.ts | 0 {locales => src/locales}/ar.json | 0 {locales => src/locales}/en.json | 0 middleware.ts => src/middleware.ts | 2 +- src/types/api.ts | 34 ++ src/types/models.ts | 15 + src/types/next.ts | 12 + lib/utils.ts => src/utils/cn.ts | 0 src/utils/index.ts | 1 + tailwind.config.ts | 2 +- tsconfig.json | 2 +- types/api-response.ts | 57 -- types/leaderboard.ts | 4 - vitest.config.ts | 2 +- 138 files changed, 1335 insertions(+), 634 deletions(-) create mode 100644 ARCHITECTURE.md delete mode 100644 lib/location-detector.ts create mode 100644 src/app/api/compare/route.ts rename {app => src/app}/api/leaderboard/route.ts (92%) rename {app => src/app}/api/user/[username]/route.ts (91%) rename {app => src/app}/globals.css (100%) rename {app => src/app}/layout.tsx (99%) rename {app => src/app}/leaderboard/[country]/page.tsx (97%) rename {app => src/app}/leaderboard/page.tsx (92%) rename {app => src/app}/manifest.ts (100%) rename {app => src/app}/page.tsx (95%) rename {app => src/app}/providers.tsx (67%) rename {app => src/app}/robots.ts (100%) rename {app => src/app}/scoring-methodology/page.tsx (96%) rename {app => src/app}/sitemap.ts (100%) rename {app => src/app}/user/[username]/loading.tsx (60%) rename {app => src/app}/user/[username]/page.tsx (94%) create mode 100644 src/components/index.ts rename {components => src/components/layout}/app-footer.tsx (96%) rename {components => src/components/layout}/app-header.tsx (83%) rename {components => src/components/layout}/avatar.tsx (93%) rename {components => src/components/layout}/brand-logo.tsx (96%) rename {components => src/components/layout}/github-link.tsx (98%) create mode 100644 src/components/layout/index.ts rename {components => src/components/layout}/skeletons.tsx (96%) create mode 100644 src/components/providers/index.ts rename {components => src/components/providers}/language-provider.tsx (83%) rename {components => src/components/providers}/language-switcher.tsx (99%) rename {components => src/components/providers}/theme-provider.tsx (100%) rename {components => src/components/providers}/theme-toggle.tsx (97%) create mode 100644 src/components/seo/index.ts rename {components => src/components}/seo/json-ld.tsx (100%) rename {components => src/components}/ui/alert.tsx (97%) rename {components => src/components}/ui/button.tsx (97%) rename {components => src/components}/ui/card.tsx (98%) create mode 100644 src/components/ui/index.ts rename {components => src/components}/ui/input.tsx (96%) rename {components => src/components}/ui/progress.tsx (95%) rename {components => src/components}/ui/skeleton.tsx (90%) rename {components => src/components}/ui/tooltip.tsx (98%) rename {data => src/data}/countries.json (100%) rename {components => src/features/comparison/components}/breakdown-bars.tsx (90%) rename {components => src/features/comparison/components}/compare-form.tsx (95%) rename {components => src/features/comparison/components}/comparison-chart.tsx (96%) rename {components => src/features/comparison/components}/comparison-table.tsx (92%) rename {components => src/features/comparison/components}/home-page-client.tsx (95%) create mode 100644 src/features/comparison/components/index.ts rename {components => src/features/comparison/components}/insights-list.tsx (96%) rename {components => src/features/comparison/components}/result-dashboard.tsx (97%) rename {components => src/features/comparison/components}/top-list.tsx (99%) create mode 100644 src/features/comparison/index.ts rename {lib => src/features/comparison/services}/compare-request.ts (95%) rename app/api/compare/route.ts => src/features/comparison/services/compare-service.ts (72%) create mode 100644 src/features/comparison/services/index.ts rename {test/ui => src/features/comparison/tests}/compare-request.test.ts (97%) rename {test/api => src/features/comparison/tests}/compare.route.test.ts (96%) create mode 100644 src/features/comparison/types.ts create mode 100644 src/features/developer/components/index.ts rename {components => src/features/developer/components}/score-card.tsx (90%) rename {components => src/features/developer/components}/user-not-found.tsx (95%) rename {components => src/features/developer/components}/user-profile-client.tsx (98%) rename {components => src/features/developer/components}/user-profile-skeleton.tsx (97%) create mode 100644 src/features/developer/index.ts create mode 100644 src/features/developer/services/index.ts rename lib/user.ts => src/features/developer/services/user-service.ts (90%) rename {test/api => src/features/developer/tests}/user.route.test.ts (94%) rename types/user-result.ts => src/features/developer/types.ts (87%) rename {app/leaderboard => src/features/leaderboard/components}/country-grid-client.tsx (93%) rename {app/leaderboard/[country] => src/features/leaderboard/components}/country-leaderboard-client.tsx (92%) create mode 100644 src/features/leaderboard/components/index.ts rename {app/leaderboard => src/features/leaderboard/components}/leaderboard-hero.tsx (92%) rename {components => src/features/leaderboard/components}/leaderboard-table.tsx (93%) create mode 100644 src/features/leaderboard/index.ts rename {lib => src/features/leaderboard/services}/calculate-leaderboard.ts (91%) create mode 100644 src/features/leaderboard/services/index.ts rename lib/leaderboard.ts => src/features/leaderboard/services/leaderboard-service.ts (87%) create mode 100644 src/features/leaderboard/types.ts create mode 100644 src/features/scoring/components/index.ts rename {components/scoring => src/features/scoring/components}/scoring-methodology-flow.tsx (98%) rename {components => src/features/scoring/components}/scoring-methodology-page-client.tsx (91%) rename {components/scoring => src/features/scoring/components}/scoring-methodology-section.tsx (100%) create mode 100644 src/features/scoring/index.ts create mode 100644 src/features/scoring/services/index.ts rename lib/scoring/languageScoring.ts => src/features/scoring/services/language-scoring.ts (98%) rename lib/score.ts => src/features/scoring/services/score-engine.ts (99%) rename {test/scoring => src/features/scoring/tests}/calculateUserScore.contribution.test.ts (97%) rename {test/scoring => src/features/scoring/tests}/calculateUserScore.language.test.ts (98%) rename {test/scoring => src/features/scoring/tests}/calculateUserScore.pr.test.ts (97%) rename {test/scoring => src/features/scoring/tests}/calculateUserScore.repo.test.ts (95%) rename {test/scoring => src/features/scoring/tests}/calculateUserScore.scenario.test.ts (94%) rename {test => src/features/scoring/tests}/helpers/score.ts (99%) rename {test/scoring => src/features/scoring/tests}/languageScoring.helpers.test.ts (96%) rename {test/ui => src/features/scoring/tests}/scoring-methodology.test.ts (85%) rename types/score.ts => src/features/scoring/types.ts (56%) rename {lib => src/lib/cache}/cache-store.ts (100%) create mode 100644 src/lib/cache/index.ts rename {lib => src/lib/db}/db-store.ts (100%) create mode 100644 src/lib/db/index.ts rename {lib => src/lib/geo}/country-flags.ts (74%) create mode 100644 src/lib/geo/index.ts create mode 100644 src/lib/geo/location-detector.ts rename lib/github.ts => src/lib/github/github-client.ts (98%) rename {lib => src/lib/github}/github-graphql-client.ts (99%) create mode 100644 src/lib/github/index.ts rename {test => src/lib/github/tests}/fixtures/github.ts (99%) rename {test/github => src/lib/github/tests}/github-cache.test.ts (99%) rename {test/github => src/lib/github/tests}/github-graphql-client.test.ts (99%) rename {lib => src/lib/github/tests}/github.test.ts (94%) rename types/github.ts => src/lib/github/types.ts (100%) rename lib/i18n-core.ts => src/lib/i18n/core.ts (88%) create mode 100644 src/lib/i18n/index.ts rename lib/i18n.ts => src/lib/i18n/provider-hook.ts (91%) rename types/i18n.ts => src/lib/i18n/types.ts (86%) create mode 100644 src/lib/logger/index.ts rename {lib => src/lib/logger}/logger.ts (100%) create mode 100644 src/lib/seo/index.ts rename {lib => src/lib/seo}/seo.ts (100%) rename {test/seo => src/lib/seo/tests}/seo.test.ts (100%) rename {locales => src/locales}/ar.json (100%) rename {locales => src/locales}/en.json (100%) rename middleware.ts => src/middleware.ts (97%) create mode 100644 src/types/api.ts create mode 100644 src/types/models.ts create mode 100644 src/types/next.ts rename lib/utils.ts => src/utils/cn.ts (100%) create mode 100644 src/utils/index.ts delete mode 100644 types/api-response.ts delete mode 100644 types/leaderboard.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..3c072c2 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,549 @@ +# πŸ›οΈ DevImpact Architecture Guide + +Welcome to the **DevImpact** architecture documentation. This document is designed for contributors, maintainers, and developers looking to understand the system design, directory structure, module boundaries, and implementation patterns of DevImpact. + +--- + +## πŸ“‘ Table of Contents + +1. [Architectural Overview & Philosophy](#-architectural-overview--philosophy) +2. [System Topology & Data Flow](#-system-topology--data-flow) +3. [Repository Directory Structure](#-repository-directory-structure) +4. [The Feature-Driven Architecture Pattern](#-the-feature-driven-architecture-pattern) +5. [Core Features Breakdown](#-core-features-breakdown) +6. [Shared Libraries & Infrastructure (`src/lib`)](#-shared-libraries--infrastructure-srclib) +7. [App Router & Presentation Layer (`src/app` & `src/components`)](#-app-router--presentation-layer) +8. [Cross-Cutting Concerns](#-cross-cutting-concerns) +9. [Architecture Rules & Import Boundaries](#-architecture-rules--import-boundaries) +10. [Step-by-Step Contributor Cookbooks](#-step-by-step-contributor-cookbooks) + - [Recipe 1: Adding a New Feature](#recipe-1-adding-a-brand-new-feature) + - [Recipe 2: Modifying an Existing Feature (e.g., Scoring Logic)](#recipe-2-modifying-an-existing-feature) + - [Recipe 3: Adding a Shared UI Component](#recipe-3-adding-a-shared-ui-component) + - [Recipe 4: Adding a New Language / Locale](#recipe-4-adding-a-new-language--locale) +11. [Testing & Quality Assurance Strategy](#-testing--quality-assurance-strategy) +12. [Ops, Worker & Deployment Architecture](#-ops-worker--deployment-architecture) + +--- + +## 🌟 Architectural Overview & Philosophy + +DevImpact is an open-source platform that measures and compares software developers based on their true impact in the open-source ecosystem. + +The project is architected around **Feature-Driven Scalable Next.js Architecture** (Domain-Driven Vertical Slices). + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Next.js App Router β”‚ +β”‚ (Thin Pages, Layouts, API Handlers) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ delegates to +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Feature Domain Layer β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ comparison β”‚ β”‚ developer β”‚ β”‚ leaderboard β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ scoring engine β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ uses +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Shared Infrastructure Layer (lib) β”‚ +β”‚ github client β”‚ cache (redis) β”‚ db (postgres) β”‚ geo β”‚ i18n β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Core Design Principles: + +1. **Feature Isolation & High Cohesion**: Code that changes together stays together. Components, services, types, and unit tests for a specific domain (e.g., developer profile or leaderboard) live within `src/features/`. +2. **Thin Routing Layer**: Next.js App Router files (`src/app/**`) act strictly as thin controllersβ€”handling routing, search params, SEO metadata, and delegating business logic to feature services and feature components. +3. **Resilience & Graceful Degradation**: Core developer comparison functions even if caching (Redis) or the persistent database (PostgreSQL) is offline or undergoing maintenance. +4. **First-Class Internationalization**: Bidirectional (RTL/LTR) support and multi-language translations (English & Arabic) are baked into components and layout structures. +5. **Strict Testability**: Business logic is isolated from React rendering lifecycles, enabling rapid and comprehensive testing using Vitest without mocking browser DOMs when testing pure algorithms. + +--- + +## πŸ”„ System Topology & Data Flow + +### 1. Developer Comparison & Profile Flow (Real-Time Read-Through) + +```mermaid +sequenceDiagram + autonumber + actor User as Client Browser + participant App as Next.js App / API (/api/compare) + participant Feat as Feature Service (comparison / developer) + participant Cache as Redis Cache (lib/cache) + participant GH as GitHub GraphQL API (lib/github) + participant Score as Scoring Engine (features/scoring) + + User->>App: Compare Request (user1, user2) + App->>Feat: compareDevelopers(user1, user2) + Feat->>Cache: Check cached user profile + alt Cache Hit + Cache-->>Feat: Return cached GitHub metrics + else Cache Miss + Feat->>GH: Query User Repos, PRs & Contributions + GH-->>Feat: Return GraphQL payload + Feat->>Cache: Save normalized user data (TTL: 7 days) + end + Feat->>Score: calculateScore(userData) + Score-->>Feat: Calculated Impact Score Breakdown + Feat-->>App: Comparison Result + App-->>User: Render Interactive Comparison & Radar Chart +``` + +### 2. Leaderboard Calculation Pipeline (Asynchronous Background Worker) + +```mermaid +sequenceDiagram + autonumber + participant Cron as Supercronic Worker (ops/) + participant Script as Calculation Script (scripts/calculate-next-country.ts) + participant DB as PostgreSQL Database + participant GH as GitHub API + participant Score as Scoring Engine + + Cron->>Script: Trigger calculation job + Script->>DB: Lock next pending country + Script->>GH: Fetch top developers for country + Script->>Score: Batch calculate impact scores + Script->>DB: Upsert developer rankings & update status + Script-->>Cron: Job completed +``` + +--- + +## πŸ“ Repository Directory Structure + +``` +DevImpact/ +β”œβ”€β”€ ops/ # Infrastructure, Dockerfiles, Cron & Deployment scripts +β”‚ β”œβ”€β”€ cron/ # Supercronic job definitions +β”‚ β”œβ”€β”€ deploy/ # VPS automated deployment scripts +β”‚ └── docker/ # Dockerfiles for Web App and Worker + Compose files +β”œβ”€β”€ public/ # Static assets, flags, screenshots, fonts +β”œβ”€β”€ scripts/ # CLI utilities (DB migration, leaderboard worker, locale check) +β”‚ β”œβ”€β”€ calculate-next-country.ts +β”‚ β”œβ”€β”€ init-db.ts +β”‚ └── validate-locales.js +β”œβ”€β”€ src/ # Application Source Code +β”‚ β”œβ”€β”€ app/ # Next.js App Router (Pages, Layouts, API Routes) +β”‚ β”œβ”€β”€ components/ # Shared, domain-agnostic UI components & Layouts +β”‚ β”‚ β”œβ”€β”€ layout/ # Navbar, Footer, Mobile Navigation, Theme/Lang Switchers +β”‚ β”‚ β”œβ”€β”€ providers/ # ThemeProvider, LocaleProvider, Client-side contexts +β”‚ β”‚ β”œβ”€β”€ seo/ # StructuredData, OpenGraph metadata wrappers +β”‚ β”‚ └── ui/ # Reusable primitives (buttons, inputs, cards, dialogs) +β”‚ β”œβ”€β”€ data/ # Static lookup data (e.g., countries list, ISO codes) +β”‚ β”œβ”€β”€ features/ # Feature-Driven Domain Modules +β”‚ β”‚ β”œβ”€β”€ comparison/ # Developer comparison logic & UI +β”‚ β”‚ β”œβ”€β”€ developer/ # Developer profile, PR metrics, repository stats +β”‚ β”‚ β”œβ”€β”€ leaderboard/ # Country rankings, grids, and filters +β”‚ β”‚ └── scoring/ # Scoring algorithms, formulas, language weightings +β”‚ β”œβ”€β”€ lib/ # Shared infrastructure adapters & clients +β”‚ β”‚ β”œβ”€β”€ cache/ # Redis client & read-through cache manager +β”‚ β”‚ β”œβ”€β”€ db/ # PostgreSQL connection pool & queries +β”‚ β”‚ β”œβ”€β”€ geo/ # Country normalization, flags, slugification +β”‚ β”‚ β”œβ”€β”€ github/ # Octokit GraphQL client & query builders +β”‚ β”‚ β”œβ”€β”€ i18n/ # Localization engine, cookies, dictionary loader +β”‚ β”‚ β”œβ”€β”€ logger/ # Structured logging utility +β”‚ β”‚ └── seo/ # Dynamic metadata & OpenGraph generators +β”‚ β”œβ”€β”€ locales/ # i18n translation dictionaries +β”‚ β”‚ β”œβ”€β”€ ar.json # Arabic translations (RTL) +β”‚ β”‚ └── en.json # English translations (LTR) +β”‚ β”œβ”€β”€ middleware.ts # Next.js middleware (locale detection & routing) +β”‚ β”œβ”€β”€ types/ # Global TypeScript definitions +β”‚ └── utils/ # Low-level helpers (e.g., cn Tailwind utility) +β”œβ”€β”€ .env.example # Template for required environment variables +β”œβ”€β”€ next.config.js # Next.js configuration +β”œβ”€β”€ tailwind.config.ts # Tailwind CSS theme & plugin configuration +β”œβ”€β”€ tsconfig.json # TypeScript path aliases configuration +└── vitest.config.ts # Vitest unit & integration test configuration +``` + +--- + +## 🧩 The Feature-Driven Architecture Pattern + +Every feature inside `src/features/` follows a standardized, modular anatomy: + +``` +src/features// +β”œβ”€β”€ components/ # UI components exclusive to this feature +β”‚ β”œβ”€β”€ feature-view.tsx +β”‚ └── feature-card.tsx +β”œβ”€β”€ services/ # Pure business logic, calculations, data transformations +β”‚ └── feature-service.ts +β”œβ”€β”€ tests/ # Colocated unit and integration tests (Vitest) +β”‚ β”œβ”€β”€ feature-service.test.ts +β”‚ └── feature.route.test.ts +β”œβ”€β”€ types.ts # Domain-specific TypeScript models and interfaces +└── index.ts # Public API / Barrel export (encapsulation boundary) +``` + +### Why This Structure? + +- **Encapsulation**: Internal implementation details of a feature are hidden behind `index.ts`. +- **Easy Deletion/Refactoring**: If a feature is removed or rewritten, all associated code (UI, tests, types, business logic) is in one place. +- **Clear Ownership**: Contributors can easily identify which files to edit for a specific functionality. + +--- + +## πŸ” Core Features Breakdown + +### 1. `comparison` (`src/features/comparison`) + +- **Responsibility**: Manages side-by-side comparison between two developers. +- **Key Components**: + - `CompareForm`: User inputs with validation and language filter selector. + - `ComparisonClient`: Interactive side-by-side metrics, score differential badges, and radar charts. +- **Key Services**: + - `compare-service.ts`: Orchestrates fetching user records, executing scoring calculations, and determining metric winners. +- **Types**: `ComparisonResult`, `CompareRequest`, `ComparisonWinner`. + +### 2. `developer` (`src/features/developer`) + +- **Responsibility**: Inspects a single GitHub developer's profile. +- **Key Components**: + - `UserProfileClient`: Full profile view showing developer avatar, bio, location, impact breakdown, and stats. + - `PullRequestMetrics`: Breakdown of external PRs, additions/deletions, and target repo quality. + - `TopRepositories`: Visual display of owned repositories and star/fork impact. +- **Key Services**: + - `developer-service.ts`: Fetches and normalizes raw GitHub profile metrics. +- **Types**: `DeveloperProfile`, `PullRequestMetric`, `RepositoryMetric`. + +### 3. `leaderboard` (`src/features/leaderboard`) + +- **Responsibility**: Handles country developer rankings and country discovery. +- **Key Components**: + - `CountryGridClient`: Responsive grid of countries with search, developer counts, and flags. + - `LeaderboardClient`: Ranked developer table with pagination, score meters, and medal indicators. +- **Key Services**: + - `leaderboard-service.ts`: Queries PostgreSQL database with fallback and caching. +- **Types**: `CountryLeaderboardEntry`, `LeaderboardFilters`. + +### 4. `scoring` (`src/features/scoring`) + +- **Responsibility**: The core mathematical engine that evaluates developers. +- **Key Components**: + - `ScoringMethodologyClient`: Interactive educational breakdown of scoring formulas and logarithmic curves. +- **Key Services**: + - `score-engine.ts`: Core algorithm combining Repo Score, PR Score, and Contribution Score. + - `repo-scoring.ts`: Star, fork, and watcher scoring with top-5 logarithmic weighting. + - `pr-scoring.ts`: External merged PR scoring with repository popularity and size factors. + - `contribution-scoring.ts`: Issue and discussion scoring (excluding duplicate PRs/commits). + - `language-scoring.ts`: Language-specific weighting and filtering. +- **Types**: `ScoreBreakdown`, `ScoringWeights`, `LanguageScore`. + +--- + +## πŸ› οΈ Shared Libraries & Infrastructure (`src/lib`) + +The `src/lib/` directory contains infrastructure adapters and cross-domain utilities: + +| Module | Location | Purpose | +| :----------- | :---------------- | :-------------------------------------------------------------------------------------------------------------- | +| **Cache** | `src/lib/cache/` | Redis client with connection pooling, read-through caching, TTL management, and single-flight request handling. | +| **Database** | `src/lib/db/` | PostgreSQL client pool (`pg`), query helpers, and automated schema migration verification. | +| **Geo** | `src/lib/geo/` | Country name normalization, slug translation, and flag asset resolution. | +| **GitHub** | `src/lib/github/` | Octokit GraphQL client with rate-limit monitoring, query batching, and schema typing. | +| **i18n** | `src/lib/i18n/` | Localization context, dictionary loader (`en.json`, `ar.json`), language switcher cookies, and RTL detection. | +| **Logger** | `src/lib/logger/` | Structured JSON logging for request tracking, cache performance, and error analysis. | +| **SEO** | `src/lib/seo/` | Metadata generator functions for dynamic page titles, OpenGraph images, and canonical URLs. | + +--- + +## πŸ–₯️ App Router & Presentation Layer + +### Next.js Pages & Routing (`src/app`) + +Next.js App Router routes act as thin presentation wrappers: + +``` +src/app/ +β”œβ”€β”€ (routes) +β”‚ β”œβ”€β”€ page.tsx # Home / Comparison page +β”‚ β”œβ”€β”€ user/[username]/page.tsx # Developer profile page +β”‚ β”œβ”€β”€ leaderboard/ +β”‚ β”‚ β”œβ”€β”€ page.tsx # Country grid discovery +β”‚ β”‚ └── [country]/page.tsx # Specific country leaderboard +β”‚ └── scoring-methodology/page.tsx # Scoring explanation & formulas +β”œβ”€β”€ api/ +β”‚ β”œβ”€β”€ compare/route.ts # POST / GET comparison API +β”‚ β”œβ”€β”€ user/[username]/route.ts # Developer profile API +β”‚ └── leaderboard/route.ts # Country rankings API +β”œβ”€β”€ layout.tsx # Root layout (Fonts, Providers, Navbar, Footer) +└── providers.tsx # Theme & Locale client wrapper +``` + +### Shared UI Components (`src/components`) + +- **`src/components/ui/`**: Generic, domain-agnostic UI building blocks (Button, Input, Card, Badge, Skeleton, Dialog, Tooltip, Table). +- **`src/components/layout/`**: Structural chrome components (Navbar, Footer, Language Switcher, Theme Switcher, Mobile Navigation Drawer). +- **`src/components/providers/`**: Context providers (NextThemesProvider, I18nProvider). +- **`src/components/seo/`**: JSON-LD Structured Data components and OpenGraph meta builders. + +--- + +## 🌐 Cross-Cutting Concerns + +### 1. Internationalization (i18n) & RTL Support + +DevImpact supports **English (LTR)** and **Arabic (RTL)**. + +- Dictionaries live in `src/locales/en.json` and `src/locales/ar.json`. +- In components, consume translations via the `useTranslations` hook: + ```tsx + import { useTranslations } from "@/lib/i18n"; + + export function MyComponent() { + const { t, locale, isRTL } = useTranslations(); + return

{t("home.title")}

; + } + ``` +- **Validation**: Whenever you modify or add translation keys, run: + ```bash + pnpm validate-locales + ``` + +### 2. Theming (Dark & Light Mode) + +Theming is managed via `next-themes` and Tailwind CSS `dark:` variant classes: + +- Always use semantic color tokens defined in `src/app/globals.css` and `tailwind.config.ts` (`bg-background`, `text-foreground`, `bg-card`, `border-border`). +- Avoid hardcoding static colors (e.g., `#ffffff` or `text-black`). + +--- + +## πŸ“ Architecture Rules & Import Boundaries + +To keep the codebase maintainable as it grows, follow these rules: + +### 1. Path Aliases + +Always use the configured path aliases instead of relative `../../` imports: + +```typescript +// βœ… Good +import { calculateUserScore } from "@/features/scoring"; +import { getRedisClient } from "@/lib/cache"; +import { Button } from "@/components/ui"; +import type { ApiResponse } from "@/types"; + +// ❌ Bad +import { calculateUserScore } from "../../../features/scoring/services/score-engine"; +import { getRedisClient } from "../../lib/cache/redis"; +``` + +### 2. Feature Encapsulation + +- Import other features **only** through their public barrel file (`@/features/`). +- **Never** import internal private files of another feature directly. + +### 3. Server vs. Client Components + +- Keep data fetching, database queries, and GitHub API calls on the **Server** (Server Components, Server Actions, or API Routes in `src/app/api/`). +- Use `'use client'` strictly for components that require React hooks (`useState`, `useEffect`), browser events, or animation libraries. + +--- + +## πŸ“– Step-by-Step Contributor Cookbooks + +### Recipe 1: Adding a Brand New Feature + +Let's say you want to add an **Organizations** feature (`src/features/organizations/`): + +#### Step 1: Create the Feature Directory Structure + +```bash +mkdir -p src/features/organizations/components +mkdir -p src/features/organizations/services +mkdir -p src/features/organizations/tests +``` + +#### Step 2: Define Domain Types (`src/features/organizations/types.ts`) + +```typescript +export interface OrganizationMetric { + name: string; + avatarUrl: string; + totalMembers: number; + totalRepos: number; + aggregatedScore: number; +} +``` + +#### Step 3: Implement Business Logic (`src/features/organizations/services/org-service.ts`) + +```typescript +import { queryGitHubOrg } from "@/lib/github"; +import { calculateRepoScore } from "@/features/scoring"; +import type { OrganizationMetric } from "../types"; + +export async function getOrganizationImpact(orgName: string): Promise { + const rawData = await queryGitHubOrg(orgName); + // Perform calculations using shared features + return { + name: orgName, + avatarUrl: rawData.avatarUrl, + totalMembers: rawData.membersCount, + totalRepos: rawData.repositories.length, + aggregatedScore: 100, // your calculation + }; +} +``` + +#### Step 4: Create UI Components (`src/features/organizations/components/org-card.tsx`) + +```tsx +import { OrganizationMetric } from "../types"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui"; + +export function OrganizationCard({ org }: { org: OrganizationMetric }) { + return ( + + + {org.name} + + +

Impact Score: {org.aggregatedScore}

+
+
+ ); +} +``` + +#### Step 5: Export Public Surface (`src/features/organizations/index.ts`) + +```typescript +export * from "./types"; +export * from "./services/org-service"; +export * from "./components/org-card"; +``` + +#### Step 6: Write Unit Tests (`src/features/organizations/tests/org-service.test.ts`) + +```typescript +import { describe, it, expect } from "vitest"; +import { getOrganizationImpact } from "../services/org-service"; + +describe("Organization Service", () => { + it("should calculate organization impact correctly", async () => { + // test logic + }); +}); +``` + +#### Step 7: Mount in Next.js App Router (`src/app/org/[slug]/page.tsx`) + +```tsx +import { getOrganizationImpact, OrganizationCard } from "@/features/organizations"; + +export default async function OrgPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const orgData = await getOrganizationImpact(slug); + return ; +} +``` + +--- + +### Recipe 2: Modifying an Existing Feature + +#### Example: Modifying the Scoring Algorithm + +1. Open the scoring service file: `src/features/scoring/services/score-engine.ts` (or `pr-scoring.ts`, `repo-scoring.ts`). +2. Update the calculation logic or weight constant. +3. If new metrics are added, update `src/features/scoring/types.ts`. +4. Run tests to verify existing invariants: + ```bash + pnpm test src/features/scoring + ``` +5. Update or add test cases in `src/features/scoring/tests/` to reflect new scoring behavior. + +--- + +### Recipe 3: Adding a Shared UI Component + +If a UI component is **domain-agnostic** (e.g., a Progress Bar, Tooltip, or Modal) and can be used across multiple features: + +1. Create the component in `src/components/ui/progress-bar.tsx`. +2. Export it from `src/components/ui/index.ts` (and `src/components/index.ts`). +3. Use Tailwind CSS variables and semantic classes for dark/light mode compatibility. + +--- + +### Recipe 4: Adding a New Language / Locale + +1. Create a new dictionary file: `src/locales/.json` (e.g., `src/locales/fr.json`). +2. Copy the key structure from `src/locales/en.json` and translate the values. +3. Update `src/lib/i18n/config.ts` to register the new supported locale and its text direction (`ltr` or `rtl`). +4. Validate translation integrity: + ```bash + pnpm validate-locales + ``` + +--- + +## πŸ§ͺ Testing & Quality Assurance Strategy + +We use **Vitest** for fast, reliable unit and integration testing. + +### Running Tests + +```bash +# Run all tests once +pnpm test + +# Run tests in watch mode during development +pnpm test:watch + +# Run a specific feature test suite +npx vitest run src/features/developer +``` + +### Pre-Commit Checklist + +Before opening a pull request, ensure all checks pass: + +```bash +# 1. Run unit & integration tests +pnpm test + +# 2. Check TypeScript types +npx tsc --noEmit + +# 3. Validate localization keys +pnpm validate-locales + +# 4. Check linting and formatting +pnpm lint +pnpm format:check + +# 5. Verify production build +pnpm build +``` + +--- + +## 🚒 Ops, Worker & Deployment Architecture + +### Background Worker Service (`ops/`) + +The Leaderboard calculation runs asynchronously via a standalone Docker container using **Supercronic**: + +- **Cron Schedule**: Defined in `ops/cron/leaderboard.cron`. +- **Worker Dockerfile**: `ops/docker/Dockerfile.worker`. +- **Execution Script**: `scripts/calculate-next-country.ts`. + +For complete documentation on running Docker Compose locally, GHCR image publishing, and VPS deployments, refer to **[ops/README.md](ops/README.md)**. + +--- + +## 🀝 Questions & Getting Help + +If you have questions about the architecture or need guidance on implementing a new capability: + +- Check existing issues or open a new discussion on [GitHub Issues](https://github.com/O2sa/DevImpact/issues). +- Review [CONTRIBUTING.md](CONTRIBUTING.md) for contribution workflows and PR guidelines. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 601bf25..6efbf3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,8 +15,9 @@ Thank you for your interest in contributing to DevImpact! This guide will help y - [Getting Started](#getting-started) - [Development Setup](#development-setup) -- [Project Structure](#project-structure) +- [Project Structure & Architecture](#project-structure--architecture) - [Making Changes](#making-changes) +- [Quality Assurance & Testing](#quality-assurance--testing) - [Pull Request Guidelines](#pull-request-guidelines) - [Issue Guidelines](#issue-guidelines) - [Coding Standards](#coding-standards) @@ -42,6 +43,7 @@ Thank you for your interest in contributing to DevImpact! This guide will help y - [Node.js](https://nodejs.org/) (v18 or higher) - [pnpm](https://pnpm.io/) package manager - A [GitHub Personal Access Token](https://github.com/settings/tokens) with `read:user` and `repo` scopes +- Docker (optional, for local PostgreSQL and Redis) ### Installation @@ -57,26 +59,46 @@ Thank you for your interest in contributing to DevImpact! This guide will help y GITHUB_TOKEN=your_github_token_here ``` -3. Start the development server: +3. (Optional) Start local database & Redis: + + ```bash + pnpm db:up && pnpm redis:up + ``` + +4. Start the development server: ```bash pnpm dev ``` -4. Open [http://localhost:3000](http://localhost:3000) in your browser. +5. Open [http://localhost:3000](http://localhost:3000) in your browser. + +## Project Structure & Architecture -## Project Structure +DevImpact uses a **Feature-Driven Architecture** inside `src/`. For in-depth design patterns, dependency diagrams, and feature anatomy, read our **[Architecture Guide (ARCHITECTURE.md)](ARCHITECTURE.md)**. ``` DevImpact/ -β”œβ”€β”€ app/ # Next.js App Router pages and API routes -β”œβ”€β”€ components/ # Reusable React components -β”œβ”€β”€ lib/ # Utility functions, GitHub API client, scoring logic -β”œβ”€β”€ types/ # TypeScript type definitions -β”œβ”€β”€ .github/ # Issue templates, PR template, workflows +β”œβ”€β”€ ops/ # Infrastructure, Dockerfiles, Cron & Deployment scripts +β”œβ”€β”€ public/ # Static assets, flags, screenshots +β”œβ”€β”€ scripts/ # CLI tools (DB migration, leaderboard worker, locale check) +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ app/ # Next.js App Router (Pages, Layouts, API Route Handlers) +β”‚ β”œβ”€β”€ components/ # Shared domain-agnostic UI (ui/, layout/, providers/, seo/) +β”‚ β”œβ”€β”€ data/ # Static lookup datasets (countries, ISO codes) +β”‚ β”œβ”€β”€ features/ # Feature-Driven Domain Modules +β”‚ β”‚ β”œβ”€β”€ comparison/ # Developer comparison logic & components +β”‚ β”‚ β”œβ”€β”€ developer/ # Developer profile view & metrics +β”‚ β”‚ β”œβ”€β”€ leaderboard/ # Country rankings, grids, and filters +β”‚ β”‚ └── scoring/ # Core scoring algorithms & formulas +β”‚ β”œβ”€β”€ lib/ # Shared infrastructure adapters (cache, db, geo, github, i18n, logger, seo) +β”‚ β”œβ”€β”€ locales/ # i18n translation dictionaries (en.json, ar.json) +β”‚ β”œβ”€β”€ middleware.ts # Next.js middleware (locale detection) +β”‚ β”œβ”€β”€ types/ # Global TypeScript definitions +β”‚ └── utils/ # Low-level helpers (cn, formatting) β”œβ”€β”€ tailwind.config.ts -β”œβ”€β”€ next.config.js -└── tsconfig.json +β”œβ”€β”€ tsconfig.json +└── vitest.config.ts ``` ### Tech Stack @@ -84,9 +106,10 @@ DevImpact/ - **Framework**: Next.js 16+ (App Router) - **Language**: TypeScript - **Styling**: Tailwind CSS -- **UI Components**: Radix UI, Lucide React icons -- **Charts**: Recharts -- **API**: GitHub GraphQL API via Octokit +- **UI Primitives**: Radix UI, Lucide React icons +- **Visualizations**: Recharts +- **Testing**: Vitest +- **Data & API**: Octokit GitHub GraphQL API, PostgreSQL, Redis ## Making Changes @@ -106,9 +129,19 @@ DevImpact/ 3. **Make your changes** and test them locally. -4. **Run the linter** before committing: +4. **Run the quality suite** before committing: ```bash + # Run tests + pnpm test + + # Run type check + npx tsc --noEmit + + # Validate translation keys + pnpm validate-locales + + # Run linter pnpm lint ``` @@ -122,7 +155,7 @@ DevImpact/ ### Commit Message Format -Use descriptive commit messages with a prefix: +Use descriptive commit messages adhering to Conventional Commits: - `feat:` for new features - `fix:` for bug fixes @@ -131,11 +164,17 @@ Use descriptive commit messages with a prefix: - `style:` for formatting changes (no logic change) - `test:` for adding or updating tests +## Quality Assurance & Testing + +- **Unit & Integration Tests**: Place feature tests inside `src/features//tests/`. Run them using `pnpm test` or `pnpm test:watch`. +- **Type Checking**: Run `npx tsc --noEmit` to verify type safety and path alias imports. +- **Localization**: If you add UI text, add keys to both `src/locales/en.json` and `src/locales/ar.json`, then verify with `pnpm validate-locales`. + ## Pull Request Guidelines - Reference the related issue using `Fixes #` in the PR description -- Keep PRs focused on a single change -- Make sure the linter passes (`pnpm lint`) +- Keep PRs focused on a single change or feature +- Ensure all quality checks pass (`pnpm test`, `npx tsc --noEmit`, `pnpm lint`) - Test your changes locally before submitting - Fill out the PR template provided - Be responsive to review feedback @@ -154,14 +193,18 @@ When opening an issue, please use the appropriate template and provide as much d ## Coding Standards -- **TypeScript**: Use proper types. Avoid `any` where possible. -- **Components**: Keep components small and focused. Use the `components/` directory for reusable UI elements. -- **Styling**: Use Tailwind CSS utility classes. Follow the existing patterns in the codebase. -- **API calls**: Use the existing GitHub API client in `lib/` rather than creating new API integrations. -- **File naming**: Use kebab-case for files (e.g., `compare-form.tsx`). +- **Feature-Driven Structure**: Keep feature-specific components, services, and tests inside `src/features//`. +- **Path Aliases**: Always use configured aliases (e.g., `@/features/scoring`, `@/lib/github`, `@/components/ui`) instead of relative paths (`../../`). +- **Encapsulation**: Import other features only via their public index barrel export (`@/features/`). +- **TypeScript**: Use strict types. Avoid `any` where possible. +- **Components**: Keep components small and focused. Use `src/components/ui/` only for domain-agnostic reusable UI elements. +- **Styling**: Use Tailwind CSS utility classes with theme tokens (`bg-card`, `text-foreground`, `border-border`) to guarantee dark/light mode compatibility. +- **API calls**: Use the shared GitHub API client in `src/lib/github` and caching in `src/lib/cache`. +- **File naming**: Use kebab-case for files (e.g., `compare-form.tsx`, `score-engine.ts`). ## Need Help? +- Read the **[Architecture Guide (ARCHITECTURE.md)](ARCHITECTURE.md)** - Check the [open issues](https://github.com/O2sa/DevImpact/issues) for tasks you can work on - Look for issues labeled `good first issue` for beginner-friendly tasks - Open a new issue if you have questions or suggestions diff --git a/README.md b/README.md index a876656..18534cd 100644 --- a/README.md +++ b/README.md @@ -139,17 +139,21 @@ Final Score = ## πŸ› οΈ Tech Stack -### Frontend +- **Framework**: [Next.js](https://nextjs.org/) (App Router, Server & Client Components) +- **Language**: [TypeScript](https://www.typescriptlang.org/) +- **Styling**: [Tailwind CSS](https://tailwindcss.com/) +- **Data & APIs**: GitHub GraphQL API via Octokit +- **Database & Cache**: PostgreSQL & Redis (read-through cache) +- **Visualizations**: [Recharts](https://recharts.org/) +- **Testing**: [Vitest](https://vitest.dev/) -- Next.js (App Router) -- TypeScript -- Tailwind CSS -- Recharts +--- + +## πŸ›οΈ Architecture & System Design -### API +DevImpact is structured around a **Feature-Driven Scalable Architecture** (`src/features/*`, `src/lib/*`, `src/components/*`, `src/app/*`). -- Node.js + Express -- GitHub GraphQL API +For full details on the system design, directory structure, module boundaries, and step-by-step contributor guides, see the **[Architecture Guide (ARCHITECTURE.md)](ARCHITECTURE.md)**. --- @@ -206,7 +210,7 @@ Then open `http://localhost:3000` in your browser! The leaderboard score updates run via a dedicated background worker container using Docker & Supercronic. -For complete local setup, Docker Compose instructions, GHCR publishing, and VPS deployment documentation, see **[ops/README.md](file:///c:/Users/msii/Documents/DevImpact/ops/README.md)**. +For complete local setup, Docker Compose instructions, GHCR publishing, and VPS deployment documentation, see **[ops/README.md](ops/README.md)**. ```bash # Quick worker setup (pulls & runs published image) @@ -219,23 +223,24 @@ docker compose -f ops/docker/leaderboard-compose.yml up -d ## 🌍 Localization -- Supported languages: English πŸ‡ΊπŸ‡Έ, Arabic πŸ‡ΈπŸ‡¦ -- Automatically detects user language -- Allows manual switching -- Easy to add new languages via `/locales` +- Supported languages: English πŸ‡ΊπŸ‡Έ (LTR), Arabic πŸ‡ΈπŸ‡¦ (RTL) +- Automatically detects user language via browser & cookies +- Allows manual switching with instant direction toggling +- Easy to add new languages via `src/locales/` (validated with `pnpm validate-locales`) --- ## 🀝 Contributing -Contributions are welcome! +Contributions are welcome! Check out our **[Contributing Guide (CONTRIBUTING.md)](CONTRIBUTING.md)** and **[Architecture Guide (ARCHITECTURE.md)](ARCHITECTURE.md)** to get started. ### How to contribute: 1. Fork the repository 2. Create a feature branch -3. Commit your changes -4. Open a pull request +3. Run tests and type checks (`pnpm test && npx tsc --noEmit`) +4. Commit your changes +5. Open a pull request --- diff --git a/algorithm.md b/algorithm.md index 9c39a31..352cb75 100644 --- a/algorithm.md +++ b/algorithm.md @@ -1,4 +1,7 @@ -# DevImpact +# DevImpact Scoring Algorithm Specification + +> [!NOTE] +> This document describes the mathematical algorithm pseudocode. The production implementation is located in [`src/features/scoring/services/score-engine.ts`](src/features/scoring/services/score-engine.ts) with corresponding unit tests in [`src/features/scoring/tests/`](src/features/scoring/tests/). ### 🧠 Main diff --git a/lib/location-detector.ts b/lib/location-detector.ts deleted file mode 100644 index 0b2743d..0000000 --- a/lib/location-detector.ts +++ /dev/null @@ -1,64 +0,0 @@ -import countries from "@/data/countries.json"; - -// ─── Types ───────────────────────────────────────────────────────────── - -type CountryEntry = { - slug: string; - title: string; - isoCode: string; - keywords: string[]; -}; - -// ─── Build keyword mapping from data/countries.json ──────────────────── - -type CountryMapping = { - slug: string; - keywords: string[]; -}; - -const COUNTRY_MAPPINGS: CountryMapping[] = (countries as CountryEntry[]) - .filter((c) => c.keywords.length > 0) - .map((c) => ({ - slug: c.slug, - keywords: c.keywords, - })); - -// ─── Helpers ─────────────────────────────────────────────────────────── - -/** - * Checks if a keyword appears as a whole word (or phrase) within the text. - * This prevents false matches like "uk" matching inside "mukalla". - */ -function matchesKeyword(text: string, keyword: string): boolean { - // Escape regex special characters in the keyword - const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - // Match as a whole word β€” surrounded by word boundaries or non-alphanumeric chars - const regex = new RegExp(`(^|[^a-z])${escaped}([^a-z]|$)`, "i"); - return regex.test(text); -} - -// ─── Detection function ──────────────────────────────────────────────── - -/** - * Attempts to detect a country from a GitHub user's free-text location field. - * - * @param location - The raw `location` field from the GitHub API (can be null/empty). - * @returns A normalized country slug (e.g. "saudi-arabia") or null if unmatchable. - */ -export function detectCountry(location: string | null): string | null { - if (!location || !location.trim()) { - return null; - } - - const normalized = location.trim().toLowerCase(); - - for (const mapping of COUNTRY_MAPPINGS) { - for (const keyword of mapping.keywords) { - if (matchesKeyword(normalized, keyword)) { - return mapping.slug; - } - } - } - - return null; -} diff --git a/ops/README.md b/ops/README.md index ce0d113..f863073 100644 --- a/ops/README.md +++ b/ops/README.md @@ -95,7 +95,7 @@ docker logs -f devimpact-leaderboard-cron ## CI/CD & GHCR Publishing Workflow -The GitHub Actions workflow at [.github/workflows/leaderboard-image.yml](file:///c:/Users/msii/Documents/DevImpact/.github/workflows/leaderboard-image.yml) triggers automatically on pushes to `main` when worker or scoring code changes. +The GitHub Actions workflow at [.github/workflows/leaderboard-image.yml](../.github/workflows/leaderboard-image.yml) triggers automatically on pushes to `main` when worker or scoring code changes. ### Image Naming & Tagging Architecture diff --git a/scripts/calculate-next-country.ts b/scripts/calculate-next-country.ts index a564d06..fd59e72 100644 --- a/scripts/calculate-next-country.ts +++ b/scripts/calculate-next-country.ts @@ -19,8 +19,8 @@ // Load .env file for standalone execution. This must be the first import. import "dotenv/config"; -import { getDatabaseStore } from "@/lib/db-store"; -import { calculateLeaderboard } from "@/lib/calculate-leaderboard"; +import { getDatabaseStore } from "@/lib/db"; +import { calculateLeaderboard } from "@/features/leaderboard"; import { logger } from "@/lib/logger"; let activeCountrySlug: string | null = null; diff --git a/scripts/init-db.ts b/scripts/init-db.ts index d05cb71..18d7406 100644 --- a/scripts/init-db.ts +++ b/scripts/init-db.ts @@ -9,7 +9,7 @@ * - DATABASE_URL must be set in .env or environment */ import "dotenv/config"; -import { getDatabaseStore } from "../lib/db-store"; +import { getDatabaseStore } from "../src/lib/db"; async function main() { console.log("Initializing database schema..."); diff --git a/scripts/validate-locales.js b/scripts/validate-locales.js index 4d66522..476fa5e 100644 --- a/scripts/validate-locales.js +++ b/scripts/validate-locales.js @@ -2,7 +2,7 @@ const fs = require("fs"); const path = require("path"); -const localesDir = path.join(__dirname, "..", "locales"); +const localesDir = path.join(__dirname, "..", "src", "locales"); const enKeys = Object.keys( JSON.parse(fs.readFileSync(path.join(localesDir, "en.json"), "utf8")), ).sort(); diff --git a/src/app/api/compare/route.ts b/src/app/api/compare/route.ts new file mode 100644 index 0000000..3a08b75 --- /dev/null +++ b/src/app/api/compare/route.ts @@ -0,0 +1,100 @@ +import { NextResponse } from "next/server"; +import { + CompareUserFetchError, + calculateWinner, + compareUsers, + createComparisonInsights, + parseSelectedLanguagesFromSearchParams, + resolveLocale, +} from "@/features/comparison"; +import { toSafeApiError } from "@/lib/github"; +import type { ClientSafeError, SafeApiError } from "@/types/api"; + +export const runtime = "nodejs"; + +function toApiErrorStatus(code: ReturnType["code"]): number { + switch (code) { + case "RATE_LIMITED": + case "TEMPORARY_THROTTLE": + return 429; + case "GITHUB_TIMEOUT": + case "GITHUB_RESOURCE_LIMIT": + case "GITHUB_AUTH": + return code === "GITHUB_AUTH" ? 401 : 503; + case "GITHUB_NOT_FOUND": + return 404; + case "NETWORK": + return 503; + default: + return 500; + } +} + +function toClientSafeError(error: SafeApiError): ClientSafeError { + return { + code: error.code, + message: error.message, + targetUsernames: error.targetUsernames, + }; +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const usernames = searchParams + .getAll("username") + .map((username) => username.trim()) + .filter(Boolean); + + if (usernames.length !== 2) { + return NextResponse.json( + { success: false, error: "provide exactly two username params" }, + { status: 400 }, + ); + } + + try { + const locale = resolveLocale(request); + const selectedLanguages = parseSelectedLanguagesFromSearchParams(searchParams); + const users = await compareUsers(usernames, selectedLanguages); + const winnerData = calculateWinner(users); + const insights = createComparisonInsights(users, locale); + return NextResponse.json({ success: true, users, ...winnerData, insights }); + } catch (error: unknown) { + console.error("GitHub score error:", error); + + let safeError: SafeApiError; + + if (error instanceof CompareUserFetchError) { + const mappedCause = toSafeApiError(error.causeError); + if ( + mappedCause.code === "GITHUB_NOT_FOUND" || + (error.causeError instanceof Error && error.causeError.message === "User not found") + ) { + safeError = { + code: "GITHUB_NOT_FOUND", + message: "GitHub user not found", + targetUsernames: [error.username], + rateLimit: mappedCause.rateLimit, + }; + } else { + safeError = mappedCause; + } + } else { + safeError = + error instanceof Error && error.message === "User not found" + ? { code: "GITHUB_NOT_FOUND", message: "GitHub user not found" } + : toSafeApiError(error); + } + + const clientSafeError = toClientSafeError(safeError); + + return NextResponse.json( + { + success: false, + error: clientSafeError.message, + errorDetails: clientSafeError, + }, + { status: toApiErrorStatus(safeError.code) }, + ); + } +} diff --git a/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts similarity index 92% rename from app/api/leaderboard/route.ts rename to src/app/api/leaderboard/route.ts index cd1e634..122200c 100644 --- a/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getLeaderboardResult } from "@/lib/leaderboard"; +import { getLeaderboardResult } from "@/features/leaderboard"; export const runtime = "nodejs"; diff --git a/app/api/user/[username]/route.ts b/src/app/api/user/[username]/route.ts similarity index 91% rename from app/api/user/[username]/route.ts rename to src/app/api/user/[username]/route.ts index 4f90321..ea74df7 100644 --- a/app/api/user/[username]/route.ts +++ b/src/app/api/user/[username]/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from "next/server"; -import { getUserProfile, UserFetchError } from "@/lib/user"; -import { normalizeSelectedLanguages } from "@/lib/scoring/languageScoring"; -import { toSafeApiError } from "@/lib/github-graphql-client"; -import type { SafeApiError } from "@/types/api-response"; +import { getUserProfile, UserFetchError } from "@/features/developer"; +import { normalizeSelectedLanguages } from "@/features/scoring"; +import { toSafeApiError } from "@/lib/github"; +import type { SafeApiError } from "@/types/api"; export const runtime = "nodejs"; diff --git a/app/globals.css b/src/app/globals.css similarity index 100% rename from app/globals.css rename to src/app/globals.css diff --git a/app/layout.tsx b/src/app/layout.tsx similarity index 99% rename from app/layout.tsx rename to src/app/layout.tsx index f459666..d5744d8 100644 --- a/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,7 +9,7 @@ import { isSupportedLocale, parseAcceptLanguage, supportedLocales, -} from "@/lib/i18n-core"; +} from "@/lib/i18n/core"; import { getMetadataBase, toAbsoluteUrl } from "@/lib/seo"; import Providers from "./providers"; diff --git a/app/leaderboard/[country]/page.tsx b/src/app/leaderboard/[country]/page.tsx similarity index 97% rename from app/leaderboard/[country]/page.tsx rename to src/app/leaderboard/[country]/page.tsx index a9f60ea..ef5561b 100644 --- a/app/leaderboard/[country]/page.tsx +++ b/src/app/leaderboard/[country]/page.tsx @@ -1,9 +1,8 @@ import type { Metadata } from "next"; import countriesData from "@/data/countries.json"; import { JsonLd } from "@/components/seo/json-ld"; -import { getLeaderboardResult } from "@/lib/leaderboard"; +import { getLeaderboardResult, CountryLeaderboardClient } from "@/features/leaderboard"; import { toAbsoluteUrl } from "@/lib/seo"; -import { CountryLeaderboardClient } from "./country-leaderboard-client"; type CountryInfo = { slug: string; diff --git a/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx similarity index 92% rename from app/leaderboard/page.tsx rename to src/app/leaderboard/page.tsx index 4a97034..ee71220 100644 --- a/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -1,12 +1,11 @@ import type { Metadata } from "next"; -import { AppHeader } from "@/components/app-header"; -import { AppFooter } from "@/components/app-footer"; +import { AppHeader } from "@/components/layout/app-header"; +import { AppFooter } from "@/components/layout/app-footer"; import { JsonLd } from "@/components/seo/json-ld"; import { toAbsoluteUrl } from "@/lib/seo"; import countriesData from "@/data/countries.json"; -import { CountryGridClient } from "./country-grid-client"; -import { LeaderboardHero } from "./leaderboard-hero"; -import type { CountryInfo } from "@/types/leaderboard"; +import { CountryGridClient, LeaderboardHero } from "@/features/leaderboard"; +import type { CountryInfo } from "@/features/leaderboard"; export const metadata: Metadata = { title: "Leaderboard - Country Impact Rankings", diff --git a/app/manifest.ts b/src/app/manifest.ts similarity index 100% rename from app/manifest.ts rename to src/app/manifest.ts diff --git a/app/page.tsx b/src/app/page.tsx similarity index 95% rename from app/page.tsx rename to src/app/page.tsx index bc6798e..a1902a4 100644 --- a/app/page.tsx +++ b/src/app/page.tsx @@ -1,8 +1,8 @@ import type { Metadata } from "next"; import { Suspense } from "react"; -import { AppFooter } from "@/components/app-footer"; -import { AppHeader } from "@/components/app-header"; -import { HomePageClient } from "@/components/home-page-client"; +import { AppFooter } from "@/components/layout/app-footer"; +import { AppHeader } from "@/components/layout/app-header"; +import { HomePageClient } from "@/features/comparison"; import { Skeleton } from "@/components/ui/skeleton"; import { JsonLd } from "@/components/seo/json-ld"; import { toAbsoluteUrl } from "@/lib/seo"; diff --git a/app/providers.tsx b/src/app/providers.tsx similarity index 67% rename from app/providers.tsx rename to src/app/providers.tsx index 2f54926..5d636d1 100644 --- a/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,9 +1,9 @@ "use client"; -import { LanguageProvider } from "@/components/language-provider"; -import type { Locale } from "@/lib/i18n-core"; +import { LanguageProvider } from "@/components/providers/language-provider"; +import type { Locale } from "@/lib/i18n"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { ThemeProvider } from "@/components/theme-provider"; +import { ThemeProvider } from "@/components/providers/theme-provider"; export default function Providers({ children, diff --git a/app/robots.ts b/src/app/robots.ts similarity index 100% rename from app/robots.ts rename to src/app/robots.ts diff --git a/app/scoring-methodology/page.tsx b/src/app/scoring-methodology/page.tsx similarity index 96% rename from app/scoring-methodology/page.tsx rename to src/app/scoring-methodology/page.tsx index f490eae..591daf1 100644 --- a/app/scoring-methodology/page.tsx +++ b/src/app/scoring-methodology/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import { JsonLd } from "@/components/seo/json-ld"; -import { ScoringMethodologyPageClient } from "@/components/scoring-methodology-page-client"; +import { ScoringMethodologyPageClient } from "@/features/scoring"; import { toAbsoluteUrl } from "@/lib/seo"; export const metadata: Metadata = { diff --git a/app/sitemap.ts b/src/app/sitemap.ts similarity index 100% rename from app/sitemap.ts rename to src/app/sitemap.ts diff --git a/app/user/[username]/loading.tsx b/src/app/user/[username]/loading.tsx similarity index 60% rename from app/user/[username]/loading.tsx rename to src/app/user/[username]/loading.tsx index 651af70..df846f4 100644 --- a/app/user/[username]/loading.tsx +++ b/src/app/user/[username]/loading.tsx @@ -1,6 +1,6 @@ -import { AppHeader } from "@/components/app-header"; -import { AppFooter } from "@/components/app-footer"; -import { UserProfileSkeleton } from "@/components/user-profile-skeleton"; +import { AppHeader } from "@/components/layout/app-header"; +import { AppFooter } from "@/components/layout/app-footer"; +import { UserProfileSkeleton } from "@/features/developer"; export default function UserProfileLoading() { return ( diff --git a/app/user/[username]/page.tsx b/src/app/user/[username]/page.tsx similarity index 94% rename from app/user/[username]/page.tsx rename to src/app/user/[username]/page.tsx index ae2303c..0ec9e99 100644 --- a/app/user/[username]/page.tsx +++ b/src/app/user/[username]/page.tsx @@ -1,14 +1,12 @@ import type { Metadata } from "next"; import { JsonLd } from "@/components/seo/json-ld"; -import { UserProfileClient } from "@/components/user-profile-client"; -import { UserNotFoundCard } from "@/components/user-not-found"; -import { AppHeader } from "@/components/app-header"; -import { AppFooter } from "@/components/app-footer"; -import { getUserProfile } from "@/lib/user"; +import { UserProfileClient, UserNotFoundCard, getUserProfile } from "@/features/developer"; +import { AppHeader } from "@/components/layout/app-header"; +import { AppFooter } from "@/components/layout/app-footer"; import { toAbsoluteUrl } from "@/lib/seo"; import countriesData from "@/data/countries.json"; -import { detectCountry } from "@/lib/location-detector"; +import { detectCountry } from "@/lib/geo"; type CountryInfo = { slug: string; diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..6a5e8b8 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,4 @@ +export * from "./ui"; +export * from "./layout"; +export * from "./providers"; +export * from "./seo"; diff --git a/components/app-footer.tsx b/src/components/layout/app-footer.tsx similarity index 96% rename from components/app-footer.tsx rename to src/components/layout/app-footer.tsx index 0f5fa87..f091470 100644 --- a/components/app-footer.tsx +++ b/src/components/layout/app-footer.tsx @@ -1,7 +1,7 @@ "use client"; -import { useTranslation } from "@/components/language-provider"; -import { cn } from "@/lib/utils"; +import { useTranslation } from "@/components/providers/language-provider"; +import { cn } from "@/utils/cn"; import { GithubLink } from "./github-link"; export function AppFooter() { diff --git a/components/app-header.tsx b/src/components/layout/app-header.tsx similarity index 83% rename from components/app-header.tsx rename to src/components/layout/app-header.tsx index b7bd7a5..80f9468 100644 --- a/components/app-header.tsx +++ b/src/components/layout/app-header.tsx @@ -2,12 +2,12 @@ import Link from "next/link"; import { Trophy } from "lucide-react"; -import { BrandLogo } from "@/components/brand-logo"; -import { LanguageSwitcher } from "@/components/language-switcher"; -import { ThemeToggle } from "@/components/theme-toggle"; -import { GithubLink } from "@/components/github-link"; -import { useTranslation } from "@/components/language-provider"; -import { cn } from "@/lib/utils"; +import { BrandLogo } from "./brand-logo"; +import { LanguageSwitcher } from "@/components/providers/language-switcher"; +import { ThemeToggle } from "@/components/providers/theme-toggle"; +import { GithubLink } from "./github-link"; +import { useTranslation } from "@/components/providers/language-provider"; +import { cn } from "@/utils/cn"; export function AppHeader() { const { t } = useTranslation(); diff --git a/components/avatar.tsx b/src/components/layout/avatar.tsx similarity index 93% rename from components/avatar.tsx rename to src/components/layout/avatar.tsx index 893f36d..8c2769e 100644 --- a/components/avatar.tsx +++ b/src/components/layout/avatar.tsx @@ -1,5 +1,5 @@ import Image from "next/image"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/cn"; type AvatarProps = { src: string; diff --git a/components/brand-logo.tsx b/src/components/layout/brand-logo.tsx similarity index 96% rename from components/brand-logo.tsx rename to src/components/layout/brand-logo.tsx index 2e3703a..229c146 100644 --- a/components/brand-logo.tsx +++ b/src/components/layout/brand-logo.tsx @@ -1,5 +1,5 @@ import Image from "next/image"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/cn"; type BrandLogoProps = { size?: "sm" | "md" | "lg" | "xl"; diff --git a/components/github-link.tsx b/src/components/layout/github-link.tsx similarity index 98% rename from components/github-link.tsx rename to src/components/layout/github-link.tsx index bacce56..91f49a3 100644 --- a/components/github-link.tsx +++ b/src/components/layout/github-link.tsx @@ -2,7 +2,7 @@ import { FaGithub } from "react-icons/fa"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/cn"; type GithubLinkProps = { variant?: "compact" | "prominent"; diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts new file mode 100644 index 0000000..05ad583 --- /dev/null +++ b/src/components/layout/index.ts @@ -0,0 +1,6 @@ +export * from "./app-header"; +export * from "./app-footer"; +export * from "./brand-logo"; +export * from "./avatar"; +export * from "./github-link"; +export * from "./skeletons"; diff --git a/components/skeletons.tsx b/src/components/layout/skeletons.tsx similarity index 96% rename from components/skeletons.tsx rename to src/components/layout/skeletons.tsx index 74db6fd..787f4b1 100644 --- a/components/skeletons.tsx +++ b/src/components/layout/skeletons.tsx @@ -1,5 +1,5 @@ import { Skeleton } from "@/components/ui/skeleton"; -import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; export function DashboardSkeleton() { return ( diff --git a/src/components/providers/index.ts b/src/components/providers/index.ts new file mode 100644 index 0000000..d03dfa1 --- /dev/null +++ b/src/components/providers/index.ts @@ -0,0 +1,4 @@ +export * from "./theme-provider"; +export * from "./theme-toggle"; +export * from "./language-provider"; +export * from "./language-switcher"; diff --git a/components/language-provider.tsx b/src/components/providers/language-provider.tsx similarity index 83% rename from components/language-provider.tsx rename to src/components/providers/language-provider.tsx index a7038cc..34167e6 100644 --- a/components/language-provider.tsx +++ b/src/components/providers/language-provider.tsx @@ -1,8 +1,7 @@ "use client"; import { createContext, useContext } from "react"; -import { useI18nProvider, type Locale } from "../lib/i18n"; -import { I18nContextValue } from "../types/i18n"; +import { useI18nProvider, type Locale, type I18nContextValue } from "@/lib/i18n"; const I18nContext = createContext(null); diff --git a/components/language-switcher.tsx b/src/components/providers/language-switcher.tsx similarity index 99% rename from components/language-switcher.tsx rename to src/components/providers/language-switcher.tsx index 9760c80..cdc9324 100644 --- a/components/language-switcher.tsx +++ b/src/components/providers/language-switcher.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "./language-provider"; -import { cn } from "../lib/utils"; +import { cn } from "@/utils/cn"; export function LanguageSwitcher() { const { locale, setLocale, locales, dir } = useTranslation(); diff --git a/components/theme-provider.tsx b/src/components/providers/theme-provider.tsx similarity index 100% rename from components/theme-provider.tsx rename to src/components/providers/theme-provider.tsx diff --git a/components/theme-toggle.tsx b/src/components/providers/theme-toggle.tsx similarity index 97% rename from components/theme-toggle.tsx rename to src/components/providers/theme-toggle.tsx index 401c584..5974930 100644 --- a/components/theme-toggle.tsx +++ b/src/components/providers/theme-toggle.tsx @@ -4,7 +4,7 @@ import { Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"; import { useSyncExternalStore } from "react"; import { useTranslation } from "./language-provider"; -import { Button } from "./ui/button"; +import { Button } from "@/components/ui/button"; const emptySubscribe = () => () => {}; diff --git a/src/components/seo/index.ts b/src/components/seo/index.ts new file mode 100644 index 0000000..ebded1b --- /dev/null +++ b/src/components/seo/index.ts @@ -0,0 +1 @@ +export * from "./json-ld"; diff --git a/components/seo/json-ld.tsx b/src/components/seo/json-ld.tsx similarity index 100% rename from components/seo/json-ld.tsx rename to src/components/seo/json-ld.tsx diff --git a/components/ui/alert.tsx b/src/components/ui/alert.tsx similarity index 97% rename from components/ui/alert.tsx rename to src/components/ui/alert.tsx index 90d0a68..7682496 100644 --- a/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/cn"; const alertVariants = cva( "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", diff --git a/components/ui/button.tsx b/src/components/ui/button.tsx similarity index 97% rename from components/ui/button.tsx rename to src/components/ui/button.tsx index 519b843..160bf25 100644 --- a/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,5 +1,5 @@ import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "../../lib/utils"; +import { cn } from "@/utils/cn"; import type { ButtonHTMLAttributes } from "react"; const buttonVariants = cva( diff --git a/components/ui/card.tsx b/src/components/ui/card.tsx similarity index 98% rename from components/ui/card.tsx rename to src/components/ui/card.tsx index 3f6c849..d99515b 100644 --- a/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/cn"; function Card({ className, diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 0000000..c245a25 --- /dev/null +++ b/src/components/ui/index.ts @@ -0,0 +1,7 @@ +export * from "./alert"; +export * from "./button"; +export * from "./card"; +export * from "./input"; +export * from "./progress"; +export * from "./skeleton"; +export * from "./tooltip"; diff --git a/components/ui/input.tsx b/src/components/ui/input.tsx similarity index 96% rename from components/ui/input.tsx rename to src/components/ui/input.tsx index ecbf9eb..dcf9ee8 100644 --- a/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/cn"; const Input = React.forwardRef>( ({ className, type, ...props }, ref) => { diff --git a/components/ui/progress.tsx b/src/components/ui/progress.tsx similarity index 95% rename from components/ui/progress.tsx rename to src/components/ui/progress.tsx index 588d3bd..ab7bc8b 100644 --- a/components/ui/progress.tsx +++ b/src/components/ui/progress.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { Progress as ProgressPrimitive } from "radix-ui"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/cn"; function Progress({ className, diff --git a/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx similarity index 90% rename from components/ui/skeleton.tsx rename to src/components/ui/skeleton.tsx index a8a4d08..8370070 100644 --- a/components/ui/skeleton.tsx +++ b/src/components/ui/skeleton.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/cn"; function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return ( diff --git a/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx similarity index 98% rename from components/ui/tooltip.tsx rename to src/components/ui/tooltip.tsx index 2db89a1..7c7c700 100644 --- a/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { Tooltip as TooltipPrimitive } from "radix-ui"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/cn"; function TooltipProvider({ delayDuration = 0, diff --git a/data/countries.json b/src/data/countries.json similarity index 100% rename from data/countries.json rename to src/data/countries.json diff --git a/components/breakdown-bars.tsx b/src/features/comparison/components/breakdown-bars.tsx similarity index 90% rename from components/breakdown-bars.tsx rename to src/features/comparison/components/breakdown-bars.tsx index fbd9641..9562ae3 100644 --- a/components/breakdown-bars.tsx +++ b/src/features/comparison/components/breakdown-bars.tsx @@ -1,7 +1,9 @@ -import { UserResult } from "@/types/user-result"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"; -import { Progress } from "./ui/progress"; -import { useTranslation } from "./language-provider"; +"use client"; + +import type { UserResult } from "@/features/developer"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { useTranslation } from "@/components/providers/language-provider"; type Props = { user1: UserResult; diff --git a/components/compare-form.tsx b/src/features/comparison/components/compare-form.tsx similarity index 95% rename from components/compare-form.tsx rename to src/features/comparison/components/compare-form.tsx index 2b60550..87ec2cc 100644 --- a/components/compare-form.tsx +++ b/src/features/comparison/components/compare-form.tsx @@ -1,10 +1,12 @@ +"use client"; + import { useEffect, useRef } from "react"; import { ArrowLeftRight, RefreshCw, X } from "lucide-react"; -import { Button } from "./ui/button"; -import { Input } from "./ui/input"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"; -import { useTranslation } from "./language-provider"; -import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { useTranslation } from "@/components/providers/language-provider"; +import { cn } from "@/utils/cn"; const LANGUAGE_OPTIONS = [ "TypeScript", @@ -129,7 +131,7 @@ export function CompareForm({ ref={firstInputRef} placeholder={t("form.username1")} value={username1} - onChange={(e) => setUsername1(e.target.value)} + onChange={(e: React.ChangeEvent) => setUsername1(e.target.value)} aria-label={t("form.username1.label")} aria-invalid={Boolean(username1Error)} aria-describedby={username1Error ? "username1-error" : undefined} @@ -149,7 +151,7 @@ export function CompareForm({ className="h-11" placeholder={t("form.username2")} value={username2} - onChange={(e) => setUsername2(e.target.value)} + onChange={(e: React.ChangeEvent) => setUsername2(e.target.value)} aria-label={t("form.username2.label")} aria-invalid={Boolean(username2Error)} aria-describedby={username2Error ? "username2-error" : undefined} diff --git a/components/comparison-chart.tsx b/src/features/comparison/components/comparison-chart.tsx similarity index 96% rename from components/comparison-chart.tsx rename to src/features/comparison/components/comparison-chart.tsx index 64a77a8..f769fc2 100644 --- a/components/comparison-chart.tsx +++ b/src/features/comparison/components/comparison-chart.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useState } from "react"; import { Bar, @@ -9,11 +11,11 @@ import { XAxis, YAxis, } from "recharts"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { BarChart3 } from "lucide-react"; -import { UserResult } from "@/types/user-result"; -import { useTranslation } from "./language-provider"; -import { Button } from "./ui/button"; +import type { UserResult } from "@/features/developer"; +import { useTranslation } from "@/components/providers/language-provider"; +import { Button } from "@/components/ui/button"; type Props = { user1: UserResult; diff --git a/components/comparison-table.tsx b/src/features/comparison/components/comparison-table.tsx similarity index 92% rename from components/comparison-table.tsx rename to src/features/comparison/components/comparison-table.tsx index 0be228d..5a339cb 100644 --- a/components/comparison-table.tsx +++ b/src/features/comparison/components/comparison-table.tsx @@ -1,7 +1,9 @@ +"use client"; + import Image from "next/image"; -import { UserResult } from "@/types/user-result"; -import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; -import { useTranslation } from "./language-provider"; +import type { UserResult } from "@/features/developer"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useTranslation } from "@/components/providers/language-provider"; type ComparisonTableProps = { user1: UserResult; diff --git a/components/home-page-client.tsx b/src/features/comparison/components/home-page-client.tsx similarity index 95% rename from components/home-page-client.tsx rename to src/features/comparison/components/home-page-client.tsx index dd723b5..554bdd6 100644 --- a/components/home-page-client.tsx +++ b/src/features/comparison/components/home-page-client.tsx @@ -3,23 +3,24 @@ import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Image from "next/image"; -import { CompareForm } from "../components/compare-form"; -import { ResultDashboard } from "../components/result-dashboard"; -import { DashboardSkeleton } from "../components/skeletons"; -import { UserResult } from "@/types/user-result"; -import { BrandLogo } from "@/components/brand-logo"; -import { AppHeader } from "@/components/app-header"; -import { AppFooter } from "@/components/app-footer"; -import { useTranslation } from "@/components/language-provider"; -import { ApiResponse, CompareInsights, CompareWinner, SafeApiError } from "@/types/api-response"; -import { cn } from "@/lib/utils"; +import { CompareForm } from "./compare-form"; +import { ResultDashboard } from "./result-dashboard"; +import { DashboardSkeleton } from "@/components/layout/skeletons"; +import type { UserResult } from "@/features/developer"; +import { BrandLogo } from "@/components/layout/brand-logo"; +import { AppHeader } from "@/components/layout/app-header"; +import { AppFooter } from "@/components/layout/app-footer"; +import { useTranslation } from "@/components/providers/language-provider"; +import type { SafeApiError } from "@/types/api"; +import type { CompareInsights, CompareWinner, ComparisonResponse } from "../types"; +import { cn } from "@/utils/cn"; import { createComparisonQuery, createComparisonRequest, isComparisonFetchDuplicate, reconcileComparisonData, sanitizeSelectedLanguages, -} from "@/lib/compare-request"; +} from "../services/compare-request"; type ComparisonData = { user1: UserResult; @@ -47,7 +48,7 @@ type UsernameErrors = { const EXIT_ANIMATION_MS = 240; -function normalizeUsers(body: ApiResponse): { user1: UserResult; user2: UserResult } | null { +function normalizeUsers(body: ComparisonResponse): { user1: UserResult; user2: UserResult } | null { if (body.users && body.users.length >= 2) { return { user1: body.users[0], user2: body.users[1] }; } @@ -155,7 +156,7 @@ export function HomePageClient() { }); }; - const applyApiError = (requestUser1: string, requestUser2: string, body: ApiResponse) => { + const applyApiError = (requestUser1: string, requestUser2: string, body: ComparisonResponse) => { const details = body.errorDetails; const localizedMessage = localizeErrorMessage(body.error, details); @@ -245,7 +246,7 @@ export function HomePageClient() { try { const res = await fetch(`/api/compare?${createComparisonQuery(request)}`); - const body: ApiResponse = await res.json(); + const body: ComparisonResponse = await res.json(); if (!res.ok) { if (latestRequestRef.current.fetchKey !== fetchKey) { return; diff --git a/src/features/comparison/components/index.ts b/src/features/comparison/components/index.ts new file mode 100644 index 0000000..77dd066 --- /dev/null +++ b/src/features/comparison/components/index.ts @@ -0,0 +1,8 @@ +export * from "./compare-form"; +export * from "./comparison-chart"; +export * from "./comparison-table"; +export * from "./result-dashboard"; +export * from "./breakdown-bars"; +export * from "./insights-list"; +export * from "./top-list"; +export * from "./home-page-client"; diff --git a/components/insights-list.tsx b/src/features/comparison/components/insights-list.tsx similarity index 96% rename from components/insights-list.tsx rename to src/features/comparison/components/insights-list.tsx index 7a82272..20701a5 100644 --- a/components/insights-list.tsx +++ b/src/features/comparison/components/insights-list.tsx @@ -1,8 +1,10 @@ +"use client"; + import { TrendingUp } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; -import { useTranslation } from "./language-provider"; -import { CompareInsights } from "@/types/api-response"; -import type { UserResult } from "@/types/user-result"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useTranslation } from "@/components/providers/language-provider"; +import type { CompareInsights } from "../types"; +import type { UserResult } from "@/features/developer"; type Props = { insights?: CompareInsights; diff --git a/components/result-dashboard.tsx b/src/features/comparison/components/result-dashboard.tsx similarity index 97% rename from components/result-dashboard.tsx rename to src/features/comparison/components/result-dashboard.tsx index 44ea05d..f905281 100644 --- a/components/result-dashboard.tsx +++ b/src/features/comparison/components/result-dashboard.tsx @@ -5,16 +5,16 @@ import Link from "next/link"; import type { Route } from "next"; import { Check, Copy, ExternalLink, Trophy } from "lucide-react"; import { useSearchParams } from "next/navigation"; -import { Avatar } from "@/components/avatar"; +import { Avatar } from "@/components/layout/avatar"; import { ComparisonChart } from "./comparison-chart"; import { TopList } from "./top-list"; import { InsightsList } from "./insights-list"; -import { ScoreCard } from "./score-card"; -import { Button } from "./ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; -import { UserResult } from "@/types/user-result"; -import { useTranslation } from "./language-provider"; -import { CompareInsights, CompareWinner } from "@/types/api-response"; +import { ScoreCard } from "@/features/developer/components/score-card"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { UserResult } from "@/features/developer"; +import { useTranslation } from "@/components/providers/language-provider"; +import type { CompareInsights, CompareWinner } from "../types"; type Props = { user1: UserResult; diff --git a/components/top-list.tsx b/src/features/comparison/components/top-list.tsx similarity index 99% rename from components/top-list.tsx rename to src/features/comparison/components/top-list.tsx index f16027c..7449049 100644 --- a/components/top-list.tsx +++ b/src/features/comparison/components/top-list.tsx @@ -1,3 +1,5 @@ +"use client"; + import type { ReactNode } from "react"; import Link from "next/link"; import type { Route } from "next"; @@ -11,9 +13,9 @@ import { MessageSquare, Star, } from "lucide-react"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"; -import { UserResult } from "@/types/user-result"; -import { useTranslation } from "./language-provider"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import type { UserResult } from "@/features/developer"; +import { useTranslation } from "@/components/providers/language-provider"; type Props = { userResults: UserResult[]; diff --git a/src/features/comparison/index.ts b/src/features/comparison/index.ts new file mode 100644 index 0000000..1271af8 --- /dev/null +++ b/src/features/comparison/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; +export * from "./services"; +export * from "./components"; diff --git a/lib/compare-request.ts b/src/features/comparison/services/compare-request.ts similarity index 95% rename from lib/compare-request.ts rename to src/features/comparison/services/compare-request.ts index 314096b..af2e268 100644 --- a/lib/compare-request.ts +++ b/src/features/comparison/services/compare-request.ts @@ -1,10 +1,6 @@ +import type { ComparisonPresentationRequest } from "../types"; + const MAX_SELECTED_LANGUAGES = 5; -export type ComparisonPresentationRequest = { - user1: string; - user2: string; - selectedLanguages: string[]; - fetchKey: string; -}; type ComparisonUser = { username: string; }; diff --git a/app/api/compare/route.ts b/src/features/comparison/services/compare-service.ts similarity index 72% rename from app/api/compare/route.ts rename to src/features/comparison/services/compare-service.ts index c173477..e536e5f 100644 --- a/app/api/compare/route.ts +++ b/src/features/comparison/services/compare-service.ts @@ -1,24 +1,19 @@ -import { NextResponse } from "next/server"; -import { getUserData } from "../../../lib/github"; -import { calculateUserScore } from "../../../lib/score"; -import { normalizeSelectedLanguages } from "@/lib/scoring/languageScoring"; -import { toSafeApiError } from "@/lib/github-graphql-client"; -import { getDatabaseStore } from "@/lib/db-store"; -import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache-store"; -import { detectCountry } from "@/lib/location-detector"; -import type { CompareInsights, SafeApiError } from "@/types/api-response"; +import { getUserData } from "@/lib/github"; +import { calculateUserScore, normalizeSelectedLanguages } from "@/features/scoring"; +import { getDatabaseStore } from "@/lib/db"; +import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache"; +import { detectCountry } from "@/lib/geo"; import { DEFAULT_LOCALE, LOCALE_COOKIE, isSupportedLocale, parseAcceptLanguage, type Locale, -} from "@/lib/i18n-core"; -import { GitHubUserData } from "@/types/github"; +} from "@/lib/i18n/core"; +import type { GitHubUserData } from "@/lib/github"; +import type { ComparedUserResult, CompareInsights, CompareWinner, LanguageWinner } from "../types"; -export const runtime = "nodejs"; - -class CompareUserFetchError extends Error { +export class CompareUserFetchError extends Error { readonly username: string; readonly causeError: unknown; @@ -30,29 +25,7 @@ class CompareUserFetchError extends Error { } } -type ComparedUserResult = { - username: string; - name: string | null; - avatarUrl: string; - repoScore: number; - prScore: number; - contributionScore: number; - finalScore: number; - normalizedRepoScore: number; - normalizedPRScore: number; - normalizedContributionScore: number; - normalizedFinalScore: number; - topRepos: ReturnType["topRepos"]; - topPullRequests: ReturnType["topPullRequests"]; - topCommunityContributions: ReturnType["topCommunityContributions"]; - languageScores: ReturnType["languageScores"]; - signals: ReturnType["signals"]; - explanations: ReturnType["explanations"]; -}; - -type ClientSafeError = Pick; - -function parseSelectedLanguagesFromSearchParams(searchParams: URLSearchParams): string[] { +export function parseSelectedLanguagesFromSearchParams(searchParams: URLSearchParams): string[] { const fromRepeated = searchParams.getAll("selectedLanguage"); const fromCsv = searchParams .get("selectedLanguages") @@ -63,18 +36,17 @@ function parseSelectedLanguagesFromSearchParams(searchParams: URLSearchParams): return normalizeSelectedLanguages([...(fromRepeated ?? []), ...(fromCsv ?? [])]); } -function calculateWinner(users: ComparedUserResult[]): { - winner?: { - username: string; - finalScoreDifference: number; - percentageDifference: number | null; - }; - languageWinner?: { - username: string; - finalScoreDifference: number; - percentageDifference: number | null; - selectedLanguages: string[]; - }; +function calculatePercentageDifference(difference: number, baseline: number): number | null { + if (baseline <= 0) { + return difference > 0 ? null : 0; + } + + return (difference / baseline) * 100; +} + +export function calculateWinner(users: ComparedUserResult[]): { + winner?: CompareWinner; + languageWinner?: LanguageWinner; } { if (users.length !== 2) { return {}; @@ -90,17 +62,8 @@ function calculateWinner(users: ComparedUserResult[]): { ); const result: { - winner: { - username: string; - finalScoreDifference: number; - percentageDifference: number | null; - }; - languageWinner?: { - username: string; - finalScoreDifference: number; - percentageDifference: number | null; - selectedLanguages: string[]; - }; + winner: CompareWinner; + languageWinner?: LanguageWinner; } = { winner: { username: overallWinner.username, @@ -134,15 +97,7 @@ function calculateWinner(users: ComparedUserResult[]): { return result; } -function calculatePercentageDifference(difference: number, baseline: number): number | null { - if (baseline <= 0) { - return difference > 0 ? null : 0; - } - - return (difference / baseline) * 100; -} - -function createComparisonInsights( +export function createComparisonInsights( users: ComparedUserResult[], locale: Locale, ): CompareInsights | undefined { @@ -285,7 +240,7 @@ function createComparisonInsights( }; } -function resolveLocale(request: Request): Locale { +export function resolveLocale(request: Request): Locale { const cookieHeader = request.headers.get("cookie"); const localeFromCookie = cookieHeader ?.split(";") @@ -300,7 +255,7 @@ function resolveLocale(request: Request): Locale { return parseAcceptLanguage(request.headers.get("accept-language"), ["en", "ar"], DEFAULT_LOCALE); } -async function compareUsers( +export async function compareUsers( usernames: string[], selectedLanguages: string[], ): Promise { @@ -389,90 +344,3 @@ async function compareUsers( return results; } - -function toApiErrorStatus(code: ReturnType["code"]): number { - switch (code) { - case "RATE_LIMITED": - case "TEMPORARY_THROTTLE": - return 429; - case "GITHUB_TIMEOUT": - case "GITHUB_RESOURCE_LIMIT": - case "GITHUB_AUTH": - return code === "GITHUB_AUTH" ? 401 : 503; - case "GITHUB_NOT_FOUND": - return 404; - case "NETWORK": - return 503; - default: - return 500; - } -} - -function toClientSafeError(error: SafeApiError): ClientSafeError { - return { - code: error.code, - message: error.message, - targetUsernames: error.targetUsernames, - }; -} - -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const usernames = searchParams - .getAll("username") - .map((username) => username.trim()) - .filter(Boolean); - - if (usernames.length !== 2) { - return NextResponse.json( - { success: false, error: "provide exactly two username params" }, - { status: 400 }, - ); - } - - try { - const locale = resolveLocale(request); - const selectedLanguages = parseSelectedLanguagesFromSearchParams(searchParams); - const users = await compareUsers(usernames, selectedLanguages); - const winnerData = calculateWinner(users); - const insights = createComparisonInsights(users, locale); - return NextResponse.json({ success: true, users, ...winnerData, insights }); - } catch (error: unknown) { - console.error("GitHub score error:", error); - - let safeError: SafeApiError; - - if (error instanceof CompareUserFetchError) { - const mappedCause = toSafeApiError(error.causeError); - if ( - mappedCause.code === "GITHUB_NOT_FOUND" || - (error.causeError instanceof Error && error.causeError.message === "User not found") - ) { - safeError = { - code: "GITHUB_NOT_FOUND", - message: "GitHub user not found", - targetUsernames: [error.username], - rateLimit: mappedCause.rateLimit, - }; - } else { - safeError = mappedCause; - } - } else { - safeError = - error instanceof Error && error.message === "User not found" - ? { code: "GITHUB_NOT_FOUND", message: "GitHub user not found" } - : toSafeApiError(error); - } - - const clientSafeError = toClientSafeError(safeError); - - return NextResponse.json( - { - success: false, - error: clientSafeError.message, - errorDetails: clientSafeError, - }, - { status: toApiErrorStatus(safeError.code) }, - ); - } -} diff --git a/src/features/comparison/services/index.ts b/src/features/comparison/services/index.ts new file mode 100644 index 0000000..64c13c7 --- /dev/null +++ b/src/features/comparison/services/index.ts @@ -0,0 +1,2 @@ +export * from "./compare-request"; +export * from "./compare-service"; diff --git a/test/ui/compare-request.test.ts b/src/features/comparison/tests/compare-request.test.ts similarity index 97% rename from test/ui/compare-request.test.ts rename to src/features/comparison/tests/compare-request.test.ts index 23c2f34..da4f972 100644 --- a/test/ui/compare-request.test.ts +++ b/src/features/comparison/tests/compare-request.test.ts @@ -8,7 +8,7 @@ import { reconcileComparisonData, sanitizeSelectedLanguages, swapComparisonRequest, -} from "@/lib/compare-request"; +} from "@/features/comparison"; function comparison(user1: string, user2: string) { return { @@ -121,7 +121,7 @@ describe("comparison response reconciliation", () => { test("binds asynchronous completion to the latest presentation ref", () => { const source = readFileSync( - resolve(process.cwd(), "components", "home-page-client.tsx"), + resolve(process.cwd(), "src", "features", "comparison", "components", "home-page-client.tsx"), "utf8", ); diff --git a/test/api/compare.route.test.ts b/src/features/comparison/tests/compare.route.test.ts similarity index 96% rename from test/api/compare.route.test.ts rename to src/features/comparison/tests/compare.route.test.ts index d6d6369..b3aadf8 100644 --- a/test/api/compare.route.test.ts +++ b/src/features/comparison/tests/compare.route.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { GitHubApiError } from "@/lib/github-graphql-client"; +import { GitHubApiError } from "@/lib/github"; const mocks = vi.hoisted(() => ({ getUserData: vi.fn(), @@ -7,15 +7,20 @@ const mocks = vi.hoisted(() => ({ upsertUser: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/github", () => ({ - getUserData: mocks.getUserData, -})); +vi.mock("@/lib/github", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getUserData: mocks.getUserData, + }; +}); -vi.mock("@/lib/score", () => ({ +vi.mock("@/features/scoring", () => ({ calculateUserScore: mocks.calculateUserScore, + normalizeSelectedLanguages: (langs: string[]) => langs, })); -vi.mock("@/lib/db-store", () => ({ +vi.mock("@/lib/db", () => ({ getDatabaseStore: () => ({ upsertUser: mocks.upsertUser, }), diff --git a/src/features/comparison/types.ts b/src/features/comparison/types.ts new file mode 100644 index 0000000..963ba3a --- /dev/null +++ b/src/features/comparison/types.ts @@ -0,0 +1,66 @@ +import type { UserResult } from "@/features/developer"; +import type { calculateUserScore } from "@/features/scoring"; +import type { ClientSafeError, SafeApiError } from "@/types/api"; + +export type ComparisonPresentationRequest = { + user1: string; + user2: string; + selectedLanguages: string[]; + fetchKey: string; +}; + +export type CompareWinner = { + username: string; + finalScoreDifference: number; + percentageDifference: number | null; +}; + +export type LanguageWinner = { + username: string; + finalScoreDifference: number; + percentageDifference: number | null; + selectedLanguages: string[]; +}; + +export type CompareInsights = { + summary: string; + keyDifferences: string[]; + user1Strengths: string[]; + user2Strengths: string[]; + recommendations: { + user1: string[]; + user2: string[]; + }; + confidenceNote: string; +}; + +export type ComparedUserResult = { + username: string; + name: string | null; + avatarUrl: string; + repoScore: number; + prScore: number; + contributionScore: number; + finalScore: number; + normalizedRepoScore: number; + normalizedPRScore: number; + normalizedContributionScore: number; + normalizedFinalScore: number; + topRepos: ReturnType["topRepos"]; + topPullRequests: ReturnType["topPullRequests"]; + topCommunityContributions: ReturnType["topCommunityContributions"]; + languageScores: ReturnType["languageScores"]; + signals: ReturnType["signals"]; + explanations: ReturnType["explanations"]; +}; + +export type ComparisonResponse = { + success: boolean; + scoreVersion?: string; + users?: UserResult[]; + winner?: CompareWinner; + languageWinner?: LanguageWinner; + insights?: CompareInsights; + error?: string; + errorDetails?: SafeApiError | ClientSafeError; +}; diff --git a/src/features/developer/components/index.ts b/src/features/developer/components/index.ts new file mode 100644 index 0000000..4f2baea --- /dev/null +++ b/src/features/developer/components/index.ts @@ -0,0 +1,4 @@ +export * from "./user-profile-client"; +export * from "./user-profile-skeleton"; +export * from "./user-not-found"; +export * from "./score-card"; diff --git a/components/score-card.tsx b/src/features/developer/components/score-card.tsx similarity index 90% rename from components/score-card.tsx rename to src/features/developer/components/score-card.tsx index 3bca892..be972f7 100644 --- a/components/score-card.tsx +++ b/src/features/developer/components/score-card.tsx @@ -1,6 +1,6 @@ -import { cn } from "../lib/utils"; -import { useTranslation } from "./language-provider"; -import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; +import { cn } from "@/utils/cn"; +import { useTranslation } from "@/components/providers/language-provider"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; type ScoreCardProps = { title: string; diff --git a/components/user-not-found.tsx b/src/features/developer/components/user-not-found.tsx similarity index 95% rename from components/user-not-found.tsx rename to src/features/developer/components/user-not-found.tsx index a47e15d..63ff418 100644 --- a/components/user-not-found.tsx +++ b/src/features/developer/components/user-not-found.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { ArrowLeft, Search, Trophy } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { useTranslation } from "@/components/language-provider"; +import { useTranslation } from "@/components/providers/language-provider"; type Props = { username: string; diff --git a/components/user-profile-client.tsx b/src/features/developer/components/user-profile-client.tsx similarity index 98% rename from components/user-profile-client.tsx rename to src/features/developer/components/user-profile-client.tsx index 797e0e5..7c9646a 100644 --- a/components/user-profile-client.tsx +++ b/src/features/developer/components/user-profile-client.tsx @@ -18,16 +18,15 @@ import { Star, Trophy, } from "lucide-react"; -import { Avatar } from "@/components/avatar"; -import { ScoreCard } from "@/components/score-card"; +import { Avatar } from "@/components/layout/avatar"; +import { ScoreCard } from "./score-card"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; -import { useTranslation } from "@/components/language-provider"; -import { getCountryCode } from "@/lib/country-flags"; -import { detectCountry } from "@/lib/location-detector"; +import { useTranslation } from "@/components/providers/language-provider"; +import { getCountryCode, detectCountry } from "@/lib/geo"; import countriesData from "@/data/countries.json"; -import type { UserResult } from "@/types/user-result"; +import type { UserResult } from "../types"; type CountryInfo = { slug: string; diff --git a/components/user-profile-skeleton.tsx b/src/features/developer/components/user-profile-skeleton.tsx similarity index 97% rename from components/user-profile-skeleton.tsx rename to src/features/developer/components/user-profile-skeleton.tsx index 8fc5965..1eec927 100644 --- a/components/user-profile-skeleton.tsx +++ b/src/features/developer/components/user-profile-skeleton.tsx @@ -1,5 +1,5 @@ import { Skeleton } from "@/components/ui/skeleton"; -import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; export function UserProfileSkeleton() { return ( diff --git a/src/features/developer/index.ts b/src/features/developer/index.ts new file mode 100644 index 0000000..1271af8 --- /dev/null +++ b/src/features/developer/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; +export * from "./services"; +export * from "./components"; diff --git a/src/features/developer/services/index.ts b/src/features/developer/services/index.ts new file mode 100644 index 0000000..a850ca1 --- /dev/null +++ b/src/features/developer/services/index.ts @@ -0,0 +1 @@ +export * from "./user-service"; diff --git a/lib/user.ts b/src/features/developer/services/user-service.ts similarity index 90% rename from lib/user.ts rename to src/features/developer/services/user-service.ts index 126bc02..d265045 100644 --- a/lib/user.ts +++ b/src/features/developer/services/user-service.ts @@ -1,10 +1,10 @@ import { getUserData } from "@/lib/github"; -import { calculateUserScore } from "@/lib/score"; -import { getDatabaseStore } from "@/lib/db-store"; -import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache-store"; -import { detectCountry } from "@/lib/location-detector"; -import type { UserResult } from "@/types/user-result"; -import type { GitHubUserData } from "@/types/github"; +import { calculateUserScore } from "@/features/scoring"; +import { getDatabaseStore } from "@/lib/db"; +import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache"; +import { detectCountry } from "@/lib/geo"; +import type { UserProfileResponse, UserResult } from "../types"; +import type { GitHubUserData } from "@/lib/github"; export class UserFetchError extends Error { readonly username: string; @@ -18,11 +18,6 @@ export class UserFetchError extends Error { } } -export type UserProfileResponse = { - user: UserResult; - location: string | null; -}; - export async function getUserProfile( username: string, selectedLanguages: string[] = [], diff --git a/test/api/user.route.test.ts b/src/features/developer/tests/user.route.test.ts similarity index 94% rename from test/api/user.route.test.ts rename to src/features/developer/tests/user.route.test.ts index 8f8be4e..4e53253 100644 --- a/test/api/user.route.test.ts +++ b/src/features/developer/tests/user.route.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { GitHubApiError } from "@/lib/github-graphql-client"; +import { GitHubApiError } from "@/lib/github"; const mocks = vi.hoisted(() => ({ getUserData: vi.fn(), @@ -7,22 +7,27 @@ const mocks = vi.hoisted(() => ({ upsertUser: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/github", () => ({ - getUserData: mocks.getUserData, -})); +vi.mock("@/lib/github", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getUserData: mocks.getUserData, + }; +}); -vi.mock("@/lib/score", () => ({ +vi.mock("@/features/scoring", () => ({ calculateUserScore: mocks.calculateUserScore, + normalizeSelectedLanguages: (langs: string[]) => langs, })); -vi.mock("@/lib/db-store", () => ({ +vi.mock("@/lib/db", () => ({ getDatabaseStore: () => ({ upsertUser: mocks.upsertUser, }), })); import { GET } from "@/app/api/user/[username]/route"; -import { getUserProfile } from "@/lib/user"; +import { getUserProfile } from "@/features/developer"; function makeUser(login: string, name: string) { return { diff --git a/types/user-result.ts b/src/features/developer/types.ts similarity index 87% rename from types/user-result.ts rename to src/features/developer/types.ts index ae6717b..9c2f4d9 100644 --- a/types/user-result.ts +++ b/src/features/developer/types.ts @@ -1,9 +1,7 @@ -import { ScoringExplanations, ScoringSignals } from "./score"; +import type { ScoringExplanations, ScoringSignals } from "@/features/scoring"; +import type { BaseDeveloper } from "@/types/models"; -export type UserResult = { - username: string; - name: string | null; - avatarUrl: string; +export type UserResult = BaseDeveloper & { repoScore: number; prScore: number; contributionScore: number; @@ -91,3 +89,8 @@ export type UserResult = { scoreVersion?: string; isWinner?: boolean; }; + +export type UserProfileResponse = { + user: UserResult; + location: string | null; +}; diff --git a/app/leaderboard/country-grid-client.tsx b/src/features/leaderboard/components/country-grid-client.tsx similarity index 93% rename from app/leaderboard/country-grid-client.tsx rename to src/features/leaderboard/components/country-grid-client.tsx index 6b511c8..cadeffa 100644 --- a/app/leaderboard/country-grid-client.tsx +++ b/src/features/leaderboard/components/country-grid-client.tsx @@ -6,9 +6,9 @@ import Link from "next/link"; import Image from "next/image"; import { Input } from "@/components/ui/input"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { useTranslation } from "@/components/language-provider"; -import { getCountryCode } from "@/lib/country-flags"; -import type { CountryInfo } from "@/types/leaderboard"; +import { useTranslation } from "@/components/providers/language-provider"; +import { getCountryCode } from "@/lib/geo"; +import type { CountryInfo } from "../types"; import type { Route } from "next"; type Props = { @@ -44,7 +44,7 @@ export function CountryGridClient({ countries }: Props) { className="h-9 pl-9" placeholder={t("leaderboard.searchCountry")} value={search} - onChange={(e) => setSearch(e.target.value)} + onChange={(e: React.ChangeEvent) => setSearch(e.target.value)} /> )} diff --git a/app/leaderboard/[country]/country-leaderboard-client.tsx b/src/features/leaderboard/components/country-leaderboard-client.tsx similarity index 92% rename from app/leaderboard/[country]/country-leaderboard-client.tsx rename to src/features/leaderboard/components/country-leaderboard-client.tsx index 82d4bdb..2e8fafd 100644 --- a/app/leaderboard/[country]/country-leaderboard-client.tsx +++ b/src/features/leaderboard/components/country-leaderboard-client.tsx @@ -3,12 +3,12 @@ import { useState } from "react"; import Link from "next/link"; import { ArrowLeft, Loader2 } from "lucide-react"; -import { LeaderboardTable } from "@/components/leaderboard-table"; -import { AppHeader } from "@/components/app-header"; -import { AppFooter } from "@/components/app-footer"; +import { LeaderboardTable } from "./leaderboard-table"; +import { AppHeader } from "@/components/layout/app-header"; +import { AppFooter } from "@/components/layout/app-footer"; import { Button } from "@/components/ui/button"; -import { useTranslation } from "@/components/language-provider"; -import type { LeaderboardResult } from "@/lib/leaderboard"; +import { useTranslation } from "@/components/providers/language-provider"; +import type { LeaderboardResult } from "../types"; type Props = { countryTitle: string; diff --git a/src/features/leaderboard/components/index.ts b/src/features/leaderboard/components/index.ts new file mode 100644 index 0000000..e4d3ed5 --- /dev/null +++ b/src/features/leaderboard/components/index.ts @@ -0,0 +1,4 @@ +export * from "./leaderboard-table"; +export * from "./country-grid-client"; +export * from "./leaderboard-hero"; +export * from "./country-leaderboard-client"; diff --git a/app/leaderboard/leaderboard-hero.tsx b/src/features/leaderboard/components/leaderboard-hero.tsx similarity index 92% rename from app/leaderboard/leaderboard-hero.tsx rename to src/features/leaderboard/components/leaderboard-hero.tsx index 2fd829d..df6d926 100644 --- a/app/leaderboard/leaderboard-hero.tsx +++ b/src/features/leaderboard/components/leaderboard-hero.tsx @@ -1,6 +1,6 @@ "use client"; -import { useTranslation } from "@/components/language-provider"; +import { useTranslation } from "@/components/providers/language-provider"; type Props = { countryCount: number; diff --git a/components/leaderboard-table.tsx b/src/features/leaderboard/components/leaderboard-table.tsx similarity index 93% rename from components/leaderboard-table.tsx rename to src/features/leaderboard/components/leaderboard-table.tsx index 50cf2ba..a9858c4 100644 --- a/components/leaderboard-table.tsx +++ b/src/features/leaderboard/components/leaderboard-table.tsx @@ -4,23 +4,13 @@ import { useState, useMemo } from "react"; import Link from "next/link"; import type { Route } from "next"; import { Search, AlertTriangle, ExternalLink } from "lucide-react"; -import { Avatar } from "./avatar"; -import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "./ui/card"; -import { Input } from "./ui/input"; -import { useTranslation } from "./language-provider"; -import { cn } from "@/lib/utils"; - -type LeaderboardEntry = { - username: string; - name: string | null; - avatarUrl: string; - repoScore: number; - prScore: number; - contributionScore: number; - finalScore: number; - impactRank: number; -}; +import { Avatar } from "@/components/layout/avatar"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { useTranslation } from "@/components/providers/language-provider"; +import { cn } from "@/utils/cn"; +import type { ScoredLeaderboardEntry as LeaderboardEntry } from "../types"; type Props = { users: LeaderboardEntry[]; @@ -93,7 +83,7 @@ export function LeaderboardTable({ className="h-9 pl-9" placeholder={t("leaderboard.search")} value={search} - onChange={(e) => setSearch(e.target.value)} + onChange={(e: React.ChangeEvent) => setSearch(e.target.value)} /> )} diff --git a/src/features/leaderboard/index.ts b/src/features/leaderboard/index.ts new file mode 100644 index 0000000..1271af8 --- /dev/null +++ b/src/features/leaderboard/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; +export * from "./services"; +export * from "./components"; diff --git a/lib/calculate-leaderboard.ts b/src/features/leaderboard/services/calculate-leaderboard.ts similarity index 91% rename from lib/calculate-leaderboard.ts rename to src/features/leaderboard/services/calculate-leaderboard.ts index 1987526..51c0510 100644 --- a/lib/calculate-leaderboard.ts +++ b/src/features/leaderboard/services/calculate-leaderboard.ts @@ -1,9 +1,15 @@ import yaml from "js-yaml"; import { getUserData } from "@/lib/github"; -import { calculateUserScore } from "@/lib/score"; -import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache-store"; -import { getDatabaseStore, type DatabaseStore } from "@/lib/db-store"; -import { detectCountry } from "@/lib/location-detector"; +import { calculateUserScore } from "@/features/scoring"; +import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache"; +import { getDatabaseStore, type DatabaseStore } from "@/lib/db"; +import { detectCountry } from "@/lib/geo"; +import type { + CalculateLeaderboardResponse, + LeaderboardMeta, + LeaderboardResult, + ScoredLeaderboardEntry as ScoredEntry, +} from "../types"; // ─── Types ───────────────────────────────────────────────────────────── @@ -21,39 +27,7 @@ type LeaderboardSourceYaml = { users?: LeaderboardSourceEntry[]; }; -export type ScoredEntry = { - username: string; - name: string | null; - avatarUrl: string; - repoScore: number; - prScore: number; - contributionScore: number; - finalScore: number; - impactRank: number; -}; - -export type LeaderboardResult = { - title: string; - totalFromSource: number; - scored: ScoredEntry[]; - errors: string[]; -}; - -export type LeaderboardMeta = { - newUsers: number; - refreshedUsers: number; - skippedExisting: number; - errors: number; - totalInDb: number; - failedUsernames?: string[]; - totalFetchTime?: number; - successfulFetches?: number; - userFetchErrors?: { username: string; errors: { part: string; reason: string }[] }[]; -}; - -export type CalculateLeaderboardResponse = LeaderboardResult & { - _meta: LeaderboardMeta; -}; +export type { CalculateLeaderboardResponse, LeaderboardMeta, LeaderboardResult, ScoredEntry }; // ─── Helpers ──────────────────────────────────────────────────────────── diff --git a/src/features/leaderboard/services/index.ts b/src/features/leaderboard/services/index.ts new file mode 100644 index 0000000..fa398f5 --- /dev/null +++ b/src/features/leaderboard/services/index.ts @@ -0,0 +1,2 @@ +export * from "./leaderboard-service"; +export * from "./calculate-leaderboard"; diff --git a/lib/leaderboard.ts b/src/features/leaderboard/services/leaderboard-service.ts similarity index 87% rename from lib/leaderboard.ts rename to src/features/leaderboard/services/leaderboard-service.ts index 22aa93e..525c5e4 100644 --- a/lib/leaderboard.ts +++ b/src/features/leaderboard/services/leaderboard-service.ts @@ -1,23 +1,8 @@ -import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache-store"; -import { getDatabaseStore } from "@/lib/db-store"; +import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache"; +import { getDatabaseStore } from "@/lib/db"; +import type { LeaderboardResult, ScoredLeaderboardEntry } from "../types"; -export type ScoredLeaderboardEntry = { - username: string; - name: string | null; - avatarUrl: string; - repoScore: number; - prScore: number; - contributionScore: number; - finalScore: number; - impactRank: number; -}; - -export type LeaderboardResult = { - title: string; - totalFromSource: number; - scored: ScoredLeaderboardEntry[]; - errors: string[]; -}; +export type { LeaderboardResult, ScoredLeaderboardEntry }; function buildLeaderboardCacheKey(country: string, namespace: string): string { return `${namespace}:leaderboard:${country.trim().toLowerCase()}`; diff --git a/src/features/leaderboard/types.ts b/src/features/leaderboard/types.ts new file mode 100644 index 0000000..603c1da --- /dev/null +++ b/src/features/leaderboard/types.ts @@ -0,0 +1,40 @@ +export type CountryInfo = { + slug: string; + title: string; + isoCode?: string; + keywords?: string[]; +}; + +export type ScoredLeaderboardEntry = { + username: string; + name: string | null; + avatarUrl: string; + repoScore: number; + prScore: number; + contributionScore: number; + finalScore: number; + impactRank: number; +}; + +export type LeaderboardResult = { + title: string; + totalFromSource: number; + scored: ScoredLeaderboardEntry[]; + errors: string[]; +}; + +export type LeaderboardMeta = { + newUsers: number; + refreshedUsers: number; + skippedExisting: number; + errors: number; + totalInDb: number; + failedUsernames?: string[]; + totalFetchTime?: number; + successfulFetches?: number; + userFetchErrors?: { username: string; errors: { part: string; reason: string }[] }[]; +}; + +export type CalculateLeaderboardResponse = LeaderboardResult & { + _meta: LeaderboardMeta; +}; diff --git a/src/features/scoring/components/index.ts b/src/features/scoring/components/index.ts new file mode 100644 index 0000000..6ce610a --- /dev/null +++ b/src/features/scoring/components/index.ts @@ -0,0 +1,3 @@ +export * from "./scoring-methodology-flow"; +export * from "./scoring-methodology-section"; +export * from "./scoring-methodology-page-client"; diff --git a/components/scoring/scoring-methodology-flow.tsx b/src/features/scoring/components/scoring-methodology-flow.tsx similarity index 98% rename from components/scoring/scoring-methodology-flow.tsx rename to src/features/scoring/components/scoring-methodology-flow.tsx index f4d390b..357c5b6 100644 --- a/components/scoring/scoring-methodology-flow.tsx +++ b/src/features/scoring/components/scoring-methodology-flow.tsx @@ -1,7 +1,7 @@ "use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useTranslation } from "@/components/language-provider"; +import { useTranslation } from "@/components/providers/language-provider"; export const SCORING_FLOW_STEP_KEYS = [ "methodology.flow.step.collect", diff --git a/components/scoring-methodology-page-client.tsx b/src/features/scoring/components/scoring-methodology-page-client.tsx similarity index 91% rename from components/scoring-methodology-page-client.tsx rename to src/features/scoring/components/scoring-methodology-page-client.tsx index 04967e4..6e64585 100644 --- a/components/scoring-methodology-page-client.tsx +++ b/src/features/scoring/components/scoring-methodology-page-client.tsx @@ -2,11 +2,11 @@ import { ArrowLeft } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; -import { AppHeader } from "@/components/app-header"; -import { AppFooter } from "@/components/app-footer"; -import { useTranslation } from "@/components/language-provider"; -import { ScoringMethodologyFlow } from "@/components/scoring/scoring-methodology-flow"; -import { ScoringMethodologySection } from "@/components/scoring/scoring-methodology-section"; +import { AppHeader } from "@/components/layout/app-header"; +import { AppFooter } from "@/components/layout/app-footer"; +import { useTranslation } from "@/components/providers/language-provider"; +import { ScoringMethodologyFlow } from "./scoring-methodology-flow"; +import { ScoringMethodologySection } from "./scoring-methodology-section"; export function ScoringMethodologyPageClient() { const { t } = useTranslation(); diff --git a/components/scoring/scoring-methodology-section.tsx b/src/features/scoring/components/scoring-methodology-section.tsx similarity index 100% rename from components/scoring/scoring-methodology-section.tsx rename to src/features/scoring/components/scoring-methodology-section.tsx diff --git a/src/features/scoring/index.ts b/src/features/scoring/index.ts new file mode 100644 index 0000000..1271af8 --- /dev/null +++ b/src/features/scoring/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; +export * from "./services"; +export * from "./components"; diff --git a/src/features/scoring/services/index.ts b/src/features/scoring/services/index.ts new file mode 100644 index 0000000..da59e42 --- /dev/null +++ b/src/features/scoring/services/index.ts @@ -0,0 +1,2 @@ +export * from "./score-engine"; +export * from "./language-scoring"; diff --git a/lib/scoring/languageScoring.ts b/src/features/scoring/services/language-scoring.ts similarity index 98% rename from lib/scoring/languageScoring.ts rename to src/features/scoring/services/language-scoring.ts index 853e358..13f0fc0 100644 --- a/lib/scoring/languageScoring.ts +++ b/src/features/scoring/services/language-scoring.ts @@ -1,4 +1,4 @@ -import type { RepoLanguages } from "@/types/github"; +import type { RepoLanguages } from "@/lib/github"; const MAX_SELECTED_LANGUAGES = 5; diff --git a/lib/score.ts b/src/features/scoring/services/score-engine.ts similarity index 99% rename from lib/score.ts rename to src/features/scoring/services/score-engine.ts index fb995da..2961397 100644 --- a/lib/score.ts +++ b/src/features/scoring/services/score-engine.ts @@ -1,18 +1,18 @@ -import type { DiscussionNode, IssueNode, PullRequestNode, RepoNode } from "@/types/github"; +import type { DiscussionNode, IssueNode, PullRequestNode, RepoNode } from "@/lib/github"; import type { CommunityContributionDetail, PullRequestScoreDetail, RepoScoreDetail, ScoringExplanations, ScoringSignals, -} from "@/types/score"; +} from "../types"; import { getLanguageDistribution, getLanguageFactor, getLanguageMatch, getTopLanguages, normalizeSelectedLanguages, -} from "@/lib/scoring/languageScoring"; +} from "./language-scoring"; const MS_PER_DAY = 86_400_000; const FALLBACK_REFERENCE_DATE = "2026-01-01T00:00:00.000Z"; diff --git a/test/scoring/calculateUserScore.contribution.test.ts b/src/features/scoring/tests/calculateUserScore.contribution.test.ts similarity index 97% rename from test/scoring/calculateUserScore.contribution.test.ts rename to src/features/scoring/tests/calculateUserScore.contribution.test.ts index 934778d..dc6e12a 100644 --- a/test/scoring/calculateUserScore.contribution.test.ts +++ b/src/features/scoring/tests/calculateUserScore.contribution.test.ts @@ -1,14 +1,14 @@ import { describe, expect, test } from "vitest"; -import { calculateUserScore } from "@/lib/score"; +import { calculateUserScore } from "@/features/scoring"; import { makeDiscussion, makeIssue, makePullRequest, makeRepo, makeUserScoreInput, -} from "@/test/fixtures/github"; -import { expectedCommunityScore } from "@/test/helpers/score"; +} from "@/lib/github/tests/fixtures/github"; +import { expectedCommunityScore } from "./helpers/score"; describe("calculateUserScore - contribution scoring", () => { test("commits are not counted", () => { diff --git a/test/scoring/calculateUserScore.language.test.ts b/src/features/scoring/tests/calculateUserScore.language.test.ts similarity index 98% rename from test/scoring/calculateUserScore.language.test.ts rename to src/features/scoring/tests/calculateUserScore.language.test.ts index 1ff5c71..3219ee8 100644 --- a/test/scoring/calculateUserScore.language.test.ts +++ b/src/features/scoring/tests/calculateUserScore.language.test.ts @@ -1,13 +1,12 @@ import { describe, expect, test } from "vitest"; -import { calculateUserScore } from "@/lib/score"; -import { getLanguageFactor } from "@/lib/scoring/languageScoring"; +import { calculateUserScore, getLanguageFactor } from "@/features/scoring"; import { makePullRequest, makeRepo, makeRepoLanguages, makeUserScoreInput, -} from "@/test/fixtures/github"; +} from "@/lib/github/tests/fixtures/github"; describe("calculateUserScore - language scoring", () => { test("languageScores is undefined when selectedLanguages is empty", () => { diff --git a/test/scoring/calculateUserScore.pr.test.ts b/src/features/scoring/tests/calculateUserScore.pr.test.ts similarity index 97% rename from test/scoring/calculateUserScore.pr.test.ts rename to src/features/scoring/tests/calculateUserScore.pr.test.ts index ef38673..182fd13 100644 --- a/test/scoring/calculateUserScore.pr.test.ts +++ b/src/features/scoring/tests/calculateUserScore.pr.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "vitest"; -import { calculateUserScore } from "@/lib/score"; -import { makePullRequest, makeUserScoreInput } from "@/test/fixtures/github"; -import { expectedPRScore, sumPRScores, sumWithDiminishingReturns } from "@/test/helpers/score"; +import { calculateUserScore } from "@/features/scoring"; +import { makePullRequest, makeUserScoreInput } from "@/lib/github/tests/fixtures/github"; +import { expectedPRScore, sumPRScores, sumWithDiminishingReturns } from "./helpers/score"; describe("calculateUserScore - pull request scoring", () => { test("unmerged PRs are ignored", () => { diff --git a/test/scoring/calculateUserScore.repo.test.ts b/src/features/scoring/tests/calculateUserScore.repo.test.ts similarity index 95% rename from test/scoring/calculateUserScore.repo.test.ts rename to src/features/scoring/tests/calculateUserScore.repo.test.ts index 060d1f2..15cb933 100644 --- a/test/scoring/calculateUserScore.repo.test.ts +++ b/src/features/scoring/tests/calculateUserScore.repo.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "vitest"; -import { calculateUserScore } from "@/lib/score"; -import { makeRepo, makeUserScoreInput } from "@/test/fixtures/github"; -import { expectedRepoScore, sumRepoScores } from "@/test/helpers/score"; +import { calculateUserScore } from "@/features/scoring"; +import { makeRepo, makeUserScoreInput } from "@/lib/github/tests/fixtures/github"; +import { expectedRepoScore, sumRepoScores } from "./helpers/score"; describe("calculateUserScore - repository scoring", () => { test("empty repos return a zero repository score", () => { diff --git a/test/scoring/calculateUserScore.scenario.test.ts b/src/features/scoring/tests/calculateUserScore.scenario.test.ts similarity index 94% rename from test/scoring/calculateUserScore.scenario.test.ts rename to src/features/scoring/tests/calculateUserScore.scenario.test.ts index 81713a4..0582556 100644 --- a/test/scoring/calculateUserScore.scenario.test.ts +++ b/src/features/scoring/tests/calculateUserScore.scenario.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "vitest"; -import { calculateUserScore } from "@/lib/score"; -import { makeIssue, makePullRequest, makeRepo, makeUserScoreInput } from "@/test/fixtures/github"; +import { calculateUserScore } from "@/features/scoring"; +import { + makeIssue, + makePullRequest, + makeRepo, + makeUserScoreInput, +} from "@/lib/github/tests/fixtures/github"; describe("calculateUserScore - final score behavior", () => { test("final score uses 45/45/10 weights", () => { diff --git a/test/helpers/score.ts b/src/features/scoring/tests/helpers/score.ts similarity index 99% rename from test/helpers/score.ts rename to src/features/scoring/tests/helpers/score.ts index f76d0c4..1178bf2 100644 --- a/test/helpers/score.ts +++ b/src/features/scoring/tests/helpers/score.ts @@ -1,4 +1,4 @@ -import type { DiscussionNode, IssueNode, PullRequestNode, RepoNode } from "@/types/github"; +import type { DiscussionNode, IssueNode, PullRequestNode, RepoNode } from "@/lib/github"; const MS_PER_DAY = 86_400_000; const DEFAULT_REFERENCE_DATE = new Date("2026-05-10T00:00:00.000Z"); diff --git a/test/scoring/languageScoring.helpers.test.ts b/src/features/scoring/tests/languageScoring.helpers.test.ts similarity index 96% rename from test/scoring/languageScoring.helpers.test.ts rename to src/features/scoring/tests/languageScoring.helpers.test.ts index 09dcf32..dc9d063 100644 --- a/test/scoring/languageScoring.helpers.test.ts +++ b/src/features/scoring/tests/languageScoring.helpers.test.ts @@ -5,8 +5,8 @@ import { getLanguageMatch, getTopLanguages, normalizeSelectedLanguages, -} from "@/lib/scoring/languageScoring"; -import { makeRepoLanguages } from "@/test/fixtures/github"; +} from "@/features/scoring"; +import { makeRepoLanguages } from "@/lib/github/tests/fixtures/github"; describe("language scoring helpers", () => { test("normalizeSelectedLanguages removes duplicates, trims, and lowercases", () => { diff --git a/test/ui/scoring-methodology.test.ts b/src/features/scoring/tests/scoring-methodology.test.ts similarity index 85% rename from test/ui/scoring-methodology.test.ts rename to src/features/scoring/tests/scoring-methodology.test.ts index 8354cf9..25bb264 100644 --- a/test/ui/scoring-methodology.test.ts +++ b/src/features/scoring/tests/scoring-methodology.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import en from "@/locales/en.json"; import ar from "@/locales/ar.json"; -import { SCORING_FLOW_STEP_KEYS } from "@/components/scoring/scoring-methodology-flow"; +import { SCORING_FLOW_STEP_KEYS } from "../components/scoring-methodology-flow"; const REQUIRED_METHODLOGY_KEYS = [ "methodology.back", @@ -49,13 +49,20 @@ describe("scoring methodology localization", () => { }); test("result dashboard links to methodology page", () => { - const dashboardPath = resolve(process.cwd(), "components", "result-dashboard.tsx"); + const dashboardPath = resolve( + process.cwd(), + "src", + "features", + "comparison", + "components", + "result-dashboard.tsx", + ); const source = readFileSync(dashboardPath, "utf8"); expect(source.includes("/scoring-methodology")).toBe(true); }); test("methodology route file exists", () => { - const routePath = resolve(process.cwd(), "app", "scoring-methodology", "page.tsx"); + const routePath = resolve(process.cwd(), "src", "app", "scoring-methodology", "page.tsx"); const source = readFileSync(routePath, "utf8"); expect(source.includes("ScoringMethodologyPage")).toBe(true); }); diff --git a/types/score.ts b/src/features/scoring/types.ts similarity index 56% rename from types/score.ts rename to src/features/scoring/types.ts index 1f7b750..a5d4c69 100644 --- a/types/score.ts +++ b/src/features/scoring/types.ts @@ -1,4 +1,4 @@ -import { DiscussionNode, IssueNode, PullRequestNode, RepoNode } from "./github"; +import type { DiscussionNode, IssueNode, PullRequestNode, RepoNode } from "@/lib/github"; export type RepoScoreDetail = { repo: RepoNode; @@ -41,3 +41,28 @@ export type ScoringExplanations = { overall: string[]; language?: string[]; }; + +export type LanguageScore = { + language: string; + repoScore: number; + prScore: number; + contributionScore: number; + finalScore: number; +}; + +export type ScoreBreakdown = { + repoScore: number; + prScore: number; + contributionScore: number; + finalScore: number; + normalizedRepoScore: number; + normalizedPRScore: number; + normalizedContributionScore: number; + normalizedFinalScore: number; + topRepos: RepoScoreDetail[]; + topPullRequests: PullRequestScoreDetail[]; + topCommunityContributions: CommunityContributionDetail[]; + languageScores: LanguageScore[]; + signals: ScoringSignals; + explanations: ScoringExplanations; +}; diff --git a/lib/cache-store.ts b/src/lib/cache/cache-store.ts similarity index 100% rename from lib/cache-store.ts rename to src/lib/cache/cache-store.ts diff --git a/src/lib/cache/index.ts b/src/lib/cache/index.ts new file mode 100644 index 0000000..db2d72c --- /dev/null +++ b/src/lib/cache/index.ts @@ -0,0 +1 @@ +export * from "./cache-store"; diff --git a/lib/db-store.ts b/src/lib/db/db-store.ts similarity index 100% rename from lib/db-store.ts rename to src/lib/db/db-store.ts diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts new file mode 100644 index 0000000..d7ec0be --- /dev/null +++ b/src/lib/db/index.ts @@ -0,0 +1 @@ +export * from "./db-store"; diff --git a/lib/country-flags.ts b/src/lib/geo/country-flags.ts similarity index 74% rename from lib/country-flags.ts rename to src/lib/geo/country-flags.ts index 0724fcc..b8176ea 100644 --- a/lib/country-flags.ts +++ b/src/lib/geo/country-flags.ts @@ -7,9 +7,6 @@ type CountryEntry = { keywords: string[]; }; -/** - * Build the slug β†’ ISO 3166-1 alpha-2 mapping from data/countries.json. - */ const SLUG_TO_ISO: Record = {}; for (const entry of countries as CountryEntry[]) { if (entry.isoCode) { @@ -17,9 +14,6 @@ for (const entry of countries as CountryEntry[]) { } } -/** - * Get the ISO 3166-1 alpha-2 code for a country slug. - */ export function getCountryCode(slug: string): string | null { return SLUG_TO_ISO[slug] ?? null; } diff --git a/src/lib/geo/index.ts b/src/lib/geo/index.ts new file mode 100644 index 0000000..50df4e6 --- /dev/null +++ b/src/lib/geo/index.ts @@ -0,0 +1,2 @@ +export * from "./location-detector"; +export * from "./country-flags"; diff --git a/src/lib/geo/location-detector.ts b/src/lib/geo/location-detector.ts new file mode 100644 index 0000000..2183e96 --- /dev/null +++ b/src/lib/geo/location-detector.ts @@ -0,0 +1,44 @@ +import countries from "@/data/countries.json"; + +type CountryEntry = { + slug: string; + title: string; + isoCode: string; + keywords: string[]; +}; + +type CountryMapping = { + slug: string; + keywords: string[]; +}; + +const COUNTRY_MAPPINGS: CountryMapping[] = (countries as CountryEntry[]) + .filter((c) => c.keywords.length > 0) + .map((c) => ({ + slug: c.slug, + keywords: c.keywords, + })); + +function matchesKeyword(text: string, keyword: string): boolean { + const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`(^|[^a-z])${escaped}([^a-z]|$)`, "i"); + return regex.test(text); +} + +export function detectCountry(location: string | null): string | null { + if (!location || !location.trim()) { + return null; + } + + const normalized = location.trim().toLowerCase(); + + for (const mapping of COUNTRY_MAPPINGS) { + for (const keyword of mapping.keywords) { + if (matchesKeyword(normalized, keyword)) { + return mapping.slug; + } + } + } + + return null; +} diff --git a/lib/github.ts b/src/lib/github/github-client.ts similarity index 98% rename from lib/github.ts rename to src/lib/github/github-client.ts index 3a2f565..42cbea9 100644 --- a/lib/github.ts +++ b/src/lib/github/github-client.ts @@ -3,15 +3,9 @@ import { getCacheConfigFromEnv, type CacheConfig, type CacheStore, -} from "@/lib/cache-store"; -import { GitHubGraphQLClient } from "@/lib/github-graphql-client"; -import type { - DiscussionNode, - GitHubUserData, - IssueNode, - PullRequestNode, - RepoNode, -} from "@/types/github"; +} from "@/lib/cache"; +import { GitHubGraphQLClient } from "./github-graphql-client"; +import type { DiscussionNode, GitHubUserData, IssueNode, PullRequestNode, RepoNode } from "./types"; export type UserFetchMetrics = { duration: number; @@ -791,7 +785,7 @@ export async function getUserData( // ── 2. Check PostgreSQL ──────────────────────────────────────────────── try { - const { getDatabaseStore } = await import("@/lib/db-store"); + const { getDatabaseStore } = await import("@/lib/db"); const db = getDatabaseStore(); const row = await db.getUser(normalizedUsername); @@ -829,8 +823,9 @@ export async function getUserData( // Upsert into PostgreSQL try { - const { getDatabaseStore: getDb } = await import("@/lib/db-store"); - const { calculateUserScore: calcScore } = await import("@/lib/score"); + const { getDatabaseStore: getDb } = await import("@/lib/db"); + const { calculateUserScore: calcScore } = + await import("@/features/scoring/services/score-engine"); const db = getDb(); const score = calcScore(fresh, normalizedUsername); diff --git a/lib/github-graphql-client.ts b/src/lib/github/github-graphql-client.ts similarity index 99% rename from lib/github-graphql-client.ts rename to src/lib/github/github-graphql-client.ts index fcd2c63..6b89b38 100644 --- a/lib/github-graphql-client.ts +++ b/src/lib/github/github-graphql-client.ts @@ -1,4 +1,4 @@ -import type { SafeApiError } from "@/types/api-response"; +import type { SafeApiError } from "@/types/api"; const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"; const SECONDARY_FALLBACK_WAIT_MS = 60_000; diff --git a/src/lib/github/index.ts b/src/lib/github/index.ts new file mode 100644 index 0000000..e950521 --- /dev/null +++ b/src/lib/github/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; +export * from "./github-graphql-client"; +export * from "./github-client"; diff --git a/test/fixtures/github.ts b/src/lib/github/tests/fixtures/github.ts similarity index 99% rename from test/fixtures/github.ts rename to src/lib/github/tests/fixtures/github.ts index 4fc98c0..6d59687 100644 --- a/test/fixtures/github.ts +++ b/src/lib/github/tests/fixtures/github.ts @@ -4,7 +4,7 @@ import type { PullRequestNode, RepoLanguages, RepoNode, -} from "@/types/github"; +} from "@/lib/github"; export type UserScoreInput = { repos: RepoNode[]; diff --git a/test/github/github-cache.test.ts b/src/lib/github/tests/github-cache.test.ts similarity index 99% rename from test/github/github-cache.test.ts rename to src/lib/github/tests/github-cache.test.ts index b661235..f6286a7 100644 --- a/test/github/github-cache.test.ts +++ b/src/lib/github/tests/github-cache.test.ts @@ -10,8 +10,8 @@ import { getCacheTtlSecondsFromEnv, MAX_CACHE_TTL_SECONDS, type CacheStore, -} from "@/lib/cache-store"; -import type { GitHubUserData } from "@/types/github"; +} from "@/lib/cache"; +import type { GitHubUserData } from "@/lib/github"; type ExecuteCall = { operationName: string; diff --git a/test/github/github-graphql-client.test.ts b/src/lib/github/tests/github-graphql-client.test.ts similarity index 99% rename from test/github/github-graphql-client.test.ts rename to src/lib/github/tests/github-graphql-client.test.ts index bd6088f..ccac709 100644 --- a/test/github/github-graphql-client.test.ts +++ b/src/lib/github/tests/github-graphql-client.test.ts @@ -7,7 +7,7 @@ import { classifyGitHubError, computeRetryDelayMs, parseRateLimitHeaders, -} from "@/lib/github-graphql-client"; +} from "@/lib/github"; function makeHeaders(values: Record): Headers { const headers = new Headers(); diff --git a/lib/github.test.ts b/src/lib/github/tests/github.test.ts similarity index 94% rename from lib/github.test.ts rename to src/lib/github/tests/github.test.ts index 430ba42..a38e76e 100644 --- a/lib/github.test.ts +++ b/src/lib/github/tests/github.test.ts @@ -1,7 +1,7 @@ import "dotenv/config"; import { describe, expect, it } from "vitest"; -import { parseCountEnv } from "./github"; +import { parseCountEnv } from "@/lib/github"; describe("parseCountEnv", () => { it("uses fallback for undefined", () => { diff --git a/types/github.ts b/src/lib/github/types.ts similarity index 100% rename from types/github.ts rename to src/lib/github/types.ts diff --git a/lib/i18n-core.ts b/src/lib/i18n/core.ts similarity index 88% rename from lib/i18n-core.ts rename to src/lib/i18n/core.ts index 28c4cbf..35a9c76 100644 --- a/lib/i18n-core.ts +++ b/src/lib/i18n/core.ts @@ -19,9 +19,6 @@ export function parseAcceptLanguage( ): T { if (!header) return fallback; - // Parse "lang;q=0.5" entries, drop q=0 (explicit rejection), and pick - // the highest-q supported language. RFC 9110 Β§12.5.4: missing q - // defaults to 1.0. A header like "en;q=0.1, ar;q=1" must select ar. const parsed: { tag: string; primary: string; q: number }[] = []; for (const part of header.split(",")) { const segments = part.trim().split(";"); diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts new file mode 100644 index 0000000..cef58f2 --- /dev/null +++ b/src/lib/i18n/index.ts @@ -0,0 +1,3 @@ +export * from "./core"; +export * from "./types"; +export * from "./provider-hook"; diff --git a/lib/i18n.ts b/src/lib/i18n/provider-hook.ts similarity index 91% rename from lib/i18n.ts rename to src/lib/i18n/provider-hook.ts index 3478459..72b4434 100644 --- a/lib/i18n.ts +++ b/src/lib/i18n/provider-hook.ts @@ -1,6 +1,8 @@ +"use client"; + import { useCallback, useEffect, useMemo, useState } from "react"; -import arMessages from "../locales/ar.json"; -import enMessages from "../locales/en.json"; +import arMessages from "@/locales/ar.json"; +import enMessages from "@/locales/en.json"; import { DEFAULT_LOCALE, LOCALE_COOKIE, @@ -8,17 +10,7 @@ import { localeMeta, supportedLocales, type Locale, -} from "./i18n-core"; - -export { - DEFAULT_LOCALE, - LOCALE_COOKIE, - getLocaleDir, - isSupportedLocale, - parseAcceptLanguage, - supportedLocales, - type Locale, -} from "./i18n-core"; +} from "./core"; type Messages = Record; diff --git a/types/i18n.ts b/src/lib/i18n/types.ts similarity index 86% rename from types/i18n.ts rename to src/lib/i18n/types.ts index 2255d68..7e813ea 100644 --- a/types/i18n.ts +++ b/src/lib/i18n/types.ts @@ -1,4 +1,4 @@ -import { Locale } from "../lib/i18n"; +import type { Locale } from "./core"; export type I18nContextValue = { locale: Locale; diff --git a/src/lib/logger/index.ts b/src/lib/logger/index.ts new file mode 100644 index 0000000..41c7bf2 --- /dev/null +++ b/src/lib/logger/index.ts @@ -0,0 +1 @@ +export * from "./logger"; diff --git a/lib/logger.ts b/src/lib/logger/logger.ts similarity index 100% rename from lib/logger.ts rename to src/lib/logger/logger.ts diff --git a/src/lib/seo/index.ts b/src/lib/seo/index.ts new file mode 100644 index 0000000..b81dc63 --- /dev/null +++ b/src/lib/seo/index.ts @@ -0,0 +1 @@ +export * from "./seo"; diff --git a/lib/seo.ts b/src/lib/seo/seo.ts similarity index 100% rename from lib/seo.ts rename to src/lib/seo/seo.ts diff --git a/test/seo/seo.test.ts b/src/lib/seo/tests/seo.test.ts similarity index 100% rename from test/seo/seo.test.ts rename to src/lib/seo/tests/seo.test.ts diff --git a/locales/ar.json b/src/locales/ar.json similarity index 100% rename from locales/ar.json rename to src/locales/ar.json diff --git a/locales/en.json b/src/locales/en.json similarity index 100% rename from locales/en.json rename to src/locales/en.json diff --git a/middleware.ts b/src/middleware.ts similarity index 97% rename from middleware.ts rename to src/middleware.ts index f0f6adb..d4cbd14 100644 --- a/middleware.ts +++ b/src/middleware.ts @@ -6,7 +6,7 @@ import { isSupportedLocale, parseAcceptLanguage, supportedLocales, -} from "./lib/i18n-core"; +} from "@/lib/i18n/core"; export function middleware(request: NextRequest) { const response = NextResponse.next(); diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..d78a4a8 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,34 @@ +export type SafeApiErrorCode = + | "RATE_LIMITED" + | "TEMPORARY_THROTTLE" + | "GITHUB_TIMEOUT" + | "GITHUB_RESOURCE_LIMIT" + | "GITHUB_AUTH" + | "GITHUB_NOT_FOUND" + | "NETWORK" + | "UNKNOWN"; + +export type SafeApiRateLimit = { + limit?: number; + remaining?: number; + used?: number; + resetAt?: number; + resource?: string; +}; + +export type SafeApiError = { + code: SafeApiErrorCode; + message: string; + retryAfterSeconds?: number; + targetUsernames?: string[]; + rateLimit?: SafeApiRateLimit; +}; + +export type ClientSafeError = Pick; + +export type ApiResponse = { + success: boolean; + data?: T; + error?: string; + errorDetails?: SafeApiError; +}; diff --git a/src/types/models.ts b/src/types/models.ts new file mode 100644 index 0000000..9276507 --- /dev/null +++ b/src/types/models.ts @@ -0,0 +1,15 @@ +export interface BaseDeveloper { + username: string; + name: string | null; + avatarUrl: string; + location?: string | null; +} + +export interface BaseRepository { + name: string; + owner: string; + url: string; + stargazerCount?: number; + forkCount?: number; + primaryLanguage?: string | null; +} diff --git a/src/types/next.ts b/src/types/next.ts new file mode 100644 index 0000000..d7ba558 --- /dev/null +++ b/src/types/next.ts @@ -0,0 +1,12 @@ +export type PageProps< + TParams = Record, + TSearchParams = Record, +> = { + params: Promise; + searchParams?: Promise; +}; + +export type LayoutProps> = { + children: React.ReactNode; + params?: Promise; +}; diff --git a/lib/utils.ts b/src/utils/cn.ts similarity index 100% rename from lib/utils.ts rename to src/utils/cn.ts diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..963c6b2 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./cn"; diff --git a/tailwind.config.ts b/tailwind.config.ts index 6bb31bb..351d2d9 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -2,7 +2,7 @@ import type { Config } from "tailwindcss"; const config: Config = { darkMode: ["class"], - content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}"], + content: ["./src/**/*.{ts,tsx}"], theme: { extend: { colors: { diff --git a/tsconfig.json b/tsconfig.json index 019858c..f78f547 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,7 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": ["./src/*"] } }, "include": [ diff --git a/types/api-response.ts b/types/api-response.ts deleted file mode 100644 index 56f1c87..0000000 --- a/types/api-response.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { UserResult } from "./user-result"; - -export type CompareWinner = { - username: string; - finalScoreDifference: number; - percentageDifference: number | null; -}; - -export type SafeApiError = { - code: - | "RATE_LIMITED" - | "TEMPORARY_THROTTLE" - | "GITHUB_TIMEOUT" - | "GITHUB_RESOURCE_LIMIT" - | "GITHUB_AUTH" - | "GITHUB_NOT_FOUND" - | "NETWORK" - | "UNKNOWN"; - message: string; - retryAfterSeconds?: number; - targetUsernames?: string[]; - rateLimit?: { - limit?: number; - remaining?: number; - used?: number; - resetAt?: number; - resource?: string; - }; -}; - -export type CompareInsights = { - summary: string; - keyDifferences: string[]; - user1Strengths: string[]; - user2Strengths: string[]; - recommendations: { - user1: string[]; - user2: string[]; - }; - confidenceNote: string; -}; - -export type ApiResponse = { - success: boolean; - scoreVersion?: string; - users?: UserResult[]; - winner?: CompareWinner; - languageWinner?: { - username: string; - finalScoreDifference: number; - percentageDifference: number | null; - selectedLanguages: string[]; - }; - insights?: CompareInsights; - error?: string; - errorDetails?: SafeApiError; -}; diff --git a/types/leaderboard.ts b/types/leaderboard.ts deleted file mode 100644 index e75c2f1..0000000 --- a/types/leaderboard.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type CountryInfo = { - slug: string; - title: string; -}; diff --git a/vitest.config.ts b/vitest.config.ts index fc79801..3cbd58d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,7 @@ const rootDir = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ resolve: { alias: { - "@": resolve(rootDir, "."), + "@": resolve(rootDir, "./src"), }, }, test: { From c7cff5b6a4c12ce0ff189f88dbdcfdbe16b77649 Mon Sep 17 00:00:00 2001 From: Osama Mabkhot <99215291+O2sa@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:10:06 +0300 Subject: [PATCH 2/5] refactor(ui): unify work cards, add github language colors, and optimize responsive layout - Move and unify shared work cards (RepoCardItem, PullRequestCardItem, CommunityCardItem, ScoreCard) under global src/components/cards/ - Fix client/server barrel boundary separation in feature index exports to avoid leaking server-only dependencies (pg, redis, dns) into client bundles - Restructure Profile Top Work section into a responsive 1-column (mobile) and max 2-column (desktop) grid with balanced community card spanning - Enable text wrapping with break-words leading-snug for repository and PR titles to prevent awkward truncation - Add GitHub Linguist language color palette supporting 150+ programming languages with alias normalization and deterministic HSL fallback - Update LanguageBreakdown and SelectedLanguageRow components to render official GitHub color bars and indicators - Add comprehensive unit test suite for language colors (15 suites / 117 tests passing) --- scripts/calculate-next-country.ts | 2 +- src/app/api/compare/route.ts | 2 +- src/app/api/leaderboard/route.ts | 2 +- src/app/api/user/[username]/route.ts | 2 +- src/app/leaderboard/[country]/page.tsx | 3 +- src/app/user/[username]/page.tsx | 3 +- src/components/cards/community-card-item.tsx | 85 ++++ src/components/cards/index.ts | 5 + src/components/cards/pr-card-item.tsx | 107 +++++ src/components/cards/repo-card-item.tsx | 103 +++++ .../cards}/score-card.tsx | 4 +- src/components/cards/work-card-helpers.tsx | 173 ++++++++ src/components/index.ts | 1 + .../components/result-dashboard.tsx | 2 +- .../comparison/components/top-list.tsx | 379 ++---------------- src/features/comparison/index.ts | 2 +- src/features/developer/components/index.ts | 1 - .../components/user-profile-client.tsx | 276 ++----------- .../components/user-profile-skeleton.tsx | 4 +- src/features/developer/index.ts | 1 - .../developer/tests/user.route.test.ts | 2 +- src/features/leaderboard/index.ts | 1 - src/lib/languages/github-colors.ts | 226 +++++++++++ src/lib/languages/index.ts | 1 + .../languages/tests/language-colors.test.ts | 45 +++ 25 files changed, 827 insertions(+), 605 deletions(-) create mode 100644 src/components/cards/community-card-item.tsx create mode 100644 src/components/cards/index.ts create mode 100644 src/components/cards/pr-card-item.tsx create mode 100644 src/components/cards/repo-card-item.tsx rename src/{features/developer/components => components/cards}/score-card.tsx (97%) create mode 100644 src/components/cards/work-card-helpers.tsx create mode 100644 src/lib/languages/github-colors.ts create mode 100644 src/lib/languages/index.ts create mode 100644 src/lib/languages/tests/language-colors.test.ts diff --git a/scripts/calculate-next-country.ts b/scripts/calculate-next-country.ts index fd59e72..d3ca2eb 100644 --- a/scripts/calculate-next-country.ts +++ b/scripts/calculate-next-country.ts @@ -20,7 +20,7 @@ import "dotenv/config"; import { getDatabaseStore } from "@/lib/db"; -import { calculateLeaderboard } from "@/features/leaderboard"; +import { calculateLeaderboard } from "@/features/leaderboard/services"; import { logger } from "@/lib/logger"; let activeCountrySlug: string | null = null; diff --git a/src/app/api/compare/route.ts b/src/app/api/compare/route.ts index 3a08b75..b1c11cd 100644 --- a/src/app/api/compare/route.ts +++ b/src/app/api/compare/route.ts @@ -6,7 +6,7 @@ import { createComparisonInsights, parseSelectedLanguagesFromSearchParams, resolveLocale, -} from "@/features/comparison"; +} from "@/features/comparison/services"; import { toSafeApiError } from "@/lib/github"; import type { ClientSafeError, SafeApiError } from "@/types/api"; diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index 122200c..4da370f 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getLeaderboardResult } from "@/features/leaderboard"; +import { getLeaderboardResult } from "@/features/leaderboard/services"; export const runtime = "nodejs"; diff --git a/src/app/api/user/[username]/route.ts b/src/app/api/user/[username]/route.ts index ea74df7..888dc6d 100644 --- a/src/app/api/user/[username]/route.ts +++ b/src/app/api/user/[username]/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getUserProfile, UserFetchError } from "@/features/developer"; +import { getUserProfile, UserFetchError } from "@/features/developer/services"; import { normalizeSelectedLanguages } from "@/features/scoring"; import { toSafeApiError } from "@/lib/github"; import type { SafeApiError } from "@/types/api"; diff --git a/src/app/leaderboard/[country]/page.tsx b/src/app/leaderboard/[country]/page.tsx index ef5561b..c6a191b 100644 --- a/src/app/leaderboard/[country]/page.tsx +++ b/src/app/leaderboard/[country]/page.tsx @@ -1,7 +1,8 @@ import type { Metadata } from "next"; import countriesData from "@/data/countries.json"; import { JsonLd } from "@/components/seo/json-ld"; -import { getLeaderboardResult, CountryLeaderboardClient } from "@/features/leaderboard"; +import { CountryLeaderboardClient } from "@/features/leaderboard"; +import { getLeaderboardResult } from "@/features/leaderboard/services"; import { toAbsoluteUrl } from "@/lib/seo"; type CountryInfo = { diff --git a/src/app/user/[username]/page.tsx b/src/app/user/[username]/page.tsx index 0ec9e99..b78f5b0 100644 --- a/src/app/user/[username]/page.tsx +++ b/src/app/user/[username]/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { JsonLd } from "@/components/seo/json-ld"; -import { UserProfileClient, UserNotFoundCard, getUserProfile } from "@/features/developer"; +import { UserProfileClient, UserNotFoundCard } from "@/features/developer"; +import { getUserProfile } from "@/features/developer/services"; import { AppHeader } from "@/components/layout/app-header"; import { AppFooter } from "@/components/layout/app-footer"; import { toAbsoluteUrl } from "@/lib/seo"; diff --git a/src/components/cards/community-card-item.tsx b/src/components/cards/community-card-item.tsx new file mode 100644 index 0000000..fca8939 --- /dev/null +++ b/src/components/cards/community-card-item.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { MessageSquare, Star } from "lucide-react"; +import { useTranslation } from "@/components/providers/language-provider"; +import type { UserResult } from "@/features/developer/types"; +import { StatChip } from "./work-card-helpers"; + +export type CommunityCardItemProps = { + item: NonNullable[number]; + rankIndex?: number; + showRank?: boolean; + className?: string; +}; + +export function CommunityCardItem({ + item, + rankIndex, + showRank = true, + className = "", +}: CommunityCardItemProps) { + const { t } = useTranslation(); + const itemTitle = item.title || t("untitled"); + + return ( +
+
+
+
+ {showRank && typeof rankIndex === "number" ? ( + + #{rankIndex + 1} + + ) : null} + + + {item.type === "issue" ? t("community.issue") : t("community.discussion")} + +
+ + {item.url ? ( + + {itemTitle} + + ) : ( +

{itemTitle}

+ )} + +

+ {item.repo || t("unknown.repo")} +

+ +
+ } + label={t("topwork.stars")} + value={item.stars ?? 0} + /> + } + label={t("community.comments")} + value={item.comments ?? 0} + /> +
+
+ +
+

{item.score ?? 0}

+

+ {t("comparsion.score")} +

+
+
+
+ ); +} diff --git a/src/components/cards/index.ts b/src/components/cards/index.ts new file mode 100644 index 0000000..cc520f6 --- /dev/null +++ b/src/components/cards/index.ts @@ -0,0 +1,5 @@ +export * from "./work-card-helpers"; +export * from "./repo-card-item"; +export * from "./pr-card-item"; +export * from "./community-card-item"; +export * from "./score-card"; diff --git a/src/components/cards/pr-card-item.tsx b/src/components/cards/pr-card-item.tsx new file mode 100644 index 0000000..909a6cb --- /dev/null +++ b/src/components/cards/pr-card-item.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { ArrowDown, ArrowUp, Star } from "lucide-react"; +import { useTranslation } from "@/components/providers/language-provider"; +import type { UserResult } from "@/features/developer/types"; +import { + formatLanguageMatch, + LanguageBreakdown, + SelectedLanguageRow, + StatChip, +} from "./work-card-helpers"; + +export type PullRequestCardItemProps = { + pr: UserResult["topPullRequests"][number]; + rankIndex?: number; + selectedLanguages?: string[]; + showRank?: boolean; + className?: string; +}; + +export function PullRequestCardItem({ + pr, + rankIndex, + selectedLanguages, + showRank = true, + className = "", +}: PullRequestCardItemProps) { + const { t } = useTranslation(); + const prTitle = pr.title || t("untitled"); + const targetRepo = pr.repo || t("unknown.repo"); + + return ( +
+
+
+
+ {showRank && typeof rankIndex === "number" ? ( + + #{rankIndex + 1} + + ) : null} + + {pr.url ? ( + + {prTitle} + + ) : ( +

{prTitle}

+ )} +
+ +

+ {t("topwork.inRepo", { repo: targetRepo })} +

+ +
+ } + label={t("topwork.pr.repo.stars")} + value={pr.stars ?? 0} + /> +
+ + +{pr.additions ?? 0} + + / + + -{pr.deletions ?? 0} + +
+
+ + + + {selectedLanguages && selectedLanguages.length > 0 ? ( + + ) : null} + + {typeof pr.languageMatch === "number" ? ( +

+ {t("language.match")}: {formatLanguageMatch(pr.languageMatch)} +

+ ) : null} +
+ +
+

{pr.score ?? 0}

+

+ {t("comparsion.score")} +

+
+
+
+ ); +} diff --git a/src/components/cards/repo-card-item.tsx b/src/components/cards/repo-card-item.tsx new file mode 100644 index 0000000..04f65e6 --- /dev/null +++ b/src/components/cards/repo-card-item.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { Eye, GitFork, Star } from "lucide-react"; +import { useTranslation } from "@/components/providers/language-provider"; +import type { UserResult } from "@/features/developer/types"; +import { + formatLanguageMatch, + LanguageBreakdown, + SelectedLanguageRow, + StatChip, +} from "./work-card-helpers"; + +export type RepoCardItemProps = { + repo: UserResult["topRepos"][number]; + rankIndex?: number; + selectedLanguages?: string[]; + showRank?: boolean; + className?: string; +}; + +export function RepoCardItem({ + repo, + rankIndex, + selectedLanguages, + showRank = true, + className = "", +}: RepoCardItemProps) { + const { t } = useTranslation(); + const repoName = repo.name || t("untitled"); + + return ( +
+
+
+
+ {showRank && typeof rankIndex === "number" ? ( + + #{rankIndex + 1} + + ) : null} + + {repo.url ? ( + + {repoName} + + ) : ( +

{repoName}

+ )} +
+ +
+ } + label={t("topwork.stars")} + value={repo.stars ?? 0} + /> + } + label={t("topwork.forks")} + value={repo.forks ?? 0} + /> + } + label={t("topwork.watchers")} + value={repo.watchers ?? 0} + /> +
+ + + + {selectedLanguages && selectedLanguages.length > 0 ? ( + + ) : null} + + {typeof repo.languageMatch === "number" ? ( +

+ {t("language.match")}: {formatLanguageMatch(repo.languageMatch)} +

+ ) : null} +
+ +
+

{repo.score ?? 0}

+

+ {t("comparsion.score")} +

+
+
+
+ ); +} diff --git a/src/features/developer/components/score-card.tsx b/src/components/cards/score-card.tsx similarity index 97% rename from src/features/developer/components/score-card.tsx rename to src/components/cards/score-card.tsx index be972f7..2bf8927 100644 --- a/src/features/developer/components/score-card.tsx +++ b/src/components/cards/score-card.tsx @@ -1,8 +1,10 @@ +"use client"; + import { cn } from "@/utils/cn"; import { useTranslation } from "@/components/providers/language-provider"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -type ScoreCardProps = { +export type ScoreCardProps = { title: string; rawValue: number; normalizedValue?: number; diff --git a/src/components/cards/work-card-helpers.tsx b/src/components/cards/work-card-helpers.tsx new file mode 100644 index 0000000..e4edd8b --- /dev/null +++ b/src/components/cards/work-card-helpers.tsx @@ -0,0 +1,173 @@ +"use client"; + +import type { ReactNode } from "react"; +import type { UserResult } from "@/features/developer/types"; + +export type LanguageEntry = { + name: string; + percentage: number; +}; + +export type LanguageMeta = { + languageMatch?: number; + topLanguages?: LanguageEntry[]; +}; + +export function formatLanguageMatch(value?: number): string { + if (value === undefined) { + return "N/A"; + } + return `${Math.round(value * 100)}%`; +} + +import { getLanguageColor, normalizeLanguageName } from "@/lib/languages"; + +export { getLanguageColor, normalizeLanguageName }; + +export function StatChip({ + icon, + label, + value, +}: { + icon: ReactNode; + label: string; + value: number | string; +}) { + return ( +
+ {icon} + {label} + {value} +
+ ); +} + +export function LanguageBreakdown({ topLanguages }: { topLanguages?: LanguageEntry[] }) { + if (!topLanguages || topLanguages.length === 0) { + return null; + } + + const normalized = topLanguages.slice(0, 5).filter((language) => language.percentage > 0); + + if (normalized.length === 0) { + return null; + } + + return ( +
+
+ {normalized.map((language) => { + const color = getLanguageColor(language.name); + return ( +
+ ); + })} +
+
+ {normalized.map((language) => { + const color = getLanguageColor(language.name); + return ( + + + {language.name} + {language.percentage}% + + ); + })} +
+
+ ); +} + +export function SelectedLanguageRow({ + topLanguages, + selectedLanguages, + label, +}: { + topLanguages?: LanguageEntry[]; + selectedLanguages?: string[]; + label: string; +}) { + if ( + !topLanguages || + topLanguages.length === 0 || + !selectedLanguages || + selectedLanguages.length === 0 + ) { + return null; + } + + const languageMap = new Map(); + for (const language of topLanguages) { + languageMap.set(normalizeLanguageName(language.name), language.percentage); + } + + return ( +
+ {label}: + {selectedLanguages.map((language) => { + const percentage = languageMap.get(normalizeLanguageName(language)) ?? 0; + const color = getLanguageColor(language); + return ( + + + + {language} {percentage}% + + + ); + })} +
+ ); +} + +export function findRepoLanguageMeta( + user: UserResult, + repo: UserResult["topRepos"][number], +): LanguageMeta { + const languageRepos = user.languageScores?.topRepos ?? []; + const byUrl = repo.url + ? languageRepos.find((item) => item.url && item.url === repo.url) + : undefined; + const byName = languageRepos.find((item) => item.name === repo.name); + const match = byUrl ?? byName; + + return { + languageMatch: match?.languageMatch ?? repo.languageMatch, + topLanguages: match?.topLanguages ?? repo.topLanguages, + }; +} + +export function findPrLanguageMeta( + user: UserResult, + pr: UserResult["topPullRequests"][number], +): LanguageMeta { + const languagePrs = user.languageScores?.topPullRequests ?? []; + const byUrl = pr.url ? languagePrs.find((item) => item.url && item.url === pr.url) : undefined; + const byTitleAndRepo = languagePrs.find( + (item) => item.title === pr.title && item.repo === pr.repo, + ); + const match = byUrl ?? byTitleAndRepo; + + return { + languageMatch: match?.languageMatch ?? pr.languageMatch, + topLanguages: match?.topLanguages ?? pr.topLanguages, + }; +} diff --git a/src/components/index.ts b/src/components/index.ts index 6a5e8b8..92f76df 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,3 +2,4 @@ export * from "./ui"; export * from "./layout"; export * from "./providers"; export * from "./seo"; +export * from "./cards"; diff --git a/src/features/comparison/components/result-dashboard.tsx b/src/features/comparison/components/result-dashboard.tsx index f905281..4d8e05d 100644 --- a/src/features/comparison/components/result-dashboard.tsx +++ b/src/features/comparison/components/result-dashboard.tsx @@ -9,7 +9,7 @@ import { Avatar } from "@/components/layout/avatar"; import { ComparisonChart } from "./comparison-chart"; import { TopList } from "./top-list"; import { InsightsList } from "./insights-list"; -import { ScoreCard } from "@/features/developer/components/score-card"; +import { ScoreCard } from "@/components/cards"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import type { UserResult } from "@/features/developer"; diff --git a/src/features/comparison/components/top-list.tsx b/src/features/comparison/components/top-list.tsx index 7449049..56b8a19 100644 --- a/src/features/comparison/components/top-list.tsx +++ b/src/features/comparison/components/top-list.tsx @@ -1,20 +1,17 @@ "use client"; -import type { ReactNode } from "react"; import Link from "next/link"; import type { Route } from "next"; -import { - ArrowDown, - ArrowUp, - ExternalLink, - Eye, - GitFork, - GitPullRequest, - MessageSquare, - Star, -} from "lucide-react"; +import { ExternalLink, GitPullRequest, MessageSquare, Star } from "lucide-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import type { UserResult } from "@/features/developer"; +import { + RepoCardItem, + PullRequestCardItem, + CommunityCardItem, + findRepoLanguageMeta, + findPrLanguageMeta, +} from "@/components/cards"; import { useTranslation } from "@/components/providers/language-provider"; type Props = { @@ -22,166 +19,6 @@ type Props = { selectedLanguages?: string[]; }; -type LanguageEntry = { - name: string; - percentage: number; -}; - -type LanguageMeta = { - languageMatch?: number; - topLanguages?: LanguageEntry[]; -}; - -function formatLanguageMatch(value?: number): string { - if (value === undefined) { - return "N/A"; - } - return `${Math.round(value * 100)}%`; -} - -function normalizeLanguageName(value: string): string { - return value.trim().toLowerCase(); -} - -function getLanguageColor(name: string): string { - const normalized = normalizeLanguageName(name); - if (normalized === "typescript") return "bg-sky-500"; - if (normalized === "javascript") return "bg-amber-400"; - if (normalized === "python") return "bg-blue-500"; - if (normalized === "go") return "bg-cyan-500"; - if (normalized === "rust") return "bg-orange-500"; - if (normalized === "java") return "bg-red-500"; - if (normalized === "c#") return "bg-violet-500"; - if (normalized === "php") return "bg-indigo-500"; - if (normalized === "ruby") return "bg-rose-500"; - if (normalized === "swift") return "bg-orange-400"; - if (normalized === "kotlin") return "bg-fuchsia-500"; - if (normalized === "c++") return "bg-blue-700"; - return "bg-slate-500"; -} - -function StatChip({ icon, label, value }: { icon: ReactNode; label: string; value: number }) { - return ( -
- {icon} - {label} - {value} -
- ); -} - -function LanguageBreakdown({ topLanguages }: { topLanguages?: LanguageEntry[] }) { - if (!topLanguages || topLanguages.length === 0) { - return null; - } - - const normalized = topLanguages.slice(0, 5).filter((language) => language.percentage > 0); - - if (normalized.length === 0) { - return null; - } - - return ( -
-
- {normalized.map((language) => ( -
- ))} -
-
- {normalized.map((language) => ( - - - - {language.name} {language.percentage}% - - - ))} -
-
- ); -} - -function SelectedLanguageRow({ - topLanguages, - selectedLanguages, - label, -}: { - topLanguages?: LanguageEntry[]; - selectedLanguages: string[]; - label: string; -}) { - if (!topLanguages || topLanguages.length === 0 || selectedLanguages.length === 0) { - return null; - } - - const languageMap = new Map(); - for (const language of topLanguages) { - languageMap.set(normalizeLanguageName(language.name), language.percentage); - } - - return ( -
- {label}: - {selectedLanguages.map((language) => { - const percentage = languageMap.get(normalizeLanguageName(language)) ?? 0; - return ( - - {language} {percentage}% - - ); - })} -
- ); -} - -function findRepoLanguageMeta( - user: UserResult, - repo: UserResult["topRepos"][number], -): LanguageMeta { - const languageRepos = user.languageScores?.topRepos ?? []; - const byUrl = repo.url - ? languageRepos.find((item) => item.url && item.url === repo.url) - : undefined; - const byName = languageRepos.find((item) => item.name === repo.name); - const match = byUrl ?? byName; - - return { - languageMatch: match?.languageMatch ?? repo.languageMatch, - topLanguages: match?.topLanguages ?? repo.topLanguages, - }; -} - -function findPrLanguageMeta( - user: UserResult, - pr: UserResult["topPullRequests"][number], -): LanguageMeta { - const languagePrs = user.languageScores?.topPullRequests ?? []; - const byUrl = pr.url ? languagePrs.find((item) => item.url && item.url === pr.url) : undefined; - const byTitleAndRepo = languagePrs.find( - (item) => item.title === pr.title && item.repo === pr.repo, - ); - const match = byUrl ?? byTitleAndRepo; - - return { - languageMatch: match?.languageMatch ?? pr.languageMatch, - topLanguages: match?.topLanguages ?? pr.topLanguages, - }; -} - export function TopList({ userResults, selectedLanguages = [] }: Props) { const { t } = useTranslation(); @@ -222,70 +59,15 @@ export function TopList({ userResults, selectedLanguages = [] }: Props) { ) : ( user.topRepos.slice(0, 3).map((repo, index) => { const languageMeta = findRepoLanguageMeta(user, repo); + const enrichedRepo = { ...repo, ...languageMeta }; return ( -
-
-
- {repo.url ? ( - - {repo.name || t("untitled")} - - ) : ( -

{repo.name || t("untitled")}

- )} - -
- } - label={t("topwork.stars")} - value={repo.stars ?? 0} - /> - } - label={t("topwork.forks")} - value={repo.forks ?? 0} - /> - } - label={t("topwork.watchers")} - value={repo.watchers ?? 0} - /> -
- - - - - {typeof languageMeta.languageMatch === "number" ? ( -

- {t("language.match")}:{" "} - {formatLanguageMatch(languageMeta.languageMatch)} -

- ) : null} -
- -
-

{repo.score ?? 0}

-

- {t("comparsion.score")} -

-
-
-
+ repo={enrichedRepo} + rankIndex={index} + selectedLanguages={selectedLanguages} + showRank={true} + /> ); }) )} @@ -302,74 +84,15 @@ export function TopList({ userResults, selectedLanguages = [] }: Props) { ) : ( user.topPullRequests.slice(0, 3).map((pr, index) => { const languageMeta = findPrLanguageMeta(user, pr); + const enrichedPr = { ...pr, ...languageMeta }; return ( -
-
-
- {pr.url ? ( - - {pr.title || t("untitled")} - - ) : ( -

{pr.title || t("untitled")}

- )} - -

- {pr.repo || t("unknown.repo")} -

- -
- } - label={t("topwork.stars")} - value={pr.stars ?? 0} - /> - } - label={t("topwork.pr.additions")} - value={pr.additions ?? 0} - /> - } - label={t("topwork.pr.deletions")} - value={pr.deletions ?? 0} - /> -
- - - - - {typeof languageMeta.languageMatch === "number" ? ( -

- {t("language.match")}:{" "} - {formatLanguageMatch(languageMeta.languageMatch)} -

- ) : null} -
- -
-

{pr.score ?? 0}

-

- {t("comparsion.score")} -

-
-
-
+ pr={enrichedPr} + rankIndex={index} + selectedLanguages={selectedLanguages} + showRank={true} + /> ); }) )} @@ -383,60 +106,12 @@ export function TopList({ userResults, selectedLanguages = [] }: Props) { {user.topCommunityContributions && user.topCommunityContributions.length > 0 ? (
{user.topCommunityContributions.slice(0, 3).map((item, index) => ( -
-
-
- - {item.type === "issue" - ? t("community.issue") - : t("community.discussion")} - - - {item.url ? ( - - {item.title} - - ) : ( -

{item.title}

- )} - -

- {item.repo} -

- -
- } - label={t("topwork.stars")} - value={item.stars} - /> - } - label={t("community.comments")} - value={item.comments} - /> -
-
- -
-

{item.score}

-

- {t("comparsion.score")} -

-
-
-
+ item={item} + rankIndex={index} + showRank={true} + /> ))}
) : ( diff --git a/src/features/comparison/index.ts b/src/features/comparison/index.ts index 1271af8..c50b2a0 100644 --- a/src/features/comparison/index.ts +++ b/src/features/comparison/index.ts @@ -1,3 +1,3 @@ export * from "./types"; -export * from "./services"; export * from "./components"; +export * from "./services/compare-request"; diff --git a/src/features/developer/components/index.ts b/src/features/developer/components/index.ts index 4f2baea..c1b4030 100644 --- a/src/features/developer/components/index.ts +++ b/src/features/developer/components/index.ts @@ -1,4 +1,3 @@ export * from "./user-profile-client"; export * from "./user-profile-skeleton"; export * from "./user-not-found"; -export * from "./score-card"; diff --git a/src/features/developer/components/user-profile-client.tsx b/src/features/developer/components/user-profile-client.tsx index 7c9646a..f6eded4 100644 --- a/src/features/developer/components/user-profile-client.tsx +++ b/src/features/developer/components/user-profile-client.tsx @@ -9,7 +9,6 @@ import { Check, Copy, ExternalLink, - GitFork, GitPullRequest, MapPin, MessageSquare, @@ -19,10 +18,15 @@ import { Trophy, } from "lucide-react"; import { Avatar } from "@/components/layout/avatar"; -import { ScoreCard } from "./score-card"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; +import { + ScoreCard, + RepoCardItem, + PullRequestCardItem, + CommunityCardItem, +} from "@/components/cards"; import { useTranslation } from "@/components/providers/language-provider"; import { getCountryCode, detectCountry } from "@/lib/geo"; import countriesData from "@/data/countries.json"; @@ -41,69 +45,6 @@ type Props = { countryParam?: string | null; }; -type LanguageEntry = { - name: string; - percentage: number; -}; - -function getLanguageColor(name: string): string { - const normalized = name.trim().toLowerCase(); - if (normalized === "typescript") return "bg-sky-500"; - if (normalized === "javascript") return "bg-amber-400"; - if (normalized === "python") return "bg-blue-500"; - if (normalized === "go") return "bg-cyan-500"; - if (normalized === "rust") return "bg-orange-500"; - if (normalized === "java") return "bg-red-500"; - if (normalized === "c#") return "bg-violet-500"; - if (normalized === "php") return "bg-indigo-500"; - if (normalized === "ruby") return "bg-rose-500"; - if (normalized === "swift") return "bg-orange-400"; - if (normalized === "kotlin") return "bg-fuchsia-500"; - if (normalized === "c++") return "bg-blue-700"; - return "bg-slate-500"; -} - -function StatChip({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }) { - return ( -
- {icon} - {label} - {value} -
- ); -} - -function LanguageBreakdown({ topLanguages }: { topLanguages?: LanguageEntry[] }) { - if (!topLanguages || topLanguages.length === 0) return null; - - const normalized = topLanguages.slice(0, 4).filter((lang) => lang.percentage > 0); - - if (normalized.length === 0) return null; - - return ( -
-
- {normalized.map((lang, idx) => ( -
- ))} -
-
- {normalized.map((lang, idx) => ( - - - {lang.name} {Math.round(lang.percentage * 100)}% - - ))} -
-
- ); -} - export function UserProfileClient({ user, location, countryParam }: Props) { const { t } = useTranslation(); const searchParams = useSearchParams(); @@ -451,7 +392,7 @@ export function UserProfileClient({ user, location, countryParam }: Props) {
-
+
{/* Top Repositories */} @@ -465,57 +406,16 @@ export function UserProfileClient({ user, location, countryParam }: Props) { {user.topRepos.length === 0 ? (

{t("empty.repos")}

) : ( - user.topRepos.slice(0, 3).map((repo, idx) => ( -
-
-
-
- - #{idx + 1} - - {repo.url ? ( - - {repo.name || t("untitled")} - - ) : ( -

{repo.name || t("untitled")}

- )} -
- -
- } - label={t("topwork.stars")} - value={repo.stars ?? 0} - /> - } - label={t("topwork.forks")} - value={repo.forks ?? 0} - /> -
- - -
- -
-

{repo.score ?? 0}

-

{t("comparsion.score")}

-
-
-
- )) + user.topRepos + .slice(0, 3) + .map((repo, idx) => ( + + )) )}
@@ -533,73 +433,22 @@ export function UserProfileClient({ user, location, countryParam }: Props) { {user.topPullRequests.length === 0 ? (

{t("empty.pullRequests")}

) : ( - user.topPullRequests.slice(0, 3).map((pr, idx) => ( -
-
-
-
- - #{idx + 1} - - {pr.url ? ( - - {pr.title || t("untitled")} - - ) : ( -

{pr.title || t("untitled")}

- )} -
- -

- {t("topwork.inRepo", { - repo: pr.repo || t("unknown.repo"), - })} -

- -
- } - label={t("topwork.pr.repo.stars")} - value={pr.stars ?? 0} - /> -
- - +{pr.additions ?? 0} - - / - - -{pr.deletions ?? 0} - -
-
- - -
- -
-

{pr.score ?? 0}

-

{t("comparsion.score")}

-
-
-
- )) + user.topPullRequests + .slice(0, 3) + .map((pr, idx) => ( + + )) )} {/* Top Community Contributions */} - + @@ -609,65 +458,16 @@ export function UserProfileClient({ user, location, countryParam }: Props) { {user.topCommunityContributions && user.topCommunityContributions.length > 0 ? ( - user.topCommunityContributions.slice(0, 3).map((item, idx) => ( -
-
-
-
- - #{idx + 1} - - - {item.type === "issue" - ? t("community.issue") - : t("community.discussion")} - -
- - {item.url ? ( - - {item.title} - - ) : ( -

{item.title}

- )} - -

- {item.repo} -

- -
- } - label={t("topwork.stars")} - value={item.stars} - /> - } - label={t("community.comments")} - value={item.comments} - /> -
-
- -
-

{item.score}

-

{t("comparsion.score")}

-
-
-
- )) + user.topCommunityContributions + .slice(0, 3) + .map((item, idx) => ( + + )) ) : (

{t("empty.community")}

)} diff --git a/src/features/developer/components/user-profile-skeleton.tsx b/src/features/developer/components/user-profile-skeleton.tsx index 1eec927..fe00123 100644 --- a/src/features/developer/components/user-profile-skeleton.tsx +++ b/src/features/developer/components/user-profile-skeleton.tsx @@ -47,7 +47,7 @@ export function UserProfileSkeleton() {
{/* Top Work Cards Skeleton */} -
+
@@ -74,7 +74,7 @@ export function UserProfileSkeleton() { - + diff --git a/src/features/developer/index.ts b/src/features/developer/index.ts index 1271af8..c22c164 100644 --- a/src/features/developer/index.ts +++ b/src/features/developer/index.ts @@ -1,3 +1,2 @@ export * from "./types"; -export * from "./services"; export * from "./components"; diff --git a/src/features/developer/tests/user.route.test.ts b/src/features/developer/tests/user.route.test.ts index 4e53253..b6c718d 100644 --- a/src/features/developer/tests/user.route.test.ts +++ b/src/features/developer/tests/user.route.test.ts @@ -27,7 +27,7 @@ vi.mock("@/lib/db", () => ({ })); import { GET } from "@/app/api/user/[username]/route"; -import { getUserProfile } from "@/features/developer"; +import { getUserProfile } from "@/features/developer/services"; function makeUser(login: string, name: string) { return { diff --git a/src/features/leaderboard/index.ts b/src/features/leaderboard/index.ts index 1271af8..c22c164 100644 --- a/src/features/leaderboard/index.ts +++ b/src/features/leaderboard/index.ts @@ -1,3 +1,2 @@ export * from "./types"; -export * from "./services"; export * from "./components"; diff --git a/src/lib/languages/github-colors.ts b/src/lib/languages/github-colors.ts new file mode 100644 index 0000000..0ccea00 --- /dev/null +++ b/src/lib/languages/github-colors.ts @@ -0,0 +1,226 @@ +/** + * GitHub Linguist canonical language colors mapping. + * Sourced from github-linguist/linguist/lib/linguist/languages.yml + */ +export const GITHUB_LANGUAGE_COLORS: Record = { + // Common Web & Application Languages + typescript: "#3178c6", + javascript: "#f1e05a", + python: "#3572A5", + java: "#b07219", + c: "#555555", + "c++": "#f34b7d", + "c#": "#178600", + go: "#00ADD8", + rust: "#dea584", + php: "#4F5D95", + ruby: "#701516", + swift: "#F05138", + kotlin: "#A97BFF", + dart: "#00B4AB", + html: "#e34c26", + css: "#563d7c", + scss: "#c6538c", + sass: "#a53b70", + less: "#1d365d", + vue: "#41b883", + svelte: "#ff3e00", + astro: "#ff5a03", + + // Shell & Scripting + shell: "#89e051", + bash: "#89e051", + zsh: "#89e051", + fish: "#4aae47", + powershell: "#012456", + batchfile: "#C1F12E", + batch: "#C1F12E", + awk: "#c30e9b", + sed: "#64b970", + lua: "#000080", + r: "#198CE7", + julia: "#a270ba", + perl: "#0298c3", + raku: "#0000fb", + applescript: "#101F1F", + autohotkey: "#6594b9", + autoit: "#1C3552", + + // JVM Languages + scala: "#c22d40", + groovy: "#4298b8", + clojure: "#db5855", + xtend: "#24255d", + + // Systems, Low-Level & Native + zig: "#ec915c", + nim: "#ffc200", + d: "#ba595e", + v: "#4f87c4", + crystal: "#000100", + carbon: "#222222", + assembly: "#6E4C13", + webassembly: "#04133b", + "objective-c": "#438eff", + "objective-c++": "#6866fb", + fortran: "#4d41b1", + pascal: "#E3F171", + ada: "#02f88c", + pony: "#4a8b7c", + + // Functional Languages + elixir: "#6e4a7e", + erlang: "#B83998", + haskell: "#5e5086", + ocaml: "#ef7a08", + "f#": "#b845fc", + elm: "#60B5CC", + purescript: "#1D222D", + rescript: "#ed5051", + reason: "#ff5847", + "common lisp": "#3fb68b", + "emacs lisp": "#c065db", + scheme: "#1e4aec", + racket: "#3c5caa", + coq: "#d0b68c", + agda: "#315665", + idris: "#b30000", + + // Modern & Emerging + gleam: "#ffaff3", + mojo: "#ff4b00", + cairo: "#ff4a2b", + move: "#4a90e2", + solidity: "#AA6746", + vyper: "#2980b9", + clarity: "#5546ff", + ballerina: "#ff5000", + vala: "#a56de2", + wren: "#383838", + ring: "#2D54CB", + red: "#f50000", + + // Data, Query, Database + sql: "#e38c00", + plpgsql: "#336790", + plsql: "#dad8d8", + tsql: "#e38c00", + graphql: "#e10098", + prisma: "#0c344b", + "protocol buffer": "#e75429", + protobuf: "#e75429", + matlab: "#e16737", + stan: "#b2011d", + + // DevOps, Infrastructure & Config + dockerfile: "#384d54", + makefile: "#427819", + cmake: "#DA3434", + meson: "#007800", + bazel: "#003990", + nix: "#7e7eff", + hcl: "#844fba", + terraform: "#844fba", + starlark: "#76d275", + jsonnet: "#0064b5", + yaml: "#cb171e", + json: "#292929", + toml: "#9c4221", + xml: "#0060ac", + ini: "#d1dbe0", + + // Template & UI + blade: "#f7523f", + jinja: "#b41717", + liquid: "#67b8de", + mustache: "#724b3b", + handlebars: "#f7931e", + ejs: "#a91e50", + pug: "#a86454", + haml: "#ece2a9", + + // Game Dev & Graphics + gdscript: "#355570", + hlsl: "#aace60", + glsl: "#5686a5", + wgsl: "#1a1a1a", + shaderlab: "#222c37", + + // Hardware & Embedded + verilog: "#b2b7f8", + systemverilog: "#DAE1C2", + vhdl: "#49f6eb", + opencl: "#ed2e2d", + + // Document & Text + markdown: "#083fa1", + tex: "#3D6117", + latex: "#3D6117", + typst: "#239dad", + "vim script": "#199f4b", + vim: "#199f4b", + "visual basic .net": "#945db7", + "visual basic": "#945db7", + vb: "#945db7", + "vb.net": "#945db7", + coffeescript: "#244776", + apex: "#1797c0", + qml: "#44a51c", + haxe: "#df7900", + hack: "#878787", + actionscript: "#882B0F", + coldfusion: "#ed2f00", + smalltalk: "#596706", +}; + +/** + * Normalizes language names for case-insensitive and symbol-friendly lookup. + */ +export function normalizeLanguageName(name: string): string { + return name.trim().toLowerCase(); +} + +/** + * Generates a consistent, deterministic HSL color for any language + * not explicitly included in the Linguist dictionary. + */ +export function hashStringToColor(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + const hue = Math.abs(hash) % 360; + return `hsl(${hue}, 65%, 48%)`; +} + +/** + * Returns the canonical GitHub color for a programming language, + * or a deterministic fallback color if unknown. + */ +export function getLanguageColor(name: string): string { + if (!name || !name.trim()) { + return "#8b949e"; + } + + const normalized = normalizeLanguageName(name); + + // Exact match + if (GITHUB_LANGUAGE_COLORS[normalized]) { + return GITHUB_LANGUAGE_COLORS[normalized]; + } + + // Common alias normalization + if (normalized === "js") return GITHUB_LANGUAGE_COLORS.javascript; + if (normalized === "ts") return GITHUB_LANGUAGE_COLORS.typescript; + if (normalized === "py") return GITHUB_LANGUAGE_COLORS.python; + if (normalized === "golang") return GITHUB_LANGUAGE_COLORS.go; + if (normalized === "rs") return GITHUB_LANGUAGE_COLORS.rust; + if (normalized === "rb") return GITHUB_LANGUAGE_COLORS.ruby; + if (normalized === "sh") return GITHUB_LANGUAGE_COLORS.shell; + if (normalized === "csharp" || normalized === "cs") return GITHUB_LANGUAGE_COLORS["c#"]; + if (normalized === "cpp" || normalized === "cplusplus") return GITHUB_LANGUAGE_COLORS["c++"]; + if (normalized === "fsharp" || normalized === "fs") return GITHUB_LANGUAGE_COLORS["f#"]; + + // Fallback to deterministic vibrant color + return hashStringToColor(normalized); +} diff --git a/src/lib/languages/index.ts b/src/lib/languages/index.ts new file mode 100644 index 0000000..d3c0294 --- /dev/null +++ b/src/lib/languages/index.ts @@ -0,0 +1 @@ +export * from "./github-colors"; diff --git a/src/lib/languages/tests/language-colors.test.ts b/src/lib/languages/tests/language-colors.test.ts new file mode 100644 index 0000000..1f5a84b --- /dev/null +++ b/src/lib/languages/tests/language-colors.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { getLanguageColor, GITHUB_LANGUAGE_COLORS } from "../github-colors"; + +describe("GitHub Language Colors", () => { + it("returns canonical GitHub colors for popular languages", () => { + expect(getLanguageColor("TypeScript")).toBe("#3178c6"); + expect(getLanguageColor("JavaScript")).toBe("#f1e05a"); + expect(getLanguageColor("Python")).toBe("#3572A5"); + expect(getLanguageColor("Go")).toBe("#00ADD8"); + expect(getLanguageColor("Rust")).toBe("#dea584"); + expect(getLanguageColor("C++")).toBe("#f34b7d"); + expect(getLanguageColor("C#")).toBe("#178600"); + expect(getLanguageColor("HTML")).toBe("#e34c26"); + expect(getLanguageColor("CSS")).toBe("#563d7c"); + expect(getLanguageColor("Vue")).toBe("#41b883"); + expect(getLanguageColor("Svelte")).toBe("#ff3e00"); + expect(getLanguageColor("Kotlin")).toBe("#A97BFF"); + expect(getLanguageColor("Swift")).toBe("#F05138"); + expect(getLanguageColor("Ruby")).toBe("#701516"); + expect(getLanguageColor("PHP")).toBe("#4F5D95"); + }); + + it("handles aliases and casing gracefully", () => { + expect(getLanguageColor("ts")).toBe(GITHUB_LANGUAGE_COLORS.typescript); + expect(getLanguageColor("js")).toBe(GITHUB_LANGUAGE_COLORS.javascript); + expect(getLanguageColor("py")).toBe(GITHUB_LANGUAGE_COLORS.python); + expect(getLanguageColor("golang")).toBe(GITHUB_LANGUAGE_COLORS.go); + expect(getLanguageColor("rs")).toBe(GITHUB_LANGUAGE_COLORS.rust); + expect(getLanguageColor("cpp")).toBe(GITHUB_LANGUAGE_COLORS["c++"]); + expect(getLanguageColor("csharp")).toBe(GITHUB_LANGUAGE_COLORS["c#"]); + expect(getLanguageColor(" TYPESCRIPT ")).toBe("#3178c6"); + }); + + it("produces deterministic fallback color for unknown languages", () => { + const unknownColor1 = getLanguageColor("CustomObscureLang"); + const unknownColor2 = getLanguageColor("customobscurelang"); + expect(unknownColor1).toMatch(/^hsl\(\d+,\s*65%,\s*48%\)$/); + expect(unknownColor1).toBe(unknownColor2); + }); + + it("handles empty or blank language strings", () => { + expect(getLanguageColor("")).toBe("#8b949e"); + expect(getLanguageColor(" ")).toBe("#8b949e"); + }); +}); From 445ca2f034e0205f1bb716f4d9e36331acf0ce61 Mon Sep 17 00:00:00 2001 From: Osama Mabkhot <99215291+O2sa@users.noreply.github.com> Date: Mon, 14 Sep 2026 04:27:54 +0300 Subject: [PATCH 3/5] chore: merge origin/main From 51a165778e2014a97a2dc0f66c236469273ce3e1 Mon Sep 17 00:00:00 2001 From: Osama Mabkhot <99215291+O2sa@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:45:44 +0300 Subject: [PATCH 4/5] refactor: modularize scoring engine, unify persistence pipeline, and streamline comparison state - Core Domain Pipeline: - Introduce `persistUserScores` in `user-persistence.ts` as the canonical persistence pipeline - Eliminate duplicate DB upsert and Redis invalidation logic across `user-service`, `compare-service`, and `calculate-leaderboard` - Remove cross-boundary dynamic imports of `db` and `score-engine` from `github-client` - Scoring Engine Modularization: - Decompose monolithic 1,230-line `score-engine.ts` into focused sub-calculators (`repo-scoring`, `pr-scoring`, `community-scoring`, `scoring-constants`, `scoring-helpers`) - Preserve 100% backward compatibility and exact mathematical output - API & Routes: - Unify API error handling and search parameter parsing into `src/lib/api` - Reduce route handler boilerplate by ~70% across `/api/user/[username]` and `/api/compare` - Remove `initializeSchema()` DDL overhead from hot read path in `leaderboard-service` - Add strong generic typing to `db-store` rows and upsert params - Frontend State & Polish: - Extract comparison orchestration into `useComparisonController` hook, slimming `home-page-client.tsx` from 545 to 125 lines - Introduce reusable `useClipboardCopy` hook with timeout cleanup in profile and comparison dashboards - Fix template placeholder replacement in `provider-hook.ts` - Deduplicate SSR profile fetches between `generateMetadata` and `UserProfilePage` using `React.cache()` --- src/app/api/compare/route.ts | 67 +- src/app/api/user/[username]/route.ts | 82 +-- src/app/user/[username]/page.tsx | 9 +- .../components/home-page-client.tsx | 457 +------------- .../components/result-dashboard.tsx | 41 +- src/features/comparison/hooks/index.ts | 1 + .../hooks/use-comparison-controller.ts | 468 ++++++++++++++ src/features/comparison/index.ts | 1 + .../comparison/services/compare-service.ts | 129 ++-- .../comparison/tests/compare-request.test.ts | 11 +- .../components/user-profile-client.tsx | 14 +- src/features/developer/services/index.ts | 1 + .../developer/services/user-persistence.ts | 69 +++ .../developer/services/user-service.ts | 48 +- .../services/calculate-leaderboard.ts | 38 +- .../services/leaderboard-service.ts | 1 - .../scoring/services/community-scoring.ts | 79 +++ src/features/scoring/services/pr-scoring.ts | 173 ++++++ src/features/scoring/services/repo-scoring.ts | 106 ++++ src/features/scoring/services/score-engine.ts | 577 +++--------------- .../scoring/services/scoring-constants.ts | 49 ++ .../scoring/services/scoring-helpers.ts | 91 +++ src/hooks/index.ts | 1 + src/hooks/use-clipboard-copy.ts | 44 ++ src/lib/api/api-helpers.ts | 92 +++ src/lib/api/index.ts | 1 + src/lib/db/db-store.ts | 14 +- src/lib/github/github-client.ts | 27 - src/lib/i18n/provider-hook.ts | 2 +- 29 files changed, 1377 insertions(+), 1316 deletions(-) create mode 100644 src/features/comparison/hooks/index.ts create mode 100644 src/features/comparison/hooks/use-comparison-controller.ts create mode 100644 src/features/developer/services/user-persistence.ts create mode 100644 src/features/scoring/services/community-scoring.ts create mode 100644 src/features/scoring/services/pr-scoring.ts create mode 100644 src/features/scoring/services/repo-scoring.ts create mode 100644 src/features/scoring/services/scoring-constants.ts create mode 100644 src/features/scoring/services/scoring-helpers.ts create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/use-clipboard-copy.ts create mode 100644 src/lib/api/api-helpers.ts create mode 100644 src/lib/api/index.ts diff --git a/src/app/api/compare/route.ts b/src/app/api/compare/route.ts index b1c11cd..6615dbb 100644 --- a/src/app/api/compare/route.ts +++ b/src/app/api/compare/route.ts @@ -1,43 +1,14 @@ import { NextResponse } from "next/server"; import { - CompareUserFetchError, calculateWinner, compareUsers, createComparisonInsights, - parseSelectedLanguagesFromSearchParams, resolveLocale, } from "@/features/comparison/services"; -import { toSafeApiError } from "@/lib/github"; -import type { ClientSafeError, SafeApiError } from "@/types/api"; +import { formatApiErrorResponse, parseSelectedLanguagesFromSearchParams } from "@/lib/api"; export const runtime = "nodejs"; -function toApiErrorStatus(code: ReturnType["code"]): number { - switch (code) { - case "RATE_LIMITED": - case "TEMPORARY_THROTTLE": - return 429; - case "GITHUB_TIMEOUT": - case "GITHUB_RESOURCE_LIMIT": - case "GITHUB_AUTH": - return code === "GITHUB_AUTH" ? 401 : 503; - case "GITHUB_NOT_FOUND": - return 404; - case "NETWORK": - return 503; - default: - return 500; - } -} - -function toClientSafeError(error: SafeApiError): ClientSafeError { - return { - code: error.code, - message: error.message, - targetUsernames: error.targetUsernames, - }; -} - export async function GET(request: Request) { const { searchParams } = new URL(request.url); const usernames = searchParams @@ -61,40 +32,6 @@ export async function GET(request: Request) { return NextResponse.json({ success: true, users, ...winnerData, insights }); } catch (error: unknown) { console.error("GitHub score error:", error); - - let safeError: SafeApiError; - - if (error instanceof CompareUserFetchError) { - const mappedCause = toSafeApiError(error.causeError); - if ( - mappedCause.code === "GITHUB_NOT_FOUND" || - (error.causeError instanceof Error && error.causeError.message === "User not found") - ) { - safeError = { - code: "GITHUB_NOT_FOUND", - message: "GitHub user not found", - targetUsernames: [error.username], - rateLimit: mappedCause.rateLimit, - }; - } else { - safeError = mappedCause; - } - } else { - safeError = - error instanceof Error && error.message === "User not found" - ? { code: "GITHUB_NOT_FOUND", message: "GitHub user not found" } - : toSafeApiError(error); - } - - const clientSafeError = toClientSafeError(safeError); - - return NextResponse.json( - { - success: false, - error: clientSafeError.message, - errorDetails: clientSafeError, - }, - { status: toApiErrorStatus(safeError.code) }, - ); + return formatApiErrorResponse(error); } } diff --git a/src/app/api/user/[username]/route.ts b/src/app/api/user/[username]/route.ts index 888dc6d..4a04a84 100644 --- a/src/app/api/user/[username]/route.ts +++ b/src/app/api/user/[username]/route.ts @@ -1,51 +1,9 @@ import { NextResponse } from "next/server"; -import { getUserProfile, UserFetchError } from "@/features/developer/services"; -import { normalizeSelectedLanguages } from "@/features/scoring"; -import { toSafeApiError } from "@/lib/github"; -import type { SafeApiError } from "@/types/api"; +import { getUserProfile } from "@/features/developer/services"; +import { formatApiErrorResponse, parseSelectedLanguagesFromSearchParams } from "@/lib/api"; export const runtime = "nodejs"; -type ClientSafeError = Pick; - -function parseSelectedLanguagesFromSearchParams(searchParams: URLSearchParams): string[] { - const fromRepeated = searchParams.getAll("selectedLanguage"); - const fromCsv = searchParams - .get("selectedLanguages") - ?.split(",") - .map((language) => language.trim()) - .filter(Boolean); - - return normalizeSelectedLanguages([...(fromRepeated ?? []), ...(fromCsv ?? [])]); -} - -function toClientSafeError(error: SafeApiError): ClientSafeError { - return { - code: error.code, - message: error.message, - targetUsernames: error.targetUsernames, - }; -} - -function toApiErrorStatus(code: ReturnType["code"]): number { - switch (code) { - case "RATE_LIMITED": - case "TEMPORARY_THROTTLE": - return 429; - case "GITHUB_TIMEOUT": - case "GITHUB_RESOURCE_LIMIT": - case "GITHUB_AUTH": - return code === "GITHUB_AUTH" ? 401 : 503; - case "GITHUB_NOT_FOUND": - return 404; - case "NETWORK": - return 503; - case "UNKNOWN": - default: - return 500; - } -} - export async function GET(request: Request, { params }: { params: Promise<{ username: string }> }) { const { username } = await params; const trimmed = username?.trim(); @@ -65,40 +23,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ user return NextResponse.json({ success: true, user, location }); } catch (error: unknown) { console.error("User profile fetch error:", error); - - let safeError: SafeApiError; - - if (error instanceof UserFetchError) { - const mappedCause = toSafeApiError(error.causeError); - if ( - mappedCause.code === "GITHUB_NOT_FOUND" || - (error.causeError instanceof Error && error.causeError.message === "User not found") - ) { - safeError = { - code: "GITHUB_NOT_FOUND", - message: "GitHub user not found", - targetUsernames: [error.username], - rateLimit: mappedCause.rateLimit, - }; - } else { - safeError = mappedCause; - } - } else { - safeError = - error instanceof Error && error.message === "User not found" - ? { code: "GITHUB_NOT_FOUND", message: "GitHub user not found" } - : toSafeApiError(error); - } - - const clientSafeError = toClientSafeError(safeError); - - return NextResponse.json( - { - success: false, - error: clientSafeError.message, - errorDetails: clientSafeError, - }, - { status: toApiErrorStatus(safeError.code) }, - ); + return formatApiErrorResponse(error); } } diff --git a/src/app/user/[username]/page.tsx b/src/app/user/[username]/page.tsx index b78f5b0..a371828 100644 --- a/src/app/user/[username]/page.tsx +++ b/src/app/user/[username]/page.tsx @@ -1,3 +1,4 @@ +import { cache } from "react"; import type { Metadata } from "next"; import { JsonLd } from "@/components/seo/json-ld"; import { UserProfileClient, UserNotFoundCard } from "@/features/developer"; @@ -9,6 +10,10 @@ import { toAbsoluteUrl } from "@/lib/seo"; import countriesData from "@/data/countries.json"; import { detectCountry } from "@/lib/geo"; +const getCachedUserProfile = cache(async (username: string) => { + return getUserProfile(username); +}); + type CountryInfo = { slug: string; title: string; @@ -27,7 +32,7 @@ export async function generateMetadata({ params }: Props): Promise { let displayName = cleanUsername; try { - const { user } = await getUserProfile(cleanUsername); + const { user } = await getCachedUserProfile(cleanUsername); displayName = user.name?.trim() || cleanUsername; } catch { // Fallback if user cannot be fetched during metadata generation @@ -96,7 +101,7 @@ export default async function UserProfilePage({ params, searchParams }: Props) { let fetchErrorMessage: string | null = null; try { - profileData = await getUserProfile(cleanUsername); + profileData = await getCachedUserProfile(cleanUsername); } catch (err: unknown) { fetchErrorMessage = err instanceof Error ? err.message : "Failed to load user profile"; } diff --git a/src/features/comparison/components/home-page-client.tsx b/src/features/comparison/components/home-page-client.tsx index 554bdd6..ae04cad 100644 --- a/src/features/comparison/components/home-page-client.tsx +++ b/src/features/comparison/components/home-page-client.tsx @@ -1,455 +1,40 @@ "use client"; -import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useMemo } from "react"; import Image from "next/image"; import { CompareForm } from "./compare-form"; import { ResultDashboard } from "./result-dashboard"; import { DashboardSkeleton } from "@/components/layout/skeletons"; -import type { UserResult } from "@/features/developer"; import { BrandLogo } from "@/components/layout/brand-logo"; import { AppHeader } from "@/components/layout/app-header"; import { AppFooter } from "@/components/layout/app-footer"; import { useTranslation } from "@/components/providers/language-provider"; -import type { SafeApiError } from "@/types/api"; -import type { CompareInsights, CompareWinner, ComparisonResponse } from "../types"; import { cn } from "@/utils/cn"; -import { - createComparisonQuery, - createComparisonRequest, - isComparisonFetchDuplicate, - reconcileComparisonData, - sanitizeSelectedLanguages, -} from "../services/compare-request"; - -type ComparisonData = { - user1: UserResult; - user2: UserResult; - winner?: CompareWinner; - languageWinner?: { - username: string; - finalScoreDifference: number; - percentageDifference: number | null; - selectedLanguages: string[]; - }; - insights?: CompareInsights; - scoreVersion?: string; -}; - -type CompareOptions = { - selectedLanguages: string[]; - updateUrl?: boolean; -}; - -type UsernameErrors = { - username1: string | null; - username2: string | null; -}; - -const EXIT_ANIMATION_MS = 240; - -function normalizeUsers(body: ComparisonResponse): { user1: UserResult; user2: UserResult } | null { - if (body.users && body.users.length >= 2) { - return { user1: body.users[0], user2: body.users[1] }; - } - - return null; -} - -function parseUsernamesFromSearchParams(searchParams: { - getAll: (name: string) => string[]; - get: (name: string) => string | null; -}): [string, string] { - const repeated = searchParams - .getAll("username") - .map((u) => u.trim()) - .filter(Boolean); - const u1 = - repeated[0] || searchParams.get("username1")?.trim() || searchParams.get("user1")?.trim() || ""; - const u2 = - repeated[1] || searchParams.get("username2")?.trim() || searchParams.get("user2")?.trim() || ""; - return [u1, u2]; -} +import { useComparisonController } from "../hooks"; export function HomePageClient() { const { t } = useTranslation(); - const router = useRouter(); - const searchParams = useSearchParams(); - const [initialUsername1, initialUsername2] = parseUsernamesFromSearchParams(searchParams); - const initialSelectedLanguages = sanitizeSelectedLanguages( - searchParams.getAll("selectedLanguage"), - ); - const [loading, setLoading] = useState(false); - const [generalError, setGeneralError] = useState(null); - const [usernameErrors, setUsernameErrors] = useState({ - username1: null, - username2: null, - }); - const [username1, setUsername1] = useState(initialUsername1); - const [username2, setUsername2] = useState(initialUsername2); - const [selectedLanguages, setSelectedLanguages] = useState(initialSelectedLanguages); - const [data, setData] = useState(null); - const [displayData, setDisplayData] = useState(null); - const [disableDuplicateFetch, setDisableDuplicateFetch] = useState(false); - const lastFetchedKeyRef = useRef(null); - const inFlightFetchKeyRef = useRef(null); - const inFlightPromiseRef = useRef | null>(null); - const latestRequestRef = useRef( - createComparisonRequest(initialUsername1, initialUsername2, initialSelectedLanguages), - ); - const hideTimerRef = useRef(null); - - const localizeErrorMessage = (message?: string, details?: SafeApiError) => { - if (details) { - switch (details.code) { - case "RATE_LIMITED": - return t("error.rateLimited", { - seconds: details.retryAfterSeconds ?? 60, - }); - case "TEMPORARY_THROTTLE": - return t("error.tempThrottle", { - seconds: details.retryAfterSeconds ?? 60, - }); - case "GITHUB_TIMEOUT": - return t("error.timeout"); - case "GITHUB_RESOURCE_LIMIT": - return t("error.resourceLimit"); - case "GITHUB_AUTH": - return t("error.missingToken"); - case "GITHUB_NOT_FOUND": - return t("error.userNotFound"); - case "NETWORK": - return t("error.fetchFailed"); - default: - break; - } - } - - switch (message) { - case "provide exactly two username params": - return t("error.missingUsername"); - case "GitHub user not found": - return t("error.userNotFound"); - case "Failed to calculate score": - return t("error.calculateFailed"); - case "Comparison failed": - return t("error.comparisonFailed"); - case "Failed to fetch": - return t("error.fetchFailed"); - case "Missing GITHUB_TOKEN": - return t("error.missingToken"); - default: - return t("error.generic"); - } - }; - - const createNotFoundFieldMessage = (username: string): string => { - const localizedPrefix = t("error.userNotFound"); - return `${localizedPrefix}: ${username}`; - }; - - const resetErrors = () => { - setGeneralError(null); - setUsernameErrors({ - username1: null, - username2: null, - }); - }; - - const applyApiError = (requestUser1: string, requestUser2: string, body: ComparisonResponse) => { - const details = body.errorDetails; - const localizedMessage = localizeErrorMessage(body.error, details); - - if (details?.code === "GITHUB_NOT_FOUND" && details.targetUsernames?.length) { - const requestedUsernames = [ - { - key: "username1" as const, - value: requestUser1, - }, - { - key: "username2" as const, - value: requestUser2, - }, - ]; - - const nextErrors: UsernameErrors = { username1: null, username2: null }; - - for (const targetUsername of details.targetUsernames) { - const normalizedTarget = targetUsername.trim().toLowerCase(); - const match = requestedUsernames.find( - (entry) => entry.value.trim().toLowerCase() === normalizedTarget, - ); - - if (match) { - nextErrors[match.key] = createNotFoundFieldMessage(match.value); - } - } - - if (nextErrors.username1 || nextErrors.username2) { - setUsernameErrors(nextErrors); - setGeneralError(null); - return; - } - } - - setUsernameErrors({ - username1: null, - username2: null, - }); - setGeneralError(localizedMessage); - }; - - const handleCompare = async (u1: string, u2: string, options: CompareOptions) => { - const request = createComparisonRequest(u1, u2, options.selectedLanguages); - latestRequestRef.current = request; - const fetchKey = request.fetchKey; - - if (inFlightFetchKeyRef.current === fetchKey && inFlightPromiseRef.current) { - return inFlightPromiseRef.current; - } - - // If we've already fetched this exact comparison and have the data, skip. - if (lastFetchedKeyRef.current === fetchKey && data) { - const reconciled = reconcileComparisonData(data, fetchKey, request); - if (reconciled) { - setData(reconciled); - setDisplayData(reconciled); - } - return Promise.resolve(); - } - - lastFetchedKeyRef.current = fetchKey; - - // update duplicate fetch state for current form values - const currentFetchKey = createComparisonRequest( - username1, - username2, - selectedLanguages, - ).fetchKey; - setDisableDuplicateFetch( - isComparisonFetchDuplicate( - currentFetchKey, - lastFetchedKeyRef.current, - inFlightFetchKeyRef.current, - Boolean(data), - ), - ); - - const requestPromise = (async () => { - if (options.updateUrl !== false) { - router.push(`/?${createComparisonQuery(request)}`, { scroll: false }); - } - - setLoading(true); - resetErrors(); - - try { - const res = await fetch(`/api/compare?${createComparisonQuery(request)}`); - - const body: ComparisonResponse = await res.json(); - if (!res.ok) { - if (latestRequestRef.current.fetchKey !== fetchKey) { - return; - } - setData(null); - applyApiError(latestRequestRef.current.user1, latestRequestRef.current.user2, body); - return; - } - const users = normalizeUsers(body); - - if (!body.success || !users) { - if (latestRequestRef.current.fetchKey !== fetchKey) return; - setData(null); - applyApiError(latestRequestRef.current.user1, latestRequestRef.current.user2, body); - return; - } - - const winnerUsername = - body.winner?.username ?? - (users.user1.finalScore > users.user2.finalScore - ? users.user1.username - : users.user2.finalScore > users.user1.finalScore - ? users.user2.username - : undefined); - - const nextData: ComparisonData = { - user1: { ...users.user1, isWinner: winnerUsername === users.user1.username }, - user2: { ...users.user2, isWinner: winnerUsername === users.user2.username }, - winner: body.winner, - languageWinner: body.languageWinner, - insights: body.insights, - scoreVersion: body.scoreVersion, - }; - - const reconciled = reconcileComparisonData(nextData, fetchKey, latestRequestRef.current); - if (!reconciled) { - if (latestRequestRef.current.fetchKey === fetchKey) { - setData(null); - setGeneralError(t("error.generic")); - } - return; - } - - setData(reconciled); - setDisplayData(reconciled); - } catch (err: unknown) { - if (latestRequestRef.current.fetchKey !== fetchKey) { - return; - } - setData(null); - setUsernameErrors({ - username1: null, - username2: null, - }); - setGeneralError(localizeErrorMessage(err instanceof Error ? err.message : undefined)); - } finally { - if (inFlightFetchKeyRef.current === fetchKey) { - inFlightFetchKeyRef.current = null; - inFlightPromiseRef.current = null; - setLoading(false); - } - } - })(); - - inFlightFetchKeyRef.current = fetchKey; - inFlightPromiseRef.current = requestPromise; - - // mark duplicate fetch disabled while request is in-flight - setDisableDuplicateFetch( - isComparisonFetchDuplicate( - currentFetchKey, - lastFetchedKeyRef.current, - inFlightFetchKeyRef.current, - Boolean(data), - ), - ); - - return requestPromise; - }; - - const syncToUrl = useEffectEvent((u1: string, u2: string, languages: string[]) => { - setUsername1(u1); - setUsername2(u2); - setSelectedLanguages(languages); - - if (!u1 || !u2) { - latestRequestRef.current = createComparisonRequest(u1, u2, languages); - lastFetchedKeyRef.current = null; - setData(null); - resetErrors(); - setDisableDuplicateFetch(false); - return; - } - - void handleCompare(u1, u2, { - selectedLanguages: languages, - updateUrl: false, - }); - }); - - useEffect(() => { - const [u1, u2] = parseUsernamesFromSearchParams(searchParams); - const urlLanguages = sanitizeSelectedLanguages(searchParams.getAll("selectedLanguage")); - queueMicrotask(() => { - syncToUrl(u1, u2, urlLanguages); - }); - }, [searchParams]); - - useEffect(() => { - if (hideTimerRef.current !== null) { - window.clearTimeout(hideTimerRef.current); - hideTimerRef.current = null; - } - - if (data) { - return; - } - - if (loading || !displayData) { - return; - } - - hideTimerRef.current = window.setTimeout(() => { - setDisplayData(null); - hideTimerRef.current = null; - }, EXIT_ANIMATION_MS); - - return () => { - if (hideTimerRef.current !== null) { - window.clearTimeout(hideTimerRef.current); - hideTimerRef.current = null; - } - }; - }, [data, displayData, loading]); + const { + username1, + username2, + selectedLanguages, + handleUsername1Change, + handleUsername2Change, + setSelectedLanguages, + handleCompare, + reset, + swapUsers, + loading, + data, + displayData, + disableDuplicateFetch, + isRefreshing, + isExiting, + generalError, + usernameErrors, + } = useComparisonController(); const skeleton = useMemo(() => , []); - const isRefreshing = loading && Boolean(displayData); - const isExiting = !loading && !data && Boolean(displayData); - - useEffect(() => { - const currentFetchKey = createComparisonRequest( - username1, - username2, - selectedLanguages, - ).fetchKey; - - const lastKey = lastFetchedKeyRef.current; - const inFlightKey = inFlightFetchKeyRef.current; - - const disabled = isComparisonFetchDuplicate( - currentFetchKey, - lastKey, - inFlightKey, - Boolean(data), - ); - setDisableDuplicateFetch(disabled); - }, [username1, username2, selectedLanguages, data, loading]); - - const handleUsername1Change = (value: string) => { - setUsername1(value); - if (usernameErrors.username1) { - setUsernameErrors((current) => ({ ...current, username1: null })); - } - }; - - const handleUsername2Change = (value: string) => { - setUsername2(value); - if (usernameErrors.username2) { - setUsernameErrors((current) => ({ ...current, username2: null })); - } - }; - - const reset = () => { - setLoading(false); - setData(null); - resetErrors(); - inFlightFetchKeyRef.current = null; - inFlightPromiseRef.current = null; - latestRequestRef.current = createComparisonRequest("", "", []); - setDisableDuplicateFetch(false); - setUsername1(""); - setUsername2(""); - setSelectedLanguages([]); - router.push("/", { scroll: false }); - }; - - const swapUsers = () => { - const nextUsername1 = username2; - const nextUsername2 = username1; - const nextRequest = createComparisonRequest(nextUsername1, nextUsername2, selectedLanguages); - latestRequestRef.current = nextRequest; - - setUsername1(nextUsername1); - setUsername2(nextUsername2); - router.push(`/?${createComparisonQuery(nextRequest)}`, { scroll: false }); - - setData((current) => - current ? reconcileComparisonData(current, nextRequest.fetchKey, nextRequest) : current, - ); - setDisplayData((current) => - current ? reconcileComparisonData(current, nextRequest.fetchKey, nextRequest) : current, - ); - }; return (
diff --git a/src/features/comparison/components/result-dashboard.tsx b/src/features/comparison/components/result-dashboard.tsx index 4d8e05d..7572dac 100644 --- a/src/features/comparison/components/result-dashboard.tsx +++ b/src/features/comparison/components/result-dashboard.tsx @@ -1,10 +1,11 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import Link from "next/link"; import type { Route } from "next"; import { Check, Copy, ExternalLink, Trophy } from "lucide-react"; import { useSearchParams } from "next/navigation"; +import { useClipboardCopy } from "@/hooks"; import { Avatar } from "@/components/layout/avatar"; import { ComparisonChart } from "./comparison-chart"; import { TopList } from "./top-list"; @@ -61,7 +62,7 @@ export function ResultDashboard({ }: Props) { const { t } = useTranslation(); const searchParams = useSearchParams(); - const [copied, setCopied] = useState(false); + const { copied, copy } = useClipboardCopy(); const methodologyHref = useMemo(() => { const query = searchParams.toString(); return query ? `/scoring-methodology?${query}` : "/scoring-methodology"; @@ -109,27 +110,21 @@ export function ResultDashboard({ ? t("results.pointsLead", { points: winnerDiffPoints }) : `${winnerDiffPct}%`; - const handleCopy = async () => { - try { - await navigator.clipboard.writeText( - JSON.stringify( - { - user1, - user2, - winner, - languageWinner, - insights, - scoreVersion, - }, - null, - 2, - ), - ); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - setCopied(false); - } + const handleCopy = () => { + copy( + JSON.stringify( + { + user1, + user2, + winner, + languageWinner, + insights, + scoreVersion, + }, + null, + 2, + ), + ); }; const renderScoreGroup = (user: UserResult) => { diff --git a/src/features/comparison/hooks/index.ts b/src/features/comparison/hooks/index.ts new file mode 100644 index 0000000..ced7e6e --- /dev/null +++ b/src/features/comparison/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-comparison-controller"; diff --git a/src/features/comparison/hooks/use-comparison-controller.ts b/src/features/comparison/hooks/use-comparison-controller.ts new file mode 100644 index 0000000..f265a5a --- /dev/null +++ b/src/features/comparison/hooks/use-comparison-controller.ts @@ -0,0 +1,468 @@ +"use client"; + +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useTranslation } from "@/components/providers/language-provider"; +import type { UserResult } from "@/features/developer"; +import type { SafeApiError } from "@/types/api"; +import type { CompareInsights, CompareWinner, ComparisonResponse } from "../types"; +import { + createComparisonQuery, + createComparisonRequest, + isComparisonFetchDuplicate, + reconcileComparisonData, + sanitizeSelectedLanguages, +} from "../services/compare-request"; + +export type ComparisonData = { + user1: UserResult; + user2: UserResult; + winner?: CompareWinner; + languageWinner?: { + username: string; + finalScoreDifference: number; + percentageDifference: number | null; + selectedLanguages: string[]; + }; + insights?: CompareInsights; + scoreVersion?: string; +}; + +export type CompareOptions = { + selectedLanguages: string[]; + updateUrl?: boolean; +}; + +export type UsernameErrors = { + username1: string | null; + username2: string | null; +}; + +const EXIT_ANIMATION_MS = 240; + +function normalizeUsers(body: ComparisonResponse): { user1: UserResult; user2: UserResult } | null { + if (body.users && body.users.length >= 2) { + return { user1: body.users[0], user2: body.users[1] }; + } + + return null; +} + +function parseUsernamesFromSearchParams(searchParams: { + getAll: (name: string) => string[]; + get: (name: string) => string | null; +}): [string, string] { + const repeated = searchParams + .getAll("username") + .map((u) => u.trim()) + .filter(Boolean); + const u1 = + repeated[0] || searchParams.get("username1")?.trim() || searchParams.get("user1")?.trim() || ""; + const u2 = + repeated[1] || searchParams.get("username2")?.trim() || searchParams.get("user2")?.trim() || ""; + return [u1, u2]; +} + +export function useComparisonController() { + const { t } = useTranslation(); + const router = useRouter(); + const searchParams = useSearchParams(); + + const [initialUsername1, initialUsername2] = parseUsernamesFromSearchParams(searchParams); + const initialSelectedLanguages = sanitizeSelectedLanguages( + searchParams.getAll("selectedLanguage"), + ); + + const [loading, setLoading] = useState(false); + const [generalError, setGeneralError] = useState(null); + const [usernameErrors, setUsernameErrors] = useState({ + username1: null, + username2: null, + }); + + const [username1, setUsername1] = useState(initialUsername1); + const [username2, setUsername2] = useState(initialUsername2); + const [selectedLanguages, setSelectedLanguages] = useState(initialSelectedLanguages); + const [data, setData] = useState(null); + const [displayData, setDisplayData] = useState(null); + const [disableDuplicateFetch, setDisableDuplicateFetch] = useState(false); + + const lastFetchedKeyRef = useRef(null); + const inFlightFetchKeyRef = useRef(null); + const inFlightPromiseRef = useRef | null>(null); + const latestRequestRef = useRef( + createComparisonRequest(initialUsername1, initialUsername2, initialSelectedLanguages), + ); + const hideTimerRef = useRef(null); + + const localizeErrorMessage = (message?: string, details?: SafeApiError) => { + if (details) { + switch (details.code) { + case "RATE_LIMITED": + return t("error.rateLimited", { + seconds: details.retryAfterSeconds ?? 60, + }); + case "TEMPORARY_THROTTLE": + return t("error.tempThrottle", { + seconds: details.retryAfterSeconds ?? 60, + }); + case "GITHUB_TIMEOUT": + return t("error.timeout"); + case "GITHUB_RESOURCE_LIMIT": + return t("error.resourceLimit"); + case "GITHUB_AUTH": + return t("error.missingToken"); + case "GITHUB_NOT_FOUND": + return t("error.userNotFound"); + case "NETWORK": + return t("error.fetchFailed"); + default: + break; + } + } + + switch (message) { + case "provide exactly two username params": + return t("error.missingUsername"); + case "GitHub user not found": + return t("error.userNotFound"); + case "Failed to calculate score": + return t("error.calculateFailed"); + case "Comparison failed": + return t("error.comparisonFailed"); + case "Failed to fetch": + return t("error.fetchFailed"); + case "Missing GITHUB_TOKEN": + return t("error.missingToken"); + default: + return t("error.generic"); + } + }; + + const createNotFoundFieldMessage = (username: string): string => { + const localizedPrefix = t("error.userNotFound"); + return `${localizedPrefix}: ${username}`; + }; + + const resetErrors = () => { + setGeneralError(null); + setUsernameErrors({ + username1: null, + username2: null, + }); + }; + + const applyApiError = (requestUser1: string, requestUser2: string, body: ComparisonResponse) => { + const details = body.errorDetails; + const localizedMessage = localizeErrorMessage(body.error, details); + + if (details?.code === "GITHUB_NOT_FOUND" && details.targetUsernames?.length) { + const requestedUsernames = [ + { + key: "username1" as const, + value: requestUser1, + }, + { + key: "username2" as const, + value: requestUser2, + }, + ]; + + const nextErrors: UsernameErrors = { username1: null, username2: null }; + + for (const targetUsername of details.targetUsernames) { + const normalizedTarget = targetUsername.trim().toLowerCase(); + const match = requestedUsernames.find( + (entry) => entry.value.trim().toLowerCase() === normalizedTarget, + ); + + if (match) { + nextErrors[match.key] = createNotFoundFieldMessage(match.value); + } + } + + if (nextErrors.username1 || nextErrors.username2) { + setUsernameErrors(nextErrors); + setGeneralError(null); + return; + } + } + + setUsernameErrors({ + username1: null, + username2: null, + }); + setGeneralError(localizedMessage); + }; + + const handleCompare = async (u1: string, u2: string, options: CompareOptions) => { + const request = createComparisonRequest(u1, u2, options.selectedLanguages); + latestRequestRef.current = request; + const fetchKey = request.fetchKey; + + if (inFlightFetchKeyRef.current === fetchKey && inFlightPromiseRef.current) { + return inFlightPromiseRef.current; + } + + // If we've already fetched this exact comparison and have the data, skip. + if (lastFetchedKeyRef.current === fetchKey && data) { + const reconciled = reconcileComparisonData(data, fetchKey, request); + if (reconciled) { + setData(reconciled); + setDisplayData(reconciled); + } + return Promise.resolve(); + } + + lastFetchedKeyRef.current = fetchKey; + + // update duplicate fetch state for current form values + const currentFetchKey = createComparisonRequest( + username1, + username2, + selectedLanguages, + ).fetchKey; + setDisableDuplicateFetch( + isComparisonFetchDuplicate( + currentFetchKey, + lastFetchedKeyRef.current, + inFlightFetchKeyRef.current, + Boolean(data), + ), + ); + + const requestPromise = (async () => { + if (options.updateUrl !== false) { + router.push(`/?${createComparisonQuery(request)}`, { scroll: false }); + } + + setLoading(true); + resetErrors(); + + try { + const res = await fetch(`/api/compare?${createComparisonQuery(request)}`); + + const body: ComparisonResponse = await res.json(); + if (!res.ok) { + if (latestRequestRef.current.fetchKey !== fetchKey) { + return; + } + setData(null); + applyApiError(latestRequestRef.current.user1, latestRequestRef.current.user2, body); + return; + } + const users = normalizeUsers(body); + + if (!body.success || !users) { + if (latestRequestRef.current.fetchKey !== fetchKey) return; + setData(null); + applyApiError(latestRequestRef.current.user1, latestRequestRef.current.user2, body); + return; + } + + const winnerUsername = + body.winner?.username ?? + (users.user1.finalScore > users.user2.finalScore + ? users.user1.username + : users.user2.finalScore > users.user1.finalScore + ? users.user2.username + : undefined); + + const nextData: ComparisonData = { + user1: { ...users.user1, isWinner: winnerUsername === users.user1.username }, + user2: { ...users.user2, isWinner: winnerUsername === users.user2.username }, + winner: body.winner, + languageWinner: body.languageWinner, + insights: body.insights, + scoreVersion: body.scoreVersion, + }; + + const reconciled = reconcileComparisonData(nextData, fetchKey, latestRequestRef.current); + if (!reconciled) { + if (latestRequestRef.current.fetchKey === fetchKey) { + setData(null); + setGeneralError(t("error.generic")); + } + return; + } + + setData(reconciled); + setDisplayData(reconciled); + } catch (err: unknown) { + if (latestRequestRef.current.fetchKey !== fetchKey) { + return; + } + setData(null); + setUsernameErrors({ + username1: null, + username2: null, + }); + setGeneralError(localizeErrorMessage(err instanceof Error ? err.message : undefined)); + } finally { + if (inFlightFetchKeyRef.current === fetchKey) { + inFlightFetchKeyRef.current = null; + inFlightPromiseRef.current = null; + setLoading(false); + } + } + })(); + + inFlightFetchKeyRef.current = fetchKey; + inFlightPromiseRef.current = requestPromise; + + // mark duplicate fetch disabled while request is in-flight + setDisableDuplicateFetch( + isComparisonFetchDuplicate( + currentFetchKey, + lastFetchedKeyRef.current, + inFlightFetchKeyRef.current, + Boolean(data), + ), + ); + + return requestPromise; + }; + + const syncToUrl = useEffectEvent((u1: string, u2: string, languages: string[]) => { + setUsername1(u1); + setUsername2(u2); + setSelectedLanguages(languages); + + if (!u1 || !u2) { + latestRequestRef.current = createComparisonRequest(u1, u2, languages); + lastFetchedKeyRef.current = null; + setData(null); + resetErrors(); + setDisableDuplicateFetch(false); + return; + } + + void handleCompare(u1, u2, { + selectedLanguages: languages, + updateUrl: false, + }); + }); + + useEffect(() => { + const [u1, u2] = parseUsernamesFromSearchParams(searchParams); + const urlLanguages = sanitizeSelectedLanguages(searchParams.getAll("selectedLanguage")); + queueMicrotask(() => { + syncToUrl(u1, u2, urlLanguages); + }); + }, [searchParams]); + + useEffect(() => { + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + + if (data) { + return; + } + + if (loading || !displayData) { + return; + } + + hideTimerRef.current = window.setTimeout(() => { + setDisplayData(null); + hideTimerRef.current = null; + }, EXIT_ANIMATION_MS); + + return () => { + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + }; + }, [data, displayData, loading]); + + const isRefreshing = loading && Boolean(displayData); + const isExiting = !loading && !data && Boolean(displayData); + + useEffect(() => { + const currentFetchKey = createComparisonRequest( + username1, + username2, + selectedLanguages, + ).fetchKey; + + const lastKey = lastFetchedKeyRef.current; + const inFlightKey = inFlightFetchKeyRef.current; + + const disabled = isComparisonFetchDuplicate( + currentFetchKey, + lastKey, + inFlightKey, + Boolean(data), + ); + setDisableDuplicateFetch(disabled); + }, [username1, username2, selectedLanguages, data, loading]); + + const handleUsername1Change = (value: string) => { + setUsername1(value); + if (usernameErrors.username1) { + setUsernameErrors((current) => ({ ...current, username1: null })); + } + }; + + const handleUsername2Change = (value: string) => { + setUsername2(value); + if (usernameErrors.username2) { + setUsernameErrors((current) => ({ ...current, username2: null })); + } + }; + + const reset = () => { + setLoading(false); + setData(null); + resetErrors(); + inFlightFetchKeyRef.current = null; + inFlightPromiseRef.current = null; + latestRequestRef.current = createComparisonRequest("", "", []); + setDisableDuplicateFetch(false); + setUsername1(""); + setUsername2(""); + setSelectedLanguages([]); + router.push("/", { scroll: false }); + }; + + const swapUsers = () => { + const nextUsername1 = username2; + const nextUsername2 = username1; + const nextRequest = createComparisonRequest(nextUsername1, nextUsername2, selectedLanguages); + latestRequestRef.current = nextRequest; + + setUsername1(nextUsername1); + setUsername2(nextUsername2); + router.push(`/?${createComparisonQuery(nextRequest)}`, { scroll: false }); + + setData((current) => + current ? reconcileComparisonData(current, nextRequest.fetchKey, nextRequest) : current, + ); + setDisplayData((current) => + current ? reconcileComparisonData(current, nextRequest.fetchKey, nextRequest) : current, + ); + }; + + return { + username1, + username2, + selectedLanguages, + handleUsername1Change, + handleUsername2Change, + setSelectedLanguages, + handleCompare, + reset, + swapUsers, + loading, + data, + displayData, + disableDuplicateFetch, + isRefreshing, + isExiting, + generalError, + usernameErrors, + }; +} diff --git a/src/features/comparison/index.ts b/src/features/comparison/index.ts index c50b2a0..5d92934 100644 --- a/src/features/comparison/index.ts +++ b/src/features/comparison/index.ts @@ -1,3 +1,4 @@ export * from "./types"; export * from "./components"; export * from "./services/compare-request"; +export * from "./hooks"; diff --git a/src/features/comparison/services/compare-service.ts b/src/features/comparison/services/compare-service.ts index e536e5f..22cb86a 100644 --- a/src/features/comparison/services/compare-service.ts +++ b/src/features/comparison/services/compare-service.ts @@ -1,8 +1,6 @@ import { getUserData } from "@/lib/github"; import { calculateUserScore, normalizeSelectedLanguages } from "@/features/scoring"; -import { getDatabaseStore } from "@/lib/db"; -import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache"; -import { detectCountry } from "@/lib/geo"; +import { persistUserScores } from "@/features/developer/services"; import { DEFAULT_LOCALE, LOCALE_COOKIE, @@ -259,88 +257,53 @@ export async function compareUsers( usernames: string[], selectedLanguages: string[], ): Promise { - const results: ComparedUserResult[] = []; + return Promise.all( + usernames.map(async (username) => { + let data: GitHubUserData; + try { + const { data: userData } = await getUserData(username, { + cacheInRedis: true, + withMetrics: true, + }); + data = userData; + } catch (error: unknown) { + throw new CompareUserFetchError(username, error); + } - for (const username of usernames) { - let data: Awaited; - try { - const { data: userData, metrics } = await getUserData(username, { - cacheInRedis: true, - withMetrics: true, - }); - data = userData; - console.log(metrics); - } catch (error: unknown) { - throw new CompareUserFetchError(username, error); - } + const score = calculateUserScore( + { + ...data, + selectedLanguages, + }, + username, + ); - const score = calculateUserScore( - { - ...data, + // Fire-and-forget: detect country & persist canonical scores into DB + void persistUserScores({ + data, + score, selectedLanguages, - }, - username, - ); - - results.push({ - username: data.login, - name: data.name, - avatarUrl: data.avatarUrl, - repoScore: Math.round(score.repoScore), - prScore: Math.round(score.prScore), - contributionScore: Math.round(score.contributionScore), - finalScore: Math.round(score.finalScore), - normalizedRepoScore: Math.round(score.normalizedRepoScore), - normalizedPRScore: Math.round(score.normalizedPRScore), - normalizedContributionScore: Math.round(score.normalizedContributionScore), - normalizedFinalScore: Math.round(score.normalizedFinalScore), - topRepos: score.topRepos, - topPullRequests: score.topPullRequests, - topCommunityContributions: score.topCommunityContributions, - languageScores: score.languageScores, - signals: score.signals, - explanations: score.explanations, - }); - - // ── Fire-and-forget: detect country & upsert into DB ────────────── - const country = detectCountry(data.location); - if (country && process.env.DATABASE_URL?.trim()) { - const staleDays = parseInt(process.env.GITHUB_USER_STALE_DAYS ?? "14", 10); - const dbScore = selectedLanguages.length > 0 ? calculateUserScore(data, data.login) : score; - - try { - const db = getDatabaseStore(); - db.upsertUser({ - username: data.login, - name: data.name, - avatarUrl: data.avatarUrl, - location: data.location, - country, - rawData: data, - scores: dbScore, - repoScore: Math.round(dbScore.repoScore), - prScore: Math.round(dbScore.prScore), - contributionScore: Math.round(dbScore.contributionScore), - finalScore: Math.round(dbScore.finalScore), - staleDays, - }) - .then(() => { - // Invalidate Redis cache for this country - const cacheConfig = getCacheConfigFromEnv(); - const cacheStore = createCacheStore(cacheConfig); - if (cacheStore.enabled && cacheStore.del) { - const key = `${cacheConfig.namespace}:leaderboard:${country.trim().toLowerCase()}`; - cacheStore.del(key).catch(() => {}); - } - }) - .catch((err: unknown) => { - console.warn("Failed to upsert user from compare:", err); - }); - } catch { - // Ignore DB connection errors in environments without DB - } - } - } + }); - return results; + return { + username: data.login, + name: data.name, + avatarUrl: data.avatarUrl, + repoScore: Math.round(score.repoScore), + prScore: Math.round(score.prScore), + contributionScore: Math.round(score.contributionScore), + finalScore: Math.round(score.finalScore), + normalizedRepoScore: Math.round(score.normalizedRepoScore), + normalizedPRScore: Math.round(score.normalizedPRScore), + normalizedContributionScore: Math.round(score.normalizedContributionScore), + normalizedFinalScore: Math.round(score.normalizedFinalScore), + topRepos: score.topRepos, + topPullRequests: score.topPullRequests, + topCommunityContributions: score.topCommunityContributions, + languageScores: score.languageScores, + signals: score.signals, + explanations: score.explanations, + }; + }), + ); } diff --git a/src/features/comparison/tests/compare-request.test.ts b/src/features/comparison/tests/compare-request.test.ts index da4f972..c9c2eeb 100644 --- a/src/features/comparison/tests/compare-request.test.ts +++ b/src/features/comparison/tests/compare-request.test.ts @@ -120,10 +120,15 @@ describe("comparison response reconciliation", () => { }); test("binds asynchronous completion to the latest presentation ref", () => { - const source = readFileSync( - resolve(process.cwd(), "src", "features", "comparison", "components", "home-page-client.tsx"), - "utf8", + const hookPath = resolve( + process.cwd(), + "src", + "features", + "comparison", + "hooks", + "use-comparison-controller.ts", ); + const source = readFileSync(hookPath, "utf8"); expect(source).toMatch( /reconcileComparisonData\(\s*nextData,\s*fetchKey,\s*latestRequestRef\.current/, diff --git a/src/features/developer/components/user-profile-client.tsx b/src/features/developer/components/user-profile-client.tsx index f6eded4..d351df4 100644 --- a/src/features/developer/components/user-profile-client.tsx +++ b/src/features/developer/components/user-profile-client.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState } from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import type { Route } from "next"; +import { useClipboardCopy } from "@/hooks"; import { ArrowLeft, Check, @@ -48,7 +48,7 @@ type Props = { export function UserProfileClient({ user, location, countryParam }: Props) { const { t } = useTranslation(); const searchParams = useSearchParams(); - const [copied, setCopied] = useState(false); + const { copied, copy } = useClipboardCopy(); const displayName = user.name?.trim() || user.username; const githubUrl = `https://github.com/${user.username}`; @@ -75,15 +75,7 @@ export function UserProfileClient({ user, location, countryParam }: Props) { const flagSlug = activeCountryInfo?.slug || detectedSlug; const flagCode = flagSlug ? getCountryCode(flagSlug) : null; - const handleCopyLink = async () => { - try { - await navigator.clipboard.writeText(window.location.href); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - setCopied(false); - } - }; + const handleCopyLink = () => copy(window.location.href); // Signal stats entries for transparency const signalEntries = user.signals diff --git a/src/features/developer/services/index.ts b/src/features/developer/services/index.ts index a850ca1..3c1fdfc 100644 --- a/src/features/developer/services/index.ts +++ b/src/features/developer/services/index.ts @@ -1 +1,2 @@ export * from "./user-service"; +export * from "./user-persistence"; diff --git a/src/features/developer/services/user-persistence.ts b/src/features/developer/services/user-persistence.ts new file mode 100644 index 0000000..f8cb073 --- /dev/null +++ b/src/features/developer/services/user-persistence.ts @@ -0,0 +1,69 @@ +import { calculateUserScore } from "@/features/scoring"; +import { getDatabaseStore } from "@/lib/db"; +import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache"; +import { detectCountry } from "@/lib/geo"; +import type { GitHubUserData } from "@/lib/github"; +import type { CalculateUserScoreResult } from "@/features/scoring/services"; + +export type PersistUserOptions = { + data: GitHubUserData; + score?: CalculateUserScoreResult; + selectedLanguages?: string[]; + explicitCountry?: string | null; + staleDays?: number; +}; + +/** + * Canonical service function to persist user score data to PostgreSQL + * and invalidate country leaderboard cache in Redis. + * + * Ensures that if selectedLanguages is provided, canonical unfiltered score + * is always computed and persisted into the database. + */ +export async function persistUserScores({ + data, + score, + selectedLanguages = [], + explicitCountry, + staleDays, +}: PersistUserOptions): Promise { + if (!process.env.DATABASE_URL?.trim()) { + return; + } + + const country = explicitCountry !== undefined ? explicitCountry : detectCountry(data.location); + const resolvedStaleDays = staleDays ?? parseInt(process.env.GITHUB_USER_STALE_DAYS ?? "14", 10); + + // Compute canonical unfiltered score if languages were selected or score was omitted + const canonicalScore = + selectedLanguages.length > 0 || !score ? calculateUserScore(data, data.login) : score; + + try { + const db = getDatabaseStore(); + await db.upsertUser({ + username: data.login, + name: data.name, + avatarUrl: data.avatarUrl, + location: data.location, + country, + rawData: data, + scores: canonicalScore, + repoScore: Math.round(canonicalScore.repoScore), + prScore: Math.round(canonicalScore.prScore), + contributionScore: Math.round(canonicalScore.contributionScore), + finalScore: Math.round(canonicalScore.finalScore), + staleDays: resolvedStaleDays, + }); + + if (country) { + const cacheConfig = getCacheConfigFromEnv(); + const cacheStore = createCacheStore(cacheConfig); + if (cacheStore.enabled && cacheStore.del) { + const key = `${cacheConfig.namespace}:leaderboard:${country.trim().toLowerCase()}`; + await cacheStore.del(key).catch(() => {}); + } + } + } catch (err: unknown) { + console.warn(`Failed to persist user score for ${data.login}:`, err); + } +} diff --git a/src/features/developer/services/user-service.ts b/src/features/developer/services/user-service.ts index d265045..43caaed 100644 --- a/src/features/developer/services/user-service.ts +++ b/src/features/developer/services/user-service.ts @@ -1,8 +1,6 @@ import { getUserData } from "@/lib/github"; import { calculateUserScore } from "@/features/scoring"; -import { getDatabaseStore } from "@/lib/db"; -import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache"; -import { detectCountry } from "@/lib/geo"; +import { persistUserScores } from "./user-persistence"; import type { UserProfileResponse, UserResult } from "../types"; import type { GitHubUserData } from "@/lib/github"; @@ -67,44 +65,12 @@ export async function getUserProfile( scoreVersion: process.env.DEVIMPACT_VERSION || undefined, }; - // Fire-and-forget: detect country & upsert into DB if configured - const country = detectCountry(data.location); - if (country && process.env.DATABASE_URL?.trim()) { - const staleDays = parseInt(process.env.GITHUB_USER_STALE_DAYS ?? "14", 10); - const dbScore = - selectedLanguages.length > 0 ? calculateUserScore(data, normalizedUsername) : score; - - try { - const db = getDatabaseStore(); - db.upsertUser({ - username: data.login, - name: data.name, - avatarUrl: data.avatarUrl, - location: data.location, - country, - rawData: data, - scores: dbScore, - repoScore: Math.round(dbScore.repoScore), - prScore: Math.round(dbScore.prScore), - contributionScore: Math.round(dbScore.contributionScore), - finalScore: Math.round(dbScore.finalScore), - staleDays, - }) - .then(() => { - const cacheConfig = getCacheConfigFromEnv(); - const cacheStore = createCacheStore(cacheConfig); - if (cacheStore.enabled && cacheStore.del) { - const key = `${cacheConfig.namespace}:leaderboard:${country.trim().toLowerCase()}`; - cacheStore.del(key).catch(() => {}); - } - }) - .catch((err: unknown) => { - console.warn("Failed to upsert user from user profile:", err); - }); - } catch { - // Ignore in environments without DB - } - } + // Fire-and-forget: detect country & persist canonical scores into DB + void persistUserScores({ + data, + score, + selectedLanguages, + }); return { user, diff --git a/src/features/leaderboard/services/calculate-leaderboard.ts b/src/features/leaderboard/services/calculate-leaderboard.ts index 51c0510..d1a2aa4 100644 --- a/src/features/leaderboard/services/calculate-leaderboard.ts +++ b/src/features/leaderboard/services/calculate-leaderboard.ts @@ -1,9 +1,9 @@ import yaml from "js-yaml"; import { getUserData } from "@/lib/github"; import { calculateUserScore } from "@/features/scoring"; +import { persistUserScores } from "@/features/developer/services"; import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache"; import { getDatabaseStore, type DatabaseStore } from "@/lib/db"; -import { detectCountry } from "@/lib/geo"; import type { CalculateLeaderboardResponse, LeaderboardMeta, @@ -119,20 +119,10 @@ export async function seedNewUsers( }); fetchMetrics.push(metrics); const score = calculateUserScore(data, user.login); - const countryDetected = detectCountry(data.location); - - await db.upsertUser({ - username: data.login, - name: data.name, - avatarUrl: data.avatarUrl, - location: data.location, - country: countryDetected, - rawData: data, - scores: score, - repoScore: Math.round(score.repoScore), - prScore: Math.round(score.prScore), - contributionScore: Math.round(score.contributionScore), - finalScore: Math.round(score.finalScore), + + await persistUserScores({ + data, + score, staleDays, }); @@ -173,20 +163,10 @@ export async function refreshStaleUsers( }); fetchMetrics.push(metrics); const score = calculateUserScore(data, row.username); - const countryDetected = detectCountry(data.location); - - await db.upsertUser({ - username: data.login, - name: data.name, - avatarUrl: data.avatarUrl, - location: data.location, - country: countryDetected, - rawData: data, - scores: score, - repoScore: Math.round(score.repoScore), - prScore: Math.round(score.prScore), - contributionScore: Math.round(score.contributionScore), - finalScore: Math.round(score.finalScore), + + await persistUserScores({ + data, + score, staleDays, }); diff --git a/src/features/leaderboard/services/leaderboard-service.ts b/src/features/leaderboard/services/leaderboard-service.ts index 525c5e4..4c3645c 100644 --- a/src/features/leaderboard/services/leaderboard-service.ts +++ b/src/features/leaderboard/services/leaderboard-service.ts @@ -51,7 +51,6 @@ export async function getLeaderboardResult(country: string): Promise b.score - a.score); + + const total = details.reduce((sum, detail, index) => { + return sum + detail.score * getDiminishingWeight(index); + }, 0); + + return { + total: sanitizeNumber(total), + details, + issuesAnalyzed: issues.length, + externalIssuesCounted, + discussionsAnalyzed: discussions.length, + externalDiscussionsCounted, + }; +} diff --git a/src/features/scoring/services/pr-scoring.ts b/src/features/scoring/services/pr-scoring.ts new file mode 100644 index 0000000..26ac4b3 --- /dev/null +++ b/src/features/scoring/services/pr-scoring.ts @@ -0,0 +1,173 @@ +import type { PullRequestNode } from "@/lib/github"; +import type { PullRequestScoreDetail } from "../types"; +import { getLanguageFactor, getLanguageMatch } from "./language-scoring"; +import { hasLanguageData } from "./repo-scoring"; +import { getDaysSince, getDiminishingWeight, safeLog, sanitizeNumber } from "./scoring-helpers"; + +export type PRScoreResult = { + total: number; + details: PullRequestScoreDetail[]; + mergedExternalPRs: number; + ownRepoPRsIgnored: number; + unmergedPRsIgnored: number; + uniqueExternalPRRepos: number; +}; + +export function getPullRequestRepoActivityFactor( + pushedAt: string | undefined, + referenceDate: Date, +): number { + if (!pushedAt) { + return 0.9; + } + + const daysSincePush = getDaysSince(pushedAt, referenceDate); + if (daysSincePush === null) { + return 0.9; + } + + if (daysSincePush <= 90) { + return 1.1; + } + if (daysSincePush <= 365) { + return 1.0; + } + if (daysSincePush <= 730) { + return 0.85; + } + return 0.7; +} + +export function calculatePRScore( + prs: PullRequestNode[], + username: string, + referenceDate: Date, +): PRScoreResult { + const grouped = new Map(); + const normalizedUsername = username.toLowerCase(); + + let mergedExternalPRs = 0; + let ownRepoPRsIgnored = 0; + let unmergedPRsIgnored = 0; + + for (const pr of prs) { + const repoOwner = pr.repository.owner.login.toLowerCase(); + + if (!pr.merged) { + unmergedPRsIgnored += 1; + continue; + } + + if (repoOwner === normalizedUsername) { + ownRepoPRsIgnored += 1; + continue; + } + + const changedLines = Math.max(0, pr.additions) + Math.max(0, pr.deletions); + const base = safeLog(pr.repository.stargazerCount) * 2; + const sizeFactor = Math.min(safeLog(changedLines), 5); + + let score = base * sizeFactor; + + if (changedLines < 5) { + score *= 0.25; + } + + if (changedLines > 5000) { + score *= 0.6; + } + + score *= getPullRequestRepoActivityFactor(pr.repository.pushedAt, referenceDate); + score = sanitizeNumber(score); + + const repoKey = pr.repository.nameWithOwner; + const existingScores = grouped.get(repoKey) ?? []; + existingScores.push({ pr, score }); + grouped.set(repoKey, existingScores); + mergedExternalPRs += 1; + } + + let total = 0; + const allDetails: PullRequestScoreDetail[] = []; + + for (const repoScores of grouped.values()) { + repoScores.sort((a, b) => b.score - a.score); + + const repoTotal = repoScores.reduce((sum, item, index) => { + return sum + item.score * getDiminishingWeight(index); + }, 0); + + total += repoTotal; + allDetails.push(...repoScores); + } + + allDetails.sort((a, b) => b.score - a.score); + + return { + total: sanitizeNumber(total), + details: allDetails, + mergedExternalPRs, + ownRepoPRsIgnored, + unmergedPRsIgnored, + uniqueExternalPRRepos: grouped.size, + }; +} + +export function calculateLanguagePRScore( + prDetails: PullRequestScoreDetail[], + selectedLanguages: string[], +): { + total: number; + details: Array<{ + pr: PullRequestNode; + score: number; + languageMatch: number; + }>; + prsWithLanguageData: number; + averageLanguageMatch: number; +} { + const grouped = new Map< + string, + Array<{ pr: PullRequestNode; score: number; languageMatch: number }> + >(); + + for (const item of prDetails) { + const languageMatch = getLanguageMatch(item.pr.repository.languages, selectedLanguages); + const languageFactor = getLanguageFactor(languageMatch); + const score = sanitizeNumber(item.score * languageFactor); + const key = item.pr.repository.nameWithOwner; + const current = grouped.get(key) ?? []; + current.push({ pr: item.pr, score, languageMatch }); + grouped.set(key, current); + } + + let total = 0; + const details: Array<{ pr: PullRequestNode; score: number; languageMatch: number }> = []; + + for (const repoScores of grouped.values()) { + repoScores.sort((a, b) => b.score - a.score); + const repoTotal = repoScores.reduce((sum, item, index) => { + return sum + item.score * getDiminishingWeight(index); + }, 0); + total += repoTotal; + details.push(...repoScores); + } + + details.sort((a, b) => b.score - a.score); + + const prsWithLanguageData = details.reduce((count, detail) => { + return count + (hasLanguageData(detail.pr.repository.languages) ? 1 : 0); + }, 0); + + const averageLanguageMatch = + details.length > 0 + ? details.reduce((sum, detail) => sum + detail.languageMatch, 0) / details.length + : 0; + + return { + total: sanitizeNumber(total), + details, + prsWithLanguageData, + averageLanguageMatch: sanitizeNumber(averageLanguageMatch), + }; +} diff --git a/src/features/scoring/services/repo-scoring.ts b/src/features/scoring/services/repo-scoring.ts new file mode 100644 index 0000000..e40081d --- /dev/null +++ b/src/features/scoring/services/repo-scoring.ts @@ -0,0 +1,106 @@ +import type { RepoNode } from "@/lib/github"; +import type { RepoScoreDetail } from "../types"; +import { getLanguageDistribution, getLanguageFactor, getLanguageMatch } from "./language-scoring"; +import { getDaysSince, getRepoRankWeight, safeLog, sanitizeNumber } from "./scoring-helpers"; + +export function getRepoActivityFactor(pushedAt: string | undefined, referenceDate: Date): number { + if (!pushedAt) { + return 0.8; + } + + const daysSincePush = getDaysSince(pushedAt, referenceDate); + if (daysSincePush === null) { + return 0.8; + } + + if (daysSincePush <= 90) { + return 1.2; + } + if (daysSincePush <= 365) { + return 1.0; + } + if (daysSincePush <= 730) { + return 0.7; + } + return 0.4; +} + +export function calculateRepoScore( + repos: RepoNode[], + referenceDate: Date, +): { total: number; details: RepoScoreDetail[] } { + const details = repos.map((repo) => { + const baseRepoScore = + safeLog(repo.stargazerCount) * 5 + + safeLog(repo.forkCount) * 3 + + safeLog(repo.watchers.totalCount) * 2; + + let score = baseRepoScore; + + if (repo.isFork === true) { + score *= 0.2; + } + + score *= getRepoActivityFactor(repo.pushedAt, referenceDate); + + return { repo, score: sanitizeNumber(score) }; + }); + + details.sort((a, b) => b.score - a.score); + + const total = details.reduce((sum, { score }, index) => { + return sum + score * getRepoRankWeight(index); + }, 0); + + return { total: sanitizeNumber(total), details }; +} + +export function hasLanguageData(languages: RepoNode["languages"] | undefined): boolean { + return Object.keys(getLanguageDistribution(languages)).length > 0; +} + +export function calculateLanguageRepoScore( + repoDetails: RepoScoreDetail[], + selectedLanguages: string[], +): { + total: number; + details: Array<{ + repo: RepoNode; + score: number; + languageMatch: number; + }>; + reposWithLanguageData: number; + averageLanguageMatch: number; +} { + const details = repoDetails.map((item) => { + const languageMatch = getLanguageMatch(item.repo.languages, selectedLanguages); + const languageFactor = getLanguageFactor(languageMatch); + return { + repo: item.repo, + score: sanitizeNumber(item.score * languageFactor), + languageMatch, + }; + }); + + details.sort((a, b) => b.score - a.score); + + const total = details.reduce((sum, detail, index) => { + return sum + detail.score * getRepoRankWeight(index); + }, 0); + + const reposWithLanguageData = details.reduce((count, detail) => { + return count + (hasLanguageData(detail.repo.languages) ? 1 : 0); + }, 0); + + const averageLanguageMatch = + details.length > 0 + ? details.reduce((sum, detail) => sum + detail.languageMatch, 0) / details.length + : 0; + + return { + total: sanitizeNumber(total), + details, + reposWithLanguageData, + averageLanguageMatch: sanitizeNumber(averageLanguageMatch), + }; +} diff --git a/src/features/scoring/services/score-engine.ts b/src/features/scoring/services/score-engine.ts index 2961397..7742347 100644 --- a/src/features/scoring/services/score-engine.ts +++ b/src/features/scoring/services/score-engine.ts @@ -1,458 +1,30 @@ import type { DiscussionNode, IssueNode, PullRequestNode, RepoNode } from "@/lib/github"; -import type { - CommunityContributionDetail, - PullRequestScoreDetail, - RepoScoreDetail, - ScoringExplanations, - ScoringSignals, -} from "../types"; +import type { ScoringExplanations, ScoringSignals } from "../types"; +import { getTopLanguages, normalizeSelectedLanguages } from "./language-scoring"; import { - getLanguageDistribution, - getLanguageFactor, - getLanguageMatch, - getTopLanguages, - normalizeSelectedLanguages, -} from "./language-scoring"; - -const MS_PER_DAY = 86_400_000; -const FALLBACK_REFERENCE_DATE = "2026-01-01T00:00:00.000Z"; - -export function safeLog(value: number): number { - return Math.log(Math.max(0, value) + 1); -} - -export function roundScore(value: number): number { - return Number.isFinite(value) ? Math.round(value) : 0; -} - -export function normalizeScore(score: number, k: number): number { - const sanitizedScore = sanitizeNumber(score); - const sanitizedK = Math.max(0, sanitizeNumber(k)); - const denominator = sanitizedScore + sanitizedK; - - if (denominator <= 0) { - return 0; - } - - return (100 * sanitizedScore) / denominator; -} - -export function getDiminishingWeight(index: number): number { - const safeIndex = Math.max(0, index); - return 1 / (safeIndex + 1); -} - -export function getRepoRankWeight(index: number): number { - return index < 5 ? 1 : 0.1; -} - -function sanitizeNumber(value: number): number { - return Number.isFinite(value) ? value : 0; -} - -function parseDate(value?: string): Date | null { - if (!value) { - return null; - } - - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return null; - } - - return parsed; -} - -function resolveReferenceDate(data: { - repos: RepoNode[]; - pullRequests: PullRequestNode[]; - referenceDate?: string; -}): Date { - const timestamps: number[] = []; - - const explicitReference = parseDate(data.referenceDate); - if (explicitReference) { - timestamps.push(explicitReference.getTime()); - } - - for (const repo of data.repos) { - const parsed = parseDate(repo.pushedAt); - if (parsed) { - timestamps.push(parsed.getTime()); - } - } - - for (const pr of data.pullRequests) { - const parsed = parseDate(pr.repository.pushedAt); - if (parsed) { - timestamps.push(parsed.getTime()); - } - } - - if (timestamps.length === 0) { - return new Date(FALLBACK_REFERENCE_DATE); - } - - return new Date(Math.max(...timestamps)); -} - -function getDaysSince(dateValue: string, referenceDate: Date): number | null { - const date = parseDate(dateValue); - if (!date) { - return null; - } - - const diff = referenceDate.getTime() - date.getTime(); - return Math.max(0, diff / MS_PER_DAY); -} - -function getRepoActivityFactor(pushedAt: string | undefined, referenceDate: Date): number { - if (!pushedAt) { - return 0.8; - } - - const daysSincePush = getDaysSince(pushedAt, referenceDate); - if (daysSincePush === null) { - return 0.8; - } - - if (daysSincePush <= 90) { - return 1.2; - } - if (daysSincePush <= 365) { - return 1.0; - } - if (daysSincePush <= 730) { - return 0.7; - } - return 0.4; -} - -function getPullRequestRepoActivityFactor( - pushedAt: string | undefined, - referenceDate: Date, -): number { - if (!pushedAt) { - return 0.9; - } - - const daysSincePush = getDaysSince(pushedAt, referenceDate); - if (daysSincePush === null) { - return 0.9; - } - - if (daysSincePush <= 90) { - return 1.1; - } - if (daysSincePush <= 365) { - return 1.0; - } - if (daysSincePush <= 730) { - return 0.85; - } - return 0.7; -} - -function calculateRepoScore( - repos: RepoNode[], - referenceDate: Date, -): { total: number; details: RepoScoreDetail[] } { - const details = repos.map((repo) => { - const baseRepoScore = - safeLog(repo.stargazerCount) * 5 + - safeLog(repo.forkCount) * 3 + - safeLog(repo.watchers.totalCount) * 2; - - let score = baseRepoScore; - - if (repo.isFork === true) { - score *= 0.2; - } - - score *= getRepoActivityFactor(repo.pushedAt, referenceDate); - - return { repo, score: sanitizeNumber(score) }; - }); - - details.sort((a, b) => b.score - a.score); - - const total = details.reduce((sum, { score }, index) => { - return sum + score * getRepoRankWeight(index); - }, 0); - - return { total: sanitizeNumber(total), details }; -} - -type PRScoreResult = { - total: number; - details: PullRequestScoreDetail[]; - mergedExternalPRs: number; - ownRepoPRsIgnored: number; - unmergedPRsIgnored: number; - uniqueExternalPRRepos: number; -}; - -function calculatePRScore( - prs: PullRequestNode[], - username: string, - referenceDate: Date, -): PRScoreResult { - const grouped = new Map(); - const normalizedUsername = username.toLowerCase(); - - let mergedExternalPRs = 0; - let ownRepoPRsIgnored = 0; - let unmergedPRsIgnored = 0; - - for (const pr of prs) { - const repoOwner = pr.repository.owner.login.toLowerCase(); - - if (!pr.merged) { - unmergedPRsIgnored += 1; - continue; - } - - if (repoOwner === normalizedUsername) { - ownRepoPRsIgnored += 1; - continue; - } - - const changedLines = Math.max(0, pr.additions) + Math.max(0, pr.deletions); - const base = safeLog(pr.repository.stargazerCount) * 2; - const sizeFactor = Math.min(safeLog(changedLines), 5); - - let score = base * sizeFactor; - - if (changedLines < 5) { - score *= 0.25; - } - - if (changedLines > 5000) { - score *= 0.6; - } - - score *= getPullRequestRepoActivityFactor(pr.repository.pushedAt, referenceDate); - score = sanitizeNumber(score); - - const repoKey = pr.repository.nameWithOwner; - const existingScores = grouped.get(repoKey) ?? []; - existingScores.push({ pr, score }); - grouped.set(repoKey, existingScores); - mergedExternalPRs += 1; - } - - let total = 0; - const allDetails: PullRequestScoreDetail[] = []; - - for (const repoScores of grouped.values()) { - repoScores.sort((a, b) => b.score - a.score); - - const repoTotal = repoScores.reduce((sum, item, index) => { - return sum + item.score * getDiminishingWeight(index); - }, 0); - - total += repoTotal; - allDetails.push(...repoScores); - } - - allDetails.sort((a, b) => b.score - a.score); - - return { - total: sanitizeNumber(total), - details: allDetails, - mergedExternalPRs, - ownRepoPRsIgnored, - unmergedPRsIgnored, - uniqueExternalPRRepos: grouped.size, - }; -} - -function calculateCommunityItemScore(item: IssueNode | DiscussionNode): number { - const repoStars = Math.max(0, item.repository.stargazerCount); - const comments = Math.max(0, item.comments.totalCount); - let score = safeLog(repoStars) * safeLog(comments); - - if (comments === 0) { - score *= 0.2; - } - - return sanitizeNumber(score); -} - -type CommunityScoreResult = { - total: number; - details: CommunityContributionDetail[]; - issuesAnalyzed: number; - externalIssuesCounted: number; - discussionsAnalyzed: number; - externalDiscussionsCounted: number; -}; - -function calculateContributionScore( - issues: IssueNode[], - discussions: DiscussionNode[], - username: string, -): CommunityScoreResult { - const normalizedUsername = username.toLowerCase(); - const details: CommunityContributionDetail[] = []; - - let externalIssuesCounted = 0; - let externalDiscussionsCounted = 0; - - for (const issue of issues) { - if (issue.repository.owner.login.toLowerCase() === normalizedUsername) { - continue; - } - - const score = calculateCommunityItemScore(issue); - details.push({ - type: "issue", - item: issue, - score, - }); - externalIssuesCounted += 1; - } - - for (const discussion of discussions) { - if (discussion.repository.owner.login.toLowerCase() === normalizedUsername) { - continue; - } - - const score = calculateCommunityItemScore(discussion); - details.push({ - type: "discussion", - item: discussion, - score, - }); - externalDiscussionsCounted += 1; - } - - details.sort((a, b) => b.score - a.score); - - const total = details.reduce((sum, detail, index) => { - return sum + detail.score * getDiminishingWeight(index); - }, 0); - - return { - total: sanitizeNumber(total), - details, - issuesAnalyzed: issues.length, - externalIssuesCounted, - discussionsAnalyzed: discussions.length, - externalDiscussionsCounted, - }; -} - -function hasLanguageData(languages: RepoNode["languages"] | undefined): boolean { - return Object.keys(getLanguageDistribution(languages)).length > 0; -} - -function calculateLanguageRepoScore( - repoDetails: RepoScoreDetail[], - selectedLanguages: string[], -): { - total: number; - details: Array<{ - repo: RepoNode; - score: number; - languageMatch: number; - }>; - reposWithLanguageData: number; - averageLanguageMatch: number; -} { - const details = repoDetails.map((item) => { - const languageMatch = getLanguageMatch(item.repo.languages, selectedLanguages); - const languageFactor = getLanguageFactor(languageMatch); - return { - repo: item.repo, - score: sanitizeNumber(item.score * languageFactor), - languageMatch, - }; - }); - - details.sort((a, b) => b.score - a.score); - - const total = details.reduce((sum, detail, index) => { - return sum + detail.score * getRepoRankWeight(index); - }, 0); - - const reposWithLanguageData = details.reduce((count, detail) => { - return count + (hasLanguageData(detail.repo.languages) ? 1 : 0); - }, 0); - - const averageLanguageMatch = - details.length > 0 - ? details.reduce((sum, detail) => sum + detail.languageMatch, 0) / details.length - : 0; - - return { - total: sanitizeNumber(total), - details, - reposWithLanguageData, - averageLanguageMatch: sanitizeNumber(averageLanguageMatch), - }; -} - -function calculateLanguagePRScore( - prDetails: PullRequestScoreDetail[], - selectedLanguages: string[], -): { - total: number; - details: Array<{ - pr: PullRequestNode; - score: number; - languageMatch: number; - }>; - prsWithLanguageData: number; - averageLanguageMatch: number; -} { - const grouped = new Map< - string, - Array<{ pr: PullRequestNode; score: number; languageMatch: number }> - >(); - - for (const item of prDetails) { - const languageMatch = getLanguageMatch(item.pr.repository.languages, selectedLanguages); - const languageFactor = getLanguageFactor(languageMatch); - const score = sanitizeNumber(item.score * languageFactor); - const key = item.pr.repository.nameWithOwner; - const current = grouped.get(key) ?? []; - current.push({ pr: item.pr, score, languageMatch }); - grouped.set(key, current); - } - - let total = 0; - const details: Array<{ pr: PullRequestNode; score: number; languageMatch: number }> = []; - - for (const repoScores of grouped.values()) { - repoScores.sort((a, b) => b.score - a.score); - const repoTotal = repoScores.reduce((sum, item, index) => { - return sum + item.score * getDiminishingWeight(index); - }, 0); - total += repoTotal; - details.push(...repoScores); - } - - details.sort((a, b) => b.score - a.score); - - const prsWithLanguageData = details.reduce((count, detail) => { - return count + (hasLanguageData(detail.pr.repository.languages) ? 1 : 0); - }, 0); - - const averageLanguageMatch = - details.length > 0 - ? details.reduce((sum, detail) => sum + detail.languageMatch, 0) / details.length - : 0; - - return { - total: sanitizeNumber(total), - details, - prsWithLanguageData, - averageLanguageMatch: sanitizeNumber(averageLanguageMatch), - }; -} - -type TopRepo = { + BASE_SCORING_EXPLANATIONS, + COMMUNITY_CAP_RATIO, + LANGUAGE_SCORING_EXPLANATIONS, + SCORE_NORMALIZATION_K, + SCORING_WEIGHTS, +} from "./scoring-constants"; +import { + normalizeScore, + resolveReferenceDate, + roundScore, + sanitizeNumber, +} from "./scoring-helpers"; +import { calculateLanguageRepoScore, calculateRepoScore } from "./repo-scoring"; +import { calculateLanguagePRScore, calculatePRScore } from "./pr-scoring"; +import { calculateContributionScore } from "./community-scoring"; + +export * from "./scoring-constants"; +export * from "./scoring-helpers"; +export * from "./repo-scoring"; +export * from "./pr-scoring"; +export * from "./community-scoring"; + +export type TopRepo = { name: string; url?: string; stars: number; @@ -465,7 +37,7 @@ type TopRepo = { }[]; }; -type TopPullRequest = { +export type TopPullRequest = { repo: string; title: string; url?: string; @@ -479,7 +51,7 @@ type TopPullRequest = { }[]; }; -type TopCommunityContribution = { +export type TopCommunityContribution = { type: "issue" | "discussion"; title: string; url?: string; @@ -489,7 +61,7 @@ type TopCommunityContribution = { score: number; }; -type TopLanguageRepo = TopRepo & { +export type TopLanguageRepo = TopRepo & { languageMatch: number; topLanguages: { name: string; @@ -497,7 +69,7 @@ type TopLanguageRepo = TopRepo & { }[]; }; -type TopLanguagePullRequest = TopPullRequest & { +export type TopLanguagePullRequest = TopPullRequest & { languageMatch: number; topLanguages: { name: string; @@ -505,7 +77,7 @@ type TopLanguagePullRequest = TopPullRequest & { }[]; }; -type LanguageScores = { +export type LanguageScores = { selectedLanguages: string[]; repoScore: number; prScore: number; @@ -547,29 +119,6 @@ export type CalculateUserScoreResult = { explanations: ScoringExplanations; }; -const scoringExplanations: ScoringExplanations = { - repo: [ - "Repository score is based on stars, forks, watchers, and activity.", - "Forked repositories are heavily reduced.", - "Top repositories contribute most to the repository score.", - ], - pr: [ - "Only merged pull requests are counted.", - "Pull requests to the user's own repositories are ignored.", - "Repeated pull requests to the same repository use diminishing returns.", - "Tiny PRs and huge generated PRs are reduced.", - ], - contribution: [ - "Contribution score is based on external issues and discussions only.", - "Commits and pull requests are excluded to avoid double-counting.", - "Issue and discussion impact is based on repository visibility and discussion activity.", - "Contribution score is capped so it cannot dominate the final score.", - ], - overall: [ - "Final score is weighted 45% repository impact, 45% pull request impact, and 10% community contribution impact.", - ], -}; - export function calculateUserScore( data: { repos: RepoNode[]; @@ -599,16 +148,27 @@ export function calculateUserScore( ); let contributionScore = communityScore.total; - contributionScore = Math.min(contributionScore, 0.3 * (repoScore.total + prScore.total)); + contributionScore = Math.min( + contributionScore, + COMMUNITY_CAP_RATIO * (repoScore.total + prScore.total), + ); contributionScore = sanitizeNumber(contributionScore); - const finalScore = repoScore.total * 0.45 + prScore.total * 0.45 + contributionScore * 0.1; + const finalScore = + repoScore.total * SCORING_WEIGHTS.repo + + prScore.total * SCORING_WEIGHTS.pr + + contributionScore * SCORING_WEIGHTS.contribution; - const normalizedRepoScore = normalizeScore(repoScore.total, 100); - const normalizedPRScore = normalizeScore(prScore.total, 300); - const normalizedContributionScore = normalizeScore(contributionScore, 100); + const normalizedRepoScore = normalizeScore(repoScore.total, SCORE_NORMALIZATION_K.repo); + const normalizedPRScore = normalizeScore(prScore.total, SCORE_NORMALIZATION_K.pr); + const normalizedContributionScore = normalizeScore( + contributionScore, + SCORE_NORMALIZATION_K.contribution, + ); const normalizedFinalScore = - normalizedRepoScore * 0.45 + normalizedPRScore * 0.45 + normalizedContributionScore * 0.1; + normalizedRepoScore * SCORING_WEIGHTS.repo + + normalizedPRScore * SCORING_WEIGHTS.pr + + normalizedContributionScore * SCORING_WEIGHTS.contribution; let languageScores: LanguageScores | undefined; let languageRepoSignals: Pick< @@ -625,22 +185,31 @@ export function calculateUserScore( let languageContributionScore = contributionScore; languageContributionScore = Math.min( languageContributionScore, - 0.3 * (languageRepoScore.total + languagePRScore.total), + COMMUNITY_CAP_RATIO * (languageRepoScore.total + languagePRScore.total), ); languageContributionScore = sanitizeNumber(languageContributionScore); const languageFinalScore = - languageRepoScore.total * 0.45 + - languagePRScore.total * 0.45 + - languageContributionScore * 0.1; + languageRepoScore.total * SCORING_WEIGHTS.repo + + languagePRScore.total * SCORING_WEIGHTS.pr + + languageContributionScore * SCORING_WEIGHTS.contribution; - const normalizedLanguageRepoScore = normalizeScore(languageRepoScore.total, 100); - const normalizedLanguagePRScore = normalizeScore(languagePRScore.total, 300); - const normalizedLanguageContributionScore = normalizeScore(languageContributionScore, 100); + const normalizedLanguageRepoScore = normalizeScore( + languageRepoScore.total, + SCORE_NORMALIZATION_K.repo, + ); + const normalizedLanguagePRScore = normalizeScore( + languagePRScore.total, + SCORE_NORMALIZATION_K.pr, + ); + const normalizedLanguageContributionScore = normalizeScore( + languageContributionScore, + SCORE_NORMALIZATION_K.contribution, + ); const normalizedLanguageFinalScore = - normalizedLanguageRepoScore * 0.45 + - normalizedLanguagePRScore * 0.45 + - normalizedLanguageContributionScore * 0.1; + normalizedLanguageRepoScore * SCORING_WEIGHTS.repo + + normalizedLanguagePRScore * SCORING_WEIGHTS.pr + + normalizedLanguageContributionScore * SCORING_WEIGHTS.contribution; languageScores = { selectedLanguages, @@ -687,20 +256,10 @@ export function calculateUserScore( } const explanations: ScoringExplanations = { - ...scoringExplanations, + ...BASE_SCORING_EXPLANATIONS, + ...(hasSelectedLanguages ? { language: LANGUAGE_SCORING_EXPLANATIONS } : {}), }; - if (hasSelectedLanguages) { - explanations.language = [ - "Language-focused score is optional and does not replace the overall score.", - "Repository language match is calculated from GitHub repository language byte distribution.", - "Pull request language match uses the target repository language distribution as an approximation.", - "Non-matching repositories are softly reduced instead of fully ignored.", - "Repositories with missing language data use a neutral language factor.", - "Language matching is applied to repositories and pull requests only.", - ]; - } - return { username, repoScore: sanitizeNumber(repoScore.total), diff --git a/src/features/scoring/services/scoring-constants.ts b/src/features/scoring/services/scoring-constants.ts new file mode 100644 index 0000000..ec647c5 --- /dev/null +++ b/src/features/scoring/services/scoring-constants.ts @@ -0,0 +1,49 @@ +import type { ScoringExplanations } from "../types"; + +export const SCORING_WEIGHTS = { + repo: 0.45, + pr: 0.45, + contribution: 0.1, +} as const; + +export const SCORE_NORMALIZATION_K = { + repo: 100, + pr: 300, + contribution: 100, +} as const; + +export const COMMUNITY_CAP_RATIO = 0.3; +export const MS_PER_DAY = 86_400_000; +export const FALLBACK_REFERENCE_DATE = "2026-01-01T00:00:00.000Z"; + +export const BASE_SCORING_EXPLANATIONS: ScoringExplanations = { + repo: [ + "Repository score is based on stars, forks, watchers, and activity.", + "Forked repositories are heavily reduced.", + "Top repositories contribute most to the repository score.", + ], + pr: [ + "Only merged pull requests are counted.", + "Pull requests to the user's own repositories are ignored.", + "Repeated pull requests to the same repository use diminishing returns.", + "Tiny PRs and huge generated PRs are reduced.", + ], + contribution: [ + "Contribution score is based on external issues and discussions only.", + "Commits and pull requests are excluded to avoid double-counting.", + "Issue and discussion impact is based on repository visibility and discussion activity.", + "Contribution score is capped so it cannot dominate the final score.", + ], + overall: [ + "Final score is weighted 45% repository impact, 45% pull request impact, and 10% community contribution impact.", + ], +}; + +export const LANGUAGE_SCORING_EXPLANATIONS: string[] = [ + "Language-focused score is optional and does not replace the overall score.", + "Repository language match is calculated from GitHub repository language byte distribution.", + "Pull request language match uses the target repository language distribution as an approximation.", + "Non-matching repositories are softly reduced instead of fully ignored.", + "Repositories with missing language data use a neutral language factor.", + "Language matching is applied to repositories and pull requests only.", +]; diff --git a/src/features/scoring/services/scoring-helpers.ts b/src/features/scoring/services/scoring-helpers.ts new file mode 100644 index 0000000..55a9d27 --- /dev/null +++ b/src/features/scoring/services/scoring-helpers.ts @@ -0,0 +1,91 @@ +import type { PullRequestNode, RepoNode } from "@/lib/github"; +import { FALLBACK_REFERENCE_DATE, MS_PER_DAY } from "./scoring-constants"; + +export function safeLog(value: number): number { + return Math.log(Math.max(0, value) + 1); +} + +export function roundScore(value: number): number { + return Number.isFinite(value) ? Math.round(value) : 0; +} + +export function sanitizeNumber(value: number): number { + return Number.isFinite(value) ? value : 0; +} + +export function normalizeScore(score: number, k: number): number { + const sanitizedScore = sanitizeNumber(score); + const sanitizedK = Math.max(0, sanitizeNumber(k)); + const denominator = sanitizedScore + sanitizedK; + + if (denominator <= 0) { + return 0; + } + + return (100 * sanitizedScore) / denominator; +} + +export function getDiminishingWeight(index: number): number { + const safeIndex = Math.max(0, index); + return 1 / (safeIndex + 1); +} + +export function getRepoRankWeight(index: number): number { + return index < 5 ? 1 : 0.1; +} + +export function parseDate(value?: string): Date | null { + if (!value) { + return null; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + + return parsed; +} + +export function resolveReferenceDate(data: { + repos: RepoNode[]; + pullRequests: PullRequestNode[]; + referenceDate?: string; +}): Date { + const timestamps: number[] = []; + + const explicitReference = parseDate(data.referenceDate); + if (explicitReference) { + timestamps.push(explicitReference.getTime()); + } + + for (const repo of data.repos) { + const parsed = parseDate(repo.pushedAt); + if (parsed) { + timestamps.push(parsed.getTime()); + } + } + + for (const pr of data.pullRequests) { + const parsed = parseDate(pr.repository.pushedAt); + if (parsed) { + timestamps.push(parsed.getTime()); + } + } + + if (timestamps.length === 0) { + return new Date(FALLBACK_REFERENCE_DATE); + } + + return new Date(Math.max(...timestamps)); +} + +export function getDaysSince(dateValue: string, referenceDate: Date): number | null { + const date = parseDate(dateValue); + if (!date) { + return null; + } + + const diff = referenceDate.getTime() - date.getTime(); + return Math.max(0, diff / MS_PER_DAY); +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..27308be --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-clipboard-copy"; diff --git a/src/hooks/use-clipboard-copy.ts b/src/hooks/use-clipboard-copy.ts new file mode 100644 index 0000000..73bb112 --- /dev/null +++ b/src/hooks/use-clipboard-copy.ts @@ -0,0 +1,44 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface UseClipboardCopyOptions { + timeoutMs?: number; +} + +export function useClipboardCopy(options: UseClipboardCopyOptions = {}) { + const { timeoutMs = 2000 } = options; + const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, []); + + const copy = useCallback( + async (text: string): Promise => { + try { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + await navigator.clipboard.writeText(text); + setCopied(true); + timerRef.current = setTimeout(() => { + setCopied(false); + timerRef.current = null; + }, timeoutMs); + return true; + } catch { + setCopied(false); + return false; + } + }, + [timeoutMs], + ); + + return { copied, copy }; +} diff --git a/src/lib/api/api-helpers.ts b/src/lib/api/api-helpers.ts new file mode 100644 index 0000000..bb34a53 --- /dev/null +++ b/src/lib/api/api-helpers.ts @@ -0,0 +1,92 @@ +import { NextResponse } from "next/server"; +import { normalizeSelectedLanguages } from "@/features/scoring"; +import { toSafeApiError } from "@/lib/github"; +import type { ClientSafeError, SafeApiError } from "@/types/api"; + +export function toApiErrorStatus(code: ReturnType["code"]): number { + switch (code) { + case "RATE_LIMITED": + case "TEMPORARY_THROTTLE": + return 429; + case "GITHUB_TIMEOUT": + case "GITHUB_RESOURCE_LIMIT": + case "GITHUB_AUTH": + return code === "GITHUB_AUTH" ? 401 : 503; + case "GITHUB_NOT_FOUND": + return 404; + case "NETWORK": + return 503; + case "UNKNOWN": + default: + return 500; + } +} + +export function toClientSafeError(error: SafeApiError): ClientSafeError { + return { + code: error.code, + message: error.message, + targetUsernames: error.targetUsernames, + }; +} + +export function parseSelectedLanguagesFromSearchParams(searchParams: URLSearchParams): string[] { + const fromRepeated = searchParams.getAll("selectedLanguage"); + const fromCsv = searchParams + .get("selectedLanguages") + ?.split(",") + .map((language) => language.trim()) + .filter(Boolean); + + return normalizeSelectedLanguages([...(fromRepeated ?? []), ...(fromCsv ?? [])]); +} + +/** + * Standardized API error response handler for route handlers. + * Translates UserFetchError, CompareUserFetchError, and generic exceptions + * into structured ClientSafeError responses with proper HTTP status codes. + */ +export function formatApiErrorResponse(error: unknown): NextResponse { + let safeError: SafeApiError; + + const isFetchError = + error !== null && + typeof error === "object" && + "causeError" in error && + "username" in error && + typeof (error as { username: unknown }).username === "string"; + + if (isFetchError) { + const fetchErr = error as { username: string; causeError: unknown }; + const mappedCause = toSafeApiError(fetchErr.causeError); + if ( + mappedCause.code === "GITHUB_NOT_FOUND" || + (fetchErr.causeError instanceof Error && fetchErr.causeError.message === "User not found") + ) { + safeError = { + code: "GITHUB_NOT_FOUND", + message: "GitHub user not found", + targetUsernames: [fetchErr.username], + rateLimit: mappedCause.rateLimit, + }; + } else { + safeError = mappedCause; + } + } else { + safeError = + error instanceof Error && error.message === "User not found" + ? { code: "GITHUB_NOT_FOUND", message: "GitHub user not found" } + : toSafeApiError(error); + } + + const clientSafeError = toClientSafeError(safeError); + + return NextResponse.json( + { + success: false, + error: clientSafeError.message, + errorDetails: clientSafeError, + }, + { status: toApiErrorStatus(safeError.code) }, + ); +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts new file mode 100644 index 0000000..1613dbc --- /dev/null +++ b/src/lib/api/index.ts @@ -0,0 +1 @@ +export * from "./api-helpers"; diff --git a/src/lib/db/db-store.ts b/src/lib/db/db-store.ts index ab68d82..ba7c12f 100644 --- a/src/lib/db/db-store.ts +++ b/src/lib/db/db-store.ts @@ -1,16 +1,18 @@ import { Pool, PoolConfig } from "pg"; import countries from "@/data/countries.json"; +import type { GitHubUserData } from "@/lib/github"; +import type { CalculateUserScoreResult } from "@/features/scoring/services"; // ─── Types ───────────────────────────────────────────────────────────── -export type GitHubUserRow = { +export type GitHubUserRow = { username: string; name: string | null; avatar_url: string; location: string | null; country: string | null; - raw_data: unknown; - scores: unknown; + raw_data: TRaw; + scores: TScores; repo_score: number; pr_score: number; contribution_score: number; @@ -21,14 +23,14 @@ export type GitHubUserRow = { updated_at: Date; }; -export type UpsertUserParams = { +export type UpsertUserParams = { username: string; name: string | null; avatarUrl: string; location: string | null; country: string | null; - rawData: unknown; - scores: unknown; + rawData: TRaw; + scores: TScores; repoScore: number; prScore: number; contributionScore: number; diff --git a/src/lib/github/github-client.ts b/src/lib/github/github-client.ts index 42cbea9..05e53b8 100644 --- a/src/lib/github/github-client.ts +++ b/src/lib/github/github-client.ts @@ -821,33 +821,6 @@ export async function getUserData( normalizedUsername, ); - // Upsert into PostgreSQL - try { - const { getDatabaseStore: getDb } = await import("@/lib/db"); - const { calculateUserScore: calcScore } = - await import("@/features/scoring/services/score-engine"); - - const db = getDb(); - const score = calcScore(fresh, normalizedUsername); - - await db.upsertUser({ - username: fresh.login, - name: fresh.name, - avatarUrl: fresh.avatarUrl, - location: fresh.location, - country: null, - rawData: fresh, - scores: score, - repoScore: Math.round(score.repoScore), - prScore: Math.round(score.prScore), - contributionScore: Math.round(score.contributionScore), - finalScore: Math.round(score.finalScore), - staleDays, - }); - } catch { - // Non-fatal: DB write failure - } - // 4. Handle Redis cache if (cacheStoreSingleton.enabled && cacheStoreSingleton.del) { const cacheKey = buildUserCacheKey(normalizedUsername); diff --git a/src/lib/i18n/provider-hook.ts b/src/lib/i18n/provider-hook.ts index 72b4434..53dc6fe 100644 --- a/src/lib/i18n/provider-hook.ts +++ b/src/lib/i18n/provider-hook.ts @@ -84,7 +84,7 @@ export function useI18nProvider(initialLocale: Locale = DEFAULT_LOCALE) { } if (!params) return template; return Object.keys(params).reduce( - (acc, k) => acc.replace(`{${k}}`, String(params[k])), + (acc, k) => acc.split(`{${k}}`).join(String(params[k])), template, ); }, From 80b40abd62142f524e877066dcc3f9c29193de92 Mon Sep 17 00:00:00 2001 From: Osama Mabkhot <99215291+O2sa@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:56:51 +0300 Subject: [PATCH 5/5] fix(persistence): prevent user updates from invalidating country leaderboard cache --- .../developer/services/user-persistence.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/features/developer/services/user-persistence.ts b/src/features/developer/services/user-persistence.ts index f8cb073..bc22579 100644 --- a/src/features/developer/services/user-persistence.ts +++ b/src/features/developer/services/user-persistence.ts @@ -1,6 +1,5 @@ import { calculateUserScore } from "@/features/scoring"; import { getDatabaseStore } from "@/lib/db"; -import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache"; import { detectCountry } from "@/lib/geo"; import type { GitHubUserData } from "@/lib/github"; import type { CalculateUserScoreResult } from "@/features/scoring/services"; @@ -14,11 +13,11 @@ export type PersistUserOptions = { }; /** - * Canonical service function to persist user score data to PostgreSQL - * and invalidate country leaderboard cache in Redis. + * Canonical service function to persist user score data to PostgreSQL. * * Ensures that if selectedLanguages is provided, canonical unfiltered score - * is always computed and persisted into the database. + * is always computed and persisted into the database without busting + * the country leaderboard cache. */ export async function persistUserScores({ data, @@ -54,15 +53,6 @@ export async function persistUserScores({ finalScore: Math.round(canonicalScore.finalScore), staleDays: resolvedStaleDays, }); - - if (country) { - const cacheConfig = getCacheConfigFromEnv(); - const cacheStore = createCacheStore(cacheConfig); - if (cacheStore.enabled && cacheStore.del) { - const key = `${cacheConfig.namespace}:leaderboard:${country.trim().toLowerCase()}`; - await cacheStore.del(key).catch(() => {}); - } - } } catch (err: unknown) { console.warn(`Failed to persist user score for ${data.login}:`, err); }