Skip to content

Commit 425ca72

Browse files
authored
Merge pull request #2 from btrustteam/feat/top-projects-load-more
feat(TopProjects): show 5 projects initially, add load more button
2 parents e6b0161 + ca86698 commit 425ca72

11 files changed

Lines changed: 175 additions & 56 deletions

File tree

src/__tests__/proxy.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe("proxy", () => {
3030
describe("unauthenticated", () => {
3131
it("redirects /dashboard to /", async () => {
3232
const { proxy } = await import("@/proxy");
33-
const response = await proxy(createRequest("/dashboard") as any);
33+
const response = await proxy(createRequest("/dashboard"));
3434

3535
expect(response.status).toBe(307);
3636
expect(new URL(response.headers.get("location")!).pathname).toBe("/");
@@ -39,7 +39,7 @@ describe("proxy", () => {
3939
it("redirects /developer/satoshi to /", async () => {
4040
const { proxy } = await import("@/proxy");
4141
const response = await proxy(
42-
createRequest("/developer/satoshi") as any
42+
createRequest("/developer/satoshi")
4343
);
4444

4545
expect(response.status).toBe(307);
@@ -49,7 +49,7 @@ describe("proxy", () => {
4949
it("redirects /api/github/* to /", async () => {
5050
const { proxy } = await import("@/proxy");
5151
const response = await proxy(
52-
createRequest("/api/github/overview/test") as any
52+
createRequest("/api/github/overview/test")
5353
);
5454

5555
expect(response.status).toBe(307);
@@ -58,7 +58,7 @@ describe("proxy", () => {
5858

5959
it("allows / (login page)", async () => {
6060
const { proxy } = await import("@/proxy");
61-
const response = await proxy(createRequest("/") as any);
61+
const response = await proxy(createRequest("/"));
6262

6363
expect(response.status).toBe(200);
6464
});
@@ -71,7 +71,7 @@ describe("proxy", () => {
7171

7272
it("redirects when session exists but accessToken is missing", async () => {
7373
const { proxy } = await import("@/proxy");
74-
const response = await proxy(createRequest("/dashboard") as any);
74+
const response = await proxy(createRequest("/dashboard"));
7575

7676
expect(response.status).toBe(307);
7777
expect(new URL(response.headers.get("location")!).pathname).toBe("/");
@@ -87,12 +87,12 @@ describe("proxy", () => {
8787
const { proxy } = await import("@/proxy");
8888

8989
const dashboardRes = await proxy(
90-
createRequest("/dashboard") as any
90+
createRequest("/dashboard")
9191
);
9292
expect(dashboardRes.status).toBe(200);
9393

9494
const devRes = await proxy(
95-
createRequest("/developer/satoshi") as any
95+
createRequest("/developer/satoshi")
9696
);
9797
expect(devRes.status).toBe(200);
9898
});

src/app/api/github/rate-limit/__tests__/route.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { describe, it, expect, vi, beforeEach } from "vitest";
1+
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
22

33
vi.mock("@/lib/auth", () => ({
44
auth: vi.fn(),
55
}));
66
import { auth } from "@/lib/auth";
7-
const mockAuth = vi.mocked(auth);
7+
const mockAuth = auth as unknown as ReturnType<typeof vi.fn>;
88

99
describe("GET /api/github/rate-limit", () => {
1010
beforeEach(() => {
@@ -14,15 +14,15 @@ describe("GET /api/github/rate-limit", () => {
1414
});
1515

1616
it("returns 401 when not authenticated", async () => {
17-
mockAuth.mockResolvedValue(null as any);
17+
mockAuth.mockResolvedValue(null);
1818
const { GET } = await import("@/app/api/github/rate-limit/route");
1919
const res = await GET();
2020
expect(res.status).toBe(401);
2121
});
2222

2323
it("returns rate limit data", async () => {
24-
mockAuth.mockResolvedValue({ accessToken: "token" } as any);
25-
(global.fetch as any).mockResolvedValue({
24+
mockAuth.mockResolvedValue({ accessToken: "token" });
25+
(global.fetch as Mock).mockResolvedValue({
2626
ok: true,
2727
json: async () => ({
2828
resources: {
@@ -42,8 +42,8 @@ describe("GET /api/github/rate-limit", () => {
4242
});
4343

4444
it("returns error status when GitHub API fails", async () => {
45-
mockAuth.mockResolvedValue({ accessToken: "token" } as any);
46-
(global.fetch as any).mockResolvedValue({
45+
mockAuth.mockResolvedValue({ accessToken: "token" });
46+
(global.fetch as Mock).mockResolvedValue({
4747
ok: false,
4848
status: 500,
4949
});

src/components/DeveloperOverviewPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export function DeveloperOverviewPage({ username }: DeveloperOverviewPageProps)
3535
if (data) {
3636
addSearch(data.login, data.avatarUrl);
3737
}
38-
}, [data?.login, data?.avatarUrl, addSearch]);
38+
}, [data, data?.login, data?.avatarUrl, addSearch]);
3939

4040
const errorVariant = error
4141
? error.status === 429

src/components/TopProjects.tsx

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
"use client";
2+
3+
import { useState, useMemo } from "react";
14
import { Star, Globe, Minus } from "lucide-react";
25
import { Card, CardContent } from "@/components/ui/card";
36
import { Badge } from "@/components/ui/badge";
7+
import { Button } from "@/components/ui/button";
48
import { AGGREGATED_SENTINEL } from "@/lib/types";
59
import type { RepoClassification, ContributionItem, RelevanceTier } from "@/lib/types";
610

@@ -22,39 +26,50 @@ const tierColors: Record<RelevanceTier, string> = {
2226
adjacent: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
2327
};
2428

29+
const PAGE_SIZE = 5;
30+
2531
export function TopProjects({ bitcoinRepos, contributions, showAdjacent }: TopProjectsProps) {
26-
const repos = showAdjacent
27-
? bitcoinRepos
28-
: bitcoinRepos.filter((r) => r.tier !== "adjacent");
32+
const [pagination, setPagination] = useState({ count: PAGE_SIZE, filter: showAdjacent });
33+
const visibleCount = pagination.filter === showAdjacent ? pagination.count : PAGE_SIZE;
34+
35+
const countByRepo = useMemo(() => {
36+
const map = new Map<string, number>();
37+
for (const c of contributions) {
38+
if (c.repoNameWithOwner === AGGREGATED_SENTINEL) continue;
39+
map.set(
40+
c.repoNameWithOwner,
41+
(map.get(c.repoNameWithOwner) ?? 0) + c.count
42+
);
43+
}
44+
return map;
45+
}, [contributions]);
46+
47+
const sorted = useMemo(() => {
48+
const repos = showAdjacent
49+
? bitcoinRepos
50+
: bitcoinRepos.filter((r) => r.tier !== "adjacent");
51+
52+
return [...repos].sort((a, b) => {
53+
const ca = countByRepo.get(a.nameWithOwner) ?? 0;
54+
const cb = countByRepo.get(b.nameWithOwner) ?? 0;
55+
return cb - ca;
56+
});
57+
}, [bitcoinRepos, showAdjacent, countByRepo]);
2958

30-
if (repos.length === 0) {
59+
if (sorted.length === 0) {
3160
return (
3261
<p className="text-sm text-zinc-500 dark:text-zinc-400">
3362
No Bitcoin-related projects found.
3463
</p>
3564
);
3665
}
3766

38-
// Sum contribution counts per repo
39-
const countByRepo = new Map<string, number>();
40-
for (const c of contributions) {
41-
if (c.repoNameWithOwner === AGGREGATED_SENTINEL) continue;
42-
countByRepo.set(
43-
c.repoNameWithOwner,
44-
(countByRepo.get(c.repoNameWithOwner) ?? 0) + c.count
45-
);
46-
}
47-
48-
// Sort by contribution count descending
49-
const sorted = [...repos].sort((a, b) => {
50-
const ca = countByRepo.get(a.nameWithOwner) ?? 0;
51-
const cb = countByRepo.get(b.nameWithOwner) ?? 0;
52-
return cb - ca;
53-
});
67+
const visible = sorted.slice(0, visibleCount);
68+
const hasMore = visibleCount < sorted.length;
5469

5570
return (
5671
<div className="space-y-3">
57-
{sorted.map((repo) => {
72+
{visible.map((repo) => {
5873
const repoUrl = repo.url ?? `https://github.com/${repo.nameWithOwner}`;
5974
return (
6075
<a
@@ -87,6 +102,17 @@ export function TopProjects({ bitcoinRepos, contributions, showAdjacent }: TopPr
87102
</a>
88103
);
89104
})}
105+
106+
{hasMore && (
107+
<div className="flex justify-center pt-2">
108+
<Button
109+
variant="outline"
110+
onClick={() => setPagination({ count: visibleCount + PAGE_SIZE, filter: showAdjacent })}
111+
>
112+
Load more
113+
</Button>
114+
</div>
115+
)}
90116
</div>
91117
);
92118
}

src/components/__tests__/ContributionHeatmap.test.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, afterEach } from "vitest";
2-
import { render, screen, cleanup } from "@testing-library/react";
2+
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
33
import { ContributionHeatmap } from "@/components/ContributionHeatmap";
44
import type { ContributionCalendarWeek } from "@/lib/types";
55

@@ -141,7 +141,6 @@ describe("ContributionHeatmap", () => {
141141
expect(cells[1]).toHaveAttribute("tabindex", "-1");
142142

143143
// Simulate arrow down
144-
const { fireEvent } = require("@testing-library/react");
145144
fireEvent.keyDown(grid, { key: "ArrowDown" });
146145

147146
// After pressing down, second cell in first week should be focused

src/components/__tests__/TopProjects.test.tsx

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,53 @@
11
import { describe, it, expect, afterEach } from "vitest";
2-
import { render, screen, cleanup } from "@testing-library/react";
2+
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
3+
import { readFileSync } from "fs";
4+
import { resolve } from "path";
35
import { TopProjects } from "@/components/TopProjects";
46
import type { RepoClassification, ContributionItem } from "@/lib/types";
57

68
afterEach(cleanup);
79

10+
const dateRange = { from: new Date(), to: new Date() };
11+
812
const repos: RepoClassification[] = [
913
{ nameWithOwner: "bitcoin/bitcoin", url: "https://github.com/bitcoin/bitcoin", tier: "core", reason: "curated" },
1014
{ nameWithOwner: "mempool/mempool", url: "https://github.com/mempool/mempool", tier: "ecosystem", reason: "curated" },
1115
{ nameWithOwner: "user/nostr-tool", url: "https://github.com/user/nostr-tool", tier: "adjacent", reason: "keyword" },
1216
];
1317

1418
const contributions: ContributionItem[] = [
15-
{ repoNameWithOwner: "bitcoin/bitcoin", type: "commit", count: 50, dateRange: { from: new Date(), to: new Date() } },
16-
{ repoNameWithOwner: "mempool/mempool", type: "commit", count: 20, dateRange: { from: new Date(), to: new Date() } },
17-
{ repoNameWithOwner: "user/nostr-tool", type: "commit", count: 5, dateRange: { from: new Date(), to: new Date() } },
19+
{ repoNameWithOwner: "bitcoin/bitcoin", type: "commit", count: 50, dateRange },
20+
{ repoNameWithOwner: "mempool/mempool", type: "commit", count: 20, dateRange },
21+
{ repoNameWithOwner: "user/nostr-tool", type: "commit", count: 5, dateRange },
22+
];
23+
24+
// 7 repos to test pagination (PAGE_SIZE = 5)
25+
const manyRepos: RepoClassification[] = [
26+
{ nameWithOwner: "bitcoin/bitcoin", tier: "core", reason: "curated" },
27+
{ nameWithOwner: "lightning/lnd", tier: "core", reason: "curated" },
28+
{ nameWithOwner: "btcsuite/btcd", tier: "core", reason: "curated" },
29+
{ nameWithOwner: "mempool/mempool", tier: "ecosystem", reason: "curated" },
30+
{ nameWithOwner: "blockstream/esplora", tier: "ecosystem", reason: "curated" },
31+
{ nameWithOwner: "AcmeInc/bitcoin-lib", tier: "ecosystem", reason: "keyword" },
32+
{ nameWithOwner: "user/nostr-tool", tier: "adjacent", reason: "keyword" },
1833
];
1934

35+
const manyContributions: ContributionItem[] = manyRepos.map((r, i) => ({
36+
repoNameWithOwner: r.nameWithOwner,
37+
type: "commit",
38+
count: 70 - i * 10,
39+
dateRange,
40+
}));
41+
2042
describe("TopProjects", () => {
43+
it("has 'use client' directive", () => {
44+
const source = readFileSync(
45+
resolve(__dirname, "../TopProjects.tsx"),
46+
"utf-8"
47+
);
48+
expect(source.trimStart().startsWith('"use client"')).toBe(true);
49+
});
50+
2151
it("renders repos with tier badges", () => {
2252
render(
2353
<TopProjects bitcoinRepos={repos} contributions={contributions} showAdjacent />
@@ -93,4 +123,58 @@ describe("TopProjects", () => {
93123
expect(screen.getByLabelText("Tier: ecosystem")).toBeInTheDocument();
94124
expect(screen.getByLabelText("Tier: adjacent")).toBeInTheDocument();
95125
});
126+
127+
it("shows only 5 projects initially when there are more", () => {
128+
render(
129+
<TopProjects bitcoinRepos={manyRepos} contributions={manyContributions} showAdjacent />
130+
);
131+
132+
// First 5 by contribution count should be visible
133+
expect(screen.getByText("bitcoin/bitcoin")).toBeInTheDocument();
134+
expect(screen.getByText("lightning/lnd")).toBeInTheDocument();
135+
expect(screen.getByText("btcsuite/btcd")).toBeInTheDocument();
136+
expect(screen.getByText("mempool/mempool")).toBeInTheDocument();
137+
expect(screen.getByText("blockstream/esplora")).toBeInTheDocument();
138+
139+
// 6th and 7th should not be visible
140+
expect(screen.queryByText("AcmeInc/bitcoin-lib")).not.toBeInTheDocument();
141+
expect(screen.queryByText("user/nostr-tool")).not.toBeInTheDocument();
142+
});
143+
144+
it("shows Load more button when there are more than 5 projects", () => {
145+
render(
146+
<TopProjects bitcoinRepos={manyRepos} contributions={manyContributions} showAdjacent />
147+
);
148+
149+
expect(screen.getByRole("button", { name: "Load more" })).toBeInTheDocument();
150+
});
151+
152+
it("does not show Load more button when 5 or fewer projects", () => {
153+
render(
154+
<TopProjects bitcoinRepos={repos} contributions={contributions} showAdjacent />
155+
);
156+
157+
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
158+
});
159+
160+
it("reveals remaining projects when Load more is clicked", () => {
161+
render(
162+
<TopProjects bitcoinRepos={manyRepos} contributions={manyContributions} showAdjacent />
163+
);
164+
165+
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
166+
167+
expect(screen.getByText("AcmeInc/bitcoin-lib")).toBeInTheDocument();
168+
expect(screen.getByText("user/nostr-tool")).toBeInTheDocument();
169+
});
170+
171+
it("hides Load more button after all projects are shown", () => {
172+
render(
173+
<TopProjects bitcoinRepos={manyRepos} contributions={manyContributions} showAdjacent />
174+
);
175+
176+
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
177+
178+
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
179+
});
96180
});

src/hooks/__tests__/use-recent-searches.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, beforeEach, vi } from "vitest";
2-
import { renderHook, act } from "@testing-library/react";
2+
import { renderHook, act, waitFor } from "@testing-library/react";
33
import { useRecentSearches } from "@/hooks/use-recent-searches";
44

55
// Mock localStorage
@@ -20,14 +20,16 @@ describe("useRecentSearches", () => {
2020
expect(result.current.searches).toEqual([]);
2121
});
2222

23-
it("hydrates from localStorage on mount", () => {
23+
it("hydrates from localStorage on mount", async () => {
2424
const existing = [{ username: "satoshi", timestamp: 1000 }];
2525
store.set("recent-searches", JSON.stringify(existing));
2626

2727
const { result } = renderHook(() => useRecentSearches());
2828

29-
// After useEffect hydration
30-
expect(result.current.searches).toEqual(existing);
29+
// useEffect + queueMicrotask defers hydration
30+
await waitFor(() => {
31+
expect(result.current.searches).toEqual(existing);
32+
});
3133
});
3234

3335
it("adds a search and persists to localStorage", () => {

src/hooks/use-recent-searches.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ export function useRecentSearches() {
1919
try {
2020
const stored = localStorage.getItem(STORAGE_KEY);
2121
if (stored) {
22-
setSearches(JSON.parse(stored));
22+
const parsed = JSON.parse(stored) as RecentSearch[];
23+
queueMicrotask(() => setSearches(parsed));
2324
}
2425
} catch {
2526
// Ignore parse errors

0 commit comments

Comments
 (0)