diff --git a/src/features/comparison/components/result-dashboard.tsx b/src/features/comparison/components/result-dashboard.tsx index b34678f..e29ca18 100644 --- a/src/features/comparison/components/result-dashboard.tsx +++ b/src/features/comparison/components/result-dashboard.tsx @@ -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 = { @@ -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 | null>(null); @@ -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; @@ -482,7 +488,7 @@ export function ResultDashboard({ ) : null} -
+
+
diff --git a/src/features/comparison/format-comparison-to-markdown.ts b/src/features/comparison/format-comparison-to-markdown.ts new file mode 100644 index 0000000..f88e35a --- /dev/null +++ b/src/features/comparison/format-comparison-to-markdown.ts @@ -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, "
"); +} + +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"); +} diff --git a/src/features/comparison/index.ts b/src/features/comparison/index.ts index 5d92934..0949c22 100644 --- a/src/features/comparison/index.ts +++ b/src/features/comparison/index.ts @@ -1,4 +1,5 @@ export * from "./types"; export * from "./components"; export * from "./services/compare-request"; +export * from "./format-comparison-to-markdown"; export * from "./hooks"; diff --git a/src/features/comparison/tests/format-comparison-to-markdown.test.ts b/src/features/comparison/tests/format-comparison-to-markdown.test.ts new file mode 100644 index 0000000..4440be7 --- /dev/null +++ b/src/features/comparison/tests/format-comparison-to-markdown.test.ts @@ -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 & Pick) { + 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
Dev 🏆 Winner | bob |", + ); + }); +}); diff --git a/src/locales/ar.json b/src/locales/ar.json index 18d20a6..b229659 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -171,6 +171,8 @@ "results.copied": "تم النسخ!", "results.copy": "نسخ النتيجة", "results.copyAria": "نسخ نتائج المقارنة إلى الحافظة", + "results.copyMarkdown": "نسخ كماركداون", + "results.copyMarkdownAria": "نسخ المقارنة كماركداون", "results.pointsLead": "+{points} نقطة", "results.share": "مشاركة", "results.shareAria": "مشاركة رابط المقارنة", diff --git a/src/locales/en.json b/src/locales/en.json index cef13b1..0689080 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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",