From 9d36b574e0f0bda3e448601721be8f4baa28d83a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 15:18:55 +0000 Subject: [PATCH 1/2] feat(voting): add neutral vote type Adds a fourth vote value (vote_type=0, "Neutral") so users can mark a set as indifferent without lying with Won't Go or leaving it unvoted. It's registered in the shared VOTES_TYPES/VOTE_CONFIG registry so Explore exclusion, Schedule filtering, and vote counting pick it up without per-type branching, and fixes several truthy checks on vote values that would otherwise have treated a 0 vote as "no vote" (0 is falsy). UPL-69 --- .../useVoteMutation.integration.test.ts | 17 ++++++++++ src/index.css | 6 ++++ src/lib/voteConfig.test.ts | 28 ++++++++++++---- src/lib/voteConfig.ts | 31 +++++++++++++++--- .../tabs/ScheduleTab/VoteButtons.tsx | 2 +- .../ExploreSetPage/VotingActions.test.tsx | 32 +++++++++++++++++++ src/pages/ExploreSetPage/VotingActions.tsx | 12 +++++++ .../ExploreSetPage/useExplorableSets.test.ts | 28 ++++++++++++++++ .../ExploreSetPage/useExplorableSets.tsx | 4 +-- src/pages/SetDetails/SetGroupVoting.tsx | 3 +- src/pages/SetDetails/SetVotingButtons.tsx | 6 ++++ .../editions/$editionSlug/explore.tsx | 17 ++++++---- .../20260917151416_add_neutral_vote_type.sql | 8 +++++ tailwind.config.ts | 5 +++ 14 files changed, 178 insertions(+), 21 deletions(-) create mode 100644 supabase/migrations/20260917151416_add_neutral_vote_type.sql 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..c14e1393c 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: "Artists 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))", From 5ccf72431e3ed15f3cf315a1c38be13f355884a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 15:20:10 +0000 Subject: [PATCH 2/2] fix(voting): use "set" wording in the neutral vote description The new neutral entry's description copied the existing "Artists ..." phrasing from the other vote types, contradicting the spec's terminology note (votes key to sets, not artists) in the very code meant to follow it. UPL-69 --- src/lib/voteConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/voteConfig.ts b/src/lib/voteConfig.ts index c14e1393c..4a3bc87b8 100644 --- a/src/lib/voteConfig.ts +++ b/src/lib/voteConfig.ts @@ -79,7 +79,7 @@ export const VOTE_CONFIG = { chipUnselected: "text-vote-neutral hover:bg-vote-neutral-soft hover:text-vote-neutral", spinnerColor: "border-vote-neutral-foreground", - description: "Artists you don't have a strong opinion on (0 points)", + description: "Sets you don't have a strong opinion on (0 points)", }, } as const;