From b09c2a6a3991a13e248c13e965e51a5f259fe75e Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 24 Jun 2026 06:40:32 +0000 Subject: [PATCH 1/4] feat(contest-table): add AWC0100 EDPC-format provider and rename AWC0001OnwardsProvider to AWC0001To0099Provider AWC0100 (special edition, 15 tasks A-O) uses EDPC display format (no header, no round label, task index shown). Existing AWC provider narrowed to 0001-0099 range. Provider key collision avoided via section parameter. Co-Authored-By: Claude Sonnet 4.6 --- .../awc0100-edpc-table-provider/plan.md | 162 ++++++++++++++++++ prisma/tasks.ts | 105 ++++++++++++ .../contest-table/contest_table_provider.ts | 57 +++++- .../utils/contest-table/awc_provider.test.ts | 122 +++++++++++-- .../tasks/utils/contest-table/awc_provider.ts | 46 ++++- .../contest_table_provider_groups.test.ts | 8 +- .../contest_table_provider_groups.ts | 6 +- 7 files changed, 476 insertions(+), 30 deletions(-) create mode 100644 docs/dev-notes/2026-06-23/awc0100-edpc-table-provider/plan.md diff --git a/docs/dev-notes/2026-06-23/awc0100-edpc-table-provider/plan.md b/docs/dev-notes/2026-06-23/awc0100-edpc-table-provider/plan.md new file mode 100644 index 000000000..da770143b --- /dev/null +++ b/docs/dev-notes/2026-06-23/awc0100-edpc-table-provider/plan.md @@ -0,0 +1,162 @@ +# AWC0100 特別回 EDPC 形式テーブルプロバイダ追加 + +## 概要 + +AWC(AtCoder Weekday Contest)の特別回 AWC0100(A〜O 15 問構成)を、EDPC と同じ形式(ヘッダ非表示・ラウンドラベル非表示・タスク記号表示)で表示するためのプロバイダを追加する。既存の `AWC0001OnwardsProvider` は AWC0001〜0099 を対象とする `AWC0001To0099Provider` に rename する。 + +## 設計方針 + +### ContestType の再利用 + +`classifyContest` は `/^awc\d{4}$/` で `awc0100` を既存の `ContestType.AWC` に分類する。新しい ContestType の追加は不要。 + +### Provider key 衝突の回避 + +同一グループに `ContestType.AWC` を使う 2 つのプロバイダを追加するため、`AWC0100Provider` のコンストラクタで `super(contestType, '0100')` を呼び、provider key を `AWC::0100` とする。`AWC0001To0099Provider` は section なし(key = `AWC`)のため衝突しない。 + +### 表示順 + +`addProvider` の呼び出し順が表示順(先 = 上)になるため、AWC0100 を先に追加する。 + +### グループキー維持 + +`contestTableProviderGroups` の `awc0001Onwards` キーは変更しない(UI・store への波及を避けるため)。 + +## 却下した代替案 + +- **新 ContestType `AWC_0100` を追加する案**: Prisma スキーマ変更・`classifyContest` 更新・contest.ts 更新が必要で大掛かり。ContestType.AWC の再利用で section による区別が可能なため不採用。 +- **AWC0100 を独立グループにする案**: ユーザー要件として「AWC グループに同居」が確定したため不採用。 + +## 変更ファイルと内容 + +### Layer 4 — Provider クラス(TDD) + +#### `src/features/tasks/utils/contest-table/awc_provider.ts` + +**AWC0001OnwardsProvider → AWC0001To0099Provider** + +| 変更点 | 旧値 | 新値 | +| ------------------ | ------------------------------------ | ---------------------------------------- | +| クラス名 | `AWC0001OnwardsProvider` | `AWC0001To0099Provider` | +| filter range | `contestRound <= 9999` | `contestRound <= 99` | +| `title` | `'AtCoder Weekday Contest 0001 〜 '` | `'AtCoder Weekday Contest 0001 〜 0099'` | +| `abbreviationName` | `'awc0001Onwards'` | `'awc0001To0099'` | + +**AWC0100Provider(新規追加)** + +```typescript +// AWC0100 (special edition, 15 tasks: A-O) +export class AWC0100Provider extends ContestTableProviderBase { + constructor(contestType: ContestType) { + super(contestType, '0100'); // provider key = 'AWC::0100' + } + + protected setFilterCondition() { + return (taskResult: TaskResult) => { + if (classifyContest(taskResult.contest_id) !== this.contestType) return false; + return taskResult.contest_id === 'awc0100'; + }; + } + + getMetadata() { + return { title: 'AtCoder Weekday Contest 0100', abbreviationName: 'awc0100' }; + } + + getDisplayConfig() { + return { + isShownHeader: false, + isShownRoundLabel: false, + roundLabelWidth: '', + tableBodyCellsWidth: 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 2xl:w-1/7 px-1 py-2', + isShownTaskIndex: true, + }; + } + + getContestRoundLabel(_contestId: string): string { + return ''; + } +} +``` + +#### `src/features/tasks/fixtures/contest-table/contest_table_provider.ts` + +- `taskResultsForAWC0001OnwardsProvider` → `taskResultsForAWC0001To0099Provider` に rename(中身は変更不要) +- `taskResultsForAWC0100Provider` を新規追加(awc0100 の A〜O 15 タスク) + +#### `src/features/tasks/utils/contest-table/awc_provider.test.ts` + +- import・describe・test 内の `AWC0001OnwardsProvider` → `AWC0001To0099Provider` に rename +- fixture 参照を `taskResultsForAWC0001To0099Provider` に変更 +- range テスト: awc0099 が含まれること+awc0100 が除外されること を追加 +- metadata テスト: 新しい title / abbreviationName に更新 +- `AWC0100Provider` の describe ブロックを新規追加: + - awc0100 のみフィルタされること + - awc0001/awc0099 が除外されること(結合 fixture を使用) + - metadata(title / abbreviationName) + - displayConfig(EDPC 形式の全フィールド) + - getContestRoundLabel が空文字を返すこと + - generateTable で 15 問(A〜O 順)が生成されること + - 空入力の処理 + +### Layer 5 — グループ登録(TDD) + +#### `src/features/tasks/utils/contest-table/contest_table_provider_groups.ts` + +```typescript +// import 変更 +import { AWC0001To0099Provider, AWC0100Provider } from './awc_provider'; + +// preset 変更(AWC0001Onwards の preset 関数を更新) +AWC0001Onwards: () => + new ContestTableProviderGroup(`AWC 0001 Onwards`, { + buttonLabel: 'AWC 0001 〜 ', + ariaLabel: 'Filter contests from AWC 0001 onwards', + }) + .addProvider(new AWC0100Provider(ContestType.AWC)) // AWC0100 を先(上)に表示 + .addProvider(new AWC0001To0099Provider(ContestType.AWC)), // AWC0001-0099 を後(下)に表示 +``` + +`contestTableProviderGroups` のキー `awc0001Onwards` は変更しない。 + +#### `src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts` + +AWC0001Onwards テストを更新: + +```typescript +expect(group.getSize()).toBe(2); // 1 → 2 +expect(group.getProvider(ContestType.AWC, '0100')).toBeInstanceOf(AWC0100Provider); // 新規 +expect(group.getProvider(ContestType.AWC)).toBeInstanceOf(AWC0001To0099Provider); // 更新 +``` + +## 変更不要なファイル + +| ファイル | 理由 | +| --------------------------------- | -------------------------------------- | +| `prisma/schema.prisma` | ContestType.AWC を再利用 | +| `src/lib/types/contest.ts` | 同上 | +| `src/lib/utils/contest.ts` | awc0100 は既存 regex で AWC に分類済み | +| `contest_table_provider_base.ts` | section サポート済み | +| `contest_table_provider_group.ts` | section ベースのキー解決サポート済み | +| `active_contest_type.svelte.ts` | awc0001Onwards キー維持のため変更不要 | +| シードデータ(`prisma/tasks.ts`) | 今回スコープ外(後日追加) | + +## 検証手順 + +```bash +# TDD サイクル(RED → GREEN) +pnpm test:unit -- awc_provider +pnpm test:unit -- contest_table_provider_groups + +# 全 unit test +pnpm test:unit + +# 型チェック・lint +pnpm check +pnpm lint +``` + +### 期待される動作 + +- `getProvider(ContestType.AWC)` → `AWC0001To0099Provider`(round 1〜99) +- `getProvider(ContestType.AWC, '0100')` → `AWC0100Provider`(AWC0100 特別回) +- AWC グループのフィルタボタンを押すと AWC0100 テーブル(A〜O)が AWC0001〜0099 テーブルの上に表示される diff --git a/prisma/tasks.ts b/prisma/tasks.ts index ec1d33f2b..cc2be5877 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -8354,6 +8354,111 @@ export const tasks = [ name: '4/N', title: 'C. 4/N', }, + { + id: 'awc0100_a', + contest_id: 'awc0100', + problem_index: 'A', + name: 'Task A', + title: 'A. Task A', + }, + { + id: 'awc0100_b', + contest_id: 'awc0100', + problem_index: 'B', + name: 'Task B', + title: 'B. Task B', + }, + { + id: 'awc0100_c', + contest_id: 'awc0100', + problem_index: 'C', + name: 'Task C', + title: 'C. Task C', + }, + { + id: 'awc0100_d', + contest_id: 'awc0100', + problem_index: 'D', + name: 'Task D', + title: 'D. Task D', + }, + { + id: 'awc0100_e', + contest_id: 'awc0100', + problem_index: 'E', + name: 'Task E', + title: 'E. Task E', + }, + { + id: 'awc0100_f', + contest_id: 'awc0100', + problem_index: 'F', + name: 'Task F', + title: 'F. Task F', + }, + { + id: 'awc0100_g', + contest_id: 'awc0100', + problem_index: 'G', + name: 'Task G', + title: 'G. Task G', + }, + { + id: 'awc0100_h', + contest_id: 'awc0100', + problem_index: 'H', + name: 'Task H', + title: 'H. Task H', + }, + { + id: 'awc0100_i', + contest_id: 'awc0100', + problem_index: 'I', + name: 'Task I', + title: 'I. Task I', + }, + { + id: 'awc0100_j', + contest_id: 'awc0100', + problem_index: 'J', + name: 'Task J', + title: 'J. Task J', + }, + { + id: 'awc0100_k', + contest_id: 'awc0100', + problem_index: 'K', + name: 'Task K', + title: 'K. Task K', + }, + { + id: 'awc0100_l', + contest_id: 'awc0100', + problem_index: 'L', + name: 'Task L', + title: 'L. Task L', + }, + { + id: 'awc0100_m', + contest_id: 'awc0100', + problem_index: 'M', + name: 'Task M', + title: 'M. Task M', + }, + { + id: 'awc0100_n', + contest_id: 'awc0100', + problem_index: 'N', + name: 'Task N', + title: 'N. Task N', + }, + { + id: 'awc0100_o', + contest_id: 'awc0100', + problem_index: 'O', + name: 'Task O', + title: 'O. Task O', + }, { id: 'awc0005_e', contest_id: 'awc0005', diff --git a/src/features/tasks/fixtures/contest-table/contest_table_provider.ts b/src/features/tasks/fixtures/contest-table/contest_table_provider.ts index 818ca3058..707c15ea9 100644 --- a/src/features/tasks/fixtures/contest-table/contest_table_provider.ts +++ b/src/features/tasks/fixtures/contest-table/contest_table_provider.ts @@ -836,7 +836,7 @@ export const taskResultsForACLProvider: TaskResults = [ acl1_f, ]; -// AWC 0001 onwards: 5 tasks (A, B, C, D, E) +// AWC 0001-0099: 5 tasks (A, B, C, D, E) // Multiple contests to test range filtering const [awc0001_a, awc0001_b, awc0001_c, awc0001_d, awc0001_e] = createContestTasks('awc0001', [ { taskTableIndex: 'A', statusName: AC }, @@ -862,7 +862,7 @@ const [awc0099_a, awc0099_b, awc0099_c, awc0099_d, awc0099_e] = createContestTas { taskTableIndex: 'E', statusName: AC_WITH_EDITORIAL }, ]); -export const taskResultsForAWC0001OnwardsProvider: TaskResults = [ +export const taskResultsForAWC0001To0099Provider: TaskResults = [ awc0001_a, awc0001_b, awc0001_c, @@ -879,3 +879,56 @@ export const taskResultsForAWC0001OnwardsProvider: TaskResults = [ awc0099_d, awc0099_e, ]; + +// AWC0100 (special edition): 15 tasks (A-O) +const [ + awc0100_a, + awc0100_b, + awc0100_c, + awc0100_d, + awc0100_e, + awc0100_f, + awc0100_g, + awc0100_h, + awc0100_i, + awc0100_j, + awc0100_k, + awc0100_l, + awc0100_m, + awc0100_n, + awc0100_o, +] = createContestTasks('awc0100', [ + { taskTableIndex: 'A', statusName: AC }, + { taskTableIndex: 'B', statusName: AC }, + { taskTableIndex: 'C', statusName: AC_WITH_EDITORIAL }, + { taskTableIndex: 'D', statusName: AC }, + { taskTableIndex: 'E', statusName: TRYING }, + { taskTableIndex: 'F', statusName: AC_WITH_EDITORIAL }, + { taskTableIndex: 'G', statusName: TRYING }, + { taskTableIndex: 'H', statusName: PENDING }, + { taskTableIndex: 'I', statusName: PENDING }, + { taskTableIndex: 'J', statusName: PENDING }, + { taskTableIndex: 'K', statusName: PENDING }, + { taskTableIndex: 'L', statusName: PENDING }, + { taskTableIndex: 'M', statusName: PENDING }, + { taskTableIndex: 'N', statusName: PENDING }, + { taskTableIndex: 'O', statusName: PENDING }, +]); + +export const taskResultsForAWC0100Provider: TaskResults = [ + awc0100_a, + awc0100_b, + awc0100_c, + awc0100_d, + awc0100_e, + awc0100_f, + awc0100_g, + awc0100_h, + awc0100_i, + awc0100_j, + awc0100_k, + awc0100_l, + awc0100_m, + awc0100_n, + awc0100_o, +]; diff --git a/src/features/tasks/utils/contest-table/awc_provider.test.ts b/src/features/tasks/utils/contest-table/awc_provider.test.ts index e27a74239..abfc0445a 100644 --- a/src/features/tasks/utils/contest-table/awc_provider.test.ts +++ b/src/features/tasks/utils/contest-table/awc_provider.test.ts @@ -3,38 +3,43 @@ import { describe, test, expect } from 'vitest'; import { ContestType } from '$lib/types/contest'; import type { TaskResults } from '$lib/types/task'; -import { AWC0001OnwardsProvider } from './awc_provider'; -import { taskResultsForAWC0001OnwardsProvider } from '$features/tasks/fixtures/contest-table/contest_table_provider'; +import { AWC0001To0099Provider, AWC0100Provider } from './awc_provider'; +import { + taskResultsForAWC0001To0099Provider, + taskResultsForAWC0100Provider, +} from '$features/tasks/fixtures/contest-table/contest_table_provider'; -describe('AWC0001OnwardsProvider', () => { +describe('AWC0001To0099Provider', () => { test('expects to filter tasks to include only AWC contests', () => { - const provider = new AWC0001OnwardsProvider(ContestType.AWC); - const filtered = provider.filter(taskResultsForAWC0001OnwardsProvider); + const provider = new AWC0001To0099Provider(ContestType.AWC); + const filtered = provider.filter(taskResultsForAWC0001To0099Provider); expect(filtered.length).toBeGreaterThan(0); expect(filtered.every((task) => task.contest_id.startsWith('awc'))).toBe(true); expect(filtered.every((task) => /^awc\d{4}$/.test(task.contest_id))).toBe(true); }); - test('expects to filter by range (awc0001 to awc9999)', () => { - const provider = new AWC0001OnwardsProvider(ContestType.AWC); - const filtered = provider.filter(taskResultsForAWC0001OnwardsProvider); + test('expects to filter by range (awc0001 to awc0099)', () => { + const provider = new AWC0001To0099Provider(ContestType.AWC); + const combined = [...taskResultsForAWC0001To0099Provider, ...taskResultsForAWC0100Provider]; + const filtered = provider.filter(combined); expect(filtered.some((task) => task.contest_id === 'awc0001')).toBe(true); expect(filtered.some((task) => task.contest_id === 'awc0002')).toBe(true); expect(filtered.some((task) => task.contest_id === 'awc0099')).toBe(true); + expect(filtered.some((task) => task.contest_id === 'awc0100')).toBe(false); }); test('expects to get correct metadata', () => { - const provider = new AWC0001OnwardsProvider(ContestType.AWC); + const provider = new AWC0001To0099Provider(ContestType.AWC); const metadata = provider.getMetadata(); - expect(metadata.title).toBe('AtCoder Weekday Contest 0001 〜 '); - expect(metadata.abbreviationName).toBe('awc0001Onwards'); + expect(metadata.title).toBe('AtCoder Weekday Contest 0001 〜 0099'); + expect(metadata.abbreviationName).toBe('awc0001To0099'); }); test('expects to return correct display config', () => { - const provider = new AWC0001OnwardsProvider(ContestType.AWC); + const provider = new AWC0001To0099Provider(ContestType.AWC); const config = provider.getDisplayConfig(); expect(config.isShownHeader).toBe(true); @@ -45,7 +50,7 @@ describe('AWC0001OnwardsProvider', () => { }); test('expects to format contest round label correctly', () => { - const provider = new AWC0001OnwardsProvider(ContestType.AWC); + const provider = new AWC0001To0099Provider(ContestType.AWC); expect(provider.getContestRoundLabel('awc0001')).toBe('0001'); expect(provider.getContestRoundLabel('awc0002')).toBe('0002'); @@ -53,8 +58,8 @@ describe('AWC0001OnwardsProvider', () => { }); test('expects to generate table for multiple AWC contests', () => { - const provider = new AWC0001OnwardsProvider(ContestType.AWC); - const filtered = provider.filter(taskResultsForAWC0001OnwardsProvider); + const provider = new AWC0001To0099Provider(ContestType.AWC); + const filtered = provider.filter(taskResultsForAWC0001To0099Provider); const table = provider.generateTable(filtered); expect(Object.keys(table).length).toBeGreaterThan(0); @@ -64,8 +69,8 @@ describe('AWC0001OnwardsProvider', () => { }); test('expects each AWC contest to have 5 problems (A-E)', () => { - const provider = new AWC0001OnwardsProvider(ContestType.AWC); - const filtered = provider.filter(taskResultsForAWC0001OnwardsProvider); + const provider = new AWC0001To0099Provider(ContestType.AWC); + const filtered = provider.filter(taskResultsForAWC0001To0099Provider); const table = provider.generateTable(filtered); Object.entries(table).forEach(([_contestId, problems]) => { @@ -76,7 +81,88 @@ describe('AWC0001OnwardsProvider', () => { }); test('expects to handle empty task results', () => { - const provider = new AWC0001OnwardsProvider(ContestType.AWC); + const provider = new AWC0001To0099Provider(ContestType.AWC); + const filtered = provider.filter([] as TaskResults); + + expect(filtered).toEqual([] as TaskResults); + }); +}); + +describe('AWC0100Provider', () => { + test('expects to filter only awc0100 tasks', () => { + const provider = new AWC0100Provider(ContestType.AWC); + const combined = [...taskResultsForAWC0001To0099Provider, ...taskResultsForAWC0100Provider]; + const filtered = provider.filter(combined); + + expect(filtered.length).toBe(15); + expect(filtered.every((task) => task.contest_id === 'awc0100')).toBe(true); + }); + + test('expects to exclude awc0001 and awc0099', () => { + const provider = new AWC0100Provider(ContestType.AWC); + const combined = [...taskResultsForAWC0001To0099Provider, ...taskResultsForAWC0100Provider]; + const filtered = provider.filter(combined); + + expect(filtered.some((task) => task.contest_id === 'awc0001')).toBe(false); + expect(filtered.some((task) => task.contest_id === 'awc0099')).toBe(false); + }); + + test('expects to get correct metadata', () => { + const provider = new AWC0100Provider(ContestType.AWC); + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('AtCoder Weekday Contest 0100'); + expect(metadata.abbreviationName).toBe('awc0100'); + }); + + test('expects to return correct display config (EDPC format)', () => { + const provider = new AWC0100Provider(ContestType.AWC); + const config = provider.getDisplayConfig(); + + expect(config.isShownHeader).toBe(false); + expect(config.isShownRoundLabel).toBe(false); + expect(config.roundLabelWidth).toBe(''); + expect(config.tableBodyCellsWidth).toBe( + 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 2xl:w-1/7 px-1 py-2', + ); + expect(config.isShownTaskIndex).toBe(true); + }); + + test('expects getContestRoundLabel to return empty string', () => { + const provider = new AWC0100Provider(ContestType.AWC); + + expect(provider.getContestRoundLabel('awc0100')).toBe(''); + }); + + test('expects generateTable to produce 15 tasks (A-O) for awc0100', () => { + const provider = new AWC0100Provider(ContestType.AWC); + const filtered = provider.filter(taskResultsForAWC0100Provider); + const table = provider.generateTable(filtered); + + expect(table).toHaveProperty('awc0100'); + const problems = table['awc0100']; + expect(Object.keys(problems)).toHaveLength(15); + expect(Object.keys(problems)).toEqual([ + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + ]); + }); + + test('expects to handle empty task results', () => { + const provider = new AWC0100Provider(ContestType.AWC); const filtered = provider.filter([] as TaskResults); expect(filtered).toEqual([] as TaskResults); diff --git a/src/features/tasks/utils/contest-table/awc_provider.ts b/src/features/tasks/utils/contest-table/awc_provider.ts index 4c9273124..299a1bbe9 100644 --- a/src/features/tasks/utils/contest-table/awc_provider.ts +++ b/src/features/tasks/utils/contest-table/awc_provider.ts @@ -2,29 +2,30 @@ import { type ContestTableMetaData, type ContestTableDisplayConfig, } from '$features/tasks/types/contest-table/contest_table_provider'; +import { ContestType } from '$lib/types/contest'; import type { TaskResult } from '$lib/types/task'; import { classifyContest, getContestNameLabel } from '$lib/utils/contest'; import { ContestTableProviderBase, parseContestRound } from './contest_table_provider_base'; -// AWC0001 〜 (2026/02/09 〜 ) +// AWC0001 〜 0099 (2026/02/09 〜 2026/06/25) // 5 tasks per contest -export class AWC0001OnwardsProvider extends ContestTableProviderBase { +export class AWC0001To0099Provider extends ContestTableProviderBase { protected setFilterCondition(): (taskResult: TaskResult) => boolean { return (taskResult: TaskResult) => { if (classifyContest(taskResult.contest_id) !== this.contestType) { return false; } const contestRound = parseContestRound(taskResult.contest_id, 'awc'); - return contestRound >= 1 && contestRound <= 9999; + return contestRound >= 1 && contestRound <= 99; }; } getMetadata(): ContestTableMetaData { return { - title: 'AtCoder Weekday Contest 0001 〜 ', - abbreviationName: 'awc0001Onwards', + title: 'AtCoder Weekday Contest 0001 〜 0099', + abbreviationName: 'awc0001To0099', }; } @@ -43,3 +44,38 @@ export class AWC0001OnwardsProvider extends ContestTableProviderBase { return contestNameLabel.replace('AWC ', ''); } } + +// AWC0100 (2026/06/26. special edition, 15 tasks: A-O) +export class AWC0100Provider extends ContestTableProviderBase { + constructor(contestType: ContestType) { + super(contestType, '0100'); // provider key = 'AWC::0100' + } + + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + if (classifyContest(taskResult.contest_id) !== this.contestType) { + return false; + } + + return taskResult.contest_id === 'awc0100'; + }; + } + + getMetadata(): ContestTableMetaData { + return { title: 'AtCoder Weekday Contest 0100', abbreviationName: 'awc0100' }; + } + + getDisplayConfig(): ContestTableDisplayConfig { + return { + isShownHeader: false, + isShownRoundLabel: false, + roundLabelWidth: '', + tableBodyCellsWidth: 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 2xl:w-1/7 px-1 py-2', + isShownTaskIndex: true, + }; + } + + getContestRoundLabel(_contestId: string): string { + return ''; + } +} 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 96c7bbcf6..a78b6c931 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 @@ -14,7 +14,8 @@ import { ARC001ToARC057Provider, AGC001OnwardsProvider, ABCLikeProvider, - AWC0001OnwardsProvider, + AWC0001To0099Provider, + AWC0100Provider, ACLPracticeProvider, ACLBeginnerProvider, ACLProvider, @@ -185,8 +186,9 @@ describe('prepareContestProviderPresets', () => { buttonLabel: 'AWC 0001 〜 ', ariaLabel: 'Filter contests from AWC 0001 onwards', }); - expect(group.getSize()).toBe(1); - expect(group.getProvider(ContestType.AWC)).toBeInstanceOf(AWC0001OnwardsProvider); + expect(group.getSize()).toBe(2); + expect(group.getProvider(ContestType.AWC, '0100')).toBeInstanceOf(AWC0100Provider); + expect(group.getProvider(ContestType.AWC)).toBeInstanceOf(AWC0001To0099Provider); }); test('expects to create MathAndAlgorithm preset correctly', () => { 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 f19c84a09..d52860551 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 @@ -15,7 +15,7 @@ import { } from './arc_providers'; import { AGC001OnwardsProvider } from './agc_provider'; import { ABCLikeProvider } from './axc_like_provider'; -import { AWC0001OnwardsProvider } from './awc_provider'; +import { AWC0001To0099Provider, AWC0100Provider } from './awc_provider'; import { Typical90Provider } from './typical90_provider'; import { TessokuBookForExamplesProvider, @@ -153,7 +153,9 @@ export const prepareContestProviderPresets = () => { new ContestTableProviderGroup(`AWC 0001 Onwards`, { buttonLabel: 'AWC 0001 〜 ', ariaLabel: 'Filter contests from AWC 0001 onwards', - }).addProvider(new AWC0001OnwardsProvider(ContestType.AWC)), + }) + .addProvider(new AWC0100Provider(ContestType.AWC)) + .addProvider(new AWC0001To0099Provider(ContestType.AWC)), /** * Single group for Typical 90 Problems From 46b64ab64cb6d8c602f1fd4f1da1836b208baf51 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 24 Jun 2026 06:55:51 +0000 Subject: [PATCH 2/4] docs: update contest-table-provider guide and skill with AWC0100 lessons Co-Authored-By: Claude Sonnet 4.6 --- .../instructions.md | 6 +- .../how-to-add-contest-table-provider.md | 55 +++++++++++++------ 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/.claude/skills/add-contest-table-provider/instructions.md b/.claude/skills/add-contest-table-provider/instructions.md index 098a29600..aa0920f44 100644 --- a/.claude/skills/add-contest-table-provider/instructions.md +++ b/.claude/skills/add-contest-table-provider/instructions.md @@ -21,6 +21,7 @@ Step 0 (seed check) is already done. Confirm the following before touching code: **Pattern 1 additional:** - Numeric range: start and end (open-ended if no upper bound)? +- Splitting an existing "Onwards" provider? Rename to `{Start}To{End}Provider`; the new special-edition provider uses a fixed section string (e.g. `super(contestType, '0100')`) to coexist under the same ContestType. - Shared problems with another contest (e.g. ARC–ABC overlap)? Which contest_ids appear in both? - Round label format (e.g. `ABC 042`)? @@ -93,6 +94,7 @@ Step 0 (seed check) is already done. Confirm the following before touching code: - [ ] Add test cases covering range boundaries and at least one mid-range value - [ ] If shared problems exist: add a test case with mixed contest_ids to confirm exclusion +- [ ] If splitting an existing range: add a combined-fixture test confirming the upper bound excludes the adjacent range's contest_id: `[...fixtureA, ...fixtureB]` → `filter` → assert `some(task => task.contest_id === 'out-of-range') === false` - [ ] `pnpm test:unit ` — **expect RED** - [ ] Implement Provider using `parseContestRound()` range check - [ ] `pnpm test:unit ` — **expect GREEN** @@ -124,13 +126,13 @@ Step 0 (seed check) is already done. Confirm the following before touching code: - [ ] Update `contest_table_provider_groups.test.ts`: - New group name string, `buttonLabel`, `ariaLabel` - `getSize()` incremented to reflect the new provider count - - Add `getProvider(ContestType.XXX)` assertion + - Add `getProvider(ContestType.XXX)` assertion; for section-based providers use `getProvider(ContestType.XXX, 'section')` - 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 `new XXXProvider(ContestType.XXX)` to `addProviders()` + - Add `new XXXProvider(ContestType.XXX)` to `addProvider()` chain — **`addProvider` call order = display order (first = top)** - [ ] `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 d4fcafa62..3ae3e77a7 100644 --- a/docs/guides/how-to-add-contest-table-provider.md +++ b/docs/guides/how-to-add-contest-table-provider.md @@ -202,6 +202,7 @@ protected setFilterCondition(): (taskResult: TaskResult) => boolean { - `super(contestType, String(year))` でセクションを年度文字列にし、プロバイダキーを `AOJ_ICPC::2025` のように一意化 - 年度範囲定数(`OLDEST_YEAR` / `LATEST_YEAR`)をモジュールトップで `export` し、tests でも参照できるようにする - グループ登録時は最新年から古い年へ降順ループし、テーブルを新しい順に並べる +- **固定セクションバリアント**: N 回インスタンス化ではなく、同一グループ内で同一 ContestType のプロバイダーを 2 種類共存させる場合も同じ仕組みが使える。一方に固定文字列を渡し(例: `super(contestType, '0100')` → key `AWC::0100`)、もう一方はセクションなし(key `AWC`)とすることで衝突を回避する **実装例**: @@ -312,18 +313,18 @@ class TessokuBookSectionProvider extends TessokuBookProvider { ### 範囲フィルタ型 -| コンテスト | 範囲 | フォーマット | セクション | ラベル | 特有の注意 | -| ----------- | -------- | ------------ | ---------- | ------ | ------------- | -| ABC 001-041 | 001~041 | 001, 041 | A~D | あり | 旧形式 | -| ABC 042-125 | 042~125 | 042, 125 | A~D | あり | 共有問題(ARC) | -| ABC 126-211 | 126~211 | 126, 211 | A~F | あり | 6問制 | -| ABC 212-318 | 212~318 | 212, 318 | A~Ex/H | あり | 8問制 | -| ABC 319- | 319~ | 319 | A~G | あり | 標準形式 | -| ARC 001-057 | 001~057 | 001, 057 | A~D | あり | 旧形式 | -| ARC 058-103 | 058~103 | 058, 103 | C~F | あり | 共有問題(ABC) | -| ARC 104- | 104~ | 104 | 4~6問 | あり | - | -| AGC 001- | 001~ | 001 | 4~7問 | あり | - | -| AWC 0001- | 0001~ | 0001 | A~E | あり | - | +| コンテスト | 範囲 | フォーマット | セクション | ラベル | 特有の注意 | +| ------------- | ---------- | ------------ | ---------- | ------ | ------------- | +| ABC 001-041 | 001~041 | 001, 041 | A~D | あり | 旧形式 | +| ABC 042-125 | 042~125 | 042, 125 | A~D | あり | 共有問題(ARC) | +| ABC 126-211 | 126~211 | 126, 211 | A~F | あり | 6問制 | +| ABC 212-318 | 212~318 | 212, 318 | A~Ex/H | あり | 8問制 | +| ABC 319- | 319~ | 319 | A~G | あり | 標準形式 | +| ARC 001-057 | 001~057 | 001, 057 | A~D | あり | 旧形式 | +| ARC 058-103 | 058~103 | 058, 103 | C~F | あり | 共有問題(ABC) | +| ARC 104- | 104~ | 104 | 4~6問 | あり | - | +| AGC 001- | 001~ | 001 | 4~7問 | あり | - | +| AWC 0001-0099 | 0001~0099 | 0001 | A~E | あり | - | ### 単一ソース型 @@ -336,9 +337,11 @@ class TessokuBookSectionProvider extends TessokuBookProvider { | ACL_PRACTICE | `'practice2'` | 12問 | A~L | | ACL_BEGINNER\* | `'abl'` | 6問 | A~F | | ACL_CONTEST1\* | `'acl1'` | 6問 | A~F | +| AWC0100† | `'awc0100'` | 15問 | A~O | \*注: ACL_PRACTICE、ACL_BEGINNER、ACL_CONTEST1 は `Acl` グループの下で 3 つのコンテストが統一管理されています。 \*\*注: EDPC・TDPC・NDPC・FPS 24 は `dps` グループ下で 4 つのコンテストが統一管理されています。 +†注: ContestType.AWC を再利用し、section `'0100'` で同グループ内の AWC0001To0099Provider と共存(provider key = `AWC::0100`)。`getProvider(ContestType.AWC, '0100')` で取得。 ### 複合ソース型 @@ -367,7 +370,12 @@ class TessokuBookSectionProvider extends TessokuBookProvider { ### パターン固有テスト -- **範囲フィルタ型**: 範囲境界値テスト、共有問題の有無確認 +- **範囲フィルタ型**: 範囲境界値テスト、共有問題の有無確認。既存プロバイダーを上限付きに分割した場合は、隣接するもう一方のフィクスチャを結合して上限境界の除外を確認する: + ```typescript + const combined = [...taskResultsForAWC0001To0099Provider, ...taskResultsForAWC0100Provider]; + const filtered = provider.filter(combined); + expect(filtered.some((task) => task.contest_id === 'awc0100')).toBe(false); + ``` - **複合ソース型**: 複数 contest_id 混在テスト、セクション分割ロジック ### Vitest テスト例 @@ -496,7 +504,7 @@ export const taskResultsForNewProvider: TaskResults = [ --- -## よくあるミス Top 5 +## よくあるミス ### 1. **getDisplayConfig() での属性漏れ** @@ -598,6 +606,21 @@ describe('CustomProvider with unique config', () => { --- +### 6. `addProvider` の順序を意識しない + +**問題**: 同一グループ内の複数プロバイダーは `addProvider` の呼び出し順が画面上の表示順(先 = 上)になる。 + +**解決策**: 上に表示したいプロバイダーを先に `addProvider` する。 + +```typescript +// AWC0100 を AWC0001-0099 の上に表示する場合 +group + .addProvider(new AWC0100Provider(ContestType.AWC)) // 上 + .addProvider(new AWC0001To0099Provider(ContestType.AWC)); // 下 +``` + +--- + ## 実装完了後 ### ドキュメント更新チェックリスト @@ -622,7 +645,7 @@ describe('CustomProvider with unique config', () => { - [#2837](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2837) - AGC001OnwardsProvider - [#2838](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2838) - ABC001~041 & ARC001~057 - [#2840](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2840)、[#3108](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3108) - ABCLikeProvider -- [#3153](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3153) - AWC0001OnwardsProvider +- [#3153](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3153) - AWC0001To0099Provider(旧: AWC0001OnwardsProvider)、AWC0100Provider - [#2776](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2776) - TessokuBookProvider - [#2785](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2785) - MathAndAlgorithmProvider - [#2797](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2797) - FPS24Provider @@ -638,4 +661,4 @@ describe('CustomProvider with unique config', () => { --- -**最終更新**: 2026-05-10 +**最終更新**: 2026-06-24 From 0c2bc7f17895d4b63ea01fae1d4964efd8da9488 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 24 Jun 2026 06:56:24 +0000 Subject: [PATCH 3/4] chore(dev-notes): remove awc0100-edpc-table-provider plan after branch completion Co-Authored-By: Claude Sonnet 4.6 --- .../awc0100-edpc-table-provider/plan.md | 162 ------------------ 1 file changed, 162 deletions(-) delete mode 100644 docs/dev-notes/2026-06-23/awc0100-edpc-table-provider/plan.md diff --git a/docs/dev-notes/2026-06-23/awc0100-edpc-table-provider/plan.md b/docs/dev-notes/2026-06-23/awc0100-edpc-table-provider/plan.md deleted file mode 100644 index da770143b..000000000 --- a/docs/dev-notes/2026-06-23/awc0100-edpc-table-provider/plan.md +++ /dev/null @@ -1,162 +0,0 @@ -# AWC0100 特別回 EDPC 形式テーブルプロバイダ追加 - -## 概要 - -AWC(AtCoder Weekday Contest)の特別回 AWC0100(A〜O 15 問構成)を、EDPC と同じ形式(ヘッダ非表示・ラウンドラベル非表示・タスク記号表示)で表示するためのプロバイダを追加する。既存の `AWC0001OnwardsProvider` は AWC0001〜0099 を対象とする `AWC0001To0099Provider` に rename する。 - -## 設計方針 - -### ContestType の再利用 - -`classifyContest` は `/^awc\d{4}$/` で `awc0100` を既存の `ContestType.AWC` に分類する。新しい ContestType の追加は不要。 - -### Provider key 衝突の回避 - -同一グループに `ContestType.AWC` を使う 2 つのプロバイダを追加するため、`AWC0100Provider` のコンストラクタで `super(contestType, '0100')` を呼び、provider key を `AWC::0100` とする。`AWC0001To0099Provider` は section なし(key = `AWC`)のため衝突しない。 - -### 表示順 - -`addProvider` の呼び出し順が表示順(先 = 上)になるため、AWC0100 を先に追加する。 - -### グループキー維持 - -`contestTableProviderGroups` の `awc0001Onwards` キーは変更しない(UI・store への波及を避けるため)。 - -## 却下した代替案 - -- **新 ContestType `AWC_0100` を追加する案**: Prisma スキーマ変更・`classifyContest` 更新・contest.ts 更新が必要で大掛かり。ContestType.AWC の再利用で section による区別が可能なため不採用。 -- **AWC0100 を独立グループにする案**: ユーザー要件として「AWC グループに同居」が確定したため不採用。 - -## 変更ファイルと内容 - -### Layer 4 — Provider クラス(TDD) - -#### `src/features/tasks/utils/contest-table/awc_provider.ts` - -**AWC0001OnwardsProvider → AWC0001To0099Provider** - -| 変更点 | 旧値 | 新値 | -| ------------------ | ------------------------------------ | ---------------------------------------- | -| クラス名 | `AWC0001OnwardsProvider` | `AWC0001To0099Provider` | -| filter range | `contestRound <= 9999` | `contestRound <= 99` | -| `title` | `'AtCoder Weekday Contest 0001 〜 '` | `'AtCoder Weekday Contest 0001 〜 0099'` | -| `abbreviationName` | `'awc0001Onwards'` | `'awc0001To0099'` | - -**AWC0100Provider(新規追加)** - -```typescript -// AWC0100 (special edition, 15 tasks: A-O) -export class AWC0100Provider extends ContestTableProviderBase { - constructor(contestType: ContestType) { - super(contestType, '0100'); // provider key = 'AWC::0100' - } - - protected setFilterCondition() { - return (taskResult: TaskResult) => { - if (classifyContest(taskResult.contest_id) !== this.contestType) return false; - return taskResult.contest_id === 'awc0100'; - }; - } - - getMetadata() { - return { title: 'AtCoder Weekday Contest 0100', abbreviationName: 'awc0100' }; - } - - getDisplayConfig() { - return { - isShownHeader: false, - isShownRoundLabel: false, - roundLabelWidth: '', - tableBodyCellsWidth: 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 2xl:w-1/7 px-1 py-2', - isShownTaskIndex: true, - }; - } - - getContestRoundLabel(_contestId: string): string { - return ''; - } -} -``` - -#### `src/features/tasks/fixtures/contest-table/contest_table_provider.ts` - -- `taskResultsForAWC0001OnwardsProvider` → `taskResultsForAWC0001To0099Provider` に rename(中身は変更不要) -- `taskResultsForAWC0100Provider` を新規追加(awc0100 の A〜O 15 タスク) - -#### `src/features/tasks/utils/contest-table/awc_provider.test.ts` - -- import・describe・test 内の `AWC0001OnwardsProvider` → `AWC0001To0099Provider` に rename -- fixture 参照を `taskResultsForAWC0001To0099Provider` に変更 -- range テスト: awc0099 が含まれること+awc0100 が除外されること を追加 -- metadata テスト: 新しい title / abbreviationName に更新 -- `AWC0100Provider` の describe ブロックを新規追加: - - awc0100 のみフィルタされること - - awc0001/awc0099 が除外されること(結合 fixture を使用) - - metadata(title / abbreviationName) - - displayConfig(EDPC 形式の全フィールド) - - getContestRoundLabel が空文字を返すこと - - generateTable で 15 問(A〜O 順)が生成されること - - 空入力の処理 - -### Layer 5 — グループ登録(TDD) - -#### `src/features/tasks/utils/contest-table/contest_table_provider_groups.ts` - -```typescript -// import 変更 -import { AWC0001To0099Provider, AWC0100Provider } from './awc_provider'; - -// preset 変更(AWC0001Onwards の preset 関数を更新) -AWC0001Onwards: () => - new ContestTableProviderGroup(`AWC 0001 Onwards`, { - buttonLabel: 'AWC 0001 〜 ', - ariaLabel: 'Filter contests from AWC 0001 onwards', - }) - .addProvider(new AWC0100Provider(ContestType.AWC)) // AWC0100 を先(上)に表示 - .addProvider(new AWC0001To0099Provider(ContestType.AWC)), // AWC0001-0099 を後(下)に表示 -``` - -`contestTableProviderGroups` のキー `awc0001Onwards` は変更しない。 - -#### `src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts` - -AWC0001Onwards テストを更新: - -```typescript -expect(group.getSize()).toBe(2); // 1 → 2 -expect(group.getProvider(ContestType.AWC, '0100')).toBeInstanceOf(AWC0100Provider); // 新規 -expect(group.getProvider(ContestType.AWC)).toBeInstanceOf(AWC0001To0099Provider); // 更新 -``` - -## 変更不要なファイル - -| ファイル | 理由 | -| --------------------------------- | -------------------------------------- | -| `prisma/schema.prisma` | ContestType.AWC を再利用 | -| `src/lib/types/contest.ts` | 同上 | -| `src/lib/utils/contest.ts` | awc0100 は既存 regex で AWC に分類済み | -| `contest_table_provider_base.ts` | section サポート済み | -| `contest_table_provider_group.ts` | section ベースのキー解決サポート済み | -| `active_contest_type.svelte.ts` | awc0001Onwards キー維持のため変更不要 | -| シードデータ(`prisma/tasks.ts`) | 今回スコープ外(後日追加) | - -## 検証手順 - -```bash -# TDD サイクル(RED → GREEN) -pnpm test:unit -- awc_provider -pnpm test:unit -- contest_table_provider_groups - -# 全 unit test -pnpm test:unit - -# 型チェック・lint -pnpm check -pnpm lint -``` - -### 期待される動作 - -- `getProvider(ContestType.AWC)` → `AWC0001To0099Provider`(round 1〜99) -- `getProvider(ContestType.AWC, '0100')` → `AWC0100Provider`(AWC0100 特別回) -- AWC グループのフィルタボタンを押すと AWC0100 テーブル(A〜O)が AWC0001〜0099 テーブルの上に表示される From c1ffdbdef020e23e3d4e8f24e9f83c154cf9132a Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 24 Jun 2026 07:49:56 +0000 Subject: [PATCH 4/4] docs: fix markdown formatting in contest-table-provider guide Co-Authored-By: Claude Sonnet 4.6 --- docs/guides/how-to-add-contest-table-provider.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/guides/how-to-add-contest-table-provider.md b/docs/guides/how-to-add-contest-table-provider.md index 3ae3e77a7..358200993 100644 --- a/docs/guides/how-to-add-contest-table-provider.md +++ b/docs/guides/how-to-add-contest-table-provider.md @@ -371,11 +371,13 @@ class TessokuBookSectionProvider extends TessokuBookProvider { ### パターン固有テスト - **範囲フィルタ型**: 範囲境界値テスト、共有問題の有無確認。既存プロバイダーを上限付きに分割した場合は、隣接するもう一方のフィクスチャを結合して上限境界の除外を確認する: + ```typescript const combined = [...taskResultsForAWC0001To0099Provider, ...taskResultsForAWC0100Provider]; const filtered = provider.filter(combined); expect(filtered.some((task) => task.contest_id === 'awc0100')).toBe(false); ``` + - **複合ソース型**: 複数 contest_id 混在テスト、セクション分割ロジック ### Vitest テスト例