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/2] 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/2] 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"); + }); +});