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
4 changes: 2 additions & 2 deletions .claude/rules/svelte-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ let groupMetadata = $derived(store?.getMetadata());
```

```svelte
{#if groupMetadata?.mainTitle}
<Heading>{groupMetadata.mainTitle}</Heading>
{#if groupMetadata?.title}
<Heading>{groupMetadata.title}</Heading>
{/if}
```

Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/add-contest-table-provider/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ Step 0 (seed check) is already done. Confirm the following before touching code:
- [ ] If display title needs transformation (e.g. prepend "A. "): override `getTaskLabels` to return `{ [contestId]: { index: letter } }`; do NOT mutate title in `generateTable`
- [ ] Write override map (`Record<string, Record<string, value>>`) for known edge cases; test the override path by mutating the export in `beforeEach` and cleaning up in `afterEach`
- [ ] If provider headings need non-default font/weight/gap: return `titleStyle` (`headingTag` / `fontSize` / `fontWeight` / `bottomGap`) from `getMetadata()`; include all set fields in the `titleStyle` assertion
- [ ] If column wrap threshold differs from default (8): return `columnWrapThreshold: N` from `getDisplayConfig()`; include it in the `getDisplayConfig` assertion
- [ ] `pnpm test:unit <providers.test.ts>` — **expect GREEN**

### Pattern 3: composite
Expand All @@ -121,15 +122,14 @@ Step 0 (seed check) is already done. Confirm the following before touching code:
## Layer 5 — Group registration (TDD)

- [ ] Update `contest_table_provider_groups.test.ts`:
- New group name string, `buttonLabel`, `ariaLabel` (add `mainTitle` if used)
- New group name string, `buttonLabel`, `ariaLabel`
- `getSize()` incremented to reflect the new provider count
- Add `getProvider(ContestType.XXX)` assertion
- Add import of new Provider class
- [ ] `pnpm test:unit contest_table_provider_groups.test.ts` — **expect RED**
- [ ] Update `contest_table_provider_groups.ts`:
- Add import of new Provider class
- Update group name string, `buttonLabel`, `ariaLabel`
- Add `mainTitle: 'XXX'` if the group needs a single h2 heading rendered above all providers (opt-in; omit when not needed)
- Add `new XXXProvider(ContestType.XXX)` to `addProviders()`
- [ ] `pnpm test:unit src/features/tasks/utils/contest-table/` — **expect GREEN**

Expand Down
7 changes: 5 additions & 2 deletions docs/guides/how-to-add-contest-table-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export class MyNewProvider extends ContestTableProviderBase {
getDisplayConfig(): ContestTableDisplayConfig {
return {
/* 未定義 */
// columnWrapThreshold?: number // optional: デフォルト8、AOJ系は6
};
}

Expand Down Expand Up @@ -253,7 +254,6 @@ afterEach(() => {
累積する(Issue [#3636](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3636))。
位置ラベルは`getTaskLabels(filtered)`が`{ [contestId]: { index: letter } }`を返し、`TaskTableBodyCell`の`$derived displayTitle`で`formatAojIcpcTitle` を呼ぶ設計にすること。

- グループ全体に一度だけ大見出し(h2)を表示したい場合は、グループ登録時に `mainTitle: 'XXX'` を追加する。省略すると描画されない。個々の provider の `title` が冗長になるなら年や回だけに絞っても良い(ICPC 国内予選は敢えて重複させた)。
- provider 見出しのフォント・太字・余白をデフォルトから変えたい場合は `getMetadata()` で `titleStyle` を返す。`ContestTableTitleStyle`(`headingTag` / `fontSize` / `fontWeight` / `bottomGap`)のうち必要なフィールドだけ指定すればよい。

---
Expand Down Expand Up @@ -359,7 +359,7 @@ class TessokuBookSectionProvider extends TessokuBookProvider {

1. 基本的なフィルタリング検証(contest_id / 型)
2. メタデータ取得(title、abbreviationName)
3. ディスプレイ設定確認(isShownHeader、isShownRoundLabel 等)
3. ディスプレイ設定確認(isShownHeader、isShownRoundLabel、columnWrapThreshold 等)
4. ラウンドラベルフォーマット(`getContestRoundLabel()`)
5. テーブル生成構造(問題数確認)
6. ヘッダー・ラウンドID取得
Expand Down Expand Up @@ -397,6 +397,8 @@ describe('MyNewProvider', () => {
const provider = new MyNewProvider(ContestType.MY_NEW);
const config = provider.getDisplayConfig();
expect(config.isShownHeader).toBe(true);
// columnWrapThreshold を明示する場合はアサーションを追加
// expect(config.columnWrapThreshold).toBe(6);
});
});
```
Expand Down Expand Up @@ -510,6 +512,7 @@ getDisplayConfig() {
roundLabelWidth: 'xl:w-16',
tableBodyCellsWidth: 'w-8 h-8 px-1 py-1',
isShownTaskIndex: false, // 必ず指定
// columnWrapThreshold?: number // optional: 省略時は8(デフォルト)、AOJ系は6
};
}
```
Expand Down
21 changes: 7 additions & 14 deletions src/features/tasks/components/contest-table/TaskTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import { getBackgroundColorFrom } from '$lib/services/submission_status';
import { areAllTasksAccepted } from '$lib/utils/task';
import { createContestTaskPairKey } from '$lib/utils/contest_task_pair';
import { getBodyRowClasses } from './_utils/contest_table_layout';

interface Props {
taskResults: TaskResults;
Expand All @@ -56,7 +57,6 @@
contestTableProviderGroups[activeContestType as ContestTableProviderGroups],
);
let providers = $derived(providerGroups?.getAllProviders() ?? []);
let groupMetadata = $derived(providerGroups?.getMetadata());

interface ProviderData {
filteredTaskResults: TaskResults;
Expand Down Expand Up @@ -111,11 +111,6 @@
return provider.getContestRoundLabel(contestId);
}

// More than 8 columns will wrap to the next line to align with ABC212 〜 ABC318 (8 tasks per contest).
function getBodyRowClasses(totalColumns: number): string {
return totalColumns > 8 ? 'flex flex-wrap' : 'flex flex-wrap xl:table-row';
}

function getRoundLabelClasses(contestTable: ProviderData, contestId: string): string {
const tasks = Object.values(contestTable.innerTaskTable[contestId]);
const bgColor = getRoundLabelBgColor(tasks);
Expand Down Expand Up @@ -217,13 +212,6 @@
</div>
</div>

<!-- Group-level main heading: rendered once above all providers when opted in. -->
{#if groupMetadata?.mainTitle}
<Heading tag="h2" class="text-2xl pb-3 text-gray-900 dark:text-white">
{groupMetadata.mainTitle}
</Heading>
{/if}

<!-- TODO: ページネーションを実装 -->
<!-- See: -->
<!-- https://github.com/kenkoooo/AtCoderProblems/blob/master/atcoder-problems-frontend/src/pages/TablePage/AtCoderRegularTable.tsx -->
Expand Down Expand Up @@ -277,7 +265,12 @@
{@const totalColumns = contestTable.headerIds.length}

{#each contestTable.contestIds as contestId (contestId)}
<TableBodyRow class={getBodyRowClasses(totalColumns)}>
<TableBodyRow
class={getBodyRowClasses(
totalColumns,
contestTable.displayConfig.columnWrapThreshold,
)}
>
{#if contestTable.displayConfig.isShownRoundLabel}
<TableBodyCell class={getRoundLabelClasses(contestTable, contestId)}>
{getContestRoundLabel(provider, contestId)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, test, expect } from 'vitest';

import { getBodyRowClasses } from './contest_table_layout';

describe('getBodyRowClasses', () => {
describe('default threshold (8)', () => {
test('returns xl:table-row class when totalColumns equals threshold', () => {
expect(getBodyRowClasses(8)).toBe('flex flex-wrap xl:table-row');
});

test('returns xl:table-row class when totalColumns is below threshold', () => {
expect(getBodyRowClasses(7)).toBe('flex flex-wrap xl:table-row');
});

test('returns flex-wrap-only class when totalColumns exceeds threshold', () => {
expect(getBodyRowClasses(9)).toBe('flex flex-wrap');
});

test('undefined wrapThreshold behaves the same as default 8', () => {
expect(getBodyRowClasses(8, undefined)).toBe(getBodyRowClasses(8));
expect(getBodyRowClasses(9, undefined)).toBe(getBodyRowClasses(9));
});
});

describe('custom threshold (6)', () => {
test('returns xl:table-row class when totalColumns equals threshold', () => {
expect(getBodyRowClasses(6, 6)).toBe('flex flex-wrap xl:table-row');
});

test('returns xl:table-row class when totalColumns is below threshold', () => {
expect(getBodyRowClasses(5, 6)).toBe('flex flex-wrap xl:table-row');
});

test('returns flex-wrap-only class when totalColumns exceeds threshold', () => {
expect(getBodyRowClasses(7, 6)).toBe('flex flex-wrap');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Rows with more columns than wrapThreshold always flex-wrap (xl:table-row suppressed).
// Default 8 matches ABC212–ABC318 (8 tasks per contest).
export function getBodyRowClasses(totalColumns: number, wrapThreshold = 8): string {
return totalColumns > wrapThreshold ? 'flex flex-wrap' : 'flex flex-wrap xl:table-row';
}
Original file line number Diff line number Diff line change
Expand Up @@ -171,12 +171,10 @@ export type ContestTableTitleStyle = {
* @typeof {Object} ContestTablesMetaData
* @property {string} buttonLabel - The text to display on the contest table's primary action button.
* @property {string} ariaLabel - Accessibility label for screen readers describing the contest table.
* @property {string} [mainTitle] - Group-level heading rendered once above the providers (opt-in). Not rendered when unset.
*/
export type ContestTablesMetaData = {
buttonLabel: string;
ariaLabel: string;
mainTitle?: string;
};

/**
Expand All @@ -188,11 +186,14 @@ export type ContestTablesMetaData = {
* @property {string} roundLabelWidth - tailwind CSS width for the round label column, e.g., "xl:w-16" or "xl:w-20"
* @property {string} tableBodyCellsWidth - tailwind CSS width for the table body cells, e.g., "w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 px-1 py-1"
* @property {boolean} isShownTaskIndex - Whether to display task index in the contest table cells
* @property {number} [columnWrapThreshold] - Column count above which rows always flex-wrap
* (xl:table-row is suppressed). Defaults to 8 at render when unset.
*/
export interface ContestTableDisplayConfig {
isShownHeader: boolean;
isShownRoundLabel: boolean;
roundLabelWidth: string;
tableBodyCellsWidth: string;
isShownTaskIndex: boolean;
columnWrapThreshold?: number;
}
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,9 @@ describe('AojIcpcPrelimProvider', () => {

test('returns titleStyle with text-base font size, pb-1 bottom gap, and h3 heading tag', () => {
expect(provider2023.getMetadata().titleStyle).toEqual({
headingTag: 'h3',
fontSize: 'text-base',
fontWeight: 'font-normal',
headingTag: 'h2',
fontSize: 'text-xl',
fontWeight: 'font-bold',
bottomGap: 'pb-1',
});
});
Expand Down Expand Up @@ -267,6 +267,10 @@ describe('AojIcpcPrelimProvider', () => {
'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 px-1 py-2',
);
});

test('returns columnWrapThreshold as 6', () => {
expect(provider2023.getDisplayConfig().columnWrapThreshold).toBe(6);
});
});

describe('getContestRoundLabel', () => {
Expand Down
7 changes: 4 additions & 3 deletions src/features/tasks/utils/contest-table/aoj_icpc_providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ export class AojIcpcPrelimProvider extends ContestTableProviderBase {
title: `ICPC 国内予選 ${this.year}`,
abbreviationName: `icpcPrelim${this.year}`,
titleStyle: {
headingTag: 'h3',
fontSize: 'text-base',
fontWeight: 'font-normal',
headingTag: 'h2',
fontSize: 'text-xl',
fontWeight: 'font-bold',
bottomGap: 'pb-1',
},
};
Expand All @@ -49,6 +49,7 @@ export class AojIcpcPrelimProvider extends ContestTableProviderBase {
roundLabelWidth: '',
tableBodyCellsWidth: 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 px-1 py-2',
isShownTaskIndex: true,
columnWrapThreshold: 6,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,11 @@ describe('ContestTableProviderBase', () => {

expect(provider.getTaskLabels(nonEmpty)).toEqual({});
});

test('columnWrapThreshold is undefined by default (component falls back to 8)', () => {
const provider = new ABSProvider(ContestType.ABS);

expect(provider.getDisplayConfig().columnWrapThreshold).toBeUndefined();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,6 @@ describe('prepareContestProviderPresets', () => {
expect(group.getMetadata()).toEqual({
buttonLabel: 'ICPC 国内予選',
ariaLabel: 'Filter ICPC Domestic Preliminary',
mainTitle: 'ICPC 国内予選',
});
expect(group.getSize()).toBe(ICPC_PRELIM_LATEST_YEAR - ICPC_PRELIM_OLDEST_YEAR + 1); // 28
expect(group.getProvider(ContestType.AOJ_ICPC, '2023')).toBeInstanceOf(AojIcpcPrelimProvider);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,6 @@ export const prepareContestProviderPresets = () => {
const group = new ContestTableProviderGroup('ICPC 国内予選', {
buttonLabel: 'ICPC 国内予選',
ariaLabel: 'Filter ICPC Domestic Preliminary',
mainTitle: 'ICPC 国内予選',
});
// Iterate from latest to oldest so the newest year's table renders on top.
for (let year = ICPC_PRELIM_LATEST_YEAR; year >= ICPC_PRELIM_OLDEST_YEAR; year--) {
Expand Down
Loading