From aff5ba59ba5d352cc26446824e35641bac5e83ce Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Fri, 20 Mar 2026 14:10:21 +0000 Subject: [PATCH 01/47] docs: Add workbooks-list-url-params dev notes and implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../workbooks-list-url-params/phase-0.md | 67 +++++ .../workbooks-list-url-params/phase-1.md | 108 ++++++++ .../workbooks-list-url-params/phase-10.md | 95 +++++++ .../workbooks-list-url-params/phase-2.md | 232 ++++++++++++++++ .../workbooks-list-url-params/phase-3.md | 238 +++++++++++++++++ .../workbooks-list-url-params/phase-4.md | 108 ++++++++ .../workbooks-list-url-params/phase-5.md | 98 +++++++ .../workbooks-list-url-params/phase-6.md | 95 +++++++ .../workbooks-list-url-params/phase-7.md | 49 ++++ .../workbooks-list-url-params/phase-8.md | 252 ++++++++++++++++++ .../workbooks-list-url-params/phase-9.md | 45 ++++ .../workbooks-list-url-params/plan.md | 130 +++++++++ 12 files changed, 1517 insertions(+) create mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-0.md create mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-1.md create mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-10.md create mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-2.md create mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-3.md create mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-4.md create mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-5.md create mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-6.md create mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-7.md create mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-8.md create mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-9.md create mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/plan.md diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-0.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-0.md new file mode 100644 index 000000000..fdef9a2e8 --- /dev/null +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-0.md @@ -0,0 +1,67 @@ +# Phase 0: `WorkBookTab` 型の統一 + +**レイヤー:** `src/features/workbooks/types/` | **リスク:** 極低 + +order ページで定義されている `ActiveTab = 'solution' | 'curriculum'` と、新たに必要な `WorkBookTab` は同一の型。feature types に一元定義し、order ページは再エクスポートに変更する。 + +--- + +## Task 0-A: `WorkBookTab` 型を feature types に追加 + +**Files:** + +- Modify: `src/features/workbooks/types/workbook.ts` + +- [ ] **Step 1: ファイル末尾に追加** + +```typescript +/** /workbooks ページの URL パラメータ `?tab=` に対応する有効値 */ +export type WorkBookTab = 'curriculum' | 'solution'; + +/** URLパラメータがない場合のデフォルトタブ */ +export const DEFAULT_WORKBOOK_TAB: WorkBookTab = 'curriculum'; +``` + +- [ ] **Step 2: 型チェック** + +```bash +pnpm check +``` + +- [ ] **Step 3: コミット** + +```bash +git add src/features/workbooks/types/workbook.ts +git commit -m "feat(workbooks/types): Add WorkBookTab type for URL param-driven tab" +``` + +--- + +## Task 0-B: order ページの `ActiveTab` を `WorkBookTab` の再エクスポートに変更 + +**Files:** + +- Modify: `src/routes/(admin)/workbooks/order/_types/kanban.ts` + +- [ ] **Step 1: `ActiveTab` の定義を再エクスポートに置き換え** + +```typescript +// 変更前 +export type ActiveTab = 'solution' | 'curriculum'; + +// 変更後 +export type { WorkBookTab as ActiveTab } from '$features/workbooks/types/workbook'; +``` + +- [ ] **Step 2: 型チェック(order ページの既存コードが壊れていないことを確認)** + +```bash +pnpm check +``` + +- [ ] **Step 3: コミット** + +```bash +git add src/routes/(admin)/workbooks/order/_types/kanban.ts +git commit -m "refactor(workbooks/order): Re-export ActiveTab from WorkBookTab feature type" +``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-1.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-1.md new file mode 100644 index 000000000..f2b30e677 --- /dev/null +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-1.md @@ -0,0 +1,108 @@ +# Phase 1: `splitWorkbooksByReplenishment()` ユーティリティ + +**レイヤー:** `src/features/workbooks/utils/` | **リスク:** 極低(純粋関数) + +サーバー側でグレードフィルタリングを行った後、クライアント側では `isReplenished` による分割のみが必要になる。現在 `CurriculumWorkBookList.svelte` に inline で書かれているフィルタを純粋関数として抽出する。 + +--- + +## Task 1-A: 失敗するテストを書く + +**Files:** + +- Modify: `src/features/workbooks/utils/workbooks.test.ts` + +- [ ] **Step 1: テストを追記** + +```typescript +import { splitWorkbooksByReplenishment } from './workbooks'; +// 既存 import に追加 + +describe('splitWorkbooksByReplenishment', () => { + const base = { + id: 1, + title: '', + workBookType: 'CURRICULUM' as const, + isPublished: true, + isOfficial: true, + authorId: 'u1', + authorName: 'u1', + description: '', + editorialUrl: '', + urlSlug: null, + createdAt: new Date(), + updatedAt: new Date(), + workBookTasks: [], + }; + + test('main contains non-replenished workbooks', () => { + const main = { ...base, id: 1, isReplenished: false }; + const replenished = { ...base, id: 2, isReplenished: true }; + const result = splitWorkbooksByReplenishment([main, replenished]); + expect(result.main).toEqual([main]); + }); + + test('replenished contains replenished workbooks', () => { + const main = { ...base, id: 1, isReplenished: false }; + const replenished = { ...base, id: 2, isReplenished: true }; + const result = splitWorkbooksByReplenishment([main, replenished]); + expect(result.replenished).toEqual([replenished]); + }); + + test('empty input returns empty arrays', () => { + const result = splitWorkbooksByReplenishment([]); + expect(result.main).toEqual([]); + expect(result.replenished).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +pnpm test:unit -- workbooks.test +# FAIL: splitWorkbooksByReplenishment is not a function +``` + +--- + +## Task 1-B: 実装 + +**Files:** + +- Modify: `src/features/workbooks/utils/workbooks.ts` + +- [ ] **Step 1: 関数を追加(既存エクスポートの末尾)** + +```typescript +/** + * Splits workbooks into main and replenished groups. + * + * @param workbooks - Full list to split + * @returns Object with `main` (isReplenished=false) and `replenished` (isReplenished=true) arrays + */ +export function splitWorkbooksByReplenishment(workbooks: WorkbooksList): { + main: WorkbooksList; + replenished: WorkbooksList; +} { + return { + main: workbooks.filter((workbook) => !workbook.isReplenished), + replenished: workbooks.filter((workbook) => workbook.isReplenished), + }; +} +``` + +- [ ] **Step 2: テストが通ることを確認** + +```bash +pnpm test:unit -- workbooks.test +# PASS +``` + +- [ ] **Step 3: コミット** + +```bash +git add src/features/workbooks/utils/workbooks.ts \ + src/features/workbooks/utils/workbooks.test.ts +git commit -m "feat(workbooks/utils): Add splitWorkbooksByReplenishment utility" +``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-10.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-10.md new file mode 100644 index 000000000..0dda13207 --- /dev/null +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-10.md @@ -0,0 +1,95 @@ +# Phase 10: E2Eテスト更新 + +**レイヤー:** `e2e/` | **リスク:** 低 + +--- + +**Files:** + +- Modify: `e2e/workbooks_list.spec.ts` + +- [ ] **Step 1: ファイルを読んで削除対象を確認** + +`activeWorkbookTabStore` / `task_grades_by_workbook_type` を前提としたテストを特定して削除する。 + +- [ ] **Step 2: URLパラメータ関連テストを追加** + +> ラベル文字列(`'10Q'`, `'グラフ'` など)は `GRADE_LABELS` / `SOLUTION_LABELS` 定数と一致させること。実装前に `src/lib/types/task.ts` と `src/features/workbooks/types/workbook_placement.ts` を確認すること。 + +```typescript +import { test, expect } from '@playwright/test'; +import { TaskGrade } from '$lib/types/task'; +import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; + +test('defaults to curriculum tab', async ({ page }) => { + await page.goto('/workbooks'); + await expect(page.getByRole('tab', { name: 'カリキュラム' })).toHaveAttribute( + 'aria-selected', + 'true', + ); +}); + +test('clicking solution tab updates URL to tab=solution', async ({ page }) => { + await page.goto('/workbooks'); + await page.getByRole('tab', { name: '解法別' }).click(); + await expect(page).toHaveURL(/tab=solution/); +}); + +test('direct URL access to solution tab selects correct tab', async ({ page }) => { + await page.goto('/workbooks?tab=solution&categories=GRAPH'); + await expect(page.getByRole('tab', { name: '解法別' })).toHaveAttribute('aria-selected', 'true'); +}); + +test('invalid tab param falls back to curriculum tab', async ({ page }) => { + await page.goto('/workbooks?tab=invalid'); + await expect(page.getByRole('tab', { name: 'カリキュラム' })).toHaveAttribute( + 'aria-selected', + 'true', + ); +}); + +// カリキュラム グレードボタン → URL 更新 +const CURRICULUM_GRADE_CASES: { grade: TaskGrade; label: string }[] = [ + { grade: TaskGrade.Q10, label: '10Q' }, + { grade: TaskGrade.Q9, label: '9Q' }, + { grade: TaskGrade.Q8, label: '8Q' }, +]; + +for (const { grade, label } of CURRICULUM_GRADE_CASES) { + test(`curriculum grade button "${label}" updates URL to grades=${grade}`, async ({ page }) => { + await page.goto('/workbooks?tab=curriculum'); + await page.getByRole('button', { name: label }).click(); + await expect(page).toHaveURL(new RegExp(`grades=${grade}`)); + }); +} + +// 解法別 カテゴリボタン → URL 更新 +const SOLUTION_CATEGORY_CASES: { category: SolutionCategory; label: string }[] = [ + { category: SolutionCategory.GRAPH, label: 'グラフ' }, + { category: SolutionCategory.DYNAMIC_PROGRAMMING, label: 'DP' }, + { category: SolutionCategory.SEARCH_SIMULATION, label: '探索・シミュレーション' }, +]; + +for (const { category, label } of SOLUTION_CATEGORY_CASES) { + test(`solution category button "${label}" updates URL to categories=${category}`, async ({ + page, + }) => { + await page.goto('/workbooks?tab=solution'); + await page.getByRole('button', { name: label }).click(); + await expect(page).toHaveURL(new RegExp(`categories=${category}`)); + }); +} +``` + +- [ ] **Step 3: E2Eテスト実行** + +```bash +pnpm test:e2e -- --grep "workbooks" +``` + +- [ ] **Step 4: コミット** + +```bash +git add e2e/workbooks_list.spec.ts +git commit -m "test(e2e/workbooks): Update tests for URL param-driven filtering" +``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-2.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-2.md new file mode 100644 index 000000000..b46363632 --- /dev/null +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-2.md @@ -0,0 +1,232 @@ +# Phase 2: `workbook_url_params.ts` — URLパラメータ解析・組み立て + +**レイヤー:** `src/features/workbooks/utils/` | **リスク:** 極低(純粋関数) + +`+page.server.ts` での解析と `+page.svelte` での URL 組み立てを担う純粋関数群。order ページの `parseInitialCategories(params: URLSearchParams)` パターンに倣い、引数は `URLSearchParams` を直接受け取る(`string | null` ではない)。 + +--- + +## Task 2-A: 失敗するテストを書く + +**Files:** + +- Create: `src/features/workbooks/utils/workbook_url_params.test.ts` + +- [ ] **Step 1: テストファイルを作成** + +```typescript +import { describe, test, expect } from 'vitest'; +import { TaskGrade } from '$lib/types/task'; +import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; +import { + parseWorkBookTab, + parseWorkBookGrade, + parseWorkBookCategory, + buildWorkbooksUrl, +} from './workbook_url_params'; + +describe('parseWorkBookTab', () => { + test('returns curriculum for tab=curriculum', () => { + expect(parseWorkBookTab(new URLSearchParams('tab=curriculum'))).toBe('curriculum'); + }); + + test('returns solution for tab=solution', () => { + expect(parseWorkBookTab(new URLSearchParams('tab=solution'))).toBe('solution'); + }); + + test('returns curriculum (default) when tab is absent', () => { + expect(parseWorkBookTab(new URLSearchParams())).toBe('curriculum'); + }); + + test('returns curriculum (default) for invalid tab value', () => { + expect(parseWorkBookTab(new URLSearchParams('tab=created_by_user'))).toBe('curriculum'); + }); +}); + +describe('parseWorkBookGrade', () => { + test('returns Q10 for grades=Q10', () => { + expect(parseWorkBookGrade(new URLSearchParams('grades=Q10'))).toBe(TaskGrade.Q10); + }); + + test('returns Q9 for grades=Q9', () => { + expect(parseWorkBookGrade(new URLSearchParams('grades=Q9'))).toBe(TaskGrade.Q9); + }); + + test('returns Q10 (default) when grades is absent', () => { + expect(parseWorkBookGrade(new URLSearchParams())).toBe(TaskGrade.Q10); + }); + + test('returns Q10 (default) for PENDING', () => { + expect(parseWorkBookGrade(new URLSearchParams('grades=PENDING'))).toBe(TaskGrade.Q10); + }); + + test('returns Q10 (default) for invalid value', () => { + expect(parseWorkBookGrade(new URLSearchParams('grades=Z99'))).toBe(TaskGrade.Q10); + }); +}); + +describe('parseWorkBookCategory', () => { + test('returns SEARCH_SIMULATION for categories=SEARCH_SIMULATION', () => { + expect(parseWorkBookCategory(new URLSearchParams('categories=SEARCH_SIMULATION'))).toBe( + SolutionCategory.SEARCH_SIMULATION, + ); + }); + + test('returns GRAPH for categories=GRAPH', () => { + expect(parseWorkBookCategory(new URLSearchParams('categories=GRAPH'))).toBe( + SolutionCategory.GRAPH, + ); + }); + + test('returns SEARCH_SIMULATION (default) when categories is absent', () => { + expect(parseWorkBookCategory(new URLSearchParams())).toBe(SolutionCategory.SEARCH_SIMULATION); + }); + + test('returns SEARCH_SIMULATION (default) for PENDING', () => { + expect(parseWorkBookCategory(new URLSearchParams('categories=PENDING'))).toBe( + SolutionCategory.SEARCH_SIMULATION, + ); + }); + + test('returns SEARCH_SIMULATION (default) for invalid value', () => { + expect(parseWorkBookCategory(new URLSearchParams('categories=FLYING_FISH'))).toBe( + SolutionCategory.SEARCH_SIMULATION, + ); + }); +}); + +describe('buildWorkbooksUrl', () => { + test('curriculum tab with grade produces correct URL', () => { + expect(buildWorkbooksUrl('curriculum', TaskGrade.Q9)).toBe( + '/workbooks?tab=curriculum&grades=Q9', + ); + }); + + test('solution tab with category produces correct URL', () => { + expect(buildWorkbooksUrl('solution', undefined, SolutionCategory.GRAPH)).toBe( + '/workbooks?tab=solution&categories=GRAPH', + ); + }); + + test('curriculum tab without grade produces URL with tab only', () => { + expect(buildWorkbooksUrl('curriculum')).toBe('/workbooks?tab=curriculum'); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +pnpm test:unit -- workbook_url_params +# FAIL: module not found +``` + +--- + +## Task 2-B: 実装 + +**Files:** + +- Create: `src/features/workbooks/utils/workbook_url_params.ts` + +- [ ] **Step 1: ファイルを作成** + +```typescript +import { TaskGrade } from '$lib/types/task'; +import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; +import { type WorkBookTab, DEFAULT_WORKBOOK_TAB } from '$features/workbooks/types/workbook'; + +const DEFAULT_CURRICULUM_GRADE = TaskGrade.Q10; +const DEFAULT_SOLUTION_CATEGORY = SolutionCategory.SEARCH_SIMULATION; +const VALID_TABS = new Set(['curriculum', 'solution']); + +/** + * Parses the `?tab=` URL parameter into a WorkBookTab. + * Falls back to the default ('curriculum') for missing or invalid values. + */ +export function parseWorkBookTab(params: URLSearchParams): WorkBookTab { + const param = params.get('tab'); + + if (param !== null && VALID_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. + */ +export function parseWorkBookGrade(params: URLSearchParams): TaskGrade { + const param = params.get('grades'); + + if ( + param !== null && + Object.values(TaskGrade).includes(param as TaskGrade) && + param !== TaskGrade.PENDING + ) { + return param as TaskGrade; + } + + return DEFAULT_CURRICULUM_GRADE; +} + +/** + * Parses the `?categories=` URL parameter into a SolutionCategory. + * Excludes PENDING. Falls back to SEARCH_SIMULATION for missing or invalid values. + */ +export function parseWorkBookCategory(params: URLSearchParams): SolutionCategory { + const param = params.get('categories'); + + if ( + param !== null && + Object.values(SolutionCategory).includes(param as SolutionCategory) && + param !== SolutionCategory.PENDING + ) { + return param as SolutionCategory; + } + + return DEFAULT_SOLUTION_CATEGORY; +} + +/** + * Builds the `/workbooks` URL with the given tab, grade, and category as query parameters. + * + * @param tab - Active tab ('curriculum' or 'solution') + * @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 === 'curriculum' && grade) { + params.set('grades', grade); + } else if (tab === 'solution' && category) { + params.set('categories', category); + } + + return `/workbooks?${params}`; +} +``` + +- [ ] **Step 2: テストが通ることを確認** + +```bash +pnpm test:unit -- workbook_url_params +# PASS: 14 tests +``` + +- [ ] **Step 3: コミット** + +```bash +git add src/features/workbooks/utils/workbook_url_params.ts \ + src/features/workbooks/utils/workbook_url_params.test.ts +git commit -m "feat(workbooks/utils): Add URL param parsing and URL builder for workbooks list" +``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-3.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-3.md new file mode 100644 index 000000000..e86bb17e7 --- /dev/null +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-3.md @@ -0,0 +1,238 @@ +# Phase 3: サービス層 — `getPublishedWorkbooksByPlacement()` + `getWorkBooksCreatedByUsers()` + +**レイヤー:** `src/features/workbooks/services/` | **リスク:** 中(Prismaクエリ追加。既存関数は変更しない) + +`PlacementQuery` は discriminated union で定義する。`workBookType === CURRICULUM` のとき `taskGrade` が確定し、`SOLUTION` のとき `solutionCategory` が確定する。これにより呼び出し側での繰り返し条件分岐を排除できる。 + +> **Prisma の挙動:** `where: { placement: { taskGrade: 'Q10' } }` は placement レコードが存在しない workbook を自動的に除外する(optional one-to-one のネストフィルタは IS NOT NULL を暗黙的に含む)。 + +--- + +## Task 3-A: 失敗するテストを書く + +**Files:** + +- Modify: `src/features/workbooks/services/workbooks.test.ts` + +- [ ] **Step 1: インポートと `describe` ブロックを追記** + +既存の `vi.mock('$lib/server/database', ...)` とモック変数(`prisma`)を再利用する。 + +```typescript +import { getPublishedWorkbooksByPlacement, getWorkBooksCreatedByUsers } from './workbooks'; +import { WorkBookType } from '$features/workbooks/types/workbook'; +import { TaskGrade } from '$lib/types/task'; +import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; + +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('getPublishedWorkbooksByPlacement', () => { + test('filters CURRICULUM workbooks by taskGrade with priority asc order', async () => { + const mockWorkbook = { + ...MOCK_WORKBOOK_BASE, + workBookType: WorkBookType.CURRICULUM, + placement: { priority: 1, taskGrade: TaskGrade.Q10, solutionCategory: null }, + }; + prisma.workBook.findMany.mockResolvedValue([mockWorkbook]); + + const result = await getPublishedWorkbooksByPlacement({ + 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' } }, + }), + ); + expect(result[0].authorName).toBe('author1'); + }); + + test('filters SOLUTION workbooks by solutionCategory', async () => { + const mockWorkbook = { + ...MOCK_WORKBOOK_BASE, + workBookType: WorkBookType.SOLUTION, + placement: { priority: 1, taskGrade: null, solutionCategory: SolutionCategory.GRAPH }, + }; + prisma.workBook.findMany.mockResolvedValue([mockWorkbook]); + + await getPublishedWorkbooksByPlacement({ + 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 () => { + prisma.workBook.findMany.mockResolvedValue([ + { + ...MOCK_WORKBOOK_BASE, + workBookType: WorkBookType.CURRICULUM, + user: null, + }, + ]); + + const result = await getPublishedWorkbooksByPlacement({ + workBookType: WorkBookType.CURRICULUM, + taskGrade: TaskGrade.Q10, + }); + + expect(result[0].authorName).toBe('unknown'); + }); +}); + +describe('getWorkBooksCreatedByUsers', () => { + test('queries only CREATED_BY_USER type workbooks', async () => { + prisma.workBook.findMany.mockResolvedValue([ + { ...MOCK_WORKBOOK_BASE, workBookType: WorkBookType.CREATED_BY_USER }, + ]); + + await getWorkBooksCreatedByUsers(); + + expect(prisma.workBook.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { workBookType: WorkBookType.CREATED_BY_USER }, + }), + ); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +pnpm test:unit -- workbooks.test +# FAIL: getPublishedWorkbooksByPlacement / getWorkBooksCreatedByUsers not found +``` + +--- + +## Task 3-B: 実装 + +**Files:** + +- Modify: `src/features/workbooks/services/workbooks.ts` + +- [ ] **Step 1: インポートを追加** + +```typescript +import { TaskGrade } from '$lib/types/task'; +import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; +``` + +- [ ] **Step 2: `getWorkBooksWithAuthors()` の直後に型と関数を追加** + +```typescript +/** + * Discriminated union representing a placement-based filter query. + * CURRICULUM filters by taskGrade; SOLUTION filters by solutionCategory. + */ +export type PlacementQuery = + | { workBookType: typeof WorkBookType.CURRICULUM; taskGrade: TaskGrade } + | { workBookType: typeof WorkBookType.SOLUTION; solutionCategory: SolutionCategory }; + +/** + * Returns published 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 + */ +export async function getPublishedWorkbooksByPlacement( + query: PlacementQuery, +): Promise { + const placementFilter = + query.workBookType === WorkBookType.CURRICULUM + ? { taskGrade: query.taskGrade } + : { solutionCategory: query.solutionCategory }; + + const workbooks = await db.workBook.findMany({ + where: { + workBookType: query.workBookType, + isPublished: true, + placement: placementFilter, + }, + orderBy: { + placement: { priority: 'asc' }, + }, + include: { + user: { + select: { username: true }, + }, + workBookTasks: { + orderBy: { priority: 'asc' }, + }, + }, + }); + + return workbooks.map((workbook) => ({ + ...workbook, + authorName: workbook.user?.username ?? 'unknown', + })); +} + +/** + * 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: WorkBookType.CREATED_BY_USER }, + orderBy: { id: 'asc' }, + include: { + user: { + select: { username: true }, + }, + workBookTasks: { + orderBy: { priority: 'asc' }, + }, + }, + }); + + return workbooks.map((workbook) => ({ + ...workbook, + authorName: workbook.user?.username ?? 'unknown', + })); +} +``` + +- [ ] **Step 3: テストが通ることを確認** + +```bash +pnpm test:unit -- workbooks.test +# PASS +``` + +- [ ] **Step 4: コミット** + +```bash +git add src/features/workbooks/services/workbooks.ts \ + src/features/workbooks/services/workbooks.test.ts +git commit -m "feat(workbooks/services): Add getPublishedWorkbooksByPlacement and getWorkBooksCreatedByUsers" +``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-4.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-4.md new file mode 100644 index 000000000..cd3bedc2f --- /dev/null +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-4.md @@ -0,0 +1,108 @@ +# Phase 4: `+page.server.ts` 修正 + +**レイヤー:** `src/routes/workbooks/` | **リスク:** 中 + +URLパラメータを解析し、`getPublishedWorkbooksByPlacement()` を呼び出す。`buildPlacementQuery()` をファイルスコープのヘルパーとして抽出することで、`load()` 内の条件分岐を排除する。管理者の場合のみ `getWorkBooksCreatedByUsers()` を並列実行する。 + +--- + +**Files:** + +- Modify: `src/routes/workbooks/+page.server.ts` + +- [ ] **Step 1: インポートを更新** + +追加: + +```typescript +import { Roles } from '$lib/types/user'; +import { isAdmin } from '$lib/utils/authorship'; +import { WorkBookType, type WorkBookTab } from '$features/workbooks/types/workbook'; +import { + type PlacementQuery, + getPublishedWorkbooksByPlacement, + getWorkBooksCreatedByUsers, +} from '$features/workbooks/services/workbooks'; +import { + parseWorkBookTab, + parseWorkBookGrade, + parseWorkBookCategory, +} from '$features/workbooks/utils/workbook_url_params'; +``` + +削除: `workBooksCrud.getWorkBooksWithAuthors` の呼び出し(`load()` 内) + +- [ ] **Step 2: `load()` を書き換え** + +```typescript +export async function load({ locals, url }) { + const loggedInUser = await getLoggedInUser(locals); + const params = url.searchParams; + + const tab = parseWorkBookTab(params); + const selectedGrade = parseWorkBookGrade(params); + const selectedCategory = parseWorkBookCategory(params); + const query = buildPlacementQuery(tab, selectedGrade, selectedCategory); + + try { + const [workbooks, tasksMapByIds, taskResultsByTaskId, userCreatedWorkbooks] = await Promise.all( + [ + workBooksCrud.getPublishedWorkbooksByPlacement(query), + taskCrud.getTasksByTaskId(), + taskResultsCrud.getTaskResultsOnlyResultExists(loggedInUser?.id as string, true), + isAdmin(loggedInUser?.role as Roles) + ? workBooksCrud.getWorkBooksCreatedByUsers() + : Promise.resolve([]), + ], + ); + + return { + workbooks, + userCreatedWorkbooks, + tasksMapByIds, + taskResultsByTaskId, + loggedInUser, + tab, + selectedGrade, + selectedCategory, + }; + } catch (e) { + console.error('Failed to fetch workbooks, tasks or task results: ', e); + error( + INTERNAL_SERVER_ERROR, + '問題もしくは回答の取得に失敗しました。しばらくしてから、もう一度試してください。', + ); + } +} +``` + +`actions.delete` は変更しない。 + +- [ ] **Step 3: `buildPlacementQuery()` を `load()` の後(ファイル末尾)に追加** + +```typescript +function buildPlacementQuery( + tab: WorkBookTab, + grade: ReturnType, + category: ReturnType, +): PlacementQuery { + if (tab === 'curriculum') { + return { workBookType: WorkBookType.CURRICULUM, taskGrade: grade }; + } + + return { workBookType: WorkBookType.SOLUTION, solutionCategory: category }; +} +``` + +- [ ] **Step 4: 型チェック** + +```bash +pnpm check +``` + +- [ ] **Step 5: コミット** + +```bash +git add src/routes/workbooks/+page.server.ts +git commit -m "feat(workbooks/server): Load workbooks from URL params via getPublishedWorkbooksByPlacement" +``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-5.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-5.md new file mode 100644 index 000000000..d1693fd69 --- /dev/null +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-5.md @@ -0,0 +1,98 @@ +# Phase 5: `SolutionWorkBookList.svelte` 新規作成 + +**レイヤー:** `src/features/workbooks/components/list/` | **リスク:** 低-中 + +`CurriculumWorkBookList.svelte` と同じ構造で解法別タブ用コンポーネントを作成する。カテゴリ選択 ButtonGroup を持ち、選択状態は `currentCategory` prop で受け取り、変更は `onCategoryChange` コールバックで親に委譲する。 + +> **注意:** `SolutionTable` の prop 名は `taskResults`(`WorkbookTableProps` の命名)。`CurriculumTable` の `taskResultsWithWorkBookId` とは異なる。 + +--- + +**Files:** + +- Create: `src/features/workbooks/components/list/SolutionWorkBookList.svelte` + +- [ ] **Step 1: コンポーネントを作成** + +```svelte + + +
+ + {#each AVAILABLE_CATEGORIES as category} + + {/each} + +
+ +{#if readableCount} + +{:else} + +{/if} +``` + +- [ ] **Step 2: 型チェック** + +```bash +pnpm check +``` + +- [ ] **Step 3: コミット** + +```bash +git add src/features/workbooks/components/list/SolutionWorkBookList.svelte +git commit -m "feat(workbooks/components): Add SolutionWorkBookList with category ButtonGroup" +``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-6.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-6.md new file mode 100644 index 000000000..73d44ed96 --- /dev/null +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-6.md @@ -0,0 +1,95 @@ +# Phase 6: `CurriculumWorkBookList.svelte` リファクタリング + +**レイヤー:** `src/features/workbooks/components/list/` | **リスク:** 中 + +ストア依存を除去し、グレード選択状態を `currentGrade` prop で受け取るよう変更する。サーバー側でグレードフィルタリングが済んでいるため、`$derived` の `getGradeMode === selectedGrade` フィルタを削除し、`splitWorkbooksByReplenishment()` に置き換える。 + +> `getGradeMode` / `workbookGradeModes` は `CurriculumTable` のグレード列表示で引き続き使われるため **削除しない**。 + +--- + +**Files:** + +- Modify: `src/features/workbooks/components/list/CurriculumWorkBookList.svelte` + +- [ ] **Step 1: ファイルを読んで現在の構造を確認** + +削除対象となる行を特定する: + +- `import { get } from 'svelte/store'` +- `import { taskGradesByWorkBookTypeStore } from '.../task_grades_by_workbook_type'` +- `WorkBookType` インポート(他で使われていなければ削除) +- `let selectedGrade = get(taskGradesByWorkBookTypeStore).get(...) ?? TaskGrade.Q10` +- `$effect()` ブロック全体(ストアとの同期) +- `taskGradesByWorkBookTypeStore.updateTaskGrade(...)` の呼び出し行 + +- [ ] **Step 2: Props インターフェースを更新** + +```typescript +interface Props { + workbooks: WorkbooksList; + workbookGradeModes: Map; + taskResultsWithWorkBookId: Map; + userId: string; + role: Roles; + currentGrade: TaskGrade; + onGradeChange: (grade: TaskGrade) => void; +} + +let { + workbooks, + workbookGradeModes, + taskResultsWithWorkBookId, + userId, + role, + currentGrade, + onGradeChange, +}: Props = $props(); +``` + +- [ ] **Step 3: `filterByGradeMode` をコールバック委譲に変更** + +```typescript +function filterByGradeMode(grade: TaskGrade) { + onGradeChange(grade); +} +``` + +- [ ] **Step 4: `$derived` のグレードフィルタを `splitWorkbooksByReplenishment` に置き換え** + +```typescript +import { splitWorkbooksByReplenishment, ... } from '$features/workbooks/utils/workbooks'; + +// 変更前 +let mainWorkbooks: WorkbooksList = $derived( + workbooks.filter((workbook) => getGradeMode(workbook.id, workbookGradeModes) === selectedGrade && !workbook.isReplenished), +); +let replenishedWorkbooks: WorkbooksList = $derived( + workbooks.filter((workbook) => getGradeMode(workbook.id, workbookGradeModes) === selectedGrade && workbook.isReplenished), +); + +// 変更後(サーバー側でグレードフィルタ済み) +let { main: mainWorkbooks, replenished: replenishedWorkbooks } = $derived( + splitWorkbooksByReplenishment(workbooks), +); +``` + +- [ ] **Step 5: ButtonGroup のアクティブ判定を `currentGrade` に変更** + +```svelte +class={currentGrade === grade ? 'text-primary-700 dark:text-primary-500!' : 'text-gray-900'} +``` + +- [ ] **Step 6: 型チェック・ユニットテスト** + +```bash +pnpm check +pnpm test:unit +``` + +- [ ] **Step 7: コミット** + +```bash +git add src/features/workbooks/components/list/CurriculumWorkBookList.svelte +git commit -m "refactor(workbooks/components): CurriculumWorkBookList uses grade prop+callback, removes store" +``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-7.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-7.md new file mode 100644 index 000000000..432c2a01f --- /dev/null +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-7.md @@ -0,0 +1,49 @@ +# Phase 7: `WorkbookTabItem.svelte` 簡素化 + +**レイヤー:** `src/features/workbooks/components/list/` | **リスク:** 低 + +`workbookType` prop と `activeWorkbookTabStore` への依存を除去し、タブクリック時の動作を `onclick` prop として親に委譲する。 + +--- + +**Files:** + +- Modify: `src/features/workbooks/components/list/WorkbookTabItem.svelte` + +- [ ] **Step 1: ファイル全体を以下に置き換え** + +```svelte + + + + {@render children?.()} + +``` + +削除: `workbookType` prop、`activeWorkbookTabStore` インポートと呼び出し + +- [ ] **Step 2: 型チェック** + +```bash +pnpm check +``` + +- [ ] **Step 3: コミット** + +```bash +git add src/features/workbooks/components/list/WorkbookTabItem.svelte +git commit -m "refactor(workbooks/components): WorkbookTabItem removes store, exposes onclick prop" +``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-8.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-8.md new file mode 100644 index 000000000..9c0428a57 --- /dev/null +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-8.md @@ -0,0 +1,252 @@ +# Phase 8: `WorkBookList.svelte` + `+page.svelte` 改修 + +**レイヤー:** `src/routes/workbooks/` + `src/features/workbooks/components/list/` | **リスク:** 中-高 + +--- + +## Task 8-A: `WorkBookList.svelte` に SOLUTION ルーティングを追加 + +**Files:** + +- Modify: `src/features/workbooks/components/list/WorkBookList.svelte` + +- [ ] **Step 1: ファイルを読んで現在の Props / ルーティングを確認** + +- [ ] **Step 2: 新しい Props を追加** + +```typescript +import { type SolutionCategory } from '$features/workbooks/types/workbook_placement'; +import SolutionWorkBookList from './SolutionWorkBookList.svelte'; + +interface Props { + // 既存 props はそのまま + workbookType: WorkBookType; + workbooks: WorkbooksList; + workbookGradeModes: Map; + taskResultsWithWorkBookId: Map; + loggedInUser: { id: string; role: Roles } | null; + // 追加 + currentGrade?: TaskGrade; + onGradeChange?: (grade: TaskGrade) => void; + currentCategory?: SolutionCategory; + onCategoryChange?: (category: SolutionCategory) => void; +} +``` + +- [ ] **Step 3: SOLUTION 分岐を追加** + +```svelte +{:else if workbookType === WorkBookType.SOLUTION} + {})} + /> +``` + +- [ ] **Step 4: 型チェック** + +```bash +pnpm check +``` + +- [ ] **Step 5: コミット** + +```bash +git add src/features/workbooks/components/list/WorkBookList.svelte +git commit -m "feat(workbooks/components): WorkBookList routes SOLUTION to SolutionWorkBookList" +``` + +--- + +## Task 8-B: `+page.svelte` 改修 + +**Files:** + +- Modify: `src/routes/workbooks/+page.svelte` + +- [ ] **Step 1: スクリプトブロックを書き換え** + +```svelte + +``` + +- [ ] **Step 2: テンプレートブロックを書き換え** + +```svelte +
+ + + + {#if role === Roles.ADMIN} +
+ +
+ {/if} + + +
+ + {#if loggedInUser} + handleTabChange('curriculum')} + > +
+ +
+
+ + handleTabChange('solution')} + > +
+ +
+
+ + {#if isAdmin(role)} + +
+ +
+
+ {/if} + {/if} +
+
+
+``` + +- [ ] **Step 3: 型チェック** + +```bash +pnpm check +# エラーゼロを確認 +``` + +- [ ] **Step 4: 開発サーバーで動作確認** + +```bash +pnpm dev +# 確認項目: +# - /workbooks → カリキュラムタブ・Q10 が表示される +# - グレードボタンクリック → URL が ?tab=curriculum&grades=Q9 に変わる(画面リロードなし) +# - 解法別タブクリック → URL が ?tab=solution&categories=SEARCH_SIMULATION に変わる +# - カテゴリボタンクリック → URL 更新・対応する問題集が表示される +# - /workbooks?tab=solution&categories=GRAPH に直アクセス → 正しく表示 +# - 管理者: ユーザ作成タブが表示される(URL 変更なし) +# - 一般ユーザ: ユーザ作成タブが表示されない +# - 補充教材トグルが引き続き動作する +``` + +- [ ] **Step 5: コミット** + +```bash +git add src/routes/workbooks/+page.svelte +git commit -m "feat(workbooks): URL-driven tab/filter navigation with admin-only CREATED_BY_USER tab" +``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-9.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-9.md new file mode 100644 index 000000000..a7e1a4d0b --- /dev/null +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-9.md @@ -0,0 +1,45 @@ +# Phase 9: 不要ストア削除 + +**リスク:** 低 + +`task_grades_by_workbook_type.ts` は URLパラメータに置き換えられた。`active_workbook_tab.ts` は `+page.svelte` のローカル `$state` に置き換えられた。参照ゼロを確認してから削除する。 + +--- + +**Files:** + +- Delete: `src/features/workbooks/stores/task_grades_by_workbook_type.ts` +- Delete: `src/features/workbooks/stores/task_grades_by_workbook_type.test.ts` +- Delete: `src/features/workbooks/stores/active_workbook_tab.ts` +- Delete: `src/features/workbooks/stores/active_workbook_tab.test.ts` + +- [ ] **Step 1: 参照ゼロを確認** + +```bash +grep -r "task_grades_by_workbook_type\|active_workbook_tab" \ + src/ --include="*.ts" --include="*.svelte" +# 結果ゼロを確認してから次へ進む +``` + +- [ ] **Step 2: ファイル削除** + +```bash +rm src/features/workbooks/stores/task_grades_by_workbook_type.ts +rm src/features/workbooks/stores/task_grades_by_workbook_type.test.ts +rm src/features/workbooks/stores/active_workbook_tab.ts +rm src/features/workbooks/stores/active_workbook_tab.test.ts +``` + +- [ ] **Step 3: 型チェック・ユニットテスト** + +```bash +pnpm check +pnpm test:unit +``` + +- [ ] **Step 4: コミット** + +```bash +git add -A +git commit -m "chore(workbooks): Remove stores replaced by URL params and local state" +``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/plan.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/plan.md new file mode 100644 index 000000000..4f3ff95bb --- /dev/null +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/plan.md @@ -0,0 +1,130 @@ +# 問題集一覧 URLパラメータフィルタリング 実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `/workbooks` ページで `WorkBookPlacement.priority` 順に問題集を表示し、URLパラメータ(`?tab=curriculum&grades=Q10` / `?tab=solution&categories=GRAPH`)でサーバーサイドフィルタリングを行う + +**Architecture:** `+page.server.ts` でURLパラメータを解析し、`getPublishedWorkbooksByPlacement(query)` が `WorkBookPlacement` レコードで絞り込み・`priority ASC` ソートして返す。クライアントサイドのグレードフィルタリングを削除し、`goto()` + `buildWorkbooksUrl()` による SvelteKit クライアントサイドナビゲーションに置き換える。`CREATED_BY_USER` タブは管理者専用として維持し、URL 変更なしのローカル状態で管理する。 + +**Tech Stack:** SvelteKit 2 + Svelte 5 Runes + TypeScript | Prisma (PostgreSQL) | Flowbite Svelte (ButtonGroup) | Vitest + Playwright + +--- + +## 背景・経緯 + +- PR #3252 で `WorkBookPlacement` モデルを使った管理者向け並び替え機能を実装済み +- PR #3281 で関連する機能について事前にリファクタリング +- 本タスクは管理者が設定した並び順を `/workbooks` 公開ページに反映させる + +**決定済みの仕様:** + +- placement レコードがない問題集は表示しない(Prisma のネスト where フィルタが IS NOT NULL を暗黙的に含む) +- `isReplenished` トグルはクライアントサイドのままで維持 +- `CREATED_BY_USER` タブは管理者のみ閲覧可能(URL パラメータ管理外・ローカル `$state` で isOpen 管理) +- グレード/カテゴリ ボタンクリック → `buildWorkbooksUrl()` で URL 組み立て → `goto()` で SvelteKit クライアントナビゲーション +- URLパラメータなし時のデフォルト: カリキュラム Q10 / 解法別 SEARCH_SIMULATION + +--- + +## ファイル構成 + +### 新規作成 + +| ファイル | 役割 | +| -------------------------------------------------------------------- | ---------------------------------------------- | +| `src/features/workbooks/utils/workbook_url_params.ts` | URLパラメータ解析・組み立てユーティリティ | +| `src/features/workbooks/utils/workbook_url_params.test.ts` | 上記のユニットテスト | +| `src/features/workbooks/components/list/SolutionWorkBookList.svelte` | 解法別カテゴリ選択 ButtonGroup + SolutionTable | + +### 修正 + +| ファイル | 変更内容 | +| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `src/features/workbooks/types/workbook.ts` | `WorkBookTab` 型・定数追加 | +| `src/routes/(admin)/workbooks/order/_types/kanban.ts` | `ActiveTab` を `WorkBookTab` の再エクスポートに変更 | +| `src/features/workbooks/utils/workbooks.ts` | `splitWorkbooksByReplenishment()` 追加 | +| `src/features/workbooks/utils/workbooks.test.ts` | 上記テスト追加 | +| `src/features/workbooks/services/workbooks.ts` | `PlacementQuery` 型・`getPublishedWorkbooksByPlacement()` / `getWorkBooksCreatedByUsers()` 追加 | +| `src/features/workbooks/services/workbooks.test.ts` | 上記テスト追加 | +| `src/routes/workbooks/+page.server.ts` | URLパラメータ解析・新サービス呼び出し | +| `src/features/workbooks/components/list/CurriculumWorkBookList.svelte` | ストア削除・`currentGrade` prop 化・`splitWorkbooksByReplenishment` 使用 | +| `src/features/workbooks/components/list/WorkbookTabItem.svelte` | `workbookType` prop 削除・`onclick` prop 化 | +| `src/features/workbooks/components/list/WorkBookList.svelte` | SOLUTION → SolutionWorkBookList ルーティング追加 | +| `src/routes/workbooks/+page.svelte` | URL駆動タブ/フィルタ・CREATED_BY_USER 管理者のみ表示 | +| `e2e/workbooks_list.spec.ts` | E2Eテスト更新 | + +### 削除(Phase 9) + +| ファイル | 理由 | +| ---------------------------------------------------------------------- | ---------------------------- | +| `src/features/workbooks/stores/task_grades_by_workbook_type.ts` + test | URLパラメータに置き換え | +| `src/features/workbooks/stores/active_workbook_tab.ts` + test | ローカル `$state` に置き換え | + +--- + +## Phase 一覧 + +| Phase | ファイル | 内容 | リスク | +| ----- | ---------------------------- | ------------------------------------------------ | ------ | +| 0 | [phase-0.md](./phase-0.md) | `WorkBookTab` 型を feature types に追加・統一 | 極低 | +| 1 | [phase-1.md](./phase-1.md) | `splitWorkbooksByReplenishment()` ユーティリティ | 極低 | +| 2 | [phase-2.md](./phase-2.md) | `workbook_url_params.ts` 解析・URL組み立て | 極低 | +| 3 | [phase-3.md](./phase-3.md) | `getPublishedWorkbooksByPlacement()` サービス | 中 | +| 4 | [phase-4.md](./phase-4.md) | `+page.server.ts` URLパラメータ対応 | 中 | +| 5 | [phase-5.md](./phase-5.md) | `SolutionWorkBookList.svelte` 新規作成 | 低-中 | +| 6 | [phase-6.md](./phase-6.md) | `CurriculumWorkBookList.svelte` リファクタリング | 中 | +| 7 | [phase-7.md](./phase-7.md) | `WorkbookTabItem.svelte` 簡素化 | 低 | +| 8 | [phase-8.md](./phase-8.md) | `WorkBookList.svelte` + `+page.svelte` 改修 | 中-高 | +| 9 | [phase-9.md](./phase-9.md) | 不要ストア削除 | 低 | +| 10 | [phase-10.md](./phase-10.md) | E2Eテスト更新 | 低 | + +--- + +## 最終検証 + +- [ ] `pnpm test:unit` — 全ユニットテスト通過 +- [ ] `pnpm test:e2e -- --grep "workbooks"` — E2Eテスト通過 +- [ ] `pnpm check` — 型エラーなし +- [ ] `pnpm lint` — Lintエラーなし +- [ ] `pnpm format` — フォーマット適用済み +- [ ] 手動確認(`pnpm dev`): + - `/workbooks` → カリキュラム Q10 が表示 + - グレードボタンクリック → 画面リロードなしで URL・コンテンツ更新 + - 解法別タブクリック → `?tab=solution&categories=SEARCH_SIMULATION` + - カテゴリボタンクリック → URL・コンテンツ更新 + - `/workbooks?tab=solution&categories=GRAPH` 直アクセス → 正しく表示 + - 管理者: ユーザ作成タブが表示される(URL 変更なし) + - 一般ユーザ: ユーザ作成タブが非表示 + - 補充教材トグルが引き続き動作する + +--- + +## 影響範囲まとめ + +| ファイル | 変更種別 | 理由 | +| ------------------------------------------------------------------ | ------------ | ---------------------------------------------------------------- | +| `src/routes/sitemap.xml/+server.ts` | **変更なし** | `/workbooks/[slug]` 個別 URL を生成するのみ。一覧ページは対象外 | +| `src/routes/workbooks/+page.server.ts` (delete action) | **変更なし** | フォーム送信後の再ロードも URL パラメータを引き継ぐ | +| `src/routes/(admin)/workbooks/order/` | **変更なし** | 管理者専用の独立ルート(`ActiveTab` 型のみ再エクスポートに変更) | +| `src/features/workbooks/components/list/WorkBookList.svelte` | **修正** | SOLUTION → SolutionWorkBookList ルーティング追加のみ | +| `src/features/workbooks/components/list/CreatedByUserTable.svelte` | **変更なし** | CREATED_BY_USER タブは管理者向けに維持 | + +--- + +## 計画中の教訓・誤解 + +| # | 誤解・ミス | 正しい判断 | +| --- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `CREATED_BY_USER` タブを「完全削除」と計画した | 「管理者のみ閲覧可能として維持」が正しい仕様。削除前に「既存ユーザー向けに残す機能か」を確認すること | +| 2 | `workbook_list_params.ts` と命名した | 何の params か不明。`workbook_url_params.ts` のように対象を明示する | +| 3 | ストアが localStorage を使っていると誤解した | `task_grades_by_workbook_type` と `active_workbook_tab` は in-memory の Svelte `writable()` のみ。localStorage を使うのは `replenishmentWorkBooksStore` だけ | +| 4 | `WorkBookList.svelte` を削除対象に含めた | ルーティングコンポーネントは既存の責務を持つ。削除前に「他で代替できるか」を確認すること | +| 5 | `WorkBookTab` を新規定義しようとした | order ページに同一の `ActiveTab = 'solution' \| 'curriculum'` が既存。重複前に `grep` で型の存在を確認すること | +| 6 | parse 関数の引数を `string \| null` にした | SvelteKit では `URLSearchParams` を直接渡すパターンが標準(order ページの `parseInitialCategories(params)` が先例)。既存コードのパターンを先に調べること | +| 7 | サービス引数をオプショナル `taskGrade?` で設計した | `tab === 'curriculum'` の条件分岐が呼び出し側に散らばる。discriminated union (`PlacementQuery`) で型レベルに閉じ込める | +| 8 | テストに `it` と日本語テスト名を使った | このプロジェクトは `test` + 英語テスト名が規約。既存テストのスタイルを先に確認すること | +| 9 | テストでハードコード文字列を直書きした | 定数(`TaskGrade.Q10` など)を使う。文字列が変わってもテストが壊れない | +| 10 | URL 組み立てをインライン文字列テンプレートで書いた | URL 組み立ては純粋関数 `buildWorkbooksUrl()` に集約する。order ページの `buildUpdatedUrl()` が先例 | +| 11 | `url.searchParams.get('tab')` を3回繰り返した | `const params = url.searchParams` で変数に切り出す | +| 12 | E2E テストのグレード・カテゴリケースが1件のみだった | URL パラメータのバリエーションを `for...of` ループで複数カバーする | +| 13 | 計画を単一ファイルに書き続けた(1000行超) | plan.md は全体俯瞰(goal / 構成 / phase 一覧 / 検証)に留め、詳細タスクは `phase-N.md` に分割する。コンテキスト圧迫を防ぎ、phase 単位での参照・更新が容易になる | From eb229b2b3dbcbcf331f0e686ec87c9f29c8beb05 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sat, 21 Mar 2026 09:56:49 +0000 Subject: [PATCH 02/47] docs: Update workbooks-list-url-params dev notes and add phase-11 plan Co-Authored-By: Claude Sonnet 4.6 --- .../workbooks-list-url-params/phase-0.md | 16 +- .../workbooks-list-url-params/phase-1.md | 24 +-- .../workbooks-list-url-params/phase-10.md | 44 ++++- .../workbooks-list-url-params/phase-11.md | 61 +++++++ .../workbooks-list-url-params/phase-2.md | 101 ++++++---- .../workbooks-list-url-params/phase-3.md | 123 ++++++++++--- .../workbooks-list-url-params/phase-4.md | 66 +++++-- .../workbooks-list-url-params/phase-5.md | 94 ++++++++-- .../workbooks-list-url-params/phase-7.md | 3 + .../workbooks-list-url-params/phase-8.md | 172 ++++++++++++------ .../workbooks-list-url-params/plan.md | 141 ++++++++------ 11 files changed, 616 insertions(+), 229 deletions(-) create mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-11.md diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-0.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-0.md index fdef9a2e8..9253c1be7 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-0.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-0.md @@ -2,7 +2,7 @@ **レイヤー:** `src/features/workbooks/types/` | **リスク:** 極低 -order ページで定義されている `ActiveTab = 'solution' | 'curriculum'` と、新たに必要な `WorkBookTab` は同一の型。feature types に一元定義し、order ページは再エクスポートに変更する。 +order ページで定義されている `ActiveTab = 'solution' | 'curriculum'` と、新たに必要な `WorkBookTab` は同一の型。feature types に一元定義し、order ページは再エクスポートに変更する。`WorkBookTab` は `WorkBookType` と同パターンの const オブジェクトとして定義し、`created_by_user` を含む3値を持つ。 --- @@ -16,10 +16,16 @@ order ページで定義されている `ActiveTab = 'solution' | 'curriculum'` ```typescript /** /workbooks ページの URL パラメータ `?tab=` に対応する有効値 */ -export type WorkBookTab = 'curriculum' | 'solution'; +export const WorkBookTab = { + CURRICULUM: 'curriculum', + SOLUTION: 'solution', + CREATED_BY_USER: 'created_by_user', +} as const; + +export type WorkBookTab = (typeof WorkBookTab)[keyof typeof WorkBookTab]; /** URLパラメータがない場合のデフォルトタブ */ -export const DEFAULT_WORKBOOK_TAB: WorkBookTab = 'curriculum'; +export const DEFAULT_WORKBOOK_TAB: WorkBookTab = WorkBookTab.CURRICULUM; ``` - [ ] **Step 2: 型チェック** @@ -32,7 +38,7 @@ pnpm check ```bash git add src/features/workbooks/types/workbook.ts -git commit -m "feat(workbooks/types): Add WorkBookTab type for URL param-driven tab" +git commit -m "feat(workbooks/types): Add WorkBookTab const object with CURRICULUM, SOLUTION, CREATED_BY_USER" ``` --- @@ -53,6 +59,8 @@ export type ActiveTab = 'solution' | 'curriculum'; export type { WorkBookTab as ActiveTab } from '$features/workbooks/types/workbook'; ``` +> **注意:** order ページは `CREATED_BY_USER` を使わないため、型の値が増えても既存ロジックには影響しない。 + - [ ] **Step 2: 型チェック(order ページの既存コードが壊れていないことを確認)** ```bash diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-1.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-1.md index f2b30e677..e8a8155bc 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-1.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-1.md @@ -1,9 +1,11 @@ -# Phase 1: `splitWorkbooksByReplenishment()` ユーティリティ +# Phase 1: `partitionWorkbooksAsMainAndReplenished()` ユーティリティ **レイヤー:** `src/features/workbooks/utils/` | **リスク:** 極低(純粋関数) サーバー側でグレードフィルタリングを行った後、クライアント側では `isReplenished` による分割のみが必要になる。現在 `CurriculumWorkBookList.svelte` に inline で書かれているフィルタを純粋関数として抽出する。 +> **命名根拠:** `main`(非補充)と `replenished`(補充)の両方が名前に現れる。`splitWorkbooksByReplenishment` は main 側の存在が不明だった。 + --- ## Task 1-A: 失敗するテストを書く @@ -15,10 +17,10 @@ - [ ] **Step 1: テストを追記** ```typescript -import { splitWorkbooksByReplenishment } from './workbooks'; +import { partitionWorkbooksAsMainAndReplenished } from './workbooks'; // 既存 import に追加 -describe('splitWorkbooksByReplenishment', () => { +describe('partitionWorkbooksAsMainAndReplenished', () => { const base = { id: 1, title: '', @@ -38,19 +40,19 @@ describe('splitWorkbooksByReplenishment', () => { test('main contains non-replenished workbooks', () => { const main = { ...base, id: 1, isReplenished: false }; const replenished = { ...base, id: 2, isReplenished: true }; - const result = splitWorkbooksByReplenishment([main, replenished]); + const result = partitionWorkbooksAsMainAndReplenished([main, replenished]); expect(result.main).toEqual([main]); }); test('replenished contains replenished workbooks', () => { const main = { ...base, id: 1, isReplenished: false }; const replenished = { ...base, id: 2, isReplenished: true }; - const result = splitWorkbooksByReplenishment([main, replenished]); + const result = partitionWorkbooksAsMainAndReplenished([main, replenished]); expect(result.replenished).toEqual([replenished]); }); test('empty input returns empty arrays', () => { - const result = splitWorkbooksByReplenishment([]); + const result = partitionWorkbooksAsMainAndReplenished([]); expect(result.main).toEqual([]); expect(result.replenished).toEqual([]); }); @@ -61,7 +63,7 @@ describe('splitWorkbooksByReplenishment', () => { ```bash pnpm test:unit -- workbooks.test -# FAIL: splitWorkbooksByReplenishment is not a function +# FAIL: partitionWorkbooksAsMainAndReplenished is not a function ``` --- @@ -76,12 +78,12 @@ pnpm test:unit -- workbooks.test ```typescript /** - * Splits workbooks into main and replenished groups. + * Partitions workbooks into main and replenished groups. * - * @param workbooks - Full list to split + * @param workbooks - Full list to partition * @returns Object with `main` (isReplenished=false) and `replenished` (isReplenished=true) arrays */ -export function splitWorkbooksByReplenishment(workbooks: WorkbooksList): { +export function partitionWorkbooksAsMainAndReplenished(workbooks: WorkbooksList): { main: WorkbooksList; replenished: WorkbooksList; } { @@ -104,5 +106,5 @@ pnpm test:unit -- workbooks.test ```bash git add src/features/workbooks/utils/workbooks.ts \ src/features/workbooks/utils/workbooks.test.ts -git commit -m "feat(workbooks/utils): Add splitWorkbooksByReplenishment utility" +git commit -m "feat(workbooks/utils): Add partitionWorkbooksAsMainAndReplenished utility" ``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-10.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-10.md index 0dda13207..084f0eaaf 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-10.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-10.md @@ -12,7 +12,7 @@ `activeWorkbookTabStore` / `task_grades_by_workbook_type` を前提としたテストを特定して削除する。 -- [ ] **Step 2: URLパラメータ関連テストを追加** +- [ ] **Step 2: URLパラメータ関連テストと `created_by_user` テストを追加** > ラベル文字列(`'10Q'`, `'グラフ'` など)は `GRADE_LABELS` / `SOLUTION_LABELS` 定数と一致させること。実装前に `src/lib/types/task.ts` と `src/features/workbooks/types/workbook_placement.ts` を確認すること。 @@ -20,6 +20,9 @@ import { test, expect } from '@playwright/test'; import { TaskGrade } from '$lib/types/task'; import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; +import { WorkBookTab } from '$features/workbooks/types/workbook'; + +// ---- タブ基本動作 ---- test('defaults to curriculum tab', async ({ page }) => { await page.goto('/workbooks'); @@ -32,11 +35,11 @@ test('defaults to curriculum tab', async ({ page }) => { test('clicking solution tab updates URL to tab=solution', async ({ page }) => { await page.goto('/workbooks'); await page.getByRole('tab', { name: '解法別' }).click(); - await expect(page).toHaveURL(/tab=solution/); + await expect(page).toHaveURL(new RegExp(`tab=${WorkBookTab.SOLUTION}`)); }); test('direct URL access to solution tab selects correct tab', async ({ page }) => { - await page.goto('/workbooks?tab=solution&categories=GRAPH'); + await page.goto(`/workbooks?tab=${WorkBookTab.SOLUTION}&categories=${SolutionCategory.GRAPH}`); await expect(page.getByRole('tab', { name: '解法別' })).toHaveAttribute('aria-selected', 'true'); }); @@ -48,7 +51,8 @@ test('invalid tab param falls back to curriculum tab', async ({ page }) => { ); }); -// カリキュラム グレードボタン → URL 更新 +// ---- カリキュラム グレードボタン → URL 更新 ---- + const CURRICULUM_GRADE_CASES: { grade: TaskGrade; label: string }[] = [ { grade: TaskGrade.Q10, label: '10Q' }, { grade: TaskGrade.Q9, label: '9Q' }, @@ -57,13 +61,14 @@ const CURRICULUM_GRADE_CASES: { grade: TaskGrade; label: string }[] = [ for (const { grade, label } of CURRICULUM_GRADE_CASES) { test(`curriculum grade button "${label}" updates URL to grades=${grade}`, async ({ page }) => { - await page.goto('/workbooks?tab=curriculum'); + await page.goto(`/workbooks?tab=${WorkBookTab.CURRICULUM}`); await page.getByRole('button', { name: label }).click(); await expect(page).toHaveURL(new RegExp(`grades=${grade}`)); }); } -// 解法別 カテゴリボタン → URL 更新 +// ---- 解法別 カテゴリボタン → URL 更新 ---- + const SOLUTION_CATEGORY_CASES: { category: SolutionCategory; label: string }[] = [ { category: SolutionCategory.GRAPH, label: 'グラフ' }, { category: SolutionCategory.DYNAMIC_PROGRAMMING, label: 'DP' }, @@ -74,11 +79,34 @@ for (const { category, label } of SOLUTION_CATEGORY_CASES) { test(`solution category button "${label}" updates URL to categories=${category}`, async ({ page, }) => { - await page.goto('/workbooks?tab=solution'); + await page.goto(`/workbooks?tab=${WorkBookTab.SOLUTION}`); await page.getByRole('button', { name: label }).click(); await expect(page).toHaveURL(new RegExp(`categories=${category}`)); }); } + +// ---- CREATED_BY_USER タブ(管理者専用) ---- + +test('admin can access created_by_user tab via URL', async ({ page, context }) => { + // 管理者としてログイン済みの状態を前提とする(fixtures / auth setup で設定) + await page.goto(`/workbooks?tab=${WorkBookTab.CREATED_BY_USER}`); + await expect(page.getByRole('tab', { name: 'ユーザ作成' })).toHaveAttribute( + 'aria-selected', + 'true', + ); +}); + +test('non-admin accessing created_by_user tab is redirected to /workbooks', async ({ page }) => { + // 一般ユーザとしてログイン済みの状態を前提とする + await page.goto(`/workbooks?tab=${WorkBookTab.CREATED_BY_USER}`); + await expect(page).toHaveURL('/workbooks'); +}); + +test('created_by_user tab is not visible to non-admin users', async ({ page }) => { + // 一般ユーザ: タブ自体が表示されていないことを確認 + await page.goto('/workbooks'); + await expect(page.getByRole('tab', { name: 'ユーザ作成' })).not.toBeVisible(); +}); ``` - [ ] **Step 3: E2Eテスト実行** @@ -91,5 +119,5 @@ pnpm test:e2e -- --grep "workbooks" ```bash git add e2e/workbooks_list.spec.ts -git commit -m "test(e2e/workbooks): Update tests for URL param-driven filtering" +git commit -m "test(e2e/workbooks): Update tests for URL param-driven filtering and created_by_user tab" ``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-11.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-11.md new file mode 100644 index 000000000..a0c53d791 --- /dev/null +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-11.md @@ -0,0 +1,61 @@ +# Phase 11: `/refactor-plan` → `/session-close` + +**リスク:** 低 | **前提:** Phase 10 まで完了・全テスト通過済み + +Phase 0–10 で変更したファイル全体に対して `/refactor-plan` スキルを実行し、見落とした改善点を体系的にリストアップする。その後 `/session-close` でセッションを締める。 + +--- + +## Task 11-A: `/refactor-plan` の実行 + +- [ ] **Step 1: `/refactor-plan` を実行** + +``` +/refactor-plan +``` + +対象パス(Phase 0–10 で変更・新規作成したファイル): + +- `src/features/workbooks/types/workbook.ts` +- `src/features/workbooks/utils/workbook_url_params.ts` +- `src/features/workbooks/utils/workbooks.ts` +- `src/features/workbooks/services/workbooks.ts` +- `src/routes/workbooks/+page.server.ts` +- `src/features/workbooks/components/list/SolutionWorkBookList.svelte` +- `src/features/workbooks/components/list/SolutionTable.svelte` +- `src/features/workbooks/components/list/CurriculumWorkBookList.svelte` +- `src/features/workbooks/components/list/WorkbookTabItem.svelte` +- `src/features/workbooks/components/list/WorkBookList.svelte` +- `src/routes/workbooks/+page.svelte` +- `e2e/workbooks_list.spec.ts` + +- [ ] **Step 2: 生成された計画をユーザーにレビュー依頼** + +critical/high な指摘があれば即時対応する。low/info はこの場で判断し、対応するなら追加コミットする。 + +--- + +## Task 11-B: CodeRabbit AI レビュー + +- [ ] **Step 1: CodeRabbit レビューを実行** + +```bash +coderabbit review --plain +``` + +- [ ] **Step 2: 指摘を severity でトリアージ** + +- **critical / high**: 次のフェーズ開始前に修正する +- **low / info**: 内容を確認し、セキュリティ・リグレッション関連なら即修正、それ以外は最終 PR レビュー時に対応 + +--- + +## Task 11-C: `/session-close` の実行 + +- [ ] **Step 1: `/session-close` を実行** + +``` +/session-close +``` + +セッション締め処理(plan チェックリスト更新・rule/skill 追加提案・bloat チェック・繰り返し指示の検出)を行う。 diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-2.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-2.md index b46363632..cc80bbb2a 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-2.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-2.md @@ -4,6 +4,12 @@ `+page.server.ts` での解析と `+page.svelte` での URL 組み立てを担う純粋関数群。order ページの `parseInitialCategories(params: URLSearchParams)` パターンに倣い、引数は `URLSearchParams` を直接受け取る(`string | null` ではない)。 +**設計方針:** + +- tab 値の比較は `WorkBookTab.CURRICULUM` 等の定数を使い、ハードコード文字列は禁止 +- `isValidEnumValue()` サブ関数で `grades`/`categories` の共通検証ロジックを切り出す +- テスト内の `new URLSearchParams('...')` は `toParams()` ヘルパーで共通化する + --- ## Task 2-A: 失敗するテストを書く @@ -18,6 +24,7 @@ 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, @@ -25,71 +32,78 @@ import { buildWorkbooksUrl, } from './workbook_url_params'; +/** テスト用ヘルパー: クエリ文字列から URLSearchParams を生成する */ +function toParams(query: string): URLSearchParams { + return new URLSearchParams(query); +} + describe('parseWorkBookTab', () => { test('returns curriculum for tab=curriculum', () => { - expect(parseWorkBookTab(new URLSearchParams('tab=curriculum'))).toBe('curriculum'); + expect(parseWorkBookTab(toParams('tab=curriculum'))).toBe(WorkBookTab.CURRICULUM); }); test('returns solution for tab=solution', () => { - expect(parseWorkBookTab(new URLSearchParams('tab=solution'))).toBe('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(new URLSearchParams())).toBe('curriculum'); + expect(parseWorkBookTab(toParams(''))).toBe(WorkBookTab.CURRICULUM); }); test('returns curriculum (default) for invalid tab value', () => { - expect(parseWorkBookTab(new URLSearchParams('tab=created_by_user'))).toBe('curriculum'); + expect(parseWorkBookTab(toParams('tab=invalid'))).toBe(WorkBookTab.CURRICULUM); }); }); describe('parseWorkBookGrade', () => { test('returns Q10 for grades=Q10', () => { - expect(parseWorkBookGrade(new URLSearchParams('grades=Q10'))).toBe(TaskGrade.Q10); + expect(parseWorkBookGrade(toParams('grades=Q10'))).toBe(TaskGrade.Q10); }); test('returns Q9 for grades=Q9', () => { - expect(parseWorkBookGrade(new URLSearchParams('grades=Q9'))).toBe(TaskGrade.Q9); + expect(parseWorkBookGrade(toParams('grades=Q9'))).toBe(TaskGrade.Q9); }); test('returns Q10 (default) when grades is absent', () => { - expect(parseWorkBookGrade(new URLSearchParams())).toBe(TaskGrade.Q10); + expect(parseWorkBookGrade(toParams(''))).toBe(TaskGrade.Q10); }); test('returns Q10 (default) for PENDING', () => { - expect(parseWorkBookGrade(new URLSearchParams('grades=PENDING'))).toBe(TaskGrade.Q10); + expect(parseWorkBookGrade(toParams('grades=PENDING'))).toBe(TaskGrade.Q10); }); test('returns Q10 (default) for invalid value', () => { - expect(parseWorkBookGrade(new URLSearchParams('grades=Z99'))).toBe(TaskGrade.Q10); + expect(parseWorkBookGrade(toParams('grades=Z99'))).toBe(TaskGrade.Q10); }); }); describe('parseWorkBookCategory', () => { test('returns SEARCH_SIMULATION for categories=SEARCH_SIMULATION', () => { - expect(parseWorkBookCategory(new URLSearchParams('categories=SEARCH_SIMULATION'))).toBe( + expect(parseWorkBookCategory(toParams('categories=SEARCH_SIMULATION'))).toBe( SolutionCategory.SEARCH_SIMULATION, ); }); test('returns GRAPH for categories=GRAPH', () => { - expect(parseWorkBookCategory(new URLSearchParams('categories=GRAPH'))).toBe( - SolutionCategory.GRAPH, - ); + expect(parseWorkBookCategory(toParams('categories=GRAPH'))).toBe(SolutionCategory.GRAPH); }); test('returns SEARCH_SIMULATION (default) when categories is absent', () => { - expect(parseWorkBookCategory(new URLSearchParams())).toBe(SolutionCategory.SEARCH_SIMULATION); + expect(parseWorkBookCategory(toParams(''))).toBe(SolutionCategory.SEARCH_SIMULATION); }); test('returns SEARCH_SIMULATION (default) for PENDING', () => { - expect(parseWorkBookCategory(new URLSearchParams('categories=PENDING'))).toBe( + expect(parseWorkBookCategory(toParams('categories=PENDING'))).toBe( SolutionCategory.SEARCH_SIMULATION, ); }); test('returns SEARCH_SIMULATION (default) for invalid value', () => { - expect(parseWorkBookCategory(new URLSearchParams('categories=FLYING_FISH'))).toBe( + expect(parseWorkBookCategory(toParams('categories=FLYING_FISH'))).toBe( SolutionCategory.SEARCH_SIMULATION, ); }); @@ -97,19 +111,23 @@ describe('parseWorkBookCategory', () => { describe('buildWorkbooksUrl', () => { test('curriculum tab with grade produces correct URL', () => { - expect(buildWorkbooksUrl('curriculum', TaskGrade.Q9)).toBe( + expect(buildWorkbooksUrl(WorkBookTab.CURRICULUM, TaskGrade.Q9)).toBe( '/workbooks?tab=curriculum&grades=Q9', ); }); test('solution tab with category produces correct URL', () => { - expect(buildWorkbooksUrl('solution', undefined, SolutionCategory.GRAPH)).toBe( + 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('curriculum')).toBe('/workbooks?tab=curriculum'); + 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'); }); }); ``` @@ -134,11 +152,23 @@ pnpm test:unit -- workbook_url_params ```typescript import { TaskGrade } from '$lib/types/task'; import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; -import { type WorkBookTab, DEFAULT_WORKBOOK_TAB } from '$features/workbooks/types/workbook'; +import { WorkBookTab, DEFAULT_WORKBOOK_TAB } from '$features/workbooks/types/workbook'; const DEFAULT_CURRICULUM_GRADE = TaskGrade.Q10; const DEFAULT_SOLUTION_CATEGORY = SolutionCategory.SEARCH_SIMULATION; -const VALID_TABS = new Set(['curriculum', 'solution']); +const VALID_TABS = new Set(Object.values(WorkBookTab)); + +/** + * 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; +} /** * Parses the `?tab=` URL parameter into a WorkBookTab. @@ -161,12 +191,8 @@ export function parseWorkBookTab(params: URLSearchParams): WorkBookTab { export function parseWorkBookGrade(params: URLSearchParams): TaskGrade { const param = params.get('grades'); - if ( - param !== null && - Object.values(TaskGrade).includes(param as TaskGrade) && - param !== TaskGrade.PENDING - ) { - return param as TaskGrade; + if (isValidNonPending(param, Object.values(TaskGrade), TaskGrade.PENDING)) { + return param; } return DEFAULT_CURRICULUM_GRADE; @@ -179,12 +205,8 @@ export function parseWorkBookGrade(params: URLSearchParams): TaskGrade { export function parseWorkBookCategory(params: URLSearchParams): SolutionCategory { const param = params.get('categories'); - if ( - param !== null && - Object.values(SolutionCategory).includes(param as SolutionCategory) && - param !== SolutionCategory.PENDING - ) { - return param as SolutionCategory; + if (isValidNonPending(param, Object.values(SolutionCategory), SolutionCategory.PENDING)) { + return param; } return DEFAULT_SOLUTION_CATEGORY; @@ -192,10 +214,11 @@ export function parseWorkBookCategory(params: URLSearchParams): SolutionCategory /** * 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 ('curriculum' or 'solution') - * @param grade - Selected grade (only appended when tab === 'curriculum') - * @param category - Selected category (only appended when tab === 'solution') + * @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( @@ -206,9 +229,9 @@ export function buildWorkbooksUrl( const params = new URLSearchParams(); params.set('tab', tab); - if (tab === 'curriculum' && grade) { + if (tab === WorkBookTab.CURRICULUM && grade) { params.set('grades', grade); - } else if (tab === 'solution' && category) { + } else if (tab === WorkBookTab.SOLUTION && category) { params.set('categories', category); } @@ -220,7 +243,7 @@ export function buildWorkbooksUrl( ```bash pnpm test:unit -- workbook_url_params -# PASS: 14 tests +# PASS: 17 tests ``` - [ ] **Step 3: コミット** diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-3.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-3.md index e86bb17e7..27f5e1903 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-3.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-3.md @@ -1,9 +1,11 @@ -# Phase 3: サービス層 — `getPublishedWorkbooksByPlacement()` + `getWorkBooksCreatedByUsers()` +# Phase 3: サービス層 — `getPublishedWorkbooksByPlacement()` / `getWorkBooksCreatedByUsers()` / `getAvailableSolutionCategories()` **レイヤー:** `src/features/workbooks/services/` | **リスク:** 中(Prismaクエリ追加。既存関数は変更しない) `PlacementQuery` は discriminated union で定義する。`workBookType === CURRICULUM` のとき `taskGrade` が確定し、`SOLUTION` のとき `solutionCategory` が確定する。これにより呼び出し側での繰り返し条件分岐を排除できる。 +`mapWithAuthorName()` はプライベートヘルパーとして切り出し、2つの関数で重複する `map()` を排除する。 + > **Prisma の挙動:** `where: { placement: { taskGrade: 'Q10' } }` は placement レコードが存在しない workbook を自動的に除外する(optional one-to-one のネストフィルタは IS NOT NULL を暗黙的に含む)。 --- @@ -19,11 +21,16 @@ 既存の `vi.mock('$lib/server/database', ...)` とモック変数(`prisma`)を再利用する。 ```typescript -import { getPublishedWorkbooksByPlacement, getWorkBooksCreatedByUsers } from './workbooks'; +import { + getPublishedWorkbooksByPlacement, + getWorkBooksCreatedByUsers, + getAvailableSolutionCategories, +} from './workbooks'; import { WorkBookType } from '$features/workbooks/types/workbook'; import { TaskGrade } from '$lib/types/task'; import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; +// テスト用ベースフィクスチャ const MOCK_WORKBOOK_BASE = { id: 1, title: 'Test workbook', @@ -40,14 +47,20 @@ const MOCK_WORKBOOK_BASE = { user: { username: 'author1' }, }; +/** Prisma の workBook.findMany モックを設定するヘルパー */ +function mockWorkbookFindMany(workbooks: (typeof MOCK_WORKBOOK_BASE)[]) { + prisma.workBook.findMany.mockResolvedValue(workbooks); +} + describe('getPublishedWorkbooksByPlacement', () => { test('filters CURRICULUM workbooks by taskGrade with priority asc order', async () => { - const mockWorkbook = { - ...MOCK_WORKBOOK_BASE, - workBookType: WorkBookType.CURRICULUM, - placement: { priority: 1, taskGrade: TaskGrade.Q10, solutionCategory: null }, - }; - prisma.workBook.findMany.mockResolvedValue([mockWorkbook]); + mockWorkbookFindMany([ + { + ...MOCK_WORKBOOK_BASE, + workBookType: WorkBookType.CURRICULUM, + placement: { priority: 1, taskGrade: TaskGrade.Q10, solutionCategory: null }, + }, + ]); const result = await getPublishedWorkbooksByPlacement({ workBookType: WorkBookType.CURRICULUM, @@ -68,12 +81,13 @@ describe('getPublishedWorkbooksByPlacement', () => { }); test('filters SOLUTION workbooks by solutionCategory', async () => { - const mockWorkbook = { - ...MOCK_WORKBOOK_BASE, - workBookType: WorkBookType.SOLUTION, - placement: { priority: 1, taskGrade: null, solutionCategory: SolutionCategory.GRAPH }, - }; - prisma.workBook.findMany.mockResolvedValue([mockWorkbook]); + mockWorkbookFindMany([ + { + ...MOCK_WORKBOOK_BASE, + workBookType: WorkBookType.SOLUTION, + placement: { priority: 1, taskGrade: null, solutionCategory: SolutionCategory.GRAPH }, + }, + ]); await getPublishedWorkbooksByPlacement({ workBookType: WorkBookType.SOLUTION, @@ -91,7 +105,7 @@ describe('getPublishedWorkbooksByPlacement', () => { }); test('maps null user to authorName "unknown"', async () => { - prisma.workBook.findMany.mockResolvedValue([ + mockWorkbookFindMany([ { ...MOCK_WORKBOOK_BASE, workBookType: WorkBookType.CURRICULUM, @@ -109,19 +123,52 @@ describe('getPublishedWorkbooksByPlacement', () => { }); describe('getWorkBooksCreatedByUsers', () => { - test('queries only CREATED_BY_USER type workbooks', async () => { - prisma.workBook.findMany.mockResolvedValue([ - { ...MOCK_WORKBOOK_BASE, workBookType: WorkBookType.CREATED_BY_USER }, - ]); + test('queries only CREATED_BY_USER type workbooks ordered by id asc', async () => { + mockWorkbookFindMany([{ ...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' }, }), ); }); + + test('maps null user to authorName "unknown"', async () => { + mockWorkbookFindMany([ + { ...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 () => { + prisma.workBookPlacement.findMany.mockResolvedValue([ + { solutionCategory: SolutionCategory.GRAPH }, + { solutionCategory: SolutionCategory.DYNAMIC_PROGRAMMING }, + ]); + + const result = await getAvailableSolutionCategories(); + + expect(result).toEqual([SolutionCategory.GRAPH, SolutionCategory.DYNAMIC_PROGRAMMING]); + }); + + test('excludes null solutionCategory entries', async () => { + prisma.workBookPlacement.findMany.mockResolvedValue([ + { solutionCategory: SolutionCategory.GRAPH }, + { solutionCategory: null }, + ]); + + const result = await getAvailableSolutionCategories(); + + expect(result).toEqual([SolutionCategory.GRAPH]); + }); }); ``` @@ -129,7 +176,7 @@ describe('getWorkBooksCreatedByUsers', () => { ```bash pnpm test:unit -- workbooks.test -# FAIL: getPublishedWorkbooksByPlacement / getWorkBooksCreatedByUsers not found +# FAIL: getPublishedWorkbooksByPlacement / getWorkBooksCreatedByUsers / getAvailableSolutionCategories not found ``` --- @@ -147,7 +194,7 @@ import { TaskGrade } from '$lib/types/task'; import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; ``` -- [ ] **Step 2: `getWorkBooksWithAuthors()` の直後に型と関数を追加** +- [ ] **Step 2: `getWorkBooksWithAuthors()` の直後に型・プライベートヘルパー・関数を追加** ```typescript /** @@ -191,10 +238,7 @@ export async function getPublishedWorkbooksByPlacement( }, }); - return workbooks.map((workbook) => ({ - ...workbook, - authorName: workbook.user?.username ?? 'unknown', - })); + return mapWithAuthorName(workbooks); } /** @@ -215,6 +259,33 @@ export async function getWorkBooksCreatedByUsers(): Promise { + const placements = await db.workBookPlacement.findMany({ + where: { + workBook: { isPublished: true, workBookType: WorkBookType.SOLUTION }, + solutionCategory: { not: null }, + }, + select: { solutionCategory: true }, + distinct: ['solutionCategory'], + }); + + return placements + .map((placement) => placement.solutionCategory) + .filter((category): category is SolutionCategory => category !== null); +} + +// ---- Private helpers ---- + +function mapWithAuthorName( + workbooks: T[], +): (T & { authorName: string })[] { return workbooks.map((workbook) => ({ ...workbook, authorName: workbook.user?.username ?? 'unknown', @@ -234,5 +305,5 @@ pnpm test:unit -- workbooks.test ```bash git add src/features/workbooks/services/workbooks.ts \ src/features/workbooks/services/workbooks.test.ts -git commit -m "feat(workbooks/services): Add getPublishedWorkbooksByPlacement and getWorkBooksCreatedByUsers" +git commit -m "feat(workbooks/services): Add getPublishedWorkbooksByPlacement, getWorkBooksCreatedByUsers, getAvailableSolutionCategories" ``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-4.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-4.md index cd3bedc2f..56f12770a 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-4.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-4.md @@ -2,7 +2,14 @@ **レイヤー:** `src/routes/workbooks/` | **リスク:** 中 -URLパラメータを解析し、`getPublishedWorkbooksByPlacement()` を呼び出す。`buildPlacementQuery()` をファイルスコープのヘルパーとして抽出することで、`load()` 内の条件分岐を排除する。管理者の場合のみ `getWorkBooksCreatedByUsers()` を並列実行する。 +URLパラメータを解析し、タブに応じてサービス関数を呼び分ける。`CREATED_BY_USER` タブは管理者専用であり、非管理者は `redirect(FOUND, '/workbooks')` でリダイレクトする。全タブとも単一 `workbooks` を返し、`userCreatedWorkbooks` は廃止(パフォーマンス改善)。 + +**設計方針:** + +- タブ判定には `WorkBookTab.CURRICULUM` 等の定数を使う(ハードコード文字列禁止) +- 3タブの呼び出し分けは if/else で十分(strategy pattern は YAGNI) +- `buildPlacementQuery()` は `+page.server.ts` 内のプライベートヘルパーとして維持(重複なし) +- `availableCategories` をページデータに追加(Phase 5 の `SolutionWorkBookList` で使用) --- @@ -16,18 +23,26 @@ URLパラメータを解析し、`getPublishedWorkbooksByPlacement()` を呼び ```typescript import { Roles } from '$lib/types/user'; -import { isAdmin } from '$lib/utils/authorship'; -import { WorkBookType, type WorkBookTab } from '$features/workbooks/types/workbook'; +import { + WorkBookTab, + type WorkBookTab as WorkBookTabType, +} from '$features/workbooks/types/workbook'; + import { type PlacementQuery, getPublishedWorkbooksByPlacement, getWorkBooksCreatedByUsers, + getAvailableSolutionCategories, } from '$features/workbooks/services/workbooks'; + +import { isAdmin } from '$lib/utils/authorship'; import { parseWorkBookTab, parseWorkBookGrade, parseWorkBookCategory, } from '$features/workbooks/utils/workbook_url_params'; + +import { FOUND } from '$lib/constants/http-response-status-codes'; ``` 削除: `workBooksCrud.getWorkBooksWithAuthors` の呼び出し(`load()` 内) @@ -40,25 +55,26 @@ export async function load({ locals, url }) { const params = url.searchParams; const tab = parseWorkBookTab(params); + + // CREATED_BY_USER は管理者専用 + if (tab === WorkBookTab.CREATED_BY_USER && !isAdmin(loggedInUser?.role as Roles)) { + redirect(FOUND, '/workbooks'); + } + const selectedGrade = parseWorkBookGrade(params); const selectedCategory = parseWorkBookCategory(params); - const query = buildPlacementQuery(tab, selectedGrade, selectedCategory); try { - const [workbooks, tasksMapByIds, taskResultsByTaskId, userCreatedWorkbooks] = await Promise.all( - [ - workBooksCrud.getPublishedWorkbooksByPlacement(query), - taskCrud.getTasksByTaskId(), - taskResultsCrud.getTaskResultsOnlyResultExists(loggedInUser?.id as string, true), - isAdmin(loggedInUser?.role as Roles) - ? workBooksCrud.getWorkBooksCreatedByUsers() - : Promise.resolve([]), - ], - ); + const [workbooks, availableCategories, tasksMapByIds, taskResultsByTaskId] = await Promise.all([ + fetchWorkbooksByTab(tab, selectedGrade, selectedCategory), + getAvailableSolutionCategories(), + taskCrud.getTasksByTaskId(), + taskResultsCrud.getTaskResultsOnlyResultExists(loggedInUser?.id as string, true), + ]); return { workbooks, - userCreatedWorkbooks, + availableCategories, tasksMapByIds, taskResultsByTaskId, loggedInUser, @@ -78,15 +94,27 @@ export async function load({ locals, url }) { `actions.delete` は変更しない。 -- [ ] **Step 3: `buildPlacementQuery()` を `load()` の後(ファイル末尾)に追加** +- [ ] **Step 3: `fetchWorkbooksByTab()` と `buildPlacementQuery()` を `load()` の後(ファイル末尾)に追加** ```typescript +function fetchWorkbooksByTab( + tab: WorkBookTabType, + grade: ReturnType, + category: ReturnType, +) { + if (tab === WorkBookTab.CREATED_BY_USER) { + return getWorkBooksCreatedByUsers(); + } + + return getPublishedWorkbooksByPlacement(buildPlacementQuery(tab, grade, category)); +} + function buildPlacementQuery( - tab: WorkBookTab, + tab: WorkBookTabType, grade: ReturnType, category: ReturnType, ): PlacementQuery { - if (tab === 'curriculum') { + if (tab === WorkBookTab.CURRICULUM) { return { workBookType: WorkBookType.CURRICULUM, taskGrade: grade }; } @@ -104,5 +132,5 @@ pnpm check ```bash git add src/routes/workbooks/+page.server.ts -git commit -m "feat(workbooks/server): Load workbooks from URL params via getPublishedWorkbooksByPlacement" +git commit -m "feat(workbooks/server): Load workbooks from URL params; add CREATED_BY_USER admin guard" ``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-5.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-5.md index d1693fd69..ce7746b21 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-5.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-5.md @@ -4,10 +4,81 @@ `CurriculumWorkBookList.svelte` と同じ構造で解法別タブ用コンポーネントを作成する。カテゴリ選択 ButtonGroup を持ち、選択状態は `currentCategory` prop で受け取り、変更は `onCategoryChange` コールバックで親に委譲する。 +**設計方針:** + +- `workbookGradeModes` は `SolutionTable` では不使用(`workbookGradeModes: _` で破棄している)。`SolutionWorkBookList.Props` にも含めない +- `SolutionTable` の props 型を `WorkbookTableProps` から `SolutionTableProps`(`workbookGradeModes` を除いた型)に変更する +- `availableCategories` prop を受け取り、問題集が存在するカテゴリのみボタンを表示する +- `SolutionTableProps` は `workbook.ts` に定義する + > **注意:** `SolutionTable` の prop 名は `taskResults`(`WorkbookTableProps` の命名)。`CurriculumTable` の `taskResultsWithWorkBookId` とは異なる。 --- +## Task 5-A: `SolutionTableProps` 型を追加 + +**Files:** + +- Modify: `src/features/workbooks/types/workbook.ts` + +- [ ] **Step 1: `WorkbookTableProps` の直後に追加** + +```typescript +// Imported by SolutionTable — excludes workbookGradeModes which is unused in the solution tab. +export type SolutionTableProps = Omit; +``` + +- [ ] **Step 2: 型チェック** + +```bash +pnpm check +``` + +- [ ] **Step 3: コミット** + +```bash +git add src/features/workbooks/types/workbook.ts +git commit -m "feat(workbooks/types): Add SolutionTableProps excluding workbookGradeModes" +``` + +--- + +## Task 5-B: `SolutionTable.svelte` の props 型を更新 + +**Files:** + +- Modify: `src/features/workbooks/components/list/SolutionTable.svelte` + +- [ ] **Step 1: `WorkbookTableProps` → `SolutionTableProps` に変更** + +```svelte + +``` + +`workbookGradeModes: _` の行を削除する。 + +- [ ] **Step 2: 型チェック** + +```bash +pnpm check +``` + +- [ ] **Step 3: コミット** + +```bash +git add src/features/workbooks/components/list/SolutionTable.svelte +git commit -m "refactor(workbooks/components): SolutionTable uses SolutionTableProps, removes unused workbookGradeModes" +``` + +--- + +## Task 5-C: `SolutionWorkBookList.svelte` を新規作成 + **Files:** - Create: `src/features/workbooks/components/list/SolutionWorkBookList.svelte` @@ -19,9 +90,9 @@ import { ButtonGroup, Button } from 'flowbite-svelte'; import type { Roles } from '$lib/types/user'; - import { TaskGrade, type TaskResults } from '$lib/types/task'; - import { SolutionCategory, SOLUTION_LABELS } from '$features/workbooks/types/workbook_placement'; + import type { TaskResults } from '$lib/types/task'; import type { WorkbooksList } from '$features/workbooks/types/workbook'; + import { SolutionCategory, SOLUTION_LABELS } from '$features/workbooks/types/workbook_placement'; import { countReadableWorkbooks } from '$features/workbooks/utils/workbooks'; @@ -30,27 +101,28 @@ interface Props { workbooks: WorkbooksList; - workbookGradeModes: Map; taskResultsWithWorkBookId: Map; userId: string; role: Roles; + availableCategories: SolutionCategory[]; currentCategory: SolutionCategory; onCategoryChange: (category: SolutionCategory) => void; } let { workbooks, - workbookGradeModes, taskResultsWithWorkBookId, userId, role, + availableCategories, currentCategory, onCategoryChange, }: Props = $props(); - // PENDING(未分類)は管理者専用のため公開ページには表示しない + // PENDING(未分類)は管理者専用のため公開ページには表示しない。 + // さらに availableCategories(サーバーサイドで問題集が存在するカテゴリのみ)に絞り込む。 const AVAILABLE_CATEGORIES = Object.values(SolutionCategory).filter( - (category) => category !== SolutionCategory.PENDING, + (category) => category !== SolutionCategory.PENDING && availableCategories.includes(category), ); let readableCount = $derived(countReadableWorkbooks(workbooks, userId)); @@ -72,13 +144,7 @@ {#if readableCount} - + {:else} {/if} @@ -94,5 +160,5 @@ pnpm check ```bash git add src/features/workbooks/components/list/SolutionWorkBookList.svelte -git commit -m "feat(workbooks/components): Add SolutionWorkBookList with category ButtonGroup" +git commit -m "feat(workbooks/components): Add SolutionWorkBookList with category ButtonGroup and availableCategories filter" ``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-7.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-7.md index 432c2a01f..33aff78fe 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-7.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-7.md @@ -4,6 +4,9 @@ `workbookType` prop と `activeWorkbookTabStore` への依存を除去し、タブクリック時の動作を `onclick` prop として親に委譲する。 +> **ストア削除の根拠(Phase 9 への前置き):** +> `active_workbook_tab.ts` と `task_grades_by_workbook_type.ts` はいずれも Svelte v4 の `writable()` を使った **in-memory ストアのみ**(localStorage への永続化なし)。これらは URL パラメータに置き換えられるため Phase 9 で安全に削除できる。`replenishmentWorkBooksStore` のみが localStorage を使用しており、そちらは対象外。 + --- **Files:** diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-8.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-8.md index 9c0428a57..65ba5e9ae 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-8.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-8.md @@ -2,9 +2,17 @@ **レイヤー:** `src/routes/workbooks/` + `src/features/workbooks/components/list/` | **リスク:** 中-高 +**設計方針:** + +- `WorkBookList.svelte` の Props は discriminated union に変更する。optional props + `?? fallback` は型安全でない +- Svelte 5 では `let props: Props = $props()` として使い、`{#if props.workbookType === ...}` ブロックで TypeScript 型ナローイングを活用する(destructure すると narrowing が効かない) +- `workbookGradeModes` は CURRICULUM ブランチのみに配置(SOLUTION/CREATED_BY_USER では不要)。グレードフィルタリングはサーバーサイドに移るが、`CurriculumTable` のグレード列表示(``)で引き続き使われるため削除しない(参照: phase-6 注記) +- `CREATED_BY_USER` タブは URL ドリブン(`isCreatedByUserTabOpen` ローカル `$state` は不要) +- `userCreatedWorkbooks` は廃止。全タブとも `data.workbooks` を使用する + --- -## Task 8-A: `WorkBookList.svelte` に SOLUTION ルーティングを追加 +## Task 8-A: `WorkBookList.svelte` に discriminated union Props と SOLUTION ルーティングを追加 **Files:** @@ -12,40 +20,70 @@ - [ ] **Step 1: ファイルを読んで現在の Props / ルーティングを確認** -- [ ] **Step 2: 新しい Props を追加** +- [ ] **Step 2: Props を discriminated union に変更し SOLUTION 分岐を追加** ```typescript import { type SolutionCategory } from '$features/workbooks/types/workbook_placement'; +import { WorkBookTab, WorkBookType } from '$features/workbooks/types/workbook'; import SolutionWorkBookList from './SolutionWorkBookList.svelte'; -interface Props { - // 既存 props はそのまま - workbookType: WorkBookType; +type CommonProps = { workbooks: WorkbooksList; - workbookGradeModes: Map; taskResultsWithWorkBookId: Map; loggedInUser: { id: string; role: Roles } | null; - // 追加 - currentGrade?: TaskGrade; - onGradeChange?: (grade: TaskGrade) => void; - currentCategory?: SolutionCategory; - onCategoryChange?: (category: SolutionCategory) => void; -} +}; + +type SpecificProps = + | { + workbookType: typeof WorkBookType.CURRICULUM; + workbookGradeModes: Map; + currentGrade: TaskGrade; + onGradeChange: (grade: TaskGrade) => void; + } + | { + workbookType: typeof WorkBookType.SOLUTION; + currentCategory: SolutionCategory; + availableCategories: SolutionCategory[]; + onCategoryChange: (category: SolutionCategory) => void; + } + | { workbookType: typeof WorkBookType.CREATED_BY_USER }; + +type Props = CommonProps & SpecificProps; + +let props: Props = $props(); ``` -- [ ] **Step 3: SOLUTION 分岐を追加** +- [ ] **Step 3: テンプレートを discriminated union に対応させる** ```svelte -{:else if workbookType === WorkBookType.SOLUTION} +{#if props.workbookType === WorkBookType.CURRICULUM} + +{:else if props.workbookType === WorkBookType.SOLUTION} {})} + workbooks={props.workbooks} + taskResultsWithWorkBookId={props.taskResultsWithWorkBookId} + userId={props.loggedInUser?.id ?? ''} + role={props.loggedInUser?.role as Roles} + availableCategories={props.availableCategories} + currentCategory={props.currentCategory} + onCategoryChange={props.onCategoryChange} /> +{:else} + +{/if} ``` - [ ] **Step 4: 型チェック** @@ -58,7 +96,7 @@ pnpm check ```bash git add src/features/workbooks/components/list/WorkBookList.svelte -git commit -m "feat(workbooks/components): WorkBookList routes SOLUTION to SolutionWorkBookList" +git commit -m "refactor(workbooks/components): WorkBookList uses discriminated union Props, routes SOLUTION to SolutionWorkBookList" ``` --- @@ -82,7 +120,7 @@ git commit -m "feat(workbooks/components): WorkBookList routes SOLUTION to Solut import { type WorkbooksList, WorkBookType, - type WorkBookTab, + WorkBookTab, } from '$features/workbooks/types/workbook'; import { type SolutionCategory } from '$features/workbooks/types/workbook_placement'; @@ -99,40 +137,30 @@ git commit -m "feat(workbooks/components): WorkBookList routes SOLUTION to Solut let { data } = $props(); let workbooks = $derived(data.workbooks as WorkbooksList); - let userCreatedWorkbooks = $derived(data.userCreatedWorkbooks as WorkbooksList); let loggedInUser = data.loggedInUser; let role = loggedInUser?.role as Roles; const tasksMapByIds: Map = data.tasksMapByIds; let taskResultsByTaskId = data.taskResultsByTaskId as Map; - // CREATED_BY_USER workbooks も含めてグレードモードを計算する - const allWorkbooks = $derived([...workbooks, ...userCreatedWorkbooks] as WorkbooksList); - const workbookGradeModes = $derived(calcWorkBookGradeModes(allWorkbooks, tasksMapByIds)); - - // CREATED_BY_USER タブは URL を変えずローカル状態で管理 - let isCreatedByUserTabOpen = $state(false); + const workbookGradeModes = $derived(calcWorkBookGradeModes(workbooks, tasksMapByIds)); - function handleTabChange(tab: WorkBookTab) { - isCreatedByUserTabOpen = false; - - if (tab === 'curriculum') { - goto(buildWorkbooksUrl('curriculum', data.selectedGrade)); + function handleTabChange(tab: (typeof WorkBookTab)[keyof typeof WorkBookTab]) { + if (tab === WorkBookTab.CURRICULUM) { + goto(buildWorkbooksUrl(WorkBookTab.CURRICULUM, data.selectedGrade)); + } else if (tab === WorkBookTab.SOLUTION) { + goto(buildWorkbooksUrl(WorkBookTab.SOLUTION, undefined, data.selectedCategory)); } else { - goto(buildWorkbooksUrl('solution', undefined, data.selectedCategory)); + goto(buildWorkbooksUrl(WorkBookTab.CREATED_BY_USER)); } } function handleGradeChange(grade: TaskGrade) { - goto(buildWorkbooksUrl('curriculum', grade)); + goto(buildWorkbooksUrl(WorkBookTab.CURRICULUM, grade)); } function handleCategoryChange(category: SolutionCategory) { - goto(buildWorkbooksUrl('solution', undefined, category)); - } - - function handleCreatedByUserTabClick() { - isCreatedByUserTabOpen = true; + goto(buildWorkbooksUrl(WorkBookTab.SOLUTION, undefined, category)); } ``` @@ -159,10 +187,10 @@ git commit -m "feat(workbooks/components): WorkBookList routes SOLUTION to Solut > {#if loggedInUser} handleTabChange('curriculum')} + onclick={() => handleTabChange(WorkBookTab.CURRICULUM)} >
handleTabChange('solution')} + onclick={() => handleTabChange(WorkBookTab.SOLUTION)} >
@@ -198,17 +226,16 @@ git commit -m "feat(workbooks/components): WorkBookList routes SOLUTION to Solut {#if isAdmin(role)} handleTabChange(WorkBookTab.CREATED_BY_USER)} >
**For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** `/workbooks` ページで `WorkBookPlacement.priority` 順に問題集を表示し、URLパラメータ(`?tab=curriculum&grades=Q10` / `?tab=solution&categories=GRAPH`)でサーバーサイドフィルタリングを行う +**Goal:** `/workbooks` ページで `WorkBookPlacement.priority` 順に問題集を表示し、URLパラメータ(`?tab=curriculum&grades=Q10` / `?tab=solution&categories=GRAPH` / `?tab=created_by_user`)でサーバーサイドフィルタリングを行う -**Architecture:** `+page.server.ts` でURLパラメータを解析し、`getPublishedWorkbooksByPlacement(query)` が `WorkBookPlacement` レコードで絞り込み・`priority ASC` ソートして返す。クライアントサイドのグレードフィルタリングを削除し、`goto()` + `buildWorkbooksUrl()` による SvelteKit クライアントサイドナビゲーションに置き換える。`CREATED_BY_USER` タブは管理者専用として維持し、URL 変更なしのローカル状態で管理する。 +**Architecture:** `+page.server.ts` でURLパラメータを解析し、タブに応じてサービス関数を呼び分ける。`CURRICULUM`/`SOLUTION` は `getPublishedWorkbooksByPlacement(query)` が `WorkBookPlacement` レコードで絞り込み・`priority ASC` ソートして返す。`CREATED_BY_USER` は `getWorkBooksCreatedByUsers()` を呼ぶ(管理者専用・非管理者は `FOUND` リダイレクト)。全タブとも単一 `workbooks` を返し、`userCreatedWorkbooks` は廃止。クライアントサイドのグレードフィルタリングを削除し、`goto()` + `buildWorkbooksUrl()` による SvelteKit クライアントサイドナビゲーションに置き換える。 **Tech Stack:** SvelteKit 2 + Svelte 5 Runes + TypeScript | Prisma (PostgreSQL) | Flowbite Svelte (ButtonGroup) | Vitest + Playwright @@ -20,9 +20,29 @@ - placement レコードがない問題集は表示しない(Prisma のネスト where フィルタが IS NOT NULL を暗黙的に含む) - `isReplenished` トグルはクライアントサイドのままで維持 -- `CREATED_BY_USER` タブは管理者のみ閲覧可能(URL パラメータ管理外・ローカル `$state` で isOpen 管理) +- `CREATED_BY_USER` タブは管理者のみ閲覧可能(`?tab=created_by_user` URL パラメータ管理・非管理者は `redirect(FOUND, '/workbooks')` でリダイレクト) - グレード/カテゴリ ボタンクリック → `buildWorkbooksUrl()` で URL 組み立て → `goto()` で SvelteKit クライアントナビゲーション - URLパラメータなし時のデフォルト: カリキュラム Q10 / 解法別 SEARCH_SIMULATION +- 問題集が存在しないカテゴリはカテゴリボタンに表示しない(`getAvailableSolutionCategories()` でサーバーサイド判定) + +--- + +## 方針・判断基準 + +| # | 方針 | 理由 | +| --- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `WorkBookTab` は `WorkBookType` と同パターンの const オブジェクト | tab 値の比較にハードコード文字列を使うと変更に弱い。`WorkBookTab.CURRICULUM` 等の定数を使う | +| 2 | `CREATED_BY_USER` は URL パラメータ管理(サーバーサイドフィルタリング) | ローカル `$state` での管理は URL の再現性がなく、URL 共有・直アクセスができない | +| 3 | 非管理者が `?tab=created_by_user` にアクセスした場合は `redirect(FOUND, '/workbooks')` | 空データを返すより明示的なリダイレクトの方が UX として正しい | +| 4 | `workbooks` / `userCreatedWorkbooks` を統合し単一 `workbooks` に | 両方を常に fetch するのはパフォーマンス上の無駄。タブに応じて1回だけ呼ぶ | +| 5 | タブ分岐は if/else を維持(strategy pattern / interface は使わない) | 3タブに対して strategy pattern は YAGNI | +| 6 | `buildPlacementQuery()` は `+page.server.ts` 内のプライベートヘルパーとして維持 | 重複なし・1箇所のみ使用。utils への移動は過剰 | +| 7 | `WorkBookList.svelte` Props は discriminated union に変更 | optional props + `?? fallback` は型安全でない。Svelte 5 では `let props: Props = $props()` で使い、`{#if}` ブロック内で TypeScript 型ナローイング | +| 8 | `workbookGradeModes` は discriminated union の CURRICULUM ブランチのみに配置 | `SolutionTable` は `workbookGradeModes: _` で破棄している。SOLUTION/CREATED_BY_USER では不要 | +| 9 | `AVAILABLE_CATEGORIES` はサーバーサイドで `getAvailableSolutionCategories()` により判定 | クライアント側は現在選択中のカテゴリの問題集しか持たないため、他カテゴリの存在を知れない | +| 10 | `partitionWorkbooksAsMainAndReplenished` に改名 | `splitWorkbooksByReplenishment` は main の存在が不明。両端(main/replenished)が名前に現れる方が直感的 | +| 11 | テスト内の `prisma.workBook.findMany.mockResolvedValue(...)` はヘルパー関数として切り出す | プロジェクト規約。類似パターンの重複を防ぐ | +| 12 | `mapWithAuthorName()` をプライベート関数として切り出す | `getPublishedWorkbooksByPlacement` / `getWorkBooksCreatedByUsers` で同一の `map()` が重複 | --- @@ -32,51 +52,52 @@ | ファイル | 役割 | | -------------------------------------------------------------------- | ---------------------------------------------- | -| `src/features/workbooks/utils/workbook_url_params.ts` | URLパラメータ解析・組み立てユーティリティ | +| `src/features/workbooks/utils/workbook_url_params.ts` | URLパラメータ解析・URL組み立てユーティリティ | | `src/features/workbooks/utils/workbook_url_params.test.ts` | 上記のユニットテスト | | `src/features/workbooks/components/list/SolutionWorkBookList.svelte` | 解法別カテゴリ選択 ButtonGroup + SolutionTable | ### 修正 -| ファイル | 変更内容 | -| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `src/features/workbooks/types/workbook.ts` | `WorkBookTab` 型・定数追加 | -| `src/routes/(admin)/workbooks/order/_types/kanban.ts` | `ActiveTab` を `WorkBookTab` の再エクスポートに変更 | -| `src/features/workbooks/utils/workbooks.ts` | `splitWorkbooksByReplenishment()` 追加 | -| `src/features/workbooks/utils/workbooks.test.ts` | 上記テスト追加 | -| `src/features/workbooks/services/workbooks.ts` | `PlacementQuery` 型・`getPublishedWorkbooksByPlacement()` / `getWorkBooksCreatedByUsers()` 追加 | -| `src/features/workbooks/services/workbooks.test.ts` | 上記テスト追加 | -| `src/routes/workbooks/+page.server.ts` | URLパラメータ解析・新サービス呼び出し | -| `src/features/workbooks/components/list/CurriculumWorkBookList.svelte` | ストア削除・`currentGrade` prop 化・`splitWorkbooksByReplenishment` 使用 | -| `src/features/workbooks/components/list/WorkbookTabItem.svelte` | `workbookType` prop 削除・`onclick` prop 化 | -| `src/features/workbooks/components/list/WorkBookList.svelte` | SOLUTION → SolutionWorkBookList ルーティング追加 | -| `src/routes/workbooks/+page.svelte` | URL駆動タブ/フィルタ・CREATED_BY_USER 管理者のみ表示 | -| `e2e/workbooks_list.spec.ts` | E2Eテスト更新 | +| ファイル | 変更内容 | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `src/features/workbooks/types/workbook.ts` | `WorkBookTab` const オブジェクト追加(`CURRICULUM`/`SOLUTION`/`CREATED_BY_USER`)・`SolutionTableProps` 追加 | +| `src/routes/(admin)/workbooks/order/_types/kanban.ts` | `ActiveTab` を `WorkBookTab` の再エクスポートに変更 | +| `src/features/workbooks/utils/workbooks.ts` | `partitionWorkbooksAsMainAndReplenished()` 追加 | +| `src/features/workbooks/utils/workbooks.test.ts` | 上記テスト追加 | +| `src/features/workbooks/services/workbooks.ts` | `PlacementQuery` 型・`getPublishedWorkbooksByPlacement()` / `getWorkBooksCreatedByUsers()` / `getAvailableSolutionCategories()` 追加 | +| `src/features/workbooks/services/workbooks.test.ts` | 上記テスト追加 | +| `src/routes/workbooks/+page.server.ts` | URLパラメータ解析・タブ別サービス呼び出し・`CREATED_BY_USER` の admin ガード追加 | +| `src/features/workbooks/components/list/CurriculumWorkBookList.svelte` | ストア削除・`currentGrade` prop 化・`partitionWorkbooksAsMainAndReplenished` 使用 | +| `src/features/workbooks/components/list/WorkbookTabItem.svelte` | `workbookType` prop 削除・`onclick` prop 化 | +| `src/features/workbooks/components/list/WorkBookList.svelte` | discriminated union Props・SOLUTION → SolutionWorkBookList ルーティング追加 | +| `src/routes/workbooks/+page.svelte` | URL駆動タブ/フィルタ・CREATED_BY_USER も URL 管理 | +| `e2e/workbooks_list.spec.ts` | E2Eテスト更新 | ### 削除(Phase 9) -| ファイル | 理由 | -| ---------------------------------------------------------------------- | ---------------------------- | -| `src/features/workbooks/stores/task_grades_by_workbook_type.ts` + test | URLパラメータに置き換え | -| `src/features/workbooks/stores/active_workbook_tab.ts` + test | ローカル `$state` に置き換え | +| ファイル | 理由 | +| ---------------------------------------------------------------------- | --------------------------------- | +| `src/features/workbooks/stores/task_grades_by_workbook_type.ts` + test | URLパラメータに置き換え | +| `src/features/workbooks/stores/active_workbook_tab.ts` + test | ローカル状態不要(URL管理に移行) | --- ## Phase 一覧 -| Phase | ファイル | 内容 | リスク | -| ----- | ---------------------------- | ------------------------------------------------ | ------ | -| 0 | [phase-0.md](./phase-0.md) | `WorkBookTab` 型を feature types に追加・統一 | 極低 | -| 1 | [phase-1.md](./phase-1.md) | `splitWorkbooksByReplenishment()` ユーティリティ | 極低 | -| 2 | [phase-2.md](./phase-2.md) | `workbook_url_params.ts` 解析・URL組み立て | 極低 | -| 3 | [phase-3.md](./phase-3.md) | `getPublishedWorkbooksByPlacement()` サービス | 中 | -| 4 | [phase-4.md](./phase-4.md) | `+page.server.ts` URLパラメータ対応 | 中 | -| 5 | [phase-5.md](./phase-5.md) | `SolutionWorkBookList.svelte` 新規作成 | 低-中 | -| 6 | [phase-6.md](./phase-6.md) | `CurriculumWorkBookList.svelte` リファクタリング | 中 | -| 7 | [phase-7.md](./phase-7.md) | `WorkbookTabItem.svelte` 簡素化 | 低 | -| 8 | [phase-8.md](./phase-8.md) | `WorkBookList.svelte` + `+page.svelte` 改修 | 中-高 | -| 9 | [phase-9.md](./phase-9.md) | 不要ストア削除 | 低 | -| 10 | [phase-10.md](./phase-10.md) | E2Eテスト更新 | 低 | +| Phase | ファイル | 内容 | リスク | +| ----- | ---------------------------- | ---------------------------------------------------------------------------------- | ------ | +| 0 | [phase-0.md](./phase-0.md) | `WorkBookTab` 型を feature types に追加・統一 | 極低 | +| 1 | [phase-1.md](./phase-1.md) | `partitionWorkbooksAsMainAndReplenished()` ユーティリティ | 極低 | +| 2 | [phase-2.md](./phase-2.md) | `workbook_url_params.ts` 解析・URL組み立て | 極低 | +| 3 | [phase-3.md](./phase-3.md) | `getPublishedWorkbooksByPlacement()` / `getAvailableSolutionCategories()` サービス | 中 | +| 4 | [phase-4.md](./phase-4.md) | `+page.server.ts` URLパラメータ対応 | 中 | +| 5 | [phase-5.md](./phase-5.md) | `SolutionWorkBookList.svelte` 新規作成 | 低-中 | +| 6 | [phase-6.md](./phase-6.md) | `CurriculumWorkBookList.svelte` リファクタリング | 中 | +| 7 | [phase-7.md](./phase-7.md) | `WorkbookTabItem.svelte` 簡素化 | 低 | +| 8 | [phase-8.md](./phase-8.md) | `WorkBookList.svelte` + `+page.svelte` 改修 | 中-高 | +| 9 | [phase-9.md](./phase-9.md) | 不要ストア削除 | 低 | +| 10 | [phase-10.md](./phase-10.md) | E2Eテスト更新 | 低 | +| 11 | [phase-11.md](./phase-11.md) | `/refactor-plan` → `/session-close` | 低 | --- @@ -92,9 +113,10 @@ - グレードボタンクリック → 画面リロードなしで URL・コンテンツ更新 - 解法別タブクリック → `?tab=solution&categories=SEARCH_SIMULATION` - カテゴリボタンクリック → URL・コンテンツ更新 + - 問題集が存在しないカテゴリのボタンが非表示 - `/workbooks?tab=solution&categories=GRAPH` 直アクセス → 正しく表示 - - 管理者: ユーザ作成タブが表示される(URL 変更なし) - - 一般ユーザ: ユーザ作成タブが非表示 + - 管理者: `/workbooks?tab=created_by_user` → ユーザ作成タブが表示 + - 一般ユーザ: `/workbooks?tab=created_by_user` → `/workbooks` にリダイレクト - 補充教材トグルが引き続き動作する --- @@ -106,25 +128,38 @@ | `src/routes/sitemap.xml/+server.ts` | **変更なし** | `/workbooks/[slug]` 個別 URL を生成するのみ。一覧ページは対象外 | | `src/routes/workbooks/+page.server.ts` (delete action) | **変更なし** | フォーム送信後の再ロードも URL パラメータを引き継ぐ | | `src/routes/(admin)/workbooks/order/` | **変更なし** | 管理者専用の独立ルート(`ActiveTab` 型のみ再エクスポートに変更) | -| `src/features/workbooks/components/list/WorkBookList.svelte` | **修正** | SOLUTION → SolutionWorkBookList ルーティング追加のみ | +| `src/features/workbooks/components/list/WorkBookList.svelte` | **修正** | discriminated union Props・SOLUTION ルーティング追加 | | `src/features/workbooks/components/list/CreatedByUserTable.svelte` | **変更なし** | CREATED_BY_USER タブは管理者向けに維持 | --- ## 計画中の教訓・誤解 -| # | 誤解・ミス | 正しい判断 | -| --- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | `CREATED_BY_USER` タブを「完全削除」と計画した | 「管理者のみ閲覧可能として維持」が正しい仕様。削除前に「既存ユーザー向けに残す機能か」を確認すること | -| 2 | `workbook_list_params.ts` と命名した | 何の params か不明。`workbook_url_params.ts` のように対象を明示する | -| 3 | ストアが localStorage を使っていると誤解した | `task_grades_by_workbook_type` と `active_workbook_tab` は in-memory の Svelte `writable()` のみ。localStorage を使うのは `replenishmentWorkBooksStore` だけ | -| 4 | `WorkBookList.svelte` を削除対象に含めた | ルーティングコンポーネントは既存の責務を持つ。削除前に「他で代替できるか」を確認すること | -| 5 | `WorkBookTab` を新規定義しようとした | order ページに同一の `ActiveTab = 'solution' \| 'curriculum'` が既存。重複前に `grep` で型の存在を確認すること | -| 6 | parse 関数の引数を `string \| null` にした | SvelteKit では `URLSearchParams` を直接渡すパターンが標準(order ページの `parseInitialCategories(params)` が先例)。既存コードのパターンを先に調べること | -| 7 | サービス引数をオプショナル `taskGrade?` で設計した | `tab === 'curriculum'` の条件分岐が呼び出し側に散らばる。discriminated union (`PlacementQuery`) で型レベルに閉じ込める | -| 8 | テストに `it` と日本語テスト名を使った | このプロジェクトは `test` + 英語テスト名が規約。既存テストのスタイルを先に確認すること | -| 9 | テストでハードコード文字列を直書きした | 定数(`TaskGrade.Q10` など)を使う。文字列が変わってもテストが壊れない | -| 10 | URL 組み立てをインライン文字列テンプレートで書いた | URL 組み立ては純粋関数 `buildWorkbooksUrl()` に集約する。order ページの `buildUpdatedUrl()` が先例 | -| 11 | `url.searchParams.get('tab')` を3回繰り返した | `const params = url.searchParams` で変数に切り出す | -| 12 | E2E テストのグレード・カテゴリケースが1件のみだった | URL パラメータのバリエーションを `for...of` ループで複数カバーする | -| 13 | 計画を単一ファイルに書き続けた(1000行超) | plan.md は全体俯瞰(goal / 構成 / phase 一覧 / 検証)に留め、詳細タスクは `phase-N.md` に分割する。コンテキスト圧迫を防ぎ、phase 単位での参照・更新が容易になる | +> 「分類」は発見のきっかけになりやすいカテゴリ。同じ分類でミスが続く場合は該当カテゴリの確認を計画レビューに組み込むこと。 + +| # | 分類 | 誤解・ミス | 正しい判断 | +| --- | -------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | 仕様確認 | `CREATED_BY_USER` タブを「完全削除」と計画した | 「管理者のみ閲覧可能として維持」が正しい仕様。削除前に「既存ユーザー向けに残す機能か」を確認すること | +| 2 | 命名 | `workbook_list_params.ts` と命名した | 何の params か不明。`workbook_url_params.ts` のように対象を明示する | +| 3 | 実装調査 | ストアが localStorage を使っていると誤解した | `task_grades_by_workbook_type` と `active_workbook_tab` は in-memory の Svelte `writable()` のみ。localStorage を使うのは `replenishmentWorkBooksStore` だけ | +| 4 | 仕様確認 | `WorkBookList.svelte` を削除対象に含めた | ルーティングコンポーネントは既存の責務を持つ。削除前に「他で代替できるか」を確認すること | +| 5 | 型設計 | `WorkBookTab` を新規定義しようとした | order ページに同一の `ActiveTab = 'solution' \| 'curriculum'` が既存。重複前に `grep` で型の存在を確認すること | +| 6 | 型設計 | parse 関数の引数を `string \| null` にした | SvelteKit では `URLSearchParams` を直接渡すパターンが標準(order ページの `parseInitialCategories(params)` が先例)。既存コードのパターンを先に調べること | +| 7 | 型設計 | サービス引数をオプショナル `taskGrade?` で設計した | `tab === 'curriculum'` の条件分岐が呼び出し側に散らばる。discriminated union (`PlacementQuery`) で型レベルに閉じ込める | +| 8 | テスト | テストに `it` と日本語テスト名を使った | このプロジェクトは `test` + 英語テスト名が規約。既存テストのスタイルを先に確認すること | +| 9 | テスト | テストでハードコード文字列を直書きした | 定数(`TaskGrade.Q10` など)を使う。文字列が変わってもテストが壊れない | +| 10 | 実装品質 | URL 組み立てをインライン文字列テンプレートで書いた | URL 組み立ては純粋関数 `buildWorkbooksUrl()` に集約する。order ページの `buildUpdatedUrl()` が先例 | +| 11 | 実装品質 | `url.searchParams.get('tab')` を3回繰り返した | `const params = url.searchParams` で変数に切り出す | +| 12 | テスト | E2E テストのグレード・カテゴリケースが1件のみだった | URL パラメータのバリエーションを `for...of` ループで複数カバーする | +| 13 | 計画構造 | 計画を単一ファイルに書き続けた(1000行超) | plan.md は全体俯瞰(goal / 構成 / phase 一覧 / 検証)に留め、詳細タスクは `phase-N.md` に分割する。コンテキスト圧迫を防ぎ、phase 単位での参照・更新が容易になる | +| 14 | 仕様確認 | `CREATED_BY_USER` をローカル `$state` で管理した | URL パラメータで管理する方が URL の再現性・直アクセスが可能になる。管理者向けでも URL ドリブンが正しい | +| 15 | 実装品質 | `workbooks` / `userCreatedWorkbooks` を両方 fetch した | タブに応じてどちらか一方だけ fetch すれば十分。両方返す設計はパフォーマンス上の無駄 | +| 16 | 型設計 | optional props + `?? fallback` で WorkBookList を設計した | discriminated union Props の方が型安全。Svelte 5 では `let props: Props = $props()` + `{#if}` ナローイングで実現可能 | +| 17 | 型設計 | Props を設計する前に使用先コンポーネントのソースを読んでいなかった | `SolutionTable` が `workbookGradeModes: _` で破棄していることを見落とし、不要な prop を Props に含めた。型設計前に必ず使用先の実装を Read/grep で確認すること | +| 18 | 命名 | バイナリ分割関数の名前が片方の概念しか示していなかった | `splitWorkbooksByReplenishment` は「補充側」しか表現しない。`partitionWorkbooksAsMainAndReplenished` のように **両端の概念を名前に含める** | +| 19 | テスト | テスト内の `new URLSearchParams('...')` 繰り返しを計画段階で気づかなかった | テスト設計時に「同パターンが2回以上現れるか」を確認し、`toParams()` のようなヘルパーを計画に含める | +| 20 | テスト | Prisma mock の `mockResolvedValue(...)` 繰り返しを計画段階で気づかなかった | テストケースが2つ以上あり同じモックパターンを使う場合は、`mockWorkbookFindMany()` のようなヘルパーを計画段階から明示すること(プロジェクト規約でもある) | +| 21 | 実装品質 | 複数の service 関数に同一の `.map()` 変換処理が現れることを見落とした | `mapWithAuthorName()` のような抽出を計画時から意識する。「2つ以上の関数が同じ変換をする」は計画レビューで検出できる | +| 22 | スコープ | `AVAILABLE_CATEGORIES` のフィルタリングを「スコープ外」と早期に判断した | 実装コストが低く(サービス関数1つの追加)、UX 価値が高い機能を安易にスコープ外にしない。「単に filtering するだけ」レベルの機能は同フェーズに含める | +| 23 | 型設計 | 新しい列挙型を string literal union で設計した | `WorkBookType` が const object パターンを使っているにもかかわらず `WorkBookTab` を string literal union で設計した。新しい列挙型を作るときは既存の型定義を先に確認し、プロジェクト内パターンに揃える | +| 24 | 実装品質 | 類似した条件ロジックの重複を計画段階で気づかなかった | `parseWorkBookGrade` と `parseWorkBookCategory` で「null チェック + 有効値確認 + PENDING 除外」が重複。計画段階で「同バリデーションロジックが複数現れるか」を確認し、`isValidNonPending()` のような汎用サブ関数を早期に設計する | From a21025a9750891f4bfa30d09da8bfd0d1fbe8f61 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sat, 21 Mar 2026 10:01:17 +0000 Subject: [PATCH 03/47] feat(workbooks/types): Add WorkBookTab const object; derive order-page ActiveTab via Exclude Co-Authored-By: Claude Sonnet 4.6 --- src/features/workbooks/types/workbook.ts | 12 ++++++++++++ src/routes/(admin)/workbooks/order/_types/kanban.ts | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/features/workbooks/types/workbook.ts b/src/features/workbooks/types/workbook.ts index cd2e9a592..fd617afba 100644 --- a/src/features/workbooks/types/workbook.ts +++ b/src/features/workbooks/types/workbook.ts @@ -85,3 +85,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/routes/(admin)/workbooks/order/_types/kanban.ts b/src/routes/(admin)/workbooks/order/_types/kanban.ts index 26d81be46..a799a718b 100644 --- a/src/routes/(admin)/workbooks/order/_types/kanban.ts +++ b/src/routes/(admin)/workbooks/order/_types/kanban.ts @@ -9,7 +9,10 @@ export type DragEndEventArg = Parameters[0]; export type ColumnKey = 'solutionCategory' | 'taskGrade'; -export type ActiveTab = 'solution' | 'curriculum'; +import type { WorkBookTab } from '$features/workbooks/types/workbook'; + +/** 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 = { From 4b71d367b751500bac86765388bf594cc80ba979 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sat, 21 Mar 2026 10:03:45 +0000 Subject: [PATCH 04/47] feat(workbooks/utils): Add partitionWorkbooksAsMainAndReplenished utility Co-Authored-By: Claude Sonnet 4.6 --- .../workbooks/utils/workbooks.test.ts | 23 +++++++++++++++++++ src/features/workbooks/utils/workbooks.ts | 16 +++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/features/workbooks/utils/workbooks.test.ts b/src/features/workbooks/utils/workbooks.test.ts index 0bfdd2f04..b157ade9e 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 { @@ -369,6 +370,28 @@ describe('Workbooks', () => { }); }); + 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('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('getTaskResult', () => { test('returns task results for the given workbook id', () => { const taskResult = { diff --git a/src/features/workbooks/utils/workbooks.ts b/src/features/workbooks/utils/workbooks.ts index 438f6b862..cf1c68a58 100644 --- a/src/features/workbooks/utils/workbooks.ts +++ b/src/features/workbooks/utils/workbooks.ts @@ -133,3 +133,19 @@ export function getTaskResult( ): TaskResults { return taskResults.get(workbookId) ?? EMPTY_TASK_RESULTS; } + +/** + * 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 partitionWorkbooksAsMainAndReplenished(workbooks: WorkbooksList): { + main: WorkbooksList; + replenished: WorkbooksList; +} { + return { + main: workbooks.filter((workbook) => !workbook.isReplenished), + replenished: workbooks.filter((workbook) => workbook.isReplenished), + }; +} From cb94ce87f16653809027f676c968b6b248158e31 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sat, 21 Mar 2026 10:05:34 +0000 Subject: [PATCH 05/47] feat(workbooks/utils): Add URL param parsing and URL builder for workbooks list Co-Authored-By: Claude Sonnet 4.6 --- .../utils/workbook_url_params.test.ts | 109 ++++++++++++++++++ .../workbooks/utils/workbook_url_params.ts | 93 +++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 src/features/workbooks/utils/workbook_url_params.test.ts create mode 100644 src/features/workbooks/utils/workbook_url_params.ts 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..fb3e64449 --- /dev/null +++ b/src/features/workbooks/utils/workbook_url_params.test.ts @@ -0,0 +1,109 @@ +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 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 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..b105fdf02 --- /dev/null +++ b/src/features/workbooks/utils/workbook_url_params.ts @@ -0,0 +1,93 @@ +import { TaskGrade } from '$lib/types/task'; +import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; +import { WorkBookTab, DEFAULT_WORKBOOK_TAB } from '$features/workbooks/types/workbook'; + +const DEFAULT_CURRICULUM_GRADE = TaskGrade.Q10; +const DEFAULT_SOLUTION_CATEGORY = SolutionCategory.SEARCH_SIMULATION; +const VALID_TABS = new Set(Object.values(WorkBookTab)); + +/** + * 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; +} + +/** + * 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 && VALID_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}`; +} From 91fcd115f2271c2d020576905c100989edab9162 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sat, 21 Mar 2026 10:08:37 +0000 Subject: [PATCH 06/47] feat(workbooks/services): Add getPublishedWorkbooksByPlacement, getWorkBooksCreatedByUsers, getAvailableSolutionCategories Co-Authored-By: Claude Sonnet 4.6 --- .../workbooks/services/workbooks.test.ts | 138 ++++++++++++++++++ src/features/workbooks/services/workbooks.ts | 103 ++++++++++++- 2 files changed, 237 insertions(+), 4 deletions(-) diff --git a/src/features/workbooks/services/workbooks.test.ts b/src/features/workbooks/services/workbooks.test.ts index 818a8afff..d45875ea4 100644 --- a/src/features/workbooks/services/workbooks.test.ts +++ b/src/features/workbooks/services/workbooks.test.ts @@ -9,7 +9,12 @@ import { createWorkBook, updateWorkBook, deleteWorkBook, + getPublishedWorkbooksByPlacement, + getWorkBooksCreatedByUsers, + getAvailableSolutionCategories, } from './workbooks'; +import { TaskGrade } from '$lib/types/task'; +import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; vi.mock('$lib/server/database', () => ({ default: { @@ -24,6 +29,9 @@ vi.mock('$lib/server/database', () => ({ workBookTask: { deleteMany: vi.fn(), }, + workBookPlacement: { + findMany: vi.fn(), + }, $transaction: vi.fn(), }, })); @@ -234,3 +242,133 @@ describe('deleteWorkBook', () => { expect(prisma.workBook.delete).not.toHaveBeenCalled(); }); }); + +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' }, +}; + +/** Sets up prisma.workBook.findMany to resolve with the given workbooks. */ +function mockWorkbookFindMany(workbooks: object[]) { + vi.mocked(prisma.workBook.findMany).mockResolvedValue( + workbooks as unknown as Awaited>, + ); +} + +describe('getPublishedWorkbooksByPlacement', () => { + test('filters CURRICULUM workbooks by taskGrade with priority asc order', async () => { + mockWorkbookFindMany([ + { ...MOCK_WORKBOOK_BASE, workBookType: WorkBookType.CURRICULUM }, + ]); + + const result = await getPublishedWorkbooksByPlacement({ + 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' } }, + }), + ); + expect(result[0].authorName).toBe('author1'); + }); + + test('filters SOLUTION workbooks by solutionCategory', async () => { + mockWorkbookFindMany([ + { ...MOCK_WORKBOOK_BASE, workBookType: WorkBookType.SOLUTION }, + ]); + + await getPublishedWorkbooksByPlacement({ + 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 () => { + mockWorkbookFindMany([ + { ...MOCK_WORKBOOK_BASE, workBookType: WorkBookType.CURRICULUM, user: null }, + ]); + + const result = await getPublishedWorkbooksByPlacement({ + 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 () => { + mockWorkbookFindMany([{ ...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' }, + }), + ); + }); + + test('maps null user to authorName "unknown"', async () => { + mockWorkbookFindMany([ + { ...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]); + }); +}); diff --git a/src/features/workbooks/services/workbooks.ts b/src/features/workbooks/services/workbooks.ts index 8f1ab7ce9..02716d1e6 100644 --- a/src/features/workbooks/services/workbooks.ts +++ b/src/features/workbooks/services/workbooks.ts @@ -7,6 +7,9 @@ import type { WorkBookTasksBase, WorkBookType, } from '$features/workbooks/types/workbook'; +import { WorkBookType as WorkBookTypeConst } from '$features/workbooks/types/workbook'; +import { TaskGrade } from '$lib/types/task'; +import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; import { getWorkBookTasks, @@ -51,10 +54,91 @@ export async function getWorkBooksWithAuthors(): Promise { }, }); - return workbooks.map((workbook) => ({ - ...workbook, - authorName: workbook.user?.username ?? 'unknown', - })); + return mapWithAuthorName(workbooks); +} + +/** + * 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 }; + +/** + * Returns published 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 + */ +export async function getPublishedWorkbooksByPlacement( + query: PlacementQuery, +): Promise { + const placementFilter = + query.workBookType === WorkBookTypeConst.CURRICULUM + ? { taskGrade: query.taskGrade } + : { solutionCategory: query.solutionCategory }; + + const workbooks = await db.workBook.findMany({ + where: { + workBookType: query.workBookType, + 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 published + * SOLUTION workbook with a placement record. + */ +export async function getAvailableSolutionCategories(): Promise { + const placements = await db.workBookPlacement.findMany({ + where: { + workBook: { 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 +295,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', + })); +} From 2453bea90db519ec3b863425993ec0008cb2ca8a Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sat, 21 Mar 2026 10:09:43 +0000 Subject: [PATCH 07/47] feat(workbooks/server): Load workbooks from URL params; add CREATED_BY_USER admin guard Co-Authored-By: Claude Sonnet 4.6 --- src/routes/workbooks/+page.server.ts | 73 ++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 9 deletions(-) diff --git a/src/routes/workbooks/+page.server.ts b/src/routes/workbooks/+page.server.ts index 28681547f..18cd757db 100644 --- a/src/routes/workbooks/+page.server.ts +++ b/src/routes/workbooks/+page.server.ts @@ -1,37 +1,68 @@ -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 { getLoggedInUser, canDelete } from '$lib/utils/authorship'; +import { Roles } from '$lib/types/user'; +import { + WorkBookTab, + type WorkBookTab as WorkBookTabType, +} from '$features/workbooks/types/workbook'; +import { + type PlacementQuery, + getPublishedWorkbooksByPlacement, + getWorkBooksCreatedByUsers, + getAvailableSolutionCategories, +} from '$features/workbooks/services/workbooks'; +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 { WorkBookType } from '$features/workbooks/types/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 && !isAdmin(loggedInUser?.role as Roles)) { + redirect(FOUND, '/workbooks'); + } + + const selectedGrade = parseWorkBookGrade(params); + const selectedCategory = parseWorkBookCategory(params); 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), + getAvailableSolutionCategories(), taskCrud.getTasksByTaskId(), - // Used to display the user's answer status taskResultsCrud.getTaskResultsOnlyResultExists(loggedInUser?.id as string, true), ]); return { workbooks, + availableCategories, tasksMapByIds, taskResultsByTaskId, loggedInUser, + tab, + selectedGrade, + selectedCategory, }; } catch (e) { console.error('Failed to fetch workbooks, tasks or task results: ', e); @@ -75,3 +106,27 @@ export const actions = { } }, }; + +function fetchWorkbooksByTab( + tab: WorkBookTabType, + grade: ReturnType, + category: ReturnType, +) { + if (tab === WorkBookTab.CREATED_BY_USER) { + return getWorkBooksCreatedByUsers(); + } + + return getPublishedWorkbooksByPlacement(buildPlacementQuery(tab, grade, category)); +} + +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 }; +} From a100fe6c620c427bcf9fb4821c3f19a0d773b522 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sat, 21 Mar 2026 10:11:14 +0000 Subject: [PATCH 08/47] feat(workbooks/components): Add SolutionWorkBookList with category ButtonGroup; SolutionTable uses SolutionTableProps Co-Authored-By: Claude Sonnet 4.6 --- .../components/list/SolutionTable.svelte | 10 +-- .../list/SolutionWorkBookList.svelte | 62 +++++++++++++++++++ src/features/workbooks/types/workbook.ts | 3 + 3 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 src/features/workbooks/components/list/SolutionWorkBookList.svelte diff --git a/src/features/workbooks/components/list/SolutionTable.svelte b/src/features/workbooks/components/list/SolutionTable.svelte index 2772b323e..50bce8087 100644 --- a/src/features/workbooks/components/list/SolutionTable.svelte +++ b/src/features/workbooks/components/list/SolutionTable.svelte @@ -1,7 +1,7 @@
diff --git a/src/features/workbooks/components/list/SolutionWorkBookList.svelte b/src/features/workbooks/components/list/SolutionWorkBookList.svelte new file mode 100644 index 000000000..1cf580ef5 --- /dev/null +++ b/src/features/workbooks/components/list/SolutionWorkBookList.svelte @@ -0,0 +1,62 @@ + + +
+ + {#each AVAILABLE_CATEGORIES as category} + + {/each} + +
+ +{#if readableCount} + +{:else} + +{/if} diff --git a/src/features/workbooks/types/workbook.ts b/src/features/workbooks/types/workbook.ts index fd617afba..11aea21ce 100644 --- a/src/features/workbooks/types/workbook.ts +++ b/src/features/workbooks/types/workbook.ts @@ -61,6 +61,9 @@ export type WorkbookTableProps = { taskResults: Map; }; +// Imported by SolutionTable — excludes workbookGradeModes which is unused in the solution tab. +export type SolutionTableProps = Omit; + export type WorkBookTaskBase = { taskId: string; priority: number; From 374d7f4ef905f80c70b69c6728e508f1ecc242e7 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sat, 21 Mar 2026 11:08:28 +0000 Subject: [PATCH 09/47] refactor(workbooks/components): CurriculumWorkBookList uses grade prop+callback, removes store Co-Authored-By: Claude Sonnet 4.6 --- .../list/CurriculumWorkBookList.svelte | 58 +++++++------------ 1 file changed, 21 insertions(+), 37 deletions(-) diff --git a/src/features/workbooks/components/list/CurriculumWorkBookList.svelte b/src/features/workbooks/components/list/CurriculumWorkBookList.svelte index b1899881b..833aa0eca 100644 --- a/src/features/workbooks/components/list/CurriculumWorkBookList.svelte +++ b/src/features/workbooks/components/list/CurriculumWorkBookList.svelte @@ -1,21 +1,17 @@ - activeWorkbookTabStore.setActiveWorkbookTab(workbookType)} -> + {@render children?.()} From e635c28b2c105c990591b60ff3406429e544a5f9 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sat, 21 Mar 2026 11:11:55 +0000 Subject: [PATCH 11/47] feat(workbooks): URL-driven tab/filter navigation; WorkBookList discriminated union Props; rename gradeModesEachWorkbook Co-Authored-By: Claude Sonnet 4.6 --- .../components/list/CreatedByUserTable.svelte | 2 +- .../components/list/CurriculumTable.svelte | 4 +- .../list/CurriculumWorkBookList.svelte | 8 +- .../components/list/WorkBookList.svelte | 97 +++++++------- src/features/workbooks/types/workbook.ts | 6 +- src/routes/workbooks/+page.svelte | 121 +++++++++++------- 6 files changed, 132 insertions(+), 106 deletions(-) diff --git a/src/features/workbooks/components/list/CreatedByUserTable.svelte b/src/features/workbooks/components/list/CreatedByUserTable.svelte index d482ca7c0..760645318 100644 --- a/src/features/workbooks/components/list/CreatedByUserTable.svelte +++ b/src/features/workbooks/components/list/CreatedByUserTable.svelte @@ -21,7 +21,7 @@ let { workbooks, - workbookGradeModes: _, + gradeModesEachWorkbook: _, userId, role, taskResults, diff --git a/src/features/workbooks/components/list/CurriculumTable.svelte b/src/features/workbooks/components/list/CurriculumTable.svelte index 89f8bdaf2..8a1c59df7 100644 --- a/src/features/workbooks/components/list/CurriculumTable.svelte +++ b/src/features/workbooks/components/list/CurriculumTable.svelte @@ -13,7 +13,7 @@ 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 +33,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 833aa0eca..249d57ebb 100644 --- a/src/features/workbooks/components/list/CurriculumWorkBookList.svelte +++ b/src/features/workbooks/components/list/CurriculumWorkBookList.svelte @@ -20,7 +20,7 @@ interface Props { workbooks: WorkbooksList; - workbookGradeModes: Map; + gradeModesEachWorkbook: Map; taskResultsWithWorkBookId: Map; userId: string; role: Roles; @@ -30,7 +30,7 @@ let { workbooks, - workbookGradeModes, + gradeModesEachWorkbook, taskResultsWithWorkBookId, userId, role, @@ -106,7 +106,7 @@ - import type { Component } from 'svelte'; - import type { Roles } from '$lib/types/user'; import { TaskGrade, type TaskResults } from '$lib/types/task'; import { WorkBookType, type WorkbooksList, - type WorkbookTableProps, } from '$features/workbooks/types/workbook'; - - import { countReadableWorkbooks } from '$features/workbooks/utils/workbooks'; + import { type SolutionCategory } from '$features/workbooks/types/workbook_placement'; import CurriculumWorkBookList from '$features/workbooks/components/list/CurriculumWorkBookList.svelte'; - import SolutionTable from '$features/workbooks/components/list/SolutionTable.svelte'; + import SolutionWorkBookList from '$features/workbooks/components/list/SolutionWorkBookList.svelte'; import CreatedByUserTable from '$features/workbooks/components/list/CreatedByUserTable.svelte'; - import EmptyWorkbookList from '$features/workbooks/components/list/EmptyWorkbookList.svelte'; - - interface LoggedInUser { - id: string; - role: Roles; - } - interface Props { - workbookType: WorkBookType; + type CommonProps = { workbooks: WorkbooksList; - workbookGradeModes: Map; taskResultsWithWorkBookId: Map; - loggedInUser: LoggedInUser; - } + loggedInUser: { id: string; role: Roles } | null; + }; - let { - workbookType, - workbooks, - workbookGradeModes, - taskResultsWithWorkBookId, - loggedInUser, - }: Props = $props(); + type SpecificProps = + | { + workbookType: typeof WorkBookType.CURRICULUM; + gradeModesEachWorkbook: Map; + currentGrade: TaskGrade; + onGradeChange: (grade: TaskGrade) => void; + } + | { + workbookType: typeof WorkBookType.SOLUTION; + currentCategory: SolutionCategory; + availableCategories: SolutionCategory[]; + onCategoryChange: (category: SolutionCategory) => void; + } + | { workbookType: typeof WorkBookType.CREATED_BY_USER }; - let userId = loggedInUser.id; - let role: Roles = loggedInUser.role; + type Props = CommonProps & SpecificProps; - const tableComponents: Partial>> = { - [WorkBookType.SOLUTION]: SolutionTable, - [WorkBookType.CREATED_BY_USER]: CreatedByUserTable, - }; - - let readableCount = $derived(countReadableWorkbooks(workbooks, userId)); + let props: Props = $props(); -{#if workbookType === WorkBookType.CURRICULUM} +{#if props.workbookType === WorkBookType.CURRICULUM} +{:else if props.workbookType === WorkBookType.SOLUTION} + {:else} - {@const TableComponent = tableComponents[workbookType]} - - {#if readableCount && TableComponent} - - {:else} - - {/if} + {/if} diff --git a/src/features/workbooks/types/workbook.ts b/src/features/workbooks/types/workbook.ts index 11aea21ce..ffaaf66c9 100644 --- a/src/features/workbooks/types/workbook.ts +++ b/src/features/workbooks/types/workbook.ts @@ -55,14 +55,14 @@ 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 — excludes workbookGradeModes which is unused in the solution tab. -export type SolutionTableProps = Omit; +// Imported by SolutionTable — excludes gradeModesEachWorkbook which is unused in the solution tab. +export type SolutionTableProps = Omit; export type WorkBookTaskBase = { taskId: string; diff --git a/src/routes/workbooks/+page.svelte b/src/routes/workbooks/+page.svelte index 5a7e20d2d..755b31eb6 100644 --- a/src/routes/workbooks/+page.svelte +++ b/src/routes/workbooks/+page.svelte @@ -1,60 +1,55 @@
@@ -74,21 +69,57 @@ 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}
From 5b0667c4159af2147bf57140d5f2101914959d7e Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sat, 21 Mar 2026 11:13:14 +0000 Subject: [PATCH 12/47] chore(workbooks): Remove stores replaced by URL params and local state Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 5 + .vscode/settings.json | 3 + .../workbooks-list-url-params/phase-0.md | 12 +- .../workbooks-list-url-params/phase-1.md | 10 +- .../workbooks-list-url-params/phase-2.md | 10 +- .../workbooks-list-url-params/phase-3.md | 12 +- .../workbooks-list-url-params/phase-4.md | 10 +- .../workbooks-list-url-params/phase-5.md | 18 +- .../workbooks-list-url-params/phase-6.md | 14 +- .../workbooks-list-url-params/phase-7.md | 6 +- .../workbooks-list-url-params/phase-8.md | 26 +-- .../workbooks-list-url-params/plan.md | 1 + .../components/list/CurriculumTable.svelte | 3 +- .../components/list/WorkBookList.svelte | 5 +- .../workbooks/services/workbooks.test.ts | 8 +- .../stores/active_workbook_tab.test.ts | 174 ------------------ .../workbooks/stores/active_workbook_tab.ts | 29 --- .../task_grades_by_workbook_type.test.ts | 128 ------------- .../stores/task_grades_by_workbook_type.ts | 25 --- src/routes/workbooks/+page.svelte | 10 +- 20 files changed, 81 insertions(+), 428 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .vscode/settings.json delete mode 100644 src/features/workbooks/stores/active_workbook_tab.test.ts delete mode 100644 src/features/workbooks/stores/active_workbook_tab.ts delete mode 100644 src/features/workbooks/stores/task_grades_by_workbook_type.test.ts delete mode 100644 src/features/workbooks/stores/task_grades_by_workbook_type.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..01940c32b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,5 @@ +{ + "permissions": { + "allow": ["Bash(pnpm format:*)", "Bash(git mv:*)", "WebFetch(domain:github.com)"] + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..97f0e81df --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "prisma.pinToPrisma6": true +} diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-0.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-0.md index 9253c1be7..043b37ccb 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-0.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-0.md @@ -12,7 +12,7 @@ order ページで定義されている `ActiveTab = 'solution' | 'curriculum'` - Modify: `src/features/workbooks/types/workbook.ts` -- [ ] **Step 1: ファイル末尾に追加** +- [x] **Step 1: ファイル末尾に追加** ```typescript /** /workbooks ページの URL パラメータ `?tab=` に対応する有効値 */ @@ -28,13 +28,13 @@ export type WorkBookTab = (typeof WorkBookTab)[keyof typeof WorkBookTab]; export const DEFAULT_WORKBOOK_TAB: WorkBookTab = WorkBookTab.CURRICULUM; ``` -- [ ] **Step 2: 型チェック** +- [x] **Step 2: 型チェック** ```bash pnpm check ``` -- [ ] **Step 3: コミット** +- [x] **Step 3: コミット** ```bash git add src/features/workbooks/types/workbook.ts @@ -49,7 +49,7 @@ git commit -m "feat(workbooks/types): Add WorkBookTab const object with CURRICUL - Modify: `src/routes/(admin)/workbooks/order/_types/kanban.ts` -- [ ] **Step 1: `ActiveTab` の定義を再エクスポートに置き換え** +- [x] **Step 1: `ActiveTab` の定義を再エクスポートに置き換え** ```typescript // 変更前 @@ -61,13 +61,13 @@ export type { WorkBookTab as ActiveTab } from '$features/workbooks/types/workboo > **注意:** order ページは `CREATED_BY_USER` を使わないため、型の値が増えても既存ロジックには影響しない。 -- [ ] **Step 2: 型チェック(order ページの既存コードが壊れていないことを確認)** +- [x] **Step 2: 型チェック(order ページの既存コードが壊れていないことを確認)** ```bash pnpm check ``` -- [ ] **Step 3: コミット** +- [x] **Step 3: コミット** ```bash git add src/routes/(admin)/workbooks/order/_types/kanban.ts diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-1.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-1.md index e8a8155bc..572bf8359 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-1.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-1.md @@ -14,7 +14,7 @@ - Modify: `src/features/workbooks/utils/workbooks.test.ts` -- [ ] **Step 1: テストを追記** +- [x] **Step 1: テストを追記** ```typescript import { partitionWorkbooksAsMainAndReplenished } from './workbooks'; @@ -59,7 +59,7 @@ describe('partitionWorkbooksAsMainAndReplenished', () => { }); ``` -- [ ] **Step 2: テストが失敗することを確認** +- [x] **Step 2: テストが失敗することを確認** ```bash pnpm test:unit -- workbooks.test @@ -74,7 +74,7 @@ pnpm test:unit -- workbooks.test - Modify: `src/features/workbooks/utils/workbooks.ts` -- [ ] **Step 1: 関数を追加(既存エクスポートの末尾)** +- [x] **Step 1: 関数を追加(既存エクスポートの末尾)** ```typescript /** @@ -94,14 +94,14 @@ export function partitionWorkbooksAsMainAndReplenished(workbooks: WorkbooksList) } ``` -- [ ] **Step 2: テストが通ることを確認** +- [x] **Step 2: テストが通ることを確認** ```bash pnpm test:unit -- workbooks.test # PASS ``` -- [ ] **Step 3: コミット** +- [x] **Step 3: コミット** ```bash git add src/features/workbooks/utils/workbooks.ts \ diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-2.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-2.md index cc80bbb2a..dad2b1b9f 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-2.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-2.md @@ -18,7 +18,7 @@ - Create: `src/features/workbooks/utils/workbook_url_params.test.ts` -- [ ] **Step 1: テストファイルを作成** +- [x] **Step 1: テストファイルを作成** ```typescript import { describe, test, expect } from 'vitest'; @@ -132,7 +132,7 @@ describe('buildWorkbooksUrl', () => { }); ``` -- [ ] **Step 2: テストが失敗することを確認** +- [x] **Step 2: テストが失敗することを確認** ```bash pnpm test:unit -- workbook_url_params @@ -147,7 +147,7 @@ pnpm test:unit -- workbook_url_params - Create: `src/features/workbooks/utils/workbook_url_params.ts` -- [ ] **Step 1: ファイルを作成** +- [x] **Step 1: ファイルを作成** ```typescript import { TaskGrade } from '$lib/types/task'; @@ -239,14 +239,14 @@ export function buildWorkbooksUrl( } ``` -- [ ] **Step 2: テストが通ることを確認** +- [x] **Step 2: テストが通ることを確認** ```bash pnpm test:unit -- workbook_url_params # PASS: 17 tests ``` -- [ ] **Step 3: コミット** +- [x] **Step 3: コミット** ```bash git add src/features/workbooks/utils/workbook_url_params.ts \ diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-3.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-3.md index 27f5e1903..453bbec89 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-3.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-3.md @@ -16,7 +16,7 @@ - Modify: `src/features/workbooks/services/workbooks.test.ts` -- [ ] **Step 1: インポートと `describe` ブロックを追記** +- [x] **Step 1: インポートと `describe` ブロックを追記** 既存の `vi.mock('$lib/server/database', ...)` とモック変数(`prisma`)を再利用する。 @@ -172,7 +172,7 @@ describe('getAvailableSolutionCategories', () => { }); ``` -- [ ] **Step 2: テストが失敗することを確認** +- [x] **Step 2: テストが失敗することを確認** ```bash pnpm test:unit -- workbooks.test @@ -187,14 +187,14 @@ pnpm test:unit -- workbooks.test - Modify: `src/features/workbooks/services/workbooks.ts` -- [ ] **Step 1: インポートを追加** +- [x] **Step 1: インポートを追加** ```typescript import { TaskGrade } from '$lib/types/task'; import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; ``` -- [ ] **Step 2: `getWorkBooksWithAuthors()` の直後に型・プライベートヘルパー・関数を追加** +- [x] **Step 2: `getWorkBooksWithAuthors()` の直後に型・プライベートヘルパー・関数を追加** ```typescript /** @@ -293,14 +293,14 @@ function mapWithAuthorName( } ``` -- [ ] **Step 3: テストが通ることを確認** +- [x] **Step 3: テストが通ることを確認** ```bash pnpm test:unit -- workbooks.test # PASS ``` -- [ ] **Step 4: コミット** +- [x] **Step 4: コミット** ```bash git add src/features/workbooks/services/workbooks.ts \ diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-4.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-4.md index 56f12770a..0f81e6ddb 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-4.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-4.md @@ -17,7 +17,7 @@ URLパラメータを解析し、タブに応じてサービス関数を呼び - Modify: `src/routes/workbooks/+page.server.ts` -- [ ] **Step 1: インポートを更新** +- [x] **Step 1: インポートを更新** 追加: @@ -47,7 +47,7 @@ import { FOUND } from '$lib/constants/http-response-status-codes'; 削除: `workBooksCrud.getWorkBooksWithAuthors` の呼び出し(`load()` 内) -- [ ] **Step 2: `load()` を書き換え** +- [x] **Step 2: `load()` を書き換え** ```typescript export async function load({ locals, url }) { @@ -94,7 +94,7 @@ export async function load({ locals, url }) { `actions.delete` は変更しない。 -- [ ] **Step 3: `fetchWorkbooksByTab()` と `buildPlacementQuery()` を `load()` の後(ファイル末尾)に追加** +- [x] **Step 3: `fetchWorkbooksByTab()` と `buildPlacementQuery()` を `load()` の後(ファイル末尾)に追加** ```typescript function fetchWorkbooksByTab( @@ -122,13 +122,13 @@ function buildPlacementQuery( } ``` -- [ ] **Step 4: 型チェック** +- [x] **Step 4: 型チェック** ```bash pnpm check ``` -- [ ] **Step 5: コミット** +- [x] **Step 5: コミット** ```bash git add src/routes/workbooks/+page.server.ts diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-5.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-5.md index ce7746b21..582d64977 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-5.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-5.md @@ -21,20 +21,20 @@ - Modify: `src/features/workbooks/types/workbook.ts` -- [ ] **Step 1: `WorkbookTableProps` の直後に追加** +- [x] **Step 1: `WorkbookTableProps` の直後に追加** ```typescript // Imported by SolutionTable — excludes workbookGradeModes which is unused in the solution tab. export type SolutionTableProps = Omit; ``` -- [ ] **Step 2: 型チェック** +- [x] **Step 2: 型チェック** ```bash pnpm check ``` -- [ ] **Step 3: コミット** +- [x] **Step 3: コミット** ```bash git add src/features/workbooks/types/workbook.ts @@ -49,7 +49,7 @@ git commit -m "feat(workbooks/types): Add SolutionTableProps excluding workbookG - Modify: `src/features/workbooks/components/list/SolutionTable.svelte` -- [ ] **Step 1: `WorkbookTableProps` → `SolutionTableProps` に変更** +- [x] **Step 1: `WorkbookTableProps` → `SolutionTableProps` に変更** ```svelte ``` -- [ ] **Step 2: テンプレートブロックを書き換え** +- [x] **Step 2: テンプレートブロックを書き換え** ```svelte
@@ -249,14 +249,14 @@ git commit -m "refactor(workbooks/components): WorkBookList uses discriminated u
``` -- [ ] **Step 3: 型チェック** +- [x] **Step 3: 型チェック** ```bash pnpm check # エラーゼロを確認 ``` -- [ ] **Step 4: 開発サーバーで動作確認** +- [x] **Step 4: 開発サーバーで動作確認** ```bash pnpm dev @@ -272,7 +272,7 @@ pnpm dev # - 補充教材トグルが引き続き動作する ``` -- [ ] **Step 5: コミット** +- [x] **Step 5: コミット** ```bash git add src/routes/workbooks/+page.svelte @@ -291,7 +291,7 @@ git commit -m "feat(workbooks): URL-driven tab/filter navigation including CREAT - Modify: `src/features/workbooks/components/list/CurriculumTable.svelte` - Modify: `src/features/workbooks/types/workbook.ts` -- [ ] **Step 1: 一括リネーム** +- [x] **Step 1: 一括リネーム** ```bash # 影響ファイルを確認 @@ -300,13 +300,13 @@ grep -r "workbookGradeModes" src/ `workbookGradeModes` をすべて `gradeModesEachWorkbook` に置換する。 -- [ ] **Step 2: 型チェック** +- [x] **Step 2: 型チェック** ```bash pnpm check ``` -- [ ] **Step 3: コミット** +- [x] **Step 3: コミット** ```bash git add -p diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/plan.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/plan.md index 21f4a8b61..bb9c8993c 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/plan.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/plan.md @@ -163,3 +163,4 @@ | 22 | スコープ | `AVAILABLE_CATEGORIES` のフィルタリングを「スコープ外」と早期に判断した | 実装コストが低く(サービス関数1つの追加)、UX 価値が高い機能を安易にスコープ外にしない。「単に filtering するだけ」レベルの機能は同フェーズに含める | | 23 | 型設計 | 新しい列挙型を string literal union で設計した | `WorkBookType` が const object パターンを使っているにもかかわらず `WorkBookTab` を string literal union で設計した。新しい列挙型を作るときは既存の型定義を先に確認し、プロジェクト内パターンに揃える | | 24 | 実装品質 | 類似した条件ロジックの重複を計画段階で気づかなかった | `parseWorkBookGrade` と `parseWorkBookCategory` で「null チェック + 有効値確認 + PENDING 除外」が重複。計画段階で「同バリデーションロジックが複数現れるか」を確認し、`isValidNonPending()` のような汎用サブ関数を早期に設計する | +| 25 | 型設計 | 型の再エクスポート後、消費側の `Record` が新しい値キーを要求するようになることを見落とした | `export type { WorkBookTab as ActiveTab }` と計画したが、`Record` が `created_by_user` を要求してエラー。型エイリアスを再エクスポートする前に「消費側で `Record` として使われているか」を確認し、そうであれば `Exclude` で絞り込む | diff --git a/src/features/workbooks/components/list/CurriculumTable.svelte b/src/features/workbooks/components/list/CurriculumTable.svelte index 8a1c59df7..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, gradeModesEachWorkbook, userId, role, taskResults }: WorkbookTableProps = $props(); + let { workbooks, gradeModesEachWorkbook, userId, role, taskResults }: WorkbookTableProps = + $props();
diff --git a/src/features/workbooks/components/list/WorkBookList.svelte b/src/features/workbooks/components/list/WorkBookList.svelte index 9ccc49ae0..348eef08e 100644 --- a/src/features/workbooks/components/list/WorkBookList.svelte +++ b/src/features/workbooks/components/list/WorkBookList.svelte @@ -1,10 +1,7 @@
diff --git a/src/features/workbooks/components/list/SolutionWorkBookList.svelte b/src/features/workbooks/components/list/SolutionWorkBookList.svelte index 1cf580ef5..b902c73b0 100644 --- a/src/features/workbooks/components/list/SolutionWorkBookList.svelte +++ b/src/features/workbooks/components/list/SolutionWorkBookList.svelte @@ -33,8 +33,10 @@ // PENDING (unclassified) is admin-only and must not appear on the public page. // Further filtered to availableCategories (server-side: only categories with at least one workbook). - const AVAILABLE_CATEGORIES = Object.values(SolutionCategory).filter( - (category) => category !== SolutionCategory.PENDING && availableCategories.includes(category), + let AVAILABLE_CATEGORIES = $derived( + Object.values(SolutionCategory).filter( + (category) => category !== SolutionCategory.PENDING && availableCategories.includes(category), + ), ); let readableCount = $derived(countReadableWorkbooks(workbooks, userId)); diff --git a/src/features/workbooks/components/list/WorkBookList.svelte b/src/features/workbooks/components/list/WorkBookList.svelte index 348eef08e..10557f9b4 100644 --- a/src/features/workbooks/components/list/WorkBookList.svelte +++ b/src/features/workbooks/components/list/WorkBookList.svelte @@ -1,5 +1,5 @@ -
- - - 作者 - - 回答状況 - - 修了 - - +{#if workbooks.length === 0} + +{:else} +
+
+ + 作者 + + 回答状況 + + 修了 + + - - {#each workbooks as workbook (workbook.id)} - {#if canRead(workbook.isPublished, userId, workbook.authorId)} - - -
- {workbook.authorName} -
-
+ + {#each workbooks as workbook (workbook.id)} + {#if canRead(workbook.isPublished, userId, workbook.authorId)} + + +
+ {workbook.authorName} +
+
- + - + - + - -
- {/if} - {/each} -
-
-
+ + + {/if} + {/each} + + +
+{/if} diff --git a/src/features/workbooks/services/workbooks.test.ts b/src/features/workbooks/services/workbooks.test.ts index 6ba023f7f..81d05a12e 100644 --- a/src/features/workbooks/services/workbooks.test.ts +++ b/src/features/workbooks/services/workbooks.test.ts @@ -9,7 +9,7 @@ import { createWorkBook, updateWorkBook, deleteWorkBook, - getPublishedWorkbooksByPlacement, + getWorkbooksByPlacement, getWorkBooksCreatedByUsers, getAvailableSolutionCategories, } from './workbooks'; @@ -259,11 +259,11 @@ const MOCK_WORKBOOK_BASE = { user: { username: 'author1' }, }; -describe('getPublishedWorkbooksByPlacement', () => { +describe('getWorkbooksByPlacement', () => { test('filters CURRICULUM workbooks by taskGrade with priority asc order', async () => { mockFindMany([{ ...MOCK_WORKBOOK_BASE, workBookType: WorkBookType.CURRICULUM }]); - const result = await getPublishedWorkbooksByPlacement({ + const result = await getWorkbooksByPlacement({ workBookType: WorkBookType.CURRICULUM, taskGrade: TaskGrade.Q10, }); @@ -281,10 +281,37 @@ describe('getPublishedWorkbooksByPlacement', () => { 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 getPublishedWorkbooksByPlacement({ + await getWorkbooksByPlacement({ workBookType: WorkBookType.SOLUTION, solutionCategory: SolutionCategory.GRAPH, }); @@ -302,7 +329,7 @@ describe('getPublishedWorkbooksByPlacement', () => { test('maps null user to authorName "unknown"', async () => { mockFindMany([{ ...MOCK_WORKBOOK_BASE, workBookType: WorkBookType.CURRICULUM, user: null }]); - const result = await getPublishedWorkbooksByPlacement({ + const result = await getWorkbooksByPlacement({ workBookType: WorkBookType.CURRICULUM, taskGrade: TaskGrade.Q10, }); diff --git a/src/features/workbooks/services/workbooks.ts b/src/features/workbooks/services/workbooks.ts index 02716d1e6..bc7200137 100644 --- a/src/features/workbooks/services/workbooks.ts +++ b/src/features/workbooks/services/workbooks.ts @@ -66,13 +66,15 @@ export type PlacementQuery = | { workBookType: typeof WorkBookTypeConst.SOLUTION; solutionCategory: SolutionCategory }; /** - * Returns published workbooks filtered by WorkBookPlacement, ordered by priority ASC. + * 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) */ -export async function getPublishedWorkbooksByPlacement( +export async function getWorkbooksByPlacement( query: PlacementQuery, + includeUnpublished = false, ): Promise { const placementFilter = query.workBookType === WorkBookTypeConst.CURRICULUM @@ -82,7 +84,7 @@ export async function getPublishedWorkbooksByPlacement( const workbooks = await db.workBook.findMany({ where: { workBookType: query.workBookType, - isPublished: true, + ...(includeUnpublished ? {} : { isPublished: true }), placement: placementFilter, }, orderBy: { diff --git a/src/routes/workbooks/+page.server.ts b/src/routes/workbooks/+page.server.ts index ec4de92f7..fc8b6ace0 100644 --- a/src/routes/workbooks/+page.server.ts +++ b/src/routes/workbooks/+page.server.ts @@ -11,7 +11,7 @@ import { } from '$features/workbooks/types/workbook'; import { type PlacementQuery, - getPublishedWorkbooksByPlacement, + getWorkbooksByPlacement, getWorkBooksCreatedByUsers, getAvailableSolutionCategories, } from '$features/workbooks/services/workbooks'; @@ -48,10 +48,11 @@ export async function load({ locals, url }) { const selectedGrade = parseWorkBookGrade(params); const selectedCategory = parseWorkBookCategory(params); + const adminUser = loggedInUser && isAdmin(loggedInUser.role as Roles); try { const [workbooks, availableCategories, tasksMapByIds, taskResultsByTaskId] = await Promise.all([ - fetchWorkbooksByTab(tab, selectedGrade, selectedCategory), + fetchWorkbooksByTab(tab, selectedGrade, selectedCategory, !!adminUser), getAvailableSolutionCategories(), taskCrud.getTasksByTaskId(), loggedInUser @@ -116,12 +117,13 @@ function fetchWorkbooksByTab( tab: WorkBookTabType, grade: ReturnType, category: ReturnType, + includeUnpublished: boolean, ) { if (tab === WorkBookTab.CREATED_BY_USER) { return getWorkBooksCreatedByUsers(); } - return getPublishedWorkbooksByPlacement(buildPlacementQuery(tab, grade, category)); + return getWorkbooksByPlacement(buildPlacementQuery(tab, grade, category), includeUnpublished); } function buildPlacementQuery( diff --git a/src/routes/workbooks/+page.svelte b/src/routes/workbooks/+page.svelte index 009714659..f164674a5 100644 --- a/src/routes/workbooks/+page.svelte +++ b/src/routes/workbooks/+page.svelte @@ -1,4 +1,5 @@ -
- - {#each AVAILABLE_CATEGORIES as category} - - {/each} - +
+ {#each AVAILABLE_CATEGORIES as category} + + {/each}
{#if readableCount} From 052cda5b8ed0485885b9a2cbe1a6efef5bbd4009 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 05:41:42 +0000 Subject: [PATCH 25/47] docs: Add workbooks-list-url-params refactor notes Co-Authored-By: Claude Sonnet 4.6 --- .../workbooks-list-url-params/refactor.md | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/refactor.md diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/refactor.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/refactor.md new file mode 100644 index 000000000..c451b26ce --- /dev/null +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/refactor.md @@ -0,0 +1,210 @@ +# Workbooks List リファクタリング計画 + +## 概要 + +Phase 12 完了後のコードレビュー指摘事項を批判的に検討し、採用・却下を決定する。 +計画: 日本語 / 実装コメント: 英語 + +--- + +## 批判的レビュー結果 + +### ✅ 採用 + +| # | ファイル | 指摘内容 | 判断理由 | +| --- | ----------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `workbook_url_params.test.ts` | grades=Q1, D1, D6 ケース追加; categories 2-3種類追加 | カバレッジ不足。Q1・D1・D6 はグレード境界値で未テスト。categories は SEARCH_SIMULATION / GRAPH の 2種類しかなく主要カテゴリが欠けている | +| 2 | `workbook_url_params.ts` | `isValidNonPending` をエクスポート関数の後へ移動 | coding-style.md §Function Ordering: 内部ヘルパーは公開関数の後に配置する規約に違反している | +| 3 | `workbook_url_params.ts` | `VALID_TABS` → `EXISTING_TABS` | set の内容(enum に存在するタブ値)を正確に表す名前。`VALID_TABS` は用途寄りの命名だが `EXISTING_TABS` の方が内容を忠実に表す | +| 4 | `workbook_placement.ts` | `SolutionCategories = SolutionCategory[]` を追加 | coding-style.md §Plural type aliases 規約: 配列型は複数形エイリアスで定義する | +| 5 | 各ファイル | `SolutionCategory[]` → `SolutionCategories` | #4 の適用。`SolutionWorkBookList.svelte` Props、`WorkBookList.svelte` Props、`getAvailableSolutionCategories()` return type が対象 | +| 6 | `utils/workbooks.ts` | `partitionWorkbooksAsMainAndReplenished` の `acc` → `partition` | coding-style.md §Naming:「省略形は厳禁」に直接違反 | +| 7 | `utils/workbooks.ts` | 関数を概念的グループ順に並び替え | `partitionWorkbooksAsMainAndReplenished` が末尾に追加されており、関連する workbook filtering 関数群と離れている | +| 8 | `utils/workbooks.test.ts` | テスト順を source と揃える | #7 と連動。現状 `partitionWorkbooks...` が `getTaskResult` の後にあるが source では逆順 | +| 9 | `services/workbooks.ts` | `PlacementQuery` 型を `workbook_placement.ts` に移動 | 型は `types/` 層に属する(coding-style.md §Pre-Implementation Layer Check)。placement 概念の型定義はすでに `workbook_placement.ts` に集約されている | +| 10 | `services/workbooks.ts` | `@param includeUnpublished` にデフォルト `false` を明記 | TSDoc は挙動を完全に記述すべき。デフォルト値は呼び出し側にとって重要な情報 | +| 11 | `services/workbooks.test.ts` | テスト順を source と揃える | source の関数順と test の describe 順が大きく乖離している(例: `getWorkBook` が先頭だが source では 6番目) | +| 12 | `WorkBookList.svelte` | `let props = $props()` → rest spread で CommonProps を展開 | `props.hoge` より Svelte shorthand `{hoge}` が書けるようになり可読性向上。TypeScript の discriminated union narrowing は rest spread(`...restProps`)で維持可能(TypeScript は `Omit` を discriminated union として正しく推論する) | +| 13 | `+page.svelte` | `handleTabChange` の if-else → `Record string>` | 可読性改善。`undefined` の扱いの非対称(CURRICULUM は末尾省略、SOLUTION は `undefined` 明示)も Record 化で解消される。plan.md 方針#5(「タブ分岐は if/else を維持」)は誤判断だったため今回修正する | +| 14 | `+page.svelte` | `onMount` 内: if ブロックと `const saved` の間に空行を追加 | if ブロックと const 宣言が密着しており視認性が低い | +| 15 | E2E | `logged-in user (general)` と `admin user` の describe 階層を整理 | タブ可視性 / URL アクセス有効性 / ナビゲーション操作 / セッション状態 に分けることで意図が明確になる | + +### ❌ 却下 + +| # | ファイル | 指摘内容 | 却下理由 | +| --- | --------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R1 | `services/workbooks.ts` | `getWorkBooks()` 削除 | `src/routes/sitemap.xml/+server.ts:47` で使用中。削除不可 | +| R2 | `+page.svelte` | `WORKBOOKS_URL_KEY` を最初に使う部分の直前に移動 | coding-style.md §No Hard-Coded Values:「定数はファイル先頭、または共有時は constants/ モジュールに配置」に反する。ファイル先頭の現在位置が正しい | +| R3 | `+page.svelte` | `` 以降の各タブを `{#snippet}` で切り出す | YAGNI。各タブはすでに `WorkBookList` コンポーネントに委譲済み。snippet 化は indirection を追加するだけで可読性向上は限定的 | +| R4 | E2E | for 文 → parameterize/test.each 化 | Playwright にネイティブの `test.each` は存在しない。`for...of` ループが公式推奨の parameterized test パターン(plan.md 教訓#12 参照)。変更する理由がない | +| R5 | E2E | `SOLUTION_CATEGORY_CASES` の for 文も同様 | R4 と同じ理由 | +| R6 | E2E | 定数を `$features/` からインポート | `e2e/` ディレクトリは SvelteKit のパスエイリアスを解決しない(plan.md 教訓#26 の既存判断)。ローカル定数+参照コメントが正解 | +| R7 | `CurriculumWorkBookList.svelte` / `SolutionWorkBookList.svelte` | グレード/カテゴリボタン群をコンポーネント化 | YAGNI。細部の差異(`size="sm"` の有無 / ラベル取得関数 / active 判定ロジック)があり、抽象化コストが 2 箇所のメリットを上回る | +| R8 | `+page.server.ts` | `fetchWorkbooksByTab` を `async` に変更 | 変更不要。関数は `getWorkBooksCreatedByUsers()` / `getWorkbooksByPlacement()` の Promise をそのまま返す。`async` は `await` を使う場合のみ必要。`Promise.all` 側で await される設計は意図通り | +| R9 | `+page.svelte` | if/for 前後の空行を linter で強制 | ESLint `padding-line-between-statements` はプロジェクト全体に影響する設定変更でスコープ外。今回は `onMount` 内の特定箇所のみ手動修正(採用 #14)にとどめる | + +--- + +## 補足: SvelteKit `goto()` について + +`$app/navigation` の `goto()` は一般的なプログラミング言語の `goto` 文とは別物。 +Vue Router の `router.push()` / React Router の `navigate()` に相当するクライアントサイドナビゲーション関数。URL を更新し `load` 関数を再実行する。`window.location` の変化(ブラウザリロード)は発生しないが、`+layout.svelte` が `{#if $navigating}` でスピナー表示するため UX 的にはリロード類似に見える(plan.md 教訓#27 参照)。 + +--- + +## 実装フェーズ + +### Phase 1 — 型定義(最低リスク) + +**依存なし。他フェーズより先に実施。** + +- [ ] `src/features/workbooks/types/workbook_placement.ts` に `export type SolutionCategories = SolutionCategory[]` を追加 +- [ ] `src/features/workbooks/services/workbooks.ts` の `PlacementQuery` 型を `workbook_placement.ts` に移動 +- [ ] 消費側 (`src/routes/workbooks/+page.server.ts`, `services/workbooks.ts`) のインポートを `workbook_placement.ts` からに更新 + +### Phase 2 — ユーティリティ整形(低リスク) + +- [ ] `workbook_url_params.ts`: `isValidNonPending` 関数をファイル末尾(`buildWorkbooksUrl` の後)へ移動 +- [ ] `workbook_url_params.ts`: `VALID_TABS` → `EXISTING_TABS` にリネーム(参照箇所は `parseWorkBookTab` 内のみ) +- [ ] `services/workbooks.ts`: `getWorkbooksByPlacement` の `@param includeUnpublished` JSDoc に `Defaults to false.` を追記 +- [ ] `utils/workbooks.ts`: `partitionWorkbooksAsMainAndReplenished` の reduce コールバック引数 `acc` → `partition` にリネーム +- [ ] `+page.svelte`: `onMount` 内の `if (window.location.search)` ブロックと `const saved = ...` の間、および `const saved` と `if (saved)` の間に空行を追加 + +### Phase 3 — 関数順序の統一(低リスク) + +`utils/workbooks.ts` 内の関数を概念グループ順に並び替える。問題集レベルのグレード概念(上位)が、問題と回答の組み合わせ(下位)より先に来るように整理する: + +``` +1. canViewWorkBook — auth/access (workbook visibility) +2. getUrlSlugFrom — URL identifier +3. getWorkBooksByType — workbook filter by type +4. partitionWorkbooksAsMainAndReplenished — workbook partition (同グループ) +5. countReadableWorkbooks — workbook count +6. calcWorkBookGradeModes — grade computation (workbook-level concept, higher) +7. getGradeMode — grade getter (同グループ) +8. buildTaskResultsByWorkBookId — task result map building (task-level concept, lower) +9. getTaskResult — task result getter (同グループ) +``` + +- [ ] `utils/workbooks.ts`: 上記順序に並び替え(主な変更: grade 関連を 6-7 に、task result 関連を 8-9 に移動) +- [ ] `utils/workbooks.test.ts`: describe 順を上記 source 順に揃える + +`services/workbooks.test.ts` を source 関数順に揃える(現状の test 先頭は `getWorkBook` だが source では 6番目): + +``` +1. getWorkBooksWithAuthors +2. getWorkbooksByPlacement +3. getWorkBooksCreatedByUsers +4. getAvailableSolutionCategories +5. getWorkBook +6. getWorkbookWithAuthor +7. createWorkBook +8. updateWorkBook +9. deleteWorkBook +``` + +- [ ] `services/workbooks.test.ts`: 上記順序に describe ブロックを並び替え + +### Phase 4 — テスト補強(低リスク) + +- [ ] `workbook_url_params.test.ts` `parseWorkBookGrade`: `TaskGrade.Q1`(最難関 Q 帯境界値)、`TaskGrade.D1`(D 帯最難)、`TaskGrade.D6`(D 帯最易)の 3 ケースを追加(いずれも有効値 → その値を返す) +- [ ] `workbook_url_params.test.ts` `parseWorkBookCategory`: `SolutionCategory.DYNAMIC_PROGRAMMING`、`SolutionCategory.DATA_STRUCTURE` の 2 ケースを追加 + +### Phase 5 — 複数形型エイリアス適用(中リスク) + +Phase 1 で追加した `SolutionCategories` をコードベースに適用: + +- [ ] `SolutionWorkBookList.svelte`: Props の `availableCategories: SolutionCategory[]` → `SolutionCategories`(インポートも更新) +- [ ] `WorkBookList.svelte`: Props の `availableCategories: SolutionCategory[]` → `SolutionCategories`(インポートも更新) +- [ ] `services/workbooks.ts`: `getAvailableSolutionCategories()` の return type を `Promise` に変更 + +### Phase 6 — コンポーネント改善(中リスク) + +- [ ] `WorkBookList.svelte`: `let props: Props = $props()` を rest spread 形式に変更し、CommonProps を shorthand で記述できるようにする + + ```typescript + // Before + let props: Props = $props(); + + // After: CommonProps fields are destructured; restProps retains the discriminated union type + let { workbooks, taskResultsWithWorkBookId, loggedInUser, ...restProps }: Props = $props(); + ``` + + テンプレート内: `props.workbooks` → `{workbooks}`、`props.workbookType` → `restProps.workbookType` で discriminated union narrowing を維持 + +- [ ] `+page.svelte`: `handleTabChange` の if-else チェーンを `Record string>` ルックアップに変更 + + ```typescript + // Each lambda closes over the reactive `data` binding at call time + const TAB_URL_BUILDERS: Record string> = { + [WorkBookTab.CURRICULUM]: () => buildWorkbooksUrl(WorkBookTab.CURRICULUM, data.selectedGrade), + [WorkBookTab.SOLUTION]: () => + buildWorkbooksUrl(WorkBookTab.SOLUTION, undefined, data.selectedCategory), + [WorkBookTab.CREATED_BY_USER]: () => buildWorkbooksUrl(WorkBookTab.CREATED_BY_USER), + }; + + function handleTabChange(tab: WorkBookTab) { + goto(TAB_URL_BUILDERS[tab]()); + } + ``` + +### Phase 7 — E2E テスト階層整理(低リスク) + +`logged-in user (general)` を以下の 4 グループに分割: + +``` +test.describe('logged-in user (general)', () => { + test.describe('tab visibility', () => { + // defaults to curriculum tab + // curriculum and solution tabs are visible + // user-created tab is not visible to non-admin + }) + + test.describe('URL parameter handling', () => { + // non-admin accessing created_by_user tab is redirected to /workbooks + // invalid tab param falls back to curriculum tab + // direct URL access to solution tab selects the solution tab + }) + + test.describe('navigation interactions', () => { + // clicking solution tab updates URL to tab=solution + // curriculum grade buttons update URL (for-of: Q10, Q9, Q8) + // solution category buttons update URL (for-of: GRAPH, DP, SEARCH) + }) + + test.describe('session state', () => { + // navigating away and back restores saved URL filter state + }) + + // toggling replenishment workbooks (standalone — requires visibility check) +}) +``` + +`admin user` を以下の 2 グループに分割: + +``` +test.describe('admin user', () => { + test.describe('tab visibility', () => { + // 新規作成 button is visible + // user-created tab is visible to admin + // admin can access created_by_user tab via URL + }) + + test.describe('workbook actions', () => { + // workbook rows show edit link and delete button + }) +}) +``` + +- [ ] `e2e/workbooks_list.spec.ts`: 上記階層に整理 + +--- + +## 検証 + +- [ ] `pnpm test:unit` — 全ユニットテスト通過 +- [ ] `pnpm check` — 型エラーなし +- [ ] `pnpm lint` — Lint エラーなし +- [ ] `pnpm format` — フォーマット適用済み +- [ ] `coderabbit review --plain` — critical/high は即修正、low/info は最終 PR レビューで対応 +- [ ] `/session-close` — plan チェックリスト更新・rule/skill 追加提案・bloat チェック From 3511a3f8a595c2ab53756d2b8762c9cc858ee04d Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 05:47:56 +0000 Subject: [PATCH 26/47] refactor: move PlacementQuery to workbook_placement.ts and add SolutionCategories type Co-Authored-By: Claude Sonnet 4.6 --- src/features/workbooks/services/workbooks.ts | 14 ++++---------- src/features/workbooks/types/workbook_placement.ts | 11 +++++++++++ src/routes/workbooks/+page.server.ts | 2 +- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/features/workbooks/services/workbooks.ts b/src/features/workbooks/services/workbooks.ts index bc7200137..963fbda52 100644 --- a/src/features/workbooks/services/workbooks.ts +++ b/src/features/workbooks/services/workbooks.ts @@ -8,8 +8,10 @@ import type { WorkBookType, } from '$features/workbooks/types/workbook'; import { WorkBookType as WorkBookTypeConst } from '$features/workbooks/types/workbook'; -import { TaskGrade } from '$lib/types/task'; -import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; +import { + type PlacementQuery, + SolutionCategory, +} from '$features/workbooks/types/workbook_placement'; import { getWorkBookTasks, @@ -57,14 +59,6 @@ export async function getWorkBooksWithAuthors(): Promise { return mapWithAuthorName(workbooks); } -/** - * 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 }; - /** * Returns workbooks filtered by WorkBookPlacement, ordered by priority ASC. * Workbooks without a placement record are automatically excluded by Prisma's nested where filter. diff --git a/src/features/workbooks/types/workbook_placement.ts b/src/features/workbooks/types/workbook_placement.ts index 51753d986..f3c7c708c 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,8 @@ export const SolutionCategory: { [key in SolutionCategoryOrigin]: key } = { export type SolutionCategory = SolutionCategoryOrigin; +export type SolutionCategories = SolutionCategory[]; + // Japanese labels for solution categories (used in admin UI) export const SOLUTION_LABELS: Record = { PENDING: '未分類', @@ -87,6 +90,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/routes/workbooks/+page.server.ts b/src/routes/workbooks/+page.server.ts index fc8b6ace0..7fd6d77b3 100644 --- a/src/routes/workbooks/+page.server.ts +++ b/src/routes/workbooks/+page.server.ts @@ -10,11 +10,11 @@ import { type WorkBookTab as WorkBookTabType, } from '$features/workbooks/types/workbook'; import { - type PlacementQuery, getWorkbooksByPlacement, getWorkBooksCreatedByUsers, getAvailableSolutionCategories, } from '$features/workbooks/services/workbooks'; +import { type PlacementQuery } from '$features/workbooks/types/workbook_placement'; import { isAdmin, getLoggedInUser, canDelete } from '$lib/utils/authorship'; import { parseWorkBookTab, From 71dc787986d6f5b05dfc78610d1df15610c059b9 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 05:51:23 +0000 Subject: [PATCH 27/47] refactor: add TSDoc to SolutionCategories type --- src/features/workbooks/types/workbook_placement.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/workbooks/types/workbook_placement.ts b/src/features/workbooks/types/workbook_placement.ts index f3c7c708c..2953e51d0 100644 --- a/src/features/workbooks/types/workbook_placement.ts +++ b/src/features/workbooks/types/workbook_placement.ts @@ -24,6 +24,7 @@ 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) From c9526f310e25a8810f73f7880c52dfbc41cd835e Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 05:55:54 +0000 Subject: [PATCH 28/47] refactor: workbook_url_params cleanup, JSDoc update, rename acc to partition, onMount spacing Co-Authored-By: Claude Sonnet 4.6 --- src/features/workbooks/services/workbooks.ts | 2 +- .../workbooks/utils/workbook_url_params.ts | 30 +++++++++---------- src/features/workbooks/utils/workbooks.ts | 6 ++-- src/routes/workbooks/+page.svelte | 2 ++ 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/features/workbooks/services/workbooks.ts b/src/features/workbooks/services/workbooks.ts index 963fbda52..4fb415fa8 100644 --- a/src/features/workbooks/services/workbooks.ts +++ b/src/features/workbooks/services/workbooks.ts @@ -64,7 +64,7 @@ export async function getWorkBooksWithAuthors(): Promise { * 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) + * @param includeUnpublished - When true, unpublished workbooks are included (admin use). Defaults to false. */ export async function getWorkbooksByPlacement( query: PlacementQuery, diff --git a/src/features/workbooks/utils/workbook_url_params.ts b/src/features/workbooks/utils/workbook_url_params.ts index b105fdf02..684ac23e6 100644 --- a/src/features/workbooks/utils/workbook_url_params.ts +++ b/src/features/workbooks/utils/workbook_url_params.ts @@ -1,22 +1,10 @@ import { TaskGrade } from '$lib/types/task'; -import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; 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 VALID_TABS = new Set(Object.values(WorkBookTab)); - -/** - * 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; -} +const EXISTING_TABS = new Set(Object.values(WorkBookTab)); /** * Parses the `?tab=` URL parameter into a WorkBookTab. @@ -27,7 +15,7 @@ function isValidNonPending( export function parseWorkBookTab(params: URLSearchParams): WorkBookTab { const param = params.get('tab'); - if (param !== null && VALID_TABS.has(param)) { + if (param !== null && EXISTING_TABS.has(param)) { return param as WorkBookTab; } @@ -91,3 +79,15 @@ export function buildWorkbooksUrl( 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.ts b/src/features/workbooks/utils/workbooks.ts index 3fe214457..cbead7077 100644 --- a/src/features/workbooks/utils/workbooks.ts +++ b/src/features/workbooks/utils/workbooks.ts @@ -145,9 +145,9 @@ export function partitionWorkbooksAsMainAndReplenished(workbooks: WorkbooksList) replenished: WorkbooksList; } { return workbooks.reduce( - (acc, workbook) => { - (workbook.isReplenished ? acc.replenished : acc.main).push(workbook); - return acc; + (partition, workbook) => { + (workbook.isReplenished ? partition.replenished : partition.main).push(workbook); + return partition; }, { main: [] as WorkbooksList, replenished: [] as WorkbooksList }, ); diff --git a/src/routes/workbooks/+page.svelte b/src/routes/workbooks/+page.svelte index f164674a5..30d67021a 100644 --- a/src/routes/workbooks/+page.svelte +++ b/src/routes/workbooks/+page.svelte @@ -43,7 +43,9 @@ if (window.location.search) { return; } + const saved = sessionStorage.getItem(WORKBOOKS_URL_KEY); + if (saved) { goto(saved, { replaceState: true }); } From 566461b7103c0399b38a94687565da13f1e8ebf0 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 06:01:44 +0000 Subject: [PATCH 29/47] refactor: reorder functions and test describes to match conceptual groups Co-Authored-By: Claude Sonnet 4.6 --- .../workbooks/services/workbooks.test.ts | 234 +++++++++--------- .../workbooks/utils/workbooks.test.ts | 150 +++++------ src/features/workbooks/utils/workbooks.ts | 104 ++++---- 3 files changed, 244 insertions(+), 244 deletions(-) diff --git a/src/features/workbooks/services/workbooks.test.ts b/src/features/workbooks/services/workbooks.test.ts index 81d05a12e..b4efe2558 100644 --- a/src/features/workbooks/services/workbooks.test.ts +++ b/src/features/workbooks/services/workbooks.test.ts @@ -106,28 +106,6 @@ function mockDelete(value: NonNullable) { vi.mocked(prisma.workBook.delete).mockResolvedValue(value); } -describe('getWorkBook', () => { - test('returns workbook when found', async () => { - const workBook = prepareWorkBook({ id: 42 }); - mockFindUnique(asPrismaWorkBook(workBook)); - - const result = await getWorkBook(42); - - expect(result).toMatchObject({ id: 42 }); - expect(prisma.workBook.findUnique).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: 42 } }), - ); - }); - - test('returns null when not found', async () => { - mockFindUnique(null); - - const result = await getWorkBook(999); - - expect(result).toBeNull(); - }); -}); - describe('getWorkBooksWithAuthors', () => { test('maps username to authorName', async () => { const workBook = prepareWorkBook({ id: 1 }); @@ -148,101 +126,6 @@ describe('getWorkBooksWithAuthors', () => { }); }); -describe('getWorkbookWithAuthor', () => { - function mockGetUserById(value: { id: string } | null) { - vi.mocked(usersCrud.getUserById).mockResolvedValue(value as never); - } - - test('returns null when workbook is not found', async () => { - mockFindUnique(null); - - const result = await getWorkbookWithAuthor('999'); - - expect(result).toBeNull(); - }); - - test('returns workbook with isExistingAuthor true when author exists', async () => { - const workBook = prepareWorkBook({ id: 1, authorId: '1' }); - mockFindUnique(asPrismaWorkBook(workBook)); - mockGetUserById({ id: '1' }); - - const result = await getWorkbookWithAuthor('1'); - - expect(result).not.toBeNull(); - expect(result!.workBook).toMatchObject({ id: 1 }); - expect(result!.isExistingAuthor).toBe(true); - }); - - test('returns workbook with isExistingAuthor false when author is deleted', async () => { - const workBook = prepareWorkBook({ id: 1, authorId: '1' }); - mockFindUnique(asPrismaWorkBook(workBook)); - mockGetUserById(null); - - const result = await getWorkbookWithAuthor('1'); - - expect(result).not.toBeNull(); - expect(result!.isExistingAuthor).toBe(false); - }); -}); - -describe('createWorkBook', () => { - test('creates workbook successfully', async () => { - const workBook = prepareWorkBook({ urlSlug: null }); - mockFindUnique(null); // slug not taken - mockCreate(asPrismaWorkBook(workBook) as NonNullable); - - await expect(createWorkBook(workBook)).resolves.toBeUndefined(); - expect(prisma.workBook.create).toHaveBeenCalledTimes(1); - }); - - test('throws when urlSlug is already in use', async () => { - const workBook = prepareWorkBook({ urlSlug: 'bfs' }); - mockFindUnique( - asPrismaWorkBook(prepareWorkBook({ urlSlug: 'bfs' })) as NonNullable, - ); - - await expect(createWorkBook(workBook)).rejects.toThrow('bfs'); - expect(prisma.workBook.create).not.toHaveBeenCalled(); - }); -}); - -describe('updateWorkBook', () => { - test('updates workbook successfully', async () => { - const workBook = prepareWorkBook({ id: 1 }); - mockCount(1); - mockTransaction(); - - await expect(updateWorkBook(1, workBook)).resolves.toBeUndefined(); - expect(prisma.$transaction).toHaveBeenCalledTimes(1); - }); - - test('throws when workbook id does not exist', async () => { - mockCount(0); - - await expect(updateWorkBook(999, prepareWorkBook({ id: 999 }))).rejects.toThrow('999'); - expect(prisma.$transaction).not.toHaveBeenCalled(); - }); -}); - -describe('deleteWorkBook', () => { - test('deletes workbook successfully', async () => { - mockCount(1); - mockDelete(asPrismaWorkBook(prepareWorkBook()) as NonNullable); - - await expect(deleteWorkBook(1)).resolves.toBeUndefined(); - expect(prisma.workBook.delete).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: 1 } }), - ); - }); - - test('throws when workbook id does not exist', async () => { - mockCount(0); - - await expect(deleteWorkBook(999)).rejects.toThrow('999'); - expect(prisma.workBook.delete).not.toHaveBeenCalled(); - }); -}); - const MOCK_WORKBOOK_BASE = { id: 1, title: 'Test workbook', @@ -386,3 +269,120 @@ describe('getAvailableSolutionCategories', () => { expect(result).toEqual([SolutionCategory.GRAPH]); }); }); + +describe('getWorkBook', () => { + test('returns workbook when found', async () => { + const workBook = prepareWorkBook({ id: 42 }); + mockFindUnique(asPrismaWorkBook(workBook)); + + const result = await getWorkBook(42); + + expect(result).toMatchObject({ id: 42 }); + expect(prisma.workBook.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 42 } }), + ); + }); + + test('returns null when not found', async () => { + mockFindUnique(null); + + const result = await getWorkBook(999); + + expect(result).toBeNull(); + }); +}); + +describe('getWorkbookWithAuthor', () => { + function mockGetUserById(value: { id: string } | null) { + vi.mocked(usersCrud.getUserById).mockResolvedValue(value as never); + } + + test('returns null when workbook is not found', async () => { + mockFindUnique(null); + + const result = await getWorkbookWithAuthor('999'); + + expect(result).toBeNull(); + }); + + test('returns workbook with isExistingAuthor true when author exists', async () => { + const workBook = prepareWorkBook({ id: 1, authorId: '1' }); + mockFindUnique(asPrismaWorkBook(workBook)); + mockGetUserById({ id: '1' }); + + const result = await getWorkbookWithAuthor('1'); + + expect(result).not.toBeNull(); + expect(result!.workBook).toMatchObject({ id: 1 }); + expect(result!.isExistingAuthor).toBe(true); + }); + + test('returns workbook with isExistingAuthor false when author is deleted', async () => { + const workBook = prepareWorkBook({ id: 1, authorId: '1' }); + mockFindUnique(asPrismaWorkBook(workBook)); + mockGetUserById(null); + + const result = await getWorkbookWithAuthor('1'); + + expect(result).not.toBeNull(); + expect(result!.isExistingAuthor).toBe(false); + }); +}); + +describe('createWorkBook', () => { + test('creates workbook successfully', async () => { + const workBook = prepareWorkBook({ urlSlug: null }); + mockFindUnique(null); // slug not taken + mockCreate(asPrismaWorkBook(workBook) as NonNullable); + + await expect(createWorkBook(workBook)).resolves.toBeUndefined(); + expect(prisma.workBook.create).toHaveBeenCalledTimes(1); + }); + + test('throws when urlSlug is already in use', async () => { + const workBook = prepareWorkBook({ urlSlug: 'bfs' }); + mockFindUnique( + asPrismaWorkBook(prepareWorkBook({ urlSlug: 'bfs' })) as NonNullable, + ); + + await expect(createWorkBook(workBook)).rejects.toThrow('bfs'); + expect(prisma.workBook.create).not.toHaveBeenCalled(); + }); +}); + +describe('updateWorkBook', () => { + test('updates workbook successfully', async () => { + const workBook = prepareWorkBook({ id: 1 }); + mockCount(1); + mockTransaction(); + + await expect(updateWorkBook(1, workBook)).resolves.toBeUndefined(); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + test('throws when workbook id does not exist', async () => { + mockCount(0); + + await expect(updateWorkBook(999, prepareWorkBook({ id: 999 }))).rejects.toThrow('999'); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); +}); + +describe('deleteWorkBook', () => { + test('deletes workbook successfully', async () => { + mockCount(1); + mockDelete(asPrismaWorkBook(prepareWorkBook()) as NonNullable); + + await expect(deleteWorkBook(1)).resolves.toBeUndefined(); + expect(prisma.workBook.delete).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 1 } }), + ); + }); + + test('throws when workbook id does not exist', async () => { + mockCount(0); + + await expect(deleteWorkBook(999)).rejects.toThrow('999'); + expect(prisma.workBook.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/workbooks/utils/workbooks.test.ts b/src/features/workbooks/utils/workbooks.test.ts index b157ade9e..2e007d1d8 100644 --- a/src/features/workbooks/utils/workbooks.test.ts +++ b/src/features/workbooks/utils/workbooks.test.ts @@ -127,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); }); }); @@ -340,55 +343,52 @@ describe('Workbooks', () => { }); }); - describe('countReadableWorkbooks', () => { - const userId = '1'; - const authorId = '1'; - const otherUserId = '2'; - - test('counts published workbooks regardless of author', () => { + 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, 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); - }); - - test('counts unpublished workbooks owned by the user', () => { - const workbooks = [createWorkBookListBase({ id: 1, isPublished: false, authorId })]; - expect(countReadableWorkbooks(workbooks, userId)).toBe(1); + const result = buildTaskResultsByWorkBookId(workbooks, taskResultsByTaskId); + expect(result.get(1)).toEqual([taskResult]); }); - test('excludes unpublished workbooks owned by other users', () => { + test('excludes workbook from map when no task results exist', () => { + const taskResultsByTaskId = new Map(); const workbooks = [ - createWorkBookListBase({ id: 1, isPublished: false, authorId: otherUserId }), + createWorkBookListBase({ + id: 1, + workBookTasks: [{ taskId: 'abc300_a', priority: 1, comment: '' }], + }), ]; - expect(countReadableWorkbooks(workbooks, userId)).toBe(0); - }); - - test('returns 0 for empty list', () => { - expect(countReadableWorkbooks([], userId)).toBe(0); - }); - }); - - 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]); + const result = buildTaskResultsByWorkBookId(workbooks, taskResultsByTaskId); + expect(result.has(1)).toBe(false); }); - 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('returns empty map when given empty workbooks array', () => { + const taskResultsByTaskId = new Map(); + const result = buildTaskResultsByWorkBookId([], taskResultsByTaskId); + expect(result.size).toBe(0); }); - test('empty input returns empty arrays', () => { - const result = partitionWorkbooksAsMainAndReplenished([]); - expect(result.main).toEqual([]); - expect(result.replenished).toEqual([]); + 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, + workBookTasks: [ + { taskId: 'abc300_a', priority: 1, comment: '' }, + { taskId: 'abc300_b', priority: 2, comment: '' }, + ], + }), + ]; + 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 cbead7077..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 = []; @@ -133,22 +152,3 @@ export function getTaskResult( ): TaskResults { return taskResults.get(workbookId) ?? EMPTY_TASK_RESULTS; } - -/** - * 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 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 }, - ); -} From af251c7fbf3aa2e1c967d293bec6b07e8153fd69 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 06:03:51 +0000 Subject: [PATCH 30/47] test: add boundary grade (Q1, D1, D6) and category (DP, DATA_STRUCTURE) test cases --- .../utils/workbook_url_params.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/features/workbooks/utils/workbook_url_params.test.ts b/src/features/workbooks/utils/workbook_url_params.test.ts index fb3e64449..d69c1ee87 100644 --- a/src/features/workbooks/utils/workbook_url_params.test.ts +++ b/src/features/workbooks/utils/workbook_url_params.test.ts @@ -45,6 +45,18 @@ describe('parseWorkBookGrade', () => { 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); }); @@ -69,6 +81,18 @@ describe('parseWorkBookCategory', () => { 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); }); From c90e987d74588e43126f2069a301868da9add06a Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 06:07:16 +0000 Subject: [PATCH 31/47] refactor: apply SolutionCategories plural type alias to components and service --- .../2026-03-20/workbooks-list-url-params/refactor.md | 3 +++ .../workbooks/components/list/SolutionWorkBookList.svelte | 8 ++++++-- .../workbooks/components/list/WorkBookList.svelte | 7 +++++-- src/features/workbooks/services/workbooks.test.ts | 3 +-- src/features/workbooks/services/workbooks.ts | 3 ++- src/routes/workbooks/+page.server.ts | 6 ++++-- 6 files changed, 21 insertions(+), 9 deletions(-) diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/refactor.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/refactor.md index c451b26ce..33dc48512 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/refactor.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/refactor.md @@ -207,4 +207,7 @@ test.describe('admin user', () => { - [ ] `pnpm lint` — Lint エラーなし - [ ] `pnpm format` — フォーマット適用済み - [ ] `coderabbit review --plain` — critical/high は即修正、low/info は最終 PR レビューで対応 + - [ ] 1回目 + - [ ] 2回目 + - [ ] 3回目 - [ ] `/session-close` — plan チェックリスト更新・rule/skill 追加提案・bloat チェック diff --git a/src/features/workbooks/components/list/SolutionWorkBookList.svelte b/src/features/workbooks/components/list/SolutionWorkBookList.svelte index ad4e99aca..60a18ca13 100644 --- a/src/features/workbooks/components/list/SolutionWorkBookList.svelte +++ b/src/features/workbooks/components/list/SolutionWorkBookList.svelte @@ -4,7 +4,11 @@ import type { Roles } from '$lib/types/user'; import type { TaskResults } from '$lib/types/task'; import type { WorkbooksList } from '$features/workbooks/types/workbook'; - import { SolutionCategory, SOLUTION_LABELS } from '$features/workbooks/types/workbook_placement'; + import { + SolutionCategory, + SolutionCategories, + SOLUTION_LABELS, + } from '$features/workbooks/types/workbook_placement'; import { countReadableWorkbooks } from '$features/workbooks/utils/workbooks'; @@ -16,7 +20,7 @@ taskResultsWithWorkBookId: Map; userId: string; role: Roles; - availableCategories: SolutionCategory[]; + availableCategories: SolutionCategories; currentCategory: SolutionCategory; onCategoryChange: (category: SolutionCategory) => void; } diff --git a/src/features/workbooks/components/list/WorkBookList.svelte b/src/features/workbooks/components/list/WorkBookList.svelte index 10557f9b4..4b4cf8acd 100644 --- a/src/features/workbooks/components/list/WorkBookList.svelte +++ b/src/features/workbooks/components/list/WorkBookList.svelte @@ -2,7 +2,10 @@ import { Roles } from '$lib/types/user'; import { TaskGrade, type TaskResults } from '$lib/types/task'; import { WorkBookType, type WorkbooksList } from '$features/workbooks/types/workbook'; - import { type SolutionCategory } from '$features/workbooks/types/workbook_placement'; + import { + type SolutionCategory, + type SolutionCategories, + } from '$features/workbooks/types/workbook_placement'; import CurriculumWorkBookList from '$features/workbooks/components/list/CurriculumWorkBookList.svelte'; import SolutionWorkBookList from '$features/workbooks/components/list/SolutionWorkBookList.svelte'; @@ -24,7 +27,7 @@ | { workbookType: typeof WorkBookType.SOLUTION; currentCategory: SolutionCategory; - availableCategories: SolutionCategory[]; + availableCategories: SolutionCategories; onCategoryChange: (category: SolutionCategory) => void; } | { workbookType: typeof WorkBookType.CREATED_BY_USER }; diff --git a/src/features/workbooks/services/workbooks.test.ts b/src/features/workbooks/services/workbooks.test.ts index b4efe2558..2034d4fea 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, @@ -14,6 +12,7 @@ import { 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', () => ({ diff --git a/src/features/workbooks/services/workbooks.ts b/src/features/workbooks/services/workbooks.ts index 4fb415fa8..a52e5da79 100644 --- a/src/features/workbooks/services/workbooks.ts +++ b/src/features/workbooks/services/workbooks.ts @@ -11,6 +11,7 @@ import { WorkBookType as WorkBookTypeConst } from '$features/workbooks/types/wor import { type PlacementQuery, SolutionCategory, + type SolutionCategories, } from '$features/workbooks/types/workbook_placement'; import { @@ -122,7 +123,7 @@ export async function getWorkBooksCreatedByUsers(): Promise { +export async function getAvailableSolutionCategories(): Promise { const placements = await db.workBookPlacement.findMany({ where: { workBook: { isPublished: true, workBookType: WorkBookTypeConst.SOLUTION }, diff --git a/src/routes/workbooks/+page.server.ts b/src/routes/workbooks/+page.server.ts index 7fd6d77b3..f37e3ce56 100644 --- a/src/routes/workbooks/+page.server.ts +++ b/src/routes/workbooks/+page.server.ts @@ -8,13 +8,16 @@ 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 { type PlacementQuery } from '$features/workbooks/types/workbook_placement'; + import { isAdmin, getLoggedInUser, canDelete } from '$lib/utils/authorship'; import { parseWorkBookTab, @@ -22,7 +25,6 @@ import { parseWorkBookCategory, } from '$features/workbooks/utils/workbook_url_params'; import { parseWorkBookId } from '$features/workbooks/utils/workbook'; -import { WorkBookType } from '$features/workbooks/types/workbook'; import { BAD_REQUEST, From ee2a33908f26d26a5b4d1ba57bead4df3951262c Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 06:11:39 +0000 Subject: [PATCH 32/47] refactor: rest spread WorkBookList props and replace handleTabChange if-else with Record lookup Co-Authored-By: Claude Sonnet 4.6 --- .../components/list/WorkBookList.svelte | 42 +++++++++---------- src/routes/workbooks/+page.svelte | 18 ++++---- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/src/features/workbooks/components/list/WorkBookList.svelte b/src/features/workbooks/components/list/WorkBookList.svelte index 4b4cf8acd..184f86187 100644 --- a/src/features/workbooks/components/list/WorkBookList.svelte +++ b/src/features/workbooks/components/list/WorkBookList.svelte @@ -34,35 +34,35 @@ type Props = CommonProps & SpecificProps; - let props: Props = $props(); + let { workbooks, taskResultsWithWorkBookId, loggedInUser, ...restProps }: Props = $props(); -{#if props.workbookType === WorkBookType.CURRICULUM} +{#if restProps.workbookType === WorkBookType.CURRICULUM} -{:else if props.workbookType === WorkBookType.SOLUTION} +{:else if restProps.workbookType === WorkBookType.SOLUTION} {:else} {/if} diff --git a/src/routes/workbooks/+page.svelte b/src/routes/workbooks/+page.svelte index 30d67021a..ba17d88da 100644 --- a/src/routes/workbooks/+page.svelte +++ b/src/routes/workbooks/+page.svelte @@ -56,14 +56,16 @@ sessionStorage.setItem(WORKBOOKS_URL_KEY, url); }); - function handleTabChange(tab: (typeof WorkBookTab)[keyof typeof WorkBookTab]) { - if (tab === WorkBookTab.CURRICULUM) { - goto(buildWorkbooksUrl(WorkBookTab.CURRICULUM, data.selectedGrade)); - } else if (tab === WorkBookTab.SOLUTION) { - goto(buildWorkbooksUrl(WorkBookTab.SOLUTION, undefined, data.selectedCategory)); - } else { - goto(buildWorkbooksUrl(WorkBookTab.CREATED_BY_USER)); - } + // Each lambda closes over the reactive `data` binding at call time + const TAB_URL_BUILDERS: Record string> = { + [WorkBookTab.CURRICULUM]: () => buildWorkbooksUrl(WorkBookTab.CURRICULUM, data.selectedGrade), + [WorkBookTab.SOLUTION]: () => + buildWorkbooksUrl(WorkBookTab.SOLUTION, undefined, data.selectedCategory), + [WorkBookTab.CREATED_BY_USER]: () => buildWorkbooksUrl(WorkBookTab.CREATED_BY_USER), + }; + + function handleTabChange(tab: WorkBookTab) { + goto(TAB_URL_BUILDERS[tab]()); } function handleGradeChange(grade: TaskGrade) { From fccf840eb0c3cebe7fe4c9f0e3feb4e87216d742 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 06:15:10 +0000 Subject: [PATCH 33/47] refactor: reorganize E2E test hierarchy for workbooks list Co-Authored-By: Claude Sonnet 4.6 --- e2e/workbooks_list.spec.ts | 280 ++++++++++++++++++++----------------- 1 file changed, 153 insertions(+), 127 deletions(-) diff --git a/e2e/workbooks_list.spec.ts b/e2e/workbooks_list.spec.ts index c5ca91586..f02103ce3 100644 --- a/e2e/workbooks_list.spec.ts +++ b/e2e/workbooks_list.spec.ts @@ -36,123 +36,141 @@ test.describe('logged-in user (general)', () => { await loginAsUser(page); }); - test('defaults to curriculum tab', async ({ page }) => { - await page.goto(WORKBOOK_LIST_URL); - await expect(page.getByRole('tab', { name: 'カリキュラム' })).toHaveAttribute( - 'aria-selected', - 'true', - { timeout: TIMEOUT }, - ); - }); - - test('curriculum and solution tabs are visible', async ({ page }) => { - await page.goto(WORKBOOK_LIST_URL); - await expect(page.getByRole('tab', { name: 'カリキュラム' })).toBeVisible({ timeout: TIMEOUT }); - await expect(page.getByRole('tab', { name: '解法別' })).toBeVisible({ timeout: TIMEOUT }); - }); - - test('user-created tab is not visible to non-admin', async ({ page }) => { - await page.goto(WORKBOOK_LIST_URL); - await expect(page.getByRole('tab', { name: 'ユーザ作成' })).not.toBeVisible(); - }); - - test('non-admin accessing created_by_user tab is redirected to /workbooks', async ({ page }) => { - await page.goto(`${WORKBOOK_LIST_URL}?tab=${TAB_CREATED_BY_USER}`); - await expect(page).toHaveURL(WORKBOOK_LIST_URL, { timeout: TIMEOUT }); - }); - - test('clicking solution tab updates URL to tab=solution', async ({ page }) => { - await page.goto(WORKBOOK_LIST_URL); - await expect(page.getByRole('tab', { name: 'カリキュラム' })).toBeVisible({ timeout: TIMEOUT }); - await page.getByRole('tab', { name: '解法別' }).click(); - await expect(page).toHaveURL(new RegExp(`tab=${TAB_SOLUTION}`), { timeout: TIMEOUT }); - }); - - test('direct URL access to solution tab selects the solution tab', async ({ page }) => { - await page.goto(`${WORKBOOK_LIST_URL}?tab=${TAB_SOLUTION}&categories=${CATEGORY_GRAPH}`); - await expect(page.getByRole('tab', { name: '解法別' })).toHaveAttribute( - 'aria-selected', - 'true', - { timeout: TIMEOUT }, - ); - }); - - test('invalid tab param falls back to curriculum tab', async ({ page }) => { - await page.goto(`${WORKBOOK_LIST_URL}?tab=invalid`); - await expect(page.getByRole('tab', { name: 'カリキュラム' })).toHaveAttribute( - 'aria-selected', - 'true', - { timeout: TIMEOUT }, - ); - }); - - // Grade buttons → URL update - const CURRICULUM_GRADE_CASES = [ - { grade: GRADE_Q10, label: '10Q' }, - { grade: GRADE_Q9, label: '9Q' }, - { grade: GRADE_Q8, label: '8Q' }, - ]; + test.describe('tab visibility', () => { + test('defaults to curriculum tab', async ({ page }) => { + await page.goto(WORKBOOK_LIST_URL); + await expect(page.getByRole('tab', { name: 'カリキュラム' })).toHaveAttribute( + 'aria-selected', + 'true', + { timeout: TIMEOUT }, + ); + }); - for (const { grade, label } of CURRICULUM_GRADE_CASES) { - test(`curriculum grade button "${label}" updates URL to grades=${grade}`, async ({ page }) => { - await page.goto(`${WORKBOOK_LIST_URL}?tab=${TAB_CURRICULUM}`); + test('curriculum and solution tabs are visible', async ({ page }) => { + await page.goto(WORKBOOK_LIST_URL); await expect(page.getByRole('tab', { name: 'カリキュラム' })).toBeVisible({ timeout: TIMEOUT, }); - await page.getByRole('button', { name: label }).click(); - await expect(page).toHaveURL(new RegExp(`grades=${grade}`), { timeout: TIMEOUT }); + await expect(page.getByRole('tab', { name: '解法別' })).toBeVisible({ timeout: TIMEOUT }); }); - } - // Solution category buttons → URL update - const SOLUTION_CATEGORY_CASES = [ - { category: CATEGORY_GRAPH, label: 'グラフ' }, - { category: CATEGORY_DP, label: '動的計画法' }, - { category: CATEGORY_SEARCH, label: '探索・シミュレーション・実装' }, - ]; + test('user-created tab is not visible to non-admin', async ({ page }) => { + await page.goto(WORKBOOK_LIST_URL); + await expect(page.getByRole('tab', { name: 'ユーザ作成' })).not.toBeVisible(); + }); + }); - for (const { category, label } of SOLUTION_CATEGORY_CASES) { - test(`solution category button "${label}" updates URL to categories=${category}`, async ({ + test.describe('URL parameter handling', () => { + test('non-admin accessing created_by_user tab is redirected to /workbooks', async ({ page, }) => { - await page.goto(`${WORKBOOK_LIST_URL}?tab=${TAB_SOLUTION}`); - await expect(page.getByRole('tab', { name: '解法別' })).toBeVisible({ timeout: TIMEOUT }); + await page.goto(`${WORKBOOK_LIST_URL}?tab=${TAB_CREATED_BY_USER}`); + await expect(page).toHaveURL(WORKBOOK_LIST_URL, { timeout: TIMEOUT }); + }); - const button = page.getByRole('button', { name: label }); - if (!(await button.isVisible({ timeout: VISIBILITY_CHECK_TIMEOUT }).catch(() => false))) { - // No workbooks for this category → button is hidden by availableCategories filter - test.skip(); - return; - } + test('invalid tab param falls back to curriculum tab', async ({ page }) => { + await page.goto(`${WORKBOOK_LIST_URL}?tab=invalid`); + await expect(page.getByRole('tab', { name: 'カリキュラム' })).toHaveAttribute( + 'aria-selected', + 'true', + { timeout: TIMEOUT }, + ); + }); - await button.click(); - await expect(page).toHaveURL(new RegExp(`categories=${category}`), { timeout: TIMEOUT }); + test('direct URL access to solution tab selects the solution tab', async ({ page }) => { + await page.goto(`${WORKBOOK_LIST_URL}?tab=${TAB_SOLUTION}&categories=${CATEGORY_GRAPH}`); + await expect(page.getByRole('tab', { name: '解法別' })).toHaveAttribute( + 'aria-selected', + 'true', + { timeout: TIMEOUT }, + ); }); - } + }); - test('navigating away and back via nav link restores saved URL filter state', async ({ - page, - }) => { - const targetUrl = `${WORKBOOK_LIST_URL}?tab=${TAB_SOLUTION}&categories=${CATEGORY_GRAPH}`; - await page.goto(targetUrl); - await expect(page.getByRole('tab', { name: '解法別' })).toHaveAttribute( - 'aria-selected', - 'true', - { + test.describe('navigation interactions', () => { + test('clicking solution tab updates URL to tab=solution', async ({ page }) => { + await page.goto(WORKBOOK_LIST_URL); + await expect(page.getByRole('tab', { name: 'カリキュラム' })).toBeVisible({ timeout: TIMEOUT, - }, - ); + }); + await page.getByRole('tab', { name: '解法別' }).click(); + await expect(page).toHaveURL(new RegExp(`tab=${TAB_SOLUTION}`), { timeout: TIMEOUT }); + }); - // Navigate to another page - await page.goto('/'); - await expect(page).toHaveURL('/', { timeout: TIMEOUT }); + // Grade buttons → URL update + const CURRICULUM_GRADE_CASES = [ + { grade: GRADE_Q10, label: '10Q' }, + { grade: GRADE_Q9, label: '9Q' }, + { grade: GRADE_Q8, label: '8Q' }, + ]; + + for (const { grade, label } of CURRICULUM_GRADE_CASES) { + test(`curriculum grade button "${label}" updates URL to grades=${grade}`, async ({ + page, + }) => { + await page.goto(`${WORKBOOK_LIST_URL}?tab=${TAB_CURRICULUM}`); + await expect(page.getByRole('tab', { name: 'カリキュラム' })).toBeVisible({ + timeout: TIMEOUT, + }); + await page.getByRole('button', { name: label }).click(); + await expect(page).toHaveURL(new RegExp(`grades=${grade}`), { timeout: TIMEOUT }); + }); + } - // Return to /workbooks via nav link (no params) - await page.goto(WORKBOOK_LIST_URL); + // Solution category buttons → URL update + const SOLUTION_CATEGORY_CASES = [ + { category: CATEGORY_GRAPH, label: 'グラフ' }, + { category: CATEGORY_DP, label: '動的計画法' }, + { category: CATEGORY_SEARCH, label: '探索・シミュレーション・実装' }, + ]; + + for (const { category, label } of SOLUTION_CATEGORY_CASES) { + test(`solution category button "${label}" updates URL to categories=${category}`, async ({ + page, + }) => { + await page.goto(`${WORKBOOK_LIST_URL}?tab=${TAB_SOLUTION}`); + await expect(page.getByRole('tab', { name: '解法別' })).toBeVisible({ timeout: TIMEOUT }); + + const button = page.getByRole('button', { name: label }); + if (!(await button.isVisible({ timeout: VISIBILITY_CHECK_TIMEOUT }).catch(() => false))) { + // No workbooks for this category → button is hidden by availableCategories filter + test.skip(); + return; + } + + await button.click(); + await expect(page).toHaveURL(new RegExp(`categories=${category}`), { timeout: TIMEOUT }); + }); + } + }); - // URL should be restored to the saved filter state - await expect(page).toHaveURL(new RegExp(`tab=${TAB_SOLUTION}`), { timeout: TIMEOUT }); - await expect(page).toHaveURL(new RegExp(`categories=${CATEGORY_GRAPH}`), { timeout: TIMEOUT }); + test.describe('session state', () => { + test('navigating away and back via nav link restores saved URL filter state', async ({ + page, + }) => { + const targetUrl = `${WORKBOOK_LIST_URL}?tab=${TAB_SOLUTION}&categories=${CATEGORY_GRAPH}`; + await page.goto(targetUrl); + await expect(page.getByRole('tab', { name: '解法別' })).toHaveAttribute( + 'aria-selected', + 'true', + { + timeout: TIMEOUT, + }, + ); + + // Navigate to another page + await page.goto('/'); + await expect(page).toHaveURL('/', { timeout: TIMEOUT }); + + // Return to /workbooks via nav link (no params) + await page.goto(WORKBOOK_LIST_URL); + + // URL should be restored to the saved filter state + await expect(page).toHaveURL(new RegExp(`tab=${TAB_SOLUTION}`), { timeout: TIMEOUT }); + await expect(page).toHaveURL(new RegExp(`categories=${CATEGORY_GRAPH}`), { + timeout: TIMEOUT, + }); + }); }); test('toggling replenishment workbooks shows/hides the section when it exists', async ({ @@ -188,39 +206,47 @@ test.describe('admin user', () => { await loginAsAdmin(page); }); - test('"新規作成" button is visible', async ({ page }) => { - await page.goto(WORKBOOK_LIST_URL); - await expect(page.getByRole('link', { name: '新規作成' })).toBeVisible({ timeout: TIMEOUT }); - }); + test.describe('tab visibility', () => { + test('"新規作成" button is visible', async ({ page }) => { + await page.goto(WORKBOOK_LIST_URL); + await expect(page.getByRole('link', { name: '新規作成' })).toBeVisible({ timeout: TIMEOUT }); + }); - test('user-created tab is visible to admin', async ({ page }) => { - await page.goto(WORKBOOK_LIST_URL); - await expect(page.getByRole('tab', { name: 'ユーザ作成' })).toBeVisible({ timeout: TIMEOUT }); - }); + test('user-created tab is visible to admin', async ({ page }) => { + await page.goto(WORKBOOK_LIST_URL); + await expect(page.getByRole('tab', { name: 'ユーザ作成' })).toBeVisible({ + timeout: TIMEOUT, + }); + }); - test('admin can access created_by_user tab via URL', async ({ page }) => { - await page.goto(`${WORKBOOK_LIST_URL}?tab=${TAB_CREATED_BY_USER}`); - await expect(page.getByRole('tab', { name: 'ユーザ作成' })).toHaveAttribute( - 'aria-selected', - 'true', - { timeout: TIMEOUT }, - ); + test('admin can access created_by_user tab via URL', async ({ page }) => { + await page.goto(`${WORKBOOK_LIST_URL}?tab=${TAB_CREATED_BY_USER}`); + await expect(page.getByRole('tab', { name: 'ユーザ作成' })).toHaveAttribute( + 'aria-selected', + 'true', + { timeout: TIMEOUT }, + ); + }); }); - test('workbook rows show edit link and delete button', async ({ page }) => { - await page.goto(WORKBOOK_LIST_URL); - await expect(page.getByRole('tab', { name: 'カリキュラム' })).toBeVisible({ timeout: TIMEOUT }); + test.describe('workbook actions', () => { + test('workbook rows show edit link and delete button', async ({ page }) => { + await page.goto(WORKBOOK_LIST_URL); + await expect(page.getByRole('tab', { name: 'カリキュラム' })).toBeVisible({ + timeout: TIMEOUT, + }); - const editLink = page.getByRole('link', { name: '編集' }).first(); - const deleteButton = page.getByRole('button', { name: '削除' }).first(); + const editLink = page.getByRole('link', { name: '編集' }).first(); + const deleteButton = page.getByRole('button', { name: '削除' }).first(); - if (!(await editLink.isVisible())) { - // No workbooks visible for the current grade → skip - test.skip(); - return; - } + if (!(await editLink.isVisible())) { + // No workbooks visible for the current grade → skip + test.skip(); + return; + } - await expect(editLink).toBeVisible({ timeout: TIMEOUT }); - await expect(deleteButton).toBeVisible({ timeout: TIMEOUT }); + await expect(editLink).toBeVisible({ timeout: TIMEOUT }); + await expect(deleteButton).toBeVisible({ timeout: TIMEOUT }); + }); }); }); From b068945bdd1e64f93fc0bd079d4967f857450ec9 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 06:17:55 +0000 Subject: [PATCH 34/47] fix: use type-only import for SolutionCategories in SolutionWorkBookList --- .../workbooks/components/list/SolutionWorkBookList.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/workbooks/components/list/SolutionWorkBookList.svelte b/src/features/workbooks/components/list/SolutionWorkBookList.svelte index 60a18ca13..cf3598788 100644 --- a/src/features/workbooks/components/list/SolutionWorkBookList.svelte +++ b/src/features/workbooks/components/list/SolutionWorkBookList.svelte @@ -6,7 +6,7 @@ import type { WorkbooksList } from '$features/workbooks/types/workbook'; import { SolutionCategory, - SolutionCategories, + type SolutionCategories, SOLUTION_LABELS, } from '$features/workbooks/types/workbook_placement'; From 8033f62200827f8ecb52fdb12c574f27e6cc947a Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 06:23:03 +0000 Subject: [PATCH 35/47] docs: update refactor.md checklist to reflect completed phases --- .../workbooks-list-url-params/refactor.md | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/refactor.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/refactor.md index 33dc48512..824f27809 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/refactor.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/refactor.md @@ -58,17 +58,17 @@ Vue Router の `router.push()` / React Router の `navigate()` に相当する **依存なし。他フェーズより先に実施。** -- [ ] `src/features/workbooks/types/workbook_placement.ts` に `export type SolutionCategories = SolutionCategory[]` を追加 -- [ ] `src/features/workbooks/services/workbooks.ts` の `PlacementQuery` 型を `workbook_placement.ts` に移動 -- [ ] 消費側 (`src/routes/workbooks/+page.server.ts`, `services/workbooks.ts`) のインポートを `workbook_placement.ts` からに更新 +- [x] `src/features/workbooks/types/workbook_placement.ts` に `export type SolutionCategories = SolutionCategory[]` を追加 +- [x] `src/features/workbooks/services/workbooks.ts` の `PlacementQuery` 型を `workbook_placement.ts` に移動 +- [x] 消費側 (`src/routes/workbooks/+page.server.ts`, `services/workbooks.ts`) のインポートを `workbook_placement.ts` からに更新 ### Phase 2 — ユーティリティ整形(低リスク) -- [ ] `workbook_url_params.ts`: `isValidNonPending` 関数をファイル末尾(`buildWorkbooksUrl` の後)へ移動 -- [ ] `workbook_url_params.ts`: `VALID_TABS` → `EXISTING_TABS` にリネーム(参照箇所は `parseWorkBookTab` 内のみ) -- [ ] `services/workbooks.ts`: `getWorkbooksByPlacement` の `@param includeUnpublished` JSDoc に `Defaults to false.` を追記 -- [ ] `utils/workbooks.ts`: `partitionWorkbooksAsMainAndReplenished` の reduce コールバック引数 `acc` → `partition` にリネーム -- [ ] `+page.svelte`: `onMount` 内の `if (window.location.search)` ブロックと `const saved = ...` の間、および `const saved` と `if (saved)` の間に空行を追加 +- [x] `workbook_url_params.ts`: `isValidNonPending` 関数をファイル末尾(`buildWorkbooksUrl` の後)へ移動 +- [x] `workbook_url_params.ts`: `VALID_TABS` → `EXISTING_TABS` にリネーム(参照箇所は `parseWorkBookTab` 内のみ) +- [x] `services/workbooks.ts`: `getWorkbooksByPlacement` の `@param includeUnpublished` JSDoc に `Defaults to false.` を追記 +- [x] `utils/workbooks.ts`: `partitionWorkbooksAsMainAndReplenished` の reduce コールバック引数 `acc` → `partition` にリネーム +- [x] `+page.svelte`: `onMount` 内の `if (window.location.search)` ブロックと `const saved = ...` の間、および `const saved` と `if (saved)` の間に空行を追加 ### Phase 3 — 関数順序の統一(低リスク) @@ -86,8 +86,8 @@ Vue Router の `router.push()` / React Router の `navigate()` に相当する 9. getTaskResult — task result getter (同グループ) ``` -- [ ] `utils/workbooks.ts`: 上記順序に並び替え(主な変更: grade 関連を 6-7 に、task result 関連を 8-9 に移動) -- [ ] `utils/workbooks.test.ts`: describe 順を上記 source 順に揃える +- [x] `utils/workbooks.ts`: 上記順序に並び替え(主な変更: grade 関連を 6-7 に、task result 関連を 8-9 に移動) +- [x] `utils/workbooks.test.ts`: describe 順を上記 source 順に揃える `services/workbooks.test.ts` を source 関数順に揃える(現状の test 先頭は `getWorkBook` だが source では 6番目): @@ -103,24 +103,24 @@ Vue Router の `router.push()` / React Router の `navigate()` に相当する 9. deleteWorkBook ``` -- [ ] `services/workbooks.test.ts`: 上記順序に describe ブロックを並び替え +- [x] `services/workbooks.test.ts`: 上記順序に describe ブロックを並び替え ### Phase 4 — テスト補強(低リスク) -- [ ] `workbook_url_params.test.ts` `parseWorkBookGrade`: `TaskGrade.Q1`(最難関 Q 帯境界値)、`TaskGrade.D1`(D 帯最難)、`TaskGrade.D6`(D 帯最易)の 3 ケースを追加(いずれも有効値 → その値を返す) -- [ ] `workbook_url_params.test.ts` `parseWorkBookCategory`: `SolutionCategory.DYNAMIC_PROGRAMMING`、`SolutionCategory.DATA_STRUCTURE` の 2 ケースを追加 +- [x] `workbook_url_params.test.ts` `parseWorkBookGrade`: `TaskGrade.Q1`(最難関 Q 帯境界値)、`TaskGrade.D1`(D 帯最難)、`TaskGrade.D6`(D 帯最易)の 3 ケースを追加(いずれも有効値 → その値を返す) +- [x] `workbook_url_params.test.ts` `parseWorkBookCategory`: `SolutionCategory.DYNAMIC_PROGRAMMING`、`SolutionCategory.DATA_STRUCTURE` の 2 ケースを追加 ### Phase 5 — 複数形型エイリアス適用(中リスク) Phase 1 で追加した `SolutionCategories` をコードベースに適用: -- [ ] `SolutionWorkBookList.svelte`: Props の `availableCategories: SolutionCategory[]` → `SolutionCategories`(インポートも更新) -- [ ] `WorkBookList.svelte`: Props の `availableCategories: SolutionCategory[]` → `SolutionCategories`(インポートも更新) -- [ ] `services/workbooks.ts`: `getAvailableSolutionCategories()` の return type を `Promise` に変更 +- [x] `SolutionWorkBookList.svelte`: Props の `availableCategories: SolutionCategory[]` → `SolutionCategories`(インポートも更新) +- [x] `WorkBookList.svelte`: Props の `availableCategories: SolutionCategory[]` → `SolutionCategories`(インポートも更新) +- [x] `services/workbooks.ts`: `getAvailableSolutionCategories()` の return type を `Promise` に変更 ### Phase 6 — コンポーネント改善(中リスク) -- [ ] `WorkBookList.svelte`: `let props: Props = $props()` を rest spread 形式に変更し、CommonProps を shorthand で記述できるようにする +- [x] `WorkBookList.svelte`: `let props: Props = $props()` を rest spread 形式に変更し、CommonProps を shorthand で記述できるようにする ```typescript // Before @@ -132,7 +132,7 @@ Phase 1 で追加した `SolutionCategories` をコードベースに適用: テンプレート内: `props.workbooks` → `{workbooks}`、`props.workbookType` → `restProps.workbookType` で discriminated union narrowing を維持 -- [ ] `+page.svelte`: `handleTabChange` の if-else チェーンを `Record string>` ルックアップに変更 +- [x] `+page.svelte`: `handleTabChange` の if-else チェーンを `Record string>` ルックアップに変更 ```typescript // Each lambda closes over the reactive `data` binding at call time @@ -196,16 +196,16 @@ test.describe('admin user', () => { }) ``` -- [ ] `e2e/workbooks_list.spec.ts`: 上記階層に整理 +- [x] `e2e/workbooks_list.spec.ts`: 上記階層に整理 --- ## 検証 -- [ ] `pnpm test:unit` — 全ユニットテスト通過 -- [ ] `pnpm check` — 型エラーなし -- [ ] `pnpm lint` — Lint エラーなし -- [ ] `pnpm format` — フォーマット適用済み +- [x] `pnpm test:unit` — 全ユニットテスト通過 +- [x] `pnpm check` — 型エラーなし(pre-existing の login/signup 2件のみ) +- [x] `pnpm lint` — Lint エラーなし(warnings は pre-existing) +- [x] `pnpm format` — フォーマット適用済み - [ ] `coderabbit review --plain` — critical/high は即修正、low/info は最終 PR レビューで対応 - [ ] 1回目 - [ ] 2回目 From e29879cf1852ea24df3d6382737cba1b2f408eea Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 06:26:44 +0000 Subject: [PATCH 36/47] docs: update coderabbit review status in refactor.md --- .../2026-03-20/workbooks-list-url-params/refactor.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/refactor.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/refactor.md index 824f27809..2bc7de5a0 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/refactor.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/refactor.md @@ -206,8 +206,8 @@ test.describe('admin user', () => { - [x] `pnpm check` — 型エラーなし(pre-existing の login/signup 2件のみ) - [x] `pnpm lint` — Lint エラーなし(warnings は pre-existing) - [x] `pnpm format` — フォーマット適用済み -- [ ] `coderabbit review --plain` — critical/high は即修正、low/info は最終 PR レビューで対応 - - [ ] 1回目 - - [ ] 2回目 - - [ ] 3回目 +- [x] `coderabbit review --plain` — critical/high は即修正、low/info は最終 PR レビューで対応 + - [x] 1回目: 22件、全て nitpick/potential_issue (low/info)。critical/high なし → 追加レビュー不要 + - [ ] 2回目(critical/high があった場合のみ) + - [ ] 3回目(critical/high があった場合のみ) - [ ] `/session-close` — plan チェックリスト更新・rule/skill 追加提案・bloat チェック From b39ad171e0cf5a67df307844d0a22a3c5f2f1e4a Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 06:40:50 +0000 Subject: [PATCH 37/47] docs: add plan lifecycle, language policy, and updated CodeRabbit triage to AGENTS.md and coding-style.md --- .claude/rules/coding-style.md | 15 +++++++++++---- AGENTS.md | 5 +++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.claude/rules/coding-style.md b/.claude/rules/coding-style.md index 4f492d4e1..d9aa2821f 100644 --- a/.claude/rules/coding-style.md +++ b/.claude/rules/coding-style.md @@ -70,6 +70,10 @@ 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. + ### 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. @@ -158,9 +162,12 @@ 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**: Must fix before opening the PR. +- **potential_issue (medium)**: Write all findings verbatim to a `## CodeRabbit Findings` section in `refactor.md`. The user decides which to fix; do not fix medium 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 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/AGENTS.md b/AGENTS.md index fab7aac0b..59175ce5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Always prefer simplicity over pathological correctness. YAGNI, KISS, DRY. No bac **When implementing:** -1. Use `/writing-plans` to generate a phased plan (2–5-min tasks, lower risk → higher risk order). Verify each task before starting: +1. Use `/writing-plans` to generate a phased plan (2–5-min tasks, lower risk → higher risk order). Store the plan at `docs/dev-notes/YYYY-MM-DD/{task-name-en}/plan.md`. Split into `phase-N.md` files when the plan exceeds 200 lines or has 5+ phases. Each plan must include: overview, design rationale, rejected alternatives, and a per-phase summary. Write plans in Japanese; source code comments in English. Verify each task before starting: - Which layer? (prisma / server / zod / types / fixtures / services / utils / stores / routes / components) — split if 2+ layers - Single responsibility: one purpose per task - Existing util/service/type? Search before creating @@ -16,7 +16,8 @@ Always prefer simplicity over pathological correctness. YAGNI, KISS, DRY. No bac 2. Before writing a new function, search `src/lib/utils/`, `src/lib/services/`, `src/features/*/utils/` and `src/features/*/services/` for existing implementations; extract shared logic there when it appears in 2+ places 3. Write tests first, then implement production code, then verify with `pnpm test:unit` 4. Review critically after implementing: flag YAGNI violations, over-abstraction, missing tests -5. Run `/session-close` at the end of each session: updates plan checklist, proposes rule/skill additions, checks for bloat, and detects repeated instructions +5. After all phases complete (feature and refactor branches only — not hotfixes or dependency bumps): run a mandatory refactor cycle. Produce `refactor.md` in the same directory as the plan, documenting: design decisions made, changes explicitly rejected and why, remaining tasks, and per-phase lessons. Transfer all lessons to `refactor.md`, then discard `phase-N.md` files. Run `coderabbit review --plain`; write all `potential_issue` (medium) and above findings to a `## CodeRabbit Findings` section in `refactor.md` — the user decides which to fix before opening a PR. `nitpick` findings defer to PR CI. +6. Run `/session-close` at the end of each session: updates plan checklist, proposes rule/skill additions, checks for bloat, and detects repeated instructions ## Tech Stack From 109a966c1a017bb80b5ef15962ea2d642344abbd Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 06:43:16 +0000 Subject: [PATCH 38/47] fix: phase-N.md discarded after transferring lessons to plan.md (not refactor.md) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 59175ce5c..17313e669 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ Always prefer simplicity over pathological correctness. YAGNI, KISS, DRY. No bac 2. Before writing a new function, search `src/lib/utils/`, `src/lib/services/`, `src/features/*/utils/` and `src/features/*/services/` for existing implementations; extract shared logic there when it appears in 2+ places 3. Write tests first, then implement production code, then verify with `pnpm test:unit` 4. Review critically after implementing: flag YAGNI violations, over-abstraction, missing tests -5. After all phases complete (feature and refactor branches only — not hotfixes or dependency bumps): run a mandatory refactor cycle. Produce `refactor.md` in the same directory as the plan, documenting: design decisions made, changes explicitly rejected and why, remaining tasks, and per-phase lessons. Transfer all lessons to `refactor.md`, then discard `phase-N.md` files. Run `coderabbit review --plain`; write all `potential_issue` (medium) and above findings to a `## CodeRabbit Findings` section in `refactor.md` — the user decides which to fix before opening a PR. `nitpick` findings defer to PR CI. +5. After all phases complete (feature and refactor branches only — not hotfixes or dependency bumps): run a mandatory refactor cycle. Produce `refactor.md` in the same directory as the plan, documenting: design decisions made, changes explicitly rejected and why, remaining tasks, and per-phase lessons. Transfer all lessons to `plan.md`, then discard `phase-N.md` files. Run `coderabbit review --plain`; write all `potential_issue` (medium) and above findings to a `## CodeRabbit Findings` section in `refactor.md` — the user decides which to fix before opening a PR. `nitpick` findings defer to PR CI. 6. Run `/session-close` at the end of each session: updates plan checklist, proposes rule/skill additions, checks for bloat, and detects repeated instructions ## Tech Stack From ccde2646c3bb9c4fb9622d98512cd89f005a5396 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 06:45:43 +0000 Subject: [PATCH 39/47] =?UTF-8?q?fix:=20CodeRabbit=20triage=20=E2=80=94=20?= =?UTF-8?q?write=20medium=20and=20above=20(including=20critical/high)=20to?= =?UTF-8?q?=20refactor.md=20for=20user=20decision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/coding-style.md | 5 ++--- AGENTS.md | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.claude/rules/coding-style.md b/.claude/rules/coding-style.md index d9aa2821f..9591c2cd1 100644 --- a/.claude/rules/coding-style.md +++ b/.claude/rules/coding-style.md @@ -166,8 +166,7 @@ Run `coderabbit review --plain` once after all phases are complete (not on every **Triage by severity:** -- **critical / high**: Must fix before opening the PR. -- **potential_issue (medium)**: Write all findings verbatim to a `## CodeRabbit Findings` section in `refactor.md`. The user decides which to fix; do not fix medium findings unilaterally. +- **potential_issue (medium) and above (including critical / high)**: 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. -Writing medium 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. +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/AGENTS.md b/AGENTS.md index 17313e669..5f97f547f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ Always prefer simplicity over pathological correctness. YAGNI, KISS, DRY. No bac 2. Before writing a new function, search `src/lib/utils/`, `src/lib/services/`, `src/features/*/utils/` and `src/features/*/services/` for existing implementations; extract shared logic there when it appears in 2+ places 3. Write tests first, then implement production code, then verify with `pnpm test:unit` 4. Review critically after implementing: flag YAGNI violations, over-abstraction, missing tests -5. After all phases complete (feature and refactor branches only — not hotfixes or dependency bumps): run a mandatory refactor cycle. Produce `refactor.md` in the same directory as the plan, documenting: design decisions made, changes explicitly rejected and why, remaining tasks, and per-phase lessons. Transfer all lessons to `plan.md`, then discard `phase-N.md` files. Run `coderabbit review --plain`; write all `potential_issue` (medium) and above findings to a `## CodeRabbit Findings` section in `refactor.md` — the user decides which to fix before opening a PR. `nitpick` findings defer to PR CI. +5. After all phases complete (feature and refactor branches only — not hotfixes or dependency bumps): run a mandatory refactor cycle. Produce `refactor.md` in the same directory as the plan, documenting: design decisions made, changes explicitly rejected and why, remaining tasks, and per-phase lessons. Transfer all lessons to `plan.md`, then discard `phase-N.md` files. Run `coderabbit review --plain`; write all findings of `potential_issue` (medium) and above — including `critical` and `high` — to a `## CodeRabbit Findings` section in `refactor.md`. The user decides which to fix before opening a PR; do not fix any finding unilaterally. `nitpick` findings defer to PR CI. 6. Run `/session-close` at the end of each session: updates plan checklist, proposes rule/skill additions, checks for bloat, and detects repeated instructions ## Tech Stack From eadb45a7934dfda1353f85290aab2e7e45e2df92 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 06:47:24 +0000 Subject: [PATCH 40/47] style: Add blank line before conditional in workbooks_list e2e test --- e2e/workbooks_list.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/workbooks_list.spec.ts b/e2e/workbooks_list.spec.ts index f02103ce3..aaad8b6f0 100644 --- a/e2e/workbooks_list.spec.ts +++ b/e2e/workbooks_list.spec.ts @@ -132,6 +132,7 @@ test.describe('logged-in user (general)', () => { await expect(page.getByRole('tab', { name: '解法別' })).toBeVisible({ timeout: TIMEOUT }); const button = page.getByRole('button', { name: label }); + if (!(await button.isVisible({ timeout: VISIBILITY_CHECK_TIMEOUT }).catch(() => false))) { // No workbooks for this category → button is hidden by availableCategories filter test.skip(); From bfda4d8dd7de0f44ca948f7f2ce5587b41dceb00 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 22 Mar 2026 06:58:42 +0000 Subject: [PATCH 41/47] docs: Consolidate workbooks-list-url-params plan and remove phase files --- .../workbooks-list-url-params/phase-0.md | 75 ----- .../workbooks-list-url-params/phase-1.md | 110 ------ .../workbooks-list-url-params/phase-10.md | 123 ------- .../workbooks-list-url-params/phase-11.md | 92 ----- .../workbooks-list-url-params/phase-12.md | 207 ------------ .../workbooks-list-url-params/phase-2.md | 255 -------------- .../workbooks-list-url-params/phase-3.md | 309 ----------------- .../workbooks-list-url-params/phase-4.md | 139 -------- .../workbooks-list-url-params/phase-5.md | 164 --------- .../workbooks-list-url-params/phase-6.md | 95 ------ .../workbooks-list-url-params/phase-7.md | 52 --- .../workbooks-list-url-params/phase-8.md | 314 ------------------ .../workbooks-list-url-params/phase-9.md | 49 --- .../workbooks-list-url-params/plan.md | 162 +++------ 14 files changed, 48 insertions(+), 2098 deletions(-) delete mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-0.md delete mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-1.md delete mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-10.md delete mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-11.md delete mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-12.md delete mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-2.md delete mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-3.md delete mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-4.md delete mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-5.md delete mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-6.md delete mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-7.md delete mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-8.md delete mode 100644 docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-9.md diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-0.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-0.md deleted file mode 100644 index 043b37ccb..000000000 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-0.md +++ /dev/null @@ -1,75 +0,0 @@ -# Phase 0: `WorkBookTab` 型の統一 - -**レイヤー:** `src/features/workbooks/types/` | **リスク:** 極低 - -order ページで定義されている `ActiveTab = 'solution' | 'curriculum'` と、新たに必要な `WorkBookTab` は同一の型。feature types に一元定義し、order ページは再エクスポートに変更する。`WorkBookTab` は `WorkBookType` と同パターンの const オブジェクトとして定義し、`created_by_user` を含む3値を持つ。 - ---- - -## Task 0-A: `WorkBookTab` 型を feature types に追加 - -**Files:** - -- Modify: `src/features/workbooks/types/workbook.ts` - -- [x] **Step 1: ファイル末尾に追加** - -```typescript -/** /workbooks ページの URL パラメータ `?tab=` に対応する有効値 */ -export const WorkBookTab = { - CURRICULUM: 'curriculum', - SOLUTION: 'solution', - CREATED_BY_USER: 'created_by_user', -} as const; - -export type WorkBookTab = (typeof WorkBookTab)[keyof typeof WorkBookTab]; - -/** URLパラメータがない場合のデフォルトタブ */ -export const DEFAULT_WORKBOOK_TAB: WorkBookTab = WorkBookTab.CURRICULUM; -``` - -- [x] **Step 2: 型チェック** - -```bash -pnpm check -``` - -- [x] **Step 3: コミット** - -```bash -git add src/features/workbooks/types/workbook.ts -git commit -m "feat(workbooks/types): Add WorkBookTab const object with CURRICULUM, SOLUTION, CREATED_BY_USER" -``` - ---- - -## Task 0-B: order ページの `ActiveTab` を `WorkBookTab` の再エクスポートに変更 - -**Files:** - -- Modify: `src/routes/(admin)/workbooks/order/_types/kanban.ts` - -- [x] **Step 1: `ActiveTab` の定義を再エクスポートに置き換え** - -```typescript -// 変更前 -export type ActiveTab = 'solution' | 'curriculum'; - -// 変更後 -export type { WorkBookTab as ActiveTab } from '$features/workbooks/types/workbook'; -``` - -> **注意:** order ページは `CREATED_BY_USER` を使わないため、型の値が増えても既存ロジックには影響しない。 - -- [x] **Step 2: 型チェック(order ページの既存コードが壊れていないことを確認)** - -```bash -pnpm check -``` - -- [x] **Step 3: コミット** - -```bash -git add src/routes/(admin)/workbooks/order/_types/kanban.ts -git commit -m "refactor(workbooks/order): Re-export ActiveTab from WorkBookTab feature type" -``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-1.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-1.md deleted file mode 100644 index 572bf8359..000000000 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-1.md +++ /dev/null @@ -1,110 +0,0 @@ -# Phase 1: `partitionWorkbooksAsMainAndReplenished()` ユーティリティ - -**レイヤー:** `src/features/workbooks/utils/` | **リスク:** 極低(純粋関数) - -サーバー側でグレードフィルタリングを行った後、クライアント側では `isReplenished` による分割のみが必要になる。現在 `CurriculumWorkBookList.svelte` に inline で書かれているフィルタを純粋関数として抽出する。 - -> **命名根拠:** `main`(非補充)と `replenished`(補充)の両方が名前に現れる。`splitWorkbooksByReplenishment` は main 側の存在が不明だった。 - ---- - -## Task 1-A: 失敗するテストを書く - -**Files:** - -- Modify: `src/features/workbooks/utils/workbooks.test.ts` - -- [x] **Step 1: テストを追記** - -```typescript -import { partitionWorkbooksAsMainAndReplenished } from './workbooks'; -// 既存 import に追加 - -describe('partitionWorkbooksAsMainAndReplenished', () => { - const base = { - id: 1, - title: '', - workBookType: 'CURRICULUM' as const, - isPublished: true, - isOfficial: true, - authorId: 'u1', - authorName: 'u1', - description: '', - editorialUrl: '', - urlSlug: null, - createdAt: new Date(), - updatedAt: new Date(), - workBookTasks: [], - }; - - test('main contains non-replenished workbooks', () => { - const main = { ...base, id: 1, isReplenished: false }; - const replenished = { ...base, id: 2, isReplenished: true }; - const result = partitionWorkbooksAsMainAndReplenished([main, replenished]); - expect(result.main).toEqual([main]); - }); - - test('replenished contains replenished workbooks', () => { - const main = { ...base, id: 1, isReplenished: false }; - const replenished = { ...base, 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([]); - }); -}); -``` - -- [x] **Step 2: テストが失敗することを確認** - -```bash -pnpm test:unit -- workbooks.test -# FAIL: partitionWorkbooksAsMainAndReplenished is not a function -``` - ---- - -## Task 1-B: 実装 - -**Files:** - -- Modify: `src/features/workbooks/utils/workbooks.ts` - -- [x] **Step 1: 関数を追加(既存エクスポートの末尾)** - -```typescript -/** - * 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 partitionWorkbooksAsMainAndReplenished(workbooks: WorkbooksList): { - main: WorkbooksList; - replenished: WorkbooksList; -} { - return { - main: workbooks.filter((workbook) => !workbook.isReplenished), - replenished: workbooks.filter((workbook) => workbook.isReplenished), - }; -} -``` - -- [x] **Step 2: テストが通ることを確認** - -```bash -pnpm test:unit -- workbooks.test -# PASS -``` - -- [x] **Step 3: コミット** - -```bash -git add src/features/workbooks/utils/workbooks.ts \ - src/features/workbooks/utils/workbooks.test.ts -git commit -m "feat(workbooks/utils): Add partitionWorkbooksAsMainAndReplenished utility" -``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-10.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-10.md deleted file mode 100644 index d37e1b75a..000000000 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-10.md +++ /dev/null @@ -1,123 +0,0 @@ -# Phase 10: E2Eテスト更新 - -**レイヤー:** `e2e/` | **リスク:** 低 - ---- - -**Files:** - -- Modify: `e2e/workbooks_list.spec.ts` - -- [x] **Step 1: ファイルを読んで削除対象を確認** - -`activeWorkbookTabStore` / `task_grades_by_workbook_type` を前提としたテストを特定して削除する。 - -- [x] **Step 2: URLパラメータ関連テストと `created_by_user` テストを追加** - -> ラベル文字列(`'10Q'`, `'グラフ'` など)は `GRADE_LABELS` / `SOLUTION_LABELS` 定数と一致させること。実装前に `src/lib/types/task.ts` と `src/features/workbooks/types/workbook_placement.ts` を確認すること。 - -```typescript -import { test, expect } from '@playwright/test'; -import { TaskGrade } from '$lib/types/task'; -import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; -import { WorkBookTab } from '$features/workbooks/types/workbook'; - -// ---- タブ基本動作 ---- - -test('defaults to curriculum tab', async ({ page }) => { - await page.goto('/workbooks'); - await expect(page.getByRole('tab', { name: 'カリキュラム' })).toHaveAttribute( - 'aria-selected', - 'true', - ); -}); - -test('clicking solution tab updates URL to tab=solution', async ({ page }) => { - await page.goto('/workbooks'); - await page.getByRole('tab', { name: '解法別' }).click(); - await expect(page).toHaveURL(new RegExp(`tab=${WorkBookTab.SOLUTION}`)); -}); - -test('direct URL access to solution tab selects correct tab', async ({ page }) => { - await page.goto(`/workbooks?tab=${WorkBookTab.SOLUTION}&categories=${SolutionCategory.GRAPH}`); - await expect(page.getByRole('tab', { name: '解法別' })).toHaveAttribute('aria-selected', 'true'); -}); - -test('invalid tab param falls back to curriculum tab', async ({ page }) => { - await page.goto('/workbooks?tab=invalid'); - await expect(page.getByRole('tab', { name: 'カリキュラム' })).toHaveAttribute( - 'aria-selected', - 'true', - ); -}); - -// ---- カリキュラム グレードボタン → URL 更新 ---- - -const CURRICULUM_GRADE_CASES: { grade: TaskGrade; label: string }[] = [ - { grade: TaskGrade.Q10, label: '10Q' }, - { grade: TaskGrade.Q9, label: '9Q' }, - { grade: TaskGrade.Q8, label: '8Q' }, -]; - -for (const { grade, label } of CURRICULUM_GRADE_CASES) { - test(`curriculum grade button "${label}" updates URL to grades=${grade}`, async ({ page }) => { - await page.goto(`/workbooks?tab=${WorkBookTab.CURRICULUM}`); - await page.getByRole('button', { name: label }).click(); - await expect(page).toHaveURL(new RegExp(`grades=${grade}`)); - }); -} - -// ---- 解法別 カテゴリボタン → URL 更新 ---- - -const SOLUTION_CATEGORY_CASES: { category: SolutionCategory; label: string }[] = [ - { category: SolutionCategory.GRAPH, label: 'グラフ' }, - { category: SolutionCategory.DYNAMIC_PROGRAMMING, label: 'DP' }, - { category: SolutionCategory.SEARCH_SIMULATION, label: '探索・シミュレーション' }, -]; - -for (const { category, label } of SOLUTION_CATEGORY_CASES) { - test(`solution category button "${label}" updates URL to categories=${category}`, async ({ - page, - }) => { - await page.goto(`/workbooks?tab=${WorkBookTab.SOLUTION}`); - await page.getByRole('button', { name: label }).click(); - await expect(page).toHaveURL(new RegExp(`categories=${category}`)); - }); -} - -// ---- CREATED_BY_USER タブ(管理者専用) ---- - -test('admin can access created_by_user tab via URL', async ({ page, context }) => { - // 管理者としてログイン済みの状態を前提とする(fixtures / auth setup で設定) - await page.goto(`/workbooks?tab=${WorkBookTab.CREATED_BY_USER}`); - await expect(page.getByRole('tab', { name: 'ユーザ作成' })).toHaveAttribute( - 'aria-selected', - 'true', - ); -}); - -test('non-admin accessing created_by_user tab is redirected to /workbooks', async ({ page }) => { - // 一般ユーザとしてログイン済みの状態を前提とする - await page.goto(`/workbooks?tab=${WorkBookTab.CREATED_BY_USER}`); - await expect(page).toHaveURL('/workbooks'); -}); - -test('created_by_user tab is not visible to non-admin users', async ({ page }) => { - // 一般ユーザ: タブ自体が表示されていないことを確認 - await page.goto('/workbooks'); - await expect(page.getByRole('tab', { name: 'ユーザ作成' })).not.toBeVisible(); -}); -``` - -- [x] **Step 3: E2Eテスト実行** - -```bash -pnpm test:e2e -- --grep "workbooks" -``` - -- [x] **Step 4: コミット** - -```bash -git add e2e/workbooks_list.spec.ts -git commit -m "test(e2e/workbooks): Update tests for URL param-driven filtering and created_by_user tab" -``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-11.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-11.md deleted file mode 100644 index f15d748cd..000000000 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-11.md +++ /dev/null @@ -1,92 +0,0 @@ -# Phase 11: `/refactor-plan` → `/session-close` - -**リスク:** 低 | **前提:** Phase 10 まで完了・全テスト通過済み - -Phase 0–10 で変更したファイル全体に対して `/refactor-plan` スキルを実行し、見落とした改善点を体系的にリストアップする。その後 `/session-close` でセッションを締める。 - ---- - -## Task 11-A: `/refactor-plan` + CodeRabbit レビュー 実施結果 - -### simplify スキル(Task 11-A)の結果 - -- [x] **Step 1: `/refactor-plan` を実行** - -``` -/refactor-plan -``` - -対象パス(Phase 0–10 で変更・新規作成したファイル): - -- `src/features/workbooks/types/workbook.ts` -- `src/features/workbooks/utils/workbook_url_params.ts` -- `src/features/workbooks/utils/workbooks.ts` -- `src/features/workbooks/services/workbooks.ts` -- `src/routes/workbooks/+page.server.ts` -- `src/features/workbooks/components/list/SolutionWorkBookList.svelte` -- `src/features/workbooks/components/list/SolutionTable.svelte` -- `src/features/workbooks/components/list/CurriculumWorkBookList.svelte` -- `src/features/workbooks/components/list/WorkbookTabItem.svelte` -- `src/features/workbooks/components/list/WorkBookList.svelte` -- `src/routes/workbooks/+page.svelte` -- `e2e/workbooks_list.spec.ts` - -- [x] **Step 2: 生成された計画をユーザーにレビュー依頼** - -**結果:** `buildTaskResultsByWorkBookId()` が 3 回呼ばれていた問題を発見・修正(`$derived` に統合)。 -コミット: `dfb79211` `refactor(workbooks): compute taskResultsWithWorkBookId once via $derived` - ---- - -## Task 11-B: CodeRabbit AI レビュー - -- [x] **Step 1: CodeRabbit レビューを実行** - -```bash -coderabbit review --plain -``` - -- [x] **Step 2: 指摘を severity でトリアージ** - -17 件の指摘。トリアージ結果は以下の通り。 - -### 即時修正対象(potential_issue 5 件) - -| # | ファイル | 内容 | 性質 | -| --- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | `src/routes/workbooks/+page.svelte:28-32` | `loggedInUser`・`role`・`tasksMapByIds`・`taskResultsByTaskId` が `data` の初期値しか取らない | **潜在バグ**。現状の grade/category 切替では実害は少ないが、form action 後など `data` が更新された場合に古い値が表示され続ける可能性がある。Svelte 5 Runes のベストプラクティス違反でもある(`pnpm check` も WARNING を出力済み)。`$derived` に変更する | -| 2 | `src/routes/workbooks/+page.server.ts:42` | `loggedInUser?.role as Roles` が unsafe | **型安全性の問題**。認証ガード(SvelteKit hooks)が先に動くため実際は `null` にならないが、TypeScript はそれを知らない。`(!loggedInUser \|\| !isAdmin(loggedInUser.role))` に変更 | -| 3 | `src/routes/workbooks/+page.server.ts:54` | `loggedInUser?.id as string` が unsafe | **型安全性の問題**(2 と同じ背景)。`loggedInUser ? ... : Promise.resolve(new Map())` に変更 | -| 4 | `src/features/workbooks/components/list/SolutionWorkBookList.svelte:36-38` | `AVAILABLE_CATEGORIES` が `const` のため props 変化時に再計算されない | **リアクティビティバグ**。SvelteKit のナビゲーション時に `availableCategories` prop が変わっても画面が更新されない。`$derived` に変更 | -| 5 | `src/features/workbooks/types/workbook.ts:64` | `SolutionTableProps` のコメントが `//` | **コーディング規約違反**。エクスポート型には TSDoc(`/** */`)が必須 | - -### 実施結果(#6〜17) - -| # | ファイル | 対応 | 備考 | -| --- | ------------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------ | -| 6 | `WorkBookList.svelte:43-44` | ✅ 修正 | `as Roles` → `?? Roles.USER`(`import type` → `import` に変更) | -| 7 | `workbook_url_params.ts:78-93` | ⏭️ 差し戻し | `Exclude` が全呼び出し元に型エラーを連鎖。parse 関数の戻り値型から変えるべき大きな変更になる | -| 8 | `WorkBookList.svelte:61` | ✅ 修正 | `CreatedByUserTable` を `SolutionTableProps` に変更し `gradeModesEachWorkbook={new Map()}` を削除 | -| 9 | `phase-4.md:60-62` | ✅ 修正 | docs コード例を最新実装(null チェック分離)に合わせた | -| 10 | `e2e/workbooks_list.spec.ts:119-124` | ✅ 修正 | `VISIBILITY_CHECK_TIMEOUT = 3000` 定数を追加し全箇所を置き換え | -| 11 | `services/workbooks.ts:129-142` | ⏭️ 差し戻し | フィルタ削除でテスト "excludes null solutionCategory entries" が失敗。mock は WHERE 句を無視するため防御的フィルタが必要 | -| 12 | `utils/workbooks.ts:143-151` | ✅ 修正 | `filter` 2 回 → `reduce` 1 パスに変更 | -| 13 | `workbooks.test.ts:263-267` | ✅ 修正 | `mockWorkbookFindMany` 削除・`mockFindMany` に統合(引数型を `object[]` に緩和) | -| 14 | `phase-9.md:42-44` | ✅ 修正 | `git add -A` → `git add -u src/features/workbooks/stores/` | -| 15 | `phase-9.md:18-22` | ✅ 修正 | grep に `--exclude` を追加し削除対象ファイル自身がヒットしないよう修正 | -| 16 | `phase-8.md:196-205` | ✅ 修正 | インライン `buildTaskResultsByWorkBookId(...)` → `{taskResultsWithWorkBookId}` に更新 | -| 17 | `.gitignore:157-159` | ✅ 修正 | VS Code セクション(145 行目)に統合し重複行を削除 | - -コミット: `abecf45b` `fix(workbooks): address CodeRabbit findings from Phase 11 review` - ---- - -## Task 11-C: `/session-close` の実行 - -- [ ] **Step 1: `/session-close` を実行** - -``` -/session-close -``` - -セッション締め処理(plan チェックリスト更新・rule/skill 追加提案・bloat チェック・繰り返し指示の検出)を行う。 diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-12.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-12.md deleted file mode 100644 index 10780d374..000000000 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-12.md +++ /dev/null @@ -1,207 +0,0 @@ -# Phase 12: admin閲覧権限・URLフィルター状態保持・空状態表示 - -**リスク:** 低〜中 | **前提:** Phase 11 まで完了・全テスト通過済み - -Phase 11 完了後に発見した3つの不足仕様を追加実装する。 - ---- - -## 背景 - -1. **admin非公開閲覧**: カリキュラム/解法別タブでは `isPublished: true` のみ取得しているため、adminでも非公開問題集が見えない -2. **フィルター状態リセット**: グレード/カテゴリボタン選択後に別ページへ遷移し、ナビリンクで `/workbooks` に戻るとデフォルト値にリセットされる -3. **空状態ヘッダー**: ユーザ作成タブでDB上の件数が0でもテーブルヘッダーが表示される - -**確認済みの仕様:** - -- 非公開バッジ: `PublicationStatusLabel` は `TitleTableBodyCell.svelte` に既存 — コンポーネント変更不要 -- CREATED_BY_USER の `canRead`: adminが著者でない他ユーザーの非公開問題集は表示しない(現行動作を維持) -- URL復元: サイト内ナビリンク(パラメータなし `/workbooks`)への遷移のみが対象(ブラウザBack は既に動作中) - ---- - -## Task 12-A: サービス層 — adminに非公開問題集を返す - -**対象レイヤー:** service + route handler | **リスク:** 低 - -### 変更ファイル - -- `src/features/workbooks/services/workbooks.ts` -- `src/routes/workbooks/+page.server.ts` - -### テスト(先行実装) - -ファイル: `src/features/workbooks/services/workbooks.test.ts` - -- [ ] DBをモックして CURRICULUM placement に非公開問題集を返すように設定 -- [ ] `getWorkbooksByPlacement(query, false)` → 非公開を除外することを確認 -- [ ] `getWorkbooksByPlacement(query, true)` → 非公開を含むことを確認 - -### 実装 - -**`workbooks.ts`** — `getPublishedWorkbooksByPlacement()` を `getWorkbooksByPlacement()` にリネームし、省略可能な `includeUnpublished` 引数を追加: - -```typescript -export async function getWorkbooksByPlacement( - query: PlacementQuery, - includeUnpublished = false, -): Promise; -``` - -Prisma `where` の変更: - -```typescript -where: { - workBookType: query.workBookType, - ...(includeUnpublished ? {} : { isPublished: true }), - placement: placementFilter, -}, -``` - -**`+page.server.ts`** — import を `getWorkbooksByPlacement` に更新し、`fetchWorkbooksByTab()` に `includeUnpublished: boolean` 引数を追加、`load()` から admin フラグを渡す: - -```typescript -const adminUser = loggedInUser && isAdmin(loggedInUser.role as Roles); -fetchWorkbooksByTab(tab, selectedGrade, selectedCategory, !!adminUser); -``` - -### 動作確認 - -- `pnpm test:unit` でサービステストが通ること -- adminでログインして非公開のカリキュラム/解法別問題集が「非公開」バッジつきで表示される(該当データがある場合) - ---- - -## Task 12-B: 空状態 — ユーザ作成タブに EmptyWorkbookList を追加 - -**対象レイヤー:** component | **リスク:** 低 - -### 変更ファイル - -- `src/features/workbooks/components/list/CreatedByUserTable.svelte` - -### 実装 - -`CurriculumWorkBookList.svelte` と同じパターンで空チェックを追加する。 - -参照: `EmptyWorkbookList` は `src/features/workbooks/components/list/EmptyWorkbookList.svelte` に既存。 - -```svelte -{#if workbooks.length === 0} - -{:else} - -{/if} -``` - -### 動作確認 - -- ユーザ作成タブで問題集が0件のとき「該当する問題集は見つかりませんでした。」が表示される - ---- - -## Task 12-C: sessionStorageによるURLフィルター状態保持 - -**対象レイヤー:** page component(クライアントサイドのみ) | **リスク:** 中 - -### 変更ファイル - -- `src/routes/workbooks/+page.svelte` -- `e2e/workbooks_list.spec.ts` - -### 実装 - -`+page.svelte` の ` -``` - -`workbookGradeModes: _` の行を削除する。 - -- [x] **Step 2: 型チェック** - -```bash -pnpm check -``` - -- [x] **Step 3: コミット** - -```bash -git add src/features/workbooks/components/list/SolutionTable.svelte -git commit -m "refactor(workbooks/components): SolutionTable uses SolutionTableProps, removes unused workbookGradeModes" -``` - ---- - -## Task 5-C: `SolutionWorkBookList.svelte` を新規作成 - -**Files:** - -- Create: `src/features/workbooks/components/list/SolutionWorkBookList.svelte` - -- [x] **Step 1: コンポーネントを作成** - -```svelte - - -
- - {#each AVAILABLE_CATEGORIES as category} - - {/each} - -
- -{#if readableCount} - -{:else} - -{/if} -``` - -- [x] **Step 2: 型チェック** - -```bash -pnpm check -``` - -- [x] **Step 3: コミット** - -```bash -git add src/features/workbooks/components/list/SolutionWorkBookList.svelte -git commit -m "feat(workbooks/components): Add SolutionWorkBookList with category ButtonGroup and availableCategories filter" -``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-6.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-6.md deleted file mode 100644 index 2118bb5a1..000000000 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-6.md +++ /dev/null @@ -1,95 +0,0 @@ -# Phase 6: `CurriculumWorkBookList.svelte` リファクタリング - -**レイヤー:** `src/features/workbooks/components/list/` | **リスク:** 中 - -ストア依存を除去し、グレード選択状態を `currentGrade` prop で受け取るよう変更する。サーバー側でグレードフィルタリングが済んでいるため、`$derived` の `getGradeMode === selectedGrade` フィルタを削除し、`splitWorkbooksByReplenishment()` に置き換える。 - -> `getGradeMode` / `workbookGradeModes` は `CurriculumTable` のグレード列表示で引き続き使われるため **削除しない**。 - ---- - -**Files:** - -- Modify: `src/features/workbooks/components/list/CurriculumWorkBookList.svelte` - -- [x] **Step 1: ファイルを読んで現在の構造を確認** - -削除対象となる行を特定する: - -- `import { get } from 'svelte/store'` -- `import { taskGradesByWorkBookTypeStore } from '.../task_grades_by_workbook_type'` -- `WorkBookType` インポート(他で使われていなければ削除) -- `let selectedGrade = get(taskGradesByWorkBookTypeStore).get(...) ?? TaskGrade.Q10` -- `$effect()` ブロック全体(ストアとの同期) -- `taskGradesByWorkBookTypeStore.updateTaskGrade(...)` の呼び出し行 - -- [x] **Step 2: Props インターフェースを更新** - -```typescript -interface Props { - workbooks: WorkbooksList; - workbookGradeModes: Map; - taskResultsWithWorkBookId: Map; - userId: string; - role: Roles; - currentGrade: TaskGrade; - onGradeChange: (grade: TaskGrade) => void; -} - -let { - workbooks, - workbookGradeModes, - taskResultsWithWorkBookId, - userId, - role, - currentGrade, - onGradeChange, -}: Props = $props(); -``` - -- [x] **Step 3: `filterByGradeMode` をコールバック委譲に変更** - -```typescript -function filterByGradeMode(grade: TaskGrade) { - onGradeChange(grade); -} -``` - -- [x] **Step 4: `$derived` のグレードフィルタを `splitWorkbooksByReplenishment` に置き換え** - -```typescript -import { splitWorkbooksByReplenishment, ... } from '$features/workbooks/utils/workbooks'; - -// 変更前 -let mainWorkbooks: WorkbooksList = $derived( - workbooks.filter((workbook) => getGradeMode(workbook.id, workbookGradeModes) === selectedGrade && !workbook.isReplenished), -); -let replenishedWorkbooks: WorkbooksList = $derived( - workbooks.filter((workbook) => getGradeMode(workbook.id, workbookGradeModes) === selectedGrade && workbook.isReplenished), -); - -// 変更後(サーバー側でグレードフィルタ済み) -let { main: mainWorkbooks, replenished: replenishedWorkbooks } = $derived( - splitWorkbooksByReplenishment(workbooks), -); -``` - -- [x] **Step 5: ButtonGroup のアクティブ判定を `currentGrade` に変更** - -```svelte -class={currentGrade === grade ? 'text-primary-700 dark:text-primary-500!' : 'text-gray-900'} -``` - -- [x] **Step 6: 型チェック・ユニットテスト** - -```bash -pnpm check -pnpm test:unit -``` - -- [x] **Step 7: コミット** - -```bash -git add src/features/workbooks/components/list/CurriculumWorkBookList.svelte -git commit -m "refactor(workbooks/components): CurriculumWorkBookList uses grade prop+callback, removes store" -``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-7.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-7.md deleted file mode 100644 index 4dd711078..000000000 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-7.md +++ /dev/null @@ -1,52 +0,0 @@ -# Phase 7: `WorkbookTabItem.svelte` 簡素化 - -**レイヤー:** `src/features/workbooks/components/list/` | **リスク:** 低 - -`workbookType` prop と `activeWorkbookTabStore` への依存を除去し、タブクリック時の動作を `onclick` prop として親に委譲する。 - -> **ストア削除の根拠(Phase 9 への前置き):** -> `active_workbook_tab.ts` と `task_grades_by_workbook_type.ts` はいずれも Svelte v4 の `writable()` を使った **in-memory ストアのみ**(localStorage への永続化なし)。これらは URL パラメータに置き換えられるため Phase 9 で安全に削除できる。`replenishmentWorkBooksStore` のみが localStorage を使用しており、そちらは対象外。 - ---- - -**Files:** - -- Modify: `src/features/workbooks/components/list/WorkbookTabItem.svelte` - -- [x] **Step 1: ファイル全体を以下に置き換え** - -```svelte - - - - {@render children?.()} - -``` - -削除: `workbookType` prop、`activeWorkbookTabStore` インポートと呼び出し - -- [x] **Step 2: 型チェック** - -```bash -pnpm check -``` - -- [x] **Step 3: コミット** - -```bash -git add src/features/workbooks/components/list/WorkbookTabItem.svelte -git commit -m "refactor(workbooks/components): WorkbookTabItem removes store, exposes onclick prop" -``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-8.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-8.md deleted file mode 100644 index df6576245..000000000 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-8.md +++ /dev/null @@ -1,314 +0,0 @@ -# Phase 8: `WorkBookList.svelte` + `+page.svelte` 改修 - -**レイヤー:** `src/routes/workbooks/` + `src/features/workbooks/components/list/` | **リスク:** 中-高 - -**設計方針:** - -- `WorkBookList.svelte` の Props は discriminated union に変更する。optional props + `?? fallback` は型安全でない -- Svelte 5 では `let props: Props = $props()` として使い、`{#if props.workbookType === ...}` ブロックで TypeScript 型ナローイングを活用する(destructure すると narrowing が効かない) -- `workbookGradeModes` は CURRICULUM ブランチのみに配置(SOLUTION/CREATED_BY_USER では不要)。グレードフィルタリングはサーバーサイドに移るが、`CurriculumTable` のグレード列表示(``)で引き続き使われるため削除しない(参照: phase-6 注記) -- `CREATED_BY_USER` タブは URL ドリブン(`isCreatedByUserTabOpen` ローカル `$state` は不要) -- `userCreatedWorkbooks` は廃止。全タブとも `data.workbooks` を使用する - ---- - -## Task 8-A: `WorkBookList.svelte` に discriminated union Props と SOLUTION ルーティングを追加 - -**Files:** - -- Modify: `src/features/workbooks/components/list/WorkBookList.svelte` - -- [x] **Step 1: ファイルを読んで現在の Props / ルーティングを確認** - -- [x] **Step 2: Props を discriminated union に変更し SOLUTION 分岐を追加** - -```typescript -import { type SolutionCategory } from '$features/workbooks/types/workbook_placement'; -import { WorkBookTab, WorkBookType } from '$features/workbooks/types/workbook'; -import SolutionWorkBookList from './SolutionWorkBookList.svelte'; - -type CommonProps = { - workbooks: WorkbooksList; - taskResultsWithWorkBookId: Map; - loggedInUser: { id: string; role: Roles } | null; -}; - -type SpecificProps = - | { - workbookType: typeof WorkBookType.CURRICULUM; - workbookGradeModes: Map; - currentGrade: TaskGrade; - onGradeChange: (grade: TaskGrade) => void; - } - | { - workbookType: typeof WorkBookType.SOLUTION; - currentCategory: SolutionCategory; - availableCategories: SolutionCategory[]; - onCategoryChange: (category: SolutionCategory) => void; - } - | { workbookType: typeof WorkBookType.CREATED_BY_USER }; - -type Props = CommonProps & SpecificProps; - -let props: Props = $props(); -``` - -- [x] **Step 3: テンプレートを discriminated union に対応させる** - -```svelte -{#if props.workbookType === WorkBookType.CURRICULUM} - -{:else if props.workbookType === WorkBookType.SOLUTION} - -{:else} - -{/if} -``` - -- [x] **Step 4: 型チェック** - -```bash -pnpm check -``` - -- [x] **Step 5: コミット** - -```bash -git add src/features/workbooks/components/list/WorkBookList.svelte -git commit -m "refactor(workbooks/components): WorkBookList uses discriminated union Props, routes SOLUTION to SolutionWorkBookList" -``` - ---- - -## Task 8-B: `+page.svelte` 改修 - -**Files:** - -- Modify: `src/routes/workbooks/+page.svelte` - -- [x] **Step 1: スクリプトブロックを書き換え** - -```svelte - -``` - -- [x] **Step 2: テンプレートブロックを書き換え** - -```svelte -
- - - - {#if role === Roles.ADMIN} -
- -
- {/if} - - -
- - {#if loggedInUser} - handleTabChange(WorkBookTab.CURRICULUM)} - > -
- -
-
- - handleTabChange(WorkBookTab.SOLUTION)} - > -
- -
-
- - {#if isAdmin(role)} - handleTabChange(WorkBookTab.CREATED_BY_USER)} - > -
- -
-
- {/if} - {/if} -
-
-
-``` - -- [x] **Step 3: 型チェック** - -```bash -pnpm check -# エラーゼロを確認 -``` - -- [x] **Step 4: 開発サーバーで動作確認** - -```bash -pnpm dev -# 確認項目: -# - /workbooks → カリキュラムタブ・Q10 が表示される -# - グレードボタンクリック → URL が ?tab=curriculum&grades=Q9 に変わる(画面リロードなし) -# - 解法別タブクリック → URL が ?tab=solution&categories=SEARCH_SIMULATION に変わる -# - カテゴリボタンクリック → URL 更新・対応する問題集が表示される -# - 問題集が存在しないカテゴリのボタンが非表示 -# - /workbooks?tab=solution&categories=GRAPH に直アクセス → 正しく表示 -# - 管理者: /workbooks?tab=created_by_user → ユーザ作成タブが表示 -# - 一般ユーザ: /workbooks?tab=created_by_user → /workbooks にリダイレクト -# - 補充教材トグルが引き続き動作する -``` - -- [x] **Step 5: コミット** - -```bash -git add src/routes/workbooks/+page.svelte -git commit -m "feat(workbooks): URL-driven tab/filter navigation including CREATED_BY_USER tab for admins" -``` - ---- - -## Task 8-C: `workbookGradeModes` → `gradeModesEachWorkbook` リネーム - -**Files:** - -- Modify: `src/routes/workbooks/+page.svelte` -- Modify: `src/features/workbooks/components/list/WorkBookList.svelte` -- Modify: `src/features/workbooks/components/list/CurriculumWorkBookList.svelte` -- Modify: `src/features/workbooks/components/list/CurriculumTable.svelte` -- Modify: `src/features/workbooks/types/workbook.ts` - -- [x] **Step 1: 一括リネーム** - -```bash -# 影響ファイルを確認 -grep -r "workbookGradeModes" src/ -``` - -`workbookGradeModes` をすべて `gradeModesEachWorkbook` に置換する。 - -- [x] **Step 2: 型チェック** - -```bash -pnpm check -``` - -- [x] **Step 3: コミット** - -```bash -git add -p -git commit -m "refactor(workbooks): rename workbookGradeModes to gradeModesEachWorkbook" -``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-9.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-9.md deleted file mode 100644 index 5632b51a7..000000000 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/phase-9.md +++ /dev/null @@ -1,49 +0,0 @@ -# Phase 9: 不要ストア削除 - -**リスク:** 低 - -`task_grades_by_workbook_type.ts` は URLパラメータに置き換えられた。`active_workbook_tab.ts` は `+page.svelte` のローカル `$state` に置き換えられた。参照ゼロを確認してから削除する。 - ---- - -**Files:** - -- Delete: `src/features/workbooks/stores/task_grades_by_workbook_type.ts` -- Delete: `src/features/workbooks/stores/task_grades_by_workbook_type.test.ts` -- Delete: `src/features/workbooks/stores/active_workbook_tab.ts` -- Delete: `src/features/workbooks/stores/active_workbook_tab.test.ts` - -- [ ] **Step 1: 参照ゼロを確認** - -```bash -grep -r "task_grades_by_workbook_type\|active_workbook_tab" \ - src/ --include="*.ts" --include="*.svelte" \ - --exclude="task_grades_by_workbook_type.ts" \ - --exclude="task_grades_by_workbook_type.test.ts" \ - --exclude="active_workbook_tab.ts" \ - --exclude="active_workbook_tab.test.ts" -# 削除対象 4 ファイル以外の参照がゼロであることを確認してから次へ進む -``` - -- [ ] **Step 2: ファイル削除** - -```bash -rm src/features/workbooks/stores/task_grades_by_workbook_type.ts -rm src/features/workbooks/stores/task_grades_by_workbook_type.test.ts -rm src/features/workbooks/stores/active_workbook_tab.ts -rm src/features/workbooks/stores/active_workbook_tab.test.ts -``` - -- [ ] **Step 3: 型チェック・ユニットテスト** - -```bash -pnpm check -pnpm test:unit -``` - -- [ ] **Step 4: コミット** - -```bash -git add -u src/features/workbooks/stores/ -git commit -m "chore(workbooks): Remove stores replaced by URL params and local state" -``` diff --git a/docs/dev-notes/2026-03-20/workbooks-list-url-params/plan.md b/docs/dev-notes/2026-03-20/workbooks-list-url-params/plan.md index 02807fe6f..48a08c164 100644 --- a/docs/dev-notes/2026-03-20/workbooks-list-url-params/plan.md +++ b/docs/dev-notes/2026-03-20/workbooks-list-url-params/plan.md @@ -1,12 +1,8 @@ # 問題集一覧 URLパラメータフィルタリング 実装計画 -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - **Goal:** `/workbooks` ページで `WorkBookPlacement.priority` 順に問題集を表示し、URLパラメータ(`?tab=curriculum&grades=Q10` / `?tab=solution&categories=GRAPH` / `?tab=created_by_user`)でサーバーサイドフィルタリングを行う -**Architecture:** `+page.server.ts` でURLパラメータを解析し、タブに応じてサービス関数を呼び分ける。`CURRICULUM`/`SOLUTION` は `getPublishedWorkbooksByPlacement(query)` が `WorkBookPlacement` レコードで絞り込み・`priority ASC` ソートして返す。`CREATED_BY_USER` は `getWorkBooksCreatedByUsers()` を呼ぶ(管理者専用・非管理者は `FOUND` リダイレクト)。全タブとも単一 `workbooks` を返し、`userCreatedWorkbooks` は廃止。クライアントサイドのグレードフィルタリングを削除し、`goto()` + `buildWorkbooksUrl()` による SvelteKit クライアントサイドナビゲーションに置き換える。 - -**Tech Stack:** SvelteKit 2 + Svelte 5 Runes + TypeScript | Prisma (PostgreSQL) | Flowbite Svelte (ButtonGroup) | Vitest + Playwright +**Architecture:** `+page.server.ts` でURLパラメータを解析し、タブに応じてサービス関数を呼び分ける。`CURRICULUM`/`SOLUTION` は `getWorkbooksByPlacement(query)` が `WorkBookPlacement` レコードで絞り込み・`priority ASC` ソートして返す。`CREATED_BY_USER` は `getWorkBooksCreatedByUsers()` を呼ぶ(管理者専用・非管理者は `FOUND` リダイレクト)。全タブとも単一 `workbooks` を返す。クライアントサイドのグレードフィルタリングを削除し、`goto()` + `buildWorkbooksUrl()` による SvelteKit クライアントサイドナビゲーションに置き換える。 --- @@ -35,83 +31,80 @@ | 2 | `CREATED_BY_USER` は URL パラメータ管理(サーバーサイドフィルタリング) | ローカル `$state` での管理は URL の再現性がなく、URL 共有・直アクセスができない | | 3 | 非管理者が `?tab=created_by_user` にアクセスした場合は `redirect(FOUND, '/workbooks')` | 空データを返すより明示的なリダイレクトの方が UX として正しい | | 4 | `workbooks` / `userCreatedWorkbooks` を統合し単一 `workbooks` に | 両方を常に fetch するのはパフォーマンス上の無駄。タブに応じて1回だけ呼ぶ | -| 5 | タブ分岐は if/else を維持(strategy pattern / interface は使わない) | 3タブに対して strategy pattern は YAGNI | +| 5 | タブ分岐は `Record string>` ルックアップ(if-else を廃止) | 方針策定時は「if-else を維持」としたが refactor で撤回。各ラムダが call time にリアクティブな `data` を閉じ込めるため、`undefined` 非対称も解消 | | 6 | `buildPlacementQuery()` は `+page.server.ts` 内のプライベートヘルパーとして維持 | 重複なし・1箇所のみ使用。utils への移動は過剰 | -| 7 | `WorkBookList.svelte` Props は discriminated union に変更 | optional props + `?? fallback` は型安全でない。Svelte 5 では `let props: Props = $props()` で使い、`{#if}` ブロック内で TypeScript 型ナローイング | +| 7 | `WorkBookList.svelte` Props は discriminated union に変更 | optional props + `?? fallback` は型安全でない。Svelte 5 では `let props: Props = $props()` または rest spread + `...restProps` で discriminated union を維持 | | 8 | `workbookGradeModes` は discriminated union の CURRICULUM ブランチのみに配置 | `SolutionTable` は `workbookGradeModes: _` で破棄している。SOLUTION/CREATED_BY_USER では不要 | | 9 | `AVAILABLE_CATEGORIES` はサーバーサイドで `getAvailableSolutionCategories()` により判定 | クライアント側は現在選択中のカテゴリの問題集しか持たないため、他カテゴリの存在を知れない | | 10 | `partitionWorkbooksAsMainAndReplenished` に改名 | `splitWorkbooksByReplenishment` は main の存在が不明。両端(main/replenished)が名前に現れる方が直感的 | -| 11 | テスト内の `prisma.workBook.findMany.mockResolvedValue(...)` はヘルパー関数として切り出す | プロジェクト規約。類似パターンの重複を防ぐ | +| 11 | テスト内の mock パターンを `mockWorkbookFindMany()` 等のヘルパーとして切り出す | プロジェクト規約(testing.md §Mock Helpers)。類似パターンの重複を防ぐ | | 12 | `mapWithAuthorName()` をプライベート関数として切り出す | `getPublishedWorkbooksByPlacement` / `getWorkBooksCreatedByUsers` で同一の `map()` が重複 | --- -## ファイル構成 - -### 新規作成 +## 却下した設計 -| ファイル | 役割 | -| -------------------------------------------------------------------- | ---------------------------------------------- | -| `src/features/workbooks/utils/workbook_url_params.ts` | URLパラメータ解析・URL組み立てユーティリティ | -| `src/features/workbooks/utils/workbook_url_params.test.ts` | 上記のユニットテスト | -| `src/features/workbooks/components/list/SolutionWorkBookList.svelte` | 解法別カテゴリ選択 ButtonGroup + SolutionTable | +| # | 案 | 却下理由 | +| --- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| R1 | `getWorkBooks()` を削除 | `src/routes/sitemap.xml/+server.ts` で使用中。削除不可 | +| R2 | `` 以降の各タブを `{#snippet}` で切り出す | YAGNI。各タブはすでに `WorkBookList` コンポーネントに委譲済み。snippet 化は indirection を追加するだけ | +| R3 | `CurriculumWorkBookList.svelte` / `SolutionWorkBookList.svelte` をコンポーネント化 | 細部の差異(`size="sm"` の有無・ラベル取得関数・active 判定ロジック)があり、抽象化コストが 2 箇所のメリットを上回る | +| R4 | `fetchWorkbooksByTab` を `async` に変更 | `async` は `await` を使う場合のみ必要。Promise をそのまま返す設計は意図通り | +| R5 | E2E テスト: `for...of` を `test.each` 化 | Playwright にネイティブの `test.each` は存在しない。`for...of` ループが公式推奨のパラメータ化テストパターン | +| R6 | E2E テスト: 定数を `$features/` からインポート | `e2e/` は SvelteKit のパスエイリアスを解決しない(後述)。ローカル定数+参照コメントが正解 | -### 修正 +--- -| ファイル | 変更内容 | -| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `src/features/workbooks/types/workbook.ts` | `WorkBookTab` const オブジェクト追加(`CURRICULUM`/`SOLUTION`/`CREATED_BY_USER`)・`SolutionTableProps` 追加 | -| `src/routes/(admin)/workbooks/order/_types/kanban.ts` | `ActiveTab` を `WorkBookTab` の再エクスポートに変更 | -| `src/features/workbooks/utils/workbooks.ts` | `partitionWorkbooksAsMainAndReplenished()` 追加 | -| `src/features/workbooks/utils/workbooks.test.ts` | 上記テスト追加 | -| `src/features/workbooks/services/workbooks.ts` | `PlacementQuery` 型・`getPublishedWorkbooksByPlacement()` / `getWorkBooksCreatedByUsers()` / `getAvailableSolutionCategories()` 追加 | -| `src/features/workbooks/services/workbooks.test.ts` | 上記テスト追加 | -| `src/routes/workbooks/+page.server.ts` | URLパラメータ解析・タブ別サービス呼び出し・`CREATED_BY_USER` の admin ガード追加 | -| `src/features/workbooks/components/list/CurriculumWorkBookList.svelte` | ストア削除・`currentGrade` prop 化・`partitionWorkbooksAsMainAndReplenished` 使用 | -| `src/features/workbooks/components/list/WorkbookTabItem.svelte` | `workbookType` prop 削除・`onclick` prop 化 | -| `src/features/workbooks/components/list/WorkBookList.svelte` | discriminated union Props・SOLUTION → SolutionWorkBookList ルーティング追加 | -| `src/routes/workbooks/+page.svelte` | URL駆動タブ/フィルタ・CREATED_BY_USER も URL 管理 | -| `e2e/workbooks_list.spec.ts` | E2Eテスト更新 | +## 補足: SvelteKit `goto()` について -### 削除(Phase 9) +`$app/navigation` の `goto()` は Vue Router の `router.push()` に相当するクライアントサイドナビゲーション関数。`window.location` の変化(ブラウザリロード)は発生しないが、`+layout.svelte` が `{#if $navigating}` でスピナー表示するため UX 的にはリロード類似に見える。`$navigating` はサーバーから新しいデータが返るまで truthy のまま継続する。 -| ファイル | 理由 | -| ---------------------------------------------------------------------- | --------------------------------- | -| `src/features/workbooks/stores/task_grades_by_workbook_type.ts` + test | URLパラメータに置き換え | -| `src/features/workbooks/stores/active_workbook_tab.ts` + test | ローカル状態不要(URL管理に移行) | +**技術負債:** `+layout.svelte` が deprecated な `$app/stores` の `navigating` を使用中。SvelteKit 2.12+ では `$app/state` の `navigating` が推奨(本タスクのスコープ外)。 --- -## Phase 一覧 - -| Phase | ファイル | 内容 | リスク | -| ----- | ---------------------------- | ---------------------------------------------------------------------------------- | ------ | -| 0 | [phase-0.md](./phase-0.md) | `WorkBookTab` 型を feature types に追加・統一 | 極低 | -| 1 | [phase-1.md](./phase-1.md) | `partitionWorkbooksAsMainAndReplenished()` ユーティリティ | 極低 | -| 2 | [phase-2.md](./phase-2.md) | `workbook_url_params.ts` 解析・URL組み立て | 極低 | -| 3 | [phase-3.md](./phase-3.md) | `getPublishedWorkbooksByPlacement()` / `getAvailableSolutionCategories()` サービス | 中 | -| 4 | [phase-4.md](./phase-4.md) | `+page.server.ts` URLパラメータ対応 | 中 | -| 5 | [phase-5.md](./phase-5.md) | `SolutionWorkBookList.svelte` 新規作成 | 低-中 | -| 6 | [phase-6.md](./phase-6.md) | `CurriculumWorkBookList.svelte` リファクタリング | 中 | -| 7 | [phase-7.md](./phase-7.md) | `WorkbookTabItem.svelte` 簡素化 | 低 | -| 8 | [phase-8.md](./phase-8.md) | `WorkBookList.svelte` + `+page.svelte` 改修 | 中-高 | -| 9 | [phase-9.md](./phase-9.md) | 不要ストア削除 | 低 | -| 10 | [phase-10.md](./phase-10.md) | E2Eテスト更新 | 低 | -| 11 | [phase-11.md](./phase-11.md) | `/refactor-plan` → `/session-close` | 低 | -| 12 | [phase-12.md](./phase-12.md) | admin非公開閲覧・URLフィルター状態保持・ユーザ作成空状態表示 | 低〜中 | +## 教訓・意思決定記録 + +> 「分類」は発見のきっかけになりやすいカテゴリ。同じ分類でミスが続く場合は該当カテゴリの確認を計画レビューに組み込むこと。 + +| # | 分類 | 教訓 | +| --- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | 仕様確認 | 既存機能を「削除」として計画する前に「管理者のみに制限」という選択肢を確認する。「既存ユーザー向けに残す機能か」を確認してから削除を決める | +| 2 | 実装調査 | ストアの実装(localStorage vs in-memory)は仮定でなく実コードを読んで確認する。`task_grades_by_workbook_type` は in-memory の Svelte `writable()` のみ。localStorage を使うのは `replenishmentWorkBooksStore` だけ | +| 3 | 型設計 | SvelteKit では URLSearchParams を直接渡すパターンが標準。`string \| null` の引数型より `parseXxx(params: URLSearchParams)` の方がキャスト不要で安全(order ページの `parseInitialCategories(params)` が先例) | +| 4 | 型設計 | サービス引数の分岐は discriminated union(`PlacementQuery`)で型レベルに閉じ込める。optional + fallback は呼び出し側に条件分岐を散らばらせる | +| 5 | 仕様確認 | 管理者専用タブでも URL パラメータで状態管理する。URL の再現性・直アクセスのためには役割を問わず URL 駆動が正しい | +| 6 | 型設計 | optional props + `?? fallback` より discriminated union Props が型安全。`let { common1, common2, ...restProps }: Props = $props()` の rest spread で TypeScript の discriminated union narrowing が維持される | +| 7 | 型設計 | Props 設計前に使用先コンポーネントのソースを読む。`SolutionTable` が `workbookGradeModes: _` で破棄していることを見落とし、不要な prop を含めるミスを防ぐ | +| 8 | 型設計 | 型エイリアスを再エクスポートする前に消費側で `Record` として使われていないか確認する。`export type { WorkBookTab as ActiveTab }` と計画したが `Record` が `created_by_user` を要求してエラー。必要なら `Exclude` で絞り込む | +| 9 | スコープ | 実装コストが低く(サービス関数1つの追加)UX 価値が高い機能を安易にスコープ外にしない。「単に filtering するだけ」レベルの機能は同フェーズに含める | +| 10 | 実装調査 | `e2e/` ディレクトリは SvelteKit のパスエイリアス(`$lib`、`$features`)を解決しない。E2E テスト内では URL 文字列値を `const TAB_CURRICULUM = 'curriculum'` のようにローカル定数として定義し、型インポートは避ける | +| 11 | 仕様確認 | アクセス制御(タブ表示)とデータ可視性(非公開データの見え方)は別の仕様軸。アクセス制御を実装したら「見えるデータの範囲は何か」も明示的に確認する | +| 12 | 仕様確認 | URL パラメータでフィルター状態を実装したら「どこから戻ってくるか」を列挙する。ブラウザ Back は URL を復元するが、サイト内ナビリンクは `/workbooks`(パラメータなし)に遷移する | +| 13 | 実装品質 | UI パターン(空状態・ローディング等)を追加したら全タブ・全テーブルに適用されているか横断確認する。CURRICULUM/SOLUTION に `EmptyWorkbookList` を追加して CREATED_BY_USER を漏らしたミスから | +| 14 | テスト | `includeUnpublished = true` のテストで「キーが存在しない」ことを `expect(callArg?.where).not.toHaveProperty('isPublished')` で確認する。`toHaveBeenCalledWith` でキーを含まないことの確認は「キーは存在するが値が違う場合」を見落とす | +| 15 | 仕様確認 | sessionStorage 復元は「パラメータなし URL への遷移」のみに適用する(`window.location.search` が空のときだけ復元)。直アクセス(`?tab=curriculum`)やブラウザ Back(URL 復元済み)と衝突しない。復元ロジックを追加するたびに「どの遷移パターンが対象か・対象外か」を列挙すること | +| 16 | UI実装 | Flowbite `ButtonGroup` はレスポンシブ折り返し非対応(内部的に `flex`)。折り返しが必要な場合は `
` + 個別 `Button` に切り替える(`TaskTable.svelte` が先例) | +| 17 | UI実装 | Tailwind v4 の important 修飾子は `dark:text-primary-500!`(v4形式)。v3 形式の `dark:!text-primary-500` は NG。IDE の `suggestCanonicalClasses` 警告で気づける | +| 18 | Svelte | Svelte 5 コンポーネントの ` -{#if workbooks.length === 0} +{#if visibleCount === 0} {:else}
diff --git a/src/features/workbooks/components/list/CurriculumWorkBookList.svelte b/src/features/workbooks/components/list/CurriculumWorkBookList.svelte index ea0c1f3d4..034998bdb 100644 --- a/src/features/workbooks/components/list/CurriculumWorkBookList.svelte +++ b/src/features/workbooks/components/list/CurriculumWorkBookList.svelte @@ -72,7 +72,7 @@
- {#each AVAILABLE_GRADES as grade} + {#each AVAILABLE_GRADES as grade (grade)}