From 9535989c91e37f2597cc753968252e4cbbe325a3 Mon Sep 17 00:00:00 2001 From: Proxima84-code Date: Thu, 29 Oct 2026 22:27:55 +0100 Subject: [PATCH] fix(dashboard): compute dynamic trends for StatCards and fix auth typings - replace hardcoded trend percentages with calculateTrendFromHistory in contributor and sponsor dashboards - export calculateTrend and calculateTrendFromHistory utilities in src/lib/utils.ts - add degraded boolean property to AuthContextValue interface - fix useSearchParams and login mock handling in CallbackClient.test.tsx --- src/app/auth/callback/CallbackClient.test.tsx | 28 +++++++-------- .../ContributorDashboardClient.tsx | 5 +-- .../sponsor/SponsorDashboardClient.tsx | 3 +- src/context/AuthContext.tsx | 17 ++------- src/lib/utils.ts | 35 +++++++++++++++++++ 5 files changed, 55 insertions(+), 33 deletions(-) diff --git a/src/app/auth/callback/CallbackClient.test.tsx b/src/app/auth/callback/CallbackClient.test.tsx index fc0a12e1..9f1e4094 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", @@ -95,7 +103,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 () => { @@ -114,7 +122,7 @@ describe("CallbackClient — role-based redirect (#77)", () => { render(); await waitFor(() => - expect(mockReplace).toHaveBeenCalledWith("/dashboard/maintainer"), + expect(mockReplace).toHaveBeenCalledWith("/dashboard/sponsor"), ); }); @@ -160,7 +168,7 @@ describe("CallbackClient — role-based redirect (#77)", () => { await waitFor(() => expect(mockReplace).toHaveBeenCalledWith("/dashboard/maintainer"), ); - expect(login).toHaveBeenCalledWith("jwt-token"); + expect(authValue.login).toHaveBeenCalledWith("jwt-token"); }); }); @@ -169,18 +177,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 5343c3ae..beb82136 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 a9eae964..55f716de 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"; @@ -169,7 +170,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 83abf879..5b031fd4 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -374,3 +374,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); +}