Skip to content

Commit 9bfb4d2

Browse files
jerrysoerclaude
andcommitted
feat: add discovery filters, badge API, sparklines, and detail modal
- Badge API (api/badge.js): shields.io-style SVG badge for README embedding - Search bar with debounced client-side filtering - Filter pills: Biggest Movers, New This Week, Under 1k Stars - Sparkline mini-charts from ranked_snapshots data - Project detail modal with stats, larger sparkline, badge embed snippet - Calibration banner when star deltas are still accumulating Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8467d89 commit 9bfb4d2

3 files changed

Lines changed: 782 additions & 32 deletions

File tree

api/badge.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// SVG badge for README embedding (shields.io style)
2+
// Usage: /api/badge?project=owner--repo
3+
// Returns: ~240x28 SVG badge
4+
5+
async function fetchProject(projectId) {
6+
const url = process.env.SUPABASE_URL
7+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
8+
if (!url || !key) return null
9+
10+
const res = await fetch(
11+
`${url}/rest/v1/ranked_projects?id=eq.${encodeURIComponent(projectId)}&select=rank,stars_gained_7d&limit=1`,
12+
{ headers: { apikey: key, Authorization: `Bearer ${key}` } }
13+
)
14+
if (!res.ok) return null
15+
const rows = await res.json()
16+
return rows[0] || null
17+
}
18+
19+
function escapeXml(str) {
20+
return String(str)
21+
.replace(/&/g, '&amp;')
22+
.replace(/</g, '&lt;')
23+
.replace(/>/g, '&gt;')
24+
.replace(/"/g, '&quot;')
25+
.replace(/'/g, '&apos;')
26+
}
27+
28+
function formatStars(n) {
29+
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k'
30+
return String(n)
31+
}
32+
33+
function generateBadge(project) {
34+
const rank = project.rank
35+
const stars = formatStars(project.stars_gained_7d || 0)
36+
const rightText = escapeXml(`#${rank} · +${stars} ★`)
37+
38+
// Color: gold for top 3, blue for rest
39+
const bg = rank <= 3 ? '#FFB830' : '#4D9CFF'
40+
const textColor = rank <= 3 ? '#1a1200' : '#fff'
41+
42+
const labelText = 'ShipRanked'
43+
const labelWidth = 78
44+
const valueWidth = rightText.length * 7.2 + 16
45+
const totalWidth = labelWidth + valueWidth
46+
47+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${labelText}: ${rightText}">
48+
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
49+
<clipPath id="r"><rect width="${totalWidth}" height="20" rx="3" fill="#fff"/></clipPath>
50+
<g clip-path="url(#r)">
51+
<rect width="${labelWidth}" height="20" fill="#333"/>
52+
<rect x="${labelWidth}" width="${valueWidth}" height="20" fill="${bg}"/>
53+
<rect width="${totalWidth}" height="20" fill="url(#s)"/>
54+
</g>
55+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
56+
<text x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${labelText}</text>
57+
<text x="${labelWidth / 2}" y="14">${labelText}</text>
58+
</g>
59+
<g fill="${textColor}" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
60+
<text x="${labelWidth + valueWidth / 2}" y="15" fill-opacity=".3">${rightText}</text>
61+
<text x="${labelWidth + valueWidth / 2}" y="14">${rightText}</text>
62+
</g>
63+
</svg>`
64+
}
65+
66+
function generateNotRankedBadge() {
67+
const labelText = 'ShipRanked'
68+
const rightText = 'not ranked'
69+
const labelWidth = 78
70+
const valueWidth = 72
71+
const totalWidth = labelWidth + valueWidth
72+
73+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${labelText}: ${rightText}">
74+
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
75+
<clipPath id="r"><rect width="${totalWidth}" height="20" rx="3" fill="#fff"/></clipPath>
76+
<g clip-path="url(#r)">
77+
<rect width="${labelWidth}" height="20" fill="#333"/>
78+
<rect x="${labelWidth}" width="${valueWidth}" height="20" fill="#777"/>
79+
<rect width="${totalWidth}" height="20" fill="url(#s)"/>
80+
</g>
81+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
82+
<text x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${labelText}</text>
83+
<text x="${labelWidth / 2}" y="14">${labelText}</text>
84+
<text x="${labelWidth + valueWidth / 2}" y="15" fill-opacity=".3">${rightText}</text>
85+
<text x="${labelWidth + valueWidth / 2}" y="14">${rightText}</text>
86+
</g>
87+
</svg>`
88+
}
89+
90+
export default async function handler(req, res) {
91+
res.setHeader('Content-Type', 'image/svg+xml')
92+
res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate=86400')
93+
res.setHeader('Access-Control-Allow-Origin', '*')
94+
95+
const { project } = req.query
96+
if (!project || !project.includes('--')) {
97+
return res.status(200).send(generateNotRankedBadge())
98+
}
99+
100+
const [owner, ...repoParts] = project.split('--')
101+
const repo = repoParts.join('--')
102+
const projectId = `github:${owner}/${repo}`
103+
104+
try {
105+
const data = await fetchProject(projectId)
106+
if (!data) return res.status(200).send(generateNotRankedBadge())
107+
return res.status(200).send(generateBadge(data))
108+
} catch (err) {
109+
console.error('Badge error:', err)
110+
return res.status(200).send(generateNotRankedBadge())
111+
}
112+
}

0 commit comments

Comments
 (0)