diff --git a/.claude/rules/svelte-components.md b/.claude/rules/svelte-components.md
index 6f354556b..bc5b6c3e0 100644
--- a/.claude/rules/svelte-components.md
+++ b/.claude/rules/svelte-components.md
@@ -95,8 +95,8 @@ let groupMetadata = $derived(store?.getMetadata());
```
```svelte
-{#if groupMetadata?.mainTitle}
- {groupMetadata.mainTitle}
+{#if groupMetadata?.title}
+ {groupMetadata.title}
{/if}
```
diff --git a/.claude/skills/add-contest-table-provider/instructions.md b/.claude/skills/add-contest-table-provider/instructions.md
index 7f87f3c5c..098a29600 100644
--- a/.claude/skills/add-contest-table-provider/instructions.md
+++ b/.claude/skills/add-contest-table-provider/instructions.md
@@ -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>`) 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 ` — **expect GREEN**
### Pattern 3: composite
@@ -121,7 +122,7 @@ 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
@@ -129,7 +130,6 @@ Step 0 (seed check) is already done. Confirm the following before touching code:
- [ ] 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**
diff --git a/docs/guides/how-to-add-contest-table-provider.md b/docs/guides/how-to-add-contest-table-provider.md
index 17548bd3a..d4fcafa62 100644
--- a/docs/guides/how-to-add-contest-table-provider.md
+++ b/docs/guides/how-to-add-contest-table-provider.md
@@ -99,6 +99,7 @@ export class MyNewProvider extends ContestTableProviderBase {
getDisplayConfig(): ContestTableDisplayConfig {
return {
/* 未定義 */
+ // columnWrapThreshold?: number // optional: デフォルト8、AOJ系は6
};
}
@@ -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`)のうち必要なフィールドだけ指定すればよい。
---
@@ -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取得
@@ -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);
});
});
```
@@ -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
};
}
```
diff --git a/src/features/tasks/components/contest-table/TaskTable.svelte b/src/features/tasks/components/contest-table/TaskTable.svelte
index ae9dee2d6..f97c8a922 100644
--- a/src/features/tasks/components/contest-table/TaskTable.svelte
+++ b/src/features/tasks/components/contest-table/TaskTable.svelte
@@ -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;
@@ -56,7 +57,6 @@
contestTableProviderGroups[activeContestType as ContestTableProviderGroups],
);
let providers = $derived(providerGroups?.getAllProviders() ?? []);
- let groupMetadata = $derived(providerGroups?.getMetadata());
interface ProviderData {
filteredTaskResults: TaskResults;
@@ -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);
@@ -217,13 +212,6 @@
-
-{#if groupMetadata?.mainTitle}
-
- {groupMetadata.mainTitle}
-
-{/if}
-
@@ -277,7 +265,12 @@
{@const totalColumns = contestTable.headerIds.length}
{#each contestTable.contestIds as contestId (contestId)}
-
+
{#if contestTable.displayConfig.isShownRoundLabel}
{getContestRoundLabel(provider, contestId)}
diff --git a/src/features/tasks/components/contest-table/_utils/contest_table_layout.test.ts b/src/features/tasks/components/contest-table/_utils/contest_table_layout.test.ts
new file mode 100644
index 000000000..563aaba7d
--- /dev/null
+++ b/src/features/tasks/components/contest-table/_utils/contest_table_layout.test.ts
@@ -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');
+ });
+ });
+});
diff --git a/src/features/tasks/components/contest-table/_utils/contest_table_layout.ts b/src/features/tasks/components/contest-table/_utils/contest_table_layout.ts
new file mode 100644
index 000000000..053b6fbcc
--- /dev/null
+++ b/src/features/tasks/components/contest-table/_utils/contest_table_layout.ts
@@ -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';
+}
diff --git a/src/features/tasks/types/contest-table/contest_table_provider.ts b/src/features/tasks/types/contest-table/contest_table_provider.ts
index 9c6aa1373..54b3ff251 100644
--- a/src/features/tasks/types/contest-table/contest_table_provider.ts
+++ b/src/features/tasks/types/contest-table/contest_table_provider.ts
@@ -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;
};
/**
@@ -188,6 +186,8 @@ 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;
@@ -195,4 +195,5 @@ export interface ContestTableDisplayConfig {
roundLabelWidth: string;
tableBodyCellsWidth: string;
isShownTaskIndex: boolean;
+ columnWrapThreshold?: number;
}
diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts b/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts
index b16361130..b5f3f3ed3 100644
--- a/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts
+++ b/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts
@@ -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',
});
});
@@ -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', () => {
diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts b/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts
index 422e442e7..63bf5b406 100644
--- a/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts
+++ b/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts
@@ -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',
},
};
@@ -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,
};
}
diff --git a/src/features/tasks/utils/contest-table/contest_table_provider_base.test.ts b/src/features/tasks/utils/contest-table/contest_table_provider_base.test.ts
index a24d4b8c5..c30191607 100644
--- a/src/features/tasks/utils/contest-table/contest_table_provider_base.test.ts
+++ b/src/features/tasks/utils/contest-table/contest_table_provider_base.test.ts
@@ -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();
+ });
});
});
diff --git a/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts b/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts
index d5a08b324..d8d0f1d55 100644
--- a/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts
+++ b/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts
@@ -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);
diff --git a/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts b/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts
index 6e39c0c46..5bde6224d 100644
--- a/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts
+++ b/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts
@@ -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--) {