Skip to content
Merged
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
Empty file added .codex
Empty file.
115 changes: 2 additions & 113 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,113 +1,2 @@
Instruction file for the AI agent working on the WCA competition groups web application.

## Project Overview

This is a React + TypeScript web application for viewing WCA (World Cube Association) competition groups digitally. The project uses Vite for development/build, Apollo Client for GraphQL, React Query for data fetching, and TailwindCSS/Styled Components for styling. The AI agent working on this project should understand the modular, component-driven structure and adhere to the repository’s coding and testing practices.

---

## Code Layout

- `src/` — Main source code.
- `pages/` — Route-level components.
- `components/` — Reusable React components.
- `containers/` — Higher-level components that manage state and logic. These use components and are used in pages.
- `hooks/` — Custom React hooks.
- `providers/` — Context providers for global state management.
- `lib/` — Utility functions and helpers.
- `lib/api.ts` — Data fetching and API abstraction layers.
- `public/` — Static assets.
- `package.json` — Project scripts and dependencies.
- `vite.config.ts` — Vite configuration.

---

## Setup & Dependencies

- **Node.js version:** Use the version compatible with Yarn 1.22+ and the dependencies in `package.json`.
- **Install dependencies:**
```bash
yarn
```
- **No special environment variables** are required for development or testing by default.

---

## Building & Running

- **Development server:**
```bash
yarn dev
```
- **Production build:**
```bash
yarn build
```
- **Preview production build:**
```bash
yarn serve
```

---

## Testing

- **Run all tests:**
```bash
yarn test
```
- **Testing libraries:** Jest and React Testing Library.
- **Before committing, always run the tests** and ensure **all tests pass**. The AI agent must run the full test suite after changes.
- All new features and bug fixes should include or update relevant tests.

---

## Linting & Formatting

- **Lint code:**
```bash
yarn lint
```
- **Type-check code:**
```bash
yarn check:type
```
- **Formatting:** Prettier is used for code formatting. ESLint is used for linting. Import sorting is handled by Prettier plugins.
- The AI agent should fix any lint or type errors it introduces. (CI will fail if errors are present.)

---

## Coding Conventions

- **TypeScript:** All code must be type-safe. Use/extend types in `src/types` as needed.
- **Styling:** Use TailwindCSS for utility-first styles; use Styled Components for component-scoped styles.
- **State Management:** Use React Query for server state and Apollo Client for GraphQL APIs.
- **Data Fetching:** Abstract API logic into `/lib/api.ts`.
- **Routing:** Use React Router v6.
- **Testing:** All new logic must have corresponding Jest/RTL tests.
- **Internationalization:** Use `i18next` and `react-i18next` for translations.
- **Documentation:** Update `README.md` and add comments where necessary.
- **Function and variable names:** Should be clear and descriptive.
- **Components:** Prefer functional components and hooks.

---

## Commit & PR Guidelines

