Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
7615f0e
feat: add SVG donut chart utility functions
river0525 Apr 1, 2026
cdc8cce
feat: add VoteDonutChart SVG donut chart component
river0525 Apr 1, 2026
28ff243
feat: replace bar chart with donut chart on vote detail page
river0525 Apr 1, 2026
aa52a72
feat: add median grade indicator line to donut chart
river0525 Apr 1, 2026
d896cdd
fix: draw median line using cumulative arc angle instead of segment l…
river0525 Apr 1, 2026
a3cd341
refactor: simplify median line to fixed bottom position
river0525 Apr 1, 2026
1f0e1db
feat: change median line to black and add label
river0525 Apr 1, 2026
f170641
feat: move grade labels inside donut ring segments
river0525 Apr 1, 2026
c945a06
feat: apply metallic gradient to D6 segment in donut chart
river0525 Apr 1, 2026
f63f3ef
fix: use objectBoundingBox gradient for D6 metallic effect
river0525 Apr 1, 2026
c379548
fix: remove arrow prefix from median label
river0525 Apr 1, 2026
1d90d0e
feat: enlarge donut chart and show checkmark on voted grade segment
river0525 Apr 1, 2026
25bf03c
feat: move vote change form above donut chart
river0525 Apr 1, 2026
2e989af
feat: enlarge donut chart size and ring thickness
river0525 Apr 1, 2026
0f50ab9
feat: increase donut ring width (INNER_RADIUS 70→50)
river0525 Apr 1, 2026
cb962e2
feat: place grade icon left of title and link title to contest page
river0525 Apr 1, 2026
2dd52b0
feat: remove voted status text (replaced by checkmark in donut chart)
river0525 Apr 1, 2026
50d6472
feat: open contest page in new tab when clicking title
river0525 Apr 1, 2026
02185c6
feat: display vote buttons in two rows (Q-tier and D-tier)
river0525 Apr 1, 2026
0310dc6
feat: unify vote button width with w-12
river0525 Apr 1, 2026
6072b30
feat: always show vote form without details toggle
river0525 Apr 1, 2026
5e0f8c0
feat: replace provisional grade text with flask icon tooltip
river0525 Apr 2, 2026
0c9382c
feat: hide flask icon when grade is confirmed (non-PENDING)
river0525 Apr 2, 2026
02c46ed
feat: replace title attribute with Flowbite Tooltip on flask icon
river0525 Apr 2, 2026
51a1806
feat: show provisional grade in grade icon when task grade is PENDING
river0525 Apr 2, 2026
0fde56f
fix: show provisional grade only when task grade is PENDING
river0525 Apr 2, 2026
409b10d
feat: overhaul /votes list page per review feedback
river0525 Apr 2, 2026
8d7ec40
fix: explicitly sort search results by task_id descending on client side
river0525 Apr 2, 2026
ab92440
fix: sort search results by contest_id desc instead of task_id
river0525 Apr 2, 2026
52d507c
fix: sort votes list using compareByContestIdAndTaskId helper
river0525 Apr 2, 2026
67b38ff
feat: show flask icon next to provisional grade in contest table
river0525 Apr 3, 2026
0cfdb09
fix: address CodeRabbit findings (round 1)
river0525 Apr 3, 2026
7b6ab11
Merge branch 'staging' of github.com:AtCoder-NoviSteps/AtCoderNoviSte…
KATO-Hiro Apr 4, 2026
c22586f
fix(votes): handle full-circle arc segment via dual-arc split
KATO-Hiro Apr 4, 2026
e7a0839
fix(votes): use ring midpoint radius and update arcPath center arg
KATO-Hiro Apr 4, 2026
1a0e5b8
refactor(votes): extract resolveDisplayGrade to grade_options utility
KATO-Hiro Apr 4, 2026
92f32eb
fix(votes): make flask icon tooltip keyboard-accessible via Flowbite …
KATO-Hiro Apr 4, 2026
72a18a4
fix(votes): add noopener to external link rel attributes
KATO-Hiro Apr 4, 2026
dc204ef
fix(votes): unify nav label from 'グレード投票' to '投票' in breadcrumb
KATO-Hiro Apr 4, 2026
7a141be
style(votes): increase table header and body font size
KATO-Hiro Apr 4, 2026
5594ce0
style(votes): use default GradeLabel size to match workbooks page
KATO-Hiro Apr 4, 2026
06f67fa
style(votes): move flask icon to the left of grade label in VotableGrade
KATO-Hiro Apr 4, 2026
5c10032
docs(votes): add TSDoc to grade_options exports
KATO-Hiro Apr 4, 2026
8f465e0
refactor: strengthen Task.grade from string to TaskGrade domain type
KATO-Hiro Apr 4, 2026
3998e90
docs: Add rule
KATO-Hiro Apr 4, 2026
5e1278d
docs: update plan
KATO-Hiro Apr 4, 2026
5dec1f4
refactor(votes): apply code review findings to donut chart implementa…
KATO-Hiro Apr 4, 2026
98793da
docs(rules): capture lessons from votes donut chart refactor
KATO-Hiro Apr 4, 2026
add1ef6
docs(votes): convert plan.md to completion memo and remove review.md
KATO-Hiro Apr 4, 2026
a4282b5
chore: Fix format
KATO-Hiro Apr 4, 2026
471fbf9
refactor(votes): remove redundant PENDING branch in grade display
KATO-Hiro Apr 4, 2026
9ad09ac
fix(votes): improve accessibility and search usability
KATO-Hiro Apr 4, 2026
109782c
refactor(votes): extract filterTasksBySearch utility with contest nam…
KATO-Hiro Apr 4, 2026
cb793eb
fix(votes): sort tasks before filtering to preserve stable order
KATO-Hiro Apr 4, 2026
6cd434d
chore: fix typo
KATO-Hiro Apr 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions .claude/rules/coding-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.

