diff --git a/stellargrant-fe/components/grants/CreateGrantForm/RewardCalculator.tsx b/stellargrant-fe/components/grants/CreateGrantForm/RewardCalculator.tsx new file mode 100644 index 00000000..639f8498 --- /dev/null +++ b/stellargrant-fe/components/grants/CreateGrantForm/RewardCalculator.tsx @@ -0,0 +1,340 @@ +"use client"; + +/** + * RewardCalculator Component + * + * Provides automatic reward distribution across milestones. + * Supports three modes: + * 1. Equal split — divides budget evenly + * 2. Front-loaded — 50/30/remainder pattern + * 3. Custom weight — user-defined slider weights + * + * Includes an Undo button that reverts to previous values for 5 seconds. + * + * @see https://github.com/StellarGrant/StellarGrant-fe/issues/391 + */ + +import { useState, useCallback, useRef, useEffect } from "react"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface RewardCalculatorProps { + /** Total budget in XLM */ + totalBudget: number; + /** Number of milestones to distribute across */ + milestoneCount: number; + /** Called with array of reward amounts (in XLM) when distribution is applied */ + onDistribute: (rewards: number[]) => void; +} + +type DistributionMode = "equal" | "frontload" | "custom"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Round to 2 decimal places and ensure sum equals totalBudget. + * Adjusts the last element for rounding errors. + */ +function roundAndBalance(values: number[], totalBudget: number): number[] { + const rounded = values.map((v) => Math.round(v * 100) / 100); + const sum = rounded.reduce((a, b) => a + b, 0); + const diff = Math.round((totalBudget - sum) * 100) / 100; + + // Distribute rounding error to the last non-zero element + if (diff !== 0 && rounded.length > 0) { + rounded[rounded.length - 1] = Math.round((rounded[rounded.length - 1] + diff) * 100) / 100; + } + + return rounded; +} + +/** + * Equal split: divide budget evenly across milestones. + * Last milestone gets any rounding remainder. + */ +function equalSplit(totalBudget: number, milestoneCount: number): number[] { + if (milestoneCount === 0) return []; + const base = totalBudget / milestoneCount; + const rewards = new Array(milestoneCount).fill(base); + return roundAndBalance(rewards, totalBudget); +} + +/** + * Front-loaded distribution: 50% first, 30% second, remainder split among rest. + * For N milestones: first gets 50%, second gets 30%, rest share remaining 20%. + */ +function frontLoad(totalBudget: number, milestoneCount: number): number[] { + if (milestoneCount === 0) return []; + if (milestoneCount === 1) return [totalBudget]; + + const first = totalBudget * 0.5; + const second = totalBudget * 0.3; + const remainder = totalBudget - first - second; + const restCount = milestoneCount - 2; + + const rewards: number[] = [first, second]; + + if (restCount > 0) { + const perRest = remainder / restCount; + for (let i = 0; i < restCount; i++) { + rewards.push(perRest); + } + } + + return roundAndBalance(rewards, totalBudget); +} + +/** + * Custom weight distribution: rewards proportional to weights. + */ +function customWeight(totalBudget: number, weights: number[]): number[] { + const totalWeight = weights.reduce((a, b) => a + b, 0); + if (totalWeight === 0) return weights.map(() => 0); + + const rewards = weights.map((w) => (w / totalWeight) * totalBudget); + return roundAndBalance(rewards, totalBudget); +} + +// ── Component ──────────────────────────────────────────────────────────────── + +export function RewardCalculator({ + totalBudget, + milestoneCount, + onDistribute, +}: RewardCalculatorProps) { + const [mode, setMode] = useState("equal"); + const [weights, setWeights] = useState([]); + const [previousRewards, setPreviousRewards] = useState(null); + const [showUndo, setShowUndo] = useState(false); + const undoTimerRef = useRef(null); + + // Initialize weights when milestone count changes + // eslint-disable-next-line react-hooks/set-state-in-effect + useEffect(() => { + setWeights((prev) => { + if (prev.length === milestoneCount) return prev; + const newWeights = []; + for (let i = 0; i < milestoneCount; i++) { + newWeights.push(prev[i] ?? 5); // default weight: 5 + } + return newWeights; + }); + }, [milestoneCount]); + + // Cleanup undo timer on unmount + useEffect(() => { + return () => { + if (undoTimerRef.current) { + clearTimeout(undoTimerRef.current); + } + }; + }, []); + + const isDisabled = totalBudget === 0 || milestoneCount === 0; + + /** + * Apply distribution and notify parent. + */ + const applyDistribution = useCallback( + (rewards: number[]) => { + // Store current rewards for undo + setPreviousRewards(rewards); + setShowUndo(true); + + // Clear any existing timer + if (undoTimerRef.current) { + clearTimeout(undoTimerRef.current); + } + + // Hide undo button after 5 seconds + undoTimerRef.current = setTimeout(() => { + setShowUndo(false); + setPreviousRewards(null); + }, 5000); + + onDistribute(rewards); + }, + [onDistribute] + ); + + /** + * Undo the last distribution. + */ + const handleUndo = useCallback(() => { + if (previousRewards) { + onDistribute(previousRewards); + setShowUndo(false); + setPreviousRewards(null); + if (undoTimerRef.current) { + clearTimeout(undoTimerRef.current); + } + } + }, [previousRewards, onDistribute]); + + /** + * Handle equal split click. + */ + const handleEqualSplit = useCallback(() => { + const rewards = equalSplit(totalBudget, milestoneCount); + applyDistribution(rewards); + }, [totalBudget, milestoneCount, applyDistribution]); + + /** + * Handle front-load click. + */ + const handleFrontLoad = useCallback(() => { + const rewards = frontLoad(totalBudget, milestoneCount); + applyDistribution(rewards); + }, [totalBudget, milestoneCount, applyDistribution]); + + /** + * Handle custom weight apply. + */ + const handleCustomWeight = useCallback(() => { + const rewards = customWeight(totalBudget, weights); + applyDistribution(rewards); + }, [totalBudget, weights, applyDistribution]); + + /** + * Update a single weight value. + */ + const updateWeight = useCallback((index: number, value: number) => { + setWeights((prev) => { + const next = [...prev]; + next[index] = Math.max(1, Math.min(10, value)); // clamp 1-10 + return next; + }); + }, []); + + return ( +
+

+ Reward Distribution +

+ + {/* Mode selector buttons */} +
+ + + +
+ + {/* Description for selected mode */} +

+ {mode === "equal" && + `Divides ${totalBudget} XLM evenly across ${milestoneCount} milestones.`} + {mode === "frontload" && + `50% to first milestone, 30% to second, remainder split among rest.`} + {mode === "custom" && + `Adjust sliders (1–10) to set relative weight for each milestone.`} +

+ + {/* Custom weight sliders */} + {mode === "custom" && milestoneCount > 0 && ( +
+ {Array.from({ length: milestoneCount }, (_, i) => ( +
+ + Milestone {i + 1} + + updateWeight(i, parseInt(e.target.value, 10))} + className="flex-1 h-1 accent-accent-secondary" + disabled={isDisabled} + /> + + {weights[i] ?? 5} + +
+ ))} +
+ )} + + {/* Apply button */} +
+ {mode === "equal" && ( + + )} + {mode === "frontload" && ( + + )} + {mode === "custom" && ( + + )} + + {/* Undo button */} + {showUndo && ( + + )} +
+ + {/* Disabled state message */} + {isDisabled && ( +

+ Set a budget and add milestones to enable distribution. +

+ )} +
+ ); +} diff --git a/stellargrant-fe/tests/components/RewardCalculator.test.tsx b/stellargrant-fe/tests/components/RewardCalculator.test.tsx new file mode 100644 index 00000000..ad726f4c --- /dev/null +++ b/stellargrant-fe/tests/components/RewardCalculator.test.tsx @@ -0,0 +1,269 @@ +/** + * RewardCalculator Tests + * + * Tests for the RewardCalculator component and its distribution logic. + * + * @see https://github.com/StellarGrant/StellarGrant-fe/issues/391 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { RewardCalculator } from "@/components/grants/CreateGrantForm/RewardCalculator"; + +describe("RewardCalculator", () => { + const defaultProps = { + totalBudget: 1000, + milestoneCount: 3, + onDistribute: vi.fn(), + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + // ── Disabled states ────────────────────────────────────────────────────── + + it("disables all buttons when budget is 0", () => { + render(); + + const buttons = screen.getAllByRole("button"); + buttons.forEach((btn) => { + if (btn.textContent?.includes("Split") || btn.textContent?.includes("Front") || btn.textContent?.includes("Custom")) { + expect(btn).toBeDisabled(); + } + }); + }); + + it("disables all buttons when milestone count is 0", () => { + render(); + + const buttons = screen.getAllByRole("button"); + buttons.forEach((btn) => { + if (btn.textContent?.includes("Split") || btn.textContent?.includes("Front") || btn.textContent?.includes("Custom")) { + expect(btn).toBeDisabled(); + } + }); + }); + + it("shows help message when disabled", () => { + render(); + + expect(screen.getByText(/Set a budget and add milestones/)).toBeDefined(); + }); + + // ── Equal split ────────────────────────────────────────────────────────── + + it("splits budget equally across milestones", () => { + const onDistribute = vi.fn(); + render(); + + // Click "Split equally" mode button + fireEvent.click(screen.getByText("⚖ Split equally")); + + // Click "Apply Equal Split" + fireEvent.click(screen.getByText("Apply Equal Split")); + + expect(onDistribute).toHaveBeenCalledWith([333.34, 333.33, 333.33]); + }); + + it("handles single milestone equal split", () => { + const onDistribute = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText("⚖ Split equally")); + fireEvent.click(screen.getByText("Apply Equal Split")); + + expect(onDistribute).toHaveBeenCalledWith([1000]); + }); + + it("ensures sum equals totalBudget after equal split", () => { + const onDistribute = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText("⚖ Split equally")); + fireEvent.click(screen.getByText("Apply Equal Split")); + + const rewards = onDistribute.mock.calls[0][0]; + const sum = rewards.reduce((a: number, b: number) => a + b, 0); + expect(sum).toBe(100); + }); + + // ── Front-loaded ───────────────────────────────────────────────────────── + + it("distributes front-loaded: 50/30/20 for 3 milestones", () => { + const onDistribute = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText("▲ Front-load")); + fireEvent.click(screen.getByText("Apply Front-load")); + + expect(onDistribute).toHaveBeenCalledWith([500, 300, 200]); + }); + + it("handles single milestone front-load", () => { + const onDistribute = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText("▲ Front-load")); + fireEvent.click(screen.getByText("Apply Front-load")); + + expect(onDistribute).toHaveBeenCalledWith([1000]); + }); + + it("handles two milestones front-load", () => { + const onDistribute = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText("▲ Front-load")); + fireEvent.click(screen.getByText("Apply Front-load")); + + expect(onDistribute).toHaveBeenCalledWith([500, 500]); + }); + + // ── Custom weights ─────────────────────────────────────────────────────── + + it("distributes by custom weights", () => { + const onDistribute = vi.fn(); + render( + + ); + + // Switch to custom mode + fireEvent.click(screen.getByText("⚙ Custom weights")); + + // Default weights are all 5, so equal distribution + fireEvent.click(screen.getByText("Apply Custom Weights")); + + const rewards = onDistribute.mock.calls[0][0]; + const sum = rewards.reduce((a: number, b: number) => a + b, 0); + expect(sum).toBe(1000); + }); + + // ── Undo ───────────────────────────────────────────────────────────────── + + it("shows undo button after distribution", () => { + render(); + + fireEvent.click(screen.getByText("⚖ Split equally")); + fireEvent.click(screen.getByText("Apply Equal Split")); + + expect(screen.getByText("↩ Undo")).toBeDefined(); + }); + + it("hides undo button after 5 seconds", () => { + render(); + + fireEvent.click(screen.getByText("⚖ Split equally")); + fireEvent.click(screen.getByText("Apply Equal Split")); + + expect(screen.getByText("↩ Undo")).toBeDefined(); + + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(screen.queryByText("↩ Undo")).toBeNull(); + }); + + it("undo reverts to previous values", () => { + const onDistribute = vi.fn(); + render(); + + // First distribution + fireEvent.click(screen.getByText("⚖ Split equally")); + fireEvent.click(screen.getByText("Apply Equal Split")); + + const firstCall = onDistribute.mock.calls[0][0]; + + // Click undo + fireEvent.click(screen.getByText("↩ Undo")); + + expect(onDistribute).toHaveBeenCalledTimes(2); + // Undo should call with the same values (the "previous" state) + expect(onDistribute.mock.calls[1][0]).toEqual(firstCall); + }); + + // ── Edge cases ─────────────────────────────────────────────────────────── + + it("handles budget with many decimal places", () => { + const onDistribute = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText("⚖ Split equally")); + fireEvent.click(screen.getByText("Apply Equal Split")); + + const rewards = onDistribute.mock.calls[0][0]; + const sum = rewards.reduce((a: number, b: number) => a + b, 0); + expect(sum).toBe(99.99); + }); + + it("rounds rewards to 2 decimal places", () => { + const onDistribute = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText("⚖ Split equally")); + fireEvent.click(screen.getByText("Apply Equal Split")); + + const rewards = onDistribute.mock.calls[0][0]; + rewards.forEach((r: number) => { + const decimals = r.toString().split(".")[1]?.length ?? 0; + expect(decimals).toBeLessThanOrEqual(2); + }); + }); +});