Skip to content
Open
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
118 changes: 103 additions & 15 deletions src/components/ComparisonChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
computeTendrilPrediction,
computeTendrilMonthlyPrediction,
filterByContributor,
filterPRsByDateRange,
} from "../lib/calculations";
import type { PullRequest, RollingDataPoint } from "../lib/types";

Expand Down Expand Up @@ -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<PullRequest[]>([]);
useEffect(() => {
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -473,7 +528,10 @@ export function ComparisonChart() {
{againstPRs.length > 0 && (
<>
<section className="summary">
<h2>{againstLabel} — Detailed Analysis</h2>
<h2>
{againstLabel} — Detailed Analysis
{dateRangeEnabled && ` (${startDateStr} to ${endDateStr})`}
</h2>
<p>
3 datasets: Ivy-Framework (real) · {againstLabel} (real) · {againstLabel} +
Ivy-Tendril (prediction from Mar 2)
Expand Down Expand Up @@ -541,6 +599,36 @@ export function ComparisonChart() {
onChange={(e) => setDetailTokensStr(e.target.value)}
/>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
checked={dateRangeEnabled}
onChange={(e) => setDateRangeEnabled(e.target.checked)}
/>
{" "}Filter by Date Range
</label>
</div>
{dateRangeEnabled && (
<>
<div className="form-group">
<label>Start Date</label>
<input
type="date"
value={startDateStr}
onChange={(e) => setStartDateStr(e.target.value)}
/>
</div>
<div className="form-group">
<label>End Date</label>
<input
type="date"
value={endDateStr}
onChange={(e) => setEndDateStr(e.target.value)}
/>
</div>
</>
)}
</div>

<section className="table-section">
Expand Down
109 changes: 108 additions & 1 deletion src/lib/calculations.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
});
});
23 changes: 19 additions & 4 deletions src/lib/calculations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,33 @@ 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[] = [];

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");

Expand Down Expand Up @@ -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 });
Expand Down