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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ This is a React + TypeScript web application for viewing WCA (World Cube Associa
- **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.
- **Translations:** Default to updating only `src/i18n/en/translation.yaml` unless the task explicitly requests other locales.
- **Keep instructions fresh:** When you discover new working conventions or project expectations, update this `AGENTS.md` to capture them.

---

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## Summary

View WCA Competition groups digitally.
View WCA Competition groups and extra information tabs digitally.

## Development

Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,14 @@
"react-ga4": "^2.1.0",
"react-i18next": "^15.5.1",
"react-intersection-observer": "^9.4.2",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.19.0",
"react-select": "^5.10.1",
"react-spinners": "^0.13.8",
"react-sticky-box": "^2.0.5",
"react-stickynode": "^4.1.1",
"react-tiny-popover": "^8.0.4",
"remark-gfm": "^4.0.0",
"styled-components": "^5.3.5",
"tailwindcss": "^3.3.5",
"typescript": "^5.8.3",
Expand Down Expand Up @@ -84,6 +86,7 @@
"prettier": "^3.5.3",
"prettier-plugin-organize-imports": "^4.1.0",
"react-test-renderer": "^19.1.0",
"sass": "^1.97.2",
"surge": "^0.23.1",
"ts-jest": "^29.3.4",
"ts-node": "^10.9.2",
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import CompetitionScramblerSchedule from './pages/Competition/ScramblerSchedule';
import CompetitionStats from './pages/Competition/Stats';
import CompetitionStreamSchedule from './pages/Competition/StreamSchedule';
import CompetitionTabs from './pages/Competition/Tabs';
import Home from './pages/Home';
import Settings from './pages/Settings';
import Support from './pages/Support';
Expand Down Expand Up @@ -110,6 +111,7 @@ const Navigation = () => {
<Route path="scramblers" element={<CompetitionScramblerSchedule />} />
<Route path="stream" element={<CompetitionStreamSchedule />} />
<Route path="information" element={<CompetitionInformation />} />
<Route path="tabs" element={<CompetitionTabs />} />
<Route path="live" element={<CompetitionLive />} />

{/* Following pages are not accessible: */}
Expand Down
21 changes: 21 additions & 0 deletions src/hooks/queries/useCompetitionTabs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { fetchCompetitionTabs } from '@/lib/api';

const sortTabsByOrder = (tabs: ApiCompetitionTab[]) =>
[...tabs].sort((a, b) => a.display_order - b.display_order);

export const competitionTabsQuery = (competitionId: string) => ({
queryKey: ['competitionTabs', competitionId],
queryFn: async () => fetchCompetitionTabs(competitionId),
});

export const useCompetitionTabs = (competitionId?: string) => {
return useQuery<ApiCompetitionTab[]>({
...competitionTabsQuery(competitionId ?? ''),
networkMode: 'offlineFirst',
staleTime: 60 * 60 * 1000,
gcTime: 24 * 60 * 60 * 1000,
enabled: !!competitionId,
select: (tabs) => sortTabsByOrder(tabs),
});
};
4 changes: 4 additions & 0 deletions src/i18n/en/translation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ competition:
viewMyAssignments: 'View My Assignments'
viewCompetitionInformation: 'View Competition Information'
searchCompetitors: 'Search Competitors'
tabs:
title: Extra Information
view: View Competition Tabs
error: Unable to load competition tabs.
groups:
backToEvents: 'Back to Events'
nextGroup: 'Next Group'
Expand Down
2 changes: 1 addition & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createRoot } from 'react-dom/client';
import App from './App';
import './i18n';
import reportWebVitals from './reportWebVitals';
import './styles/index.css';
import './styles/index.scss';

const container = document.getElementById('root');
if (!container) throw new Error('Failed to find the root element');
Expand Down
3 changes: 3 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,8 @@ export const fetchWcif = async (competitionId: string) =>
export const fetchCompetition = async (competitionId: string) =>
await wcaApiFetch<ApiCompetition>(`/competitions/${competitionId}`);

export const fetchCompetitionTabs = async (competitionId: string) =>
await wcaApiFetch<ApiCompetitionTab[]>(`/competitions/${competitionId}/tabs`);

export const fetchSearchCompetition = (search: string) =>
wcaApiFetch<{ result: ApiCompetition[] }>(`/search/competitions?q=${search}`);
2 changes: 1 addition & 1 deletion src/lib/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const colors: Record<string, string> = {

/**
* Assignment color class mapping
* Maps assignment codes to their CSS token class names (defined in tokens.css)
* Maps assignment codes to their CSS token class names (defined in _tokens.scss)
* Use these instead of inline styles for proper dark mode support
*/
export type AssignmentColorClasses = {
Expand Down
1 change: 1 addition & 0 deletions src/pages/Competition/Home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export default function CompetitionHome() {
title={t('competition.competitors.viewCompetitionInformation')}
variant="green"
/>
<LinkButton to="tabs" title={t('competition.tabs.view')} variant="blue" />
<PinCompetitionButton competitionId={competitionId} />
</div>
<OngoingActivities competitionId={competitionId!} />
Expand Down
66 changes: 66 additions & 0 deletions src/pages/Competition/Tabs/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { UseQueryResult } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import CompetitionTabs from './index';

jest.mock('@/hooks/queries/useCompetitionTabs', () => ({
useCompetitionTabs: jest.fn(),
}));

jest.mock('@/providers/WCIFProvider', () => ({
useWCIF: jest.fn(),
}));

jest.mock('remark-gfm', () => () => undefined);

const mockUseCompetitionTabs = jest.requireMock('@/hooks/queries/useCompetitionTabs')
.useCompetitionTabs as jest.Mock;
const mockUseWCIF = jest.requireMock('@/providers/WCIFProvider').useWCIF as jest.Mock;

jest.mock('react-markdown', () => ({
__esModule: true,
default: ({
components,
}: {
components?: {
a?: ({ href, children }: { href?: string; children: React.ReactNode }) => JSX.Element;
};
}) => <div>{components?.a?.({ href: 'https://example.com', children: 'Example Link' })}</div>,
}));

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

describe('CompetitionTabs', () => {
it('renders markdown content for tabs', () => {
mockUseWCIF.mockReturnValue({
competitionId: 'LakewoodFall2025',
wcif: undefined,
setTitle: jest.fn(),
});

mockUseCompetitionTabs.mockReturnValue({
data: [
{
id: 1,
competition_id: 'LakewoodFall2025',
name: 'Competitor Info',
content: '# Header\n\nSome **bold** text and [link](https://example.com).',
display_order: 1,
},
],
isLoading: false,
error: null,
} as UseQueryResult<ApiCompetitionTab[], Error>);

render(<CompetitionTabs />);

expect(screen.getByRole('heading', { name: 'Competitor Info' })).toBeInTheDocument();
const link = screen.getByRole('link', { name: 'Example Link' });
expect(link).toHaveAttribute('href', 'https://example.com');
expect(screen.getByText('example.com')).toBeInTheDocument();
expect(screen.getByText('https://example.com')).toBeInTheDocument();
});
});
188 changes: 188 additions & 0 deletions src/pages/Competition/Tabs/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import remarkGfm from 'remark-gfm';
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import ReactMarkdown, { Components } from 'react-markdown';
import { Container } from '@/components/Container';
import { useCompetitionTabs } from '@/hooks/queries/useCompetitionTabs';
import { useWCIF } from '@/providers/WCIFProvider';

type TabWithSlug = ApiCompetitionTab & { slug: string };

const slugify = (value: string) =>
value
.toLowerCase()
.trim()
.replace(/[^\p{L}\p{N}\s-]/gu, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');

const getLinkText = (children: React.ReactNode): string | null => {
if (typeof children === 'string' || typeof children === 'number') {
return String(children);
}

if (Array.isArray(children)) {
return children
.map((child) => getLinkText(child as React.ReactNode))
.filter((value): value is string => Boolean(value))
.join('');
}

return null;
};

const getPreviewData = (href?: string, children?: React.ReactNode) => {
if (!href || !(href.startsWith('http://') || href.startsWith('https://'))) {
return null;
}

try {
const url = new URL(href);
return {
host: url.hostname.replace(/^www\./, ''),
url: href,
title: getLinkText(children) || href,
};
} catch {
return null;
}
};

const MarkdownLink = ({ href, children }: { href?: string; children: React.ReactNode }) => {
if (!href) {
return <span>{children}</span>;
}

const preview = getPreviewData(href, children);

return (
<span className="inline-block w-full">
<a className="break-words link-inline" href={href} target="_blank" rel="noreferrer">
{children}
</a>
{preview && (
<span
className="block p-3 mt-2 text-sm border rounded border-slate-200 bg-slate-50 dark:border-gray-700 dark:bg-gray-900"
role="group"
aria-label={`Link preview for ${preview.host}`}>
<span className="block text-xs tracking-wide uppercase text-slate-500 dark:text-gray-400">
{preview.host}
</span>
<span className="block font-semibold type-body-sm">{preview.title}</span>
<span className="block text-xs break-all text-slate-600 dark:text-gray-300">
{preview.url}
</span>
</span>
)}
</span>
);
};

const markdownComponents: Components = {
h1: ({ children }) => <h3 className="type-title">{children}</h3>,
h2: ({ children }) => <h3 className="type-heading">{children}</h3>,
h3: ({ children }) => <h4 className="type-heading">{children}</h4>,
p: ({ children }) => <p className="leading-relaxed type-body">{children}</p>,
ul: ({ children }) => <ul className="pl-6 space-y-1 list-disc">{children}</ul>,
ol: ({ children }) => <ol className="pl-6 space-y-1 list-decimal">{children}</ol>,
li: ({ children }) => <li className="type-body">{children}</li>,
a: ({ href, children }) => <MarkdownLink href={href}>{children}</MarkdownLink>,
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
blockquote: ({ children }) => (
<blockquote className="pl-4 border-l-4 border-slate-200 text-slate-600 dark:border-gray-700 dark:text-gray-300">
{children}
</blockquote>
),
hr: () => <hr className="border-slate-200 dark:border-gray-700" />,
code: ({ children }) => (
<code className="rounded bg-slate-100 px-1 py-0.5 font-mono text-xs dark:bg-gray-900">
{children}
</code>
),
pre: ({ children }) => (
<pre className="p-3 overflow-x-auto text-xs rounded bg-slate-100 dark:bg-gray-900">
{children}
</pre>
),
table: ({ children }) => (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left border-collapse">{children}</table>
</div>
),
th: ({ children }) => (
<th className="px-3 py-2 font-semibold border-b border-slate-200 dark:border-gray-700">
{children}
</th>
),
td: ({ children }) => (
<td className="px-3 py-2 border-b border-slate-100 dark:border-gray-800">{children}</td>
),
img: ({ src, alt }) => (
<img
src={src}
alt={alt || ''}
className="max-w-full border rounded border-slate-200 dark:border-gray-700"
/>
),
};

export default function CompetitionTabs() {
const { t } = useTranslation();
const { competitionId, setTitle } = useWCIF();
const { data: tabs, error, isLoading } = useCompetitionTabs(competitionId);

const tabsWithSlugs = useMemo<TabWithSlug[]>(() => {
if (!tabs) {
return [];
}

const slugCounts = new Map<string, number>();

return tabs.map((tab) => {
const baseSlug = slugify(tab.name || 'tab');
const currentCount = slugCounts.get(baseSlug) ?? 0;
const slug = currentCount ? `${baseSlug}-${currentCount + 1}` : baseSlug;
slugCounts.set(baseSlug, currentCount + 1);
return { ...tab, slug };
});
}, [tabs]);

useEffect(() => {
setTitle(t('competition.tabs.title'));
}, [setTitle, t]);

if (isLoading) {
return (
<Container className="p-2">
<p className="type-body">{t('common.loading')}</p>
</Container>
);
}

if (error || !tabs) {
return (
<Container className="p-2">
<p className="type-body">{t('competition.tabs.error')}</p>
</Container>
);
}

return (
<div className="flex w-full justify-center">
<Container className="p-2 space-y-4">
{tabsWithSlugs.map((tab) => (
<section key={tab.id} id={tab.slug} className="scroll-mt-16" aria-label={tab.name}>
<div className="flex flex-col p-3 space-y-3 bg-white border rounded border-slate-100 dark:border-gray-700 dark:bg-gray-800">
<h2 className="type-title">{tab.name}</h2>
<div className="space-y-3">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{tab.content}
</ReactMarkdown>
</div>
</div>
</section>
))}
</Container>
</div>
);
}
Loading