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
27 changes: 26 additions & 1 deletion src/features/comparison/components/result-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { UserResult } from "@/features/developer";
import { useTranslation } from "@/components/providers/language-provider";
import { formatComparisonToMarkdown } from "../format-comparison-to-markdown";
import type { CompareInsights, CompareWinner } from "../types";

type Props = {
Expand Down Expand Up @@ -63,6 +64,7 @@ export function ResultDashboard({
const { t } = useTranslation();
const searchParams = useSearchParams();
const { copied, copy } = useClipboardCopy();
const { copied: markdownCopied, copy: copyMarkdown } = useClipboardCopy();
const { copy: copyShare } = useClipboardCopy();
const [shareState, setShareState] = useState<"idle" | "success" | "error">("idle");
const shareTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -155,6 +157,10 @@ export function ResultDashboard({
);
};

const handleCopyMarkdown = () => {
copyMarkdown(formatComparisonToMarkdown(user1, user2, winner));
};

const renderScoreGroup = (user: UserResult) => {
const title = getDisplayName(user);
const isWinner = user.isWinner;
Expand Down Expand Up @@ -482,7 +488,7 @@ export function ResultDashboard({
</Card>
) : null}

<div className="flex justify-end gap-2">
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="secondary"
size="sm"
Expand Down Expand Up @@ -526,6 +532,25 @@ export function ResultDashboard({
</>
)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleCopyMarkdown}
className="flex items-center gap-2"
aria-label={t("results.copyMarkdownAria")}
>
{markdownCopied ? (
<>
<Check className="h-4 w-4 text-green-500" />
<span className="text-green-500">{t("results.copied")}</span>
</>
) : (
<>
<Copy className="h-4 w-4" />
{t("results.copyMarkdown")}
</>
)}
</Button>
</div>

<section className="space-y-4">
Expand Down
69 changes: 69 additions & 0 deletions src/features/comparison/format-comparison-to-markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { UserResult } from "@/features/developer";
import type { CompareWinner } from "./types";

type ComparisonMarkdownUser = Pick<
UserResult,
| "username"
| "name"
| "finalScore"
| "repoScore"
| "prScore"
| "contributionScore"
| "normalizedFinalScore"
| "normalizedRepoScore"
| "normalizedPRScore"
| "normalizedContributionScore"
>;

function escapeTableCell(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
}

function getDisplayName(user: ComparisonMarkdownUser): string {
return user.name?.trim() || user.username;
}

function formatRawScore(score: number): string {
return score.toFixed(2);
}

function formatNormalizedScore(score?: number): string {
return typeof score === "number" ? `${Math.round(score)} / 100` : "—";
}

export function formatComparisonToMarkdown(
user1: ComparisonMarkdownUser,
user2: ComparisonMarkdownUser,
winner?: CompareWinner,
): string {
const winnerUsername =
winner?.username === user1.username || winner?.username === user2.username
? winner.username
: user1.finalScore === user2.finalScore
? undefined
: user1.finalScore > user2.finalScore
? user1.username
: user2.username;

const user1Name = escapeTableCell(getDisplayName(user1));
const user2Name = escapeTableCell(getDisplayName(user2));
const user1Header =
winnerUsername === user1.username ? `${user1Name} 🏆 Winner` : user1Name;
const user2Header =
winnerUsername === user2.username ? `${user2Name} 🏆 Winner` : user2Name;

return [
`| Metric | ${user1Header} | ${user2Header} |`,
"| --- | ---: | ---: |",
`| Final score | ${formatRawScore(user1.finalScore)} | ${formatRawScore(user2.finalScore)} |`,
`| Normalized final score | ${formatNormalizedScore(user1.normalizedFinalScore)} | ${formatNormalizedScore(user2.normalizedFinalScore)} |`,
`| Repository score | ${formatRawScore(user1.repoScore)} | ${formatRawScore(user2.repoScore)} |`,
`| Normalized repository score | ${formatNormalizedScore(user1.normalizedRepoScore)} | ${formatNormalizedScore(user2.normalizedRepoScore)} |`,
`| Pull request score | ${formatRawScore(user1.prScore)} | ${formatRawScore(user2.prScore)} |`,
`| Normalized pull request score | ${formatNormalizedScore(user1.normalizedPRScore)} | ${formatNormalizedScore(user2.normalizedPRScore)} |`,
`| Community contribution score | ${formatRawScore(user1.contributionScore)} | ${formatRawScore(user2.contributionScore)} |`,
`| Normalized community contribution score | ${formatNormalizedScore(user1.normalizedContributionScore)} | ${formatNormalizedScore(user2.normalizedContributionScore)} |`,
"",
"_Generated with [DevImpact](https://github.com/O2sa/DevImpact)_",
].join("\n");
}
1 change: 1 addition & 0 deletions src/features/comparison/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./types";
export * from "./components";
export * from "./services/compare-request";
export * from "./format-comparison-to-markdown";
export * from "./hooks";
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, test } from "vitest";
import { formatComparisonToMarkdown } from "@/features/comparison/format-comparison-to-markdown";
import type { UserResult } from "@/features/developer";
import type { CompareWinner } from "@/features/comparison/types";

function comparisonUser(overrides: Partial<UserResult> & Pick<UserResult, "username">) {
return {
username: overrides.username,
name: overrides.name ?? null,
finalScore: overrides.finalScore ?? 0,
repoScore: overrides.repoScore ?? 0,
prScore: overrides.prScore ?? 0,
contributionScore: overrides.contributionScore ?? 0,
normalizedFinalScore: overrides.normalizedFinalScore,
normalizedRepoScore: overrides.normalizedRepoScore,
normalizedPRScore: overrides.normalizedPRScore,
normalizedContributionScore: overrides.normalizedContributionScore,
};
}

describe("formatComparisonToMarkdown", () => {
test("formats the score comparison, winner badge, and attribution", () => {
const user1 = comparisonUser({
username: "alice",
name: "Alice",
finalScore: 82.456,
normalizedFinalScore: 82.4,
repoScore: 30.123,
normalizedRepoScore: 30.6,
prScore: 25.456,
normalizedPRScore: 25.4,
contributionScore: 26.877,
normalizedContributionScore: 26.4,
});
const user2 = comparisonUser({
username: "bob",
name: "Bob",
finalScore: 70.2,
normalizedFinalScore: 70.1,
repoScore: 25.1,
normalizedRepoScore: 25.2,
prScore: 20.2,
normalizedPRScore: 20.3,
contributionScore: 24.9,
normalizedContributionScore: 24.6,
});
const winner: CompareWinner = {
username: "alice",
finalScoreDifference: 12,
percentageDifference: 17,
};

expect(formatComparisonToMarkdown(user1, user2, winner)).toBe(
[
"| Metric | Alice 🏆 Winner | Bob |",
"| --- | ---: | ---: |",
"| Final score | 82.46 | 70.20 |",
"| Normalized final score | 82 / 100 | 70 / 100 |",
"| Repository score | 30.12 | 25.10 |",
"| Normalized repository score | 31 / 100 | 25 / 100 |",
"| Pull request score | 25.46 | 20.20 |",
"| Normalized pull request score | 25 / 100 | 20 / 100 |",
"| Community contribution score | 26.88 | 24.90 |",
"| Normalized community contribution score | 26 / 100 | 25 / 100 |",
"",
"_Generated with [DevImpact](https://github.com/O2sa/DevImpact)_",
].join("\n"),
);
});

test("falls back to usernames and score ranking when display names and winner are absent", () => {
const user1 = comparisonUser({ username: "alice", finalScore: 1 });
const user2 = comparisonUser({ username: "bob", finalScore: 2 });

expect(formatComparisonToMarkdown(user1, user2)).toContain(
"| Metric | alice | bob 🏆 Winner |",
);
expect(formatComparisonToMarkdown(user1, user2)).toContain(
"| Normalized final score | — | — |",
);
});

test("escapes table delimiters and line breaks in display names", () => {
const user1 = comparisonUser({ username: "alice", name: "Alice | A\nDev", finalScore: 1 });
const user2 = comparisonUser({ username: "bob" });

expect(formatComparisonToMarkdown(user1, user2)).toContain(
"| Metric | Alice \\| A<br>Dev 🏆 Winner | bob |",
);
});
});
2 changes: 2 additions & 0 deletions src/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@
"results.copied": "تم النسخ!",
"results.copy": "نسخ النتيجة",
"results.copyAria": "نسخ نتائج المقارنة إلى الحافظة",
"results.copyMarkdown": "نسخ كماركداون",
"results.copyMarkdownAria": "نسخ المقارنة كماركداون",
"results.pointsLead": "+{points} نقطة",
"results.share": "مشاركة",
"results.shareAria": "مشاركة رابط المقارنة",
Expand Down
2 changes: 2 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@
"results.copied": "Copied!",
"results.copy": "Copy Result",
"results.copyAria": "Copy comparison results to clipboard",
"results.copyMarkdown": "Copy as Markdown",
"results.copyMarkdownAria": "Copy comparison as Markdown",
"results.pointsLead": "+{points} points",
"results.share": "Share",
"results.shareAria": "Share comparison link",
Expand Down
Loading