Skip to content
Open
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
28 changes: 12 additions & 16 deletions src/app/auth/callback/CallbackClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockedUseSearchParams = useSearchParams as jest.MockedFunction<any>;
const mockedUseRouter = useRouter as jest.Mock;

function makeUser(roles: UserRole[], stellarAddress: string | null = "GWALLET"): AuthUser {
return {
id: "1",
Expand Down Expand Up @@ -159,7 +167,7 @@ describe("CallbackClient — role-based redirect (#77)", () => {

render(<CallbackClient />);

await waitFor(() => expect(mockReplace).toHaveBeenCalledWith(expected));
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith("/dashboard/maintainer"));
});

it("redirects sponsor to /dashboard/sponsor after async login", async () => {
Expand All @@ -178,7 +186,7 @@ describe("CallbackClient — role-based redirect (#77)", () => {
render(<CallbackClient />);

await waitFor(() =>
expect(mockReplace).toHaveBeenCalledWith("/dashboard/maintainer"),
expect(mockReplace).toHaveBeenCalledWith("/dashboard/sponsor"),
);
});

Expand Down Expand Up @@ -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");
});
});

Expand All @@ -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(<CallbackClient />);

// /connect is the only page that can complete the link, and nothing in the
Expand Down
5 changes: 3 additions & 2 deletions src/app/dashboard/contributor/ContributorDashboardClient.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { calculateTrendFromHistory } from "@/lib/utils";
"use client";

import { useEffect, useState, useCallback, useRef } from "react";
Expand Down Expand Up @@ -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"
/>
Expand All @@ -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"
/>
Expand Down
3 changes: 2 additions & 1 deletion src/app/dashboard/sponsor/SponsorDashboardClient.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { calculateTrendFromHistory } from "@/lib/utils";
"use client";

import { useEffect, useState, useRef } from "react";
Expand Down Expand Up @@ -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}
/>
<StatCard
Expand Down
17 changes: 2 additions & 15 deletions src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

import {
createContext,
useCallback,
useContext,
useState,
useEffect,
useCallback,
useRef,
useState,
} from "react";
import {
getToken,
Expand All @@ -24,24 +24,11 @@ interface AuthContextValue {
/**
* `true` while the session is still unresolved and the UI must not commit to
* a signed-in *or* signed-out appearance.
*
* This covers both the initial mount hydration and any in-flight re-resolve
* (sign-in token exchange, cross-tab login). It previously only ever went
* `true → false` because `refresh()` never set it back, so for the whole
* duration of the GitHub OAuth round trip the Navbar rendered its
* logged-out CTAs — including a live "Connect GitHub" button that would
* re-enter the very OAuth flow the user had just completed.
*/
loading: boolean;
/** True when the auth backend is unreachable and the session state is uncertain (#587) */
degraded: boolean;
login: (token: string) => Promise<AuthUser | null>;
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<void> or the role-based redirect silently degrades to the
// contributor fallback.
refresh: () => Promise<AuthUser | null>;
}

Expand Down
35 changes: 35 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,3 +444,38 @@ export function sumMoney(values: ReadonlyArray<string | number>, 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);
}