diff --git a/.claude/rules/coding-style.md b/.claude/rules/coding-style.md
index 4f492d4e1..9852d5876 100644
--- a/.claude/rules/coding-style.md
+++ b/.claude/rules/coding-style.md
@@ -28,6 +28,7 @@ Before writing new logic, decide which layer it belongs to. Run this check at pl
- **`upsert`**: only use when the implementation performs both insert and update. For insert-only, use `initialize`, `seed`, or another accurate verb.
- **`any`**: before using `any`, check the value's origin — adding a missing `@types/*` or `devDependency` often provides the correct type.
- **UI labels**: if a label does not match actual behavior, update it or add an inline comment explaining the intentional mismatch.
+- **Constant names**: reflect what the value IS (content), not what it is used for (purpose). e.g., a set holding all enum tab values is `EXISTING_TABS`, not `VALID_TABS`.
- **New files**: before naming a new file or directory, grep the relevant `src/` directory to confirm existing conventions. Confirm at plan time, not during implementation:
- Custom files in routes (utilities, helpers, etc.): `snake_case` (e.g., `user_profile.ts`)
- SvelteKit special files: follow framework conventions (`+page.svelte`, `+page.server.ts`, `+server.ts`)
@@ -70,10 +71,18 @@ Shared helper functions (used by two or more exports) should be grouped at the e
## Documentation
+### Language Policy
+
+Write all project documentation (plans, dev-notes, guides, refactor notes) in Japanese. Write all source code comments, TSDoc, commit messages, and test titles in English. This keeps documentation readable for the team while keeping code comments universally accessible and searchable.
+
+**Exception**: The `## CodeRabbit Findings` section in `refactor.md` must quote findings verbatim in their original language (English). Do not translate CodeRabbit output.
+
### TSDoc
Add TSDoc comments to every exported function, type, and class. The minimum required fields are `@param` (for non-obvious parameters) and `@returns` (when the return value is not evident from the type). One-liner `/** ... */` is sufficient for simple cases; use multi-line only when behavior needs explanation.
+For optional parameters with a default, state it explicitly in `@param`: `Defaults to false.`
+
```typescript
/** Returns the URL slug for a workbook, falling back to the workbook ID. */
export function getUrlSlugFrom(workbook: WorkbookList): string { ... }
@@ -158,9 +167,11 @@ update payload, not the reactivity system.
### CodeRabbit Review: Severity Triage
-When running `coderabbit review --plain` at a Phase milestone:
+Run `coderabbit review --plain` once after all phases are complete (not on every commit).
+
+**Triage by severity:**
-- **critical / high**: fix before starting the next Phase
-- **low / info**: review before the next Phase starts; fix immediately only if security- or regression-related; otherwise defer to final PR review (alongside CodeRabbit CI comments)
+- **critical / high / potential_issue (medium)**: Write all findings verbatim to a `## CodeRabbit Findings` section in `refactor.md`. The user decides which to fix before opening the PR. Do not fix any of these findings unilaterally.
+- **nitpick / info**: Defer to PR CI — CodeRabbit will re-comment on the open PR.
-Run once per Phase boundary — not on every commit.
+Writing medium-and-above findings to `refactor.md` serves a dual purpose: it gives the user full visibility for a fix/defer decision, and it builds the implementer's understanding of recurring quality issues.
diff --git a/.claude/rules/prisma-db.md b/.claude/rules/prisma-db.md
index f7f5a5c03..0ebb40448 100644
--- a/.claude/rules/prisma-db.md
+++ b/.claude/rules/prisma-db.md
@@ -74,6 +74,15 @@ Prefer `createMany({ skipDuplicates: true })` over catching P2002 for expected u
`z.number().positive()` passes decimals. For Prisma `Int` fields use `z.number().int().positive()`.
+## Relation Filter Exclusion
+
+Filtering on a relation field (e.g. `where: { placement: { type: 'CURRICULUM' } }`)
+performs an INNER JOIN internally — rows without a matching relation record are
+automatically excluded. This is not an IS NOT NULL check; the mechanism is the JOIN.
+
+When documenting this behavior, write "excluded by INNER JOIN" rather than
+"implicitly includes IS NOT NULL".
+
## Validate Constraints
Prisma does not support `@@check`. To add one:
diff --git a/.claude/rules/svelte-components.md b/.claude/rules/svelte-components.md
index b28649340..9739f468b 100644
--- a/.claude/rules/svelte-components.md
+++ b/.claude/rules/svelte-components.md
@@ -29,7 +29,27 @@ Use `$props()`, `$state()`, `$derived()`, `$effect()` in all components. Props p
## Flowbite Svelte
-Import from `flowbite-svelte`. Use Tailwind CSS v4 utility classes. Dark mode: `dark:` prefix.
+Import from `flowbite-svelte`. Use Tailwind CSS v4 utility classes. Dark mode: `dark:` prefix. Important modifier: `dark:text-xxx!` (v4 syntax) — the v3 form `dark:!text-xxx` is invalid.
+
+### ButtonGroup: No Responsive Wrapping
+
+`ButtonGroup` uses `flex` internally — buttons do not wrap on narrow screens. When wrapping is needed, use `
` with individual `Button` components (reference: `TaskTable.svelte`).
+
+When copying button styles from a reference component, always check all three axes: `color`, `size`, and `class`. Omitting `color` applies Flowbite's default (filled blue).
+
+## `let`/`const` — Reactive Data Requires `$derived`
+
+Plain `let` or `const` in Svelte 5 component `
-
-
-
- 作者
-
- 回答状況
-
- 修了
-
-
+{#if visibleWorkbooks.length >= 1}
+
+
+
+ 作者
+
+ 回答状況
+
+ 修了
+
+
-
- {#each workbooks as workbook (workbook.id)}
- {#if canRead(workbook.isPublished, userId, workbook.authorId)}
+
+ {#each visibleWorkbooks as workbook (workbook.id)}
@@ -63,8 +62,10 @@
- {/if}
- {/each}
-
-
-
+ {/each}
+
+
+
+{:else}
+
+{/if}
diff --git a/src/features/workbooks/components/list/CurriculumTable.svelte b/src/features/workbooks/components/list/CurriculumTable.svelte
index 89f8bdaf2..ea0a4df0c 100644
--- a/src/features/workbooks/components/list/CurriculumTable.svelte
+++ b/src/features/workbooks/components/list/CurriculumTable.svelte
@@ -13,7 +13,8 @@
import { canRead } from '$lib/utils/authorship';
import { getGradeMode, getTaskResult } from '$features/workbooks/utils/workbooks';
- let { workbooks, workbookGradeModes, userId, role, taskResults }: WorkbookTableProps = $props();
+ let { workbooks, gradeModesEachWorkbook, userId, role, taskResults }: WorkbookTableProps =
+ $props();
@@ -33,7 +34,7 @@
{#each workbooks as workbook (workbook.id)}
{#if canRead(workbook.isPublished, userId, workbook.authorId)}
-
+
diff --git a/src/features/workbooks/components/list/CurriculumWorkBookList.svelte b/src/features/workbooks/components/list/CurriculumWorkBookList.svelte
index b1899881b..034998bdb 100644
--- a/src/features/workbooks/components/list/CurriculumWorkBookList.svelte
+++ b/src/features/workbooks/components/list/CurriculumWorkBookList.svelte
@@ -1,21 +1,17 @@
diff --git a/src/features/workbooks/components/list/SolutionWorkBookList.svelte b/src/features/workbooks/components/list/SolutionWorkBookList.svelte
new file mode 100644
index 000000000..6b03e48ae
--- /dev/null
+++ b/src/features/workbooks/components/list/SolutionWorkBookList.svelte
@@ -0,0 +1,65 @@
+
+
+
+ {#each AVAILABLE_CATEGORIES as category (category)}
+ onCategoryChange(category)}
+ color="alternative"
+ class={`rounded-lg dark:text-white ${currentCategory === category ? 'text-primary-700 dark:text-primary-500!' : ''}`}
+ >
+ {SOLUTION_LABELS[category]}
+
+ {/each}
+
+
+{#if readableCount}
+
+{:else}
+
+{/if}
diff --git a/src/features/workbooks/components/list/WorkBookList.svelte b/src/features/workbooks/components/list/WorkBookList.svelte
index 1af38e378..184f86187 100644
--- a/src/features/workbooks/components/list/WorkBookList.svelte
+++ b/src/features/workbooks/components/list/WorkBookList.svelte
@@ -1,74 +1,68 @@
-{#if workbookType === WorkBookType.CURRICULUM}
+{#if restProps.workbookType === WorkBookType.CURRICULUM}
+{:else if restProps.workbookType === WorkBookType.SOLUTION}
+
{:else}
- {@const TableComponent = tableComponents[workbookType]}
-
- {#if readableCount && TableComponent}
-
- {:else}
-
- {/if}
+
{/if}
diff --git a/src/features/workbooks/components/list/WorkbookTabItem.svelte b/src/features/workbooks/components/list/WorkbookTabItem.svelte
index 4c77bd961..55d5e7d5b 100644
--- a/src/features/workbooks/components/list/WorkbookTabItem.svelte
+++ b/src/features/workbooks/components/list/WorkbookTabItem.svelte
@@ -1,27 +1,18 @@
-
activeWorkbookTabStore.setActiveWorkbookTab(workbookType)}
->
+
{@render children?.()}
diff --git a/src/features/workbooks/services/workbooks.test.ts b/src/features/workbooks/services/workbooks.test.ts
index 818a8afff..ee65b7c36 100644
--- a/src/features/workbooks/services/workbooks.test.ts
+++ b/src/features/workbooks/services/workbooks.test.ts
@@ -1,7 +1,5 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
-import { WorkBookType, type WorkBook } from '$features/workbooks/types/workbook';
-
import {
getWorkBook,
getWorkBooksWithAuthors,
@@ -9,7 +7,13 @@ import {
createWorkBook,
updateWorkBook,
deleteWorkBook,
+ getWorkbooksByPlacement,
+ getWorkBooksCreatedByUsers,
+ getAvailableSolutionCategories,
} from './workbooks';
+import { TaskGrade } from '$lib/types/task';
+import { WorkBookType, type WorkBook } from '$features/workbooks/types/workbook';
+import { SolutionCategory } from '$features/workbooks/types/workbook_placement';
vi.mock('$lib/server/database', () => ({
default: {
@@ -24,6 +28,9 @@ vi.mock('$lib/server/database', () => ({
workBookTask: {
deleteMany: vi.fn(),
},
+ workBookPlacement: {
+ findMany: vi.fn(),
+ },
$transaction: vi.fn(),
},
}));
@@ -76,7 +83,7 @@ function mockFindUnique(value: PrismaWorkBook) {
vi.mocked(prisma.workBook.findUnique).mockResolvedValue(value);
}
-function mockFindMany(value: PrismaWorkBookWithUser[]) {
+function mockFindMany(value: object[]) {
vi.mocked(prisma.workBook.findMany).mockResolvedValue(
value as unknown as Awaited>,
);
@@ -98,6 +105,209 @@ function mockDelete(value: NonNullable) {
vi.mocked(prisma.workBook.delete).mockResolvedValue(value);
}
+describe('getWorkBooksWithAuthors', () => {
+ test('maps username to authorName', async () => {
+ const workBook = prepareWorkBook({ id: 1 });
+ mockFindMany([asPrismaWorkBookWithUser(workBook, { username: 'alice' })]);
+
+ const result = await getWorkBooksWithAuthors();
+
+ expect(result[0].authorName).toBe('alice');
+ });
+
+ test('uses "unknown" as authorName when author is deleted', async () => {
+ const workBook = prepareWorkBook({ id: 2 });
+ mockFindMany([asPrismaWorkBookWithUser(workBook, null)]);
+
+ const result = await getWorkBooksWithAuthors();
+
+ expect(result[0].authorName).toBe('unknown');
+ });
+});
+
+const MOCK_WORKBOOK_BASE = {
+ id: 1,
+ title: 'Test workbook',
+ isPublished: true,
+ isReplenished: false,
+ isOfficial: true,
+ authorId: 'user1',
+ description: '',
+ editorialUrl: '',
+ urlSlug: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ workBookTasks: [],
+ user: { username: 'author1' },
+};
+
+describe('getWorkbooksByPlacement', () => {
+ test('filters CURRICULUM workbooks by taskGrade with priority asc order', async () => {
+ mockFindMany([{ ...MOCK_WORKBOOK_BASE, workBookType: WorkBookType.CURRICULUM }]);
+
+ const result = await getWorkbooksByPlacement({
+ workBookType: WorkBookType.CURRICULUM,
+ taskGrade: TaskGrade.Q10,
+ });
+
+ expect(prisma.workBook.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ workBookType: WorkBookType.CURRICULUM,
+ isPublished: true,
+ placement: { taskGrade: TaskGrade.Q10 },
+ }),
+ orderBy: { placement: { priority: 'asc' } },
+ include: {
+ user: { select: { username: true } },
+ workBookTasks: { orderBy: { priority: 'asc' } },
+ },
+ }),
+ );
+ expect(result[0].authorName).toBe('author1');
+ });
+
+ test('excludes unpublished workbooks by default (includeUnpublished = false)', async () => {
+ mockFindMany([{ ...MOCK_WORKBOOK_BASE, workBookType: WorkBookType.CURRICULUM }]);
+
+ await getWorkbooksByPlacement(
+ { workBookType: WorkBookType.CURRICULUM, taskGrade: TaskGrade.Q10 },
+ false,
+ );
+
+ expect(prisma.workBook.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({ isPublished: true }),
+ }),
+ );
+ });
+
+ test('includes unpublished workbooks when includeUnpublished = true', async () => {
+ mockFindMany([{ ...MOCK_WORKBOOK_BASE, workBookType: WorkBookType.CURRICULUM }]);
+
+ await getWorkbooksByPlacement(
+ { workBookType: WorkBookType.CURRICULUM, taskGrade: TaskGrade.Q10 },
+ true,
+ );
+
+ const callArg = vi.mocked(prisma.workBook.findMany).mock.calls[0][0];
+ expect(callArg?.where).not.toHaveProperty('isPublished');
+ });
+
+ test('filters SOLUTION workbooks by solutionCategory', async () => {
+ mockFindMany([{ ...MOCK_WORKBOOK_BASE, workBookType: WorkBookType.SOLUTION }]);
+
+ await getWorkbooksByPlacement({
+ workBookType: WorkBookType.SOLUTION,
+ solutionCategory: SolutionCategory.GRAPH,
+ });
+
+ expect(prisma.workBook.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ workBookType: WorkBookType.SOLUTION,
+ placement: { solutionCategory: SolutionCategory.GRAPH },
+ }),
+ }),
+ );
+ });
+
+ test('maps null user to authorName "unknown"', async () => {
+ mockFindMany([{ ...MOCK_WORKBOOK_BASE, workBookType: WorkBookType.CURRICULUM, user: null }]);
+
+ const result = await getWorkbooksByPlacement({
+ workBookType: WorkBookType.CURRICULUM,
+ taskGrade: TaskGrade.Q10,
+ });
+
+ expect(result[0].authorName).toBe('unknown');
+ });
+});
+
+describe('getWorkBooksCreatedByUsers', () => {
+ test('queries only CREATED_BY_USER type workbooks ordered by id asc', async () => {
+ mockFindMany([{ ...MOCK_WORKBOOK_BASE, workBookType: WorkBookType.CREATED_BY_USER }]);
+
+ await getWorkBooksCreatedByUsers();
+
+ expect(prisma.workBook.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { workBookType: WorkBookType.CREATED_BY_USER },
+ orderBy: { id: 'asc' },
+ include: {
+ user: { select: { username: true } },
+ workBookTasks: { orderBy: { priority: 'asc' } },
+ },
+ }),
+ );
+ });
+
+ test('maps null user to authorName "unknown"', async () => {
+ mockFindMany([
+ { ...MOCK_WORKBOOK_BASE, workBookType: WorkBookType.CREATED_BY_USER, user: null },
+ ]);
+
+ const result = await getWorkBooksCreatedByUsers();
+
+ expect(result[0].authorName).toBe('unknown');
+ });
+});
+
+describe('getAvailableSolutionCategories', () => {
+ test('returns distinct non-null solutionCategory values', async () => {
+ vi.mocked(prisma.workBookPlacement.findMany).mockResolvedValue([
+ { solutionCategory: SolutionCategory.GRAPH },
+ { solutionCategory: SolutionCategory.DYNAMIC_PROGRAMMING },
+ ] as unknown as Awaited>);
+
+ const result = await getAvailableSolutionCategories();
+
+ expect(result).toEqual([SolutionCategory.GRAPH, SolutionCategory.DYNAMIC_PROGRAMMING]);
+ });
+
+ test('excludes null solutionCategory entries', async () => {
+ vi.mocked(prisma.workBookPlacement.findMany).mockResolvedValue([
+ { solutionCategory: SolutionCategory.GRAPH },
+ { solutionCategory: null },
+ ] as unknown as Awaited>);
+
+ const result = await getAvailableSolutionCategories();
+
+ expect(result).toEqual([SolutionCategory.GRAPH]);
+ });
+
+ test('passes isPublished: true filter by default', async () => {
+ vi.mocked(prisma.workBookPlacement.findMany).mockResolvedValue([]);
+
+ await getAvailableSolutionCategories();
+
+ expect(prisma.workBookPlacement.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ workBook: expect.objectContaining({ isPublished: true }),
+ }),
+ }),
+ );
+ });
+
+ test('omits isPublished filter when includeUnpublished is true', async () => {
+ vi.mocked(prisma.workBookPlacement.findMany).mockResolvedValue([
+ { solutionCategory: SolutionCategory.GRAPH },
+ ] as unknown as Awaited>);
+
+ const result = await getAvailableSolutionCategories(true);
+
+ expect(result).toEqual([SolutionCategory.GRAPH]);
+ expect(prisma.workBookPlacement.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ workBook: expect.not.objectContaining({ isPublished: expect.anything() }),
+ }),
+ }),
+ );
+ });
+});
+
describe('getWorkBook', () => {
test('returns workbook when found', async () => {
const workBook = prepareWorkBook({ id: 42 });
@@ -120,26 +330,6 @@ describe('getWorkBook', () => {
});
});
-describe('getWorkBooksWithAuthors', () => {
- test('maps username to authorName', async () => {
- const workBook = prepareWorkBook({ id: 1 });
- mockFindMany([asPrismaWorkBookWithUser(workBook, { username: 'alice' })]);
-
- const result = await getWorkBooksWithAuthors();
-
- expect(result[0].authorName).toBe('alice');
- });
-
- test('uses "unknown" as authorName when author is deleted', async () => {
- const workBook = prepareWorkBook({ id: 2 });
- mockFindMany([asPrismaWorkBookWithUser(workBook, null)]);
-
- const result = await getWorkBooksWithAuthors();
-
- expect(result[0].authorName).toBe('unknown');
- });
-});
-
describe('getWorkbookWithAuthor', () => {
function mockGetUserById(value: { id: string } | null) {
vi.mocked(usersCrud.getUserById).mockResolvedValue(value as never);
diff --git a/src/features/workbooks/services/workbooks.ts b/src/features/workbooks/services/workbooks.ts
index 8f1ab7ce9..191aa159c 100644
--- a/src/features/workbooks/services/workbooks.ts
+++ b/src/features/workbooks/services/workbooks.ts
@@ -7,6 +7,12 @@ import type {
WorkBookTasksBase,
WorkBookType,
} from '$features/workbooks/types/workbook';
+import { WorkBookType as WorkBookTypeConst } from '$features/workbooks/types/workbook';
+import {
+ type PlacementQuery,
+ SolutionCategory,
+ type SolutionCategories,
+} from '$features/workbooks/types/workbook_placement';
import {
getWorkBookTasks,
@@ -51,10 +57,92 @@ export async function getWorkBooksWithAuthors(): Promise {
},
});
- return workbooks.map((workbook) => ({
- ...workbook,
- authorName: workbook.user?.username ?? 'unknown',
- }));
+ return mapWithAuthorName(workbooks);
+}
+
+/**
+ * Returns workbooks filtered by WorkBookPlacement, ordered by priority ASC.
+ * Workbooks without a placement record are automatically excluded by Prisma's nested where filter.
+ *
+ * @param query - Discriminated union: CURRICULUM uses taskGrade; SOLUTION uses solutionCategory
+ * @param includeUnpublished - When true, unpublished workbooks are included (admin use). Defaults to false.
+ */
+export async function getWorkbooksByPlacement(
+ query: PlacementQuery,
+ includeUnpublished = false,
+): Promise {
+ const placementFilter =
+ query.workBookType === WorkBookTypeConst.CURRICULUM
+ ? { taskGrade: query.taskGrade }
+ : { solutionCategory: query.solutionCategory };
+
+ const workbooks = await db.workBook.findMany({
+ where: {
+ workBookType: query.workBookType,
+ ...(includeUnpublished ? {} : { isPublished: true }),
+ placement: placementFilter,
+ },
+ orderBy: {
+ placement: { priority: 'asc' },
+ },
+ include: {
+ user: {
+ select: { username: true },
+ },
+ workBookTasks: {
+ orderBy: { priority: 'asc' },
+ },
+ },
+ });
+
+ return mapWithAuthorName(workbooks);
+}
+
+/**
+ * Returns all CREATED_BY_USER workbooks with author names, ordered by id ASC.
+ * Intended for admin-only display on the workbooks list page.
+ */
+export async function getWorkBooksCreatedByUsers(): Promise {
+ const workbooks = await db.workBook.findMany({
+ where: { workBookType: WorkBookTypeConst.CREATED_BY_USER },
+ orderBy: { id: 'asc' },
+ include: {
+ user: {
+ select: { username: true },
+ },
+ workBookTasks: {
+ orderBy: { priority: 'asc' },
+ },
+ },
+ });
+
+ return mapWithAuthorName(workbooks);
+}
+
+/**
+ * Returns the list of SolutionCategory values that have at least one SOLUTION
+ * workbook with a placement record.
+ *
+ * @param includeUnpublished - When true, includes categories from unpublished workbooks. Defaults to false.
+ */
+export async function getAvailableSolutionCategories(
+ includeUnpublished = false,
+): Promise {
+ const placements = await db.workBookPlacement.findMany({
+ where: {
+ workBook: {
+ ...(includeUnpublished ? {} : { isPublished: true }),
+ workBookType: WorkBookTypeConst.SOLUTION,
+ },
+ solutionCategory: { not: null },
+ },
+ select: { solutionCategory: true },
+ distinct: ['solutionCategory'],
+ });
+
+ return placements
+ .map((placement) => placement.solutionCategory)
+ .filter((category): category is SolutionCategory => category !== null);
}
export async function getWorkBook(workBookId: number): Promise {
@@ -211,3 +299,14 @@ export async function deleteWorkBook(workBookId: number): Promise {
},
});
}
+
+// ---- Private helpers ----
+
+function mapWithAuthorName(
+ workbooks: T[],
+): (T & { authorName: string })[] {
+ return workbooks.map((workbook) => ({
+ ...workbook,
+ authorName: workbook.user?.username ?? 'unknown',
+ }));
+}
diff --git a/src/features/workbooks/stores/active_workbook_tab.test.ts b/src/features/workbooks/stores/active_workbook_tab.test.ts
deleted file mode 100644
index 25f5e88c2..000000000
--- a/src/features/workbooks/stores/active_workbook_tab.test.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-import { get } from 'svelte/store';
-import { expect, test } from 'vitest';
-
-import { activeWorkbookTabStore } from '$features/workbooks/stores/active_workbook_tab';
-import { WorkBookType } from '$features/workbooks/types/workbook';
-
-type InitialState = {
- workBookType: WorkBookType;
-};
-
-type InitialStates = InitialState[];
-
-type UpdateOnce = InitialState;
-
-type UpdateOnces = UpdateOnce[];
-
-type UpdateTwice = {
- firstTimeUpdated: WorkBookType;
- secondTimeUpdated: WorkBookType;
-};
-
-describe('Active workbook tab store', () => {
- describe('initial values is true only for CURRICULUM', () => {
- test('getActiveWorkbookTab(workBookType: CURRICULUM)', () => {
- expect(get(activeWorkbookTabStore).get(WorkBookType.CURRICULUM)).toBeTruthy();
- });
- });
-
- describe('initial value is false except for CURRICULUM', () => {
- const testCases = [
- { workBookType: WorkBookType.TEXTBOOK },
- { workBookType: WorkBookType.SOLUTION },
- { workBookType: WorkBookType.CREATED_BY_USER },
- ];
- runTests('getActiveWorkbookTab', testCases, ({ workBookType }: InitialState) => {
- expect(get(activeWorkbookTabStore).get(workBookType)).toBeFalsy();
- });
-
- function runTests(
- testName: string,
- testCases: InitialStates,
- testFunction: (testCase: InitialState) => void,
- ) {
- test.each(testCases)(`${testName}(workBookType: $workBookType)`, testFunction);
- }
- });
-
- describe('update active workbook tab once', () => {
- const testCases = [
- { workBookType: WorkBookType.CURRICULUM },
- { workBookType: WorkBookType.TEXTBOOK },
- { workBookType: WorkBookType.SOLUTION },
- { workBookType: WorkBookType.CREATED_BY_USER },
- ];
- runTests('updateActiveWorkBookTabOnce', testCases, ({ workBookType }: UpdateOnce) => {
- activeWorkbookTabStore.setActiveWorkbookTab(workBookType);
-
- const workBookTabs = get(activeWorkbookTabStore);
- workBookTabs.forEach((isOpen, activeWorkBookType) => {
- if (activeWorkBookType === workBookType) {
- expect(isOpen).toBeTruthy();
- } else {
- expect(isOpen).toBeFalsy();
- }
- });
- });
-
- function runTests(
- testName: string,
- testCases: UpdateOnces,
- testFunction: (testCase: UpdateOnce) => void,
- ) {
- test.each(testCases)(`${testName}(workBookType: $workBookType)`, testFunction);
- }
- });
-
- describe('update active workbook tab twice', () => {
- const testCases = [
- {
- firstTimeUpdated: WorkBookType.CURRICULUM,
- secondTimeUpdated: WorkBookType.CURRICULUM,
- },
- {
- firstTimeUpdated: WorkBookType.CURRICULUM,
- secondTimeUpdated: WorkBookType.TEXTBOOK,
- },
- {
- firstTimeUpdated: WorkBookType.CURRICULUM,
- secondTimeUpdated: WorkBookType.SOLUTION,
- },
- {
- firstTimeUpdated: WorkBookType.CURRICULUM,
- secondTimeUpdated: WorkBookType.CREATED_BY_USER,
- },
- {
- firstTimeUpdated: WorkBookType.TEXTBOOK,
- secondTimeUpdated: WorkBookType.CURRICULUM,
- },
- {
- firstTimeUpdated: WorkBookType.TEXTBOOK,
- secondTimeUpdated: WorkBookType.TEXTBOOK,
- },
- {
- firstTimeUpdated: WorkBookType.TEXTBOOK,
- secondTimeUpdated: WorkBookType.SOLUTION,
- },
- {
- firstTimeUpdated: WorkBookType.TEXTBOOK,
- secondTimeUpdated: WorkBookType.CREATED_BY_USER,
- },
- {
- firstTimeUpdated: WorkBookType.SOLUTION,
- secondTimeUpdated: WorkBookType.CURRICULUM,
- },
- {
- firstTimeUpdated: WorkBookType.SOLUTION,
- secondTimeUpdated: WorkBookType.TEXTBOOK,
- },
- {
- firstTimeUpdated: WorkBookType.SOLUTION,
- secondTimeUpdated: WorkBookType.SOLUTION,
- },
- {
- firstTimeUpdated: WorkBookType.SOLUTION,
- secondTimeUpdated: WorkBookType.CREATED_BY_USER,
- },
- {
- firstTimeUpdated: WorkBookType.CREATED_BY_USER,
- secondTimeUpdated: WorkBookType.CURRICULUM,
- },
- {
- firstTimeUpdated: WorkBookType.CREATED_BY_USER,
- secondTimeUpdated: WorkBookType.TEXTBOOK,
- },
- {
- firstTimeUpdated: WorkBookType.CREATED_BY_USER,
- secondTimeUpdated: WorkBookType.SOLUTION,
- },
- {
- firstTimeUpdated: WorkBookType.CREATED_BY_USER,
- secondTimeUpdated: WorkBookType.CREATED_BY_USER,
- },
- ];
-
- runTests(
- 'updateTaskGradeTwice',
- testCases,
- ({ firstTimeUpdated, secondTimeUpdated }: UpdateTwice) => {
- activeWorkbookTabStore.setActiveWorkbookTab(firstTimeUpdated);
- activeWorkbookTabStore.setActiveWorkbookTab(secondTimeUpdated);
-
- const workBookTabs = get(activeWorkbookTabStore);
- workBookTabs.forEach((isOpen, activeWorkBookType) => {
- if (activeWorkBookType === secondTimeUpdated) {
- expect(isOpen).toBeTruthy();
- } else {
- expect(isOpen).toBeFalsy();
- }
- });
- },
- );
-
- function runTests(
- testName: string,
- testCases: UpdateTwice[],
- testFunction: (testCase: UpdateTwice) => void,
- ) {
- test.each(testCases)(
- `${testName}(firstTimeUpdated: $firstTimeUpdated, secondTimeUpdated: $secondTimeUpdated)`,
- testFunction,
- );
- }
- });
-});
diff --git a/src/features/workbooks/stores/active_workbook_tab.ts b/src/features/workbooks/stores/active_workbook_tab.ts
deleted file mode 100644
index fe2ba8c87..000000000
--- a/src/features/workbooks/stores/active_workbook_tab.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { writable } from 'svelte/store';
-
-import { WorkBookType } from '$features/workbooks/types/workbook';
-
-const workBookTypes = Object.values(WorkBookType) as Array;
-// Map
-const initialValues = new Map(
- workBookTypes.map((workBookType: WorkBookType) => [workBookType, false]),
-);
-initialValues.set(WorkBookType.CURRICULUM, true);
-
-function createActiveWorkbookTabStore() {
- const { subscribe, update } = writable(initialValues);
-
- return {
- subscribe,
- setActiveWorkbookTab: (activeWorkBookTab: WorkBookType) =>
- update((currentValues) => {
- const newValues = new Map(currentValues);
- newValues.forEach((_, workBookType) => {
- newValues.set(workBookType, workBookType === activeWorkBookTab);
- });
-
- return newValues;
- }),
- };
-}
-
-export const activeWorkbookTabStore = createActiveWorkbookTabStore();
diff --git a/src/features/workbooks/stores/task_grades_by_workbook_type.test.ts b/src/features/workbooks/stores/task_grades_by_workbook_type.test.ts
deleted file mode 100644
index 1e039940c..000000000
--- a/src/features/workbooks/stores/task_grades_by_workbook_type.test.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-import { expect, test } from 'vitest';
-
-import { TaskGrade } from '$lib/types/task';
-import { WorkBookType } from '$features/workbooks/types/workbook';
-
-import { taskGradesByWorkBookTypeStore } from '$features/workbooks/stores/task_grades_by_workbook_type';
-
-type InitialState = {
- workBookType: WorkBookType;
- expected: TaskGrade;
-};
-
-type InitialStates = InitialState[];
-
-interface UpdateOnce extends InitialState {
- newGrade: TaskGrade;
-}
-
-type UpdateOnces = UpdateOnce[];
-
-interface UpdateTwice extends InitialState {
- firstTimeGradeUpdated: TaskGrade;
- secondTimeGradeUpdated: TaskGrade;
-}
-
-describe('Task grades by workbook type', () => {
- describe('initial values is 10Q in each workbook type', () => {
- const testCases = [
- { workBookType: WorkBookType.CURRICULUM, expected: TaskGrade.Q10 },
- { workBookType: WorkBookType.SOLUTION, expected: TaskGrade.Q10 },
- ];
- runTests('getTaskGrade', testCases, ({ workBookType, expected }: InitialState) => {
- expect(taskGradesByWorkBookTypeStore.getTaskGrade(workBookType)).toBe(expected);
- });
-
- function runTests(
- testName: string,
- testCases: InitialStates,
- testFunction: (testCase: InitialState) => void,
- ) {
- test.each(testCases)(`${testName}(workBookType: $workBookType)`, testFunction);
- }
- });
-
- describe('update task grade once', () => {
- const testCases = [
- { workBookType: WorkBookType.CURRICULUM, newGrade: TaskGrade.Q10, expected: TaskGrade.Q10 },
- { workBookType: WorkBookType.CURRICULUM, newGrade: TaskGrade.Q9, expected: TaskGrade.Q9 },
- { workBookType: WorkBookType.CURRICULUM, newGrade: TaskGrade.Q8, expected: TaskGrade.Q8 },
- { workBookType: WorkBookType.CURRICULUM, newGrade: TaskGrade.Q7, expected: TaskGrade.Q7 },
- ];
- runTests(
- 'updateTaskGradeOnce',
- testCases,
- ({ workBookType, newGrade, expected }: UpdateOnce) => {
- taskGradesByWorkBookTypeStore.updateTaskGrade(workBookType, newGrade);
- expect(taskGradesByWorkBookTypeStore.getTaskGrade(workBookType)).toBe(expected);
- },
- );
-
- function runTests(
- testName: string,
- testCases: UpdateOnces,
- testFunction: (testCase: UpdateOnce) => void,
- ) {
- test.each(testCases)(
- `${testName}(workBookType: $workBookType, newGrade: $newGrade)`,
- testFunction,
- );
- }
- });
-
- describe('update task grade twice', () => {
- const testCases = [
- {
- workBookType: WorkBookType.CURRICULUM,
- firstTimeGradeUpdated: TaskGrade.Q10,
- secondTimeGradeUpdated: TaskGrade.Q10,
- expected: TaskGrade.Q10,
- },
- {
- workBookType: WorkBookType.CURRICULUM,
- firstTimeGradeUpdated: TaskGrade.Q9,
- secondTimeGradeUpdated: TaskGrade.Q10,
- expected: TaskGrade.Q10,
- },
- {
- workBookType: WorkBookType.CURRICULUM,
- firstTimeGradeUpdated: TaskGrade.Q9,
- secondTimeGradeUpdated: TaskGrade.Q9,
- expected: TaskGrade.Q9,
- },
- {
- workBookType: WorkBookType.CURRICULUM,
- firstTimeGradeUpdated: TaskGrade.Q9,
- secondTimeGradeUpdated: TaskGrade.Q8,
- expected: TaskGrade.Q8,
- },
- {
- workBookType: WorkBookType.CURRICULUM,
- firstTimeGradeUpdated: TaskGrade.Q6,
- secondTimeGradeUpdated: TaskGrade.Q7,
- expected: TaskGrade.Q7,
- },
- ];
-
- runTests(
- 'updateTaskGradeTwice',
- testCases,
- ({ workBookType, firstTimeGradeUpdated, secondTimeGradeUpdated, expected }: UpdateTwice) => {
- taskGradesByWorkBookTypeStore.updateTaskGrade(workBookType, firstTimeGradeUpdated);
- taskGradesByWorkBookTypeStore.updateTaskGrade(workBookType, secondTimeGradeUpdated);
- expect(taskGradesByWorkBookTypeStore.getTaskGrade(workBookType)).toBe(expected);
- },
- );
-
- function runTests(
- testName: string,
- testCases: UpdateTwice[],
- testFunction: (testCase: UpdateTwice) => void,
- ) {
- test.each(testCases)(
- `${testName}(workBookType: $workBookType, firstTimeGradeUpdated: $firstTimeGradeUpdated, secondTimeGradeUpdated: $secondTimeGradeUpdated)`,
- testFunction,
- );
- }
- });
-});
diff --git a/src/features/workbooks/stores/task_grades_by_workbook_type.ts b/src/features/workbooks/stores/task_grades_by_workbook_type.ts
deleted file mode 100644
index 0fe379360..000000000
--- a/src/features/workbooks/stores/task_grades_by_workbook_type.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { get, writable } from 'svelte/store';
-
-import { TaskGrade } from '$lib/types/task';
-import { WorkBookType } from '$features/workbooks/types/workbook';
-
-const workBookTypes = Object.values(WorkBookType) as Array;
-const initialValues = new Map(
- workBookTypes.map((workBookType: WorkBookType) => [workBookType, TaskGrade.Q10]),
-);
-
-function createTaskGradesByWorkBookTypeStore() {
- const { subscribe, update } = writable(initialValues);
-
- return {
- subscribe,
- updateTaskGrade: (workBookType: WorkBookType, grade: TaskGrade) =>
- update((originalTaskGrades) => new Map(originalTaskGrades.set(workBookType, grade))),
- getTaskGrade: (workBookType: WorkBookType) => {
- const taskGrades = get(taskGradesByWorkBookTypeStore);
- return taskGrades.get(workBookType);
- },
- };
-}
-
-export const taskGradesByWorkBookTypeStore = createTaskGradesByWorkBookTypeStore();
diff --git a/src/features/workbooks/types/workbook.ts b/src/features/workbooks/types/workbook.ts
index cd2e9a592..08631ada1 100644
--- a/src/features/workbooks/types/workbook.ts
+++ b/src/features/workbooks/types/workbook.ts
@@ -55,12 +55,15 @@ export type WorkBookType = WorkBookTypeOrigin;
// Imported by table components — avoids repeating the same Props definition in three places.
export type WorkbookTableProps = {
workbooks: WorkbooksList;
- workbookGradeModes: Map;
+ gradeModesEachWorkbook: Map;
userId: string;
role: Roles;
taskResults: Map;
};
+/** Imported by SolutionTable and CreatedByUserTable — excludes gradeModesEachWorkbook which is unused in those tabs. */
+export type SolutionTableProps = Omit;
+
export type WorkBookTaskBase = {
taskId: string;
priority: number;
@@ -85,3 +88,15 @@ export type WorkBookTasksCreate = WorkBookTaskCreate[];
export type WorkBookTaskEdit = WorkBookTaskCreate;
export type WorkBookTasksEdit = WorkBookTaskEdit[];
+
+/** Valid values for the `?tab=` URL parameter on the /workbooks page. */
+export const WorkBookTab = {
+ CURRICULUM: 'curriculum',
+ SOLUTION: 'solution',
+ CREATED_BY_USER: 'created_by_user',
+} as const;
+
+export type WorkBookTab = (typeof WorkBookTab)[keyof typeof WorkBookTab];
+
+/** Default tab when the URL parameter is absent. */
+export const DEFAULT_WORKBOOK_TAB: WorkBookTab = WorkBookTab.CURRICULUM;
diff --git a/src/features/workbooks/types/workbook_placement.ts b/src/features/workbooks/types/workbook_placement.ts
index 51753d986..2953e51d0 100644
--- a/src/features/workbooks/types/workbook_placement.ts
+++ b/src/features/workbooks/types/workbook_placement.ts
@@ -1,6 +1,7 @@
import type { SolutionCategory as SolutionCategoryOrigin } from '@prisma/client';
import type { TaskGrade } from '$lib/types/task';
import type { WorkBookTaskBase } from '$features/workbooks/types/workbook';
+import { WorkBookType as WorkBookTypeConst } from '$features/workbooks/types/workbook';
// Categories for solution placement.
export const SolutionCategory: { [key in SolutionCategoryOrigin]: key } = {
@@ -23,6 +24,9 @@ export const SolutionCategory: { [key in SolutionCategoryOrigin]: key } = {
export type SolutionCategory = SolutionCategoryOrigin;
+/** Ordered list of solution categories used to filter SOLUTION workbooks. */
+export type SolutionCategories = SolutionCategory[];
+
// Japanese labels for solution categories (used in admin UI)
export const SOLUTION_LABELS: Record = {
PENDING: '未分類',
@@ -87,6 +91,14 @@ export type UnplacedCurriculumRow = {
export type UnplacedCurriculumRows = UnplacedCurriculumRow[];
+/**
+ * Discriminated union representing a placement-based filter query.
+ * CURRICULUM filters by taskGrade; SOLUTION filters by solutionCategory.
+ */
+export type PlacementQuery =
+ | { workBookType: typeof WorkBookTypeConst.CURRICULUM; taskGrade: TaskGrade }
+ | { workBookType: typeof WorkBookTypeConst.SOLUTION; solutionCategory: SolutionCategory };
+
// Shape of workbooks returned from the load function for use in KanbanBoard
export type WorkbookWithPlacement = {
id: number;
diff --git a/src/features/workbooks/utils/workbook_url_params.test.ts b/src/features/workbooks/utils/workbook_url_params.test.ts
new file mode 100644
index 000000000..d69c1ee87
--- /dev/null
+++ b/src/features/workbooks/utils/workbook_url_params.test.ts
@@ -0,0 +1,133 @@
+import { describe, test, expect } from 'vitest';
+import { TaskGrade } from '$lib/types/task';
+import { SolutionCategory } from '$features/workbooks/types/workbook_placement';
+import { WorkBookTab } from '$features/workbooks/types/workbook';
+import {
+ parseWorkBookTab,
+ parseWorkBookGrade,
+ parseWorkBookCategory,
+ buildWorkbooksUrl,
+} from './workbook_url_params';
+
+/** Test helper: creates URLSearchParams from a query string. */
+function toParams(query: string): URLSearchParams {
+ return new URLSearchParams(query);
+}
+
+describe('parseWorkBookTab', () => {
+ test('returns curriculum for tab=curriculum', () => {
+ expect(parseWorkBookTab(toParams('tab=curriculum'))).toBe(WorkBookTab.CURRICULUM);
+ });
+
+ test('returns solution for tab=solution', () => {
+ expect(parseWorkBookTab(toParams('tab=solution'))).toBe(WorkBookTab.SOLUTION);
+ });
+
+ test('returns created_by_user for tab=created_by_user', () => {
+ expect(parseWorkBookTab(toParams('tab=created_by_user'))).toBe(WorkBookTab.CREATED_BY_USER);
+ });
+
+ test('returns curriculum (default) when tab is absent', () => {
+ expect(parseWorkBookTab(toParams(''))).toBe(WorkBookTab.CURRICULUM);
+ });
+
+ test('returns curriculum (default) for invalid tab value', () => {
+ expect(parseWorkBookTab(toParams('tab=invalid'))).toBe(WorkBookTab.CURRICULUM);
+ });
+});
+
+describe('parseWorkBookGrade', () => {
+ test('returns Q10 for grades=Q10', () => {
+ expect(parseWorkBookGrade(toParams('grades=Q10'))).toBe(TaskGrade.Q10);
+ });
+
+ test('returns Q9 for grades=Q9', () => {
+ expect(parseWorkBookGrade(toParams('grades=Q9'))).toBe(TaskGrade.Q9);
+ });
+
+ test('returns Q1 for grades=Q1', () => {
+ expect(parseWorkBookGrade(toParams('grades=Q1'))).toBe(TaskGrade.Q1);
+ });
+
+ test('returns D1 for grades=D1', () => {
+ expect(parseWorkBookGrade(toParams('grades=D1'))).toBe(TaskGrade.D1);
+ });
+
+ test('returns D6 for grades=D6', () => {
+ expect(parseWorkBookGrade(toParams('grades=D6'))).toBe(TaskGrade.D6);
+ });
+
+ test('returns Q10 (default) when grades is absent', () => {
+ expect(parseWorkBookGrade(toParams(''))).toBe(TaskGrade.Q10);
+ });
+
+ test('returns Q10 (default) for PENDING', () => {
+ expect(parseWorkBookGrade(toParams('grades=PENDING'))).toBe(TaskGrade.Q10);
+ });
+
+ test('returns Q10 (default) for invalid value', () => {
+ expect(parseWorkBookGrade(toParams('grades=Z99'))).toBe(TaskGrade.Q10);
+ });
+});
+
+describe('parseWorkBookCategory', () => {
+ test('returns SEARCH_SIMULATION for categories=SEARCH_SIMULATION', () => {
+ expect(parseWorkBookCategory(toParams('categories=SEARCH_SIMULATION'))).toBe(
+ SolutionCategory.SEARCH_SIMULATION,
+ );
+ });
+
+ test('returns GRAPH for categories=GRAPH', () => {
+ expect(parseWorkBookCategory(toParams('categories=GRAPH'))).toBe(SolutionCategory.GRAPH);
+ });
+
+ test('returns DYNAMIC_PROGRAMMING for categories=DYNAMIC_PROGRAMMING', () => {
+ expect(parseWorkBookCategory(toParams('categories=DYNAMIC_PROGRAMMING'))).toBe(
+ SolutionCategory.DYNAMIC_PROGRAMMING,
+ );
+ });
+
+ test('returns DATA_STRUCTURE for categories=DATA_STRUCTURE', () => {
+ expect(parseWorkBookCategory(toParams('categories=DATA_STRUCTURE'))).toBe(
+ SolutionCategory.DATA_STRUCTURE,
+ );
+ });
+
+ test('returns SEARCH_SIMULATION (default) when categories is absent', () => {
+ expect(parseWorkBookCategory(toParams(''))).toBe(SolutionCategory.SEARCH_SIMULATION);
+ });
+
+ test('returns SEARCH_SIMULATION (default) for PENDING', () => {
+ expect(parseWorkBookCategory(toParams('categories=PENDING'))).toBe(
+ SolutionCategory.SEARCH_SIMULATION,
+ );
+ });
+
+ test('returns SEARCH_SIMULATION (default) for invalid value', () => {
+ expect(parseWorkBookCategory(toParams('categories=FLYING_FISH'))).toBe(
+ SolutionCategory.SEARCH_SIMULATION,
+ );
+ });
+});
+
+describe('buildWorkbooksUrl', () => {
+ test('curriculum tab with grade produces correct URL', () => {
+ expect(buildWorkbooksUrl(WorkBookTab.CURRICULUM, TaskGrade.Q9)).toBe(
+ '/workbooks?tab=curriculum&grades=Q9',
+ );
+ });
+
+ test('solution tab with category produces correct URL', () => {
+ expect(buildWorkbooksUrl(WorkBookTab.SOLUTION, undefined, SolutionCategory.GRAPH)).toBe(
+ '/workbooks?tab=solution&categories=GRAPH',
+ );
+ });
+
+ test('curriculum tab without grade produces URL with tab only', () => {
+ expect(buildWorkbooksUrl(WorkBookTab.CURRICULUM)).toBe('/workbooks?tab=curriculum');
+ });
+
+ test('created_by_user tab produces URL with tab only', () => {
+ expect(buildWorkbooksUrl(WorkBookTab.CREATED_BY_USER)).toBe('/workbooks?tab=created_by_user');
+ });
+});
diff --git a/src/features/workbooks/utils/workbook_url_params.ts b/src/features/workbooks/utils/workbook_url_params.ts
new file mode 100644
index 000000000..684ac23e6
--- /dev/null
+++ b/src/features/workbooks/utils/workbook_url_params.ts
@@ -0,0 +1,93 @@
+import { TaskGrade } from '$lib/types/task';
+import { WorkBookTab, DEFAULT_WORKBOOK_TAB } from '$features/workbooks/types/workbook';
+import { SolutionCategory } from '$features/workbooks/types/workbook_placement';
+
+const DEFAULT_CURRICULUM_GRADE = TaskGrade.Q10;
+const DEFAULT_SOLUTION_CATEGORY = SolutionCategory.SEARCH_SIMULATION;
+const EXISTING_TABS = new Set(Object.values(WorkBookTab));
+
+/**
+ * Parses the `?tab=` URL parameter into a WorkBookTab.
+ * Falls back to the default ('curriculum') for missing or invalid values.
+ *
+ * @param params - URL search params to read from
+ */
+export function parseWorkBookTab(params: URLSearchParams): WorkBookTab {
+ const param = params.get('tab');
+
+ if (param !== null && EXISTING_TABS.has(param)) {
+ return param as WorkBookTab;
+ }
+
+ return DEFAULT_WORKBOOK_TAB;
+}
+
+/**
+ * Parses the `?grades=` URL parameter into a TaskGrade.
+ * Excludes PENDING. Falls back to Q10 for missing or invalid values.
+ *
+ * @param params - URL search params to read from
+ */
+export function parseWorkBookGrade(params: URLSearchParams): TaskGrade {
+ const param = params.get('grades');
+
+ if (isValidNonPending(param, Object.values(TaskGrade), TaskGrade.PENDING)) {
+ return param;
+ }
+
+ return DEFAULT_CURRICULUM_GRADE;
+}
+
+/**
+ * Parses the `?categories=` URL parameter into a SolutionCategory.
+ * Excludes PENDING. Falls back to SEARCH_SIMULATION for missing or invalid values.
+ *
+ * @param params - URL search params to read from
+ */
+export function parseWorkBookCategory(params: URLSearchParams): SolutionCategory {
+ const param = params.get('categories');
+
+ if (isValidNonPending(param, Object.values(SolutionCategory), SolutionCategory.PENDING)) {
+ return param;
+ }
+
+ return DEFAULT_SOLUTION_CATEGORY;
+}
+
+/**
+ * Builds the `/workbooks` URL with the given tab, grade, and category as query parameters.
+ * CREATED_BY_USER tab does not append additional params.
+ *
+ * @param tab - Active tab
+ * @param grade - Selected grade (only appended when tab === CURRICULUM)
+ * @param category - Selected category (only appended when tab === SOLUTION)
+ * @returns URL string suitable for use with goto()
+ */
+export function buildWorkbooksUrl(
+ tab: WorkBookTab,
+ grade?: TaskGrade,
+ category?: SolutionCategory,
+): string {
+ const params = new URLSearchParams();
+ params.set('tab', tab);
+
+ if (tab === WorkBookTab.CURRICULUM && grade) {
+ params.set('grades', grade);
+ } else if (tab === WorkBookTab.SOLUTION && category) {
+ params.set('categories', category);
+ }
+
+ return `/workbooks?${params}`;
+}
+
+/**
+ * Returns true when `param` is a valid enum value excluding PENDING.
+ * Extracted to avoid repeating the same three-condition check for grades and categories.
+ */
+function isValidNonPending(
+ param: string | null,
+ values: T[],
+ pending: T,
+): param is T {
+ return param !== null && (values as string[]).includes(param) && param !== pending;
+}
diff --git a/src/features/workbooks/utils/workbooks.test.ts b/src/features/workbooks/utils/workbooks.test.ts
index 0bfdd2f04..2e007d1d8 100644
--- a/src/features/workbooks/utils/workbooks.test.ts
+++ b/src/features/workbooks/utils/workbooks.test.ts
@@ -13,6 +13,7 @@ import {
getGradeMode,
getTaskResult,
countReadableWorkbooks,
+ partitionWorkbooksAsMainAndReplenished,
} from '$features/workbooks/utils/workbooks';
function createTask(taskId: string, grade: TaskGrade): Task {
@@ -126,52 +127,55 @@ describe('Workbooks', () => {
});
});
- describe('buildTaskResultsByWorkBookId', () => {
- test('includes workbook in map when task results exist', () => {
- const taskResult = createTaskResult('abc300_a');
- const taskResultsByTaskId = new Map([['abc300_a', taskResult]]);
- const workbooks = [
- createWorkBookListBase({
- id: 1,
- workBookTasks: [{ taskId: 'abc300_a', priority: 1, comment: '' }],
- }),
- ];
- const result = buildTaskResultsByWorkBookId(workbooks, taskResultsByTaskId);
- expect(result.get(1)).toEqual([taskResult]);
+ describe('partitionWorkbooksAsMainAndReplenished', () => {
+ test('main contains non-replenished workbooks', () => {
+ const main = createWorkBookListBase({ id: 1, isReplenished: false });
+ const replenished = createWorkBookListBase({ id: 2, isReplenished: true });
+ const result = partitionWorkbooksAsMainAndReplenished([main, replenished]);
+ expect(result.main).toEqual([main]);
});
- test('excludes workbook from map when no task results exist', () => {
- const taskResultsByTaskId = new Map();
+ test('replenished contains replenished workbooks', () => {
+ const main = createWorkBookListBase({ id: 1, isReplenished: false });
+ const replenished = createWorkBookListBase({ id: 2, isReplenished: true });
+ const result = partitionWorkbooksAsMainAndReplenished([main, replenished]);
+ expect(result.replenished).toEqual([replenished]);
+ });
+
+ test('empty input returns empty arrays', () => {
+ const result = partitionWorkbooksAsMainAndReplenished([]);
+ expect(result.main).toEqual([]);
+ expect(result.replenished).toEqual([]);
+ });
+ });
+
+ describe('countReadableWorkbooks', () => {
+ const userId = '1';
+ const authorId = '1';
+ const otherUserId = '2';
+
+ test('counts published workbooks regardless of author', () => {
const workbooks = [
- createWorkBookListBase({
- id: 1,
- workBookTasks: [{ taskId: 'abc300_a', priority: 1, comment: '' }],
- }),
+ createWorkBookListBase({ id: 1, isPublished: true, authorId: otherUserId }),
+ createWorkBookListBase({ id: 2, isPublished: true, authorId: otherUserId }),
];
- const result = buildTaskResultsByWorkBookId(workbooks, taskResultsByTaskId);
- expect(result.has(1)).toBe(false);
+ expect(countReadableWorkbooks(workbooks, userId)).toBe(2);
});
- test('returns empty map when given empty workbooks array', () => {
- const taskResultsByTaskId = new Map();
- const result = buildTaskResultsByWorkBookId([], taskResultsByTaskId);
- expect(result.size).toBe(0);
+ test('counts unpublished workbooks owned by the user', () => {
+ const workbooks = [createWorkBookListBase({ id: 1, isPublished: false, authorId })];
+ expect(countReadableWorkbooks(workbooks, userId)).toBe(1);
});
- test('includes only tasks with existing results when workbook has partial results', () => {
- const taskResult = createTaskResult('abc300_a');
- const taskResultsByTaskId = new Map([['abc300_a', taskResult]]);
+ test('excludes unpublished workbooks owned by other users', () => {
const workbooks = [
- createWorkBookListBase({
- id: 1,
- workBookTasks: [
- { taskId: 'abc300_a', priority: 1, comment: '' },
- { taskId: 'abc300_b', priority: 2, comment: '' },
- ],
- }),
+ createWorkBookListBase({ id: 1, isPublished: false, authorId: otherUserId }),
];
- const result = buildTaskResultsByWorkBookId(workbooks, taskResultsByTaskId);
- expect(result.get(1)).toEqual([taskResult]);
+ expect(countReadableWorkbooks(workbooks, userId)).toBe(0);
+ });
+
+ test('returns 0 for empty list', () => {
+ expect(countReadableWorkbooks([], userId)).toBe(0);
});
});
@@ -339,33 +343,52 @@ describe('Workbooks', () => {
});
});
- describe('countReadableWorkbooks', () => {
- const userId = '1';
- const authorId = '1';
- const otherUserId = '2';
+ describe('buildTaskResultsByWorkBookId', () => {
+ test('includes workbook in map when task results exist', () => {
+ const taskResult = createTaskResult('abc300_a');
+ const taskResultsByTaskId = new Map([['abc300_a', taskResult]]);
+ const workbooks = [
+ createWorkBookListBase({
+ id: 1,
+ workBookTasks: [{ taskId: 'abc300_a', priority: 1, comment: '' }],
+ }),
+ ];
+ const result = buildTaskResultsByWorkBookId(workbooks, taskResultsByTaskId);
+ expect(result.get(1)).toEqual([taskResult]);
+ });
- test('counts published workbooks regardless of author', () => {
+ test('excludes workbook from map when no task results exist', () => {
+ const taskResultsByTaskId = new Map();
const workbooks = [
- createWorkBookListBase({ id: 1, isPublished: true, authorId: otherUserId }),
- createWorkBookListBase({ id: 2, isPublished: true, authorId: otherUserId }),
+ createWorkBookListBase({
+ id: 1,
+ workBookTasks: [{ taskId: 'abc300_a', priority: 1, comment: '' }],
+ }),
];
- expect(countReadableWorkbooks(workbooks, userId)).toBe(2);
+ const result = buildTaskResultsByWorkBookId(workbooks, taskResultsByTaskId);
+ expect(result.has(1)).toBe(false);
});
- test('counts unpublished workbooks owned by the user', () => {
- const workbooks = [createWorkBookListBase({ id: 1, isPublished: false, authorId })];
- expect(countReadableWorkbooks(workbooks, userId)).toBe(1);
+ test('returns empty map when given empty workbooks array', () => {
+ const taskResultsByTaskId = new Map();
+ const result = buildTaskResultsByWorkBookId([], taskResultsByTaskId);
+ expect(result.size).toBe(0);
});
- test('excludes unpublished workbooks owned by other users', () => {
+ test('includes only tasks with existing results when workbook has partial results', () => {
+ const taskResult = createTaskResult('abc300_a');
+ const taskResultsByTaskId = new Map([['abc300_a', taskResult]]);
const workbooks = [
- createWorkBookListBase({ id: 1, isPublished: false, authorId: otherUserId }),
+ createWorkBookListBase({
+ id: 1,
+ workBookTasks: [
+ { taskId: 'abc300_a', priority: 1, comment: '' },
+ { taskId: 'abc300_b', priority: 2, comment: '' },
+ ],
+ }),
];
- expect(countReadableWorkbooks(workbooks, userId)).toBe(0);
- });
-
- test('returns 0 for empty list', () => {
- expect(countReadableWorkbooks([], userId)).toBe(0);
+ const result = buildTaskResultsByWorkBookId(workbooks, taskResultsByTaskId);
+ expect(result.get(1)).toEqual([taskResult]);
});
});
diff --git a/src/features/workbooks/utils/workbooks.ts b/src/features/workbooks/utils/workbooks.ts
index 438f6b862..5eaf3013c 100644
--- a/src/features/workbooks/utils/workbooks.ts
+++ b/src/features/workbooks/utils/workbooks.ts
@@ -45,35 +45,30 @@ export function getWorkBooksByType(
}
/**
- * Builds a map from workbook ID to the task results for that workbook's tasks.
- * Workbooks with no matching task results are omitted from the map.
+ * Partitions workbooks into main and replenished groups.
+ *
+ * @param workbooks - Full list to partition
+ * @returns Object with `main` (isReplenished=false) and `replenished` (isReplenished=true) arrays
*/
-export function buildTaskResultsByWorkBookId(
- workbooks: WorkbooksList,
- taskResultsByTaskId: Map,
-): Map {
- const taskResultsWithWorkBookId = new Map();
-
- workbooks.forEach((workbook: WorkbookList) => {
- const taskResults: TaskResults = workbook.workBookTasks.reduce(
- (array: TaskResults, workBookTask: WorkBookTaskBase) => {
- const taskResult = taskResultsByTaskId.get(workBookTask.taskId);
-
- if (taskResult !== undefined) {
- array.push(taskResult);
- }
-
- return array;
- },
- [],
- );
-
- if (taskResults.length > 0) {
- taskResultsWithWorkBookId.set(workbook.id, taskResults);
- }
- });
+export function partitionWorkbooksAsMainAndReplenished(workbooks: WorkbooksList): {
+ main: WorkbooksList;
+ replenished: WorkbooksList;
+} {
+ return workbooks.reduce(
+ (partition, workbook) => {
+ (workbook.isReplenished ? partition.replenished : partition.main).push(workbook);
+ return partition;
+ },
+ { main: [] as WorkbooksList, replenished: [] as WorkbooksList },
+ );
+}
- return taskResultsWithWorkBookId;
+/** Returns the number of workbooks the given user can read. */
+export function countReadableWorkbooks(workbooks: WorkbooksList, userId: string): number {
+ return workbooks.reduce((count, workbook) => {
+ const hasReadPermission = canRead(workbook.isPublished, userId, workbook.authorId);
+ return count + (hasReadPermission ? 1 : 0);
+ }, 0);
}
/**
@@ -116,12 +111,36 @@ export function getGradeMode(workbookId: number, gradeModes: Map {
- const hasReadPermission = canRead(workbook.isPublished, userId, workbook.authorId);
- return count + (hasReadPermission ? 1 : 0);
- }, 0);
+/**
+ * Builds a map from workbook ID to the task results for that workbook's tasks.
+ * Workbooks with no matching task results are omitted from the map.
+ */
+export function buildTaskResultsByWorkBookId(
+ workbooks: WorkbooksList,
+ taskResultsByTaskId: Map,
+): Map {
+ const taskResultsWithWorkBookId = new Map();
+
+ workbooks.forEach((workbook: WorkbookList) => {
+ const taskResults: TaskResults = workbook.workBookTasks.reduce(
+ (array: TaskResults, workBookTask: WorkBookTaskBase) => {
+ const taskResult = taskResultsByTaskId.get(workBookTask.taskId);
+
+ if (taskResult !== undefined) {
+ array.push(taskResult);
+ }
+
+ return array;
+ },
+ [],
+ );
+
+ if (taskResults.length > 0) {
+ taskResultsWithWorkBookId.set(workbook.id, taskResults);
+ }
+ });
+
+ return taskResultsWithWorkBookId;
}
const EMPTY_TASK_RESULTS: TaskResults = [];
diff --git a/src/routes/(admin)/workbooks/order/_types/kanban.ts b/src/routes/(admin)/workbooks/order/_types/kanban.ts
index 26d81be46..cebcc7ca4 100644
--- a/src/routes/(admin)/workbooks/order/_types/kanban.ts
+++ b/src/routes/(admin)/workbooks/order/_types/kanban.ts
@@ -1,6 +1,8 @@
import type { DragDropManager, Draggable, Droppable } from '@dnd-kit/dom';
import type { DragDropEvents } from '@dnd-kit/abstract';
+import type { WorkBookTab } from '$features/workbooks/types/workbook';
+
// DnD event types derived from dnd-kit abstractions
type DndEvents = DragDropEvents;
@@ -9,7 +11,8 @@ export type DragEndEventArg = Parameters[0];
export type ColumnKey = 'solutionCategory' | 'taskGrade';
-export type ActiveTab = 'solution' | 'curriculum';
+/** Tabs available on the admin order page — excludes CREATED_BY_USER which has no placement config. */
+export type ActiveTab = Exclude;
// Static per-tab configuration used to eliminate activeTab === 'solution' if-branches
export type TabConfig = {
diff --git a/src/routes/workbooks/+page.server.ts b/src/routes/workbooks/+page.server.ts
index 28681547f..0f01d1f39 100644
--- a/src/routes/workbooks/+page.server.ts
+++ b/src/routes/workbooks/+page.server.ts
@@ -1,37 +1,78 @@
-import { error } from '@sveltejs/kit';
+import { error, redirect } from '@sveltejs/kit';
-import * as workBooksCrud from '$features/workbooks/services/workbooks';
import * as taskCrud from '$lib/services/tasks';
import * as taskResultsCrud from '$lib/services/task_results';
+import * as workBooksCrud from '$features/workbooks/services/workbooks';
+
+import { Roles } from '$lib/types/user';
+import {
+ WorkBookTab,
+ type WorkBookTab as WorkBookTabType,
+ WorkBookType,
+} from '$features/workbooks/types/workbook';
+import { type PlacementQuery } from '$features/workbooks/types/workbook_placement';
+
+import {
+ getWorkbooksByPlacement,
+ getWorkBooksCreatedByUsers,
+ getAvailableSolutionCategories,
+} from '$features/workbooks/services/workbooks';
-import { getLoggedInUser, canDelete } from '$lib/utils/authorship';
+import { isAdmin, getLoggedInUser, canDelete } from '$lib/utils/authorship';
+import {
+ parseWorkBookTab,
+ parseWorkBookGrade,
+ parseWorkBookCategory,
+} from '$features/workbooks/utils/workbook_url_params';
import { parseWorkBookId } from '$features/workbooks/utils/workbook';
import {
BAD_REQUEST,
FORBIDDEN,
+ FOUND,
NOT_FOUND,
INTERNAL_SERVER_ERROR,
} from '$lib/constants/http-response-status-codes';
-export async function load({ locals }) {
+export async function load({ locals, url }) {
const loggedInUser = await getLoggedInUser(locals);
+ const params = url.searchParams;
+
+ const tab = parseWorkBookTab(params);
+
+ // CREATED_BY_USER tab is admin-only
+ if (
+ tab === WorkBookTab.CREATED_BY_USER &&
+ (!loggedInUser || !isAdmin(loggedInUser.role as Roles))
+ ) {
+ redirect(FOUND, '/workbooks');
+ }
+
+ const selectedGrade = parseWorkBookGrade(params);
+ const selectedCategory = parseWorkBookCategory(params);
+ const adminUser = loggedInUser && isAdmin(loggedInUser.role as Roles);
try {
- // Each query is independent, so we execute them in parallel with Promise.all
- const [workbooks, tasksMapByIds, taskResultsByTaskId] = await Promise.all([
- workBooksCrud.getWorkBooksWithAuthors(),
- // Used to get the most frequent grade of the tasks that make up the workbook
+ const [workbooks, availableCategories, tasksMapByIds, taskResultsByTaskId] = await Promise.all([
+ fetchWorkbooksByTab(tab, selectedGrade, selectedCategory, !!adminUser),
+ tab === WorkBookTab.SOLUTION
+ ? getAvailableSolutionCategories(!!adminUser)
+ : Promise.resolve([]),
taskCrud.getTasksByTaskId(),
- // Used to display the user's answer status
- taskResultsCrud.getTaskResultsOnlyResultExists(loggedInUser?.id as string, true),
+ loggedInUser
+ ? taskResultsCrud.getTaskResultsOnlyResultExists(loggedInUser.id, true)
+ : Promise.resolve(new Map()),
]);
return {
workbooks,
+ availableCategories,
tasksMapByIds,
taskResultsByTaskId,
loggedInUser,
+ tab,
+ selectedGrade,
+ selectedCategory,
};
} catch (e) {
console.error('Failed to fetch workbooks, tasks or task results: ', e);
@@ -75,3 +116,28 @@ export const actions = {
}
},
};
+
+function fetchWorkbooksByTab(
+ tab: WorkBookTabType,
+ grade: ReturnType,
+ category: ReturnType,
+ includeUnpublished: boolean,
+) {
+ if (tab === WorkBookTab.CREATED_BY_USER) {
+ return getWorkBooksCreatedByUsers();
+ }
+
+ return getWorkbooksByPlacement(buildPlacementQuery(tab, grade, category), includeUnpublished);
+}
+
+function buildPlacementQuery(
+ tab: WorkBookTabType,
+ grade: ReturnType,
+ category: ReturnType,
+): PlacementQuery {
+ if (tab === WorkBookTab.CURRICULUM) {
+ return { workBookType: WorkBookType.CURRICULUM, taskGrade: grade };
+ }
+
+ return { workBookType: WorkBookType.SOLUTION, solutionCategory: category };
+}
diff --git a/src/routes/workbooks/+page.svelte b/src/routes/workbooks/+page.svelte
index 5a7e20d2d..ba17d88da 100644
--- a/src/routes/workbooks/+page.svelte
+++ b/src/routes/workbooks/+page.svelte
@@ -1,60 +1,80 @@
@@ -74,29 +94,62 @@
contentClass="bg-white dark:bg-gray-800 mt-0 p-0"
ulClass="flex flex-wrap md:flex-nowrap md:gap-2 rtl:space-x-reverse items-start"
>
- {#each workBookTabs as workBookTab}
- {#if loggedInUser && canViewWorkBook(role, workBookTab.canUsersView)}
+ {#if loggedInUser}
+
handleTabChange(WorkBookTab.CURRICULUM)}
+ >
+
+
+
+
+
+
handleTabChange(WorkBookTab.SOLUTION)}
+ >
+
+
+
+
+
+ {#if isAdmin(role)}
handleTabChange(WorkBookTab.CREATED_BY_USER)}
>
{/if}
- {/each}
+ {/if}