From e0c84fe10c5d6d23954c13e51bf7a128f0398457 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Mon, 25 May 2026 08:23:42 +0900 Subject: [PATCH 1/4] feat(workbooks): show relative evaluation badge on workbook detail page Fetch vote grade statistics alongside task results in parallel and display RelativeEvaluationBadge on each task's grade icon when the task has a confirmed grade and a known vote median. Co-Authored-By: Claude Sonnet 4.6 --- src/routes/workbooks/[slug]/+page.server.ts | 10 ++++++---- src/routes/workbooks/[slug]/+page.svelte | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/routes/workbooks/[slug]/+page.server.ts b/src/routes/workbooks/[slug]/+page.server.ts index 2d5ef1f40..4d95ba69a 100644 --- a/src/routes/workbooks/[slug]/+page.server.ts +++ b/src/routes/workbooks/[slug]/+page.server.ts @@ -6,6 +6,7 @@ import type { TaskResult } from '$lib/types/task'; import * as taskResultsCrud from '$lib/services/task_results'; import { getWorkbookWithAuthor } from '$features/workbooks/services/workbooks'; import * as action from '$lib/actions/update_task_result'; +import { getVoteGradeStatistics } from '$features/votes/services/vote_statistics'; import { isAdmin, canRead } from '$lib/utils/authorship'; import { getLoggedInUser } from '$features/auth/services/session'; @@ -36,16 +37,17 @@ export async function load({ locals, params, url }) { error(FORBIDDEN, `問題集id: ${slug} にアクセスする権限がありません。`); } - const taskResults: Map = await taskResultsCrud.getTaskResultsByTaskId( - workBook.workBookTasks, - loggedInUser?.id as string, - ); + const [taskResults, voteStatisticsMap] = await Promise.all([ + taskResultsCrud.getTaskResultsByTaskId(workBook.workBookTasks, loggedInUser?.id as string), + getVoteGradeStatistics(), + ]); return { isLoggedIn: loggedInUser !== null, loggedInAsAdmin: loggedInAsAdmin, ...workbookWithAuthor, taskResults: taskResults, + voteStatisticsMap: voteStatisticsMap, }; } diff --git a/src/routes/workbooks/[slug]/+page.svelte b/src/routes/workbooks/[slug]/+page.svelte index 87152b3d2..3cfbe519e 100644 --- a/src/routes/workbooks/[slug]/+page.svelte +++ b/src/routes/workbooks/[slug]/+page.svelte @@ -18,6 +18,7 @@ import ExternalLinkWrapper from '$lib/components/ExternalLinkWrapper.svelte'; import PublicationStatusLabel from '$features/workbooks/components/shared/PublicationStatusLabel.svelte'; import CommentAndHint from '$features/workbooks/components/detail/CommentAndHint.svelte'; + import RelativeEvaluationBadge from '$features/votes/components/RelativeEvaluationBadge.svelte'; import { getBackgroundColorFrom } from '$lib/services/submission_status'; @@ -33,6 +34,7 @@ let workBook = data.workBook; let workBookTasks: WorkBookTaskBase[] = $state([]); let taskResults: Map = $derived(data.taskResults); + let voteStatisticsMap = $derived(data.voteStatisticsMap); let isLoggedIn = data.isLoggedIn; @@ -157,6 +159,8 @@ {#each workBookTasks as workBookTask (workBookTask.taskId)} + {@const taskGrade = getTaskGrade(workBookTask.taskId)} + {@const statsEntry = voteStatisticsMap?.get(workBookTask.taskId)}
- +
+ + {#if taskGrade !== TaskGrade.PENDING && statsEntry} + + {/if} +
From 71735fad8198226224beed5248a4db1d2f48afab Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Mon, 25 May 2026 09:22:27 +0900 Subject: [PATCH 2/4] perf(votes): filter vote statistics by workbook task IDs Replace the full-table getVoteGradeStatistics() with a new getVoteGradeStatisticsForTaskIds() that issues a WHERE IN query, avoiding a full scan of votedGradeStatistics on each workbook page load. Co-Authored-By: Claude Sonnet 4.6 --- .../votes/services/vote_statistics.test.ts | 43 +++++++++++++++++++ .../votes/services/vote_statistics.ts | 9 ++++ src/routes/workbooks/[slug]/+page.server.ts | 5 ++- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/features/votes/services/vote_statistics.test.ts b/src/features/votes/services/vote_statistics.test.ts index ea3113f30..13e4073d6 100644 --- a/src/features/votes/services/vote_statistics.test.ts +++ b/src/features/votes/services/vote_statistics.test.ts @@ -4,6 +4,7 @@ import { TaskGrade } from '@prisma/client'; import { getVoteGradeStatistics, + getVoteGradeStatisticsForTaskIds, getAllTasksWithVoteInfo, getVoteCountersByTaskId, getVoteStatsByTaskId, @@ -290,3 +291,45 @@ describe('getAllVoteCounters', () => { expect(prisma.votedGradeCounter.findMany).toHaveBeenCalledWith(); }); }); + +describe('getVoteGradeStatisticsForTaskIds', () => { + test('queries with a WHERE IN filter for the given taskIds', async () => { + mockVotedGradeStatisticsFindMany([]); + + await getVoteGradeStatisticsForTaskIds(['abc001_a', 'abc002_a']); + + expect(prisma.votedGradeStatistics.findMany).toHaveBeenCalledWith({ + where: { taskId: { in: ['abc001_a', 'abc002_a'] } }, + }); + }); + + test('returns an empty Map when no matching statistics exist', async () => { + mockVotedGradeStatisticsFindMany([]); + + const result = await getVoteGradeStatisticsForTaskIds(['abc001_a']); + + expect(result.size).toBe(0); + }); + + test('maps each taskId to its statistics record', async () => { + const stat = makeStatisticsRecord({ taskId: 'abc001_a', grade: TaskGrade.Q5 }); + mockVotedGradeStatisticsFindMany([stat]); + + const result = await getVoteGradeStatisticsForTaskIds(['abc001_a']); + + expect(result.get('abc001_a')?.grade).toBe(TaskGrade.Q5); + }); + + test('returns only statistics for the specified taskIds', async () => { + const records = [ + makeStatisticsRecord({ taskId: 'abc001_a', grade: TaskGrade.Q5 }), + makeStatisticsRecord({ id: 'stats-abc002_a', taskId: 'abc002_a', grade: TaskGrade.D1 }), + ]; + mockVotedGradeStatisticsFindMany(records); + + const result = await getVoteGradeStatisticsForTaskIds(['abc001_a', 'abc002_a']); + + expect(result.size).toBe(2); + expect(result.get('abc002_a')?.grade).toBe(TaskGrade.D1); + }); +}); diff --git a/src/features/votes/services/vote_statistics.ts b/src/features/votes/services/vote_statistics.ts index 9149fc664..acedf4f45 100644 --- a/src/features/votes/services/vote_statistics.ts +++ b/src/features/votes/services/vote_statistics.ts @@ -24,6 +24,15 @@ export async function getVoteGradeStatistics(): Promise> { + const stats = await prisma.votedGradeStatistics.findMany({ + where: { taskId: { in: taskIds } }, + }); + return new Map(stats.map((s) => [s.taskId, s])); +} + export async function getAllTasksWithVoteInfo(): Promise { const [allTasks, stats, counters] = await Promise.all([ prisma.task.findMany({ orderBy: { task_id: 'desc' } }), diff --git a/src/routes/workbooks/[slug]/+page.server.ts b/src/routes/workbooks/[slug]/+page.server.ts index 4d95ba69a..ea5ba8e55 100644 --- a/src/routes/workbooks/[slug]/+page.server.ts +++ b/src/routes/workbooks/[slug]/+page.server.ts @@ -6,7 +6,7 @@ import type { TaskResult } from '$lib/types/task'; import * as taskResultsCrud from '$lib/services/task_results'; import { getWorkbookWithAuthor } from '$features/workbooks/services/workbooks'; import * as action from '$lib/actions/update_task_result'; -import { getVoteGradeStatistics } from '$features/votes/services/vote_statistics'; +import { getVoteGradeStatisticsForTaskIds } from '$features/votes/services/vote_statistics'; import { isAdmin, canRead } from '$lib/utils/authorship'; import { getLoggedInUser } from '$features/auth/services/session'; @@ -37,9 +37,10 @@ export async function load({ locals, params, url }) { error(FORBIDDEN, `問題集id: ${slug} にアクセスする権限がありません。`); } + const taskIds = workBook.workBookTasks.map((t) => t.taskId); const [taskResults, voteStatisticsMap] = await Promise.all([ taskResultsCrud.getTaskResultsByTaskId(workBook.workBookTasks, loggedInUser?.id as string), - getVoteGradeStatistics(), + getVoteGradeStatisticsForTaskIds(taskIds), ]); return { From dc1c21659ec1c438eb2c533833cf4486ef2fb2b0 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Mon, 25 May 2026 09:24:39 +0900 Subject: [PATCH 3/4] fix(workbooks): remove unused TaskResult import from page server --- src/routes/workbooks/[slug]/+page.server.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/routes/workbooks/[slug]/+page.server.ts b/src/routes/workbooks/[slug]/+page.server.ts index ea5ba8e55..599213f9b 100644 --- a/src/routes/workbooks/[slug]/+page.server.ts +++ b/src/routes/workbooks/[slug]/+page.server.ts @@ -1,7 +1,6 @@ import { error, type Actions } from '@sveltejs/kit'; import { Roles } from '$lib/types/user'; -import type { TaskResult } from '$lib/types/task'; import * as taskResultsCrud from '$lib/services/task_results'; import { getWorkbookWithAuthor } from '$features/workbooks/services/workbooks'; From 89704804c1484ee513385ab37366eafc3b0cfde2 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Mon, 25 May 2026 10:39:56 +0900 Subject: [PATCH 4/4] perf(votes): skip DB query when taskIds is empty in getVoteGradeStatisticsForTaskIds Co-Authored-By: Claude Sonnet 4.6 --- src/features/votes/services/vote_statistics.test.ts | 7 +++++++ src/features/votes/services/vote_statistics.ts | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/src/features/votes/services/vote_statistics.test.ts b/src/features/votes/services/vote_statistics.test.ts index 13e4073d6..89d32000d 100644 --- a/src/features/votes/services/vote_statistics.test.ts +++ b/src/features/votes/services/vote_statistics.test.ts @@ -293,6 +293,13 @@ describe('getAllVoteCounters', () => { }); describe('getVoteGradeStatisticsForTaskIds', () => { + test('returns an empty Map without querying the DB when taskIds is empty', async () => { + const result = await getVoteGradeStatisticsForTaskIds([]); + + expect(result.size).toBe(0); + expect(prisma.votedGradeStatistics.findMany).not.toHaveBeenCalled(); + }); + test('queries with a WHERE IN filter for the given taskIds', async () => { mockVotedGradeStatisticsFindMany([]); diff --git a/src/features/votes/services/vote_statistics.ts b/src/features/votes/services/vote_statistics.ts index acedf4f45..e84483c50 100644 --- a/src/features/votes/services/vote_statistics.ts +++ b/src/features/votes/services/vote_statistics.ts @@ -27,6 +27,10 @@ export async function getVoteGradeStatistics(): Promise> { + if (taskIds.length === 0) { + return new Map(); + } + const stats = await prisma.votedGradeStatistics.findMany({ where: { taskId: { in: taskIds } }, });