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--) {