diff --git a/mobileapp/__tests__/homeYield.test.tsx b/mobileapp/__tests__/homeYield.test.tsx index 1d47ddd..a8ef38e 100644 --- a/mobileapp/__tests__/homeYield.test.tsx +++ b/mobileapp/__tests__/homeYield.test.tsx @@ -61,6 +61,7 @@ jest.mock("@expo/vector-icons", () => ({ })); const mockFetchYieldBalance = jest.fn(); +const mockFetchYieldEarningsHistory = jest.fn(); const mockUpdateAutoEarn = jest.fn(); jest.mock("../src/services/socialService", () => ({ @@ -72,6 +73,8 @@ jest.mock("../src/services/socialService", () => ({ jest.mock("../src/services/api", () => ({ fetchYieldBalance: (...args: unknown[]) => mockFetchYieldBalance(...args), + fetchYieldEarningsHistory: (...args: unknown[]) => + mockFetchYieldEarningsHistory(...args), updateAutoEarn: (...args: unknown[]) => mockUpdateAutoEarn(...args), })); @@ -209,6 +212,7 @@ describe("HomeScreen auto-earn haptics (#907)", () => { beforeEach(() => { jest.clearAllMocks(); mockUpdateAutoEarn.mockResolvedValue(undefined); + mockFetchYieldEarningsHistory.mockResolvedValue([]); }); it("fires a light haptic when auto-earn is switched off", async () => { @@ -273,4 +277,64 @@ describe("HomeScreen auto-earn haptics (#907)", () => { expect(impactAsync).toHaveBeenCalledTimes(1); }); + + it("updates the Auto-Earn widget after saving the preference", async () => { + mockFetchYieldBalance.mockResolvedValue(loadedYield); + const { getByLabelText, findByText } = render(); + await findByText("Your money is working"); + + await act(async () => { + fireEvent( + getByLabelText("Toggle Auto-Earn on Idle Funds"), + "valueChange", + false + ); + }); + + expect(mockUpdateAutoEarn).toHaveBeenCalledWith(false); + expect(await findByText("Enable to sweep idle balance into yield")).toBeTruthy(); + }); + + it("rolls back the Auto-Earn widget when the update request fails", async () => { + mockFetchYieldBalance.mockResolvedValue(loadedYield); + mockUpdateAutoEarn.mockRejectedValue(new Error("Unavailable")); + const { getByLabelText, findByText } = render(); + await findByText("Your money is working"); + + await act(async () => { + fireEvent( + getByLabelText("Toggle Auto-Earn on Idle Funds"), + "valueChange", + false + ); + }); + + expect(await findByText("Idle balance is actively earning yield")).toBeTruthy(); + }); + + it("plots monthly earned and reward history from the API", async () => { + const currentPeriod = new Date().toISOString().slice(0, 7); + mockFetchYieldBalance.mockResolvedValue(loadedYield); + mockFetchYieldEarningsHistory.mockResolvedValue([ + { + type: "EARNED", + amount: 500_000, + asset: "XLM", + created_at: `${currentPeriod}-10 12:00:00`, + }, + { + type: "REWARD", + amount: 250_000, + asset: "XLM", + created_at: `${currentPeriod}-12 12:00:00`, + }, + ]); + const { getByTestId, getByText, findByText } = render(); + await findByText("Your money is working"); + fireEvent.press(getByTestId("earning-balance-card")); + + await findByText(/₦1,200\.00/); + expect(mockFetchYieldEarningsHistory).toHaveBeenCalled(); + expect(getByText("Monthly earnings")).toBeTruthy(); + }); }); diff --git a/mobileapp/__tests__/yieldApi.test.ts b/mobileapp/__tests__/yieldApi.test.ts index 682785a..2bd2237 100644 --- a/mobileapp/__tests__/yieldApi.test.ts +++ b/mobileapp/__tests__/yieldApi.test.ts @@ -1,5 +1,9 @@ import * as SecureStore from "expo-secure-store"; -import { fetchYieldBalance, updateAutoEarn } from "../src/services/api"; +import { + fetchYieldBalance, + fetchYieldEarningsHistory, + updateAutoEarn, +} from "../src/services/api"; jest.mock("expo-secure-store", () => ({ getItemAsync: jest.fn().mockResolvedValue(null), @@ -75,6 +79,40 @@ describe("yield API", () => { ); }); + it("loads all pages of earned and reward history", async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + items: [ + { type: "EARNED", amount: 1_000_000, asset: "XLM", created_at: "2026-10-01 00:00:00" }, + ], + total: 101, + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + items: [ + { type: "REWARD", amount: 500_000, asset: "XLM", created_at: "2026-10-02 00:00:00" }, + ], + total: 1, + }) + ) + .mockResolvedValueOnce(jsonResponse({ items: [], total: 101 })); + + const history = await fetchYieldEarningsHistory("2025-11-01", "2026-10-22"); + + expect(history).toHaveLength(2); + expect(mockFetch).toHaveBeenCalledTimes(3); + const requestUrls = mockFetch.mock.calls.map(([url]) => String(url)); + expect(requestUrls).toEqual( + expect.arrayContaining([ + expect.stringContaining("action=EARNED"), + expect.stringContaining("action=REWARD"), + expect.stringContaining("offset=1"), + ]) + ); + }); + it("throws when saving the Auto-Earn setting fails", async () => { mockFetch.mockResolvedValueOnce(jsonResponse({}, 500)); diff --git a/mobileapp/app/(personal)/home.tsx b/mobileapp/app/(personal)/home.tsx index 8569459..d9cfb80 100644 --- a/mobileapp/app/(personal)/home.tsx +++ b/mobileapp/app/(personal)/home.tsx @@ -41,7 +41,11 @@ import { fetchFeed, addComment, } from "../../src/services/socialService"; -import { fetchYieldBalance, updateAutoEarn } from "../../src/services/api"; +import { + fetchYieldBalance, + fetchYieldEarningsHistory, + updateAutoEarn, +} from "../../src/services/api"; import { BalanceCurrency, formatBalance, @@ -89,23 +93,25 @@ interface Comment { interface MonthlyEarningPoint { month: string; + period: string; amount: number; } -const MONTHLY_EARNINGS: MonthlyEarningPoint[] = [ - { month: "Jan", amount: 140 }, - { month: "Feb", amount: 172 }, - { month: "Mar", amount: 205 }, - { month: "Apr", amount: 238 }, - { month: "May", amount: 261 }, - { month: "Jun", amount: 296 }, - { month: "Jul", amount: 317 }, - { month: "Aug", amount: 334 }, - { month: "Sep", amount: 352 }, - { month: "Oct", amount: 365 }, - { month: "Nov", amount: 388 }, - { month: "Dec", amount: 412 }, -]; +const createRecentMonthlyEarnings = (): MonthlyEarningPoint[] => { + const now = new Date(); + return Array.from({ length: 12 }, (_, index) => { + const date = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 11 + index, 1) + ); + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); + return { + month: date.toLocaleString("en-US", { month: "short", timeZone: "UTC" }), + period: `${year}-${String(month + 1).padStart(2, "0")}`, + amount: 0, + }; + }); +}; const INITIAL_FEED: FeedItem[] = [ { @@ -161,9 +167,13 @@ export default function HomeScreen() { const [yieldError, setYieldError] = useState(""); const [yieldRetryCount, setYieldRetryCount] = useState(0); const [chartWidth, setChartWidth] = useState(0); - const [selectedMonthIndex, setSelectedMonthIndex] = useState( - MONTHLY_EARNINGS.length - 1 + const [monthlyEarnings, setMonthlyEarnings] = useState( + createRecentMonthlyEarnings ); + const [earningsHistoryStatus, setEarningsHistoryStatus] = useState< + "idle" | "loading" | "success" | "error" + >("idle"); + const [selectedMonthIndex, setSelectedMonthIndex] = useState(11); const [earningsModalVisible, setEarningsModalVisible] = useState(false); const [autoYieldEnabled, setAutoYieldEnabled] = useState(true); const [autoEarnSyncing, setAutoEarnSyncing] = useState(false); @@ -182,6 +192,7 @@ export default function HomeScreen() { const earningPulseAnim = useRef(new Animated.Value(1)).current; const earningOuterGlowAnim = useRef(new Animated.Value(0)).current; const yieldRequestRef = useRef(0); + const earningsHistoryRequestRef = useRef(0); // Animated values for like heart scale per feed item const scaleAnims = useRef>(new Map()); @@ -232,6 +243,43 @@ export default function HomeScreen() { }; }; + const loadEarningsHistory = useCallback(async () => { + const requestId = ++earningsHistoryRequestRef.current; + const months = createRecentMonthlyEarnings(); + const from = `${months[0].period}-01`; + const to = new Date().toISOString().slice(0, 10); + setEarningsHistoryStatus("loading"); + + try { + const history = await fetchYieldEarningsHistory(from, to); + if (requestId !== earningsHistoryRequestRef.current) return; + + const totalsByMonth = new Map(); + history.forEach((item) => { + if (item.asset.toUpperCase() !== "XLM") return; + const amount = Number(item.amount); + const period = item.created_at.slice(0, 7); + if (!Number.isFinite(amount)) return; + totalsByMonth.set( + period, + (totalsByMonth.get(period) ?? 0) + fiatFromTokens(amount / 1_000_000) + ); + }); + + setMonthlyEarnings( + months.map((month) => ({ + ...month, + amount: Number((totalsByMonth.get(month.period) ?? 0).toFixed(2)), + })) + ); + setSelectedMonthIndex(months.length - 1); + setEarningsHistoryStatus("success"); + } catch { + if (requestId !== earningsHistoryRequestRef.current) return; + setEarningsHistoryStatus("error"); + } + }, []); + const withTimeout = async ( promise: Promise, timeoutMs: number @@ -616,6 +664,7 @@ export default function HomeScreen() { () => undefined ); setEarningsModalVisible(true); + void loadEarningsHistory(); earningsSheetTranslateY.setValue(48); earningsBackdropOpacity.setValue(0); Animated.parallel([ @@ -721,22 +770,6 @@ export default function HomeScreen() { const displayBalance = (amount: string) => balanceMasked ? MASKED_BALANCE : amount; - const monthlyEarnings = useMemo(() => { - const baseTotal = MONTHLY_EARNINGS.reduce( - (sum, item) => sum + item.amount, - 0 - ); - const targetTotal = parseCurrencyAmount( - yieldData?.totalYieldEarned ?? "₦3,280.45" - ); - const scale = - targetTotal > 0 && baseTotal > 0 ? targetTotal / baseTotal : 1; - return MONTHLY_EARNINGS.map((item) => ({ - ...item, - amount: Number((item.amount * scale).toFixed(2)), - })); - }, [yieldData]); - useEffect(() => { if (selectedMonthIndex > monthlyEarnings.length - 1) { setSelectedMonthIndex(monthlyEarnings.length - 1); @@ -1487,7 +1520,20 @@ export default function HomeScreen() { - {chartGeometry && ( + {earningsHistoryStatus === "loading" ? ( + + Loading earnings history... + + ) : earningsHistoryStatus === "error" ? ( + + Earnings history is unavailable. + + ) : earningsHistoryStatus === "success" && + monthlyEarnings.every((item) => item.amount === 0) ? ( + + No yield earnings recorded in the last 12 months. + + ) : chartGeometry ? ( <> @@ -1548,7 +1594,7 @@ export default function HomeScreen() { ))} - )} + ) : null} @@ -2659,6 +2705,13 @@ const styles = StyleSheet.create({ overflow: "hidden", backgroundColor: "#F2F9F0", }, + earningsHistoryMessage: { + alignSelf: "center", + color: "#64748B", + fontFamily: "Outfit_400Regular", + fontSize: 13, + textAlign: "center", + }, chartTouchOverlay: { ...StyleSheet.absoluteFillObject, }, diff --git a/mobileapp/src/services/api.ts b/mobileapp/src/services/api.ts index f28599d..cc489d6 100644 --- a/mobileapp/src/services/api.ts +++ b/mobileapp/src/services/api.ts @@ -243,6 +243,56 @@ export async function fetchYieldBalance(): Promise { }; } +export interface YieldEarningHistoryRecord { + type: string; + amount: number; + asset: string; + created_at: string; +} + +interface YieldHistoryResponse { + items: YieldEarningHistoryRecord[]; + total: number; +} + +export async function fetchYieldEarningsHistory( + from: string, + to: string +): Promise { + const actions = ["EARNED", "REWARD"]; + const historyPages = await Promise.all( + actions.map(async (action) => { + const items: YieldEarningHistoryRecord[] = []; + let offset = 0; + let total = Number.POSITIVE_INFINITY; + + while (offset < total) { + const params = new URLSearchParams({ + action, + from, + to, + limit: "100", + offset: String(offset), + }); + const res = await apiFetch( + `${API_BASE}/api/yield/history?${params.toString()}` + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const page = (await res.json()) as YieldHistoryResponse; + items.push(...page.items); + total = page.total; + if (page.items.length === 0) break; + offset += page.items.length; + } + + return items; + }) + ); + + return historyPages.flat(); +} + export async function updateAutoEarn(enabled: boolean): Promise { const res = await apiFetch(`${API_BASE}/api/yield/toggle-auto`, { method: "POST",