Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions __tests__/components/schedule/ScheduleContainer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { render, screen } from "@testing-library/react";
import ScheduleContainer from "../../../components/schedule/ScheduleContainer";
import { DailySchedule } from "../../../hooks/useSchedule";
import { useMediaQuery } from "../../../hooks/useMediaQuery";

// Mock the hook
jest.mock("../../../hooks/useMediaQuery");

// Mock child components to verify rendering
jest.mock("../../../components/schedule/ScheduleGrid", () => {
const MockScheduleGrid = () => <div data-testid="schedule-grid">Grid</div>;
MockScheduleGrid.displayName = "MockScheduleGrid";
return MockScheduleGrid;
});

jest.mock("../../../components/schedule/ScheduleMobile", () => {
const MockScheduleMobile = () => <div data-testid="schedule-mobile">Mobile</div>;
MockScheduleMobile.displayName = "MockScheduleMobile";
return MockScheduleMobile;
});

// Mock context
jest.mock("../../../context/ScheduleContext", () => ({
useScheduleContext: () => ({
savedSessionIds: [],
isSaved: () => false,
toggleSession: jest.fn(),
}),
}));

const mockSchedule: DailySchedule[] = [];

describe("ScheduleContainer Optimization", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("renders both components initially (SSR/Hydration)", () => {
(useMediaQuery as jest.Mock).mockReturnValue(null);
render(<ScheduleContainer initialSchedule={mockSchedule} year="2024" />);

// Both should be present to support hydration matching and CSS hiding
expect(screen.getByTestId("schedule-grid")).toBeInTheDocument();
expect(screen.getByTestId("schedule-mobile")).toBeInTheDocument();
});

it("renders only ScheduleGrid on Desktop", () => {
(useMediaQuery as jest.Mock).mockReturnValue(false);
render(<ScheduleContainer initialSchedule={mockSchedule} year="2024" />);

expect(screen.getByTestId("schedule-grid")).toBeInTheDocument();
expect(screen.queryByTestId("schedule-mobile")).not.toBeInTheDocument();
});

it("renders only ScheduleMobile on Mobile", () => {
(useMediaQuery as jest.Mock).mockReturnValue(true);
render(<ScheduleContainer initialSchedule={mockSchedule} year="2024" />);

expect(screen.queryByTestId("schedule-grid")).not.toBeInTheDocument();
expect(screen.getByTestId("schedule-mobile")).toBeInTheDocument();
});
});
27 changes: 21 additions & 6 deletions components/schedule/ScheduleContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ScheduleGrid from "./ScheduleGrid";
import ScheduleMobile from "./ScheduleMobile";
import { useScheduleContext } from "@/context/ScheduleContext";
import styles from "./schedule.module.scss";
import { useMediaQuery } from "@/hooks/useMediaQuery";

interface ScheduleContainerProps {
initialSchedule: DailySchedule[];
Expand All @@ -15,6 +16,8 @@ interface ScheduleContainerProps {
export default function ScheduleContainer({ initialSchedule, year }: ScheduleContainerProps) {
const { savedSessionIds } = useScheduleContext();
const [showSavedOnly, setShowSavedOnly] = useState(false);
const isMobile = useMediaQuery("(max-width: 991px)");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The media query string (max-width: 991px) is hardcoded. This is considered a "magic string" and can make maintenance harder. If this breakpoint is used elsewhere or needs to be updated, it would have to be changed in multiple places. It's a best practice to define such values as constants in a shared theme or constants file and reference it here, for example: const isMobile = useMediaQuery(breakpoints.lg);.


const filteredSchedule = useMemo(() => {
if (!showSavedOnly) {
return initialSchedule;
Expand Down Expand Up @@ -46,12 +49,24 @@ export default function ScheduleContainer({ initialSchedule, year }: ScheduleCon
</button>
</div>

<div className="d-none d-lg-block">
<ScheduleGrid schedule={filteredSchedule} year={year} />
</div>
<div className="d-lg-none">
<ScheduleMobile schedule={filteredSchedule} year={year} />
</div>
{/*
Optimization: Only render the visible component to reduce DOM nodes.
Render both (hidden via CSS) only during SSR/initial hydration (isMobile === null).
*/}

{/* Desktop View */}
{(isMobile === null || isMobile === false) && (
<div className="d-none d-lg-block">
<ScheduleGrid schedule={filteredSchedule} year={year} />
</div>
)}

{/* Mobile View */}
{(isMobile === null || isMobile === true) && (
<div className="d-lg-none">
<ScheduleMobile schedule={filteredSchedule} year={year} />
</div>
)}
Comment on lines +58 to +69
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The conditional rendering logic is correct, but it can be simplified for better readability. The expressions isMobile === null || isMobile === false and isMobile === null || isMobile === true can be made more concise by checking for the opposite case, which also correctly handles the null state during server-side rendering.

      {/* Desktop View */}
      {isMobile !== true && (
        <div className="d-none d-lg-block">
          <ScheduleGrid schedule={filteredSchedule} year={year} />
        </div>
      )}

      {/* Mobile View */}
      {isMobile !== false && (
        <div className="d-lg-none">
          <ScheduleMobile schedule={filteredSchedule} year={year} />
        </div>
      )}

</div>
);
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions hooks/useMediaQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client";

import { useState, useEffect } from "react";

/**
* Custom hook to detect if a media query matches.
* Returns null during SSR/hydration to avoid mismatches,
* and then updates to true/false on the client.
*/
export function useMediaQuery(query: string): boolean | null {
const [matches, setMatches] = useState<boolean | null>(null);

useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = (e: MediaQueryListEvent) => setMatches(e.matches);

// Support for older browsers and modern ones
if (media.addEventListener) {
media.addEventListener("change", listener);
} else {
media.addListener(listener);
}
return () => {
if (media.removeEventListener) {
media.removeEventListener("change", listener);
} else {
media.removeListener(listener);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
Comment on lines +13 to +34
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The useEffect hook can be simplified to improve readability and remove the need for eslint-disable-next-line. You can set the initial state directly on the client-side render. React is smart enough to bail out of a re-render if the state value doesn't change, so there's no performance penalty. This change makes the hook's logic clearer and aligns better with standard hook patterns.

  useEffect(() => {
    const media = window.matchMedia(query);

    // Set the initial value on the client.
    setMatches(media.matches);

    const listener = (e: MediaQueryListEvent) => setMatches(e.matches);

    // Support for older browsers and modern ones
    if (media.addEventListener) {
      media.addEventListener("change", listener);
    } else {
      media.addListener(listener);
    }

    return () => {
      if (media.removeEventListener) {
        media.removeEventListener("change", listener);
      } else {
        media.removeListener(listener);
      }
    };
  }, [query]);


return matches;
}
9 changes: 3 additions & 6 deletions stylelint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
const config = {
extends: [
"stylelint-config-standard",
"stylelint-config-standard-scss"
],
extends: ["stylelint-config-standard", "stylelint-config-standard-scss"],
rules: {
"no-descending-specificity": null,
"selector-class-pattern": null,
Expand All @@ -14,8 +11,8 @@ const config = {
"keyframes-name-pattern": null,
"declaration-block-no-shorthand-property-overrides": null,
"block-no-empty": null,
"number-max-precision": null
}
"number-max-precision": null,
},
};

export default config;
Loading