- **Commits:** Use clear, descriptive commit messages. Conventional Commits format is preferred (`feat: ...`, `fix: ...`, `refactor: ...`).
- **Pull Requests:** Include a summary of changes and reasoning. Reference issues if applicable (e.g., “Closes #123”).
- **CI:** Tests and linting run on every PR. Ensure all checks pass before finalizing.

---

## Additional Instructions

- **Do not modify** files in `public/` unless the task explicitly requires it.
- **Do not update dependencies** in `package.json` without approval.
- **New libraries:** Prefer existing dependencies or standard approaches first.
- **New files:** The agent can create new files for features or tests, but all new code should be covered by tests.
- **Comments:** Add comments to explain complex logic; maintainability is valued.

---

If unsure, review the `README.md`, existing code, or add clarifying comments in your PR.
Use space-y instead of mt-2 for better spacing between elements.
Always use spacing in multiples of 2 unless you need to use odd spacing for a specific reason. This helps maintain visual consistency across the app.
4 changes: 2 additions & 2 deletions src/components/CompetitionList/CompetitionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ export function CompetitionListFragment({
}

return (
<div className="w-full p-2">
<span className="type-body-sm text-blue-800 dark:text-blue-400">{title}</span>
<div className="w-full px-2">
<span className="text-blue-800 type-body-sm dark:text-blue-400">{title}</span>
{loading ? <BarLoader width="100%" /> : <div style={{ height: '4px' }} />}
{!!competitions.length && (
<ul className="px-0">
Expand Down
1 change: 1 addition & 0 deletions src/components/CompetitionSelect/CompetitionSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const CompetitionSelect = ({ onSelect, className }: CompetitionSelectProp
'bg-panel',
// Borders + focus
'border border-tertiary-weak',
'px-1.5',
state.isFocused
? 'ring-1 ring-blue-500 dark:ring-blue-400 border-blue-500 dark:border-blue-400'
: '',
Expand Down
38 changes: 38 additions & 0 deletions src/components/LoggedOutPromptCard/LoggedOutPromptCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useTranslation } from 'react-i18next';
import { LoggedOutPromptCard } from './LoggedOutPromptCard';

jest.mock('react-i18next', () => ({
useTranslation: jest.fn(),
}));

describe('LoggedOutPromptCard', () => {
it('renders the login prompt and triggers the login callback', async () => {
const user = userEvent.setup();
const onLogin = jest.fn();

const messages: Record<string, string> = {
'home.loggedOutCard.eyebrow': 'Personalized view',
'home.loggedOutCard.title': 'Log in to see your competitions',
'home.loggedOutCard.description':
'Sign in with your WCA account to load your competition list and personalized schedule shortcuts.',
'common.login': 'Login',
};

jest.mocked(useTranslation).mockReturnValue({
t: (key: string) => messages[key] ?? key,
i18n: {} as never,
ready: true,
} as unknown as ReturnType<typeof useTranslation>);

render(<LoggedOutPromptCard onLogin={onLogin} />);

expect(screen.getByRole('heading', { name: /log in to see your competitions/i })).toBeVisible();

await user.click(screen.getByRole('button', { name: /login/i }));

expect(onLogin).toHaveBeenCalledTimes(1);
});
});
27 changes: 27 additions & 0 deletions src/components/LoggedOutPromptCard/LoggedOutPromptCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/Button';

interface LoggedOutPromptCardProps {
onLogin: () => void;
}

export function LoggedOutPromptCard({ onLogin }: LoggedOutPromptCardProps) {
const { t } = useTranslation();

return (
<section className="px-2" aria-label={t('home.loggedOutCard.title')}>
<div className="px-4 py-4 space-y-2 border rounded-md shadow-sm border-primary bg-primary/30">
<div className="space-y-1">
<h2 className="type-subheading">{t('home.loggedOutCard.title')}</h2>
<p className="type-body-sm text-subtle">{t('home.loggedOutCard.description')}</p>
</div>
<div>
<Button className="justify-center w-full sm:w-auto" onClick={onLogin}>
<i className="fa fa-user" aria-hidden="true" />
<span>{t('common.login')}</span>
</Button>
</div>
</div>
</section>
);
}
1 change: 1 addition & 0 deletions src/components/LoggedOutPromptCard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './LoggedOutPromptCard';
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ export * from './ErrorFallback';
export * from './ExternalLink';
export * from './LastFetchedAt';
export * from './LinkButton';
export * from './LoggedOutPromptCard';
export * from './Notebox';
export * from './PinCompetitionButton';
4 changes: 2 additions & 2 deletions src/containers/PersonalSchedule/Assignments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useNow } from '@/hooks/useNow/useNow';
import { parseActivityCodeFlexible } from '@/lib/activityCodes';
import { isActivityWithRoomOrParent } from '@/lib/typeguards';
import { byDate, roundTime } from '@/lib/utils';
import { getRoomData, getRooms } from '../../lib/activities';
import { getRoomData, hasMultipleScheduleLocations } from '../../lib/activities';
import { ExtraAssignment } from './PersonalExtraAssignment';
import { PersonalNormalAssignment } from './PersonalNormalAssignment';
import { getGroupedAssignmentsByDate } from './utils';
Expand All @@ -21,7 +21,7 @@ const key = (compId: string, id) => `${compId}-${id}`;
export function Assignments({ wcif, person, showStationNumber }: AssignmentsProps) {
const { t } = useTranslation();

const showRoom = useMemo(() => wcif && getRooms(wcif).length > 1, [wcif]);
const showRoom = useMemo(() => hasMultipleScheduleLocations(wcif), [wcif]);

const { collapsedDates, setCollapsedDates, toggleDate } = useCollapse(
key(wcif.id, person.registrantId),
Expand Down
9 changes: 7 additions & 2 deletions src/containers/Schedule/Schedule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { Competition } from '@wca/helpers';
import { useCallback, useEffect, useMemo } from 'react';
import { ActivityRow } from '@/components';
import { useCollapse } from '@/hooks/UseCollapse';
import { getRoomData, getRooms, getScheduledDays, getVenueForActivity } from '@/lib/activities';
import {
getRoomData,
getScheduledDays,
getVenueForActivity,
hasMultipleScheduleLocations,
} from '@/lib/activities';
import { ActivityWithRoomOrParent } from '@/lib/types';

const key = (compId: string) => `${compId}-schedule`;
Expand Down Expand Up @@ -85,7 +90,7 @@ export const ScheduleContainer = ({ wcif }: ScheduleContainerProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scheduleDays]);

const showRoom = useMemo(() => wcif && getRooms(wcif).length > 1, [wcif]);
const showRoom = useMemo(() => hasMultipleScheduleLocations(wcif), [wcif]);

return (
<div>
Expand Down
6 changes: 5 additions & 1 deletion src/i18n/en/translation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,13 @@ header:
stream: 'Stream'
home:
subtitle: 'Learn all you need about your WCA competition assignments!'
explanation: 'Note: This website exists as a convenience tool for organizers, delegates, and competitors. The information provided is based on scheduled data. Pay close attention to the competition for the most up-to-date information. Start and end times can fluctuate.'
explanation: 'Note: This site is a convenience tool for organizers, delegates, and competitors. It uses scheduled data, so check with organizers for the latest details. Start and end times may change.'
learnMore: 'How does this site work?'
support: 'Keep the lights on'
loggedOutCard:
eyebrow: 'Personalized view'
title: 'Log in to see your competitions'
description: 'Sign in with your WCA account to load your competition list and personalized schedule'
loadingMore: 'Loading more...'
myCompetitions: 'My Competitions'
upcomingCompetitions: 'Upcoming Competitions'
Expand Down
6 changes: 3 additions & 3 deletions src/layouts/RootLayout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,12 @@ export default function Header() {
</Popover>
) : (
<div className="flex items-center space-x-2">
<button onClick={signIn} className="link-inline type-label text-primary">
{t('common.login')}
</button>
<Link to="/settings" className="link-inline">
<i className="fa fa-gear" />
</Link>
<button onClick={signIn} className="mx-2 link-inline">
{t('common.login')}
</button>
</div>
)}
</header>
Expand Down
81 changes: 81 additions & 0 deletions src/lib/activities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Competition } from '@wca/helpers';
import { hasMultipleScheduleLocations } from './activities';

const baseCompetition = {
formatVersion: '1.0',
id: 'TestComp2026',
name: 'Test Comp 2026',
shortName: 'Test Comp',
events: [],
persons: [],
competitorLimit: 0,
extensions: [],
} as const;

describe('hasMultipleScheduleLocations', () => {
it('returns false for a single room without stage metadata', () => {
const wcif = {
...baseCompetition,
schedule: {
numberOfDays: 1,
startDate: '2026-03-15',
venues: [
{
id: 1,
name: 'Venue',
timezone: 'America/Los_Angeles',
rooms: [
{
id: 10,
name: 'Main Room',
color: '#123456',
activities: [],
extensions: [],
},
],
},
],
},
} as unknown as Competition;

expect(hasMultipleScheduleLocations(wcif)).toBe(false);
});

it('returns true for a single room with multiple stages in the natshelper extension', () => {
const wcif = {
...baseCompetition,
schedule: {
numberOfDays: 1,
startDate: '2026-03-15',
venues: [
{
id: 1,
name: 'Venue',
timezone: 'America/Los_Angeles',
rooms: [
{
id: 10,
name: 'Main Room',
color: '#123456',
activities: [],
extensions: [
{
id: 'org.cubingusa.natshelper.v1.Room',
data: {
stages: [
{ id: 1, name: 'Stage A', color: '#ff0000' },
{ id: 2, name: 'Stage B', color: '#00ff00' },
],
},
},
],
},
],
},
],
},
} as unknown as Competition;

expect(hasMultipleScheduleLocations(wcif)).toBe(true);
});
});
10 changes: 10 additions & 0 deletions src/lib/activities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ export const getRooms = (
})),
);

export const hasMultipleScheduleLocations = (wcif: Competition): boolean => {
const rooms = getRooms(wcif);

if (rooms.length > 1) {
return true;
}

return rooms.some((room) => (getNatsHelperRoomExtension(room)?.stages?.length || 0) > 1);
};

/**
* Returns the activity's child activities with a reference to the parent activity
*/
Expand Down
Loading
Loading