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..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,20 +3,35 @@ import {render, screen, fireEvent} from '@testing-library/react'; import {SynergyDetailModal} from '../SynergyDetailModal'; import {createCard, createConnection, createPairSynergy} from '../../../../shared/test-utils'; -vi.mock('../../../shared/components', () => ({ +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}
, + 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, onTransitionEnd: vi.fn(), }), + useScrollLock: vi.fn(), + useResponsive: () => ({isMobile: false}), })); vi.mock('../../../cards', () => ({ @@ -135,4 +150,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(); + }); }); 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..8d3f666 --- /dev/null +++ b/apps/web/src/features/voting/components/DistributionBar.tsx @@ -0,0 +1,105 @@ +import {useState, useEffect} from 'react'; +import {COLORS, FONT_SIZES} from '../../../shared/constants'; + +interface DistributionBarProps { + lower: number; + right: number; + higher: number; + animate: boolean; + showLabels?: 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, 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 = { + 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]}% +
+ ))} +
+ {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..ee16728 --- /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: 'submission_failed'}, +}; + +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..502cbd2 --- /dev/null +++ b/apps/web/src/features/voting/components/QuickVoteControl.tsx @@ -0,0 +1,208 @@ +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, Accuracy} from '../hooks/useQuickVote'; +import {DistributionBar} from './DistributionBar'; + +interface QuickVoteControlProps { + state: QuickVoteState; + onVote: (accuracy: Accuracy) => void; + distribution: AccuracyDistribution | null; + userChoice: Accuracy | null; + error: QuickVoteError; +} + +const CHOICE_LABELS: Record = { + [-1]: 'Should be lower', + [0]: 'About right', + [1]: 'Should be higher', +}; + +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)'}, +}; + +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}px`, + 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}px`, + 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}px`, + 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: Accuracy | null; + onVote: (accuracy: Accuracy) => 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 ? ( + + ) : ( +

+ Loading community votes… +

+ )} + {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__/DistributionBar.test.tsx b/apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx new file mode 100644 index 0000000..25a33c7 --- /dev/null +++ b/apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx @@ -0,0 +1,47 @@ +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('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(); + }); + + 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 new file mode 100644 index 0000000..4dde822 --- /dev/null +++ b/apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx @@ -0,0 +1,122 @@ +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(); + +beforeEach(() => { + mockVote.mockClear(); +}); + +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('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 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(); + }); + + 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'; 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..94ba324 --- /dev/null +++ b/apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts @@ -0,0 +1,181 @@ +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('submission_failed'); + expect(result.current.userChoice).toBeNull(); + 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'}); + + 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'); + 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/index.ts b/apps/web/src/features/voting/hooks/index.ts index b523cdf..d5ff8c2 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, Accuracy} 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..28af227 --- /dev/null +++ b/apps/web/src/features/voting/hooks/useQuickVote.ts @@ -0,0 +1,147 @@ +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 = 'submission_failed' | 'rate_limited' | null; + +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 { + 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 parsed = JSON.parse(raw); + const accuracy = parsed?.accuracy; + if (accuracy === -1 || accuracy === 0 || accuracy === 1) return accuracy; + console.error('[getStoredVote] Stored value has unexpected shape, clearing:', parsed); + try { localStorage.removeItem(key); } catch { /* storage cleanup failed */ } + return null; + } catch (e) { + console.error('[getStoredVote] Corrupted JSON in storage, clearing key:', key, e); + try { localStorage.removeItem(key); } catch { /* storage cleanup failed */ } + return null; + } +} + +function storeVote(cardA: string, cardB: string, accuracy: Accuracy): void { + 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 { + 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); + const submittingRef = useRef(false); + + // Fetch distribution for returning voters + useEffect(() => { + 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 { + const result = await submitVote({cardA, cardB, accuracy}); + + if (result.error === null) { + storeVote(cardA, cardB, 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') { + setUserChoice(null); + setError('rate_limited'); + setState('error'); + } else { + console.error('[useQuickVote] Vote submission failed:', result.error, {cardA, cardB}); + setUserChoice(null); + setError('submission_failed'); + setState('error'); + } + } catch (err) { + console.error('[useQuickVote] Unexpected error during vote submission:', err); + setUserChoice(null); + setError('submission_failed'); + setState('error'); + } finally { + submittingRef.current = false; + } + }, + [cardA, cardB, state], + ); + + return {state, vote, distribution, userChoice, error}; +} 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/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/apps/web/src/shared/lib/supabase.ts b/apps/web/src/shared/lib/supabase.ts index f5f1abf..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; @@ -140,3 +142,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; + } +} 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` | 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` | diff --git a/supabase/migrations/20260406000001_add_accuracy_distribution.sql b/supabase/migrations/20260406000001_add_accuracy_distribution.sql new file mode 100644 index 0000000..bfb0727 --- /dev/null +++ b/supabase/migrations/20260406000001_add_accuracy_distribution.sql @@ -0,0 +1,30 @@ +drop view if exists pair_scores; + +create view pair_scores with (security_invoker = true) 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;