diff --git a/bun.lock b/bun.lock index e6fed31..cff45d2 100644 --- a/bun.lock +++ b/bun.lock @@ -39,6 +39,7 @@ "@tailwindcss/vite": "^4.2.2", "@types/bun": "^1.3.11", "@types/node": "^25.6.0", + "@vitest/ui": "4.1.4", "concurrently": "^9.2.1", "convex-helpers": "^0.1.114", "convex-test": "^0.0.47", @@ -460,6 +461,8 @@ "@vitest/spy": ["@vitest/spy@4.1.4", "", {}, "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ=="], + "@vitest/ui": ["@vitest/ui@4.1.4", "", { "dependencies": { "@vitest/utils": "4.1.4", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "vitest": "4.1.4" } }, "sha512-EgFR7nlj5iTDYZYCvavjFokNYwr3c3ry0sFiCg+N7B233Nwp+NNx7eoF/XvMWDCKY71xXAG3kFkt97ZHBJVL8A=="], + "@vitest/utils": ["@vitest/utils@4.1.4", "", { "dependencies": { "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw=="], "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], @@ -642,6 +645,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], diff --git a/package.json b/package.json index 1747e16..f69df1f 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@tailwindcss/vite": "^4.2.2", "@types/bun": "^1.3.11", "@types/node": "^25.6.0", + "@vitest/ui": "4.1.4", "concurrently": "^9.2.1", "convex-helpers": "^0.1.114", "convex-test": "^0.0.47", diff --git a/src/convex/_generated/api.d.ts b/src/convex/_generated/api.d.ts index eaeeeb0..c3b6dd6 100644 --- a/src/convex/_generated/api.d.ts +++ b/src/convex/_generated/api.d.ts @@ -55,6 +55,7 @@ import type * as meeting_users_meetingPoll from "../meeting/users/meetingPoll.js import type * as meeting_users_participant from "../meeting/users/participant.js"; import type * as meeting_users_queue from "../meeting/users/queue.js"; import type * as meeting_users_simplified from "../meeting/users/simplified.js"; +import type * as migrations from "../migrations.js"; import type * as schema_meetingPolls from "../schema/meetingPolls.js"; import type * as schema_meetings from "../schema/meetings.js"; import type * as schema_userPolls from "../schema/userPolls.js"; @@ -120,6 +121,7 @@ declare const fullApi: ApiFromModules<{ "meeting/users/participant": typeof meeting_users_participant; "meeting/users/queue": typeof meeting_users_queue; "meeting/users/simplified": typeof meeting_users_simplified; + migrations: typeof migrations; "schema/meetingPolls": typeof schema_meetingPolls; "schema/meetings": typeof schema_meetings; "schema/userPolls": typeof schema_userPolls; diff --git a/src/convex/helpers/poll.test.ts b/src/convex/helpers/poll.test.ts new file mode 100644 index 0000000..ee904a4 --- /dev/null +++ b/src/convex/helpers/poll.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from 'vitest'; + +import type { PollOptionTotal } from './poll'; +import { computePollOutcome } from './poll'; + +function totals(rows: Array<{ option: string; votes: number }>): PollOptionTotal[] { + return rows.map((r, optionIndex) => ({ + optionIndex, + option: r.option, + description: null, + votes: r.votes, + })); +} + +/** Descending by votes (how `rankOptionsForScoring` orders before outcome). */ +function rankedDescending(rows: Array<{ option: string; votes: number }>): PollOptionTotal[] { + const t = totals(rows); + return t.toSorted((a, b) => b.votes - a.votes || a.optionIndex - b.optionIndex); +} + +function* voteCountTuples(length: number, max: number): Generator { + if (length === 0) { + yield []; + return; + } + for (const prefix of voteCountTuples(length - 1, max)) { + for (let v = 0; v <= max; v++) { + yield [...prefix, v]; + } + } +} + +describe('computePollOutcome', () => { + describe('single_winner', () => { + it('throws when majorityRule is missing', () => { + expect(() => + computePollOutcome({ type: 'single_winner' }, totals([{ option: 'A', votes: 1 }])), + ).toThrow(/single_winner poll missing majorityRule/); + }); + + describe('relative (plurality)', () => { + it('picks the unique top vote getter', () => { + const ranked = totals([ + { option: 'A', votes: 4 }, + { option: 'B', votes: 3 }, + { option: 'C', votes: 2 }, + ]); + const { winners, isTie, majorityRule } = computePollOutcome( + { type: 'single_winner', majorityRule: 'relative' }, + ranked, + ); + expect(winners.map((w) => w.option)).toEqual(['A']); + expect(isTie).toBe(false); + expect(majorityRule).toBe('relative'); + }); + + it('returns all options tied for first place', () => { + const ranked = totals([ + { option: 'A', votes: 5 }, + { option: 'B', votes: 5 }, + { option: 'C', votes: 2 }, + ]); + const { winners, isTie } = computePollOutcome( + { type: 'single_winner', majorityRule: 'relative' }, + ranked, + ); + expect(winners.map((w) => w.option).toSorted()).toEqual(['A', 'B']); + expect(isTie).toBe(true); + }); + + it('has no winners when every option has zero votes', () => { + const ranked = totals([ + { option: 'A', votes: 0 }, + { option: 'B', votes: 0 }, + ]); + const { winners, isTie } = computePollOutcome( + { type: 'single_winner', majorityRule: 'relative' }, + ranked, + ); + expect(winners).toEqual([]); + expect(isTie).toBe(false); + }); + }); + + describe('simple majority (>50% of usable votes)', () => { + it('wins only when the leader clears the threshold', () => { + const ranked = totals([ + { option: 'A', votes: 6 }, + { option: 'B', votes: 3 }, + { option: 'C', votes: 2 }, + ]); + const { winners, isTie } = computePollOutcome( + { type: 'single_winner', majorityRule: 'simple' }, + ranked, + ); + expect(winners.map((w) => w.option)).toEqual(['A']); + expect(isTie).toBe(false); + }); + + it('has no winner when the leader is only a plurality', () => { + const ranked = totals([ + { option: 'A', votes: 4 }, + { option: 'B', votes: 3 }, + { option: 'C', votes: 2 }, + ]); + const { winners, isTie } = computePollOutcome( + { type: 'single_winner', majorityRule: 'simple' }, + ranked, + ); + expect(winners).toEqual([]); + expect(isTie).toBe(false); + }); + + it('has no winner on a two-way tie at 50%', () => { + const ranked = totals([ + { option: 'A', votes: 5 }, + { option: 'B', votes: 5 }, + ]); + const { winners, isTie } = computePollOutcome( + { type: 'single_winner', majorityRule: 'simple' }, + ranked, + ); + expect(winners).toEqual([]); + expect(isTie).toBe(false); + }); + + it('never yields multiple winners or isTie (exhaustive small grids)', () => { + const labels = ['A', 'B', 'C', 'D'] as const; + const maxPerOption = 6; + for (let n = 2; n <= labels.length; n++) { + for (const counts of voteCountTuples(n, maxPerOption)) { + const ranked = rankedDescending( + counts.map((votes, i) => ({ option: labels[i], votes })), + ); + const { winners, isTie } = computePollOutcome( + { type: 'single_winner', majorityRule: 'simple' }, + ranked, + ); + expect(winners.length, `counts=${counts.join(',')}`).toBeLessThanOrEqual(1); + expect(isTie, `counts=${counts.join(',')}`).toBe(false); + } + } + }); + }); + + describe('qualified majorities', () => { + it('two_thirds: requires ceil(2/3 * total) on the leader', () => { + const ranked = totals([ + { option: 'A', votes: 7 }, + { option: 'B', votes: 3 }, + ]); + const ok = computePollOutcome( + { type: 'single_winner', majorityRule: 'two_thirds' }, + ranked, + ); + expect(ok.winners.map((w) => w.option)).toEqual(['A']); + + const short = totals([ + { option: 'A', votes: 6 }, + { option: 'B', votes: 4 }, + ]); + const none = computePollOutcome( + { type: 'single_winner', majorityRule: 'two_thirds' }, + short, + ); + expect(none.winners).toEqual([]); + }); + + it('three_quarters: requires ceil(3/4 * total) on the leader', () => { + const ranked = totals([ + { option: 'A', votes: 8 }, + { option: 'B', votes: 2 }, + ]); + const { winners } = computePollOutcome( + { type: 'single_winner', majorityRule: 'three_quarters' }, + ranked, + ); + expect(winners.map((w) => w.option)).toEqual(['A']); + + const short = totals([ + { option: 'A', votes: 7 }, + { option: 'B', votes: 3 }, + ]); + expect( + computePollOutcome({ type: 'single_winner', majorityRule: 'three_quarters' }, short) + .winners, + ).toEqual([]); + }); + + it('unanimous: only wins when the leader has every vote', () => { + const ranked = totals([ + { option: 'A', votes: 5 }, + { option: 'B', votes: 0 }, + ]); + const { winners } = computePollOutcome( + { type: 'single_winner', majorityRule: 'unanimous' }, + ranked, + ); + expect(winners.map((w) => w.option)).toEqual(['A']); + + const split = totals([ + { option: 'A', votes: 4 }, + { option: 'B', votes: 1 }, + ]); + const none = computePollOutcome( + { type: 'single_winner', majorityRule: 'unanimous' }, + split, + ); + expect(none.winners).toEqual([]); + }); + }); + }); + + describe('multi_winner', () => { + it('with winningCount 1 matches relative plurality', () => { + const ranked = totals([ + { option: 'A', votes: 4 }, + { option: 'B', votes: 3 }, + { option: 'C', votes: 2 }, + ]); + const multi = computePollOutcome({ type: 'multi_winner', winningCount: 1 }, ranked); + const rel = computePollOutcome({ type: 'single_winner', majorityRule: 'relative' }, ranked); + expect(multi.winners.map((w) => w.option)).toEqual(rel.winners.map((w) => w.option)); + expect(multi.isTie).toBe(rel.isTie); + expect(multi.majorityRule).toBe(null); + }); + + it('includes everyone at or above the K-th place score (ties expand the set)', () => { + const ranked = totals([ + { option: 'A', votes: 10 }, + { option: 'B', votes: 8 }, + { option: 'C', votes: 8 }, + { option: 'D', votes: 3 }, + ]); + const { winners, isTie } = computePollOutcome( + { type: 'multi_winner', winningCount: 2 }, + ranked, + ); + expect(winners.map((w) => w.option).toSorted()).toEqual(['A', 'B', 'C']); + expect(isTie).toBe(true); + }); + + it('defaults winningCount to 1 when omitted', () => { + const ranked = totals([ + { option: 'A', votes: 2 }, + { option: 'B', votes: 1 }, + ]); + const { winners } = computePollOutcome({ type: 'multi_winner' }, ranked); + expect(winners.map((w) => w.option)).toEqual(['A']); + }); + }); +}); diff --git a/src/convex/helpers/schema.ts b/src/convex/helpers/schema.ts index 1559b83..438288e 100644 --- a/src/convex/helpers/schema.ts +++ b/src/convex/helpers/schema.ts @@ -3,6 +3,7 @@ import { v } from 'convex/values'; export const pollType = v.union(v.literal('multi_winner'), v.literal('single_winner')); export const majorityRule = v.union( v.literal('simple'), + v.literal('relative'), v.literal('two_thirds'), v.literal('three_quarters'), v.literal('unanimous'), diff --git a/src/convex/migrations.ts b/src/convex/migrations.ts new file mode 100644 index 0000000..5c29174 --- /dev/null +++ b/src/convex/migrations.ts @@ -0,0 +1,98 @@ +import { Migrations } from '@convex-dev/migrations'; +import { components, internal } from '$convex/_generated/api'; +import type { DataModel } from '$convex/_generated/dataModel'; +import { internalMutation } from '$convex/_generated/server'; + +/** + * `multi_winner` with winningCount 1 is equivalent to `single_winner` + relative majority + * (plurality). Migrate stored polls and embedded poll snapshots for consistency. + */ +function isMultiWinnerSingleSeat(poll: { type: string; winningCount?: number }): boolean { + return poll.type === 'multi_winner' && poll.winningCount === 1; +} + +const migrations = new Migrations(components.migrations, { + internalMutation, +}); + +export const migrateMeetingPollsMultiWinnerCount1ToSingleRelative = migrations.define({ + table: 'meetingPolls', + migrateOne: async (ctx, doc) => { + if (!isMultiWinnerSingleSeat(doc)) { + return; + } + await ctx.db.patch(doc._id, { + type: 'single_winner', + majorityRule: 'relative', + winningCount: 1, + maxVotesPerVoter: 1, + }); + }, +}); + +export const migrateUserPollsMultiWinnerCount1ToSingleRelative = migrations.define({ + table: 'userPolls', + migrateOne: async (ctx, doc) => { + if (!isMultiWinnerSingleSeat(doc)) { + return; + } + await ctx.db.patch(doc._id, { + type: 'single_winner', + majorityRule: 'relative', + winningCount: 1, + maxVotesPerVoter: 1, + }); + }, +}); + +export const migrateMeetingPollResultsEmbeddedPollMultiWinnerCount1 = migrations.define({ + table: 'meetingPollResults', + migrateOne: async (ctx, doc) => { + if (!isMultiWinnerSingleSeat(doc.poll)) { + return; + } + await ctx.db.patch(doc._id, { + poll: { + ...doc.poll, + type: 'single_winner', + majorityRule: 'relative', + winningCount: 1, + maxVotesPerVoter: 1, + }, + results: { + ...doc.results, + majorityRule: 'relative', + }, + }); + }, +}); + +export const migrateUserPollResultsEmbeddedPollMultiWinnerCount1 = migrations.define({ + table: 'userPollResults', + migrateOne: async (ctx, doc) => { + if (!isMultiWinnerSingleSeat(doc.poll)) { + return; + } + await ctx.db.patch(doc._id, { + poll: { + ...doc.poll, + type: 'single_winner', + majorityRule: 'relative', + winningCount: 1, + maxVotesPerVoter: 1, + }, + results: { + ...doc.results, + majorityRule: 'relative', + }, + }); + }, +}); + +/** Run all four steps in order (safe to re-run; already-migrated docs are no-ops). */ +export const runMultiWinnerCount1ToSingleRelativeSeries = migrations.runner([ + internal.migrations.migrateMeetingPollsMultiWinnerCount1ToSingleRelative, + internal.migrations.migrateUserPollsMultiWinnerCount1ToSingleRelative, + internal.migrations.migrateMeetingPollResultsEmbeddedPollMultiWinnerCount1, + internal.migrations.migrateUserPollResultsEmbeddedPollMultiWinnerCount1, +]); diff --git a/src/lib/components/ui/edit-poll.svelte b/src/lib/components/ui/edit-poll.svelte index f6c3c2e..b720894 100644 --- a/src/lib/components/ui/edit-poll.svelte +++ b/src/lib/components/ui/edit-poll.svelte @@ -368,7 +368,7 @@ Top X vinnare - En vinnare (majoritet) + En vinnare diff --git a/src/lib/polls.ts b/src/lib/polls.ts index 773af3b..f64e48c 100644 --- a/src/lib/polls.ts +++ b/src/lib/polls.ts @@ -9,11 +9,18 @@ export { ABSTAIN_OPTION_LABEL } from './pollConstants'; export const POLL_TYPES = ['multi_winner', 'single_winner'] as const; export type PollType = (typeof POLL_TYPES)[number]; -export const MAJORITY_RULES = ['simple', 'two_thirds', 'three_quarters', 'unanimous'] as const; +export const MAJORITY_RULES = [ + 'simple', + 'relative', + 'two_thirds', + 'three_quarters', + 'unanimous', +] as const; export type MajorityRule = (typeof MAJORITY_RULES)[number]; export const MAJORITY_LABELS = { simple: 'Enkel majoritet (>50 %)', + relative: 'Relativ majoritet (flest röster)', two_thirds: 'Kvalificerad majoritet (≥2/3)', three_quarters: '3/4 majoritet', unanimous: 'Enighet (100 %)', @@ -21,6 +28,10 @@ export const MAJORITY_LABELS = { export function getMajorityRuleThreshold(rule: MajorityRule) { switch (rule) { + case 'relative': + throw new Error( + 'getMajorityRuleThreshold: relative (plurality) has no fixed fraction threshold', + ); case 'simple': return 0.5; case 'two_thirds': @@ -33,6 +44,10 @@ export function getMajorityRuleThreshold(rule: MajorityRule) { } export function meetsMajorityThreshold(rule: MajorityRule, votesCast: number, maxVotes: number) { + if (rule === 'relative') { + // Plurality: per-option threshold depends on being in the lead; this is only meaningful when compared to other counts. + return maxVotes > 0 && votesCast >= 1; + } const threshold = getMajorityRuleThreshold(rule); const minVotes = rule === 'simple' ? Math.floor(maxVotes * threshold) + 1 : Math.ceil(maxVotes * threshold); @@ -40,6 +55,9 @@ export function meetsMajorityThreshold(rule: MajorityRule, votesCast: number, ma } export function minimumVotesForMajority(rule: MajorityRule, maxVotes: number) { + if (rule === 'relative') { + return maxVotes > 0 ? 1 : Number.POSITIVE_INFINITY; + } const threshold = getMajorityRuleThreshold(rule); return rule === 'simple' ? Math.floor(maxVotes * threshold) + 1 : Math.ceil(maxVotes * threshold); } @@ -81,9 +99,9 @@ export const POLL_PRESETS = [ preset: () => ({ ...newPollDraft(), - type: 'multi_winner', + type: 'single_winner', winningCount: 1, - majorityRule: 'simple', + majorityRule: 'relative', allowsAbstain: true, }) satisfies PollDraft, },