From 248dfc0ce3d91e53c77260ca0405d7cd4c2174d9 Mon Sep 17 00:00:00 2001 From: johnn Date: Mon, 6 Apr 2026 20:52:45 +0300 Subject: [PATCH 01/12] docs: add quick vote design spec (#212) Brainstormed and documented UI/UX design for synergy modal quick vote feature including state machine, distribution bar, mobile layout, animation specs, and Supabase migration plan. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-04-06-quick-vote-design.md | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-06-quick-vote-design.md diff --git a/docs/superpowers/specs/2026-04-06-quick-vote-design.md b/docs/superpowers/specs/2026-04-06-quick-vote-design.md new file mode 100644 index 0000000..4f08df6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-quick-vote-design.md @@ -0,0 +1,264 @@ +# Synergy Modal Quick Vote — Design Spec (#212) + +**Issue**: [#212](https://github.com/Doberjohn/inkweave/issues/212) +**Epic**: Community Voting (#205) +**Date**: 2026-04-06 + +## Overview + +Add a lightweight three-button voting control to the `SynergyDetailModal`. Users give directional feedback on the algorithm's synergy score — "Should be lower" / "About right" / "Should be higher". After voting, an animated distribution bar reveals how the community voted, creating a social feedback loop. + +**Scope**: Modal UI component, new hook, Supabase migration (distribution columns), localStorage persistence for voted pairs. + +**Out of scope**: "Rate in Detail" navigation (disabled teaser only — enabled by #213), user authentication, vote editing UI. + +## UI Layout + +### Placement + +Below the tier label ("Strong Synergy"), above the connections list. Wrapped in a subtle gold-bordered container (`rgba(212, 175, 55, 0.04)` background, `rgba(212, 175, 55, 0.12)` border). + +### Desktop + +Three buttons in a horizontal row. Full labels: +- "Should be lower" → `accuracy: -1` +- "About right" → `accuracy: 0` +- "Should be higher" → `accuracy: 1` + +Question text above: "Do you agree with this score?" + +Disabled "Rate in detail →" teaser below the buttons. + +### Mobile + +Three buttons in a vertical stack. Same labels, same wording. Full-width, `min-height: 44px` touch targets. To be validated during implementation. + +## State Machine + +``` + ┌──────────────────┐ + │ Hidden │ ← Supabase unavailable + └──────────────────┘ + + Modal opens ──→ localStorage check + │ + ┌───────┴────────┐ + │ │ + not found found + │ │ + ▼ ▼ + ┌──────────┐ ┌──────────────┐ + │ Ready │ │ Result │ ← fetch fresh distribution + └────┬─────┘ └──────────────┘ + │ click + ▼ + ┌──────────┐ + │Submitting│ + └────┬─────┘ + │ + ┌────┴────┐ + │ │ + success error + │ │ + ▼ ▼ +┌────────┐ ┌──────┐ +│ Result │ │Error │ → buttons re-enable → Ready +└────────┘ └──────┘ +``` + +### State details + +| State | Trigger | UI | +|-------|---------|-----| +| **Hidden** | `getSupabase() === null` | Vote section not rendered | +| **Ready** | Modal opens, pair not in localStorage | Question + 3 buttons + disabled "Rate in detail" | +| **Submitting** | User clicks a vote button | Selected button: scale(1.05) + glow + checkmark. Others: opacity 0.3, disabled | +| **Result** | Vote succeeds OR pair in localStorage | "Thanks! You voted: {choice}" + distribution bar + vote count + disabled "Rate in detail" | +| **Error** | Submission fails | Inline "Something went wrong, try again" — buttons re-enable | +| **Rate Limited** | 30 votes/hour exceeded | Inline "You're voting fast! Try again in a bit." — buttons stay disabled | + +## Post-Vote: Distribution Bar + +After voting, the three buttons collapse and are replaced by: + +1. **Confirmation**: "Thanks! You voted: {choice}" with gold star `✦` +2. **Distribution bar**: Three colored segments showing community vote percentages + - Lower: `#f59090` (red, 25% opacity background) + - About right: `#6ee7a0` (green, 20% opacity background) + - Higher: `#60b5f5` (blue, 20% opacity background) +3. **Vote count**: "{N} votes on this pair" +4. **Disabled "Rate in detail →"** teaser + +### First voter + +When total votes = 1 (the user is the first), show: +- Confirmation line (same as above) +- Gold badge: "First to rate this pair!" with `✦` pulse animation +- "Distribution will show after more votes" + +No minimum vote threshold for showing the distribution bar — it shows at 2+ votes. At exactly 1 vote (the user is the only voter), show the first voter badge instead. On return visits, fetch fresh distribution: if total is now > 1, show the bar; if still 1, show the badge again. + +## Animation + +### Button press (Ready → Submitting) + +- Selected button: `transform: scale(1.05)`, border shifts to distribution color, `box-shadow` glow, checkmark replaces text +- Other buttons: `opacity: 0.3`, `pointer-events: none` +- Duration: `200ms ease-out` + +### Result reveal (Submitting → Result) + +- Container height animates smoothly (no layout jump) +- "Thanks! You voted:" fades in: `opacity 0→1, 300ms` +- Distribution bar segments grow from `width: 0` to final percentage: `500ms ease-out`, staggered 50ms per segment +- Vote count fades in after bar completes + +### First voter badge + +Gold star `✦` single pulse: `scale 1→1.15→1`, plays once. + +### Returning pair (localStorage hit) + +Skip to Result immediately — no animation. Distribution bar renders at full width. Fresh distribution fetched in background, updates silently if numbers changed. + +### No toast + +Unlike the random pair voting page, quick vote uses inline feedback only. A toast on top of a modal is noisy. + +## Data Layer + +### Submission + +Use existing `submitVote()` from `shared/lib/supabase.ts` with `QuickVote` type: + +```typescript +submitVote({ cardA: pair.cardA.id, cardB: pair.cardB.id, accuracy: -1 | 0 | 1 }) +``` + +No changes needed to the `submit_vote` RPC. + +### Supabase migration + +Add three columns to the `pair_scores` view for per-bucket accuracy breakdown: + +```sql +count(*) filter (where accuracy = -1) as accuracy_lower, +count(*) filter (where accuracy = 0) as accuracy_right, +count(*) filter (where accuracy = 1) as accuracy_higher +``` + +### Distribution read + +New function in `supabase.ts`: + +```typescript +getAccuracyDistribution(cardA: string, cardB: string): Promise<{ + lower: number; + right: number; + higher: number; + total: number; +} | null> +``` + +Queries `pair_scores` view for the three counts. Returns null if Supabase unavailable or query fails. + +### localStorage + +- **Key**: `inkweave:vote:{sortedCardA}:{sortedCardB}` (canonical pair ordering matches Supabase) +- **Value**: `{ accuracy: -1 | 0 | 1, timestamp: number }` +- Checked on modal open to determine initial state +- Written after successful submission +- No expiry (until user auth lands) + +## New Files + +### `apps/web/src/features/voting/hooks/useQuickVote.ts` + +Custom hook encapsulating the full quick vote lifecycle: + +- Check localStorage for existing vote on mount +- `vote(accuracy)` — submit, store in localStorage, fetch distribution +- Manage state machine (hidden/ready/submitting/result/error) +- Expose: `state`, `vote`, `distribution`, `userChoice`, `error` + +Separate from `useVoteSession` (which is specific to the random pair page). + +### `apps/web/src/features/voting/components/QuickVoteControl.tsx` + +Presentational component rendering the vote UI based on state. Props: + +- `state`: current state from hook +- `onVote(accuracy)`: callback +- `distribution`: vote counts (if available) +- `userChoice`: the user's vote (for result display) +- `error`: error type (for message display) + +Handles desktop (horizontal) and mobile (vertical) layouts via `useResponsive()`. + +### `apps/web/src/features/voting/components/DistributionBar.tsx` + +Reusable animated bar component. Props: + +- `lower`, `right`, `higher`: vote counts +- `animate`: boolean (true for fresh votes, false for returning pairs) + +### `supabase/migrations/YYYYMMDD_add_accuracy_distribution.sql` + +Migration adding accuracy_lower/right/higher to pair_scores view. + +## Modified Files + +- `SynergyDetailModal.tsx` — import and render `QuickVoteControl` between tier label and connections +- `supabase.ts` — add `getAccuracyDistribution()` function +- `database.types.ts` — regenerate after migration (adds new view columns) +- `features/voting/hooks/index.ts` — export `useQuickVote` +- `features/voting/components/index.ts` — export `QuickVoteControl`, `DistributionBar` + +## Testing + +### Unit tests (`useQuickVote`) + +- Returns hidden state when Supabase unavailable +- Returns result state when pair found in localStorage +- Submits accuracy value and transitions to result on success +- Handles error and rate-limit states correctly +- Fetches distribution after successful vote +- Stores vote in localStorage after success + +### Component tests (`QuickVoteControl`) + +- Renders three buttons in ready state +- Hides entirely when state is hidden +- Disables buttons during submission +- Shows distribution bar in result state +- Shows first voter badge when total = 1 +- Shows error message on failure, re-enables buttons +- Vertical layout on mobile, horizontal on desktop + +### Integration (within `SynergyDetailModal`) + +- Vote section appears below tier label +- Opening modal for previously-voted pair shows result state + +### Storybook + +`QuickVoteControl.stories.tsx` with stories for: ready, submitting, result, result-first-voter, error, rate-limited, hidden. + +### E2E + +Skipped — quick vote hits real Supabase without test environment isolation. Unit/component tests cover the interaction flow. Voting E2E is covered by the random pair page tests. + +## Decisions Log + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Placement | Below tier label | Prominent, gut-reaction before reading details | +| Button labels | "Should be lower/higher" | Unambiguous, references the score directionally | +| Mobile layout | Vertical stack | Better touch targets, validate during implementation | +| Post-vote reveal | Distribution bar | Gamification — community signal drives engagement | +| First voter | Gold badge | Rewards early participation | +| Returning pairs | localStorage → result state | Avoids "didn't I already vote?" confusion | +| Supabase down | Hide section | Per issue spec, no broken UI | +| "Rate in Detail" | Disabled teaser | Placeholder for #213 | +| Vote threshold | None | Show distribution from 1 vote | +| Separate hook | `useQuickVote` | Different lifecycle from `useVoteSession` | From 7440859d9a18391162a7171128fb391d5b1f2583 Mon Sep 17 00:00:00 2001 From: johnn Date: Mon, 6 Apr 2026 21:14:47 +0300 Subject: [PATCH 02/12] docs: add quick vote implementation plan (#212) 6-task TDD plan: Supabase migration, distribution query, DistributionBar component, useQuickVote hook, QuickVoteControl component, modal integration. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-06-quick-vote.md | 961 ++++++++++++++++++ 1 file changed, 961 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-06-quick-vote.md diff --git a/docs/superpowers/plans/2026-04-06-quick-vote.md b/docs/superpowers/plans/2026-04-06-quick-vote.md new file mode 100644 index 0000000..350c2e1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-quick-vote.md @@ -0,0 +1,961 @@ +# Quick Vote Implementation Plan + +> **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:** Add a three-button quick vote control to the synergy detail modal so users can give directional feedback on synergy scores, with an animated distribution bar reveal after voting. + +**Architecture:** New `useQuickVote` hook manages the vote state machine (hidden/ready/submitting/result/error). New `QuickVoteControl` component renders the UI and `DistributionBar` sub-component handles the animated bar. A small Supabase migration adds per-bucket accuracy columns to the `pair_scores` view. localStorage tracks which pairs the user has voted on. + +**Tech Stack:** React 19, TypeScript, Vitest + RTL, Supabase RPC, CSS transitions, Storybook + +**Spec:** `docs/superpowers/specs/2026-04-06-quick-vote-design.md` + +--- + +## File Map + +| Action | Path | Responsibility | +|--------|------|---------------| +| Create | `supabase/migrations/20260406000001_accuracy_distribution.sql` | Add accuracy_lower/right/higher columns to pair_scores view | +| Create | `apps/web/src/features/voting/hooks/useQuickVote.ts` | State machine hook: localStorage check, submit, fetch distribution | +| Create | `apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts` | Hook unit tests | +| Create | `apps/web/src/features/voting/components/QuickVoteControl.tsx` | Vote UI component (all states, responsive) | +| Create | `apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx` | Component tests | +| Create | `apps/web/src/features/voting/components/DistributionBar.tsx` | Animated three-segment bar | +| Create | `apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx` | Bar component tests | +| Create | `apps/web/src/features/voting/components/QuickVoteControl.stories.tsx` | Storybook stories for all states | +| Create | `apps/web/src/features/voting/components/DistributionBar.stories.tsx` | Storybook stories for bar | +| Modify | `apps/web/src/shared/lib/supabase.ts` | Add `getAccuracyDistribution()` function | +| Modify | `apps/web/src/shared/lib/database.types.ts` | Regenerate after migration | +| Modify | `apps/web/src/features/voting/hooks/index.ts` | Export useQuickVote | +| Modify | `apps/web/src/features/voting/components/index.ts` | Export QuickVoteControl, DistributionBar | +| Modify | `apps/web/src/features/synergies/components/SynergyDetailModal.tsx` | Integrate QuickVoteControl below tier label | +| Modify | `apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx` | Add integration tests | + +--- + +### Task 1: Supabase Migration — accuracy distribution columns + +**Files:** +- Create: `supabase/migrations/20260406000001_accuracy_distribution.sql` + +- [ ] **Step 1: Write the migration** + +```sql +-- Replace pair_scores view to add per-bucket accuracy breakdown +create or replace view pair_scores as +select + card_a_id, + card_b_id, + count(*) as total_votes, + count(score) as score_votes, + count(accuracy) as accuracy_votes, + + internal.trimmed_mean( + array_agg(score::numeric) filter (where score is not null) + )::numeric(4,2) as avg_score, + + avg(accuracy)::numeric(3,2) as accuracy_sentiment, + count(*) filter (where accuracy = -1) as accuracy_lower, + count(*) filter (where accuracy = 0) as accuracy_right, + count(*) filter (where accuracy = 1) as accuracy_higher, + avg(is_real::int)::numeric(3,2) as pct_real, + avg(would_play::int)::numeric(3,2) as pct_would_play, + count(*) filter (where who_carries = 'a') as carries_a, + count(*) filter (where who_carries = 'b') as carries_b, + count(*) filter (where who_carries = 'both') as carries_both, + avg(difficulty)::numeric(3,2) as avg_difficulty + +from votes +group by card_a_id, card_b_id; +``` + +- [ ] **Step 2: Apply the migration via Supabase MCP** + +Use `apply_migration` MCP tool with the SQL above. Then verify with `execute_sql`: + +```sql +select column_name from information_schema.columns +where table_name = 'pair_scores' +and column_name like 'accuracy_%' +order by column_name; +``` + +Expected: `accuracy_higher`, `accuracy_lower`, `accuracy_right`, `accuracy_sentiment`, `accuracy_votes` + +- [ ] **Step 3: Regenerate TypeScript types** + +Use Supabase MCP `generate_typescript_types` tool. Save output to `apps/web/src/shared/lib/database.types.ts`. Verify the `pair_scores` Row type now includes `accuracy_lower: number | null`, `accuracy_right: number | null`, `accuracy_higher: number | null`. + +- [ ] **Step 4: Commit** + +```bash +git add supabase/migrations/20260406000001_accuracy_distribution.sql apps/web/src/shared/lib/database.types.ts +git commit -m "feat(supabase): add accuracy distribution columns to pair_scores view (#212)" +``` + +--- + +### Task 2: Supabase client — getAccuracyDistribution + +**Files:** +- Modify: `apps/web/src/shared/lib/supabase.ts` + +- [ ] **Step 1: Write the failing test** + +Create a test inline in the existing supabase test file or a new one. Since supabase.ts doesn't have tests yet (it's a thin client), we'll test `getAccuracyDistribution` through the `useQuickVote` hook tests in Task 4. For now, add the function. + +- [ ] **Step 2: Add AccuracyDistribution type and function** + +Add to `apps/web/src/shared/lib/supabase.ts` after the `getPairScore` function: + +```typescript +export type AccuracyDistribution = { + lower: number; + right: number; + higher: number; + total: number; +}; + +export async function getAccuracyDistribution( + cardA: string, + cardB: string, +): Promise { + const supabase = getSupabase(); + if (!supabase) return null; + + const [a, b] = [cardA, cardB].sort(); + + try { + const { data, error } = await supabase + .from('pair_scores') + .select('accuracy_lower, accuracy_right, accuracy_higher') + .eq('card_a_id', a) + .eq('card_b_id', b) + .single(); + + if (error) { + if (error.code === 'PGRST116') return null; + console.error('[getAccuracyDistribution] Supabase query failed:', { + code: error.code, + message: error.message, + pair: `${a} / ${b}`, + }); + return null; + } + + const lower = data.accuracy_lower ?? 0; + const right = data.accuracy_right ?? 0; + const higher = data.accuracy_higher ?? 0; + + return { lower, right, higher, total: lower + right + higher }; + } catch (e) { + console.error('[getAccuracyDistribution] Network error:', e); + return null; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/shared/lib/supabase.ts +git commit -m "feat(supabase): add getAccuracyDistribution query (#212)" +``` + +--- + +### Task 3: DistributionBar component + +**Files:** +- Create: `apps/web/src/features/voting/components/DistributionBar.tsx` +- Create: `apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx` +- Create: `apps/web/src/features/voting/components/DistributionBar.stories.tsx` + +- [ ] **Step 1: Write failing tests** + +```typescript +// DistributionBar.test.tsx +import {describe, it, expect} from 'vitest'; +import {render, screen} from '@testing-library/react'; +import {DistributionBar} from '../DistributionBar'; + +describe('DistributionBar', () => { + it('renders three segments with percentages', () => { + render(); + + expect(screen.getByText('15%')).toBeInTheDocument(); + expect(screen.getByText('70%')).toBeInTheDocument(); + // Two segments show 15% + expect(screen.getAllByText('15%')).toHaveLength(2); + }); + + it('renders labels below the bar', () => { + render(); + + expect(screen.getByText('Should be lower')).toBeInTheDocument(); + expect(screen.getByText('About right')).toBeInTheDocument(); + expect(screen.getByText('Should be higher')).toBeInTheDocument(); + }); + + it('handles all votes in one bucket', () => { + render(); + + expect(screen.getByText('100%')).toBeInTheDocument(); + }); + + it('handles single vote', () => { + render(); + + expect(screen.getByText('100%')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter inkweave-web vitest run src/features/voting/components/__tests__/DistributionBar.test.tsx` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement DistributionBar** + +```typescript +// DistributionBar.tsx +import {COLORS, FONT_SIZES} from '../../../shared/constants'; + +interface DistributionBarProps { + lower: number; + right: number; + higher: number; + animate: boolean; +} + +const SEGMENT_COLORS = { + lower: {bg: 'rgba(245, 144, 144, 0.25)', text: '#f59090'}, + right: {bg: 'rgba(110, 231, 160, 0.2)', text: '#6ee7a0'}, + higher: {bg: 'rgba(96, 181, 245, 0.2)', text: '#60b5f5'}, +} as const; + +const LABELS = { + lower: 'Should be lower', + right: 'About right', + higher: 'Should be higher', +} as const; + +export function DistributionBar({lower, right, higher, animate}: DistributionBarProps) { + const total = lower + right + higher; + if (total === 0) return null; + + const pct = { + lower: Math.round((lower / total) * 100), + right: Math.round((right / total) * 100), + higher: Math.round((higher / total) * 100), + }; + + // Fix rounding to sum to 100 + const diff = 100 - pct.lower - pct.right - pct.higher; + if (diff !== 0) { + const largest = Object.entries(pct).sort(([, a], [, b]) => b - a)[0][0] as keyof typeof pct; + pct[largest] += diff; + } + + const segments = (['lower', 'right', 'higher'] as const).filter((key) => pct[key] > 0); + + return ( +
+
+ {segments.map((key, i) => ( +
1 + ? '6px 0 0 6px' + : i === segments.length - 1 && segments.length > 1 + ? '0 6px 6px 0' + : segments.length === 1 + ? '6px' + : undefined, + transition: animate ? 'width 500ms ease-out' : undefined, + transitionDelay: animate ? `${i * 50}ms` : undefined, + }}> + {pct[key]}% +
+ ))} +
+
+ {LABELS.lower} + {LABELS.right} + {LABELS.higher} +
+
+ ); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter inkweave-web vitest run src/features/voting/components/__tests__/DistributionBar.test.tsx` +Expected: PASS (4 tests) + +- [ ] **Step 5: Write stories** + +```typescript +// DistributionBar.stories.tsx +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {DistributionBar} from './DistributionBar'; + +const meta = { + title: 'Voting/DistributionBar', + component: DistributionBar, + parameters: {layout: 'centered'}, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Balanced: Story = { + args: {lower: 15, right: 68, higher: 17, animate: false}, +}; + +export const Animated: Story = { + args: {lower: 15, right: 68, higher: 17, animate: true}, +}; + +export const Unanimous: Story = { + args: {lower: 0, right: 25, higher: 0, animate: false}, +}; + +export const Controversial: Story = { + args: {lower: 12, right: 5, higher: 13, animate: false}, +}; + +export const SingleVote: Story = { + args: {lower: 0, right: 1, higher: 0, animate: false}, +}; +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/features/voting/components/DistributionBar.tsx \ + apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx \ + apps/web/src/features/voting/components/DistributionBar.stories.tsx +git commit -m "feat(voting): add DistributionBar component (#212)" +``` + +--- + +### Task 4: useQuickVote hook + +**Files:** +- Create: `apps/web/src/features/voting/hooks/useQuickVote.ts` +- Create: `apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts` + +- [ ] **Step 1: Write failing tests** + +```typescript +// useQuickVote.test.ts +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; +import {renderHook, act} from '@testing-library/react'; +import {useQuickVote} from '../useQuickVote'; +import {getSupabase, submitVote, getAccuracyDistribution} from '../../../../shared/lib/supabase'; + +vi.mock('../../../../shared/lib/supabase', () => ({ + getSupabase: vi.fn(), + submitVote: vi.fn(), + getAccuracyDistribution: vi.fn(), +})); + +const CARD_A = 'card-aaa'; +const CARD_B = 'card-bbb'; +// Canonical key: sorted +const STORAGE_KEY = `inkweave:vote:${CARD_A}:${CARD_B}`; + +describe('useQuickVote', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('returns hidden state when Supabase is unavailable', () => { + vi.mocked(getSupabase).mockReturnValue(null); + const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B)); + expect(result.current.state).toBe('hidden'); + }); + + it('returns ready state when Supabase is available and no prior vote', () => { + vi.mocked(getSupabase).mockReturnValue({} as ReturnType); + const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B)); + expect(result.current.state).toBe('ready'); + }); + + it('returns result state when pair found in localStorage', async () => { + vi.mocked(getSupabase).mockReturnValue({} as ReturnType); + vi.mocked(getAccuracyDistribution).mockResolvedValue({ + lower: 3, right: 10, higher: 2, total: 15, + }); + localStorage.setItem(STORAGE_KEY, JSON.stringify({accuracy: 0, timestamp: Date.now()})); + + const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B)); + expect(result.current.state).toBe('result'); + expect(result.current.userChoice).toBe(0); + }); + + it('submits vote, stores in localStorage, and fetches distribution', async () => { + vi.mocked(getSupabase).mockReturnValue({} as ReturnType); + vi.mocked(submitVote).mockResolvedValue({error: null}); + vi.mocked(getAccuracyDistribution).mockResolvedValue({ + lower: 1, right: 5, higher: 0, total: 6, + }); + + const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B)); + expect(result.current.state).toBe('ready'); + + await act(async () => { + await result.current.vote(0); + }); + + expect(submitVote).toHaveBeenCalledWith({ + cardA: CARD_A, + cardB: CARD_B, + accuracy: 0, + }); + expect(result.current.state).toBe('result'); + expect(result.current.userChoice).toBe(0); + expect(result.current.distribution).toEqual({ + lower: 1, right: 5, higher: 0, total: 6, + }); + expect(localStorage.getItem(STORAGE_KEY)).toBeTruthy(); + }); + + it('transitions to error state on submission failure', async () => { + vi.mocked(getSupabase).mockReturnValue({} as ReturnType); + vi.mocked(submitVote).mockResolvedValue({error: 'Network error'}); + + const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B)); + + await act(async () => { + await result.current.vote(1); + }); + + expect(result.current.state).toBe('error'); + expect(result.current.error).toBe('error'); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('transitions to rate-limited state', async () => { + vi.mocked(getSupabase).mockReturnValue({} as ReturnType); + vi.mocked(submitVote).mockResolvedValue({error: 'rate_limited'}); + + const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B)); + + await act(async () => { + await result.current.vote(-1); + }); + + expect(result.current.state).toBe('error'); + expect(result.current.error).toBe('rate_limited'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter inkweave-web vitest run src/features/voting/hooks/__tests__/useQuickVote.test.ts` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement useQuickVote** + +```typescript +// useQuickVote.ts +import {useState, useEffect, useCallback, useMemo} from 'react'; +import { + getSupabase, + submitVote, + getAccuracyDistribution, + type AccuracyDistribution, +} from '../../../shared/lib/supabase'; + +export type QuickVoteState = 'hidden' | 'ready' | 'submitting' | 'result' | 'error'; +export type QuickVoteError = 'error' | 'rate_limited' | null; +type Accuracy = -1 | 0 | 1; + +function storageKey(cardA: string, cardB: string): string { + const [a, b] = [cardA, cardB].sort(); + return `inkweave:vote:${a}:${b}`; +} + +function getStoredVote(cardA: string, cardB: string): Accuracy | null { + try { + const raw = localStorage.getItem(storageKey(cardA, cardB)); + if (!raw) return null; + const parsed = JSON.parse(raw); + return parsed.accuracy ?? null; + } catch { + return null; + } +} + +function storeVote(cardA: string, cardB: string, accuracy: Accuracy): void { + localStorage.setItem( + storageKey(cardA, cardB), + JSON.stringify({accuracy, timestamp: Date.now()}), + ); +} + +export interface UseQuickVoteReturn { + state: QuickVoteState; + vote: (accuracy: Accuracy) => Promise; + distribution: AccuracyDistribution | null; + userChoice: Accuracy | null; + error: QuickVoteError; +} + +export function useQuickVote(cardA: string, cardB: string): UseQuickVoteReturn { + const isAvailable = useMemo(() => getSupabase() !== null, []); + const storedChoice = useMemo(() => getStoredVote(cardA, cardB), [cardA, cardB]); + + const [state, setState] = useState(() => { + if (!isAvailable) return 'hidden'; + if (storedChoice !== null) return 'result'; + return 'ready'; + }); + const [userChoice, setUserChoice] = useState(storedChoice); + const [distribution, setDistribution] = useState(null); + const [error, setError] = useState(null); + + // Fetch distribution for returning voters + useEffect(() => { + if (state === 'result' && !distribution) { + getAccuracyDistribution(cardA, cardB).then((dist) => { + if (dist) setDistribution(dist); + }); + } + }, [state, distribution, cardA, cardB]); + + const vote = useCallback( + async (accuracy: Accuracy) => { + if (state !== 'ready' && state !== 'error') return; + + setState('submitting'); + setError(null); + + const result = await submitVote({cardA, cardB, accuracy}); + + if (result.error === null) { + storeVote(cardA, cardB, accuracy); + setUserChoice(accuracy); + setState('result'); + const dist = await getAccuracyDistribution(cardA, cardB); + if (dist) setDistribution(dist); + } else if (result.error === 'rate_limited') { + setError('rate_limited'); + setState('error'); + } else { + setError('error'); + setState('error'); + } + }, + [cardA, cardB, state], + ); + + return {state, vote, distribution, userChoice, error}; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter inkweave-web vitest run src/features/voting/hooks/__tests__/useQuickVote.test.ts` +Expected: PASS (6 tests) + +- [ ] **Step 5: Export from hooks index** + +Add to `apps/web/src/features/voting/hooks/index.ts`: + +```typescript +export {useQuickVote} from './useQuickVote'; +export type {UseQuickVoteReturn, QuickVoteState, QuickVoteError} from './useQuickVote'; +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/features/voting/hooks/useQuickVote.ts \ + apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts \ + apps/web/src/features/voting/hooks/index.ts +git commit -m "feat(voting): add useQuickVote hook (#212)" +``` + +--- + +### Task 5: QuickVoteControl component + +**Files:** +- Create: `apps/web/src/features/voting/components/QuickVoteControl.tsx` +- Create: `apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx` +- Create: `apps/web/src/features/voting/components/QuickVoteControl.stories.tsx` + +- [ ] **Step 1: Write failing tests** + +```typescript +// QuickVoteControl.test.tsx +import {describe, it, expect, vi} from 'vitest'; +import {render, screen, fireEvent} from '@testing-library/react'; +import {QuickVoteControl} from '../QuickVoteControl'; +import type {AccuracyDistribution} from '../../../../shared/lib/supabase'; + +const mockVote = vi.fn(); + +describe('QuickVoteControl', () => { + it('renders nothing when state is hidden', () => { + const {container} = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders three vote buttons in ready state', () => { + render( + , + ); + expect(screen.getByText('Do you agree with this score?')).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Should be lower'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'About right'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Should be higher'})).toBeInTheDocument(); + }); + + it('calls onVote with -1 when "Should be lower" is clicked', () => { + render( + , + ); + fireEvent.click(screen.getByRole('button', {name: 'Should be lower'})); + expect(mockVote).toHaveBeenCalledWith(-1); + }); + + it('calls onVote with 0 when "About right" is clicked', () => { + render( + , + ); + fireEvent.click(screen.getByRole('button', {name: 'About right'})); + expect(mockVote).toHaveBeenCalledWith(0); + }); + + it('calls onVote with 1 when "Should be higher" is clicked', () => { + render( + , + ); + fireEvent.click(screen.getByRole('button', {name: 'Should be higher'})); + expect(mockVote).toHaveBeenCalledWith(1); + }); + + it('disables buttons during submitting state', () => { + render( + , + ); + const buttons = screen.getAllByRole('button'); + buttons.forEach((btn) => expect(btn).toBeDisabled()); + }); + + it('shows distribution bar and confirmation in result state', () => { + const dist: AccuracyDistribution = {lower: 3, right: 14, higher: 3, total: 20}; + render( + , + ); + expect(screen.getByText(/You voted:/)).toBeInTheDocument(); + expect(screen.getByText('About right')).toBeInTheDocument(); + expect(screen.getByText('20 votes on this pair')).toBeInTheDocument(); + }); + + it('shows first voter badge when total is 1', () => { + const dist: AccuracyDistribution = {lower: 0, right: 1, higher: 0, total: 1}; + render( + , + ); + expect(screen.getByText('First to rate this pair!')).toBeInTheDocument(); + }); + + it('shows error message and re-enables buttons on error', () => { + render( + , + ); + expect(screen.getByText('Something went wrong, try again')).toBeInTheDocument(); + const buttons = screen.getAllByRole('button').filter((b) => !b.textContent?.includes('Rate')); + buttons.forEach((btn) => expect(btn).not.toBeDisabled()); + }); + + it('shows rate limit message on rate_limited error', () => { + render( + , + ); + expect(screen.getByText("You're voting fast! Try again in a bit.")).toBeInTheDocument(); + }); + + it('shows disabled "Rate in detail" teaser', () => { + render( + , + ); + const teaser = screen.getByText(/Rate in detail/); + expect(teaser).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter inkweave-web vitest run src/features/voting/components/__tests__/QuickVoteControl.test.tsx` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement QuickVoteControl** + +This is a TODO(human) step — see the Learn by Doing request below. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter inkweave-web vitest run src/features/voting/components/__tests__/QuickVoteControl.test.tsx` +Expected: PASS (10 tests) + +- [ ] **Step 5: Write stories** + +```typescript +// QuickVoteControl.stories.tsx +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {fn} from 'storybook/test'; +import {QuickVoteControl} from './QuickVoteControl'; + +const meta = { + title: 'Voting/QuickVoteControl', + component: QuickVoteControl, + parameters: {layout: 'centered'}, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: {onVote: fn()}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Ready: Story = { + args: {state: 'ready', distribution: null, userChoice: null, error: null}, +}; + +export const Submitting: Story = { + args: {state: 'submitting', distribution: null, userChoice: 0, error: null}, +}; + +export const Result: Story = { + args: { + state: 'result', + distribution: {lower: 15, right: 68, higher: 17, total: 100}, + userChoice: 0, + error: null, + }, +}; + +export const ResultFirstVoter: Story = { + args: { + state: 'result', + distribution: {lower: 0, right: 1, higher: 0, total: 1}, + userChoice: 0, + error: null, + }, +}; + +export const Error: Story = { + args: {state: 'error', distribution: null, userChoice: null, error: 'error'}, +}; + +export const RateLimited: Story = { + args: {state: 'error', distribution: null, userChoice: null, error: 'rate_limited'}, +}; + +export const Mobile: Story = { + args: {state: 'ready', distribution: null, userChoice: null, error: null}, + parameters: {viewport: {defaultViewport: 'mobile1'}}, +}; + +export const MobileResult: Story = { + args: { + state: 'result', + distribution: {lower: 8, right: 30, higher: 12, total: 50}, + userChoice: 1, + error: null, + }, + parameters: {viewport: {defaultViewport: 'mobile1'}}, +}; +``` + +- [ ] **Step 6: Export from components index** + +Add to `apps/web/src/features/voting/components/index.ts`: + +```typescript +export {QuickVoteControl} from './QuickVoteControl'; +export {DistributionBar} from './DistributionBar'; +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/features/voting/components/QuickVoteControl.tsx \ + apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx \ + apps/web/src/features/voting/components/QuickVoteControl.stories.tsx \ + apps/web/src/features/voting/components/index.ts +git commit -m "feat(voting): add QuickVoteControl component (#212)" +``` + +--- + +### Task 6: Integrate into SynergyDetailModal + +**Files:** +- Modify: `apps/web/src/features/synergies/components/SynergyDetailModal.tsx` +- Modify: `apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx` + +- [ ] **Step 1: Write failing integration test** + +Add to the existing `SynergyDetailModal.test.tsx`: + +```typescript +// Add mock at top of file with other mocks +vi.mock('../../../features/voting/hooks/useQuickVote', () => ({ + useQuickVote: vi.fn().mockReturnValue({ + state: 'ready', + vote: vi.fn(), + distribution: null, + userChoice: null, + error: null, + }), +})); + +// Add test +it('renders quick vote control below tier label', () => { + render( + , + ); + expect(screen.getByText('Do you agree with this score?')).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run tests to verify it fails** + +Run: `pnpm --filter inkweave-web vitest run src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx` +Expected: FAIL — "Do you agree with this score?" not found + +- [ ] **Step 3: Add QuickVoteControl to SynergyDetailModal** + +In `SynergyDetailModal.tsx`, add import at top: + +```typescript +import {QuickVoteControl} from '../../voting/components'; +import {useQuickVote} from '../../voting/hooks'; +``` + +Inside the `SynergyDetailModal` function, after destructuring `pair`, add the hook call: + +```typescript +const quickVote = useQuickVote(cardA.id, cardB.id); +``` + +Then insert `QuickVoteControl` between the tier label `` (line 152) and the connections section (line 154): + +```tsx + {/* Quick vote */} +
+ +
+``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter inkweave-web vitest run src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx` +Expected: PASS + +- [ ] **Step 5: Run full test suite** + +Run: `pnpm test` +Expected: All tests pass (engine 195 + web 541+ new tests) + +- [ ] **Step 6: Visual verification** + +Start dev server (`pnpm dev`), navigate to a card with synergies, click a synergy card to open the modal. Verify: +- Vote section appears below "Strong Synergy" label +- Three buttons render correctly +- Clicking a button submits (check Network tab for Supabase RPC call) +- Distribution bar appears after voting +- On mobile viewport, buttons stack vertically + +Use Chrome DevTools MCP screenshot to verify. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/features/synergies/components/SynergyDetailModal.tsx \ + apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx +git commit -m "feat(voting): integrate quick vote into synergy detail modal (#212)" +``` + +--- + +## Summary + +| Task | What | Tests | Commit | +|------|------|-------|--------| +| 1 | Supabase migration: accuracy distribution columns | Verified via SQL query | `feat(supabase): add accuracy distribution columns` | +| 2 | `getAccuracyDistribution()` in supabase.ts | Tested via hook tests | `feat(supabase): add getAccuracyDistribution query` | +| 3 | DistributionBar component + stories | 4 unit tests | `feat(voting): add DistributionBar component` | +| 4 | useQuickVote hook | 6 unit tests | `feat(voting): add useQuickVote hook` | +| 5 | QuickVoteControl component + stories | 10 unit tests | `feat(voting): add QuickVoteControl component` | +| 6 | Integrate into SynergyDetailModal | 1 integration test + visual | `feat(voting): integrate quick vote into synergy detail modal` | From b9b0053176820825b54d6327aa107f05cf2b7964 Mon Sep 17 00:00:00 2001 From: johnn Date: Mon, 6 Apr 2026 21:24:01 +0300 Subject: [PATCH 03/12] feat(supabase): add accuracy distribution columns to pair_scores view (#212) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/src/shared/lib/database.types.ts | 3 ++ ...260406000001_add_accuracy_distribution.sql | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 supabase/migrations/20260406000001_add_accuracy_distribution.sql diff --git a/apps/web/src/shared/lib/database.types.ts b/apps/web/src/shared/lib/database.types.ts index a8279e6..2b74b7a 100644 --- a/apps/web/src/shared/lib/database.types.ts +++ b/apps/web/src/shared/lib/database.types.ts @@ -60,6 +60,9 @@ export type Database = { Views: { pair_scores: { Row: { + accuracy_higher: number | null + accuracy_lower: number | null + accuracy_right: number | null accuracy_sentiment: number | null accuracy_votes: number | null avg_difficulty: number | null diff --git a/supabase/migrations/20260406000001_add_accuracy_distribution.sql b/supabase/migrations/20260406000001_add_accuracy_distribution.sql new file mode 100644 index 0000000..444bd2f --- /dev/null +++ b/supabase/migrations/20260406000001_add_accuracy_distribution.sql @@ -0,0 +1,30 @@ +drop view if exists pair_scores; + +create view pair_scores as +select + card_a_id, + card_b_id, + count(*) as total_votes, + count(score) as score_votes, + count(accuracy) as accuracy_votes, + + internal.trimmed_mean( + array_agg(score::numeric) filter (where score is not null) + )::numeric(4,2) as avg_score, + + avg(accuracy)::numeric(3,2) as accuracy_sentiment, + count(*) filter (where accuracy = -1) as accuracy_lower, + count(*) filter (where accuracy = 0) as accuracy_right, + count(*) filter (where accuracy = 1) as accuracy_higher, + avg(is_real::int)::numeric(3,2) as pct_real, + avg(would_play::int)::numeric(3,2) as pct_would_play, + count(*) filter (where who_carries = 'a') as carries_a, + count(*) filter (where who_carries = 'b') as carries_b, + count(*) filter (where who_carries = 'both') as carries_both, + count(*) filter (where who_carries = 'neither') as carries_neither, + avg(difficulty)::numeric(3,2) as avg_difficulty + +from votes +group by card_a_id, card_b_id; + +grant select on pair_scores to anon; From 80e8b42b160c5b92b576d43f9a6843debcb5ecae Mon Sep 17 00:00:00 2001 From: johnn Date: Mon, 6 Apr 2026 21:33:12 +0300 Subject: [PATCH 04/12] feat(supabase): add getAccuracyDistribution query (#212) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/src/shared/lib/supabase.ts | 47 +++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/apps/web/src/shared/lib/supabase.ts b/apps/web/src/shared/lib/supabase.ts index f5f1abf..39f4b6b 100644 --- a/apps/web/src/shared/lib/supabase.ts +++ b/apps/web/src/shared/lib/supabase.ts @@ -140,3 +140,50 @@ export async function getPairScore( return null; } } + +// --- Accuracy distribution --- + +export type AccuracyDistribution = { + lower: number; + right: number; + higher: number; + total: number; +}; + +export async function getAccuracyDistribution( + cardA: string, + cardB: string, +): Promise { + const supabase = getSupabase(); + if (!supabase) return null; + + const [a, b] = [cardA, cardB].sort(); + + try { + const { data, error } = await supabase + .from('pair_scores') + .select('accuracy_lower, accuracy_right, accuracy_higher') + .eq('card_a_id', a) + .eq('card_b_id', b) + .single(); + + if (error) { + if (error.code === 'PGRST116') return null; + console.error('[getAccuracyDistribution] Supabase query failed:', { + code: error.code, + message: error.message, + pair: `${a} / ${b}`, + }); + return null; + } + + const lower = data.accuracy_lower ?? 0; + const right = data.accuracy_right ?? 0; + const higher = data.accuracy_higher ?? 0; + + return { lower, right, higher, total: lower + right + higher }; + } catch (e) { + console.error('[getAccuracyDistribution] Network error:', e); + return null; + } +} From 8e49d3d628af2a342ffccaadd72fe332cd030fa8 Mon Sep 17 00:00:00 2001 From: johnn Date: Mon, 6 Apr 2026 21:34:45 +0300 Subject: [PATCH 05/12] feat(voting): add DistributionBar component (#212) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/DistributionBar.stories.tsx | 39 ++++++++ .../voting/components/DistributionBar.tsx | 93 +++++++++++++++++++ .../__tests__/DistributionBar.test.tsx | 28 ++++++ 3 files changed, 160 insertions(+) create mode 100644 apps/web/src/features/voting/components/DistributionBar.stories.tsx create mode 100644 apps/web/src/features/voting/components/DistributionBar.tsx create mode 100644 apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx diff --git a/apps/web/src/features/voting/components/DistributionBar.stories.tsx b/apps/web/src/features/voting/components/DistributionBar.stories.tsx new file mode 100644 index 0000000..c1930b6 --- /dev/null +++ b/apps/web/src/features/voting/components/DistributionBar.stories.tsx @@ -0,0 +1,39 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {DistributionBar} from './DistributionBar'; + +const meta = { + title: 'Voting/DistributionBar', + component: DistributionBar, + parameters: {layout: 'centered'}, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Balanced: Story = { + args: {lower: 15, right: 68, higher: 17, animate: false}, +}; + +export const Animated: Story = { + args: {lower: 15, right: 68, higher: 17, animate: true}, +}; + +export const Unanimous: Story = { + args: {lower: 0, right: 25, higher: 0, animate: false}, +}; + +export const Controversial: Story = { + args: {lower: 12, right: 5, higher: 13, animate: false}, +}; + +export const SingleVote: Story = { + args: {lower: 0, right: 1, higher: 0, animate: false}, +}; diff --git a/apps/web/src/features/voting/components/DistributionBar.tsx b/apps/web/src/features/voting/components/DistributionBar.tsx new file mode 100644 index 0000000..bb9b190 --- /dev/null +++ b/apps/web/src/features/voting/components/DistributionBar.tsx @@ -0,0 +1,93 @@ +import {COLORS, FONT_SIZES} from '../../../shared/constants'; + +interface DistributionBarProps { + lower: number; + right: number; + higher: number; + animate: boolean; +} + +const SEGMENT_COLORS = { + lower: {bg: 'rgba(245, 144, 144, 0.25)', text: '#f59090'}, + right: {bg: 'rgba(110, 231, 160, 0.2)', text: '#6ee7a0'}, + higher: {bg: 'rgba(96, 181, 245, 0.2)', text: '#60b5f5'}, +} as const; + +const LABELS = { + lower: 'Should be lower', + right: 'About right', + higher: 'Should be higher', +} as const; + +export function DistributionBar({lower, right, higher, animate}: DistributionBarProps) { + const total = lower + right + higher; + if (total === 0) return null; + + const pct = { + lower: Math.round((lower / total) * 100), + right: Math.round((right / total) * 100), + higher: Math.round((higher / total) * 100), + }; + + // Fix rounding to sum to 100 + const diff = 100 - pct.lower - pct.right - pct.higher; + if (diff !== 0) { + const largest = Object.entries(pct).sort(([, a], [, b]) => b - a)[0][0] as keyof typeof pct; + pct[largest] += diff; + } + + const segments = (['lower', 'right', 'higher'] as const).filter((key) => pct[key] > 0); + + return ( +
+
+ {segments.map((key, i) => ( +
1 + ? '6px 0 0 6px' + : i === segments.length - 1 && segments.length > 1 + ? '0 6px 6px 0' + : segments.length === 1 + ? '6px' + : undefined, + transition: animate ? 'width 500ms ease-out' : undefined, + transitionDelay: animate ? `${i * 50}ms` : undefined, + }}> + {pct[key]}% +
+ ))} +
+
+ {LABELS.lower} + {LABELS.right} + {LABELS.higher} +
+
+ ); +} diff --git a/apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx b/apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx new file mode 100644 index 0000000..3f58447 --- /dev/null +++ b/apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx @@ -0,0 +1,28 @@ +import {describe, it, expect} from 'vitest'; +import {render, screen} from '@testing-library/react'; +import {DistributionBar} from '../DistributionBar'; + +describe('DistributionBar', () => { + it('renders three segments with percentages', () => { + render(); + expect(screen.getAllByText('15%')).toHaveLength(2); + expect(screen.getByText('70%')).toBeInTheDocument(); + }); + + it('renders labels below the bar', () => { + render(); + expect(screen.getByText('Should be lower')).toBeInTheDocument(); + expect(screen.getByText('About right')).toBeInTheDocument(); + expect(screen.getByText('Should be higher')).toBeInTheDocument(); + }); + + it('handles all votes in one bucket', () => { + render(); + expect(screen.getByText('100%')).toBeInTheDocument(); + }); + + it('handles single vote', () => { + render(); + expect(screen.getByText('100%')).toBeInTheDocument(); + }); +}); From 93dec889fc87bb184ba090de12bddff4e789e3d6 Mon Sep 17 00:00:00 2001 From: johnn Date: Mon, 6 Apr 2026 21:39:48 +0300 Subject: [PATCH 06/12] feat(voting): add useQuickVote hook (#212) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../hooks/__tests__/useQuickVote.test.ts | 105 ++++++++++++++++++ apps/web/src/features/voting/hooks/index.ts | 2 + .../src/features/voting/hooks/useQuickVote.ts | 93 ++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts create mode 100644 apps/web/src/features/voting/hooks/useQuickVote.ts diff --git a/apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts b/apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts new file mode 100644 index 0000000..ac72442 --- /dev/null +++ b/apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts @@ -0,0 +1,105 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; +import {renderHook, act} from '@testing-library/react'; +import {useQuickVote} from '../useQuickVote'; +import {getSupabase, submitVote, getAccuracyDistribution} from '../../../../shared/lib/supabase'; + +vi.mock('../../../../shared/lib/supabase', () => ({ + getSupabase: vi.fn(), + submitVote: vi.fn(), + getAccuracyDistribution: vi.fn(), +})); + +const CARD_A = 'card-aaa'; +const CARD_B = 'card-bbb'; +const STORAGE_KEY = `inkweave:vote:${CARD_A}:${CARD_B}`; + +describe('useQuickVote', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('returns hidden state when Supabase is unavailable', () => { + vi.mocked(getSupabase).mockReturnValue(null); + const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B)); + expect(result.current.state).toBe('hidden'); + }); + + it('returns ready state when Supabase is available and no prior vote', () => { + vi.mocked(getSupabase).mockReturnValue({} as ReturnType); + const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B)); + expect(result.current.state).toBe('ready'); + }); + + it('returns result state when pair found in localStorage', async () => { + vi.mocked(getSupabase).mockReturnValue({} as ReturnType); + vi.mocked(getAccuracyDistribution).mockResolvedValue({ + lower: 3, right: 10, higher: 2, total: 15, + }); + localStorage.setItem(STORAGE_KEY, JSON.stringify({accuracy: 0, timestamp: Date.now()})); + + const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B)); + expect(result.current.state).toBe('result'); + expect(result.current.userChoice).toBe(0); + }); + + it('submits vote, stores in localStorage, and fetches distribution', async () => { + vi.mocked(getSupabase).mockReturnValue({} as ReturnType); + vi.mocked(submitVote).mockResolvedValue({error: null}); + vi.mocked(getAccuracyDistribution).mockResolvedValue({ + lower: 1, right: 5, higher: 0, total: 6, + }); + + const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B)); + expect(result.current.state).toBe('ready'); + + await act(async () => { + await result.current.vote(0); + }); + + expect(submitVote).toHaveBeenCalledWith({ + cardA: CARD_A, + cardB: CARD_B, + accuracy: 0, + }); + expect(result.current.state).toBe('result'); + expect(result.current.userChoice).toBe(0); + expect(result.current.distribution).toEqual({ + lower: 1, right: 5, higher: 0, total: 6, + }); + expect(localStorage.getItem(STORAGE_KEY)).toBeTruthy(); + }); + + it('transitions to error state on submission failure', async () => { + vi.mocked(getSupabase).mockReturnValue({} as ReturnType); + vi.mocked(submitVote).mockResolvedValue({error: 'Network error'}); + + const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B)); + + await act(async () => { + await result.current.vote(1); + }); + + expect(result.current.state).toBe('error'); + expect(result.current.error).toBe('error'); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('transitions to rate-limited state', async () => { + vi.mocked(getSupabase).mockReturnValue({} as ReturnType); + vi.mocked(submitVote).mockResolvedValue({error: 'rate_limited'}); + + const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B)); + + await act(async () => { + await result.current.vote(-1); + }); + + expect(result.current.state).toBe('error'); + expect(result.current.error).toBe('rate_limited'); + }); +}); diff --git a/apps/web/src/features/voting/hooks/index.ts b/apps/web/src/features/voting/hooks/index.ts index b523cdf..d5d532c 100644 --- a/apps/web/src/features/voting/hooks/index.ts +++ b/apps/web/src/features/voting/hooks/index.ts @@ -2,3 +2,5 @@ export {usePairQueue} from './usePairQueue'; export type {UsePairQueueReturn, PairPreview} from './usePairQueue'; export {useVoteSession} from './useVoteSession'; export type {UseVoteSessionReturn} from './useVoteSession'; +export {useQuickVote} from './useQuickVote'; +export type {UseQuickVoteReturn, QuickVoteState, QuickVoteError} from './useQuickVote'; diff --git a/apps/web/src/features/voting/hooks/useQuickVote.ts b/apps/web/src/features/voting/hooks/useQuickVote.ts new file mode 100644 index 0000000..ca17dea --- /dev/null +++ b/apps/web/src/features/voting/hooks/useQuickVote.ts @@ -0,0 +1,93 @@ +import {useState, useEffect, useCallback, useMemo} from 'react'; +import { + getSupabase, + submitVote, + getAccuracyDistribution, + type AccuracyDistribution, +} from '../../../shared/lib/supabase'; + +export type QuickVoteState = 'hidden' | 'ready' | 'submitting' | 'result' | 'error'; +export type QuickVoteError = 'error' | 'rate_limited' | null; +type Accuracy = -1 | 0 | 1; + +function storageKey(cardA: string, cardB: string): string { + const [a, b] = [cardA, cardB].sort(); + return `inkweave:vote:${a}:${b}`; +} + +function getStoredVote(cardA: string, cardB: string): Accuracy | null { + try { + const raw = localStorage.getItem(storageKey(cardA, cardB)); + if (!raw) return null; + const parsed = JSON.parse(raw); + return parsed.accuracy ?? null; + } catch { + return null; + } +} + +function storeVote(cardA: string, cardB: string, accuracy: Accuracy): void { + localStorage.setItem( + storageKey(cardA, cardB), + JSON.stringify({accuracy, timestamp: Date.now()}), + ); +} + +export interface UseQuickVoteReturn { + state: QuickVoteState; + vote: (accuracy: Accuracy) => Promise; + distribution: AccuracyDistribution | null; + userChoice: Accuracy | null; + error: QuickVoteError; +} + +export function useQuickVote(cardA: string, cardB: string): UseQuickVoteReturn { + const isAvailable = useMemo(() => getSupabase() !== null, []); + const storedChoice = useMemo(() => getStoredVote(cardA, cardB), [cardA, cardB]); + + const [state, setState] = useState(() => { + if (!isAvailable) return 'hidden'; + if (storedChoice !== null) return 'result'; + return 'ready'; + }); + const [userChoice, setUserChoice] = useState(storedChoice); + const [distribution, setDistribution] = useState(null); + const [error, setError] = useState(null); + + // Fetch distribution for returning voters + useEffect(() => { + if (state === 'result' && !distribution) { + getAccuracyDistribution(cardA, cardB).then((dist) => { + if (dist) setDistribution(dist); + }); + } + }, [state, distribution, cardA, cardB]); + + const vote = useCallback( + async (accuracy: Accuracy) => { + if (state !== 'ready' && state !== 'error') return; + + setState('submitting'); + setError(null); + + const result = await submitVote({cardA, cardB, accuracy}); + + if (result.error === null) { + storeVote(cardA, cardB, accuracy); + setUserChoice(accuracy); + setState('result'); + const dist = await getAccuracyDistribution(cardA, cardB); + if (dist) setDistribution(dist); + } else if (result.error === 'rate_limited') { + setError('rate_limited'); + setState('error'); + } else { + setError('error'); + setState('error'); + } + }, + [cardA, cardB, state], + ); + + return {state, vote, distribution, userChoice, error}; +} From bb559e45b9cd359375c969e2d5be63727b79e5d9 Mon Sep 17 00:00:00 2001 From: johnn Date: Mon, 6 Apr 2026 21:50:43 +0300 Subject: [PATCH 07/12] feat(voting): add QuickVoteControl component (#212) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../voting/components/DistributionBar.tsx | 29 +-- .../components/QuickVoteControl.stories.tsx | 70 ++++++ .../voting/components/QuickVoteControl.tsx | 206 ++++++++++++++++++ .../__tests__/QuickVoteControl.test.tsx | 99 +++++++++ .../src/features/voting/components/index.ts | 2 + 5 files changed, 393 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/features/voting/components/QuickVoteControl.stories.tsx create mode 100644 apps/web/src/features/voting/components/QuickVoteControl.tsx create mode 100644 apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx diff --git a/apps/web/src/features/voting/components/DistributionBar.tsx b/apps/web/src/features/voting/components/DistributionBar.tsx index bb9b190..7b39f8e 100644 --- a/apps/web/src/features/voting/components/DistributionBar.tsx +++ b/apps/web/src/features/voting/components/DistributionBar.tsx @@ -5,6 +5,7 @@ interface DistributionBarProps { right: number; higher: number; animate: boolean; + showLabels?: boolean; } const SEGMENT_COLORS = { @@ -19,7 +20,7 @@ const LABELS = { higher: 'Should be higher', } as const; -export function DistributionBar({lower, right, higher, animate}: DistributionBarProps) { +export function DistributionBar({lower, right, higher, animate, showLabels = true}: DistributionBarProps) { const total = lower + right + higher; if (total === 0) return null; @@ -76,18 +77,20 @@ export function DistributionBar({lower, right, higher, animate}: DistributionBar ))} -
- {LABELS.lower} - {LABELS.right} - {LABELS.higher} -
+ {showLabels && ( +
+ {LABELS.lower} + {LABELS.right} + {LABELS.higher} +
+ )} ); } diff --git a/apps/web/src/features/voting/components/QuickVoteControl.stories.tsx b/apps/web/src/features/voting/components/QuickVoteControl.stories.tsx new file mode 100644 index 0000000..f6c187b --- /dev/null +++ b/apps/web/src/features/voting/components/QuickVoteControl.stories.tsx @@ -0,0 +1,70 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {fn} from 'storybook/test'; +import {QuickVoteControl} from './QuickVoteControl'; + +const meta = { + title: 'Voting/QuickVoteControl', + component: QuickVoteControl, + parameters: {layout: 'centered'}, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: {onVote: fn()}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Ready: Story = { + args: {state: 'ready', distribution: null, userChoice: null, error: null}, +}; + +export const Submitting: Story = { + args: {state: 'submitting', distribution: null, userChoice: 0, error: null}, +}; + +export const Result: Story = { + args: { + state: 'result', + distribution: {lower: 15, right: 68, higher: 17, total: 100}, + userChoice: 0, + error: null, + }, +}; + +export const ResultFirstVoter: Story = { + args: { + state: 'result', + distribution: {lower: 0, right: 1, higher: 0, total: 1}, + userChoice: 0, + error: null, + }, +}; + +export const Error: Story = { + args: {state: 'error', distribution: null, userChoice: null, error: 'error'}, +}; + +export const RateLimited: Story = { + args: {state: 'error', distribution: null, userChoice: null, error: 'rate_limited'}, +}; + +export const Mobile: Story = { + args: {state: 'ready', distribution: null, userChoice: null, error: null}, + parameters: {viewport: {defaultViewport: 'mobile1'}}, +}; + +export const MobileResult: Story = { + args: { + state: 'result', + distribution: {lower: 8, right: 30, higher: 12, total: 50}, + userChoice: 1, + error: null, + }, + parameters: {viewport: {defaultViewport: 'mobile1'}}, +}; diff --git a/apps/web/src/features/voting/components/QuickVoteControl.tsx b/apps/web/src/features/voting/components/QuickVoteControl.tsx new file mode 100644 index 0000000..9d9f5eb --- /dev/null +++ b/apps/web/src/features/voting/components/QuickVoteControl.tsx @@ -0,0 +1,206 @@ +import {COLORS, FONT_SIZES, FONTS, RADIUS, SPACING} from '../../../shared/constants'; +import {useResponsive} from '../../../shared/hooks'; +import type {AccuracyDistribution} from '../../../shared/lib/supabase'; +import type {QuickVoteError, QuickVoteState} from '../hooks/useQuickVote'; +import {DistributionBar} from './DistributionBar'; + +interface QuickVoteControlProps { + state: QuickVoteState; + onVote: (accuracy: -1 | 0 | 1) => void; + distribution: AccuracyDistribution | null; + userChoice: -1 | 0 | 1 | null; + error: QuickVoteError; +} + +const CHOICE_LABELS: Record<-1 | 0 | 1, string> = { + [-1]: 'Should be lower', + [0]: 'About right', + [1]: 'Should be higher', +}; + +const CHOICE_COLORS: Record<-1 | 0 | 1, {border: string; glow: string}> = { + [-1]: {border: '#f59090', glow: 'rgba(245, 144, 144, 0.15)'}, + [0]: {border: '#6ee7a0', glow: 'rgba(110, 231, 160, 0.15)'}, + [1]: {border: '#60b5f5', glow: 'rgba(96, 181, 245, 0.15)'}, +}; + +const VOTES = [-1, 0, 1] as const; + +const CONTAINER_STYLE: React.CSSProperties = { + background: 'rgba(212, 175, 55, 0.04)', + border: '1px solid rgba(212, 175, 55, 0.12)', + borderRadius: 10, + padding: '14px 16px', + display: 'flex', + flexDirection: 'column', + gap: SPACING.sm, + fontFamily: FONTS.body, +}; + +const QUESTION_STYLE: React.CSSProperties = { + fontSize: FONT_SIZES.base, + color: COLORS.textMuted, + margin: 0, +}; + +const BASE_BUTTON_STYLE: React.CSSProperties = { + background: 'rgba(255,255,255,0.03)', + border: `1px solid ${COLORS.surfaceBorder}`, + borderRadius: RADIUS.lg, + minHeight: 44, + cursor: 'pointer', + fontFamily: 'inherit', + fontSize: FONT_SIZES.base, + color: COLORS.text, + padding: `${SPACING.xs}px ${SPACING.md}px`, + transition: 'all 0.2s', + flex: 1, +}; + +const TEASER_STYLE: React.CSSProperties = { + fontSize: FONT_SIZES.sm, + color: COLORS.textDim, + background: 'none', + border: 'none', + padding: 0, + cursor: 'not-allowed', + fontFamily: 'inherit', + textAlign: 'left', +}; + +function VoteButtons({ + disabled, + submitting, + selectedChoice, + onVote, + isMobile, +}: { + disabled: boolean; + submitting: boolean; + selectedChoice: -1 | 0 | 1 | null; + onVote: (accuracy: -1 | 0 | 1) => void; + isMobile: boolean; +}) { + return ( +
+ {VOTES.map((vote) => { + const isSelected = submitting && selectedChoice === vote; + const isDimmed = submitting && selectedChoice !== vote; + const colors = CHOICE_COLORS[vote]; + + return ( + + ); + })} +
+ ); +} + +function TeaserButton() { + return ( + + ); +} + +export function QuickVoteControl({ + state, + onVote, + distribution, + userChoice, + error, +}: QuickVoteControlProps) { + const {isMobile} = useResponsive(); + + if (state === 'hidden') return null; + + if (state === 'result') { + const isFirstVoter = distribution?.total === 1; + + return ( +
+

+ ✦ Thanks! You voted:{' '} + + {userChoice !== null ? CHOICE_LABELS[userChoice] : ''} + +

+ {isFirstVoter ? ( +

+ + First to rate this pair! +

+ ) : ( + distribution && ( + + ) + )} + {distribution && ( +

+ {distribution.total} votes on this pair +

+ )} + +
+ ); + } + + const isSubmitting = state === 'submitting'; + const isError = state === 'error'; + const buttonsDisabled = isSubmitting || (isError && error === 'rate_limited'); + + return ( +
+

Do you agree with this score?

+ + {isError && ( +

+ {error === 'rate_limited' + ? "You're voting fast! Try again in a bit." + : 'Something went wrong, try again'} +

+ )} + +
+ ); +} diff --git a/apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx b/apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx new file mode 100644 index 0000000..3d870b4 --- /dev/null +++ b/apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx @@ -0,0 +1,99 @@ +import {describe, it, expect, vi} from 'vitest'; +import {render, screen, fireEvent} from '@testing-library/react'; +import {QuickVoteControl} from '../QuickVoteControl'; +import type {AccuracyDistribution} from '../../../../shared/lib/supabase'; + +const mockVote = vi.fn(); + +describe('QuickVoteControl', () => { + it('renders nothing when state is hidden', () => { + const {container} = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders three vote buttons in ready state', () => { + render( + , + ); + expect(screen.getByText('Do you agree with this score?')).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Should be lower'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'About right'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Should be higher'})).toBeInTheDocument(); + }); + + it('calls onVote with -1 when "Should be lower" is clicked', () => { + render( + , + ); + fireEvent.click(screen.getByRole('button', {name: 'Should be lower'})); + expect(mockVote).toHaveBeenCalledWith(-1); + }); + + it('calls onVote with 0 when "About right" is clicked', () => { + render( + , + ); + fireEvent.click(screen.getByRole('button', {name: 'About right'})); + expect(mockVote).toHaveBeenCalledWith(0); + }); + + it('calls onVote with 1 when "Should be higher" is clicked', () => { + render( + , + ); + fireEvent.click(screen.getByRole('button', {name: 'Should be higher'})); + expect(mockVote).toHaveBeenCalledWith(1); + }); + + it('disables buttons during submitting state', () => { + render( + , + ); + const buttons = screen.getAllByRole('button'); + buttons.forEach((btn) => expect(btn).toBeDisabled()); + }); + + it('shows distribution bar and confirmation in result state', () => { + const dist: AccuracyDistribution = {lower: 3, right: 14, higher: 3, total: 20}; + render( + , + ); + expect(screen.getByText(/You voted:/)).toBeInTheDocument(); + expect(screen.getByText('About right')).toBeInTheDocument(); + expect(screen.getByText('20 votes on this pair')).toBeInTheDocument(); + }); + + it('shows first voter badge when total is 1', () => { + const dist: AccuracyDistribution = {lower: 0, right: 1, higher: 0, total: 1}; + render( + , + ); + expect(screen.getByText('First to rate this pair!')).toBeInTheDocument(); + }); + + it('shows error message and re-enables buttons on error', () => { + render( + , + ); + expect(screen.getByText('Something went wrong, try again')).toBeInTheDocument(); + // Vote buttons should be enabled (not the teaser) + expect(screen.getByRole('button', {name: 'Should be lower'})).not.toBeDisabled(); + }); + + it('shows rate limit message on rate_limited error', () => { + render( + , + ); + expect(screen.getByText("You're voting fast! Try again in a bit.")).toBeInTheDocument(); + }); + + it('shows disabled "Rate in detail" teaser', () => { + render( + , + ); + const teaser = screen.getByText(/Rate in detail/); + expect(teaser).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/features/voting/components/index.ts b/apps/web/src/features/voting/components/index.ts index 8f8f9c6..bd2bafd 100644 --- a/apps/web/src/features/voting/components/index.ts +++ b/apps/web/src/features/voting/components/index.ts @@ -7,3 +7,5 @@ export type {VoteToastData} from './VoteToast'; export {VoteStatusBanner} from './VoteStatusBanner'; export {VoteProgress} from './VoteProgress'; export {PairStack} from './PairStack'; +export {QuickVoteControl} from './QuickVoteControl'; +export {DistributionBar} from './DistributionBar'; From 5204ef8994395ba4455b424f7cebd2a3a95a7c6c Mon Sep 17 00:00:00 2001 From: johnn Date: Mon, 6 Apr 2026 21:58:11 +0300 Subject: [PATCH 08/12] feat(voting): integrate quick vote into synergy detail modal (#212) Co-Authored-By: Claude Sonnet 4.6 --- .../components/SynergyDetailModal.tsx | 14 ++++++++++++ .../__tests__/SynergyDetailModal.test.tsx | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/apps/web/src/features/synergies/components/SynergyDetailModal.tsx b/apps/web/src/features/synergies/components/SynergyDetailModal.tsx index 3a5a69a..ef7fd84 100644 --- a/apps/web/src/features/synergies/components/SynergyDetailModal.tsx +++ b/apps/web/src/features/synergies/components/SynergyDetailModal.tsx @@ -12,6 +12,8 @@ import { LOCATION_ROLE_DESCRIPTIONS, } from 'inkweave-synergy-engine'; import {getStrengthTier} from '../utils'; +import {QuickVoteControl} from '../../voting/components'; +import {useQuickVote} from '../../voting/hooks'; import {CardImage, CardLightbox, RenderProfiler} from '../../../shared/components'; import {useDialogFocus} from '../../../shared/hooks/useDialogFocus'; import {useScrollLock, useTransitionPresence, useResponsive} from '../../../shared/hooks'; @@ -54,6 +56,7 @@ export function SynergyDetailModal({ const {cardA, cardB, connections, aggregateScore} = pair; const tier = getStrengthTier(aggregateScore); + const quickVote = useQuickVote(cardA.id, cardB.id); if (!mounted) return null; @@ -151,6 +154,17 @@ export function SynergyDetailModal({ + {/* Quick vote */} +
+ +
+ {/* Connections list */} {connections.length > 0 && ( diff --git a/apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx b/apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx index b6c6d0e..5c56fb0 100644 --- a/apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx +++ b/apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx @@ -3,6 +3,16 @@ import {render, screen, fireEvent} from '@testing-library/react'; import {SynergyDetailModal} from '../SynergyDetailModal'; import {createCard, createConnection, createPairSynergy} from '../../../../shared/test-utils'; +vi.mock('../../voting/hooks', () => ({ + useQuickVote: vi.fn().mockReturnValue({ + state: 'ready', + vote: vi.fn(), + distribution: null, + userChoice: null, + error: null, + }), +})); + vi.mock('../../../shared/components', () => ({ CardImage: ({alt}: {alt: string}) =>
{alt}
, })); @@ -135,4 +145,16 @@ describe('SynergyDetailModal', () => { fireEvent.click(screen.getByTestId('synergy-detail-cta')); expect(onViewSynergies).toHaveBeenCalledWith('elsa-base'); }); + + it('renders quick vote control below tier label', () => { + render( + , + ); + expect(screen.getByText('Do you agree with this score?')).toBeInTheDocument(); + }); }); From 46f83d3871f420c76fa0c93150f6338af3cd22f6 Mon Sep 17 00:00:00 2001 From: johnn Date: Tue, 7 Apr 2026 01:05:34 +0300 Subject: [PATCH 09/12] fix(voting): address PR review findings (#212) - Fix silent localStorage error handling (getStoredVote/storeVote) - Add try-catch to vote callback and .catch() to useEffect - Fix fontSize to use template literal px strings (project convention) - Fix aria-pressed to use undefined instead of false for idle state - Export Accuracy type, use in QuickVoteControlProps - Add error logging in generic error branch - Add 5 missing tests: retry from error, reversed card order lookup, rate-limited buttons disabled, result with null distribution, rounding correction sum-to-100 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../voting/components/QuickVoteControl.tsx | 22 +++--- .../__tests__/DistributionBar.test.tsx | 7 ++ .../__tests__/QuickVoteControl.test.tsx | 18 +++++ .../hooks/__tests__/useQuickVote.test.ts | 39 ++++++++++ apps/web/src/features/voting/hooks/index.ts | 2 +- .../src/features/voting/hooks/useQuickVote.ts | 78 +++++++++++++------ 6 files changed, 131 insertions(+), 35 deletions(-) diff --git a/apps/web/src/features/voting/components/QuickVoteControl.tsx b/apps/web/src/features/voting/components/QuickVoteControl.tsx index 9d9f5eb..1cab989 100644 --- a/apps/web/src/features/voting/components/QuickVoteControl.tsx +++ b/apps/web/src/features/voting/components/QuickVoteControl.tsx @@ -1,14 +1,14 @@ import {COLORS, FONT_SIZES, FONTS, RADIUS, SPACING} from '../../../shared/constants'; import {useResponsive} from '../../../shared/hooks'; import type {AccuracyDistribution} from '../../../shared/lib/supabase'; -import type {QuickVoteError, QuickVoteState} from '../hooks/useQuickVote'; +import type {QuickVoteError, QuickVoteState, Accuracy} from '../hooks/useQuickVote'; import {DistributionBar} from './DistributionBar'; interface QuickVoteControlProps { state: QuickVoteState; - onVote: (accuracy: -1 | 0 | 1) => void; + onVote: (accuracy: Accuracy) => void; distribution: AccuracyDistribution | null; - userChoice: -1 | 0 | 1 | null; + userChoice: Accuracy | null; error: QuickVoteError; } @@ -38,7 +38,7 @@ const CONTAINER_STYLE: React.CSSProperties = { }; const QUESTION_STYLE: React.CSSProperties = { - fontSize: FONT_SIZES.base, + fontSize: `${FONT_SIZES.base}px`, color: COLORS.textMuted, margin: 0, }; @@ -50,7 +50,7 @@ const BASE_BUTTON_STYLE: React.CSSProperties = { minHeight: 44, cursor: 'pointer', fontFamily: 'inherit', - fontSize: FONT_SIZES.base, + fontSize: `${FONT_SIZES.base}px`, color: COLORS.text, padding: `${SPACING.xs}px ${SPACING.md}px`, transition: 'all 0.2s', @@ -58,7 +58,7 @@ const BASE_BUTTON_STYLE: React.CSSProperties = { }; const TEASER_STYLE: React.CSSProperties = { - fontSize: FONT_SIZES.sm, + fontSize: `${FONT_SIZES.sm}px`, color: COLORS.textDim, background: 'none', border: 'none', @@ -77,8 +77,8 @@ function VoteButtons({ }: { disabled: boolean; submitting: boolean; - selectedChoice: -1 | 0 | 1 | null; - onVote: (accuracy: -1 | 0 | 1) => void; + selectedChoice: Accuracy | null; + onVote: (accuracy: Accuracy) => void; isMobile: boolean; }) { return ( @@ -99,7 +99,7 @@ function VoteButtons({ type="button" disabled={disabled} onClick={() => onVote(vote)} - aria-pressed={isSelected} + aria-pressed={isSelected || undefined} style={{ ...BASE_BUTTON_STYLE, opacity: isDimmed ? 0.3 : 1, @@ -163,7 +163,7 @@ export function QuickVoteControl({ ) )} {distribution && ( -

+

{distribution.total} votes on this pair

)} @@ -190,7 +190,7 @@ export function QuickVoteControl({

{ expect(screen.getByText('100%')).toBeInTheDocument(); }); + it('rounds percentages to sum to exactly 100', () => { + // 1/3 each = 33.33% → rounds to 33+33+33=99, correction bumps one to 34 + render(); + expect(screen.getAllByText('33%')).toHaveLength(2); + expect(screen.getByText('34%')).toBeInTheDocument(); + }); + it('handles single vote', () => { render(); expect(screen.getByText('100%')).toBeInTheDocument(); diff --git a/apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx b/apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx index 3d870b4..9012322 100644 --- a/apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx +++ b/apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx @@ -82,6 +82,24 @@ describe('QuickVoteControl', () => { expect(screen.getByRole('button', {name: 'Should be lower'})).not.toBeDisabled(); }); + it('disables vote buttons when rate limited', () => { + render( + , + ); + expect(screen.getByRole('button', {name: 'Should be lower'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'About right'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Should be higher'})).toBeDisabled(); + }); + + it('shows confirmation without distribution bar when distribution is null', () => { + render( + , + ); + expect(screen.getByText(/You voted:/)).toBeInTheDocument(); + expect(screen.getByText('About right')).toBeInTheDocument(); + expect(screen.queryByText(/votes on this pair/)).not.toBeInTheDocument(); + }); + it('shows rate limit message on rate_limited error', () => { render( , diff --git a/apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts b/apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts index ac72442..5e63fe9 100644 --- a/apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts +++ b/apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts @@ -89,6 +89,45 @@ describe('useQuickVote', () => { expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); }); + it('retries successfully from error state', async () => { + vi.mocked(getSupabase).mockReturnValue({} as ReturnType); + vi.mocked(submitVote).mockResolvedValueOnce({error: 'Network error'}); + vi.mocked(submitVote).mockResolvedValueOnce({error: null}); + vi.mocked(getAccuracyDistribution).mockResolvedValue({ + lower: 0, right: 3, higher: 1, total: 4, + }); + + const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B)); + + // First attempt fails + await act(async () => { + await result.current.vote(0); + }); + expect(result.current.state).toBe('error'); + + // Retry succeeds + await act(async () => { + await result.current.vote(0); + }); + expect(result.current.state).toBe('result'); + expect(submitVote).toHaveBeenCalledTimes(2); + expect(localStorage.getItem(STORAGE_KEY)).toBeTruthy(); + }); + + it('finds stored vote regardless of card argument order', () => { + vi.mocked(getSupabase).mockReturnValue({} as ReturnType); + vi.mocked(getAccuracyDistribution).mockResolvedValue({ + lower: 1, right: 5, higher: 0, total: 6, + }); + // Store under canonical key (card-aaa comes first alphabetically) + localStorage.setItem(STORAGE_KEY, JSON.stringify({accuracy: 1, timestamp: Date.now()})); + + // Pass cards in REVERSED order + const {result} = renderHook(() => useQuickVote(CARD_B, CARD_A)); + expect(result.current.state).toBe('result'); + expect(result.current.userChoice).toBe(1); + }); + it('transitions to rate-limited state', async () => { vi.mocked(getSupabase).mockReturnValue({} as ReturnType); vi.mocked(submitVote).mockResolvedValue({error: 'rate_limited'}); diff --git a/apps/web/src/features/voting/hooks/index.ts b/apps/web/src/features/voting/hooks/index.ts index d5d532c..d5ff8c2 100644 --- a/apps/web/src/features/voting/hooks/index.ts +++ b/apps/web/src/features/voting/hooks/index.ts @@ -3,4 +3,4 @@ export type {UsePairQueueReturn, PairPreview} from './usePairQueue'; export {useVoteSession} from './useVoteSession'; export type {UseVoteSessionReturn} from './useVoteSession'; export {useQuickVote} from './useQuickVote'; -export type {UseQuickVoteReturn, QuickVoteState, QuickVoteError} from './useQuickVote'; +export type {UseQuickVoteReturn, QuickVoteState, QuickVoteError, Accuracy} from './useQuickVote'; diff --git a/apps/web/src/features/voting/hooks/useQuickVote.ts b/apps/web/src/features/voting/hooks/useQuickVote.ts index ca17dea..46b1c06 100644 --- a/apps/web/src/features/voting/hooks/useQuickVote.ts +++ b/apps/web/src/features/voting/hooks/useQuickVote.ts @@ -8,7 +8,7 @@ import { export type QuickVoteState = 'hidden' | 'ready' | 'submitting' | 'result' | 'error'; export type QuickVoteError = 'error' | 'rate_limited' | null; -type Accuracy = -1 | 0 | 1; +export type Accuracy = -1 | 0 | 1; function storageKey(cardA: string, cardB: string): string { const [a, b] = [cardA, cardB].sort(); @@ -16,21 +16,38 @@ function storageKey(cardA: string, cardB: string): string { } function getStoredVote(cardA: string, cardB: string): Accuracy | null { + const key = storageKey(cardA, cardB); + let raw: string | null; + try { + raw = localStorage.getItem(key); + } catch (e) { + console.warn('[getStoredVote] localStorage access denied:', e); + return null; + } + if (!raw) return null; try { - const raw = localStorage.getItem(storageKey(cardA, cardB)); - if (!raw) return null; const parsed = JSON.parse(raw); - return parsed.accuracy ?? null; - } catch { + const accuracy = parsed?.accuracy; + if (accuracy === -1 || accuracy === 0 || accuracy === 1) return accuracy; + console.error('[getStoredVote] Stored value has unexpected shape, clearing:', parsed); + localStorage.removeItem(key); + return null; + } catch (e) { + console.error('[getStoredVote] Corrupted JSON in storage, clearing key:', key, e); + localStorage.removeItem(key); return null; } } function storeVote(cardA: string, cardB: string, accuracy: Accuracy): void { - localStorage.setItem( - storageKey(cardA, cardB), - JSON.stringify({accuracy, timestamp: Date.now()}), - ); + try { + localStorage.setItem( + storageKey(cardA, cardB), + JSON.stringify({accuracy, timestamp: Date.now()}), + ); + } catch (e) { + console.error('[storeVote] Failed to persist vote to localStorage:', e); + } } export interface UseQuickVoteReturn { @@ -57,9 +74,13 @@ export function useQuickVote(cardA: string, cardB: string): UseQuickVoteReturn { // Fetch distribution for returning voters useEffect(() => { if (state === 'result' && !distribution) { - getAccuracyDistribution(cardA, cardB).then((dist) => { - if (dist) setDistribution(dist); - }); + getAccuracyDistribution(cardA, cardB) + .then((dist) => { + if (dist) setDistribution(dist); + }) + .catch((err) => { + console.error('[useQuickVote] Failed to fetch accuracy distribution:', err); + }); } }, [state, distribution, cardA, cardB]); @@ -70,18 +91,29 @@ export function useQuickVote(cardA: string, cardB: string): UseQuickVoteReturn { setState('submitting'); setError(null); - const result = await submitVote({cardA, cardB, accuracy}); + try { + const result = await submitVote({cardA, cardB, accuracy}); - if (result.error === null) { - storeVote(cardA, cardB, accuracy); - setUserChoice(accuracy); - setState('result'); - const dist = await getAccuracyDistribution(cardA, cardB); - if (dist) setDistribution(dist); - } else if (result.error === 'rate_limited') { - setError('rate_limited'); - setState('error'); - } else { + if (result.error === null) { + storeVote(cardA, cardB, accuracy); + setUserChoice(accuracy); + setState('result'); + try { + const dist = await getAccuracyDistribution(cardA, cardB); + if (dist) setDistribution(dist); + } catch (distErr) { + console.error('[useQuickVote] Failed to fetch distribution after vote:', distErr); + } + } else if (result.error === 'rate_limited') { + setError('rate_limited'); + setState('error'); + } else { + console.error('[useQuickVote] Vote submission failed:', result.error, {cardA, cardB}); + setError('error'); + setState('error'); + } + } catch (err) { + console.error('[useQuickVote] Unexpected error during vote submission:', err); setError('error'); setState('error'); } From 7e370f9e58f26b41e2d258a6de1a59dfe1e90527 Mon Sep 17 00:00:00 2001 From: johnn Date: Tue, 7 Apr 2026 12:38:37 +0300 Subject: [PATCH 10/12] fix(voting): address comprehensive PR review findings (#212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: add security_invoker to migration, optimistic userChoice, useEffect cancellation guard. Important: double-submit useRef guard, DistributionBar two-phase animation, loading indicator for null distribution, getAccuracyDistribution test coverage. Suggestions: safe removeItem, Record, rename error→submission_failed, consolidate Accuracy type, rate-limit auto-recovery. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../voting/components/DistributionBar.tsx | 11 +- .../components/QuickVoteControl.stories.tsx | 2 +- .../voting/components/QuickVoteControl.tsx | 24 ++-- .../__tests__/DistributionBar.test.tsx | 12 ++ .../__tests__/QuickVoteControl.test.tsx | 9 +- .../hooks/__tests__/useQuickVote.test.ts | 39 ++++++- .../src/features/voting/hooks/useQuickVote.ts | 54 ++++++--- .../src/shared/lib/__tests__/supabase.test.ts | 104 ++++++++++++++++++ apps/web/src/shared/lib/supabase.ts | 6 +- ...260406000001_add_accuracy_distribution.sql | 2 +- 10 files changed, 228 insertions(+), 35 deletions(-) diff --git a/apps/web/src/features/voting/components/DistributionBar.tsx b/apps/web/src/features/voting/components/DistributionBar.tsx index 7b39f8e..8d3f666 100644 --- a/apps/web/src/features/voting/components/DistributionBar.tsx +++ b/apps/web/src/features/voting/components/DistributionBar.tsx @@ -1,3 +1,4 @@ +import {useState, useEffect} from 'react'; import {COLORS, FONT_SIZES} from '../../../shared/constants'; interface DistributionBarProps { @@ -22,6 +23,14 @@ const LABELS = { export function DistributionBar({lower, right, higher, animate, showLabels = true}: DistributionBarProps) { const total = lower + right + higher; + const [mounted, setMounted] = useState(!animate); + + useEffect(() => { + if (!animate) return; + const id = requestAnimationFrame(() => setMounted(true)); + return () => cancelAnimationFrame(id); + }, [animate]); + if (total === 0) return null; const pct = { @@ -54,7 +63,7 @@ export function DistributionBar({lower, right, higher, animate, showLabels = tru key={key} style={{ flex: animate ? undefined : pct[key], - width: animate ? `${pct[key]}%` : undefined, + width: animate ? (mounted ? `${pct[key]}%` : '0%') : undefined, background: SEGMENT_COLORS[key].bg, display: 'flex', alignItems: 'center', diff --git a/apps/web/src/features/voting/components/QuickVoteControl.stories.tsx b/apps/web/src/features/voting/components/QuickVoteControl.stories.tsx index f6c187b..ee16728 100644 --- a/apps/web/src/features/voting/components/QuickVoteControl.stories.tsx +++ b/apps/web/src/features/voting/components/QuickVoteControl.stories.tsx @@ -47,7 +47,7 @@ export const ResultFirstVoter: Story = { }; export const Error: Story = { - args: {state: 'error', distribution: null, userChoice: null, error: 'error'}, + args: {state: 'error', distribution: null, userChoice: null, error: 'submission_failed'}, }; export const RateLimited: Story = { diff --git a/apps/web/src/features/voting/components/QuickVoteControl.tsx b/apps/web/src/features/voting/components/QuickVoteControl.tsx index 1cab989..502cbd2 100644 --- a/apps/web/src/features/voting/components/QuickVoteControl.tsx +++ b/apps/web/src/features/voting/components/QuickVoteControl.tsx @@ -12,13 +12,13 @@ interface QuickVoteControlProps { error: QuickVoteError; } -const CHOICE_LABELS: Record<-1 | 0 | 1, string> = { +const CHOICE_LABELS: Record = { [-1]: 'Should be lower', [0]: 'About right', [1]: 'Should be higher', }; -const CHOICE_COLORS: Record<-1 | 0 | 1, {border: string; glow: string}> = { +const CHOICE_COLORS: Record = { [-1]: {border: '#f59090', glow: 'rgba(245, 144, 144, 0.15)'}, [0]: {border: '#6ee7a0', glow: 'rgba(110, 231, 160, 0.15)'}, [1]: {border: '#60b5f5', glow: 'rgba(96, 181, 245, 0.15)'}, @@ -151,16 +151,18 @@ export function QuickVoteControl({ First to rate this pair!

+ ) : distribution ? ( + ) : ( - distribution && ( - - ) +

+ Loading community votes… +

)} {distribution && (

diff --git a/apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx b/apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx index 8ff4b96..25a33c7 100644 --- a/apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx +++ b/apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx @@ -32,4 +32,16 @@ describe('DistributionBar', () => { render(); expect(screen.getByText('100%')).toBeInTheDocument(); }); + + it('returns null when total is zero', () => { + const {container} = render(); + expect(container.firstChild).toBeNull(); + }); + + it('hides labels when showLabels is false', () => { + render(); + expect(screen.queryByText('Should be lower')).not.toBeInTheDocument(); + expect(screen.queryByText('About right')).not.toBeInTheDocument(); + expect(screen.queryByText('Should be higher')).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx b/apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx index 9012322..4dde822 100644 --- a/apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx +++ b/apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx @@ -5,6 +5,10 @@ import type {AccuracyDistribution} from '../../../../shared/lib/supabase'; const mockVote = vi.fn(); +beforeEach(() => { + mockVote.mockClear(); +}); + describe('QuickVoteControl', () => { it('renders nothing when state is hidden', () => { const {container} = render( @@ -75,7 +79,7 @@ describe('QuickVoteControl', () => { it('shows error message and re-enables buttons on error', () => { render( - , + , ); expect(screen.getByText('Something went wrong, try again')).toBeInTheDocument(); // Vote buttons should be enabled (not the teaser) @@ -91,12 +95,13 @@ describe('QuickVoteControl', () => { expect(screen.getByRole('button', {name: 'Should be higher'})).toBeDisabled(); }); - it('shows confirmation without distribution bar when distribution is null', () => { + it('shows loading text when distribution is null in result state', () => { render( , ); expect(screen.getByText(/You voted:/)).toBeInTheDocument(); expect(screen.getByText('About right')).toBeInTheDocument(); + expect(screen.getByText('Loading community votes…')).toBeInTheDocument(); expect(screen.queryByText(/votes on this pair/)).not.toBeInTheDocument(); }); diff --git a/apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts b/apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts index 5e63fe9..94ba324 100644 --- a/apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts +++ b/apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts @@ -85,7 +85,8 @@ describe('useQuickVote', () => { }); expect(result.current.state).toBe('error'); - expect(result.current.error).toBe('error'); + expect(result.current.error).toBe('submission_failed'); + expect(result.current.userChoice).toBeNull(); expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); }); @@ -140,5 +141,41 @@ describe('useQuickVote', () => { expect(result.current.state).toBe('error'); expect(result.current.error).toBe('rate_limited'); + expect(result.current.userChoice).toBeNull(); + }); + + it('sets userChoice optimistically during submitting', async () => { + vi.mocked(getSupabase).mockReturnValue({} as ReturnType); + let resolveVote: (v: {error: null}) => void; + vi.mocked(submitVote).mockReturnValue(new Promise((r) => { resolveVote = r; })); + + const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B)); + + // Start voting but don't resolve yet + act(() => { result.current.vote(1); }); + expect(result.current.state).toBe('submitting'); + expect(result.current.userChoice).toBe(1); + + // Resolve and clean up + vi.mocked(getAccuracyDistribution).mockResolvedValue({lower: 0, right: 0, higher: 1, total: 1}); + await act(async () => { resolveVote!({error: null}); }); + expect(result.current.state).toBe('result'); + }); + + it('ignores vote() calls from result state (double-submit guard)', async () => { + vi.mocked(getSupabase).mockReturnValue({} as ReturnType); + vi.mocked(submitVote).mockResolvedValue({error: null}); + vi.mocked(getAccuracyDistribution).mockResolvedValue({lower: 0, right: 1, higher: 0, total: 1}); + + const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B)); + + await act(async () => { await result.current.vote(0); }); + expect(result.current.state).toBe('result'); + + // Try voting again from result state — should be a no-op + await act(async () => { await result.current.vote(-1); }); + expect(submitVote).toHaveBeenCalledTimes(1); + expect(result.current.state).toBe('result'); + expect(result.current.userChoice).toBe(0); }); }); diff --git a/apps/web/src/features/voting/hooks/useQuickVote.ts b/apps/web/src/features/voting/hooks/useQuickVote.ts index 46b1c06..28af227 100644 --- a/apps/web/src/features/voting/hooks/useQuickVote.ts +++ b/apps/web/src/features/voting/hooks/useQuickVote.ts @@ -1,14 +1,15 @@ -import {useState, useEffect, useCallback, useMemo} from 'react'; +import {useState, useEffect, useCallback, useMemo, useRef} from 'react'; import { getSupabase, submitVote, getAccuracyDistribution, type AccuracyDistribution, + type Accuracy, } from '../../../shared/lib/supabase'; +export type {Accuracy}; export type QuickVoteState = 'hidden' | 'ready' | 'submitting' | 'result' | 'error'; -export type QuickVoteError = 'error' | 'rate_limited' | null; -export type Accuracy = -1 | 0 | 1; +export type QuickVoteError = 'submission_failed' | 'rate_limited' | null; function storageKey(cardA: string, cardB: string): string { const [a, b] = [cardA, cardB].sort(); @@ -30,11 +31,11 @@ function getStoredVote(cardA: string, cardB: string): Accuracy | null { const accuracy = parsed?.accuracy; if (accuracy === -1 || accuracy === 0 || accuracy === 1) return accuracy; console.error('[getStoredVote] Stored value has unexpected shape, clearing:', parsed); - localStorage.removeItem(key); + try { localStorage.removeItem(key); } catch { /* storage cleanup failed */ } return null; } catch (e) { console.error('[getStoredVote] Corrupted JSON in storage, clearing key:', key, e); - localStorage.removeItem(key); + try { localStorage.removeItem(key); } catch { /* storage cleanup failed */ } return null; } } @@ -70,25 +71,42 @@ export function useQuickVote(cardA: string, cardB: string): UseQuickVoteReturn { const [userChoice, setUserChoice] = useState(storedChoice); const [distribution, setDistribution] = useState(null); const [error, setError] = useState(null); + const submittingRef = useRef(false); // Fetch distribution for returning voters useEffect(() => { - if (state === 'result' && !distribution) { - getAccuracyDistribution(cardA, cardB) - .then((dist) => { - if (dist) setDistribution(dist); - }) - .catch((err) => { + if (state !== 'result' || distribution) return; + let cancelled = false; + getAccuracyDistribution(cardA, cardB) + .then((dist) => { + if (!cancelled && dist) setDistribution(dist); + }) + .catch((err) => { + if (!cancelled) { console.error('[useQuickVote] Failed to fetch accuracy distribution:', err); - }); - } + } + }); + return () => { cancelled = true; }; }, [state, distribution, cardA, cardB]); + // Auto-recover from rate limit after 30s + useEffect(() => { + if (error !== 'rate_limited') return; + const timer = setTimeout(() => { + setState('ready'); + setError(null); + }, 30_000); + return () => clearTimeout(timer); + }, [error]); + const vote = useCallback( async (accuracy: Accuracy) => { if (state !== 'ready' && state !== 'error') return; + if (submittingRef.current) return; + submittingRef.current = true; setState('submitting'); + setUserChoice(accuracy); setError(null); try { @@ -96,7 +114,6 @@ export function useQuickVote(cardA: string, cardB: string): UseQuickVoteReturn { if (result.error === null) { storeVote(cardA, cardB, accuracy); - setUserChoice(accuracy); setState('result'); try { const dist = await getAccuracyDistribution(cardA, cardB); @@ -105,17 +122,22 @@ export function useQuickVote(cardA: string, cardB: string): UseQuickVoteReturn { console.error('[useQuickVote] Failed to fetch distribution after vote:', distErr); } } else if (result.error === 'rate_limited') { + setUserChoice(null); setError('rate_limited'); setState('error'); } else { console.error('[useQuickVote] Vote submission failed:', result.error, {cardA, cardB}); - setError('error'); + setUserChoice(null); + setError('submission_failed'); setState('error'); } } catch (err) { console.error('[useQuickVote] Unexpected error during vote submission:', err); - setError('error'); + setUserChoice(null); + setError('submission_failed'); setState('error'); + } finally { + submittingRef.current = false; } }, [cardA, cardB, state], diff --git a/apps/web/src/shared/lib/__tests__/supabase.test.ts b/apps/web/src/shared/lib/__tests__/supabase.test.ts index 2062d5c..82e7be8 100644 --- a/apps/web/src/shared/lib/__tests__/supabase.test.ts +++ b/apps/web/src/shared/lib/__tests__/supabase.test.ts @@ -3,6 +3,7 @@ import { getSupabase, submitVote, getPairScore, + getAccuracyDistribution, _resetClient, } from '../supabase'; @@ -239,3 +240,106 @@ describe('getPairScore', () => { vi.unstubAllEnvs(); }); }); + +describe('getAccuracyDistribution', () => { + it('returns null when Supabase is not configured', async () => { + const result = await getAccuracyDistribution('a', 'b'); + expect(result).toBeNull(); + expect(mockFrom).not.toHaveBeenCalled(); + }); + + it('queries pair_scores with canonical pair order', async () => { + vi.stubEnv('VITE_SUPABASE_URL', 'https://test.supabase.co'); + vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-key'); + mockSingle.mockResolvedValue({ + data: {accuracy_lower: 2, accuracy_right: 8, accuracy_higher: 1}, + }); + + // Pass in reverse order — should sort to (aaa, zzz) + await getAccuracyDistribution('zzz', 'aaa'); + + expect(mockFrom).toHaveBeenCalledWith('pair_scores'); + expect(mockSelect).toHaveBeenCalledWith('accuracy_lower, accuracy_right, accuracy_higher'); + expect(mockEq1).toHaveBeenCalledWith('card_a_id', 'aaa'); + expect(mockEq2).toHaveBeenCalledWith('card_b_id', 'zzz'); + + vi.unstubAllEnvs(); + }); + + it('returns distribution with computed total', async () => { + vi.stubEnv('VITE_SUPABASE_URL', 'https://test.supabase.co'); + vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-key'); + mockSingle.mockResolvedValue({ + data: {accuracy_lower: 3, accuracy_right: 10, accuracy_higher: 2}, + }); + + const result = await getAccuracyDistribution('a', 'b'); + expect(result).toEqual({lower: 3, right: 10, higher: 2, total: 15}); + + vi.unstubAllEnvs(); + }); + + it('handles null column values (nullable database columns)', async () => { + vi.stubEnv('VITE_SUPABASE_URL', 'https://test.supabase.co'); + vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-key'); + mockSingle.mockResolvedValue({ + data: {accuracy_lower: null, accuracy_right: null, accuracy_higher: null}, + }); + + const result = await getAccuracyDistribution('a', 'b'); + expect(result).toEqual({lower: 0, right: 0, higher: 0, total: 0}); + + vi.unstubAllEnvs(); + }); + + it('returns null for PGRST116 (no rows found)', async () => { + vi.stubEnv('VITE_SUPABASE_URL', 'https://test.supabase.co'); + vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-key'); + mockSingle.mockResolvedValue({ + data: null, + error: {code: 'PGRST116', message: 'No rows found'}, + }); + + const result = await getAccuracyDistribution('a', 'b'); + expect(result).toBeNull(); + + vi.unstubAllEnvs(); + }); + + it('returns null and logs on query error', async () => { + vi.stubEnv('VITE_SUPABASE_URL', 'https://test.supabase.co'); + vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-key'); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockSingle.mockResolvedValue({ + data: null, + error: {code: '42P01', message: 'relation does not exist'}, + }); + + const result = await getAccuracyDistribution('a', 'b'); + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + '[getAccuracyDistribution] Supabase query failed:', + expect.objectContaining({code: '42P01'}), + ); + + consoleSpy.mockRestore(); + vi.unstubAllEnvs(); + }); + + it('catches network exceptions', async () => { + vi.stubEnv('VITE_SUPABASE_URL', 'https://test.supabase.co'); + vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-key'); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockSingle.mockRejectedValue(new TypeError('Failed to fetch')); + + const result = await getAccuracyDistribution('a', 'b'); + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + '[getAccuracyDistribution] Network error:', + expect.any(TypeError), + ); + + consoleSpy.mockRestore(); + vi.unstubAllEnvs(); + }); +}); diff --git a/apps/web/src/shared/lib/supabase.ts b/apps/web/src/shared/lib/supabase.ts index 39f4b6b..8d58a45 100644 --- a/apps/web/src/shared/lib/supabase.ts +++ b/apps/web/src/shared/lib/supabase.ts @@ -38,10 +38,12 @@ export function _resetClient(): void { // --- Vote types --- +export type Accuracy = -1 | 0 | 1; + export type QuickVote = { cardA: string; cardB: string; - accuracy: -1 | 0 | 1; + accuracy: Accuracy; }; // Fix #6: Constrain score to 1-10 @@ -50,7 +52,7 @@ export type Score = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; export type InDepthVote = { cardA: string; cardB: string; - accuracy?: -1 | 0 | 1; + accuracy?: Accuracy; isReal?: boolean; score?: Score; wouldPlay?: boolean; diff --git a/supabase/migrations/20260406000001_add_accuracy_distribution.sql b/supabase/migrations/20260406000001_add_accuracy_distribution.sql index 444bd2f..bfb0727 100644 --- a/supabase/migrations/20260406000001_add_accuracy_distribution.sql +++ b/supabase/migrations/20260406000001_add_accuracy_distribution.sql @@ -1,6 +1,6 @@ drop view if exists pair_scores; -create view pair_scores as +create view pair_scores with (security_invoker = true) as select card_a_id, card_b_id, From 24aa0070a75f978cb573c2c5986f4ffc536a586f Mon Sep 17 00:00:00 2001 From: johnn Date: Tue, 7 Apr 2026 12:47:13 +0300 Subject: [PATCH 11/12] fix(test): add missing hook mocks to SynergyDetailModal test (#212) The shared/hooks mock only provided useTransitionPresence, leaving useResponsive and useScrollLock undefined. On Linux CI (identical path resolution), QuickVoteControl's useResponsive import hits the same mock and crashes. On Windows, different path canonicalization meant the mock didn't intercept the child component's import. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../synergies/components/__tests__/SynergyDetailModal.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx b/apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx index 5c56fb0..3aaee6e 100644 --- a/apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx +++ b/apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx @@ -27,6 +27,8 @@ vi.mock('../../../shared/hooks', () => ({ visible: isOpen, onTransitionEnd: vi.fn(), }), + useScrollLock: vi.fn(), + useResponsive: () => ({isMobile: false}), })); vi.mock('../../../cards', () => ({ From 1c4cdacc61d07f9f0e8675043fea5a4c4b34c230 Mon Sep 17 00:00:00 2001 From: johnn Date: Tue, 7 Apr 2026 13:01:57 +0300 Subject: [PATCH 12/12] fix(test): correct vi.mock paths for cross-platform CI (#212) Mock paths in __tests__/ must account for being one directory deeper than the component. Add missing shared/components stubs (RenderProfiler, CardLightbox, StrengthBadge). On Windows, wrong paths silently fell through to real modules; on Linux CI, Vitest correctly resolves and intercepts them, requiring complete mock factories. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/__tests__/SynergyDetailModal.test.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx b/apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx index 3aaee6e..601c797 100644 --- a/apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx +++ b/apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx @@ -3,7 +3,7 @@ import {render, screen, fireEvent} from '@testing-library/react'; import {SynergyDetailModal} from '../SynergyDetailModal'; import {createCard, createConnection, createPairSynergy} from '../../../../shared/test-utils'; -vi.mock('../../voting/hooks', () => ({ +vi.mock('../../../voting/hooks', () => ({ useQuickVote: vi.fn().mockReturnValue({ state: 'ready', vote: vi.fn(), @@ -13,15 +13,18 @@ vi.mock('../../voting/hooks', () => ({ }), })); -vi.mock('../../../shared/components', () => ({ +vi.mock('../../../../shared/components', () => ({ CardImage: ({alt}: {alt: string}) =>

{alt}
, + CardLightbox: () => null, + RenderProfiler: ({children}: {children: React.ReactNode}) => <>{children}, + StrengthBadge: ({score}: {score: number}) => {score}, })); -vi.mock('../../../shared/hooks/useDialogFocus', () => ({ +vi.mock('../../../../shared/hooks/useDialogFocus', () => ({ useDialogFocus: () => ({handleKeyDown: vi.fn()}), })); -vi.mock('../../../shared/hooks', () => ({ +vi.mock('../../../../shared/hooks', () => ({ useTransitionPresence: (isOpen: boolean) => ({ mounted: isOpen, visible: isOpen,