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
17 changes: 17 additions & 0 deletions src/api/voting/useVoteMutation.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ describe("vote", () => {
expect(data?.vote_type).toBe(2);
});

it("persists a Neutral (0) vote, proving the DB constraint allows it", async () => {
const userId = await signInAsTestUser();
const setId = await createSet();

await vote({ setId, voteType: 0, userId });

const { data, error } = await testSupabase
.from("votes")
.select("vote_type")
.eq("user_id", userId)
.eq("set_id", setId)
.single();

expect(error).toBeNull();
expect(data?.vote_type).toBe(0);
});

it("rejects an unauthenticated vote with the real RLS-denial error", async () => {
const setId = await createSet();

Expand Down
6 changes: 6 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@
--vote-skip: 218.8 9.2% 63.7%; /* #9aa0ab */
--vote-skip-foreground: 216.9 11.9% 78.6%; /* #c2c7cf */
--vote-skip-soft: 218.8 9.2% 63.7% / 0.14;
--vote-neutral: 270 20% 65%; /* #a29ab8 */
--vote-neutral-foreground: 270 24% 79%; /* #cbc4dc */
--vote-neutral-soft: 270 20% 65% / 0.14;

--radius: 0.75rem;

Expand Down Expand Up @@ -217,6 +220,9 @@
--vote-skip: 218.8 8.5% 39%; /* #5b616c */
--vote-skip-foreground: 218.8 8.5% 39%;
--vote-skip-soft: 218.8 8.5% 39% / 0.12;
--vote-neutral: 270 24% 41%; /* #6b5a86 */
--vote-neutral-foreground: 270 24% 41%;
--vote-neutral-soft: 270 24% 41% / 0.12;
}

.dark {
Expand Down
28 changes: 22 additions & 6 deletions src/lib/voteConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
getVoteValue,
type VoteType,
} from "./voteConfig";
import { Star, Heart, X } from "lucide-react";
import { Star, Heart, X, Minus } from "lucide-react";

describe("VOTE_CONFIG", () => {
it("has correct structure for mustGo", () => {
Expand All @@ -30,6 +30,13 @@ describe("VOTE_CONFIG", () => {
expect(VOTE_CONFIG.wontGo.icon).toBe(X);
});

it("has correct structure for neutral", () => {
expect(VOTE_CONFIG.neutral).toBeDefined();
expect(VOTE_CONFIG.neutral.value).toBe(0);
expect(VOTE_CONFIG.neutral.label).toBe("Neutral");
expect(VOTE_CONFIG.neutral.icon).toBe(Minus);
});

it("has consistent properties across all vote types", () => {
const requiredProps = [
"value",
Expand Down Expand Up @@ -72,11 +79,11 @@ describe("VOTE_CONFIG", () => {

describe("VOTES_TYPES", () => {
it("contains all vote types", () => {
expect(VOTES_TYPES).toEqual(["mustGo", "interested", "wontGo"]);
expect(VOTES_TYPES).toEqual(["mustGo", "interested", "wontGo", "neutral"]);
});

it("is a readonly array", () => {
expect(VOTES_TYPES).toHaveLength(3);
expect(VOTES_TYPES).toHaveLength(4);
});
});

Expand All @@ -93,8 +100,11 @@ describe("getVoteConfig", () => {
expect(getVoteConfig(-1)).toBe("wontGo");
});

it("returns correct vote type for value 0", () => {
expect(getVoteConfig(0)).toBe("neutral");
});

it("returns undefined for invalid values", () => {
expect(getVoteConfig(0)).toBeUndefined();
expect(getVoteConfig(3)).toBeUndefined();
expect(getVoteConfig(-2)).toBeUndefined();
expect(getVoteConfig(999)).toBeUndefined();
Expand All @@ -111,6 +121,7 @@ describe("getVoteConfig", () => {
[2, "mustGo"],
[1, "interested"],
[-1, "wontGo"],
[0, "neutral"],
];

validMappings.forEach(([value, expectedType]) => {
Expand All @@ -132,11 +143,16 @@ describe("getVoteValue", () => {
expect(getVoteValue("wontGo")).toBe(-1);
});

it("returns 0 for neutral", () => {
expect(getVoteValue("neutral")).toBe(0);
});

it("returns correct values for all vote types", () => {
const expectedValues: Record<VoteType, -1 | 1 | 2> = {
const expectedValues: Record<VoteType, -1 | 0 | 1 | 2> = {
mustGo: 2,
interested: 1,
wontGo: -1,
neutral: 0,
};

VOTES_TYPES.forEach((voteType) => {
Expand All @@ -147,7 +163,7 @@ describe("getVoteValue", () => {

describe("getVoteConfig and getVoteValue integration", () => {
it("should be inverse operations for valid values", () => {
const validValues = [2, 1, -1];
const validValues = [2, 1, -1, 0];

validValues.forEach((value) => {
const voteType = getVoteConfig(value);
Expand Down
31 changes: 27 additions & 4 deletions src/lib/voteConfig.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Star, Heart, X } from "lucide-react";
import { Star, Heart, X, Minus } from "lucide-react";

export const VOTES_TYPES = ["mustGo", "interested", "wontGo"] as const;
export const VOTES_TYPES = [
"mustGo",
"interested",
"wontGo",
"neutral",
] as const;
export type VoteType = (typeof VOTES_TYPES)[number];

export const VOTE_CONFIG = {
Expand Down Expand Up @@ -58,10 +63,28 @@ export const VOTE_CONFIG = {
spinnerColor: "border-vote-skip-foreground",
description: "Artists you'd prefer to skip (-1 point)",
},
neutral: {
value: 0,
label: "Neutral",
icon: Minus,
bgColor: "bg-vote-neutral-soft",
iconColor: "text-vote-neutral",
textColor: "text-vote-neutral-foreground",
descColor: "text-vote-neutral-foreground",
circleColor: "bg-vote-neutral",
buttonSelected:
"border border-vote-neutral bg-[hsl(var(--vote-neutral)/0.28)] text-vote-neutral hover:bg-[hsl(var(--vote-neutral)/0.34)]",
buttonUnselected:
"border-vote-neutral-foreground text-vote-neutral-foreground hover:bg-vote-neutral-soft hover:text-vote-neutral hover:border-vote-neutral",
chipUnselected:
"text-vote-neutral hover:bg-vote-neutral-soft hover:text-vote-neutral",
spinnerColor: "border-vote-neutral-foreground",
description: "Sets you don't have a strong opinion on (0 points)",
},
} as const;

export type VoteConfig = {
value: -1 | 1 | 2;
value: -1 | 0 | 1 | 2;
label: string;
icon: typeof Star;
bgColor: string;
Expand All @@ -82,6 +105,6 @@ export function getVoteConfig(voteValue: number): VoteType | undefined {
);
}

export function getVoteValue(voteType: VoteType): -1 | 1 | 2 {
export function getVoteValue(voteType: VoteType): -1 | 0 | 1 | 2 {
return VOTE_CONFIG[voteType].value;
}
2 changes: 1 addition & 1 deletion src/pages/EditionView/tabs/ScheduleTab/VoteButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function VoteButtons({ set }: VoteButtonsProps) {

const userVote = userVotesQuery.data?.[set.id];
const userVoteType = useMemo(() => {
return userVote ? getVoteConfig(userVote) : undefined;
return userVote !== undefined ? getVoteConfig(userVote) : undefined;
}, [userVote]);

const votesMap = useMemo(() => {
Expand Down
32 changes: 32 additions & 0 deletions src/pages/ExploreSetPage/VotingActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,26 @@ describe("VotingActions", () => {
expect(
screen.getByRole("button", { name: VOTE_CONFIG.wontGo.label }),
).toHaveAttribute("aria-pressed", "false");
expect(
screen.getByRole("button", { name: VOTE_CONFIG.neutral.label }),
).toHaveAttribute("aria-pressed", "false");
});

it("marks the Neutral button as pressed when currentVote is 0", () => {
render(
<VotingActions
onVote={vi.fn()}
onSkip={vi.fn()}
currentVote={VOTE_CONFIG.neutral.value}
/>,
);

expect(
screen.getByRole("button", { name: VOTE_CONFIG.neutral.label }),
).toHaveAttribute("aria-pressed", "true");
expect(
screen.getByRole("button", { name: VOTE_CONFIG.mustGo.label }),
).toHaveAttribute("aria-pressed", "false");
});

it("marks the Must Go button as pressed when currentVote matches it", () => {
Expand Down Expand Up @@ -62,6 +82,18 @@ describe("VotingActions", () => {
expect(onVote).toHaveBeenCalledWith(VOTE_CONFIG.mustGo.value);
});

it("calls onVote with 0 when the Neutral button is clicked", async () => {
const user = userEvent.setup();
const onVote = vi.fn();
render(<VotingActions onVote={onVote} onSkip={vi.fn()} />);

await user.click(
screen.getByRole("button", { name: VOTE_CONFIG.neutral.label }),
);

expect(onVote).toHaveBeenCalledWith(VOTE_CONFIG.neutral.value);
});

it("calls onSkip when the Skip button is clicked", async () => {
const user = userEvent.setup();
const onSkip = vi.fn();
Expand Down
12 changes: 12 additions & 0 deletions src/pages/ExploreSetPage/VotingActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function VotingActions({
const wontGoConfig = VOTE_CONFIG.wontGo;
const interestedConfig = VOTE_CONFIG.interested;
const mustGoConfig = VOTE_CONFIG.mustGo;
const neutralConfig = VOTE_CONFIG.neutral;

// Calculate highlight intensity based on drag feedback
const isLeftDrag = dragFeedback?.direction === "left";
Expand Down Expand Up @@ -52,6 +53,17 @@ export function VotingActions({
</Button>
</motion.div>

<VoteButton
icon={neutralConfig.icon}
label={neutralConfig.label}
isSelected={currentVote === neutralConfig.value}
selectedClassName="bg-[hsl(var(--vote-neutral)/0.28)] border-vote-neutral text-vote-neutral shadow-lg"
unselectedClassName="border-vote-neutral hover:bg-vote-neutral-soft text-vote-neutral"
scale={1}
opacity={isLeftDrag || isRightDrag ? 0.5 : 1}
onClick={() => onVote(neutralConfig.value)}
/>

<VoteButton
icon={mustGoConfig.icon}
label={mustGoConfig.label}
Expand Down
28 changes: 28 additions & 0 deletions src/pages/ExploreSetPage/useExplorableSets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,34 @@ describe("useExplorableSets", () => {
expect(result.current.data.map((s) => s.id).sort()).toEqual(["a", "c"]);
});

it("excludes a set voted Neutral (0), not just truthy vote values", () => {
mockSetsQuery([makeSet("a"), makeSet("b"), makeSet("c")]);

const { result } = renderHook(() =>
useExplorableSets({
editionId: "edition-1",
userVotes: { b: 0 },
votesReady: true,
}),
);

expect(result.current.data.map((s) => s.id).sort()).toEqual(["a", "c"]);
});

it("counts a Neutral (0) vote toward votedCount", () => {
mockSetsQuery([makeSet("a"), makeSet("b"), makeSet("c")]);

const { result } = renderHook(() =>
useExplorableSets({
editionId: "edition-1",
userVotes: { a: 0 },
votesReady: true,
}),
);

expect(result.current.votedCount).toBe(1);
});

it("keeps the currently displayed set in the queue after it is voted on", () => {
mockSetsQuery([makeSet("a"), makeSet("b"), makeSet("c")]);

Expand Down
4 changes: 2 additions & 2 deletions src/pages/ExploreSetPage/useExplorableSets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function useExplorableSets({

if (queue === null && allSets.length > 0 && votesReady) {
const validSets = allSets.filter(
(set) => hasExplorableData(set) && !userVotes[set.id],
(set) => hasExplorableData(set) && userVotes[set.id] === undefined,
);
setQueue(shuffle(validSets));
}
Expand All @@ -32,7 +32,7 @@ export function useExplorableSets({
let votedCount = 0;
let nonExplorableCount = 0;
for (const set of allSets) {
if (userVotes[set.id]) {
if (userVotes[set.id] !== undefined) {
votedCount++;
} else if (!hasExplorableData(set)) {
nonExplorableCount++;
Expand Down
3 changes: 2 additions & 1 deletion src/pages/SetDetails/SetGroupVoting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function SetGroupVotingContent({
2: groupVotes.filter((vote) => vote.vote_type === 2).length,
1: groupVotes.filter((vote) => vote.vote_type === 1).length,
[-1]: groupVotes.filter((vote) => vote.vote_type === -1).length,
0: groupVotes.filter((vote) => vote.vote_type === 0).length,
};

const activeGroup = groups.find((g) => g.id === activeGroupId);
Expand Down Expand Up @@ -78,7 +79,7 @@ function SetGroupVotingContent({
) : (
<div className="space-y-4">
{/* Vote Summary */}
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-4 gap-4">
{VOTES_TYPES.map((voteTypeKey) => {
const config = VOTE_CONFIG[voteTypeKey];
const voteType = config.value;
Expand Down
6 changes: 6 additions & 0 deletions src/pages/SetDetails/SetVotingButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ export function SetVotingButtons({ set }: SetVotingButtonsProps) {
onClick={() => handleVote(-1)}
count={getVoteCount(-1)}
/>
<VoteButton
voteType={0}
isActive={userVoteForSet === 0}
onClick={() => handleVote(0)}
count={getVoteCount(0)}
/>
</div>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,14 @@ function ExploreSetPageContent({
}

const existingVote = userVotes[currentSet.id];
// Only "Won't Go" advances to the next artist, matching the explicit
// skip action. "Must Go" / "Interested" just cast the vote and stay.
const isWontGo = voteType === VOTE_CONFIG.wontGo.value;

if (isWontGo) {
// "Won't Go" and "Neutral" both settle the decision on this artist, so
// they advance to the next one, matching the explicit skip action.
// "Must Go" / "Interested" just cast the vote and stay.
Comment on lines +143 to +145
const advancesQueue =
voteType === VOTE_CONFIG.wontGo.value ||
voteType === VOTE_CONFIG.neutral.value;

if (advancesQueue) {
setIsAnimating(true);
}

Expand All @@ -157,11 +160,11 @@ function ExploreSetPageContent({
},
{
onSuccess: () => {
if (isWontGo) advanceToNextOrExit();
if (advancesQueue) advanceToNextOrExit();
},
onError: (error) => {
console.error("Failed to vote:", error);
if (isWontGo) setIsAnimating(false);
if (advancesQueue) setIsAnimating(false);
},
},
);
Expand Down
8 changes: 8 additions & 0 deletions supabase/migrations/20260917151416_add_neutral_vote_type.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Adds the neutral vote type (0) alongside the existing -1 (Won't Go),
-- 1 (Interested), 2 (Must Go). A neutral vote is a real row distinct from
-- having no vote at all, so it can be excluded from Explore and filtered
-- in Schedule the same way the other vote types are.
ALTER TABLE public.votes DROP CONSTRAINT IF EXISTS votes_type_check;

ALTER TABLE public.votes
ADD CONSTRAINT votes_type_check CHECK (vote_type IN (-1, 0, 1, 2));
Comment on lines +5 to +8
5 changes: 5 additions & 0 deletions tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ export default {
foreground: "hsl(var(--vote-skip-foreground))",
soft: "hsl(var(--vote-skip-soft))",
},
neutral: {
DEFAULT: "hsl(var(--vote-neutral))",
foreground: "hsl(var(--vote-neutral-foreground))",
soft: "hsl(var(--vote-neutral-soft))",
},
},
primary: {
DEFAULT: "hsl(var(--primary))",
Expand Down
Loading