diff --git a/CONTEXT.md b/CONTEXT.md index 45ac0e1d0..fabd654ac 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -56,6 +56,10 @@ _Avoid_: Venue, room A user's reaction to an artist. Three values: "Must Go" (+2), "Interested" (+1), "Won't Go" (-1). A vote belongs to the voting user alone — it is never scoped to a Group; Groups only change whose votes are being looked at, never which votes exist. Anticipatory — answers "will I go." See Retrospective rating for the after-the-fact counterpart. _Avoid_: Rating, like +**Score**: +The single aggregate measure of a **set**'s **votes** within a scope (the whole **edition**, or one **group**): the sum of the vote values (`2·mustGo + interested − wontGo`), computed by `tallyVotes()` in `src/lib/votes/score.ts`. Always displayed alongside the per-type vote counts it sums, so it can be verified at a glance. See ADR-0010. +_Avoid_: Rating (formerly a separate mean-of-values metric — removed), popularity (formerly a separate positives-only metric — removed), net score, points + **Retrospective rating**: A user's after-the-fact reaction to a **set** — "how was it" — recorded once the edition is Post-Festival. Distinct from **Vote**: Vote is anticipatory ("will I go", drives planning points) while a rating answers "did I like it" and never affects or is affected by a Vote on the same set. Stored in its own `set_ratings` table with its own scale (loved / liked / meh), never the Vote scale or copy. See ADR-0004. _Avoid_: Vote, score @@ -73,7 +77,7 @@ The user's durable, Settings-level default lens: Group, Everyone, or Me. When se _Avoid_: Active group (the pin covers both which Group and which lens; "Active Group" alone is only the former) **Vote Perspective**: -On the Artists tab, which votes are aggregated into a set's rating and popularity score: Everyone, or the Active Group. Perspective re-scores and re-sorts; it never hides sets. See ADR-0005. +On the Artists tab, which votes are aggregated into a set's Score: Everyone, or the Active Group. Perspective re-scores and re-sorts; it never hides sets. See ADR-0005. _Avoid_: Group filter, rating scope **Vote Scope**: diff --git a/docs/adr/0010-single-vote-score.md b/docs/adr/0010-single-vote-score.md new file mode 100644 index 000000000..455ad6c29 --- /dev/null +++ b/docs/adr/0010-single-vote-score.md @@ -0,0 +1,52 @@ +# One vote Score, replacing rating and popularity + +Three vote-aggregation formulas coexisted, each hand-rolled from `vote_type` +literals in a different file: **rating** (mean of vote values, drove the +`rating-desc` sort and the Minimum Rating filter), **popularity** +(`2·mustGo + interested`, no Won't-Go term, drove the default +`popularity-desc` sort), and a **net score** (`2·mustGo + interested − wontGo`, +the Set-detail "Score" badge). All three bypassed the vote config's canonical +weights. The popularity/net split was documented in the sort-help popover, so +it was intentional — but it meant a set could rank above another it visibly +"scores" below, and none of the aggregation logic was tested. + +We consolidated on a single metric: **Score = the sum of a set's vote values** +(`2·mustGo + interested − wontGo`, weights read from the vote config). It is +computed in one pure module, `src/lib/votes/score.ts` (`tallyVotes(votes) → +{ counts, score }`), which also owns per-type counting. One sort ("Top Score", +`score-desc`, the default) replaces both vote sorts; the Minimum Rating filter +is removed; the badge is unchanged but now provably shows the same number the +sort ranks by. The vote config moved to `src/lib/votes/config.ts` alongside it. + +## Considered Options + +- **Single net Score (chosen).** UpLine's core loop is group consensus, and + Won't-Go is the consensus signal — a sort that ignores it hides exactly the + disagreement a group needs to see. One number users can verify by counting + the per-type counts displayed beside it. Sum over mean because every set is + scored by the same small pool (a group), so normalization buys little, and + "more people caring moves it up" matches intuition. +- **Keep rating + popularity as documented, deliberately distinct metrics.** + Defensible (popularity as an "enthusiasm/buzz" measure that tolerates + controversy), but it permanently costs explaining why the #1-ranked set can + show a lower score than #3. With no usage data indicating anyone relies on + the distinction, legibility won. +- **Mean rating as the single metric.** Normalized, but favours + few-but-enthusiastic votes over broad support, and its own filter UI proved + how illegible it was: the "Minimum Rating 3+" option was unreachable (max + possible mean is 2). + +## Consequences + +- Ranking visibly changes: sets with many Won't-Go votes drop relative to the + old default popularity sort. One-time reshuffle toward the number already + shown on the detail page. +- Old bookmarked URLs with `sort=rating-desc`/`popularity-desc` or `minRating` + degrade gracefully: the zod `.catch` falls back to `score-desc` and unknown + params are dropped. +- If a "buzz"-style enthusiasm metric is ever wanted (e.g. for an explore + surface), add it as a new named concept in `tallyVotes` and `CONTEXT.md` — + do not re-derive from `vote_type` literals in components, and do not reuse + the names "rating" or "popularity" (retired, see CONTEXT.md). +- `useVoteCount` was deleted; all per-type counts come from + `tallyVotes().counts`. diff --git a/docs/design/edition-color-vocabulary.md b/docs/design/edition-color-vocabulary.md index 7779413f5..1abe768e2 100644 --- a/docs/design/edition-color-vocabulary.md +++ b/docs/design/edition-color-vocabulary.md @@ -63,7 +63,7 @@ A hairline drawn in `border-white/NN` (not purple) doesn't fit either border rol ### Vote triad -Vote colors become token families consumed by `src/lib/voteConfig.ts` (shared with SetDetails/groups, which shift along): +Vote colors become token families consumed by `src/lib/votes/config.ts` (shared with SetDetails/groups, which shift along): | Family | Vote | Initial value | | ------------------------------------------- | --------------- | -------------- | diff --git a/docs/design/edition-identity-spec.md b/docs/design/edition-identity-spec.md index 0bf8f5593..aec3b5f52 100644 --- a/docs/design/edition-identity-spec.md +++ b/docs/design/edition-identity-spec.md @@ -74,7 +74,7 @@ Flat ground `#fafaf7`, solid white surfaces, soft borders + small shadows (the n ### Vote triad -Initial values keep today's hues (orange / blue / gray), tuned per theme for AA on their ground; consumed via `src/lib/voteConfig.ts` (SetDetails/groups shift along). Exact tuning is an implementation detail inside the family contract: `vote-*` (solid), `vote-*-foreground` (legible text/icon on ground), `vote-*-soft` (translucent card/chip wash). +Initial values keep today's hues (orange / blue / gray), tuned per theme for AA on their ground; consumed via `src/lib/votes/config.ts` (SetDetails/groups shift along). Exact tuning is an implementation detail inside the family contract: `vote-*` (solid), `vote-*-foreground` (legible text/icon on ground), `vote-*-soft` (translucent card/chip wash). | Family | Dark base | Light base | | ----------------- | --------- | ---------- | diff --git a/src/components/onboarding/steps/VotingExplanationStep.tsx b/src/components/onboarding/steps/VotingExplanationStep.tsx index 6c539b250..e9d620cfd 100644 --- a/src/components/onboarding/steps/VotingExplanationStep.tsx +++ b/src/components/onboarding/steps/VotingExplanationStep.tsx @@ -5,7 +5,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { ArrowLeft, Vote } from "lucide-react"; -import { VOTE_CONFIG } from "@/lib/voteConfig"; +import { VOTE_CONFIG } from "@/lib/votes/config"; import { OnboardingContent } from "../OnboardingContent"; import votingImage from "./voting.png?url"; interface VotingExplanationStepProps { diff --git a/src/hooks/useTimelineUrlState.ts b/src/hooks/useTimelineUrlState.ts index 51184f386..0052461bb 100644 --- a/src/hooks/useTimelineUrlState.ts +++ b/src/hooks/useTimelineUrlState.ts @@ -5,7 +5,7 @@ import { useRouteContext, } from "@tanstack/react-router"; import type { TimelineSearch } from "@/lib/searchSchemas"; -import type { VoteType } from "@/lib/voteConfig"; +import type { VoteType } from "@/lib/votes/config"; import type { SetType } from "@/api/sets/types"; import { useStageSlugResolver } from "@/hooks/useStageSlugResolver"; diff --git a/src/hooks/useVoteCount.ts b/src/hooks/useVoteCount.ts deleted file mode 100644 index 07523312e..000000000 --- a/src/hooks/useVoteCount.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { FestivalSet } from "@/api/sets/types"; - -export function useVoteCount(set: FestivalSet | undefined) { - const voteCounts = useMemo(() => { - if (!set?.votes) { - return {}; - } - - const counts: Record = {}; - for (const vc of set.votes) { - counts[vc.vote_type] = (counts[vc.vote_type] || 0) + 1; - } - return counts; - }, [set?.votes]); - - const getVoteCount = useCallback( - (voteType: number) => voteCounts[voteType] || 0, - [voteCounts], - ); - - return { getVoteCount }; -} diff --git a/src/lib/scheduleFilter.ts b/src/lib/scheduleFilter.ts index 05cc08a9c..5c238269c 100644 --- a/src/lib/scheduleFilter.ts +++ b/src/lib/scheduleFilter.ts @@ -1,6 +1,6 @@ import { getFestivalHour } from "@/lib/timeUtils"; import type { TimelineSearch } from "@/lib/searchSchemas"; -import { getVoteConfig, type VoteType } from "@/lib/voteConfig"; +import { getVoteConfig, type VoteType } from "@/lib/votes/config"; import { resolveVotesForScope, type VoteScope } from "@/lib/voteScope"; import { matchesSetTypeFilter } from "@/lib/setTypeFilter"; import type { SetType } from "@/api/sets/types"; diff --git a/src/lib/searchSchemas.test.ts b/src/lib/searchSchemas.test.ts index 3311ca30f..f348f0d2c 100644 --- a/src/lib/searchSchemas.test.ts +++ b/src/lib/searchSchemas.test.ts @@ -34,3 +34,29 @@ describe("filterSortSearchSchema types", () => { expect(result.types).toEqual([]); }); }); + +describe("filterSortSearchSchema legacy URL compatibility", () => { + it("falls back to score-desc for the retired rating-desc sort", () => { + const result = filterSortSearchSchema.parse({ + ...filterSortSearchDefaults, + sort: "rating-desc", + }); + expect(result.sort).toBe("score-desc"); + }); + + it("falls back to score-desc for the retired popularity-desc sort", () => { + const result = filterSortSearchSchema.parse({ + ...filterSortSearchDefaults, + sort: "popularity-desc", + }); + expect(result.sort).toBe("score-desc"); + }); + + it("drops the retired minRating param instead of erroring", () => { + const result = filterSortSearchSchema.parse({ + ...filterSortSearchDefaults, + minRating: 2, + }); + expect(result).not.toHaveProperty("minRating"); + }); +}); diff --git a/src/lib/searchSchemas.ts b/src/lib/searchSchemas.ts index ed3c48366..3c3e939c4 100644 --- a/src/lib/searchSchemas.ts +++ b/src/lib/searchSchemas.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { VOTES_TYPES } from "@/lib/voteConfig"; +import { VOTES_TYPES } from "@/lib/votes/config"; import { SET_TYPES } from "@/api/sets/types"; /** Array param whose unknown entries are dropped individually, not the whole array. */ @@ -19,19 +19,17 @@ function enumArrayParam(values: readonly T[]) { export const sortOptionSchema = z.enum([ "name-asc", "name-desc", - "rating-desc", - "popularity-desc", + "score-desc", "date-asc", ]); export const timelineViewSchema = z.enum(["horizontal", "list"]); export const filterSortSearchSchema = z.object({ - sort: sortOptionSchema.catch("popularity-desc"), + sort: sortOptionSchema.catch("score-desc"), /** Stage slugs (not ids) — resolved to ids internally by useUrlState. */ stages: z.array(z.string()).catch([]), genres: z.array(z.string()).catch([]), - minRating: z.coerce.number().catch(0), timelineView: timelineViewSchema.catch("list"), use24Hour: z.boolean().catch(true), invite: z.string().optional(), @@ -42,10 +40,9 @@ export const filterSortSearchSchema = z.object({ export type FilterSortSearch = z.infer; export const filterSortSearchDefaults = { - sort: "popularity-desc", + sort: "score-desc", stages: [], genres: [], - minRating: 0, timelineView: "list", use24Hour: true, sortLocked: false, diff --git a/src/lib/voteConfig.test.ts b/src/lib/votes/config.test.ts similarity index 99% rename from src/lib/voteConfig.test.ts rename to src/lib/votes/config.test.ts index 72f6661ae..a49ce4275 100644 --- a/src/lib/voteConfig.test.ts +++ b/src/lib/votes/config.test.ts @@ -5,7 +5,7 @@ import { getVoteConfig, getVoteValue, type VoteType, -} from "./voteConfig"; +} from "./config"; import { Star, Heart, X } from "lucide-react"; describe("VOTE_CONFIG", () => { diff --git a/src/lib/voteConfig.ts b/src/lib/votes/config.ts similarity index 100% rename from src/lib/voteConfig.ts rename to src/lib/votes/config.ts diff --git a/src/lib/votes/score.test.ts b/src/lib/votes/score.test.ts new file mode 100644 index 000000000..a5c49d0e6 --- /dev/null +++ b/src/lib/votes/score.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { tallyVotes } from "./score"; + +describe("tallyVotes", () => { + it("returns zero counts and zero score for no votes", () => { + expect(tallyVotes([])).toEqual({ + counts: { mustGo: 0, interested: 0, wontGo: 0 }, + score: 0, + }); + }); + + it("returns zero counts and zero score for undefined votes", () => { + expect(tallyVotes(undefined)).toEqual({ + counts: { mustGo: 0, interested: 0, wontGo: 0 }, + score: 0, + }); + }); + + it("returns zero counts and zero score for null votes", () => { + expect(tallyVotes(null)).toEqual({ + counts: { mustGo: 0, interested: 0, wontGo: 0 }, + score: 0, + }); + }); + + it("counts each vote type", () => { + const { counts } = tallyVotes([ + vote(2), + vote(2), + vote(2), + vote(1), + vote(1), + vote(-1), + ]); + expect(counts).toEqual({ mustGo: 3, interested: 2, wontGo: 1 }); + }); + + it("scores as the sum of vote values: 2·mustGo + interested − wontGo", () => { + const { score } = tallyVotes([ + vote(2), + vote(2), + vote(2), + vote(1), + vote(1), + vote(-1), + ]); + expect(score).toBe(2 * 3 + 2 - 1); + }); + + it("goes negative when Won't Go votes outweigh the rest", () => { + const { score } = tallyVotes([vote(-1), vote(-1), vote(-1), vote(1)]); + expect(score).toBe(-2); + }); + + it("scores an all-negative vote set as minus the vote count", () => { + expect(tallyVotes([vote(-1), vote(-1), vote(-1)])).toEqual({ + counts: { mustGo: 0, interested: 0, wontGo: 3 }, + score: -3, + }); + }); + + it("ignores unknown vote_type values in both counts and score", () => { + const result = tallyVotes([vote(2), vote(0), vote(99), vote(-5)]); + expect(result).toEqual({ + counts: { mustGo: 1, interested: 0, wontGo: 0 }, + score: 2, + }); + }); + + it("tallies a group-scoped subset independently of the full set", () => { + const all = [vote(2), vote(2), vote(1), vote(-1)]; + const groupSubset = all.slice(0, 3); + expect(tallyVotes(all).score).toBe(4); + expect(tallyVotes(groupSubset).score).toBe(5); + expect(tallyVotes(groupSubset).counts).toEqual({ + mustGo: 2, + interested: 1, + wontGo: 0, + }); + }); +}); + +function vote(vote_type: number) { + return { vote_type }; +} diff --git a/src/lib/votes/score.ts b/src/lib/votes/score.ts new file mode 100644 index 000000000..24a0dff35 --- /dev/null +++ b/src/lib/votes/score.ts @@ -0,0 +1,28 @@ +import { VOTES_TYPES, VOTE_CONFIG, VoteType, getVoteConfig } from "./config"; + +export interface VoteTally { + counts: Record; + score: number; +} + +export function tallyVotes( + votes: Array<{ vote_type: number }> | null | undefined, +): VoteTally { + const counts = Object.fromEntries( + VOTES_TYPES.map((voteType) => [voteType, 0]), + ) as Record; + + for (const vote of votes || []) { + const voteType = getVoteConfig(vote.vote_type); + if (voteType) { + counts[voteType] += 1; + } + } + + const score = VOTES_TYPES.reduce( + (sum, voteType) => sum + counts[voteType] * VOTE_CONFIG[voteType].value, + 0, + ); + + return { counts, score }; +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/VoteButtons.tsx b/src/pages/EditionView/tabs/ScheduleTab/VoteButtons.tsx index 2a358dc62..174da8f72 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/VoteButtons.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/VoteButtons.tsx @@ -4,7 +4,8 @@ import { VoteType, getVoteConfig, getVoteValue, -} from "@/lib/voteConfig"; +} from "@/lib/votes/config"; +import { tallyVotes } from "@/lib/votes/score"; import { cn } from "@/lib/utils"; import type { ScheduleSet } from "@/hooks/useScheduleData"; import { useMemo } from "react"; @@ -26,25 +27,7 @@ export function VoteButtons({ set }: VoteButtonsProps) { return userVote ? getVoteConfig(userVote) : undefined; }, [userVote]); - const votesMap = useMemo(() => { - if (!set.votes) { - return {} as Record; - } - - return set.votes?.reduce( - (agg, item) => { - const voteType = getVoteConfig(item.vote_type); - if (!voteType) { - return agg; - } - agg[voteType] = (agg[voteType] || 0) + 1; - return agg; - }, - Object.fromEntries( - VOTES_TYPES.map((voteType) => [voteType, 0] as const), - ) as Record, - ); - }, [set.votes]); + const { counts } = tallyVotes(set.votes); return (
handleVote(getVoteValue(voteType))} - count={votesMap[voteType]} + count={counts[voteType]} value={userVoteType} /> ); diff --git a/src/pages/EditionView/tabs/ScheduleTab/VoteFilterChips.tsx b/src/pages/EditionView/tabs/ScheduleTab/VoteFilterChips.tsx index a0ba4abb6..8a7d9af73 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/VoteFilterChips.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/VoteFilterChips.tsx @@ -1,6 +1,6 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { VOTES_TYPES, VOTE_CONFIG, type VoteType } from "@/lib/voteConfig"; +import { VOTES_TYPES, VOTE_CONFIG, type VoteType } from "@/lib/votes/config"; import { useAuth } from "@/contexts/AuthContext"; import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/OverviewStageRow.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/OverviewStageRow.tsx index a7853547d..19bcbf75b 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/OverviewStageRow.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/OverviewStageRow.tsx @@ -1,5 +1,5 @@ import { cn } from "@/lib/utils"; -import { getVoteConfig, VOTE_CONFIG } from "@/lib/voteConfig"; +import { getVoteConfig, VOTE_CONFIG } from "@/lib/votes/config"; import { calculateOverviewSetBlocks } from "@/lib/timelineOverviewGeometry"; import type { HorizontalTimelineSet } from "@/lib/timelineCalculator"; import { DEFAULT_STAGE_COLOR } from "@/lib/constants/stages"; diff --git a/src/pages/EditionView/tabs/VoteTab/SetCard/SetVotingButtons.tsx b/src/pages/EditionView/tabs/VoteTab/SetCard/SetVotingButtons.tsx index b9a8dc0ea..e562aeda6 100644 --- a/src/pages/EditionView/tabs/VoteTab/SetCard/SetVotingButtons.tsx +++ b/src/pages/EditionView/tabs/VoteTab/SetCard/SetVotingButtons.tsx @@ -1,10 +1,10 @@ import { Button } from "@/components/ui/button"; -import { VOTE_CONFIG, VOTES_TYPES, type VoteConfig } from "@/lib/voteConfig"; +import { VOTE_CONFIG, VOTES_TYPES, type VoteConfig } from "@/lib/votes/config"; import { useFestivalSet } from "../FestivalSetContext"; import { useUserVotesQuery } from "@/api/voting/useUserVotesQuery"; import { useVoteMutation } from "@/api/voting/useVoteMutation"; import { useAuth } from "@/contexts/AuthContext"; -import { useVoteCount } from "@/hooks/useVoteCount"; +import { tallyVotes } from "@/lib/votes/score"; interface SetVotingButtonsProps { size?: "sm" | "default"; @@ -18,7 +18,7 @@ export function SetVotingButtons({ const { user, showAuthDialog } = useAuth(); const { set, onLockSort } = useFestivalSet(); - const { getVoteCount } = useVoteCount(set); + const { counts } = tallyVotes(set.votes); const userVotesQuery = useUserVotesQuery(user?.id); const voteMutation = useVoteMutation(); @@ -42,7 +42,7 @@ export function SetVotingButtons({ config={config} isSelected={userVoteForSet === config.value} onClick={() => handleVote(config.value)} - voteCount={getVoteCount(config.value)} + voteCount={counts[voteType]} isVoting={voteMutation.isPending} size={size} layout={layout} diff --git a/src/pages/EditionView/tabs/VoteTab/filters/DesktopFilters.tsx b/src/pages/EditionView/tabs/VoteTab/filters/DesktopFilters.tsx index 8aa79ab98..019c640e4 100644 --- a/src/pages/EditionView/tabs/VoteTab/filters/DesktopFilters.tsx +++ b/src/pages/EditionView/tabs/VoteTab/filters/DesktopFilters.tsx @@ -99,30 +99,6 @@ export function DesktopFilters({ ))}
- - {/* Rating Filter */} -
-

- Minimum Rating -

-
- {[0, 1, 2, 3].map((rating) => ( - - ))} -
-
); } diff --git a/src/pages/EditionView/tabs/VoteTab/filters/FilterSortControls.tsx b/src/pages/EditionView/tabs/VoteTab/filters/FilterSortControls.tsx index 30dc2880e..f977e91c9 100644 --- a/src/pages/EditionView/tabs/VoteTab/filters/FilterSortControls.tsx +++ b/src/pages/EditionView/tabs/VoteTab/filters/FilterSortControls.tsx @@ -58,13 +58,9 @@ export function FilterSortControls({ const hasActiveFilters = state.stagesIds.length > 0 || state.genres.length > 0 || - state.minRating > 0 || state.types.length > 0; const activeFilterCount = - state.stagesIds.length + - state.genres.length + - (state.minRating > 0 ? 1 : 0) + - state.types.length; + state.stagesIds.length + state.genres.length + state.types.length; const Filters = isMobile ? MobileFilters : DesktopFilters; diff --git a/src/pages/EditionView/tabs/VoteTab/filters/MobileFilters.tsx b/src/pages/EditionView/tabs/VoteTab/filters/MobileFilters.tsx index 85b444df9..4f60f0117 100644 --- a/src/pages/EditionView/tabs/VoteTab/filters/MobileFilters.tsx +++ b/src/pages/EditionView/tabs/VoteTab/filters/MobileFilters.tsx @@ -119,37 +119,6 @@ export function MobileFilters({ - - {/* Rating Filter */} -
-

- Minimum Rating -

- -
); } diff --git a/src/pages/EditionView/tabs/VoteTab/filters/SortControls.tsx b/src/pages/EditionView/tabs/VoteTab/filters/SortControls.tsx index e3d626e1d..d2ba22846 100644 --- a/src/pages/EditionView/tabs/VoteTab/filters/SortControls.tsx +++ b/src/pages/EditionView/tabs/VoteTab/filters/SortControls.tsx @@ -15,7 +15,6 @@ import { HelpCircle, ArrowUpAZ, ArrowDownAZ, - Star, TrendingUp, Calendar, } from "lucide-react"; @@ -31,8 +30,7 @@ interface SortControlsProps { const SORT_ICONS = { "name-asc": ArrowUpAZ, "name-desc": ArrowDownAZ, - "rating-desc": Star, - "popularity-desc": TrendingUp, + "score-desc": TrendingUp, "date-asc": Calendar, } as const; @@ -111,28 +109,13 @@ export function SortControls({ sort, onSortChange }: SortControlsProps) { -
- -
- - Highest Rated: - -

- Sort by weighted average rating based on votes (Must go = 2 - points, Interested = 1 point, Won't go = -1 point) -

-
-
-
- - Most Popular: - + Top Score:

- Sort by weighted popularity score (Must go = 2 points, - Interested = 1 point) + Sort by score, the sum of all votes (Must Go = +2, + Interested = +1, Won't Go = -1)

diff --git a/src/pages/EditionView/tabs/VoteTab/filters/constants.ts b/src/pages/EditionView/tabs/VoteTab/filters/constants.ts index 734e442a5..d4e3cbcd6 100644 --- a/src/pages/EditionView/tabs/VoteTab/filters/constants.ts +++ b/src/pages/EditionView/tabs/VoteTab/filters/constants.ts @@ -1,8 +1,7 @@ export const SORT_OPTIONS = [ { value: "name-asc", label: "Name (A-Z)" }, { value: "name-desc", label: "Name (Z-A)" }, - { value: "rating-desc", label: "Highest Rated" }, - { value: "popularity-desc", label: "Most Popular" }, + { value: "score-desc", label: "Top Score" }, { value: "date-asc", label: "By Date" }, ] as const; diff --git a/src/pages/EditionView/tabs/VoteTab/useSetFiltering.ts b/src/pages/EditionView/tabs/VoteTab/useSetFiltering.ts index d5ce094f8..c989afa4c 100644 --- a/src/pages/EditionView/tabs/VoteTab/useSetFiltering.ts +++ b/src/pages/EditionView/tabs/VoteTab/useSetFiltering.ts @@ -3,6 +3,7 @@ import type { FilterSortState } from "@/hooks/useUrlState"; import { FestivalSet } from "@/api/sets/types"; import { resolveVotesForScope, type VoteScope } from "@/lib/voteScope"; import { matchesSetTypeFilter } from "@/lib/setTypeFilter"; +import { tallyVotes } from "@/lib/votes/score"; export function useSetFiltering( sets: FestivalSet[], @@ -12,29 +13,6 @@ export function useSetFiltering( ) { const [lockedOrder, setLockedOrder] = useState([]); - // Calculate rating for a set based on vote weights - function calculateRating(set: FestivalSet): number { - if (!set.votes || set.votes.length === 0) return 0; - - const totalScore = set.votes.reduce((sum, vote) => { - // Use the actual vote type values: 2 (Must go), 1 (Interested), -1 (Won't go) - return sum + vote.vote_type; - }, 0); - - return totalScore / set.votes.length; - } - - // Get weighted popularity score: 2 * (must go votes) + interested votes - function getWeightedPopularityScore(set: FestivalSet): number { - if (!set.votes) return 0; - const mustGoVotes = set.votes.filter((vote) => vote.vote_type === 2).length; - const interestedVotes = set.votes.filter( - (vote) => vote.vote_type === 1, - ).length; - - return 2 * mustGoVotes + interestedVotes; - } - // Filter and sort sets based on current state const filteredAndSortedSets = useMemo(() => { if (!filterSortState) return sets; @@ -72,12 +50,6 @@ export function useSetFiltering( if (!hasMatchingGenre) return false; } - // Rating filter - if (filterSortState.minRating > 0) { - const rating = calculateRating(set); - if (rating < filterSortState.minRating) return false; - } - return true; }); @@ -89,12 +61,8 @@ export function useSetFiltering( return a.name.localeCompare(b.name); case "name-desc": return b.name.localeCompare(a.name); - case "rating-desc": - primarySort = calculateRating(b) - calculateRating(a); - break; - case "popularity-desc": - primarySort = - getWeightedPopularityScore(b) - getWeightedPopularityScore(a); + case "score-desc": + primarySort = tallyVotes(b.votes).score - tallyVotes(a.votes).score; break; case "date-asc": if (!a.time_start && !b.time_start) { diff --git a/src/pages/ExploreSetPage/VotingActions.test.tsx b/src/pages/ExploreSetPage/VotingActions.test.tsx index 9d9a01f38..acb5ad87a 100644 --- a/src/pages/ExploreSetPage/VotingActions.test.tsx +++ b/src/pages/ExploreSetPage/VotingActions.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { VotingActions } from "./VotingActions"; -import { VOTE_CONFIG } from "@/lib/voteConfig"; +import { VOTE_CONFIG } from "@/lib/votes/config"; describe("VotingActions", () => { it("shows no button as pressed when there is no vote yet", () => { diff --git a/src/pages/ExploreSetPage/VotingActions.tsx b/src/pages/ExploreSetPage/VotingActions.tsx index 675d074f7..841bc065d 100644 --- a/src/pages/ExploreSetPage/VotingActions.tsx +++ b/src/pages/ExploreSetPage/VotingActions.tsx @@ -1,5 +1,5 @@ import { Button } from "@/components/ui/button"; -import { VOTE_CONFIG } from "@/lib/voteConfig"; +import { VOTE_CONFIG } from "@/lib/votes/config"; import { motion } from "framer-motion"; import { VoteButton } from "./VoteButton"; diff --git a/src/pages/SetDetails/MultiArtistSetInfoCard.tsx b/src/pages/SetDetails/MultiArtistSetInfoCard.tsx index a10af238b..cd5c543cb 100644 --- a/src/pages/SetDetails/MultiArtistSetInfoCard.tsx +++ b/src/pages/SetDetails/MultiArtistSetInfoCard.tsx @@ -19,13 +19,13 @@ import { useRouteContext } from "@tanstack/react-router"; interface MultiArtistSetInfoCardProps { set: FestivalSet; - netVoteScore: number; + score: number; use24Hour?: boolean; } export function MultiArtistSetInfoCard({ set, - netVoteScore, + score, use24Hour = false, }: MultiArtistSetInfoCardProps) { const allGenres = set.artists.flatMap( @@ -75,17 +75,17 @@ export function MultiArtistSetInfoCard({ genreId={genre.music_genre_id} /> ))} - {netVoteScore !== 0 && ( + {score !== 0 && ( 0 + score > 0 ? "border-green-400 text-green-400" : "border-red-400 text-red-400" }`} > - Score: {netVoteScore > 0 ? "+" : ""} - {netVoteScore} + Score: {score > 0 ? "+" : ""} + {score} )} diff --git a/src/pages/SetDetails/NonMusicSetDetail/NonMusicSetBanner.tsx b/src/pages/SetDetails/NonMusicSetDetail/NonMusicSetBanner.tsx index 31c2a80b0..84a66d3d2 100644 --- a/src/pages/SetDetails/NonMusicSetDetail/NonMusicSetBanner.tsx +++ b/src/pages/SetDetails/NonMusicSetDetail/NonMusicSetBanner.tsx @@ -10,13 +10,13 @@ import { useRouteContext } from "@tanstack/react-router"; interface NonMusicSetBannerProps { set: FestivalSet; - netVoteScore: number; + score: number; use24Hour: boolean; } export function NonMusicSetBanner({ set, - netVoteScore, + score, use24Hour, }: NonMusicSetBannerProps) { const { festival } = useRouteContext({ @@ -44,17 +44,17 @@ export function NonMusicSetBanner({ {label} - {netVoteScore !== 0 && ( + {score !== 0 && ( 0 + score > 0 ? "border-green-400 text-green-400" : "border-red-400 text-red-400", )} > - Score: {netVoteScore > 0 ? "+" : ""} - {netVoteScore} + Score: {score > 0 ? "+" : ""} + {score} )} diff --git a/src/pages/SetDetails/NonMusicSetDetail/NonMusicSetDetail.tsx b/src/pages/SetDetails/NonMusicSetDetail/NonMusicSetDetail.tsx index 3fe7a684f..1ac23880f 100644 --- a/src/pages/SetDetails/NonMusicSetDetail/NonMusicSetDetail.tsx +++ b/src/pages/SetDetails/NonMusicSetDetail/NonMusicSetDetail.tsx @@ -7,24 +7,20 @@ import { NonMusicSetAboutCard } from "./NonMusicSetAboutCard"; interface NonMusicSetDetailProps { set: FestivalSet; - netVoteScore: number; + score: number; use24Hour: boolean; } export function NonMusicSetDetail({ set, - netVoteScore, + score, use24Hour, }: NonMusicSetDetailProps) { const artistWithImage = set.artists.find((artist) => artist.image_url); return (
- +
{artistWithImage?.image_url ? ( diff --git a/src/pages/SetDetails/SetGroupVoting.tsx b/src/pages/SetDetails/SetGroupVoting.tsx index 7429b1303..ce561382f 100644 --- a/src/pages/SetDetails/SetGroupVoting.tsx +++ b/src/pages/SetDetails/SetGroupVoting.tsx @@ -6,7 +6,8 @@ import { useActiveScope } from "@/contexts/ActiveScopeContext"; import { userGroupsQuery } from "@/api/groups/useUserGroups"; import { useGroupVotesQuery } from "@/api/voting/useGroupVotes"; import { Users } from "lucide-react"; -import { VOTE_CONFIG, VOTES_TYPES, getVoteConfig } from "@/lib/voteConfig"; +import { VOTE_CONFIG, VOTES_TYPES, getVoteConfig } from "@/lib/votes/config"; +import { tallyVotes } from "@/lib/votes/score"; import { cn } from "@/lib/utils"; interface SetGroupVotingProps { @@ -45,11 +46,7 @@ function SetGroupVotingContent({ return null; } - const voteCounts = { - 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, - }; + const { counts } = tallyVotes(groupVotes); const activeGroup = groups.find((g) => g.id === activeGroupId); @@ -81,12 +78,10 @@ function SetGroupVotingContent({
{VOTES_TYPES.map((voteTypeKey) => { const config = VOTE_CONFIG[voteTypeKey]; - const voteType = config.value; const IconComponent = config.icon; return ( -
+
- {voteCounts[voteType as keyof typeof voteCounts]} + {counts[voteTypeKey]}

{config.label}

diff --git a/src/pages/SetDetails/SetInfoCard.tsx b/src/pages/SetDetails/SetInfoCard.tsx index ac411c006..797519ed9 100644 --- a/src/pages/SetDetails/SetInfoCard.tsx +++ b/src/pages/SetDetails/SetInfoCard.tsx @@ -19,13 +19,13 @@ import { useRouteContext } from "@tanstack/react-router"; interface SetInfoCardProps { set: FestivalSet; - netVoteScore: number; + score: number; use24Hour?: boolean; } export function SetInfoCard({ set, - netVoteScore, + score, use24Hour = false, }: SetInfoCardProps) { const artist = set.artists[0]; @@ -54,17 +54,17 @@ export function SetInfoCard({ genreId={genre.music_genre_id} /> ))} - {netVoteScore !== 0 && ( + {score !== 0 && ( 0 + score > 0 ? "border-green-400 text-green-400" : "border-red-400 text-red-400" }`} > - Score: {netVoteScore > 0 ? "+" : ""} - {netVoteScore} + Score: {score > 0 ? "+" : ""} + {score} )}
diff --git a/src/pages/SetDetails/SetVotingButtons.tsx b/src/pages/SetDetails/SetVotingButtons.tsx index 372f297e4..30b1d9601 100644 --- a/src/pages/SetDetails/SetVotingButtons.tsx +++ b/src/pages/SetDetails/SetVotingButtons.tsx @@ -3,8 +3,8 @@ import { useAuth } from "@/contexts/AuthContext"; import { FestivalSet } from "@/api/sets/types"; import { useUserVotesQuery } from "@/api/voting/useUserVotesQuery"; import { useVoteMutation } from "@/api/voting/useVoteMutation"; -import { useVoteCount } from "@/hooks/useVoteCount"; -import { VOTE_CONFIG, getVoteConfig } from "@/lib/voteConfig"; +import { tallyVotes } from "@/lib/votes/score"; +import { VOTE_CONFIG, getVoteConfig } from "@/lib/votes/config"; interface SetVotingButtonsProps { set: FestivalSet; @@ -12,7 +12,7 @@ interface SetVotingButtonsProps { export function SetVotingButtons({ set }: SetVotingButtonsProps) { const { user, showAuthDialog } = useAuth(); - const { getVoteCount } = useVoteCount(set); + const { counts } = tallyVotes(set.votes); const userVotesQuery = useUserVotesQuery(user?.id); const voteMutation = useVoteMutation(); @@ -25,19 +25,19 @@ export function SetVotingButtons({ set }: SetVotingButtonsProps) { voteType={2} isActive={userVoteForSet === 2} onClick={() => handleVote(2)} - count={getVoteCount(2)} + count={counts.mustGo} /> handleVote(1)} - count={getVoteCount(1)} + count={counts.interested} /> handleVote(-1)} - count={getVoteCount(-1)} + count={counts.wontGo} />
); diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/explore.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/explore.tsx index e0699ce8f..9073d4983 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/explore.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/explore.tsx @@ -10,7 +10,7 @@ import { useUserVotesQuery } from "@/api/voting/useUserVotesQuery"; import { useState } from "react"; import { useExplorableSets } from "@/pages/ExploreSetPage/useExplorableSets"; import { pageMeta } from "@/lib/pageHead"; -import { VOTE_CONFIG } from "@/lib/voteConfig"; +import { VOTE_CONFIG } from "@/lib/votes/config"; import type { User } from "@supabase/supabase-js"; export const Route = createFileRoute( diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/sets/$setSlug.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/sets/$setSlug.tsx index e311a1749..e32d4c8c0 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/sets/$setSlug.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/sets/$setSlug.tsx @@ -12,7 +12,7 @@ import { useUrlState } from "@/hooks/useUrlState"; import { setBySlugQuery } from "@/api/sets/useSetBySlug"; import { isNonMusicSetType } from "@/api/sets/types"; import { useAuth } from "@/contexts/AuthContext"; -import { useVoteCount } from "@/hooks/useVoteCount"; +import { tallyVotes } from "@/lib/votes/score"; import { pageMeta } from "@/lib/pageHead"; import { TopBar } from "@/components/layout/TopBar"; import { FestivalIndicator } from "@/components/layout/AppHeader/FestivalIndicator"; @@ -58,9 +58,7 @@ function SetDetails() { setBySlugQuery(setSlug, edition.id), ); - const { getVoteCount } = useVoteCount(currentSet); - - const netVoteScore = 2 * getVoteCount(2) + getVoteCount(1) - getVoteCount(-1); + const { score } = tallyVotes(currentSet.votes); const isNonMusicSet = isNonMusicSetType(currentSet.set_type); const isArtistlessSet = currentSet.artists.length === 0; @@ -82,7 +80,7 @@ function SetDetails() { {isNonMusicSet || isArtistlessSet ? ( ) : isMultiArtistSet ? ( @@ -96,7 +94,7 @@ function SetDetails() {
@@ -109,7 +107,7 @@ function SetDetails() {
diff --git a/tests/e2e/voting.spec.ts b/tests/e2e/voting.spec.ts index 8e43b700d..64ec9539c 100644 --- a/tests/e2e/voting.spec.ts +++ b/tests/e2e/voting.spec.ts @@ -6,7 +6,7 @@ import { type Page, } from "@playwright/test"; import { signIn } from "../utils/login"; -import { VOTE_CONFIG, type VoteType } from "../../src/lib/voteConfig"; +import { VOTE_CONFIG, type VoteType } from "../../src/lib/votes/config"; // Seeded via supabase/seed.sql: "Test festival" edition "Boom Festival 2025". const EDITION_SETS_PATH = "/festivals/test/editions/2025/sets";