Skip to content
Closed
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
2 changes: 2 additions & 0 deletions js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"preview": "vite preview"
},
"dependencies": {
"@mantine/charts": "^8.1.2",
"@mantine/core": "^8.1.2",
"@mantine/dates": "^8.1.2",
"@mantine/form": "^8.1.2",
Expand All @@ -42,6 +43,7 @@
"react-router": "^7.18.0",
"react-router-dom": "^7.18.0",
"react-shiki": "^0.9.1",
"recharts": "^2.15.4",
"type-fest": "github:tahminator/type-fest#main",
"use-debounce": "^10.0.4",
"use-immer": "^0.11.0",
Expand Down
287 changes: 255 additions & 32 deletions js/pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions js/src/app/user/[userId]/UserProfile.page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import MetricsChart from "@/app/user/[userId]/_components/UserProfile/MetricsChart/MetricsChart";
import ProfilePicture from "@/app/user/[userId]/_components/UserProfile/ProfilePicture/ProfilePicture";
import UserProfileHeader from "@/app/user/[userId]/_components/UserProfile/UserProfileHeader";
import UserTags from "@/app/user/[userId]/_components/UserProfile/UserTags/UserTags";
Expand Down Expand Up @@ -64,6 +65,7 @@ export default function UserProfilePage() {
<ProfilePicture userId={userId} />
<UserProfileHeader userId={userId} />
<UserTags userId={userId} />
<MetricsChart userId={userId} key={userId} />
</Flex>
</Box>
<Box
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import MetricsChart from "@/app/user/[userId]/_components/UserProfile/MetricsChart/MetricsChart.tsx";
import { ApiURL } from "@/lib/api/common/apiURL";
import { TestUtils, TestUtilTypes } from "@/lib/test";
import { cleanup, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { v4 as uuid } from "uuid";

const FIXED_USER_ID = "metrics-test-user";

const userMetricsUrl = ApiURL.create("/api/user/{userId}/metrics", {
method: "GET",
params: { userId: FIXED_USER_ID },
});

const MOCK_METRICS = [
{
id: "metric-1",
userId: FIXED_USER_ID,
points: 500,
createdAt: new Date(Date.UTC(2026, 9, 1)).toISOString(),
},
{
id: "metric-2",
userId: FIXED_USER_ID,
points: 1500,
createdAt: new Date(Date.UTC(2026, 9, 2)).toISOString(),
},
{
id: "metric-3",
userId: FIXED_USER_ID,
points: 2000,
createdAt: new Date(Date.UTC(2026, 9, 3)).toISOString(),
},
{
id: "metric-4",
userId: FIXED_USER_ID,
points: 3000,
createdAt: new Date(Date.UTC(2026, 9, 4)).toISOString(),
},
{
id: "metric-5",
userId: FIXED_USER_ID,
points: 3000,
createdAt: new Date(Date.UTC(2026, 9, 5)).toISOString(),
},
{
id: "metric-6",
userId: FIXED_USER_ID,
points: 3000,
createdAt: new Date(Date.UTC(2026, 9, 6)).toISOString(),
},
];

const userMetricsSuccessHandler = http.get(userMetricsUrl.url.toString(), () =>
HttpResponse.json({
success: true,
message: "Metrics loaded!",
payload: {
hasNextPage: false,
pages: 1,
items: MOCK_METRICS,
success: true,
},
}),
);

const userMetricsErrorHandler = http.get(userMetricsUrl.url.toString(), () =>
HttpResponse.error(),
);

const mockUseUserMetricsQuery = vi.fn();

vi.mock("@/lib/api/queries/user", () => ({
useUserMetricsQuery: (...args: unknown[]) => mockUseUserMetricsQuery(...args),
}));

const BASE_QUERY_RESULT = {
data: undefined,
status: "pending",
page: 1,
goBack: vi.fn(),
goForward: vi.fn(),
goTo: vi.fn(),
};

describe("MetricsChart succeeded", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});

let renderProviderFn: TestUtilTypes.RenderWithAllProvidersFn | null = null;
beforeEach(() => {
renderProviderFn = TestUtils.getRenderWithAllProvidersFn();
mockUseUserMetricsQuery.mockReturnValue(BASE_QUERY_RESULT);
});

it("should render skeleton metrics chart initially", () => {
const MOCK_USER_ID = uuid();
renderProviderFn?.(<MetricsChart userId={MOCK_USER_ID} />);
const element = screen.getByTestId("user-metrics-chart-skeleton");
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
});

it("should render AreaChart eventually", async () => {
mockUseUserMetricsQuery.mockReturnValue({
...BASE_QUERY_RESULT,
status: "success",
data: {
payload: { items: [], pages: 1, hasNextPage: false },
success: true,
},
});
const MOCK_USER_ID = uuid();
renderProviderFn?.(<MetricsChart userId={MOCK_USER_ID} />);
const element = await screen.findByTestId(/metrics-area-chart.*$/);
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
});

it("should show data message toast and not render AreaChart when data is not successful", async () => {
const mockMessage = "Data not success.";
mockUseUserMetricsQuery.mockReturnValue({
...BASE_QUERY_RESULT,
status: "success",
data: {
payload: { items: [], pages: 1, hasNextPage: false },
success: false,
message: mockMessage,
},
});
const MOCK_USER_ID = uuid();
renderProviderFn?.(<MetricsChart userId={MOCK_USER_ID} />);

await waitFor(() => {
expect(screen.getByText(mockMessage)).toBeInTheDocument();
});

await waitFor(() => {
expect(
screen.queryByTestId(/metrics-area-chart.*$/),
).not.toBeInTheDocument();
});
});
});

