Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions docs/superpowers/plans/2026-05-20-zero-diff-badge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# ±0 相対評価バッジ表示 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** diff=0(ユーザ投票中央値と運営グレードが一致)のとき、グレードアイコン右上に緑色の `±0` バッジを表示する。

**Architecture:** `getRelativeEvaluationLabel` が diff=0 で `'±0'` を返すよう変更し、対応するバッジ色・ツールチップ文言をユーティリティ関数に追加する。`RelativeEvaluationBadge.svelte` は `{#if label}` で表示制御しており `'±0'` は truthy なため、コンポーネント本体の変更は不要。

**Tech Stack:** TypeScript, Svelte 5 (Runes), Vitest, Tailwind CSS v4

---

## File Map

| Action | File | 変更内容 |
| ------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Modify | `src/features/votes/utils/relative_evaluation.ts` | `getRelativeEvaluationLabel(0)` → `'±0'`、`getRelativeEvaluationBadgeColorClass(0)` → グリーン、`getRelativeEvaluationTooltipText('±0')` → 日本語文言追加 |
| Modify | `src/features/votes/utils/relative_evaluation.test.ts` | diff=0 のアサーション更新・追加 |

---

### Task 1: テストを更新して失敗させる ✅

**Files:**

- Modify: `src/features/votes/utils/relative_evaluation.test.ts`

- [x] **Step 1: `getRelativeEvaluationLabel(0)` のアサーションを `'±0'` に変更する**
- [x] **Step 2: `getRelativeEvaluationBadgeColorClass(0)` のアサーションをグリーンクラスに変更する**
- [x] **Step 3: `getRelativeEvaluationTooltipText('±0')` のテストを追加する**
- [x] **Step 4: テストを実行して失敗を確認する**

---

### Task 2: 実装を変更してテストを通す ✅

**Files:**

- Modify: `src/features/votes/utils/relative_evaluation.ts`

- [x] **Step 1: `getRelativeEvaluationLabel` の diff=0 分岐を `'±0'` に変更する**
- [x] **Step 2: `getRelativeEvaluationBadgeColorClass` の diff=0 分岐にグリーンを返すよう変更する**
- [x] **Step 3: `getRelativeEvaluationTooltipText` に `'±0'` ケースを追加する**
- [x] **Step 4: テストを実行してすべて通ることを確認する** (38 tests passed)
- [x] **Step 5: ユニットテスト全体を実行してリグレッションがないことを確認する**
- [x] **Step 6: TSDoc コメントを更新する**
- [x] **Step 7: コミットする**

---

## 追加変更(実装後にユーザー要求)

- `prisma/seed.ts`: `addVoteStatisticsDemoData()` 追加 — APG4bPython_co に diff=0 の VotedGradeStatistics を作成してバッジを目視確認できるようにした
- `getRelativeEvaluationBadgeColorClass(0)`: バッジの色合いを `500/600` → `400/500` に修正(sky/orange の既存バッジと統一)
- `getRelativeEvaluationColorClass(0)`: テキスト色を gray → green に変更(diff=0 のニュートラル色を統一)

---

## 教訓(新規・非自明なもの)

### Docker node_modules シンボリックリンク欠損問題

**現象:** `pnpm db:seed` が `Cannot find module '/usr/src/app/node_modules/pnpm/bin/pnpm.mjs'` でクラッシュ。
**原因:** `node_modules/.pnpm-workspace-state-v1.json` に `lastValidatedTimestamp` が記録されていたため、pnpm がインストール済みと判断してスコープなしパッケージ(tsx, pnpm, p-queue, prisma 等)のトップレベルシンボリックリンクを貼り直さなかった。スコープ付きパッケージ(@prisma 等)は正常だったため一見インストール済みに見える。
**修正コマンド:**

```bash
docker compose exec web rm node_modules/.pnpm-workspace-state-v1.json
docker compose exec web /usr/local/share/npm-global/bin/pnpm install
```

この問題は `compose.yaml` の `./node_modules:/usr/src/app/node_modules:cached` マウントでホスト側 node_modules をコンテナに共有しているため、ホスト側で別の Node バージョンを使って操作した際などに発生する。

### `getRelativeEvaluationColorClass` の TSDoc 陳腐化

