Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/web/src/features/synergies/components/SynergyDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -151,6 +154,17 @@ export function SynergyDetailModal({
</h2>
</div>

{/* Quick vote */}
<div style={{padding: '0 24px 16px'}}>
<QuickVoteControl
state={quickVote.state}
onVote={quickVote.vote}
distribution={quickVote.distribution}
userChoice={quickVote.userChoice}
error={quickVote.error}
/>
</div>

{/* Connections list */}
{connections.length > 0 && (
<ConnectionsSection connections={connections} cardA={cardA} cardB={cardB} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}) => <div data-testid="card-image">{alt}</div>,
CardLightbox: () => null,
RenderProfiler: ({children}: {children: React.ReactNode}) => <>{children}</>,
StrengthBadge: ({score}: {score: number}) => <span data-testid="strength-badge">{score}</span>,
}));

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', () => ({
Expand Down Expand Up @@ -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(
<SynergyDetailModal
isOpen={true}
onClose={vi.fn()}
pair={mockPair}
onViewSynergies={vi.fn()}
/>,
);
expect(screen.getByText('Do you agree with this score?')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -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) => (
<div style={{width: 400, background: '#1a1a2e', padding: 24, borderRadius: 12}}>
<Story />
</div>
),
],
} satisfies Meta<typeof DistributionBar>;

export default meta;
type Story = StoryObj<typeof meta>;

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},
};
105 changes: 105 additions & 0 deletions apps/web/src/features/voting/components/DistributionBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<div
style={{
display: 'flex',
height: 28,
borderRadius: 6,
overflow: 'hidden',
gap: 2,
}}>
{segments.map((key, i) => (
<div
key={key}
style={{
flex: animate ? undefined : pct[key],
width: animate ? (mounted ? `${pct[key]}%` : '0%') : undefined,
background: SEGMENT_COLORS[key].bg,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: `${FONT_SIZES.xs}px`,
color: SEGMENT_COLORS[key].text,
fontWeight: 600,
borderRadius:
i === 0 && segments.length > 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]}%
</div>
))}
</div>
{showLabels && (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: 4,
fontSize: `${FONT_SIZES.xs}px`,
color: COLORS.textMuted,
}}>
<span>{LABELS.lower}</span>
<span>{LABELS.right}</span>
<span>{LABELS.higher}</span>
</div>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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) => (
<div style={{width: 500, background: '#1a1a2e', padding: 24, borderRadius: 12}}>
<Story />
</div>
),
],
args: {onVote: fn()},
} satisfies Meta<typeof QuickVoteControl>;

export default meta;
type Story = StoryObj<typeof meta>;

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'}},
};
Loading
Loading