diff --git a/src/api/voting/useVoteMutation.integration.test.ts b/src/api/voting/useVoteMutation.integration.test.ts index 5b472e3c5..0a523e657 100644 --- a/src/api/voting/useVoteMutation.integration.test.ts +++ b/src/api/voting/useVoteMutation.integration.test.ts @@ -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(); diff --git a/src/index.css b/src/index.css index e3b8f5f8c..353811e8d 100644 --- a/src/index.css +++ b/src/index.css @@ -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; @@ -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 { diff --git a/src/lib/voteConfig.test.ts b/src/lib/voteConfig.test.ts index 72f6661ae..207c3999d 100644 --- a/src/lib/voteConfig.test.ts +++ b/src/lib/voteConfig.test.ts @@ -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", () => { @@ -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", @@ -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); }); }); @@ -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(); @@ -111,6 +121,7 @@ describe("getVoteConfig", () => { [2, "mustGo"], [1, "interested"], [-1, "wontGo"], + [0, "neutral"], ]; validMappings.forEach(([value, expectedType]) => { @@ -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 = { + const expectedValues: Record = { mustGo: 2, interested: 1, wontGo: -1, + neutral: 0, }; VOTES_TYPES.forEach((voteType) => { @@ -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); diff --git a/src/lib/voteConfig.ts b/src/lib/voteConfig.ts index 5cc9cbc84..4a3bc87b8 100644 --- a/src/lib/voteConfig.ts +++ b/src/lib/voteConfig.ts @@ -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 = { @@ -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; @@ -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; } diff --git a/src/pages/EditionView/tabs/ScheduleTab/VoteButtons.tsx b/src/pages/EditionView/tabs/ScheduleTab/VoteButtons.tsx index 2a358dc62..c63cd328b 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/VoteButtons.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/VoteButtons.tsx @@ -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(() => { diff --git a/src/pages/ExploreSetPage/VotingActions.test.tsx b/src/pages/ExploreSetPage/VotingActions.test.tsx index 9d9a01f38..0e8461087 100644 --- a/src/pages/ExploreSetPage/VotingActions.test.tsx +++ b/src/pages/ExploreSetPage/VotingActions.test.tsx @@ -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( + , + ); + + 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", () => { @@ -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(); + + 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(); diff --git a/src/pages/ExploreSetPage/VotingActions.tsx b/src/pages/ExploreSetPage/VotingActions.tsx index 675d074f7..9a0f168cd 100644 --- a/src/pages/ExploreSetPage/VotingActions.tsx +++ b/src/pages/ExploreSetPage/VotingActions.tsx @@ -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"; @@ -52,6 +53,17 @@ export function VotingActions({ + onVote(neutralConfig.value)} + /> + { 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")]); diff --git a/src/pages/ExploreSetPage/useExplorableSets.tsx b/src/pages/ExploreSetPage/useExplorableSets.tsx index 884544a8f..18cf92e71 100644 --- a/src/pages/ExploreSetPage/useExplorableSets.tsx +++ b/src/pages/ExploreSetPage/useExplorableSets.tsx @@ -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)); } @@ -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++; diff --git a/src/pages/SetDetails/SetGroupVoting.tsx b/src/pages/SetDetails/SetGroupVoting.tsx index 7429b1303..0155e68ef 100644 --- a/src/pages/SetDetails/SetGroupVoting.tsx +++ b/src/pages/SetDetails/SetGroupVoting.tsx @@ -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); @@ -78,7 +79,7 @@ function SetGroupVotingContent({ ) : (
{/* Vote Summary */} -
+
{VOTES_TYPES.map((voteTypeKey) => { const config = VOTE_CONFIG[voteTypeKey]; const voteType = config.value; diff --git a/src/pages/SetDetails/SetVotingButtons.tsx b/src/pages/SetDetails/SetVotingButtons.tsx index 372f297e4..9fbe2464b 100644 --- a/src/pages/SetDetails/SetVotingButtons.tsx +++ b/src/pages/SetDetails/SetVotingButtons.tsx @@ -39,6 +39,12 @@ export function SetVotingButtons({ set }: SetVotingButtonsProps) { onClick={() => handleVote(-1)} count={getVoteCount(-1)} /> + handleVote(0)} + count={getVoteCount(0)} + />
); diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/explore.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/explore.tsx index e0699ce8f..b95dd4375 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/explore.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/explore.tsx @@ -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. + const advancesQueue = + voteType === VOTE_CONFIG.wontGo.value || + voteType === VOTE_CONFIG.neutral.value; + + if (advancesQueue) { setIsAnimating(true); } @@ -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); }, }, ); diff --git a/supabase/migrations/20260917151416_add_neutral_vote_type.sql b/supabase/migrations/20260917151416_add_neutral_vote_type.sql new file mode 100644 index 000000000..e5fcfd673 --- /dev/null +++ b/supabase/migrations/20260917151416_add_neutral_vote_type.sql @@ -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)); diff --git a/tailwind.config.ts b/tailwind.config.ts index 7948c3efe..d5b0ced54 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -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))",