バッジ色と同じ関数のコメントが古い情報(gray)を残したまま実装だけ緑に更新した。コードレビューで検出。今後: 色変更時は必ず同じ関数の TSDoc 内の色名も同時に確認する。

## CodeRabbit Findings

(PRを開いた後に実施予定)
57 changes: 57 additions & 0 deletions docs/superpowers/specs/2026-05-20-zero-diff-badge-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# ±0 相対評価バッジ表示機能 — 設計仕様

## 概要

ユーザ投票の中央値グレードと運営グレードの差(diff)が 0 のとき、グレードアイコン右上に `±0` バッジを表示する。現在は diff=0 のときバッジが非表示だが、「一致している」という情報を明示的に伝えるよう変更する。

## 設計根拠

- diff=0 は「ユーザの評価が運営グレードと一致」という有意な情報であり、表示しないと他の diff 値と非対称になる
- 既存の badge ロジック(`getRelativeEvaluationLabel` → `RelativeEvaluationBadge`)をそのまま流用できるため、変更箇所が最小限

## 却下した代替案

- **Option B(コンポーネント内で diff=0 を特別処理)**: `label` と `diff` の責務が混在し、変更箇所が増えるため却下
- **Option C(`showZeroBadge` prop 追加)**: 全呼び出し箇所で同じ挙動をするため YAGNI。不要な複雑性を避けて却下

## 変更対象

### `src/features/votes/utils/relative_evaluation.ts`

| 関数 | diff=0 の現在値 | 変更後 |
| -------------------------------------- | --------------- | ------------------------------------------------------------- |
| `getRelativeEvaluationLabel` | `''` | `'±0'` |
| `getRelativeEvaluationBadgeColorClass` | `''` | `'bg-green-500 text-white dark:bg-green-600 dark:text-white'` |
| `getRelativeEvaluationTooltipText` | `''`(default) | `'ユーザは「ふつう」と評価'`(`'±0'` case 追加) |

### `src/features/votes/components/RelativeEvaluationBadge.svelte`

- `{#if label}` ブロックは変更不要(`'±0'` は truthy)
- `--`/`++` のスペーシング処理(`- -`)は `±0` に不要なため追加不要
- `aria-label` / `tooltipText` は既存ロジック経由で自動的に機能する
- **ツールチップの新規表示**: 現在は diff=0 のとき `tooltipText === ''` のため `{#if showTooltip && tooltipText}` が false でツールチップ非表示だった。変更後は `tooltipText = 'ユーザは「ふつう」と評価'`(truthy)になるため、`showTooltip=true` の呼び出し箇所(`votes/+page.svelte`、`votes/[slug]/+page.svelte`、`vote_management/+page.svelte`)でツールチップが初めて表示される。これは意図した挙動変更である。

### `src/features/votes/utils/relative_evaluation.test.ts`

- `getRelativeEvaluationLabel(0)` の既存アサーション(`''`)を `'±0'` に更新
- `getRelativeEvaluationBadgeColorClass(0)` の既存アサーション(`''`)をグリーンクラスに更新(既存テスト `'returns empty string for diff === 0 (badge not shown)'` を修正)
- `getRelativeEvaluationTooltipText('±0')` → `'ユーザは「ふつう」と評価'` のテストケースを追加。`default: return ''` ブランチは変更なし(無効な label 値に対して `''` を返す挙動は維持)

## 影響範囲

`RelativeEvaluationBadge` を使う 4 箇所すべてで ±0 バッジが表示される:

- `src/features/votes/components/VotableGrade.svelte`(グレードアイコンのドロップダウントリガー)
- `src/routes/votes/+page.svelte`(投票一覧)
- `src/routes/votes/[slug]/+page.svelte`(投票詳細)
- `src/routes/(admin)/vote_management/+page.svelte`(管理画面)

**スクリーンリーダーの読み上げ**: `VotableGrade.svelte` の sr-only テキストが `, relative evaluation: ±0` を含むようになる。`±0` はほとんどのスクリーンリーダーで "plus minus zero" または "plus-minus zero" として読み上げられる。これは意図した挙動変更である。

