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;