Expand Down Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions .claude/rules/svelte-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ Define snippets at the **top level**, outside component tags. Inside a tag = nam
<Dialog>{#snippet footer()}...{/snippet}</Dialog>
```

## `{#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
<!-- Bad: implicit any → svelte-check error -->
{#snippet segmentLabel(segment)}

<!-- Good -->
{#snippet segmentLabel(segment: DonutSegment)}
```

Import the type in `<script lang="ts">` as usual.

## Snippet vs Component

Prefer `{#snippet}` when: (1) needs direct `$state` access, (2) pure display only, (3) same-file DRY.
Expand Down
6 changes: 4 additions & 2 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ E2E test files must use the `.spec.ts` extension. `playwright.config.ts` matches
- For DB query tests, assert `orderBy`, `include`, and other significant parameters with `expect.objectContaining` — not just `where`. When a returned field (e.g. `authorName`) depends on an `include` relation, that `include` clause must be part of the assertion, or a regression in the query shape will go undetected
- Enum membership: `in` traverses the prototype chain; use `Object.hasOwn(Enum, value)` instead

## Test Stubs

Test stub parameter types must match the production function's signature — use domain types (e.g. `TaskGrade`), not `string`; a mismatch compiles silently but lets the stub accept inputs the real function would reject.

## Test Data

- Use realistic fixture values (real task IDs, grade names) instead of placeholders like `'t1'`
Expand Down Expand Up @@ -118,8 +122,6 @@ try {
}
```

This is not needed for standard service unit tests that use Prisma mocks.

### File Split for Testability

When a service file mixes DB operations and pure functions, split it into two files:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { TaskResult, TaskResults } from '$lib/types/task';
import { TaskGrade, type TaskResult, type TaskResults } from '$lib/types/task';

// Default task result with minimal initialization.
// Most fields are empty strings as they're not relevant for these tests.
Expand Down Expand Up @@ -26,7 +26,7 @@ const defaultTaskResult: Readonly<TaskResult> = {
task_table_index: '',
task_id: '',
title: '',
grade: '',
grade: TaskGrade.PENDING,
updated_at: new Date(0), // Use the Unix epoch as the default value.
};

Expand Down
58 changes: 36 additions & 22 deletions src/features/votes/components/VotableGrade.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@

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';
import { nonPendingGrades } from '$features/votes/utils/grade_options';
import { nonPendingGrades, resolveDisplayGrade } from '$features/votes/utils/grade_options';
import { SIGNUP_PAGE, LOGIN_PAGE, EDIT_PROFILE_PAGE } from '$lib/constants/navbar-links';
import { errorMessageStore } from '$lib/stores/error_message';

Expand All @@ -20,18 +21,15 @@
isLoggedIn: boolean;
// undefined means the prop was not passed — treat as verified to maintain backward compatibility.
isAtCoderVerified?: boolean;
estimatedGrade?: string;
estimatedGrade?: TaskGrade | null;
}

let { taskResult, isLoggedIn, isAtCoderVerified, estimatedGrade }: Props = $props();

// 表示用のグレード(投票後に画面リロードなしで差し替えるためのローカル状態)
// PENDING かつ estimatedGrade(集計済み中央値)があればそれを優先表示。
// DBグレード付与済みの場合はそちらを優先。
const initialGrade =
taskResult.grade === TaskGrade.PENDING
? (estimatedGrade ?? taskResult.grade)
: taskResult.grade;
const initialGrade = resolveDisplayGrade(taskResult.grade, estimatedGrade);
let displayGrade = $state<TaskGrade | string>(initialGrade);

// Use task_id as a deterministic component ID to avoid SSR/hydration mismatches.
Expand All @@ -43,6 +41,10 @@
let showForm = $state(false);
let formElement = $state<HTMLFormElement | undefined>(undefined);

const isProvisional = $derived(
taskResult.grade === TaskGrade.PENDING && displayGrade !== TaskGrade.PENDING,
);

let isOpening = $state(false);
let votedGrade = $state<TaskGrade | null>(null);

Expand Down Expand Up @@ -137,22 +139,34 @@
</script>

<!-- Grade Icon(全問題で投票ドロップダウンを表示) -->
<button
id={`update-grade-dropdown-trigger-${componentId}`}
class="relative group shrink-0 cursor-pointer"
type="button"
tabindex="0"
aria-label="Vote grade"
onclick={() => onTriggerClick()}
>
<GradeLabel taskGrade={displayGrade} defaultPadding={0.25} defaultWidth={6} reducedWidth={6} />

<!-- Overlay -->
<span
aria-hidden="true"
class="pointer-events-none absolute inset-0 rounded-lg bg-gray-200 dark:bg-gray-700 mix-blend-multiply opacity-0 transition-opacity duration-150 group-hover:opacity-100"
></span>
</button>
<div class="inline-flex items-center gap-1">
{#if isProvisional}
<FlaskConical
class="w-3.5 h-3.5 shrink-0 text-gray-400 dark:text-gray-500"
aria-label="暫定グレード"
/>
{/if}

<button
id={`update-grade-dropdown-trigger-${componentId}`}
class="relative group shrink-0 cursor-pointer"
type="button"
tabindex="0"
onclick={() => onTriggerClick()}
>
<span class="sr-only">
Voted grade: {getTaskGradeLabel(displayGrade)}{isProvisional ? ', provisional' : ''}
</span>

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

<!-- Overlay -->
<span
aria-hidden="true"
class="pointer-events-none absolute inset-0 rounded-lg bg-gray-200 dark:bg-gray-700 mix-blend-multiply opacity-0 transition-opacity duration-150 group-hover:opacity-100"
></span>
</button>
</div>

<!-- Dropdown Menu -->
{#if isLoggedIn && isAtCoderVerified !== false}
Expand Down
194 changes: 194 additions & 0 deletions src/features/votes/components/VoteDonutChart.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
<script lang="ts">
import type { VotedGradeCounter } from '@prisma/client';
import { TaskGrade } from '$lib/types/task';
import type { DonutSegment } from '$features/votes/types/donut_graph';

import { getTaskGradeColor, getTaskGradeLabel } from '$lib/utils/task';
import { nonPendingGrades } from '$features/votes/utils/grade_options';
import {
buildDonutSegments,
buildArcPath,
calcPointOnCircle,
} from '$features/votes/utils/donut_chart';

type VotedGradeCounters = VotedGradeCounter[];

interface Props {
counters: VotedGradeCounters;
totalVotes: number;
/** Median grade to indicate with a radial line. Omit when stats are unavailable. */
medianGrade?: TaskGrade | null;
/** The grade the current user voted for. Shows a ✅ on the matching segment. */
votedGrade?: TaskGrade | null;
}
let { counters, totalVotes, medianGrade = null, votedGrade = null }: Props = $props();

const CX = 160;
const CY = 155;
const OUTER_RADIUS = 120;
const INNER_RADIUS = 50;
const RING_MID_RADIUS = (INNER_RADIUS + OUTER_RADIUS) / 2;

const segments = $derived(
buildDonutSegments(nonPendingGrades, counters, getTaskGradeColor, getTaskGradeLabel),
);
</script>

<svg viewBox="0 0 320 310" class="w-full max-w-lg mx-auto" role="img" aria-label="投票分布円グラフ">
<title>投票分布</title>
<defs>
<!-- Metallic gradient for D6 segment, matching the vote button style.
objectBoundingBox ensures the gradient spans the segment itself. -->
{@render metallicGradient()}
</defs>

{#if totalVotes >= 1}
{#each segments as segment (segment.grade)}
{@render segmentPath(segment)}
{/each}

<!-- Median grade indicator line: the chart starts at the top and the median
always falls at the 50% cumulative mark, which is fixed at the bottom. -->
{#if medianGrade}
{@render medianIndicator()}
{/if}

{#each segments as segment (segment.grade)}
{@render segmentLabel(segment)}
{/each}
{:else}
<!-- Show empty ring when there are no votes -->
{@render emptyRing()}
{/if}

{@render totalVotedCount()}
</svg>

{#snippet metallicGradient()}
<linearGradient id="d6-metallic" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#432414" />
<stop offset="40%" stop-color="rgb(120, 113, 108)" />
<stop offset="70%" stop-color="rgb(217, 119, 6)" />
<stop offset="100%" stop-color="#432414" />
</linearGradient>
{/snippet}

{#snippet segmentPath(segment: DonutSegment)}
<path
d={buildArcPath(
{ x: CX, y: CY },
OUTER_RADIUS,
INNER_RADIUS,
segment.startAngle,
segment.endAngle,
)}
fill={segment.grade === TaskGrade.D6 ? 'url(#d6-metallic)' : segment.color}
/>
{/snippet}

{#snippet medianIndicator()}
<line
x1={CX}
y1={CY + INNER_RADIUS}
x2={CX}
y2={CY + OUTER_RADIUS}
stroke="black"
stroke-width="2.5"
stroke-linecap="round"
class="dark:stroke-white"
/>
<text
x={CX}
y={CY + OUTER_RADIUS + 14}
text-anchor="middle"
class="fill-gray-700 dark:fill-gray-300"
font-size="11">中央値</text
>
{/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}
<text
x={labelX}
y={labelY - 7}
text-anchor="middle"
dominant-baseline="middle"
fill="white"
stroke="rgba(0,0,0,0.55)"
stroke-width="2.5"
paint-order="stroke"
font-size="11"
font-weight="bold"
>
{label}
</text>
<text
x={labelX}
y={labelY + 8}
text-anchor="middle"
dominant-baseline="middle"
fill="white"
stroke="rgba(0,0,0,0.55)"
stroke-width="2"
paint-order="stroke"
font-size="9.5"
>
{segment.count}票 ({segment.percentage}%)
</text>
{:else if segment.percentage >= 5}
<text
x={labelX}
y={labelY}
text-anchor="middle"
dominant-baseline="middle"
fill="white"
stroke="rgba(0,0,0,0.55)"
stroke-width="2.5"
paint-order="stroke"
font-size="11"
font-weight="bold"
>
{label}
</text>
{/if}
{/snippet}

{#snippet emptyRing()}
<circle
cx={CX}
cy={CY}
r={RING_MID_RADIUS}
fill="none"
stroke="currentColor"
stroke-width={OUTER_RADIUS - INNER_RADIUS}
class="text-gray-200 dark:text-gray-700"
opacity="0.5"
/>
{/snippet}

{#snippet totalVotedCount()}
<text
x={CX}
y={CY - 6}
text-anchor="middle"
class="fill-gray-800 dark:fill-gray-200"
font-size="28"
font-weight="bold"
>
{totalVotes}
</text>
<text
x={CX}
y={CY + 16}
text-anchor="middle"
class="fill-gray-500 dark:fill-gray-400"
font-size="13"
>
</text>
{/snippet}
Loading
Loading