Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
dd86f92
feat: show relative evaluation badge on confirmed grade icons
river0525 Apr 15, 2026
6042e96
docs: clarify diff direction and fix branch name typo in plan.md
river0525 Apr 15, 2026
a1568f3
fix: address PR #3416 review feedback for relative evaluation voting
river0525 Apr 16, 2026
9660dd7
fix: move relative evaluation badge to DB grade column in vote_manage…
river0525 Apr 16, 2026
0c8b37f
fix: guard estimatedGrade with truthy check in vote_management badge …
river0525 Apr 16, 2026
217803d
fix: improve relative evaluation badge visibility
river0525 Apr 16, 2026
645d590
fix: remove tabindex from non-interactive span in RelativeEvaluationB…
river0525 Apr 16, 2026
bd0471d
fix: increase letter-spacing on badge to visually separate '--' chara…
river0525 Apr 16, 2026
0a0f976
fix: balance badge letter-spacing with negative margin-right
river0525 Apr 16, 2026
5c18b97
fix: use inline style for letter-spacing and negative margin-right on…
river0525 Apr 16, 2026
d61a23f
fix: replace letter-spacing with thinsp between '--'/'++' chars in badge
river0525 Apr 16, 2026
1f5cc77
refactor: extract getRelativeEvaluationTooltipText to relative_evalua…
river0525 Apr 16, 2026
24838b1
Merge branch 'staging' of github.com:AtCoder-NoviSteps/AtCoderNoviSte…
KATO-Hiro Apr 16, 2026
f0582fe
Merge branch 'feature/relative_evaluation_voting' of github.com:AtCod…
KATO-Hiro Apr 16, 2026
8ecf079
refactor: shorten tooltip text for relative evaluation labels
KATO-Hiro Apr 16, 2026
553e535
refactor: update tooltip text to user-centric phrasing
KATO-Hiro Apr 16, 2026
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
34 changes: 34 additions & 0 deletions docs/dev-notes/2026-04-15/relative_evalution_voting/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 概要
Comment thread
coderabbitai[bot] marked this conversation as resolved.

このタスクでは、投票(相対評価)の実装を行う。
相対評価とは、既にグレードが確定している問題に対してもユーザーの投票が反映される仕組みである。

# 仕組み

まず、ユーザーが投票したグレードから中央値を取得する。この処理はすでに実装済みである。

そして、中央値グレードと確定しているグレードの差を求める。
差は `gradeOrder(中央値) − gradeOrder(確定グレード)` で計算する(Q11=1, ..., D6=17)。
正値はユーザーが確定グレードより難しいと感じていることを意味し、負値は簡単と感じていることを意味する。

投票数が最小閾値(`MIN_VOTES_FOR_STATISTICS`)未満の場合は中央値が算出されないため、バッジは表示しない。

差に応じて、以下のように5段階評価を行う。

- 差が-2以下: --
- 差が-1: -
- 差が0: 何もなし(バッジ非表示)
- 差が1: +
- 差が2以上: ++

グレードが未定のものに対しては、これまでと同様絶対評価を行う。

# UI

グレードが確定している問題のグレードアイコンの右上に「++」、「+」、「何もなし」、「-」、「--」の5段階評価を表示することで、確定したグレードからの体感難易度の乖離を表す。

投票の際のUIは既存のものから変更しない。
Comment thread
KATO-Hiro marked this conversation as resolved.

# 作業ブランチ

`feature/relative_evaluation_voting`
57 changes: 57 additions & 0 deletions src/features/votes/components/RelativeEvaluationBadge.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<script lang="ts">
import { Tooltip } from 'flowbite-svelte';

import type { TaskGrade } from '$lib/types/task';
import {
calcGradeDiff,
getRelativeEvaluationLabel,
getRelativeEvaluationTooltipText,
} from '$features/votes/utils/relative_evaluation';