**`calcGradeDiff` の戻り値型**: `getGradeOrder` はグレード順序を表す整数を返すため、`calcGradeDiff` は常に整数を返す。`diff === 0` の厳密等価比較は安全である。

`getRelativeEvaluationJapaneseLabel(0)` は既に `'ふつう'` を返しており変更不要。

## テスト方針

TDD: テストを先に修正・追加してから実装コードを変更する。`pnpm test:unit` で全テスト通過を確認。
42 changes: 38 additions & 4 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ async function main() {
await addTaskTags();
await addSubmissionStatuses();
await addAnswers();
await addVoteStatisticsDemoData();

console.log('Seeding has been completed.');
} catch (e) {
Expand Down Expand Up @@ -192,7 +193,9 @@ async function addContestTaskPairs() {
const contestTaskPairFactory = defineContestTaskPairFactory();

// Create a queue with limited concurrency for contest task pair operations
const contestTaskPairQueue = new PQueue({ concurrency: QUEUE_CONCURRENCY.contestTaskPairs });
const contestTaskPairQueue = new PQueue({
concurrency: QUEUE_CONCURRENCY.contestTaskPairs,
});

for (const pair of contest_task_pairs) {
contestTaskPairQueue.add(async () => {
Expand Down Expand Up @@ -256,7 +259,9 @@ async function addContestTaskPair(
async function addWorkBooks() {
console.log('Start adding workbooks...');

const workBookFactory = defineWorkBookFactory({ defaultData: { user: defineUserFactory() } });
const workBookFactory = defineWorkBookFactory({
defaultData: { user: defineUserFactory() },
});

// Note: Use a for loop to ensure each workbook is processed sequentially.
for (const workbook of workbooks) {
Expand Down Expand Up @@ -321,7 +326,9 @@ async function addWorkBookPlacements() {
prisma.workBook.findMany({
where: { workBookType: 'CURRICULUM', placement: null },
include: {
workBookTasks: { include: { task: { select: { task_id: true, grade: true } } } },
workBookTasks: {
include: { task: { select: { task_id: true, grade: true } } },
},
},
orderBy: { id: 'asc' },
}),
Expand Down Expand Up @@ -526,7 +533,9 @@ async function addSubmissionStatuses() {
const submissionStatusFactory = defineSubmissionStatusFactory();

// Create a queue with limited concurrency for submission status operations
const submissionStatusQueue = new PQueue({ concurrency: QUEUE_CONCURRENCY.submissionStatuses });
const submissionStatusQueue = new PQueue({
concurrency: QUEUE_CONCURRENCY.submissionStatuses,
});

for (const submission_status of submission_statuses) {
submissionStatusQueue.add(async () => {
Expand Down Expand Up @@ -634,6 +643,31 @@ async function addAnswer(
});
}

// Demo data: tasks with VotedGradeStatistics so the ±0 badge is visible in the UI.
// grade matches the task's official grade → diff=0 → ±0 badge displayed.
const VOTE_STATS_DEMO_DATA = [
{ id: 'demo-vote-stat-apg4b-co', taskId: 'APG4bPython_co', grade: 'Q8' },
] as const;

async function addVoteStatisticsDemoData() {
console.log('Start adding vote statistics demo data...');

for (const entry of VOTE_STATS_DEMO_DATA) {
try {
await prisma.votedGradeStatistics.upsert({
where: { taskId: entry.taskId },
update: {},
create: { id: entry.id, taskId: entry.taskId, grade: entry.grade },
});
console.log('vote stats demo: taskId', entry.taskId, 'was registered.');
} catch (e) {
console.error('Failed to add vote stats demo entry', entry.taskId, e);
}
}

console.log('Finished adding vote statistics demo data.');
}

main()
.catch(async (e) => {
console.error(e);
Expand Down
18 changes: 12 additions & 6 deletions src/features/votes/utils/relative_evaluation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ describe('getRelativeEvaluationLabel', () => {
expect(getRelativeEvaluationLabel(1)).toBe('+');
});

test('returns "" for diff === 0', () => {
expect(getRelativeEvaluationLabel(0)).toBe('');
test('returns "±0" for diff === 0', () => {
expect(getRelativeEvaluationLabel(0)).toBe('±0');
});

test('returns "-" for diff === -1', () => {
Expand All @@ -108,6 +108,10 @@ describe('getRelativeEvaluationTooltipText', () => {
expect(getRelativeEvaluationTooltipText('')).toBe('');
});

test('returns tooltip text for ±0 (neutral match)', () => {
expect(getRelativeEvaluationTooltipText('±0')).toBe('ユーザは「ふつう」と評価');
});

test('returns ++ for users feel the difficult than official grade', () => {
expect(getRelativeEvaluationTooltipText('++')).toBe('ユーザは「難しい」と評価');
});
Expand Down Expand Up @@ -163,8 +167,8 @@ describe('getRelativeEvaluationColorClass', () => {
expect(getRelativeEvaluationColorClass(-16)).toBe('text-sky-500 dark:text-sky-400');
});

test('returns gray text classes for diff === 0', () => {
expect(getRelativeEvaluationColorClass(0)).toBe('text-gray-400 dark:text-gray-500');
test('returns green text classes for diff === 0 (neutral)', () => {
expect(getRelativeEvaluationColorClass(0)).toBe('text-green-500 dark:text-green-400');
});

test('returns orange text classes for positive diff (harder)', () => {
Expand All @@ -183,8 +187,10 @@ describe('getRelativeEvaluationBadgeColorClass', () => {
);
});

test('returns empty string for diff === 0 (badge not shown)', () => {
expect(getRelativeEvaluationBadgeColorClass(0)).toBe('');
test('returns green bg classes for diff === 0 (neutral)', () => {
expect(getRelativeEvaluationBadgeColorClass(0)).toBe(
'bg-green-400 text-white dark:bg-green-500 dark:text-white',
);
});

test('returns orange bg classes for positive diff (harder)', () => {
Expand Down
15 changes: 8 additions & 7 deletions src/features/votes/utils/relative_evaluation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function calcGradeDiff(officialGrade: TaskGrade, medianGrade: TaskGrade):
* | ------ | ----- |
* | ≤ −2 | `--` |
* | −1 | `-` |
* | 0 | `""` |
* | 0 | `±0` |
* | +1 | `+` |
* | ≥ +2 | `++` |
*
Expand All @@ -35,7 +35,7 @@ export function getRelativeEvaluationLabel(diff: number): string {
return '-';
}
if (diff === 0) {
return '';
return '±0';
}
if (diff === 1) {
return '+';
Expand All @@ -55,6 +55,8 @@ export function getRelativeEvaluationTooltipText(label: string): string {
return 'ユーザは「難しい」と評価';
case '+':
return 'ユーザは「やや難しい」と評価';
case '±0':
return 'ユーザは「ふつう」と評価';
case '-':
return 'ユーザは「やや易しい」と評価';
case '--':
Expand Down Expand Up @@ -100,7 +102,7 @@ export function getRelativeEvaluationJapaneseLabel(diff: number): string {

/**
* Returns Tailwind text color classes for a diff value in the vote dropdown.
* Negative diff (easier) → sky, zero → gray, positive (harder) → orange.
* Negative diff (easier) → sky, zero → green, positive (harder) → orange.
*
* @param diff - The result of {@link calcGradeDiff}.
*/
Expand All @@ -109,15 +111,14 @@ export function getRelativeEvaluationColorClass(diff: number): string {
return 'text-sky-500 dark:text-sky-400';
}
if (diff === 0) {
return 'text-gray-400 dark:text-gray-500';
return 'text-green-500 dark:text-green-400';
}
return 'text-orange-400 dark:text-orange-300';
}

/**
* Returns Tailwind background + text color classes for the relative evaluation badge.
* Negative diff (easier) → sky, positive (harder) → orange.
* Returns empty string for diff === 0 (badge is not shown).
* Negative diff (easier) → sky, zero diff (neutral) → green, positive (harder) → orange.
*
* @param diff - The result of {@link calcGradeDiff}.
*/
Expand All @@ -128,5 +129,5 @@ export function getRelativeEvaluationBadgeColorClass(diff: number): string {
if (diff > 0) {
return 'bg-orange-400 text-white dark:bg-orange-500 dark:text-white';
}
return '';
return 'bg-green-400 text-white dark:bg-green-500 dark:text-white';
}
Loading