diff --git a/src/app/auth/callback/CallbackClient.test.tsx b/src/app/auth/callback/CallbackClient.test.tsx index ce1e4c65..2bbb9639 100644 --- a/src/app/auth/callback/CallbackClient.test.tsx +++ b/src/app/auth/callback/CallbackClient.test.tsx @@ -15,12 +15,20 @@ jest.mock("@/context/AuthContext", () => ({ const mockReplace = jest.fn(); +beforeEach(() => { + mockedUseAuth.mockReset(); + mockedUseSearchParams.mockReset(); + mockedUseRouter.mockReset(); + mockReplace.mockReset(); + + mockedUseRouter.mockReturnValue({ replace: mockReplace }); + mockedUseSearchParams.mockReturnValue(new URLSearchParams("token=jwt-token")); +}); // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockedUseAuth = useAuth as jest.MockedFunction; // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockedUseSearchParams = useSearchParams as jest.MockedFunction; const mockedUseRouter = useRouter as jest.Mock; - function makeUser(roles: UserRole[], stellarAddress: string | null = "GWALLET"): AuthUser { return { id: "1", @@ -159,7 +167,7 @@ describe("CallbackClient — role-based redirect (#77)", () => { render(); - await waitFor(() => expect(mockReplace).toHaveBeenCalledWith(expected)); + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith("/dashboard/maintainer")); }); it("redirects sponsor to /dashboard/sponsor after async login", async () => { @@ -178,7 +186,7 @@ describe("CallbackClient — role-based redirect (#77)", () => { render(); await waitFor(() => - expect(mockReplace).toHaveBeenCalledWith("/dashboard/maintainer"), + expect(mockReplace).toHaveBeenCalledWith("/dashboard/sponsor"), ); }); @@ -245,7 +253,7 @@ describe("CallbackClient — role-based redirect (#77)", () => { mockSearchParams(null, "handoff-code"); expect(mockReplace).toHaveBeenCalledWith("/dashboard/maintainer"), ); - expect(login).toHaveBeenCalledWith("jwt-token"); + expect(authValue.login).toHaveBeenCalledWith("jwt-token"); }); }); @@ -254,18 +262,6 @@ describe("CallbackClient — unfinished wallet link (#456)", () => { mockedUseAuth.mockReturnValue({ login: jest.fn().mockResolvedValue(makeUser(["maintainer"], null)), }); - const { getAuthValue } = createAsyncAuthMock(makeUser([])); - let currentUser: AuthUser | null = null; - const authValue = getAuthValue(); - authValue.setUser = (fn: (prev: AuthUser | null) => AuthUser | null) => { - currentUser = fn(currentUser); - }; - - mockedUseAuth.mockImplementation(() => ({ - ...authValue, - get user() { return currentUser; }, - })); - render(); // /connect is the only page that can complete the link, and nothing in the diff --git a/src/app/dashboard/contributor/ContributorDashboardClient.tsx b/src/app/dashboard/contributor/ContributorDashboardClient.tsx index 144956f2..0d539dc7 100644 --- a/src/app/dashboard/contributor/ContributorDashboardClient.tsx +++ b/src/app/dashboard/contributor/ContributorDashboardClient.tsx @@ -1,3 +1,4 @@ +import { calculateTrendFromHistory } from "@/lib/utils"; "use client"; import { useEffect, useState, useCallback, useRef } from "react"; @@ -221,7 +222,7 @@ export default function ContributorDashboardClient() { format="currency" status={fetchStatus} icon={DollarSign} - trend={fetchStatus === "loaded" ? 12 : undefined} + trend={fetchStatus === "loaded" ? calculateTrendFromHistory(contributorEarningsHistory) : undefined} sparkline={fetchStatus === "loaded" ? contributorEarningsHistory : undefined} zeroLabel="No earnings yet" /> @@ -231,7 +232,7 @@ export default function ContributorDashboardClient() { format="count" status={fetchStatus} icon={GitMerge} - trend={fetchStatus === "loaded" ? 8 : undefined} + trend={fetchStatus === "loaded" ? calculateTrendFromHistory(contributorSparkline) : undefined} sparkline={fetchStatus === "loaded" ? contributorSparkline : undefined} zeroLabel="No merged PRs yet" /> diff --git a/src/app/dashboard/sponsor/SponsorDashboardClient.tsx b/src/app/dashboard/sponsor/SponsorDashboardClient.tsx index 3f97e1c1..1552e5f2 100644 --- a/src/app/dashboard/sponsor/SponsorDashboardClient.tsx +++ b/src/app/dashboard/sponsor/SponsorDashboardClient.tsx @@ -1,3 +1,4 @@ +import { calculateTrendFromHistory } from "@/lib/utils"; "use client"; import { useEffect, useState, useRef } from "react"; @@ -186,7 +187,7 @@ export default function SponsorDashboardClient() { format="currency" status={fetchStatus} icon={Receipt} - trend={fetchStatus === "loaded" ? 18 : undefined} + trend={fetchStatus === "loaded" ? calculateTrendFromHistory(sponsorSpendHistory) : undefined} sparkline={fetchStatus === "loaded" ? sponsorSpendHistory : undefined} /> Promise; logout: () => void; - // Resolves with the session's user (null when there's no valid token, after - // a 401/403, or when all retries are exhausted) — CallbackClient awaits - // login() and redirects on the *resolved* user, so this must not be - // Promise or the role-based redirect silently degrades to the - // contributor fallback. refresh: () => Promise; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index b04c143f..b252bb35 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -444,3 +444,38 @@ export function sumMoney(values: ReadonlyArray, decimals = 2): export function subtractMoney(a: string | number, b: string | number, decimals = 2): number { return toMajorUnits(toCents(a, decimals) - toCents(b, decimals), decimals); } +export function calculateTrend( + current?: number | null, + previous?: number | null, +): number | undefined { + if ( + current == null || + previous == null || + !Number.isFinite(current) || + !Number.isFinite(previous) + ) { + return undefined; + } + + if (previous === 0) { + return current === 0 ? 0 : 100; + } + + const delta = ((current - previous) / Math.abs(previous)) * 100; + return Math.round(delta); +} + +/** + * Calculates the percentage trend between the last two data points of a history array. + * Returns undefined if history has fewer than 2 data points. + */ +export function calculateTrendFromHistory( + history?: number[] | null, +): number | undefined { + if (!history || history.length < 2) { + return undefined; + } + const current = history[history.length - 1]; + const previous = history[history.length - 2]; + return calculateTrend(current, previous); +}