Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
05de29a
✨ Add EDPC and TDPC to contest table (#1956)
KATO-Hiro Jul 5, 2025
b50edae
🔧 Add @types/jsdom for vitest (#1956)
KATO-Hiro Jul 5, 2025
3891886
:books: Prepare docs to add contest table provider (#1956)
KATO-Hiro Jul 5, 2025
24188aa
:art: More than 8 columns will wrap to the next line in contest table…
KATO-Hiro Jul 6, 2025
5fc8eb8
:art: Fix layout (#1956)
KATO-Hiro Jul 6, 2025
3d9fb45
✨ Enable to show/hide header and round labels in contest table (#1956)
KATO-Hiro Jul 6, 2025
b01c283
:art: Fix border colors in contest table (#1956)
KATO-Hiro Jul 6, 2025
c956975
♻️ Remove unused metadata (#1956)
KATO-Hiro Jul 6, 2025
e607cc8
🚨 Add tests for contest table (#1956)
KATO-Hiro Jul 6, 2025
76f96d7
🚨 Add tests for contest table (#1956)
KATO-Hiro Jul 6, 2025
7e09661
:chore: Remove caret from package.json (#1956)
KATO-Hiro Jul 6, 2025
b6f3b5d
:chore: Remove caret from package.json (#1956)
KATO-Hiro Jul 6, 2025
ca1a422
:chore: Replace toBeTruthy() to toBe(true) (#1956)
KATO-Hiro Jul 6, 2025
802de19
:art: Define custom width (#1956)
KATO-Hiro Jul 6, 2025
22700ea
♻️ Use function instead of class (#1956)
KATO-Hiro Jul 6, 2025
a2ed51e
✨ Add tasks to seed (#1956)
KATO-Hiro Jul 7, 2025
a90ab83
Merge branch 'staging' of github.com:KATO-Hiro/AtCoderNoviceProblemsS…
KATO-Hiro Jul 8, 2025
de8202a
Merge branch 'staging' of github.com:KATO-Hiro/AtCoderNoviceProblemsS…
KATO-Hiro Jul 9, 2025
2947c61
Merge branch 'staging' of github.com:KATO-Hiro/AtCoderNoviSteps into …
KATO-Hiro Jul 9, 2025
48934ba
:art: Add prefix to task name in table cells (#1956)
KATO-Hiro Jul 9, 2025
32d006b
:chore: Add keys expression to {#each} block (#1956)
KATO-Hiro Jul 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@tailwindcss/forms": "0.5.10",
"@testing-library/jest-dom": "6.6.3",
"@types/gtag.js": "0.0.20",
"@types/jsdom": "^21.1.7",
"@typescript-eslint/eslint-plugin": "8.35.1",
"@typescript-eslint/parser": "8.35.1",
"@vitest/coverage-v8": "3.2.4",
Expand Down
17 changes: 17 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ body {
font-style: normal;
}

@layer utilities {
.w-1\/7 {
width: 14.285714%;
}
.w-1\/8 {
width: 12.5%;
}
}

#root {
@apply w-full max-w-screen-xl;
}
213 changes: 140 additions & 73 deletions src/lib/components/TaskTables/TaskTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@
} from 'svelte-5-ui-lib';
import type { TaskResults, TaskResult } from '$lib/types/task';
import type { ContestTableProvider } from '$lib/types/contest_table_provider';
import type {
ContestTableProvider,
ContestTableDisplayConfig,
} from '$lib/types/contest_table_provider';
import TaskTableBodyCell from '$lib/components/TaskTables/TaskTableBodyCell.svelte';
import { activeContestTypeStore } from '$lib/stores/active_contest_type.svelte';
import {
contestTableProviders,
type ContestTableProviders,
contestTableProviderGroups,
type ContestTableProviderGroup,
type ContestTableProviderGroups,
} from '$lib/utils/contest_table_provider';
import { getBackgroundColorFrom } from '$lib/services/submission_status';
Expand All @@ -32,38 +36,81 @@
let { taskResults, isLoggedIn }: Props = $props();
// Prepare contest table provider based on the active contest type.
let activeContestType = $derived(activeContestTypeStore.get());
let activeContestType: ContestTableProviderGroups = $derived(activeContestTypeStore.get());
// Note: This is necessary to ensure that the active contest type is updated correctly.
function updateActiveContestType(type: ContestTableProviders): void {
function updateActiveContestType(type: ContestTableProviderGroups): void {
activeContestType = type;
activeContestTypeStore.set(type);
}
let provider: ContestTableProvider = $derived(
contestTableProviders[activeContestType as ContestTableProviders],
let providerGroups: ContestTableProviderGroup = $derived(
contestTableProviderGroups[activeContestType as ContestTableProviderGroups],
);
// 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<string, Record<string, TaskResult>> = $derived(
provider.generateTable(filteredTaskResults),
);
let taskTableHeaderIds: Array<string> = $derived(
provider.getHeaderIdsForTask(filteredTaskResults),
);
let contestIds: Array<string> = $derived(provider.getContestRoundIds(filteredTaskResults));
let title = $derived(provider.getMetadata().title);
let providers = $derived(providerGroups.getAllProviders());
interface ProviderData {
filteredTaskResults: TaskResults;
innerTaskTable: Record<string, Record<string, TaskResult>>;
headerIds: Array<string>;
contestIds: Array<string>;
metadata: any;
displayConfig: ContestTableDisplayConfig;
}
let contestTableMaps = $derived(() => prepareContestTablesMap(providers));
function prepareContestTablesMap(providers: ContestTableProvider[]): Map<string, ProviderData> {
const map = new Map<string, ProviderData>();
for (const provider of providers) {
const abbreviationName = provider.getMetadata().abbreviationName;
const contestTable = prepareContestTable(provider);
if (contestTable) {
map.set(abbreviationName, contestTable);
}
}
return map;
}
function prepareContestTable(provider: ContestTableProvider): ProviderData | null {
const filteredTaskResults = provider.filter(taskResults);
if (filteredTaskResults.length === 0) {
return null;
}
return {
filteredTaskResults: filteredTaskResults,
innerTaskTable: provider.generateTable(filteredTaskResults),
headerIds: provider.getHeaderIdsForTask(filteredTaskResults),
contestIds: provider.getContestRoundIds(filteredTaskResults),
metadata: provider.getMetadata(),
displayConfig: provider.getDisplayConfig(),
};
}
function getTaskTable(abbreviationName: string) {
return contestTableMaps().get(abbreviationName);
}
function getContestRoundLabel(provider: ContestTableProvider, contestId: string): string {
return provider.getContestRoundLabel(contestId);
}
function getBodyCellClasses(contestId: string, taskIndex: string): string {
// 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 getBodyCellClasses(taskResult: TaskResult, totalColumns: number): string {
const baseClasses = 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 px-1 py-1';
const backgroundColor = getBackgroundColor(taskTable[contestId][taskIndex]);
const additionalClasses = totalColumns > 8 ? 'lg:w-1/7 2xl:w-1/8 py-2' : '';
const backgroundColor = getBackgroundColor(taskResult);
return `${baseClasses} ${backgroundColor}`;
return `${baseClasses} ${additionalClasses} ${backgroundColor}`;
}
function getBackgroundColor(taskResult: TaskResult): string {
Expand Down Expand Up @@ -117,10 +164,10 @@
<!-- See: -->
<!-- https://flowbite-svelte.com/docs/components/button-group -->
<ButtonGroup class="m-4 contents-center">
{#each Object.entries(contestTableProviders) as [type, config]}
{#each Object.entries(contestTableProviderGroups) as [type, config]}
<Button
onclick={() => updateActiveContestType(type as ContestTableProviders)}
class={activeContestType === (type as ContestTableProviders)
onclick={() => updateActiveContestType(type as ContestTableProviderGroups)}
class={activeContestType === (type as ContestTableProviderGroups)
? 'active-button-class text-primary-700 dark:!text-primary-500'
: ''}
aria-label={config.getMetadata().ariaLabel}
Expand All @@ -130,60 +177,80 @@
{/each}
</ButtonGroup>

<Heading tag="h2" class="text-2xl pb-3 text-gray-900 dark:text-white">
{title}
</Heading>

<!-- TODO: ページネーションを実装 -->
<!-- See: -->
<!-- https://github.com/kenkoooo/AtCoderProblems/blob/master/atcoder-problems-frontend/src/pages/TablePage/AtCoderRegularTable.tsx -->
<!-- https://github.com/birdou/atcoder-blogs/blob/main/app/atcoder-blogs-frontend/src/pages/BlogTablePage/BlogTablePage.tsx -->
<!-- https://tailwindcss.com/docs/position#sticky-positioning-elements -->
<div class="container w-full rounded-md border shadow-sm">
<div class="w-full sticky top-0 z-20 border-b">
<Table id="task-table" class="text-md table-fixed w-full" aria-label="Task table">
<TableHead class="text-sm bg-gray-100">
<TableHeadCell class="w-full xl:w-16 px-2 text-center" scope="col">Round</TableHeadCell>

{#if taskTableHeaderIds.length}
{#each taskTableHeaderIds as taskTableHeaderId}
<TableHeadCell class="text-center" scope="col">
{taskTableHeaderId}
</TableHeadCell>
{/each}
{/if}
</TableHead>
</Table>
</div>
{#each providers as provider}
{@const metadata = provider.getMetadata()}
{@const contestTable = getTaskTable(metadata.abbreviationName)}

<!-- Title -->
<Heading tag="h2" class="text-2xl pb-3 text-gray-900 dark:text-white">
{metadata.title}
</Heading>

<div class="w-full overflow-auto max-h-[calc(80vh-56px)]">
<Table id="task-table" class="text-md table-fixed w-full" aria-label="Task table">
<TableBody class="divide-y">
{#if contestIds.length && taskTableHeaderIds.length}
{#each contestIds as contestId}
<TableBodyRow class="flex flex-wrap xl:table-row">
<TableBodyCell class="w-full xl:w-16 truncate px-2 py-2 text-center">
{getContestRoundLabel(provider, contestId)}
</TableBodyCell>

{#each taskTableHeaderIds as taskTableHeaderId}
<TableBodyCell
id={contestId + '-' + taskTableHeaderId}
class={getBodyCellClasses(contestId, taskTableHeaderId)}
>
{#if taskTable[contestId][taskTableHeaderId]}
<TaskTableBodyCell
taskResult={taskTable[contestId][taskTableHeaderId]}
{isLoggedIn}
onupdate={(updatedTask: TaskResult) => handleUpdateTaskResult(updatedTask)}
/>
{/if}
</TableBodyCell>
<div
class="container w-full rounded-md border border-gray-200 dark:border-gray-100 shadow-sm mb-6 overflow-hidden"
>
<!-- Table header -->
{#if contestTable && contestTable.displayConfig.isShownHeader}
<div class="w-full sticky top-0 z-20 border-b border-gray-200 dark:border-gray-100">
<Table id="task-table" class="text-md table-fixed w-full" aria-label="Task table">
<TableHead class="text-sm border-gray-200 dark:border-gray-100">
<TableHeadCell class="w-full xl:w-16 px-2 text-center" scope="col">Round</TableHeadCell>

{#if contestTable.headerIds}
{#each contestTable.headerIds as taskTableHeaderId}
<TableHeadCell class="text-center" scope="col">
{taskTableHeaderId}
</TableHeadCell>
{/each}
</TableBodyRow>
{/each}
{/if}
</TableBody>
</Table>
{/if}
</TableHead>
</Table>
</div>
{/if}

<!-- Table body -->
<div class="w-full overflow-auto max-h-[calc(80vh-56px)] bg-white dark:bg-gray-900">
<Table id="task-table" class="text-md table-fixed w-full" aria-label="Task table">
<TableBody class="divide-y divide-gray-200 dark:divide-gray-700">
{#if contestTable && contestTable.contestIds && contestTable.headerIds}
{@const totalColumns = contestTable.headerIds.length}

{#each contestTable.contestIds as contestId}
<TableBodyRow class={getBodyRowClasses(totalColumns)}>
{#if contestTable.displayConfig.isShownRoundLabel}
<TableBodyCell
class="w-full xl:w-16 truncate px-2 py-2 text-center bg-gray-50 dark:bg-gray-800"
>
{getContestRoundLabel(provider, contestId)}
</TableBodyCell>
{/if}

{#each contestTable.headerIds as taskTableHeaderId}
{@const taskResult = contestTable.innerTaskTable[contestId][taskTableHeaderId]}

<TableBodyCell
id={contestId + '-' + taskTableHeaderId}
class={getBodyCellClasses(taskResult, totalColumns)}
>
{#if taskResult}
<TaskTableBodyCell
{taskResult}
{isLoggedIn}
onupdate={(updatedTask: TaskResult) => handleUpdateTaskResult(updatedTask)}
/>
{/if}
</TableBodyCell>
{/each}
</TableBodyRow>
{/each}
{/if}
</TableBody>
</Table>
</div>
</div>
</div>
{/each}
14 changes: 7 additions & 7 deletions src/lib/stores/active_contest_type.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useLocalStorage } from '$lib/stores/local_storage_helper.svelte';
import { type ContestTableProviders } from '$lib/utils/contest_table_provider';
import { type ContestTableProviderGroups } from '$lib/utils/contest_table_provider';

/**
* Store that manages the active contest type selection.
Expand All @@ -8,12 +8,12 @@ import { type ContestTableProviders } from '$lib/utils/contest_table_provider';
* is currently active button. It provides methods to get, set, and
* compare the active contest type.
*
* The store uses the ContestTableProviders type which represents
* The store uses the ContestTableProviderGroups type which represents
* different contest table configurations or data providers,
* with a default value of 'abcLatest20Rounds'.
*/
export class ActiveContestTypeStore {
private storage = useLocalStorage<ContestTableProviders>(
private storage = useLocalStorage<ContestTableProviderGroups>(
'contest_table_providers',
'abcLatest20Rounds',
);
Expand All @@ -24,7 +24,7 @@ export class ActiveContestTypeStore {
* @param defaultContestType - The default contest type to initialize.
* Defaults to 'abcLatest20Rounds'.
*/
constructor(defaultContestType: ContestTableProviders = 'abcLatest20Rounds') {
constructor(defaultContestType: ContestTableProviderGroups = 'abcLatest20Rounds') {
if (defaultContestType !== 'abcLatest20Rounds' || !this.storage.value) {
this.storage.value = defaultContestType;
}
Expand All @@ -35,7 +35,7 @@ export class ActiveContestTypeStore {
*
* @returns The current value of contest table providers.
*/
get(): ContestTableProviders {
get(): ContestTableProviderGroups {
return this.storage.value;
}

Expand All @@ -44,7 +44,7 @@ export class ActiveContestTypeStore {
*
* @param newContestType - The contest type to set as the current value
*/
set(newContestType: ContestTableProviders): void {
set(newContestType: ContestTableProviderGroups): void {
this.storage.value = newContestType;
}

Expand All @@ -53,7 +53,7 @@ export class ActiveContestTypeStore {
* @param contestType - The contest type to compare against
* @returns `true` if the current contest type matches the provided contest type, `false` otherwise
*/
isSame(contestType: ContestTableProviders): boolean {
isSame(contestType: ContestTableProviderGroups): boolean {
return this.storage.value === contestType;
}

Expand Down
Loading
Loading