Skip to content

Commit 8500ad6

Browse files
feat: add profile scanning — user dashboard, profiles scanned stat, mobile input fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 37b7429 commit 8500ad6

10 files changed

Lines changed: 829 additions & 18 deletions

File tree

src/app/api/stats/route.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { NextRequest, NextResponse } from 'next/server'
22

3-
// Repos buried before the Vercel migration — always added on top of the Redis counter.
4-
// Redis only stores new burials since migration; the baseline is never baked in.
53
const BURIED_HISTORICAL_BASELINE = 800
64

75
function normalizeBuriedCount(rawBuried: number | null | undefined) {
@@ -22,15 +20,17 @@ export async function GET() {
2220
try {
2321
const redis = await getRedis()
2422
if (!redis) return NextResponse.json({ buried: BURIED_HISTORICAL_BASELINE, shared: 0, downloaded: 0 })
25-
const [buried, shared, downloaded] = await Promise.all([
23+
const [buried, shared, downloaded, profiles] = await Promise.all([
2624
redis.get<number>('stats:buried'),
2725
redis.get<number>('stats:shared'),
2826
redis.get<number>('stats:downloaded'),
27+
redis.get<number>('stats:profiles'),
2928
])
3029
return NextResponse.json({
3130
buried: normalizeBuriedCount(buried),
3231
shared: shared ?? 0,
3332
downloaded: downloaded ?? 0,
33+
profiles: profiles ?? 0,
3434
})
3535
} catch {
3636
return NextResponse.json({ buried: BURIED_HISTORICAL_BASELINE, shared: 0, downloaded: 0 })
@@ -39,13 +39,14 @@ export async function GET() {
3939

4040
export async function POST(req: NextRequest) {
4141
try {
42-
const { counter } = await req.json() as { counter: 'buried' | 'shared' | 'downloaded' }
43-
if (!['buried', 'shared', 'downloaded'].includes(counter)) {
42+
const { counter, by } = await req.json() as { counter: 'buried' | 'shared' | 'downloaded' | 'profiles'; by?: number }
43+
if (!['buried', 'shared', 'downloaded', 'profiles'].includes(counter)) {
4444
return NextResponse.json({ error: 'invalid counter' }, { status: 400 })
4545
}
4646
const redis = await getRedis()
4747
if (!redis) return NextResponse.json({ ok: true })
48-
await redis.incr(`stats:${counter}`)
48+
const amount = typeof by === 'number' && by > 1 ? by : 1
49+
await redis.incrby(`stats:${counter}`, amount)
4950
return NextResponse.json({ ok: true })
5051
} catch {
5152
return NextResponse.json({ ok: true })

src/app/api/user/route.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import {
3+
computeDeathIndex,
4+
determineCauseOfDeath,
5+
generateLastWords,
6+
getDeathLabel,
7+
formatDate,
8+
} from '@/lib/scoring'
9+
import { RepoData, UserRepoSummary } from '@/lib/types'
10+
import { addRecentBatch } from '@/lib/recentStore'
11+
12+
const VALID_USERNAME = /^[a-zA-Z0-9_.-]+$/
13+
14+
export async function GET(request: NextRequest) {
15+
const username = request.nextUrl.searchParams.get('username')?.trim()
16+
if (!username || !VALID_USERNAME.test(username)) {
17+
return NextResponse.json({ error: 'Invalid username.' }, { status: 400 })
18+
}
19+
20+
const headers: HeadersInit = {
21+
Accept: 'application/vnd.github.v3+json',
22+
'User-Agent': 'commitmentissues.dev',
23+
...(process.env.GITHUB_TOKEN
24+
? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
25+
: {}),
26+
}
27+
28+
const userRes = await fetch(`https://api.github.com/users/${username}`, {
29+
headers,
30+
signal: AbortSignal.timeout(8000),
31+
next: { revalidate: 3600 },
32+
})
33+
34+
if (userRes.status === 404) {
35+
return NextResponse.json(
36+
{ error: `User "${username}" not found on GitHub.` },
37+
{ status: 404 }
38+
)
39+
}
40+
if (userRes.status === 403) {
41+
return NextResponse.json(
42+
{ error: 'GitHub rate limit hit. Try again later.' },
43+
{ status: 429 }
44+
)
45+
}
46+
if (!userRes.ok) {
47+
return NextResponse.json({ error: 'GitHub is unresponsive. Try again.' }, { status: 502 })
48+
}
49+
50+
const [page1Res, page2Res] = await Promise.all([
51+
fetch(
52+
`https://api.github.com/users/${username}/repos?per_page=100&type=public&sort=pushed&page=1`,
53+
{ headers, signal: AbortSignal.timeout(10000), next: { revalidate: 3600 } }
54+
),
55+
fetch(
56+
`https://api.github.com/users/${username}/repos?per_page=100&type=public&sort=pushed&page=2`,
57+
{ headers, signal: AbortSignal.timeout(10000), next: { revalidate: 3600 } }
58+
),
59+
])
60+
61+
const repos1 = page1Res.ok ? await page1Res.json() : []
62+
const repos2 = page2Res.ok ? await page2Res.json() : []
63+
const allRepos = [...repos1, ...(Array.isArray(repos2) ? repos2 : [])]
64+
65+
if (!Array.isArray(allRepos) || allRepos.length === 0) {
66+
return NextResponse.json({ username, repos: [] })
67+
}
68+
69+
const results: UserRepoSummary[] = allRepos.map(r => {
70+
const repoData: RepoData = {
71+
name: r.name,
72+
fullName: r.full_name,
73+
description: r.description ?? null,
74+
createdAt: r.created_at,
75+
pushedAt: r.pushed_at,
76+
isArchived: r.archived ?? false,
77+
stargazersCount: r.stargazers_count ?? 0,
78+
forksCount: r.forks_count ?? 0,
79+
openIssuesCount: r.open_issues_count ?? 0,
80+
language: r.language ?? null,
81+
topics: r.topics ?? [],
82+
isFork: r.fork ?? false,
83+
commitCount: 0,
84+
lastCommitMessage: '',
85+
lastCommitDate: r.pushed_at,
86+
}
87+
const deathIndex = computeDeathIndex(repoData)
88+
const daysSincePush = Math.floor(
89+
(Date.now() - new Date(r.pushed_at).getTime()) / (1000 * 60 * 60 * 24)
90+
)
91+
return {
92+
fullName: r.full_name,
93+
name: r.name,
94+
description: r.description ?? null,
95+
language: r.language ?? null,
96+
deathIndex,
97+
deathLabel: getDeathLabel(deathIndex),
98+
causeOfDeath: determineCauseOfDeath(repoData),
99+
lastWords: generateLastWords(repoData),
100+
lastCommitDate: formatDate(r.pushed_at),
101+
isArchived: r.archived ?? false,
102+
stargazersCount: r.stargazers_count ?? 0,
103+
daysSincePush,
104+
}
105+
})
106+
107+
results.sort((a, b) => b.deathIndex - a.deathIndex)
108+
109+
// Add top 10 to recently buried + increment buried counter
110+
const now = new Date().toISOString()
111+
addRecentBatch(
112+
results.slice(0, 10).map(r => ({
113+
fullName: r.fullName,
114+
cause: r.causeOfDeath,
115+
score: r.deathIndex,
116+
analyzedAt: now,
117+
}))
118+
).catch(() => {})
119+
120+
fetch(`${request.nextUrl.origin}/api/stats`, {
121+
method: 'POST',
122+
headers: { 'Content-Type': 'application/json' },
123+
body: JSON.stringify({ counter: 'buried', by: results.length }),
124+
}).catch(() => {})
125+
126+
fetch(`${request.nextUrl.origin}/api/stats`, {
127+
method: 'POST',
128+
headers: { 'Content-Type': 'application/json' },
129+
body: JSON.stringify({ counter: 'profiles', by: 1 }),
130+
}).catch(() => {})
131+
132+
return NextResponse.json({ username, repos: results })
133+
}

src/app/globals.css

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,126 @@ a.subpage-inline-mail {
610610
animation: section-fade-up 420ms ease-out both;
611611
}
612612

613+
.user-page-header {
614+
width: 100%;
615+
margin-top: 2px;
616+
margin-bottom: 8px;
617+
}
618+
619+
.user-page-kicker {
620+
font-family: var(--font-courier), 'Courier New', monospace;
621+
font-size: 11px;
622+
color: #8a8a8a;
623+
letter-spacing: 0.14em;
624+
text-transform: uppercase;
625+
margin: 0 0 6px 0;
626+
}
627+
628+
.user-page-handle {
629+
font-family: var(--font-dm), -apple-system, sans-serif;
630+
font-size: 28px;
631+
font-weight: 750;
632+
color: #160A06;
633+
margin: 0;
634+
letter-spacing: -0.03em;
635+
word-break: break-word;
636+
}
637+
638+
.user-page-results {
639+
width: 100%;
640+
margin-top: 8px;
641+
}
642+
643+
.user-page-back {
644+
font-family: var(--font-courier), 'Courier New', monospace;
645+
font-size: 10px;
646+
color: #938882;
647+
background: transparent;
648+
border: none;
649+
padding: 0;
650+
margin-bottom: 20px;
651+
letter-spacing: 0.06em;
652+
display: inline-flex;
653+
align-items: center;
654+
}
655+
656+
@media (hover: hover) and (pointer: fine) {
657+
.user-page-back:hover {
658+
color: #1f1f1f;
659+
}
660+
}
661+
662+
.user-page-state {
663+
width: 100%;
664+
padding: 26px 0 12px;
665+
display: flex;
666+
justify-content: center;
667+
}
668+
669+
.user-page-state-card {
670+
width: 100%;
671+
border: 2px solid #d8cfc4;
672+
background: #faf7f3;
673+
padding: 18px 16px;
674+
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
675+
}
676+
677+
.user-page-error {
678+
font-family: var(--font-courier), 'Courier New', monospace;
679+
font-size: 12px;
680+
color: #8B0000;
681+
margin: 0 0 14px 0;
682+
letter-spacing: 0.04em;
683+
}
684+
685+
.user-page-state-actions {
686+
display: flex;
687+
flex-wrap: wrap;
688+
gap: 10px;
689+
align-items: center;
690+
}
691+
692+
.user-page-btn {
693+
font-family: var(--font-dm), -apple-system, sans-serif;
694+
font-size: 13px;
695+
font-weight: 700;
696+
letter-spacing: 0.02em;
697+
border-radius: 8px;
698+
padding: 11px 14px;
699+
min-height: 44px;
700+
border: 2px solid #0a0a0a;
701+
}
702+
703+
.user-page-btn--primary {
704+
background: #0a0a0a;
705+
color: #FAF6EF;
706+
}
707+
708+
@media (hover: hover) and (pointer: fine) {
709+
.user-page-btn--primary:hover {
710+
background: #2a2a2a;
711+
}
712+
}
713+
714+
.user-page-btn--ghost {
715+
background: transparent;
716+
color: #0a0a0a;
717+
}
718+
719+
@media (hover: hover) and (pointer: fine) {
720+
.user-page-btn--ghost:hover {
721+
background: #EDE8E1;
722+
}
723+
}
724+
725+
.user-page-state-hint {
726+
font-family: var(--font-dm), -apple-system, sans-serif;
727+
font-size: 12px;
728+
color: #7a7a7a;
729+
line-height: 1.5;
730+
margin: 14px 0 0 0;
731+
}
732+
613733
.certificate-card-shell {
614734
animation: section-fade-up 380ms ease-out both;
615735
}

0 commit comments

Comments
 (0)