diff --git a/src/components/ComparisonChart.tsx b/src/components/ComparisonChart.tsx index 4cc18da..8477ac3 100644 --- a/src/components/ComparisonChart.tsx +++ b/src/components/ComparisonChart.tsx @@ -20,6 +20,7 @@ import { computeTendrilPrediction, computeTendrilMonthlyPrediction, filterByContributor, + filterPRsByDateRange, } from "../lib/calculations"; import type { PullRequest, RollingDataPoint } from "../lib/types"; @@ -119,6 +120,11 @@ export function ComparisonChart() { const detailSalary = Number(detailSalaryStr) || 0; const detailTokens = Number(detailTokensStr) || 0; + // Date range filtering + const [startDateStr, setStartDateStr] = useState("2025-11-01"); + const [endDateStr, setEndDateStr] = useState("2026-04-30"); + const [dateRangeEnabled, setDateRangeEnabled] = useState(false); + // Always keep Ivy-Framework data loaded for bottom charts const [ivyPRs, setIvyPRs] = useState([]); useEffect(() => { @@ -188,19 +194,44 @@ export function ComparisonChart() { return Array.from(logins).sort(); }, [againstPRs]); - // Filter against PRs by contributor + // Filter against PRs by contributor and date range const filteredAgainstPRs = useMemo(() => { - if (detailContributor === "all") return againstPRs; - return filterByContributor(againstPRs, detailContributor); - }, [againstPRs, detailContributor]); + let filtered = againstPRs; + + if (dateRangeEnabled && startDateStr && endDateStr) { + filtered = filterPRsByDateRange(filtered, new Date(startDateStr), new Date(endDateStr)); + } + + if (detailContributor !== "all") { + filtered = filterByContributor(filtered, detailContributor); + } + + return filtered; + }, [againstPRs, detailContributor, dateRangeEnabled, startDateStr, endDateStr]); const compareData = useMemo( - () => getRollingAverages(comparePRs, detailSalary, detailTokens), - [comparePRs, detailSalary, detailTokens], + () => + getRollingAverages( + comparePRs, + detailSalary, + detailTokens, + 5, + dateRangeEnabled ? new Date(startDateStr) : undefined, + dateRangeEnabled ? new Date(endDateStr) : undefined, + ), + [comparePRs, detailSalary, detailTokens, dateRangeEnabled, startDateStr, endDateStr], ); const againstData = useMemo( - () => getRollingAverages(filteredAgainstPRs, detailSalary, detailTokens), - [filteredAgainstPRs, detailSalary, detailTokens], + () => + getRollingAverages( + filteredAgainstPRs, + detailSalary, + detailTokens, + 5, + dateRangeEnabled ? new Date(startDateStr) : undefined, + dateRangeEnabled ? new Date(endDateStr) : undefined, + ), + [filteredAgainstPRs, detailSalary, detailTokens, dateRangeEnabled, startDateStr, endDateStr], ); const compareLabel = PREFETCHED_REPOS.find((r) => r.key === compareKey)?.label ?? compareKey; @@ -222,18 +253,42 @@ export function ComparisonChart() { // Ivy rolling data (always available for bottom charts) const ivyRolling = useMemo( - () => getRollingAverages(ivyPRs, detailSalary, detailTokens), - [ivyPRs, detailSalary, detailTokens], + () => + getRollingAverages( + ivyPRs, + detailSalary, + detailTokens, + 5, + dateRangeEnabled ? new Date(startDateStr) : undefined, + dateRangeEnabled ? new Date(endDateStr) : undefined, + ), + [ivyPRs, detailSalary, detailTokens, dateRangeEnabled, startDateStr, endDateStr], ); const ivyMonthlyStats = useMemo( - () => getMonthlyStats(ivyPRs, detailSalary, detailTokens), - [ivyPRs, detailSalary, detailTokens], + () => + getMonthlyStats( + ivyPRs, + detailSalary, + detailTokens, + 5, + dateRangeEnabled ? new Date(startDateStr) : undefined, + dateRangeEnabled ? new Date(endDateStr) : undefined, + ), + [ivyPRs, detailSalary, detailTokens, dateRangeEnabled, startDateStr, endDateStr], ); // Against monthly stats const againstMonthlyStats = useMemo( - () => getMonthlyStats(filteredAgainstPRs, detailSalary, detailTokens), - [filteredAgainstPRs, detailSalary, detailTokens], + () => + getMonthlyStats( + filteredAgainstPRs, + detailSalary, + detailTokens, + 5, + dateRangeEnabled ? new Date(startDateStr) : undefined, + dateRangeEnabled ? new Date(endDateStr) : undefined, + ), + [filteredAgainstPRs, detailSalary, detailTokens, dateRangeEnabled, startDateStr, endDateStr], ); // Tendril prediction: against data with Ivy's growth applied from Mar 02 @@ -473,7 +528,10 @@ export function ComparisonChart() { {againstPRs.length > 0 && ( <>
-

{againstLabel} — Detailed Analysis

+

+ {againstLabel} — Detailed Analysis + {dateRangeEnabled && ` (${startDateStr} to ${endDateStr})`} +

3 datasets: Ivy-Framework (real) · {againstLabel} (real) · {againstLabel} + Ivy-Tendril (prediction from Mar 2) @@ -541,6 +599,36 @@ export function ComparisonChart() { onChange={(e) => setDetailTokensStr(e.target.value)} /> +

+ +
+ {dateRangeEnabled && ( + <> +
+ + setStartDateStr(e.target.value)} + /> +
+
+ + setEndDateStr(e.target.value)} + /> +
+ + )}
diff --git a/src/lib/calculations.test.ts b/src/lib/calculations.test.ts index 3d235e6..c7f4f51 100644 --- a/src/lib/calculations.test.ts +++ b/src/lib/calculations.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { filterByContributor, getMonthlyStats, getRollingAverages } from "./calculations"; +import { + filterByContributor, + filterPRsByDateRange, + getMonthlyStats, + getRollingAverages, +} from "./calculations"; import type { PullRequest } from "./types"; import { format } from "date-fns"; @@ -33,6 +38,52 @@ describe("filterByContributor", () => { }); }); +describe("filterPRsByDateRange", () => { + it("filters PRs within the date range", () => { + const prs = [ + makePR({ user: { login: "alice" }, created_at: "2025-11-15T00:00:00Z" }), + makePR({ user: { login: "bob" }, created_at: "2025-12-15T00:00:00Z" }), + makePR({ user: { login: "charlie" }, created_at: "2026-01-15T00:00:00Z" }), + makePR({ user: { login: "dave" }, created_at: "2026-05-15T00:00:00Z" }), + ]; + const result = filterPRsByDateRange( + prs, + new Date("2025-11-01"), + new Date("2026-04-30"), + ); + expect(result).toHaveLength(3); + expect(result.map((pr) => pr.user.login)).toEqual(["alice", "bob", "charlie"]); + }); + + it("includes PRs exactly at boundary dates", () => { + const prs = [ + makePR({ user: { login: "alice" }, created_at: "2025-11-01T00:00:00Z" }), + makePR({ user: { login: "bob" }, created_at: "2026-04-30T23:59:59Z" }), + makePR({ user: { login: "charlie" }, created_at: "2025-10-31T23:59:59Z" }), + ]; + const result = filterPRsByDateRange( + prs, + new Date("2025-11-01"), + new Date("2026-04-30T23:59:59Z"), + ); + expect(result).toHaveLength(2); + expect(result.map((pr) => pr.user.login)).toEqual(["alice", "bob"]); + }); + + it("returns empty array when no PRs in range", () => { + const prs = [ + makePR({ user: { login: "alice" }, created_at: "2025-10-15T00:00:00Z" }), + makePR({ user: { login: "bob" }, created_at: "2026-05-15T00:00:00Z" }), + ]; + const result = filterPRsByDateRange( + prs, + new Date("2025-11-01"), + new Date("2026-04-30"), + ); + expect(result).toHaveLength(0); + }); +}); + describe("getMonthlyStats", () => { it("calculates stats correctly for merged and denied PRs", () => { const now = new Date(); @@ -192,4 +243,60 @@ describe("getRollingAverages", () => { expect(pointWithPRs).toBeDefined(); expect(pointWithPRs!.prsMerged).toBeLessThan(pointWithPRs!.prsCreated); }); + + it("respects custom date range when provided", () => { + const prs = [ + makePR({ + user: { login: "alice" }, + merged_at: "2025-11-15T00:00:00Z", + created_at: "2025-11-15T00:00:00Z", + }), + makePR({ + user: { login: "bob" }, + merged_at: "2026-01-15T00:00:00Z", + created_at: "2026-01-15T00:00:00Z", + }), + ]; + const data = getRollingAverages( + prs, + 5000, + 200, + 5, + new Date("2025-11-01"), + new Date("2026-04-30"), + ); + expect(data.length).toBeGreaterThan(0); + const firstDate = data[0].date; + const lastDate = data[data.length - 1].date; + expect(firstDate).toContain("Nov"); + expect(lastDate).toContain("Apr"); + }); +}); + +describe("getMonthlyStats with date range", () => { + it("respects custom date range when provided", () => { + const prs = [ + makePR({ + user: { login: "alice" }, + merged_at: "2025-11-15T00:00:00Z", + created_at: "2025-11-15T00:00:00Z", + }), + makePR({ + user: { login: "bob" }, + merged_at: "2026-01-15T00:00:00Z", + created_at: "2026-01-15T00:00:00Z", + }), + ]; + const stats = getMonthlyStats( + prs, + 5000, + 200, + 5, + new Date("2025-11-01"), + new Date("2026-04-30"), + ); + expect(stats).toHaveLength(5); + const totalPRs = stats.reduce((sum, s) => sum + s.totalPRs, 0); + expect(totalPRs).toBe(2); + }); }); diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index 4b23667..7b6b19c 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -18,11 +18,24 @@ export function filterByContributor(prs: PullRequest[], contributor: string): Pu return prs.filter((pr) => pr.user.login === contributor); } +export function filterPRsByDateRange( + prs: PullRequest[], + startDate: Date, + endDate: Date, +): PullRequest[] { + return prs.filter((pr) => { + const createdAt = parseISO(pr.created_at); + return isWithinInterval(createdAt, { start: startDate, end: endDate }); + }); +} + export function getMonthlyStats( prs: PullRequest[], monthlySalary: number, dailyTokenSpend: number, months: number = 5, + startDate?: Date, + endDate?: Date, ): MonthlyStats[] { const now = new Date(); const stats: MonthlyStats[] = []; @@ -30,8 +43,8 @@ export function getMonthlyStats( for (let i = months - 1; i >= 0; i--) { const monthDate = subMonths(now, i); const interval = { - start: startOfMonth(monthDate), - end: endOfMonth(monthDate), + start: startDate && i === months - 1 ? startDate : startOfMonth(monthDate), + end: endDate && i === 0 ? endDate : endOfMonth(monthDate), }; const monthLabel = format(monthDate, "yyyy-MM"); @@ -70,10 +83,12 @@ export function getRollingAverages( monthlySalary: number, dailyTokenSpend: number, months: number = 5, + customStartDate?: Date, + customEndDate?: Date, ): RollingDataPoint[] { const now = new Date(); - const startDate = startOfMonth(subMonths(now, months - 1)); - const endDate = new Date(); + const startDate = customStartDate ?? startOfMonth(subMonths(now, months - 1)); + const endDate = customEndDate ?? new Date(); const windowDays = 14; const days = eachDayOfInterval({ start: startDate, end: endDate });