diff --git a/src/lib/components/TaskTables/TaskTable.svelte b/src/lib/components/TaskTables/TaskTable.svelte index 08312674c..aae0b57ee 100644 --- a/src/lib/components/TaskTables/TaskTable.svelte +++ b/src/lib/components/TaskTables/TaskTable.svelte @@ -12,24 +12,15 @@ } from 'svelte-5-ui-lib'; import type { TaskResults, TaskResult } from '$lib/types/task'; - import { ContestType } from '$lib/types/contest'; + import type { ContestTableProvider } from '$lib/types/contest_table_provider'; import UpdatingModal from '$lib/components/SubmissionStatus/UpdatingModal.svelte'; import TaskTableBodyCell from '$lib/components/TaskTables/TaskTableBodyCell.svelte'; import { - type TaskResultsFilter, - taskResultsForABCLatest20, - taskResultsFromABC319Onwards, - taskResultsFromABC212ToABC318, - } from '$lib/utils/task_results_filter'; - - import { - type TaskTableGenerator, - taskTableGeneratorForABCLatest20, - taskTableGeneratorFromABC319Onwards, - taskTableGeneratorFromABC212ToABC318, - } from '$lib/utils/task_table_generator'; + contestTableProviders, + type ContestTableProviders, + } from '$lib/utils/contest_table_provider'; import { getBackgroundColorFrom } from '$lib/services/submission_status'; @@ -40,58 +31,27 @@ let { taskResults, isLoggedIn }: Props = $props(); - // TODO: 任意のコンテスト種別を追加 - // TODO: コンテスト種別の並び順を決める - const contestFilterConfigs = { - abcLatest20Rounds: { - filter: () => taskResultsForABCLatest20(taskResults), - table: (results: TaskResults) => taskTableGeneratorForABCLatest20(results, ContestType.ABC), - buttonLabel: 'ABC 最新 20 回', - ariaLabel: 'Filter ABC latest 20 rounds', - }, - abc319Onwards: { - filter: () => taskResultsFromABC319Onwards(taskResults), - table: (results: TaskResults) => - taskTableGeneratorFromABC319Onwards(results, ContestType.ABC), - buttonLabel: 'ABC319 〜', - ariaLabel: 'Filter contests from ABC 319 onwards', - }, - abc212To318: { - filter: () => taskResultsFromABC212ToABC318(taskResults), - table: (results: TaskResults) => - taskTableGeneratorFromABC212ToABC318(results, ContestType.ABC), - buttonLabel: 'ABC212 〜 318', - ariaLabel: 'Filter contests from ABC 212 to ABC 318', - }, - }; - - type ContestTypeFilter = 'abcLatest20Rounds' | 'abc319Onwards' | 'abc212To318'; - - let activeContestType = $state('abcLatest20Rounds'); - - // Select the task results based on the active contest type. - let taskResultsFilter: TaskResultsFilter = $derived( - contestFilterConfigs[activeContestType].filter(), - ); - let selectedTaskResults: TaskResults = $derived(taskResultsFilter.run()); + let activeContestType = $state('abcLatest20Rounds'); - // Generate the task table based on the selected task results. - let taskTableGenerator: TaskTableGenerator = $derived( - contestFilterConfigs[activeContestType].table(selectedTaskResults), + let provider: ContestTableProvider = $derived( + contestTableProviders[activeContestType as ContestTableProviders], ); - let taskTable: Record> = $derived(taskTableGenerator.run()); - let taskTableHeaderIds: Array = $derived(taskTableGenerator.getHeaderIdsForTask()); - let contestIds: Array = $derived(taskTableGenerator.getContestRoundIds()); - - function getTaskTableTitle(taskTableGenerator: TaskTableGenerator): string { - return taskTableGenerator.getTitle() ?? ''; - } + // Filter the task results based on the active contest type. + let filteredTaskResults = $derived(provider.filter(taskResults)); + // Generate the task table based on the filtered task results. + let taskTable: Record> = $derived( + provider.generateTable(filteredTaskResults), + ); + let taskTableHeaderIds: Array = $derived( + provider.getHeaderIdsForTask(filteredTaskResults), + ); + let contestIds: Array = $derived(provider.getContestRoundIds(filteredTaskResults)); + let title = $derived(provider.getMetadata().title); - function getContestRoundLabel(taskTableGenerator: TaskTableGenerator, contestId: string): string { - return taskTableGenerator.getContestRoundLabel(contestId); + function getContestRoundLabel(provider: ContestTableProvider, contestId: string): string { + return provider.getContestRoundLabel(contestId); } - // FIXME: 他のコンポーネントと完全に重複しているので、コンポーネントとして切り出す。 let updatingModal: UpdatingModal | null = null; // WHY: () => updatingModal.openModal(taskResult) だけだと、updatingModalがnullの可能性があるため。 @@ -124,19 +84,19 @@ - {#each Object.entries(contestFilterConfigs) as [type, config]} + {#each Object.entries(contestTableProviders) as [type, config]} {/each} - {getTaskTableTitle(taskTableGenerator)} + {title} @@ -162,7 +122,7 @@ {#each contestIds as contestId} - {getContestRoundLabel(taskTableGenerator, contestId)} + {getContestRoundLabel(provider, contestId)} {#each taskTableHeaderIds as taskTableHeaderId} diff --git a/src/lib/types/contest_table_provider.ts b/src/lib/types/contest_table_provider.ts new file mode 100644 index 000000000..51a6ca42e --- /dev/null +++ b/src/lib/types/contest_table_provider.ts @@ -0,0 +1,105 @@ +import type { TaskResults, TaskResult } from '$lib/types/task'; + +/** + * Provider interface for building and managing contest tables. + * + * This interface defines the contract for components that create, filter, and + * generate contest tables from task results. + * + */ +export interface ContestTableProvider { + /** + * Filters the provided task results according to implementation-specific criteria. + * + * @param {TaskResults} taskResults - The original task results to be filtered + * @returns {TaskResults} The filtered task results + */ + filter(taskResults: TaskResults): TaskResults; + + /** + * Generates a contest table based on the provided filtered task results. + * + * @param {TaskResults} filteredTaskResults - The filtered task results to use for table generation + * @returns {ContestTable} The generated contest table + */ + generateTable(filteredTaskResults: TaskResults): ContestTable; + + /** + * Retrieves the unique identifiers for all contest rounds. + * + * @param {TaskResults} filteredTaskResults - The filtered task results to use for table generation + * @returns {Array} An array of contest round identifiers. + */ + getContestRoundIds(filteredTaskResults: TaskResults): Array; + + /** + * Retrieves an array of header IDs associated with the current contest tasks. + * These IDs are used to identify and display the relevant columns in the task table. + * + * @param {TaskResults} filteredTaskResults - The filtered task results to use for table generation + * @returns {Array} An array of string IDs corresponding to the header columns for the task. + */ + getHeaderIdsForTask(filteredTaskResults: TaskResults): Array; + + /** + * Retrieves metadata associated with the contest table. + * + * @returns {ContestTableMetaData} Metadata for the contest table + */ + getMetadata(): ContestTableMetaData; + + /** + * Returns a formatted label for the contest round. + * + * This abstract method must be implemented by subclasses to provide + * a string representation of the contest round that can be displayed + * in the task table. + * + * @param contestId - The ID of the contest. + * + * @returns {string} The formatted label string for the contest round. + */ + getContestRoundLabel(contestId: string): string; +} + +/** + * Represents a two-dimensional table of contest results. + * + * The structure is organized as a nested record: + * - The outer keys represent contest id + * - The inner keys represent task id + * - The values are the results for each task + * + * @example + * { + * "abc396": { + * "abc396_a": {contest_id: "abc396", task_id: "abc396_a", status_name: "ac", ...}, + * "abc396_b": {contest_id: "abc396", task_id: "abc396_b", status_name: "ac", ...}, + * "abc396_c": {contest_id: "abc396", task_id: "abc396_c", status_name: "ac_with_editorial", ...}, + * ..., + * "abc396_g": {contest_id: "abc396", task_id: "abc396_g", status_name: "wa", ...}, + * }, + * "abc395": { + * "abc395_a": {contest_id: "abc395", task_id: "abc395_a", status_name: "ac", ...}, + * "abc395_b": {contest_id: "abc395", task_id: "abc395_b", status_name: "ac", ...}, + * "abc395_c": {contest_id: "abc395", task_id: "abc395_c", status_name: "ac", ...}, + * ..., + * "abc395_g": {contest_id: "abc395", task_id: "abc395_g", status_name: "wa", ...}, + * }, + * } + */ +export type ContestTable = Record>; + +/** + * Metadata for configuring a contest table's display properties. + * + * @typedef {Object} ContestTableMetaData + * @property {string} title - The title text to display for the contest table. + * @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. + */ +export type ContestTableMetaData = { + title: string; + buttonLabel: string; + ariaLabel: string; +}; diff --git a/src/lib/utils/contest_table_provider.ts b/src/lib/utils/contest_table_provider.ts new file mode 100644 index 000000000..303f314e7 --- /dev/null +++ b/src/lib/utils/contest_table_provider.ts @@ -0,0 +1,170 @@ +import type { + ContestTableProvider, + ContestTable, + ContestTableMetaData, +} from '$lib/types/contest_table_provider'; +import { ContestType } from '$lib/types/contest'; +import type { TaskResults, TaskResult } from '$lib/types/task'; + +import { classifyContest, getContestNameLabel } from '$lib/utils/contest'; +import { getTaskTableHeaderName } from '$lib/utils/task'; + +export abstract class ContestTableProviderBase implements ContestTableProvider { + protected contestType: ContestType; + + /** + * Creates a new TaskTableGenerator instance. + * + * @param {ContestType} contestType - The type of contest associated with these tasks. + */ + constructor(contestType: ContestType) { + this.contestType = contestType; + } + + filter(taskResults: TaskResults): TaskResults { + return taskResults.filter(this.setFilterCondition()); + } + + /** + * This is an abstract method that must be implemented by any subclass. + * It is intended to set a condition that will be used to filter task results. + * + * @abstract + * @protected + * @returns {(taskResult: TaskResult) => boolean} A function that takes a TaskResult + * and returns a boolean indicating whether the task result meets the condition. + */ + protected abstract setFilterCondition(): (taskResult: TaskResult) => boolean; + + /** + * Generate a table for task and submission statuses. + * + * Computational complexity of preparation table: O(N), where N is the number of task results. + * Computational complexity of accessing table: O(1). + * + * @returns A table for task and submission statuses. + */ + generateTable(filteredTaskResults: TaskResults): ContestTable { + const table: ContestTable = {}; + + filteredTaskResults.forEach((taskResult: TaskResult) => { + const contestId = taskResult.contest_id; + const taskTableIndex = getTaskTableHeaderName(this.contestType, taskResult); + + if (!table[contestId]) { + table[contestId] = {}; + } + + table[contestId][taskTableIndex] = taskResult; + }); + + return table; + } + + getContestRoundIds(filteredTaskResults: TaskResults): Array { + const contestList = filteredTaskResults.map((taskResult: TaskResult) => taskResult.contest_id); + return Array.from(new Set(contestList)).sort().reverse(); + } + + getHeaderIdsForTask(filteredTaskResults: TaskResults): Array { + const headerList = filteredTaskResults.map((taskResult: TaskResult) => + getTaskTableHeaderName(this.contestType, taskResult), + ); + return Array.from(new Set(headerList)).sort(); + } + + abstract getMetadata(): ContestTableMetaData; + abstract getContestRoundLabel(contestId: string): string; +} + +class ABCLatest20RoundsProvider extends ContestTableProviderBase { + filter(taskResults: TaskResults): TaskResults { + const taskResultsOnlyABC = taskResults.filter(this.setFilterCondition()); + + const CONTEST_ROUND_COUNT = 20; + const latest20ContestIds = Array.from( + new Set(taskResultsOnlyABC.map((taskResult: TaskResult) => taskResult.contest_id)), + ) + .sort() + .reverse() + .slice(0, CONTEST_ROUND_COUNT); + + return taskResultsOnlyABC.filter((task: TaskResult) => + latest20ContestIds.includes(task.contest_id), + ); + } + + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + // Note: Narrow down taskResults in advance to reduce time to display. + return (task: TaskResult) => classifyContest(task.contest_id) === ContestType.ABC; + } + + getMetadata(): ContestTableMetaData { + return { + title: 'AtCoder Beginner Contest 最新 20 回', + buttonLabel: 'ABC 最新 20 回', + ariaLabel: 'Filter ABC latest 20 rounds', + }; + } + + getContestRoundLabel(contestId: string): string { + const contestNameLabel = getContestNameLabel(contestId); + return contestNameLabel.replace('ABC ', ''); + } +} + +// ABC319 〜 (2023/09/09 〜 ) +// 7 tasks per contest +class ABC319OnwardsProvider extends ContestTableProviderBase { + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => + taskResult.contest_id >= 'abc319' && taskResult.contest_id <= 'abc999'; + } + + getMetadata(): ContestTableMetaData { + return { + title: 'AtCoder Beginner Contest 319 〜 ', + buttonLabel: 'ABC 319 〜 ', + ariaLabel: 'Filter contests from ABC 319 onwards', + }; + } + + getContestRoundLabel(contestId: string): string { + const contestNameLabel = getContestNameLabel(contestId); + return contestNameLabel.replace('ABC ', ''); + } +} + +// ABC212 〜 ABC318 (2021/07/31 〜 2023/09/02) +// 8 tasks per contest +// +// Note: +// Before and from ABC212 onwards, the number and tendency of tasks are very different. +class ABC212ToABC318Provider extends ContestTableProviderBase { + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => + taskResult.contest_id >= 'abc212' && taskResult.contest_id <= 'abc318'; + } + + getMetadata(): ContestTableMetaData { + return { + title: 'AtCoder Beginner Contest 212 〜 318', + buttonLabel: 'ABC 212 〜 318', + ariaLabel: 'Filter contests from ABC 212 to ABC 318', + }; + } + + getContestRoundLabel(contestId: string): string { + const contestNameLabel = getContestNameLabel(contestId); + return contestNameLabel.replace('ABC ', ''); + } +} + +// TODO: Add providers for other contest types if needs. +export const contestTableProviders = { + abcLatest20Rounds: new ABCLatest20RoundsProvider(ContestType.ABC), + abc319Onwards: new ABC319OnwardsProvider(ContestType.ABC), + fromAbc212ToAbc318: new ABC212ToABC318Provider(ContestType.ABC), +}; + +export type ContestTableProviders = keyof typeof contestTableProviders; diff --git a/src/lib/utils/task_results_filter.ts b/src/lib/utils/task_results_filter.ts deleted file mode 100644 index a1e86fecd..000000000 --- a/src/lib/utils/task_results_filter.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { TaskResults, TaskResult } from '$lib/types/task'; -import { ContestType } from '$lib/types/contest'; - -import { classifyContest } from '$lib/utils/contest'; - -export const taskResultsForABCLatest20 = (taskResults: TaskResults) => { - return new TaskResultsForABCLatest20(taskResults); -}; - -export const taskResultsFromABC319Onwards = (taskResults: TaskResults) => { - return new TaskResultsFromABC319Onwards(taskResults); -}; - -// Note: -// Before and from ABC212 onwards, the number and tendency of tasks are very different. -export const taskResultsFromABC212ToABC318 = (taskResults: TaskResults) => { - return new TaskResultsFromABC212ToABC318(taskResults); -}; - -export abstract class TaskResultsFilter { - protected taskResults: TaskResults; - - constructor(taskResults: TaskResults) { - this.taskResults = taskResults; - } - - /** - * Filter the taskResults using setCondition(). - * - * @returns {TaskResults} The results of the filtered taskResults. - */ - run(): TaskResults { - return this.taskResults.filter(this.setCondition()); - } - - /** - * This is an abstract method that must be implemented by any subclass. - * It is intended to set a condition that will be used to filter task results. - * - * @abstract - * @protected - * @returns {(taskResult: TaskResult) => boolean} A function that takes a TaskResult - * and returns a boolean indicating whether the task result meets the condition. - */ - protected abstract setCondition(): (taskResult: TaskResult) => boolean; -} - -// ABC Latest 20 rounds -class TaskResultsForABCLatest20 extends TaskResultsFilter { - run(): TaskResults { - const CONTEST_ROUND_COUNT = 20; - - const taskResultsOnlyABC = this.taskResults.filter(this.setCondition()); - const latest20ContestIds = Array.from( - new Set(taskResultsOnlyABC.map((taskResult: TaskResult) => taskResult.contest_id)), - ) - .sort() - .reverse() - .slice(0, CONTEST_ROUND_COUNT); - - return taskResultsOnlyABC.filter((taskResult: TaskResult) => - latest20ContestIds.includes(taskResult.contest_id), - ); - } - - // Note: Narrow down taskResults in advance to reduce time to display. - protected setCondition(): (taskResult: TaskResult) => boolean { - return (taskResult: TaskResult) => classifyContest(taskResult.contest_id) === ContestType.ABC; - } -} - -// ABC319 〜 (2023/09/09 〜 ) -// 7 tasks per contest -class TaskResultsFromABC319Onwards extends TaskResultsFilter { - protected setCondition(): (taskResult: TaskResult) => boolean { - return (taskResult: TaskResult) => - taskResult.contest_id >= 'abc319' && taskResult.contest_id <= 'abc999'; - } -} - -// ABC212 〜 ABC318 (2021/07/31 〜 2023/09/02) -// 8 tasks per contest -class TaskResultsFromABC212ToABC318 extends TaskResultsFilter { - protected setCondition(): (taskResult: TaskResult) => boolean { - return (taskResult: TaskResult) => - taskResult.contest_id >= 'abc212' && taskResult.contest_id <= 'abc318'; - } -} diff --git a/src/lib/utils/task_table_generator.ts b/src/lib/utils/task_table_generator.ts deleted file mode 100644 index 67587d622..000000000 --- a/src/lib/utils/task_table_generator.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { TaskResults, TaskResult } from '$lib/types/task'; -import { ContestType } from '$lib/types/contest'; - -import { getContestNameLabel } from '$lib/utils/contest'; -import { getTaskTableHeaderName } from '$lib/utils/task'; - -export const taskTableGeneratorForABCLatest20 = ( - taskResults: TaskResults, - contestType: ContestType, -) => { - return new TaskTableGeneratorForABCLatest20(taskResults, contestType); -}; - -export const taskTableGeneratorFromABC319Onwards = ( - taskResults: TaskResults, - contestType: ContestType, -) => { - return new TaskTableGeneratorFromABC319Onwards(taskResults, contestType); -}; - -export const taskTableGeneratorFromABC212ToABC318 = ( - taskResults: TaskResults, - contestType: ContestType, -) => { - return new TaskTableGeneratorFromABC212ToABC318(taskResults, contestType); -}; - -export abstract class TaskTableGenerator { - protected selectedTaskResults: TaskResults; - protected contestType: ContestType; - - /** - * Creates a new TaskTableGenerator instance. - * - * @param {TaskResults} selectedTaskResults - The task results to be displayed in the table. - * @param {ContestType} contestType - The type of contest associated with these tasks. - */ - constructor(selectedTaskResults: TaskResults, contestType: ContestType) { - this.selectedTaskResults = selectedTaskResults; - this.contestType = contestType; - } - - /** - * Prepare a table for task and submission statuses. - * - * Computational complexity of preparation table: O(N), where N is the number of task results. - * Computational complexity of accessing table: O(1). - * - * @returns A table for task and submission statuses. - */ - run(): Record> { - const table: Record> = {}; - - this.selectedTaskResults.forEach((taskResult: TaskResult) => { - const contestId = taskResult.contest_id; - const taskTableIndex = getTaskTableHeaderName(this.contestType, taskResult); - - if (!table[contestId]) { - table[contestId] = {}; - } - - table[contestId][taskTableIndex] = taskResult; - }); - - return table; - } - - /** - * Retrieves the unique identifiers for all contest rounds. - * - * @returns {Array} An array of contest round identifiers. - */ - getContestRoundIds(): Array { - const contestList = this.selectedTaskResults.map( - (taskResult: TaskResult) => taskResult.contest_id, - ); - return Array.from(new Set(contestList)).sort().reverse(); - } - - /** - * Retrieves an array of header IDs associated with the current contest tasks. - * These IDs are used to identify and display the relevant columns in the task table. - * - * @returns {Array} An array of string IDs corresponding to the header columns for the task. - */ - getHeaderIdsForTask(): Array { - const headerList = this.selectedTaskResults.map((taskResult: TaskResult) => - getTaskTableHeaderName(this.contestType, taskResult), - ); - return Array.from(new Set(headerList)).sort(); - } - - /** - * Returns a formatted label for the contest round. - * - * This abstract method must be implemented by subclasses to provide - * a string representation of the contest round that can be displayed - * in the task table. - * - * @param contestId - The ID of the contest. - * - * @returns {string} The formatted label string for the contest round. - */ - abstract getContestRoundLabel(contestId: string): string; - - /** - * Returns the title of the task table. - * - * @abstract This method must be implemented by subclasses. - * @returns A string representing the title to be displayed for the task table. - */ - abstract getTitle(): string; -} - -class TaskTableGeneratorForABC extends TaskTableGenerator { - getContestRoundLabel(contestId: string): string { - const contestNameLabel = getContestNameLabel(contestId); - return contestNameLabel.replace('ABC ', ''); - } - - getTitle(): string { - return ''; - } -} - -class TaskTableGeneratorForABCLatest20 extends TaskTableGeneratorForABC { - getTitle(): string { - return 'AtCoder Beginners Contest latest 20 rounds'; - } -} - -class TaskTableGeneratorFromABC319Onwards extends TaskTableGeneratorForABC { - getTitle(): string { - return 'AtCoder Beginners Contest 319 〜 '; - } -} - -class TaskTableGeneratorFromABC212ToABC318 extends TaskTableGeneratorForABC { - getTitle(): string { - return 'AtCoder Beginners Contest 212 〜 318'; - } -}