-
-
Notifications
You must be signed in to change notification settings - Fork 10
Feature/relative evaluation voting #3416
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 6042e96
docs: clarify diff direction and fix branch name typo in plan.md
river0525 a1568f3
fix: address PR #3416 review feedback for relative evaluation voting
river0525 9660dd7
fix: move relative evaluation badge to DB grade column in vote_manage…
river0525 0c8b37f
fix: guard estimatedGrade with truthy check in vote_management badge …
river0525 217803d
fix: improve relative evaluation badge visibility
river0525 645d590
fix: remove tabindex from non-interactive span in RelativeEvaluationB…
river0525 bd0471d
fix: increase letter-spacing on badge to visually separate '--' chara…
river0525 0a0f976
fix: balance badge letter-spacing with negative margin-right
river0525 5c18b97
fix: use inline style for letter-spacing and negative margin-right on…
river0525 d61a23f
fix: replace letter-spacing with thinsp between '--'/'++' chars in badge
river0525 1f5cc77
refactor: extract getRelativeEvaluationTooltipText to relative_evalua…
river0525 24838b1
Merge branch 'staging' of github.com:AtCoder-NoviSteps/AtCoderNoviSte…
KATO-Hiro f0582fe
Merge branch 'feature/relative_evaluation_voting' of github.com:AtCod…
KATO-Hiro 8ecf079
refactor: shorten tooltip text for relative evaluation labels
KATO-Hiro 553e535
refactor: update tooltip text to user-centric phrasing
KATO-Hiro File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
34 changes: 34 additions & 0 deletions
34
docs/dev-notes/2026-04-15/relative_evalution_voting/plan.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| # 概要 | ||
|
|
||
| このタスクでは、投票(相対評価)の実装を行う。 | ||
| 相対評価とは、既にグレードが確定している問題に対してもユーザーの投票が反映される仕組みである。 | ||
|
|
||
| # 仕組み | ||
|
|
||
| まず、ユーザーが投票したグレードから中央値を取得する。この処理はすでに実装済みである。 | ||
|
|
||
| そして、中央値グレードと確定しているグレードの差を求める。 | ||
| 差は `gradeOrder(中央値) − gradeOrder(確定グレード)` で計算する(Q11=1, ..., D6=17)。 | ||
| 正値はユーザーが確定グレードより難しいと感じていることを意味し、負値は簡単と感じていることを意味する。 | ||
|
|
||
| 投票数が最小閾値(`MIN_VOTES_FOR_STATISTICS`)未満の場合は中央値が算出されないため、バッジは表示しない。 | ||
|
|
||
| 差に応じて、以下のように5段階評価を行う。 | ||
|
|
||
| - 差が-2以下: -- | ||
| - 差が-1: - | ||
| - 差が0: 何もなし(バッジ非表示) | ||
| - 差が1: + | ||
| - 差が2以上: ++ | ||
|
|
||
| グレードが未定のものに対しては、これまでと同様絶対評価を行う。 | ||
|
|
||
| # UI | ||
|
|
||
| グレードが確定している問題のグレードアイコンの右上に「++」、「+」、「何もなし」、「-」、「--」の5段階評価を表示することで、確定したグレードからの体感難易度の乖離を表す。 | ||
|
|
||
| 投票の際のUIは既存のものから変更しない。 | ||
|
KATO-Hiro marked this conversation as resolved.
|
||
|
|
||
| # 作業ブランチ | ||
|
|
||
| `feature/relative_evaluation_voting` | ||
57 changes: 57 additions & 0 deletions
57
src/features/votes/components/RelativeEvaluationBadge.svelte
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 === '--'} | ||
| - - | ||
| {:else if label === '++'} | ||
| + + | ||
| {:else} | ||
| {label} | ||
| {/if} | ||
| </span> | ||
| {#if showTooltip && tooltipText} | ||
| <Tooltip triggeredBy="#{badgeId}" placement="top"> | ||
| {tooltipText} | ||
| </Tooltip> | ||
| {/if} | ||
| {/if} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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('ユーザは「易しい」と評価'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 ''; | ||
| } | ||
| } | ||
|
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 '++'; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.