From 8ca28fdec067fc4f62b7f9dfd7a520cd0b0a041a Mon Sep 17 00:00:00 2001 From: Osama Mabkhot <99215291+O2sa@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:22:24 +0300 Subject: [PATCH 1/2] chore: add docker infrastructure for background leaderboard worker and database initialization logic --- .dockerignore | 10 +- .env.example | 45 +++--- .github/workflows/deploy-vps.yml | 32 ++++ .github/workflows/leaderboard-image.yml | 71 +++++++++ README.md | 72 ++++----- lib/db-store.ts | 7 + ops/README.md | 143 ++++++++++++++++++ ops/cron/leaderboard.cron | 1 + ops/deploy/deploy-leaderboard.sh | 40 +++++ ops/docker/.dockerignore | 23 +++ ops/docker/Dockerfile.web | 28 ++++ ops/docker/Dockerfile.worker | 37 +++++ .../docker/docker-compose.yml | 33 ++-- ops/docker/entrypoint.sh | 11 ++ ops/docker/leaderboard-compose.yml | 28 ++++ package.json | 6 +- scripts/calculate-next-country.ts | 17 ++- 17 files changed, 535 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/deploy-vps.yml create mode 100644 .github/workflows/leaderboard-image.yml create mode 100644 ops/README.md create mode 100644 ops/cron/leaderboard.cron create mode 100644 ops/deploy/deploy-leaderboard.sh create mode 100644 ops/docker/.dockerignore create mode 100644 ops/docker/Dockerfile.web create mode 100644 ops/docker/Dockerfile.worker rename docker-compose.yml => ops/docker/docker-compose.yml (68%) create mode 100644 ops/docker/entrypoint.sh create mode 100644 ops/docker/leaderboard-compose.yml diff --git a/.dockerignore b/.dockerignore index 94378b0..001686d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,17 +1,23 @@ .git +.github .nx node_modules **/node_modules +.next +coverage dist **/dist .pnpm-store +*.log +logs.txt npm-debug.log* yarn-debug.log* yarn-error.log* .env .env.* -Dockerfile -**/*.tsbuildinfo +!.env.example +*.tsbuildinfo tmp temp pnpm-lock.yaml.prev +ops/deploy diff --git a/.env.example b/.env.example index b9f4061..1f5691f 100644 --- a/.env.example +++ b/.env.example @@ -1,32 +1,43 @@ +# ── DevImpact Global Environment Configuration ────────────────────────────── +# Copy this file to .env before starting the web application or background worker: +# cp .env.example .env + +# ── GitHub API Configuration ──────────────────────────────────────────────── +# GitHub Personal Access Token (PAT) for GraphQL / REST API requests. +# Required scope: public_repo (or read-only fine-grained token). GITHUB_TOKEN=your_github_token_here -# Public GitHub repository URL shown by the app. -# If omitted, the app falls back to https://github.com/O2sa/DevImpact -NEXT_PUBLIC_GITHUB_REPO_URL=your_github_repo_url_here +# Public GitHub repository URL shown by the web application. +NEXT_PUBLIC_GITHUB_REPO_URL=https://github.com/O2sa/DevImpact -# GitHub query limits +# GitHub query fetch limits per user GITHUB_REPO_COUNT=30 GITHUB_PR_COUNT=80 GITHUB_ISSUE_COUNT=20 GITHUB_DISCUSSION_COUNT=10 +GITHUB_USER_STALE_DAYS=14 -# Leaderboard source data -LEADERBOARD_SOURCE_URL_TEMPLATE=https://raw.githubusercontent.com/ashkulz/committers.top/gh-pages/_data/locations/{country}.yml -DISABLE_CALCULATE_LEADERBOARD_ENDPOINT=false +# ── Database (PostgreSQL) ─────────────────────────────────────────────────── +DATABASE_URL=postgresql://devimpact:devimpact@localhost:5432/devimpact?sslmode=disable +POSTGRES_PASSWORD=CHANGE_THIS_TO_A_LONG_RANDOM_PASSWORD -# Redis caching (optional — strongly recommended for leaderboard performance) -# Use either redis://localhost:6379 or include password if enabled: redis://:password@localhost:6379 -REDIS_URL= +# ── Caching (Redis) ───────────────────────────────────────────────────────── REDIS_ENABLED=false +REDIS_URL=redis://localhost:6379 REDIS_PASSWORD= -# CACHE_NAMESPACE is also accepted as an alias. -# CACHE_NAMESPACE=devimpact:v1 REDIS_CACHE_NAMESPACE=devimpact:v1 -# CACHE_TTL_SECONDS is also accepted as an alias. Valid range: 1-31536000. -# CACHE_TTL_SECONDS=604800 REDIS_CACHE_TTL_SECONDS=604800 REDIS_CONNECT_TIMEOUT_MS=1500 -# ── PostgreSQL (local development) ───────────────── -DATABASE_URL=postgresql://devimpact:devimpact@localhost:5432/devimpact?sslmode=disable -POSTGRES_PASSWORD=CHANGE_THIS_TO_A_LONG_RANDOM_PASSWORD +# ── Leaderboard Data & Worker Options ─────────────────────────────────────── +# Cron schedule for background calculation worker (standard 5-field cron syntax) +# Default: "0 0 * * *" (runs once per day at midnight) +LEADERBOARD_CRON_SCHEDULE=0 0 * * * + +# Data source template for seed country profiles +LEADERBOARD_SOURCE_URL_TEMPLATE=https://raw.githubusercontent.com/ashkulz/committers.top/gh-pages/_data/locations/{country}.yml + +LEADERBOARD_SEED_LIMIT=256 +LEADERBOARD_REFRESH_LIMIT=500 +LEADERBOARD_USER_STALE_DAYS=30 +DISABLE_CALCULATE_LEADERBOARD_ENDPOINT=false diff --git a/.github/workflows/deploy-vps.yml b/.github/workflows/deploy-vps.yml new file mode 100644 index 0000000..4db3d32 --- /dev/null +++ b/.github/workflows/deploy-vps.yml @@ -0,0 +1,32 @@ +name: Deploy Leaderboard Worker to VPS + +on: + workflow_run: + workflows: ["Leaderboard Worker GHCR Image"] + types: + - completed + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + deploy: + name: Deploy to VPS + runs-on: ubuntu-latest + if: ${{ (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') && secrets.VPS_HOST != '' && secrets.VPS_USER != '' && secrets.VPS_SSH_KEY != '' }} + timeout-minutes: 10 + + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + port: ${{ secrets.VPS_SSH_PORT || 22 }} + script: | + cd ~/projects/DevImpact || cd /app || exit 1 + bash ops/deploy/deploy-leaderboard.sh diff --git a/.github/workflows/leaderboard-image.yml b/.github/workflows/leaderboard-image.yml new file mode 100644 index 0000000..18d1241 --- /dev/null +++ b/.github/workflows/leaderboard-image.yml @@ -0,0 +1,71 @@ +name: Leaderboard Worker GHCR Image + +on: + push: + branches: + - main + paths: + - "ops/docker/**" + - "ops/cron/**" + - "package.json" + - "pnpm-lock.yaml" + - "scripts/**" + - "lib/**" + - "app/**" + - "types/**" + - ".github/workflows/leaderboard-image.yml" + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: leaderboard-image-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-push: + name: Build & Push GHCR Image + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/o2sa/devimpact-leaderboard + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=sha,prefix=,format=long + type=ref,event=branch + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ops/docker/Dockerfile.worker + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + GIT_COMMIT_SHA=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/README.md b/README.md index cb0ee95..7f8932a 100644 --- a/README.md +++ b/README.md @@ -161,57 +161,59 @@ Final Score = ## 🚀 Getting Started -### 1. Clone the repo +### ⚡ Option A: Run Full Platform via Docker (Fastest) -```bash -git clone https://github.com/O2sa/DevImpact.git -cd DevImpact -``` - ---- - -### 2. Install dependencies +Run the entire platform (Web App UI + PostgreSQL + Redis + Worker) in 2 simple steps: ```bash -pnpm install +# 1. Copy environment template and set GITHUB_TOKEN +cp .env.example .env + +# 2. Start full platform +docker compose -f ops/docker/docker-compose.yml up -d --build ``` +Then open `http://localhost:3000` in your browser! --- -### 3. Set up environment variables +### 📦 Option B: Run Locally with Node.js & pnpm -Create a `.env` file: +1. **Install dependencies**: + ```bash + pnpm install + ``` -``` -GITHUB_TOKEN=your_github_token -NEXT_PUBLIC_GITHUB_REPO_URL=your_github_repo_url -GITHUB_REPO_COUNT=30 -GITHUB_PR_COUNT=80 -GITHUB_ISSUE_COUNT=20 -GITHUB_DISCUSSION_COUNT=10 -REDIS_URL=redis://localhost:6379 -REDIS_ENABLED=false -REDIS_CACHE_NAMESPACE=devimpact:v1 -REDIS_CACHE_TTL_SECONDS=604800 -``` +2. **Configure environment**: + ```bash + cp .env.example .env + ``` -`CACHE_NAMESPACE` and `CACHE_TTL_SECONDS` are accepted as aliases for the -Redis-prefixed cache settings. The namespace must be non-empty. Cache TTL must -be a positive integer no greater than `31536000` seconds (one year); invalid or -missing values fall back to `devimpact:v1` and `604800` seconds (seven days). +3. **Start local database & Redis**: + ```bash + pnpm db:up && pnpm redis:up + ``` ---- +4. **Run development server**: + ```bash + pnpm run dev + ``` -### 4. Run the app +5. **Calculate leaderboard scores manually**: + ```bash + pnpm leaderboard:calculate + ``` -```bash -pnpm run dev -``` +### Leaderboard Worker & Infrastructure + +The leaderboard score updates run via a dedicated background worker container using Docker & Supercronic. -To calculate the next leaderboard country from the script, run: +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)**. ```bash -pnpm run calculate-next-country +# Quick worker setup (pulls & runs published image) +cp .env.example .env +docker compose -f ops/docker/leaderboard-compose.yml pull +docker compose -f ops/docker/leaderboard-compose.yml up -d ``` --- diff --git a/lib/db-store.ts b/lib/db-store.ts index 90e8ce1..a9882a7 100644 --- a/lib/db-store.ts +++ b/lib/db-store.ts @@ -147,6 +147,13 @@ export class DatabaseStore { async getNextCountryToCalculate(): Promise<{ slug: string; title: string } | null> { const client = getPool(); + // Auto-recover calculations that were interrupted (e.g. by container deploy/restart) + await client.query( + `UPDATE leaderboard_calculation + SET status = 'failed', error_message = 'Interrupted by process restart or timeout', updated_at = NOW() + WHERE status = 'running' AND updated_at < NOW() - INTERVAL '6 hours'`, + ); + const result = await client.query( `SELECT country_slug, country_title FROM leaderboard_calculation WHERE status != 'running' diff --git a/ops/README.md b/ops/README.md new file mode 100644 index 0000000..bc03de8 --- /dev/null +++ b/ops/README.md @@ -0,0 +1,143 @@ +# DevImpact Infrastructure & Deployment (ops/) + +This directory contains the infrastructure, Docker configuration, cron job definitions, and deployment scripts for the **DevImpact Leaderboard Worker**. + +--- + +## Overview + +### What is the Leaderboard Worker? +The Leaderboard Worker is a standalone service that periodically fetches contributor metadata from the GitHub GraphQL/REST APIs, recalculates scores, and updates the shared PostgreSQL database and Redis cache. + +### Why is it separate from the web application? +Calculating leaderboard scores involves heavy API querying, rate limit tracking, and database bulk operations. Running this work asynchronously via a background worker ensures that the Next.js web application remains fast, responsive, and unaffected by calculation spikes. + +--- + +## Directory Structure + +``` +ops/ +├── docker/ +│ ├── Dockerfile.web # Dockerfile for Next.js Web App UI +│ ├── Dockerfile.worker # Single Dockerfile for Leaderboard Worker +│ ├── .dockerignore # Docker build context exclusions +│ ├── entrypoint.sh # Worker cron startup entrypoint +│ ├── docker-compose.yml # Full platform Compose (PostgreSQL, Redis, Web App, Worker) +│ └── leaderboard-compose.yml # Leaderboard Worker Compose +├── cron/ +│ └── leaderboard.cron # Supercronic job schedule +├── deploy/ +│ └── deploy-leaderboard.sh # VPS deployment automation script +└── README.md # Infrastructure documentation +``` + +--- + +## Environment Configuration + +Copy `.env.example` to `.env` in the root directory before running the worker: + +```bash +cp .env.example .env +``` + +--- + +## Developer Workflows + +### 1. Run Published GHCR Image (Locally or on VPS) + +To run the worker using the published container image: + +```bash +# 1. Copy environment template +cp .env.example .env + +# 2. Pull and start container +docker compose -f ops/docker/leaderboard-compose.yml pull +docker compose -f ops/docker/leaderboard-compose.yml up -d +``` + +### 2. Build Docker Image Directly (Optional Local Testing) + +If you want to build the Docker image locally from source: + +```bash +docker build \ + -f ops/docker/Dockerfile.worker \ + --build-arg GIT_COMMIT_SHA=$(git rev-parse HEAD) \ + -t devimpact-leaderboard:local \ + . +``` + +### 3. Manually Run a Single Calculation + +To trigger a calculation manually inside a worker container: + +```bash +docker compose -f ops/docker/leaderboard-compose.yml run --rm \ + leaderboard-cron \ + pnpm leaderboard:calculate +``` + +### 4. Inspect Worker Logs + +The container logs Supercronic output and script execution directly to `stdout`/`stderr`: + +```bash +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. + +### Image Naming & Tagging Architecture + +- **Registry**: `ghcr.io/o2sa/devimpact-leaderboard` +- **Tags**: + - `latest`: Latest build from `main` branch. + - `` (e.g., `ghcr.io/o2sa/devimpact-leaderboard:a1b2c3d...`): Immutable commit tag for reproducibility and pin/rollback capability. + +--- + +## VPS Deployment + +Production deployments on the VPS consume the prebuilt GHCR image. + +### Deployment Script + +To deploy or update the worker on the VPS: + +```bash +bash ops/deploy/deploy-leaderboard.sh +``` + +This script safely executes: +1. `docker compose -f ops/docker/leaderboard-compose.yml pull` +2. **Waits for any active calculation job to finish**: Checks if `devimpact-leaderboard-cron` is currently running a calculation job (`calculate-next-country`) and polls until the job completes naturally. +3. `docker compose -f ops/docker/leaderboard-compose.yml up -d --remove-orphans` once no calculation is running. + +### Rolling Back to a Specific Version + +To rollback to a previous version on the VPS, set the `LEADERBOARD_IMAGE` variable to an explicit commit SHA tag before executing: + +```bash +LEADERBOARD_IMAGE=ghcr.io/o2sa/devimpact-leaderboard: bash ops/deploy/deploy-leaderboard.sh +``` + +--- + +## Concurrent Job Handling & Locking Note + +The leaderboard script uses database-level tracking (`leaderboard_calculation` table with `status = 'running'`). The query selects the next country where `status != 'running'`, preventing the worker from picking a country currently being processed. + +--- + +## Security Best Practices + +1. **Non-Root & Unprivileged**: The container runs under standard user permissions without `--privileged` or Docker socket access. +2. **Runtime Injection**: All credentials (`GITHUB_TOKEN`, `DATABASE_URL`) are passed at container startup via environment variables. diff --git a/ops/cron/leaderboard.cron b/ops/cron/leaderboard.cron new file mode 100644 index 0000000..475068a --- /dev/null +++ b/ops/cron/leaderboard.cron @@ -0,0 +1 @@ +0 0 * * * cd /app && pnpm leaderboard:calculate >> /proc/1/fd/1 2>> /proc/1/fd/2 diff --git a/ops/deploy/deploy-leaderboard.sh b/ops/deploy/deploy-leaderboard.sh new file mode 100644 index 0000000..1fcf9bb --- /dev/null +++ b/ops/deploy/deploy-leaderboard.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Determine script directory and path to compose file +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE_FILE="${SCRIPT_DIR}/../docker/leaderboard-compose.yml" + +echo "==================================================" +echo " DevImpact Leaderboard Worker Deployment" +echo "==================================================" +echo "Compose File: ${COMPOSE_FILE}" + +if [ ! -f "${COMPOSE_FILE}" ]; then + echo "Error: Compose file not found at ${COMPOSE_FILE}" >&2 + exit 1 +fi + +echo "[1/3] Pulling latest GHCR image..." +docker compose -f "${COMPOSE_FILE}" pull + +# Check if worker container is running and an active calculation is in progress +CONTAINER_NAME="devimpact-leaderboard-cron" +if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo "[2/3] Checking for active leaderboard calculation..." + while docker exec "${CONTAINER_NAME}" pgrep -f "calculate-next-country" > /dev/null 2>&1 || \ + docker exec "${CONTAINER_NAME}" sh -c 'ps aux | grep -v grep | grep -q "calculate-next-country"'; do + echo " >> A leaderboard calculation job is currently running. Waiting for it to finish..." + sleep 10 + done + echo " >> No active calculation running (or active calculation completed)." +else + echo "[2/3] Worker container is not running yet." +fi + +echo "[3/3] Recreating leaderboard worker container with new image..." +docker compose -f "${COMPOSE_FILE}" up -d --remove-orphans + +echo "==================================================" +echo " Leaderboard Worker Deployed Successfully!" +echo "==================================================" diff --git a/ops/docker/.dockerignore b/ops/docker/.dockerignore new file mode 100644 index 0000000..001686d --- /dev/null +++ b/ops/docker/.dockerignore @@ -0,0 +1,23 @@ +.git +.github +.nx +node_modules +**/node_modules +.next +coverage +dist +**/dist +.pnpm-store +*.log +logs.txt +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.env +.env.* +!.env.example +*.tsbuildinfo +tmp +temp +pnpm-lock.yaml.prev +ops/deploy diff --git a/ops/docker/Dockerfile.web b/ops/docker/Dockerfile.web new file mode 100644 index 0000000..72fe08d --- /dev/null +++ b/ops/docker/Dockerfile.web @@ -0,0 +1,28 @@ +FROM node:24-alpine + +WORKDIR /app + +# Enable pnpm via corepack +RUN corepack enable + +# Copy dependency manifests first for layer caching +COPY package.json pnpm-lock.yaml ./ + +# Configure workspace policy for native dependencies if required +RUN printf "packages:\n - '.'\n\nallowBuilds:\n esbuild: true\n sharp: true\n unrs-resolver: true\n" > pnpm-workspace.yaml + +# Install dependencies using frozen lockfile +RUN pnpm install --frozen-lockfile + +# Copy application source code +COPY . . + +# Build Next.js application +RUN pnpm build + +EXPOSE 3000 + +ENV PORT=3000 +ENV NODE_ENV=production + +CMD ["pnpm", "start"] diff --git a/ops/docker/Dockerfile.worker b/ops/docker/Dockerfile.worker new file mode 100644 index 0000000..cc957c0 --- /dev/null +++ b/ops/docker/Dockerfile.worker @@ -0,0 +1,37 @@ +FROM node:24-alpine + +WORKDIR /app + +# Enable pnpm via corepack +RUN corepack enable + +# Copy dependency manifests first for layer caching +COPY package.json pnpm-lock.yaml ./ + +# Configure workspace policy for native dependencies if required +RUN printf "packages:\n - '.'\n\nallowBuilds:\n esbuild: true\n sharp: true\n unrs-resolver: true\n" > pnpm-workspace.yaml + +# Install dependencies using frozen lockfile +RUN pnpm install --frozen-lockfile + +# Copy application source code +COPY . . + +# Install Supercronic cron runner +RUN wget -O /usr/local/bin/supercronic \ + https://github.com/aptible/supercronic/releases/download/v0.2.29/supercronic-linux-amd64 \ + && chmod +x /usr/local/bin/supercronic + +# Copy entrypoint script and initial leaderboard cron job definition +COPY ops/docker/entrypoint.sh /usr/local/bin/entrypoint.sh +COPY ops/cron/leaderboard.cron /etc/leaderboard.cron +RUN chmod +x /usr/local/bin/entrypoint.sh + +# Build-time argument for version identification +ARG GIT_COMMIT_SHA=development +ENV DEVIMPACT_VERSION=${GIT_COMMIT_SHA} + +# Default cron schedule environment variable (daily execution at midnight) +ENV LEADERBOARD_CRON_SCHEDULE="0 0 * * *" + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/docker-compose.yml b/ops/docker/docker-compose.yml similarity index 68% rename from docker-compose.yml rename to ops/docker/docker-compose.yml index 45588fb..b35b5b4 100644 --- a/docker-compose.yml +++ b/ops/docker/docker-compose.yml @@ -3,25 +3,20 @@ services: image: postgres:16-alpine container_name: devimpact-postgres restart: unless-stopped - environment: POSTGRES_DB: devimpact POSTGRES_USER: devimpact POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-devimpact} - ports: - "5432:5432" - volumes: - postgres-data:/var/lib/postgresql/data - healthcheck: test: ["CMD-SHELL", "pg_isready -U devimpact -d devimpact"] interval: 10s timeout: 5s retries: 5 start_period: 30s - logging: driver: json-file options: @@ -32,7 +27,6 @@ services: image: redis:7-alpine container_name: devimpact-redis restart: unless-stopped - command: [ "redis-server", @@ -45,13 +39,10 @@ services: "--maxmemory-policy", "allkeys-lru" ] - ports: - "6379:6379" - volumes: - redis-data:/data - healthcheck: test: [ @@ -65,13 +56,33 @@ services: timeout: 3s retries: 5 start_period: 10s - logging: driver: json-file options: max-size: "10m" max-file: "5" + web: + build: + context: ../.. + dockerfile: ops/docker/Dockerfile.web + container_name: devimpact-web + restart: unless-stopped + ports: + - "3000:3000" + env_file: + - path: ${ENV_FILE:-../../.env} + required: false + environment: + DATABASE_URL: ${DATABASE_URL:-postgresql://devimpact:devimpact@postgres:5432/devimpact?sslmode=disable} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + REDIS_ENABLED: ${REDIS_ENABLED:-false} + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + GITHUB_TOKEN: ${GITHUB_TOKEN:-} + depends_on: + postgres: + condition: service_healthy + volumes: postgres-data: - redis-data: \ No newline at end of file + redis-data: diff --git a/ops/docker/entrypoint.sh b/ops/docker/entrypoint.sh new file mode 100644 index 0000000..fe01ff4 --- /dev/null +++ b/ops/docker/entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -e + +# Default to daily execution at midnight if LEADERBOARD_CRON_SCHEDULE is not set +CRON_EXPR="${LEADERBOARD_CRON_SCHEDULE:-0 0 * * *}" + +echo "Configuring leaderboard cron schedule: ${CRON_EXPR}" +echo "${CRON_EXPR} cd /app && pnpm leaderboard:calculate >> /proc/1/fd/1 2>> /proc/1/fd/2" > /etc/leaderboard.cron + +# Execute supercronic +exec supercronic /etc/leaderboard.cron diff --git a/ops/docker/leaderboard-compose.yml b/ops/docker/leaderboard-compose.yml new file mode 100644 index 0000000..5d005bf --- /dev/null +++ b/ops/docker/leaderboard-compose.yml @@ -0,0 +1,28 @@ +services: + leaderboard-cron: + image: ${LEADERBOARD_IMAGE:-ghcr.io/o2sa/devimpact-leaderboard:latest} + container_name: devimpact-leaderboard-cron + restart: unless-stopped + stop_grace_period: 10m + env_file: + - path: ${ENV_FILE:-../../.env} + required: false + environment: + GITHUB_TOKEN: ${GITHUB_TOKEN} + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + REDIS_ENABLED: ${REDIS_ENABLED:-false} + REDIS_PASSWORD: ${REDIS_PASSWORD} + REDIS_CACHE_NAMESPACE: ${REDIS_CACHE_NAMESPACE:-devimpact:v1} + REDIS_CACHE_TTL_SECONDS: ${REDIS_CACHE_TTL_SECONDS:-604800} + REDIS_CONNECT_TIMEOUT_MS: ${REDIS_CONNECT_TIMEOUT_MS:-1500} + LEADERBOARD_CRON_SCHEDULE: ${LEADERBOARD_CRON_SCHEDULE:-0 0 * * *} + LEADERBOARD_SOURCE_URL_TEMPLATE: ${LEADERBOARD_SOURCE_URL_TEMPLATE:-https://raw.githubusercontent.com/ashkulz/committers.top/gh-pages/_data/locations/{country}.yml} + LEADERBOARD_SEED_LIMIT: ${LEADERBOARD_SEED_LIMIT:-256} + LEADERBOARD_REFRESH_LIMIT: ${LEADERBOARD_REFRESH_LIMIT:-500} + LEADERBOARD_USER_STALE_DAYS: ${LEADERBOARD_USER_STALE_DAYS:-30} + GITHUB_USER_STALE_DAYS: ${GITHUB_USER_STALE_DAYS:-14} + GITHUB_REPO_COUNT: ${GITHUB_REPO_COUNT:-30} + GITHUB_PR_COUNT: ${GITHUB_PR_COUNT:-80} + GITHUB_ISSUE_COUNT: ${GITHUB_ISSUE_COUNT:-20} + GITHUB_DISCUSSION_COUNT: ${GITHUB_DISCUSSION_COUNT:-10} diff --git a/package.json b/package.json index e34c93a..e0a21d4 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ "lint": "eslint .", "test": "vitest", "test:watch": "vitest --watch", - "redis:up": "docker compose up -d redis", - "redis:down": "docker compose stop redis", - "db:up": "docker compose up -d postgres", + "redis:up": "docker compose -f ops/docker/docker-compose.yml up -d redis", + "redis:down": "docker compose -f ops/docker/docker-compose.yml stop redis", + "db:up": "docker compose -f ops/docker/docker-compose.yml up -d postgres", "db:init": "tsx scripts/init-db.ts", "db:migrate": "tsx scripts/init-db.ts", "leaderboard:calculate": "tsx scripts/calculate-next-country.ts", diff --git a/scripts/calculate-next-country.ts b/scripts/calculate-next-country.ts index d97a399..b328fd7 100644 --- a/scripts/calculate-next-country.ts +++ b/scripts/calculate-next-country.ts @@ -23,10 +23,22 @@ import { getDatabaseStore } from "@/lib/db-store"; import { calculateLeaderboard } from "@/lib/calculate-leaderboard"; import { logger } from "@/lib/logger"; +let activeCountrySlug: string | null = null; +let isCalculating = false; + +const handleShutdownSignal = (signal: string) => { + logger.warn(`Received ${signal}. Graceful shutdown initiated. Waiting for active calculation (${activeCountrySlug ?? "none"}) to finish...`); +}; + +process.on("SIGTERM", () => handleShutdownSignal("SIGTERM")); +process.on("SIGINT", () => handleShutdownSignal("SIGINT")); + async function main() { const overallStartTime = performance.now(); + const workerVersion = process.env.DEVIMPACT_VERSION || process.env.GIT_COMMIT_SHA || "development"; logger.info("=== DevImpact Leaderboard Calculator Start ==="); - logger.info(`DB: ${(process.env.DATABASE_URL ?? "").slice(0, 40)}...`); + logger.info(`Version: ${workerVersion}`); + logger.info(`DB: ${(process.env.DATABASE_URL ?? "").slice(0, 40)}...`); const db = getDatabaseStore(); await db.initializeSchema(); @@ -40,6 +52,9 @@ async function main() { process.exit(0); } + activeCountrySlug = next.slug; + isCalculating = true; + logger.info(`Selected: ${next.title} (${next.slug})`); // 2. Mark as running From d03cb503ba1524633173ec7a65e97bfda462fcf9 Mon Sep 17 00:00:00 2001 From: Osama Mabkhot <99215291+O2sa@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:25:52 +0300 Subject: [PATCH 2/2] chore(tooling): setup ESLint, Prettier, Husky, and lint-staged - Add Prettier configuration (.prettierrc, .prettierignore) with Tailwind CSS plugin - Integrate eslint-config-prettier into ESLint 9 Flat Config - Setup Husky v9 pre-commit hook with lint-staged - Format codebase with Prettier --- .github/ISSUE_TEMPLATE/bug-report.md | 5 +- .github/ISSUE_TEMPLATE/documentation.md | 5 +- .../ISSUE_TEMPLATE/feature-change-request.md | 5 +- .github/ISSUE_TEMPLATE/feature-request.md | 5 +- .github/ISSUE_TEMPLATE/refactoring.md | 7 +- .husky/pre-commit | 1 + .prettierignore | 8 + .prettierrc | 9 + CODE_OF_CONDUCT.md | 22 +- CONTRIBUTING.md | 15 +- README.md | 11 +- SECURITY.md | 26 +- algorithm.md | 12 +- app/api/compare/route.ts | 67 +- app/globals.css | 19 +- app/layout.tsx | 11 +- .../[country]/country-leaderboard-client.tsx | 8 +- app/leaderboard/[country]/page.tsx | 5 +- app/leaderboard/country-grid-client.tsx | 21 +- app/leaderboard/page.tsx | 6 +- app/manifest.ts | 3 +- app/page.tsx | 5 +- components/app-footer.tsx | 27 +- components/app-header.tsx | 4 +- components/avatar.tsx | 8 +- components/brand-logo.tsx | 8 +- components/breakdown-bars.tsx | 44 +- components/compare-form.tsx | 69 +- components/comparison-chart.tsx | 11 +- components/comparison-table.tsx | 22 +- components/github-link.tsx | 3 +- components/home-page-client.tsx | 96 +- components/insights-list.tsx | 29 +- components/language-provider.tsx | 2 - components/language-switcher.tsx | 13 +- components/leaderboard-table.tsx | 36 +- components/result-dashboard.tsx | 71 +- components/score-card.tsx | 5 +- .../scoring-methodology-page-client.tsx | 4 +- .../scoring/scoring-methodology-flow.tsx | 22 +- components/skeletons.tsx | 4 +- components/theme-toggle.tsx | 8 +- components/top-list.tsx | 42 +- components/ui/alert.tsx | 30 +- components/ui/button.tsx | 14 +- components/ui/card.tsx | 47 +- components/ui/input.tsx | 2 +- components/ui/progress.tsx | 14 +- components/ui/skeleton.tsx | 6 +- components/ui/tooltip.tsx | 30 +- data/countries.json | 2410 ++++++++++++++++- eslint.config.mjs | 10 +- lib/cache-store.ts | 16 +- lib/calculate-leaderboard.ts | 37 +- lib/compare-request.ts | 4 +- lib/country-flags.ts | 2 +- lib/db-store.ts | 17 +- lib/github-graphql-client.ts | 60 +- lib/github.test.ts | 8 +- lib/github.ts | 191 +- lib/i18n-core.ts | 2 +- lib/i18n.ts | 8 +- lib/leaderboard.ts | 4 +- lib/location-detector.ts | 2 +- lib/logger.ts | 2 +- lib/score.ts | 58 +- lib/scoring/languageScoring.ts | 9 +- lib/seo.ts | 5 +- middleware.ts | 2 +- ops/README.md | 3 + ops/docker/docker-compose.yml | 11 +- package.json | 17 + pnpm-lock.yaml | 149 +- scripts/calculate-next-country.ts | 12 +- scripts/init-db.ts | 2 +- scripts/validate-locales.js | 26 +- tailwind.config.ts | 6 +- test/api/compare.route.test.ts | 5 +- test/fixtures/github.ts | 27 +- test/github/github-cache.test.ts | 105 +- test/helpers/score.ts | 21 +- .../calculateUserScore.contribution.test.ts | 5 +- .../calculateUserScore.language.test.ts | 2 +- test/scoring/calculateUserScore.pr.test.ts | 8 +- test/scoring/calculateUserScore.repo.test.ts | 12 +- .../calculateUserScore.scenario.test.ts | 11 +- test/seo/seo.test.ts | 25 +- test/ui/compare-request.test.ts | 46 +- test/ui/scoring-methodology.test.ts | 13 +- tsconfig.json | 20 +- types/github.ts | 3 - types/i18n.ts | 14 +- types/score.ts | 7 +- 93 files changed, 3133 insertions(+), 1211 deletions(-) create mode 100644 .husky/pre-commit create mode 100644 .prettierignore create mode 100644 .prettierrc diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index eb106ba..3901cb1 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -1,10 +1,9 @@ --- name: Bug Report about: Reporting bugs in existing code -title: '' +title: "" labels: bug, good first issue -assignees: '' - +assignees: "" --- ## The Problem diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md index 2a85514..7e53c61 100644 --- a/.github/ISSUE_TEMPLATE/documentation.md +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -1,10 +1,9 @@ --- name: Documentation about: Report issues or additions to Documentation -title: '' +title: "" labels: documentation, good first issue -assignees: '' - +assignees: "" --- ## Description of what to add diff --git a/.github/ISSUE_TEMPLATE/feature-change-request.md b/.github/ISSUE_TEMPLATE/feature-change-request.md index 7cc49cc..dd91022 100644 --- a/.github/ISSUE_TEMPLATE/feature-change-request.md +++ b/.github/ISSUE_TEMPLATE/feature-change-request.md @@ -1,10 +1,9 @@ --- name: Feature-Change Request about: Suggest a change in an existing feature -title: '' +title: "" labels: change, good first issue -assignees: '' - +assignees: "" --- ## What needs to change diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md index 8864bec..4bc541e 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -1,10 +1,9 @@ --- name: Feature Request about: Suggest a new feature for this project -title: '' +title: "" labels: feature, good first issue -assignees: '' - +assignees: "" --- ## Description of the feature diff --git a/.github/ISSUE_TEMPLATE/refactoring.md b/.github/ISSUE_TEMPLATE/refactoring.md index 7fca9e2..6fec0d5 100644 --- a/.github/ISSUE_TEMPLATE/refactoring.md +++ b/.github/ISSUE_TEMPLATE/refactoring.md @@ -1,10 +1,9 @@ --- name: Refactoring Request about: Suggest refactoring or code quality improvements -title: '' +title: "" labels: refactoring, good first issue -assignees: '' - +assignees: "" --- ## Area to Refactor @@ -20,9 +19,7 @@ assignees: '' - describe the refactoring approach in bullet points - > [!NOTE] > **CONTRIBUTIONS ARE WELCOME!** > If you want to get this issue assigned to you, just comment `assign this issue to me`. > You will be assigned to the issue instantly via GitHub-actions bot. - diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..5ee7abd --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm exec lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..fef4326 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +.next +node_modules +dist +build +coverage +public +pnpm-lock.yaml +*.min.* diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ec2b289 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "all", + "printWidth": 100, + "bracketSpacing": true, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 74f4bb2..38a14ff 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -17,23 +17,23 @@ diverse, inclusive, and healthy community. Examples of behavior that contributes to a positive environment for our community include: -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -* Focusing on what is best not just for us as individuals, but for the +- Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: -* The use of sexualized language or imagery, and sexual attention or +- The use of sexualized language or imagery, and sexual attention or advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities @@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an +standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ce133b..601bf25 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,12 +1,10 @@ > [!WARNING] > **Important Note on AI-Generated Contributions** -> -> While we appreciate the use of AI as a productivity tool, pull requests consisting of code or documentation generated entirely by AI **without significant human review and testing** are not welcome. -> +> +> While we appreciate the use of AI as a productivity tool, pull requests consisting of code or documentation generated entirely by AI **without significant human review and testing** are not welcome. +> > Every contributor is responsible for the code they submit. If we suspect a contribution is a "blind" AI generation that has not been verified for logic, security, or style, it will be closed without review. - - --- # Contributing to DevImpact @@ -48,16 +46,19 @@ Thank you for your interest in contributing to DevImpact! This guide will help y ### Installation 1. Install dependencies: + ```bash pnpm install ``` 2. Create a `.env` file in the project root (see `.env.example`): + ``` GITHUB_TOKEN=your_github_token_here ``` 3. Start the development server: + ```bash pnpm dev ``` @@ -90,6 +91,7 @@ DevImpact/ ## Making Changes 1. **Sync your fork** with the latest upstream changes: + ```bash git fetch upstream git checkout main @@ -97,6 +99,7 @@ DevImpact/ ``` 2. **Create a feature branch** from `main`: + ```bash git checkout -b feat/your-feature-name ``` @@ -104,11 +107,13 @@ DevImpact/ 3. **Make your changes** and test them locally. 4. **Run the linter** before committing: + ```bash pnpm lint ``` 5. **Commit your changes** with a clear message: + ```bash git commit -m "feat: add your feature description" ``` diff --git a/README.md b/README.md index f640c7b..1ef09d3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ # 🚀 DevImpact -

