Skip to content
Merged
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
64 changes: 64 additions & 0 deletions mobileapp/__tests__/homeYield.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -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),
}));

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(<HomeScreen />);
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(<HomeScreen />);
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(<HomeScreen />);
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();
});
});
40 changes: 39 additions & 1 deletion mobileapp/__tests__/yieldApi.test.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand Down Expand Up @@ -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));

Expand Down
123 changes: 88 additions & 35 deletions mobileapp/app/(personal)/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@
fetchFeed,
addComment,
} from "../../src/services/socialService";
import { fetchYieldBalance, updateAutoEarn } from "../../src/services/api";
import {
fetchYieldBalance,
fetchYieldEarningsHistory,
updateAutoEarn,
} from "../../src/services/api";
import {
BalanceCurrency,
formatBalance,
Expand All @@ -51,7 +55,7 @@
import {
YIELD_REQUEST_TIMEOUT_MS,
describeYieldError,
withTimeout,

Check failure on line 58 in mobileapp/app/(personal)/home.tsx

View workflow job for this annotation

GitHub Actions / Frontend (React Native)

'withTimeout' is defined but never used
} from "../../src/utils/yieldRequest";
import {
markNotificationPromptDismissed,
Expand Down Expand Up @@ -89,23 +93,25 @@

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[] = [
{
Expand Down Expand Up @@ -161,9 +167,13 @@
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);
Expand All @@ -182,6 +192,7 @@
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<Map<string, Animated.Value>>(new Map());
Expand Down Expand Up @@ -232,6 +243,43 @@
};
};

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<string, number>();
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 <T,>(
promise: Promise<T>,
timeoutMs: number
Expand Down Expand Up @@ -280,7 +328,7 @@
[totalYieldEarnedXlm, displayCurrency]
);

const toggleCurrency = useCallback(() => {

Check failure on line 331 in mobileapp/app/(personal)/home.tsx

View workflow job for this annotation

GitHub Actions / Frontend (React Native)

'toggleCurrency' is assigned a value but never used
void Haptics.selectionAsync().catch(() => undefined);
setDisplayCurrency((prev) => {
const next = prev === "NGN" ? "XLM" : "NGN";
Expand Down Expand Up @@ -616,6 +664,7 @@
() => undefined
);
setEarningsModalVisible(true);
void loadEarningsHistory();
earningsSheetTranslateY.setValue(48);
earningsBackdropOpacity.setValue(0);
Animated.parallel([
Expand Down Expand Up @@ -721,22 +770,6 @@
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);
Expand Down Expand Up @@ -1487,7 +1520,20 @@
</View>

<View style={styles.earningsChartCanvas}>
{chartGeometry && (
{earningsHistoryStatus === "loading" ? (
<Text style={styles.earningsHistoryMessage}>
Loading earnings history...
</Text>
) : earningsHistoryStatus === "error" ? (
<Text style={styles.earningsHistoryMessage}>
Earnings history is unavailable.
</Text>
) : earningsHistoryStatus === "success" &&
monthlyEarnings.every((item) => item.amount === 0) ? (
<Text style={styles.earningsHistoryMessage}>
No yield earnings recorded in the last 12 months.
</Text>
) : chartGeometry ? (
<>
<Svg width={chartWidth} height={chartHeight}>
<Defs>
Expand Down Expand Up @@ -1548,7 +1594,7 @@
))}
</View>
</>
)}
) : null}
</View>

<View style={styles.earningsMonthRow}>
Expand Down Expand Up @@ -1619,7 +1665,7 @@

return (
<>
{projections.map((proj, idx) => {

Check failure on line 1668 in mobileapp/app/(personal)/home.tsx

View workflow job for this annotation

GitHub Actions / Frontend (React Native)

'idx' is defined but never used. Allowed unused args must match /^_/u
const barHeight = (proj.value / maxValue) * 110;
return (
<View key={proj.label} style={styles.compoundingBar}>
Expand Down Expand Up @@ -2478,7 +2524,7 @@
},
retryChip: {
borderWidth: 1,
borderColor: "#CBD5E1",

Check failure on line 2527 in mobileapp/app/(personal)/home.tsx

View workflow job for this annotation

GitHub Actions / Frontend (React Native)

Duplicate key 'earningHint'
borderRadius: 14,
alignSelf: "flex-start",
paddingHorizontal: 10,
Expand Down Expand Up @@ -2659,6 +2705,13 @@
overflow: "hidden",
backgroundColor: "#F2F9F0",
},
earningsHistoryMessage: {
alignSelf: "center",
color: "#64748B",
fontFamily: "Outfit_400Regular",
fontSize: 13,
textAlign: "center",
},
chartTouchOverlay: {
...StyleSheet.absoluteFillObject,
},
Expand Down
50 changes: 50 additions & 0 deletions mobileapp/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,56 @@ export async function fetchYieldBalance(): Promise<YieldBalance> {
};
}

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<YieldEarningHistoryRecord[]> {
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<void> {
const res = await apiFetch(`${API_BASE}/api/yield/toggle-auto`, {
method: "POST",
Expand Down
Loading