From b04e5449ece837f7a006a4d76a137daf718fa47e Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 13 Jun 2026 02:05:19 +0000 Subject: [PATCH 1/5] feat(contest): update AojIcpcPrelim titleStyle and remove mainTitle group heading Change heading from h3/text-base/font-normal to h2/text-xl/font-bold to match the visual hierarchy of other contest table groups. Remove mainTitle from the provider group registration to avoid redundant heading alongside per-year titles. Co-Authored-By: Claude Sonnet 4.6 --- .../tasks/utils/contest-table/aoj_icpc_providers.test.ts | 6 +++--- .../tasks/utils/contest-table/aoj_icpc_providers.ts | 6 +++--- .../contest-table/contest_table_provider_groups.test.ts | 1 - .../utils/contest-table/contest_table_provider_groups.ts | 1 - 4 files changed, 6 insertions(+), 8 deletions(-) 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..a3dfca354 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', }); }); 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..64c2c10c6 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', }, }; 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--) { From 20ea6ebd085be4ab2a3267a02283e47da8e8c408 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 13 Jun 2026 08:33:16 +0000 Subject: [PATCH 2/5] refactor(contest): remove unused mainTitle from ContestTablesMetaData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mainTitle was opt-in but never used in practice — the ICPC Prelim group that prompted its addition was updated to use per-provider titleStyle instead. Remove the field, the rendering snippet in TaskTable, and all related docs. Co-Authored-By: Claude Sonnet 4.6 --- .claude/rules/svelte-components.md | 4 ++-- .claude/skills/add-contest-table-provider/instructions.md | 3 +-- docs/guides/how-to-add-contest-table-provider.md | 1 - .../tasks/components/contest-table/TaskTable.svelte | 8 -------- .../tasks/types/contest-table/contest_table_provider.ts | 2 -- 5 files changed, 3 insertions(+), 15 deletions(-) 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..9d3671dde 100644 --- a/.claude/skills/add-contest-table-provider/instructions.md +++ b/.claude/skills/add-contest-table-provider/instructions.md @@ -121,7 +121,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 +129,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..b9200acde 100644 --- a/docs/guides/how-to-add-contest-table-provider.md +++ b/docs/guides/how-to-add-contest-table-provider.md @@ -253,7 +253,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`)のうち必要なフィールドだけ指定すればよい。 --- diff --git a/src/features/tasks/components/contest-table/TaskTable.svelte b/src/features/tasks/components/contest-table/TaskTable.svelte index ae9dee2d6..7c9b880f4 100644 --- a/src/features/tasks/components/contest-table/TaskTable.svelte +++ b/src/features/tasks/components/contest-table/TaskTable.svelte @@ -56,7 +56,6 @@ contestTableProviderGroups[activeContestType as ContestTableProviderGroups], ); let providers = $derived(providerGroups?.getAllProviders() ?? []); - let groupMetadata = $derived(providerGroups?.getMetadata()); interface ProviderData { filteredTaskResults: TaskResults; @@ -217,13 +216,6 @@ - -{#if groupMetadata?.mainTitle} - - {groupMetadata.mainTitle} - -{/if} - 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..637c89db5 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; }; /** From 278f3a3d5ec11eee94b88929f7012ce98587ccdb Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 13 Jun 2026 09:12:56 +0000 Subject: [PATCH 3/5] feat(contest): add columnWrapThreshold to ContestTableDisplayConfig Extracts the hardcoded wrap threshold (8) into a configurable field on ContestTableDisplayConfig, allowing per-provider overrides. AojIcpcPrelim sets it to 6 to match its fixed 6-column layout. Also extracts getBodyRowClasses into a tested utility under _utils/. Co-Authored-By: Claude Sonnet 4.6 --- .../instructions.md | 1 + docs/dev-notes/2026-06-13/plan.md | 132 ++++++++++++++++++ .../how-to-add-contest-table-provider.md | 6 +- .../components/contest-table/TaskTable.svelte | 13 +- .../_utils/contest_table_layout.test.ts | 34 +++++ .../_utils/contest_table_layout.ts | 12 ++ .../contest-table/contest_table_provider.ts | 3 + .../contest-table/aoj_icpc_providers.test.ts | 4 + .../utils/contest-table/aoj_icpc_providers.ts | 1 + .../contest_table_provider_base.test.ts | 6 + 10 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 docs/dev-notes/2026-06-13/plan.md create mode 100644 src/features/tasks/components/contest-table/_utils/contest_table_layout.test.ts create mode 100644 src/features/tasks/components/contest-table/_utils/contest_table_layout.ts diff --git a/.claude/skills/add-contest-table-provider/instructions.md b/.claude/skills/add-contest-table-provider/instructions.md index 9d3671dde..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 diff --git a/docs/dev-notes/2026-06-13/plan.md b/docs/dev-notes/2026-06-13/plan.md new file mode 100644 index 000000000..c750a283b --- /dev/null +++ b/docs/dev-notes/2026-06-13/plan.md @@ -0,0 +1,132 @@ +# ContestTableDisplayConfig に columnWrapThreshold を追加 + +## 概要 + +`getBodyRowClasses(totalColumns)` が列数 > 8 のときレイアウトを切り替えるマジックナンバー `8` をハードコードしている。AOJ ICPC Prelim は6列固定になるため、provider ごとに閾値を設定できるようにする。 + +## 設計方針 + +`ContestTableDisplayConfig` に `columnWrapThreshold?: number` を追加し、AOJ 系 provider のみ `6` を明示する。 + +`getBodyRowClasses` はコンポーネントから `_utils/` に切り出してテスト可能にする。デフォルト `8` の動作(`undefined` 渡し時を含む)はこのユーティリティのテストで保証する。 + +### 却下した代替案 + +- **`ContestTableMetaData` に置く**: 同型は「コンテストの同一性・見出し」の責務。レイアウト閾値は無関係。 +- **変更 provider だけに定数を局所定義**: `ContestTableDisplayConfig` が既にレイアウト設定の責務を担うため、ここに集約するほうが一貫性がある。 + +--- + +## 変更ファイル + +### Phase 1: 型定義 + +**`src/features/tasks/types/contest-table/contest_table_provider.ts`** + +`ContestTableDisplayConfig` に追加: + +```typescript +/** + * @property {number} [columnWrapThreshold] - Column count above which rows always flex-wrap + * (xl:table-row is suppressed). Defaults to 8 at render when unset. + */ +columnWrapThreshold?: number; +``` + +### Phase 2: AOJ ICPC provider + テスト更新 + +**`src/features/tasks/utils/contest-table/aoj_icpc_providers.ts`** + +`AojIcpcPrelimProvider.getDisplayConfig()` の返り値に追加: + +```typescript +columnWrapThreshold: 6, +``` + +**`src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts`** + +既存の `getDisplayConfig` describe ブロックにアサーションを追加: + +```typescript +expect(provider2023.getDisplayConfig().columnWrapThreshold).toBe(6); +``` + +> `contest_table_provider.ts` は型定義のみ(ランタイム挙動なし)のためテスト不要。`pnpm check` が型検査を担う。 + +**`src/features/tasks/utils/contest-table/contest_table_provider_base.test.ts`** + +基底クラスが `columnWrapThreshold` を持たない(`undefined`)ことを明示するテストを追加。コンポーネント側のデフォルト `8` と合わせて「デフォルト = 8」の契約を両端でテストする: + +```typescript +test('columnWrapThreshold is undefined by default (component falls back to 8)', () => { + const provider = new ABSProvider(ContestType.ABS); + expect(provider.getDisplayConfig().columnWrapThreshold).toBeUndefined(); +}); +``` + +### Phase 3: ユーティリティ切り出し + テスト + +**新規: `src/features/tasks/components/contest-table/_utils/contest_table_layout.ts`** + +```typescript +export function getBodyRowClasses(totalColumns: number, wrapThreshold = 8): string { + return totalColumns > wrapThreshold ? 'flex flex-wrap' : 'flex flex-wrap xl:table-row'; +} +``` + +**新規: `src/features/tasks/components/contest-table/_utils/contest_table_layout.test.ts`** + +テストケース: + +- `wrapThreshold` 省略時(`undefined`)は `8` と同じ挙動になること +- `totalColumns <= wrapThreshold` → `'flex flex-wrap xl:table-row'` +- `totalColumns > wrapThreshold` → `'flex flex-wrap'` +- `wrapThreshold: 6` で境界値(6列・7列)が正しく分岐すること + +### Phase 4: コンポーネント更新 + +**`src/features/tasks/components/contest-table/TaskTable.svelte`** + +`getBodyRowClasses` をインポートに切り替え、インライン定義を削除: + +```typescript +import { getBodyRowClasses } from './_utils/contest_table_layout'; +``` + +呼び出し箇所を変更: + +```svelte + +``` + +### Phase 5: ドキュメント更新 + +**`docs/guides/how-to-add-contest-table-provider.md`** の4箇所を更新する: + +| 箇所 | 内容 | +| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | +| L99–103: スケルトン Provider 最小例 | `getDisplayConfig()` の返り値に `columnWrapThreshold` をオプション欄として追記(デフォルト 8、AOJ は 6) | +| L361: 必須テスト項目(全 Provider 共通) | ディスプレイ設定確認の説明に `columnWrapThreshold` を追記 | +| L395–399: Vitest テスト例 | `columnWrapThreshold` のアサーション例を追加 | +| L498–514: よくある間違い `getDisplayConfig()` 属性漏れ | コードブロックに `columnWrapThreshold?: number` をオプション例として追加(コメントでデフォルト値を明記) | + +**`.claude/skills/add-contest-table-provider/instructions.md`** + +Layer 4 Pattern 4(L107)に `titleStyle` と同形式で追記: + +``` +- [ ] If column wrap threshold differs from default (8): return `columnWrapThreshold: N` + from `getDisplayConfig()`; include it in the `getDisplayConfig` assertion +``` + +`SKILL.md` 本体はエントリポイントのみのため更新不要。`rules/`・`AGENTS.md` は該当記述なし。 + +--- + +## 検証 + +```bash +pnpm test:unit aoj_icpc # getDisplayConfig スナップショットがあれば columnWrapThreshold: 6 を追加 +pnpm test:unit # 全ユニットテスト通過 +pnpm check # 型エラーなし +``` diff --git a/docs/guides/how-to-add-contest-table-provider.md b/docs/guides/how-to-add-contest-table-provider.md index b9200acde..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 }; } @@ -358,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取得 @@ -396,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); }); }); ``` @@ -509,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 7c9b880f4..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; @@ -110,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); @@ -269,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..84acf682d --- /dev/null +++ b/src/features/tasks/components/contest-table/_utils/contest_table_layout.test.ts @@ -0,0 +1,34 @@ +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 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..666514da4 --- /dev/null +++ b/src/features/tasks/components/contest-table/_utils/contest_table_layout.ts @@ -0,0 +1,12 @@ +/** + * Returns Tailwind classes for a table body row based on column count. + * + * Rows with more columns than `wrapThreshold` always flex-wrap (xl:table-row suppressed), + * keeping wide tables readable on desktop. Defaults to 8 columns to match ABC212–ABC318. + * + * @param totalColumns - Number of task columns in the row + * @param wrapThreshold - Column count above which xl:table-row is suppressed (default: 8) + */ +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 637c89db5..54b3ff251 100644 --- a/src/features/tasks/types/contest-table/contest_table_provider.ts +++ b/src/features/tasks/types/contest-table/contest_table_provider.ts @@ -186,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; @@ -193,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 a3dfca354..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 @@ -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 64c2c10c6..63bf5b406 100644 --- a/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts +++ b/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts @@ -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(); + }); }); }); From 2c1c1ffb414318d99be9e6c5e4d3f868adf659ba Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 13 Jun 2026 09:18:47 +0000 Subject: [PATCH 4/5] refactor(contest): simplify getBodyRowClasses TSDoc and add missing test case Co-Authored-By: Claude Sonnet 4.6 --- .../contest-table/_utils/contest_table_layout.test.ts | 4 ++++ .../contest-table/_utils/contest_table_layout.ts | 11 ++--------- 2 files changed, 6 insertions(+), 9 deletions(-) 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 index 84acf682d..563aaba7d 100644 --- 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 @@ -27,6 +27,10 @@ describe('getBodyRowClasses', () => { 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 index 666514da4..053b6fbcc 100644 --- a/src/features/tasks/components/contest-table/_utils/contest_table_layout.ts +++ b/src/features/tasks/components/contest-table/_utils/contest_table_layout.ts @@ -1,12 +1,5 @@ -/** - * Returns Tailwind classes for a table body row based on column count. - * - * Rows with more columns than `wrapThreshold` always flex-wrap (xl:table-row suppressed), - * keeping wide tables readable on desktop. Defaults to 8 columns to match ABC212–ABC318. - * - * @param totalColumns - Number of task columns in the row - * @param wrapThreshold - Column count above which xl:table-row is suppressed (default: 8) - */ +// 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'; } From 973991e27d9754e12265724ea970c4d7afdf625e Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 13 Jun 2026 09:23:59 +0000 Subject: [PATCH 5/5] docs: remove completed plan for columnWrapThreshold feature Co-Authored-By: Claude Sonnet 4.6 --- docs/dev-notes/2026-06-13/plan.md | 132 ------------------------------ 1 file changed, 132 deletions(-) delete mode 100644 docs/dev-notes/2026-06-13/plan.md diff --git a/docs/dev-notes/2026-06-13/plan.md b/docs/dev-notes/2026-06-13/plan.md deleted file mode 100644 index c750a283b..000000000 --- a/docs/dev-notes/2026-06-13/plan.md +++ /dev/null @@ -1,132 +0,0 @@ -# ContestTableDisplayConfig に columnWrapThreshold を追加 - -## 概要 - -`getBodyRowClasses(totalColumns)` が列数 > 8 のときレイアウトを切り替えるマジックナンバー `8` をハードコードしている。AOJ ICPC Prelim は6列固定になるため、provider ごとに閾値を設定できるようにする。 - -## 設計方針 - -`ContestTableDisplayConfig` に `columnWrapThreshold?: number` を追加し、AOJ 系 provider のみ `6` を明示する。 - -`getBodyRowClasses` はコンポーネントから `_utils/` に切り出してテスト可能にする。デフォルト `8` の動作(`undefined` 渡し時を含む)はこのユーティリティのテストで保証する。 - -### 却下した代替案 - -- **`ContestTableMetaData` に置く**: 同型は「コンテストの同一性・見出し」の責務。レイアウト閾値は無関係。 -- **変更 provider だけに定数を局所定義**: `ContestTableDisplayConfig` が既にレイアウト設定の責務を担うため、ここに集約するほうが一貫性がある。 - ---- - -## 変更ファイル - -### Phase 1: 型定義 - -**`src/features/tasks/types/contest-table/contest_table_provider.ts`** - -`ContestTableDisplayConfig` に追加: - -```typescript -/** - * @property {number} [columnWrapThreshold] - Column count above which rows always flex-wrap - * (xl:table-row is suppressed). Defaults to 8 at render when unset. - */ -columnWrapThreshold?: number; -``` - -### Phase 2: AOJ ICPC provider + テスト更新 - -**`src/features/tasks/utils/contest-table/aoj_icpc_providers.ts`** - -`AojIcpcPrelimProvider.getDisplayConfig()` の返り値に追加: - -```typescript -columnWrapThreshold: 6, -``` - -**`src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts`** - -既存の `getDisplayConfig` describe ブロックにアサーションを追加: - -```typescript -expect(provider2023.getDisplayConfig().columnWrapThreshold).toBe(6); -``` - -> `contest_table_provider.ts` は型定義のみ(ランタイム挙動なし)のためテスト不要。`pnpm check` が型検査を担う。 - -**`src/features/tasks/utils/contest-table/contest_table_provider_base.test.ts`** - -基底クラスが `columnWrapThreshold` を持たない(`undefined`)ことを明示するテストを追加。コンポーネント側のデフォルト `8` と合わせて「デフォルト = 8」の契約を両端でテストする: - -```typescript -test('columnWrapThreshold is undefined by default (component falls back to 8)', () => { - const provider = new ABSProvider(ContestType.ABS); - expect(provider.getDisplayConfig().columnWrapThreshold).toBeUndefined(); -}); -``` - -### Phase 3: ユーティリティ切り出し + テスト - -**新規: `src/features/tasks/components/contest-table/_utils/contest_table_layout.ts`** - -```typescript -export function getBodyRowClasses(totalColumns: number, wrapThreshold = 8): string { - return totalColumns > wrapThreshold ? 'flex flex-wrap' : 'flex flex-wrap xl:table-row'; -} -``` - -**新規: `src/features/tasks/components/contest-table/_utils/contest_table_layout.test.ts`** - -テストケース: - -- `wrapThreshold` 省略時(`undefined`)は `8` と同じ挙動になること -- `totalColumns <= wrapThreshold` → `'flex flex-wrap xl:table-row'` -- `totalColumns > wrapThreshold` → `'flex flex-wrap'` -- `wrapThreshold: 6` で境界値(6列・7列)が正しく分岐すること - -### Phase 4: コンポーネント更新 - -**`src/features/tasks/components/contest-table/TaskTable.svelte`** - -`getBodyRowClasses` をインポートに切り替え、インライン定義を削除: - -```typescript -import { getBodyRowClasses } from './_utils/contest_table_layout'; -``` - -呼び出し箇所を変更: - -```svelte - -``` - -### Phase 5: ドキュメント更新 - -**`docs/guides/how-to-add-contest-table-provider.md`** の4箇所を更新する: - -| 箇所 | 内容 | -| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | -| L99–103: スケルトン Provider 最小例 | `getDisplayConfig()` の返り値に `columnWrapThreshold` をオプション欄として追記(デフォルト 8、AOJ は 6) | -| L361: 必須テスト項目(全 Provider 共通) | ディスプレイ設定確認の説明に `columnWrapThreshold` を追記 | -| L395–399: Vitest テスト例 | `columnWrapThreshold` のアサーション例を追加 | -| L498–514: よくある間違い `getDisplayConfig()` 属性漏れ | コードブロックに `columnWrapThreshold?: number` をオプション例として追加(コメントでデフォルト値を明記) | - -**`.claude/skills/add-contest-table-provider/instructions.md`** - -Layer 4 Pattern 4(L107)に `titleStyle` と同形式で追記: - -``` -- [ ] If column wrap threshold differs from default (8): return `columnWrapThreshold: N` - from `getDisplayConfig()`; include it in the `getDisplayConfig` assertion -``` - -`SKILL.md` 本体はエントリポイントのみのため更新不要。`rules/`・`AGENTS.md` は該当記述なし。 - ---- - -## 検証 - -```bash -pnpm test:unit aoj_icpc # getDisplayConfig スナップショットがあれば columnWrapThreshold: 6 を追加 -pnpm test:unit # 全ユニットテスト通過 -pnpm check # 型エラーなし -```