License @@ -36,9 +35,6 @@ CSS · Tailwind

- - - **DevImpact** is an open-source platform that compares software developers based on their real impact in the open-source ecosystem — not just raw numbers. It evaluates developers using a smart scoring system that considers: @@ -141,7 +137,6 @@ Final Score = --- - ## 🛠️ Tech Stack ### Frontend @@ -172,6 +167,7 @@ cp .env.example .env # 2. Start full platform docker compose -f ops/docker/docker-compose.yml up -d --build ``` + Then open `http://localhost:3000` in your browser! --- @@ -179,21 +175,25 @@ Then open `http://localhost:3000` in your browser! ### 📦 Option B: Run Locally with Node.js & pnpm 1. **Install dependencies**: + ```bash pnpm install ``` 2. **Configure environment**: + ```bash cp .env.example .env ``` 3. **Start local database & Redis**: + ```bash pnpm db:up && pnpm redis:up ``` 4. **Run development server**: + ```bash pnpm run dev ``` @@ -218,7 +218,6 @@ docker compose -f ops/docker/leaderboard-compose.yml up -d --- - ## 🌍 Localization - Supported languages: English 🇺🇸, Arabic 🇸🇦 diff --git a/SECURITY.md b/SECURITY.md index bc0e133..b91e2b4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,32 +4,38 @@ We actively provide security updates for the following versions of DevImpact: -| Version | Supported | -| ------- | ------------------ | -| Main | ✅ Yes | -| < 1.0.0 | ❌ No | +| Version | Supported | +| ------- | --------- | +| Main | ✅ Yes | +| < 1.0.0 | ❌ No | ## Reporting a Vulnerability We take the security of DevImpact seriously. If you discover a security vulnerability, please do not open a public issue. Instead, follow the steps below: ### How to report + 1. **Email:** Please send a detailed report to osama.f.mabkhot@gmail.com or use GitHub's Private Vulnerability Reporting. 2. **Details:** Include a description of the vulnerability, steps to reproduce, and the potential impact. 3. **Response:** You can expect an acknowledgment within 48 hours. ### Scope + This policy covers the core DevImpact application, its scoring logic, and how it handles the `GITHUB_TOKEN`. It does not cover the GitHub API itself or third-party dependencies (though we appreciate reports regarding how we use them). ## Best Practices for Contributors + To keep this project secure, please keep the following in mind: -* **Environment Variables:** Never commit your `.env` file. It contains your `GITHUB_TOKEN`. -* **Data Sanitization:** Ensure all data fetched from the GitHub GraphQL API is sanitized before being rendered in the UI to prevent XSS. -* **Dependency Updates:** We use automated tools to keep our dependencies up to date. Please ensure your PRs do not introduce insecure or outdated packages. + +- **Environment Variables:** Never commit your `.env` file. It contains your `GITHUB_TOKEN`. +- **Data Sanitization:** Ensure all data fetched from the GitHub GraphQL API is sanitized before being rendered in the UI to prevent XSS. +- **Dependency Updates:** We use automated tools to keep our dependencies up to date. Please ensure your PRs do not introduce insecure or outdated packages. ## Security Controls -* **Code Scanning:** We use GitHub Actions to run automated security scans on every Pull Request. -* **Secret Scanning:** GitHub's secret scanning is enabled to prevent the accidental leak of tokens. + +- **Code Scanning:** We use GitHub Actions to run automated security scans on every Pull Request. +- **Secret Scanning:** GitHub's secret scanning is enabled to prevent the accidental leak of tokens. --- -*Thank you for helping keep DevImpact safe for the open-source community!* + +_Thank you for helping keep DevImpact safe for the open-source community!_ diff --git a/algorithm.md b/algorithm.md index 93891cb..9c39a31 100644 --- a/algorithm.md +++ b/algorithm.md @@ -1,9 +1,7 @@ # DevImpact - - - ### 🧠 Main + ``` compareUsers(user1, user2): @@ -17,6 +15,7 @@ ``` ### 🧠 User Score + ``` calculateUserScore(user): @@ -37,6 +36,7 @@ ``` ### 📦 Repository Score + ``` calculateRepoScore(repos): @@ -65,8 +65,8 @@ RETURN total ``` - ### 🔥 Pull Request Score + ``` calculatePRScore(prs, username): @@ -119,8 +119,8 @@ RETURN totalScore ``` - ### 🌍 Contribution Score (Activity) + ``` calculateContributionScore(contributions): @@ -134,4 +134,4 @@ issues * 0.3 RETURN score -``` \ No newline at end of file +``` diff --git a/app/api/compare/route.ts b/app/api/compare/route.ts index c2e2168..8aa2db4 100644 --- a/app/api/compare/route.ts +++ b/app/api/compare/route.ts @@ -44,22 +44,15 @@ type ComparedUserResult = { normalizedFinalScore: number; topRepos: ReturnType["topRepos"]; topPullRequests: ReturnType["topPullRequests"]; - topCommunityContributions: ReturnType< - typeof calculateUserScore - >["topCommunityContributions"]; + topCommunityContributions: ReturnType["topCommunityContributions"]; languageScores: ReturnType["languageScores"]; signals: ReturnType["signals"]; explanations: ReturnType["explanations"]; }; -type ClientSafeError = Pick< - SafeApiError, - "code" | "message" | "targetUsernames" ->; +type ClientSafeError = Pick; -function parseSelectedLanguagesFromSearchParams( - searchParams: URLSearchParams, -): string[] { +function parseSelectedLanguagesFromSearchParams(searchParams: URLSearchParams): string[] { const fromRepeated = searchParams.getAll("selectedLanguage"); const fromCsv = searchParams .get("selectedLanguages") @@ -67,10 +60,7 @@ function parseSelectedLanguagesFromSearchParams( .map((language) => language.trim()) .filter(Boolean); - return normalizeSelectedLanguages([ - ...(fromRepeated ?? []), - ...(fromCsv ?? []), - ]); + return normalizeSelectedLanguages([...(fromRepeated ?? []), ...(fromCsv ?? [])]); } function calculateWinner(users: ComparedUserResult[]): { @@ -92,8 +82,7 @@ function calculateWinner(users: ComparedUserResult[]): { const [userA, userB] = users; const overallWinner = userA.finalScore >= userB.finalScore ? userA : userB; - const overallLoser = - overallWinner.username === userA.username ? userB : userA; + const overallLoser = overallWinner.username === userA.username ? userB : userA; const overallDifference = Math.abs(userA.finalScore - userB.finalScore); const overallPercentage = calculatePercentageDifference( overallDifference, @@ -116,18 +105,14 @@ function calculateWinner(users: ComparedUserResult[]): { winner: { username: overallWinner.username, finalScoreDifference: Math.round(overallDifference), - percentageDifference: - overallPercentage === null ? null : Math.round(overallPercentage), + percentageDifference: overallPercentage === null ? null : Math.round(overallPercentage), }, }; if (userA.languageScores && userB.languageScores) { const languageWinner = - userA.languageScores.finalScore >= userB.languageScores.finalScore - ? userA - : userB; - const languageLoser = - languageWinner.username === userA.username ? userB : userA; + userA.languageScores.finalScore >= userB.languageScores.finalScore ? userA : userB; + const languageLoser = languageWinner.username === userA.username ? userB : userA; const winnerLanguageScores = languageWinner.languageScores!; const loserLanguageScores = languageLoser.languageScores!; const languageDifference = Math.abs( @@ -141,8 +126,7 @@ function calculateWinner(users: ComparedUserResult[]): { result.languageWinner = { username: languageWinner.username, finalScoreDifference: Math.round(languageDifference), - percentageDifference: - languagePercentage === null ? null : Math.round(languagePercentage), + percentageDifference: languagePercentage === null ? null : Math.round(languagePercentage), selectedLanguages: winnerLanguageScores.selectedLanguages, }; } @@ -150,10 +134,7 @@ function calculateWinner(users: ComparedUserResult[]): { return result; } -function calculatePercentageDifference( - difference: number, - baseline: number, -): number | null { +function calculatePercentageDifference(difference: number, baseline: number): number | null { if (baseline <= 0) { return difference > 0 ? null : 0; } @@ -179,8 +160,7 @@ function createComparisonInsights( const repoLeader = user1.repoScore >= user2.repoScore ? user1 : user2; const prLeader = user1.prScore >= user2.prScore ? user1 : user2; - const contributionLeader = - user1.contributionScore >= user2.contributionScore ? user1 : user2; + const contributionLeader = user1.contributionScore >= user2.contributionScore ? user1 : user2; const user1Strengths: string[] = []; const user2Strengths: string[] = []; @@ -317,11 +297,7 @@ function resolveLocale(request: Request): Locale { return localeFromCookie; } - return parseAcceptLanguage( - request.headers.get("accept-language"), - ["en", "ar"], - DEFAULT_LOCALE, - ); + return parseAcceptLanguage(request.headers.get("accept-language"), ["en", "ar"], DEFAULT_LOCALE); } async function compareUsers( @@ -361,9 +337,7 @@ async function compareUsers( finalScore: Math.round(score.finalScore), normalizedRepoScore: Math.round(score.normalizedRepoScore), normalizedPRScore: Math.round(score.normalizedPRScore), - normalizedContributionScore: Math.round( - score.normalizedContributionScore, - ), + normalizedContributionScore: Math.round(score.normalizedContributionScore), normalizedFinalScore: Math.round(score.normalizedFinalScore), topRepos: score.topRepos, topPullRequests: score.topPullRequests, @@ -376,10 +350,7 @@ async function compareUsers( // ── Fire-and-forget: detect country & upsert into DB ────────────── const country = detectCountry(data.location); if (country) { - const staleDays = parseInt( - process.env.GITHUB_USER_STALE_DAYS ?? "14", - 10, - ); + const staleDays = parseInt(process.env.GITHUB_USER_STALE_DAYS ?? "14", 10); const db = getDatabaseStore(); db.upsertUser({ @@ -414,9 +385,7 @@ async function compareUsers( return results; } -function toApiErrorStatus( - code: ReturnType["code"], -): number { +function toApiErrorStatus(code: ReturnType["code"]): number { switch (code) { case "RATE_LIMITED": case "TEMPORARY_THROTTLE": @@ -458,8 +427,7 @@ export async function GET(request: Request) { try { const locale = resolveLocale(request); - const selectedLanguages = - parseSelectedLanguagesFromSearchParams(searchParams); + const selectedLanguages = parseSelectedLanguagesFromSearchParams(searchParams); const users = await compareUsers(usernames, selectedLanguages); const winnerData = calculateWinner(users); const insights = createComparisonInsights(users, locale); @@ -473,8 +441,7 @@ export async function GET(request: Request) { const mappedCause = toSafeApiError(error.causeError); if ( mappedCause.code === "GITHUB_NOT_FOUND" || - (error.causeError instanceof Error && - error.causeError.message === "User not found") + (error.causeError instanceof Error && error.causeError.message === "User not found") ) { safeError = { code: "GITHUB_NOT_FOUND", diff --git a/app/globals.css b/app/globals.css index 794cfbc..cb526ea 100644 --- a/app/globals.css +++ b/app/globals.css @@ -55,7 +55,12 @@ body { @apply bg-background text-foreground antialiased; - font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; + font-family: + "Inter", + system-ui, + -apple-system, + "Segoe UI", + sans-serif; background-color: hsl(var(--background)); min-height: 100vh; position: relative; @@ -101,14 +106,15 @@ body::after { } .card { - @apply bg-card/90 text-card-foreground shadow-card rounded-2xl border border-border backdrop-blur; - transition: transform 180ms ease, box-shadow 180ms ease; + @apply rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card backdrop-blur; + transition: + transform 180ms ease, + box-shadow 180ms ease; box-shadow: 0 18px 48px rgba(15, 23, 42, 0.12); } - .dark .card { - @apply bg-card/80 border-border; + @apply border-border bg-card/80; box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45); } @@ -116,7 +122,8 @@ html.theme-transition, html.theme-transition *, html.theme-transition *::before, html.theme-transition *::after { - transition-property: background-color, border-color, color, fill, stroke, box-shadow, opacity, backdrop-filter; + transition-property: + background-color, border-color, color, fill, stroke, box-shadow, opacity, backdrop-filter; transition-duration: var(--theme-transition-duration); transition-timing-function: var(--theme-transition-ease); } diff --git a/app/layout.tsx b/app/layout.tsx index 5865918..f459666 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -102,20 +102,13 @@ export default async function RootLayout({ children }: { children: ReactNode }) const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value; const initialLocale = isSupportedLocale(cookieLocale) ? cookieLocale - : parseAcceptLanguage( - headerStore.get("accept-language"), - supportedLocales, - DEFAULT_LOCALE - ); + : parseAcceptLanguage(headerStore.get("accept-language"), supportedLocales, DEFAULT_LOCALE); const dir = getLocaleDir(initialLocale); return ( -