Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pr_list_latest.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion src/app/[username]/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { useEffect } from "react";
import Link from "next/link";

import { logger } from "@/lib/logger";

export default function ErrorPage({
error,
reset,
Expand All @@ -11,7 +13,7 @@ export default function ErrorPage({
reset: () => void;
}) {
useEffect(() => {
console.error("User page error:", error);
logger.error("User page error:", error);
}, [error]);

const isRateLimit = error.message.includes("rate limit");
Expand Down
140 changes: 140 additions & 0 deletions src/app/api/dashboard/summary/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { getServerSession } from "next-auth";
import { fetchUserSummary } from "@/lib/github";
import { fetchViewerLogin } from "@/lib/githubViewer";

import type { UserSummary } from "@/lib/types";

vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));

vi.mock("@/lib/github", () => ({
fetchUserSummary: vi.fn(),
}));

vi.mock("@/lib/githubViewer", () => ({
fetchViewerLogin: vi.fn(),
}));

describe("GET /api/dashboard/summary", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns 401 if no session exists", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);

const { GET } = await import("./route");
const response = await GET();
const data = await response.json();

expect(response.status).toBe(401);
expect(data.error).toBe("Unauthorized");
});

it("returns 401 if no access token exists", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { login: "testuser" } });

const { GET } = await import("./route");
const response = await GET();
const data = await response.json();

expect(response.status).toBe(401);
expect(data.error).toBe("Unauthorized");
});

it("returns 200 and summary if session has login", async () => {
const mockSession = {
accessToken: "fake-token",
user: { login: "testuser" },
};
const mockSummary = { profile: { login: "testuser" } };

vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
vi.mocked(fetchUserSummary).mockResolvedValueOnce(mockSummary as unknown as UserSummary);

const { GET } = await import("./route");
const response = await GET();
const data = await response.json();

expect(response.status).toBe(200);
expect(data.username).toBe("testuser");
expect(data.summary).toEqual(mockSummary);
expect(fetchViewerLogin).not.toHaveBeenCalled();
expect(fetchUserSummary).toHaveBeenCalledWith("testuser", "fake-token");
});

it("returns 200 and fetches login if missing from session", async () => {
const mockSession = {
accessToken: "fake-token",
user: { name: "Test User" }, // login missing
};
const mockSummary = { profile: { login: "testuser" } };

vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
vi.mocked(fetchViewerLogin).mockResolvedValueOnce("testuser");
vi.mocked(fetchUserSummary).mockResolvedValueOnce(mockSummary as unknown as UserSummary);

const { GET } = await import("./route");
const response = await GET();
const data = await response.json();

expect(response.status).toBe(200);
expect(data.username).toBe("testuser");
expect(data.summary).toEqual(mockSummary);
expect(fetchViewerLogin).toHaveBeenCalledWith("fake-token");
expect(fetchUserSummary).toHaveBeenCalledWith("testuser", "fake-token");
});

it("returns 500 if fetchViewerLogin fails", async () => {
const mockSession = {
accessToken: "fake-token",
user: { name: "Test User" }, // login missing
};

vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
vi.mocked(fetchViewerLogin).mockRejectedValueOnce(new Error("Viewer login failed"));

const { GET } = await import("./route");
const response = await GET();
const data = await response.json();

expect(response.status).toBe(500);
expect(data.error).toBe("Viewer login failed");
});

it("returns 500 if fetchUserSummary fails", async () => {
const mockSession = {
accessToken: "fake-token",
user: { login: "testuser" },
};

vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
vi.mocked(fetchUserSummary).mockRejectedValueOnce(new Error("Summary fetch failed"));

const { GET } = await import("./route");
const response = await GET();
const data = await response.json();

expect(response.status).toBe(500);
expect(data.error).toBe("Summary fetch failed");
});

it("returns 500 with 'Unknown error' if error is not an Error instance", async () => {
const mockSession = {
accessToken: "fake-token",
user: { login: "testuser" },
};

vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
vi.mocked(fetchUserSummary).mockRejectedValueOnce("Something went wrong");

const { GET } = await import("./route");
const response = await GET();
const data = await response.json();

expect(response.status).toBe(500);
expect(data.error).toBe("Unknown error");
});
});
4 changes: 3 additions & 1 deletion src/app/api/og/[username]/route.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ImageResponse } from "next/og";
import { NextRequest } from "next/server";

import { logger } from "@/lib/logger";

export const runtime = "edge";

export async function GET(
Expand Down Expand Up @@ -30,7 +32,7 @@
publicRepos = data.public_repos ?? 0;
}
} catch (error) {
console.error(`Failed to fetch GitHub profile for OG image: ${username}`, error);
logger.error(`Failed to fetch GitHub profile for OG image: ${username}`, error);
// fallback to defaults
}

