diff --git a/app/components/MergedStreamChart.test.tsx b/app/components/MergedStreamChart.test.tsx
new file mode 100644
index 0000000..38403fc
--- /dev/null
+++ b/app/components/MergedStreamChart.test.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { MergedStreamChart } from './MergedStreamChart';
+
+describe('MergedStreamChart', () => {
+ it('renders empty state when no streams are provided', () => {
+ render();
+ expect(screen.getByText('No streams available')).toBeInTheDocument();
+ });
+
+ it('renders aggregated progress correctly', () => {
+ const streams = [
+ { id: '1', status: 'active' as const, accruedAmount: 10, totalAmount: 100, name: 'Stream 1' },
+ { id: '2', status: 'active' as const, accruedAmount: 20, totalAmount: 100, name: 'Stream 2' },
+ ];
+ render();
+
+ expect(screen.getByText('Total Merged Progress')).toBeInTheDocument();
+ expect(screen.getByText('Breakdown by Stream')).toBeInTheDocument();
+
+ // Total accrued = 30, Total amount = 200 -> 15% accrued
+ expect(screen.getByText('15% accrued')).toBeInTheDocument();
+
+ // Individual streams
+ expect(screen.getByText('Stream 1')).toBeInTheDocument();
+ expect(screen.getByText('10% accrued')).toBeInTheDocument();
+
+ expect(screen.getByText('Stream 2')).toBeInTheDocument();
+ expect(screen.getByText('20% accrued')).toBeInTheDocument();
+ });
+
+ it('calculates aggregate status as ended when all streams are ended', () => {
+ const streams = [
+ { id: '1', status: 'ended' as const, accruedAmount: 100, totalAmount: 100 },
+ { id: '2', status: 'ended' as const, accruedAmount: 50, totalAmount: 50 },
+ ];
+ const { container } = render();
+ // All ended streams are 100% completed
+ const completedLabels = screen.getAllByText('Completed');
+ // 2 individual + 1 aggregated = 3 "Completed" labels
+ expect(completedLabels).toHaveLength(3);
+ });
+});
diff --git a/app/components/MergedStreamChart.tsx b/app/components/MergedStreamChart.tsx
new file mode 100644
index 0000000..75dc804
--- /dev/null
+++ b/app/components/MergedStreamChart.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import React from "react";
+import type { StreamStatus } from "@/app/types/openapi";
+import { StreamProgress } from "./StreamProgress";
+
+export interface StreamData {
+ id: string;
+ status: StreamStatus;
+ accruedAmount?: number;
+ totalAmount?: number;
+ startedAt?: string;
+ endsAt?: string;
+ name?: string;
+}
+
+export interface MergedStreamChartProps {
+ streams: StreamData[];
+ className?: string;
+}
+
+export function MergedStreamChart({ streams, className = "" }: MergedStreamChartProps) {
+ if (!streams || streams.length === 0) {
+ return
No streams available
;
+ }
+
+ const totalAccrued = streams.reduce((sum, s) => sum + (s.accruedAmount || 0), 0);
+ const totalAmount = streams.reduce((sum, s) => sum + (s.totalAmount || 0), 0);
+
+ const allEnded = streams.every(s => s.status === "ended" || s.status === "withdrawn" || s.status === "cancelled");
+ const anyActive = streams.some(s => s.status === "active");
+ const anyPaused = streams.some(s => s.status === "paused");
+
+ const aggregateStatus: StreamStatus = allEnded ? "ended" : (anyActive ? "active" : (anyPaused ? "paused" : "draft"));
+
+ return (
+
+
+
Total Merged Progress
+
+
+
+
Breakdown by Stream
+
+ {streams.map(stream => (
+
+ {stream.name &&
{stream.name}
}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/docs/BODY-SIZE-LIMITS.md b/docs/BODY-SIZE-LIMITS.md
index a08ec4e..334ba4f 100644
--- a/docs/BODY-SIZE-LIMITS.md
+++ b/docs/BODY-SIZE-LIMITS.md
@@ -2,6 +2,7 @@
## Overview
+
Implemented per-route body size limit configuration for the GrantFox campaign, allowing different size limits for different API route categories:
- **Default routes**: 256 KB (configurable via `MAX_STREAM_BODY_BYTES`)