interface Props {
officialGrade: TaskGrade;
medianGrade: TaskGrade;
/** Unique element ID used to anchor the Tooltip. Must be unique on the page. */
badgeId: string;
/**
* Set to false when the badge is rendered inside a `<button>`.
* The tooltip is suppressed and aria-hidden is applied since the
* parent button's sr-only text already covers screen reader needs.
* Default: true
*/
showTooltip?: boolean;
}

let { officialGrade, medianGrade, badgeId, showTooltip = true }: Props = $props();

const label = $derived(getRelativeEvaluationLabel(calcGradeDiff(officialGrade, medianGrade)));
const isHarder = $derived(label.startsWith('+'));

const tooltipText = $derived(getRelativeEvaluationTooltipText(label));
</script>

{#if label}
<span
id={badgeId}
class="absolute -top-2 -right-2 z-10 rounded-full px-1 py-px text-[0.65rem] font-bold leading-none shadow-sm
{isHarder
? 'bg-orange-400 text-white dark:bg-orange-500 dark:text-white'
: 'bg-sky-400 text-white dark:bg-sky-500 dark:text-white'}"
aria-hidden={!showTooltip}
role={showTooltip ? 'img' : undefined}
aria-label={showTooltip ? tooltipText : undefined}
>
{#if label === '--'}
-&thinsp;-
{:else if label === '++'}
+&thinsp;+
{:else}
{label}
{/if}
</span>
{#if showTooltip && tooltipText}
<Tooltip triggeredBy="#{badgeId}" placement="top">
{tooltipText}
</Tooltip>
{/if}
{/if}
41 changes: 37 additions & 4 deletions src/features/votes/components/VotableGrade.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import { tick, untrack } from 'svelte';
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';

Expand All @@ -17,10 +17,15 @@

import { getTaskGradeLabel } from '$lib/utils/task';
import { nonPendingGrades, resolveDisplayGrade } from '$features/votes/utils/grade_options';
import {
calcGradeDiff,
getRelativeEvaluationLabel,
} from '$features/votes/utils/relative_evaluation';
import { SIGNUP_PAGE, LOGIN_PAGE, EDIT_PROFILE_PAGE } from '$lib/constants/navbar-links';

import GradeLabel from '$lib/components/GradeLabel.svelte';
import InputFieldWrapper from '$lib/components/InputFieldWrapper.svelte';
import RelativeEvaluationBadge from '$features/votes/components/RelativeEvaluationBadge.svelte';

interface Props {
taskResult: TaskResult;
Expand Down Expand Up @@ -52,6 +57,19 @@
taskResult.grade === TaskGrade.PENDING && displayGrade !== TaskGrade.PENDING,
);

// Track the latest median grade locally so the badge updates immediately after voting,
// even for confirmed-grade tasks (where displayGrade does not change on vote).
// Initialized from the prop; updated by the vote handler after fetchMedianVote.
let latestMedianGrade = $state<TaskGrade | null>(untrack(() => estimatedGrade ?? null));

// Relative evaluation badge label: shown only for confirmed grades with a known median.
const relativeEvaluationLabel = $derived.by(() => {
if (taskResult.grade === TaskGrade.PENDING || !latestMedianGrade) {
return '';
}
return getRelativeEvaluationLabel(calcGradeDiff(taskResult.grade, latestMedianGrade));
});

let isOpening = $state(false);
let votedGrade = $state<TaskGrade | null>(null);
let voteAbortController: AbortController | null = null;
Expand Down Expand Up @@ -113,8 +131,12 @@
const taskId = formData.get('taskId') as string;
const medianGrade = await fetchMedianVote(taskId, signal);

if (medianGrade !== null && taskResult.grade === TaskGrade.PENDING) {
displayGrade = medianGrade;
if (medianGrade !== null) {
// Always update latestMedianGrade so the relative evaluation badge refreshes.
latestMedianGrade = medianGrade;
if (taskResult.grade === TaskGrade.PENDING) {
displayGrade = medianGrade;
}
}
})
.catch((error) => {
Expand Down Expand Up @@ -156,11 +178,22 @@
onclick={() => onTriggerClick()}
>
<span class="sr-only">
Voted grade: {getTaskGradeLabel(displayGrade)}{isProvisional ? ', provisional' : ''}
Voted grade: {getTaskGradeLabel(displayGrade)}{relativeEvaluationLabel
? `, relative evaluation: ${relativeEvaluationLabel}`
: ''}{isProvisional ? ', provisional' : ''}
</span>

<GradeLabel taskGrade={displayGrade} defaultPadding={0.25} defaultWidth={6} reducedWidth={6} />

{#if taskResult.grade !== TaskGrade.PENDING && latestMedianGrade}
<RelativeEvaluationBadge
officialGrade={taskResult.grade}
medianGrade={latestMedianGrade}
badgeId="relative-eval-{componentId}"
showTooltip={false}
/>
{/if}

<!-- Overlay -->
<span
aria-hidden="true"
Expand Down
123 changes: 123 additions & 0 deletions src/features/votes/utils/relative_evaluation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, expect, test } from 'vitest';

import { TaskGrade } from '$lib/types/task';
import {
calcGradeDiff,
getRelativeEvaluationLabel,
getRelativeEvaluationTooltipText,
} from './relative_evaluation';

describe('calcGradeDiff', () => {
test('returns 0 when official and median are the same grade', () => {
expect(calcGradeDiff(TaskGrade.Q5, TaskGrade.Q5)).toBe(0);
});

test('returns positive when median is harder than official', () => {
// Q5(order 7) vs D1(order 12): diff = 12 - 7 = 5
expect(calcGradeDiff(TaskGrade.Q5, TaskGrade.D1)).toBeGreaterThan(0);
});

test('returns negative when median is easier than official', () => {
// D1(order 12) vs Q5(order 7): diff = 7 - 12 = -5
expect(calcGradeDiff(TaskGrade.D1, TaskGrade.Q5)).toBeLessThan(0);
});

test('returns 1 for adjacent grade upward (Q3 -> Q2)', () => {
expect(calcGradeDiff(TaskGrade.Q3, TaskGrade.Q2)).toBe(1);
});

test('returns -1 for adjacent grade downward (Q2 -> Q3)', () => {
expect(calcGradeDiff(TaskGrade.Q2, TaskGrade.Q3)).toBe(-1);
});

test('returns 2 for two-step grade upward (Q3 -> Q1)', () => {
expect(calcGradeDiff(TaskGrade.Q3, TaskGrade.Q1)).toBe(2);
});

test('handles boundary grades Q11 and D6', () => {
// D6(17) - Q11(1) = 16
expect(calcGradeDiff(TaskGrade.Q11, TaskGrade.D6)).toBe(16);
// Q11(1) - D6(17) = -16
expect(calcGradeDiff(TaskGrade.D6, TaskGrade.Q11)).toBe(-16);
});

describe('D-tier grades', () => {
test('returns 0 when both are the same D grade', () => {
expect(calcGradeDiff(TaskGrade.D3, TaskGrade.D3)).toBe(0);
});

test('returns -1 for adjacent D grade downward (D3 -> D2)', () => {
expect(calcGradeDiff(TaskGrade.D3, TaskGrade.D2)).toBe(-1);
});

test('returns 1 for adjacent D grade upward (D2 -> D3)', () => {
expect(calcGradeDiff(TaskGrade.D2, TaskGrade.D3)).toBe(1);
});

test('returns -2 for two-step D grade downward (D4 -> D2)', () => {
expect(calcGradeDiff(TaskGrade.D4, TaskGrade.D2)).toBe(-2);
});

test('returns 2 for two-step D grade upward (D2 -> D4)', () => {
expect(calcGradeDiff(TaskGrade.D2, TaskGrade.D4)).toBe(2);
});
});

describe('Q1/D1 boundary', () => {
test('returns 1 when median crosses from Q1 to D1 (adjacent upward)', () => {
expect(calcGradeDiff(TaskGrade.Q1, TaskGrade.D1)).toBe(1);
});

test('returns -1 when median crosses from D1 to Q1 (adjacent downward)', () => {
expect(calcGradeDiff(TaskGrade.D1, TaskGrade.Q1)).toBe(-1);
});
});
});

describe('getRelativeEvaluationLabel', () => {
test('returns "++" for diff >= 2', () => {
expect(getRelativeEvaluationLabel(2)).toBe('++');
expect(getRelativeEvaluationLabel(5)).toBe('++');
expect(getRelativeEvaluationLabel(16)).toBe('++');
});

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

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

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

test('returns "--" for diff <= -2', () => {
expect(getRelativeEvaluationLabel(-2)).toBe('--');
expect(getRelativeEvaluationLabel(-5)).toBe('--');
expect(getRelativeEvaluationLabel(-16)).toBe('--');
});
});

describe('getRelativeEvaluationTooltipText', () => {
test('returns "" for empty string (default case)', () => {
expect(getRelativeEvaluationTooltipText('')).toBe('');
});

test('returns ++ for users feel the difficult than official grade', () => {
expect(getRelativeEvaluationTooltipText('++')).toBe('ユーザは「難しい」と評価');
});

test('returns + for users feel the slightly difficult than official grade', () => {
expect(getRelativeEvaluationTooltipText('+')).toBe('ユーザは「やや難しい」と評価');
});

test('returns - for users feel the slightly easy than official grade', () => {
expect(getRelativeEvaluationTooltipText('-')).toBe('ユーザは「やや易しい」と評価');
});

test('returns -- for users feel the easy than official grade', () => {
expect(getRelativeEvaluationTooltipText('--')).toBe('ユーザは「易しい」と評価');
});
});
65 changes: 65 additions & 0 deletions src/features/votes/utils/relative_evaluation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { TaskGrade } from '$lib/types/task';
import { getGradeOrder } from '$lib/utils/task';

/**
* Computes the difference in grade order between the median vote and the official grade.
* Positive means users consider the problem harder than the official grade; negative means easier.
*
* @param officialGrade - The officially confirmed grade stored in the DB.
* @param medianGrade - The median grade derived from user votes.
* @returns `gradeOrder(medianGrade) - gradeOrder(officialGrade)`
*/
export function calcGradeDiff(officialGrade: TaskGrade, medianGrade: TaskGrade): number {
return getGradeOrder(medianGrade) - getGradeOrder(officialGrade);
}

/**
* Returns a Japanese tooltip string explaining the relative evaluation label.
*
* @param label - The label returned by {@link getRelativeEvaluationLabel}.
* @returns A human-readable explanation, or `''` when label is empty.
*/
export function getRelativeEvaluationTooltipText(label: string): string {
switch (label) {
case '++':
return 'ユーザは「難しい」と評価';
case '+':
return 'ユーザは「やや難しい」と評価';
case '-':
return 'ユーザは「やや易しい」と評価';
case '--':
return 'ユーザは「易しい」と評価';
default:
return '';
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Converts a grade difference to a 5-level relative evaluation label.
*
* | diff | label |
* | ------ | ----- |
* | ≤ −2 | `--` |
* | −1 | `-` |
* | 0 | `""` |
* | +1 | `+` |
* | ≥ +2 | `++` |
*
* @param diff - The result of {@link calcGradeDiff}.
* @returns The display label string.
*/
export function getRelativeEvaluationLabel(diff: number): string {
if (diff <= -2) {
return '--';
}
if (diff === -1) {
return '-';
}
if (diff === 0) {
return '';
}
if (diff === 1) {
return '+';
}
return '++';
}
Loading
Loading