Skip to content
Merged
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
11 changes: 5 additions & 6 deletions src/features/votes/components/RelativeEvaluationBadge.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
calcGradeDiff,
getRelativeEvaluationLabel,
getRelativeEvaluationTooltipText,
getRelativeEvaluationBadgeColorClass,
} from '$features/votes/utils/relative_evaluation';

interface Props {
Expand All @@ -24,19 +25,17 @@

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

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

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'}"
class="absolute -top-2 -right-2 z-10 rounded-full px-1 py-px text-[0.65rem] font-bold leading-none shadow-sm {badgeColorClass}"
aria-hidden={!showTooltip}
role={showTooltip ? 'img' : undefined}
aria-label={showTooltip ? tooltipText : undefined}
Expand Down
19 changes: 15 additions & 4 deletions src/features/votes/components/VotableGrade.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import {
calcGradeDiff,
getRelativeEvaluationLabel,
getRelativeEvaluationJapaneseLabel,
getRelativeEvaluationColorClass,
} from '$features/votes/utils/relative_evaluation';
import { SIGNUP_PAGE, LOGIN_PAGE, EDIT_PROFILE_PAGE } from '$lib/constants/navbar-links';

Expand Down Expand Up @@ -207,16 +209,25 @@
<Dropdown
triggeredBy={`#update-grade-dropdown-trigger-${componentId}`}
simple
class="h-48 w-25 z-50 border border-gray-200 dark:border-gray-100 overflow-y-auto"
class="h-48 w-44 z-50 border border-gray-200 dark:border-gray-100 overflow-y-auto"
>
{#each nonPendingGrades as grade (grade)}
<DropdownItem onclick={() => handleClick(grade)} class="rounded-md">
<div
class="flex items-center justify-between w-full text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
class="flex items-center w-full gap-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
>
<span>{getTaskGradeLabel(grade)}</span>
<span class="flex-1">{getTaskGradeLabel(grade)}</span>
{#if taskResult.grade !== TaskGrade.PENDING}
{@const diff = calcGradeDiff(taskResult.grade, grade)}
{@const relLabel = getRelativeEvaluationJapaneseLabel(diff)}
{#if relLabel}
<span class="w-16 text-right text-xs {getRelativeEvaluationColorClass(diff)}">
{relLabel}
</span>
{/if}
{/if}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{#if votedGrade === grade}
<Check class="w-4 h-4 text-primary-600 dark:text-gray-300" strokeWidth={3} />
<Check class="w-4 h-4 shrink-0 text-primary-600 dark:text-gray-300" strokeWidth={3} />
{/if}
</div>
</DropdownItem>
Expand Down
75 changes: 75 additions & 0 deletions src/features/votes/utils/relative_evaluation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
calcGradeDiff,
getRelativeEvaluationLabel,
getRelativeEvaluationTooltipText,
getRelativeEvaluationJapaneseLabel,
getRelativeEvaluationColorClass,
getRelativeEvaluationBadgeColorClass,
} from './relative_evaluation';

describe('calcGradeDiff', () => {
Expand Down Expand Up @@ -121,3 +124,75 @@ describe('getRelativeEvaluationTooltipText', () => {
expect(getRelativeEvaluationTooltipText('--')).toBe('ユーザは「易しい」と評価');
});
});

describe('getRelativeEvaluationJapaneseLabel', () => {
test('returns "" for diff <= -3 (out of expected range)', () => {
expect(getRelativeEvaluationJapaneseLabel(-3)).toBe('');
expect(getRelativeEvaluationJapaneseLabel(-16)).toBe('');
});

test('returns "易しい" for diff === -2', () => {
expect(getRelativeEvaluationJapaneseLabel(-2)).toBe('易しい');
});

test('returns "やや易しい" for diff === -1', () => {
expect(getRelativeEvaluationJapaneseLabel(-1)).toBe('やや易しい');
});

test('returns "ふつう" for diff === 0', () => {
expect(getRelativeEvaluationJapaneseLabel(0)).toBe('ふつう');
});

test('returns "やや難しい" for diff === 1', () => {
expect(getRelativeEvaluationJapaneseLabel(1)).toBe('やや難しい');
});

test('returns "難しい" for diff === 2', () => {
expect(getRelativeEvaluationJapaneseLabel(2)).toBe('難しい');
});

test('returns "" for diff >= 3 (out of expected range)', () => {
expect(getRelativeEvaluationJapaneseLabel(3)).toBe('');
expect(getRelativeEvaluationJapaneseLabel(16)).toBe('');
});
});

describe('getRelativeEvaluationColorClass', () => {
test('returns sky text classes for negative diff (easier)', () => {
expect(getRelativeEvaluationColorClass(-1)).toBe('text-sky-500 dark:text-sky-400');
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 orange text classes for positive diff (harder)', () => {
expect(getRelativeEvaluationColorClass(1)).toBe('text-orange-400 dark:text-orange-300');
expect(getRelativeEvaluationColorClass(16)).toBe('text-orange-400 dark:text-orange-300');
});
});

describe('getRelativeEvaluationBadgeColorClass', () => {
test('returns sky bg classes for negative diff (easier)', () => {
expect(getRelativeEvaluationBadgeColorClass(-1)).toBe(
'bg-sky-400 text-white dark:bg-sky-500 dark:text-white',
);
expect(getRelativeEvaluationBadgeColorClass(-16)).toBe(
'bg-sky-400 text-white dark:bg-sky-500 dark:text-white',
);
});

test('returns empty string for diff === 0 (badge not shown)', () => {
expect(getRelativeEvaluationBadgeColorClass(0)).toBe('');
});

test('returns orange bg classes for positive diff (harder)', () => {
expect(getRelativeEvaluationBadgeColorClass(1)).toBe(
'bg-orange-400 text-white dark:bg-orange-500 dark:text-white',
);
expect(getRelativeEvaluationBadgeColorClass(16)).toBe(
'bg-orange-400 text-white dark:bg-orange-500 dark:text-white',
);
});
});
105 changes: 86 additions & 19 deletions src/features/votes/utils/relative_evaluation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,36 @@ export function calcGradeDiff(officialGrade: TaskGrade, medianGrade: TaskGrade):
return getGradeOrder(medianGrade) - getGradeOrder(officialGrade);
}

/**
* 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 '++';
}

/**
* Returns a Japanese tooltip string explaining the relative evaluation label.
*
Expand All @@ -35,31 +65,68 @@ export function getRelativeEvaluationTooltipText(label: string): string {
}

/**
* Converts a grade difference to a 5-level relative evaluation label.
* Maps a grade difference to a Japanese label for display in the vote dropdown.
* Returns an empty string when the diff falls outside the expected ±2 range.
*
* | diff | label |
* | ------ | ----- |
* | ≤ −2 | `--` |
* | −1 | `-` |
* | 0 | `""` |
* | +1 | `+` |
* | ≥ +2 | `++` |
* | diff | label |
* | ---- | ------------ |
* | ≤ −3 | `''` |
* | −2 | `易しい` |
* | −1 | `やや易しい` |
* | 0 | `ふつう` |
* | +1 | `やや難しい` |
* | +2 | `難しい` |
* | ≥ +3 | `''` |
*
* @param diff - The result of {@link calcGradeDiff}.
* @returns The display label string.
* @param diff - A grade difference (e.g., from {@link calcGradeDiff}).
* @returns The Japanese label string, or `''` if out of expected range.
*/
export function getRelativeEvaluationLabel(diff: number): string {
if (diff <= -2) {
return '--';
export function getRelativeEvaluationJapaneseLabel(diff: number): string {
switch (diff) {
case -2:
return '易しい';
case -1:
return 'やや易しい';
case 0:
return 'ふつう';
case 1:
return 'やや難しい';
case 2:
return '難しい';
default:
return '';
}
Comment on lines +69 to +98

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getRelativeEvaluationJapaneseLabel returns an empty string for diff <= -3 / diff >= 3, but calcGradeDiff can legitimately produce those values (e.g., Q11↔D6 is ±16) and this function is used in VotableGrade for every grade option. As a result, most dropdown items may show no relative label even though the existing relative-evaluation logic clamps to the extremes (e.g., getRelativeEvaluationLabel(16) => ++). Consider clamping here as well (e.g., treat diff <= -2 as '易しい' and diff >= 2 as '難しい') or derive the Japanese label from getRelativeEvaluationLabel(diff) to keep behavior consistent.

Suggested change
* Returns an empty string when the diff falls outside the expected ±2 range.
*
* | diff | label |
* | ---- | ------------ |
* | −3 | `''` |
* | −2 | `易しい` |
* | −1 | `やや易しい` |
* | 0 | `ふつう` |
* | +1 | `やや難しい` |
* | +2 | `難しい` |
* | +3 | `''` |
*
* @param diff - A grade difference (e.g., from {@link calcGradeDiff}).
* @returns The Japanese label string, or `''` if out of expected range.
*/
export function getRelativeEvaluationJapaneseLabel(diff: number): string {
switch (diff) {
case -2:
return '易しい';
case -1:
return 'やや易しい';
case 0:
return 'ふつう';
case 1:
return 'やや難しい';
case 2:
return '難しい';
default:
return '';
}
* Values outside the central ±1 range are clamped to the nearest extreme label.
*
* | diff | label |
* | ------ | ------------ |
* | −2 | `易しい` |
* | −1 | `やや易しい` |
* | 0 | `ふつう` |
* | +1 | `やや難しい` |
* | +2 | `難しい` |
*
* @param diff - A grade difference (e.g., from {@link calcGradeDiff}).
* @returns The Japanese label string.
*/
export function getRelativeEvaluationJapaneseLabel(diff: number): string {
if (diff <= -2) {
return '易しい';
}
if (diff === -1) {
return 'やや易しい';
}
if (diff === 0) {
return 'ふつう';
}
if (diff === 1) {
return 'やや難しい';
}
return '難しい';

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DBグレードと中央値の差が±3より大きいとき、相対ラベルが付けられていない件の話ですね。
これに関しては意図的で、運営とユーザーのグレード乖離が±2を超えることは想定していないためです。
もちろん、投票自体は可能です。

@KATO-Hiro この件に関してはこのままでよろしいでしょうか?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

確かに正規分布にしたがうと仮定して±2σ相当と考えれば、大半のケースはカバーできていそうです。
このまま進めましょう。
実際の投票状況を確認して、必要に応じて軌道修正する方向で良さそうです。

if (diff === -1) {
return '-';
}

/**
* Returns Tailwind text color classes for a diff value in the vote dropdown.
* Negative diff (easier) → sky, zero → gray, positive (harder) → orange.
*
* @param diff - The result of {@link calcGradeDiff}.
*/
export function getRelativeEvaluationColorClass(diff: number): string {
if (diff < 0) {
return 'text-sky-500 dark:text-sky-400';
}
if (diff === 0) {
return '';
return 'text-gray-400 dark:text-gray-500';
}
if (diff === 1) {
return '+';
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).
*
* @param diff - The result of {@link calcGradeDiff}.
*/
export function getRelativeEvaluationBadgeColorClass(diff: number): string {
if (diff < 0) {
return 'bg-sky-400 text-white dark:bg-sky-500 dark:text-white';
}
return '++';
if (diff > 0) {
return 'bg-orange-400 text-white dark:bg-orange-500 dark:text-white';
}
return '';
}
Loading