From 7615f0e463b736671624219e8dbdc202f097f3e2 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Wed, 1 Apr 2026 10:02:51 +0900 Subject: [PATCH 01/54] feat: add SVG donut chart utility functions Co-Authored-By: Claude Sonnet 4.6 --- src/features/votes/utils/donut_chart.test.ts | 78 ++++++++++++++ src/features/votes/utils/donut_chart.ts | 107 +++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 src/features/votes/utils/donut_chart.test.ts create mode 100644 src/features/votes/utils/donut_chart.ts 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..23356ae73 --- /dev/null +++ b/src/features/votes/utils/donut_chart.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { buildDonutSegments, arcPath, MIN_LABEL_PCT } from './donut_chart'; + +const GRADE_A = 'Q1'; +const GRADE_B = 'Q2'; +const COLOR_A = 'var(--color-atcoder-Q1)'; + +const getColor = (g: string) => `var(--color-atcoder-${g})`; +const getLabel = (g: string) => `${g.slice(1)}${g.slice(0, 1)}`; // Q1 -> 1Q + +describe('buildDonutSegments', () => { + it('returns empty array when totalVotes is 0', () => { + const result = buildDonutSegments([GRADE_A], [], getColor, getLabel); + expect(result).toEqual([]); + }); + + it('excludes grades with count 0', () => { + const counters = [{ grade: GRADE_A, count: 0 }]; + const result = buildDonutSegments([GRADE_A], counters, getColor, getLabel); + expect(result).toHaveLength(0); + }); + + it('builds a single segment covering the full circle', () => { + const counters = [{ grade: GRADE_A, count: 10 }]; + const [seg] = buildDonutSegments([GRADE_A], counters, getColor, getLabel); + expect(seg.pct).toBe(100); + expect(seg.count).toBe(10); + expect(seg.color).toBe(COLOR_A); + expect(seg.label).toBe('1Q'); + // full circle: endAngle - startAngle ≈ 2π + expect(seg.endAngle - seg.startAngle).toBeCloseTo(2 * Math.PI); + }); + + it('builds two segments with correct proportions', () => { + const counters = [ + { grade: GRADE_A, count: 1 }, + { grade: GRADE_B, count: 3 }, + ]; + const segs = buildDonutSegments([GRADE_A, GRADE_B], counters, getColor, getLabel); + expect(segs).toHaveLength(2); + expect(segs[0].pct).toBe(25); + expect(segs[1].pct).toBe(75); + // Segments are contiguous: second starts where first ends + expect(segs[1].startAngle).toBeCloseTo(segs[0].endAngle); + }); + + it('starts at the top of the circle (−π/2)', () => { + const counters = [{ grade: GRADE_A, count: 5 }]; + const [seg] = buildDonutSegments([GRADE_A], counters, getColor, getLabel); + expect(seg.startAngle).toBeCloseTo(-Math.PI / 2); + }); +}); + +describe('arcPath', () => { + it('returns a string containing M and A and Z commands', () => { + const path = arcPath(100, 100, 70, 40, 0, Math.PI); + expect(path).toMatch(/^M /); + expect(path).toContain(' A '); + expect(path).toContain(' Z'); + }); + + it('uses large-arc-flag=1 when angle span exceeds π', () => { + const path = arcPath(100, 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/); + }); + + it('uses large-arc-flag=0 when angle span is less than π', () => { + const path = arcPath(100, 100, 70, 40, 0, Math.PI - 0.1); + expect(path).toMatch(/A \d+ \d+ 0 0 1/); + }); +}); + +describe('MIN_LABEL_PCT', () => { + it('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..2afe08b1a --- /dev/null +++ b/src/features/votes/utils/donut_chart.ts @@ -0,0 +1,107 @@ +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; + +export type DonutSegment = { + grade: string; + count: number; + /** 0–100, rounded */ + pct: 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; +}; + +/** + * 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: string[], + counters: { grade: string; count: number }[], + getColor: (grade: string) => string, + getLabel: (grade: string) => string, +): DonutSegment[] { + const totalVotes = counters.reduce((sum, c) => sum + c.count, 0); + if (totalVotes === 0) { + return []; + } + + let cumulative = 0; + const segments: DonutSegment[] = []; + + for (const grade of grades) { + const count = counters.find((c) => c.grade === grade)?.count ?? 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, + pct: Math.round(ratio * 100), + color: getColor(grade), + label: getLabel(grade), + startAngle, + endAngle, + midAngle: (startAngle + endAngle) / 2, + }); + } + + return segments; +} + +/** + * Generates SVG path data for one donut arc segment. + * @param cx - Center x coordinate. + * @param cy - Center y coordinate. + * @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 arcPath( + cx: number, + cy: number, + outerRadius: number, + innerRadius: number, + startAngle: number, + endAngle: number, +): string { + const x1 = cx + outerRadius * Math.cos(startAngle); + const y1 = cy + outerRadius * Math.sin(startAngle); + const x2 = cx + outerRadius * Math.cos(endAngle); + const y2 = cy + outerRadius * Math.sin(endAngle); + const x3 = cx + innerRadius * Math.cos(endAngle); + const y3 = cy + innerRadius * Math.sin(endAngle); + const x4 = cx + innerRadius * Math.cos(startAngle); + const y4 = cy + innerRadius * Math.sin(startAngle); + const largeArcFlag = endAngle - startAngle > Math.PI ? 1 : 0; + + return [ + `M ${x1} ${y1}`, + `A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${x2} ${y2}`, + `L ${x3} ${y3}`, + `A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${x4} ${y4}`, + 'Z', + ].join(' '); +} From cdc8cce532c66dbbcd4c02f7141660bc2cd93fb9 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Wed, 1 Apr 2026 10:03:56 +0900 Subject: [PATCH 02/54] feat: add VoteDonutChart SVG donut chart component Co-Authored-By: Claude Sonnet 4.6 --- .../votes/components/VoteDonutChart.svelte | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/features/votes/components/VoteDonutChart.svelte diff --git a/src/features/votes/components/VoteDonutChart.svelte b/src/features/votes/components/VoteDonutChart.svelte new file mode 100644 index 000000000..b1b5526d6 --- /dev/null +++ b/src/features/votes/components/VoteDonutChart.svelte @@ -0,0 +1,86 @@ + + + + 投票分布 + + {#if totalVotes === 0} + + + {:else} + {#each segments as seg (seg.grade)} + + {/each} + + {#each segments as seg (seg.grade)} + {#if seg.pct / 100 >= MIN_LABEL_PCT} + {@const labelX = CX + LABEL_RADIUS * Math.cos(seg.midAngle)} + {@const labelY = CY + LABEL_RADIUS * Math.sin(seg.midAngle)} + {@const anchor = Math.cos(seg.midAngle) >= 0 ? 'start' : 'end'} + {seg.label} + ({seg.count}票, {seg.pct}%) + {/if} + {/each} + {/if} + + + {totalVotes} + + From 28ff243f47e940b664b329430cb08fa4e7241cc6 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Wed, 1 Apr 2026 10:04:54 +0900 Subject: [PATCH 03/54] feat: replace bar chart with donut chart on vote detail page Co-Authored-By: Claude Sonnet 4.6 --- src/routes/votes/[slug]/+page.svelte | 36 +++------------------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte index 2344eed2c..9015749e4 100644 --- a/src/routes/votes/[slug]/+page.svelte +++ b/src/routes/votes/[slug]/+page.svelte @@ -17,6 +17,7 @@ } from '$lib/utils/task'; import { nonPendingGrades } from '$features/votes/utils/grade_options'; import { SIGNUP_PAGE, LOGIN_PAGE, EDIT_PROFILE_PAGE } from '$lib/constants/navbar-links'; + import VoteDonutChart from '$features/votes/components/VoteDonutChart.svelte'; let { data } = $props(); @@ -25,15 +26,6 @@ const totalVotes = $derived( data.counters ? data.counters.reduce((sum, c) => sum + c.count, 0) : 0, ); - - function getCount(grade: string): number { - return data.counters?.find((c) => c.grade === grade)?.count ?? 0; - } - - function getPct(grade: string): number { - if (totalVotes === 0) return 0; - return Math.round((getCount(grade) / totalVotes) * 100); - }
@@ -80,30 +72,8 @@

