Skip to content

Commit e16d380

Browse files
authored
Merge pull request #3416 from AtCoder-NoviSteps/feature/relative_evaluation_voting
Feature/relative evaluation voting
2 parents 6c0d275 + 553e535 commit e16d380

8 files changed

Lines changed: 371 additions & 21 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# 概要
2+
3+
このタスクでは、投票(相対評価)の実装を行う。
4+
相対評価とは、既にグレードが確定している問題に対してもユーザーの投票が反映される仕組みである。
5+
6+
# 仕組み
7+
8+
まず、ユーザーが投票したグレードから中央値を取得する。この処理はすでに実装済みである。
9+
10+
そして、中央値グレードと確定しているグレードの差を求める。
11+
差は `gradeOrder(中央値) − gradeOrder(確定グレード)` で計算する(Q11=1, ..., D6=17)。
12+
正値はユーザーが確定グレードより難しいと感じていることを意味し、負値は簡単と感じていることを意味する。
13+
14+
投票数が最小閾値(`MIN_VOTES_FOR_STATISTICS`)未満の場合は中央値が算出されないため、バッジは表示しない。
15+
16+
差に応じて、以下のように5段階評価を行う。
17+
18+
- 差が-2以下: --
19+
- 差が-1: -
20+
- 差が0: 何もなし(バッジ非表示)
21+
- 差が1: +
22+
- 差が2以上: ++
23+
24+
グレードが未定のものに対しては、これまでと同様絶対評価を行う。
25+
26+
# UI
27+
28+
グレードが確定している問題のグレードアイコンの右上に「++」、「+」、「何もなし」、「-」、「--」の5段階評価を表示することで、確定したグレードからの体感難易度の乖離を表す。
29+
30+
投票の際のUIは既存のものから変更しない。
31+
32+
# 作業ブランチ
33+
34+
`feature/relative_evaluation_voting`
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<script lang="ts">
2+
import { Tooltip } from 'flowbite-svelte';
3+
4+
import type { TaskGrade } from '$lib/types/task';
5+
import {
6+
calcGradeDiff,
7+
getRelativeEvaluationLabel,
8+
getRelativeEvaluationTooltipText,
9+
} from '$features/votes/utils/relative_evaluation';
10+
11+
interface Props {
12+
officialGrade: TaskGrade;
13+
medianGrade: TaskGrade;
14+
/** Unique element ID used to anchor the Tooltip. Must be unique on the page. */
15+
badgeId: string;
16+
/**
17+
* Set to false when the badge is rendered inside a `<button>`.
18+
* The tooltip is suppressed and aria-hidden is applied since the
19+
* parent button's sr-only text already covers screen reader needs.
20+
* Default: true
21+
*/
22+
showTooltip?: boolean;
23+
}
24+
25+
let { officialGrade, medianGrade, badgeId, showTooltip = true }: Props = $props();
26+
27+
const label = $derived(getRelativeEvaluationLabel(calcGradeDiff(officialGrade, medianGrade)));
28+
const isHarder = $derived(label.startsWith('+'));
29+
30+
const tooltipText = $derived(getRelativeEvaluationTooltipText(label));
31+
</script>
32+
33+
{#if label}
34+
<span
35+
id={badgeId}
36+
class="absolute -top-2 -right-2 z-10 rounded-full px-1 py-px text-[0.65rem] font-bold leading-none shadow-sm
37+
{isHarder
38+
? 'bg-orange-400 text-white dark:bg-orange-500 dark:text-white'
39+
: 'bg-sky-400 text-white dark:bg-sky-500 dark:text-white'}"
40+
aria-hidden={!showTooltip}
41+
role={showTooltip ? 'img' : undefined}
42+
aria-label={showTooltip ? tooltipText : undefined}
43+
>
44+
{#if label === '--'}
45+
-&thinsp;-
46+
{:else if label === '++'}
47+
+&thinsp;+
48+
{:else}
49+
{label}
50+
{/if}
51+
</span>
52+
{#if showTooltip && tooltipText}
53+
<Tooltip triggeredBy="#{badgeId}" placement="top">
54+
{tooltipText}
55+
</Tooltip>
56+
{/if}
57+
{/if}

src/features/votes/components/VotableGrade.svelte

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { tick } from 'svelte';
2+
import { tick, untrack } from 'svelte';
33
import { enhance } from '$app/forms';
44
import { resolve } from '$app/paths';
55
@@ -17,10 +17,15 @@
1717
1818
import { getTaskGradeLabel } from '$lib/utils/task';
1919
import { nonPendingGrades, resolveDisplayGrade } from '$features/votes/utils/grade_options';
20+
import {
21+
calcGradeDiff,
22+
getRelativeEvaluationLabel,
23+
} from '$features/votes/utils/relative_evaluation';
2024
import { SIGNUP_PAGE, LOGIN_PAGE, EDIT_PROFILE_PAGE } from '$lib/constants/navbar-links';
2125
2226
import GradeLabel from '$lib/components/GradeLabel.svelte';
2327
import InputFieldWrapper from '$lib/components/InputFieldWrapper.svelte';
28+
import RelativeEvaluationBadge from '$features/votes/components/RelativeEvaluationBadge.svelte';
2429
2530
interface Props {
2631
taskResult: TaskResult;
@@ -52,6 +57,19 @@
5257
taskResult.grade === TaskGrade.PENDING && displayGrade !== TaskGrade.PENDING,
5358
);
5459
60+
// Track the latest median grade locally so the badge updates immediately after voting,
61+
// even for confirmed-grade tasks (where displayGrade does not change on vote).
62+
// Initialized from the prop; updated by the vote handler after fetchMedianVote.
63+
let latestMedianGrade = $state<TaskGrade | null>(untrack(() => estimatedGrade ?? null));
64+
65+
// Relative evaluation badge label: shown only for confirmed grades with a known median.
66+
const relativeEvaluationLabel = $derived.by(() => {
67+
if (taskResult.grade === TaskGrade.PENDING || !latestMedianGrade) {
68+
return '';
69+
}
70+
return getRelativeEvaluationLabel(calcGradeDiff(taskResult.grade, latestMedianGrade));
71+
});
72+
5573
let isOpening = $state(false);
5674
let votedGrade = $state<TaskGrade | null>(null);
5775
let voteAbortController: AbortController | null = null;
@@ -113,8 +131,12 @@
113131
const taskId = formData.get('taskId') as string;
114132
const medianGrade = await fetchMedianVote(taskId, signal);
115133
116-
if (medianGrade !== null && taskResult.grade === TaskGrade.PENDING) {
117-
displayGrade = medianGrade;
134+
if (medianGrade !== null) {
135+
// Always update latestMedianGrade so the relative evaluation badge refreshes.
136+
latestMedianGrade = medianGrade;
137+
if (taskResult.grade === TaskGrade.PENDING) {
138+
displayGrade = medianGrade;
139+
}
118140
}
119141
})
120142
.catch((error) => {
@@ -156,11 +178,22 @@
156178
onclick={() => onTriggerClick()}
157179
>
158180
<span class="sr-only">
159-
Voted grade: {getTaskGradeLabel(displayGrade)}{isProvisional ? ', provisional' : ''}
181+
Voted grade: {getTaskGradeLabel(displayGrade)}{relativeEvaluationLabel
182+
? `, relative evaluation: ${relativeEvaluationLabel}`
183+
: ''}{isProvisional ? ', provisional' : ''}
160184
</span>
161185

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

188+
{#if taskResult.grade !== TaskGrade.PENDING && latestMedianGrade}
189+
<RelativeEvaluationBadge
190+
officialGrade={taskResult.grade}
191+
medianGrade={latestMedianGrade}
192+
badgeId="relative-eval-{componentId}"
193+
showTooltip={false}
194+
/>
195+
{/if}
196+
164197
<!-- Overlay -->
165198
<span
166199
aria-hidden="true"
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, expect, test } from 'vitest';
2+
3+
import { TaskGrade } from '$lib/types/task';
4+
import {
5+
calcGradeDiff,
6+
getRelativeEvaluationLabel,
7+
getRelativeEvaluationTooltipText,
8+
} from './relative_evaluation';
9+
10+
describe('calcGradeDiff', () => {
11+
test('returns 0 when official and median are the same grade', () => {
12+
expect(calcGradeDiff(TaskGrade.Q5, TaskGrade.Q5)).toBe(0);
13+
});
14+
15+
test('returns positive when median is harder than official', () => {
16+
// Q5(order 7) vs D1(order 12): diff = 12 - 7 = 5
17+
expect(calcGradeDiff(TaskGrade.Q5, TaskGrade.D1)).toBeGreaterThan(0);
18+
});
19+
20+
test('returns negative when median is easier than official', () => {
21+
// D1(order 12) vs Q5(order 7): diff = 7 - 12 = -5
22+
expect(calcGradeDiff(TaskGrade.D1, TaskGrade.Q5)).toBeLessThan(0);
23+
});
24+
25+
test('returns 1 for adjacent grade upward (Q3 -> Q2)', () => {
26+
expect(calcGradeDiff(TaskGrade.Q3, TaskGrade.Q2)).toBe(1);
27+
});
28+
29+
test('returns -1 for adjacent grade downward (Q2 -> Q3)', () => {
30+
expect(calcGradeDiff(TaskGrade.Q2, TaskGrade.Q3)).toBe(-1);
31+
});
32+
33+
test('returns 2 for two-step grade upward (Q3 -> Q1)', () => {
34+
expect(calcGradeDiff(TaskGrade.Q3, TaskGrade.Q1)).toBe(2);
35+
});
36+
37+
test('handles boundary grades Q11 and D6', () => {
38+
// D6(17) - Q11(1) = 16
39+
expect(calcGradeDiff(TaskGrade.Q11, TaskGrade.D6)).toBe(16);
40+
// Q11(1) - D6(17) = -16
41+
expect(calcGradeDiff(TaskGrade.D6, TaskGrade.Q11)).toBe(-16);
42+
});
43+
44+
describe('D-tier grades', () => {
45+
test('returns 0 when both are the same D grade', () => {
46+
expect(calcGradeDiff(TaskGrade.D3, TaskGrade.D3)).toBe(0);
47+
});
48+
49+
test('returns -1 for adjacent D grade downward (D3 -> D2)', () => {
50+
expect(calcGradeDiff(TaskGrade.D3, TaskGrade.D2)).toBe(-1);
51+
});
52+
53+
test('returns 1 for adjacent D grade upward (D2 -> D3)', () => {
54+
expect(calcGradeDiff(TaskGrade.D2, TaskGrade.D3)).toBe(1);
55+
});
56+
57+
test('returns -2 for two-step D grade downward (D4 -> D2)', () => {
58+
expect(calcGradeDiff(TaskGrade.D4, TaskGrade.D2)).toBe(-2);
59+
});
60+
61+
test('returns 2 for two-step D grade upward (D2 -> D4)', () => {
62+
expect(calcGradeDiff(TaskGrade.D2, TaskGrade.D4)).toBe(2);
63+
});
64+
});
65+
66+
describe('Q1/D1 boundary', () => {
67+
test('returns 1 when median crosses from Q1 to D1 (adjacent upward)', () => {
68+
expect(calcGradeDiff(TaskGrade.Q1, TaskGrade.D1)).toBe(1);
69+
});
70+
71+
test('returns -1 when median crosses from D1 to Q1 (adjacent downward)', () => {
72+
expect(calcGradeDiff(TaskGrade.D1, TaskGrade.Q1)).toBe(-1);
73+
});
74+
});
75+
});
76+
77+
describe('getRelativeEvaluationLabel', () => {
78+
test('returns "++" for diff >= 2', () => {
79+
expect(getRelativeEvaluationLabel(2)).toBe('++');
80+
expect(getRelativeEvaluationLabel(5)).toBe('++');
81+
expect(getRelativeEvaluationLabel(16)).toBe('++');
82+
});
83+
84+
test('returns "+" for diff === 1', () => {
85+
expect(getRelativeEvaluationLabel(1)).toBe('+');
86+
});
87+
88+
test('returns "" for diff === 0', () => {
89+
expect(getRelativeEvaluationLabel(0)).toBe('');
90+
});
91+
92+
test('returns "-" for diff === -1', () => {
93+
expect(getRelativeEvaluationLabel(-1)).toBe('-');
94+
});
95+
96+
test('returns "--" for diff <= -2', () => {
97+
expect(getRelativeEvaluationLabel(-2)).toBe('--');
98+
expect(getRelativeEvaluationLabel(-5)).toBe('--');
99+
expect(getRelativeEvaluationLabel(-16)).toBe('--');
100+
});
101+
});
102+
103+
describe('getRelativeEvaluationTooltipText', () => {
104+
test('returns "" for empty string (default case)', () => {
105+
expect(getRelativeEvaluationTooltipText('')).toBe('');
106+
});
107+
108+
test('returns ++ for users feel the difficult than official grade', () => {
109+
expect(getRelativeEvaluationTooltipText('++')).toBe('ユーザは「難しい」と評価');
110+
});
111+
112+
test('returns + for users feel the slightly difficult than official grade', () => {
113+
expect(getRelativeEvaluationTooltipText('+')).toBe('ユーザは「やや難しい」と評価');
114+
});
115+
116+
test('returns - for users feel the slightly easy than official grade', () => {
117+
expect(getRelativeEvaluationTooltipText('-')).toBe('ユーザは「やや易しい」と評価');
118+
});
119+
120+
test('returns -- for users feel the easy than official grade', () => {
121+
expect(getRelativeEvaluationTooltipText('--')).toBe('ユーザは「易しい」と評価');
122+
});
123+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { TaskGrade } from '$lib/types/task';
2+
import { getGradeOrder } from '$lib/utils/task';
3+
4+
/**
5+
* Computes the difference in grade order between the median vote and the official grade.
6+
* Positive means users consider the problem harder than the official grade; negative means easier.
7+
*
8+
* @param officialGrade - The officially confirmed grade stored in the DB.
9+
* @param medianGrade - The median grade derived from user votes.
10+
* @returns `gradeOrder(medianGrade) - gradeOrder(officialGrade)`
11+
*/
12+
export function calcGradeDiff(officialGrade: TaskGrade, medianGrade: TaskGrade): number {
13+
return getGradeOrder(medianGrade) - getGradeOrder(officialGrade);
14+
}
15+
16+
/**
17+
* Returns a Japanese tooltip string explaining the relative evaluation label.
18+
*
19+
* @param label - The label returned by {@link getRelativeEvaluationLabel}.
20+
* @returns A human-readable explanation, or `''` when label is empty.
21+
*/
22+
export function getRelativeEvaluationTooltipText(label: string): string {
23+
switch (label) {
24+
case '++':
25+
return 'ユーザは「難しい」と評価';
26+
case '+':
27+
return 'ユーザは「やや難しい」と評価';
28+
case '-':
29+
return 'ユーザは「やや易しい」と評価';
30+
case '--':
31+
return 'ユーザは「易しい」と評価';
32+
default:
33+
return '';
34+
}
35+
}
36+
37+
/**
38+
* Converts a grade difference to a 5-level relative evaluation label.
39+
*
40+
* | diff | label |
41+
* | ------ | ----- |
42+
* | ≤ −2 | `--` |
43+
* | −1 | `-` |
44+
* | 0 | `""` |
45+
* | +1 | `+` |
46+
* | ≥ +2 | `++` |
47+
*
48+
* @param diff - The result of {@link calcGradeDiff}.
49+
* @returns The display label string.
50+
*/
51+
export function getRelativeEvaluationLabel(diff: number): string {
52+
if (diff <= -2) {
53+
return '--';
54+
}
55+
if (diff === -1) {
56+
return '-';
57+
}
58+
if (diff === 0) {
59+
return '';
60+
}
61+
if (diff === 1) {
62+
return '+';
63+
}
64+
return '++';
65+
}

0 commit comments

Comments
 (0)