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
6 changes: 5 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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**:
Expand Down
52 changes: 52 additions & 0 deletions docs/adr/0010-single-vote-score.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion docs/design/edition-color-vocabulary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| ------------------------------------------- | --------------- | -------------- |
Expand Down
2 changes: 1 addition & 1 deletion docs/design/edition-identity-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| ----------------- | --------- | ---------- |
Expand Down
2 changes: 1 addition & 1 deletion src/components/onboarding/steps/VotingExplanationStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useTimelineUrlState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
23 changes: 0 additions & 23 deletions src/hooks/useVoteCount.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/lib/scheduleFilter.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
26 changes: 26 additions & 0 deletions src/lib/searchSchemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
11 changes: 4 additions & 7 deletions src/lib/searchSchemas.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -19,19 +19,17 @@ function enumArrayParam<T extends string>(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"),
Comment thread
chiptus marked this conversation as resolved.
use24Hour: z.boolean().catch(true),
invite: z.string().optional(),
Expand All @@ -42,10 +40,9 @@ export const filterSortSearchSchema = z.object({
export type FilterSortSearch = z.infer<typeof filterSortSearchSchema>;

export const filterSortSearchDefaults = {
sort: "popularity-desc",
sort: "score-desc",
stages: [],
genres: [],
minRating: 0,
timelineView: "list",
use24Hour: true,
sortLocked: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
getVoteConfig,
getVoteValue,
type VoteType,
} from "./voteConfig";
} from "./config";
import { Star, Heart, X } from "lucide-react";

describe("VOTE_CONFIG", () => {
Expand Down
File renamed without changes.
85 changes: 85 additions & 0 deletions src/lib/votes/score.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
28 changes: 28 additions & 0 deletions src/lib/votes/score.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { VOTES_TYPES, VOTE_CONFIG, VoteType, getVoteConfig } from "./config";

export interface VoteTally {
counts: Record<VoteType, number>;
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<VoteType, number>;

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 };
}
25 changes: 4 additions & 21 deletions src/pages/EditionView/tabs/ScheduleTab/VoteButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -26,25 +27,7 @@ export function VoteButtons({ set }: VoteButtonsProps) {
return userVote ? getVoteConfig(userVote) : undefined;
}, [userVote]);

const votesMap = useMemo(() => {
if (!set.votes) {
return {} as Record<VoteType, number>;
}

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<VoteType, number>,
);
}, [set.votes]);
const { counts } = tallyVotes(set.votes);

return (
<div
Expand All @@ -58,7 +41,7 @@ export function VoteButtons({ set }: VoteButtonsProps) {
voteType={voteType}
key={voteType}
onVote={() => handleVote(getVoteValue(voteType))}
count={votesMap[voteType]}
count={counts[voteType]}
value={userVoteType}
/>
);
Expand Down
2 changes: 1 addition & 1 deletion src/pages/EditionView/tabs/ScheduleTab/VoteFilterChips.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
Loading
Loading