From a684dbd1fdf6134119413041166e5b2eee0e671d Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Fri, 13 Mar 2026 07:52:54 +0900 Subject: [PATCH 01/26] =?UTF-8?q?add:=20schema.prisma=E3=81=AB=E6=8A=95?= =?UTF-8?q?=E7=A5=A8=E6=A9=9F=E8=83=BD=E3=81=AB=E5=BF=85=E8=A6=81=E3=81=AA?= =?UTF-8?q?=E3=83=86=E3=83=BC=E3=83=96=E3=83=AB=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/schema.prisma | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a0edd4aee..340477ba3 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -236,6 +236,44 @@ model WorkBookTask { @@map("workbooktask") } +model VoteGrade { + id String @id @default(uuid()) + userId String + taskId String + grade TaskGrade + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(references: [id], fields: [userId]) + task Task @relation(references: [task_id], fields: [taskId]) + + @@unique([userId, taskId]) + @@map("votegrade") +} + +model VotedGradeCounter { + id String @id @default(uuid()) + taskId String + grade TaskGrade + count Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("votedgradecounter") +} + +model VotedGradeStatistics { + id String @id @default(uuid()) + taskId String + grade TaskGrade + isExperimental Boolean @default(false) + isApproved Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("votedgradestatistics") +} + enum ContestType { ABC // AtCoder Beginner Contest APG4B // C++入門 AtCoder Programming Guide for beginners From 068e6b972d451f07f7bfc5fb81267a90b2030aac Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Sun, 15 Mar 2026 09:58:23 +0900 Subject: [PATCH 02/26] =?UTF-8?q?fix:=20relation=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E4=B8=8D=E8=B6=B3=EF=BC=86=E3=83=9E=E3=82=A4=E3=82=B0?= =?UTF-8?q?=E3=83=AC=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E5=BF=98=E3=82=8C?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/ERD.md | 36 +++++++++++++++ .../migration.sql | 45 +++++++++++++++++++ prisma/schema.prisma | 2 + 3 files changed, 83 insertions(+) create mode 100644 prisma/migrations/20260315003958_add_vote_table/migration.sql diff --git a/prisma/ERD.md b/prisma/ERD.md index d60e4edc5..39c8222d0 100644 --- a/prisma/ERD.md +++ b/prisma/ERD.md @@ -237,6 +237,37 @@ ANALYSIS ANALYSIS DateTime updatedAt } + + "votegrade" { + String id "🗝️" + String userId + String taskId + TaskGrade grade + DateTime createdAt + DateTime updatedAt + } + + + "votedgradecounter" { + String id "🗝️" + String taskId + TaskGrade grade + Int count + DateTime createdAt + DateTime updatedAt + } + + + "votedgradestatistics" { + String id "🗝️" + String taskId + TaskGrade grade + Boolean isExperimental + Boolean isApproved + DateTime createdAt + DateTime updatedAt + } + "user" |o--|| "Roles" : "enum:role" "session" }o--|| user : "user" "key" }o--|| user : "user" @@ -255,4 +286,9 @@ ANALYSIS ANALYSIS "workbookplacement" |o--|| workbook : "workBook" "workbooktask" }o--|| workbook : "workBook" "workbooktask" }o--|| task : "task" + "votegrade" |o--|| "TaskGrade" : "enum:grade" + "votegrade" }o--|| user : "user" + "votegrade" }o--|| task : "task" + "votedgradecounter" |o--|| "TaskGrade" : "enum:grade" + "votedgradestatistics" |o--|| "TaskGrade" : "enum:grade" ``` diff --git a/prisma/migrations/20260315003958_add_vote_table/migration.sql b/prisma/migrations/20260315003958_add_vote_table/migration.sql new file mode 100644 index 000000000..f4ee8829b --- /dev/null +++ b/prisma/migrations/20260315003958_add_vote_table/migration.sql @@ -0,0 +1,45 @@ +-- CreateTable +CREATE TABLE "votegrade" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "grade" "TaskGrade" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "votegrade_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "votedgradecounter" ( + "id" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "grade" "TaskGrade" NOT NULL, + "count" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "votedgradecounter_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "votedgradestatistics" ( + "id" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "grade" "TaskGrade" NOT NULL, + "isExperimental" BOOLEAN NOT NULL DEFAULT false, + "isApproved" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "votedgradestatistics_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "votegrade_userId_taskId_key" ON "votegrade"("userId", "taskId"); + +-- AddForeignKey +ALTER TABLE "votegrade" ADD CONSTRAINT "votegrade_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "votegrade" ADD CONSTRAINT "votegrade_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "task"("task_id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 340477ba3..2ef912878 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -55,6 +55,7 @@ model User { key Key[] taskAnswer TaskAnswer[] workBooks WorkBook[] + voteGrade VoteGrade[] @@map("user") } @@ -104,6 +105,7 @@ model Task { tags TaskTag[] task_answers TaskAnswer[] workBookTasks WorkBookTask[] + voteGrade VoteGrade[] @@map("task") } From 8f45a8d835765a6a500da58d9fcf7643e265eb7c Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Fri, 20 Mar 2026 01:52:05 +0900 Subject: [PATCH 03/26] =?UTF-8?q?add:=20=E6=8A=95=E7=A5=A8=E7=94=A8?= =?UTF-8?q?=E3=83=89=E3=83=AD=E3=83=83=E3=83=97=E3=83=80=E3=82=A6=E3=83=B3?= =?UTF-8?q?=E3=82=92=E5=87=BA=E3=81=99=E6=A9=9F=E8=83=BD=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contest-table/TaskTableBodyCell.svelte | 11 +-- .../contest-table/VotableGrade.svelte | 68 +++++++++++++++++++ 2 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 src/features/tasks/components/contest-table/VotableGrade.svelte diff --git a/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte b/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte index 662f74948..36a2aec74 100644 --- a/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte +++ b/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte @@ -1,7 +1,7 @@ + + +
+ + + + +
+ + + + + {#if isLoggedIn} + {#each nonPendingGradeNames as grade} + +
+ {grade} +
+
+ {/each} + {:else} + アカウント作成 + + ログイン + {/if} +
\ No newline at end of file From 0646e35b895dde9862e447814fba86443b21de14 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Fri, 20 Mar 2026 10:27:20 +0900 Subject: [PATCH 04/26] =?UTF-8?q?fix:=20=E6=9C=AA=E3=83=AD=E3=82=B0?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E6=99=82=E3=81=AB=E3=83=89=E3=83=AD=E3=83=83?= =?UTF-8?q?=E3=83=97=E3=83=80=E3=82=A6=E3=83=B3=E3=81=AE=E3=83=AC=E3=82=A4?= =?UTF-8?q?=E3=82=A2=E3=82=A6=E3=83=88=E3=81=8C=E5=B4=A9=E3=82=8C=E3=82=8B?= =?UTF-8?q?=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contest-table/VotableGrade.svelte | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/features/tasks/components/contest-table/VotableGrade.svelte b/src/features/tasks/components/contest-table/VotableGrade.svelte index 174ee6320..8fc9043ff 100644 --- a/src/features/tasks/components/contest-table/VotableGrade.svelte +++ b/src/features/tasks/components/contest-table/VotableGrade.svelte @@ -45,12 +45,12 @@ - - {#if isLoggedIn} +{#if isLoggedIn} + {#each nonPendingGradeNames as grade}
{/each} - {:else} - アカウント作成 - - ログイン - {/if} - \ No newline at end of file + +{:else} + + アカウント作成 + + ログイン + +{/if} \ No newline at end of file From d608c593cfbaec54711092d2d9e2217e93aa898e Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Fri, 20 Mar 2026 15:33:43 +0900 Subject: [PATCH 05/26] =?UTF-8?q?add:=20=E9=81=B8=E6=8A=9E=E3=81=97?= =?UTF-8?q?=E3=81=9F=E3=82=B0=E3=83=AC=E3=83=BC=E3=83=89=E3=82=92=E3=82=B3?= =?UTF-8?q?=E3=83=B3=E3=82=BD=E3=83=BC=E3=83=AB=E3=81=AB=E5=87=BA=E5=8A=9B?= =?UTF-8?q?=E3=81=99=E3=82=8B=E6=A9=9F=E8=83=BD=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contest-table/TaskTableBodyCell.svelte | 2 +- .../contest-table/VotableGrade.svelte | 114 ++++++++++++++++-- src/routes/problems/+page.server.ts | 5 + 3 files changed, 109 insertions(+), 12 deletions(-) diff --git a/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte b/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte index 36a2aec74..7d7be6d30 100644 --- a/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte +++ b/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte @@ -29,7 +29,7 @@
{#snippet taskGradeLabel(taskResult: TaskResult)} - + {/snippet} {#snippet taskTitleAndExternalLink(taskResult: TaskResult, isShownTaskIndex: boolean)} diff --git a/src/features/tasks/components/contest-table/VotableGrade.svelte b/src/features/tasks/components/contest-table/VotableGrade.svelte index 8fc9043ff..a311121fa 100644 --- a/src/features/tasks/components/contest-table/VotableGrade.svelte +++ b/src/features/tasks/components/contest-table/VotableGrade.svelte @@ -1,27 +1,89 @@ - +
{#each nonPendingGradeNames as grade} - + handleClick(grade)} class="rounded-md">
@@ -71,4 +133,34 @@ ログイン -{/if} \ No newline at end of file +{/if} + +{#if showForm && selectedVoteGrade} + {@render voteGradeForm(taskResult, selectedVoteGrade)} +{/if} + +{#snippet voteGradeForm(selectedTaskResult: TaskResult, voteGrade: string)} +
{/each} diff --git a/src/features/votes/services/absolute_vote_results.ts b/src/features/votes/services/absolute_vote_results.ts deleted file mode 100644 index 99acebc87..000000000 --- a/src/features/votes/services/absolute_vote_results.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { default as db } from '$lib/server/database'; -import * as vote_crud from '$features/votes/services/absolute_votes'; - -export async function updateAbsoluteVoteResult(taskId: string, grade: string, userId: string) { - await db.$transaction(async () => { - await vote_crud.upsertVoteGrade(taskId, userId, grade); - }); -} \ No newline at end of file diff --git a/src/features/votes/services/absolute_votes.ts b/src/features/votes/services/vote_table_manager.ts similarity index 65% rename from src/features/votes/services/absolute_votes.ts rename to src/features/votes/services/vote_table_manager.ts index 6d9dac232..da4552d71 100644 --- a/src/features/votes/services/absolute_votes.ts +++ b/src/features/votes/services/vote_table_manager.ts @@ -2,7 +2,25 @@ import { default as prisma } from '$lib/server/database'; import { TaskGrade, type VoteGrade } from '@prisma/client'; import { sha256 } from '$lib/utils/hash'; -export async function upsertVoteGrade(taskId: string, userId: string, grade: string) { +export async function getVoteGrade(userId: string, taskId: string) { + const res = await prisma.voteGrade.findUnique({ + where: { + userId_taskId: { userId: userId, taskId: taskId }, + }, + }); + let voted = false; + let grade = null; + if(res !== null){ + voted = true; + grade = res.grade; + } + return { + voted: voted, + grade: grade, + }; +} + +export async function upsertVoteGrade(userId: string, taskId: string, grade: string) { try { const id = await sha256(taskId + userId); const newVoteGrade: VoteGrade = { diff --git a/src/routes/problems/+page.server.ts b/src/routes/problems/+page.server.ts index 434a8398f..9a2a82b13 100644 --- a/src/routes/problems/+page.server.ts +++ b/src/routes/problems/+page.server.ts @@ -4,7 +4,7 @@ import * as crud from '$lib/services/task_results'; import type { TaskResults } from '$lib/types/task'; import { Roles } from '$lib/types/user'; import { updateTaskResult } from '$lib/actions/update_task_result'; -import { updateAbsoluteVoteResult } from '@/features/votes/actions/update_absolute_vote_result'; +import { voteAbsoluteGrade } from '@/features/votes/actions/vote_actions'; // 一覧表ページは、ログインしていなくても閲覧できるようにする export async function load({ locals, url }) { @@ -38,6 +38,6 @@ export const actions = { }, voteAbsoluteGrade: async ({request, locals}) => { const operationLog = 'problems -> actions -> voteAbsoluteGrade'; - return await updateAbsoluteVoteResult({ request, locals }, operationLog); + await voteAbsoluteGrade({ request, locals }, operationLog); }, } satisfies Actions; diff --git a/src/routes/problems/getMyVote/+server.ts b/src/routes/problems/getMyVote/+server.ts new file mode 100644 index 000000000..e5d69df2f --- /dev/null +++ b/src/routes/problems/getMyVote/+server.ts @@ -0,0 +1,13 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { getVoteGrade } from '@/features/votes/services/vote_table_manager'; + +export const GET: RequestHandler = async ({ url, locals }) => { + const taskId = url.searchParams.get('taskId'); + if (!taskId) return json({ error: 'taskId required' }, { status: 400 }); + + const session = await locals.auth.validate(); + if (!session || !session.user || !session.user.userId) return json({ error: 'unauthorized' }, { status: 401 }); + + const res = await getVoteGrade(session.user.userId, taskId); + return json(res); +}; \ No newline at end of file From cc06b0c653fad74107969318e75e05fbdabf72db Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Sun, 22 Mar 2026 13:41:35 +0900 Subject: [PATCH 08/26] =?UTF-8?q?add:=20=E7=A5=A8=E3=81=AE=E7=B5=B1?= =?UTF-8?q?=E8=A8=88=E3=83=87=E3=83=BC=E3=82=BF=E5=8C=96=E3=82=92=E5=AE=9F?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/ERD.md | 6 +- .../migration.sql | 17 +++ .../migration.sql | 14 ++ prisma/schema.prisma | 8 +- src/features/votes/actions/vote_actions.ts | 2 +- .../votes/components/VotableGrade.svelte | 1 - .../votes/services/vote_table_manager.ts | 141 ++++++++++++++---- 7 files changed, 154 insertions(+), 35 deletions(-) create mode 100644 prisma/migrations/20260321164035_change_voted_grade_counter/migration.sql create mode 100644 prisma/migrations/20260322043315_change_some_vote_tables_decorator/migration.sql diff --git a/prisma/ERD.md b/prisma/ERD.md index 95c58f9a5..08f2b1270 100644 --- a/prisma/ERD.md +++ b/prisma/ERD.md @@ -249,9 +249,9 @@ ANALYSIS ANALYSIS "votedgradecounter" { - String id "🗝️" - String taskId - TaskGrade grade + String id + String taskId "🗝️" + TaskGrade grade "🗝️" Int count DateTime createdAt DateTime updatedAt diff --git a/prisma/migrations/20260321164035_change_voted_grade_counter/migration.sql b/prisma/migrations/20260321164035_change_voted_grade_counter/migration.sql new file mode 100644 index 000000000..28895b8fc --- /dev/null +++ b/prisma/migrations/20260321164035_change_voted_grade_counter/migration.sql @@ -0,0 +1,17 @@ +/* + Warnings: + + - The primary key for the `votedgradecounter` table will be changed. If it partially fails, the table could be left without primary key constraint. + - A unique constraint covering the columns `[id]` on the table `votedgradecounter` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[taskId,grade]` on the table `votedgradecounter` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "votedgradecounter" DROP CONSTRAINT "votedgradecounter_pkey", +ADD CONSTRAINT "votedgradecounter_pkey" PRIMARY KEY ("taskId", "grade"); + +-- CreateIndex +CREATE UNIQUE INDEX "votedgradecounter_id_key" ON "votedgradecounter"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "votedgradecounter_taskId_grade_key" ON "votedgradecounter"("taskId", "grade"); diff --git a/prisma/migrations/20260322043315_change_some_vote_tables_decorator/migration.sql b/prisma/migrations/20260322043315_change_some_vote_tables_decorator/migration.sql new file mode 100644 index 000000000..39b32642a --- /dev/null +++ b/prisma/migrations/20260322043315_change_some_vote_tables_decorator/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - A unique constraint covering the columns `[taskId]` on the table `votedgradestatistics` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "votedgradecounter_taskId_grade_key"; + +-- DropIndex +DROP INDEX "votegrade_userId_taskId_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "votedgradestatistics_taskId_key" ON "votedgradestatistics"("taskId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b0ab92c8..e4c627070 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -250,24 +250,24 @@ model VoteGrade { task Task @relation(references: [task_id], fields: [taskId]) @@id([userId, taskId]) - @@unique([userId, taskId]) @@map("votegrade") } model VotedGradeCounter { - id String @id @default(uuid()) + id String @unique taskId String grade TaskGrade count Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@id([taskId, grade]) @@map("votedgradecounter") } model VotedGradeStatistics { - id String @id @default(uuid()) - taskId String + id String @id + taskId String @unique grade TaskGrade isExperimental Boolean @default(false) isApproved Boolean @default(true) diff --git a/src/features/votes/actions/vote_actions.ts b/src/features/votes/actions/vote_actions.ts index 8e8fa9171..3106059af 100644 --- a/src/features/votes/actions/vote_actions.ts +++ b/src/features/votes/actions/vote_actions.ts @@ -22,7 +22,7 @@ export const voteAbsoluteGrade = async ( const grade = response.get('grade') as string; try { - await crud.upsertVoteGrade(userId, taskId, grade); + await crud.upsertVoteGradeTables(userId, taskId, grade); } catch (error) { console.error('Failed to vote absolute grade: ', error); return fail(BAD_REQUEST); diff --git a/src/features/votes/components/VotableGrade.svelte b/src/features/votes/components/VotableGrade.svelte index 376bb5007..881325f38 100644 --- a/src/features/votes/components/VotableGrade.svelte +++ b/src/features/votes/components/VotableGrade.svelte @@ -39,7 +39,6 @@ if (res.ok) { const data = await res.json(); votedGrade = data.grade; - console.dir(data); } } catch (err) { console.error(err); diff --git a/src/features/votes/services/vote_table_manager.ts b/src/features/votes/services/vote_table_manager.ts index da4552d71..f7f082fda 100644 --- a/src/features/votes/services/vote_table_manager.ts +++ b/src/features/votes/services/vote_table_manager.ts @@ -1,6 +1,28 @@ import { default as prisma } from '$lib/server/database'; -import { TaskGrade, type VoteGrade } from '@prisma/client'; +import { TaskGrade } from '@prisma/client'; import { sha256 } from '$lib/utils/hash'; +import { getGradeOrder, taskGradeOrderInfinity } from '$lib/utils/task'; + +const OrderToTaskGrade: Map = new Map([ + [1, TaskGrade.Q11], + [2, TaskGrade.Q10], + [3, TaskGrade.Q9], + [4, TaskGrade.Q8], + [5, TaskGrade.Q7], + [6, TaskGrade.Q6], + [7, TaskGrade.Q5], + [8, TaskGrade.Q4], + [9, TaskGrade.Q3], + [10, TaskGrade.Q2], + [11, TaskGrade.Q1], + [12, TaskGrade.D1], + [13, TaskGrade.D2], + [14, TaskGrade.D3], + [15, TaskGrade.D4], + [16, TaskGrade.D5], + [17, TaskGrade.D6], + [taskGradeOrderInfinity, TaskGrade.PENDING], +]); export async function getVoteGrade(userId: string, taskId: string) { const res = await prisma.voteGrade.findUnique({ @@ -20,32 +42,99 @@ export async function getVoteGrade(userId: string, taskId: string) { }; } -export async function upsertVoteGrade(userId: string, taskId: string, grade: string) { - try { - const id = await sha256(taskId + userId); - const newVoteGrade: VoteGrade = { - id: id, - userId: userId, - taskId: taskId, - grade: grade as TaskGrade, - createdAt: new Date(), - updatedAt: new Date(), - }; - - await prisma.voteGrade.upsert({ - where: { - userId_taskId: { userId: userId, taskId: taskId }, +// 概念実装(読み込み→処理を同一トランザクション内で行う) +export async function upsertVoteGradeTables(userId: string, taskId: string, grade: string) { + await prisma.$transaction(async (tx) => { + const existing = await tx.voteGrade.findUnique({ + where: { userId_taskId: { userId, taskId } }, + }); + + // 冪等性: 既に同じグレードなら何もしない + if (existing?.grade === grade) return; + + // old があるならそのカウントをデクリメント + if (existing) { + await tx.votedGradeCounter.update({ + where: { taskId_grade: { taskId, grade: existing.grade } }, + data: { count: { decrement: 1 } }, + }); + } + + // vote の upsert + const voteId = await sha256(taskId + userId); + await tx.voteGrade.upsert({ + where: { userId_taskId: { userId, taskId } }, + update: { grade: grade as TaskGrade }, + create: { + id: voteId, + userId, + taskId, + grade: grade as TaskGrade, + createdAt: new Date(), + updatedAt: new Date(), }, - update: { + }); + + // new グレードのカウンタを upsert で increment + const counterId = await sha256(taskId + grade); + await tx.votedGradeCounter.upsert({ + where: { taskId_grade: { taskId, grade: grade as TaskGrade } }, + update: { count: { increment: 1 } }, + create: { + id: counterId, + taskId, grade: grade as TaskGrade, + count: 1, + createdAt: new Date(), + updatedAt: new Date(), }, - create: newVoteGrade, }); - } catch (error) { - console.error( - `Failed to update answer with taskId ${taskId}, userId ${userId}, grade: ${grade}:`, - error, - ); - throw error; - } -} + + // Recompute median for this task and update VotedGradeStatistics + const counters = await tx.votedGradeCounter.findMany({ + where: { taskId }, + orderBy: { grade: 'asc' }, + }); + + const total = counters.reduce((s, c) => s + c.count, 0); + if (total > 0) { + let median = 0; + + const getGradeOrderAtPosition = (target: number): number => { + let cum = 0; + for (const c of counters) { + cum += c.count; + if (cum >= target) return getGradeOrder(c.grade); + } + console.error('範囲外の値にアクセスしました'); + return taskGradeOrderInfinity; + }; + + if(total % 2){ + const target = Math.ceil(total / 2); + median = getGradeOrderAtPosition(target); + } + else{ + const target = total / 2; + median = Math.round((getGradeOrderAtPosition(target) + getGradeOrderAtPosition(target + 1)) / 2); + } + + let medianGrade: TaskGrade = OrderToTaskGrade.get(median) as TaskGrade; + const statsId = await sha256(taskId + 'stats'); + + await tx.votedGradeStatistics.upsert({ + where: { taskId: taskId }, + update: { grade: medianGrade }, + create: { + id: statsId, + taskId, + grade: medianGrade, + isExperimental: false, + isApproved: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + } + }); +} \ No newline at end of file From 9c8e40c4f2c7b0f57a9d94076bdc13248ce2dae4 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Tue, 24 Mar 2026 18:03:42 +0900 Subject: [PATCH 09/26] =?UTF-8?q?add:=20=E3=83=9A=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E3=83=AD=E3=83=BC=E3=83=89=E6=99=82=E3=81=AB=E6=8A=95=E7=A5=A8?= =?UTF-8?q?=E6=B8=88=E3=81=BF=E4=B8=AD=E5=A4=AE=E5=80=A4=E3=82=B0=E3=83=AC?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=82=92=E5=8F=8D=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - voteResults(Map)を +page.svelte → TaskTable → TaskTableBodyCell → VotableGrade に伝達 - VotableGrade の displayGrade 初期値を estimatedGrade ?? taskResult.grade に変更 - 投票成功後に votedGrade をローカル状態で即時更新(チェックマーク反映) - vote_table_manager.ts を削除し vote_crud.ts に統合 Co-Authored-By: Claude Sonnet 4.6 --- .../components/contest-table/TaskTable.svelte | 4 +- .../contest-table/TaskTableBodyCell.svelte | 13 +++- src/features/votes/actions/vote_actions.ts | 4 +- .../votes/components/VotableGrade.svelte | 70 ++++++++++++------- .../{vote_table_manager.ts => vote_crud.ts} | 27 ++++--- src/routes/problems/+page.server.ts | 14 ++-- src/routes/problems/+page.svelte | 3 +- src/routes/problems/getMedianVote/+server.ts | 16 +++++ src/routes/problems/getMyVote/+server.ts | 7 +- 9 files changed, 111 insertions(+), 47 deletions(-) rename src/features/votes/services/{vote_table_manager.ts => vote_crud.ts} (89%) create mode 100644 src/routes/problems/getMedianVote/+server.ts diff --git a/src/features/tasks/components/contest-table/TaskTable.svelte b/src/features/tasks/components/contest-table/TaskTable.svelte index 037161d4d..2b21876c2 100644 --- a/src/features/tasks/components/contest-table/TaskTable.svelte +++ b/src/features/tasks/components/contest-table/TaskTable.svelte @@ -36,9 +36,10 @@ interface Props { taskResults: TaskResults; isLoggedIn: boolean; + voteResults: Map; } - let { taskResults, isLoggedIn }: Props = $props(); + let { taskResults, isLoggedIn, voteResults }: Props = $props(); // Prepare contest table provider based on the active contest type. let activeContestType: ContestTableProviderGroups = $derived(activeContestTypeStore.get()); @@ -281,6 +282,7 @@ handleUpdateTaskResult(updatedTask)} /> diff --git a/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte b/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte index 5b38d1c5c..435e4bdd4 100644 --- a/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte +++ b/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte @@ -11,10 +11,19 @@ taskResult: TaskResult; isLoggedIn: boolean; isShownTaskIndex: boolean; + voteResults: Map; onupdate?: (updatedTask: TaskResult) => void; // Ensure to update task result in parent component. } - let { taskResult, isLoggedIn, isShownTaskIndex, onupdate = () => {} }: Props = $props(); + let { + taskResult, + isLoggedIn, + isShownTaskIndex, + voteResults, + onupdate = () => {}, + }: Props = $props(); + + let estimatedGrade = $derived(voteResults.get(taskResult.task_id)?.grade);
{#snippet taskGradeLabel(taskResult: TaskResult)} - + {/snippet} {#snippet taskTitleAndExternalLink(taskResult: TaskResult, isShownTaskIndex: boolean)} diff --git a/src/features/votes/actions/vote_actions.ts b/src/features/votes/actions/vote_actions.ts index 3106059af..44356ab1e 100644 --- a/src/features/votes/actions/vote_actions.ts +++ b/src/features/votes/actions/vote_actions.ts @@ -1,6 +1,6 @@ import { fail } from '@sveltejs/kit'; -import * as crud from '@/features/votes/services/vote_table_manager'; +import * as crud from '@/features/votes/services/vote_crud'; import { BAD_REQUEST, UNAUTHORIZED } from '$lib/constants/http-response-status-codes'; export const voteAbsoluteGrade = async ( @@ -27,4 +27,4 @@ export const voteAbsoluteGrade = async ( console.error('Failed to vote absolute grade: ', error); return fail(BAD_REQUEST); } -}; \ No newline at end of file +}; diff --git a/src/features/votes/components/VotableGrade.svelte b/src/features/votes/components/VotableGrade.svelte index 881325f38..bf819f066 100644 --- a/src/features/votes/components/VotableGrade.svelte +++ b/src/features/votes/components/VotableGrade.svelte @@ -3,7 +3,7 @@ import { Dropdown, DropdownItem, DropdownDivider } from 'flowbite-svelte'; import Check from '@lucide/svelte/icons/check'; - + import { taskGradeValues, TaskGrade, getTaskGrade, type TaskResult } from '$lib/types/task'; import { getTaskGradeLabel } from '$lib/utils/task'; import { SIGNUP_PAGE, LOGIN_PAGE } from '$lib/constants/navbar-links'; @@ -11,13 +11,19 @@ import GradeLabel from '$lib/components/GradeLabel.svelte'; import InputFieldWrapper from '$lib/components/InputFieldWrapper.svelte'; - + interface Props { - taskResult: TaskResult + taskResult: TaskResult; isLoggedIn: boolean; + estimatedGrade?: string; } - let { taskResult, isLoggedIn }: Props = $props(); + let { taskResult, isLoggedIn, estimatedGrade }: Props = $props(); + + // 表示用のグレード(投票後に画面リロードなしで差し替えるためのローカル状態) + // estimatedGrade(集計済み中央値)があればそれを優先表示 + const initialGrade = estimatedGrade ?? taskResult.grade; + let displayGrade = $state(initialGrade); const componentId = Math.random().toString(36).substring(2); const nonPendingGrades = taskGradeValues.filter((g) => g !== TaskGrade.PENDING); @@ -32,10 +38,12 @@ if (isOpening) return; isOpening = true; try { - // ここで先にやりたい処理(例: getMyVote フェッチ) - const res = await fetch(`/problems/getMyVote?taskId=${encodeURIComponent(taskResult.task_id)}`, { - headers: { Accept: 'application/json' } - }); + const res = await fetch( + `/problems/getMyVote?taskId=${encodeURIComponent(taskResult.task_id)}`, + { + headers: { Accept: 'application/json' }, + }, + ); if (res.ok) { const data = await res.json(); votedGrade = data.grade; @@ -70,8 +78,7 @@ cancel: () => void; }; - const FAILED_TO_UPDATE_VOTE_STATUS = - '投票状況の更新に失敗しました。もう一度試してください。'; + const FAILED_TO_UPDATE_VOTE_STATUS = '投票状況の更新に失敗しました。もう一度試してください。'; const handleSubmit = () => { return ({ formData, action, cancel }: EnhanceForVote) => { @@ -86,6 +93,27 @@ Accept: 'application/json', }, }) + .then(async (res) => { + if (!res.ok) throw new Error('vote failed'); + + // 投票したグレードをローカル状態に反映(チェックマーク更新) + votedGrade = selectedVoteGrade ?? null; + + // 成功したらサーバから最新の中央値を取得して表示を更新 + try { + const taskId = formData.get('taskId') as string; + const medianRes = await fetch( + `/problems/getMedianVote?taskId=${encodeURIComponent(taskId)}`, + { headers: { Accept: 'application/json' } }, + ); + if (medianRes.ok) { + const data = await medianRes.json(); + if (data?.grade) displayGrade = data.grade; + } + } catch (err) { + console.error('Failed to fetch median after vote', err); + } + }) .catch((error) => { console.error('Failed to update submission status: ', error); errorMessageStore.setAndClearAfterTimeout(FAILED_TO_UPDATE_VOTE_STATUS, 10000); @@ -113,12 +141,7 @@ aria-label="Vote grade" onclick={() => onTriggerClick()} > - + - {#if isLoggedIn} - アカウント作成 - - ログイン + アカウント作成 + + ログイン {/if} @@ -180,12 +202,8 @@ /> - + -{/snippet} \ No newline at end of file +{/snippet} diff --git a/src/features/votes/services/vote_table_manager.ts b/src/features/votes/services/vote_crud.ts similarity index 89% rename from src/features/votes/services/vote_table_manager.ts rename to src/features/votes/services/vote_crud.ts index f7f082fda..645ab8013 100644 --- a/src/features/votes/services/vote_table_manager.ts +++ b/src/features/votes/services/vote_crud.ts @@ -32,7 +32,7 @@ export async function getVoteGrade(userId: string, taskId: string) { }); let voted = false; let grade = null; - if(res !== null){ + if (res !== null) { voted = true; grade = res.grade; } @@ -42,6 +42,16 @@ export async function getVoteGrade(userId: string, taskId: string) { }; } +export async function getVoteGradeStatistics() { + const all_data = prisma.votedGradeStatistics.findMany(); + const gradesMap = new Map(); + + (await all_data).map((data) => { + gradesMap.set(data.taskId, data); + }); + return gradesMap; +} + // 概念実装(読み込み→処理を同一トランザクション内で行う) export async function upsertVoteGradeTables(userId: string, taskId: string, grade: string) { await prisma.$transaction(async (tx) => { @@ -89,7 +99,7 @@ export async function upsertVoteGradeTables(userId: string, taskId: string, grad updatedAt: new Date(), }, }); - + // Recompute median for this task and update VotedGradeStatistics const counters = await tx.votedGradeCounter.findMany({ where: { taskId }, @@ -97,7 +107,7 @@ export async function upsertVoteGradeTables(userId: string, taskId: string, grad }); const total = counters.reduce((s, c) => s + c.count, 0); - if (total > 0) { + if (total >= 3) { let median = 0; const getGradeOrderAtPosition = (target: number): number => { @@ -110,13 +120,14 @@ export async function upsertVoteGradeTables(userId: string, taskId: string, grad return taskGradeOrderInfinity; }; - if(total % 2){ + if (total % 2) { const target = Math.ceil(total / 2); median = getGradeOrderAtPosition(target); - } - else{ + } else { const target = total / 2; - median = Math.round((getGradeOrderAtPosition(target) + getGradeOrderAtPosition(target + 1)) / 2); + median = Math.round( + (getGradeOrderAtPosition(target) + getGradeOrderAtPosition(target + 1)) / 2, + ); } let medianGrade: TaskGrade = OrderToTaskGrade.get(median) as TaskGrade; @@ -137,4 +148,4 @@ export async function upsertVoteGradeTables(userId: string, taskId: string, grad }); } }); -} \ No newline at end of file +} diff --git a/src/routes/problems/+page.server.ts b/src/routes/problems/+page.server.ts index 9a2a82b13..d15f6d237 100644 --- a/src/routes/problems/+page.server.ts +++ b/src/routes/problems/+page.server.ts @@ -1,6 +1,7 @@ import { type Actions } from '@sveltejs/kit'; -import * as crud from '$lib/services/task_results'; +import * as task_crud from '$lib/services/task_results'; +import * as vote_crud from '$features/votes/services/vote_crud'; import type { TaskResults } from '$lib/types/task'; import { Roles } from '$lib/types/user'; import { updateTaskResult } from '$lib/actions/update_task_result'; @@ -18,13 +19,18 @@ export async function load({ locals, url }) { if (tagIds != null) { return { - taskResults: (await crud.getTasksWithTagIds(tagIds, session?.user.userId)) as TaskResults, + taskResults: (await task_crud.getTasksWithTagIds( + tagIds, + session?.user.userId, + )) as TaskResults, + voteResults: await vote_crud.getVoteGradeStatistics(), isAdmin: isAdmin, isLoggedIn: isLoggedIn, }; } else { return { - taskResults: (await crud.getTaskResults(session?.user.userId)) as TaskResults, + taskResults: (await task_crud.getTaskResults(session?.user.userId)) as TaskResults, + voteResults: await vote_crud.getVoteGradeStatistics(), isAdmin: isAdmin, isLoggedIn: isLoggedIn, }; @@ -36,7 +42,7 @@ export const actions = { const operationLog = 'problems -> actions -> update'; return await updateTaskResult({ request, locals }, operationLog); }, - voteAbsoluteGrade: async ({request, locals}) => { + voteAbsoluteGrade: async ({ request, locals }) => { const operationLog = 'problems -> actions -> voteAbsoluteGrade'; await voteAbsoluteGrade({ request, locals }, operationLog); }, diff --git a/src/routes/problems/+page.svelte b/src/routes/problems/+page.svelte index 45d0c0153..a3689daf8 100644 --- a/src/routes/problems/+page.svelte +++ b/src/routes/problems/+page.svelte @@ -24,6 +24,7 @@ let isAdmin: boolean = data.isAdmin; let isLoggedIn: boolean = data.isLoggedIn; + let voteResults = data.voteResults; function isActiveTab(currentTab: ActiveProblemListTab): boolean { return currentTab === activeProblemListTabStore.get(); @@ -59,7 +60,7 @@ {/snippet} {#snippet contestTable()} - + {/snippet} {#snippet listByGrade()} diff --git a/src/routes/problems/getMedianVote/+server.ts b/src/routes/problems/getMedianVote/+server.ts new file mode 100644 index 000000000..ee716af1d --- /dev/null +++ b/src/routes/problems/getMedianVote/+server.ts @@ -0,0 +1,16 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import prisma from '$lib/server/database'; + +export const GET: RequestHandler = async ({ url }) => { + const taskId = url.searchParams.get('taskId'); + if (!taskId) return json({ error: 'taskId required' }, { status: 400 }); + + try { + const stats = await prisma.votedGradeStatistics.findFirst({ where: { taskId } }); + if (stats && stats.grade) return json({ grade: stats.grade }); + return json({ grade: null }); + } catch (err) { + console.error('getMedianVote failed', err); + return json({ error: 'internal error' }, { status: 500 }); + } +}; diff --git a/src/routes/problems/getMyVote/+server.ts b/src/routes/problems/getMyVote/+server.ts index e5d69df2f..89bfe4cee 100644 --- a/src/routes/problems/getMyVote/+server.ts +++ b/src/routes/problems/getMyVote/+server.ts @@ -1,13 +1,14 @@ import { json, type RequestHandler } from '@sveltejs/kit'; -import { getVoteGrade } from '@/features/votes/services/vote_table_manager'; +import { getVoteGrade } from '@/features/votes/services/vote_crud'; export const GET: RequestHandler = async ({ url, locals }) => { const taskId = url.searchParams.get('taskId'); if (!taskId) return json({ error: 'taskId required' }, { status: 400 }); const session = await locals.auth.validate(); - if (!session || !session.user || !session.user.userId) return json({ error: 'unauthorized' }, { status: 401 }); + if (!session || !session.user || !session.user.userId) + return json({ error: 'unauthorized' }, { status: 401 }); const res = await getVoteGrade(session.user.userId, taskId); return json(res); -}; \ No newline at end of file +}; From 71d3d5670c0e807ad3d67908019ac5e65664afed Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Tue, 24 Mar 2026 18:36:13 +0900 Subject: [PATCH 10/26] =?UTF-8?q?fix:=20=E3=82=B0=E3=83=AC=E3=83=BC?= =?UTF-8?q?=E3=83=89=E4=BB=98=E4=B8=8E=E6=B8=88=E3=81=BF=E5=95=8F=E9=A1=8C?= =?UTF-8?q?=E3=81=B8=E3=81=AE=E6=8A=95=E7=A5=A8=E3=82=92=E5=88=B6=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grade が PENDING(未確定)の問題のみ投票ドロップダウンを表示し、 公式グレードが付与済みの問題は静的なグレードラベルのみ表示する。 Co-Authored-By: Claude Sonnet 4.6 --- .../votes/components/VotableGrade.svelte | 114 ++++++++++-------- 1 file changed, 62 insertions(+), 52 deletions(-) diff --git a/src/features/votes/components/VotableGrade.svelte b/src/features/votes/components/VotableGrade.svelte index bf819f066..a537d7aa6 100644 --- a/src/features/votes/components/VotableGrade.svelte +++ b/src/features/votes/components/VotableGrade.svelte @@ -20,9 +20,12 @@ let { taskResult, isLoggedIn, estimatedGrade }: Props = $props(); + // PENDING(未確定)の問題のみ投票可能。公式グレード付与済みは投票不可。 + const isVotable = taskResult.grade === TaskGrade.PENDING; + // 表示用のグレード(投票後に画面リロードなしで差し替えるためのローカル状態) - // estimatedGrade(集計済み中央値)があればそれを優先表示 - const initialGrade = estimatedGrade ?? taskResult.grade; + // PENDING かつ estimatedGrade(集計済み中央値)があればそれを優先表示 + const initialGrade = isVotable ? (estimatedGrade ?? taskResult.grade) : taskResult.grade; let displayGrade = $state(initialGrade); const componentId = Math.random().toString(36).substring(2); @@ -132,58 +135,65 @@ } - - - - -{#if isLoggedIn} - - {#each nonPendingGrades as grade} - handleClick(grade)} class="rounded-md"> -
- {getTaskGradeLabel(grade)} - {#if votedGrade === grade} - - {/if} -
-
- {/each} -
+ +{#if !isVotable} +
+ +
{:else} - + + + + {#if isLoggedIn} + + {#each nonPendingGrades as grade} + handleClick(grade)} class="rounded-md"> +
+ {getTaskGradeLabel(grade)} + {#if votedGrade === grade} + + {/if} +
+
+ {/each} +
+ {:else} + + アカウント作成 + + ログイン + + {/if} + + {#if showForm && selectedVoteGrade} + {@render voteGradeForm(taskResult, selectedVoteGrade)} + {/if} {/if} {#snippet voteGradeForm(selectedTaskResult: TaskResult, voteGrade: TaskGrade)} From 7a240edea6c6d3a1c7b31cf5de3e107cde32305a Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Tue, 24 Mar 2026 18:50:19 +0900 Subject: [PATCH 11/26] =?UTF-8?q?add:=20=E6=8A=95=E7=A5=A8=E9=96=A2?= =?UTF-8?q?=E9=80=A3=E3=83=9A=E3=83=BC=E3=82=B8=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /votes: PENDING問題一覧ページ(テキスト検索対応) - /votes/[slug]: 投票詳細ページ(先入観低減のため投票後に統計表示) - /(admin)/votes: 管理者用投票統計一覧ページ - vote_crud.ts に getPendingTasksWithVoteInfo / getVoteCountersByTaskId / getVoteStatsByTaskId / getAllVoteStatisticsAsArray を追加 Co-Authored-By: Claude Sonnet 4.6 --- src/features/votes/services/vote_crud.ts | 37 ++++++ src/routes/(admin)/votes/+page.server.ts | 51 ++++++++ src/routes/(admin)/votes/+page.svelte | 86 ++++++++++++++ src/routes/votes/+page.server.ts | 12 ++ src/routes/votes/+page.svelte | 81 +++++++++++++ src/routes/votes/[slug]/+page.server.ts | 52 ++++++++ src/routes/votes/[slug]/+page.svelte | 144 +++++++++++++++++++++++ 7 files changed, 463 insertions(+) create mode 100644 src/routes/(admin)/votes/+page.server.ts create mode 100644 src/routes/(admin)/votes/+page.svelte create mode 100644 src/routes/votes/+page.server.ts create mode 100644 src/routes/votes/+page.svelte create mode 100644 src/routes/votes/[slug]/+page.server.ts create mode 100644 src/routes/votes/[slug]/+page.svelte diff --git a/src/features/votes/services/vote_crud.ts b/src/features/votes/services/vote_crud.ts index 645ab8013..d9aec6f06 100644 --- a/src/features/votes/services/vote_crud.ts +++ b/src/features/votes/services/vote_crud.ts @@ -52,6 +52,43 @@ export async function getVoteGradeStatistics() { return gradesMap; } +export async function getPendingTasksWithVoteInfo() { + const [pendingTasks, stats, counters] = await Promise.all([ + prisma.task.findMany({ where: { grade: 'PENDING' }, orderBy: { task_id: 'asc' } }), + prisma.votedGradeStatistics.findMany(), + prisma.votedGradeCounter.findMany(), + ]); + + const statsMap = new Map(stats.map((s) => [s.taskId, s])); + const totalsMap = new Map(); + for (const c of counters) { + totalsMap.set(c.taskId, (totalsMap.get(c.taskId) ?? 0) + c.count); + } + + return pendingTasks.map((task) => ({ + task_id: task.task_id, + contest_id: task.contest_id, + title: task.title, + estimatedGrade: statsMap.get(task.task_id)?.grade ?? null, + voteTotal: totalsMap.get(task.task_id) ?? 0, + })); +} + +export async function getVoteCountersByTaskId(taskId: string) { + return prisma.votedGradeCounter.findMany({ + where: { taskId }, + orderBy: { grade: 'asc' }, + }); +} + +export async function getVoteStatsByTaskId(taskId: string) { + return prisma.votedGradeStatistics.findFirst({ where: { taskId } }); +} + +export async function getAllVoteStatisticsAsArray() { + return prisma.votedGradeStatistics.findMany({ orderBy: { taskId: 'asc' } }); +} + // 概念実装(読み込み→処理を同一トランザクション内で行う) export async function upsertVoteGradeTables(userId: string, taskId: string, grade: string) { await prisma.$transaction(async (tx) => { diff --git a/src/routes/(admin)/votes/+page.server.ts b/src/routes/(admin)/votes/+page.server.ts new file mode 100644 index 000000000..369b25eed --- /dev/null +++ b/src/routes/(admin)/votes/+page.server.ts @@ -0,0 +1,51 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +import { Roles } from '$lib/types/user'; +import { isAdmin } from '$lib/utils/authorship'; +import { getUser } from '$lib/services/users'; +import { getTasksByTaskId } from '$lib/services/tasks'; +import { + getAllVoteStatisticsAsArray, + getVoteCountersByTaskId, +} from '$features/votes/services/vote_crud'; +import { TEMPORARY_REDIRECT } from '$lib/constants/http-response-status-codes'; +import { LOGIN_PAGE } from '$lib/constants/navbar-links'; + +async function validateAdminAccess(locals: App.Locals): Promise { + const session = await locals.auth.validate(); + if (!session) redirect(TEMPORARY_REDIRECT, LOGIN_PAGE); + + const user = await getUser(session.user.username as string); + if (!isAdmin(user?.role as Roles)) redirect(TEMPORARY_REDIRECT, LOGIN_PAGE); +} + +export const load: PageServerLoad = async ({ locals }) => { + await validateAdminAccess(locals); + + const [allStats, tasksMap] = await Promise.all([ + getAllVoteStatisticsAsArray(), + getTasksByTaskId(), + ]); + + // 各タスクの投票総数を取得 + const statsWithInfo = await Promise.all( + allStats.map(async (stat) => { + const task = tasksMap.get(stat.taskId); + const counters = await getVoteCountersByTaskId(stat.taskId); + const voteTotal = counters.reduce((sum, c) => sum + c.count, 0); + return { + taskId: stat.taskId, + title: task?.title ?? stat.taskId, + contestId: task?.contest_id ?? '', + dbGrade: task?.grade ?? 'PENDING', + estimatedGrade: stat.grade, + voteTotal, + isExperimental: stat.isExperimental, + isApproved: stat.isApproved, + }; + }), + ); + + return { stats: statsWithInfo }; +}; diff --git a/src/routes/(admin)/votes/+page.svelte b/src/routes/(admin)/votes/+page.svelte new file mode 100644 index 000000000..6d76b715f --- /dev/null +++ b/src/routes/(admin)/votes/+page.svelte @@ -0,0 +1,86 @@ + + +
+ + +

+ 集計済み統計一覧(3票以上で暫定グレードが算出されます) +

+ + + + 問題 + コンテスト + DBグレード + 中央値グレード + 票数 + ステータス + + + {#each data.stats as stat (stat.taskId)} + + + + {stat.title} + + + {stat.contestId} + + + + + + + {stat.voteTotal} + +
+ {#if stat.isExperimental} + 暫定 + {/if} + {#if stat.isApproved} + 承認済 + {:else} + 未承認 + {/if} +
+
+
+ {/each} + {#if data.stats.length === 0} + + + 集計データがありません + + + {/if} +
+
+
diff --git a/src/routes/votes/+page.server.ts b/src/routes/votes/+page.server.ts new file mode 100644 index 000000000..a6f115155 --- /dev/null +++ b/src/routes/votes/+page.server.ts @@ -0,0 +1,12 @@ +import type { PageServerLoad } from './$types'; +import * as voteCrud from '$features/votes/services/vote_crud'; + +export const load: PageServerLoad = async ({ locals }) => { + const session = await locals.auth.validate(); + const tasks = await voteCrud.getPendingTasksWithVoteInfo(); + + return { + tasks, + isLoggedIn: session !== null, + }; +}; diff --git a/src/routes/votes/+page.svelte b/src/routes/votes/+page.svelte new file mode 100644 index 000000000..18a597309 --- /dev/null +++ b/src/routes/votes/+page.svelte @@ -0,0 +1,81 @@ + + +
+ + +
+ +
+ + + + 問題 + コンテスト + 暫定グレード + 票数 + + + {#each filteredTasks as task (task.task_id)} + + + + {task.title} + + + {task.contest_id} + + {#if task.estimatedGrade} + + {:else} + - + {/if} + + {task.voteTotal} + + {/each} + {#if filteredTasks.length === 0} + + + 該当する問題が見つかりませんでした + + + {/if} + +
+
diff --git a/src/routes/votes/[slug]/+page.server.ts b/src/routes/votes/[slug]/+page.server.ts new file mode 100644 index 000000000..dd737b2e8 --- /dev/null +++ b/src/routes/votes/[slug]/+page.server.ts @@ -0,0 +1,52 @@ +import { error, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +import { getTask } from '$lib/services/tasks'; +import { + getVoteGrade, + getVoteCountersByTaskId, + getVoteStatsByTaskId, +} from '$features/votes/services/vote_crud'; +import { voteAbsoluteGrade } from '$features/votes/actions/vote_actions'; +import { TEMPORARY_REDIRECT } from '$lib/constants/http-response-status-codes'; + +export const load: PageServerLoad = async ({ locals, params }) => { + const session = await locals.auth.validate(); + const taskId = params.slug; + + const tasks = await getTask(taskId); + const task = tasks[0]; + if (!task) throw error(404, 'Task not found'); + + // PENDING以外の問題は投票ページの対象外 + if (task.grade !== 'PENDING') redirect(TEMPORARY_REDIRECT, '/votes'); + + let myVote = null; + if (session?.user.userId) { + myVote = await getVoteGrade(session.user.userId, taskId); + } + + // 先入観の影響を低減させるため、投票するまで統計情報は表示しない + let counters = null; + let stats = null; + if (myVote?.voted) { + [counters, stats] = await Promise.all([ + getVoteCountersByTaskId(taskId), + getVoteStatsByTaskId(taskId), + ]); + } + + return { + task, + myVote, + counters, + stats, + isLoggedIn: session !== null, + }; +}; + +export const actions: Actions = { + voteAbsoluteGrade: async ({ request, locals }) => { + return await voteAbsoluteGrade({ request, locals }, 'votes/[slug] -> voteAbsoluteGrade'); + }, +}; diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte new file mode 100644 index 000000000..c388fdd49 --- /dev/null +++ b/src/routes/votes/[slug]/+page.svelte @@ -0,0 +1,144 @@ + + +
+ + + + + + +
+ + +
+ + + {#if data.myVote?.voted} + +
+

+ + 投票済み:{getTaskGradeLabel(data.myVote.grade)} +

+ + {#if data.stats} +

+ 暫定グレード:{getTaskGradeLabel(data.stats.grade)}({totalVotes}票) +

+ {/if} + + +
+ {#each nonPendingGrades as grade} + {@const count = getCount(grade)} + {@const pct = getPct(grade)} + {@const isMyVote = data.myVote?.grade === grade} +
+ + {getTaskGradeLabel(grade)} + +
+
+
+ + {count}票 ({pct}%) + +
+ {/each} +
+
+ + +
+ + 投票を変更する + +
+ {@render voteForm()} +
+
+ {:else if data.isLoggedIn} + +

+ この問題のグレードを投票してください。投票後に集計結果を確認できます。 +

+ {@render voteForm()} + {:else} + +

投票するにはログインが必要です。

+
+ + +
+ {/if} +
+ +{#snippet voteForm()} +
+ +
+ {#each nonPendingGrades as grade} + + {/each} +
+
+{/snippet} From 2ee029d307786e97af6b5a48072d27797408e0bf Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Tue, 24 Mar 2026 18:51:59 +0900 Subject: [PATCH 12/26] =?UTF-8?q?fix:=20/(admin)/votes=20=E3=81=A8=20/vote?= =?UTF-8?q?s=20=E3=81=AE=E3=83=AB=E3=83=BC=E3=83=88=E8=A1=9D=E7=AA=81?= =?UTF-8?q?=E3=82=92=E8=A7=A3=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /(admin)/votes は URL が /votes になり公開ルートと衝突するため、 /(admin)/vote_management(URL: /vote_management)にリネーム。 Co-Authored-By: Claude Sonnet 4.6 --- src/routes/(admin)/{votes => vote_management}/+page.server.ts | 0 src/routes/(admin)/{votes => vote_management}/+page.svelte | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/routes/(admin)/{votes => vote_management}/+page.server.ts (100%) rename src/routes/(admin)/{votes => vote_management}/+page.svelte (100%) diff --git a/src/routes/(admin)/votes/+page.server.ts b/src/routes/(admin)/vote_management/+page.server.ts similarity index 100% rename from src/routes/(admin)/votes/+page.server.ts rename to src/routes/(admin)/vote_management/+page.server.ts diff --git a/src/routes/(admin)/votes/+page.svelte b/src/routes/(admin)/vote_management/+page.svelte similarity index 100% rename from src/routes/(admin)/votes/+page.svelte rename to src/routes/(admin)/vote_management/+page.svelte From a673e769f9c34d99be3e138894e88d93e4b2d5a2 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Tue, 24 Mar 2026 19:06:55 +0900 Subject: [PATCH 13/26] =?UTF-8?q?add:=20=E6=8A=95=E7=A5=A8=E6=A9=9F?= =?UTF-8?q?=E8=83=BD=E3=81=AEUI=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ナビバーに「グレード投票」(/votes)を追加 - 管理画面ドロップダウンに「投票管理」(/vote_management)を追加 - 投票詳細ページに「3票以上で一覧表に反映」の説明を追記 - VotableGrade ドロップダウン末尾に詳細ページへのリンクを追加 Co-Authored-By: Claude Sonnet 4.6 --- src/features/votes/components/VotableGrade.svelte | 6 +++++- src/lib/constants/navbar-links.ts | 4 ++++ src/routes/votes/[slug]/+page.svelte | 3 +++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/features/votes/components/VotableGrade.svelte b/src/features/votes/components/VotableGrade.svelte index a537d7aa6..0a947f34a 100644 --- a/src/features/votes/components/VotableGrade.svelte +++ b/src/features/votes/components/VotableGrade.svelte @@ -6,7 +6,7 @@ import { taskGradeValues, TaskGrade, getTaskGrade, type TaskResult } from '$lib/types/task'; import { getTaskGradeLabel } from '$lib/utils/task'; - import { SIGNUP_PAGE, LOGIN_PAGE } from '$lib/constants/navbar-links'; + import { SIGNUP_PAGE, LOGIN_PAGE, VOTES_PAGE } from '$lib/constants/navbar-links'; import { errorMessageStore } from '$lib/stores/error_message'; import GradeLabel from '$lib/components/GradeLabel.svelte'; @@ -178,6 +178,10 @@
{/each} + + + 詳細・統計を見る + {:else} この問題のグレードを投票してください。投票後に集計結果を確認できます。

+

+ ※ 3票以上集まると中央値が暫定グレードとして一覧表に反映されます。 +

{@render voteForm()} {:else} From 0cf12da7ad6b6f6e2b0cfc045c78a7d7006e3931 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Tue, 24 Mar 2026 19:41:19 +0900 Subject: [PATCH 14/26] =?UTF-8?q?fix:=20=E6=8A=95=E7=A5=A8=E8=A9=B3?= =?UTF-8?q?=E7=B4=B0=E3=83=9A=E3=83=BC=E3=82=B8=E3=81=AE=E8=AA=AC=E6=98=8E?= =?UTF-8?q?=E6=96=87=E3=82=92=E6=8A=95=E7=A5=A8=E5=BE=8C=E3=82=82=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「3票以上集まると一覧表に反映される」の説明を {#if} ブロック外に移動し、 投票前・投票後・未ログインのいずれの状態でも常に表示されるようにした。 Co-Authored-By: Claude Sonnet 4.6 --- src/routes/votes/[slug]/+page.svelte | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte index f7c59f30c..05ea4a933 100644 --- a/src/routes/votes/[slug]/+page.svelte +++ b/src/routes/votes/[slug]/+page.svelte @@ -54,6 +54,10 @@ />
+

+ ※ 3票以上集まると中央値が暫定グレードとして一覧表に反映されます。 +

+ {#if data.myVote?.voted} @@ -111,9 +115,6 @@

この問題のグレードを投票してください。投票後に集計結果を確認できます。

-

- ※ 3票以上集まると中央値が暫定グレードとして一覧表に反映されます。 -

{@render voteForm()} {:else} From c6ed2ca7925eced78bbbbecc35832ba26d138dda Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Tue, 24 Mar 2026 19:59:03 +0900 Subject: [PATCH 15/26] =?UTF-8?q?fix:=E3=80=80=E8=A9=B3=E7=B4=B0=E3=83=9A?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=81=B8=E3=81=AE=E9=81=B7=E7=A7=BB=E3=82=92?= =?UTF-8?q?=E3=83=89=E3=83=AD=E3=83=83=E3=83=97=E3=83=80=E3=82=A6=E3=83=B3?= =?UTF-8?q?=E3=81=AE=E6=A8=AA=E5=B9=85=E3=81=AB=E5=90=88=E3=81=86=E8=A1=A8?= =?UTF-8?q?=E8=A8=98=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../votes/components/VotableGrade.svelte | 2 +- src/routes/votes/[slug]/+page.svelte | 18 +++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/features/votes/components/VotableGrade.svelte b/src/features/votes/components/VotableGrade.svelte index 0a947f34a..a1936f2d1 100644 --- a/src/features/votes/components/VotableGrade.svelte +++ b/src/features/votes/components/VotableGrade.svelte @@ -180,7 +180,7 @@ {/each} - 詳細・統計を見る + 詳細
{:else} diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte index 05ea4a933..94a00208f 100644 --- a/src/routes/votes/[slug]/+page.svelte +++ b/src/routes/votes/[slug]/+page.svelte @@ -7,8 +7,8 @@ import GradeLabel from '$lib/components/GradeLabel.svelte'; import ExternalLinkWrapper from '$lib/components/ExternalLinkWrapper.svelte'; - import { taskGradeValues, TaskGrade, getTaskGrade } from '$lib/types/task'; - import { getTaskGradeLabel, getTaskUrl } from '$lib/utils/task'; + import { taskGradeValues, TaskGrade } from '$lib/types/task'; + import { getTaskGradeLabel, getTaskUrl, getTaskGradeColor, toChangeTextColorIfNeeds } from '$lib/utils/task'; import { SIGNUP_PAGE, LOGIN_PAGE } from '$lib/constants/navbar-links'; let { data } = $props(); @@ -64,7 +64,7 @@

- 投票済み:{getTaskGradeLabel(data.myVote.grade)} + 投票済み:{getTaskGradeLabel(data.myVote.grade as TaskGrade)}

{#if data.stats} @@ -135,10 +135,14 @@ name="grade" value={grade} type="submit" - class="px-3 py-1.5 rounded-md text-sm font-medium border transition-colors - {data.myVote?.grade === grade - ? 'bg-primary-600 text-white border-primary-600' - : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'}" + class="px-3 py-1.5 rounded-md text-sm font-medium border transition-opacity + {grade === TaskGrade.D6 + ? 'text-white shadow-md shadow-amber-900/80 ring-2 ring-amber-300/50 font-bold drop-shadow relative overflow-hidden' + : toChangeTextColorIfNeeds(getTaskGradeLabel(grade))} + {data.myVote?.grade === grade ? 'ring-2 ring-offset-1 ring-gray-600 dark:ring-gray-300' : 'opacity-80 hover:opacity-100'}" + style={grade === TaskGrade.D6 + ? 'background-image: linear-gradient(to bottom right, var(--color-atcoder-D6), rgb(120, 113, 108), rgb(217, 119, 6)); border-color: var(--color-atcoder-D6);' + : `background-color: ${getTaskGradeColor(grade)}; border-color: ${getTaskGradeColor(grade)};`} > {getTaskGradeLabel(grade)} From 5c795b586362cd7b7f2a8803a51977808902746f Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Wed, 25 Mar 2026 10:36:17 +0900 Subject: [PATCH 16/26] =?UTF-8?q?add:=20=E6=8A=95=E7=A5=A8=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=83=9A=E3=83=BC=E3=82=B8=E3=81=AE=E6=94=B9=E5=96=84?= =?UTF-8?q?=E3=81=A8=E6=8A=95=E7=A5=A8=E5=88=B6=E9=99=90=E3=81=AE=E8=A7=A3?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 投票管理ページのステータス列を削除 - DBからisApprovedフィールドを削除(マイグレーション追加) - DBグレード列をプルダウンに変更し運営が直接グレードを設定可能に - グレード付与済み問題への投票を解禁(PENDING制限を撤廃) - 表示はDBグレードを中央値より優先 Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 8 ++ prisma/schema.prisma | 11 +- .../votes/components/VotableGrade.svelte | 127 +++++++++--------- src/features/votes/services/vote_crud.ts | 9 +- .../(admin)/vote_management/+page.server.ts | 18 ++- .../(admin)/vote_management/+page.svelte | 40 +++--- src/routes/votes/+page.server.ts | 2 +- src/routes/votes/[slug]/+page.server.ts | 6 +- 8 files changed, 112 insertions(+), 109 deletions(-) create mode 100644 prisma/migrations/20260325000000_remove_is_approved_from_vote_statistics/migration.sql diff --git a/prisma/migrations/20260325000000_remove_is_approved_from_vote_statistics/migration.sql b/prisma/migrations/20260325000000_remove_is_approved_from_vote_statistics/migration.sql new file mode 100644 index 000000000..b41b9332d --- /dev/null +++ b/prisma/migrations/20260325000000_remove_is_approved_from_vote_statistics/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `isApproved` on the `votedgradestatistics` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "votedgradestatistics" DROP COLUMN "isApproved"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e4c627070..a01dbbfbc 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -266,13 +266,12 @@ model VotedGradeCounter { } model VotedGradeStatistics { - id String @id - taskId String @unique + id String @id + taskId String @unique grade TaskGrade - isExperimental Boolean @default(false) - isApproved Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + isExperimental Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("votedgradestatistics") } diff --git a/src/features/votes/components/VotableGrade.svelte b/src/features/votes/components/VotableGrade.svelte index a1936f2d1..14ec1fcc2 100644 --- a/src/features/votes/components/VotableGrade.svelte +++ b/src/features/votes/components/VotableGrade.svelte @@ -20,12 +20,13 @@ let { taskResult, isLoggedIn, estimatedGrade }: Props = $props(); - // PENDING(未確定)の問題のみ投票可能。公式グレード付与済みは投票不可。 - const isVotable = taskResult.grade === TaskGrade.PENDING; - // 表示用のグレード(投票後に画面リロードなしで差し替えるためのローカル状態) - // PENDING かつ estimatedGrade(集計済み中央値)があればそれを優先表示 - const initialGrade = isVotable ? (estimatedGrade ?? taskResult.grade) : taskResult.grade; + // PENDING かつ estimatedGrade(集計済み中央値)があればそれを優先表示。 + // DBグレード付与済みの場合はそちらを優先。 + const initialGrade = + taskResult.grade === TaskGrade.PENDING + ? (estimatedGrade ?? taskResult.grade) + : taskResult.grade; let displayGrade = $state(initialGrade); const componentId = Math.random().toString(36).substring(2); @@ -111,7 +112,8 @@ ); if (medianRes.ok) { const data = await medianRes.json(); - if (data?.grade) displayGrade = data.grade; + // DBグレード付与済みはそちらを優先表示するため更新しない + if (data?.grade && taskResult.grade === TaskGrade.PENDING) displayGrade = data.grade; } } catch (err) { console.error('Failed to fetch median after vote', err); @@ -135,69 +137,60 @@ } - -{#if !isVotable} -
- -
-{:else} - - + + +{#if isLoggedIn} + - - - - - - - - {#if isLoggedIn} - - {#each nonPendingGrades as grade} - handleClick(grade)} class="rounded-md"> -
- {getTaskGradeLabel(grade)} - {#if votedGrade === grade} - - {/if} -
-
- {/each} - - - 詳細 + {#each nonPendingGrades as grade} + handleClick(grade)} class="rounded-md"> +
+ {getTaskGradeLabel(grade)} + {#if votedGrade === grade} + + {/if} +
-
- {:else} - - アカウント作成 - - ログイン - - {/if} - - {#if showForm && selectedVoteGrade} - {@render voteGradeForm(taskResult, selectedVoteGrade)} - {/if} + {/each} + + 詳細 +
+{:else} + + アカウント作成 + + ログイン + +{/if} + +{#if showForm && selectedVoteGrade} + {@render voteGradeForm(taskResult, selectedVoteGrade)} {/if} {#snippet voteGradeForm(selectedTaskResult: TaskResult, voteGrade: TaskGrade)} diff --git a/src/features/votes/services/vote_crud.ts b/src/features/votes/services/vote_crud.ts index d9aec6f06..c8752bda8 100644 --- a/src/features/votes/services/vote_crud.ts +++ b/src/features/votes/services/vote_crud.ts @@ -52,9 +52,9 @@ export async function getVoteGradeStatistics() { return gradesMap; } -export async function getPendingTasksWithVoteInfo() { - const [pendingTasks, stats, counters] = await Promise.all([ - prisma.task.findMany({ where: { grade: 'PENDING' }, orderBy: { task_id: 'asc' } }), +export async function getAllTasksWithVoteInfo() { + const [allTasks, stats, counters] = await Promise.all([ + prisma.task.findMany({ orderBy: { task_id: 'asc' } }), prisma.votedGradeStatistics.findMany(), prisma.votedGradeCounter.findMany(), ]); @@ -65,7 +65,7 @@ export async function getPendingTasksWithVoteInfo() { totalsMap.set(c.taskId, (totalsMap.get(c.taskId) ?? 0) + c.count); } - return pendingTasks.map((task) => ({ + return allTasks.map((task) => ({ task_id: task.task_id, contest_id: task.contest_id, title: task.title, @@ -178,7 +178,6 @@ export async function upsertVoteGradeTables(userId: string, taskId: string, grad taskId, grade: medianGrade, isExperimental: false, - isApproved: true, createdAt: new Date(), updatedAt: new Date(), }, diff --git a/src/routes/(admin)/vote_management/+page.server.ts b/src/routes/(admin)/vote_management/+page.server.ts index 369b25eed..16097485f 100644 --- a/src/routes/(admin)/vote_management/+page.server.ts +++ b/src/routes/(admin)/vote_management/+page.server.ts @@ -1,10 +1,11 @@ import { redirect } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; +import type { Actions, PageServerLoad } from './$types'; import { Roles } from '$lib/types/user'; +import { type TaskGrade } from '$lib/types/task'; import { isAdmin } from '$lib/utils/authorship'; import { getUser } from '$lib/services/users'; -import { getTasksByTaskId } from '$lib/services/tasks'; +import { getTasksByTaskId, updateTask } from '$lib/services/tasks'; import { getAllVoteStatisticsAsArray, getVoteCountersByTaskId, @@ -41,11 +42,20 @@ export const load: PageServerLoad = async ({ locals }) => { dbGrade: task?.grade ?? 'PENDING', estimatedGrade: stat.grade, voteTotal, - isExperimental: stat.isExperimental, - isApproved: stat.isApproved, }; }), ); return { stats: statsWithInfo }; }; + +export const actions: Actions = { + setTaskGrade: async ({ request, locals }) => { + await validateAdminAccess(locals); + const data = await request.formData(); + const taskId = data.get('taskId') as string; + const grade = data.get('grade') as TaskGrade; + await updateTask(taskId, grade); + return { success: true }; + }, +}; diff --git a/src/routes/(admin)/vote_management/+page.svelte b/src/routes/(admin)/vote_management/+page.svelte index 6d76b715f..8a0c8e9f5 100644 --- a/src/routes/(admin)/vote_management/+page.svelte +++ b/src/routes/(admin)/vote_management/+page.svelte @@ -1,4 +1,5 @@ @@ -29,7 +32,6 @@ DBグレード 中央値グレード 票数 - ステータス {#each data.stats as stat (stat.taskId)} @@ -44,12 +46,20 @@ {stat.contestId} - +
+ + +
{stat.voteTotal} - -
- {#if stat.isExperimental} - 暫定 - {/if} - {#if stat.isApproved} - 承認済 - {:else} - 未承認 - {/if} -
-
{/each} {#if data.stats.length === 0} - + 集計データがありません diff --git a/src/routes/votes/+page.server.ts b/src/routes/votes/+page.server.ts index a6f115155..9c2240afd 100644 --- a/src/routes/votes/+page.server.ts +++ b/src/routes/votes/+page.server.ts @@ -3,7 +3,7 @@ import * as voteCrud from '$features/votes/services/vote_crud'; export const load: PageServerLoad = async ({ locals }) => { const session = await locals.auth.validate(); - const tasks = await voteCrud.getPendingTasksWithVoteInfo(); + const tasks = await voteCrud.getAllTasksWithVoteInfo(); return { tasks, diff --git a/src/routes/votes/[slug]/+page.server.ts b/src/routes/votes/[slug]/+page.server.ts index dd737b2e8..c4a8af4e3 100644 --- a/src/routes/votes/[slug]/+page.server.ts +++ b/src/routes/votes/[slug]/+page.server.ts @@ -1,4 +1,4 @@ -import { error, redirect } from '@sveltejs/kit'; +import { error } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { getTask } from '$lib/services/tasks'; @@ -8,7 +8,6 @@ import { getVoteStatsByTaskId, } from '$features/votes/services/vote_crud'; import { voteAbsoluteGrade } from '$features/votes/actions/vote_actions'; -import { TEMPORARY_REDIRECT } from '$lib/constants/http-response-status-codes'; export const load: PageServerLoad = async ({ locals, params }) => { const session = await locals.auth.validate(); @@ -18,9 +17,6 @@ export const load: PageServerLoad = async ({ locals, params }) => { const task = tasks[0]; if (!task) throw error(404, 'Task not found'); - // PENDING以外の問題は投票ページの対象外 - if (task.grade !== 'PENDING') redirect(TEMPORARY_REDIRECT, '/votes'); - let myVote = null; if (session?.user.userId) { myVote = await getVoteGrade(session.user.userId, taskId); From 8fb1f25a588c630c4aab37e2b01b7f535bb17655 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Wed, 25 Mar 2026 17:14:45 +0900 Subject: [PATCH 17/26] =?UTF-8?q?refactor:=20=E6=8A=95=E7=A5=A8=E6=A9=9F?= =?UTF-8?q?=E8=83=BD=E3=81=AE=E3=82=B3=E3=83=BC=E3=83=89=E5=93=81=E8=B3=AA?= =?UTF-8?q?=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vote_crud.ts: 変数名修正 (res→voteRecord, all_data→allStats) - vote_actions.ts: 変数名修正 (response→formData)、不要な operationLog パラメータ削除 - getVoteGradeStatistics: .map()→.forEach() 修正、Map に型パラメータ付与 - VoteGradeResult 型を types/vote_result.ts に定義 - nonPendingGrades 定数を utils/grade_options.ts に抽出 (重複解消) - 中央値計算を computeMedianGrade() として utils/median.ts に抽出しテスト追加 - 投票管理ページの N+1 クエリを getAllVoteCounters() 一括取得に変更 Co-Authored-By: Claude Sonnet 4.6 --- src/features/votes/actions/vote_actions.ts | 18 +++-- .../votes/components/VotableGrade.svelte | 4 +- src/features/votes/services/vote_crud.ts | 80 +++++-------------- src/features/votes/types/vote_result.ts | 7 ++ .../votes/utils/grade_options.test.ts | 21 +++++ src/features/votes/utils/grade_options.ts | 4 + src/features/votes/utils/median.test.ts | 66 +++++++++++++++ src/features/votes/utils/median.ts | 64 +++++++++++++++ .../(admin)/vote_management/+page.server.ts | 37 ++++----- src/routes/problems/+page.server.ts | 3 +- src/routes/votes/[slug]/+page.server.ts | 2 +- src/routes/votes/[slug]/+page.svelte | 20 +++-- 12 files changed, 229 insertions(+), 97 deletions(-) create mode 100644 src/features/votes/types/vote_result.ts create mode 100644 src/features/votes/utils/grade_options.test.ts create mode 100644 src/features/votes/utils/grade_options.ts create mode 100644 src/features/votes/utils/median.test.ts create mode 100644 src/features/votes/utils/median.ts diff --git a/src/features/votes/actions/vote_actions.ts b/src/features/votes/actions/vote_actions.ts index 44356ab1e..1a420ba1d 100644 --- a/src/features/votes/actions/vote_actions.ts +++ b/src/features/votes/actions/vote_actions.ts @@ -3,12 +3,14 @@ import { fail } from '@sveltejs/kit'; import * as crud from '@/features/votes/services/vote_crud'; import { BAD_REQUEST, UNAUTHORIZED } from '$lib/constants/http-response-status-codes'; -export const voteAbsoluteGrade = async ( - { request, locals }: { request: Request; locals: App.Locals }, - operationLog: string, -) => { - console.log(operationLog); - const response = await request.formData(); +export const voteAbsoluteGrade = async ({ + request, + locals, +}: { + request: Request; + locals: App.Locals; +}) => { + const formData = await request.formData(); const session = await locals.auth.validate(); if (!session || !session.user || !session.user.userId) { @@ -18,8 +20,8 @@ export const voteAbsoluteGrade = async ( } const userId = session.user.userId; - const taskId = response.get('taskId') as string; - const grade = response.get('grade') as string; + const taskId = formData.get('taskId') as string; + const grade = formData.get('grade') as string; try { await crud.upsertVoteGradeTables(userId, taskId, grade); diff --git a/src/features/votes/components/VotableGrade.svelte b/src/features/votes/components/VotableGrade.svelte index 14ec1fcc2..089a357a8 100644 --- a/src/features/votes/components/VotableGrade.svelte +++ b/src/features/votes/components/VotableGrade.svelte @@ -4,8 +4,9 @@ import { Dropdown, DropdownItem, DropdownDivider } from 'flowbite-svelte'; import Check from '@lucide/svelte/icons/check'; - import { taskGradeValues, TaskGrade, getTaskGrade, type TaskResult } from '$lib/types/task'; + import { TaskGrade, getTaskGrade, type TaskResult } from '$lib/types/task'; import { getTaskGradeLabel } from '$lib/utils/task'; + import { nonPendingGrades } from '$features/votes/utils/grade_options'; import { SIGNUP_PAGE, LOGIN_PAGE, VOTES_PAGE } from '$lib/constants/navbar-links'; import { errorMessageStore } from '$lib/stores/error_message'; @@ -30,7 +31,6 @@ let displayGrade = $state(initialGrade); const componentId = Math.random().toString(36).substring(2); - const nonPendingGrades = taskGradeValues.filter((g) => g !== TaskGrade.PENDING); let selectedVoteGrade = $state(); let showForm = $state(false); diff --git a/src/features/votes/services/vote_crud.ts b/src/features/votes/services/vote_crud.ts index c8752bda8..905cb49a2 100644 --- a/src/features/votes/services/vote_crud.ts +++ b/src/features/votes/services/vote_crud.ts @@ -1,40 +1,20 @@ import { default as prisma } from '$lib/server/database'; -import { TaskGrade } from '@prisma/client'; +import { TaskGrade, type VotedGradeStatistics } from '@prisma/client'; import { sha256 } from '$lib/utils/hash'; -import { getGradeOrder, taskGradeOrderInfinity } from '$lib/utils/task'; - -const OrderToTaskGrade: Map = new Map([ - [1, TaskGrade.Q11], - [2, TaskGrade.Q10], - [3, TaskGrade.Q9], - [4, TaskGrade.Q8], - [5, TaskGrade.Q7], - [6, TaskGrade.Q6], - [7, TaskGrade.Q5], - [8, TaskGrade.Q4], - [9, TaskGrade.Q3], - [10, TaskGrade.Q2], - [11, TaskGrade.Q1], - [12, TaskGrade.D1], - [13, TaskGrade.D2], - [14, TaskGrade.D3], - [15, TaskGrade.D4], - [16, TaskGrade.D5], - [17, TaskGrade.D6], - [taskGradeOrderInfinity, TaskGrade.PENDING], -]); - -export async function getVoteGrade(userId: string, taskId: string) { - const res = await prisma.voteGrade.findUnique({ +import type { VoteGradeResult } from '$features/votes/types/vote_result'; +import { computeMedianGrade } from '$features/votes/utils/median'; + +export async function getVoteGrade(userId: string, taskId: string): Promise { + const voteRecord = await prisma.voteGrade.findUnique({ where: { userId_taskId: { userId: userId, taskId: taskId }, }, }); let voted = false; let grade = null; - if (res !== null) { + if (voteRecord !== null) { voted = true; - grade = res.grade; + grade = voteRecord.grade; } return { voted: voted, @@ -42,12 +22,12 @@ export async function getVoteGrade(userId: string, taskId: string) { }; } -export async function getVoteGradeStatistics() { - const all_data = prisma.votedGradeStatistics.findMany(); - const gradesMap = new Map(); +export async function getVoteGradeStatistics(): Promise> { + const allStats = await prisma.votedGradeStatistics.findMany(); + const gradesMap = new Map(); - (await all_data).map((data) => { - gradesMap.set(data.taskId, data); + allStats.forEach((stat) => { + gradesMap.set(stat.taskId, stat); }); return gradesMap; } @@ -81,6 +61,11 @@ export async function getVoteCountersByTaskId(taskId: string) { }); } +/** Fetches all vote counters at once, for use when aggregating across many tasks. */ +export async function getAllVoteCounters() { + return prisma.votedGradeCounter.findMany(); +} + export async function getVoteStatsByTaskId(taskId: string) { return prisma.votedGradeStatistics.findFirst({ where: { taskId } }); } @@ -138,36 +123,13 @@ export async function upsertVoteGradeTables(userId: string, taskId: string, grad }); // Recompute median for this task and update VotedGradeStatistics - const counters = await tx.votedGradeCounter.findMany({ + const latestCounters = await tx.votedGradeCounter.findMany({ where: { taskId }, orderBy: { grade: 'asc' }, }); - const total = counters.reduce((s, c) => s + c.count, 0); - if (total >= 3) { - let median = 0; - - const getGradeOrderAtPosition = (target: number): number => { - let cum = 0; - for (const c of counters) { - cum += c.count; - if (cum >= target) return getGradeOrder(c.grade); - } - console.error('範囲外の値にアクセスしました'); - return taskGradeOrderInfinity; - }; - - if (total % 2) { - const target = Math.ceil(total / 2); - median = getGradeOrderAtPosition(target); - } else { - const target = total / 2; - median = Math.round( - (getGradeOrderAtPosition(target) + getGradeOrderAtPosition(target + 1)) / 2, - ); - } - - let medianGrade: TaskGrade = OrderToTaskGrade.get(median) as TaskGrade; + const medianGrade = computeMedianGrade(latestCounters); + if (medianGrade !== null) { const statsId = await sha256(taskId + 'stats'); await tx.votedGradeStatistics.upsert({ diff --git a/src/features/votes/types/vote_result.ts b/src/features/votes/types/vote_result.ts new file mode 100644 index 000000000..b5c836354 --- /dev/null +++ b/src/features/votes/types/vote_result.ts @@ -0,0 +1,7 @@ +import type { TaskGrade } from '$lib/types/task'; + +/** Result of fetching a single user's vote for a task. */ +export type VoteGradeResult = { + voted: boolean; + grade: TaskGrade | null; +}; diff --git a/src/features/votes/utils/grade_options.test.ts b/src/features/votes/utils/grade_options.test.ts new file mode 100644 index 000000000..0e76702b0 --- /dev/null +++ b/src/features/votes/utils/grade_options.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { TaskGrade, taskGradeValues } from '$lib/types/task'; + +import { nonPendingGrades } from './grade_options'; + +describe('nonPendingGrades', () => { + it('does not include PENDING', () => { + expect(nonPendingGrades).not.toContain(TaskGrade.PENDING); + }); + + it('contains all grades except PENDING', () => { + const expected = taskGradeValues.filter((grade) => grade !== TaskGrade.PENDING); + expect(nonPendingGrades).toEqual(expected); + }); + + it('starts with Q11 and ends with D6', () => { + expect(nonPendingGrades[0]).toBe(TaskGrade.Q11); + expect(nonPendingGrades[nonPendingGrades.length - 1]).toBe(TaskGrade.D6); + }); +}); diff --git a/src/features/votes/utils/grade_options.ts b/src/features/votes/utils/grade_options.ts new file mode 100644 index 000000000..57f443244 --- /dev/null +++ b/src/features/votes/utils/grade_options.ts @@ -0,0 +1,4 @@ +import { taskGradeValues, TaskGrade } from '$lib/types/task'; + +/** All grade values except PENDING, used for vote buttons and distribution display. */ +export const nonPendingGrades = taskGradeValues.filter((grade) => grade !== TaskGrade.PENDING); diff --git a/src/features/votes/utils/median.test.ts b/src/features/votes/utils/median.test.ts new file mode 100644 index 000000000..588ff6ec1 --- /dev/null +++ b/src/features/votes/utils/median.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { TaskGrade } from '@prisma/client'; + +import { computeMedianGrade } from './median'; + +describe('computeMedianGrade', () => { + it('returns null when total votes is below minVotes (default 3)', () => { + const counters = [ + { grade: TaskGrade.Q5, count: 1 }, + { grade: TaskGrade.Q4, count: 1 }, + ]; + expect(computeMedianGrade(counters)).toBeNull(); + }); + + it('returns null when counters are empty', () => { + expect(computeMedianGrade([])).toBeNull(); + }); + + it('returns null when total is exactly minVotes - 1', () => { + const counters = [{ grade: TaskGrade.Q3, count: 2 }]; + expect(computeMedianGrade(counters)).toBeNull(); + }); + + it('returns the median for an odd total (single grade, 3 votes)', () => { + const counters = [{ grade: TaskGrade.Q5, count: 3 }]; + expect(computeMedianGrade(counters)).toBe(TaskGrade.Q5); + }); + + it('returns the median for an odd total (multiple grades)', () => { + // votes: Q11, Q5, Q5 → sorted: Q11(1), Q5(2) → median is 2nd = Q5 + const counters = [ + { grade: TaskGrade.Q11, count: 1 }, + { grade: TaskGrade.Q5, count: 2 }, + ]; + expect(computeMedianGrade(counters)).toBe(TaskGrade.Q5); + }); + + it('returns the lower-rounded median for an even total', () => { + // votes: Q7(2), Q5(2) → positions 2=Q7, 3=Q5 → avg order → rounds to Q6 + const counters = [ + { grade: TaskGrade.Q7, count: 2 }, + { grade: TaskGrade.Q5, count: 2 }, + ]; + expect(computeMedianGrade(counters)).toBe(TaskGrade.Q6); + }); + + it('returns same grade when all votes are the same', () => { + const counters = [{ grade: TaskGrade.D1, count: 5 }]; + expect(computeMedianGrade(counters)).toBe(TaskGrade.D1); + }); + + it('handles votes concentrated at the extremes (Q11 and D6)', () => { + // 3 votes for Q11, 3 votes for D6 → order avg of (1+17)/2 = 9 = Q3 + const counters = [ + { grade: TaskGrade.Q11, count: 3 }, + { grade: TaskGrade.D6, count: 3 }, + ]; + expect(computeMedianGrade(counters)).toBe(TaskGrade.Q3); + }); + + it('respects a custom minVotes threshold', () => { + const counters = [{ grade: TaskGrade.Q5, count: 2 }]; + expect(computeMedianGrade(counters, 2)).toBe(TaskGrade.Q5); + }); +}); diff --git a/src/features/votes/utils/median.ts b/src/features/votes/utils/median.ts new file mode 100644 index 000000000..46e44b1c8 --- /dev/null +++ b/src/features/votes/utils/median.ts @@ -0,0 +1,64 @@ +import { TaskGrade } from '@prisma/client'; +import { getGradeOrder, taskGradeOrderInfinity } from '$lib/utils/task'; + +/** Maps grade order (1=Q11 … 17=D6) back to the corresponding TaskGrade enum value. */ +const ORDER_TO_TASK_GRADE: Map = new Map([ + [1, TaskGrade.Q11], + [2, TaskGrade.Q10], + [3, TaskGrade.Q9], + [4, TaskGrade.Q8], + [5, TaskGrade.Q7], + [6, TaskGrade.Q6], + [7, TaskGrade.Q5], + [8, TaskGrade.Q4], + [9, TaskGrade.Q3], + [10, TaskGrade.Q2], + [11, TaskGrade.Q1], + [12, TaskGrade.D1], + [13, TaskGrade.D2], + [14, TaskGrade.D3], + [15, TaskGrade.D4], + [16, TaskGrade.D5], + [17, TaskGrade.D6], + [taskGradeOrderInfinity, TaskGrade.PENDING], +]); + +type GradeCounter = { grade: TaskGrade; count: number }; + +/** + * Computes the median grade from a list of grade counters. + * Returns `null` when the total vote count is below the minimum threshold. + * + * @param counters - Grade counters sorted by grade ascending. + * @param minVotes - Minimum votes required to compute a median. Defaults to 3. + * @returns The median TaskGrade, or `null` if there are fewer than `minVotes` total votes. + */ +export function computeMedianGrade(counters: GradeCounter[], minVotes = 3): TaskGrade | null { + const total = counters.reduce((sum, counter) => sum + counter.count, 0); + if (total < minVotes) { + return null; + } + + const getGradeOrderAtPosition = (target: number): number => { + let cumulative = 0; + for (const counter of counters) { + cumulative += counter.count; + if (cumulative >= target) { + return getGradeOrder(counter.grade); + } + } + console.error('getGradeOrderAtPosition: position out of range'); + return taskGradeOrderInfinity; + }; + + let medianOrder: number; + if (total % 2 !== 0) { + medianOrder = getGradeOrderAtPosition(Math.ceil(total / 2)); + } else { + const lower = getGradeOrderAtPosition(total / 2); + const upper = getGradeOrderAtPosition(total / 2 + 1); + medianOrder = Math.round((lower + upper) / 2); + } + + return ORDER_TO_TASK_GRADE.get(medianOrder) as TaskGrade; +} diff --git a/src/routes/(admin)/vote_management/+page.server.ts b/src/routes/(admin)/vote_management/+page.server.ts index 16097485f..3140ae7ef 100644 --- a/src/routes/(admin)/vote_management/+page.server.ts +++ b/src/routes/(admin)/vote_management/+page.server.ts @@ -8,7 +8,7 @@ import { getUser } from '$lib/services/users'; import { getTasksByTaskId, updateTask } from '$lib/services/tasks'; import { getAllVoteStatisticsAsArray, - getVoteCountersByTaskId, + getAllVoteCounters, } from '$features/votes/services/vote_crud'; import { TEMPORARY_REDIRECT } from '$lib/constants/http-response-status-codes'; import { LOGIN_PAGE } from '$lib/constants/navbar-links'; @@ -24,27 +24,28 @@ async function validateAdminAccess(locals: App.Locals): Promise { export const load: PageServerLoad = async ({ locals }) => { await validateAdminAccess(locals); - const [allStats, tasksMap] = await Promise.all([ + const [allStats, tasksMap, allCounters] = await Promise.all([ getAllVoteStatisticsAsArray(), getTasksByTaskId(), + getAllVoteCounters(), ]); - // 各タスクの投票総数を取得 - const statsWithInfo = await Promise.all( - allStats.map(async (stat) => { - const task = tasksMap.get(stat.taskId); - const counters = await getVoteCountersByTaskId(stat.taskId); - const voteTotal = counters.reduce((sum, c) => sum + c.count, 0); - return { - taskId: stat.taskId, - title: task?.title ?? stat.taskId, - contestId: task?.contest_id ?? '', - dbGrade: task?.grade ?? 'PENDING', - estimatedGrade: stat.grade, - voteTotal, - }; - }), - ); + const voteTotalsMap = new Map(); + for (const counter of allCounters) { + voteTotalsMap.set(counter.taskId, (voteTotalsMap.get(counter.taskId) ?? 0) + counter.count); + } + + const statsWithInfo = allStats.map((stat) => { + const task = tasksMap.get(stat.taskId); + return { + taskId: stat.taskId, + title: task?.title ?? stat.taskId, + contestId: task?.contest_id ?? '', + dbGrade: task?.grade ?? 'PENDING', + estimatedGrade: stat.grade, + voteTotal: voteTotalsMap.get(stat.taskId) ?? 0, + }; + }); return { stats: statsWithInfo }; }; diff --git a/src/routes/problems/+page.server.ts b/src/routes/problems/+page.server.ts index d15f6d237..177b912fd 100644 --- a/src/routes/problems/+page.server.ts +++ b/src/routes/problems/+page.server.ts @@ -43,7 +43,6 @@ export const actions = { return await updateTaskResult({ request, locals }, operationLog); }, voteAbsoluteGrade: async ({ request, locals }) => { - const operationLog = 'problems -> actions -> voteAbsoluteGrade'; - await voteAbsoluteGrade({ request, locals }, operationLog); + await voteAbsoluteGrade({ request, locals }); }, } satisfies Actions; diff --git a/src/routes/votes/[slug]/+page.server.ts b/src/routes/votes/[slug]/+page.server.ts index c4a8af4e3..51b4e6e1b 100644 --- a/src/routes/votes/[slug]/+page.server.ts +++ b/src/routes/votes/[slug]/+page.server.ts @@ -43,6 +43,6 @@ export const load: PageServerLoad = async ({ locals, params }) => { export const actions: Actions = { voteAbsoluteGrade: async ({ request, locals }) => { - return await voteAbsoluteGrade({ request, locals }, 'votes/[slug] -> voteAbsoluteGrade'); + return await voteAbsoluteGrade({ request, locals }); }, }; diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte index 94a00208f..10b3a8211 100644 --- a/src/routes/votes/[slug]/+page.svelte +++ b/src/routes/votes/[slug]/+page.svelte @@ -7,14 +7,18 @@ import GradeLabel from '$lib/components/GradeLabel.svelte'; import ExternalLinkWrapper from '$lib/components/ExternalLinkWrapper.svelte'; - import { taskGradeValues, TaskGrade } from '$lib/types/task'; - import { getTaskGradeLabel, getTaskUrl, getTaskGradeColor, toChangeTextColorIfNeeds } from '$lib/utils/task'; + import { TaskGrade } from '$lib/types/task'; + import { + getTaskGradeLabel, + getTaskUrl, + getTaskGradeColor, + toChangeTextColorIfNeeds, + } from '$lib/utils/task'; + import { nonPendingGrades } from '$features/votes/utils/grade_options'; import { SIGNUP_PAGE, LOGIN_PAGE } from '$lib/constants/navbar-links'; let { data } = $props(); - const nonPendingGrades = taskGradeValues.filter((g) => g !== TaskGrade.PENDING); - const totalVotes = $derived( data.counters ? data.counters.reduce((sum, c) => sum + c.count, 0) : 0, ); @@ -137,9 +141,11 @@ type="submit" class="px-3 py-1.5 rounded-md text-sm font-medium border transition-opacity {grade === TaskGrade.D6 - ? 'text-white shadow-md shadow-amber-900/80 ring-2 ring-amber-300/50 font-bold drop-shadow relative overflow-hidden' - : toChangeTextColorIfNeeds(getTaskGradeLabel(grade))} - {data.myVote?.grade === grade ? 'ring-2 ring-offset-1 ring-gray-600 dark:ring-gray-300' : 'opacity-80 hover:opacity-100'}" + ? 'text-white shadow-md shadow-amber-900/80 ring-2 ring-amber-300/50 font-bold drop-shadow relative overflow-hidden' + : toChangeTextColorIfNeeds(getTaskGradeLabel(grade))} + {data.myVote?.grade === grade + ? 'ring-2 ring-offset-1 ring-gray-600 dark:ring-gray-300' + : 'opacity-80 hover:opacity-100'}" style={grade === TaskGrade.D6 ? 'background-image: linear-gradient(to bottom right, var(--color-atcoder-D6), rgb(120, 113, 108), rgb(217, 119, 6)); border-color: var(--color-atcoder-D6);' : `background-color: ${getTaskGradeColor(grade)}; border-color: ${getTaskGradeColor(grade)};`} From 57bee462d20f6256c7ac0fbdfc8fd5c1bd829191 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Wed, 25 Mar 2026 17:30:08 +0900 Subject: [PATCH 18/26] =?UTF-8?q?test:=20vote=5Fcrud.ts=20=E3=81=AE?= =?UTF-8?q?=E5=8D=98=E4=BD=93=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全 8 関数 (getVoteGrade, getVoteGradeStatistics, getAllTasksWithVoteInfo, getVoteCountersByTaskId, getVoteStatsByTaskId, getAllVoteStatisticsAsArray, getAllVoteCounters, upsertVoteGradeTables) をカバーする 20 件のテストを追加。 $transaction はコールバック実行方式でモックし、 冪等性・カウンタ増減・中央値統計の条件分岐を検証。 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + src/features/votes/services/vote_crud.test.ts | 398 ++++++++++++++++++ 2 files changed, 399 insertions(+) create mode 100644 src/features/votes/services/vote_crud.test.ts diff --git a/.gitignore b/.gitignore index dbe68c82b..ae7b09a38 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,4 @@ prisma/.fabbrica # Directory for playwright test results test-results +docs/dev-notes/2025-03-23/plan.md \ No newline at end of file diff --git a/src/features/votes/services/vote_crud.test.ts b/src/features/votes/services/vote_crud.test.ts new file mode 100644 index 000000000..bdbc4275d --- /dev/null +++ b/src/features/votes/services/vote_crud.test.ts @@ -0,0 +1,398 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import { TaskGrade } from '@prisma/client'; + +import { + getVoteGrade, + getVoteGradeStatistics, + getAllTasksWithVoteInfo, + getVoteCountersByTaskId, + getVoteStatsByTaskId, + getAllVoteStatisticsAsArray, + getAllVoteCounters, + upsertVoteGradeTables, +} from './vote_crud'; + +vi.mock('$lib/server/database', () => ({ + default: { + voteGrade: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + votedGradeCounter: { + findMany: vi.fn(), + update: vi.fn(), + upsert: vi.fn(), + }, + votedGradeStatistics: { + findMany: vi.fn(), + findFirst: vi.fn(), + upsert: vi.fn(), + }, + task: { + findMany: vi.fn(), + }, + $transaction: vi.fn(), + }, +})); + +import prisma from '$lib/server/database'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Type aliases +// --------------------------------------------------------------------------- + +type PrismaVoteGrade = Awaited>; +type PrismaVotedGradeStatistics = Awaited< + ReturnType +>[number]; +type PrismaVotedGradeCounter = Awaited< + ReturnType +>[number]; + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +function mockVoteGradeFindUnique(value: PrismaVoteGrade) { + vi.mocked(prisma.voteGrade.findUnique).mockResolvedValue(value); +} + +function mockVotedGradeStatisticsFindMany(value: PrismaVotedGradeStatistics[]) { + vi.mocked(prisma.votedGradeStatistics.findMany).mockResolvedValue( + value as unknown as Awaited>, + ); +} + +function mockVotedGradeCounterFindMany(value: PrismaVotedGradeCounter[]) { + vi.mocked(prisma.votedGradeCounter.findMany).mockResolvedValue( + value as unknown as Awaited>, + ); +} + +function mockTaskFindMany(value: object[]) { + vi.mocked(prisma.task.findMany).mockResolvedValue( + value as unknown as Awaited>, + ); +} + +/** Creates a mock transaction client and wires prisma.$transaction to execute the callback with it. */ +function setupTransaction() { + const mockTx = { + voteGrade: { findUnique: vi.fn(), upsert: vi.fn() }, + votedGradeCounter: { update: vi.fn(), upsert: vi.fn(), findMany: vi.fn() }, + votedGradeStatistics: { upsert: vi.fn() }, + }; + vi.mocked(prisma.$transaction).mockImplementation(async (callback: unknown) => + (callback as (tx: typeof mockTx) => Promise)(mockTx), + ); + return mockTx; +} + +function makeStatisticsRecord( + overrides: Partial = {}, +): PrismaVotedGradeStatistics { + return { + id: 'stats-abc001_a', + taskId: 'abc001_a', + grade: TaskGrade.Q5, + isExperimental: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as unknown as PrismaVotedGradeStatistics; +} + +function makeCounterRecord( + overrides: Partial = {}, +): PrismaVotedGradeCounter { + return { + id: 'counter-abc001_a-Q5', + taskId: 'abc001_a', + grade: TaskGrade.Q5, + count: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as unknown as PrismaVotedGradeCounter; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('getVoteGrade', () => { + test('returns voted: false and grade: null when no vote record exists', async () => { + mockVoteGradeFindUnique(null); + + const result = await getVoteGrade('user-1', 'abc001_a'); + + expect(result).toEqual({ voted: false, grade: null }); + }); + + test('returns voted: true and the stored grade when a vote record exists', async () => { + mockVoteGradeFindUnique({ + id: 'vote-1', + userId: 'user-1', + taskId: 'abc001_a', + grade: TaskGrade.Q5, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await getVoteGrade('user-1', 'abc001_a'); + + expect(result).toEqual({ voted: true, grade: TaskGrade.Q5 }); + }); + + test('queries with the correct userId and taskId', async () => { + mockVoteGradeFindUnique(null); + + await getVoteGrade('user-42', 'abc123_d'); + + expect(prisma.voteGrade.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId_taskId: { userId: 'user-42', taskId: 'abc123_d' } }, + }), + ); + }); +}); + +describe('getVoteGradeStatistics', () => { + test('returns an empty Map when no statistics exist', async () => { + mockVotedGradeStatisticsFindMany([]); + + const result = await getVoteGradeStatistics(); + + 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 getVoteGradeStatistics(); + + expect(result.get('abc001_a')?.grade).toBe(TaskGrade.Q5); + }); + + test('builds a Map entry per statistics record', 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 getVoteGradeStatistics(); + + expect(result.size).toBe(2); + expect(result.get('abc002_a')?.grade).toBe(TaskGrade.D1); + }); +}); + +describe('getAllTasksWithVoteInfo', () => { + test('attaches estimatedGrade from statistics when available', async () => { + mockTaskFindMany([ + { task_id: 'abc001_a', contest_id: 'abc001', title: 'Problem A', grade: TaskGrade.PENDING }, + ]); + mockVotedGradeStatisticsFindMany([ + makeStatisticsRecord({ taskId: 'abc001_a', grade: TaskGrade.Q5 }), + ]); + mockVotedGradeCounterFindMany([]); + + const result = await getAllTasksWithVoteInfo(); + + expect(result[0].estimatedGrade).toBe(TaskGrade.Q5); + }); + + test('returns null estimatedGrade when no statistics exist for the task', async () => { + mockTaskFindMany([ + { task_id: 'abc001_a', contest_id: 'abc001', title: 'Problem A', grade: TaskGrade.PENDING }, + ]); + mockVotedGradeStatisticsFindMany([]); + mockVotedGradeCounterFindMany([]); + + const result = await getAllTasksWithVoteInfo(); + + expect(result[0].estimatedGrade).toBeNull(); + }); + + test('aggregates voteTotal across all grade counters for the task', async () => { + mockTaskFindMany([ + { task_id: 'abc001_a', contest_id: 'abc001', title: 'Problem A', grade: TaskGrade.PENDING }, + ]); + mockVotedGradeStatisticsFindMany([]); + mockVotedGradeCounterFindMany([ + makeCounterRecord({ taskId: 'abc001_a', grade: TaskGrade.Q5, count: 2 }), + makeCounterRecord({ id: 'c2', taskId: 'abc001_a', grade: TaskGrade.Q4, count: 3 }), + ]); + + const result = await getAllTasksWithVoteInfo(); + + expect(result[0].voteTotal).toBe(5); + }); + + test('returns 0 voteTotal when no counters exist for the task', async () => { + mockTaskFindMany([ + { task_id: 'abc001_a', contest_id: 'abc001', title: 'Problem A', grade: TaskGrade.PENDING }, + ]); + mockVotedGradeStatisticsFindMany([]); + mockVotedGradeCounterFindMany([]); + + const result = await getAllTasksWithVoteInfo(); + + expect(result[0].voteTotal).toBe(0); + }); +}); + +describe('getVoteCountersByTaskId', () => { + test('queries with the correct taskId filter and grade ascending order', async () => { + mockVotedGradeCounterFindMany([]); + + await getVoteCountersByTaskId('abc001_a'); + + expect(prisma.votedGradeCounter.findMany).toHaveBeenCalledWith({ + where: { taskId: 'abc001_a' }, + orderBy: { grade: 'asc' }, + }); + }); +}); + +describe('getVoteStatsByTaskId', () => { + test('queries with the correct taskId filter', async () => { + vi.mocked(prisma.votedGradeStatistics.findFirst).mockResolvedValue( + makeStatisticsRecord() as unknown as Awaited< + ReturnType + >, + ); + + await getVoteStatsByTaskId('abc001_a'); + + expect(prisma.votedGradeStatistics.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { taskId: 'abc001_a' } }), + ); + }); + + test('returns null when no statistics exist for the task', async () => { + vi.mocked(prisma.votedGradeStatistics.findFirst).mockResolvedValue(null); + + const result = await getVoteStatsByTaskId('abc001_a'); + + expect(result).toBeNull(); + }); +}); + +describe('getAllVoteStatisticsAsArray', () => { + test('queries with taskId ascending order', async () => { + mockVotedGradeStatisticsFindMany([]); + + await getAllVoteStatisticsAsArray(); + + expect(prisma.votedGradeStatistics.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { taskId: 'asc' } }), + ); + }); +}); + +describe('getAllVoteCounters', () => { + test('returns all vote counters without filters', async () => { + const counters = [makeCounterRecord(), makeCounterRecord({ id: 'c2', taskId: 'abc002_a' })]; + mockVotedGradeCounterFindMany(counters); + + const result = await getAllVoteCounters(); + + expect(result).toHaveLength(2); + expect(prisma.votedGradeCounter.findMany).toHaveBeenCalledWith(); + }); +}); + +describe('upsertVoteGradeTables', () => { + test('exits early without any writes when vote grade is unchanged (idempotency)', async () => { + const tx = setupTransaction(); + tx.voteGrade.findUnique.mockResolvedValue({ grade: TaskGrade.Q5 }); + + await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5); + + expect(tx.votedGradeCounter.update).not.toHaveBeenCalled(); + expect(tx.voteGrade.upsert).not.toHaveBeenCalled(); + expect(tx.votedGradeCounter.upsert).not.toHaveBeenCalled(); + }); + + test('decrements the old grade counter when the user changes their vote', async () => { + const tx = setupTransaction(); + tx.voteGrade.findUnique.mockResolvedValue({ grade: TaskGrade.Q4 }); + tx.voteGrade.upsert.mockResolvedValue({}); + tx.votedGradeCounter.upsert.mockResolvedValue({}); + tx.votedGradeCounter.findMany.mockResolvedValue([ + { grade: TaskGrade.Q5, count: 1 }, + { grade: TaskGrade.Q4, count: 0 }, + ]); + + await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5); + + expect(tx.votedGradeCounter.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { taskId_grade: { taskId: 'abc001_a', grade: TaskGrade.Q4 } }, + data: { count: { decrement: 1 } }, + }), + ); + }); + + test('upserts VoteGrade and increments counter for the new grade', async () => { + const tx = setupTransaction(); + tx.voteGrade.findUnique.mockResolvedValue(null); + tx.voteGrade.upsert.mockResolvedValue({}); + tx.votedGradeCounter.upsert.mockResolvedValue({}); + tx.votedGradeCounter.findMany.mockResolvedValue([{ grade: TaskGrade.Q5, count: 1 }]); + + await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5); + + expect(tx.voteGrade.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId_taskId: { userId: 'user-1', taskId: 'abc001_a' } }, + update: { grade: TaskGrade.Q5 }, + }), + ); + expect(tx.votedGradeCounter.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { taskId_grade: { taskId: 'abc001_a', grade: TaskGrade.Q5 } }, + update: { count: { increment: 1 } }, + }), + ); + }); + + test('upserts VotedGradeStatistics when total votes reaches 3', async () => { + const tx = setupTransaction(); + tx.voteGrade.findUnique.mockResolvedValue(null); + tx.voteGrade.upsert.mockResolvedValue({}); + tx.votedGradeCounter.upsert.mockResolvedValue({}); + // 3 votes all on Q5 → median = Q5 + tx.votedGradeCounter.findMany.mockResolvedValue([{ grade: TaskGrade.Q5, count: 3 }]); + tx.votedGradeStatistics.upsert.mockResolvedValue({}); + + await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5); + + expect(tx.votedGradeStatistics.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { taskId: 'abc001_a' }, + update: { grade: TaskGrade.Q5 }, + }), + ); + }); + + test('does not upsert VotedGradeStatistics when total votes is below 3', async () => { + const tx = setupTransaction(); + tx.voteGrade.findUnique.mockResolvedValue(null); + tx.voteGrade.upsert.mockResolvedValue({}); + tx.votedGradeCounter.upsert.mockResolvedValue({}); + tx.votedGradeCounter.findMany.mockResolvedValue([{ grade: TaskGrade.Q5, count: 2 }]); + + await upsertVoteGradeTables('user-1', 'abc001_a', TaskGrade.Q5); + + expect(tx.votedGradeStatistics.upsert).not.toHaveBeenCalled(); + }); +}); From f67208673e93f0545f4fff9ca8e5bc7aec3bfdb0 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Wed, 25 Mar 2026 17:46:08 +0900 Subject: [PATCH 19/26] =?UTF-8?q?refactor:=20vote=5Fcrud.ts=20=E3=82=92=20?= =?UTF-8?q?vote=5Fgrade=20/=20vote=5Fstatistics=20=E3=81=AB=E5=88=86?= =?UTF-8?q?=E5=89=B2=E3=81=97=E5=9E=8B=E3=83=BB=E4=BE=9D=E5=AD=98=E3=82=92?= =?UTF-8?q?=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vote_crud.ts を vote_grade.ts(書き込み)と vote_statistics.ts(読み込み)に分割 - vote_crud.test.ts も対応する2ファイルに分割 - upsertVoteGradeTables を小関数に分解、MIN_VOTES_FOR_STATISTICS を定数化 - grade を string から TaskGrade に型強化 (vote_actions.ts) - VoteStatisticsMap / VoteStatisticsEntry の named type を追加 - vote_statistics.ts の全関数に明示的な戻り値型を追加 - validateAdminAccess を _utils/auth.ts から再利用 (vote_management) - getMedianVote をサービス層 (getVoteStatsByTaskId) 経由に変更 Co-Authored-By: Claude Sonnet 4.6 --- .../components/contest-table/TaskTable.svelte | 3 +- .../contest-table/TaskTableBodyCell.svelte | 3 +- src/features/votes/actions/vote_actions.ts | 7 +- src/features/votes/services/vote_crud.ts | 149 -------------- .../votes/services/vote_grade.test.ts | 184 ++++++++++++++++++ src/features/votes/services/vote_grade.ts | 133 +++++++++++++ ...e_crud.test.ts => vote_statistics.test.ts} | 151 +------------- .../votes/services/vote_statistics.ts | 65 +++++++ src/features/votes/types/vote_result.ts | 6 + .../(admin)/vote_management/+page.server.ts | 17 +- src/routes/problems/+page.server.ts | 6 +- src/routes/problems/getMedianVote/+server.ts | 4 +- src/routes/problems/getMyVote/+server.ts | 2 +- src/routes/votes/+page.server.ts | 4 +- src/routes/votes/[slug]/+page.server.ts | 4 +- 15 files changed, 409 insertions(+), 329 deletions(-) delete mode 100644 src/features/votes/services/vote_crud.ts create mode 100644 src/features/votes/services/vote_grade.test.ts create mode 100644 src/features/votes/services/vote_grade.ts rename src/features/votes/services/{vote_crud.test.ts => vote_statistics.test.ts} (58%) create mode 100644 src/features/votes/services/vote_statistics.ts diff --git a/src/features/tasks/components/contest-table/TaskTable.svelte b/src/features/tasks/components/contest-table/TaskTable.svelte index 2b21876c2..1276e11fd 100644 --- a/src/features/tasks/components/contest-table/TaskTable.svelte +++ b/src/features/tasks/components/contest-table/TaskTable.svelte @@ -19,6 +19,7 @@ ContestTableMetaData, } from '$features/tasks/types/contest-table/contest_table_provider'; import type { ContestTaskPairKey } from '$lib/types/contest_task_pair'; + import type { VoteStatisticsMap } from '$features/votes/types/vote_result'; import TaskTableBodyCell from './TaskTableBodyCell.svelte'; @@ -36,7 +37,7 @@ interface Props { taskResults: TaskResults; isLoggedIn: boolean; - voteResults: Map; + voteResults: VoteStatisticsMap; } let { taskResults, isLoggedIn, voteResults }: Props = $props(); diff --git a/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte b/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte index 435e4bdd4..684ad5fc7 100644 --- a/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte +++ b/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte @@ -1,5 +1,6 @@ From 024f9bb260139d00a8073c737f2747c5621916eb Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Thu, 26 Mar 2026 10:32:17 +0900 Subject: [PATCH 22/26] =?UTF-8?q?fix:=20lint=E4=BF=AE=E6=AD=A3=E3=83=BBe2e?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E8=BF=BD=E5=8A=A0=E3=83=BB=E3=83=90?= =?UTF-8?q?=E3=83=AA=E3=83=87=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=83=AB?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E8=BF=BD=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pnpm format で Prettier フォーマットを修正 (vote_grade.ts, vote_statistics.ts, median.ts) - svelte/no-navigation-without-resolve エラーを修正: 投票関連3ページの を resolve() from '$app/paths' でラップ (votes/+page.svelte, votes/[slug]/+page.svelte, vote_management/+page.svelte) - e2e/votes.spec.ts を追加: 投票一覧・詳細・管理ページの認証状態別アクセス制御を確認 (未認証/一般ユーザ/管理者ごとに表示内容・リダイレクト・UI要素を検証) - .claude/rules/sveltekit.md にサーバーサイドバリデーションルールを追記: formData.get() の null/型チェックと enum バリデーションのパターンを明文化 Co-Authored-By: Claude Sonnet 4.6 --- .claude/rules/sveltekit.md | 29 +++ e2e/votes.spec.ts | 178 ++++++++++++++++++ src/features/votes/services/vote_grade.ts | 9 +- .../votes/services/vote_statistics.ts | 4 +- src/features/votes/utils/median.ts | 4 +- .../(admin)/vote_management/+page.svelte | 3 +- src/routes/votes/+page.svelte | 3 +- src/routes/votes/[slug]/+page.svelte | 3 +- 8 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 e2e/votes.spec.ts diff --git a/.claude/rules/sveltekit.md b/.claude/rules/sveltekit.md index 8b086db7b..d7f8bf00b 100644 --- a/.claude/rules/sveltekit.md +++ b/.claude/rules/sveltekit.md @@ -44,6 +44,35 @@ goto(resolve(url.pathname + url.search)); replaceState(resolve(url.pathname + url.search + url.hash), state); ``` +## Server-side Form Data Validation + +`formData.get()` returns `string | File | null`. Never cast directly with `as string` or `as TaskGrade` — always validate first: + +```typescript +// Bad — unsafe cast, null reaches the DB layer +const taskId = data.get('taskId') as string; +const grade = data.get('grade') as TaskGrade; + +// Good — validate before use +const taskId = data.get('taskId'); +const grade = data.get('grade'); +if (typeof taskId !== 'string' || !taskId || typeof grade !== 'string') { + return { success: false }; +} +// taskId and grade are now string, safe to pass onward +``` + +For enum fields, add a membership check after the type guard: + +```typescript +if (!(Object.values(TaskGrade) as string[]).includes(gradeRaw)) { + return fail(BAD_REQUEST, { message: 'Invalid grade value.' }); +} +const grade = gradeRaw as TaskGrade; +``` + +The same pattern applies to `url.searchParams.get()` in `+server.ts` handlers. + ## Page Component Props SvelteKit page components (`+page.svelte`) accept only `data` and `form` as props (`svelte/valid-prop-names-in-kit-pages`). Commented-out features that reference other props are not "dead code" — remove only the violating prop declaration, preserve the feature code. diff --git a/e2e/votes.spec.ts b/e2e/votes.spec.ts new file mode 100644 index 000000000..152ffef99 --- /dev/null +++ b/e2e/votes.spec.ts @@ -0,0 +1,178 @@ +import { test, expect } from '@playwright/test'; + +import { loginAsAdmin, loginAsUser } from './helpers/auth'; + +const TIMEOUT = 60 * 1000; +const VOTES_LIST_URL = '/votes'; +const VOTE_MANAGEMENT_URL = '/vote_management'; + +// --------------------------------------------------------------------------- +// Votes list page (/votes) +// --------------------------------------------------------------------------- + +test.describe('votes list page (/votes)', () => { + test('unauthenticated user can view the page without redirect', async ({ page }) => { + await page.goto(VOTES_LIST_URL); + await expect(page).toHaveURL(VOTES_LIST_URL, { timeout: TIMEOUT }); + await expect(page.getByRole('heading', { name: 'グレード投票' })).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('task table is visible to unauthenticated user', async ({ page }) => { + await page.goto(VOTES_LIST_URL); + await expect(page.getByRole('columnheader', { name: '問題' })).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.getByRole('columnheader', { name: 'コンテスト' })).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('logged-in user can view the page', async ({ page }) => { + await loginAsUser(page); + await page.goto(VOTES_LIST_URL); + await expect(page).toHaveURL(VOTES_LIST_URL, { timeout: TIMEOUT }); + await expect(page.getByRole('heading', { name: 'グレード投票' })).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('search input filters tasks by title', async ({ page }) => { + await page.goto(VOTES_LIST_URL); + const searchInput = page.getByPlaceholder('問題名・問題ID・コンテストIDで検索'); + await expect(searchInput).toBeVisible({ timeout: TIMEOUT }); + + // Type a string unlikely to match any task to get 0 results + await searchInput.fill('__no_match_expected__'); + await expect(page.getByText('該当する問題が見つかりませんでした')).toBeVisible({ + timeout: TIMEOUT, + }); + }); +}); + +// --------------------------------------------------------------------------- +// Vote detail page (/votes/[slug]) +// --------------------------------------------------------------------------- + +test.describe('vote detail page (/votes/[slug])', () => { + /** + * Navigates to the first task in the vote list. + * Assumes at least one task exists in the DB. + */ + async function navigateToFirstVoteDetailPage(page: Parameters[1]): Promise { + await page.goto(VOTES_LIST_URL); + await expect(page.getByRole('columnheader', { name: '問題' })).toBeVisible({ + timeout: TIMEOUT, + }); + // Click the first task title link in the table + await page.locator('table').getByRole('link').first().click(); + await expect(page).toHaveURL(/\/votes\/.+/, { timeout: TIMEOUT }); + } + + test.describe('unauthenticated user', () => { + test('can view the task detail page without redirect', async ({ page }) => { + await navigateToFirstVoteDetailPage(page); + // Should stay on /votes/[slug], not redirected + await expect(page).toHaveURL(/\/votes\/.+/, { timeout: TIMEOUT }); + }); + + test('sees login prompt instead of vote buttons', async ({ page }) => { + await navigateToFirstVoteDetailPage(page); + await expect(page.getByText('投票するにはログインが必要です')).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.getByRole('link', { name: 'ログイン' })).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.getByRole('link', { name: 'アカウント作成' })).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('does not see vote grade buttons', async ({ page }) => { + await navigateToFirstVoteDetailPage(page); + // Grade buttons are only rendered inside the vote form (not shown when logged out) + await expect(page.locator('form[action="?/voteAbsoluteGrade"]')).not.toBeAttached(); + }); + + test('breadcrumb link navigates back to /votes', async ({ page }) => { + await navigateToFirstVoteDetailPage(page); + await page.getByRole('link', { name: 'グレード投票' }).click(); + await expect(page).toHaveURL(VOTES_LIST_URL, { timeout: TIMEOUT }); + }); + }); + + test.describe('logged-in user', () => { + test.beforeEach(async ({ page }) => { + await loginAsUser(page); + }); + + test('can view the task detail page', async ({ page }) => { + await navigateToFirstVoteDetailPage(page); + await expect(page).toHaveURL(/\/votes\/.+/, { timeout: TIMEOUT }); + }); + + test('sees vote grade buttons', async ({ page }) => { + await navigateToFirstVoteDetailPage(page); + // Vote form with grade buttons is rendered for logged-in users + await expect(page.locator('form[action="?/voteAbsoluteGrade"]')).toBeVisible({ + timeout: TIMEOUT, + }); + // The grade buttons should include Q11 (11Q) + await expect(page.getByRole('button', { name: '11Q' })).toBeVisible({ timeout: TIMEOUT }); + }); + + test('does not see login prompt', async ({ page }) => { + await navigateToFirstVoteDetailPage(page); + await expect(page.getByText('投票するにはログインが必要です')).not.toBeVisible(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Vote management page (/vote_management) — admin only +// --------------------------------------------------------------------------- + +test.describe('vote management page (/vote_management)', () => { + test('unauthenticated user is redirected to /login', async ({ page }) => { + await page.goto(VOTE_MANAGEMENT_URL); + await expect(page).toHaveURL('/login', { timeout: TIMEOUT }); + }); + + test('non-admin user is redirected to /login', async ({ page }) => { + await loginAsUser(page); + await page.goto(VOTE_MANAGEMENT_URL); + await expect(page).toHaveURL('/login', { timeout: TIMEOUT }); + }); + + test.describe('admin user', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test('can access the page', async ({ page }) => { + await page.goto(VOTE_MANAGEMENT_URL); + await expect(page).toHaveURL(VOTE_MANAGEMENT_URL, { timeout: TIMEOUT }); + await expect(page.getByRole('heading', { name: '投票管理' })).toBeVisible({ + timeout: TIMEOUT, + }); + }); + + test('sees the vote management table with expected columns', async ({ page }) => { + await page.goto(VOTE_MANAGEMENT_URL); + await expect(page.getByRole('columnheader', { name: '問題' })).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.getByRole('columnheader', { name: 'DBグレード' })).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.getByRole('columnheader', { name: '中央値グレード' })).toBeVisible({ + timeout: TIMEOUT, + }); + await expect(page.getByRole('columnheader', { name: '票数' })).toBeVisible({ + timeout: TIMEOUT, + }); + }); + }); +}); diff --git a/src/features/votes/services/vote_grade.ts b/src/features/votes/services/vote_grade.ts index 33187d38c..66dc536c9 100644 --- a/src/features/votes/services/vote_grade.ts +++ b/src/features/votes/services/vote_grade.ts @@ -103,7 +103,14 @@ async function incrementNewGradeCounter( await tx.votedGradeCounter.upsert({ where: { taskId_grade: { taskId, grade } }, update: { count: { increment: 1 } }, - create: { id: counterId, taskId, grade, count: 1, createdAt: new Date(), updatedAt: new Date() }, + create: { + id: counterId, + taskId, + grade, + count: 1, + createdAt: new Date(), + updatedAt: new Date(), + }, }); } diff --git a/src/features/votes/services/vote_statistics.ts b/src/features/votes/services/vote_statistics.ts index c59cd471a..b7516ae1a 100644 --- a/src/features/votes/services/vote_statistics.ts +++ b/src/features/votes/services/vote_statistics.ts @@ -54,9 +54,7 @@ export async function getAllVoteCounters(): Promise { return prisma.votedGradeCounter.findMany(); } -export async function getVoteStatsByTaskId( - taskId: string, -): Promise { +export async function getVoteStatsByTaskId(taskId: string): Promise { return prisma.votedGradeStatistics.findFirst({ where: { taskId } }); } diff --git a/src/features/votes/utils/median.ts b/src/features/votes/utils/median.ts index 24ccd257d..42ba7b187 100644 --- a/src/features/votes/utils/median.ts +++ b/src/features/votes/utils/median.ts @@ -47,7 +47,9 @@ export function computeMedianGrade(counters: GradeCounter[], minVotes = 3): Task return getGradeOrder(counter.grade); } } - throw new RangeError(`getGradeOrderAtPosition: position ${target} is out of range (total=${total})`); + throw new RangeError( + `getGradeOrderAtPosition: position ${target} is out of range (total=${total})`, + ); }; let medianOrder: number; diff --git a/src/routes/(admin)/vote_management/+page.svelte b/src/routes/(admin)/vote_management/+page.svelte index 8a0c8e9f5..17d60cad6 100644 --- a/src/routes/(admin)/vote_management/+page.svelte +++ b/src/routes/(admin)/vote_management/+page.svelte @@ -1,5 +1,6 @@ -
+
Date: Fri, 27 Mar 2026 08:29:19 +0900 Subject: [PATCH 26/26] fix: address remaining CodeRabbit/Copilot findings on PR #3316 - Skip getMyVote API call when user is not logged in to avoid unnecessary 401s - Use resolve() for /votes/[slug] href in dropdown to satisfy no-navigation-without-resolve - Guard getMedianVote endpoint: require auth and verify user has voted before returning stats Co-Authored-By: Claude Sonnet 4.6 --- src/features/votes/components/VotableGrade.svelte | 9 ++++++--- src/routes/problems/getMedianVote/+server.ts | 12 +++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/features/votes/components/VotableGrade.svelte b/src/features/votes/components/VotableGrade.svelte index 6af19da55..4f8757e6d 100644 --- a/src/features/votes/components/VotableGrade.svelte +++ b/src/features/votes/components/VotableGrade.svelte @@ -1,6 +1,7 @@