From c567c95613fda1ed786c992d3e6d39f1deddb5b3 Mon Sep 17 00:00:00 2001
From: Pradip Kumar Kohar <980pk4456@gmail.com>
Date: Fri, 7 Aug 2026 12:56:18 +0530
Subject: [PATCH] Add city rankings and city rank badges
---
app/api/card/route.jsx | 27 +++++++++++++++++++++++++--
components/DetailPanel.jsx | 22 ++++++++++++++++------
lib/country.js | 29 +++++++++++++++++++++++++++++
lib/ranking.js | 38 +++++++++++++++++++++++++++++++++-----
styles/main.css | 13 +++++++++++++
5 files changed, 116 insertions(+), 13 deletions(-)
diff --git a/app/api/card/route.jsx b/app/api/card/route.jsx
index 97a815c..0862f3d 100644
--- a/app/api/card/route.jsx
+++ b/app/api/card/route.jsx
@@ -59,6 +59,8 @@ export async function GET(request) {
const agent = classifyAgent(dev);
const power = getPowerTier(dev.score || 0);
+ const hasCityRank = Boolean(dev.cityRank && dev.city);
+ const rankCardWidth = hasCityRank ? '166' : dev.countryRank ? '255' : '522';
return new ImageResponse(
(
@@ -269,7 +271,7 @@ export async function GET(request) {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
- width: dev.countryRank ? '255' : '522',
+ width: rankCardWidth,
height: '82',
padding: '12px 18px',
background: 'linear-gradient(135deg, rgba(34,211,238,0.15), rgba(34,211,238,0.04))',
@@ -289,7 +291,7 @@ export async function GET(request) {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
- width: '255',
+ width: rankCardWidth,
height: '82',
padding: '12px 18px',
background: 'linear-gradient(135deg, rgba(251,146,60,0.15), rgba(251,146,60,0.04))',
@@ -304,6 +306,27 @@ export async function GET(request) {
IN {dev.country.toUpperCase()}
)}
+ {hasCityRank && (
+
+
+ #{formatNum(dev.cityRank)}
+ of {formatNum(dev.cityTotal)}
+
+
IN {dev.city.toUpperCase()}
+
+ )}
{/* Score + Tier */}
diff --git a/components/DetailPanel.jsx b/components/DetailPanel.jsx
index 1444ce3..1d64c11 100644
--- a/components/DetailPanel.jsx
+++ b/components/DetailPanel.jsx
@@ -89,12 +89,22 @@ export default function DetailPanel({ dev, onClose, claimedLogins, openCardOnMou
)}
{dev.countryRank && (
-
- #{formatNum(dev.countryRank)} in {dev.country}
-
+
+
+ #{formatNum(dev.countryRank)} in {dev.country}
+
+ {dev.cityRank && (
+
+ #{formatNum(dev.cityRank)} in {dev.city}
+
+ )}
+
)}
diff --git a/lib/country.js b/lib/country.js
index 3aeff8c..1aee537 100644
--- a/lib/country.js
+++ b/lib/country.js
@@ -53,6 +53,17 @@ const COUNTRY_ALIASES = {
'macedonia': 'North Macedonia',
};
+// Canonical names for common city aliases and alternate spellings.
+const CITY_ALIASES = {
+ 'bangalore': 'Bengaluru',
+ 'bombay': 'Mumbai',
+ 'delhi': 'New Delhi',
+ 'nyc': 'New York',
+ 'new york city': 'New York',
+ 'san fran': 'San Francisco',
+ 'sf': 'San Francisco',
+};
+
export function normalizeCountry(name) {
if (!name) return '';
const trimmed = name.trim();
@@ -64,7 +75,19 @@ export function countryKey(name) {
return normalizeCountry(name).toLowerCase();
}
+export function normalizeCity(name) {
+ if (!name) return '';
+ const normalized = name.trim().replace(/\s+/g, ' ');
+ return CITY_ALIASES[normalized.toLowerCase()] || normalized;
+}
+
+// Comparable form — use whenever city names from different locations are matched
+export function cityKey(name) {
+ return normalizeCity(name).toLowerCase();
+}
+
export function extractCountry(location) {
+ if (!location) return '';
const parts = location.split(/[,\-–]/).map(s => s.trim());
const lastPart = parts[parts.length - 1];
@@ -87,3 +110,9 @@ export function extractCountry(location) {
return lastPart;
}
+
+export function extractCity(location) {
+ if (!location) return '';
+ const parts = location.split(/[,\-–]/).map(s => s.trim()).filter(Boolean);
+ return parts[0] || '';
+}
diff --git a/lib/ranking.js b/lib/ranking.js
index b047adf..018420f 100644
--- a/lib/ranking.js
+++ b/lib/ranking.js
@@ -1,16 +1,32 @@
-import { countryKey, extractCountry, normalizeCountry } from './country.js';
+import {
+ cityKey,
+ countryKey,
+ extractCity,
+ extractCountry,
+ normalizeCity,
+ normalizeCountry,
+} from './country.js';
export function addDeveloperRanks(developers) {
const countryGroups = new Map();
+ const cityGroups = new Map();
developers.forEach((developer) => {
if (!developer.location) return;
const country = normalizeCountry(extractCountry(developer.location));
- const key = countryKey(country);
- if (!key) return;
+ const countryGroupKey = countryKey(country);
+ if (!countryGroupKey) return;
- if (!countryGroups.has(key)) countryGroups.set(key, { country, logins: [] });
- countryGroups.get(key).logins.push(developer.login);
+ if (!countryGroups.has(countryGroupKey)) countryGroups.set(countryGroupKey, { country, logins: [] });
+ countryGroups.get(countryGroupKey).logins.push(developer.login);
+
+ const city = normalizeCity(extractCity(developer.location));
+ const cityGroupKey = cityKey(city);
+ if (!cityGroupKey) return;
+
+ const key = `${countryGroupKey}:${cityGroupKey}`;
+ if (!cityGroups.has(key)) cityGroups.set(key, { city, logins: [] });
+ cityGroups.get(key).logins.push(developer.login);
});
const countryRanks = new Map();
@@ -24,10 +40,22 @@ export function addDeveloperRanks(developers) {
});
});
+ const cityRanks = new Map();
+ cityGroups.forEach(({ city, logins }) => {
+ logins.forEach((login, index) => {
+ cityRanks.set(login, {
+ city,
+ cityRank: index + 1,
+ cityTotal: logins.length,
+ });
+ });
+ });
+
return developers.map((developer, index) => ({
...developer,
globalRank: index + 1,
globalTotal: developers.length,
...(countryRanks.get(developer.login) || {}),
+ ...(cityRanks.get(developer.login) || {}),
}));
}
\ No newline at end of file
diff --git a/styles/main.css b/styles/main.css
index 4edb1c1..ffcd59b 100644
--- a/styles/main.css
+++ b/styles/main.css
@@ -757,6 +757,13 @@ body {
font-weight: 600;
}
+.rank-badge-stack {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+}
+
.rank-badge--global {
background: rgba(8, 145, 178, 0.12);
border: 1px solid rgba(8, 145, 178, 0.4);
@@ -769,6 +776,12 @@ body {
color: #fbbf24;
}
+.rank-badge--city {
+ background: rgba(139, 92, 246, 0.15);
+ border: 1px solid rgba(139, 92, 246, 0.45);
+ color: #a78bfa;
+}
+
.detail-header__links {
display: flex;
gap: 8px;