(0);
37 |
38 | const classes = useProgressBarStyles();
39 | const theme = useTheme();
40 |
41 | const startProgress = () => {
42 | setShow(true);
43 | setCurProgress(0);
44 | };
45 |
46 | const endProgress = () => {
47 | setShow(false);
48 | };
49 |
50 | const advanceProgress = (value: number) => {
51 | const newProgress = curProgress + value > 100 ? 100 : curProgress + value;
52 | setCurProgress(newProgress);
53 | };
54 |
55 | const setProgress = (value: number) => {
56 | const newProgress = value > 100 ? 100 : value;
57 | setCurProgress(newProgress);
58 | };
59 |
60 | return (
61 |
62 |
74 | {children}
75 |
76 | );
77 | };
78 |
79 | const useProgress = (): ProgressContextActions => {
80 | const context = useContext(ProgressContext);
81 |
82 | if (!context || Object.keys(context).length === 0) {
83 | throw new Error("useProgress must be used within an ProgressProvider");
84 | }
85 |
86 | return context;
87 | };
88 |
89 | export { useProgress, ProgressProvider };
90 |
--------------------------------------------------------------------------------
/src/shared/ProgressContext.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, fireEvent } from "@testing-library/react";
2 | import { useProgress, ProgressProvider } from "./ProgressContext";
3 | import { Button } from "@mui/material";
4 |
5 | const TestComponent = () => {
6 | const { startProgress, advanceProgress, setProgress, endProgress } = useProgress();
7 |
8 | const handleStart = () => {
9 | startProgress();
10 | };
11 |
12 | const handleAdvance = () => {
13 | advanceProgress(20);
14 | };
15 |
16 | const handleSet = (value: number) => {
17 | setProgress(value);
18 | };
19 |
20 | const handleEnd = () => {
21 | endProgress();
22 | };
23 |
24 | return (
25 |
26 | ;
27 | ;
28 | ;
29 | ;
30 | ;
31 |
32 | );
33 | };
34 |
35 | describe("Progress context", () => {
36 | it("start, advance, set and end progress", () => {
37 | render(
38 |
39 |
40 | ,
41 | );
42 |
43 | const progressBar = screen.getByRole("progressbar");
44 |
45 | expect(progressBar).toHaveStyle({ opacity: 0 });
46 |
47 | const startButton = screen.getByTestId("Start");
48 | fireEvent.click(startButton);
49 |
50 | expect(progressBar).toHaveStyle({ opacity: 1 });
51 |
52 | expect(progressBar.getAttribute("aria-valuenow")).toStrictEqual("0");
53 |
54 | const advanceButton = screen.getByTestId("Advance");
55 |
56 | for (const expectedValue of [20, 40, 60, 80, 100, 100]) {
57 | fireEvent.click(advanceButton);
58 | expect(progressBar.getAttribute("aria-valuenow")).toStrictEqual(expectedValue.toString());
59 | }
60 |
61 | const setButton = screen.getByTestId("Set");
62 | fireEvent.click(setButton);
63 | expect(progressBar.getAttribute("aria-valuenow")).toStrictEqual("10");
64 |
65 | const setHighButton = screen.getByTestId("SetHigh");
66 | fireEvent.click(setHighButton);
67 | expect(progressBar.getAttribute("aria-valuenow")).toStrictEqual("100");
68 |
69 | const endButton = screen.getByTestId("End");
70 | fireEvent.click(endButton);
71 |
72 | expect(progressBar).toHaveStyle({ opacity: 0 });
73 | });
74 |
75 | it("throws an error if ProgressProvider doesn't exist", () => {
76 | // prevent `render` from logging the error to console
77 | jest.spyOn(console, "error").mockImplementation(jest.fn());
78 | expect(() => render()).toThrow(
79 | "useProgress must be used within an ProgressProvider",
80 | );
81 | });
82 | });
83 |
--------------------------------------------------------------------------------
/src/components/Footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { IconButton, Stack, Typography } from "@mui/material";
2 | import { useTheme } from "@mui/material/styles";
3 | import GitHubButton from "react-github-btn";
4 | import GitHubIcon from "@mui/icons-material/GitHub";
5 | import TwitterIcon from "@mui/icons-material/Twitter";
6 | import EmailIcon from "@mui/icons-material/Email";
7 | import {
8 | starTrackGitHubMaintainer,
9 | starTrackGitHubRepo,
10 | twitter,
11 | email,
12 | } from "../../utils/Constants";
13 |
14 | export default function Footer() {
15 | const theme = useTheme();
16 |
17 | return (
18 | theme.custom.additionalBackgroundColor,
22 | }}
23 | padding={1}
24 | display="flex"
25 | alignItems="center"
26 | component="footer"
27 | >
28 |
29 |
30 | Created by
31 |
37 | @{starTrackGitHubMaintainer}
38 |
39 | {new Date().getFullYear()}
40 |
41 |
42 | Give us a
43 |
50 | Star
51 |
52 |
53 |
54 |
55 |
61 |
62 |
63 |
64 |
65 |
66 |
72 |
73 |
74 |
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/tests/forecast.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from "@playwright/test";
2 | import { getComparator } from "playwright-core/lib/utils";
3 | import { localUrl, referenceUrl, username, repo1, authenticate, getChartScreenshot } from "./utils";
4 |
5 | test("Forecast", async ({ page }, testDir) => {
6 | const enableForecast = async () => {
7 | await page.getByRole("button", { name: "Do not show forecast" }).click();
8 | await page.getByTestId("backwardCount").fill("4");
9 | await page.getByTestId("forwardCount").fill("6");
10 | await page.getByTestId("pointCount").fill("150");
11 | await page.getByRole("button", { name: "Ok" }).click();
12 | };
13 |
14 | const takeReferenceScreenshots = async () => {
15 | await page.goto(referenceUrl);
16 |
17 | await authenticate(page);
18 |
19 | await page.getByPlaceholder("Username").fill(username);
20 | await page.getByPlaceholder("Repo name").fill(repo1);
21 | await page.getByRole("button", { name: "Go!" }).click();
22 | await page.getByRole("button", { name: "Go!" }).waitFor({ state: "visible" });
23 |
24 | await enableForecast();
25 |
26 | return await getChartScreenshot(page, testDir.outputPath("forecast-reference.png"));
27 | };
28 |
29 | const expectedScreenshot = await takeReferenceScreenshots();
30 |
31 | await page.goto(localUrl);
32 |
33 | await authenticate(page);
34 |
35 | await page.getByPlaceholder("Username").fill(username);
36 | await page.getByPlaceholder("Repo name").fill(repo1);
37 | await page.getByRole("button", { name: "Go!" }).click();
38 | await page.getByRole("button", { name: "Go!" }).waitFor({ state: "visible" });
39 |
40 | // Enable forecast
41 | await enableForecast();
42 |
43 | const screenshot = await getChartScreenshot(page, testDir.outputPath("forecast-actual.png"));
44 | await expect(
45 | page.getByRole("button", { name: "6 months forecast based on the last 4 months" }),
46 | ).toBeVisible();
47 |
48 | const comparator = getComparator("image/png");
49 | expect(comparator(expectedScreenshot, screenshot)).toBeNull();
50 |
51 | // Show forecast properties
52 | await page.getByRole("button", { name: "6 months forecast based on the last 4 months" }).click();
53 |
54 | expect(await page.getByTestId("backwardCount").inputValue()).toBe("4");
55 | expect(await page.getByTestId("forwardCount").inputValue()).toBe("6");
56 | expect(await page.getByTestId("pointCount").inputValue()).toBe("150");
57 |
58 | await page.getByRole("button", { name: "Cancel" }).click();
59 |
60 | // Disable forecast
61 | await page.getByTestId("CancelIcon").last().click();
62 |
63 | expect(page.getByRole("button", { name: "Do not show forecast" })).toBeVisible();
64 | const screenshotWithoutForecast = await getChartScreenshot(
65 | page,
66 | testDir.outputPath("without-forecast-actual.png"),
67 | );
68 |
69 | expect(comparator(screenshot, screenshotWithoutForecast)).not.toBeNull();
70 | });
71 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "startrack-js",
3 | "homepage": ".",
4 | "version": "3.2.1",
5 | "private": true,
6 | "dependencies": {
7 | "@emotion/react": "^11.13.3",
8 | "@emotion/styled": "^11.14.0",
9 | "@mui/icons-material": "^5.16.14",
10 | "@mui/lab": "^5.0.0-alpha.169",
11 | "@mui/material": "^5.16.14",
12 | "@mui/styles": "^5.15.7",
13 | "@mui/x-data-grid": "^7.14.0",
14 | "@testing-library/jest-dom": "^6.6.3",
15 | "@testing-library/react": "^15.0.7",
16 | "@testing-library/user-event": "^14.6.1",
17 | "@types/jest": "^29.5.14",
18 | "@types/node": "^20.12.8",
19 | "@types/react": "^18.3.12",
20 | "@types/react-dom": "^18.2.18",
21 | "axios": "^1.12.0",
22 | "client-zip": "^2.4.5",
23 | "formik": "^2.4.6",
24 | "moment": "^2.30.1",
25 | "plotly.js": "^3.0.1",
26 | "react": "^18.3.1",
27 | "react-dom": "^18.2.0",
28 | "react-ga": "^3.3.1",
29 | "react-github-btn": "^1.4.0",
30 | "react-plotly.js": "^2.6.0",
31 | "react-responsive": "^10.0.1",
32 | "react-router-dom": "^7.2.0",
33 | "react-scripts": "5.0.1",
34 | "typescript": "^5.7.2",
35 | "web-vitals": "^5.1.0",
36 | "yup": "^1.6.1"
37 | },
38 | "scripts": {
39 | "start": "react-scripts start",
40 | "build": "react-scripts build",
41 | "test": "react-scripts test",
42 | "eject": "react-scripts eject",
43 | "lint": "eslint --ext js,jsx,ts,tsx src/",
44 | "lint:fix": "eslint --ext js,jsx,ts,tsx --fix src/",
45 | "format": "prettier --write . --config ./.prettierrc",
46 | "format:check": "prettier --check . --config ./.prettierrc",
47 | "spellcheck": "cspell **/*.{ts,tsx}",
48 | "coverage": "yarn test -- --watchAll=false --coverage"
49 | },
50 | "jest": {
51 | "transformIgnorePatterns": [
52 | "node_modules/(?!axios|react-github-btn/.*)"
53 | ],
54 | "coveragePathIgnorePatterns": [
55 | "/src/index.tsx",
56 | "/src/reportWebVitals.ts"
57 | ]
58 | },
59 | "eslintConfig": {
60 | "extends": [
61 | "react-app",
62 | "react-app/jest"
63 | ]
64 | },
65 | "browserslist": {
66 | "production": [
67 | ">0.2%",
68 | "not dead",
69 | "not op_mini all"
70 | ],
71 | "development": [
72 | "last 1 chrome version",
73 | "last 1 firefox version",
74 | "last 1 safari version"
75 | ]
76 | },
77 | "devDependencies": {
78 | "@playwright/test": "^1.52.0",
79 | "@types/css-mediaquery": "^0.1.4",
80 | "@types/react-plotly.js": "^2.6.3",
81 | "@typescript-eslint/eslint-plugin": "^8.22.0",
82 | "@typescript-eslint/parser": "^8.31.1",
83 | "cspell": "^8.15.5",
84 | "csv-parse": "^6.1.0",
85 | "eslint": "^8.57.0",
86 | "eslint-config-prettier": "^9.1.0",
87 | "eslint-plugin-prettier": "^5.5.4",
88 | "eslint-plugin-react": "^7.37.3",
89 | "eslint-plugin-react-hooks": "^5.2.0",
90 | "prettier": "^3.5.3"
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/components/Forecast/Forecast.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, act } from "@testing-library/react";
2 | import Forecast from "./Forecast";
3 | import { ForecastInfo } from "./ForecastInfo";
4 | import { getLastCallArguments } from "../../utils/test";
5 |
6 | const mockForecastRow = jest.fn();
7 |
8 | jest.mock("./ForecastRow", () => ({
9 | __esModule: true,
10 | default: (props: unknown[]) => {
11 | mockForecastRow(props);
12 | return <>>;
13 | },
14 | }));
15 |
16 | interface mockForecastFormProps {
17 | onClose: (forecastProps: ForecastInfo | null) => void;
18 | forecastProps: ForecastInfo | null;
19 | }
20 |
21 | const mockForecastForm = jest.fn();
22 |
23 | jest.mock("./ForecastForm", () => ({
24 | __esModule: true,
25 | default: (props: mockForecastFormProps) => {
26 | mockForecastForm(props);
27 | return <>>;
28 | },
29 | }));
30 |
31 | describe(Forecast, () => {
32 | const onForecastInfoChange = jest.fn();
33 |
34 | const setup = (forecastInfo?: ForecastInfo) => {
35 | const info =
36 | forecastInfo ||
37 | new ForecastInfo({ unit: "weeks", count: 1 }, { unit: "months", count: 1 }, 10);
38 | render();
39 | };
40 |
41 | it("render with forecast info", () => {
42 | const forecastInfo = new ForecastInfo(
43 | { count: 1, unit: "weeks" },
44 | { count: 1, unit: "weeks" },
45 | 50,
46 | );
47 |
48 | setup(forecastInfo);
49 |
50 | expect(mockForecastRow).toHaveBeenCalledWith(expect.objectContaining({ info: forecastInfo }));
51 | expect(mockForecastForm).toHaveBeenCalledWith(
52 | expect.objectContaining({ initialValues: forecastInfo }),
53 | );
54 | });
55 |
56 | it("open forecast form", () => {
57 | setup();
58 |
59 | act(() => getLastCallArguments(mockForecastRow)[0].onClick());
60 |
61 | expect(mockForecastForm).toHaveBeenCalledWith(expect.objectContaining({ open: true }));
62 | });
63 |
64 | it("does not change call onForecastInfoChange if dialog closes with cancel", async () => {
65 | setup();
66 |
67 | await act(() => getLastCallArguments(mockForecastForm)[0].onClose(null));
68 |
69 | expect(onForecastInfoChange).not.toHaveBeenCalled();
70 | });
71 |
72 | it("change forecast info in form", async () => {
73 | setup();
74 |
75 | const forecastInfo = new ForecastInfo(
76 | { count: 1, unit: "weeks" },
77 | { count: 1, unit: "weeks" },
78 | 50,
79 | );
80 |
81 | await act(() => getLastCallArguments(mockForecastForm)[0].onClose(forecastInfo));
82 |
83 | expect(onForecastInfoChange).toHaveBeenCalledWith(forecastInfo);
84 | });
85 |
86 | it("clear forecast info in row", async () => {
87 | const forecastInfo = new ForecastInfo(
88 | { count: 1, unit: "weeks" },
89 | { count: 1, unit: "weeks" },
90 | 50,
91 | );
92 |
93 | setup(forecastInfo);
94 |
95 | await act(() => getLastCallArguments(mockForecastRow)[0].onDelete());
96 |
97 | expect(onForecastInfoChange).toHaveBeenCalledWith(null);
98 | });
99 | });
100 |
--------------------------------------------------------------------------------
/src/components/RepoStats/StatsGridLargeScreen.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from "@testing-library/react";
2 | import StatsGridLargeScreen, { RenderRepoChip } from "./StatsGridLargeScreen";
3 |
4 | const mockRepoChip = jest.fn();
5 |
6 | jest.mock("./RepoChip", () => ({
7 | __esModule: true,
8 | default: (props: unknown[]) => {
9 | mockRepoChip(props);
10 | return <>RepoChip>;
11 | },
12 | }));
13 |
14 | const mockDataGrid = jest.fn();
15 | jest.mock("@mui/x-data-grid", () => ({
16 | __esModule: true,
17 | DataGrid: (props: unknown[]) => {
18 | mockDataGrid(props);
19 | return ;
20 | },
21 | }));
22 |
23 | describe(RenderRepoChip, () => {
24 | it("render the chip correctly", () => {
25 | const row = {
26 | user: "user",
27 | repo: "repo",
28 | color: "red",
29 | };
30 |
31 | render();
32 |
33 | expect(mockRepoChip).toHaveBeenCalledWith({ user: row.user, repo: row.repo, color: row.color });
34 | });
35 | });
36 |
37 | describe(StatsGridLargeScreen, () => {
38 | const statInfos = [
39 | {
40 | username: "user1",
41 | repo: "repo1",
42 | color: { hsl: "color1hsl", hex: "color1hex" },
43 | stats: {
44 | "Stat 1": 1,
45 | "Stat 2": "1",
46 | },
47 | },
48 | {
49 | username: "user2",
50 | repo: "repo2",
51 | color: { hsl: "color2hsl", hex: "color2hex" },
52 | stats: {
53 | "Stat 1": 2,
54 | "Stat 2": "2",
55 | },
56 | },
57 | ];
58 |
59 | it("render the stats datagrid", () => {
60 | render();
61 |
62 | const expectedColumns = [
63 | {
64 | field: "repo",
65 | headerName: "",
66 | sortable: false,
67 | width: 200,
68 | renderCell: RenderRepoChip,
69 | },
70 | {
71 | field: "stat-1",
72 | headerName: "Stat 1",
73 | sortable: true,
74 | width: 130,
75 | type: "number",
76 | align: "center",
77 | },
78 | {
79 | field: "stat-2",
80 | headerName: "Stat 2",
81 | sortable: true,
82 | width: 130,
83 | type: "string",
84 | align: "center",
85 | },
86 | ];
87 |
88 | const expectedRows = [
89 | {
90 | id: 0,
91 | user: statInfos[0].username,
92 | repo: statInfos[0].repo,
93 | color: statInfos[0].color.hex,
94 | "stat-1": statInfos[0].stats["Stat 1"],
95 | "stat-2": statInfos[0].stats["Stat 2"],
96 | },
97 | {
98 | id: 1,
99 | user: statInfos[1].username,
100 | repo: statInfos[1].repo,
101 | color: statInfos[1].color.hex,
102 | "stat-1": statInfos[1].stats["Stat 1"],
103 | "stat-2": statInfos[1].stats["Stat 2"],
104 | },
105 | ];
106 |
107 | expect(mockDataGrid).toHaveBeenCalledWith({
108 | columns: expectedColumns,
109 | rows: expectedRows,
110 | autoHeight: true,
111 | pageSizeOptions: [],
112 | sx: {
113 | "& .MuiDataGrid-columnHeaderTitle": {
114 | whiteSpace: "break-spaces",
115 | lineHeight: "normal",
116 | textAlign: "center",
117 | },
118 | },
119 | });
120 | });
121 | });
122 |
--------------------------------------------------------------------------------
/src/components/GitHubAuth/LoggedIn.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, fireEvent } from "@testing-library/react";
2 | import LoggedIn from "./LoggedIn";
3 | import { createMatchMedia, expectMuiMenuVisibility } from "../../utils/test";
4 | import * as GitHubUtils from "../../utils/GitHubUtils";
5 |
6 | describe(LoggedIn, () => {
7 | const accessToken = "access-token";
8 | const accessTokenDisplayText = "-token";
9 |
10 | const handleLogOut = jest.fn();
11 |
12 | const setup = () => {
13 | render();
14 | };
15 |
16 | it("render on small screen", () => {
17 | window.matchMedia = createMatchMedia(200);
18 |
19 | setup();
20 |
21 | const button = screen.getByRole("button");
22 |
23 | expect(button.textContent).toHaveLength(0);
24 |
25 | const menu = screen.getByTestId("menu-navbar");
26 | expectMuiMenuVisibility(menu, false);
27 |
28 | fireEvent.click(button);
29 |
30 | expectMuiMenuVisibility(menu, true);
31 |
32 | const menuItems = screen.getAllByRole("menuitem");
33 | expect(menuItems).toHaveLength(3);
34 |
35 | expect(menuItems[0].textContent).toEqual(accessTokenDisplayText);
36 | expect(menuItems[0]).toHaveAttribute("aria-disabled", "true");
37 | expect(menuItems[1].textContent).toEqual("Stored in session storage");
38 | expect(menuItems[1]).toHaveAttribute("aria-disabled", "true");
39 | expect(menuItems[2].textContent).toEqual("Log out");
40 | expect(menuItems[2]).not.toHaveAttribute("aria-disabled");
41 |
42 | fireEvent.click(menuItems[2]);
43 |
44 | expect(handleLogOut).toHaveBeenCalled();
45 |
46 | expectMuiMenuVisibility(menu, false);
47 | });
48 |
49 | it("render on large screen", () => {
50 | const mockRemoveAccessToken = jest.spyOn(GitHubUtils, "removeAccessToken");
51 |
52 | window.matchMedia = createMatchMedia(800);
53 |
54 | setup();
55 |
56 | const button = screen.getByRole("button", { name: accessTokenDisplayText });
57 |
58 | const menu = screen.getByTestId("menu-navbar");
59 | expectMuiMenuVisibility(menu, false);
60 |
61 | fireEvent.click(button);
62 |
63 | expectMuiMenuVisibility(menu, true);
64 |
65 | const menuItems = screen.getAllByRole("menuitem");
66 | expect(menuItems).toHaveLength(2);
67 |
68 | expect(menuItems[0].textContent).toEqual("Stored in session storage");
69 | expect(menuItems[0]).toHaveAttribute("aria-disabled", "true");
70 | expect(menuItems[1].textContent).toEqual("Log out");
71 | expect(menuItems[1]).not.toHaveAttribute("aria-disabled");
72 |
73 | fireEvent.click(menuItems[1]);
74 |
75 | expect(handleLogOut).toHaveBeenCalled();
76 | expect(mockRemoveAccessToken).toHaveBeenCalled();
77 |
78 | expectMuiMenuVisibility(menu, false);
79 | });
80 |
81 | it.each([[GitHubUtils.StorageType.LocalStorage], [GitHubUtils.StorageType.SessionStorage]])(
82 | "display the correct storage type",
83 | (storageType) => {
84 | setup();
85 |
86 | jest.spyOn(GitHubUtils, "getStorageType").mockReturnValueOnce(storageType);
87 |
88 | const button = screen.getByRole("button", { name: accessTokenDisplayText });
89 | fireEvent.click(button);
90 |
91 | const menuItems = screen.getAllByRole("menuitem");
92 |
93 | expect(
94 | menuItems.some((element) => element.textContent === `Stored in ${storageType}`),
95 | ).toBeTruthy();
96 | },
97 | );
98 | });
99 |
--------------------------------------------------------------------------------
/src/utils/StargazerLoader.test.ts:
--------------------------------------------------------------------------------
1 | import * as stargazerLoader from "./StargazerLoader";
2 | import * as gitHubUtils from "./GitHubUtils";
3 | import * as stargazerStats from "./StargazerStats";
4 | import StarData from "./StarData";
5 |
6 | describe(stargazerLoader.makeColor, () => {
7 | it("generate a color", () => {
8 | expect(stargazerLoader.makeColor()).toStrictEqual({
9 | hsl: `hsl(${0},50%,50%)`,
10 | hex: "#bf4040",
11 | });
12 | expect(stargazerLoader.makeColor()).toStrictEqual({
13 | hsl: `hsl(${137.508},50%,50%)`,
14 | hex: "#40bf65",
15 | });
16 | });
17 | });
18 |
19 | describe(stargazerLoader.loadStargazers, () => {
20 | const stargazerData: StarData = {
21 | timestamps: ["ts1", "ts2"],
22 | starCounts: [1, 2],
23 | };
24 |
25 | const forecastData: StarData = {
26 | timestamps: ["ts3"],
27 | starCounts: [2],
28 | };
29 |
30 | const user = "user";
31 | const repo = "repo";
32 | const handleProgressCallback = jest.fn();
33 | const shouldStop = jest.fn();
34 |
35 | const setup = () => {
36 | return {
37 | loadStargazersMock: jest
38 | .spyOn(gitHubUtils, "loadStargazers")
39 | .mockReturnValue(Promise.resolve(stargazerData)),
40 | makeColorMock: jest
41 | .spyOn(stargazerLoader, "makeColor")
42 | .mockReturnValue({ hsl: "hslColor", hex: "hexColor" }),
43 | calcForecastMock: jest.spyOn(stargazerStats, "calcForecast").mockReturnValue(forecastData),
44 | };
45 | };
46 |
47 | it("returns null if no star data", async () => {
48 | jest.spyOn(gitHubUtils, "loadStargazers").mockReturnValue(Promise.resolve(null));
49 | const result = await stargazerLoader.loadStargazers(
50 | user,
51 | repo,
52 | handleProgressCallback,
53 | shouldStop,
54 | );
55 | expect(result).toBeNull();
56 | });
57 |
58 | it("loads star data without forecast", async () => {
59 | const { loadStargazersMock, makeColorMock, calcForecastMock } = setup();
60 |
61 | const result = await stargazerLoader.loadStargazers(
62 | user,
63 | repo,
64 | handleProgressCallback,
65 | shouldStop,
66 | );
67 | expect(result).toStrictEqual({
68 | username: user,
69 | repo: repo,
70 | color: { hsl: "hslColor", hex: "hexColor" },
71 | stargazerData: stargazerData,
72 | forecast: undefined,
73 | });
74 |
75 | expect(loadStargazersMock).toHaveBeenCalledWith(user, repo, handleProgressCallback, shouldStop);
76 | expect(makeColorMock).toHaveBeenCalled();
77 | expect(calcForecastMock).not.toHaveBeenCalled();
78 | });
79 |
80 | it("loads star data with forecast", async () => {
81 | const { loadStargazersMock, makeColorMock, calcForecastMock } = setup();
82 |
83 | const forecastProps = {
84 | daysBackwards: 1,
85 | daysForward: 1,
86 | numValues: 1,
87 | };
88 |
89 | const result = await stargazerLoader.loadStargazers(
90 | user,
91 | repo,
92 | handleProgressCallback,
93 | shouldStop,
94 | forecastProps,
95 | );
96 | expect(result).toStrictEqual({
97 | username: user,
98 | repo: repo,
99 | color: { hsl: "hslColor", hex: "hexColor" },
100 | stargazerData: stargazerData,
101 | forecast: forecastData,
102 | });
103 |
104 | expect(loadStargazersMock).toHaveBeenCalledWith(user, repo, handleProgressCallback, shouldStop);
105 | expect(makeColorMock).toHaveBeenCalled();
106 | expect(calcForecastMock).toHaveBeenCalledWith(stargazerData, forecastProps);
107 | });
108 | });
109 |
--------------------------------------------------------------------------------
/src/components/RepoStats/DownloadData.test.tsx:
--------------------------------------------------------------------------------
1 | import { screen, render, fireEvent } from "@testing-library/react";
2 | import DownloadData from "./DownloadData";
3 | import RepoInfo from "../../utils/RepoInfo";
4 |
5 | const mockDownloadFile = jest.fn();
6 | const mockZipAndDownloadFiles = jest.fn();
7 |
8 | jest.mock("../../utils/FileUtils", () => ({
9 | downloadFile: (fileName: string, fileContent: string, contentType: string) => {
10 | mockDownloadFile(fileName, fileContent, contentType);
11 | },
12 | zipAndDownloadFiles: (files: unknown[], zipFileName: string) => {
13 | mockZipAndDownloadFiles(files, zipFileName);
14 | },
15 | }));
16 |
17 | const mockExportRepoInfosToJson = jest.fn();
18 | const mockExportRepoInfosToCsv = jest.fn();
19 |
20 | jest.mock("../../utils/RepoInfoExporter", () => ({
21 | exportRepoInfosToJson: (repoInfos: Array) => {
22 | mockExportRepoInfosToJson(repoInfos);
23 | return { key1: "value1", key2: "value2" };
24 | },
25 | exportRepoInfosToCsv: (repoInfos: Array) => {
26 | return mockExportRepoInfosToCsv(repoInfos);
27 | },
28 | }));
29 |
30 | const repoInfos: Array = [
31 | {
32 | username: "user1",
33 | repo: "repo1",
34 | color: {
35 | hex: "hexColor1",
36 | hsl: "stlColor1",
37 | },
38 | stargazerData: {
39 | timestamps: ["ts1", "ts2"],
40 | starCounts: [1, 2],
41 | },
42 | },
43 | {
44 | username: "user2",
45 | repo: "repo2",
46 | color: {
47 | hex: "hexColor2",
48 | hsl: "stlColor2",
49 | },
50 | stargazerData: {
51 | timestamps: ["ts1", "ts2"],
52 | starCounts: [1, 2],
53 | },
54 | },
55 | ];
56 |
57 | describe(DownloadData, () => {
58 | it("download json", () => {
59 | render();
60 |
61 | const button = screen.getByRole("button");
62 |
63 | fireEvent.click(button);
64 |
65 | expect(mockExportRepoInfosToJson).toHaveBeenCalledWith(repoInfos);
66 | expect(mockExportRepoInfosToCsv).not.toHaveBeenCalled();
67 |
68 | expect(mockDownloadFile).toHaveBeenCalledWith(
69 | "StargazerData.json",
70 | // eslint-disable-next-line quotes
71 | '{\n "key1": "value1",\n "key2": "value2"\n}',
72 | "application/json",
73 | );
74 | expect(mockZipAndDownloadFiles).not.toHaveBeenCalled();
75 | });
76 |
77 | it("download single csv", () => {
78 | mockExportRepoInfosToCsv.mockImplementationOnce(() => {
79 | return [
80 | {
81 | name: "file1.csv",
82 | content: "content",
83 | },
84 | ];
85 | });
86 |
87 | render();
88 |
89 | const csv = screen.getByLabelText("CSV") as HTMLInputElement;
90 | fireEvent.click(csv);
91 |
92 | const button = screen.getByRole("button");
93 | fireEvent.click(button);
94 |
95 | expect(mockExportRepoInfosToJson).not.toHaveBeenCalled();
96 | expect(mockExportRepoInfosToCsv).toHaveBeenCalledWith(repoInfos);
97 |
98 | expect(mockDownloadFile).toHaveBeenCalledWith("file1.csv", "content", "text/csv");
99 | expect(mockZipAndDownloadFiles).not.toHaveBeenCalled();
100 | });
101 |
102 | it("download multiple csv", () => {
103 | const files = [
104 | {
105 | name: "file1.csv",
106 | content: "content",
107 | },
108 | {
109 | name: "file2.csv",
110 | content: "content",
111 | },
112 | ];
113 |
114 | mockExportRepoInfosToCsv.mockImplementationOnce(() => files);
115 |
116 | render();
117 |
118 | const csv = screen.getByLabelText("CSV") as HTMLInputElement;
119 | fireEvent.click(csv);
120 |
121 | const button = screen.getByRole("button");
122 | fireEvent.click(button);
123 |
124 | expect(mockExportRepoInfosToJson).not.toHaveBeenCalled();
125 | expect(mockExportRepoInfosToCsv).toHaveBeenCalledWith(repoInfos);
126 |
127 | expect(mockDownloadFile).not.toHaveBeenCalled();
128 | expect(mockZipAndDownloadFiles).toHaveBeenCalledWith(files, "StargazerData.zip");
129 | });
130 | });
131 |
--------------------------------------------------------------------------------
/src/components/RepoDetailsInput/RepoDetailsInputDesktop.tsx:
--------------------------------------------------------------------------------
1 | import React, { KeyboardEvent } from "react";
2 | import { TextField, TextFieldProps, Box, BoxProps, Stack } from "@mui/material";
3 | import LoadingButton from "./LoadingButton";
4 | import RepoDetailsInputProps from "./RepoDetailsInputProps";
5 | import { parseGitHubUrl } from "../../utils/GitHubUtils";
6 | import StarTrackTooltip from "../../shared/Tooltip";
7 |
8 | interface RepoInputLabelProps extends BoxProps {
9 | prepend?: boolean;
10 | }
11 |
12 | const componentMaxWidth = 800;
13 | const goButtonWidth = 155;
14 |
15 | const RepoInputLabel = React.forwardRef(
16 | function RepoInputLabel(props, ref) {
17 | const { prepend, children, ...otherProps } = props;
18 |
19 | return (
20 | theme.custom.additionalBackgroundColor,
25 | borderColor: (theme) => `${theme.custom.borderColor} !important`,
26 | fontSize: (theme) => theme.typography.fontSize,
27 | border: "1px solid",
28 | display: "flex",
29 | alignItems: "center",
30 | paddingRight: 2,
31 | paddingLeft: 2,
32 | ...(prepend && {
33 | borderTopLeftRadius: 4,
34 | borderBottomLeftRadius: 4,
35 | }),
36 | }}
37 | {...otherProps}
38 | >
39 | {children}
40 |
41 | );
42 | },
43 | );
44 |
45 | function RepoInputTextField(props: TextFieldProps) {
46 | return (
47 |
57 | );
58 | }
59 |
60 | export default function RepoDetailsInputDesktop({
61 | loading,
62 | onGoClick,
63 | onCancelClick,
64 | }: RepoDetailsInputProps) {
65 | const [username, setUsername] = React.useState("");
66 | const [repo, setRepo] = React.useState("");
67 |
68 | const repoInputRef = React.useRef();
69 |
70 | const handleGoClick = () => {
71 | onGoClick(username.trim(), repo.trim());
72 | };
73 |
74 | const handleCancelClick = () => {
75 | onCancelClick();
76 | };
77 |
78 | const handlePaste = (event: React.ClipboardEvent) => {
79 | const url = parseGitHubUrl(event.clipboardData.getData("Text"));
80 | if (!url) {
81 | return;
82 | }
83 | event.preventDefault();
84 | setUsername(url[0]);
85 | setRepo(url[1]);
86 | };
87 |
88 | const handleKeyDown = (event: KeyboardEvent) => {
89 | if (event.key === "Enter") {
90 | onGoClick(username.trim(), repo.trim());
91 | event.preventDefault();
92 | }
93 |
94 | if (event.key === "/") {
95 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions
96 | repoInputRef.current && repoInputRef.current.focus();
97 | event.preventDefault();
98 | }
99 | };
100 |
101 | return (
102 |
103 |
104 | Repo details
105 |
106 | {
110 | setUsername(e.target.value);
111 | }}
112 | onPaste={handlePaste}
113 | onKeyDown={handleKeyDown}
114 | />
115 | /
116 | {
120 | setRepo(e.target.value);
121 | }}
122 | onPaste={handlePaste}
123 | onKeyDown={handleKeyDown}
124 | inputRef={repoInputRef}
125 | />
126 |
132 |
133 | );
134 | }
135 |
--------------------------------------------------------------------------------
/src/utils/RepoInfoExporter.test.ts:
--------------------------------------------------------------------------------
1 | import RepoInfo from "./RepoInfo";
2 | import * as exporter from "./RepoInfoExporter";
3 |
4 | const repoInfos: Array = [
5 | {
6 | username: "user1",
7 | repo: "repo1",
8 | color: {
9 | hex: "hexColor1",
10 | hsl: "stlColor1",
11 | },
12 | stargazerData: {
13 | timestamps: ["ts1", "ts2"],
14 | starCounts: [1, 2],
15 | },
16 | },
17 | {
18 | username: "user2",
19 | repo: "repo2",
20 | color: {
21 | hex: "hexColor2",
22 | hsl: "stlColor2",
23 | },
24 | stargazerData: {
25 | timestamps: ["ts1", "ts2"],
26 | starCounts: [1, 2],
27 | },
28 | },
29 | ];
30 |
31 | const forecast = {
32 | timestamps: ["ts3", "ts4"],
33 | starCounts: [3, 4],
34 | };
35 |
36 | const repoInfosWithForecast = () => {
37 | return repoInfos.map((repoInfo) => {
38 | return {
39 | ...repoInfo,
40 | forecast: forecast,
41 | };
42 | });
43 | };
44 |
45 | describe(exporter.exportRepoInfosToJson, () => {
46 | const expectedRepoInfoAsJson = [
47 | {
48 | username: "user1",
49 | repo: "repo1",
50 | stargazerData: [
51 | { starCount: 1, starredAt: "ts1" },
52 | { starCount: 2, starredAt: "ts2" },
53 | ],
54 | },
55 | {
56 | username: "user2",
57 | repo: "repo2",
58 | stargazerData: [
59 | { starCount: 1, starredAt: "ts1" },
60 | { starCount: 2, starredAt: "ts2" },
61 | ],
62 | },
63 | ];
64 |
65 | const expectedForecastAsJson = [
66 | { starCount: 3, starredAt: "ts3" },
67 | { starCount: 4, starredAt: "ts4" },
68 | ];
69 |
70 | const expectedRepoInfoWithForecastAsJson = () => {
71 | return expectedRepoInfoAsJson.map((repoInfoAsJson) => {
72 | return {
73 | ...repoInfoAsJson,
74 | forecast: expectedForecastAsJson,
75 | };
76 | });
77 | };
78 |
79 | it("export single info without forecast", () => {
80 | const result = exporter.exportRepoInfosToJson(repoInfos.slice(0, 1));
81 |
82 | expect(result).toEqual(expectedRepoInfoAsJson[0]);
83 | });
84 |
85 | it("export multiple infos without forecast", () => {
86 | const result = exporter.exportRepoInfosToJson(repoInfos);
87 |
88 | expect(result).toEqual(expectedRepoInfoAsJson);
89 | });
90 |
91 | it("export single info with forecast", () => {
92 | const result = exporter.exportRepoInfosToJson(repoInfosWithForecast().slice(0, 1));
93 |
94 | expect(result).toEqual(expectedRepoInfoWithForecastAsJson()[0]);
95 | });
96 |
97 | it("export multiple infos with forecast", () => {
98 | const result = exporter.exportRepoInfosToJson(repoInfosWithForecast());
99 |
100 | expect(result).toEqual(expectedRepoInfoWithForecastAsJson());
101 | });
102 | });
103 |
104 | describe(exporter.exportRepoInfosToCsv, () => {
105 | const expectedRepoInfoAsCsv = [
106 | {
107 | name: "user1-repo1.csv",
108 | content: "Star Count,Starred At\n1,ts1\n2,ts2",
109 | },
110 | {
111 | name: "user2-repo2.csv",
112 | content: "Star Count,Starred At\n1,ts1\n2,ts2",
113 | },
114 | ];
115 |
116 | const expectedForecastAsCsv = "Star Count,Starred At\n3,ts3\n4,ts4";
117 |
118 | const expectedRepoInfoWithForecastAsCsv = [
119 | {
120 | name: "user1-repo1.csv",
121 | content: "Star Count,Starred At\n1,ts1\n2,ts2",
122 | },
123 | {
124 | name: "user1-repo1-forecast.csv",
125 | content: expectedForecastAsCsv,
126 | },
127 | {
128 | name: "user2-repo2.csv",
129 | content: "Star Count,Starred At\n1,ts1\n2,ts2",
130 | },
131 | {
132 | name: "user2-repo2-forecast.csv",
133 | content: expectedForecastAsCsv,
134 | },
135 | ];
136 |
137 | it("export infos without forecast", () => {
138 | const result = exporter.exportRepoInfosToCsv(repoInfos);
139 |
140 | expect(result).toEqual(expectedRepoInfoAsCsv);
141 | });
142 |
143 | it("export infos with forecast", () => {
144 | const result = exporter.exportRepoInfosToCsv(repoInfosWithForecast());
145 |
146 | expect(result).toEqual(expectedRepoInfoWithForecastAsCsv);
147 | });
148 | });
149 |
--------------------------------------------------------------------------------
/src/routes/Preload/Preload.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, act, screen, fireEvent } from "@testing-library/react";
2 | import { parseUrlParams, Preload } from "./Preload";
3 | import { getLastCallArguments } from "../../utils/test";
4 |
5 | describe("parseUrlParams", () => {
6 | it("filters only the relevant QS params", () => {
7 | expect(parseUrlParams("r=u1,r1&rr=u2,r2&r=u3,r3&x=u4,r4")).toEqual([
8 | { username: "u1", repo: "r1" },
9 | { username: "u3", repo: "r3" },
10 | ]);
11 | });
12 |
13 | it("ignores wrong QS params", () => {
14 | expect(parseUrlParams("r=u1,r1&r&r=u2,r3,x3&r=foo&r=foo+bar")).toEqual([
15 | { username: "u1", repo: "r1" },
16 | ]);
17 | });
18 |
19 | it("removes duplicates", () => {
20 | expect(parseUrlParams("r=u1,r1&r&r=u2,r2&r=u1,r1&r=u3,r3&r=u3,r3&r=u1,r1")).toEqual([
21 | { username: "u1", repo: "r1" },
22 | { username: "u2", repo: "r2" },
23 | { username: "u3", repo: "r3" },
24 | ]);
25 | });
26 | });
27 |
28 | const mockRepoLoader = jest.fn();
29 |
30 | jest.mock("./RepoLoader", () => ({
31 | __esModule: true,
32 | default: (props: unknown[]) => {
33 | mockRepoLoader(props);
34 | return <>>;
35 | },
36 | }));
37 |
38 | const mockNavigate = jest.fn();
39 | const mockLocation = jest.fn();
40 |
41 | jest.mock("react-router", () => ({
42 | ...jest.requireActual("react-router"),
43 | useNavigate: () => mockNavigate,
44 | useLocation: () => {
45 | return mockLocation() ?? { search: "" };
46 | },
47 | }));
48 |
49 | describe(Preload, () => {
50 | const username1 = "user1";
51 | const repo1 = "repo1";
52 | const repoInfo1 = "repoInfo1";
53 | const username2 = "user2";
54 | const repo2 = "repo2";
55 | const repoInfo2 = "repoInfo2";
56 | const username3 = "user3";
57 | const repo3 = "repo3";
58 | const repoInfo3 = "repoInfo3";
59 |
60 | it("loads repos data", async () => {
61 | mockLocation.mockReturnValue({ search: `?r=${username1},${repo1}&r=${username2},${repo2}` });
62 |
63 | render();
64 |
65 | expect(screen.getByText("Loading repo data...")).toBeInTheDocument();
66 | expect(screen.getByText(`${username1} / ${repo1}`)).toBeInTheDocument();
67 |
68 | await act(() => getLastCallArguments(mockRepoLoader)[0].onLoadDone(repoInfo1));
69 |
70 | expect(screen.getByText("Loading repo data...")).toBeInTheDocument();
71 | expect(screen.getByText(`${username2} / ${repo2}`)).toBeInTheDocument();
72 |
73 | await act(() => getLastCallArguments(mockRepoLoader)[0].onLoadDone(repoInfo2));
74 |
75 | expect(mockNavigate).toHaveBeenCalledWith("/", { state: [repoInfo1, repoInfo2] });
76 | });
77 |
78 | it("handles load errors", async () => {
79 | const errorMessage1 = "some error occurred1";
80 | const errorMessage2 = "some error occurred2";
81 |
82 | mockLocation.mockReturnValue({
83 | search: `?r=${username1},${repo1}&r=${username2},${repo2}&r=${username3},${repo3}`,
84 | });
85 |
86 | render();
87 |
88 | expect(screen.getByText("Loading repo data...")).toBeInTheDocument();
89 |
90 | expect(screen.getByText(`${username1} / ${repo1}`)).toBeInTheDocument();
91 |
92 | await act(() => getLastCallArguments(mockRepoLoader)[0].onLoadError(errorMessage1));
93 |
94 | expect(screen.getByText(`${username2} / ${repo2}`)).toBeInTheDocument();
95 |
96 | await act(() => getLastCallArguments(mockRepoLoader)[0].onLoadError(errorMessage2));
97 |
98 | await act(() => getLastCallArguments(mockRepoLoader)[0].onLoadDone(repoInfo3));
99 |
100 | expect(screen.getByText("Error loading repo data")).toBeInTheDocument();
101 |
102 | const errorElement1 = screen.getByText(errorMessage1, { exact: false });
103 | expect(errorElement1?.textContent).toEqual(
104 | `Error loading ${username1}/${repo1}: ${errorMessage1}`,
105 | );
106 |
107 | const errorElement2 = screen.getByText(errorMessage2, { exact: false });
108 | expect(errorElement2?.textContent).toEqual(
109 | `Error loading ${username2}/${repo2}: ${errorMessage2}`,
110 | );
111 |
112 | expect(mockNavigate).not.toHaveBeenCalled();
113 |
114 | const continueButton = screen.getByRole("button");
115 | fireEvent.click(continueButton);
116 |
117 | expect(mockNavigate).toHaveBeenCalledWith("/", { state: [repoInfo3] });
118 | });
119 | });
120 |
--------------------------------------------------------------------------------
/src/routes/Preload/Preload.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useNavigate, useLocation } from "react-router";
3 | import { ProgressProvider } from "../../shared/ProgressContext";
4 | import RepoLoader from "./RepoLoader";
5 | import { RepoMetadata } from "./PreloadTypes";
6 | import RepoInfo from "../../utils/RepoInfo";
7 | import { useTheme } from "@mui/material/styles";
8 | import { Alert, Button, Container, Grid, Stack, Typography } from "@mui/material";
9 | import { grey, red } from "@mui/material/colors";
10 |
11 | export const parseUrlParams = (urlParams: string): RepoMetadata[] => {
12 | const reposMetadata = new URLSearchParams(urlParams)
13 | .getAll("r")
14 | .map((value) => {
15 | const userAndRepoName = value && value.split(",");
16 | return userAndRepoName.length === 2
17 | ? { username: userAndRepoName[0], repo: userAndRepoName[1] }
18 | : null;
19 | })
20 | .filter((v) => v) as RepoMetadata[];
21 |
22 | // remove duplicates
23 | return [...new Set(reposMetadata.map((val) => JSON.stringify(val)))].map((val) =>
24 | JSON.parse(val),
25 | );
26 | };
27 |
28 | type RepoLoadError = {
29 | repoMetadata: RepoMetadata;
30 | error: string;
31 | };
32 |
33 | export function Preload() {
34 | const theme = useTheme();
35 | const navigate = useNavigate();
36 |
37 | const [currentlyLoadingIndex, setCurrentlyLoadingIndex] = React.useState(0);
38 | const [repoDataLoaded, setRepoDataLoaded] = React.useState([]);
39 | const [repoLoadErrors, setRepoLoadErrors] = React.useState([]);
40 |
41 | React.useEffect(() => {
42 | if (currentlyLoadingIndex >= dataToLoad.length && repoLoadErrors.length === 0) {
43 | navigate("/", { state: repoDataLoaded });
44 | }
45 | }, [currentlyLoadingIndex]);
46 |
47 | const { search } = useLocation();
48 | const dataToLoad = parseUrlParams(search);
49 |
50 | const getSubTitle = () => {
51 | return currentlyLoadingIndex < dataToLoad.length
52 | ? `${dataToLoad[currentlyLoadingIndex].username} / ${dataToLoad[currentlyLoadingIndex].repo}`
53 | : repoLoadErrors.length > 0
54 | ? "Error loading repo data"
55 | : "Done!";
56 | };
57 |
58 | const handleLoadDone = (repoInfo: RepoInfo | null) => {
59 | if (repoInfo !== null) {
60 | setRepoDataLoaded([...repoDataLoaded, repoInfo]);
61 | }
62 | setCurrentlyLoadingIndex(currentlyLoadingIndex + 1);
63 | };
64 |
65 | const handleLoadError = (error: string) => {
66 | setRepoLoadErrors([
67 | ...repoLoadErrors,
68 | { repoMetadata: dataToLoad[currentlyLoadingIndex], error: error },
69 | ]);
70 | setCurrentlyLoadingIndex(currentlyLoadingIndex + 1);
71 | };
72 |
73 | const continueButtonClick = () => {
74 | navigate("/", { state: repoDataLoaded });
75 | };
76 |
77 | return (
78 |
86 |
99 | Loading repo data...
100 | {getSubTitle()}
101 |
102 |
109 |
110 | {repoLoadErrors.length > 0 && (
111 | <>
112 |
117 | {repoLoadErrors.map((repoLoadError, i) => {
118 | return (
119 |
120 | Error loading {repoLoadError.repoMetadata.username}/
121 | {repoLoadError.repoMetadata.repo}: {repoLoadError.error}
122 |
123 | );
124 | })}
125 |
126 |
127 |
130 |
131 | >
132 | )}
133 |
134 |
135 | );
136 | }
137 |
--------------------------------------------------------------------------------
/src/components/GitHubAuth/GitHubAuthForm.test.tsx:
--------------------------------------------------------------------------------
1 | import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2 | import GitHubAuthForm from "./GitHubAuthForm";
3 | import * as GitHubUtils from "../../utils/GitHubUtils";
4 |
5 | describe(GitHubAuthForm, () => {
6 | const onClose = jest.fn();
7 | const accessTokenValue = "access_token";
8 |
9 | const setup = (isTokenValid: boolean) => {
10 | const validateAndStoreAccessTokenMock = jest
11 | .spyOn(GitHubUtils, "validateAndStoreAccessToken")
12 | .mockReturnValue(Promise.resolve(isTokenValid));
13 |
14 | render();
15 |
16 | const textBox = screen.getByTestId("access-token-input").firstChild as HTMLInputElement;
17 | const loginBtn = screen.getByRole("button", { name: "Login" });
18 | const closeBtn = screen.getByRole("button", { name: "Close" });
19 | const checkBox = screen.getByRole("checkbox");
20 |
21 | return {
22 | textBox: textBox,
23 | loginBtn: loginBtn,
24 | closeBtn: closeBtn,
25 | checkBox: checkBox,
26 | validateAndStoreAccessTokenMock: validateAndStoreAccessTokenMock,
27 | };
28 | };
29 |
30 | it("Handles valid access token", async () => {
31 | const { textBox, loginBtn, validateAndStoreAccessTokenMock } = setup(true);
32 |
33 | fireEvent.change(textBox, { target: { value: accessTokenValue } });
34 | fireEvent.click(loginBtn);
35 |
36 | await waitFor(() => {
37 | expect(validateAndStoreAccessTokenMock).toHaveBeenCalledWith(
38 | accessTokenValue,
39 | GitHubUtils.StorageType.SessionStorage,
40 | );
41 | expect(onClose).toHaveBeenCalledWith(accessTokenValue);
42 | expect(textBox.value).toBe("");
43 | });
44 | });
45 |
46 | it("Handles valid access token and save in local storage", async () => {
47 | const { textBox, loginBtn, checkBox, validateAndStoreAccessTokenMock } = setup(true);
48 |
49 | fireEvent.change(textBox, { target: { value: accessTokenValue } });
50 | fireEvent.click(checkBox);
51 | fireEvent.click(loginBtn);
52 |
53 | await waitFor(() => {
54 | expect(validateAndStoreAccessTokenMock).toHaveBeenCalledWith(
55 | accessTokenValue,
56 | GitHubUtils.StorageType.LocalStorage,
57 | );
58 | expect(onClose).toHaveBeenCalledWith(accessTokenValue);
59 | expect(textBox.value).toBe("");
60 | });
61 | });
62 |
63 | it("Cleanup when close", async () => {
64 | const { textBox, closeBtn, validateAndStoreAccessTokenMock } = setup(true);
65 |
66 | fireEvent.change(textBox, { target: { value: accessTokenValue } });
67 | fireEvent.click(closeBtn);
68 |
69 | await waitFor(() => {
70 | expect(validateAndStoreAccessTokenMock).not.toHaveBeenCalled();
71 | expect(onClose).toHaveBeenCalledWith(null);
72 | expect(textBox.value).toBe("");
73 | });
74 | });
75 |
76 | it("Handles missing token", async () => {
77 | const { loginBtn, validateAndStoreAccessTokenMock } = setup(true);
78 |
79 | fireEvent.click(loginBtn);
80 |
81 | await waitFor(() => {
82 | expect(screen.getByText("Access token is empty or invalid.")).toBeInTheDocument();
83 | expect(onClose).not.toHaveBeenCalled();
84 | expect(validateAndStoreAccessTokenMock).not.toHaveBeenCalled();
85 | });
86 | });
87 |
88 | it("Handles invalid token", async () => {
89 | const { textBox, loginBtn, validateAndStoreAccessTokenMock } = setup(false);
90 |
91 | fireEvent.change(textBox, { target: { value: accessTokenValue } });
92 | fireEvent.click(loginBtn);
93 |
94 | await waitFor(() => {
95 | expect(validateAndStoreAccessTokenMock).toHaveBeenCalledWith(
96 | accessTokenValue,
97 | GitHubUtils.StorageType.SessionStorage,
98 | );
99 | expect(screen.getByText("Access token is empty or invalid.")).toBeInTheDocument();
100 | expect(onClose).not.toHaveBeenCalled();
101 | });
102 | });
103 |
104 | it("Cleanup when close after invalid token", async () => {
105 | const { textBox, loginBtn, closeBtn } = setup(true);
106 |
107 | fireEvent.click(loginBtn);
108 |
109 | await waitFor(() => {
110 | expect(screen.getByText("Access token is empty or invalid.")).toBeInTheDocument();
111 | expect(onClose).not.toHaveBeenCalledWith(null);
112 | });
113 |
114 | fireEvent.click(closeBtn);
115 |
116 | await waitFor(() => {
117 | expect(
118 | screen.getByText("These credentials aren't stored in any server."),
119 | ).toBeInTheDocument();
120 | expect(onClose).toHaveBeenCalledWith(null);
121 | expect(textBox.value).toBe("");
122 | });
123 | });
124 |
125 | it("Show or hide access token", async () => {
126 | const { textBox } = setup(true);
127 | const showAccessTokenButton = screen.getByLabelText(/Access token hidden/i);
128 |
129 | expect(textBox).toHaveAttribute("type", "password");
130 |
131 | fireEvent.click(showAccessTokenButton);
132 |
133 | expect(textBox).toHaveAttribute("type", "text");
134 |
135 | fireEvent.click(showAccessTokenButton);
136 |
137 | expect(textBox).toHaveAttribute("type", "password");
138 | });
139 | });
140 |
--------------------------------------------------------------------------------
/src/routes/Preload/RepoLoader.test.tsx:
--------------------------------------------------------------------------------
1 | import RepoLoader from "./RepoLoader";
2 | import * as StargazerLoader from "../../utils/StargazerLoader";
3 | import { render, waitFor } from "@testing-library/react";
4 |
5 | const mockStartProgress = jest.fn();
6 | const mockSetProgress = jest.fn();
7 | const mockEndProgress = jest.fn();
8 |
9 | jest.mock("../../shared/ProgressContext", () => ({
10 | useProgress: () => ({
11 | startProgress: mockStartProgress,
12 | setProgress: mockSetProgress,
13 | endProgress: mockEndProgress,
14 | }),
15 | }));
16 |
17 | describe(RepoLoader, () => {
18 | const handleLoadDone = jest.fn();
19 | const handleLoadError = jest.fn();
20 |
21 | const repoMetadata = { username: "username", repo: "repo" };
22 |
23 | const repoInfo = {
24 | username: "username",
25 | repo: "repo",
26 | color: { hsl: "hsl", hex: "hex" },
27 | stargazerData: {
28 | timestamps: ["ts1", "ts2"],
29 | starCounts: [1, 2],
30 | },
31 | };
32 |
33 | it("loads repo data", async () => {
34 | jest.resetAllMocks();
35 |
36 | const mockLoadStargazers = jest.spyOn(StargazerLoader, "loadStargazers").mockImplementation(
37 | (
38 | _username: string,
39 | _repo: string,
40 | handleProgress: (val: number) => void,
41 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
42 | _shouldStop: () => boolean,
43 | ) => {
44 | handleProgress(50);
45 | handleProgress(100);
46 | return Promise.resolve(repoInfo || null);
47 | },
48 | );
49 |
50 | render(
51 | ,
56 | );
57 |
58 | await waitFor(() => {
59 | expect(mockStartProgress).toHaveBeenCalled();
60 | expect(mockSetProgress.mock.calls).toEqual([[50], [100]]);
61 | expect(mockEndProgress).toHaveBeenCalled();
62 |
63 | expect(mockLoadStargazers).toHaveBeenCalledWith(
64 | repoMetadata.username,
65 | repoMetadata.repo,
66 | expect.anything(),
67 | expect.anything(),
68 | );
69 |
70 | expect(handleLoadDone).toHaveBeenCalledWith(repoInfo);
71 | expect(handleLoadError).not.toHaveBeenCalled();
72 | });
73 | });
74 |
75 | it("no repo data", () => {
76 | const mockLoadStargazers = jest.spyOn(StargazerLoader, "loadStargazers");
77 |
78 | render(
79 | ,
84 | );
85 |
86 | expect(mockLoadStargazers).not.toHaveBeenCalled();
87 | });
88 |
89 | it.each([[new Error("error")], ["error"]])("load repo data error", async (error) => {
90 | jest.spyOn(StargazerLoader, "loadStargazers").mockImplementation(() => {
91 | return Promise.reject(error);
92 | });
93 |
94 | render(
95 | ,
100 | );
101 |
102 | await waitFor(() => {
103 | expect(mockStartProgress).toHaveBeenCalled();
104 | expect(mockSetProgress).not.toHaveBeenCalled();
105 | expect(mockEndProgress).toHaveBeenCalled();
106 |
107 | expect(handleLoadDone).not.toHaveBeenCalled();
108 | expect(handleLoadError).toHaveBeenCalledWith("error");
109 | });
110 | });
111 |
112 | it("load multiple repo data", async () => {
113 | const mockLoadStargazers = jest.spyOn(StargazerLoader, "loadStargazers").mockImplementation(
114 | (
115 | _username: string,
116 | _repo: string,
117 | handleProgress: (val: number) => void,
118 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
119 | _shouldStop: () => boolean,
120 | ) => {
121 | handleProgress(100);
122 | return Promise.resolve(repoInfo || null);
123 | },
124 | );
125 |
126 | const { rerender } = render(
127 | ,
132 | );
133 |
134 | const repoMetadata2 = { username: "username2", repo: "repo2" };
135 | rerender(
136 | ,
141 | );
142 |
143 | await waitFor(() => {
144 | expect(mockStartProgress.mock.calls.length).toEqual(2);
145 | expect(mockSetProgress.mock.calls).toEqual([[100], [100]]);
146 | expect(mockEndProgress.mock.calls.length).toEqual(2);
147 |
148 | expect(mockLoadStargazers.mock.calls).toEqual([
149 | [repoMetadata.username, repoMetadata.repo, expect.anything(), expect.anything()],
150 | [repoMetadata2.username, repoMetadata2.repo, expect.anything(), expect.anything()],
151 | ]);
152 |
153 | expect(handleLoadDone.mock.calls).toEqual([[repoInfo], [repoInfo]]);
154 | expect(handleLoadError).not.toHaveBeenCalled();
155 | });
156 | });
157 | });
158 |
--------------------------------------------------------------------------------
/src/components/RepoStats/RepoStats.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, fireEvent } from "@testing-library/react";
2 | import * as StargazerStats from "../../utils/StargazerStats";
3 | import RepoInfo from "../../utils/RepoInfo";
4 | import RepoStats from "./RepoStats";
5 | import { createMatchMedia } from "../../utils/test";
6 |
7 | const mockStatsGridLargeScreen = jest.fn();
8 | jest.mock("./StatsGridLargeScreen", () => ({
9 | __esModule: true,
10 | default: (props: unknown[]) => {
11 | mockStatsGridLargeScreen(props);
12 | return ;
13 | },
14 | }));
15 |
16 | const mockStatsGridSmallScreen = jest.fn();
17 | jest.mock("./StatsGridSmallScreen", () => ({
18 | __esModule: true,
19 | default: (props: unknown[]) => {
20 | mockStatsGridSmallScreen(props);
21 | return ;
22 | },
23 | }));
24 |
25 | const mockDownloadData = jest.fn();
26 | jest.mock("./DownloadData", () => ({
27 | __esModule: true,
28 | default: (props: unknown[]) => {
29 | mockDownloadData(props);
30 | return ;
31 | },
32 | }));
33 |
34 | describe(RepoStats, () => {
35 | const repoInfos: Array = [
36 | {
37 | username: "user1",
38 | repo: "repo1",
39 | color: {
40 | hex: "hexColor1",
41 | hsl: "stlColor1",
42 | },
43 | stargazerData: {
44 | timestamps: ["ts1", "ts2"],
45 | starCounts: [1, 2],
46 | },
47 | },
48 | {
49 | username: "user2",
50 | repo: "repo2",
51 | color: {
52 | hex: "hexColor2",
53 | hsl: "stlColor2",
54 | },
55 | stargazerData: {
56 | timestamps: ["ts1", "ts2"],
57 | starCounts: [1, 2],
58 | },
59 | },
60 | ];
61 |
62 | const stats = {
63 | "Stat 1": 1,
64 | "Stat 2": "2",
65 | };
66 |
67 | const expectedStatsInfos = [
68 | {
69 | ...repoInfos[0],
70 | stats: stats,
71 | },
72 | {
73 | ...repoInfos[1],
74 | stats: stats,
75 | },
76 | ];
77 |
78 | it("render on a small screen", () => {
79 | jest.spyOn(StargazerStats, "calcStats").mockReturnValue(stats);
80 |
81 | window.matchMedia = createMatchMedia(200);
82 |
83 | render();
84 |
85 | expect(mockStatsGridSmallScreen).toHaveBeenCalledWith({ statInfos: expectedStatsInfos });
86 | expect(mockStatsGridLargeScreen).not.toHaveBeenCalled();
87 | });
88 |
89 | it("render on a large screen", () => {
90 | jest.spyOn(StargazerStats, "calcStats").mockReturnValue(stats);
91 |
92 | window.matchMedia = createMatchMedia(1000);
93 |
94 | render();
95 |
96 | expect(mockStatsGridSmallScreen).not.toHaveBeenCalled();
97 | expect(mockStatsGridLargeScreen).toHaveBeenCalledWith({ statInfos: expectedStatsInfos });
98 | });
99 |
100 | it("calcs stats with date range", () => {
101 | const mockCalcStats = jest.spyOn(StargazerStats, "calcStats").mockReturnValue(stats);
102 |
103 | const minDate = new Date();
104 | minDate.setDate(new Date().getDate() - 30);
105 | const maxDate = new Date();
106 | maxDate.setDate(new Date().getDate() - 2);
107 | const dateRange = {
108 | min: minDate.toISOString(),
109 | max: maxDate.toISOString(),
110 | };
111 |
112 | render();
113 |
114 | expect(mockCalcStats.mock.calls).toEqual([
115 | [repoInfos[0].stargazerData, undefined],
116 | [repoInfos[1].stargazerData, undefined],
117 | ]);
118 |
119 | expect(screen.queryByText("Date range:")).not.toBeInTheDocument();
120 |
121 | mockCalcStats.mockReset();
122 | mockCalcStats.mockReturnValue(stats);
123 |
124 | const checkBox = screen.getByLabelText("Sync stats to chart zoom level");
125 | fireEvent.click(checkBox);
126 |
127 | expect(mockCalcStats.mock.calls).toEqual([
128 | [repoInfos[0].stargazerData, dateRange],
129 | [repoInfos[1].stargazerData, dateRange],
130 | ]);
131 |
132 | const dateRangeElement = screen.getByText("Date range", { exact: false });
133 | expect(dateRangeElement.parentNode?.textContent).toEqual(
134 | `Date range: ${minDate.toLocaleDateString()} → ${maxDate.toLocaleDateString()}`,
135 | );
136 | });
137 |
138 | it("renders correctly when date range is undefined", () => {
139 | const mockCalcStats = jest.spyOn(StargazerStats, "calcStats").mockReturnValue(stats);
140 |
141 | render();
142 |
143 | mockCalcStats.mockReset();
144 | mockCalcStats.mockReturnValue(stats);
145 |
146 | const checkBox = screen.getByLabelText("Sync stats to chart zoom level");
147 | fireEvent.click(checkBox);
148 |
149 | expect(mockCalcStats.mock.calls).toEqual([
150 | [repoInfos[0].stargazerData, undefined],
151 | [repoInfos[1].stargazerData, undefined],
152 | ]);
153 |
154 | expect(screen.queryByText("Date range:")).not.toBeInTheDocument();
155 | });
156 |
157 | it("render download data", () => {
158 | render();
159 |
160 | expect(mockDownloadData).toHaveBeenCalledWith({ repoInfos: repoInfos });
161 | });
162 | });
163 |
--------------------------------------------------------------------------------
/src/utils/StargazerStats.test.ts:
--------------------------------------------------------------------------------
1 | import StarData from "./StarData";
2 | import * as stargazerStats from "./StargazerStats";
3 |
4 | const daysToMS = (days: number) => days * 1000 * 60 * 60 * 24;
5 | const calculateTimestampsFromDaysList = (curDate: Date, daysList: Array) => {
6 | return daysList.map((n) => new Date(curDate.getTime() + daysToMS(n)).toISOString());
7 | };
8 |
9 | describe(stargazerStats.calcLeastSquares, () => {
10 | it("returns the correct result", () => {
11 | const xaxis = [1, 2, 3, 4];
12 | const yaxis = [6, 5, 7, 10];
13 |
14 | const result = stargazerStats.calcLeastSquares(xaxis, yaxis);
15 | expect(result(3.6)).toBe(8.54);
16 | });
17 | });
18 |
19 | describe(stargazerStats.calcStats, () => {
20 | it("returns zeros if no data", () => {
21 | expect(stargazerStats.calcStats({ timestamps: [], starCounts: [] })).toStrictEqual({
22 | "Number of stars": 0,
23 | "Number of days": 0,
24 | "Average stars per day": "0",
25 | "Days with stars": 0,
26 | "Max stars in one day": 0,
27 | "Day with most stars": 0,
28 | });
29 | });
30 |
31 | it("returns stats without date range", () => {
32 | const curDate = new Date();
33 |
34 | const inputData: StarData = {
35 | timestamps: calculateTimestampsFromDaysList(curDate, [-4, -3, -3, -2, 0]),
36 | starCounts: [1, 2, 3, 4, 5],
37 | };
38 |
39 | expect(stargazerStats.calcStats(inputData)).toStrictEqual({
40 | "Number of stars": 5,
41 | "Number of days": 4,
42 | "Average stars per day": "1.250",
43 | "Days with stars": 3,
44 | "Max stars in one day": 2,
45 | "Day with most stars": new Date(curDate.getTime() - daysToMS(3)).toISOString().slice(0, 10),
46 | });
47 | });
48 |
49 | it("returns stats with date range", () => {
50 | const curDate = new Date();
51 |
52 | const inputData: StarData = {
53 | timestamps: calculateTimestampsFromDaysList(curDate, [-4, -3, -3, -2, 0]),
54 | starCounts: [1, 2, 3, 4, 5],
55 | };
56 |
57 | const dateRange = {
58 | min: new Date(curDate.getTime() - daysToMS(3)).toISOString(),
59 | max: new Date(curDate.getTime() - daysToMS(1)).toISOString(),
60 | };
61 |
62 | expect(stargazerStats.calcStats(inputData, dateRange)).toStrictEqual({
63 | "Number of stars": 3,
64 | "Number of days": 1,
65 | "Average stars per day": "3.000",
66 | "Days with stars": 1,
67 | "Max stars in one day": 2,
68 | "Day with most stars": new Date(curDate.getTime() - daysToMS(3)).toISOString().slice(0, 10),
69 | });
70 | });
71 | });
72 |
73 | describe(stargazerStats.calcForecast, () => {
74 | const curDate = new Date();
75 |
76 | it.each([
77 | [0, 2, 2],
78 | [2, 0, 2],
79 | [2, 2, 0],
80 | ])(
81 | "throws exception for invalid forecast props",
82 | (daysBackwards: number, daysForward: number, numValues: number) => {
83 | const inputData: StarData = {
84 | timestamps: calculateTimestampsFromDaysList(curDate, [-1]),
85 | starCounts: [1],
86 | };
87 |
88 | expect(() => {
89 | stargazerStats.calcForecast(inputData, {
90 | daysBackwards: daysBackwards,
91 | daysForward: daysForward,
92 | numValues: numValues,
93 | });
94 | }).toThrow(stargazerStats.InvalidForecastProps);
95 | },
96 | );
97 |
98 | it("throws exception if data doesn't match forecast props", () => {
99 | const inputData: StarData = {
100 | timestamps: calculateTimestampsFromDaysList(curDate, [-100]),
101 | starCounts: [1],
102 | };
103 |
104 | expect(() => {
105 | stargazerStats.calcForecast(inputData, {
106 | daysBackwards: 99,
107 | daysForward: 10,
108 | numValues: 10,
109 | });
110 | }).toThrow(stargazerStats.NotEnoughDataError);
111 | });
112 |
113 | it("calculates forecast data, props cover the full data", () => {
114 | jest
115 | .spyOn(stargazerStats, "calcLeastSquares")
116 | .mockReturnValue((val) => 2 * ((val - curDate.getTime()) / daysToMS(1)));
117 | const timestamps = calculateTimestampsFromDaysList(curDate, [-4, -3, -2, -1, 0]);
118 | const inputData: StarData = {
119 | timestamps: timestamps,
120 | starCounts: [1, 2, 3, 4, 5],
121 | };
122 |
123 | expect(
124 | stargazerStats.calcForecast(inputData, { daysBackwards: 10, daysForward: 5, numValues: 5 }),
125 | ).toStrictEqual({
126 | timestamps: calculateTimestampsFromDaysList(curDate, [0, 1, 2, 3, 4, 5]),
127 | starCounts: [5, 7, 9, 11, 13, 15],
128 | });
129 | });
130 |
131 | it("calculates forecast data, props cover part of the data", () => {
132 | const calcLeastSquaresMock = jest
133 | .spyOn(stargazerStats, "calcLeastSquares")
134 | .mockReturnValue((x) => x);
135 | const inputData: StarData = {
136 | timestamps: calculateTimestampsFromDaysList(curDate, [-4, -3, -2, -1, 0]),
137 | starCounts: [1, 2, 3, 4, 5],
138 | };
139 |
140 | stargazerStats.calcForecast(inputData, { daysBackwards: 2, daysForward: 5, numValues: 5 });
141 | expect(calcLeastSquaresMock).toHaveBeenCalledWith(
142 | calculateTimestampsFromDaysList(curDate, [-1, 0]).map((d) => new Date(d).getTime()),
143 | [4, 5],
144 | );
145 | });
146 | });
147 |
--------------------------------------------------------------------------------
/src/components/RepoDetailsInput/RepoDetailsInputMobile.test.tsx:
--------------------------------------------------------------------------------
1 | import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2 | import userEvent from "@testing-library/user-event";
3 | import RepoDetailsInputMobile from "./RepoDetailsInputMobile";
4 | import { renderWithTheme } from "../../utils/test";
5 |
6 | const goClickHandler = jest.fn();
7 | const cancelClickHandler = jest.fn();
8 |
9 | const username = "user";
10 | const repo = "repo";
11 |
12 | describe(RepoDetailsInputMobile, () => {
13 | it("render correctly on non-loading state and fires an event on Go click", () => {
14 | render(
15 | ,
20 | );
21 |
22 | const [usernameTextBox, repoTextBox] = screen.getAllByRole("textbox");
23 | fireEvent.change(usernameTextBox, { target: { value: username } });
24 | fireEvent.change(repoTextBox, { target: { value: repo } });
25 |
26 | const goBtn = screen.getByRole("button");
27 | fireEvent.click(goBtn);
28 |
29 | expect(goClickHandler).toHaveBeenCalledWith(username, repo);
30 | expect(cancelClickHandler).not.toHaveBeenCalled();
31 | });
32 |
33 | it.each(["repoTextBox", "usernameTextBox"])(
34 | "render correctly on non-loading state and fires an event on Enter key",
35 | async (textbox) => {
36 | render(
37 | ,
42 | );
43 |
44 | const [usernameTextBox, repoTextBox] = screen.getAllByRole("textbox");
45 | fireEvent.change(usernameTextBox, { target: { value: username } });
46 | fireEvent.change(repoTextBox, { target: { value: repo } });
47 |
48 | if (textbox == "repoTextBox") {
49 | userEvent.type(repoTextBox, "{Enter}");
50 | } else {
51 | userEvent.type(usernameTextBox, "{Enter}");
52 | }
53 |
54 | await waitFor(() => {
55 | expect(goClickHandler).toHaveBeenCalledWith(username, repo);
56 | });
57 |
58 | expect(cancelClickHandler).not.toHaveBeenCalled();
59 | },
60 | );
61 |
62 | it("move to repo text box when '/' is pressed", async () => {
63 | render(
64 | ,
69 | );
70 |
71 | const [usernameTextBox, repoTextBox] = screen.getAllByRole("textbox");
72 |
73 | expect(repoTextBox).not.toHaveFocus();
74 |
75 | await userEvent.type(usernameTextBox, `${username}/`);
76 |
77 | expect(repoTextBox).toHaveFocus();
78 | });
79 |
80 | it("render correctly in loading state and fires an event on Cancel click", () => {
81 | renderWithTheme(
82 | ,
87 | );
88 |
89 | const [usernameTextBox, repoTextBox] = screen.getAllByRole("textbox");
90 | fireEvent.change(usernameTextBox, { target: { value: username } });
91 | fireEvent.change(repoTextBox, { target: { value: repo } });
92 |
93 | const [loadingBtn, cancelBtn] = screen.getAllByRole("button");
94 |
95 | expect(loadingBtn.textContent).toStrictEqual("Loading...");
96 |
97 | fireEvent.click(cancelBtn);
98 |
99 | expect(goClickHandler).not.toHaveBeenCalled();
100 | expect(cancelClickHandler).toHaveBeenCalled();
101 | });
102 |
103 | it("trim the username and repo", () => {
104 | render(
105 | ,
110 | );
111 |
112 | const [usernameTextBox, repoTextBox] = screen.getAllByRole("textbox");
113 | fireEvent.change(usernameTextBox, { target: { value: ` ${username} ` } });
114 | fireEvent.change(repoTextBox, { target: { value: ` ${repo} ` } });
115 |
116 | const goBtn = screen.getByRole("button");
117 | fireEvent.click(goBtn);
118 |
119 | expect(goClickHandler).toHaveBeenCalledWith(username, repo);
120 | });
121 |
122 | it.each([
123 | ["username", "https://github.com/seladb/pcapplusplus", "seladb", "pcapplusplus"],
124 | ["repo", "https://github.com/seladb/pcapplusplus", "seladb", "pcapplusplus"],
125 | ["username", "https://google.com", "", ""],
126 | ["repo", "https://google.com", "", ""],
127 | ])(
128 | "parse pasted GitHub URL",
129 | (whereToPaste, pasted, expectedValueInUserBox, expectedValueInRepoBox) => {
130 | render(
131 | ,
136 | );
137 |
138 | const [usernameTextBox, repoTextBox] = screen.getAllByRole("textbox");
139 | const boxToPaste = whereToPaste === "username" ? usernameTextBox : repoTextBox;
140 | fireEvent.paste(boxToPaste, { clipboardData: { getData: () => pasted } });
141 |
142 | expect(usernameTextBox).toHaveValue(expectedValueInUserBox);
143 | expect(repoTextBox).toHaveValue(expectedValueInRepoBox);
144 | },
145 | );
146 | });
147 |
--------------------------------------------------------------------------------
/src/utils/StargazerStats.ts:
--------------------------------------------------------------------------------
1 | import StarData from "./StarData";
2 | import * as StargazerStats from "./StargazerStats";
3 |
4 | export const calcLeastSquares = (xaxis: Array, yaxis: Array) => {
5 | let sumX = 0;
6 | let sumY = 0;
7 | let sumXY = 0;
8 | let sumXSq = 0;
9 | const N = xaxis.length;
10 |
11 | for (let i = 0; i < N; ++i) {
12 | sumX += xaxis[i];
13 | sumY += yaxis[i];
14 | sumXY += xaxis[i] * yaxis[i];
15 | sumXSq += xaxis[i] * xaxis[i];
16 | }
17 |
18 | const m = (sumXY - (sumX * sumY) / N) / (sumXSq - (sumX * sumX) / N);
19 | const b = sumY / N - (m * sumX) / N;
20 |
21 | return (x: number) => {
22 | return m * x + b;
23 | };
24 | };
25 |
26 | interface DateRange {
27 | min: string;
28 | max: string;
29 | }
30 |
31 | export const calcStats = (
32 | stargazerData: StarData,
33 | dateRange?: DateRange,
34 | ): Record => {
35 | const timestampToDays = (ts: number) => Math.floor(ts / 1000 / 60 / 60 / 24);
36 | const daysToTimestamp = (days: number) => days * 1000 * 60 * 60 * 24;
37 |
38 | let stargazerDates = stargazerData.timestamps.map((cur) => new Date(cur));
39 | if (dateRange) {
40 | const minDate = new Date(dateRange.min.replace(/ /g, "T"));
41 | const maxDate = new Date(dateRange.max.replace(/ /g, "T"));
42 | stargazerDates = stargazerDates.filter((cur) => cur >= minDate && cur <= maxDate);
43 | }
44 |
45 | if (stargazerDates.length === 0) {
46 | return {
47 | "Number of stars": 0,
48 | "Number of days": 0,
49 | "Average stars per day": "0",
50 | "Days with stars": 0,
51 | "Max stars in one day": 0,
52 | "Day with most stars": 0,
53 | };
54 | }
55 |
56 | const firstStarDate = stargazerDates[0];
57 | const lastStarDate = stargazerDates[stargazerDates.length - 1];
58 | const numOfDays =
59 | stargazerDates.length === 1
60 | ? 1
61 | : timestampToDays(lastStarDate.getTime() - firstStarDate.getTime());
62 | let daysWithoutStars = 0;
63 | let maxStarsPerDay = 0;
64 | let dayWithMostStars = stargazerDates[0];
65 | let curSameDays = 1;
66 | const startDate = timestampToDays(stargazerDates[0].getTime()) - 1;
67 | let prevDate = startDate;
68 | stargazerDates.forEach((stargazerDate) => {
69 | const curDate = timestampToDays(stargazerDate.getTime());
70 |
71 | if (curDate === prevDate) {
72 | curSameDays += 1;
73 | } else {
74 | if (prevDate !== startDate) {
75 | daysWithoutStars += curDate - prevDate - 1;
76 | }
77 |
78 | if (curSameDays > maxStarsPerDay) {
79 | maxStarsPerDay = curSameDays;
80 | dayWithMostStars = new Date(daysToTimestamp(prevDate));
81 | }
82 |
83 | curSameDays = 1;
84 | }
85 |
86 | prevDate = curDate;
87 | });
88 |
89 | return {
90 | "Number of stars": stargazerDates.length,
91 | "Number of days": numOfDays,
92 | "Average stars per day": (stargazerDates.length / numOfDays).toFixed(3),
93 | "Days with stars": numOfDays - daysWithoutStars,
94 | "Max stars in one day": maxStarsPerDay,
95 | "Day with most stars": dayWithMostStars.toISOString().slice(0, 10),
96 | };
97 | };
98 |
99 | export interface ForecastProps {
100 | daysBackwards: number;
101 | daysForward: number;
102 | numValues: number;
103 | }
104 |
105 | export class InvalidForecastProps extends RangeError {}
106 | export class NotEnoughDataError extends RangeError {}
107 |
108 | export const calcForecast = (
109 | stargazerData: StarData,
110 | { daysBackwards, daysForward, numValues }: ForecastProps,
111 | ) => {
112 | if (daysBackwards < 1 || daysForward < 1 || numValues < 1) {
113 | throw new InvalidForecastProps();
114 | }
115 |
116 | const minDate = new Date();
117 | minDate.setDate(minDate.getDate() - daysBackwards);
118 |
119 | const filteredTimestamps: Array = [];
120 | const filteredStarCount: Array = [];
121 |
122 | stargazerData.timestamps.forEach((timestamp, index) => {
123 | const tsDate = new Date(timestamp);
124 | if (tsDate > minDate) {
125 | filteredTimestamps.push(tsDate.getTime());
126 | filteredStarCount.push(stargazerData.starCounts[index]);
127 | }
128 | });
129 |
130 | if (filteredTimestamps.length <= 1 || filteredStarCount.length <= 1) {
131 | throw new NotEnoughDataError();
132 | }
133 |
134 | const leastSquaresFun = StargazerStats.calcLeastSquares(filteredTimestamps, filteredStarCount);
135 |
136 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
137 | const lastDate = filteredTimestamps.at(-1)!;
138 |
139 | // This makes sure the forecast starts from the recent star count
140 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
141 | const delta = filteredStarCount.at(-1)! - Math.round(leastSquaresFun(lastDate));
142 |
143 | const forecastData = Array.from(Array(numValues + 1).keys())
144 | .map((i) => (daysForward * i) / numValues)
145 | .map((daysFromNow) => {
146 | const dateFromNow = new Date(lastDate);
147 | dateFromNow.setDate(dateFromNow.getDate() + daysFromNow);
148 | return {
149 | timestamp: dateFromNow.toISOString(),
150 | starCount: Math.round(delta + leastSquaresFun(dateFromNow.getTime())),
151 | };
152 | });
153 |
154 | const resultStarData: StarData = {
155 | timestamps: forecastData.map((x) => x.timestamp),
156 | starCounts: forecastData.map((x) => x.starCount),
157 | };
158 |
159 | return resultStarData;
160 | };
161 |
--------------------------------------------------------------------------------
/tests/basic.spec.ts:
--------------------------------------------------------------------------------
1 | // cspell: ignore nsewdrag
2 |
3 | import { test, expect } from "@playwright/test";
4 | import { getComparator } from "playwright-core/lib/utils";
5 | import {
6 | localUrl,
7 | referenceUrl,
8 | username,
9 | repo1,
10 | repo2,
11 | authenticate,
12 | getChartScreenshot,
13 | } from "./utils";
14 |
15 | test("Basic Flow", async ({ page }, testDir) => {
16 | const getGridStarsCount = async (rowNum: number) => {
17 | return Number(
18 | await page.getByRole("row").nth(rowNum).getByRole("gridcell").nth(1).textContent(),
19 | );
20 | };
21 |
22 | const takeReferenceScreenshots = async () => {
23 | await page.goto(referenceUrl);
24 |
25 | await authenticate(page);
26 |
27 | const screenshots: Buffer[] = [];
28 |
29 | // Get screenshot for repo1
30 | await page.getByPlaceholder("Username").fill(username);
31 | await page.getByPlaceholder("Repo name").fill(repo1);
32 | await page.getByRole("button", { name: "Go!" }).click();
33 | await page.getByRole("button", { name: "Go!" }).waitFor({ state: "visible" });
34 |
35 | screenshots.push(await getChartScreenshot(page, testDir.outputPath("repo1-reference.png")));
36 |
37 | // Get screenshot for repo1 + repo2
38 | await page.getByPlaceholder("Username").fill(username);
39 | await page.getByPlaceholder("Repo name").fill(repo2);
40 | await page.getByRole("button", { name: "Go!" }).click();
41 | await page.getByRole("button", { name: "Go!" }).waitFor({ state: "visible" });
42 |
43 | screenshots.push(await getChartScreenshot(page, testDir.outputPath("repo1and2-reference.png")));
44 |
45 | return screenshots;
46 | };
47 |
48 | const expectedScreenshots = await takeReferenceScreenshots();
49 |
50 | await page.goto(localUrl);
51 |
52 | await authenticate(page);
53 |
54 | const screenshots: Buffer[] = [];
55 |
56 | // Get stats for repo1
57 | await page.getByPlaceholder("Username").fill(username);
58 | await page.getByPlaceholder("Repo name").fill(repo1);
59 | await page.getByRole("button", { name: "Go!" }).click();
60 | await expect(page.getByRole("button", { name: `${username} / ${repo1}` }).first()).toBeVisible();
61 | await expect(
62 | page.getByRole("gridcell", { name: `${username} / ${repo1}` }).getByRole("button"),
63 | ).toBeVisible();
64 | await expect(page.getByRole("textbox").last()).toHaveValue(
65 | `${localUrl}preload?r=${username},${repo1}`,
66 | );
67 | screenshots.push(await getChartScreenshot(page, testDir.outputPath("repo1-actual.png")));
68 |
69 | // Get stats for repo2
70 | await page.getByPlaceholder("Username").fill(username);
71 | await page.getByPlaceholder("Repo name").fill(repo2);
72 | await page.getByRole("button", { name: "Go!" }).click();
73 | await expect(page.getByRole("button", { name: `${username} / ${repo1}` }).first()).toBeVisible();
74 | await expect(page.getByRole("button", { name: `${username} / ${repo2}` }).first()).toBeVisible();
75 | await expect(
76 | page.getByRole("gridcell", { name: `${username} / ${repo1}` }).getByRole("button"),
77 | ).toBeVisible();
78 | page.getByRole("row").first().first();
79 | await expect(
80 | page.getByRole("gridcell", { name: `${username} / ${repo2}` }).getByRole("button"),
81 | ).toBeVisible();
82 | await expect(page.getByRole("textbox").last()).toHaveValue(
83 | `${localUrl}preload?r=${username},${repo1}&r=${username},${repo2}`,
84 | );
85 | screenshots.push(await getChartScreenshot(page, testDir.outputPath("repo1and2-actual.png")));
86 |
87 | const comparator = getComparator("image/png");
88 | screenshots.forEach((screenshot, index) => {
89 | expect(comparator(expectedScreenshots[index], screenshot)).toBeNull();
90 | });
91 |
92 | const gridStarsCountRepo1 = await getGridStarsCount(1);
93 | const gridStarsCountRepo2 = await getGridStarsCount(2);
94 |
95 | // Sync stats to chart data
96 | await page.getByLabel("Sync stats to chart zoom level").check();
97 | await expect(page.getByText("Date range")).not.toBeVisible();
98 | await page.locator(".nsewdrag").scrollIntoViewIfNeeded();
99 | const chartBoundingBox = await page.locator(".nsewdrag").boundingBox();
100 | expect(chartBoundingBox).not.toBeNull();
101 | if (chartBoundingBox) {
102 | await page.mouse.move(
103 | chartBoundingBox?.x + chartBoundingBox?.width / 2,
104 | chartBoundingBox?.y + chartBoundingBox?.height / 2,
105 | );
106 | await page.mouse.down();
107 | await page.mouse.move(
108 | chartBoundingBox?.x + chartBoundingBox?.width / 2 + 150,
109 | chartBoundingBox?.y + chartBoundingBox?.height / 2,
110 | );
111 | await page.mouse.up();
112 | }
113 | await expect(page.getByText("Date range")).toBeVisible();
114 | expect(await getGridStarsCount(1)).toBeLessThan(gridStarsCountRepo1);
115 | expect(await getGridStarsCount(2)).toBeLessThan(gridStarsCountRepo2);
116 |
117 | // Remove repo2
118 | await page.getByTestId("CancelIcon").last().click();
119 | const screenshotWithRepo1 = await getChartScreenshot(
120 | page,
121 | testDir.outputPath("repo1b-actual.png"),
122 | );
123 | expect(comparator(screenshots[0], screenshotWithRepo1)).toBeNull();
124 |
125 | // Remove repo1
126 | await page.getByTestId("CancelIcon").last().click();
127 | const chartElement = page.locator(".nsewdrag");
128 | const gridElement = page.getByRole("gridcell");
129 | const downloadDataElement = page.getByText("Download Data");
130 | await chartElement.waitFor({ state: "hidden", timeout: 5000 });
131 | await gridElement.waitFor({ state: "hidden", timeout: 5000 });
132 | await downloadDataElement.waitFor({ state: "hidden", timeout: 5000 });
133 | expect(chartElement).not.toBeVisible();
134 | expect(gridElement).not.toBeVisible();
135 | expect(downloadDataElement).not.toBeVisible();
136 | });
137 |
--------------------------------------------------------------------------------
/src/components/RepoDetailsInput/RepoDetailsInputDesktop.test.tsx:
--------------------------------------------------------------------------------
1 | import { fireEvent, screen, waitFor } from "@testing-library/react";
2 | import userEvent from "@testing-library/user-event";
3 | import RepoDetailsInputDesktop from "./RepoDetailsInputDesktop";
4 | import { renderWithTheme } from "../../utils/test";
5 |
6 | const goClickHandler = jest.fn();
7 | const cancelClickHandler = jest.fn();
8 |
9 | const username = "user";
10 | const repo = "repo";
11 |
12 | describe(RepoDetailsInputDesktop, () => {
13 | it("render correctly on non-loading state and fires an event on Go click", () => {
14 | renderWithTheme(
15 | ,
20 | );
21 |
22 | const [usernameTextBox, repoTextBox] = screen.getAllByRole("textbox");
23 | fireEvent.change(usernameTextBox, { target: { value: username } });
24 | fireEvent.change(repoTextBox, { target: { value: repo } });
25 |
26 | const goBtn = screen.getByRole("button");
27 | fireEvent.click(goBtn);
28 |
29 | expect(goClickHandler).toHaveBeenCalledWith(username, repo);
30 | expect(cancelClickHandler).not.toHaveBeenCalled();
31 | });
32 |
33 | it.each(["repoTextBox", "usernameTextBox"])(
34 | "render correctly on non-loading state and fires an event on Enter key",
35 | async (textbox) => {
36 | renderWithTheme(
37 | ,
42 | );
43 |
44 | const [usernameTextBox, repoTextBox] = screen.getAllByRole("textbox");
45 | fireEvent.change(usernameTextBox, { target: { value: username } });
46 | fireEvent.change(repoTextBox, { target: { value: repo } });
47 |
48 | if (textbox == "repoTextBox") {
49 | userEvent.type(repoTextBox, "{Enter}");
50 | } else {
51 | userEvent.type(usernameTextBox, "{Enter}");
52 | }
53 |
54 | await waitFor(() => {
55 | expect(goClickHandler).toHaveBeenCalledWith(username, repo);
56 | });
57 |
58 | expect(cancelClickHandler).not.toHaveBeenCalled();
59 | },
60 | );
61 |
62 | it("move to repo text box when '/' is pressed", async () => {
63 | renderWithTheme(
64 | ,
69 | );
70 |
71 | const [usernameTextBox, repoTextBox] = screen.getAllByRole("textbox");
72 |
73 | expect(repoTextBox).not.toHaveFocus();
74 |
75 | await userEvent.type(usernameTextBox, `${username}/`);
76 |
77 | expect(repoTextBox).toHaveFocus();
78 | });
79 |
80 | it("render correctly in loading state and fires an event on Cancel click", () => {
81 | renderWithTheme(
82 | ,
87 | );
88 |
89 | const [usernameTextBox, repoTextBox] = screen.getAllByRole("textbox");
90 | fireEvent.change(usernameTextBox, { target: { value: username } });
91 | fireEvent.change(repoTextBox, { target: { value: repo } });
92 |
93 | const [loadingBtn, cancelBtn] = screen.getAllByRole("button");
94 |
95 | expect(loadingBtn.textContent).toStrictEqual("Loading...");
96 |
97 | fireEvent.click(cancelBtn);
98 |
99 | expect(goClickHandler).not.toHaveBeenCalled();
100 | expect(cancelClickHandler).toHaveBeenCalled();
101 | });
102 |
103 | it("trim the username and repo", () => {
104 | renderWithTheme(
105 | ,
110 | );
111 |
112 | const [usernameTextBox, repoTextBox] = screen.getAllByRole("textbox");
113 | fireEvent.change(usernameTextBox, { target: { value: ` ${username} ` } });
114 | fireEvent.change(repoTextBox, { target: { value: ` ${repo} ` } });
115 |
116 | const goBtn = screen.getByRole("button");
117 | fireEvent.click(goBtn);
118 |
119 | expect(goClickHandler).toHaveBeenCalledWith(username, repo);
120 | });
121 |
122 | it("render a tooltip when hovering on repo details", async () => {
123 | renderWithTheme(
124 | ,
129 | );
130 |
131 | const repoDetails = screen.getByText("Repo details");
132 | userEvent.hover(repoDetails);
133 |
134 | const toolTip = await screen.findByRole("tooltip");
135 | expect(toolTip).toBeInTheDocument();
136 | });
137 |
138 | it.each([
139 | ["username", "https://github.com/seladb/pcapplusplus", "seladb", "pcapplusplus"],
140 | ["repo", "https://github.com/seladb/pcapplusplus", "seladb", "pcapplusplus"],
141 | ["username", "https://google.com", "", ""],
142 | ["repo", "https://google.com", "", ""],
143 | ])(
144 | "parse pasted GitHub URL",
145 | (whereToPaste, pasted, expectedValueInUserBox, expectedValueInRepoBox) => {
146 | renderWithTheme(
147 | ,
152 | );
153 |
154 | const [usernameTextBox, repoTextBox] = screen.getAllByRole("textbox");
155 | const boxToPaste = whereToPaste === "username" ? usernameTextBox : repoTextBox;
156 | fireEvent.paste(boxToPaste, { clipboardData: { getData: () => pasted } });
157 |
158 | expect(usernameTextBox).toHaveValue(expectedValueInUserBox);
159 | expect(repoTextBox).toHaveValue(expectedValueInRepoBox);
160 | },
161 | );
162 | });
163 |
--------------------------------------------------------------------------------
/src/components/GitHubAuth/GitHubAuthForm.tsx:
--------------------------------------------------------------------------------
1 | // cspell: ignore aren
2 |
3 | import {
4 | Button,
5 | Dialog,
6 | DialogActions,
7 | DialogContent,
8 | DialogContentText,
9 | DialogTitle,
10 | FormControl,
11 | FormHelperText,
12 | IconButton,
13 | InputAdornment,
14 | InputLabel,
15 | OutlinedInput,
16 | // TextField,
17 | } from "@mui/material";
18 | import Checkbox from "@mui/material/Checkbox";
19 | import FormControlLabel from "@mui/material/FormControlLabel";
20 | import React from "react";
21 | import { StorageType, validateAndStoreAccessToken } from "../../utils/GitHubUtils";
22 | import { Visibility, VisibilityOff } from "@mui/icons-material";
23 |
24 | enum TokenValidationStatus {
25 | Init = "init",
26 | Valid = "valid",
27 | Invalid = "invalid",
28 | }
29 |
30 | interface GitHubAuthFormProps {
31 | open: boolean;
32 | onClose: (accessToken: string | null) => void;
33 | }
34 |
35 | export default function GitHubAuthForm({ open, onClose }: GitHubAuthFormProps) {
36 | const [accessTokenValue, setAccessTokenValue] = React.useState(null);
37 | const [accessTokenValid, setAccessTokenValid] = React.useState(
38 | TokenValidationStatus.Init,
39 | );
40 | const [storageType, setStorageType] = React.useState(StorageType.SessionStorage);
41 | const [showAccessToken, setShowAccessToken] = React.useState(false);
42 |
43 | const handleStorageTypeCheckChanged = (event: React.ChangeEvent) => {
44 | setStorageType(event.target.checked ? StorageType.LocalStorage : StorageType.SessionStorage);
45 | };
46 |
47 | const handleLoginClick = async () => {
48 | if (accessTokenValue) {
49 | const isValid = await validateAndStoreAccessToken(accessTokenValue, storageType);
50 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions
51 | isValid
52 | ? setAccessTokenValid(TokenValidationStatus.Valid)
53 | : setAccessTokenValid(TokenValidationStatus.Invalid);
54 | } else {
55 | setAccessTokenValid(TokenValidationStatus.Invalid);
56 | }
57 | };
58 |
59 | const handleClose = () => {
60 | const accessToken = accessTokenValid === TokenValidationStatus.Valid ? accessTokenValue : null;
61 | setAccessTokenValid(TokenValidationStatus.Init);
62 | setAccessTokenValue(null);
63 | onClose(accessToken);
64 | };
65 |
66 | const textFieldHelperText = () => {
67 | return accessTokenValid === TokenValidationStatus.Invalid
68 | ? "Access token is empty or invalid."
69 | : "These credentials aren't stored in any server.";
70 | };
71 |
72 | const handleClickShowAccessToken = () => setShowAccessToken((show) => !show);
73 |
74 | React.useEffect(() => {
75 | if (accessTokenValid === TokenValidationStatus.Valid) {
76 | handleClose();
77 | }
78 | }, [accessTokenValid]);
79 |
80 | return (
81 |
154 | );
155 | }
156 |
--------------------------------------------------------------------------------
/src/components/Chart/Chart.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Plot from "react-plotly.js";
3 | import RepoInfo from "../../utils/RepoInfo";
4 | import Plotly, { ModeBarButton, PlotRelayoutEvent } from "plotly.js";
5 | import { Box } from "@mui/material";
6 | import { useTheme } from "@mui/material/styles";
7 |
8 | interface ChartProps {
9 | repoInfos: Array;
10 | onZoomChanged?: (start: string, end: string) => void;
11 | }
12 |
13 | function Chart({ repoInfos, onZoomChanged }: ChartProps) {
14 | const theme = useTheme();
15 |
16 | const [chartHeight, setChartHeight] = React.useState(800);
17 | const [yaxisType, setYaxisType] = React.useState("linear");
18 |
19 | const plotRef = React.useRef();
20 |
21 | React.useEffect(() => {
22 | handleResize();
23 | window.addEventListener("resize", handleResize);
24 |
25 | return () => {
26 | window.removeEventListener("resize", handleResize);
27 | };
28 | }, []);
29 |
30 | const handleResize = () => {
31 | if (!plotRef.current) {
32 | return;
33 | }
34 |
35 | const { width } = plotRef.current.getBoundingClientRect();
36 | setChartHeight(Math.min(width * 0.8, 800));
37 | };
38 |
39 | const handleChartEvent = (event: Readonly) => {
40 | if (!onZoomChanged) {
41 | return;
42 | }
43 |
44 | if (event["xaxis.range[0]"] && event["xaxis.range[1]"]) {
45 | onZoomChanged(
46 | event["xaxis.range[0]"].toString().replace(/ /g, "T"),
47 | event["xaxis.range[1]"].toString().replace(/ /g, "T"),
48 | );
49 | } else if (event["xaxis.autorange"]) {
50 | const minDates = repoInfos.map((repoInfo) => repoInfo.stargazerData.timestamps[0]);
51 | const maxDates = repoInfos.map(
52 | (repoInfo) =>
53 | repoInfo.stargazerData.timestamps[repoInfo.stargazerData.timestamps.length - 1],
54 | );
55 | const minTimestamps = minDates.map((dateAsString) => new Date(dateAsString).getTime());
56 | const maxTimestamps = maxDates.map((dateAsString) => new Date(dateAsString).getTime());
57 | onZoomChanged(
58 | minDates[minTimestamps.indexOf(Math.min(...minTimestamps))].replace(/ /g, "T"),
59 | maxDates[maxTimestamps.indexOf(Math.max(...maxTimestamps))].replace(/ /g, "T"),
60 | );
61 | }
62 | };
63 |
64 | const LogButton: ModeBarButton = {
65 | name: "log-scale",
66 | title: "Use logarithmic scale",
67 | icon: {
68 | width: 512,
69 | height: 512,
70 | path: "M480 32c0-11.1-5.7-21.4-15.2-27.2s-21.2-6.4-31.1-1.4l-32 16c-15.8 7.9-22.2 27.1-14.3 42.9C393 73.5 404.3 80 416 80v80c-17.7 0-32 14.3-32 32s14.3 32 32 32h32 32c17.7 0 32-14.3 32-32s-14.3-32-32-32V32zM32 64C14.3 64 0 78.3 0 96s14.3 32 32 32H47.3l89.6 128L47.3 384H32c-17.7 0-32 14.3-32 32s14.3 32 32 32H64c10.4 0 20.2-5.1 26.2-13.6L176 311.8l85.8 122.6c6 8.6 15.8 13.6 26.2 13.6h32c17.7 0 32-14.3 32-32s-14.3-32-32-32H304.7L215.1 256l89.6-128H320c17.7 0 32-14.3 32-32s-14.3-32-32-32H288c-10.4 0-20.2 5.1-26.2 13.6L176 200.2 90.2 77.6C84.2 69.1 74.4 64 64 64H32z",
71 | },
72 | click: () => setYaxisType("log"),
73 | };
74 |
75 | const LinearButton: ModeBarButton = {
76 | name: "linear-scale",
77 | title: "Use linear scale",
78 | icon: {
79 | width: 512,
80 | height: 512,
81 | path: "M64 64c0-17.7-14.3-32-32-32S0 46.3 0 64V400c0 44.2 35.8 80 80 80H480c17.7 0 32-14.3 32-32s-14.3-32-32-32H80c-8.8 0-16-7.2-16-16V64zm406.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L320 210.7l-57.4-57.4c-12.5-12.5-32.8-12.5-45.3 0l-112 112c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L240 221.3l57.4 57.4c12.5 12.5 32.8 12.5 45.3 0l128-128z",
82 | },
83 | click: () => setYaxisType("linear"),
84 | };
85 |
86 | return (
87 |
88 | {
90 | const series = [
91 | {
92 | x: stargazerData.timestamps,
93 | y: stargazerData.starCounts,
94 | name: `${username}/${repo}`,
95 | hovertemplate: `%{x|%d %b %Y}
${username}/${repo}: %{y}`,
96 | line: {
97 | color: color.hex,
98 | width: 5,
99 | dash: "solid",
100 | },
101 | },
102 | ];
103 |
104 | if (forecast) {
105 | series.push({
106 | x: forecast.timestamps,
107 | y: forecast.starCounts,
108 | name: `${username}/${repo} (forecast)`,
109 | hovertemplate: `%{x|%d %b %Y}
${username}/${repo} (forecast): %{y}`,
110 | line: {
111 | color: color.hex,
112 | width: 5,
113 | dash: "dot",
114 | },
115 | });
116 | }
117 |
118 | return series;
119 | })}
120 | style={{ width: "100%", height: "100%" }}
121 | useResizeHandler
122 | config={{
123 | modeBarButtonsToAdd: [yaxisType === "linear" ? LogButton : LinearButton],
124 | }}
125 | layout={{
126 | font: {
127 | family: theme.typography.fontFamily,
128 | size: theme.typography.fontSize,
129 | color: theme.palette.text.primary,
130 | },
131 | // eslint-disable-next-line camelcase
132 | plot_bgcolor: theme.palette.background.default,
133 | // eslint-disable-next-line camelcase
134 | paper_bgcolor: theme.palette.background.default,
135 | hoverlabel: {
136 | font: {
137 | family: theme.typography.fontFamily,
138 | },
139 | },
140 | showlegend: repoInfos.length > 1 || repoInfos[0].forecast !== undefined,
141 | modebar: {
142 | remove: ["zoomIn2d"],
143 | },
144 | xaxis: {
145 | type: "date",
146 | },
147 | yaxis: {
148 | fixedrange: true,
149 | type: yaxisType,
150 | },
151 | legend: {
152 | orientation: "h",
153 | },
154 | margin: {
155 | l: 50,
156 | r: 5,
157 | t: 10,
158 | b: 15,
159 | },
160 | height: chartHeight,
161 | }}
162 | onRelayout={handleChartEvent}
163 | />
164 |
165 | );
166 | }
167 |
168 | export default React.memo(Chart);
169 |
--------------------------------------------------------------------------------