{/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} -
+ +
From aa52a722ae3e2e882ce282c1f3b5051e4f2b40cc Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Wed, 1 Apr 2026 10:25:23 +0900 Subject: [PATCH 04/54] feat: add median grade indicator line to donut chart Co-Authored-By: Claude Sonnet 4.6 --- .../votes/components/VoteDonutChart.svelte | 38 ++++++++++++++----- src/routes/votes/[slug]/+page.svelte | 6 ++- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/features/votes/components/VoteDonutChart.svelte b/src/features/votes/components/VoteDonutChart.svelte index b1b5526d6..63b00c0d1 100644 --- a/src/features/votes/components/VoteDonutChart.svelte +++ b/src/features/votes/components/VoteDonutChart.svelte @@ -1,5 +1,6 @@ @@ -44,6 +51,19 @@ /> {/each} + + {#if medianSegment} + + {/if} + {#each segments as seg (seg.grade)} {#if seg.pct / 100 >= MIN_LABEL_PCT} {@const labelX = CX + LABEL_RADIUS * Math.cos(seg.midAngle)} @@ -54,15 +74,15 @@ y={labelY - 6} text-anchor={anchor} class="fill-gray-800 dark:fill-gray-200" - font-size="10" - >{seg.label} + font-size="10">{seg.label} ({seg.count}票, {seg.pct}%) + font-size="9">({seg.count}票, {seg.pct}%) {/if} {/each} {/if} @@ -74,13 +94,13 @@ text-anchor="middle" class="fill-gray-800 dark:fill-gray-200" font-size="22" - font-weight="bold" - >{totalVotes} + font-weight="bold">{totalVotes} + font-size="10">票 diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte index 9015749e4..806ee4ce9 100644 --- a/src/routes/votes/[slug]/+page.svelte +++ b/src/routes/votes/[slug]/+page.svelte @@ -73,7 +73,11 @@ {/if} - + From d896cddec29f803b70bbad5c6d2080864427647d Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Wed, 1 Apr 2026 13:18:00 +0900 Subject: [PATCH 05/54] fix: draw median line using cumulative arc angle instead of segment lookup Median grade may have zero votes and therefore no segment, causing the indicator line to be invisible. Compute the angle from the cumulative distribution so zero-vote grades are handled correctly. Co-Authored-By: Claude Sonnet 4.6 --- .../votes/components/VoteDonutChart.svelte | 21 +++++---- src/features/votes/utils/donut_chart.test.ts | 43 ++++++++++++++++++- src/features/votes/utils/donut_chart.ts | 34 +++++++++++++++ 3 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src/features/votes/components/VoteDonutChart.svelte b/src/features/votes/components/VoteDonutChart.svelte index 63b00c0d1..63f36b09f 100644 --- a/src/features/votes/components/VoteDonutChart.svelte +++ b/src/features/votes/components/VoteDonutChart.svelte @@ -3,7 +3,12 @@ import type { TaskGrade } from '$lib/types/task'; import { getTaskGradeColor, getTaskGradeLabel } from '$lib/utils/task'; import { nonPendingGrades } from '$features/votes/utils/grade_options'; - import { buildDonutSegments, arcPath, MIN_LABEL_PCT } from '$features/votes/utils/donut_chart'; + import { + buildDonutSegments, + arcPath, + getGradeAngle, + MIN_LABEL_PCT, + } from '$features/votes/utils/donut_chart'; interface Props { counters: VotedGradeCounter[]; @@ -23,8 +28,8 @@ buildDonutSegments(nonPendingGrades, counters, getTaskGradeColor, getTaskGradeLabel), ); - const medianSegment = $derived( - medianGrade ? (segments.find((seg) => seg.grade === medianGrade) ?? null) : null, + const medianAngle = $derived( + medianGrade ? getGradeAngle(nonPendingGrades, counters, medianGrade) : null, ); @@ -52,12 +57,12 @@ {/each} - {#if medianSegment} + {#if medianAngle !== null} { }); }); +describe('getGradeAngle', () => { + it('returns null when totalVotes is 0', () => { + expect(getGradeAngle([GRADE_A], [], GRADE_A)).toBeNull(); + }); + + it('returns null when grade is not in the list', () => { + const counters = [{ grade: GRADE_A, count: 1 }]; + expect(getGradeAngle([GRADE_A], counters, 'UNKNOWN')).toBeNull(); + }); + + it('returns top-of-circle angle for the first grade', () => { + const counters = [{ grade: GRADE_A, count: 1 }]; + // single grade covers full circle: midAngle = -π/2 + π = π/2 + const angle = getGradeAngle([GRADE_A], counters, GRADE_A); + expect(angle).toBeCloseTo(-Math.PI / 2 + Math.PI); // π/2 + }); + + it('returns boundary angle for a zero-vote grade between two voted grades', () => { + // GRADE_A 50%, GRADE_ZERO 0%, GRADE_B 50% + const GRADE_ZERO = 'Q3'; + const counters = [ + { grade: GRADE_A, count: 1 }, + { grade: GRADE_B, count: 1 }, + ]; + // GRADE_ZERO sits at the 50% boundary; start=end so midAngle = 50% * TAU - π/2 + const angle = getGradeAngle([GRADE_A, GRADE_ZERO, GRADE_B], counters, GRADE_ZERO); + expect(angle).toBeCloseTo(Math.PI / 2); // 0.5 * 2π - π/2 = π - π/2 = π/2 + }); + + it('returns the mid-arc angle for a grade that has votes', () => { + const counters = [ + { grade: GRADE_A, count: 1 }, + { grade: GRADE_B, count: 3 }, + ]; + // GRADE_B spans 25%→100% of the arc; midAngle = 62.5% * TAU - π/2 + const angle = getGradeAngle([GRADE_A, GRADE_B], counters, GRADE_B); + const expected = 0.625 * 2 * Math.PI - Math.PI / 2; + expect(angle).toBeCloseTo(expected); + }); +}); + describe('MIN_LABEL_PCT', () => { it('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 index 2afe08b1a..36be1f740 100644 --- a/src/features/votes/utils/donut_chart.ts +++ b/src/features/votes/utils/donut_chart.ts @@ -69,6 +69,40 @@ export function buildDonutSegments( return segments; } +/** + * Returns the midpoint angle (radians) of a grade's position in the donut arc, + * even when the grade has zero votes. For zero-vote grades the midAngle equals + * the boundary angle between the preceding and following segments. + * Returns null when totalVotes is 0 or the grade is not found in the list. + * @param grades - Ordered list of all grade values (including zero-vote ones). + * @param counters - Raw counter records from DB. + * @param targetGrade - The grade whose angular position to look up. + */ +export function getGradeAngle( + grades: string[], + counters: { grade: string; count: number }[], + targetGrade: string, +): number | null { + const totalVotes = counters.reduce((sum, c) => sum + c.count, 0); + if (totalVotes === 0) { + return null; + } + + let cumulative = 0; + for (const grade of grades) { + const count = counters.find((c) => c.grade === grade)?.count ?? 0; + const ratio = count / totalVotes; + if (grade === targetGrade) { + const startAngle = cumulative * TAU - HALF_PI; + const endAngle = (cumulative + ratio) * TAU - HALF_PI; + return (startAngle + endAngle) / 2; + } + cumulative += ratio; + } + + return null; +} + /** * Generates SVG path data for one donut arc segment. * @param cx - Center x coordinate. From a3cd3413eaf434f8f67f9e44d863eea8c8d6e0b8 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Wed, 1 Apr 2026 13:39:25 +0900 Subject: [PATCH 06/54] refactor: simplify median line to fixed bottom position The chart always starts at the top so the 50th percentile always falls at the bottom of the ring. Remove getGradeAngle and draw the line with hardcoded coordinates instead. Co-Authored-By: Claude Sonnet 4.6 --- .../votes/components/VoteDonutChart.svelte | 24 ++++------- src/features/votes/utils/donut_chart.test.ts | 43 +------------------ src/features/votes/utils/donut_chart.ts | 34 --------------- 3 files changed, 9 insertions(+), 92 deletions(-) diff --git a/src/features/votes/components/VoteDonutChart.svelte b/src/features/votes/components/VoteDonutChart.svelte index 63f36b09f..301441113 100644 --- a/src/features/votes/components/VoteDonutChart.svelte +++ b/src/features/votes/components/VoteDonutChart.svelte @@ -3,12 +3,7 @@ import type { TaskGrade } from '$lib/types/task'; import { getTaskGradeColor, getTaskGradeLabel } from '$lib/utils/task'; import { nonPendingGrades } from '$features/votes/utils/grade_options'; - import { - buildDonutSegments, - arcPath, - getGradeAngle, - MIN_LABEL_PCT, - } from '$features/votes/utils/donut_chart'; + import { buildDonutSegments, arcPath, MIN_LABEL_PCT } from '$features/votes/utils/donut_chart'; interface Props { counters: VotedGradeCounter[]; @@ -27,10 +22,6 @@ const segments = $derived( buildDonutSegments(nonPendingGrades, counters, getTaskGradeColor, getTaskGradeLabel), ); - - const medianAngle = $derived( - medianGrade ? getGradeAngle(nonPendingGrades, counters, medianGrade) : null, - ); @@ -56,13 +47,14 @@ /> {/each} - - {#if medianAngle !== null} + + {#if medianGrade} { }); }); -describe('getGradeAngle', () => { - it('returns null when totalVotes is 0', () => { - expect(getGradeAngle([GRADE_A], [], GRADE_A)).toBeNull(); - }); - - it('returns null when grade is not in the list', () => { - const counters = [{ grade: GRADE_A, count: 1 }]; - expect(getGradeAngle([GRADE_A], counters, 'UNKNOWN')).toBeNull(); - }); - - it('returns top-of-circle angle for the first grade', () => { - const counters = [{ grade: GRADE_A, count: 1 }]; - // single grade covers full circle: midAngle = -π/2 + π = π/2 - const angle = getGradeAngle([GRADE_A], counters, GRADE_A); - expect(angle).toBeCloseTo(-Math.PI / 2 + Math.PI); // π/2 - }); - - it('returns boundary angle for a zero-vote grade between two voted grades', () => { - // GRADE_A 50%, GRADE_ZERO 0%, GRADE_B 50% - const GRADE_ZERO = 'Q3'; - const counters = [ - { grade: GRADE_A, count: 1 }, - { grade: GRADE_B, count: 1 }, - ]; - // GRADE_ZERO sits at the 50% boundary; start=end so midAngle = 50% * TAU - π/2 - const angle = getGradeAngle([GRADE_A, GRADE_ZERO, GRADE_B], counters, GRADE_ZERO); - expect(angle).toBeCloseTo(Math.PI / 2); // 0.5 * 2π - π/2 = π - π/2 = π/2 - }); - - it('returns the mid-arc angle for a grade that has votes', () => { - const counters = [ - { grade: GRADE_A, count: 1 }, - { grade: GRADE_B, count: 3 }, - ]; - // GRADE_B spans 25%→100% of the arc; midAngle = 62.5% * TAU - π/2 - const angle = getGradeAngle([GRADE_A, GRADE_B], counters, GRADE_B); - const expected = 0.625 * 2 * Math.PI - Math.PI / 2; - expect(angle).toBeCloseTo(expected); - }); -}); - describe('MIN_LABEL_PCT', () => { it('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 index 36be1f740..2afe08b1a 100644 --- a/src/features/votes/utils/donut_chart.ts +++ b/src/features/votes/utils/donut_chart.ts @@ -69,40 +69,6 @@ export function buildDonutSegments( return segments; } -/** - * Returns the midpoint angle (radians) of a grade's position in the donut arc, - * even when the grade has zero votes. For zero-vote grades the midAngle equals - * the boundary angle between the preceding and following segments. - * Returns null when totalVotes is 0 or the grade is not found in the list. - * @param grades - Ordered list of all grade values (including zero-vote ones). - * @param counters - Raw counter records from DB. - * @param targetGrade - The grade whose angular position to look up. - */ -export function getGradeAngle( - grades: string[], - counters: { grade: string; count: number }[], - targetGrade: string, -): number | null { - const totalVotes = counters.reduce((sum, c) => sum + c.count, 0); - if (totalVotes === 0) { - return null; - } - - let cumulative = 0; - for (const grade of grades) { - const count = counters.find((c) => c.grade === grade)?.count ?? 0; - const ratio = count / totalVotes; - if (grade === targetGrade) { - const startAngle = cumulative * TAU - HALF_PI; - const endAngle = (cumulative + ratio) * TAU - HALF_PI; - return (startAngle + endAngle) / 2; - } - cumulative += ratio; - } - - return null; -} - /** * Generates SVG path data for one donut arc segment. * @param cx - Center x coordinate. From 1f0e1db5cff0bb6452aa135653aa3a882ceb4555 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Wed, 1 Apr 2026 15:37:33 +0900 Subject: [PATCH 07/54] feat: change median line to black and add label Co-Authored-By: Claude Sonnet 4.6 --- src/features/votes/components/VoteDonutChart.svelte | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/features/votes/components/VoteDonutChart.svelte b/src/features/votes/components/VoteDonutChart.svelte index 301441113..0b6039564 100644 --- a/src/features/votes/components/VoteDonutChart.svelte +++ b/src/features/votes/components/VoteDonutChart.svelte @@ -24,7 +24,7 @@ ); - + 投票分布 {#if totalVotes === 0} @@ -55,10 +55,18 @@ y1={CY + INNER_RADIUS} x2={CX} y2={CY + OUTER_RADIUS} - stroke="white" + stroke="black" stroke-width="2.5" stroke-linecap="round" + class="dark:stroke-white" /> + ↑ 中央値 {/if} {#each segments as seg (seg.grade)} From f1706414708a039c07cf729f03c5cbb74d48455e Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Wed, 1 Apr 2026 15:58:23 +0900 Subject: [PATCH 08/54] feat: move grade labels inside donut ring segments Co-Authored-By: Claude Sonnet 4.6 --- .../votes/components/VoteDonutChart.svelte | 53 +++++++++++++------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/src/features/votes/components/VoteDonutChart.svelte b/src/features/votes/components/VoteDonutChart.svelte index 0b6039564..4ab6f13f0 100644 --- a/src/features/votes/components/VoteDonutChart.svelte +++ b/src/features/votes/components/VoteDonutChart.svelte @@ -3,7 +3,7 @@ import type { TaskGrade } from '$lib/types/task'; import { getTaskGradeColor, getTaskGradeLabel } from '$lib/utils/task'; import { nonPendingGrades } from '$features/votes/utils/grade_options'; - import { buildDonutSegments, arcPath, MIN_LABEL_PCT } from '$features/votes/utils/donut_chart'; + import { buildDonutSegments, arcPath } from '$features/votes/utils/donut_chart'; interface Props { counters: VotedGradeCounter[]; @@ -17,7 +17,7 @@ const CY = 130; const OUTER_RADIUS = 90; const INNER_RADIUS = 55; - const LABEL_RADIUS = 115; + const RING_MID_RADIUS = (INNER_RADIUS + OUTER_RADIUS) / 2; const segments = $derived( buildDonutSegments(nonPendingGrades, counters, getTaskGradeColor, getTaskGradeLabel), @@ -70,23 +70,44 @@ {/if} {#each segments as seg (seg.grade)} - {#if seg.pct / 100 >= MIN_LABEL_PCT} - {@const labelX = CX + LABEL_RADIUS * Math.cos(seg.midAngle)} - {@const labelY = CY + LABEL_RADIUS * Math.sin(seg.midAngle)} - {@const anchor = Math.cos(seg.midAngle) >= 0 ? 'start' : 'end'} + {@const lx = CX + RING_MID_RADIUS * Math.cos(seg.midAngle)} + {@const ly = CY + RING_MID_RADIUS * Math.sin(seg.midAngle)} + {#if seg.pct >= 10} {seg.label}{seg.label} ({seg.count}票, {seg.pct}%){seg.count}票 {seg.pct}% + {:else if seg.pct >= 5} + {seg.label} {/if} {/each} From c945a06e164e243ff742811021e0c5940b93b96e Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Wed, 1 Apr 2026 16:01:40 +0900 Subject: [PATCH 09/54] feat: apply metallic gradient to D6 segment in donut chart Co-Authored-By: Claude Sonnet 4.6 --- .../votes/components/VoteDonutChart.svelte | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/features/votes/components/VoteDonutChart.svelte b/src/features/votes/components/VoteDonutChart.svelte index 4ab6f13f0..bd31f08e8 100644 --- a/src/features/votes/components/VoteDonutChart.svelte +++ b/src/features/votes/components/VoteDonutChart.svelte @@ -1,6 +1,6 @@ - + 投票分布 +
+ + 投票を変更する + +
+ {@render voteForm()} +
+ + - - -
- - 投票を変更する - -
- {@render voteForm()} -
-
{:else if data.isLoggedIn && !data.isAtCoderVerified}
Date: Thu, 2 Apr 2026 01:06:25 +0900 Subject: [PATCH 14/54] feat: enlarge donut chart size and ring thickness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OUTER_RADIUS 90→120, INNER_RADIUS 55→70 (ring width 35→50) - CX/CY 130→160/155, viewBox 260×275→320×310 - max-w-md→max-w-lg - Scale up font sizes proportionally Co-Authored-By: Claude Sonnet 4.6 --- .../votes/components/VoteDonutChart.svelte | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/features/votes/components/VoteDonutChart.svelte b/src/features/votes/components/VoteDonutChart.svelte index be7137bae..1a6ec2f93 100644 --- a/src/features/votes/components/VoteDonutChart.svelte +++ b/src/features/votes/components/VoteDonutChart.svelte @@ -15,10 +15,10 @@ } let { counters, totalVotes, medianGrade = null, votedGrade = null }: Props = $props(); - const CX = 130; - const CY = 130; - const OUTER_RADIUS = 90; - const INNER_RADIUS = 55; + const CX = 160; + const CY = 155; + const OUTER_RADIUS = 120; + const INNER_RADIUS = 70; const RING_MID_RADIUS = (INNER_RADIUS + OUTER_RADIUS) / 2; const segments = $derived( @@ -26,7 +26,7 @@ ); - + 投票分布 -
+