describe("UserMetricsChart with successful API", () => {
const server = setupServer(userMetricsSuccessHandler);

beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
cleanup();
vi.clearAllMocks();
});
afterAll(() => server.close());

let renderProviderFn: TestUtilTypes.RenderWithAllProvidersFn | null = null;
beforeEach(() => {
renderProviderFn = TestUtils.getRenderWithAllProvidersFn();

mockUseUserMetricsQuery.mockReturnValue({
...BASE_QUERY_RESULT,
status: "success",
data: {
payload: { items: MOCK_METRICS, pages: 2, hasNextPage: true },
success: true,
},
});
});

it("should have formatted dates for chart after successful API call", async () => {
renderProviderFn?.(<MetricsChart userId={FIXED_USER_ID} />);

const element = await screen.findByTestId(
/metrics-area-chart Oct 01, 2026.*$/,
);
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
});

it("should have correct userMetrics pages and hasNextPage based on data payload", async () => {
renderProviderFn?.(<MetricsChart userId={FIXED_USER_ID} />);

const element = await screen.findByTestId(
"metrics-area-chart Oct 01, 2026 pages2 hasNextPagetrue",
);
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
});
});

describe("UserMetricsChart error state", () => {
const server = setupServer(userMetricsErrorHandler);

beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
cleanup();
vi.clearAllMocks();
});
afterAll(() => server.close());

let renderProviderFn: TestUtilTypes.RenderWithAllProvidersFn | null = null;
beforeEach(() => {
renderProviderFn = TestUtils.getRenderWithAllProvidersFn();
mockUseUserMetricsQuery.mockReturnValue({
...BASE_QUERY_RESULT,
status: "error",
data: undefined,
});
});

it("should show error toast when API errors", async () => {
renderProviderFn?.(<MetricsChart userId={FIXED_USER_ID} />);

await waitFor(() => {
expect(
screen.getByText(/sorry, something went wrong/i),
).toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import MetricsChartSkeleton from "@/app/user/[userId]/_components/UserProfile/MetricsChart/MetricsChartSkeleton";
import Paginator from "@/components/ui/table/Paginator";
import Toast from "@/components/ui/toast/Toast";
import { useUserMetricsQuery } from "@/lib/api/queries/user";
import { theme } from "@/lib/theme";
import { AreaChart } from "@mantine/charts";
import { Box, Divider } from "@mantine/core";
import "@mantine/charts/styles.css";
import d from "dayjs";
import utc from "dayjs/plugin/utc";

d.extend(utc);

export default function MetricsChart({ userId }: { readonly userId: string }) {
const { data, status, page, goBack, goForward, goTo } = useUserMetricsQuery({
userId,
});

if (status === "pending") {
return <MetricsChartSkeleton />;
}

if (status === "error") {
return (
<Toast message="Sorry, something went wrong. Please try again later." />
);
}

if (!data.success) {
return <Toast message={data.message} />;
}

const userMetrics = data.payload;
const pageData = userMetrics.items.map((item) => {
return {
...item,
createdAt: d(item.createdAt).utc().format("MMM DD, YYYY"),
};
});

return (
<>
<Divider w={"70%"} />
<Box w={"90%"}>
<AreaChart
h={250}
data={pageData}
dataKey="createdAt"
data-testId={`metrics-area-chart ${pageData[0]?.createdAt} pages${userMetrics?.pages ?? 0} hasNextPage${userMetrics?.hasNextPage ?? false}`}
series={[
{ name: "points", label: "Points", color: theme.colors.patina[4] },
]}
curveType="monotone"
tickLine="none"
gridAxis="xy"
/>
</Box>
<Paginator
pages={userMetrics?.pages ?? 0}
currentPage={page}
hasNextPage={userMetrics?.hasNextPage ?? false}
goBack={goBack}
goForward={goForward}
goTo={goTo}
/>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Flex, Skeleton, Divider } from "@mantine/core";

export default function MetricsChartSkeleton() {
return (
<Flex direction={"column"} align={"center"} gap={"sm"} w={"100%"}>
<Divider w={"70%"} />
<Skeleton
data-testid="user-metrics-chart-skeleton"
height={250}
width={"70%"}
mx={"md"}
/>
</Flex>
);
}
Loading