Expand All @@ -56,7 +58,7 @@
}}
>
{avatarUrl && (
<img

Check warning on line 61 in src/app/api/og/[username]/route.tsx

View workflow job for this annotation

GitHub Actions / Lint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={avatarUrl}
alt=""
width={120}
Expand Down
19 changes: 3 additions & 16 deletions src/components/ActivityHeatmap.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import HeatmapLegend from "./HeatmapLegend";

type Props = {
/** heatmap[dayOfWeek 0-6][hour 0-23] event counts */
heatmap: number[][];
Expand Down Expand Up @@ -91,22 +93,7 @@ export default function ActivityHeatmap({ heatmap, totalEvents }: Props) {
))}
</svg>

<div className="mt-2 flex items-center justify-end gap-1 text-xs text-muted">
<span>Less</span>
{[0, 1, 2, 3, 4].map((level) => (
<div
key={level}
className="h-3 w-3 rounded-sm"
style={{
backgroundColor:
level === 0
? "rgba(var(--card-border-rgb), 0.4)"
: `rgba(var(--accent-rgb), ${0.2 + level * 0.2})`,
}}
/>
))}
<span>More</span>
</div>
<HeatmapLegend />
</div>
);
}
78 changes: 43 additions & 35 deletions src/components/ContributionGraph.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
import HeatmapLegend from "./HeatmapLegend";
import type { ContributionData } from "@/lib/types";

type Props = {
contributions: ContributionData;
};

const MONTHS = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];

export default function ContributionGraph({ contributions }: Props) {
const { calendar } = contributions;
if (calendar.length === 0) return null;
const CELL_SIZE = 12;
const CELL_GAP = 3;
const STEP = CELL_SIZE + CELL_GAP;
const DAY_LABEL_WIDTH = 28;

function processCalendarData(calendar: ContributionData["calendar"]) {
if (calendar.length === 0) {
return { weeks: [], monthLabels: [], maxCount: 1 };
}

const cellSize = 12;
const cellGap = 3;
const step = cellSize + cellGap;
const maxCount = Math.max(...calendar.map((d) => d.count), 1);

// Group entries by week columns
Expand All @@ -39,10 +53,6 @@ export default function ContributionGraph({ contributions }: Props) {
}
if (currentWeek.length > 0) weeks.push(currentWeek);

const dayLabelWidth = 28;
const svgWidth = dayLabelWidth + weeks.length * step + cellGap;
const svgHeight = 7 * step + 20;

const monthLabels: { label: string; x: number }[] = [];
let lastMonth = -1;
weeks.forEach((week, wIdx) => {
Expand All @@ -51,12 +61,24 @@ export default function ContributionGraph({ contributions }: Props) {
if (month !== lastMonth) {
monthLabels.push({
label: MONTHS[month],
x: dayLabelWidth + wIdx * step,
x: DAY_LABEL_WIDTH + wIdx * STEP,
});
lastMonth = month;
}
});

return { weeks, monthLabels, maxCount };
}

export default function ContributionGraph({ contributions }: Props) {
const { calendar } = contributions;
if (calendar.length === 0) return null;

const { weeks, monthLabels, maxCount } = processCalendarData(calendar);

const svgWidth = DAY_LABEL_WIDTH + weeks.length * STEP + CELL_GAP;
const svgHeight = 7 * STEP + 20;

const dayLabels = ["", "Mon", "", "Wed", "", "Fri", ""];

function getIntensityColor(count: number): string {
Expand Down Expand Up @@ -97,7 +119,7 @@ export default function ContributionGraph({ contributions }: Props) {
<text
key={idx}
x={0}
y={18 + idx * step + cellSize / 2 + 3}
y={18 + idx * STEP + CELL_SIZE / 2 + 3}
className="fill-muted text-[10px]"
>
{label}
Expand All @@ -109,39 +131,25 @@ export default function ContributionGraph({ contributions }: Props) {
week.map((entry) => (
<rect
key={entry.date}
x={dayLabelWidth + wIdx * step}
y={18 + entry.dayOfWeek * step}
width={cellSize}
height={cellSize}
x={DAY_LABEL_WIDTH + wIdx * STEP}
y={18 + entry.dayOfWeek * STEP}
width={CELL_SIZE}
height={CELL_SIZE}
rx={2}
fill={getIntensityColor(entry.count)}
className="transition-all duration-200 hover:opacity-70 hover:stroke-foreground/20"
style={{ strokeWidth: 1 }}
>
<title>
{entry.date}: {entry.count} contribution{entry.count !== 1 ? "s" : ""}
{entry.date}: {entry.count} contribution
{entry.count !== 1 ? "s" : ""}
</title>
</rect>
)),
)}
</svg>

<div className="mt-2 flex items-center justify-end gap-1 text-xs text-muted">
<span>Less</span>
{[0, 1, 2, 3, 4].map((level) => (
<div
key={level}
className="h-3 w-3 rounded-sm"
style={{
backgroundColor:
level === 0
? "rgba(var(--card-border-rgb), 0.4)"
: `rgba(var(--accent-rgb), ${0.2 + level * 0.2})`,
}}
/>
))}
<span>More</span>
</div>
<HeatmapLegend />
</div>
);
}
20 changes: 20 additions & 0 deletions src/components/HeatmapLegend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export default function HeatmapLegend() {
return (
<div className="mt-2 flex items-center justify-end gap-1 text-xs text-muted">
<span>Less</span>
{[0, 1, 2, 3, 4].map((level) => (
<div
key={level}
className="h-3 w-3 rounded-sm"
style={{
backgroundColor:
level === 0
? "rgba(var(--card-border-rgb), 0.4)"
: `rgba(var(--accent-rgb), ${0.2 + level * 0.2})`,
}}
/>
))}
<span>More</span>
</div>
);
}
2 changes: 1 addition & 1 deletion src/lib/__tests__/color.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { adjustAccentColor, type ColorResult } from "../color";
import { adjustAccentColor } from "../color";

/**
* adjustAccentColor のユニットテスト
Expand Down
Loading
Loading