From 2dd52b0f3513d7b2bf973eaaf074d03734a95232 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Thu, 2 Apr 2026 01:29:16 +0900 Subject: [PATCH 17/54] feat: remove voted status text (replaced by checkmark in donut chart) Co-Authored-By: Claude Sonnet 4.6 --- src/routes/votes/[slug]/+page.svelte | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte index 596d379eb..0c575cbb8 100644 --- a/src/routes/votes/[slug]/+page.svelte +++ b/src/routes/votes/[slug]/+page.svelte @@ -2,8 +2,6 @@ import { enhance } from '$app/forms'; import { resolve } from '$app/paths'; import { Button } from 'flowbite-svelte'; - import Check from '@lucide/svelte/icons/check'; - import GradeLabel from '$lib/components/GradeLabel.svelte'; import { TaskGrade } from '$lib/types/task'; @@ -58,11 +56,6 @@ {#if data.myVote?.voted}

-

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

- {#if data.stats}

暫定グレード:{getTaskGradeLabel(data.stats.grade)}({totalVotes}票) From 50d647209411d64e17d8fe85aa4ca4f1d19f89a3 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Thu, 2 Apr 2026 01:30:07 +0900 Subject: [PATCH 18/54] feat: open contest page in new tab when clicking title Co-Authored-By: Claude Sonnet 4.6 --- src/routes/votes/[slug]/+page.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte index 0c575cbb8..4b9d16462 100644 --- a/src/routes/votes/[slug]/+page.svelte +++ b/src/routes/votes/[slug]/+page.svelte @@ -43,6 +43,7 @@ {data.task.title} From 02185c6616171455b51f7f91294c15d9f110a59d Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Thu, 2 Apr 2026 01:42:31 +0900 Subject: [PATCH 19/54] feat: display vote buttons in two rows (Q-tier and D-tier) Co-Authored-By: Claude Sonnet 4.6 --- src/features/votes/utils/grade_options.ts | 6 ++++ src/routes/votes/[slug]/+page.svelte | 44 ++++++++++++----------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/features/votes/utils/grade_options.ts b/src/features/votes/utils/grade_options.ts index 57f443244..3fbee600c 100644 --- a/src/features/votes/utils/grade_options.ts +++ b/src/features/votes/utils/grade_options.ts @@ -2,3 +2,9 @@ import { taskGradeValues, TaskGrade } from '$lib/types/task'; /** All grade values except PENDING, used for vote buttons and distribution display. */ export const nonPendingGrades = taskGradeValues.filter((grade) => grade !== TaskGrade.PENDING); + +/** Q-tier grades (11Q–1Q), used for the first row of vote buttons. */ +export const qGrades = nonPendingGrades.filter((grade) => grade.startsWith('Q')); + +/** D-tier grades (1D–6D), used for the second row of vote buttons. */ +export const dGrades = nonPendingGrades.filter((grade) => grade.startsWith('D')); diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte index 4b9d16462..e5e3c1df7 100644 --- a/src/routes/votes/[slug]/+page.svelte +++ b/src/routes/votes/[slug]/+page.svelte @@ -11,7 +11,7 @@ getTaskGradeColor, toChangeTextColorIfNeeds, } from '$lib/utils/task'; - import { nonPendingGrades } from '$features/votes/utils/grade_options'; + import { qGrades, dGrades } from '$features/votes/utils/grade_options'; import { SIGNUP_PAGE, LOGIN_PAGE, EDIT_PROFILE_PAGE } from '$lib/constants/navbar-links'; import VoteDonutChart from '$features/votes/components/VoteDonutChart.svelte'; @@ -112,25 +112,29 @@ {#snippet voteForm()}

-
- {#each nonPendingGrades as grade (grade)} - +
+ {#each [qGrades, dGrades] as row, i (i)} +
+ {#each row as grade (grade)} + + {/each} +
{/each}
From 0310dc67c42eb8500e3d2ebeb2bc26cc863ffcd7 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Thu, 2 Apr 2026 01:47:15 +0900 Subject: [PATCH 20/54] feat: unify vote button width with w-12 Co-Authored-By: Claude Sonnet 4.6 --- src/routes/votes/[slug]/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte index e5e3c1df7..cfc9baa81 100644 --- a/src/routes/votes/[slug]/+page.svelte +++ b/src/routes/votes/[slug]/+page.svelte @@ -120,7 +120,7 @@ name="grade" value={grade} type="submit" - class="px-3 py-1.5 rounded-md text-sm font-medium border transition-opacity + class="w-12 py-1.5 rounded-md text-sm font-medium border transition-opacity text-center {grade === TaskGrade.D6 ? 'text-white shadow-md shadow-amber-900/80 ring-2 ring-amber-300/50 font-bold drop-shadow relative overflow-hidden' : toChangeTextColorIfNeeds(getTaskGradeLabel(grade))} From 6072b3059c9f507e358203ebb1e3fcf54ae7b742 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Thu, 2 Apr 2026 01:48:56 +0900 Subject: [PATCH 21/54] feat: always show vote form without details toggle Co-Authored-By: Claude Sonnet 4.6 --- src/routes/votes/[slug]/+page.svelte | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte index cfc9baa81..a689096d7 100644 --- a/src/routes/votes/[slug]/+page.svelte +++ b/src/routes/votes/[slug]/+page.svelte @@ -64,16 +64,9 @@ {/if} -
- - 投票を変更する - -
- {@render voteForm()} -
-
+
+ {@render voteForm()} +
Date: Thu, 2 Apr 2026 12:28:10 +0900 Subject: [PATCH 22/54] feat: replace provisional grade text with flask icon tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove footnote paragraph and "暫定グレード:" text - Show flask icon left of grade icon when stats (provisional grade) exist - Tooltip on flask explains the provisional grade rule Co-Authored-By: Claude Sonnet 4.6 --- src/routes/votes/[slug]/+page.svelte | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte index a689096d7..539ec5809 100644 --- a/src/routes/votes/[slug]/+page.svelte +++ b/src/routes/votes/[slug]/+page.svelte @@ -2,6 +2,7 @@ import { enhance } from '$app/forms'; import { resolve } from '$app/paths'; import { Button } from 'flowbite-svelte'; + import FlaskConical from '@lucide/svelte/icons/flask-conical'; import GradeLabel from '$lib/components/GradeLabel.svelte'; import { TaskGrade } from '$lib/types/task'; @@ -33,6 +34,14 @@
+ {#if data.stats} + + + + {/if}
-

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

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

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

- {/if} -
{@render voteForm()} From 0c9382cb23a32abb8bfc5014cc7f3b601c6dca79 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Thu, 2 Apr 2026 14:30:30 +0900 Subject: [PATCH 23/54] feat: hide flask icon when grade is confirmed (non-PENDING) Co-Authored-By: Claude Sonnet 4.6 --- src/routes/votes/[slug]/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte index 539ec5809..f0788278e 100644 --- a/src/routes/votes/[slug]/+page.svelte +++ b/src/routes/votes/[slug]/+page.svelte @@ -34,7 +34,7 @@
- {#if data.stats} + {#if data.stats && data.task.grade === TaskGrade.PENDING} Date: Thu, 2 Apr 2026 14:31:49 +0900 Subject: [PATCH 24/54] feat: replace title attribute with Flowbite Tooltip on flask icon Co-Authored-By: Claude Sonnet 4.6 --- src/routes/votes/[slug]/+page.svelte | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte index f0788278e..8cae493f9 100644 --- a/src/routes/votes/[slug]/+page.svelte +++ b/src/routes/votes/[slug]/+page.svelte @@ -1,7 +1,7 @@
@@ -42,12 +46,7 @@ 3票以上集まると中央値が暫定グレードとして一覧表に反映されます。 {/if} - +

Date: Thu, 2 Apr 2026 21:22:09 +0900 Subject: [PATCH 27/54] feat: overhaul /votes list page per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Navbar: グレード投票 → 投票 - Default: show empty state (search required) to reduce initial load - Search: limit to 20 results, sorted by task_id desc (newer first) - Table: outer border + rounded corners, lighter row dividers - Column order: グレード | 問題名 | 出典 | 票数 - Grade column: flask icon for provisional grades, "-" when no grade - 問題名: add external link icon to problem page - 出典: use getContestNameLabel helper - service: add grade field to TaskWithVoteInfo, sort desc Co-Authored-By: Claude Sonnet 4.6 --- .../votes/services/vote_statistics.ts | 5 +- src/lib/constants/navbar-links.ts | 2 +- src/routes/votes/+page.svelte | 148 ++++++++++++------ 3 files changed, 101 insertions(+), 54 deletions(-) diff --git a/src/features/votes/services/vote_statistics.ts b/src/features/votes/services/vote_statistics.ts index b7516ae1a..496ad1536 100644 --- a/src/features/votes/services/vote_statistics.ts +++ b/src/features/votes/services/vote_statistics.ts @@ -6,6 +6,8 @@ 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; estimatedGrade: TaskGrade | null; voteTotal: number; }; @@ -22,7 +24,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 +39,7 @@ export async function getAllTasksWithVoteInfo(): Promise { task_id: task.task_id, contest_id: task.contest_id, title: task.title, + grade: task.grade, estimatedGrade: statsMap.get(task.task_id)?.grade ?? null, voteTotal: totalsMap.get(task.task_id) ?? 0, })); 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/routes/votes/+page.svelte b/src/routes/votes/+page.svelte index 8646f0e12..7a634c0c5 100644 --- a/src/routes/votes/+page.svelte +++ b/src/routes/votes/+page.svelte @@ -9,74 +9,118 @@ TableHeadCell, Input, } 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 { getTaskUrl } from '$lib/utils/task'; + import { getContestNameLabel } from '$lib/utils/contest'; + + 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()), - ), + ? [] + : data.tasks + .filter( + (t) => + (t.title ?? '').toLowerCase().includes(search.toLowerCase()) || + (t.task_id ?? '').toLowerCase().includes(search.toLowerCase()) || + (t.contest_id ?? '').toLowerCase().includes(search.toLowerCase()), + ) + .slice(0, 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 isProvisional = + task.grade === TaskGrade.PENDING && task.estimatedGrade !== null} + {@const displayGrade = + task.grade !== TaskGrade.PENDING ? task.grade : (task.estimatedGrade ?? task.grade)} + + +
+ {#if isProvisional} + + + + {/if} + {#if displayGrade !== TaskGrade.PENDING} + + {:else} + - + {/if} +
+
+ + + + {getContestNameLabel(task.contest_id)} + {task.voteTotal} +
+ {/each} + {#if filteredTasks.length === 0} + + + 該当する問題が見つかりませんでした + + + {/if} + {/if} +
+
+
From 8d7ec4038cd1fc5c713b1fa098c424a6db56aa4b Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Fri, 3 Apr 2026 08:27:40 +0900 Subject: [PATCH 28/54] fix: explicitly sort search results by task_id descending on client side Co-Authored-By: Claude Sonnet 4.6 --- src/routes/votes/+page.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/votes/+page.svelte b/src/routes/votes/+page.svelte index 7a634c0c5..8fe677eb7 100644 --- a/src/routes/votes/+page.svelte +++ b/src/routes/votes/+page.svelte @@ -35,6 +35,7 @@ (t.task_id ?? '').toLowerCase().includes(search.toLowerCase()) || (t.contest_id ?? '').toLowerCase().includes(search.toLowerCase()), ) + .sort((a, b) => b.task_id.localeCompare(a.task_id)) .slice(0, MAX_SEARCH_RESULTS), ); From ab92440344bb00a59d2c2e96fb26d0e136ceb70e Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Fri, 3 Apr 2026 08:37:00 +0900 Subject: [PATCH 29/54] fix: sort search results by contest_id desc instead of task_id task_id includes the problem letter suffix (_a, _b, ...) and localeCompare is locale-dependent. contest_id comparison is consistent with the existing compareByContestIdAndTaskId helper. Co-Authored-By: Claude Sonnet 4.6 --- src/routes/votes/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/votes/+page.svelte b/src/routes/votes/+page.svelte index 8fe677eb7..f36ffd2ad 100644 --- a/src/routes/votes/+page.svelte +++ b/src/routes/votes/+page.svelte @@ -35,7 +35,7 @@ (t.task_id ?? '').toLowerCase().includes(search.toLowerCase()) || (t.contest_id ?? '').toLowerCase().includes(search.toLowerCase()), ) - .sort((a, b) => b.task_id.localeCompare(a.task_id)) + .sort((a, b) => (b.contest_id > a.contest_id ? 1 : b.contest_id < a.contest_id ? -1 : 0)) .slice(0, MAX_SEARCH_RESULTS), ); From 52d507c25acf2aac26b383e21e6f9c607f8ddc91 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Fri, 3 Apr 2026 08:43:39 +0900 Subject: [PATCH 30/54] fix: sort votes list using compareByContestIdAndTaskId helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add task_table_index to TaskWithVoteInfo so the existing helper can be used. This matches the grade-based view sort order: contest type priority → contest_id desc → task_table_index asc. Co-Authored-By: Claude Sonnet 4.6 --- .../votes/services/vote_statistics.test.ts | 51 +++++++++++++++++-- .../votes/services/vote_statistics.ts | 3 ++ src/routes/votes/+page.svelte | 6 ++- 3 files changed, 55 insertions(+), 5 deletions(-) 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 496ad1536..9149fc664 100644 --- a/src/features/votes/services/vote_statistics.ts +++ b/src/features/votes/services/vote_statistics.ts @@ -8,6 +8,8 @@ export type TaskWithVoteInfo = { 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; }; @@ -40,6 +42,7 @@ export async function getAllTasksWithVoteInfo(): Promise { 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/routes/votes/+page.svelte b/src/routes/votes/+page.svelte index f36ffd2ad..5e95fbaef 100644 --- a/src/routes/votes/+page.svelte +++ b/src/routes/votes/+page.svelte @@ -18,6 +18,8 @@ import { TaskGrade } from '$lib/types/task'; import { getTaskUrl } from '$lib/utils/task'; import { getContestNameLabel } from '$lib/utils/contest'; + import { compareByContestIdAndTaskId } from '$lib/utils/task'; + import type { TaskResult } from '$lib/types/task'; const MAX_SEARCH_RESULTS = 20; @@ -35,7 +37,9 @@ (t.task_id ?? '').toLowerCase().includes(search.toLowerCase()) || (t.contest_id ?? '').toLowerCase().includes(search.toLowerCase()), ) - .sort((a, b) => (b.contest_id > a.contest_id ? 1 : b.contest_id < a.contest_id ? -1 : 0)) + .sort((a, b) => + compareByContestIdAndTaskId(a as unknown as TaskResult, b as unknown as TaskResult), + ) .slice(0, MAX_SEARCH_RESULTS), ); From 67b38ff0626c4c2a664264b52a1ba1849a6f5b2d Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Fri, 3 Apr 2026 09:06:36 +0900 Subject: [PATCH 31/54] feat: show flask icon next to provisional grade in contest table Grade button itself is unchanged; flask appears to its right inside an inline-flex wrapper. Visible only when task grade is PENDING and an estimated grade from votes is being displayed. Co-Authored-By: Claude Sonnet 4.6 --- .../votes/components/VotableGrade.svelte | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/src/features/votes/components/VotableGrade.svelte b/src/features/votes/components/VotableGrade.svelte index a42e50102..d3deea575 100644 --- a/src/features/votes/components/VotableGrade.svelte +++ b/src/features/votes/components/VotableGrade.svelte @@ -5,6 +5,7 @@ import { Dropdown, DropdownItem, DropdownDivider } from 'flowbite-svelte'; import Check from '@lucide/svelte/icons/check'; + import FlaskConical from '@lucide/svelte/icons/flask-conical'; import { TaskGrade, getTaskGrade, type TaskResult } from '$lib/types/task'; import { getTaskGradeLabel } from '$lib/utils/task'; @@ -43,6 +44,10 @@ let showForm = $state(false); let formElement = $state(undefined); + const isProvisional = $derived( + taskResult.grade === TaskGrade.PENDING && displayGrade !== TaskGrade.PENDING, + ); + let isOpening = $state(false); let votedGrade = $state(null); @@ -137,22 +142,28 @@ - +
+ + + {#if isProvisional} + + {/if} +
{#if isLoggedIn && isAtCoderVerified !== false} From 0cfdb09fb9a01cbbc755102d3ead7452239e3629 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Fri, 3 Apr 2026 09:16:50 +0900 Subject: [PATCH 32/54] fix: address CodeRabbit findings (round 1) - Widen compareByContestIdAndTaskId signature to accept any object with contest_id/task_table_index, removing double cast in +page.svelte - Replace find() loop with Map in buildDonutSegments (O(n+m)) - Extract segLabel {@const} in VoteDonutChart to remove duplicate ternary - Add aria-label to FlaskConical in VotableGrade - Add aria-label to external link icon in votes list page - Add qGrades/dGrades tests to grade_options.test.ts Co-Authored-By: Claude Sonnet 4.6 --- .../votes/components/VotableGrade.svelte | 5 ++- .../votes/components/VoteDonutChart.svelte | 5 ++- src/features/votes/utils/donut_chart.ts | 3 +- .../votes/utils/grade_options.test.ts | 40 ++++++++++++++++++- src/lib/utils/task.ts | 5 ++- src/routes/votes/+page.svelte | 8 ++-- 6 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/features/votes/components/VotableGrade.svelte b/src/features/votes/components/VotableGrade.svelte index d3deea575..216f68983 100644 --- a/src/features/votes/components/VotableGrade.svelte +++ b/src/features/votes/components/VotableGrade.svelte @@ -161,7 +161,10 @@ {#if isProvisional} - + {/if}

diff --git a/src/features/votes/components/VoteDonutChart.svelte b/src/features/votes/components/VoteDonutChart.svelte index 86f055b1d..fd0457a69 100644 --- a/src/features/votes/components/VoteDonutChart.svelte +++ b/src/features/votes/components/VoteDonutChart.svelte @@ -84,6 +84,7 @@ {#each segments as seg (seg.grade)} {@const lx = CX + RING_MID_RADIUS * Math.cos(seg.midAngle)} {@const ly = CY + RING_MID_RADIUS * Math.sin(seg.midAngle)} + {@const segLabel = seg.grade === votedGrade ? `✅ ${seg.label}` : seg.label} {#if seg.pct >= 10} {seg.grade === votedGrade ? `✅ ${seg.label}` : seg.label}{segLabel} {seg.grade === votedGrade ? `✅ ${seg.label}` : seg.label}{segLabel} {/if} {/each} diff --git a/src/features/votes/utils/donut_chart.ts b/src/features/votes/utils/donut_chart.ts index 2afe08b1a..a0b35f669 100644 --- a/src/features/votes/utils/donut_chart.ts +++ b/src/features/votes/utils/donut_chart.ts @@ -40,11 +40,12 @@ export function buildDonutSegments( return []; } + const countMap = new Map(counters.map((c) => [c.grade, c.count])); let cumulative = 0; const segments: DonutSegment[] = []; for (const grade of grades) { - const count = counters.find((c) => c.grade === grade)?.count ?? 0; + const count = countMap.get(grade) ?? 0; if (count === 0) { continue; } diff --git a/src/features/votes/utils/grade_options.test.ts b/src/features/votes/utils/grade_options.test.ts index 0e76702b0..fc9d59897 100644 --- a/src/features/votes/utils/grade_options.test.ts +++ b/src/features/votes/utils/grade_options.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { TaskGrade, taskGradeValues } from '$lib/types/task'; -import { nonPendingGrades } from './grade_options'; +import { nonPendingGrades, qGrades, dGrades } from './grade_options'; describe('nonPendingGrades', () => { it('does not include PENDING', () => { @@ -19,3 +19,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/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/routes/votes/+page.svelte b/src/routes/votes/+page.svelte index 5e95fbaef..dcb65cfd9 100644 --- a/src/routes/votes/+page.svelte +++ b/src/routes/votes/+page.svelte @@ -19,7 +19,6 @@ import { getTaskUrl } from '$lib/utils/task'; import { getContestNameLabel } from '$lib/utils/contest'; import { compareByContestIdAndTaskId } from '$lib/utils/task'; - import type { TaskResult } from '$lib/types/task'; const MAX_SEARCH_RESULTS = 20; @@ -37,9 +36,7 @@ (t.task_id ?? '').toLowerCase().includes(search.toLowerCase()) || (t.contest_id ?? '').toLowerCase().includes(search.toLowerCase()), ) - .sort((a, b) => - compareByContestIdAndTaskId(a as unknown as TaskResult, b as unknown as TaskResult), - ) + .sort((a, b) => compareByContestIdAndTaskId(a, b)) .slice(0, MAX_SEARCH_RESULTS), ); @@ -107,9 +104,10 @@ href={getTaskUrl(task.contest_id, task.task_id)} target="_blank" rel="noreferrer external" + aria-label={`${task.title} を別タブで開く`} class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 shrink-0" > - +
From c22586fee0b06e7253da73871e39264ee92f9a6b Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sat, 4 Apr 2026 01:35:36 +0000 Subject: [PATCH 33/54] fix(votes): handle full-circle arc segment via dual-arc split --- .../2026-04-04/pr-3351-votes/plan.md | 778 ++++++++++++++++++ .../2026-04-04/pr-3351-votes/review.md | 36 + src/features/votes/utils/donut_chart.test.ts | 17 +- src/features/votes/utils/donut_chart.ts | 60 +- 4 files changed, 872 insertions(+), 19 deletions(-) create mode 100644 docs/dev-notes/2026-04-04/pr-3351-votes/plan.md create mode 100644 docs/dev-notes/2026-04-04/pr-3351-votes/review.md diff --git a/docs/dev-notes/2026-04-04/pr-3351-votes/plan.md b/docs/dev-notes/2026-04-04/pr-3351-votes/plan.md new file mode 100644 index 000000000..511abd23a --- /dev/null +++ b/docs/dev-notes/2026-04-04/pr-3351-votes/plan.md @@ -0,0 +1,778 @@ +# PR #3351 レビュー対応 実装プラン + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** PR #3351 の AI レビュー指摘(Critical×2, Major×3, Minor×3)と目視 UI 調整(3件)をすべて解消する + +**Architecture:** 既存の votes 機能ファイル群への局所修正。新ファイルは追加しない。`grade_options.ts` に純粋関数 1 件を追加してロジック重複を解消する。 + +**Tech Stack:** SvelteKit 2 + Svelte 5 Runes, Flowbite Svelte, TypeScript, Vitest + +--- + +## 概要・設計方針 + +| # | 指摘 | 分類 | 対応方針 | +| ---- | ------------------------------------------------ | -------- | ---------------------------------------------------- | +| 1 | SVG arc 100% セグメント描画不可 | Critical | `arcPath()` でフルサークル検出 → デュアルアーク分割 | +| 2 | ナビバー「投票」 vs パンくず「グレード投票」 | Critical | パンくずを「投票」に統一 | +| 3 | 空リング半径計算誤り | Major | `r={OUTER_RADIUS}` → `r={RING_MID_RADIUS}` | +| 4 | PENDING フォールバックがルートテンプレートに存在 | Major | `resolveDisplayGrade()` を `grade_options.ts` に抽出 | +| 5 | フラスコアイコンのツールチップがキーボード非対応 | Major | `votes/+page.svelte` を Flowbite `` に置換 | +| 6 | 外部リンクに `noopener` 未設定 | Minor | `rel` に `noopener` を追加 | +| 7 | 仮グレードロジック重複 | Minor | Task 4(`resolveDisplayGrade` 抽出)で同時解消 | +| 8 | TSDoc coverage 未達 | Minor | 追加された関数と既存 exports に TSDoc 補完 | +| UI-1 | テーブルのヘッダー+本文フォントが小さい | UI | Flowbite override クラスでサイズアップ | +| UI-2 | グレードアイコンが小さい | UI | `GradeLabel` props をデフォルトサイズに戻す | +| UI-3 | フラスコアイコンがグレードの右にある | UI | `VotableGrade.svelte` で順序入れ替え | + +## 却下した代替案 + +- **Task 1 の epsilon 丸め**: `endAngle -= 0.0001` で SVG の問題を回避するアプローチ。形状が微妙にずれるため却下。デュアルアークが標準解。 +- **Task 4 の `+page.svelte` 内 inline 関数化**: Svelte の ` 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..349d0cfdb --- /dev/null +++ b/src/test/lib/utils/task_filter.test.ts @@ -0,0 +1,56 @@ +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' }, +]; + +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 (e.g. "ABC 300")', () => { + // getContestNameLabel('abc300') => 'ABC 300' + const result = filterTasksBySearch(tasks, 'ABC 300', 20); + expect(result).toHaveLength(1); + expect(result[0].task_id).toBe('abc300_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('returns all matched results when count is less than limit', () => { + const result = filterTasksBySearch(tasks, 'arc', 20); + expect(result).toHaveLength(1); + }); +}); From cb793eb11ab514355e50fc7ab0c36e6fa8afae16 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sat, 4 Apr 2026 08:03:03 +0000 Subject: [PATCH 53/54] fix(votes): sort tasks before filtering to preserve stable order Previously sort() was applied after filter(), which mutated a derived array on every keystroke. Pre-sort via sortedTasks derived state ensures stable order regardless of search input. Also strengthen test: use typical90 fixture to isolate contest name label matching, and add order-preservation test. Co-Authored-By: Claude Sonnet 4.6 --- src/routes/votes/+page.svelte | 5 ++--- src/test/lib/utils/task_filter.test.ts | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/routes/votes/+page.svelte b/src/routes/votes/+page.svelte index bbe3297cf..fd81136ed 100644 --- a/src/routes/votes/+page.svelte +++ b/src/routes/votes/+page.svelte @@ -29,9 +29,8 @@ let search = $state(''); - const filteredTasks = $derived( - filterTasksBySearch(data.tasks, search, MAX_SEARCH_RESULTS).sort(compareByContestIdAndTaskId), - ); + const sortedTasks = $derived([...data.tasks].sort(compareByContestIdAndTaskId)); + const filteredTasks = $derived(filterTasksBySearch(sortedTasks, search, MAX_SEARCH_RESULTS));
diff --git a/src/test/lib/utils/task_filter.test.ts b/src/test/lib/utils/task_filter.test.ts index 349d0cfdb..53188924c 100644 --- a/src/test/lib/utils/task_filter.test.ts +++ b/src/test/lib/utils/task_filter.test.ts @@ -7,6 +7,8 @@ const tasks = [ { 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', () => { @@ -32,11 +34,11 @@ describe('filterTasksBySearch', () => { expect(result[0].task_id).toBe('agc060_c'); }); - test('matches by contest name label (e.g. "ABC 300")', () => { - // getContestNameLabel('abc300') => 'ABC 300' - const result = filterTasksBySearch(tasks, 'ABC 300', 20); + 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('abc300_a'); + expect(result[0].task_id).toBe('typical90_a'); }); test('returns empty array when no tasks match', () => { @@ -49,6 +51,14 @@ describe('filterTasksBySearch', () => { 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((t) => t.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); From 6cd434d30936dcb15a67c38fc5836d8d92c8eb25 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sat, 4 Apr 2026 08:04:52 +0000 Subject: [PATCH 54/54] chore: fix typo --- src/test/lib/utils/task_filter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/lib/utils/task_filter.test.ts b/src/test/lib/utils/task_filter.test.ts index 53188924c..1d3893da5 100644 --- a/src/test/lib/utils/task_filter.test.ts +++ b/src/test/lib/utils/task_filter.test.ts @@ -56,7 +56,7 @@ describe('filterTasksBySearch', () => { // 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((t) => t.task_id)).toEqual(['abc300_a', 'abc301_a', 'abc302_a']); + 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', () => {