diff --git a/.claude/rules/coding-style.md b/.claude/rules/coding-style.md index 5dee900e2..516d6b631 100644 --- a/.claude/rules/coding-style.md +++ b/.claude/rules/coding-style.md @@ -26,6 +26,7 @@ Before writing new logic, decide which layer it belongs to. Run this check at pl - **Abbreviations**: avoid non-standard abbreviations (`res` → `response`, `btn` → `button`). When in doubt, spell it out. - **Lambda parameters**: no single-character names (e.g., use `placement`, `workbook`). Iterator index `i` is the only exception. - **`upsert`**: only use when the implementation performs both insert and update. For insert-only, use `initialize`, `seed`, or another accurate verb. +- **Function verbs**: every function name must start with a verb. Noun-only names (`pointOnCircle`, `arcPath`) are ambiguous — use `calcPointOnCircle`, `buildArcPath`, etc. Common prefixes: `get` (read existing), `build`/`create` (construct new), `calc`/`compute` (derive by formula), `update`, `fetch`, `resolve`. - **`any`**: before using `any`, check the value's origin — adding a missing `@types/*` or `devDependency` often provides the correct type. When `any` seems unavoidable, use the narrowest alternative: | Situation | Alternative | @@ -47,6 +48,7 @@ Before writing new logic, decide which layer it belongs to. Run this check at pl ### Syntax - **Braces**: always use braces for single-statement `if` blocks. Never `if () return;` — write `if () { return; }`. +- **Domain types over `string`**: when the Prisma schema uses an enum (e.g. `grade: TaskGrade`), the corresponding app-layer type must use the same enum — not `string`. A loose `string` type hides misspellings in fixtures and forces `as TaskGrade` casts throughout the codebase. When a field comes from an external source (form data, query params), validate and narrow it at the boundary; inside the app it must always be the domain type. - **Plural type aliases**: define `type Placements = Placement[]` instead of using `Placement[]` directly in signatures and variables. - **Empty `catch` blocks**: never use `catch { }` or `catch (_e)` to silence errors. Every `catch` must re-throw, log, or contain an explanatory comment justifying the suppression. Silent swallowing hides bugs and makes failures untraceable. @@ -126,15 +128,7 @@ Common identifiers: `typescript`, `svelte`, `sql`, `bash`, `mermaid`, `json`, `p ### Svelte 5: Prefer Official Docs Over Training Knowledge -When Svelte 5 behavior is unclear, fetch the official docs directly via WebFetch instead of relying on training knowledge. - -URL pattern: `https://svelte.dev/docs/svelte/{section}` - -Examples: - -- `$effect` behavior → `https://svelte.dev/docs/svelte/$effect` -- Stores usage → `https://svelte.dev/docs/svelte/stores` -- Runes overview → `https://svelte.dev/docs/svelte/what-are-runes` +When Svelte 5 behavior is unclear, fetch official docs via WebFetch — do not rely on training knowledge. URL pattern: `https://svelte.dev/docs/svelte/{section}` (e.g. `/$effect`, `/stores`, `/what-are-runes`). ## Security diff --git a/.claude/rules/svelte-components.md b/.claude/rules/svelte-components.md index e9fa8ca40..61c23fa7d 100644 --- a/.claude/rules/svelte-components.md +++ b/.claude/rules/svelte-components.md @@ -68,6 +68,20 @@ Define snippets at the **top level**, outside component tags. Inside a tag = nam {#snippet footer()}...{/snippet} ``` +## `{#snippet}` Parameter Types + +Snippet parameters do not infer types from call sites — always annotate explicitly or TypeScript will error with "implicitly has an 'any' type": + +```svelte + +{#snippet segmentLabel(segment)} + + +{#snippet segmentLabel(segment: DonutSegment)} +``` + +Import the type in ` - +
+ {#if isProvisional} + + {/if} + + +
{#if isLoggedIn && isAtCoderVerified !== false} diff --git a/src/features/votes/components/VoteDonutChart.svelte b/src/features/votes/components/VoteDonutChart.svelte new file mode 100644 index 000000000..fdf8af91b --- /dev/null +++ b/src/features/votes/components/VoteDonutChart.svelte @@ -0,0 +1,194 @@ + + + + 投票分布 + + + {@render metallicGradient()} + + + {#if totalVotes >= 1} + {#each segments as segment (segment.grade)} + {@render segmentPath(segment)} + {/each} + + + {#if medianGrade} + {@render medianIndicator()} + {/if} + + {#each segments as segment (segment.grade)} + {@render segmentLabel(segment)} + {/each} + {:else} + + {@render emptyRing()} + {/if} + + {@render totalVotedCount()} + + +{#snippet metallicGradient()} + + + + + + +{/snippet} + +{#snippet segmentPath(segment: DonutSegment)} + +{/snippet} + +{#snippet medianIndicator()} + + 中央値 +{/snippet} + +{#snippet segmentLabel(segment: DonutSegment)} + {@const labelPoint = calcPointOnCircle({ x: CX, y: CY }, RING_MID_RADIUS, segment.midAngle)} + {@const labelX = labelPoint.x} + {@const labelY = labelPoint.y} + {@const label = segment.grade === votedGrade ? `✅ ${segment.label}` : segment.label} + + {#if segment.percentage >= 10} + + {label} + + + {segment.count}票 ({segment.percentage}%) + + {:else if segment.percentage >= 5} + + {label} + + {/if} +{/snippet} + +{#snippet emptyRing()} + +{/snippet} + +{#snippet totalVotedCount()} + + {totalVotes} + + + 票 + +{/snippet} diff --git a/src/features/votes/services/vote_statistics.test.ts b/src/features/votes/services/vote_statistics.test.ts index 6a9fd1fce..ea3113f30 100644 --- a/src/features/votes/services/vote_statistics.test.ts +++ b/src/features/votes/services/vote_statistics.test.ts @@ -135,7 +135,13 @@ describe('getVoteGradeStatistics', () => { describe('getAllTasksWithVoteInfo', () => { test('attaches estimatedGrade from statistics when available', async () => { mockTaskFindMany([ - { task_id: 'abc001_a', contest_id: 'abc001', title: 'Problem A', grade: TaskGrade.PENDING }, + { + task_id: 'abc001_a', + contest_id: 'abc001', + title: 'Problem A', + grade: TaskGrade.PENDING, + task_table_index: 'A', + }, ]); mockVotedGradeStatisticsFindMany([ makeStatisticsRecord({ taskId: 'abc001_a', grade: TaskGrade.Q5 }), @@ -149,7 +155,13 @@ describe('getAllTasksWithVoteInfo', () => { test('returns null estimatedGrade when no statistics exist for the task', async () => { mockTaskFindMany([ - { task_id: 'abc001_a', contest_id: 'abc001', title: 'Problem A', grade: TaskGrade.PENDING }, + { + task_id: 'abc001_a', + contest_id: 'abc001', + title: 'Problem A', + grade: TaskGrade.PENDING, + task_table_index: 'A', + }, ]); mockVotedGradeStatisticsFindMany([]); mockVotedGradeCounterFindMany([]); @@ -159,9 +171,34 @@ describe('getAllTasksWithVoteInfo', () => { expect(result[0].estimatedGrade).toBeNull(); }); + test('includes the confirmed grade and task_table_index from the task record', async () => { + mockTaskFindMany([ + { + task_id: 'abc001_a', + contest_id: 'abc001', + title: 'Problem A', + grade: TaskGrade.Q5, + task_table_index: 'A', + }, + ]); + mockVotedGradeStatisticsFindMany([]); + mockVotedGradeCounterFindMany([]); + + const result = await getAllTasksWithVoteInfo(); + + expect(result[0].grade).toBe(TaskGrade.Q5); + expect(result[0].task_table_index).toBe('A'); + }); + test('aggregates voteTotal across all grade counters for the task', async () => { mockTaskFindMany([ - { task_id: 'abc001_a', contest_id: 'abc001', title: 'Problem A', grade: TaskGrade.PENDING }, + { + task_id: 'abc001_a', + contest_id: 'abc001', + title: 'Problem A', + grade: TaskGrade.PENDING, + task_table_index: 'A', + }, ]); mockVotedGradeStatisticsFindMany([]); mockVotedGradeCounterFindMany([ @@ -176,7 +213,13 @@ describe('getAllTasksWithVoteInfo', () => { test('returns 0 voteTotal when no counters exist for the task', async () => { mockTaskFindMany([ - { task_id: 'abc001_a', contest_id: 'abc001', title: 'Problem A', grade: TaskGrade.PENDING }, + { + task_id: 'abc001_a', + contest_id: 'abc001', + title: 'Problem A', + grade: TaskGrade.PENDING, + task_table_index: 'A', + }, ]); mockVotedGradeStatisticsFindMany([]); mockVotedGradeCounterFindMany([]); diff --git a/src/features/votes/services/vote_statistics.ts b/src/features/votes/services/vote_statistics.ts index b7516ae1a..9149fc664 100644 --- a/src/features/votes/services/vote_statistics.ts +++ b/src/features/votes/services/vote_statistics.ts @@ -6,6 +6,10 @@ export type TaskWithVoteInfo = { task_id: string; contest_id: string; title: string; + /** The confirmed grade stored in the DB. PENDING means not yet confirmed by admin. */ + grade: TaskGrade; + /** Problem index within the contest (e.g. "A", "B"). Used for sorting. */ + task_table_index: string; estimatedGrade: TaskGrade | null; voteTotal: number; }; @@ -22,7 +26,7 @@ export async function getVoteGradeStatistics(): Promise { const [allTasks, stats, counters] = await Promise.all([ - prisma.task.findMany({ orderBy: { task_id: 'asc' } }), + prisma.task.findMany({ orderBy: { task_id: 'desc' } }), prisma.votedGradeStatistics.findMany(), prisma.votedGradeCounter.findMany(), ]); @@ -37,6 +41,8 @@ export async function getAllTasksWithVoteInfo(): Promise { task_id: task.task_id, contest_id: task.contest_id, title: task.title, + grade: task.grade, + task_table_index: task.task_table_index, estimatedGrade: statsMap.get(task.task_id)?.grade ?? null, voteTotal: totalsMap.get(task.task_id) ?? 0, })); diff --git a/src/features/votes/types/donut_graph.ts b/src/features/votes/types/donut_graph.ts new file mode 100644 index 000000000..b48de66af --- /dev/null +++ b/src/features/votes/types/donut_graph.ts @@ -0,0 +1,22 @@ +import type { TaskGrade } from '$lib/types/task'; + +/** A single arc segment of the vote distribution donut chart. */ +export type DonutSegment = { + grade: TaskGrade; + count: number; + /** 0–100, one decimal place (e.g. 33.3) */ + percentage: number; + /** CSS color string (e.g. "var(--color-atcoder-Q1)") */ + color: string; + /** Display label (e.g. "1Q") */ + label: string; + /** Radians, 0 = top of circle, clockwise */ + startAngle: number; + /** Radians */ + endAngle: number; + /** Midpoint angle for label placement */ + midAngle: number; +}; + +/** Ordered list of donut chart segment descriptors. */ +export type DonutSegments = DonutSegment[]; diff --git a/src/features/votes/utils/donut_chart.test.ts b/src/features/votes/utils/donut_chart.test.ts new file mode 100644 index 000000000..11c74049a --- /dev/null +++ b/src/features/votes/utils/donut_chart.test.ts @@ -0,0 +1,111 @@ +import { describe, test, expect } from 'vitest'; + +import { TaskGrade } from '$lib/types/task'; +import { buildDonutSegments, buildArcPath, calcPointOnCircle, MIN_LABEL_PCT } from './donut_chart'; + +const GRADE_A = TaskGrade.Q1; +const GRADE_B = TaskGrade.Q2; +const COLOR_A = 'var(--color-atcoder-Q1)'; + +const getColor = (grade: TaskGrade) => `var(--color-atcoder-${grade})`; +const getLabel = (grade: TaskGrade) => `${grade.slice(1)}${grade.slice(0, 1)}`; // Q1 -> 1Q + +describe('buildDonutSegments', () => { + test('returns empty array when totalVotes is 0', () => { + const result = buildDonutSegments([GRADE_A], [], getColor, getLabel); + expect(result).toEqual([]); + }); + + test('excludes grades with count 0', () => { + const counters = [{ grade: GRADE_A, count: 0 }]; + const result = buildDonutSegments([GRADE_A], counters, getColor, getLabel); + expect(result).toHaveLength(0); + }); + + test('builds a single segment covering the full circle', () => { + const counters = [{ grade: GRADE_A, count: 10 }]; + const [segment] = buildDonutSegments([GRADE_A], counters, getColor, getLabel); + expect(segment.percentage).toBe(100); + expect(segment.count).toBe(10); + expect(segment.color).toBe(COLOR_A); + expect(segment.label).toBe(getLabel(GRADE_A)); + // full circle: endAngle - startAngle ≈ 2π + expect(segment.endAngle - segment.startAngle).toBeCloseTo(2 * Math.PI); + }); + + test('builds two segments with correct proportions', () => { + const counters = [ + { grade: GRADE_A, count: 1 }, + { grade: GRADE_B, count: 3 }, + ]; + const segments = buildDonutSegments([GRADE_A, GRADE_B], counters, getColor, getLabel); + expect(segments).toHaveLength(2); + expect(segments[0].percentage).toBe(25); + expect(segments[1].percentage).toBe(75); + // Segments are contiguous: second starts where first ends + expect(segments[1].startAngle).toBeCloseTo(segments[0].endAngle); + }); + + test('starts at the top of the circle (−π/2)', () => { + const counters = [{ grade: GRADE_A, count: 5 }]; + const [segment] = buildDonutSegments([GRADE_A], counters, getColor, getLabel); + expect(segment.startAngle).toBeCloseTo(-Math.PI / 2); + }); +}); + +describe('calcPointOnCircle', () => { + test('returns center when radius is 0', () => { + const point = calcPointOnCircle({ x: 10, y: 20 }, 0, 0); + expect(point.x).toBeCloseTo(10); + expect(point.y).toBeCloseTo(20); + }); + + test('returns top of circle at angle -π/2', () => { + const point = calcPointOnCircle({ x: 0, y: 0 }, 100, -Math.PI / 2); + expect(point.x).toBeCloseTo(0); + expect(point.y).toBeCloseTo(-100); + }); + + test('returns right of circle at angle 0', () => { + const point = calcPointOnCircle({ x: 0, y: 0 }, 100, 0); + expect(point.x).toBeCloseTo(100); + expect(point.y).toBeCloseTo(0); + }); +}); + +describe('buildArcPath', () => { + test('returns a string containing M and A and Z commands', () => { + const path = buildArcPath({ x: 100, y: 100 }, 70, 40, 0, Math.PI); + expect(path).toMatch(/^M /); + expect(path).toContain(' A '); + expect(path).toContain(' Z'); + }); + + test('uses large-arc-flag=1 when angle span exceeds π', () => { + const path = buildArcPath({ x: 100, y: 100 }, 70, 40, 0, Math.PI + 0.1); + // large-arc-flag appears as the 4th param of the A command + expect(path).toMatch(/A \d+ \d+ 0 1 1/); + }); + + test('uses large-arc-flag=0 when angle span is less than π', () => { + const path = buildArcPath({ x: 100, y: 100 }, 70, 40, 0, Math.PI - 0.1); + expect(path).toMatch(/A \d+ \d+ 0 0 1/); + }); +}); + +describe('buildArcPath - full circle', () => { + test('renders full-circle segment as two sub-paths to avoid degenerate arc', () => { + const start = -Math.PI / 2; + const end = start + 2 * Math.PI; + const path = buildArcPath({ x: 100, y: 100 }, 70, 40, start, end); + // Two M commands indicate two sub-paths (dual-arc workaround) + const subPathCount = (path.match(/\bM\b/g) ?? []).length; + expect(subPathCount).toBe(2); + }); +}); + +describe('MIN_LABEL_PCT', () => { + test('is 0.05', () => { + expect(MIN_LABEL_PCT).toBe(0.05); + }); +}); diff --git a/src/features/votes/utils/donut_chart.ts b/src/features/votes/utils/donut_chart.ts new file mode 100644 index 000000000..66a54510f --- /dev/null +++ b/src/features/votes/utils/donut_chart.ts @@ -0,0 +1,132 @@ +import { TaskGrade } from '$lib/types/task'; +import type { DonutSegment, DonutSegments } from '$features/votes/types/donut_graph'; + +export type { DonutSegment, DonutSegments }; + +const TAU = 2 * Math.PI; +const HALF_PI = Math.PI / 2; + +/** Minimum percentage (0–1) threshold for a segment to receive an external label. */ +export const MIN_LABEL_PCT = 0.05; + +/** + * Builds donut chart segment descriptors from vote counters. + * Segments with count 0 are excluded. + * @param grades - Ordered list of grade values to iterate over. + * @param counters - Raw counter records from DB. + * @param getColor - Maps grade to CSS color string. + * @param getLabel - Maps grade to display string. + */ +export function buildDonutSegments( + grades: TaskGrade[], + counters: { grade: TaskGrade; count: number }[], + getColor: (grade: TaskGrade) => string, + getLabel: (grade: TaskGrade) => string, +): DonutSegments { + const totalVotes = counters.reduce((sum, counter) => sum + counter.count, 0); + + if (totalVotes === 0) { + return []; + } + + const countMap = new Map(counters.map((counter) => [counter.grade, counter.count])); + let cumulative = 0; + const segments: DonutSegments = []; + + for (const grade of grades) { + const count = countMap.get(grade) ?? 0; + + if (count === 0) { + continue; + } + + const ratio = count / totalVotes; + const startAngle = cumulative * TAU - HALF_PI; + cumulative += ratio; + const endAngle = cumulative * TAU - HALF_PI; + + segments.push({ + grade, + count, + percentage: Math.round(ratio * 1000) / 10, + color: getColor(grade), + label: getLabel(grade), + startAngle, + endAngle, + midAngle: (startAngle + endAngle) / 2, + }); + } + + return segments; +} + +type Point = { x: number; y: number }; + +/** + * Calculates the point on a circle at the given angle. + * @param center - Center of the circle. + * @param radius - Radius of the circle. + * @param angle - Angle in radians. + * @returns The Cartesian coordinates of the point. + */ +export function calcPointOnCircle(center: Point, radius: number, angle: number): Point { + return { + x: center.x + radius * Math.cos(angle), + y: center.y + radius * Math.sin(angle), + }; +} + +/** + * Generates SVG path data for one donut arc segment. + * When the span covers a full circle, the path is split into two semicircular + * arcs to avoid the SVG arc command degeneracy (start == end coordinates). + * + * @param center - Center coordinates of the donut chart. + * @param outerRadius - Outer ring radius. + * @param innerRadius - Inner hole radius. + * @param startAngle - Radians, clockwise from top. + * @param endAngle - Radians, clockwise from top. + * + * @returns SVG path `d` attribute string. + */ +export function buildArcPath( + center: Point, + outerRadius: number, + innerRadius: number, + startAngle: number, + endAngle: number, +): string { + if (endAngle - startAngle >= 2 * Math.PI - 1e-9) { + const midAngle = startAngle + Math.PI; + + return [ + arcPathSegment(center, outerRadius, innerRadius, startAngle, midAngle), + arcPathSegment(center, outerRadius, innerRadius, midAngle, endAngle), + ].join(' '); + } + + return arcPathSegment(center, outerRadius, innerRadius, startAngle, endAngle); +} + +function arcPathSegment( + center: Point, + outerRadius: number, + innerRadius: number, + startAngle: number, + endAngle: number, +): string { + const outerStart = calcPointOnCircle(center, outerRadius, startAngle); + const outerEnd = calcPointOnCircle(center, outerRadius, endAngle); + const innerEnd = calcPointOnCircle(center, innerRadius, endAngle); + const innerStart = calcPointOnCircle(center, innerRadius, startAngle); + const largeArcFlag = endAngle - startAngle > Math.PI ? 1 : 0; + + // SVG path commands per spec: M=moveto, A=arc, L=lineto, Z=closepath + return [ + `M ${outerStart.x} ${outerStart.y}`, + `A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${outerEnd.x} ${outerEnd.y}`, + `L ${innerEnd.x} ${innerEnd.y}`, + `A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${innerStart.x} ${innerStart.y}`, + 'Z', + ].join(' '); +} diff --git a/src/features/votes/utils/grade_options.test.ts b/src/features/votes/utils/grade_options.test.ts index 0e76702b0..e94792922 100644 --- a/src/features/votes/utils/grade_options.test.ts +++ b/src/features/votes/utils/grade_options.test.ts @@ -1,8 +1,26 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, test } from 'vitest'; import { TaskGrade, taskGradeValues } from '$lib/types/task'; -import { nonPendingGrades } from './grade_options'; +import { resolveDisplayGrade, nonPendingGrades, qGrades, dGrades } from './grade_options'; + +describe('resolveDisplayGrade', () => { + test('returns the grade as-is when it is not PENDING', () => { + expect(resolveDisplayGrade(TaskGrade.Q1, TaskGrade.Q2)).toBe(TaskGrade.Q1); + }); + + test('returns estimatedGrade when grade is PENDING and estimatedGrade is provided', () => { + expect(resolveDisplayGrade(TaskGrade.PENDING, TaskGrade.Q3)).toBe(TaskGrade.Q3); + }); + + test('returns PENDING when grade is PENDING and estimatedGrade is null', () => { + expect(resolveDisplayGrade(TaskGrade.PENDING, null)).toBe(TaskGrade.PENDING); + }); + + test('returns PENDING when grade is PENDING and estimatedGrade is undefined', () => { + expect(resolveDisplayGrade(TaskGrade.PENDING)).toBe(TaskGrade.PENDING); + }); +}); describe('nonPendingGrades', () => { it('does not include PENDING', () => { @@ -19,3 +37,41 @@ describe('nonPendingGrades', () => { expect(nonPendingGrades[nonPendingGrades.length - 1]).toBe(TaskGrade.D6); }); }); + +describe('qGrades', () => { + it('contains only Q-tier grades', () => { + expect(qGrades.every((g) => g.startsWith('Q'))).toBe(true); + }); + + it('contains no D-tier grades', () => { + expect(qGrades.some((g) => g.startsWith('D'))).toBe(false); + }); + + it('equals nonPendingGrades filtered to Q prefix', () => { + expect(qGrades).toEqual(nonPendingGrades.filter((g) => g.startsWith('Q'))); + }); + + it('starts with Q11 and ends with Q1', () => { + expect(qGrades[0]).toBe(TaskGrade.Q11); + expect(qGrades[qGrades.length - 1]).toBe(TaskGrade.Q1); + }); +}); + +describe('dGrades', () => { + it('contains only D-tier grades', () => { + expect(dGrades.every((g) => g.startsWith('D'))).toBe(true); + }); + + it('contains no Q-tier grades', () => { + expect(dGrades.some((g) => g.startsWith('Q'))).toBe(false); + }); + + it('equals nonPendingGrades filtered to D prefix', () => { + expect(dGrades).toEqual(nonPendingGrades.filter((g) => g.startsWith('D'))); + }); + + it('starts with D1 and ends with D6', () => { + expect(dGrades[0]).toBe(TaskGrade.D1); + expect(dGrades[dGrades.length - 1]).toBe(TaskGrade.D6); + }); +}); diff --git a/src/features/votes/utils/grade_options.ts b/src/features/votes/utils/grade_options.ts index 57f443244..1a34a8488 100644 --- a/src/features/votes/utils/grade_options.ts +++ b/src/features/votes/utils/grade_options.ts @@ -1,4 +1,30 @@ import { taskGradeValues, TaskGrade } from '$lib/types/task'; /** All grade values except PENDING, used for vote buttons and distribution display. */ +/** All task grades excluding PENDING, in display order (Q11 → D6). */ export const nonPendingGrades = taskGradeValues.filter((grade) => grade !== TaskGrade.PENDING); + +/** Q-tier grades only (Q11 → Q1). */ +export const qGrades = nonPendingGrades.filter((grade) => grade.startsWith('Q')); + +/** D-tier grades only (D1 → D6). */ +export const dGrades = nonPendingGrades.filter((grade) => grade.startsWith('D')); + +/** + * Resolves the display grade for a PENDING task. + * Returns `estimatedGrade` (median-based) when the official grade is still PENDING, + * otherwise returns the official grade unchanged. + * @param grade - The official task grade from the DB. + * @param estimatedGrade - The median-based estimated grade, if available. + * @returns The grade to display in the UI. + */ +export function resolveDisplayGrade( + grade: TaskGrade, + estimatedGrade?: TaskGrade | null, +): TaskGrade { + if (grade !== TaskGrade.PENDING) { + return grade; + } + + return estimatedGrade ?? grade; +} diff --git a/src/features/workbooks/utils/workbooks.ts b/src/features/workbooks/utils/workbooks.ts index 5eaf3013c..b638dfee1 100644 --- a/src/features/workbooks/utils/workbooks.ts +++ b/src/features/workbooks/utils/workbooks.ts @@ -92,7 +92,7 @@ export function calcWorkBookGradeModes( const task = tasksMapByIds.get(workBookTask.taskId); if (task && task.grade !== TaskGrade.PENDING) { - results.push(task.grade as TaskGrade); + results.push(task.grade); } return results; diff --git a/src/lib/constants/navbar-links.ts b/src/lib/constants/navbar-links.ts index 52034a109..4d1aa974f 100644 --- a/src/lib/constants/navbar-links.ts +++ b/src/lib/constants/navbar-links.ts @@ -19,7 +19,7 @@ export const navbarLinks = [ { title: `ホーム`, path: HOME_PAGE }, { title: `問題集`, path: WORKBOOKS_PAGE }, { title: `一覧表`, path: PROBLEMS_PAGE }, - { title: `グレード投票`, path: VOTES_PAGE }, + { title: `投票`, path: VOTES_PAGE }, { title: `サービスの説明`, path: ABOUT_PAGE }, ]; diff --git a/src/lib/types/task.ts b/src/lib/types/task.ts index 26e14510a..3f09ee387 100644 --- a/src/lib/types/task.ts +++ b/src/lib/types/task.ts @@ -8,7 +8,7 @@ export interface Task { task_table_index: string; task_id: string; title: string; - grade: string; + grade: TaskGrade; } export type Tasks = Task[]; diff --git a/src/lib/utils/task.ts b/src/lib/utils/task.ts index db9da82ec..678cd243e 100644 --- a/src/lib/utils/task.ts +++ b/src/lib/utils/task.ts @@ -72,7 +72,10 @@ export const areAllTasksAccepted = ( // See: // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort -export function compareByContestIdAndTaskId(first: TaskResult, second: TaskResult): number { +export function compareByContestIdAndTaskId( + first: { contest_id: string; task_table_index: string }, + second: { contest_id: string; task_table_index: string }, +): number { const firstContestPriority = getContestPriority(first.contest_id); const secondContestPriority = getContestPriority(second.contest_id); diff --git a/src/lib/utils/task_filter.ts b/src/lib/utils/task_filter.ts new file mode 100644 index 000000000..5098d15d6 --- /dev/null +++ b/src/lib/utils/task_filter.ts @@ -0,0 +1,39 @@ +import { getContestNameLabel } from '$lib/utils/contest'; + +type SearchableTask = { + title: string; + task_id: string; + contest_id: string; +}; + +/** + * Filters tasks by a search string against title, task_id, contest_id, and contest name label. + * Sorting and order are the caller's responsibility. + * + * @param tasks - The task list to filter + * @param search - Search string (case-insensitive). Returns empty array when empty. + * @param limit - Maximum number of results to return + * + * @returns Filtered tasks, capped at limit + */ +export function filterTasksBySearch( + tasks: T[], + search: string, + limit: number, +): T[] { + if (search === '') { + return []; + } + + const query = search.toLowerCase(); + + return tasks + .filter( + (task) => + task.title.toLowerCase().includes(query) || + task.task_id.toLowerCase().includes(query) || + task.contest_id.toLowerCase().includes(query) || + getContestNameLabel(task.contest_id).toLowerCase().includes(query), + ) + .slice(0, limit); +} diff --git a/src/routes/votes/+page.svelte b/src/routes/votes/+page.svelte index 8646f0e12..fd81136ed 100644 --- a/src/routes/votes/+page.svelte +++ b/src/routes/votes/+page.svelte @@ -8,75 +8,112 @@ TableHead, TableHeadCell, Input, + Tooltip, } from 'flowbite-svelte'; + import ExternalLink from '@lucide/svelte/icons/external-link'; + import FlaskConical from '@lucide/svelte/icons/flask-conical'; import HeadingOne from '$lib/components/HeadingOne.svelte'; import GradeLabel from '$lib/components/GradeLabel.svelte'; + import { TaskGrade } from '$lib/types/task'; + + import { getContestNameLabel } from '$lib/utils/contest'; + import { getTaskUrl, compareByContestIdAndTaskId } from '$lib/utils/task'; + import { filterTasksBySearch } from '$lib/utils/task_filter'; + import { resolveDisplayGrade } from '$features/votes/utils/grade_options'; + + const MAX_SEARCH_RESULTS = 20; + let { data } = $props(); let search = $state(''); - const filteredTasks = $derived( - search === '' - ? data.tasks - : data.tasks.filter( - (t) => - (t.title ?? '').toLowerCase().includes(search.toLowerCase()) || - (t.task_id ?? '').toLowerCase().includes(search.toLowerCase()) || - (t.contest_id ?? '').toLowerCase().includes(search.toLowerCase()), - ), - ); + const sortedTasks = $derived([...data.tasks].sort(compareByContestIdAndTaskId)); + const filteredTasks = $derived(filterTasksBySearch(sortedTasks, search, MAX_SEARCH_RESULTS));
- +
- +
- - - 問題 - コンテスト - 暫定グレード - 票数 - - - {#each filteredTasks as task (task.task_id)} - - - - {task.title} - - - {task.contest_id} - - {#if task.estimatedGrade} - - {:else} - - - {/if} - - {task.voteTotal} - - {/each} - {#if filteredTasks.length === 0} - - - 該当する問題が見つかりませんでした - - - {/if} - -
+
+ + + グレード + 問題名 + 出典 + 票数 + + + {#if search === ''} + + + 問題名・問題ID・出典で検索してください + + + {:else} + {#each filteredTasks as task (task.task_id)} + {@const displayGrade = resolveDisplayGrade(task.grade, task.estimatedGrade)} + {@const isProvisional = + task.grade === TaskGrade.PENDING && displayGrade !== TaskGrade.PENDING} + + +
+ {#if isProvisional} + + + + 3票以上集まると中央値が暫定グレードとして一覧表に反映されます。 + + {/if} + + +
+
+ + + + {getContestNameLabel(task.contest_id)} + {task.voteTotal} +
+ {/each} + {#if filteredTasks.length === 0} + + + 該当する問題が見つかりませんでした + + + {/if} + {/if} +
+
+
diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte index 2344eed2c..a60b52a94 100644 --- a/src/routes/votes/[slug]/+page.svelte +++ b/src/routes/votes/[slug]/+page.svelte @@ -1,12 +1,9 @@
- - - -
- - +
+ {#if data.stats && data.task.grade === TaskGrade.PENDING} + + + + + 3票以上集まると中央値が暫定グレードとして一覧表に反映されます。 + + {/if} + +

+ {data.task.title} +

-

- ※ 3票以上集まると中央値が暫定グレードとして一覧表に反映されます。 -

- {#if data.myVote?.voted}
-

- - 投票済み:{data.myVote.grade ? getTaskGradeLabel(data.myVote.grade) : ''} -

- - {#if data.stats} -

- 暫定グレード:{getTaskGradeLabel(data.stats.grade)}({totalVotes}票) -

- {/if} - - -
- {#each nonPendingGrades as grade (grade)} - {@const count = getCount(grade)} - {@const pct = getPct(grade)} - {@const isMyVote = data.myVote?.grade === grade} -
- - {getTaskGradeLabel(grade)} - -
-
-
- - {count}票 ({pct}%) - -
- {/each} -
-
- - -
- - 投票を変更する - -
+ +
{@render voteForm()}
-
+ + + +
{:else if data.isLoggedIn && !data.isAtCoderVerified}
-
- {#each nonPendingGrades as grade (grade)} - +
+ {#each [qGrades, dGrades] as row, i (i)} +
+ {#each row as grade (grade)} + + {/each} +
{/each}
diff --git a/src/routes/workbooks/[slug]/+page.svelte b/src/routes/workbooks/[slug]/+page.svelte index ba9712a66..87152b3d2 100644 --- a/src/routes/workbooks/[slug]/+page.svelte +++ b/src/routes/workbooks/[slug]/+page.svelte @@ -24,7 +24,8 @@ import { addContestNameToTaskIndex } from '$lib/utils/contest'; import { getTaskUrl, removeTaskIndexFromTitle } from '$lib/utils/task'; - import type { TaskResult, TaskGrade } from '$lib/types/task'; + import { TaskGrade } from '$lib/types/task'; + import type { TaskResult } from '$lib/types/task'; import type { WorkBookTaskBase } from '$features/workbooks/types/workbook'; let { data } = $props(); @@ -41,7 +42,7 @@ }; const getTaskGrade = (taskId: string): TaskGrade => { - return getTaskResult(taskId)?.grade as TaskGrade; + return getTaskResult(taskId)?.grade ?? TaskGrade.PENDING; }; const getContestIdFrom = (taskId: string): string => { diff --git a/src/test/lib/utils/task_filter.test.ts b/src/test/lib/utils/task_filter.test.ts new file mode 100644 index 000000000..1d3893da5 --- /dev/null +++ b/src/test/lib/utils/task_filter.test.ts @@ -0,0 +1,66 @@ +import { describe, test, expect } from 'vitest'; +import { filterTasksBySearch } from '$lib/utils/task_filter'; + +const tasks = [ + { title: 'ABC 300 A - N-choice question', task_id: 'abc300_a', contest_id: 'abc300' }, + { title: 'ARC 150 B - Count ABC', task_id: 'arc150_b', contest_id: 'arc150' }, + { title: 'AGC 060 C - No Majority', task_id: 'agc060_c', contest_id: 'agc060' }, + { title: 'ABC 301 A - Overall Winner', task_id: 'abc301_a', contest_id: 'abc301' }, + { title: 'ABC 302 A - Attack', task_id: 'abc302_a', contest_id: 'abc302' }, + // title intentionally omits '典型' so only getContestNameLabel matches '競プロ典型 90 問' + { title: 'Shortest Path', task_id: 'typical90_a', contest_id: 'typical90' }, +]; + +describe('filterTasksBySearch', () => { + test('returns empty array when search is empty', () => { + expect(filterTasksBySearch(tasks, '', 20)).toEqual([]); + }); + + test('matches by title (case-insensitive)', () => { + const result = filterTasksBySearch(tasks, 'n-choice question', 20); + expect(result).toHaveLength(1); + expect(result[0].task_id).toBe('abc300_a'); + }); + + test('matches by task_id', () => { + const result = filterTasksBySearch(tasks, 'arc150_b', 20); + expect(result).toHaveLength(1); + expect(result[0].task_id).toBe('arc150_b'); + }); + + test('matches by contest_id', () => { + const result = filterTasksBySearch(tasks, 'agc060', 20); + expect(result).toHaveLength(1); + expect(result[0].task_id).toBe('agc060_c'); + }); + + test('matches by contest name label when title does not contain the label', () => { + // getContestNameLabel('typical90') => '競プロ典型 90 問'; title is 'Shortest Path' — no overlap + const result = filterTasksBySearch(tasks, '競プロ典型 90 問', 20); + expect(result).toHaveLength(1); + expect(result[0].task_id).toBe('typical90_a'); + }); + + test('returns empty array when no tasks match', () => { + expect(filterTasksBySearch(tasks, 'zzzzz', 20)).toEqual([]); + }); + + test('applies limit to results', () => { + // abc300, abc301, abc302 all match 'abc30' + const result = filterTasksBySearch(tasks, 'abc30', 2); + expect(result).toHaveLength(2); + }); + + test('preserves input order — sorting is the caller responsibility', () => { + // tasks are declared as: abc300, arc150, agc060, abc301, abc302 + // abc30x matches: abc300, abc301, abc302 (in that input order) + // With limit=3, the first 2 from input order should be returned when limit=2 + const result = filterTasksBySearch(tasks, 'abc30', 3); + expect(result.map((task) => task.task_id)).toEqual(['abc300_a', 'abc301_a', 'abc302_a']); + }); + + test('returns all matched results when count is less than limit', () => { + const result = filterTasksBySearch(tasks, 'arc', 20); + expect(result).toHaveLength(1); + }); +}); diff --git a/src/test/lib/utils/test_cases/task_results.ts b/src/test/lib/utils/test_cases/task_results.ts index e73d84076..565de0401 100644 --- a/src/test/lib/utils/test_cases/task_results.ts +++ b/src/test/lib/utils/test_cases/task_results.ts @@ -1,3 +1,4 @@ +import { TaskGrade } from '$lib/types/task'; import type { TaskResult, TaskResults } from '$lib/types/task'; import type { WorkBookTaskBase } from '$features/workbooks/types/workbook'; @@ -33,7 +34,7 @@ export const taskResultsForUserId2: TaskResults = [ task_table_index: 'B', task_id: 'abc999_b', title: 'B. Foo', - grade: '6Q', + grade: TaskGrade.Q6, updated_at: new Date(), }, { @@ -47,7 +48,7 @@ export const taskResultsForUserId2: TaskResults = [ task_table_index: 'C', task_id: 'abc999_c', title: 'C. Bar', - grade: '4Q', + grade: TaskGrade.Q4, updated_at: new Date(), }, ]; @@ -79,7 +80,7 @@ export const taskResultsForUserId3: TaskResults = [ task_table_index: 'B', task_id: 'abc999_b', title: 'B. Foo', - grade: '6Q', + grade: TaskGrade.Q6, updated_at: new Date(), }, { @@ -93,7 +94,7 @@ export const taskResultsForUserId3: TaskResults = [ task_table_index: 'C', task_id: 'abc999_c', title: 'C. Bar', - grade: '4Q', + grade: TaskGrade.Q4, updated_at: new Date(), }, ]; @@ -125,7 +126,7 @@ export const taskResultsForUserId4: TaskResults = [ task_table_index: 'B', task_id: 'abc999_b', title: 'B. Foo', - grade: '6Q', + grade: TaskGrade.Q6, updated_at: new Date(), }, { @@ -139,7 +140,7 @@ export const taskResultsForUserId4: TaskResults = [ task_table_index: 'C', task_id: 'abc999_c', title: 'C. Bar', - grade: '4Q', + grade: TaskGrade.Q4, updated_at: new Date(), }, ]; @@ -171,7 +172,7 @@ export const taskResultsForUserId5: TaskResults = [ task_table_index: 'B', task_id: 'abc999_b', title: 'B. Foo', - grade: '6Q', + grade: TaskGrade.Q6, updated_at: new Date(), }, { @@ -185,7 +186,7 @@ export const taskResultsForUserId5: TaskResults = [ task_table_index: 'C', task_id: 'abc999_c', title: 'C. Bar', - grade: '4Q', + grade: TaskGrade.Q4, updated_at: new Date(), }, { @@ -199,7 +200,7 @@ export const taskResultsForUserId5: TaskResults = [ task_table_index: 'D', task_id: 'abc999_d', title: 'D. Fizz', - grade: '1Q', + grade: TaskGrade.Q1, updated_at: new Date(), }, ]; diff --git a/src/test/lib/utils/test_cases/task_table_header_name.ts b/src/test/lib/utils/test_cases/task_table_header_name.ts index 382de06d3..dfe45767d 100644 --- a/src/test/lib/utils/test_cases/task_table_header_name.ts +++ b/src/test/lib/utils/test_cases/task_table_header_name.ts @@ -1,3 +1,4 @@ +import { TaskGrade } from '$lib/types/task'; import type { TaskResult } from '$lib/types/task'; // Default task result with minimal initialization. @@ -15,7 +16,7 @@ const defaultTaskResult: TaskResult = { task_table_index: '', task_id: '', title: '', - grade: '', + grade: TaskGrade.PENDING, updated_at: new Date(0), // Use the Unix epoch as the default value. };